diff --git a/.changeset/per-app-verify-email.md b/.changeset/per-app-verify-email.md new file mode 100644 index 0000000000..42c8922161 --- /dev/null +++ b/.changeset/per-app-verify-email.md @@ -0,0 +1,15 @@ +--- +"@agent-native/core": patch +--- + +Brand transactional auth emails per app. Signup verification and password +reset emails now send from `@agent-native.com` with reply-to +agent-native@builder.io and per-app subjects/headings ("Verify your email for +Agent-Native " / "Reset your Agent-Native password"). The +verification email body also includes the app's one-line description (competitor +names reframed as "replacement"); the reset email omits the pitch since it's a +security email. Unknown apps fall back to the generic "Agent Native" branding. + +The branded sender and reply-to are applied only when the configured +EMAIL_FROM is already on agent-native.com, so self-hosted deployments keep +their own verified sender and support mailbox. diff --git a/packages/core/src/server/app-name.ts b/packages/core/src/server/app-name.ts index 6de9da8b3b..e26f407c4d 100644 --- a/packages/core/src/server/app-name.ts +++ b/packages/core/src/server/app-name.ts @@ -51,3 +51,34 @@ export function getAppName(): string | undefined { cachedFromPkg = name ?? undefined; return name; } + +/** + * Resolve the app's slug — the machine-readable identifier used for the + * per-app transactional email sender (e.g. `clips@agent-native.com`). + * + * Only a first-party template name matched by package.json qualifies. A slug + * derived from an arbitrary `APP_NAME` must not mint a mailbox on the + * agent-native.com domain: a custom app would then send as + * `@agent-native.com` and route replies to the Builder mailbox. + * Returns `undefined` for anything unrecognized, which leaves the deployment's + * configured sender in place. + */ +export function getAppSlug(): string | undefined { + const pkg = readPkg(); + if (!pkg?.name) return undefined; + return TEMPLATES.find((t) => t.name === pkg.name)?.name; +} + +/** + * One-line description of the app, used to tailor transactional email bodies + * so recipients recognize which app they signed up for. Sourced from the + * first-party template metadata; `undefined` for unknown apps. + */ +export function getAppDescription(): string | undefined { + const pkg = readPkg(); + if (pkg?.name) { + const tmpl = TEMPLATES.find((t) => t.name === pkg.name); + if (tmpl) return tmpl.hint; + } + return undefined; +} diff --git a/packages/core/src/server/better-auth-instance.ts b/packages/core/src/server/better-auth-instance.ts index eeeee9fdcd..bcd0637c1f 100644 --- a/packages/core/src/server/better-auth-instance.ts +++ b/packages/core/src/server/better-auth-instance.ts @@ -933,11 +933,11 @@ async function createBetterAuthInstance( "" ).replace(/\/$/, ""); const resetUrl = `${appUrl}${appBasePath}/_agent-native/auth/reset?token=${encodeURIComponent(token)}`; - const { subject, html, text } = renderResetPasswordEmail({ + const { subject, html, text, appSender } = renderResetPasswordEmail({ email: user.email, resetUrl, }); - await sendEmail({ to: user.email, subject, html, text }); + await sendEmail({ to: user.email, subject, html, text, appSender }); }, }, emailVerification: { @@ -960,11 +960,11 @@ async function createBetterAuthInstance( const verifyUrl = verifyBasePath ? url.replace(/(\/\/[^/]+)(\/)/, `$1${verifyBasePath}$2`) : url; - const { subject, html, text } = renderVerifySignupEmail({ + const { subject, html, text, appSender } = renderVerifySignupEmail({ email: user.email, verifyUrl, }); - await sendEmail({ to: user.email, subject, html, text }); + await sendEmail({ to: user.email, subject, html, text, appSender }); }, }, socialProviders, diff --git a/packages/core/src/server/email-templates.spec.ts b/packages/core/src/server/email-templates.spec.ts new file mode 100644 index 0000000000..d61ff7355c --- /dev/null +++ b/packages/core/src/server/email-templates.spec.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { renderVerifySignupEmail } from "./email-templates"; + +describe("renderVerifySignupEmail", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("does not mint an agent-native.com mailbox for an unrecognized app", () => { + vi.stubEnv("APP_NAME", "Acme Portal"); + + const rendered = renderVerifySignupEmail({ + email: "reader@example.com", + verifyUrl: "https://example.com/verify?token=abc", + }); + + // No slug means sendEmail keeps the deployment's configured sender rather + // than branding a third-party app onto the first-party domain. + expect(rendered.appSender).toBeUndefined(); + }); + + it("does not present an unrecognized app as an Agent-Native app", () => { + vi.stubEnv("APP_NAME", "Acme Portal"); + + const rendered = renderVerifySignupEmail({ + email: "reader@example.com", + verifyUrl: "https://example.com/verify?token=abc", + }); + + expect(rendered.subject).toBe("Verify your email for Acme Portal"); + expect(rendered.html).not.toContain("Agent-Native Acme Portal"); + }); +}); diff --git a/packages/core/src/server/email-templates.ts b/packages/core/src/server/email-templates.ts index 10fc8b3d56..a71ef71a20 100644 --- a/packages/core/src/server/email-templates.ts +++ b/packages/core/src/server/email-templates.ts @@ -11,13 +11,22 @@ * site — keeps the transactional look-and-feel consistent. */ -import { getAppName } from "./app-name.js"; +import { getAppName, getAppSlug, getAppDescription } from "./app-name.js"; import { renderEmail, emailStrong } from "./email-template.js"; +/** Shared reply-to for the framework's transactional emails. */ +export const AGENT_NATIVE_REPLY_TO = "agent-native@builder.io"; + export interface RenderedEmailMessage { subject: string; html: string; text: string; + /** + * Per-app sender branding, applied by `sendEmail` only on first-party + * agent-native.com deployments. Self-hosted deployments keep the sender and + * reply-to they configured via EMAIL_FROM. + */ + appSender?: { name: string; slug: string; replyTo?: string }; } /** @@ -33,6 +42,17 @@ function resolveAppName(): string { return stripCrlf(getAppName() || "Agent Native"); } +/** + * Recipient-facing brand for auth emails. Only a recognized first-party + * template is presented as "Agent-Native "; a custom deployment keeps its + * own name so its users aren't told they signed up for an Agent Native app. + */ +function resolveBrand(slug: string | undefined): string { + const appName = getAppName(); + if (!appName) return "Agent Native"; + return slug ? `Agent-Native ${stripCrlf(appName)}` : stripCrlf(appName); +} + // --------------------------------------------------------------------------- // Organization invitation // --------------------------------------------------------------------------- @@ -89,28 +109,58 @@ export interface RenderVerifySignupEmailArgs { verifyUrl: string; } +/** + * Customer-facing description overrides for the verification email body. The + * default descriptions come from the app-picker hints, which name competitors + * for internal positioning; these rewrites frame them as "replacement" for a + * customer-facing email without changing the picker copy. + */ +const VERIFY_EMAIL_DESCRIPTIONS: Record = { + calendar: + "Agent-native Google Calendar replacement — manage events, sync, and public booking", + content: + "Open-source Obsidian/Notion replacement for MDX — edit local docs with agent assistance", + slides: + "Agent-native Google Slides replacement — generate and edit React presentations", + analytics: + "Agent-native Amplitude/Mixpanel replacement — connect data sources, prompt for charts", + mail: "Agent-native Superhuman replacement — email client with keyboard shortcuts and AI triage", +}; + export function renderVerifySignupEmail( args: RenderVerifySignupEmailArgs, ): RenderedEmailMessage { const email = stripCrlf(args.email); - const appName = resolveAppName(); + const slug = getAppSlug(); + const brand = resolveBrand(slug); + const description = slug + ? (VERIFY_EMAIL_DESCRIPTIONS[slug] ?? getAppDescription()) + : undefined; + + const paragraphs = [ + `Thanks for signing up for ${emailStrong(brand)}. To finish creating your account, confirm that ${emailStrong(email)} is your email address.`, + ]; + if (description) { + paragraphs.push(`${stripCrlf(description).replace(/\.\s*$/, "")}.`); + } + paragraphs.push(`This link expires in 1 hour.`); const { html, text } = renderEmail({ - brandName: appName, - preheader: `Confirm ${email} to finish setting up your ${appName} account.`, - heading: `Verify your email for ${appName}`, - paragraphs: [ - `Thanks for signing up for ${emailStrong(appName)}. To finish creating your account, confirm that ${emailStrong(email)} is your email address.`, - `This link expires in 1 hour.`, - ], + brandName: brand, + preheader: `Confirm ${email} to finish setting up your ${brand} account.`, + heading: `Verify your email for ${brand}`, + paragraphs, cta: { label: "Verify email", url: args.verifyUrl }, footer: `If you didn't sign up, you can safely ignore this email.`, }); return { - subject: `Verify your email for ${appName}`, + subject: `Verify your email for ${brand}`, html, text, + appSender: slug + ? { name: brand, slug, replyTo: AGENT_NATIVE_REPLY_TO } + : undefined, }; } @@ -129,12 +179,15 @@ export function renderResetPasswordEmail( args: RenderResetPasswordEmailArgs, ): RenderedEmailMessage { const email = stripCrlf(args.email); - const appName = resolveAppName(); + // Match the verification email branding so password resets are clearly tied + // to the specific app. No value pitch here — it's a security email. + const slug = getAppSlug(); + const brand = resolveBrand(slug); const { html, text } = renderEmail({ - brandName: appName, + brandName: brand, preheader: `Reset the password for ${email}. This link expires in 1 hour.`, - heading: `Reset your ${appName} password`, + heading: `Reset your ${brand} password`, paragraphs: [ `Someone requested a password reset for ${emailStrong(email)}. Click the button below to choose a new password.`, `This link expires in 1 hour.`, @@ -144,8 +197,11 @@ export function renderResetPasswordEmail( }); return { - subject: `Reset your ${appName} password`, + subject: `Reset your ${brand} password`, html, text, + appSender: slug + ? { name: brand, slug, replyTo: AGENT_NATIVE_REPLY_TO } + : undefined, }; } diff --git a/packages/core/src/server/email.spec.ts b/packages/core/src/server/email.spec.ts index eca4b1218f..08053f5411 100644 --- a/packages/core/src/server/email.spec.ts +++ b/packages/core/src/server/email.spec.ts @@ -31,6 +31,108 @@ describe("sendEmail", () => { expect(body.reply_to).toEqual({ email: "alex@example.com" }); }); + it("applies per-app sender branding on agent-native.com deployments", async () => { + vi.stubEnv("SENDGRID_API_KEY", "sendgrid-example-key"); + vi.stubEnv("EMAIL_FROM", "Agent Native "); + const fetchMock = vi.fn(async () => new Response(null, { status: 202 })); + vi.stubGlobal("fetch", fetchMock); + + await sendEmail({ + to: "reader@example.com", + subject: "Verify your email", + html: "

hi

", + appSender: { + name: "Agent-Native Clips", + slug: "clips", + replyTo: "agent-native@builder.io", + }, + }); + + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); + expect(body.from).toEqual({ + name: "Agent-Native Clips", + email: "clips@agent-native.com", + }); + expect(body.reply_to).toEqual({ email: "agent-native@builder.io" }); + }); + + it("keeps the branded address intact when APP_NAME contains header specials", async () => { + vi.stubEnv("SENDGRID_API_KEY", "sendgrid-example-key"); + vi.stubEnv("EMAIL_FROM", "Agent Native "); + const fetchMock = vi.fn(async () => new Response(null, { status: 202 })); + vi.stubGlobal("fetch", fetchMock); + + await sendEmail({ + to: "reader@example.com", + subject: "Verify your email", + html: "

hi

", + appSender: { + name: "Agent-Native Acme , Inc.", + slug: "clips", + replyTo: "agent-native@builder.io", + }, + }); + + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); + expect(body.from).toEqual({ + name: "Agent-Native Acme Support , Inc.", + email: "clips@agent-native.com", + }); + }); + + it("keeps a self-hosted verified sender and reply-to untouched", async () => { + vi.stubEnv("SENDGRID_API_KEY", "sendgrid-example-key"); + vi.stubEnv("EMAIL_FROM", "Acme "); + const fetchMock = vi.fn(async () => new Response(null, { status: 202 })); + vi.stubGlobal("fetch", fetchMock); + + await sendEmail({ + to: "reader@example.com", + subject: "Verify your email", + html: "

hi

", + appSender: { + name: "Agent-Native Clips", + slug: "clips", + replyTo: "agent-native@builder.io", + }, + }); + + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); + expect(body.from).toEqual({ name: "Acme", email: "noreply@acme.com" }); + expect(body.reply_to).toBeUndefined(); + }); + + it("warns once without leaking the tenant sender into logs", async () => { + // Fresh module: the suppression notice is process-scoped by design. + vi.resetModules(); + const { sendEmail: freshSendEmail } = await import("./email"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetchMock = vi.fn(async () => new Response(null, { status: 202 })); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("SENDGRID_API_KEY", "sendgrid-example-key"); + + const send = async () => + freshSendEmail({ + to: "reader@example.com", + subject: "Verify your email", + html: "

hi

", + appSender: { name: "Agent-Native Clips", slug: "clips" }, + }); + + vi.stubEnv("EMAIL_FROM", "Tenant "); + await send(); + vi.stubEnv("EMAIL_FROM", "Other "); + await send(); + + expect(warn).toHaveBeenCalledTimes(1); + const logged = String(warn.mock.calls[0]?.[0]); + expect(logged).not.toContain("tenant-one.example"); + expect(logged).not.toContain("tenant-two.example"); + expect(logged).toContain("agent-native.com"); + + warn.mockRestore(); + }); + it("maps inline CID attachments for SendGrid", async () => { vi.stubEnv("SENDGRID_API_KEY", "sendgrid-example-key"); vi.stubEnv("EMAIL_FROM", "Agent Native "); @@ -129,6 +231,28 @@ describe("sendEmail", () => { expect(body.from).not.toContain("\n"); }); + it("carries branded sender and reply-to through the Resend payload", async () => { + vi.stubEnv("RESEND_API_KEY", "resend-example-key"); + vi.stubEnv("EMAIL_FROM", "Agent Native "); + const fetchMock = vi.fn(async () => Response.json({ id: "email_123" })); + vi.stubGlobal("fetch", fetchMock); + + await sendEmail({ + to: "reader@example.com", + subject: "Verify your email", + html: "

hi

", + appSender: { + name: "Agent-Native Clips", + slug: "clips", + replyTo: "agent-native@builder.io", + }, + }); + + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); + expect(body.from).toBe('"Agent-Native Clips" '); + expect(body.reply_to).toBe("agent-native@builder.io"); + }); + it("maps inline CID attachments for Resend", async () => { vi.stubEnv("RESEND_API_KEY", "resend-example-key"); vi.stubEnv("EMAIL_FROM", "Agent Native "); diff --git a/packages/core/src/server/email.ts b/packages/core/src/server/email.ts index 663a0486c4..0a4fe47862 100644 --- a/packages/core/src/server/email.ts +++ b/packages/core/src/server/email.ts @@ -39,6 +39,14 @@ export interface SendEmailArgs { fromName?: string; cc?: string | string[]; replyTo?: string; + /** + * Per-app branding for first-party agent-native.com deployments. Applied + * only when the configured EMAIL_FROM is already on agent-native.com, so a + * self-hosted deployment keeps its own verified sender and support mailbox + * instead of sending as an unverified address a provider would reject. + * An explicit `from` / `replyTo` always wins. + */ + appSender?: { name: string; slug: string; replyTo?: string }; inReplyTo?: string; references?: string; attachments?: EmailAttachment[]; @@ -150,6 +158,56 @@ function withDisplayName(from: string, name: string): string { return `"${safe}" <${address}>`; } +const AGENT_NATIVE_SENDER_DOMAIN = "agent-native.com"; + +/** + * Resolve the per-app sender address, but only for deployments whose + * configured sender is already on agent-native.com. Any other (or missing) + * EMAIL_FROM means we cannot prove the branded address is a verified sender, + * so the deployment's own configuration is left untouched. + */ +let warnedAppSenderSuppressed = false; + +/** + * Suppressing the branding is correct for a sender we cannot prove we own, + * but it must not be invisible, or an operator sees generic senders with + * nothing pointing at why. + * + * EMAIL_FROM resolves per user/org/workspace through the scoped secret store, + * so the resolved address is tenant data and must never reach shared logs, and + * anything keyed by it would grow without bound in a warm worker. The message + * therefore carries no tenant values, which also makes it identical for every + * suppressed config — so emitting it once per process loses nothing. + */ +function warnAppSenderSuppressed(): void { + if (warnedAppSenderSuppressed) return; + warnedAppSenderSuppressed = true; + console.warn( + `[agent-native:email] Per-app sender branding is off because the ` + + `configured EMAIL_FROM is not on ${AGENT_NATIVE_SENDER_DOMAIN}. ` + + `Transactional email keeps the configured sender. Expected when self-hosting.`, + ); +} + +function resolveAppSender( + configuredFrom: string | undefined, + appSender: SendEmailArgs["appSender"], +): { address: string; name: string; replyTo?: string } | undefined { + if (!appSender) return undefined; + const address = configuredFrom + ? parseSendGridFrom(configuredFrom).email.toLowerCase() + : undefined; + if (!address?.endsWith(`@${AGENT_NATIVE_SENDER_DOMAIN}`)) { + warnAppSenderSuppressed(); + return undefined; + } + return { + address: `${appSender.slug}@${AGENT_NATIVE_SENDER_DOMAIN}`, + name: appSender.name, + replyTo: appSender.replyTo, + }; +} + async function sendEmailWithSignal( args: SendEmailArgs, signal?: AbortSignal, @@ -157,7 +215,12 @@ async function sendEmailWithSignal( const config = await resolveEmailTransport(); signal?.throwIfAborted(); const provider = config.provider; - const from = getFromAddress(config, args.from, args.fromName); + const branded = resolveAppSender(config.from, args.appSender); + const from = + branded && !args.from + ? withDisplayName(branded.address, args.fromName ?? branded.name) + : getFromAddress(config, args.from, args.fromName); + const replyTo = args.replyTo ?? branded?.replyTo; const attachments = resolveAttachments(args); if (provider === "resend") { @@ -169,7 +232,7 @@ async function sendEmailWithSignal( text: args.text, }; if (args.cc) payload.cc = Array.isArray(args.cc) ? args.cc : [args.cc]; - if (args.replyTo) payload.reply_to = args.replyTo; + if (replyTo) payload.reply_to = replyTo; if (attachments?.length) { payload.attachments = attachments.map((a) => ({ filename: a.filename, @@ -220,7 +283,7 @@ async function sendEmailWithSignal( { type: "text/html", value: args.html }, ], }; - if (args.replyTo) sgPayload.reply_to = parseSendGridFrom(args.replyTo); + if (replyTo) sgPayload.reply_to = parseSendGridFrom(replyTo); const sgHeaders: Record = {}; if (args.inReplyTo) sgHeaders["In-Reply-To"] = args.inReplyTo; if (args.references) sgHeaders["References"] = args.references;