Record the shipped mail slices and the MAS evidence mapping.
SCOPE.md now describes the mailbox scanner and the transactional receiver. The generic provider API and UI stay intent. A MAS gateway event maps into the existing evidence ceiling without a claim that a person read the mail. Assistant: grok Assistant-Session: 01a198fa-fd27-7e41-a0a2-57355c3e4efd
This commit is contained in:
parent
e795b2970c
commit
bdf95c1af8
5 changed files with 277 additions and 9 deletions
29
SCOPE.md
29
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`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
69
docs/mas-email-evidence-v0.1.json
Normal file
69
docs/mas-email-evidence-v0.1.json
Normal file
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
127
src/email_connect/mas_gateway_evidence.py
Normal file
127
src/email_connect/mas_gateway_evidence.py
Normal file
|
|
@ -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"),
|
||||
}
|
||||
60
tests/test_mas_gateway_evidence.py
Normal file
60
tests/test_mas_gateway_evidence.py
Normal file
|
|
@ -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="<m@x>", 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="<m@x>", 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue