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>
98 lines
3.8 KiB
Python
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)
|