Files
ai-agent/backend/mail_trigger.py
Gabriel Vidal b0c021d8e8 feat(settings): one collapsible card for every settings list
Workers, Webhooks, Mail trigger and Claude accounts now render like the Cron
page: a collapsed header row (icon · name · chips · toggle) that expands into
the editable fields, a Save button only while dirty, and delete behind a
confirm at the bottom of the open body. Webhooks, Mail trigger and Accounts
each get a page of their own (/settings/<name>) instead of an anchor on the
Settings page; the old #anchors forward there.

- technical/ItemCard.tsx: the shared card + the field/button/list pieces,
  Cron refactored onto it (no visual change)
- mail trigger: an address can be switched off without being removed —
  the store keeps a second `disabled` list; the mail service still reads
  only `emails`, so nothing changes on its side (API + orval types regenerated)
- workers: the routing switch is the header toggle; name and account are
  editable in the body
- loginHref / dashboard links point at /settings/accounts

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:36:10 +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)