feat(notify): per-mention/DM notification emails (LC-77-REPLY stage 1) #205

Merged
longjacksonle merged 4 commits from feat/lc-77-reply-stage1 into main 2026-05-25 23:33:20 +02:00

Summary

Ships stage 1 of LC-77-REPLY (#201): the outbound per-mention / per-DM notification email surface. Each @username mention or DM sends the recipient an email (gated by opt-in) with a snippet, a CTA link, and a Reply-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 with address_no_match until that branch ships.

What's included

  • users.notify_email_activity_enabled column (default 0, mirrors the digest-opt-in precedent) with a checkbox at /settings.
  • chat.db::reply_tokens table 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_notification with 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 a DispatchOutcome enum for clean logging.
  • Outbound mail carries Auto-Submitted: auto-generated (RFC 3834) to break reciprocal auto-responder loops.
  • Wired into the 5 mention-reconcile / DM-send hook sites in routes::room (post_message, finalize_webhook_message_send, patch_message via the shared fanout_mention_events helper; DM branch and email_inbox finalize inline-loop each get their own spawn).
  • New RateLimitKind::EmailMentionNotification (tag emn).
  • Reply-To header is omitted when imap_inbox_config.ingress_domain is unset, so deployments without email-ingress configured still get the notification mail; they just can't reply-back yet.

Threat model additions

  • The reply token is a bearer credential. A forwarded notification email lets the recipient of the forward post as the original user until the token expires. Mitigations: 7-day TTL, single (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-generated on every notification breaks the recipient's vacation-responder reciprocal loop symmetrically with the inbound v1 loop-detection.
  • Notification body draws from the chat message body text after the markdown pipeline already stripped raw HTML; the notification email never echoes user-supplied HTML.

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 real POST /room/{id}/messages through the router; the fixture uses a real Mailer pointed at an unreachable host so SMTP fails but the reply-token row is inserted BEFORE submit, which proves the wiring).
  • just test and just test-saas pass (modulo the documented routes_uploads concurrent-load flake; passes in isolation).
  • just check clean (fmt + clippy --deny warnings on both standalone and saas binaries).

Test plan

  • just test
  • just test-saas
  • just check
  • Manual smoke: opt-in at /settings, get @-mentioned, confirm the mail lands with a Reply-To: reply-<token>@<ingress-domain>.
## Summary Ships stage 1 of LC-77-REPLY (#201): the outbound per-mention / per-DM notification email surface. Each `@username` mention or DM sends the recipient an email (gated by opt-in) with a snippet, a CTA link, and a `Reply-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 with `address_no_match` until that branch ships. ## What's included - `users.notify_email_activity_enabled` column (default 0, mirrors the digest-opt-in precedent) with a checkbox at `/settings`. - `chat.db::reply_tokens` table 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_notification` with 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 a `DispatchOutcome` enum for clean logging. - Outbound mail carries `Auto-Submitted: auto-generated` (RFC 3834) to break reciprocal auto-responder loops. - Wired into the 5 mention-reconcile / DM-send hook sites in `routes::room` (`post_message`, `finalize_webhook_message_send`, `patch_message` via the shared `fanout_mention_events` helper; DM branch and `email_inbox` finalize inline-loop each get their own spawn). - New `RateLimitKind::EmailMentionNotification` (tag `emn`). - `Reply-To` header is omitted when `imap_inbox_config.ingress_domain` is unset, so deployments without email-ingress configured still get the notification mail; they just can't reply-back yet. ## Threat model additions - The reply token is a bearer credential. A forwarded notification email lets the recipient of the forward post as the original user until the token expires. Mitigations: 7-day TTL, single `(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-generated` on every notification breaks the recipient's vacation-responder reciprocal loop symmetrically with the inbound v1 loop-detection. - Notification body draws from the chat message body text after the markdown pipeline already stripped raw HTML; the notification email never echoes user-supplied HTML. ## 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 real `POST /room/{id}/messages` through the router; the fixture uses a real `Mailer` pointed at an unreachable host so SMTP fails but the reply-token row is inserted BEFORE submit, which proves the wiring). - `just test` and `just test-saas` pass (modulo the documented `routes_uploads` concurrent-load flake; passes in isolation). - `just check` clean (fmt + clippy --deny warnings on both standalone and saas binaries). ## Test plan - [x] `just test` - [x] `just test-saas` - [x] `just check` - [ ] Manual smoke: opt-in at `/settings`, get @-mentioned, confirm the mail lands with a `Reply-To: reply-<token>@<ingress-domain>`.
Foundation for LC-77-REPLY stage 1. No runtime behavior yet; the dispatcher that consumes this surface lands in commit 1b.

Migrations:
- auth/0023_notify_email_activity.sql: ALTER TABLE users ADD COLUMN notify_email_activity_enabled INTEGER NOT NULL DEFAULT 0. OFF by default, mirrors notify_email_digest_enabled (chat/0013) precedent. Per-message mention + DM emails are noisy; operators opt their users in deliberately.
- chat/0050_reply_tokens.sql: new reply_tokens table. token TEXT PRIMARY KEY, user_id, message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE, issued_at, expires_at. Index on expires_at for the sweep query. Cascade ensures replies to deleted messages resolve to None.

New module:
- db/reply_tokens.rs with mint_token (32 random bytes, base32-encoded, ~52 chars, safe as email local-part), insert, resolve, sweep_expired. Plaintext-token storage is intentional and documented: the token leaks only to a single verified email address, binds to one (user_id, message_id) pair, and is bounded by expires_at (commit 1b will set 7-day TTL). The bearer risk is acknowledged in the module doc-comment for stage 2's operator docs.

Plumbing:
- models/user.rs: notify_email_activity_enabled: bool field on UserRecord + User + From impl.
- db/auth.rs: every SELECT users projection (5 unprefixed + 2 u.-prefixed) gains the new column; row_to_user_record reads it; new set_notify_email_activity_enabled setter mirrors set_notify_login_alerts_enabled (separate single-toggle setter rather than extending the 4-arg set_notification_prefs bulk setter, so existing callers stay untouched).
- main.rs spawn_orphan_sweeper: after the existing 24-hour upload-orphan tick, calls db::reply_tokens::sweep_expired. Single DELETE keyed on the expires_at index; idle deployments touch zero rows.

Test drift (CLAUDE.md category 2):
- 19 auth-array-form test files + 17 chat-array-form test files get the new migrations appended via the existing Python helper script.
- 3 auth-named-variable form (db_auth.rs, db_invite.rs, rbac.rs) updated manually with the migration23 named-block.
- 1 chat-verbose form (db_private_rooms.rs) gets the migration49 block appended.

Verification:
- tests/db_reply_tokens.rs (6 tests): mint_token distinct + charset, insert+resolve round-trip, resolve unknown returns None, sweep_expired drops only past-dated rows, message-delete cascades reply_tokens, notify_email_activity_enabled round-trip via setter.
- Full server test suite green under both default and saas feature sets.
- cargo clippy --tests -- -D warnings clean.
- cargo fmt --check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Hooks the commit-1b dispatcher into the 5 mention-reconcile + DM-send sites. Per-event tokio::spawn so the post path never waits on SMTP.

Wiring:

- routes/room.rs::fanout_mention_events: signature gains a `&Room` parameter so the helper can call dispatch_mention_notification with the room id + name in addition to the existing WS broadcast + push::dispatch. Covers 3 of the 5 mention sites: post_message non-DM (line 744), finalize_webhook_message_send (line 951), patch_message edit (line 1340). All three call sites already had `room` (or `edited_room`) in scope and just gain one extra argument.

- routes/room.rs::finalize_email_inbox_message_send (LC-77 v1) inlines its own per-mention loop instead of calling fanout_mention_events. The same email-dispatch spawn is added inline at that loop, matching what fanout_mention_events does for the helper-using paths.

- routes/room.rs::post_message DM branch (line 754) doesn't go through fanout (DMs don't write mention rows). After the existing push::dispatch call, a sibling email::notification::dispatch_mention_notification spawn fires with NotificationKind::Dm.

The fanout helper only fires the email dispatch for ChatEvent::Mentioned variants (the events Vec carries the typed event); any future non-Mentioned ChatEvent that gets fanned out won't accidentally trigger an email.

Settings UI:

- routes/settings.rs::SettingsForm gains notify_email_activity_enabled: Option<String>; post_settings reads it and calls db::auth::set_notify_email_activity_enabled. Same operator-gate posture as the existing digest toggle (the column persists the user's chosen value even when SMTP is unconfigured; the dispatch is already a no-op without a mailer).
- templates/settings/page.html gains a new checkbox row alongside the existing digest + login-alert toggles. Help text names the rate cap (20/min per recipient) and the reply-back affordance when email-ingress is configured.

Verification:
- Default + saas test suites green; 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.
- All 7 commit-1b dispatcher tests still pass after the room.rs hook changes (no signature mismatch).

Commit 1d will add the integration tests that drive the post path end-to-end (post a message that mentions a user, then assert the dispatcher saw the event) and the docs touch-up (docs/email-ingress.md, README.md, CLAUDE.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
test(notify): end-to-end mention/DM dispatch tests + docs touch-up (LC-77-REPLY commit 1d)
All checks were successful
check-secrets / Nosey parker (push) Successful in 3s
check-secrets / Kingfisher (push) Successful in 4s
check-secrets / TruffleHog (push) Successful in 5s
check-secrets / Nosey parker (pull_request) Successful in 3s
check-secrets / TruffleHog (pull_request) Successful in 5s
check-secrets / Kingfisher (pull_request) Successful in 6s
Create release / Create release from merged PR (pull_request) Has been skipped
Check / clippy + fmt + tests (pull_request) Successful in 1m41s
9646eb2bce
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>
longjacksonle deleted branch feat/lc-77-reply-stage1 2026-05-25 23:33:20 +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!205
No description provided.