Files
silo-server/migrations/sql/20260612001031_profile_email_notifications.sql
QuickandClaude Fable 5 ebf3352bda 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>
2026-06-11 20:55:30 -04:00

84 lines
3.9 KiB
SQL

-- +goose Up
-- +goose StatementBegin
-- Re-keys the email notification channel from login accounts to profiles.
-- Each profile now owns its mode, dispatch watermark, and verified
-- destination address. There is deliberately no fallback to the account
-- email (it would funnel every profile's mail to the account holder), so
-- old account-level opt-ins are NOT carried over: a profile receives
-- nothing until its own address is verified and the mode re-enabled.
-- user_id is denormalized (no FK, no join to profile storage): profiles may
-- live in per-user SQLite stores, so Postgres never joins notification
-- tables against profile tables (see 20260611100000).
DROP TABLE public.notification_email_prefs;
CREATE TABLE public.notification_email_prefs (
profile_id text PRIMARY KEY,
user_id integer NOT NULL,
mode text NOT NULL DEFAULT 'off'
CHECK (mode IN ('off', 'per_episode', 'daily_digest', 'per_episode_and_digest')),
-- Verified destination address; '' = fall back to users.email.
custom_email text NOT NULL DEFAULT '',
-- In-flight address verification: the candidate address and the SHA-256
-- hex of its single-use token. Cleared on success or replacement.
pending_email text NOT NULL DEFAULT '',
pending_token_hash text NOT NULL DEFAULT '',
pending_expires_at timestamptz,
-- Verification-send rate limiting: minimum gap since the last send, plus
-- a daily cap counted within pending_last_sent_at's UTC day.
pending_last_sent_at timestamptz,
verify_sends_today integer NOT NULL DEFAULT 0,
-- Capability token embedded in every email's unsubscribe link; only
-- powers "set this profile's mode to off", so stored plaintext. Minted by
-- the send path right before the first email that embeds it.
unsubscribe_token text NOT NULL DEFAULT '',
watermark_created_at timestamptz NOT NULL DEFAULT now(),
watermark_id text NOT NULL DEFAULT '',
last_digest_at timestamptz,
last_attempt_at timestamptz,
consecutive_failures integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX notification_email_prefs_user_idx
ON public.notification_email_prefs (user_id);
CREATE INDEX notification_email_prefs_pending_token_idx
ON public.notification_email_prefs (pending_token_hash)
WHERE pending_token_hash <> '';
CREATE INDEX notification_email_prefs_unsubscribe_token_idx
ON public.notification_email_prefs (unsubscribe_token)
WHERE unsubscribe_token <> '';
-- One verified destination per profile, globally unique: an address may not
-- serve two profiles. Backstops the application-level checks against
-- concurrent verifications.
CREATE UNIQUE INDEX notification_email_prefs_custom_email_key
ON public.notification_email_prefs (lower(custom_email))
WHERE custom_email <> '';
-- The per-profile sweep reads deliveries by profile in watermark order.
CREATE INDEX notification_deliveries_profile_created_idx
ON public.notification_deliveries (profile_id, created_at, id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS public.notification_deliveries_profile_created_idx;
-- Rollback restores the account-keyed schema empty: per-profile state
-- (verified addresses, watermarks) has no account-level representation, so
-- accounts re-opt-in. Symmetric with the up migration, which also carries
-- nothing over.
DROP TABLE public.notification_email_prefs;
CREATE TABLE public.notification_email_prefs (
user_id integer PRIMARY KEY,
mode text NOT NULL DEFAULT 'off'
CHECK (mode IN ('off', 'per_episode', 'daily_digest', 'per_episode_and_digest')),
watermark_created_at timestamptz NOT NULL DEFAULT now(),
watermark_id text NOT NULL DEFAULT '',
last_digest_at timestamptz,
last_attempt_at timestamptz,
consecutive_failures integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- +goose StatementEnd