Files
mailcat/mail_routes.py
wienen ae0813d519 Add route management web interface
Prompts used since previous commit:
- Kun je ook een webinterface maken waarmee ik de regels kan beheren?
- Ik wil dat de webinterface benaderbaar is vanaf mijn hele interne netwerk. En ik wil alle regels kunnen beheren. Dus ook de bestaande ingebouwde routes.
- 1. basic auth
- 2. 4321

Special observations:
- Domain routes moved from Python source to domain_routes.json so the web UI can manage all previous built-in domain routes.
- web_config.json remains ignored; live sorter processes must be restarted after route edits to pick up changed rules.
2026-07-05 00:19:53 +02:00

166 lines
5.3 KiB
Python

"""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)