Add opt-in tenant-to-client bindings for invitation mail
User-engine side of EMAIL-WP-0008: USER_ENGINE_MAIL_CLIENT_BINDINGS maps tenant globs to a single client_id carried on invitation mail envelopes only. Unmatched or conflicting tenants fail as mail_binding_denied before either lane is contacted, and the outbox now records that reason instead of collapsing it to delivery_unavailable. Unset keeps legacy envelopes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 445231@bnt-lap001 Assistant-Session: 024a807e-0cc5-4f2c-bc8a-b83322d1395d
This commit is contained in:
parent
a9360842f8
commit
adf6e992a6
9 changed files with 199 additions and 2 deletions
|
|
@ -19,6 +19,7 @@ class NoRedirect(HTTPRedirectHandler):
|
|||
|
||||
|
||||
from user_engine.domain import OutboxEvent
|
||||
from user_engine.mail_bindings import load_mail_client_bindings, mail_client_for
|
||||
|
||||
_MAIL_EVENTS = {"family_member.invited", "family_invitation.resent"}
|
||||
|
||||
|
|
@ -34,12 +35,14 @@ class HTTPOutboxDeliveryAdapter:
|
|||
mail_url: str | None = None,
|
||||
mail_bearer_token: str | None = None,
|
||||
timeout_seconds: float = 5.0,
|
||||
mail_client_bindings: str | None = None,
|
||||
) -> None:
|
||||
self.event_url = event_url
|
||||
self.mail_url = mail_url
|
||||
self.event_bearer_token = event_bearer_token
|
||||
self.mail_bearer_token = mail_bearer_token
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.mail_client_bindings = load_mail_client_bindings(mail_client_bindings)
|
||||
|
||||
def __call__(self, event: OutboxEvent) -> None:
|
||||
envelope = {
|
||||
|
|
@ -57,7 +60,15 @@ class HTTPOutboxDeliveryAdapter:
|
|||
if self.mail_url and event.event_type in _MAIL_EVENTS:
|
||||
if not self.mail_bearer_token:
|
||||
raise RuntimeError("mail delivery token is required")
|
||||
self._post(self.mail_url, envelope, self.mail_bearer_token)
|
||||
mail_envelope = dict(envelope)
|
||||
if self.mail_client_bindings is not None:
|
||||
try:
|
||||
mail_envelope["client_id"] = mail_client_for(
|
||||
event.tenant, self.mail_client_bindings
|
||||
)
|
||||
except ValueError:
|
||||
raise DeliveryError("mail_binding_denied") from None
|
||||
self._post(self.mail_url, mail_envelope, self.mail_bearer_token)
|
||||
event_envelope = dict(envelope)
|
||||
event_data = dict(envelope["data"])
|
||||
if "primary_email" in event_data:
|
||||
|
|
|
|||
40
src/user_engine/mail_bindings.py
Normal file
40
src/user_engine/mail_bindings.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""Explicit tenant globs for the scoped invitation mail transport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from fnmatch import fnmatchcase
|
||||
|
||||
IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\Z")
|
||||
PATTERN = re.compile(r"[A-Za-z0-9*?][A-Za-z0-9._:*?-]{0,127}\Z")
|
||||
|
||||
|
||||
def load_mail_client_bindings(raw):
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
rows = json.loads(raw)
|
||||
if not isinstance(rows, list) or not 1 <= len(rows) <= 128:
|
||||
raise ValueError
|
||||
result = []
|
||||
for row in rows:
|
||||
if (not isinstance(row, list) or len(row) != 2
|
||||
or not isinstance(row[0], str) or not PATTERN.fullmatch(row[0])
|
||||
or not isinstance(row[1], str) or not IDENTIFIER.fullmatch(row[1])):
|
||||
raise ValueError
|
||||
result.append(tuple(row))
|
||||
if len({row[0] for row in result}) != len(result):
|
||||
raise ValueError
|
||||
return tuple(result)
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError("mail_client_bindings_invalid") from None
|
||||
|
||||
|
||||
def mail_client_for(tenant, bindings):
|
||||
if not isinstance(tenant, str) or not IDENTIFIER.fullmatch(tenant):
|
||||
raise ValueError("mail_binding_denied")
|
||||
matches = {client for pattern, client in bindings if fnmatchcase(tenant, pattern)}
|
||||
if len(matches) != 1:
|
||||
raise ValueError("mail_binding_denied")
|
||||
return matches.pop()
|
||||
|
|
@ -65,6 +65,7 @@ def create_application() -> PortalApplication:
|
|||
else None
|
||||
),
|
||||
timeout_seconds=float(os.environ.get("USER_ENGINE_DELIVERY_TIMEOUT", "5")),
|
||||
mail_client_bindings=os.environ.get("USER_ENGINE_MAIL_CLIENT_BINDINGS"),
|
||||
)
|
||||
app = PortalApplication(
|
||||
service,
|
||||
|
|
|
|||
|
|
@ -2964,7 +2964,7 @@ class UserEngineService:
|
|||
deliver(claimed)
|
||||
except Exception as exc:
|
||||
code = getattr(exc, "code", "delivery_unavailable")
|
||||
if code not in {"mail_unconfigured", "delivery_outcome_unknown", "temporary_deferral", "permanent_rejection", "recipient_suppressed", "provider_timeout", "provider_unavailable"}:
|
||||
if code not in {"mail_unconfigured", "mail_binding_denied", "delivery_outcome_unknown", "temporary_deferral", "permanent_rejection", "recipient_suppressed", "provider_timeout", "provider_unavailable"}:
|
||||
code = "delivery_unavailable"
|
||||
outcome = replace(claimed, claimed_by=None, claimed_at=None,
|
||||
failed_at=utc_now(), failure_reason=code,
|
||||
|
|
|
|||
|
|
@ -1299,6 +1299,7 @@ class PortalApplication:
|
|||
if event.delivered_at: return "Accepted by delivery adapter; receipt by the person is unverified"
|
||||
if event.failure_reason == "delivery_outcome_unknown": return "Mail outcome unknown — check provider evidence before sending another message"
|
||||
if event.failure_reason == "mail_unconfigured": return "Mail is not configured — use assisted setup or restore the mail lane"
|
||||
if event.failure_reason == "mail_binding_denied": return "Mail is not admitted for this tenant — fix the mail client binding or use assisted setup"
|
||||
if event.dead_lettered_at: return "Delivery stopped after repeated failures"
|
||||
if event.failed_at: return "Delivery failed; retry pending"
|
||||
if event.claimed_by: return "Being processed"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue