diff --git a/SCOPE.md b/SCOPE.md index c325ad8..eab5c5b 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -65,14 +65,25 @@ as the first reference adapter for `coordination-engine`. ## Current State -- Status: concept / planning -- Implementation: intent and PRD only -- Stability: evolving -- Usage: none yet - -The repository currently contains product intent and requirements. No runtime -stack, API implementation, persistence model, provider integration, or UI has -been committed yet. +- Status: active for two shipped slices. The broader provider API and UI in + this intent are not built. +- Mailbox scanner: `python3 -m email_connect.cli scan-mailbox` reads a fixture + directory or a mailbox over read-only IMAP, classifies return mail, and + writes CSV reports plus local SQLite. It does not mark mail seen and it + does not send. +- Transactional receiver: `src/email_connect/transactional.py` is the live + HTTP service in namespace `email-connect` on railiance01. Callers with an + admitted policy send fixed templates through authenticated SMTP. The SQLite + ledger records attempts, verifications, and suppressions. SMTP acceptance + is not inbox receipt. See `deploy/k8s/railiance/README.md`. +- MAS evidence: `src/email_connect/mas_gateway_evidence.py` maps one MAS + gateway event into this service's evidence ceiling + (`docs/mas-email-evidence-v0.1.json`). The MAS facility stays the receiver + for `mas.coulomb.social`. This service does not parse statutes or send + MAS syntax. +- Stability: the two slices and the evidence mapping are in use. The generic + `POST /email/send` API, provider webhooks, template manager, and UI stay + intent until a workplan builds them. --- @@ -104,7 +115,7 @@ been committed yet. - Start with: `INTENT.md`. - Key files / directories: `spec/ProductRequirementsDocument.md`, `workplans/`, `AGENTS.md`. -- Entry points: no code entry points yet. +- Entry points: `python3 -m email_connect.cli`, `src/email_connect/transactional.py`, `deploy/k8s/railiance/README.md`. --- diff --git a/docs/mas-email-evidence-v0.1.json b/docs/mas-email-evidence-v0.1.json new file mode 100644 index 0000000..ac4bb6c --- /dev/null +++ b/docs/mas-email-evidence-v0.1.json @@ -0,0 +1,69 @@ +{ + "contract": "mas-email-evidence", + "version": "0.1", + "source": "mas-gateway", + "owner": "mail-address-semantics", + "consumer": "email-connect", + "statement": "A MAS gateway event is a channel fact from the MAS MX. This mapping reports MX acceptance, a bounce, or a reply. It does not claim that a person read the message, opened a payload, or satisfied a business result. Tickets, statutes, and templates stay outside this contract.", + "evidence_ceiling": { + "max_positive_event": "interaction.reply_received", + "max_positive_strength": "medium", + "can_prove_human_awareness": false, + "can_prove_payload_access": false, + "can_prove_payload_delivery": false, + "can_prove_identity": false, + "can_prove_authority": false, + "can_prove_non_repudiation": false + }, + "outcomes": { + "parse_failed": { + "event_type": "diagnostic.message.parse_failed", + "assessment_category": "undef", + "assessment_subclass": "undef.parse_failed", + "evidence_strength": "none", + "normalized_meaning": "The value is not a MAS gateway event v0.1. No acceptance, bounce, or reply is claimed." + }, + "bounced_permanent": { + "event_type": "notification.endpoint.rejected_permanent", + "assessment_category": "fail", + "assessment_subclass": "fail.hard_bounce", + "evidence_strength": "negative", + "normalized_meaning": "The message accepted by the MAS MX is a permanent delivery failure for an earlier message. That is not evidence about a person." + }, + "bounced_temporary": { + "event_type": "notification.endpoint.rejected_temporary", + "assessment_category": "undef", + "assessment_subclass": "undef.deferred", + "evidence_strength": "ambiguous", + "normalized_meaning": "The message accepted by the MAS MX is a temporary delivery failure. Later evidence can change this." + }, + "bounce_unclassified": { + "event_type": "notification.endpoint.unknown", + "assessment_category": "undef", + "assessment_subclass": "undef.conflicting_evidence", + "evidence_strength": "ambiguous", + "normalized_meaning": "The message looks like a delivery report, and the permanent or temporary result is not reliable." + }, + "out_of_office": { + "event_type": "interaction.out_of_office_received", + "assessment_category": "undef", + "assessment_subclass": "undef.out_of_office", + "evidence_strength": "weak", + "normalized_meaning": "An automatic reply was accepted by the MAS MX. It is not a human reply and it does not show that a person read the earlier message." + }, + "reply_received": { + "event_type": "interaction.reply_received", + "assessment_category": "success", + "assessment_subclass": "success.reply_received", + "evidence_strength": "medium", + "normalized_meaning": "A non-automatic reply was accepted by the MAS MX. A reply does not prove who read the earlier message or that a request was accepted." + }, + "mx_accepted": { + "event_type": "notification.endpoint.accepted", + "assessment_category": "undef", + "assessment_subclass": "undef.endpoint_accepted_only", + "evidence_strength": "weak", + "normalized_meaning": "The MAS MX accepted this message for the recipient. Acceptance is not inbox placement and not human reading." + } + } +} diff --git a/src/email_connect/adapter_contract.py b/src/email_connect/adapter_contract.py index f28764a..eaa3dd4 100644 --- a/src/email_connect/adapter_contract.py +++ b/src/email_connect/adapter_contract.py @@ -4,6 +4,7 @@ ADAPTER_ID = "email-connect" ADAPTER_CONTRACT_VERSION = "1.1" EMITTED_EVENT_TYPES = [ + "notification.endpoint.accepted", "notification.endpoint.rejected_permanent", "notification.endpoint.rejected_temporary", "notification.endpoint.deferred", diff --git a/src/email_connect/mas_gateway_evidence.py b/src/email_connect/mas_gateway_evidence.py new file mode 100644 index 0000000..d21fed7 --- /dev/null +++ b/src/email_connect/mas_gateway_evidence.py @@ -0,0 +1,127 @@ +"""Map a MAS gateway event into the email-connect evidence ceiling. + +The normative outcome table is docs/mas-email-evidence-v0.1.json. The same +file is vendored in email-connect. A gateway event means the MAS MX took the +message. Bounce and reply are properties of that message. Nothing here claims +that a person read it. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +CONTRACT_PATH = Path(__file__).resolve().parents[2] / "docs" / "mas-email-evidence-v0.1.json" +_CONTRACT = json.loads(CONTRACT_PATH.read_text()) +_ENHANCED = re.compile(r"\b([45]\.\d{1,3}\.\d{1,3})\b") +_SMTP = re.compile(r"\b([45]\d{2})\b") +_DSN_MARKERS = ( + "mailer-daemon", + "delivery status notification", + "undelivered mail", + "mail delivery failed", + "final-recipient", + "diagnostic-code", +) +_PERMANENT_TEXT = ("user unknown", "mailbox not found", "unknown user", "undeliverable") +_TEMPORARY_TEXT = ("mailbox full", "try again later", "temporary failure") + + +def from_gateway_event(event: object) -> dict: + """Return one evidence record for a gateway event.""" + if not _is_event(event): + return _record("parse_failed", None) + message = event["message"] + blob = _blob(message) + if _is_bounce(message, blob): + if _permanent(blob): + outcome = "bounced_permanent" + elif _temporary(blob): + outcome = "bounced_temporary" + else: + outcome = "bounce_unclassified" + elif _is_out_of_office(message): + outcome = "out_of_office" + elif _is_reply(message): + outcome = "reply_received" + else: + outcome = "mx_accepted" + return _record(outcome, event) + + +def _is_event(event: object) -> bool: + return ( + isinstance(event, dict) + and event.get("mas_event_version") == "0.1" + and isinstance(event.get("message"), dict) + and isinstance(event.get("recipient"), str) + ) + + +def _blob(message: dict) -> str: + parts = [ + message.get("from") or "", + message.get("subject") or "", + message.get("auto_submitted") or "", + message.get("text") or "", + ] + return "\n".join(str(part) for part in parts).lower() + + +def _is_bounce(message: dict, blob: str) -> bool: + sender = str(message.get("from") or "").lower() + if "mailer-daemon" in sender or "postmaster@" in sender: + return True + return any(marker in blob for marker in _DSN_MARKERS if marker != "mailer-daemon") + + +def _permanent(blob: str) -> bool: + enhanced = _ENHANCED.search(blob) + smtp = _SMTP.search(blob) + if enhanced and enhanced.group(1).startswith("5."): + return True + if smtp and smtp.group(1).startswith("5"): + return True + return any(marker in blob for marker in _PERMANENT_TEXT) + + +def _temporary(blob: str) -> bool: + enhanced = _ENHANCED.search(blob) + smtp = _SMTP.search(blob) + if enhanced and enhanced.group(1).startswith("4."): + return True + if smtp and smtp.group(1).startswith("4"): + return True + return any(marker in blob for marker in _TEMPORARY_TEXT) + + +def _is_out_of_office(message: dict) -> bool: + auto = str(message.get("auto_submitted") or "").lower() + return auto.startswith("auto-replied") + + +def _is_reply(message: dict) -> bool: + reply = str(message.get("in_reply_to") or "").strip() + auto = str(message.get("auto_submitted") or "").lower() + return bool(reply) and auto in ("", "no") + + +def _record(outcome: str, event: dict | None) -> dict: + spec = _CONTRACT["outcomes"][outcome] + message = (event or {}).get("message") or {} + return { + "contract": _CONTRACT["contract"], + "contract_version": _CONTRACT["version"], + "source": _CONTRACT["source"], + "outcome": outcome, + "event_type": spec["event_type"], + "assessment_category": spec["assessment_category"], + "assessment_subclass": spec["assessment_subclass"], + "evidence_strength": spec["evidence_strength"], + "normalized_meaning": spec["normalized_meaning"], + "evidence_ceiling": _CONTRACT["evidence_ceiling"], + "recipient": (event or {}).get("recipient"), + "message_id": message.get("message_id"), + "in_reply_to": message.get("in_reply_to"), + } diff --git a/tests/test_mas_gateway_evidence.py b/tests/test_mas_gateway_evidence.py new file mode 100644 index 0000000..e9ea3d5 --- /dev/null +++ b/tests/test_mas_gateway_evidence.py @@ -0,0 +1,60 @@ +"""MAS gateway event to email-connect evidence ceiling (mas-email-evidence 0.1).""" + +from email_connect.mas_gateway_evidence import from_gateway_event + + +def event(**message): + base = {"from": "a@example.org", "subject": "hello", "text": "body"} + base.update(message) + return { + "mas_event_version": "0.1", + "recipient": "ticket=GS-1@mas.coulomb.social", + "mas": {"valid": True}, + "message": base, + } + + +def test_ordinary_mail_is_mx_acceptance_only(): + got = from_gateway_event(event()) + assert got["outcome"] == "mx_accepted" + assert got["event_type"] == "notification.endpoint.accepted" + assert got["assessment_subclass"] == "undef.endpoint_accepted_only" + assert got["evidence_ceiling"]["can_prove_human_awareness"] is False + assert got["evidence_ceiling"]["max_positive_strength"] == "medium" + + +def test_reply_is_medium_and_does_not_prove_a_person_read_it(): + got = from_gateway_event(event(in_reply_to="", text="I saw this")) + assert got["outcome"] == "reply_received" + assert got["evidence_strength"] == "medium" + assert "does not prove" in got["normalized_meaning"] + assert got["evidence_ceiling"]["can_prove_human_awareness"] is False + + +def test_automatic_reply_is_not_a_human_reply(): + got = from_gateway_event(event(in_reply_to="", auto_submitted="auto-replied", text="away")) + assert got["outcome"] == "out_of_office" + assert got["event_type"] == "interaction.out_of_office_received" + + +def test_permanent_and_temporary_bounces(): + hard = from_gateway_event(event(**{ + "from": "mailer-daemon@mx.example", + "subject": "Undelivered Mail Returned to Sender", + "text": "Final-Recipient: rfc822; b@example.org\nStatus: 5.1.1\nuser unknown", + })) + soft = from_gateway_event(event(**{ + "from": "mailer-daemon@mx.example", + "subject": "Delivery Status Notification", + "text": "Final-Recipient: rfc822; b@example.org\nStatus: 4.2.2\nmailbox full", + })) + assert hard["outcome"] == "bounced_permanent" + assert soft["outcome"] == "bounced_temporary" + assert hard["evidence_ceiling"] == soft["evidence_ceiling"] + + +def test_a_non_event_makes_no_channel_claim(): + got = from_gateway_event({"hello": "no"}) + assert got["outcome"] == "parse_failed" + assert got["evidence_strength"] == "none" + assert got["recipient"] is None