Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
75e69fe
Brand signup verification emails per app
builderio-bot Jul 27, 2026
1c36599
feat(email): add customer-facing descriptions for verification email
builderio-bot Jul 27, 2026
4fa7577
feat(auth): implement per-app forgot password emails
builderio-bot Jul 27, 2026
7eb25a2
Merge remote-tracking branch 'refs/remotes/origin/main' into ai_main_…
builderio-bot Jul 27, 2026
7039562
fix(email): scope per-app sender branding to agent-native.com deploym…
builderio-bot Jul 27, 2026
afcbf26
chore(email): route transactional reply-to to agent-native@builder.io
builderio-bot Jul 27, 2026
0f7d24a
Merge remote-tracking branch 'refs/remotes/origin/main' into ai_main_…
builderio-bot Jul 28, 2026
e4dc584
fix(email): sanitize branded sender display name
builderio-bot Jul 28, 2026
241c8af
Merge remote-tracking branch 'refs/remotes/origin/main' into ai_main_…
builderio-bot Jul 28, 2026
9b8e98b
Merge remote-tracking branch 'refs/remotes/origin/main' into ai_main_…
builderio-bot Jul 28, 2026
7c4356e
Merge remote-tracking branch 'refs/remotes/origin/main' into ai_main_…
builderio-bot Jul 28, 2026
785c01d
test(email): cover branded sender and reply-to on the Resend path
builderio-bot Jul 28, 2026
c2ec3a1
Merge remote-tracking branch 'refs/remotes/origin/main' into ai_main_…
builderio-bot Jul 29, 2026
6f69e4d
fix(email): surface suppressed per-app sender branding
builderio-bot Jul 29, 2026
76517e3
fix(email): key branding-suppressed warning by sender config
builderio-bot Jul 29, 2026
99d071b
fix(email): keep tenant sender data out of the suppression warning
builderio-bot Jul 29, 2026
4ca1534
Merge remote-tracking branch 'refs/remotes/origin/main' into ai_main_…
builderio-bot Jul 29, 2026
7d80aae
Merge branch 'main' into ai_main_a22984fd7a074c0f90c5
timmilazzo Jul 30, 2026
e03d95b
Merge remote-tracking branch 'refs/remotes/origin/main' into ai_main_…
builderio-bot Jul 31, 2026
703aebb
fix(email): restrict branded sender to first-party templates
builderio-bot Jul 31, 2026
9771881
Merge remote-tracking branch 'refs/remotes/origin/main' into ai_main_…
builderio-bot Jul 31, 2026
f82f39f
fix(email): keep custom app names out of Agent-Native branding
builderio-bot Jul 31, 2026
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
15 changes: 15 additions & 0 deletions .changeset/per-app-verify-email.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@agent-native/core": patch
---

Brand transactional auth emails per app. Signup verification and password
reset emails now send from `<app-slug>@agent-native.com` with reply-to
agent-native@builder.io and per-app subjects/headings ("Verify your email for
Agent-Native <App>" / "Reset your Agent-Native <App> 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.
31 changes: 31 additions & 0 deletions packages/core/src/server/app-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* `<its-name>@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;
}
8 changes: 4 additions & 4 deletions packages/core/src/server/better-auth-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/server/email-templates.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
84 changes: 70 additions & 14 deletions packages/core/src/server/email-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

/**
Expand All @@ -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 <App>"; 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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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<string, string> = {
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
Comment thread
builder-io-integration[bot] marked this conversation as resolved.
? { name: brand, slug, replyTo: AGENT_NATIVE_REPLY_TO }
: undefined,
};
}

Expand All @@ -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.`,
Expand All @@ -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,
};
}
124 changes: 124 additions & 0 deletions packages/core/src/server/email.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <noreply@agent-native.com>");
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: "<p>hi</p>",
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 <noreply@agent-native.com>");
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: "<p>hi</p>",
appSender: {
name: "Agent-Native Acme <Support>, 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 <noreply@acme.com>");
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: "<p>hi</p>",
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: "<p>hi</p>",
appSender: { name: "Agent-Native Clips", slug: "clips" },
});

vi.stubEnv("EMAIL_FROM", "Tenant <ceo@tenant-one.example>");
await send();
vi.stubEnv("EMAIL_FROM", "Other <owner@tenant-two.example>");
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 <reports@example.com>");
Expand Down Expand Up @@ -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 <noreply@agent-native.com>");
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: "<p>hi</p>",
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" <clips@agent-native.com>');
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 <reports@example.com>");
Expand Down
Loading
Loading