Files
ai-agent/backend/mail_trigger.py
Gabriel Vidal 9301e520f5 feat(settings): editable mail-trigger sender allowlist
Add Settings -> Authorized mails for triggers: emails allowed to start a
conversation via the mail trigger, on top of the mail service's own
MAIL_TRIGGER_SENDERS env-var seed. Backed by a new JSON store
(mail_trigger.py, /data/mail-trigger-senders.json) the mail service's
container reads off the same shared volume.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:05:48 +02:00

81 lines
2.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.
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._load()
def _load(self) -> None:
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
self._emails = valid_emails(data.get("emails") or [])
except (OSError, ValueError):
self._emails = []
def emails(self) -> list[str]:
with self._lock:
return list(self._emails)
def set_emails(self, emails: list[str]) -> list[str]:
clean = valid_emails(emails)
with self._lock:
self._emails = clean
self._save()
return list(clean)
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}, indent=2), encoding="utf-8")
os.replace(tmp, self.path)