Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/tender-moons-attack.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 43 additions & 0 deletions packages/core/src/notifications/channels.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,49 @@ describe("email notification channel", () => {
expect(sent.html).not.toContain("<pre>");
});

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(
'<a href="https://assets.example.test/settings"',
);
expect(sent.text).toContain(
"Email notifications are on in your Assets settings (https://assets.example.test/settings).",
);
});

it("adds no opt-out footer when the sender named none", async () => {
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();
Expand Down
36 changes: 31 additions & 5 deletions packages/core/src/notifications/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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 = [
`<p><strong>${escapeHtml(input.title)}</strong></p>`,
input.body ? `<p>${escapeHtml(input.body)}</p>` : "",
].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) =>
Expand Down Expand Up @@ -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<string, unknown> | 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<string, unknown> | undefined,
): string[] {
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/server/email-template.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<a href="https://clips.example/settings?tab=general" style="color:#a1a1aa; text-decoration:underline;">Clips settings</a>',
);
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}.");
});
});
37 changes: 34 additions & 3 deletions packages/core/src/server/email-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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(…)`).
Expand Down Expand Up @@ -185,7 +203,14 @@ export function renderEmail(args: RenderEmailArgs): RenderedEmail {
: "";

const footerHtml = args.footer
? `<p style="margin:28px 0 0 0; font-size:13px; line-height:1.5; color:#71717a;">${escapeHtml(args.footer)}</p>`
? // guard:allow-raw-color — inlined for email clients
`<p style="margin:28px 0 0 0; font-size:13px; line-height:1.5; color:#71717a;">${resolveFooterLink(
escapeHtml(args.footer),
args.footerLink,
(link) =>
// guard:allow-raw-color — inlined for email clients
`<a href="${escapeAttr(link.url)}" style="color:#a1a1aa; text-decoration:underline;">${escapeHtml(link.label)}</a>`,
)}</p>`
: "";

const brandHeaderHtml = `
Expand Down Expand Up @@ -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() };
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions templates/analytics/server/jobs/dashboard-report.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 },
Expand Down
5 changes: 5 additions & 0 deletions templates/analytics/server/lib/analytics-alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 } : {}),
},
Expand Down
7 changes: 7 additions & 0 deletions templates/analytics/server/lib/app-url.ts
Original file line number Diff line number Diff line change
@@ -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}`}`;
Comment on lines +4 to +6

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Preserve Analytics base paths in notification URLs

analyticsUrl appends paths directly to the production origin, but Analytics routes strip a configured APP_BASE_PATH/VITE_APP_BASE_PATH. In a deployment mounted at /analytics, new alert, monitor, error, and report links point to /settings or /monitoring at the host root instead of the Analytics app. Use the configured base-path-aware URL helper and cover a mounted-path case.

Additional Info
Found by 1 of 3 review agents; surrounding Analytics routing reads APP_BASE_PATH/VITE_APP_BASE_PATH, while this helper does not.

Fix in Builder

}
5 changes: 5 additions & 0 deletions templates/analytics/server/lib/error-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"),
}
: {}),
},
Expand Down
7 changes: 7 additions & 0 deletions templates/analytics/server/lib/uptime-monitors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -706,6 +707,12 @@ function monitorNotifyMetadata(monitor: Monitor): Record<string, unknown> {
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,
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
type: improved
date: 2026-08-04
---

Generation finished and failed emails are now branded and link to your Assets settings.
12 changes: 11 additions & 1 deletion templates/assets/server/lib/generation-run-notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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`,
Comment on lines +79 to +82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Use the Assets base-path-aware URL builder

This direct concatenation bypasses Assets' existing absoluteUrl helper, which prefixes getConfiguredAppBasePath(). When Assets is mounted under /assets, the generation email footer links to the host /settings rather than /assets/settings; build the URL through the existing helper and add a mounted-path test.

Additional Info
Found by 1 of 3 review agents; confirmed by templates/assets/server/lib/json.ts and its base-path tests.

Fix in Builder

}
: {}),
},
},
{ owner },
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading