Files
ai-agent/backend/settings/mail_trigger.py
Gabriel Vidal 0ff9e40242 refactor(backend): split main.py into domain packages with app.state injection
main.py (5146 lines, 106 routes) becomes an assembly only: one package per
domain — core, files, settings, dashboards, conversations, diff,
notifications, forms, runs, accounts, workers, models, cron, agents,
projects, services, goals, memories, plans, templates — each exposing an
APIRouter; the flat domain modules move into their package behind a barrel
that keeps the old `import conversations` / `import projects` spellings.

The shared singletons (store, meta_store, hub, indexer, …) are built once by
core.state.build_state() and attached to app.state.ai; routes take them as
the `deps: State` dependency and helpers as an explicit `deps: AppState`.
conversations/pricing.py carries the per-model rates out of the parser.

Verified: route table and OpenAPI byte-identical; 90 read endpoints
golden-diffed against the monolith on a copy of the live data (identical);
write routes smoke-tested; 66 backend tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:55:47 +02:00

98 lines
3.8 KiB
Python

"""
The mail trigger's authorized-sender list, editable from Settings.
A mail to claude@gabvdl.xyz starts a conversation (see
``services/mail/ui/backend/mailtrigger.py`` in the homelab repo) once its
sender is proven by DKIM and checked against an allowlist. That allowlist
used to be a single env var (``MAIL_TRIGGER_SENDERS``) baked into the mail
service's compose file — editing it meant a redeploy. This store lets it be
edited from the ai-agent Settings UI instead: one JSON sidecar
(``/data/mail-trigger-senders.json``), the same shared volume the mail
service's container mounts read-only. The mail trigger keeps its env var too
(a seed/fallback that always stays allowed) and unions it with whatever this
file holds — it hashes the file's bytes on every poll cycle and only
re-parses when the hash changes, so writes here take effect within one poll
cycle with no restart on either side.
An address can also be **disabled** without being forgotten (the Settings
toggle): it moves from ``emails`` to a second ``disabled`` list in the same
file. The mail service only ever reads ``emails``, so a disabled address is
simply absent from what it allows — no change on its side.
Single JSON file, whole-list rewrite on every set (atomic via a temp file +
rename), mirroring ``ui_state.py``/``notify.py``.
"""
import json
import os
import pathlib
import threading
def valid_emails(raw: list) -> list[str]:
"""Normalize + dedupe a list of email strings; raises ValueError on bad input."""
if not isinstance(raw, list):
raise ValueError("emails must be a list")
out: list[str] = []
seen: set[str] = set()
for e in raw:
if not isinstance(e, str):
raise ValueError("each email must be a string")
email = e.strip().lower()
if not email:
continue
if "@" not in email or email.startswith("@") or email.endswith("@"):
raise ValueError(f"not an email address: {e!r}")
if email in seen:
continue
seen.add(email)
out.append(email)
return out
class MailTriggerStore:
"""The list of emails allowed to trigger a conversation, beyond the mail
service's own env var seed."""
def __init__(self, path: str):
self.path = pathlib.Path(path)
self._lock = threading.Lock()
self._emails: list[str] = []
self._disabled: list[str] = []
self._load()
def _load(self) -> None:
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
self._emails = valid_emails(data.get("emails") or [])
enabled = set(self._emails)
self._disabled = [e for e in valid_emails(data.get("disabled") or [])
if e not in enabled]
except (OSError, ValueError):
self._emails, self._disabled = [], []
def emails(self) -> list[str]:
"""The enabled addresses — what the mail service allows."""
with self._lock:
return list(self._emails)
def disabled(self) -> list[str]:
with self._lock:
return list(self._disabled)
def set_emails(self, emails: list[str], disabled: list[str] | None = None) -> dict:
"""Replace both lists. An address in both is enabled (``emails`` wins)."""
clean = valid_emails(emails)
off = [e for e in valid_emails(disabled or []) if e not in set(clean)]
with self._lock:
self._emails, self._disabled = clean, off
self._save()
return {"emails": list(clean), "disabled": list(off)}
def _save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".json.tmp")
tmp.write_text(json.dumps({"emails": self._emails, "disabled": self._disabled},
indent=2), encoding="utf-8")
os.replace(tmp, self.path)