"""Shared mail routing policy for mailcat.""" import email.utils import json import re from datetime import datetime from functools import cache from pathlib import Path PREFIX = "INBOX." MAILINGLIST_ROUTES_FILE = Path(__file__).parent / "mailinglist_routes.json" DOMAIN_ROUTES_FILE = Path(__file__).parent / "domain_routes.json" def _normalize_domain(value: str) -> str: domain = value.strip().lower() if not domain or "@" in domain or "/" in domain or any(ch.isspace() for ch in domain): raise ValueError(f"Invalid domain route domain: {value!r}") return domain def _normalize_mailbox(value: str) -> str: mailbox = value.strip() if not mailbox or mailbox.startswith(PREFIX) or mailbox.startswith(".") or mailbox.endswith(".") or ".." in mailbox: raise ValueError(f"Invalid domain route mailbox: {value!r}") return mailbox def normalize_domain_route(rule: dict) -> tuple[list[str], str]: """Return a validated domain route as (domains, mailbox).""" if not isinstance(rule, dict): raise ValueError(f"Invalid domain route entry: {rule!r}") raw_domains = rule.get("domains") if not isinstance(raw_domains, list): raise ValueError(f"Domain route domains must be a list: {rule!r}") domains = [_normalize_domain(str(item)) for item in raw_domains] domains = list(dict.fromkeys(domains)) if not domains: raise ValueError(f"Domain route must contain at least one domain: {rule!r}") mailbox = _normalize_mailbox(str(rule.get("mailbox", ""))) return domains, mailbox @cache def domain_routes() -> list[tuple[list[str], str]]: """Return validated domain routing rules from JSON policy.""" raw = json.loads(DOMAIN_ROUTES_FILE.read_text(encoding="utf-8")) if not isinstance(raw, list): raise ValueError("domain_routes.json must contain a list") return [normalize_domain_route(rule) for rule in raw] @cache def domain_lookup() -> dict[str, str]: """Return domain-to-mailbox lookup built from domain_routes.json.""" lookup: dict[str, str] = {} for domains, target in domain_routes(): for domain in domains: lookup[domain] = target return lookup # Compatibility for existing scripts/tests that imported DOMAIN_ROUTES directly. DOMAIN_ROUTES = domain_routes() DOMAIN_LOOKUP = domain_lookup() FACTUUR_KEYWORDS = [ "factuur", "invoice", "rekening", "nota", "betaalverzoek", "receipt", "orderbevestiging", "order confirmation", "betaling ontvangen", "payment received", "uw factuur", "your invoice", "je factuur", ] EXCLUDED_SOURCE_FOLDERS = { "INBOX.Sent", "Sent", "INBOX.Drafts", "Drafts", "INBOX.Trash", "Trash", "INBOX.Spam", "Spam", } ALREADY_SORTED_FOLDERS = {"INBOX.Facturen - verwerkt"} @cache def mailinglist_routes() -> list[dict]: """Return normalized mailing-list routing rules.""" if not MAILINGLIST_ROUTES_FILE.exists(): return [] return json.loads(MAILINGLIST_ROUTES_FILE.read_text(encoding="utf-8")) def mailinglist_destination(from_addr: str) -> str | None: """Return a mailing-list destination based on normalized policy rules.""" domain = sender_domain(from_addr) from_lo = from_addr.lower() for rule in mailinglist_routes(): domains = {item.lower() for item in rule.get("domains", [])} if domain and domain in domains: return rule["mailbox"] for needle in rule.get("from_contains", []): if needle.lower() in from_lo: return rule["mailbox"] return None def sender_domain(from_addr: str) -> str: match = re.search(r"@([\w.\-]+)", from_addr) return match.group(1).lower() if match else "" def quarter_folder(date_str: str) -> str: """Return the invoice quarter target based on an email Date header.""" try: dt = email.utils.parsedate_to_datetime(date_str) year = dt.year quarter = (dt.month - 1) // 3 + 1 return f"Financieel.Facturen.{year}.Q{quarter}" except Exception: year = datetime.now().year return f"Financieel.Facturen.{year}.Q1" def route_from_subject(from_addr: str, subject: str) -> str | None: """Return a destination folder, __FACTUUR_DATE__, or None.""" subj_lo = subject.lower() if any(keyword in subj_lo for keyword in FACTUUR_KEYWORDS): return "__FACTUUR_DATE__" if destination := mailinglist_destination(from_addr): return destination return domain_lookup().get(sender_domain(from_addr)) def is_source_folder(folder: str) -> bool: """True if a folder should be scanned as a source mailbox.""" return folder not in EXCLUDED_SOURCE_FOLDERS and folder not in ALREADY_SORTED_FOLDERS def destination_folders(start_year: int = 2020, end_year: int = 2026) -> list[str]: """Return all destination folders without the INBOX. prefix.""" folders = {target for _, target in domain_routes()} folders.update( rule["mailbox"] for rule in mailinglist_routes() if rule.get("mailbox") ) folders.update( f"Financieel.Facturen.{year}.Q{quarter}" for year in range(start_year, end_year + 1) for quarter in range(1, 5) ) with_parents = set(folders) for folder in list(folders): parts = folder.split(".") for i in range(1, len(parts)): with_parents.add(".".join(parts[:i])) return sorted(with_parents)