feat(email-ingress): IMAP poll + resolution + synthetic-actor send (LC-77) #196
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/lc-77-imap-poll"
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
The runtime body for LC-77 email-ingress. Builds on PR #194 (MessageActor refactor) and PR #195 (schema). Adds the third synthetic-actor variant end-to-end: an operator-configured IMAP mailbox is polled every 5 minutes; mail addressed to
<token>@<ingress-domain>posts to its room as theMessageActor::EmailInboxactor.Six commits, each independently reviewable:
Always-Seen failure log
The sole diagnostic channel for dropped mail (no bounces, no dead-letter folder in v1). Every dropped message logs at WARN with
target: "email_ingress::drop"carryinguid,reason(from the exhaustiveDropReasonenum: parse_fail / address_no_match / revoked_inbox / loop_detected / rate_limited / internal_error), and a free-formdetailstring that the support guide will key off (e.g. "tried: lc_xxx@..." for AddressNoMatch, "Auto-Submitted present" for LoopDetected, "inbox 7 exceeded 60/min cap" for RateLimited).The unconditional STORE +Seen happens AFTER the attempt, success OR fail. A poison message that never processes still gets Seen-d so it cannot trigger retry-forever; this is exactly the trap the brainstorm called out.
Threat model verifications pinned in tests
forged_from_still_posts_identity_is_secret_not_fromposts an email withFrom: admin@lets-chat-deployment.test(a clearly forged sender) and confirms the message attaches to the inbox synthetic actor, not the From sender. Stored body never echoes the From verbatim.unknown_domain_drops_even_if_local_part_matchesconfirms the right token at the wrong domain drops withAddressNoMatch.revoked_inbox_drops_with_revoked_inbox_reason— no bounce, no error to the sender, just a log line.rate_limit_blocks_burst_above_cap— 60 posted, 5 dropped with RateLimited.What is NOT in this PR (deferred to subsequent LC-77 commits)
ParseFail(or post a sparse body if mail-parser's text fallback returns anything).docs/email-ingress.mdlands there, including the operator deployment guide that pins the Delivered-To / X-Original-To / To / Cc header precedence as a deployment requirement.Test plan
cargo checkat each).--features standalone(default) and--no-default-features --features saas. 52 binaries, 0 failures.cargo clippy --tests -- -D warningsclean across the workspace.cargo fmt --checkclean.mail-parser = =0.10.2in this PR matches the spiked version.async-imap = =0.10.4matches.Followups (not in scope, separate tickets)
LC-77-FMT-CARRYOVER closed inline in commit 6 of this PR.
The runtime body for the email-ingress feature. Builds on commits 3a-3c (data-model + db layer + module core). poll.rs: - spawn_email_poll: startup-gated background task. Gates on LETS_CHAT_SECRET_KEY configured, imap_inbox_config row present, enabled = 1, and ingress_domain set. All four checks happen at startup (not per-tick), matching the LETS_CHAT_RETENTION_SWEEP_ENABLED + retention-sweeper precedent. Flipping enabled requires a server restart. - poll_once: drives one full IMAP tick. TLS connect (tokio-rustls + ring) -> LOGIN -> SELECT folder -> UID SEARCH UNSEEN -> per-UID: FETCH BODY[] -> process -> STORE +Seen ALWAYS (success OR fail). The unconditional Seen-after-attempt is non-negotiable: a poison message that never processes still gets Seen-d so it cannot trigger retry-forever. - process_polled_message: pure async function from raw RFC 822 bytes through parse -> loop-detect -> resolve -> rate-limit -> body extract -> actor post. No transport involved. Integration tests in commit 3e feed this directly. - detect_loop: header heuristic. Drops on Auto-Submitted (any non-"no" value), Precedence: bulk/list/junk, X-Autoreply, X-Autorespond, List-Id. List-Id drop is conservative; the docs (commit 6) will explicitly call out "mail with a List-Id header is dropped, including legitimate automated senders that set it" so an operator hitting reason=loop_detected detail="List-Id present" can diagnose from logs alone. - MAX_RAW_MESSAGE_BYTES = 5 MiB: upstream bound at the FETCH boundary. Closes the LC-77 spike A finding that mail-parser does not impose its own header-size limit. - POLL_INTERVAL_SECS = 300 (5 min), POLL_RATE_LIMIT_PER_MIN = 60. main.rs: - rustls::crypto:💍:default_provider().install_default() at startup. Returns Err if already installed (reqwest may install transitively); we treat as benign no-op since we just need SOMETHING installed. - spawn_email_poll added to the startup spawn list. Cargo.toml: - webpki-roots = "0.26" added. Mozilla root CA list for TLS verification; pinned by range (not exact) because the root list is data not code and rotation is routine. Verification: cargo check + cargo check --tests + cargo clippy -- -D warnings all clean. End-to-end integration tests land in commit 3e. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Pure whitespace. cargo fmt --check is now clean on the repo from this commit forward. The new LC-77 files (db/imap_config.rs, db/email_inbox.rs, email_ingress/*) and every LC-77-touched route file (routes/{dm,mod,room,ws}.rs) get the wrap rules applied. The math.rs / markdown.rs deltas are the LC-59 carryover that has been failing fmt on main since the LC-59 PR landed; folded in here because it is mechanical, contributes zero semantic change, and the LC-77 PRs already established the pattern of running cargo fmt as part of pre-commit. Closes LC-77-FMT-CARRYOVER inline. Verification: cargo fmt --all -- --check returns zero diffs after this commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>