feat(email-ingress): exactly-once dedup via Message-ID hash (LC-77-MID-DEDUP) #207
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/lc-77-mid-dedup"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Closes #202. Adds an exactly-once dedup defense to the LC-77 IMAP poll loop. The v1 posture is at-least-once with
\Seen-after-attempt: a crash between (process) and (STORE +Seen) on the same UID re-fetches and re-posts on the next tick. This PR adds a second defense that drops the replay before the actor runs.Mechanism
Message-ID:header is HMAC-SHA256-hashed underLETS_CHAT_SECRET_KEYand recorded inchat.db::processed_message_ids(only the hash; the plaintext never persists).process_polled_messagechecks the table. If the Message-ID is already there, drop withDropReason::Duplicateand post nothing.duplicateregardless of intervening state changes (revoke, token expiry).Message-ID:header falls back to v1 at-least-once. RFC 5322 says senders SHOULD include one; almost all real mail does. Documented as a known gap.Scoping decision (deviates from ticket)
The ticket proposed a
(inbox_id, message_id_hash)UNIQUE composite key. After LC-77-REPLY stage 2 shipped, the polled mailbox now serves both the per-room inbox path AND the reply-by-email path, and the reply path has noinbox_id. This PR uses a single global key overmessage_id_hashalone, which covers both without an awkward NULL/sentinel column. A Message-ID is sender-globally-unique in practice so the global keying adds no false-positive risk.Tests
db_email_ingress_dedup.rs(5 round-trip tests: hash stability + keying, unknown-hash lookup, mark-then-is round trip, idempotent mark under racing inserts, 30-day cutoff sweep).email_ingress_dedup_path.rs(4 integration tests: same Message-ID twice -> Posted+Duplicate, two distinct Message-IDs both post, no-Message-ID falls back to at-least-once, dedup check runs before resolve so a replay against a revoked inbox still drops asduplicate).just testandjust test-saaspass (modulo the documentedroutes_uploadsconcurrent-load flake; passes in isolation).just checkclean (fmt + clippy --deny warnings).Docs
docs/email-ingress.mdremoves the deferred entry for exactly-once dedup and adds a new Duplicate suppression section covering the mechanism, coverage gaps, and an operator escape hatch (aDELETE ... WHERE message_id_hash = ?workaround for forcing a re-process).CLAUDE.mdgets a one-paragraph note.Test plan
just testjust test-saasjust checkreason=duplicateand posts nothing additional.