From e9609b4024d7ef08af15b6fd2b8a2167fe3a8749 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 14 Aug 2026 01:54:56 +0200 Subject: [PATCH] EMAIL-WP-0005-T04: end-to-end send-and-scan tests, and two fixes they found Adds tests/test_integration_send_scan.py: 7 harness-gated cases covering delivery and Message-ID correlation, scanner ingestion without a delivery claim, idempotency, resend, suppression, rejected requests never reaching the provider, and verification mail carrying no authorization. Fixes the reply heuristic, which matched against headers as well as body. The Received: trace that every MTA-handled message carries matched its "received" keyword, so ordinary mail was classified human_reply with a success.reply_received assessment at medium confidence -- the exact overclaim this repo exists to prevent. Hand-written fixtures have no Received headers, so only real scanned mail exposed it. The heuristic now takes the body alone; DSN detection still sees headers, which it needs. Regression test is offline. Fixes the provider reference: SMTPProvider set no Message-ID, so send() fell back to abs(hash((recipient, subject))) -- randomized per process and colliding for equal recipient/subject. Outgoing mail now carries a proper RFC 5322 Message-ID, returned as the reference, which is what makes send-to-scan correlation testable. Suite: 72 passed with the harness up, 62 passed + 10 skipped with it down. Co-Authored-By: Claude Opus 5 --- WORK-RECORDS.md | 2 +- src/email_connect/parser.py | 10 +- src/email_connect/transactional.py | 7 +- .../mailbox_transit/ordinary_transit.eml | 13 + tests/test_integration_send_scan.py | 227 ++++++++++++++++++ tests/test_parser.py | 15 ++ .../EMAIL-WP-0005-test-mailbox-harness.md | 32 ++- 7 files changed, 301 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/mailbox_transit/ordinary_transit.eml create mode 100644 tests/test_integration_send_scan.py 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