feat(email-ingress): reply-by-email ingress (LC-77-REPLY stage 2) #206
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/lc-77-reply-stage2"
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 #201. Ships stage 2 of LC-77-REPLY: the inbound resolver branch + actor that consumes replies to the stage-1 notification emails and posts them to chat as the real user (not the email-inbox synthetic actor).
Closes the loop on #201: a user who opted in at
/settings, got mentioned in a chat room, and replied to the notification email from their MUA now has that reply land in the room as their own message. No round-trip through the web UI required.What's included
email_ingress::resolve::resolve_inboxis renamed toresolve_address. A polled address whose local part starts withreply-(case-insensitive) routes to thechat.db::reply_tokenstable; anything else continues through the existing per-room inbox HMAC path. Per-room inbox secrets are minted with thelc_prefix so the two namespaces are structurally disjoint and can never accidentally collide. Two new outcomes:ResolveOutcome::ReplyMatch(ReplyTokenRow)for active reply tokens andResolveOutcome::ReplyExpired(ReplyTokenRow)for tokens past TTL; the latter gets a distinctDropReason::ReplyExpiredso an operator can tell a late reply from a garbage address.email_ingress::reply_actor::post_reply_messageposts as the real user from the resolved token row. Posting gates mirror the HTTPpost_messagepath point-for-point: banned/muted check,is_room_accessible,can_post_with_policy, DM-block check, per-userRateLimitKind::Messagecap. A gate failure does NOT consume the token, so a fixable error (rate limit, transient state) leaves the user's reply window intact.strip_quoted_replyis a conservative line-based heuristic that cuts at RFC 3676 sigsep, commonOn ... wrote:intros (Gmail, Apple Mail), and trailing>-prefixed quote blocks. Errs on the side of leaving extra text in chat.db::reply_tokens::consumedeletes the row only after a successful post; a forwarded notification email that races the user loses the second attempt.parse::extract_body_no_subjectso the reply path doesn't echoSubject: Re: [lets-chat] ...as Markdown-bold in the chat row.Threat model
(user_id, message_id)binding (replay across other messages is impossible), per-user opt-in, per-user rate cap, AND one-shot consume on success.Fromcannot change identity: the token is the identity. Tested.Auto-Submitted: auto-generatedon the stage-1 outbound prevents recipient auto-responders from triggering the poll loop'sloop_detecteddrop.Tests
db_reply_tokens.rs(8 round-trip tests, +2 forresolve_active_or_expiredandconsume).email_ingress_resolve.rs(5 resolver-level tests for the namespace fork: active reply-token toReplyMatch, past-dated toReplyExpired, unknown token without inbox fallback, inbox path unaffected, case-insensitivereply-prefix).reply_actor::tests(8 unit tests forstrip_quoted_reply: clean body, sigsep, Gmail intro, shortwrote:form, trailing quote without intro, CRLF tolerance,--mid-line is NOT a sigsep, inline-quote followed by new text).email_ingress_reply_path.rs(3 smoke tests: real-user authorship + token consume, expired token drops without insert, quote/signature strip applied before insert).email_ingress_reply_threat_model.rs(10 tests covering banned user, muted user, removed-from-enclave, moderators-only room, DM block, forged-From, deleted-original CASCADE, empty-after-strip, Cc-header resolution, replay-after-consume).just testandjust test-saaspass (modulo the documentedroutes_uploadsconcurrent-load flake; passes in isolation in both standalone and saas modes).just checkclean (fmt + clippy --deny warnings on both binaries).Docs
docs/email-ingress.mdextended with a top-level Reply-by-email (LC-77-REPLY stage 2) section covering the namespace-fork rules, posting-gate matrix, strip heuristic, threat-model additions, and deliberate scope cuts (no auto-quote, no attachment processing, no slash-command dispatch).CLAUDE.mdandREADME.mdupdated to describe both stages as shipped.Test plan
just testjust test-saasjust check/settings, get @-mentioned, hit Reply in MUA, confirm chat row arrives authored as me. Hit Reply again on the same notification, confirm second attempt drops withaddress_no_match(one-shot).