From adf6e992a668dfa9fee7b0148638fbf280361e9c Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 9 Oct 2026 11:21:17 +0200 Subject: [PATCH] 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 Assistant: claude-code Assistant-Model: opus Assistant-Process: 445231@bnt-lap001 Assistant-Session: 024a807e-0cc5-4f2c-bc8a-b83322d1395d --- docs/configuration.md | 11 +++++ src/user_engine/adapters/delivery.py | 13 ++++- src/user_engine/mail_bindings.py | 40 +++++++++++++++ src/user_engine/runtime.py | 1 + src/user_engine/service.py | 2 +- src/user_engine/web.py | 1 + tests/test_mail_bindings.py | 73 ++++++++++++++++++++++++++++ tests/test_service_operations.py | 17 +++++++ workplans/ADHOC-2026-10-09.md | 43 ++++++++++++++++ 9 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 src/user_engine/mail_bindings.py create mode 100644 tests/test_mail_bindings.py create mode 100644 workplans/ADHOC-2026-10-09.md diff --git a/docs/configuration.md b/docs/configuration.md index af5c32b..2aecaef 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -71,6 +71,17 @@ Enabling it also requires the verification adapter and allow-lists: The service fails closed when those settings are absent. Do not add a placeholder or reused credential to turn the flag on. +`USER_ENGINE_MAIL_CLIENT_BINDINGS` is optional and unset by default. When +unset, invitation mail envelopes keep the legacy shape (no `client_id`). When +set, it is a JSON list of `[tenant_glob, client_id]` pairs (1–128 rows, unique +globs, case-sensitive whole-string `*`/`?` matching), for example +`[["tenant:*-family", "coulomb-social"]]`. Each invitation mail carries the +single matching `client_id`, so email-connect can admit it as a scoped caller +(EMAIL-WP-0008). A tenant with no match, or with matches naming different +clients, is denied as `mail_binding_denied` before either delivery lane is +contacted; the platform event never carries `client_id`. An invalid value stops +startup — it never falls back to the legacy shape. + ## Authenticator management handoff diff --git a/src/user_engine/adapters/delivery.py b/src/user_engine/adapters/delivery.py index 6c8bdc0..80a256e 100644 --- a/src/user_engine/adapters/delivery.py +++ b/src/user_engine/adapters/delivery.py @@ -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: diff --git a/src/user_engine/mail_bindings.py b/src/user_engine/mail_bindings.py new file mode 100644 index 0000000..745df48 --- /dev/null +++ b/src/user_engine/mail_bindings.py @@ -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() diff --git a/src/user_engine/runtime.py b/src/user_engine/runtime.py index 909fd63..dc65648 100644 --- a/src/user_engine/runtime.py +++ b/src/user_engine/runtime.py @@ -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, diff --git a/src/user_engine/service.py b/src/user_engine/service.py index 38f4f3a..55fb469 100644 --- a/src/user_engine/service.py +++ b/src/user_engine/service.py @@ -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, diff --git a/src/user_engine/web.py b/src/user_engine/web.py index 467e0c7..f6c0ad3 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -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" diff --git a/tests/test_mail_bindings.py b/tests/test_mail_bindings.py new file mode 100644 index 0000000..fd7ee02 --- /dev/null +++ b/tests/test_mail_bindings.py @@ -0,0 +1,73 @@ +"""Scoped mail migration uses synthetic identities and a mocked HTTP boundary.""" + +import json +import unittest +from unittest.mock import patch + +from user_engine.adapters.delivery import HTTPOutboxDeliveryAdapter, DeliveryError +from user_engine.domain import OutboxEvent +from user_engine.mail_bindings import load_mail_client_bindings, mail_client_for + + +class MailBindingsTests(unittest.TestCase): + def test_whole_case_sensitive_globs_and_literal_clients(self): + bindings = load_mail_client_bindings('[ ["tenant:*-family", "coulomb-social"] ]') + self.assertEqual(mail_client_for("tenant:synthetic-family", bindings), "coulomb-social") + for tenant in ("tenant:platform", "tenant:Synthetic-Family", "tenant:x-family/path"): + with self.assertRaisesRegex(ValueError, "mail_binding_denied"): + mail_client_for(tenant, bindings) + + def test_invalid_configuration_never_falls_back(self): + for raw in ('', '[]', '{}', '[["*", "*"]]', '[["[a]", "client"]]', + '[["*", "one"], ["*", "two"]]', '[[true, "client"]]'): + with self.assertRaisesRegex(ValueError, "mail_client_bindings_invalid"): + load_mail_client_bindings(raw) + self.assertIsNone(load_mail_client_bindings(None)) + + def test_conflicting_overlaps_are_denied(self): + bindings = load_mail_client_bindings('[["tenant:*", "one"], ["tenant:x", "two"]]') + with self.assertRaisesRegex(ValueError, "mail_binding_denied"): + mail_client_for("tenant:x", bindings) + + def adapter(self, bindings=None): + return HTTPOutboxDeliveryAdapter(event_url="http://events.example.test", + event_bearer_token="synthetic-event-token", mail_url="http://mail.example.test", + mail_bearer_token="synthetic-mail-token", mail_client_bindings=bindings) + + def event(self, kind="family_member.invited", tenant="tenant:synthetic-family"): + return OutboxEvent(event_id="evt-synthetic", event_type=kind, aggregate_id="inv-synthetic", + payload={"primary_email": "member@example.test", "invitation_id": "inv-synthetic"}, + tenant=tenant, correlation_id="trace-synthetic") + + def test_binding_only_changes_mail_transport_not_event_or_outbox(self): + event = self.event() + adapter = self.adapter('[["tenant:*-family", "coulomb-social"]]') + with patch.object(adapter, "_post") as post: + adapter(event) + mail, audit = [call.args[1] for call in post.call_args_list] + self.assertEqual(mail["client_id"], "coulomb-social") + self.assertNotIn("client_id", audit) + self.assertNotIn("primary_email", audit["data"]) + self.assertNotIn("client_id", event.payload) + self.assertEqual(mail["id"], event.event_id) + + def test_legacy_mode_and_non_mail_events_preserve_envelopes(self): + adapter = self.adapter() + with patch.object(adapter, "_post") as post: + adapter(self.event()) + self.assertNotIn("client_id", post.call_args_list[0].args[1]) + adapter = self.adapter('[["tenant:*-family", "coulomb-social"]]') + with patch.object(adapter, "_post") as post: + adapter(self.event("membership.added", "tenant:platform")) + self.assertEqual(post.call_count, 1) + + def test_unadmitted_mail_tenant_contacts_neither_lane(self): + adapter = self.adapter('[["tenant:*-family", "coulomb-social"]]') + with patch.object(adapter, "_post") as post: + with self.assertRaisesRegex(DeliveryError, "mail_binding_denied"): + adapter(self.event(tenant="tenant:platform")) + post.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_service_operations.py b/tests/test_service_operations.py index 47b875f..c0786a2 100644 --- a/tests/test_service_operations.py +++ b/tests/test_service_operations.py @@ -106,6 +106,23 @@ class ServiceOperations(JourneyFixture): self.app.service.store.outbox_event(event.event_id).delivered_at ) + def test_mail_binding_denial_is_recorded_not_collapsed(self): + event = self.seed() + + def deny(event): + raise DeliveryError("mail_binding_denied") + + self.app.outbox_delivery = deny + self.post( + "/platform/operations/deliver", + who="operator", + event_id=event.event_id, + confirmed=True, + ) + failed = self.app.service.store.outbox_event(event.event_id) + self.assertEqual("mail_binding_denied", failed.failure_reason) + self.assertIsNone(failed.delivered_at) + def test_crashed_claim_reconciles_under_event_guard(self): event = self.seed() self.app.service.store.save_outbox( diff --git a/workplans/ADHOC-2026-10-09.md b/workplans/ADHOC-2026-10-09.md new file mode 100644 index 0000000..88ce25c --- /dev/null +++ b/workplans/ADHOC-2026-10-09.md @@ -0,0 +1,43 @@ +--- +id: USER-WP-ADHOC-2026-10-09 +type: workplan +title: "Scoped invitation mail client bindings" +domain: communication +repo: user-engine +status: finished +owner: claude +created: "2026-10-09" +updated: "2026-10-09" +--- + +## Send an admitted tenant/client pair on invitation mail + +```task +id: USER-WP-ADHOC-2026-10-09-T01 +status: done +priority: medium +``` + +User-engine side of email-connect EMAIL-WP-0008 (caller policy draft +2026-10-08): current invitation envelopes omit `client_id`, so a scoped +user-engine caller cannot be admitted. Opt-in +`USER_ENGINE_MAIL_CLIENT_BINDINGS` maps tenant globs to one literal client id. + +Files: + +- `src/user_engine/mail_bindings.py` — strict loader and single-match resolver +- `src/user_engine/adapters/delivery.py` — adds `client_id` to the mail + envelope only; denial contacts neither lane +- `src/user_engine/runtime.py` — reads the env var +- `src/user_engine/service.py`, `src/user_engine/web.py` — keep + `mail_binding_denied` as the recorded failure reason with an operator status + line instead of collapsing it to `delivery_unavailable` +- `docs/configuration.md`, `tests/test_mail_bindings.py`, + `tests/test_service_operations.py` + +Acceptance: unset keeps legacy envelopes; invalid config stops startup; +unmatched or conflicting tenants are denied; platform events and outbox payloads +never carry `client_id`; `make test` and `make check-layer` pass. + +Not done here: activation. Deploy config, email-connect caller admission +(EMAIL-WP-0008-T03) and token custody stay with their owners and the founder.