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:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user