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. */
+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(),
@@ -18,6 +18,7 @@ import {
import { toast } from "sonner";
import type {
NotificationChannelMode,
NotificationEmailPreferences,
NotificationPreferences,
NotificationWebhook,
NotificationWebhookInput,
@@ -47,12 +48,13 @@ import {
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { useAuth } from "@/hooks/useAuth";
import {
useClearEmailNotificationAddress,
useDiscordLinkInit,
useDiscordNotificationPreferences,
useEmailNotificationPreferences,
useNotificationPreferences,
useRequestEmailNotificationAddress,
useUnlinkDiscord,
useUpdateDiscordNotificationPreferences,
useUpdateEmailNotificationPreferences,
@@ -216,8 +218,97 @@ function ChannelFrequencyRow({
);
}
/**
* Destination address for this profile's emails. There is no account-email
* fallback: the profile receives nothing until an address is verified here.
* Changing it sends a verification link to the new address; the old address
* keeps receiving mail until the link is clicked. Removing the address also
* turns the channel off. Child profiles cannot set addresses.
*/
function EmailDestinationRow({ prefs }: { prefs: NotificationEmailPreferences }) {
const [editing, setEditing] = useState(false);
const [address, setAddress] = useState("");
const requestAddress = useRequestEmailNotificationAddress();
const clearAddress = useClearEmailNotificationAddress();
const hasAddress = prefs.custom_email !== "";
const submit = () => {
const trimmed = address.trim();
if (!trimmed) {
return;
}
requestAddress.mutate(trimmed, {
onSuccess: () => {
setEditing(false);
setAddress("");
},
});
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm">Deliver to</div>
<div className="text-muted-foreground text-xs">
{hasAddress ? prefs.custom_email : "No address set — verify one to receive emails"}
</div>
</div>
{prefs.can_edit_address && (
<div className="flex items-center gap-2">
{hasAddress && (
<Button
variant="ghost"
size="sm"
disabled={clearAddress.isPending}
onClick={() => clearAddress.mutate()}
>
Remove
</Button>
)}
<Button variant="outline" size="sm" onClick={() => setEditing((value) => !value)}>
{editing ? "Cancel" : hasAddress ? "Change" : "Add address"}
</Button>
</div>
)}
</div>
{editing && (
<div className="flex items-center gap-2">
<Input
type="email"
placeholder="name@example.com"
value={address}
onChange={(event) => setAddress(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
submit();
}
}}
className="max-w-xs"
/>
<Button size="sm" disabled={requestAddress.isPending || !address.trim()} onClick={submit}>
{requestAddress.isPending && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
Send verification
</Button>
</div>
)}
{prefs.pending_email !== "" && (
<div className="text-xs text-amber-500">
Verification email sent to {prefs.pending_email} — it becomes active once the link in it
is opened.
</div>
)}
{!prefs.can_edit_address && (
<div className="text-muted-foreground text-xs">
Child profiles can't receive email notifications.
</div>
)}
</div>
);
}
function EmailSection() {
const { user } = useAuth();
const capability = useNotificationCapability();
const emailCap = capability.data?.email;
const available = emailCap?.available ?? false;
@@ -233,7 +324,7 @@ function EmailSection() {
const allowPerEpisode = emailCap?.modes.includes("per_episode") ?? false;
const digestHour = String(emailCap?.digest_hour ?? 8).padStart(2, "0");
if (isLoading) {
if (isLoading || !prefs) {
return (
<SettingsGroup title="Email Notifications">
<Skeleton className="h-16 w-full" />
@@ -241,26 +332,29 @@ function EmailSection() {
);
}
const hasAddress = prefs.custom_email !== "";
return (
<SettingsGroup
title="Email Notifications"
description="Account-wide: one email covers every profile on this account."
description="Per profile: each profile verifies its own address and picks its own frequency."
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">Send to {user?.email || "your account email"}</div>
<div className="text-sm font-medium">Email this profile's notifications</div>
<div className="text-muted-foreground text-xs">
Notifications you'd see in the inbox, delivered by email
</div>
</div>
<Switch
checked={enabled}
disabled={updatePrefs.isPending}
disabled={updatePrefs.isPending || (!enabled && !hasAddress)}
onCheckedChange={(checked) =>
updatePrefs.mutate({ mode: checked ? "daily_digest" : "off" })
}
/>
</div>
<EmailDestinationRow prefs={prefs} />
{enabled && (
<ChannelFrequencyRow
mode={mode}