Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
hello@agent-native.com 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.
38 changes: 38 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,41 @@ 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`).
*
* Prefer the first-party template name matched by package.json (safe even on
* serverless runtimes where `process.cwd()` may expose a bogus name). Fall
* back to slugifying the resolved display name so custom `APP_NAME` overrides
* still produce a usable local-part. Returns `undefined` when nothing resolves.
*/
export function getAppSlug(): string | undefined {
const pkg = readPkg();
if (pkg?.name) {
const tmpl = TEMPLATES.find((t) => t.name === pkg.name);
if (tmpl) return tmpl.name;
}
const name = getAppName();
if (!name) return undefined;
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || undefined;
}

/**
* 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 @@ -906,11 +906,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 @@ -933,11 +933,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
78 changes: 64 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 = "hello@agent-native.com";

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 Down Expand Up @@ -89,28 +98,62 @@ 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 appName = getAppName();
// Brand each app's verification email as "Agent-Native <App>" so recipients
// can tell which app they signed up for. Fall back to the generic name when
// the app can't be resolved (unknown/serverless runtime).
const brand = appName ? `Agent-Native ${stripCrlf(appName)}` : "Agent Native";
const slug = appName ? getAppSlug() : undefined;
const description = appName
? ((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 +172,16 @@ export function renderResetPasswordEmail(
args: RenderResetPasswordEmailArgs,
): RenderedEmailMessage {
const email = stripCrlf(args.email);
const appName = resolveAppName();
const appName = getAppName();
// 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 brand = appName ? `Agent-Native ${stripCrlf(appName)}` : "Agent Native";
const slug = appName ? getAppSlug() : undefined;

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 +191,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,
};
}
47 changes: 47 additions & 0 deletions packages/core/src/server/email.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,53 @@ describe("sendEmail", () => {
vi.unstubAllGlobals();
});

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: "hello@agent-native.com",
},
});

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: "hello@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: "hello@agent-native.com",
},
});

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("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
37 changes: 34 additions & 3 deletions packages/core/src/server/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ export interface SendEmailArgs {
from?: 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[];
Expand Down Expand Up @@ -123,14 +131,37 @@ function getFromAddress(
return "Agent Native <onboarding@resend.dev>";
}

const AGENT_NATIVE_SENDER_DOMAIN = "agent-native.com";

/**
* Resolve per-app sender branding, 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.
*/
function resolveAppSender(
configuredFrom: string | undefined,
appSender: SendEmailArgs["appSender"],
): { from: string; replyTo?: string } | undefined {
if (!configuredFrom || !appSender) return undefined;
const address = parseSendGridFrom(configuredFrom).email.toLowerCase();
if (!address.endsWith(`@${AGENT_NATIVE_SENDER_DOMAIN}`)) return undefined;

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.

🟡 Recognize the current first-party sender configuration

resolveAppSender only enables branding when EMAIL_FROM ends in @agent-native.com, but this PR identifies the existing first-party sender as noreply@builder.io. With that production configuration, branded remains undefined and both Better Auth callbacks continue sending the generic configured sender, so the feature is disabled for the first-party deployment it targets. Use an explicit first-party deployment identity or include the currently verified first-party domain, and add coverage for that configuration while still excluding arbitrary self-hosted domains.

Additional Info
New issue found by 1 of 3 incremental review agents; previous sender/reply-to issues were verified fixed and their threads resolved.

Fix in Builder

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.

Not changing this — the premise does not hold up, though the failure mode you describe is a real one worth stating explicitly.

noreply@builder.io comes from the PR description, which is a paraphrase of the reported symptom, not a configured value. It is not the sender configuration anywhere in this repo:

  • grep -rn "noreply@builder\.io" across the repo returns nothing. The only @builder.io strings are unrelated fixtures in templates/mail/server/lib/sender-identity.spec.ts.
  • EMAIL_FROM is a per-deployment secret (synced via scripts/sync-template-netlify-env.ts), not a repo constant, so the PR cannot "identify" it.
  • The value actually configured for this workspace is Agent Native <noreply@agent-native.com>, which ends in @agent-native.com. Under that config resolveAppSender returns branding and both callbacks send as <slug>@agent-native.com — the intended behavior, not a no-op.

On the residual risk: you are right that if some deployment sets EMAIL_FROM to a non-agent-native.com address, branding silently does not apply there. I deliberately did not widen the allowlist on a guess. Adding builder.io would mean sending as <slug>@agent-native.com from an account that may only have verified builder.io — reintroducing exactly the unverified-sender rejection that the 🔴 comment on this PR just flagged. Trading a cosmetic no-op for a hard delivery failure is the wrong direction.

If someone confirms the production EMAIL_FROM really is on builder.io and that agent-native.com is a verified sending domain on that provider account, this becomes a one-line addition to AGENT_NATIVE_SENDER_DOMAIN. That is a config fact I cannot verify from the repo, so I have flagged it for @steve8708 / @tim rather than guessing.

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.

@steve8708 asked me to settle this rather than repeat myself, so here is a split verdict: I partly agree, and I fixed the part I agree with in 6f69e4d.

What I still reject: widening the domain gate.

The stated premise is that the first-party sender is noreply@builder.io. That is not a configured value — it is a paraphrase of the reported symptom from the PR description. grep -rn "noreply@builder\.io" returns nothing but unrelated fixtures in templates/mail/.../sender-identity.spec.ts, and EMAIL_FROM is a per-deployment secret, so no PR can "identify" it.

There is also positive evidence the gate is already correct: this workspace has SENDGRID_API_KEY set with EMAIL_FROM="Agent Native <noreply@agent-native.com>" and is sending today. SendGrid rejects unverified senders outright, so agent-native.com is demonstrably a verified sending domain here — exactly the condition resolveAppSender checks. Branding applies; it is not a no-op.

I also considered the obvious alternative of keeping the configured domain and swapping only the local-part (noreply@acme.comclips@acme.com). I rejected it: SendGrid single sender verification authorizes one exact address, not the domain, so that would break delivery for precisely the self-hosted deployments the 🔴 comment on this PR was raised to protect. Trading a cosmetic no-op for a hard delivery failure is the wrong direction.

What I agree with: the suppression should not be silent.

Strip away the incorrect premise and there is a real defect underneath. When the gate does not match, branding vanishes with no signal at all — an operator sees generic senders and has nothing pointing at why. That is the failure mode AGENTS.md calls out directly: a guard that returns a value callers cannot distinguish from success is a bug, and a loud failure beats a plausible-looking normal state.

Fixed by making it observable instead of invisible. resolveAppSender now warns once when appSender is supplied but suppressed, naming the configured address and the domain it needed:

[agent-native:email] Per-app sender branding is off because EMAIL_FROM
(noreply@acme.com) is not on agent-native.com. Transactional email keeps the
configured sender. Expected when self-hosting.

Warn-once so a high-volume signup flow cannot spam logs, and it fires for a missing EMAIL_FROM too. Behavior is otherwise unchanged — still no delivery risk. 13/13 email tests passing, tsc --noEmit clean.

If someone confirms a real deployment sets EMAIL_FROM on builder.io and that agent-native.com is verified on that provider account, the remaining change is one entry in the domain list. Until then that would be a guess, and the warning now makes the condition self-diagnosing rather than silent.

return {
from: `${appSender.name} <${appSender.slug}@${AGENT_NATIVE_SENDER_DOMAIN}>`,
Comment thread
builder-io-integration[bot] marked this conversation as resolved.
Outdated
replyTo: appSender.replyTo,
};
}

async function sendEmailWithSignal(
args: SendEmailArgs,
signal?: AbortSignal,
): Promise<void> {
const config = await resolveEmailTransport();
signal?.throwIfAborted();
const provider = config.provider;
const from = getFromAddress(config, args.from);
const branded = resolveAppSender(config.from, args.appSender);
const from = getFromAddress(config, args.from ?? branded?.from);
const replyTo = args.replyTo ?? branded?.replyTo;
const attachments = resolveAttachments(args);

if (provider === "resend") {
Expand All @@ -142,7 +173,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,
Expand Down Expand Up @@ -193,7 +224,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<string, string> = {};
if (args.inReplyTo) sgHeaders["In-Reply-To"] = args.inReplyTo;
if (args.references) sgHeaders["References"] = args.references;
Expand Down
Loading