feat(email-ingress): reply-by-email ingress (LC-77-REPLY stage 2) #206

Merged
longjacksonle merged 4 commits from feat/lc-77-reply-stage2 into main 2026-05-26 01:49:59 +02:00

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

  • Namespace-forked resolver. email_ingress::resolve::resolve_inbox is renamed to resolve_address. A polled address whose local part starts with reply- (case-insensitive) routes to the chat.db::reply_tokens table; anything else continues through the existing per-room inbox HMAC path. Per-room inbox secrets are minted with the lc_ prefix so the two namespaces are structurally disjoint and can never accidentally collide. Two new outcomes: ResolveOutcome::ReplyMatch(ReplyTokenRow) for active reply tokens and ResolveOutcome::ReplyExpired(ReplyTokenRow) for tokens past TTL; the latter gets a distinct DropReason::ReplyExpired so an operator can tell a late reply from a garbage address.
  • Reply actor. email_ingress::reply_actor::post_reply_message posts as the real user from the resolved token row. Posting gates mirror the HTTP post_message path point-for-point: banned/muted check, is_room_accessible, can_post_with_policy, DM-block check, per-user RateLimitKind::Message cap. A gate failure does NOT consume the token, so a fixable error (rate limit, transient state) leaves the user's reply window intact.
  • Quote and signature stripping. strip_quoted_reply is a conservative line-based heuristic that cuts at RFC 3676 sigsep, common On ... wrote: intros (Gmail, Apple Mail), and trailing >-prefixed quote blocks. Errs on the side of leaving extra text in chat.
  • One-shot consumption. db::reply_tokens::consume deletes the row only after a successful post; a forwarded notification email that races the user loses the second attempt.
  • Body extraction without subject prefix. parse::extract_body_no_subject so the reply path doesn't echo Subject: Re: [lets-chat] ... as Markdown-bold in the chat row.

