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 <noreply@anthropic.com>
This commit is contained in:
parent
442a574cf9
commit
e9609b4024
7 changed files with 301 additions and 5 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue