feat(notifications): per-profile email channel with verified addresses

Re-key the email notification channel from login accounts to profiles.
Each profile owns its mode, dispatch watermark, and destination address;
there is deliberately no fallback to the account email, so the account
holder no longer receives mail for every household profile. A profile
receives nothing until its own address is verified.

- Genericize the watermark-sweep engine over a recipient key
  (accountChannel[K]): email keys by profile_id, Discord stays on
  user_id. Delivery reads move into the channel adapters.
- Custom addresses verify via single-use SHA-256-hashed token links
  served by a public endpoint; enabling the channel requires a verified
  address, and clearing the address switches the channel off.
- Addresses are globally unique (case-insensitive): rejected when
  verified for another profile or matching another account's email or
  username. Checked at request time, re-checked at verify time
  (first-to-verify wins), backstopped by a partial unique index.
- Every email carries an RFC 8058 one-click unsubscribe link backed by
  a per-profile capability token, minted lazily under the claim tx.
- Child profiles cannot set addresses (and so receive no email in v1).
- Verification sends are rate limited (1/min, 10/day per profile);
  mail.Message gains custom header support for List-Unsubscribe.
- Migration drops the account-level prefs table without carrying
  opt-ins over, so nobody gets surprise emails post-upgrade.

Android/Apple notification settings need follow-up for the new
profile-scoped response shape and address-management endpoints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-06-11 20:55:30 -04:00
co-authored by Claude Fable 5
parent 5f05374a1d
commit ebf3352bda
17 changed files with 1389 additions and 239 deletions
+16 -1
View File
@@ -2352,9 +2352,24 @@ export type NotificationChannelMode =
export type NotificationEmailMode = NotificationChannelMode;
export type NotificationDiscordMode = NotificationChannelMode;
/** Account-level (not per-profile): one mode covers all profiles. */
/**
* Profile-scoped email channel state. Each profile verifies its own
* destination address and receives nothing until it has one — there is no
* account-email fallback.
*/
export interface NotificationEmailPreferences {
mode: NotificationEmailMode;
/** Verified destination; "" = none, channel inert. */
custom_email: string;
/** Address awaiting link-click verification. */
pending_email: string;
/** False for child profiles, which cannot set addresses. */
can_edit_address: boolean;
}
/** PUT /notifications/email-preferences body: only the mode is writable. */
export interface NotificationEmailPreferencesUpdate {
mode: NotificationEmailMode;
}
/** Account-level Discord DM channel: link state, mode, and delivery health. */