Threat model

  • The reply token is a bearer credential. Mitigations: 7-day TTL, single (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.
  • Forged From cannot change identity: the token is the identity. Tested.
  • Gate failures cannot consume the token (recovery posture). Tested for banned user.
  • Auto-Submitted: auto-generated on the stage-1 outbound prevents recipient auto-responders from triggering the poll loop's loop_detected drop.

Tests

  • db_reply_tokens.rs (8 round-trip tests, +2 for resolve_active_or_expired and consume).
  • email_ingress_resolve.rs (5 resolver-level tests for the namespace fork: active reply-token to ReplyMatch, past-dated to ReplyExpired, unknown token without inbox fallback, inbox path unaffected, case-insensitive reply- prefix).
  • reply_actor::tests (8 unit tests for strip_quoted_reply: clean body, sigsep, Gmail intro, short wrote: 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 test and just test-saas pass (modulo the documented routes_uploads concurrent-load flake; passes in isolation in both standalone and saas modes).
  • just check clean (fmt + clippy --deny warnings on both binaries).

Docs

  • docs/email-ingress.md extended 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.md and README.md updated to describe both stages as shipped.

Test plan

  • just test
  • just test-saas
  • just check
  • Manual smoke: opt in at /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 with address_no_match (one-shot).
## 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 - **Namespace-forked resolver.** `email_ingress::resolve::resolve_inbox` is renamed to `resolve_address`. A polled address whose local part starts with `reply-` (case-insensitive) routes to the `chat.db::reply_tokens` table; anything else continues through the existing per-room inbox HMAC path. Per-room inbox secrets are minted with the `lc_` prefix so the two namespaces are structurally disjoint and can never accidentally collide. Two new outcomes: `ResolveOutcome::ReplyMatch(ReplyTokenRow)` for active reply tokens and `ResolveOutcome::ReplyExpired(ReplyTokenRow)` for tokens past TTL; the latter gets a distinct `DropReason::ReplyExpired` so an operator can tell a late reply from a garbage address. - **Reply actor.** `email_ingress::reply_actor::post_reply_message` posts as the real user from the resolved token row. Posting gates mirror the HTTP `post_message` path point-for-point: banned/muted check, `is_room_accessible`, `can_post_with_policy`, DM-block check, per-user `RateLimitKind::Message` cap. A gate failure does NOT consume the token, so a fixable error (rate limit, transient state) leaves the user's reply window intact. - **Quote and signature stripping.** `strip_quoted_reply` is a conservative line-based heuristic that cuts at RFC 3676 sigsep, common `On ... wrote:` intros (Gmail, Apple Mail), and trailing `>`-prefixed quote blocks. Errs on the side of leaving extra text in chat. - **One-shot consumption.** `db::reply_tokens::consume` deletes the row only after a successful post; a forwarded notification email that races the user loses the second attempt. - **Body extraction without subject prefix.** `parse::extract_body_no_subject` so the reply path doesn't echo `Subject: Re: [lets-chat] ...` as Markdown-bold in the chat row. ## Threat model - The reply token is a bearer credential. Mitigations: 7-day TTL, single `(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. - Forged `From` cannot change identity: the token is the identity. Tested. - Gate failures cannot consume the token (recovery posture). Tested for banned user. - `Auto-Submitted: auto-generated` on the stage-1 outbound prevents recipient auto-responders from triggering the poll loop's `loop_detected` drop. ## Tests - `db_reply_tokens.rs` (8 round-trip tests, +2 for `resolve_active_or_expired` and `consume`). - `email_ingress_resolve.rs` (5 resolver-level tests for the namespace fork: active reply-token to `ReplyMatch`, past-dated to `ReplyExpired`, unknown token without inbox fallback, inbox path unaffected, case-insensitive `reply-` prefix). - `reply_actor::tests` (8 unit tests for `strip_quoted_reply`: clean body, sigsep, Gmail intro, short `wrote:` 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 test` and `just test-saas` pass (modulo the documented `routes_uploads` concurrent-load flake; passes in isolation in both standalone and saas modes). - `just check` clean (fmt + clippy --deny warnings on both binaries). ## Docs - `docs/email-ingress.md` extended 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.md` and `README.md` updated to describe both stages as shipped. ## Test plan - [x] `just test` - [x] `just test-saas` - [x] `just check` - [ ] Manual smoke: opt in at `/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 with `address_no_match` (one-shot).
Renames `resolve_inbox` to `resolve_address` to reflect the broadened responsibility and adds a `reply-` prefix branch: a polled address whose local part starts with `reply-` (case-insensitive) is resolved against the `chat.db::reply_tokens` table; anything else continues through the existing per-room inbox HMAC path. The two namespaces are structurally disjoint (per-room inbox secrets are minted with the `lc_` prefix by `auth::generate_api_token`) so a `reply-<token>` that misses the reply table never falls through to the HMAC path, and vice versa. Adds two outcomes: `ResolveOutcome::ReplyMatch(ReplyTokenRow)` for active reply tokens and `ResolveOutcome::ReplyExpired(ReplyTokenRow)` for tokens past their `expires_at` (the dispatcher in stage 1 sets a 7-day TTL); the expired case carries a distinct `DropReason::ReplyExpired` so an operator can tell a late reply from a garbage address. Adds `db::reply_tokens::resolve_active_or_expired` (one-round-trip lookup that returns the row plus an expiry bool) and `db::reply_tokens::consume` (single-use deletion; stage 2b's actor consumes a token after a successful post for replay defense). `poll.rs` is updated to call the renamed function and handle the new outcomes; the `ReplyMatch` arm drops with a placeholder `InternalError` detail until commit 2c wires the actor.

`server/src/email/notification.rs` is updated to construct the outbound `Reply-To` from the new `email_ingress::resolve::REPLY_PREFIX` constant so the inbound resolver and the outbound dispatcher share one source of truth for the `reply-` prefix.

Tests: extends `server/tests/db_reply_tokens.rs` with two new tests covering `resolve_active_or_expired` (fresh vs. stale) and `consume` (one-shot deletion). Adds a new test file `server/tests/email_ingress_resolve.rs` with five resolver-level tests: active reply-token routes to `ReplyMatch`; past-dated token routes to `ReplyExpired`; an unknown `reply-` token drops to `NotFound` without falling back to the inbox HMAC path; the existing inbox-secret happy path is unchanged by the fork; the `reply-` prefix is case-insensitive (a polite MTA that uppercases the local part still hits the reply namespace).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `email_ingress::reply_actor::post_reply_message`, the actor that posts a reply-by-email as the real user identified by a resolved `ReplyTokenRow`. Differs from the existing email-inbox synthetic-actor path in three ways: the post is authored by a real user (not the synthetic `MessageActor::EmailInbox`); the room is derived from the original message that the user is replying to (not from a per-room inbox config row); the body is the email reply with the quoted-original and signature stripped via the new `strip_quoted_reply` helper, with NO auto-quoting of the original (a chat reply is a sibling, not a thread reply, so threading mid-chat would surprise room participants who never saw the email round trip).

The actor reuses `routes::room::finalize_message_send` so a reply-by-email post is indistinguishable from an HTTP-form post at the broadcast layer: same `ChatEvent::NewMessage` shape, same mention-reconcile, same outgoing-webhook dispatch, same per-user mention notification email (recursion is broken by the recipient's `Auto-Submitted: auto-generated` outbound on the next round; see `email::notification`).

Posting gates mirror the HTTP `post_message` path point-for-point: banned/muted check, `is_room_accessible`, `can_post_with_policy`, DM-block check, per-user `RateLimitKind::Message` cap. Any gate failure drops the reply with a specific `DropReason` and a structured detail so an operator log can distinguish "user replied from a quarantined account" from "user replied to a room they were removed from."

Token consumption: a reply token is one-shot. The actor calls `db::reply_tokens::consume` only after a successful post; gate failures leave the token in place so a fixable error (rate limit, transient room state) does not burn the user's reply window.

`strip_quoted_reply` is a conservative heuristic:
- Cut at the first occurrence of an RFC 3676 signature delimiter (`-- ` on its own line, with the trailing space required by the RFC).
- Cut at the first line matching a common quote-intro pattern (`On ... wrote:`, Gmail / Apple Mail).
- After the cut, drop a trailing block of `>`-prefixed quote lines (with their preceding blank line).

Erring on the side of leaving extra text (a missed strip degrades UX; an over-aggressive strip would lose the user's real reply). Eight inline unit tests cover the common cases: clean body, sigsep, Gmail intro, short `wrote:` form, trailing quote without intro, CRLF tolerance, edge cases where `--` is mid-line, and inline-quote-followed-by-new-text.

Commit 2c wires the poll loop's `ResolveOutcome::ReplyMatch` arm to this actor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires `process_polled_message`'s `ResolveOutcome::ReplyMatch` arm to `reply_actor::post_reply_message`. Adds `parse::extract_body_no_subject` so the reply path can skip the Markdown-bold subject prefix that the synthetic-actor path applies (a reply's `Subject: Re: [lets-chat] ...` is mail-routing metadata, not chat content). The placeholder `InternalError` drop reason from commit 2a is removed.

`ReplyTokenRow` now carries the plaintext `token` field. The token IS the row's primary key, so threading it through the `consume` call required either a parallel parameter or this field; the field keeps the call site clean and the row self-describing.

Smoke test: `server/tests/email_ingress_reply_path.rs` drives a raw RFC 822 reply through `process_polled_message` and asserts (1) the resulting `messages` row is authored by the real user (not the synthetic email-inbox actor; `email_inbox_id` and `webhook_id` are both NULL); (2) the reply token is consumed (one-shot replay defense); (3) an expired token drops with `reason=reply_expired` and inserts no row; (4) the quote/signature strip is applied before insert. Full threat-model coverage (banned user, blocked DM, posting-policy gates, hostile signature payloads) lands in commit 2d.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
test(email-ingress): threat-model tests + docs for reply-by-email (LC-77-REPLY commit 2d)
All checks were successful
check-secrets / Nosey parker (push) Successful in 5s
check-secrets / TruffleHog (push) Successful in 7s
check-secrets / Kingfisher (push) Successful in 8s
check-secrets / Kingfisher (pull_request) Successful in 4s
check-secrets / TruffleHog (pull_request) Successful in 5s
check-secrets / Nosey parker (pull_request) Successful in 5s
Create release / Create release from merged PR (pull_request) Has been skipped
Check / clippy + fmt + tests (pull_request) Successful in 2m5s
6250aeb3de
Adds `server/tests/email_ingress_reply_threat_model.rs` with ten integration tests covering the stage-2 posting-gate matrix and reply-token replay defenses:
- Banned user reply is dropped AND the token is not consumed (recovery posture).
- Muted user reply is dropped.
- A user removed from the room's enclave between mint and reply cannot post.
- A `moderators_only` room drops a non-moderator's reply.
- A DM where either party blocked the other drops the reply silently.
- A forged `From` does not change identity: the post is authored as the token's `user_id` regardless of the sender's `From` header.
- The CASCADE on `reply_tokens.message_id` reaps the token when the original message is deleted; an incoming reply against the now-orphaned token drops with `address_no_match`.
- A reply that strips to empty body (just a quote-intro plus quoted content) drops with `parse_fail` instead of inserting an empty row.
- A `reply-<token>` address in the `Cc` header resolves the same as `To` (mirroring the header-precedence semantics of the existing inbox surface).
- After a successful consume, a second request crafted against the same token drops with `address_no_match` (one-shot replay defense).

Docs: extends `docs/email-ingress.md` with a top-level Reply-by-email (LC-77-REPLY stage 2) section covering the namespace-fork rules (`reply-` prefix vs. `lc_` inbox-secret namespace), the posting-gate matrix the actor mirrors from `routes::room::post_message`, the quote/signature strip heuristic, the threat-model additions (bearer-credential semantics, forged-From robustness, gate-failures-do-not-consume), and the deliberate scope cuts (no auto-quote, no attachment processing, no slash-command dispatch). Removes the "Signature / quoted-history stripping" deferred entry (it shipped on the reply path) and reframes it as a doc note that the synthetic-actor path still posts verbatim. Updates the project `CLAUDE.md` LC-77-REPLY section and the README's notifications bullet to describe both stages as shipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
longjacksonle deleted branch feat/lc-77-reply-stage2 2026-05-26 01:49:59 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!206
No description provided.