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
+36 -1
View File
@@ -7,6 +7,7 @@ import type {
NotificationDiscordMode,
NotificationDiscordPreferences,
NotificationEmailPreferences,
NotificationEmailPreferencesUpdate,
NotificationListResponse,
NotificationPreferences,
NotificationReadEventPayload,
@@ -108,7 +109,7 @@ export function useEmailNotificationPreferences(enabled = true) {
export function useUpdateEmailNotificationPreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (update: NotificationEmailPreferences) =>
mutationFn: (update: NotificationEmailPreferencesUpdate) =>
api<NotificationEmailPreferences>("/notifications/email-preferences", {
method: "PUT",
body: JSON.stringify(update),
@@ -122,6 +123,40 @@ export function useUpdateEmailNotificationPreferences() {
});
}
export function useRequestEmailNotificationAddress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (email: string) =>
api<NotificationEmailPreferences>("/notifications/email-preferences/address", {
method: "PUT",
body: JSON.stringify({ email }),
}),
onSuccess: (prefs) => {
queryClient.setQueryData(notificationKeys.emailPreferences(), prefs);
toast.success(`Verification email sent to ${prefs.pending_email}`);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Failed to send the verification email");
},
});
}
export function useClearEmailNotificationAddress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () =>
api<NotificationEmailPreferences>("/notifications/email-preferences/address", {
method: "DELETE",
}),
onSuccess: (prefs) => {
queryClient.setQueryData(notificationKeys.emailPreferences(), prefs);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Failed to remove the custom address");
},
});
}
export function useDiscordNotificationPreferences(enabled = true) {
return useQuery({
queryKey: notificationKeys.discordPreferences(),