diff --git a/.changeset/tender-moons-attack.md b/.changeset/tender-moons-attack.md new file mode 100644 index 0000000000..395cbe08ca --- /dev/null +++ b/.changeset/tender-moons-attack.md @@ -0,0 +1,14 @@ +--- +"@agent-native/core": minor +--- + +`renderEmail` accepts a `footerLink`, so a footer can point at a real +destination. A `{link}` token in `footer` becomes an anchor in the HTML part and +`label (url)` in the plain-text part. + +Notification emails now render through `renderEmail` instead of a bare +paragraph pair, so they carry the same branding as the rest of the framework's +mail. A sender can name the surface that controls the notification with +`metadata.emailFooter`, `emailFooterLinkLabel`, and `emailFooterLinkUrl`; +notifications that name none get no footer, so env-configured ops recipients +are never told to turn off a toggle they do not have. diff --git a/packages/core/src/notifications/channels.spec.ts b/packages/core/src/notifications/channels.spec.ts index e30d6b7ef0..361f2973c5 100644 --- a/packages/core/src/notifications/channels.spec.ts +++ b/packages/core/src/notifications/channels.spec.ts @@ -535,6 +535,49 @@ describe("email notification channel", () => { expect(sent.html).not.toContain("
");
});
+ it("links the sender's opt-out surface in the footer when one is named", async () => {
+ process.env.NOTIFICATIONS_EMAIL_CHANNEL = "1";
+ const channels = await loadChannels();
+ const channel = channels.find((c) => c.name === "email")!;
+
+ await channel.deliver(
+ {
+ severity: "info",
+ title: "Generation finished",
+ metadata: {
+ emailRecipients: ["alice@example.com"],
+ emailFooter: "Email notifications are on in your {link}.",
+ emailFooterLinkLabel: "Assets settings",
+ emailFooterLinkUrl: "https://assets.example.test/settings",
+ },
+ },
+ { owner: "alice@example.com" },
+ );
+
+ const sent = sendEmail.mock.calls[0][0];
+ expect(sent.html).toContain(
+ ' {
+ process.env.NOTIFICATIONS_EMAIL_CHANNEL = "1";
+ process.env.NOTIFICATIONS_EMAIL_RECIPIENTS = "ops@example.com";
+ const channels = await loadChannels();
+ const channel = channels.find((c) => c.name === "email")!;
+
+ await channel.deliver(
+ { severity: "critical", title: "Disk full" },
+ { owner: "ops@example.com" },
+ );
+
+ const sent = sendEmail.mock.calls[0][0];
+ expect(sent.text).not.toMatch(/turn|settings/i);
+ });
+
it("does nothing when email has no recipients", async () => {
process.env.NOTIFICATIONS_EMAIL_CHANNEL = "1";
const channels = await loadChannels();
diff --git a/packages/core/src/notifications/channels.ts b/packages/core/src/notifications/channels.ts
index f3003785cc..415e7f8bbd 100644
--- a/packages/core/src/notifications/channels.ts
+++ b/packages/core/src/notifications/channels.ts
@@ -25,6 +25,10 @@
* → comma-separated fallback recipients for email
* notifications that do not pass
* `metadata.emailRecipients`.
+ *
+ * Per-notification email metadata: `emailRecipients`, `emailSubject`, and an
+ * optional `emailFooter` (a `{link}` token in it is filled from
+ * `emailFooterLinkLabel` + `emailFooterLinkUrl`).
*/
import { ssrfSafeFetch } from "../extensions/url-safety.js";
@@ -35,6 +39,7 @@ import {
resolveKeyReferencesWithRequestScopes,
validateUrlAllowlist,
} from "../secrets/substitution.js";
+import { renderEmail } from "../server/email-template.js";
import { sendEmail } from "../server/email.js";
import { registerNotificationChannel } from "./registry.js";
import type { NotificationChannel, NotificationInput } from "./types.js";
@@ -191,11 +196,12 @@ function createEmailChannel(): NotificationChannel {
input.metadata.emailSubject.trim()
? input.metadata.emailSubject.trim()
: `[${input.severity}] ${input.title}`;
- const text = `${input.title}\n\n${input.body ?? ""}`;
- const html = [
- `${escapeHtml(input.title)}
`,
- input.body ? `${escapeHtml(input.body)}
` : "",
- ].join("");
+ const { html, text } = renderEmail({
+ preheader: input.title,
+ heading: input.title,
+ paragraphs: input.body ? [escapeHtml(input.body)] : [],
+ ...notificationEmailFooter(input.metadata),
+ });
await Promise.all(
recipients.map((to) =>
@@ -338,6 +344,26 @@ function scrubDeliveryMetadata(
return entries.length ? Object.fromEntries(entries) : undefined;
}
+/**
+ * A notification only claims an opt-out exists when its sender named one.
+ * Env-configured ops recipients have no per-user toggle, so they must not be
+ * told to go turn one off.
+ */
+function notificationEmailFooter(
+ metadata: Record | undefined,
+): { footer?: string; footerLink?: { label: string; url: string } } {
+ const footer = trimmedString(metadata?.emailFooter);
+ if (!footer) return {};
+ const label = trimmedString(metadata?.emailFooterLinkLabel);
+ const url = trimmedString(metadata?.emailFooterLinkUrl);
+ return label && url ? { footer, footerLink: { label, url } } : { footer };
+}
+
+function trimmedString(value: unknown): string | undefined {
+ if (typeof value !== "string") return undefined;
+ return value.trim() || undefined;
+}
+
function notificationEmailRecipients(
metadata: Record | undefined,
): string[] {
diff --git a/packages/core/src/server/email-template.spec.ts b/packages/core/src/server/email-template.spec.ts
index 9daa01157f..87de8a3f8e 100644
--- a/packages/core/src/server/email-template.spec.ts
+++ b/packages/core/src/server/email-template.spec.ts
@@ -135,4 +135,33 @@ describe("renderEmail", () => {
"Open Clip: https://clips.example/r/rec-1?view=agent&mode=summary\n\nOr feed this link to your AI agent:\nhttps://clips.example/r/rec-1?view=agent&mode=summary",
);
});
+ it("turns the footer link token into an anchor and keeps its URL in text", () => {
+ const { html, text } = renderEmail({
+ heading: "New comment",
+ paragraphs: ["Someone commented."],
+ footer: "Notifications are on in your {link}.",
+ footerLink: {
+ label: "Clips settings",
+ url: "https://clips.example/settings?tab=general",
+ },
+ });
+
+ expect(html).toContain(
+ 'Clips settings',
+ );
+ expect(text).toContain(
+ "Notifications are on in your Clips settings (https://clips.example/settings?tab=general).",
+ );
+ });
+
+ it("leaves the token visible when no footer link was supplied", () => {
+ const { html, text } = renderEmail({
+ heading: "New comment",
+ paragraphs: ["Someone commented."],
+ footer: "Notifications are on in your {link}.",
+ });
+
+ expect(html).toContain("Notifications are on in your {link}.");
+ expect(text).toContain("Notifications are on in your {link}.");
+ });
});
diff --git a/packages/core/src/server/email-template.ts b/packages/core/src/server/email-template.ts
index 95970a6e1a..50d2d7b923 100644
--- a/packages/core/src/server/email-template.ts
+++ b/packages/core/src/server/email-template.ts
@@ -54,8 +54,14 @@ export interface RenderEmailArgs {
heroHtml?: string;
/** Body paragraphs rendered after the CTA and link block. Escaped-by-caller. */
closingParagraphs?: string[];
- /** Small muted text under the CTA (e.g. expiry note). */
+ /**
+ * Small muted text under the CTA (e.g. expiry note). A `{link}` token is
+ * replaced by `footerLink` — an anchor in the HTML part, and `label (url)` in
+ * the plain-text part so the destination survives there too.
+ */
footer?: string;
+ /** Destination for the `{link}` token in `footer`. */
+ footerLink?: EmailCta;
/** Optional app name shown beside the framework logo. */
brandName?: string;
/**
@@ -89,6 +95,18 @@ function escapeAttr(s: string): string {
return escapeHtml(s);
}
+/**
+ * Leaves the token in the copy when no link was supplied. A silently dropped
+ * token would read as a finished sentence that quietly points nowhere.
+ */
+function resolveFooterLink(
+ footer: string,
+ link: EmailCta | undefined,
+ render: (target: EmailCta) => string,
+): string {
+ return link ? footer.replace(/\{link\}/g, render(link)) : footer;
+}
+
/**
* Only accept a strict `#rrggbb` hex color for `brandColor`. Anything else
* could inject CSS into the inline `style` attribute (`red; background:url(…)`).
@@ -185,7 +203,14 @@ export function renderEmail(args: RenderEmailArgs): RenderedEmail {
: "";
const footerHtml = args.footer
- ? `${escapeHtml(args.footer)}
`
+ ? // guard:allow-raw-color — inlined for email clients
+ `${resolveFooterLink(
+ escapeHtml(args.footer),
+ args.footerLink,
+ (link) =>
+ // guard:allow-raw-color — inlined for email clients
+ `${escapeHtml(link.label)}`,
+ )}
`
: "";
const brandHeaderHtml = `
@@ -273,7 +298,13 @@ export function renderEmail(args: RenderEmailArgs): RenderedEmail {
textLines.push("");
}
if (args.footer) {
- textLines.push(args.footer);
+ textLines.push(
+ resolveFooterLink(
+ args.footer,
+ args.footerLink,
+ (link) => `${link.label} (${link.url})`,
+ ),
+ );
}
return { html, text: textLines.join("\n").trim() };
diff --git a/templates/analytics/changelog/2026-08-04-error-alert-monitor-and-scheduled-report-emails-are-now-bran.md b/templates/analytics/changelog/2026-08-04-error-alert-monitor-and-scheduled-report-emails-are-now-bran.md
new file mode 100644
index 0000000000..78c3e2aeb7
--- /dev/null
+++ b/templates/analytics/changelog/2026-08-04-error-alert-monitor-and-scheduled-report-emails-are-now-bran.md
@@ -0,0 +1,6 @@
+---
+type: improved
+date: 2026-08-04
+---
+
+Error, alert, monitor, and scheduled-report emails are now branded and link to the exact setting that sent them.
diff --git a/templates/analytics/server/jobs/dashboard-report.ts b/templates/analytics/server/jobs/dashboard-report.ts
index a7e1c52945..c02732e464 100644
--- a/templates/analytics/server/jobs/dashboard-report.ts
+++ b/templates/analytics/server/jobs/dashboard-report.ts
@@ -1,6 +1,7 @@
import { notifyWithDelivery } from "@agent-native/core/notifications";
import { runWithRequestContext } from "@agent-native/core/server/request-context";
+import { analyticsUrl } from "../lib/app-url";
import { sendDashboardReportSubscription } from "../lib/dashboard-report";
import {
claimDueDashboardReportSubscriptions,
@@ -86,6 +87,14 @@ async function notifyDashboardReportGaveUp(
// The email channel is a no-op without explicit recipients.
emailRecipients: [sub.ownerEmail],
emailSubject: "Your scheduled dashboard report did not send",
+ emailFooter:
+ "You received this because you scheduled this report. Change or cancel it in {link}.",
+ emailFooterLinkLabel: "your dashboard reports",
+ emailFooterLinkUrl: analyticsUrl(
+ sub.dashboardId
+ ? `/dashboards/${encodeURIComponent(sub.dashboardId)}`
+ : "/dashboards",
+ ),
},
},
{ owner: sub.ownerEmail },
diff --git a/templates/analytics/server/lib/analytics-alerts.ts b/templates/analytics/server/lib/analytics-alerts.ts
index d27f6503db..5daa416171 100644
--- a/templates/analytics/server/lib/analytics-alerts.ts
+++ b/templates/analytics/server/lib/analytics-alerts.ts
@@ -17,6 +17,7 @@ import {
} from "drizzle-orm";
import { getDb, schema } from "../db/index.js";
+import { analyticsUrl } from "./app-url.js";
export type AnalyticsAlertFilterOp =
| "equals"
@@ -883,6 +884,10 @@ export async function evaluateAndNotifyAnalyticsAlertRule(
filters: rule.filters,
sampleEvents: evaluation.sampleEvents,
emailRecipients: rule.emailRecipients,
+ emailFooter:
+ "You received this because this alert rule emails you. Change its recipients in {link}.",
+ emailFooterLinkLabel: "Analytics settings",
+ emailFooterLinkUrl: analyticsUrl("/settings"),
requestedChannels: rule.channels,
...(deliveryMetadata ? { delivery: deliveryMetadata } : {}),
},
diff --git a/templates/analytics/server/lib/app-url.ts b/templates/analytics/server/lib/app-url.ts
new file mode 100644
index 0000000000..fc497f98b0
--- /dev/null
+++ b/templates/analytics/server/lib/app-url.ts
@@ -0,0 +1,7 @@
+import { getAppProductionUrl } from "@agent-native/core/server";
+
+/** Absolute URL for an in-app path, for use in emails and notifications. */
+export function analyticsUrl(path: string): string {
+ const base = getAppProductionUrl().replace(/\/+$/, "");
+ return `${base}${path.startsWith("/") ? path : `/${path}`}`;
+}
diff --git a/templates/analytics/server/lib/error-capture.ts b/templates/analytics/server/lib/error-capture.ts
index b9130ba03e..036b8e6ec7 100644
--- a/templates/analytics/server/lib/error-capture.ts
+++ b/templates/analytics/server/lib/error-capture.ts
@@ -36,6 +36,7 @@ import {
import { ANALYTICS_USER_PREFS_KEY } from "../../shared/analytics-user-prefs";
import { getDb, schema } from "../db/index.js";
+import { analyticsUrl } from "./app-url.js";
export type ExceptionLevel = "fatal" | "error" | "warning" | "info" | "debug";
export type IssueStatus = "unresolved" | "resolved" | "ignored";
@@ -1012,6 +1013,10 @@ async function notifyNewIssue(
? {
emailRecipients: [scope.ownerEmail],
emailSubject: `New error in your app: ${issue.title}`,
+ emailFooter:
+ "You received this because new error emails are on in your {link}.",
+ emailFooterLinkLabel: "Analytics settings",
+ emailFooterLinkUrl: analyticsUrl("/settings"),
}
: {}),
},
diff --git a/templates/analytics/server/lib/uptime-monitors.ts b/templates/analytics/server/lib/uptime-monitors.ts
index acf16d8601..c4d1d78167 100644
--- a/templates/analytics/server/lib/uptime-monitors.ts
+++ b/templates/analytics/server/lib/uptime-monitors.ts
@@ -43,6 +43,7 @@ import {
} from "drizzle-orm";
import { getDb, schema } from "../db/index.js";
+import { analyticsUrl } from "./app-url.js";
declare global {
var __AGENT_NATIVE_UPTIME_MONITOR_SCHEDULED_RUNTIME__: boolean | undefined;
@@ -706,6 +707,12 @@ function monitorNotifyMetadata(monitor: Monitor): Record {
monitorName: monitor.name,
url: monitor.url,
emailRecipients: monitor.emailRecipients,
+ emailFooter:
+ "You received this because this monitor emails you. Change its recipients in {link}.",
+ emailFooterLinkLabel: "the monitor's settings",
+ emailFooterLinkUrl: analyticsUrl(
+ `/monitoring?view=uptime&monitor=${encodeURIComponent(monitor.id)}`,
+ ),
requestedChannels: monitor.channels,
};
}
diff --git a/templates/assets/changelog/2026-08-04-generation-finished-and-failed-emails-are-now-branded-and-li.md b/templates/assets/changelog/2026-08-04-generation-finished-and-failed-emails-are-now-branded-and-li.md
new file mode 100644
index 0000000000..f1e4969365
--- /dev/null
+++ b/templates/assets/changelog/2026-08-04-generation-finished-and-failed-emails-are-now-branded-and-li.md
@@ -0,0 +1,6 @@
+---
+type: improved
+date: 2026-08-04
+---
+
+Generation finished and failed emails are now branded and link to your Assets settings.
diff --git a/templates/assets/server/lib/generation-run-notifications.ts b/templates/assets/server/lib/generation-run-notifications.ts
index f53641f431..b7f4de1361 100644
--- a/templates/assets/server/lib/generation-run-notifications.ts
+++ b/templates/assets/server/lib/generation-run-notifications.ts
@@ -7,6 +7,7 @@
* there would mail a user who is already looking at the image.
*/
import { notifyWithDelivery } from "@agent-native/core/notifications";
+import { getAppProductionUrl } from "@agent-native/core/server";
import { getUserSetting } from "@agent-native/core/settings";
import {
@@ -71,7 +72,16 @@ export async function notifyGenerationRunFinished(
libraryId: run.libraryId,
outcome,
// The email channel is a no-op without explicit recipients.
- ...(emailed ? { emailRecipients: [owner], emailSubject: title } : {}),
+ ...(emailed
+ ? {
+ emailRecipients: [owner],
+ emailSubject: title,
+ emailFooter:
+ "You received this because email notifications are on in your {link}.",
+ emailFooterLinkLabel: "Assets settings",
+ emailFooterLinkUrl: `${getAppProductionUrl().replace(/\/+$/, "")}/settings`,
+ }
+ : {}),
},
},
{ owner },
diff --git a/templates/clips/changelog/2026-08-04-clip-activity-emails-now-name-the-viewer-and-the-clip-in-the.md b/templates/clips/changelog/2026-08-04-clip-activity-emails-now-name-the-viewer-and-the-clip-in-the.md
new file mode 100644
index 0000000000..bbeb142727
--- /dev/null
+++ b/templates/clips/changelog/2026-08-04-clip-activity-emails-now-name-the-viewer-and-the-clip-in-the.md
@@ -0,0 +1,6 @@
+---
+type: improved
+date: 2026-08-04
+---
+
+Clip activity emails now name the viewer and the Clip in the subject heading, and link straight to your Clips settings.
diff --git a/templates/clips/server/lib/transactional-email-templates.test.ts b/templates/clips/server/lib/transactional-email-templates.test.ts
index 7c4121dfd5..d687e28073 100644
--- a/templates/clips/server/lib/transactional-email-templates.test.ts
+++ b/templates/clips/server/lib/transactional-email-templates.test.ts
@@ -47,8 +47,8 @@ describe("renderClipsTransactionalEmail", () => {
viewerEmail: "jane.doe@example.test",
},
subject: "Your Clip “Product tour” got its first view",
- heading: "Someone watched your Clip",
- cta: "See Clip activity: https://clips.example/r/rec-1",
+ heading: "Jane Doe watched “Product tour”",
+ cta: "See all Clip activity: https://clips.example/r/rec-1",
},
{
input: {
@@ -140,7 +140,7 @@ describe("renderClipsTransactionalEmail", () => {
appBasePath: "/clips/",
}).text,
).toContain(
- "See Clip activity: https://workspace.example/clips/r/rec%2Fwith%20space",
+ "See all Clip activity: https://workspace.example/clips/r/rec%2Fwith%20space",
);
expect(
@@ -149,7 +149,7 @@ describe("renderClipsTransactionalEmail", () => {
appBasePath: "clips",
}).text,
).toContain(
- "See Clip activity: https://workspace.example/clips/r/rec%2Fwith%20space",
+ "See all Clip activity: https://workspace.example/clips/r/rec%2Fwith%20space",
);
});
@@ -295,6 +295,31 @@ describe("renderClipsTransactionalEmail", () => {
);
});
+ it("names the reactor and the Clip, and links the settings footer", () => {
+ const result = render({
+ kind: "activity-reaction",
+ to: "owner@example.test",
+ recordingId: "rec-5",
+ title: "Deploy walkthrough",
+ emoji: "\u{1F389}",
+ authorEmail: "jane.doe@example.test",
+ videoTimestampMs: 65_000,
+ });
+
+ expect(result.html).toContain(
+ "Jane Doe reacted to \u201cDeploy walkthrough\u201d",
+ );
+ expect(result.html).toContain(
+ 'Clips settings',
+ );
+ expect(result.text).toContain(
+ "Clips settings (https://clips.example/settings)",
+ );
+ expect(result.text).toContain(
+ "See all Clip activity: https://clips.example/r/rec-5?panel=comments&t=65",
+ );
+ });
+
it("offers both the analytics and import calls to action", () => {
const result = render({
kind: "first-agent-view",
diff --git a/templates/clips/server/lib/transactional-email-templates.ts b/templates/clips/server/lib/transactional-email-templates.ts
index b9d95d894d..d08ad2766e 100644
--- a/templates/clips/server/lib/transactional-email-templates.ts
+++ b/templates/clips/server/lib/transactional-email-templates.ts
@@ -21,7 +21,7 @@ const EMAIL_SEND_TIMEOUT_MS = 60_000;
const FRIENDLY_REPLY_TO = "hello@agent-native.com";
const UNTITLED_CLIP = "Untitled Clip";
const ACTIVITY_EMAIL_FOOTER =
- "You received this because email notifications are on in your Clips settings.";
+ "You received this because email notifications are on in your {link}.";
interface TransactionalEmailBase {
to: string;
@@ -189,6 +189,10 @@ function recordUrl(options: ClipsTransactionalEmailRenderOptions): string {
return appUrlForPath("/record", options);
}
+function settingsLink(options: ClipsTransactionalEmailRenderOptions) {
+ return { label: "Clips settings", url: appUrlForPath("/settings", options) };
+}
+
function resolveBrandLogoUrl(
value: string | null | undefined,
options: ClipsTransactionalEmailRenderOptions,
@@ -378,13 +382,13 @@ export function renderClipsTransactionalEmail(
const rendered = renderEmail({
brandName: CLIPS_BRAND_NAME,
preheader: subject,
- heading: "Someone watched your Clip",
+ heading: `${viewer} watched “${title}”`,
paragraphs: [
`${emailStrong(viewer)} registered the first view of ${emailStrong(title!)}.`,
"Clips tracks advanced analytics on your viewers' activity, and can even tell you whether your recipient took AI actions with your link. Come back to Clips to view analytics, or configure Clips AI to take agentic actions on your behalf.",
],
cta: {
- label: "See Clip activity",
+ label: "See all Clip activity",
url: clipUrl(input.recordingId, options),
},
footer:
@@ -546,6 +550,7 @@ export function renderClipsTransactionalEmail(
),
},
footer: ACTIVITY_EMAIL_FOOTER,
+ footerLink: settingsLink(options),
});
return { subject, ...rendered };
}
@@ -562,12 +567,12 @@ export function renderClipsTransactionalEmail(
const rendered = renderEmail({
brandName: CLIPS_BRAND_NAME,
preheader: subject,
- heading: "Someone reacted to your Clip",
+ heading: `${author} reacted to “${title}”`,
paragraphs: [
`${emailStrong(author)} reacted ${emailStrong(input.emoji)} on ${emailStrong(title!)}${at}.`,
],
cta: {
- label: "See Clip activity",
+ label: "See all Clip activity",
url: clipCommentsUrl(
input.recordingId,
input.videoTimestampMs,
@@ -575,6 +580,7 @@ export function renderClipsTransactionalEmail(
),
},
footer: ACTIVITY_EMAIL_FOOTER,
+ footerLink: settingsLink(options),
});
return { subject, ...rendered };
}
diff --git a/templates/content/changelog/2026-08-04-comment-notification-emails-now-link-directly-to-your-docume.md b/templates/content/changelog/2026-08-04-comment-notification-emails-now-link-directly-to-your-docume.md
new file mode 100644
index 0000000000..4b3d7205d9
--- /dev/null
+++ b/templates/content/changelog/2026-08-04-comment-notification-emails-now-link-directly-to-your-docume.md
@@ -0,0 +1,6 @@
+---
+type: improved
+date: 2026-08-04
+---
+
+Comment notification emails now link directly to your Documents settings so you can turn them off in one click.
diff --git a/templates/content/server/lib/comment-notifications.ts b/templates/content/server/lib/comment-notifications.ts
index bd29251f35..6c07b90666 100644
--- a/templates/content/server/lib/comment-notifications.ts
+++ b/templates/content/server/lib/comment-notifications.ts
@@ -35,8 +35,15 @@ function excerpt(content: string): string {
}
function documentUrl(documentId: string): string {
- const base = getAppProductionUrl().replace(/\/+$/, "");
- return `${base}/page/${encodeURIComponent(documentId)}`;
+ return `${appBaseUrl()}/page/${encodeURIComponent(documentId)}`;
+}
+
+function settingsUrl(): string {
+ return `${appBaseUrl()}/settings`;
+}
+
+function appBaseUrl(): string {
+ return getAppProductionUrl().replace(/\/+$/, "");
}
async function threadParticipants(
@@ -128,7 +135,8 @@ async function deliverDocumentCommentEmails(
paragraphs: [lead, `"${excerpt(input.content)}"`],
cta: { label: "Open document", url },
footer:
- "You received this because you own, were mentioned in, or participated in this thread. Turn these off in Documents settings.",
+ "You received this because you own, were mentioned in, or participated in this thread. Turn these off in {link}.",
+ footerLink: { label: "Documents settings", url: settingsUrl() },
});
await sendEmail({
diff --git a/templates/forms/changelog/2026-08-04-new-response-emails-now-link-back-to-the-form-whose-notifica.md b/templates/forms/changelog/2026-08-04-new-response-emails-now-link-back-to-the-form-whose-notifica.md
new file mode 100644
index 0000000000..3b2af13801
--- /dev/null
+++ b/templates/forms/changelog/2026-08-04-new-response-emails-now-link-back-to-the-form-whose-notifica.md
@@ -0,0 +1,6 @@
+---
+type: improved
+date: 2026-08-04
+---
+
+New response emails now link back to the form whose notification setting sent them.
diff --git a/templates/forms/server/handlers/submissions.ts b/templates/forms/server/handlers/submissions.ts
index 8de5795143..be4232ecfb 100644
--- a/templates/forms/server/handlers/submissions.ts
+++ b/templates/forms/server/handlers/submissions.ts
@@ -260,6 +260,7 @@ export const submitForm = defineEventHandler(async (event: H3Event) => {
() =>
sendNewResponseEmail({
to: form.ownerEmail!,
+ formId: id,
formTitle: form.title,
fields,
data,
diff --git a/templates/forms/server/lib/response-email.ts b/templates/forms/server/lib/response-email.ts
index 9b70427b25..4ab7af8d2b 100644
--- a/templates/forms/server/lib/response-email.ts
+++ b/templates/forms/server/lib/response-email.ts
@@ -1,9 +1,15 @@
-import { emailStrong, renderEmail, sendEmail } from "@agent-native/core/server";
+import {
+ emailStrong,
+ getAppProductionUrl,
+ renderEmail,
+ sendEmail,
+} from "@agent-native/core/server";
import type { FormField } from "../../shared/types.js";
export interface NewResponseEmailArgs {
to: string;
+ formId: string;
formTitle: string;
fields: FormField[];
data: Record;
@@ -21,7 +27,13 @@ function formatResponseValue(value: unknown): string {
return String(value);
}
+function formSettingsUrl(formId: string): string {
+ const base = getAppProductionUrl().replace(/\/+$/, "");
+ return `${base}/forms/${encodeURIComponent(formId)}`;
+}
+
export function renderNewResponseEmail({
+ formId,
formTitle,
fields,
data,
@@ -50,7 +62,8 @@ export function renderNewResponseEmail({
: "No response fields were submitted.",
],
footer:
- "You received this because email notifications are enabled for this form.",
+ "You received this because email notifications are enabled in this {link}.",
+ footerLink: { label: "form's settings", url: formSettingsUrl(formId) },
}),
};
}
diff --git a/templates/plan/app/components/ui/switch.tsx b/templates/plan/app/components/ui/switch.tsx
new file mode 100644
index 0000000000..13bb49b268
--- /dev/null
+++ b/templates/plan/app/components/ui/switch.tsx
@@ -0,0 +1 @@
+export * from "@agent-native/toolkit/ui/switch";
diff --git a/templates/plan/app/hooks/use-plan-prefs.ts b/templates/plan/app/hooks/use-plan-prefs.ts
new file mode 100644
index 0000000000..8d9bb0d5a1
--- /dev/null
+++ b/templates/plan/app/hooks/use-plan-prefs.ts
@@ -0,0 +1,55 @@
+import { agentNativePath } from "@agent-native/core/client/api-path";
+import type { PlanUserPrefs } from "@shared/plan-user-prefs";
+import { useCallback, useEffect, useState } from "react";
+
+const PREFS_PATH = "/_agent-native/plan/user-prefs";
+
+export interface PlanPrefsState {
+ prefs: PlanUserPrefs;
+ loading: boolean;
+ /** Applies the patch optimistically and rolls back if the write fails. */
+ save: (patch: PlanUserPrefs) => Promise;
+}
+
+export function usePlanPrefs(): PlanPrefsState {
+ const [prefs, setPrefs] = useState({});
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ try {
+ const res = await fetch(agentNativePath(PREFS_PATH));
+ const json = res.ok ? await res.json() : null;
+ if (cancelled) return;
+ if (json && typeof json === "object" && !("error" in json)) {
+ setPrefs(json as PlanUserPrefs);
+ }
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const save = useCallback(
+ async (patch: PlanUserPrefs) => {
+ const previous = prefs;
+ setPrefs((current) => ({ ...current, ...patch }));
+ const res = await fetch(agentNativePath(PREFS_PATH), {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(patch),
+ });
+ if (!res.ok) {
+ setPrefs(previous);
+ throw new Error(`Save failed (${res.status})`);
+ }
+ },
+ [prefs],
+ );
+
+ return { prefs, loading, save };
+}
diff --git a/templates/plan/app/i18n/ar-SA.ts b/templates/plan/app/i18n/ar-SA.ts
index 56d0f43981..b4599608d0 100644
--- a/templates/plan/app/i18n/ar-SA.ts
+++ b/templates/plan/app/i18n/ar-SA.ts
@@ -37,6 +37,10 @@ const messages = {
editorDescription:
"افتح الخطط وراجعها في لوحة جانبية داخل VS Code بدلاً من علامة تبويب منفصلة في المتصفح.",
openEditorExtension: "احصل على إضافة VS Code",
+ emailNotifications: "إشعارات البريد الإلكتروني",
+ emailNotificationsDescription:
+ "احصل على بريد إلكتروني عندما يعلّق شخص على خطتك أو يرد أو يذكرك.",
+ saveFailed: "تعذّر الحفظ",
},
agent: {
emptyState:
diff --git a/templates/plan/app/i18n/de-DE.ts b/templates/plan/app/i18n/de-DE.ts
index 0d69db9da1..5e1481a362 100644
--- a/templates/plan/app/i18n/de-DE.ts
+++ b/templates/plan/app/i18n/de-DE.ts
@@ -38,6 +38,10 @@ const messages = {
editorDescription:
"Öffne und prüfe Pläne in einem Seitenbereich in VS Code statt in einem separaten Browser-Tab.",
openEditorExtension: "VS-Code-Erweiterung holen",
+ emailNotifications: "E-Mail-Benachrichtigungen",
+ emailNotificationsDescription:
+ "Erhalte eine E-Mail, wenn jemand deinen Plan kommentiert, antwortet oder dich erwähnt.",
+ saveFailed: "Speichern fehlgeschlagen",
},
agent: {
emptyState:
diff --git a/templates/plan/app/i18n/en-US.ts b/templates/plan/app/i18n/en-US.ts
index 228a2506a4..b4fb85a15e 100644
--- a/templates/plan/app/i18n/en-US.ts
+++ b/templates/plan/app/i18n/en-US.ts
@@ -38,6 +38,10 @@ const messages = {
editorDescription:
"Open and review plans in a side panel inside VS Code instead of a separate browser tab.",
openEditorExtension: "Get the VS Code extension",
+ emailNotifications: "Email notifications",
+ emailNotificationsDescription:
+ "Get an email when someone comments on, replies in, or mentions you on your plan.",
+ saveFailed: "Couldn't save",
},
agent: {
emptyState:
diff --git a/templates/plan/app/i18n/es-ES.ts b/templates/plan/app/i18n/es-ES.ts
index d2a514440b..158cc8e00a 100644
--- a/templates/plan/app/i18n/es-ES.ts
+++ b/templates/plan/app/i18n/es-ES.ts
@@ -38,6 +38,10 @@ const messages = {
editorDescription:
"Abre y revisa los planes en un panel lateral dentro de VS Code en lugar de una pestaña aparte del navegador.",
openEditorExtension: "Obtener la extensión de VS Code",
+ emailNotifications: "Notificaciones por correo",
+ emailNotificationsDescription:
+ "Recibe un correo cuando alguien comente, responda o te mencione en tu plan.",
+ saveFailed: "No se pudo guardar",
},
agent: {
emptyState:
diff --git a/templates/plan/app/i18n/fr-FR.ts b/templates/plan/app/i18n/fr-FR.ts
index 2dc8a17ac5..9c3f7fdc8a 100644
--- a/templates/plan/app/i18n/fr-FR.ts
+++ b/templates/plan/app/i18n/fr-FR.ts
@@ -38,6 +38,10 @@ const messages = {
editorDescription:
"Ouvrez et examinez les plans dans un panneau latéral de VS Code plutôt que dans un onglet de navigateur séparé.",
openEditorExtension: "Obtenir l’extension VS Code",
+ emailNotifications: "Notifications par e-mail",
+ emailNotificationsDescription:
+ "Recevez un e-mail lorsqu’une personne commente, répond ou vous mentionne dans votre plan.",
+ saveFailed: "Impossible d’enregistrer",
},
agent: {
emptyState:
diff --git a/templates/plan/app/i18n/hi-IN.ts b/templates/plan/app/i18n/hi-IN.ts
index 1cc429917a..2f199e221c 100644
--- a/templates/plan/app/i18n/hi-IN.ts
+++ b/templates/plan/app/i18n/hi-IN.ts
@@ -37,6 +37,10 @@ const messages = {
editorDescription:
"अलग ब्राउज़र टैब के बजाय VS Code के साइड पैनल में योजनाएँ खोलें और उनकी समीक्षा करें।",
openEditorExtension: "VS Code एक्सटेंशन पाएँ",
+ emailNotifications: "ईमेल सूचनाएँ",
+ emailNotificationsDescription:
+ "जब कोई आपकी योजना पर टिप्पणी करे, जवाब दे या आपका उल्लेख करे तो ईमेल पाएँ।",
+ saveFailed: "सहेजा नहीं जा सका",
},
agent: {
emptyState:
diff --git a/templates/plan/app/i18n/ja-JP.ts b/templates/plan/app/i18n/ja-JP.ts
index abc47806e4..1aa8e0ee8f 100644
--- a/templates/plan/app/i18n/ja-JP.ts
+++ b/templates/plan/app/i18n/ja-JP.ts
@@ -38,6 +38,10 @@ const messages = {
editorDescription:
"別のブラウザータブではなく、VS Code のサイドパネルでプランを開いてレビューします。",
openEditorExtension: "VS Code 拡張機能を入手",
+ emailNotifications: "メール通知",
+ emailNotificationsDescription:
+ "誰かがあなたのプランにコメント、返信、またはあなたにメンションしたときにメールを受け取ります。",
+ saveFailed: "保存できませんでした",
},
agent: {
emptyState:
diff --git a/templates/plan/app/i18n/ko-KR.ts b/templates/plan/app/i18n/ko-KR.ts
index 3b184a6c1b..ac7df822e3 100644
--- a/templates/plan/app/i18n/ko-KR.ts
+++ b/templates/plan/app/i18n/ko-KR.ts
@@ -38,6 +38,10 @@ const messages = {
editorDescription:
"별도의 브라우저 탭 대신 VS Code 사이드 패널에서 계획을 열고 검토하세요.",
openEditorExtension: "VS Code 확장 프로그램 받기",
+ emailNotifications: "이메일 알림",
+ emailNotificationsDescription:
+ "누군가 내 플랜에 댓글을 달거나 답글을 남기거나 나를 멘션하면 이메일을 받습니다.",
+ saveFailed: "저장하지 못했습니다",
},
agent: {
emptyState:
diff --git a/templates/plan/app/i18n/pt-BR.ts b/templates/plan/app/i18n/pt-BR.ts
index 0296df20bf..41ba6641d8 100644
--- a/templates/plan/app/i18n/pt-BR.ts
+++ b/templates/plan/app/i18n/pt-BR.ts
@@ -38,6 +38,10 @@ const messages = {
editorDescription:
"Abra e revise planos em um painel lateral dentro do VS Code em vez de uma aba separada do navegador.",
openEditorExtension: "Obter a extensão do VS Code",
+ emailNotifications: "Notificações por e-mail",
+ emailNotificationsDescription:
+ "Receba um e-mail quando alguém comentar, responder ou mencionar você no seu plano.",
+ saveFailed: "Não foi possível salvar",
},
agent: {
emptyState:
diff --git a/templates/plan/app/i18n/zh-CN.ts b/templates/plan/app/i18n/zh-CN.ts
index ae8f620742..d0fbba79b2 100644
--- a/templates/plan/app/i18n/zh-CN.ts
+++ b/templates/plan/app/i18n/zh-CN.ts
@@ -35,6 +35,10 @@ const messages = {
editorDescription:
"在 VS Code 的侧边面板中打开并审阅计划,而不是切换到单独的浏览器标签页。",
openEditorExtension: "获取 VS Code 扩展",
+ emailNotifications: "邮件通知",
+ emailNotificationsDescription:
+ "当有人评论你的计划、在讨论串中回复或提到你时,收到邮件通知。",
+ saveFailed: "保存失败",
},
agent: {
emptyState:
diff --git a/templates/plan/app/i18n/zh-TW.ts b/templates/plan/app/i18n/zh-TW.ts
index 6badc44380..30349a2ca7 100644
--- a/templates/plan/app/i18n/zh-TW.ts
+++ b/templates/plan/app/i18n/zh-TW.ts
@@ -35,6 +35,10 @@ const messages = {
editorDescription:
"在 VS Code 的側邊面板中開啟並審閱計畫,而不是切換到單獨的瀏覽器標籤頁面。",
openEditorExtension: "取得 VS Code 擴充功能",
+ emailNotifications: "郵件通知",
+ emailNotificationsDescription:
+ "當有人評論你的計畫、在討論串中回覆或提到你時,收到郵件通知。",
+ saveFailed: "儲存失敗",
},
agent: {
emptyState:
diff --git a/templates/plan/app/routes/settings.tsx b/templates/plan/app/routes/settings.tsx
index 8518614f1a..86b5f3896d 100644
--- a/templates/plan/app/routes/settings.tsx
+++ b/templates/plan/app/routes/settings.tsx
@@ -11,8 +11,11 @@ import {
} from "@agent-native/core/client/settings";
import { useSetPageTitle } from "@agent-native/toolkit/app-shell";
import { useMemo } from "react";
+import { toast } from "sonner";
import { Button } from "@/components/ui/button";
+import { Switch } from "@/components/ui/switch";
+import { usePlanPrefs } from "@/hooks/use-plan-prefs";
import { APP_TITLE } from "@/lib/app-config";
import changelog from "../../CHANGELOG.md?raw";
@@ -25,6 +28,7 @@ export default function SettingsRoute() {
const t = useT();
const agentSettingsTabs = useAgentSettingsTabs();
useSetPageTitle(t("settings.title"));
+ const { prefs, loading: prefsLoading, save: savePrefs } = usePlanPrefs();
const generalSearchEntries = useMemo(
() => [
@@ -34,6 +38,12 @@ export default function SettingsRoute() {
keywords: "language locale translation i18n",
hash: "language",
},
+ {
+ id: "plan-notifications",
+ label: t("settings.emailNotifications"),
+ keywords: "email notifications comments replies mentions alerts",
+ hash: "notifications",
+ },
{
id: "plan-editor",
label: t("settings.editorTitle"),
@@ -67,6 +77,27 @@ export default function SettingsRoute() {
}
/>
+ {
+ savePrefs({ emailNotifications: checked }).catch((err) => {
+ toast.error(
+ err instanceof Error
+ ? err.message
+ : t("settings.saveFailed"),
+ );
+ });
+ }}
+ />
+ }
+ />
const isEmailConfiguredMock = vi.hoisted(() => vi.fn(() => true));
const selectPlanMock = vi.hoisted(() => vi.fn());
const getDbMock = vi.hoisted(() => vi.fn());
+const resolveActivityRecipientsMock = vi.hoisted(() =>
+ vi.fn(async ({ candidates }: { candidates: string[] }) => candidates),
+);
vi.mock("drizzle-orm", () => ({
eq: vi.fn((left: unknown, right: unknown) => ({ left, right })),
@@ -20,6 +23,8 @@ vi.mock("@agent-native/core/server", () => ({
getAppProductionUrl: () => "https://plans.example.test",
isEmailConfigured: () => isEmailConfiguredMock(),
renderEmail: (args: unknown) => renderEmailMock(args),
+ resolveActivityRecipients: (args: { candidates: string[] }) =>
+ resolveActivityRecipientsMock(args),
sendEmail: (args: unknown) => sendEmailMock(args),
}));
@@ -107,6 +112,7 @@ describe("plan comment notification recipients", () => {
renderEmailMock.mockClear();
selectPlanMock.mockReset();
sendEmailMock.mockReset();
+ resolveActivityRecipientsMock.mockClear();
});
it("notifies the plan owner for a new root comment", () => {
@@ -263,6 +269,48 @@ describe("plan comment notification recipients", () => {
});
});
+ it("links Plan settings in the footer and skips recipients who opted out", async () => {
+ const newComment = comment("reviewer", {
+ authorEmail: "reviewer@example.com",
+ authorName: "Reviewer",
+ });
+ selectPlanMock.mockResolvedValue([
+ {
+ id: "plan_1",
+ title: "Launch Plan",
+ ownerEmail: "owner@example.com",
+ sourceAuthorEmail: null,
+ },
+ ]);
+
+ await notifyPlanCommentRecipients({
+ bundle: bundle([newComment]),
+ insertedCommentIds: [newComment.id],
+ });
+
+ expect(renderEmailMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ footer:
+ "You received this because you own this plan. Turn these off in {link}.",
+ footerLink: {
+ label: "Plan settings",
+ url: "https://plans.example.test/settings",
+ },
+ }),
+ );
+
+ renderEmailMock.mockClear();
+ sendEmailMock.mockReset();
+ resolveActivityRecipientsMock.mockResolvedValueOnce([]);
+
+ await notifyPlanCommentRecipients({
+ bundle: bundle([newComment]),
+ insertedCommentIds: [newComment.id],
+ });
+
+ expect(sendEmailMock).not.toHaveBeenCalled();
+ });
+
it("does not notify later batch commenters for earlier replies", async () => {
const root = comment("root", {
authorEmail: "root@example.com",
diff --git a/templates/plan/server/lib/comment-notifications.ts b/templates/plan/server/lib/comment-notifications.ts
index 7b56072b0c..6b4e183bc5 100644
--- a/templates/plan/server/lib/comment-notifications.ts
+++ b/templates/plan/server/lib/comment-notifications.ts
@@ -1,8 +1,18 @@
+/**
+ * Email notifications for plan comments, replies, and mentions.
+ *
+ * Recipient reasons (owner, mention, thread participant) are Plan's own; the
+ * per-user `emailNotifications` opt-out is the shared one from
+ * `@agent-native/core/server`. Access requests are not routed through that
+ * preference — they have their own delivery path.
+ */
+
import {
emailStrong,
getAppProductionUrl,
isEmailConfigured,
renderEmail,
+ resolveActivityRecipients,
sendEmail,
} from "@agent-native/core/server";
import { eq } from "drizzle-orm";
@@ -11,6 +21,7 @@ import {
SOURCE_AUTHOR_COMMENT_MENTION_EMAIL,
extractCommentMentions,
} from "../../shared/comment-context.js";
+import { PLAN_USER_PREFS_KEY } from "../../shared/plan-user-prefs.js";
import type { PlanBundle, PlanComment } from "../../shared/types.js";
import { getDb, schema } from "../db/index.js";
@@ -96,16 +107,24 @@ function appPath(path: string): string {
return `${normalizedBase}${path}`;
}
-function planUrl(planId: string): string {
- const appUrl = getAppProductionUrl().replace(/\/+$/, "");
- const path = appPath(`/plans/${encodeURIComponent(planId)}`);
+function appUrl(path: string): string {
+ const base = getAppProductionUrl().replace(/\/+$/, "");
+ const resolved = appPath(path);
try {
- return new URL(path, `${appUrl}/`).toString();
+ return new URL(resolved, `${base}/`).toString();
} catch {
- return `${appUrl}${path}`;
+ return `${base}${resolved}`;
}
}
+function planUrl(planId: string): string {
+ return appUrl(`/plans/${encodeURIComponent(planId)}`);
+}
+
+function settingsLink() {
+ return { label: "Plan settings", url: appUrl("/settings") };
+}
+
function appName(): string {
return (
process.env.APP_NAME || process.env.VITE_APP_NAME || "Agent-Native Plan"
@@ -231,12 +250,14 @@ async function sendPlanCommentNotification(input: {
`Comment: "${commentExcerpt(input.comment.message)}"`,
],
cta: { label: "Open plan", url: planUrl(input.planId) },
- footer:
+ footer: `${
input.recipient.reason === "plan-owner"
? "You received this because you own this plan."
: input.recipient.reason === "mention"
? "You received this because you were mentioned in this comment."
- : "You received this because you participated in this comment thread.",
+ : "You received this because you participated in this comment thread."
+ } Turn these off in {link}.`,
+ footerLink: settingsLink(),
});
await sendEmail({ to: input.recipient.email, subject, html, text });
}
@@ -281,7 +302,18 @@ export async function notifyPlanCommentRecipients({
planOwnerEmail,
sourceAuthorEmail,
});
+ // Recipient resolution is Plan's own (owner/mention/thread reasons), so
+ // only the opt-out filter is borrowed from the shared activity helper.
+ const optedIn = new Set(
+ await resolveActivityRecipients({
+ candidates: recipients.map((recipient) => recipient.email),
+ actorEmail: comment.authorEmail,
+ preferenceKey: PLAN_USER_PREFS_KEY,
+ }),
+ );
+
for (const recipient of recipients) {
+ if (!optedIn.has(recipient.email)) continue;
try {
await sendPlanCommentNotification({
recipient,
diff --git a/templates/plan/server/routes/_agent-native/plan/user-prefs.get.ts b/templates/plan/server/routes/_agent-native/plan/user-prefs.get.ts
new file mode 100644
index 0000000000..a4df17b59d
--- /dev/null
+++ b/templates/plan/server/routes/_agent-native/plan/user-prefs.get.ts
@@ -0,0 +1,16 @@
+import { getSession } from "@agent-native/core/server";
+import { getUserSetting } from "@agent-native/core/settings";
+import { defineEventHandler, setResponseStatus } from "h3";
+
+import { PLAN_USER_PREFS_KEY } from "../../../../shared/plan-user-prefs.js";
+
+export default defineEventHandler(async (event) => {
+ const session = await getSession(event);
+ if (!session?.email) {
+ setResponseStatus(event, 401);
+ return { error: "unauthorized" };
+ }
+
+ // coercion-ok: no stored blob means no preferences set yet, which is a real state, not a read failure.
+ return (await getUserSetting(session.email, PLAN_USER_PREFS_KEY)) ?? {};
+});
diff --git a/templates/plan/server/routes/_agent-native/plan/user-prefs.put.ts b/templates/plan/server/routes/_agent-native/plan/user-prefs.put.ts
new file mode 100644
index 0000000000..c43bf25389
--- /dev/null
+++ b/templates/plan/server/routes/_agent-native/plan/user-prefs.put.ts
@@ -0,0 +1,36 @@
+import { getSession } from "@agent-native/core/server";
+import { getUserSetting, putUserSetting } from "@agent-native/core/settings";
+import { defineEventHandler, getHeader, readBody, setResponseStatus } from "h3";
+
+import { PLAN_USER_PREFS_KEY } from "../../../../shared/plan-user-prefs.js";
+
+export default defineEventHandler(async (event) => {
+ const session = await getSession(event);
+ if (!session?.email) {
+ setResponseStatus(event, 401);
+ return { error: "unauthorized" };
+ }
+
+ // coercion-ok: an unparsable body is rejected with 400 immediately below.
+ const body = await readBody(event).catch(() => null);
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
+ setResponseStatus(event, 400);
+ return { error: "Invalid settings payload" };
+ }
+
+ // Merge so a partial save never wipes preferences written elsewhere.
+ const stored = await getUserSetting(session.email, PLAN_USER_PREFS_KEY);
+ // coercion-ok: a null read means this user has no preferences yet, which is a real state.
+ const existing = stored ?? {};
+ const next = {
+ ...(typeof existing === "object" && existing && !Array.isArray(existing)
+ ? existing
+ : {}),
+ ...(body as Record),
+ };
+
+ await putUserSetting(session.email, PLAN_USER_PREFS_KEY, next, {
+ requestSource: getHeader(event, "x-request-source") || undefined,
+ });
+ return next;
+});
diff --git a/templates/plan/shared/plan-user-prefs.ts b/templates/plan/shared/plan-user-prefs.ts
new file mode 100644
index 0000000000..8c7615a639
--- /dev/null
+++ b/templates/plan/shared/plan-user-prefs.ts
@@ -0,0 +1,11 @@
+/**
+ * Per-user Plan preferences, stored under one user-setting key so Settings and
+ * the notification senders read and write the same object.
+ */
+
+export const PLAN_USER_PREFS_KEY = "plan-user-prefs";
+
+export type PlanUserPrefs = {
+ /** Comment, reply, and mention emails only — never access requests. */
+ emailNotifications?: boolean;
+};
diff --git a/templates/slides/actions/_app-url.ts b/templates/slides/actions/_app-url.ts
index ecb54479b9..ea61154d90 100644
--- a/templates/slides/actions/_app-url.ts
+++ b/templates/slides/actions/_app-url.ts
@@ -38,6 +38,10 @@ export function getDeckUrl(deckId: string): string {
return `${getSlidesAppUrl()}/deck/${deckId}`;
}
+export function getSettingsUrl(): string {
+ return `${getSlidesAppUrl()}/settings`;
+}
+
export function getExportUrl(filename: string): string {
return `${getSlidesAppUrl()}/api/exports/${filename}`;
}
diff --git a/templates/slides/changelog/2026-08-04-comment-notification-emails-now-link-directly-to-your-slides.md b/templates/slides/changelog/2026-08-04-comment-notification-emails-now-link-directly-to-your-slides.md
new file mode 100644
index 0000000000..6528388118
--- /dev/null
+++ b/templates/slides/changelog/2026-08-04-comment-notification-emails-now-link-directly-to-your-slides.md
@@ -0,0 +1,6 @@
+---
+type: improved
+date: 2026-08-04
+---
+
+Comment notification emails now link directly to your Slides settings so you can turn them off in one click.
diff --git a/templates/slides/server/lib/comment-notifications.spec.ts b/templates/slides/server/lib/comment-notifications.spec.ts
index c03d5cd9f9..1eb2f3ad94 100644
--- a/templates/slides/server/lib/comment-notifications.spec.ts
+++ b/templates/slides/server/lib/comment-notifications.spec.ts
@@ -48,6 +48,7 @@ vi.mock("@agent-native/core/sharing", () => ({
vi.mock("../../actions/_app-url.js", () => ({
getDeckUrl: (deckId: string) => `https://slides.test/deck/${deckId}`,
+ getSettingsUrl: () => "https://slides.test/settings",
}));
vi.mock("../db/index.js", () => ({
diff --git a/templates/slides/server/lib/comment-notifications.ts b/templates/slides/server/lib/comment-notifications.ts
index 9d480bddf8..7a2801ffb5 100644
--- a/templates/slides/server/lib/comment-notifications.ts
+++ b/templates/slides/server/lib/comment-notifications.ts
@@ -18,7 +18,7 @@ import {
import { filterRecipientsByResourceAccess } from "@agent-native/core/sharing";
import { and, eq } from "drizzle-orm";
-import { getDeckUrl } from "../../actions/_app-url.js";
+import { getDeckUrl, getSettingsUrl } from "../../actions/_app-url.js";
import { SLIDES_USER_PREFS_KEY } from "../../shared/slides-user-prefs.js";
import { getDb, schema } from "../db/index.js";
@@ -166,7 +166,8 @@ async function deliverDeckCommentEmails(input: {
],
cta: { label: "Open deck", url },
footer:
- "You received this because you own or participated in this thread. Turn these off in Slides settings.",
+ "You received this because you own or participated in this thread. Turn these off in {link}.",
+ footerLink: { label: "Slides settings", url: getSettingsUrl() },
});
await sendEmail({