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:
+16
-1
@@ -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. */
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user