diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 2628827..1c3958c 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -42,7 +42,7 @@ | task | EMAIL-WP-0004-T04 | done | — | workplans/EMAIL-WP-0004-transactional-mail-delivery-service.md | | task | EMAIL-WP-0005-T01 | done | — | workplans/EMAIL-WP-0005-test-mailbox-harness.md | | task | EMAIL-WP-0005-T02 | done | — | workplans/EMAIL-WP-0005-test-mailbox-harness.md | -| task | EMAIL-WP-0005-T03 | todo | — | workplans/EMAIL-WP-0005-test-mailbox-harness.md | +| task | EMAIL-WP-0005-T03 | done | — | workplans/EMAIL-WP-0005-test-mailbox-harness.md | | task | EMAIL-WP-0005-T04 | todo | — | workplans/EMAIL-WP-0005-test-mailbox-harness.md | | task | EMAIL-WP-0005-T05 | todo | — | workplans/EMAIL-WP-0005-test-mailbox-harness.md | | task | EMAIL-WP-0005-T06 | todo | — | workplans/EMAIL-WP-0005-test-mailbox-harness.md | diff --git a/src/email_connect/parser.py b/src/email_connect/parser.py index 93262a8..402065d 100644 --- a/src/email_connect/parser.py +++ b/src/email_connect/parser.py @@ -93,7 +93,7 @@ def parse_message_bytes( deduplication_key=dedup_key, ) - parsed = classify_message(inbound, combined, observed_at=observed_at) + parsed = classify_message(inbound, combined, observed_at=observed_at, body_text=text) candidate = candidate_from_parsed( parsed, raw_message_ref=raw_message_ref, @@ -123,8 +123,14 @@ def classify_message( combined_text: str, *, observed_at: datetime, + body_text: str | None = None, ) -> ParsedMailboxMessage: text = combined_text.lower() + # The reply heuristic looks at the body alone. Matched against headers it + # fires on the `Received:` trace that every MTA-handled message carries, + # which would label ordinary mail a human reply. Defaults to the combined + # text so callers that have no separate body are unaffected. + reply_text = (body_text if body_text is not None else combined_text).lower() enhanced_status = _first_match(ENHANCED_STATUS_RE, combined_text) smtp_status = _first_match(SMTP_STATUS_RE, combined_text) dsn_fields = _extract_dsn_fields(combined_text) @@ -161,7 +167,7 @@ def classify_message( reason_code = "delayed" elif _is_dsn_like(text): message_class, confidence, reason_code = _classify_dsn(text, smtp_status, enhanced_status) - elif _looks_like_human_reply(inbound, text): + elif _looks_like_human_reply(inbound, reply_text): message_class = MessageClass.HUMAN_REPLY confidence = Confidence.MEDIUM reason_code = "reply" diff --git a/src/email_connect/transactional.py b/src/email_connect/transactional.py index fd32438..e883e6b 100644 --- a/src/email_connect/transactional.py +++ b/src/email_connect/transactional.py @@ -12,7 +12,7 @@ import sqlite3 import secrets from datetime import datetime, timedelta, timezone from email.message import EmailMessage -from email.utils import parseaddr +from email.utils import make_msgid, parseaddr from http import HTTPStatus from threading import RLock from wsgiref.simple_server import make_server @@ -180,6 +180,11 @@ class SMTPProvider: def send(self, recipient: str, subject: str, text: str) -> str: message = EmailMessage() message["From"], message["To"], message["Subject"] = self.sender, recipient, subject + # RFC 5322 wants a Message-ID on outgoing mail, and it is the only handle + # that ties an accepted send to a message later observed in a mailbox. + # Without it the reference below fell back to hash(), which Python + # randomizes per process and which collides for equal recipient/subject. + message["Message-ID"] = make_msgid(domain=self.sender.rpartition("@")[2] or None) message.set_content(text) try: with smtplib.SMTP(self.host, self.port, timeout=10) as smtp: diff --git a/tests/fixtures/mailbox_transit/ordinary_transit.eml b/tests/fixtures/mailbox_transit/ordinary_transit.eml new file mode 100644 index 0000000..cffdd5b --- /dev/null +++ b/tests/fixtures/mailbox_transit/ordinary_transit.eml @@ -0,0 +1,13 @@ +Received: from sender.example.test (sender.example.test [192.0.2.10]) + by mx.example.test with ESMTP id ABC123 + for ; Tue, 02 Jun 2026 10:00:00 +0000 +Received: by sender.example.test with SMTP id XYZ789; + Tue, 02 Jun 2026 09:59:58 +0000 +Message-ID: +Date: Tue, 02 Jun 2026 10:00:00 +0000 +From: noreply@example.test +To: person@example.test +Subject: Your invitation +Content-Type: text/plain; charset="utf-8" + +You have been invited. Continue securely at https://portal.example.test/invitations/inv-1 diff --git a/tests/test_integration_send_scan.py b/tests/test_integration_send_scan.py new file mode 100644 index 0000000..e68cbf9 --- /dev/null +++ b/tests/test_integration_send_scan.py @@ -0,0 +1,227 @@ +"""End-to-end send-and-scan continuity against the local mail harness. + +Every case here needs the harness (`docker compose -f tests/harness/docker-compose.yml +up -d`) and is skipped without it, so the default suite stays offline. + +What these tests may assert is bounded: a message reaching a harness mailbox is +`provider_accepted` transport evidence and nothing more. Asserting delivery, +awareness, identity, or authorization from it would be a defect in the test. +""" + +from __future__ import annotations + +import imaplib +import io +import json +import tempfile +from email import policy +from email.parser import BytesParser +from pathlib import Path + +import pytest + +import harness +from email_connect.scanner import scan_mailbox +from email_connect.transactional import SQLiteDeliveryStore, TransactionalApplication + +requires_harness = pytest.mark.skipif( + not harness.available(), + reason="mail harness not running: docker compose -f tests/harness/docker-compose.yml up -d", +) + +TOKEN = "harness-ingest-token" +PORTAL = "https://users.harness.email-connect.test" + + +def application(tmp_path: Path) -> TransactionalApplication: + return TransactionalApplication( + SQLiteDeliveryStore(str(tmp_path / "mail.db")), + harness.smtp_provider(), + TOKEN, + PORTAL, + ) + + +def invoke(app, payload, *, path="/v1/send", key=None, token=TOKEN): + raw = json.dumps(payload).encode() + result = {} + env = { + "PATH_INFO": path, + "REQUEST_METHOD": "POST", + "CONTENT_LENGTH": str(len(raw)), + "wsgi.input": io.BytesIO(raw), + "HTTP_AUTHORIZATION": f"Bearer {token}", + } + if path == "/v1/send": + env["HTTP_IDEMPOTENCY_KEY"] = key if key is not None else payload.get("id") + body = b"".join(app(env, lambda status, _headers: result.update(status=status))) + return result["status"], json.loads(body) + + +def invitation(event_id: str, recipient: str, typ: str = "family_member.invited") -> dict: + return { + "id": event_id, + "type": typ, + "source": "user-engine", + "data": {"primary_email": recipient, "invitation_id": f"inv-{event_id}"}, + } + + +def delivered_messages(user_address: str) -> list: + """Every message in a harness mailbox, parsed.""" + + connection = imaplib.IMAP4(harness.HOST, harness.IMAP_PORT) + try: + connection.login(user_address, harness.PASSWORD) + connection.select("INBOX", readonly=True) + _status, data = connection.uid("search", None, "ALL") + uids = (data[0] or b"").split() + messages = [] + for uid in uids: + _status, fetched = connection.uid("fetch", uid, "(BODY.PEEK[])") + for item in fetched: + if isinstance(item, tuple) and len(item) >= 2 and isinstance(item[1], bytes): + messages.append(BytesParser(policy=policy.default).parsebytes(item[1])) + return messages + finally: + connection.logout() + + +@requires_harness +def test_accepted_invitation_reaches_the_mailbox_and_is_correlatable(tmp_path): + harness.reset() + recipient = harness.address("invitation delivered") + + status, body = invoke(application(tmp_path), invitation("evt-1", recipient)) + + assert status.startswith("202") + assert body["status"] == "accepted" + assert body["evidence_ceiling"] == "provider_accepted" + + messages = delivered_messages(recipient) + assert len(messages) == 1 + assert messages[0]["To"] == recipient + assert f"{PORTAL}/invitations/inv-evt-1" in messages[0].get_content() + # The returned reference is the sent Message-ID, so an accepted send can be + # tied to a message later observed in a mailbox. + assert body["reference"] == str(messages[0]["Message-ID"]).strip() + + +@requires_harness +def test_scanner_ingests_a_delivered_invitation_without_claiming_delivery(tmp_path): + harness.reset() + recipient = harness.address("invitation scanned") + invoke(application(tmp_path), invitation("evt-2", recipient)) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + config = harness.mailbox_config( + recipient, + storage_path=str(root / "state.sqlite"), + reports_dir=str(root / "reports"), + ) + result = scan_mailbox(config) + report = result.report_path.read_text(encoding="utf-8") + + assert result.scan.messages_seen == 1 + assert result.scan.messages_parsed == 1 + + # An ordinary outbound invitation sitting in a mailbox is not return-path + # evidence. The scanner must not turn it into a delivery, awareness, + # identity, or interaction claim. + for forbidden in ( + "notification.endpoint.delivered", + "notification.message.delivered", + "interaction.reply_received", + "interaction.opened", + "identity.verified", + ): + assert forbidden not in report + + +@requires_harness +def test_duplicate_event_sends_once_and_returns_the_same_reference(tmp_path): + harness.reset() + recipient = harness.address("invitation idempotent") + app = application(tmp_path) + + first_status, first = invoke(app, invitation("evt-3", recipient)) + second_status, second = invoke(app, invitation("evt-3", recipient)) + + assert first_status.startswith("202") and first["status"] == "accepted" + assert second_status.startswith("200") and second["status"] == "duplicate" + assert second["reference"] == first["reference"] + assert len(delivered_messages(recipient)) == 1 + + +@requires_harness +def test_resend_is_a_separate_event_and_delivers_again(tmp_path): + harness.reset() + recipient = harness.address("invitation resent") + app = application(tmp_path) + + invoke(app, invitation("evt-4", recipient)) + status, body = invoke(app, invitation("evt-5", recipient, "family_invitation.resent")) + + assert status.startswith("202") and body["status"] == "accepted" + messages = delivered_messages(recipient) + assert len(messages) == 2 + assert len({message["Message-ID"] for message in messages}) == 2 + + +@requires_harness +def test_suppressed_recipient_receives_nothing(tmp_path): + harness.reset() + recipient = harness.address("invitation suppressed") + store = SQLiteDeliveryStore(str(tmp_path / "mail.db")) + store.suppress(recipient, "complaint") + app = TransactionalApplication(store, harness.smtp_provider(), TOKEN, PORTAL) + + status, body = invoke(app, invitation("evt-6", recipient)) + + assert status.startswith("400") + assert body["error"] == "recipient_suppressed" + assert delivered_messages(recipient) == [] + + +@requires_harness +def test_rejected_requests_never_reach_the_provider(tmp_path): + harness.reset() + recipient = harness.address("invitation rejected") + app = application(tmp_path) + + unauthorized = invoke(app, invitation("evt-7", recipient), token="wrong") + not_allowed = invoke(app, invitation("evt-8", recipient, "arbitrary.send")) + key_mismatch = invoke(app, invitation("evt-9", recipient), key="other") + + assert unauthorized[0].startswith("401") + assert not_allowed[1]["error"] == "template_not_allowed" + assert key_mismatch[1]["error"] == "idempotency_key_mismatch" + assert delivered_messages(recipient) == [] + + +@requires_harness +def test_verification_mail_is_delivered_and_carries_no_authorization(tmp_path): + harness.reset() + recipient = harness.address("verification delivered") + app = application(tmp_path) + + status, body = invoke( + app, + { + "registration_id": "reg-1", + "normalized_email": recipient, + "preferred_username": "harness-user", + "client_id": "portal", + "tenant": "harness", + "correlation_id": "corr-1", + }, + path="/v1/registration-verifications", + ) + + assert status.startswith("2"), (status, body) + messages = delivered_messages(recipient) + assert len(messages) == 1 + assert f"{PORTAL}/registration/verify?handle=" in messages[0].get_content() + # Delivering a verification link is not proof that anyone verified anything. + assert body.get("authorization", False) is False diff --git a/tests/test_parser.py b/tests/test_parser.py index 3cfcbd2..a105f51 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -92,6 +92,21 @@ class ParserTests(unittest.TestCase): self.assertIn("action=failed", parsed.notes) self.assertIn("diagnostic_code=smtp; 550 5.1.1 User unknown", parsed.notes) + def test_mta_received_headers_do_not_make_a_message_a_reply(self) -> None: + """Regression: the reply heuristic used to match the Received: trace. + + Every message that traverses an MTA carries `Received:` headers, and the + word "received" was enough to classify ordinary mail as a human reply + with a success.reply_received assessment. Hand-written fixtures have no + Received headers, so only real scanned mail exposed it. + """ + + transit = Path(__file__).parent / "fixtures" / "mailbox_transit" / "ordinary_transit.eml" + _inbound, parsed, candidate = parse_message_file(transit, mailbox_id="test") + self.assertNotEqual(parsed.message_class, MessageClass.HUMAN_REPLY) + if candidate is not None: + self.assertNotEqual(candidate.event_type, "interaction.reply_received") + if __name__ == "__main__": unittest.main() diff --git a/workplans/EMAIL-WP-0005-test-mailbox-harness.md b/workplans/EMAIL-WP-0005-test-mailbox-harness.md index 24a9d6f..a5dd411 100644 --- a/workplans/EMAIL-WP-0005-test-mailbox-harness.md +++ b/workplans/EMAIL-WP-0005-test-mailbox-harness.md @@ -264,7 +264,7 @@ Done 2026-08-14: ```task id: EMAIL-WP-0005-T04 -status: todo +status: done priority: high state_hub_task_id: "69655fad-97b8-4c17-bc54-af341aaaf38c" ``` @@ -288,6 +288,36 @@ An integration run proves send-to-scan continuity against a live local server, while the default test run remains offline, deterministic, and unchanged. ``` +Done 2026-08-14: + +* `tests/test_integration_send_scan.py`, 7 harness-gated cases: accepted + invitation reaches the mailbox and correlates by Message-ID; scanner ingests + it without a delivery claim; duplicate event sends once and returns the same + reference; resend delivers a second distinct message; suppressed recipient + receives nothing; unauthorized / template-denied / key-mismatch requests never + reach the provider; verification mail delivers and reports + `authorization=false`. +* Suite: 72 passed with the harness up, 62 passed + 10 skipped with it down. + +Two production defects surfaced, both invisible to the fixture-only suite: + +* **Reply heuristic matched message headers.** `_looks_like_human_reply` ran + against headers plus body, so the `Received:` trace that every MTA-handled + message carries matched its "received" keyword. Ordinary mail was classified + `human_reply` with a `success.reply_received` assessment at medium confidence + — exactly the overclaim this repo exists to prevent. Hand-written fixtures + carry no `Received:` headers, which is why only real scanned mail exposed it. + The heuristic now takes the body alone; DSN detection still sees headers, + which it needs. Same message before/after: `human_reply`/medium → + `unknown_return_message`/low. Regression test is offline + (`tests/fixtures/mailbox_transit/ordinary_transit.eml`). +* **Provider reference was not a real reference.** `SMTPProvider.send` fell back + to `abs(hash((recipient, subject)))` because it set no `Message-ID`. Python + randomizes string hashing per process, and equal recipient/subject pairs + collided. Outgoing mail now carries a proper RFC 5322 `Message-ID`, which is + returned as the reference — so an accepted send can be tied to a message + later observed in a mailbox. This is what makes correlation testable at all. + ## T05 - Bounce, complaint, and deferral realism ```task