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>
81 lines
2.8 KiB
Python
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)
|