LC-77-REPLY: reply-by-email (depends on per-message notification email surface) #201

Closed
opened 2026-05-25 21:22:14 +02:00 by longjacksonle · 0 comments

Background

LC-77 v1 (PRs #194-#199) shipped per-room email ingress: an external sender mails <token>@<ingress-domain> and the message posts to the inbox's room as a synthetic "Email" actor.

The brainstorm split LC-77 into two halves: per-room ingress (shipped) and reply-by-email (this ticket). Reply-by-email lets a chat user mail a response to a chat notification email and have it post to chat as that user, not as the synthetic actor.

Reply-by-email was explicitly deferred from v1 because it depends on a surface that doesn't exist yet: per-message notification emails to reply TO. The current outbound mail surfaces (digest, password reset, email verification, login alerts) don't carry a per-message Reply-To that maps back to a chat user + room.

Scope (two stages, in order)

Stage 1: per-message notification email surface

Build the outbound side that reply-by-email replies AGAINST. Likely shape:

  • New surface: /notify/mention (or per-mention email) gated by per-user opt-in (mirror existing email-digest opt-in pattern).
  • Email carries a Reply-To: <reply-token>@<ingress-domain> where <reply-token> is HMAC-SHA256 of (message_id, user_id, issued_at) keyed by LETS_CHAT_SECRET_KEY. Expiry: 7 days (lean conservative, lengthen on feedback). Token is opaque to the user.
  • Token expiry is independent of session token (session does NOT rotate today; tying reply-token expiry to it would invalidate every outstanding notification every 30 days for no reason).

Stage 2: reply-by-email ingress

Build the inbound side that consumes those replies:

  • New resolution path in email_ingress::resolve for <reply-token>@<ingress-domain> addresses: verify the HMAC, parse (message_id, user_id, issued_at), check issued_at + EXPIRY > now().
  • Identity attaches to the user_id in the token (NOT the From header; same posture as v1).
  • Re-check at post time against current room membership + posting policy + banned/muted status (routes/room.rs:434-474). The token does NOT carry permission state.
  • Posts via routes::room::finalize_message_send (the existing real-user path), NOT finalize_email_inbox_message_send (that's for the synthetic-actor surface).
  • Signature stripping lands here, NOT in v1 ingress: a human-replied email carries signatures and quoted history that v1's external-automated-sender posture didn't have to worry about.
  • Loop detection from v1 still applies + a new symmetric outbound Auto-Submitted: auto-generated header on the notification email to break reciprocal-loop chains.

Why this depends on v1

Reply-by-email reuses everything v1 built: the poll loop, the resolve header-precedence walk, the failure-log taxonomy, the rate-limit infrastructure, the IMAP transport. The only new pieces are the HMAC token scheme + the per-message notification email + the real-user post path.

Anti-scope

  • Not changing v1's synthetic-actor posture; the EmailInbox actor stays. Reply-by-email is a SECOND inbound surface running alongside.
  • Not building a generic "real-user impersonation via email" surface. Only messages with a valid HMAC reply-token are accepted. Forged From: addresses pointing at a real user without a token drop with AddressNoMatch (the v1 posture).

References

  • LC-77 parent brainstorm + plan (sections on reply-by-email).
  • docs/email-ingress.md "Not supported" section names this as LC-77-REPLY.
  • crate::email_ingress::resolve for the v1 resolution surface to extend.
## Background LC-77 v1 (PRs #194-#199) shipped **per-room** email ingress: an external sender mails `<token>@<ingress-domain>` and the message posts to the inbox's room as a synthetic "Email" actor. The brainstorm split LC-77 into two halves: per-room ingress (shipped) and **reply-by-email** (this ticket). Reply-by-email lets a chat user mail a response to a chat notification email and have it post to chat as that user, not as the synthetic actor. Reply-by-email was explicitly deferred from v1 because it depends on a surface that doesn't exist yet: per-message notification emails to reply TO. The current outbound mail surfaces (digest, password reset, email verification, login alerts) don't carry a per-message Reply-To that maps back to a chat user + room. ## Scope (two stages, in order) ### Stage 1: per-message notification email surface Build the outbound side that reply-by-email replies AGAINST. Likely shape: - New surface: `/notify/mention` (or per-mention email) gated by per-user opt-in (mirror existing email-digest opt-in pattern). - Email carries a `Reply-To: <reply-token>@<ingress-domain>` where `<reply-token>` is HMAC-SHA256 of `(message_id, user_id, issued_at)` keyed by `LETS_CHAT_SECRET_KEY`. Expiry: 7 days (lean conservative, lengthen on feedback). Token is opaque to the user. - Token expiry is independent of session token (session does NOT rotate today; tying reply-token expiry to it would invalidate every outstanding notification every 30 days for no reason). ### Stage 2: reply-by-email ingress Build the inbound side that consumes those replies: - New resolution path in `email_ingress::resolve` for `<reply-token>@<ingress-domain>` addresses: verify the HMAC, parse `(message_id, user_id, issued_at)`, check `issued_at + EXPIRY > now()`. - Identity attaches to the user_id in the token (NOT the From header; same posture as v1). - Re-check at post time against current room membership + posting policy + banned/muted status (`routes/room.rs:434-474`). The token does NOT carry permission state. - Posts via `routes::room::finalize_message_send` (the existing real-user path), NOT `finalize_email_inbox_message_send` (that's for the synthetic-actor surface). - Signature stripping lands here, NOT in v1 ingress: a human-replied email carries signatures and quoted history that v1's external-automated-sender posture didn't have to worry about. - Loop detection from v1 still applies + a new symmetric outbound `Auto-Submitted: auto-generated` header on the notification email to break reciprocal-loop chains. ## Why this depends on v1 Reply-by-email reuses everything v1 built: the poll loop, the `resolve` header-precedence walk, the failure-log taxonomy, the rate-limit infrastructure, the IMAP transport. The only new pieces are the HMAC token scheme + the per-message notification email + the real-user post path. ## Anti-scope - Not changing v1's synthetic-actor posture; the EmailInbox actor stays. Reply-by-email is a SECOND inbound surface running alongside. - Not building a generic "real-user impersonation via email" surface. Only messages with a valid HMAC reply-token are accepted. Forged From: addresses pointing at a real user without a token drop with `AddressNoMatch` (the v1 posture). ## References - LC-77 parent brainstorm + plan (sections on reply-by-email). - `docs/email-ingress.md` "Not supported" section names this as `LC-77-REPLY`. - `crate::email_ingress::resolve` for the v1 resolution surface to extend.
Sign in to join this conversation.
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#201
No description provided.