feat(notify): per-mention/DM notification emails (LC-77-REPLY stage 1) #205
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/lc-77-reply-stage1"
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
Ships stage 1 of LC-77-REPLY (#201): the outbound per-mention / per-DM notification email surface. Each
@usernamemention or DM sends the recipient an email (gated by opt-in) with a snippet, a CTA link, and aReply-To: reply-<token>@<ingress-domain>header. Stage 2 (the resolver branch that consumes those replies and posts to chat) lands in a follow-up PR; today, replies to the notification mail hit the polled mailbox and drop withaddress_no_matchuntil that branch ships.What's included
users.notify_email_activity_enabledcolumn (default 0, mirrors the digest-opt-in precedent) with a checkbox at/settings.chat.db::reply_tokenstable mapping random 32-byte base32 tokens to(user_id, message_id, expires_at); 7-day TTL; expired rows swept by the existing orphan sweeper.crate::email::notification::dispatch_mention_notificationwith a fail-closed gate ordering: recipient lookup -> email present -> verified -> opt-in -> mailer configured -> 20/min per-user rate limit -> message still exists -> sender resolvable -> token mint -> render -> submit. Returns aDispatchOutcomeenum for clean logging.Auto-Submitted: auto-generated(RFC 3834) to break reciprocal auto-responder loops.routes::room(post_message,finalize_webhook_message_send,patch_messagevia the sharedfanout_mention_eventshelper; DM branch andemail_inboxfinalize inline-loop each get their own spawn).RateLimitKind::EmailMentionNotification(tagemn).Reply-Toheader is omitted whenimap_inbox_config.ingress_domainis unset, so deployments without email-ingress configured still get the notification mail; they just can't reply-back yet.Threat model additions
(user_id, message_id)binding (token can't be replayed against other messages), per-user opt-in, per-user rate cap. Acknowledged because the notification email is, by definition, sent to a verified address the user controls; forwarding is a user-side choice.Auto-Submitted: auto-generatedon every notification breaks the recipient's vacation-responder reciprocal loop symmetrically with the inbound v1 loop-detection.Tests
db_reply_tokens.rs(6 schema round-trip tests).email_notification_dispatch.rs(7 gate tests covering the short-circuit order).email_notification_post_path.rs(3 end-to-end tests that drive a realPOST /room/{id}/messagesthrough the router; the fixture uses a realMailerpointed at an unreachable host so SMTP fails but the reply-token row is inserted BEFORE submit, which proves the wiring).just testandjust test-saaspass (modulo the documentedroutes_uploadsconcurrent-load flake; passes in isolation).just checkclean (fmt + clippy --deny warnings on both standalone and saas binaries).Test plan
just testjust test-saasjust check/settings, get @-mentioned, confirm the mail lands with aReply-To: reply-<token>@<ingress-domain>.Foundation for stage 1's wiring (commit 1c). No call sites yet; this commit lands the dispatch function, the mailer extension, the templates, and the gate tests. mail.rs: - `Mailer::send_multipart_with_reply_to(to, reply_to, subject, text, html)`: new method that sets `Reply-To: reply-<token>@<ingress-domain>` (when provided) AND `Auto-Submitted: auto-generated` per RFC 3834. The Auto-Submitted header breaks reciprocal loops with the recipient's auto-responder. - `AutoSubmitted` Header impl (lettre 0.11 doesn't ship a typed header for it). rate_limit.rs: - `RateLimitKind::EmailMentionNotification` (tag "emn") with cap = 20/minute keyed by recipient user_id. Future LC-77-REPLY-COALESCE follow-up may replace with debounce. New module crate:📧 - `email/mod.rs` documents the surface: new outbound mail surfaces that fire from inside chat mutations (mention reconcile, DM send). Single-shot HTTP-handler-coupled mails stay under `routes/`; the digest stays at `crate::digest`. - `email/notification.rs`: dispatch_mention_notification(state, recipient_user_id, message_id, kind, room_id, room_name). Gates in order: recipient lookup -> email present -> email_verified_at non-NULL -> notify_email_activity_enabled = 1 -> mailer configured -> per-recipient rate limit -> message still exists -> sender label resolvable. On all-pass: mints a reply token via db::reply_tokens with 7-day TTL, builds Reply-To from imap_inbox_config.ingress_domain (None if unset means no Reply-To header but mail still sends), renders both template parts via Askama, submits via the new mailer method. Returns DispatchOutcome enum so tests assert on the gate decision without parsing logs. Templates + view structs: - templates/email/notification.html: HTML body with snippet block + CTA button + reply-back affordance text + settings unsub link. - templates/email/notification.txt: parallel plaintext for multipart/alternative. - views/email_notification.rs: NotificationHtml + NotificationText parallel Askama structs. Verification: - tests/email_notification_dispatch.rs covers 7 gate paths: no-mailer, no-email, unverified, opt-out, no-recipient (user delete race), no-message (message delete race), rate-limit-trips-after-cap. Each test asserts on the DispatchOutcome variant and (where applicable) confirms no reply_token row was inserted on a short-circuit. - Full server test suite green under both feature sets (one routes_uploads flake under concurrent load is the documented carve-out; passes in isolation). - cargo clippy --tests -- -D warnings clean. - cargo fmt --check clean. Commit 1c wires the dispatcher into the 5 mention/DM reconcile hook points in routes/room.rs and adds the per-user opt-in checkbox to /settings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Adds `server/tests/email_notification_post_path.rs` with three end-to-end tests that drive a real `POST /room/{id}/messages` through the router and assert the dispatcher wiring by inspecting the `reply_tokens` row the dispatcher mints. The fixture uses a real Mailer pointed at an unreachable host so the SMTP submit fails, but the reply-token row is inserted BEFORE the send, so the SMTP failure does not mask the wiring check. Tests cover: an `@bob` mention in a public-room post mints a token for bob; a DM to bob mints a token without an explicit `@`-mention; an opt-out recipient does not get a token even from a DM. Docs: extends `docs/email-ingress.md` with a Notification emails (LC-77-REPLY stage 1) section covering the per-user opt-in, the gate order, the outbound headers (`Reply-To`, `Auto-Submitted: auto-generated`), reply-token TTL and sweep, and the bearer-token-via-forwarded-mail threat model addition. Updates `README.md` Notifications bullets to list the new per-message notification surface alongside the existing digest. Updates the project `CLAUDE.md` Email-ingress section with a Per-message notification emails (LC-77-REPLY stage 1, #201) paragraph that points future readers at the dispatcher and the 5 hook sites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>