Skip to content

Brand transactional auth emails per app - #2439

Open
timmilazzo wants to merge 12 commits into
mainfrom
ai_main_a22984fd7a074c0f90c5
Open

Brand transactional auth emails per app#2439
timmilazzo wants to merge 12 commits into
mainfrom
ai_main_a22984fd7a074c0f90c5

Conversation

@timmilazzo

@timmilazzo timmilazzo commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Signup verification and password reset emails are now branded per app instead of using a generic "Agent Native" sender, subject, and body.

Problem

Every agent-native app sent the same generic "Verify your email for Agent Native" email from noreply@builder.io, with no indication of which specific app the user had signed up for.

Solution

Resolve the app's slug and description from template metadata, then use them to customize the sender address, reply-to, subject, heading, and body copy for both the signup verification and password reset emails. Unknown apps still fall back to the generic "Agent Native" branding and configured EMAIL_FROM sender.

Key Changes

  • Added getAppSlug() and getAppDescription() to app-name.ts, resolving from first-party template metadata (with a slugified fallback for custom APP_NAME overrides).
  • RenderedEmailMessage now includes optional from and replyTo fields, and AGENT_NATIVE_REPLY_TO (hello@agent-native.com) is exported as the shared reply-to.
  • renderVerifySignupEmail builds an "Agent-Native <App>" brand, sets from to <slug>@agent-native.com, and appends a per-app one-line description (competitor names reframed as "replacement") to the email body.
  • renderResetPasswordEmail uses the same per-app branding and sender/reply-to but omits the value-pitch description since it's a security email.
  • better-auth-instance.ts passes the new from/replyTo values through to sendEmail for both verification and reset flows.
  • Added a changeset documenting the per-app email branding patch.

Edit in Builder  Preview


To clone this PR locally use the Github CLI with command gh pr checkout 2439

You can tag me at @BuilderIO for anything you want me to fix or change

@builder-io-integration builder-io-integration Bot changed the title Update from the Builder.io agent Brand transactional auth emails per app Jul 27, 2026
builder-io-integration[bot]

This comment was marked as outdated.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@netlify

This comment has been minimized.

@steve8708

Copy link
Copy Markdown
Contributor

@builderio-bot look at latest PR feedback and fix anything you agree with. Be skeptical. Reply to every comment (directly on the comment thread of each comment) if you fixed it or not and why. then check back every 2 minutes on a loop and see if any new feedback posted, until at least 20 minutes go by without any new feedback posted we want to address, including making sure CI passes too and no merge conflicts (make sure code is mergeable)

…ents

Applying the branded sender unconditionally overrode a self-hosted
deployment's verified EMAIL_FROM, which providers can reject and would break
signup verification and password reset. Route branding through a new
appSender arg that sendEmail only honors when the configured sender is
already on agent-native.com; the same gate covers the reply-to so custom
deployments keep their own support mailbox.

@builder-io-integration builder-io-integration Bot left a comment

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.

Builder reviewed your changes and found 1 potential issue 🟡

Review Details

Incremental Code Review Summary

The latest commit addresses both previously reported custom-deployment issues: branding is now gated on the configured sender domain, and the branded reply-to is only applied alongside that first-party sender. I verified those fixes and resolved both stale review threads.

The new appSender plumbing and SendGrid coverage are sound, including explicit sender/reply-to precedence and preservation of self-hosted configuration. However, the first-party detection gate appears narrower than the deployment described by this PR: it recognizes only @agent-native.com, while the PR's stated existing first-party sender is noreply@builder.io. If that remains the actual production configuration, the feature is silently disabled for the main first-party deployment and auth emails retain generic branding.

Key Finding

  • 🟡 MEDIUM — Include the current verified first-party sender/domain (or use an explicit first-party deployment identity) in the gate and add a regression test for it, while continuing to exclude arbitrary self-hosted domains.

🧪 Browser testing: Skipped — PR only modifies backend authentication email rendering and delivery, with no user-facing browser UI impact.

Comment on lines +147 to +148
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.

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Here's a visual recap of what changed:

Visual recap

Open the full interactive recap

@builder-io-integration

Copy link
Copy Markdown
Contributor

There was a problem with your request, please try again later. Error id: 8f5e4fae1d5243f296f9211ff5f30f5b

@builder-io-integration

Copy link
Copy Markdown
Contributor

@steve8708 Triage complete. Head 7039562, all CI terminal and green, mergeable: MERGEABLE, no conflicts.

Fixed (1 commit, 7039562)

The two original findings were both valid and shared one root cause: getFromAddress() resolves override || config.from, so the per-app from I added unconditionally beat a deployment-configured EMAIL_FROM.

  • 🔴 Sender override — a self-hosted deployment that verified its own domain would have sent verification and reset mail as an unverified <slug>@agent-native.com and been rejected by the provider, breaking signup outright.
  • 🟡 Reply-to — the hard-coded hello@agent-native.com routed replies away from the deploying teams support mailbox.

Templates no longer emit a raw from/replyTo. They emit appSender: { name, slug, replyTo }, and sendEmail only converts that to a real sender when the configured EMAIL_FROM is already on agent-native.com (resolveAppSender). Everything else is left untouched, and an explicit from/replyTo still wins. Two regression tests added in email.spec.ts (branded on agent-native.com / untouched on a self-hosted domain); file passes 6/6 and tsc --noEmit is clean.

Not fixed (1)

  • 🟡 "first-party sender is noreply@builder.io" — the premise does not hold. That string comes from the PR descriptions paraphrase of the reported symptom, not from any configuration: it appears nowhere in the repo, EMAIL_FROM is a per-deployment secret rather than a repo constant, and the value configured for this workspace is Agent Native <noreply@agent-native.com>, under which the branding does apply. Widening the allowlist to builder.io on a guess would mean sending as agent-native.com from an account that may only have verified builder.io — reintroducing the 🔴 delivery failure above. Full reasoning in the thread.

Needs a human

  • One config fact I cannot verify from the repo: if any production deployment really does set EMAIL_FROM on builder.io, branding silently no-ops there. That is a one-line change to AGENT_NATIVE_SENDER_DOMAIN once someone confirms the actual sender and that agent-native.com is verified on that provider account.
  • mergeStateStatus: BLOCKED is purely REVIEW_REQUIRED (branch protection wants 1 approval). Leaving that and the open thread for a human reviewer — not resolving or merging.

builder-io-integration[bot]

This comment was marked as outdated.

@steve8708

Copy link
Copy Markdown
Contributor

@builderio-bot look at latest PR feedback and fix anything you agree with. Be skeptical. Reply to every comment (directly on the comment thread of each comment) if you fixed it or not and why. then check back every 2 minutes on a loop and see if any new feedback posted, until at least 20 minutes go by without any new feedback posted we want to address, including making sure CI passes too and no merge conflicts (make sure code is mergeable)

APP_NAME flows into the branded From display name, and angle brackets there
made parseSendGridFrom read the address as part of the name, producing a
malformed sender the provider rejects. Strip header-significant characters
before interpolating, and fall back to the bare address when nothing is left.
builder-io-integration[bot]

This comment was marked as outdated.

@builder-io-integration

Copy link
Copy Markdown
Contributor

@steve8708 Second triage pass complete. Head e4dc584, all CI terminal and green (56 success / 27 neutral / 3 skipped, 0 failures), mergeable: MERGEABLE, no conflicts. Quiet 14:26Z → 15:01Z (35 min).

Fixed since last pass (e4dc584)

  • 🟡 Unencoded branded sender display name — valid, and I reproduced it before touching anything. APP_NAME feeds the From display name, and parseSendGridFroms lazy prefix matches the first <, so Agent-Native Acme <Support> <clips@agent-native.com> parsed to email: "Support> <clips@agent-native.com" — a malformed sender the provider rejects. stripCrlf only covered CR/LF. Added sanitizeSenderDisplayName at the single interpolation point in resolveAppSender (strips <>(),;:\" + CR/LF, collapses whitespace, falls back to the bare address if nothing survives), plus a regression test. 7/7 passing, tsc --noEmit clean.

I scoped that as a deliverability bug rather than a security one — APP_NAME is operator config, not attacker-controlled — and separately confirmed the same value cannot break the HTML body, since renderEmail/emailStrong run escapeHtml on every sink. The From header was the only unescaped one.

Also in this branch

AGENT_NATIVE_REPLY_TO is now agent-native@builder.io per @tims request. Note this is unrelated to the still-open sender-domain thread: the branding gate is keyed on EMAIL_FROM being on agent-native.com, and only the reply-to moved to builder.io. Senders remain <app-slug>@agent-native.com.

Still open, needs a human

  • The earlier 🟡 "first-party sender is noreply@builder.io" thread remains rejected on evidence (that string exists nowhere in the repo; EMAIL_FROM is a per-deployment secret, and this workspaces value is on agent-native.com). If someone confirms a production deployment actually sets EMAIL_FROM on builder.io and that agent-native.com is verified on that provider account, it is a one-line change to AGENT_NATIVE_SENDER_DOMAIN.
  • mergeStateStatus: BLOCKED is purely REVIEW_REQUIRED (1 approval). Not approving, merging, or resolving threads.

Full ledger: 4 review threads — 3 fixed and replied, 1 rejected with evidence and replied. No unanswered feedback.

@builder-io-integration

Copy link
Copy Markdown
Contributor

Babysitting complete on PR #2439.

Final state — head e4dc584, all CI terminal and green (56 success / 27 neutral / 3 skipped, 0 failures), mergeable: MERGEABLE, no conflicts. Quiet 14:26Z → 15:01Z (35 min, well past the 20-minute bar).

Fixed this pass (e4dc584)

One new finding, and it was real — I reproduced it before changing anything. APP_NAME flows into the sender display name, and parseSendGridFrom's lazy prefix matches the first <:

"Agent-Native Acme <Support> <clips@agent-native.com>"
  => email: "Support> <clips@agent-native.com"   ← malformed, provider rejects

Fixed with sanitizeSenderDisplayName at the single interpolation point in resolveAppSender, so it protects every appSender caller rather than just the two templates. Added a regression test — 7/7 passing, typecheck clean.

Two judgment calls worth surfacing:

  • I scoped it as a deliverability bug, not security — APP_NAME is operator config, not attacker-controlled input.
  • I checked whether the same value could break the HTML body (it also feeds brandName/heading/paragraphs). It can't — renderEmail and emailStrong escape every sink. The From header was the only unescaped one.

Cumulative ledger — 4 review threads: 3 fixed and replied, 1 rejected with evidence and replied. No feedback left unanswered.

Two things I did not do, deliberately:

  1. The earlier "production sender is noreply@builder.io" claim stays rejected — that string appears nowhere in the repo, and EMAIL_FROM is a per-deployment secret whose value here is on agent-native.com. Widening the domain allowlist on that guess would reintroduce the 🔴 delivery failure this PR just fixed. If someone confirms the real production sender, it's a one-line change.
  2. Merge is BLOCKED solely by REVIEW_REQUIRED (1 approval). I did not approve, merge, or resolve any threads — those are reviewer-owned.

Note the reply-to change you asked for (agent-native@builder.io) is independent of that open thread: only the reply-to moved to builder.io; senders remain <app-slug>@agent-native.com.



🟡 PR is green, mergeable, and fully triaged at e4dc584; blocked only on a human approval and one open thread awaiting a config confirmation.

@steve8708

Copy link
Copy Markdown
Contributor

@builderio-bot resolve hte merge conflict plz

…a22984fd7a074c0f90c5

# Conflicts:
#	packages/core/src/server/email.ts
@builder-io-integration

Copy link
Copy Markdown
Contributor

Conflict resolved and pushed as 241c8af. mergeable: MERGEABLE — no conflicts remain.

What the conflict actually was

Not a trivial textual clash. While this PR was open, main independently shipped a solution to the same problem I'd just fixed — the sender display name. Main added fromName + withDisplayName(), which swaps the display name while keeping the verified address, sanitizing and quoting it.

That's a better-scoped fix than mine, so I preserved main's version and deleted my sanitizeSenderDisplayName rather than keeping two competing sanitizers.

How I resolved it

Took main's side wholesale, then re-added only the piece main doesn't cover — the per-app address swap (clips@agent-native.com), which fromName alone can't do:

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);

resolveAppSender now returns a bare address and lets main's withDisplayName do all naming, so there's one sanitizer, not two. The args.fromName ?? branded.name ordering matters — routing the branded address through getFromAddress as an override would have silently discarded an explicit fromName, since main's function early-returns on override.

Verification: diff of the resolved file against main's version shows only my additions — nothing from main was lost (withDisplayName, defaultFromAddress, the 3-arg getFromAddress, and fromName all intact). Typecheck clean; 144 tests pass across email, credential-provider, and core-routes-plugin specs, including main's 8 new email tests.

One test expectation changed: my malformed-name regression test now asserts main's quoted output (Agent-Native Acme Support , Inc.) instead of my stripped version. The assertion that matters — email: "clips@agent-native.com" — still passes, confirming the address swap survived and the malformed-sender bug stays fixed.

CI is re-running on the new head. Merge is still BLOCKED solely by REVIEW_REQUIRED (1 approval) — I haven't approved or merged.



🟢 Merge conflict resolved preserving main's fromName work; branch is MERGEABLE at 241c8af with typecheck and 144 tests green.

builder-io-integration[bot]

This comment was marked as outdated.

@steve8708

Copy link
Copy Markdown
Contributor

@builderio-bot wdyt of the latest feedback? also resolve the merge conflict

…a22984fd7a074c0f90c5

# Conflicts:
#	packages/core/src/server/email.spec.ts
@builder-io-integration

Copy link
Copy Markdown
Contributor

@steve8708 Second conflict resolved (9b8e98b) and my read on the latest review below.

The merge conflict

main merged #2399 (org logo / treated links / first-view email), which added its own test at the same position as mine in email.spec.ts — an overlapping insert, not a semantic clash. Kept both sides: mains overrides only the verified sender display name and maps Reply-To plus my three appSender cases. Verified by diffing test names against main that nothing was dropped — 12 tests, all passing.

email.ts auto-merged cleanly; confirmed both mains fromName/withDisplayName and my appSender/resolveAppSender survived intact.

One thing worth flagging separately: the merge pulled in a new @builder.io/ai-utils dependency that broke tsc in builder-design-systems.ts (unrelated to this PR). pnpm install fixed it; typecheck is clean and the lockfile needed no changes beyond the merge.

The latest review

Its a pass. Quoting the relevant part: it says the refactor "fixes the previously reported display-name parsing issue without regressing fromName precedence, reply-to handling, or SendGrid/Resend payload formatting" and that no new actionable issues were found.

The only item it still lists is the sender-domain one it has now raised three times, and I still disagree with it for the same reason: it rests on noreply@builder.io being the production sender, which is a paraphrase of the symptom from the PR description, not a configured value anywhere in the repo.

Id actually go further now — theres positive evidence the current gate is correct. This workspace has SENDGRID_API_KEY set and EMAIL_FROM="Agent Native <noreply@agent-native.com>", i.e. it is already sending from agent-native.com through SendGrid today. SendGrid rejects unverified senders outright, so agent-native.com is demonstrably a verified sending domain here — which is exactly the condition resolveAppSender gates on. Branding applies, and the feature is not a no-op.

So Im leaving it as-is. If you or @tim know of a deployment that sets EMAIL_FROM on builder.io, say so and its a one-line addition — but Id rather not widen the allowlist on a guess and risk the delivery failure the original 🔴 comment correctly caught.

CI is re-running on 9b8e98b; mergeable: MERGEABLE, no conflicts. Still BLOCKED only on the required approval.

builder-io-integration[bot]

This comment was marked as outdated.

@builder-io-integration

Copy link
Copy Markdown
Contributor

Both done. CI is fully terminal and green on 9b8e98b (41 success / 3 neutral / 3 skipped, 0 failures), mergeable: MERGEABLE.

The merge conflict

main merged #2399 (org logo / treated links / first-view email), which added a test at the same position as mine in email.spec.ts — an overlapping insert, not a semantic clash. I kept both sides and verified by diffing test names against main that nothing was dropped. 12 tests, all passing.

email.ts auto-merged cleanly; I confirmed both main's fromName/withDisplayName and my appSender/resolveAppSender survived intact.

One thing that wasn't obvious: the merge pulled in a new @builder.io/ai-utils dependency that broke tsc in builder-design-systems.ts — unrelated to this PR, just a missing install. pnpm install cleared it; the lockfile needed no changes beyond the merge.

What I think of the latest feedback

It's a pass. The review states the refactor "fixes the previously reported display-name parsing issue without regressing fromName precedence, reply-to handling, or SendGrid/Resend payload formatting," and found no new actionable issues.

The only item it still lists is the sender-domain one, now raised three times. I still disagree, and I found evidence that strengthens the case rather than weakens it:

This workspace has SENDGRID_API_KEY set and EMAIL_FROM="Agent Native <noreply@agent-native.com>" — it is already sending from agent-native.com through SendGrid today. SendGrid rejects unverified senders outright, so agent-native.com is demonstrably verified here. That's exactly the condition resolveAppSender gates on, so branding applies and the feature is not a no-op.

The bot's premise (noreply@builder.io is the production sender) is a paraphrase of the symptom from the PR description, not a configured value that exists anywhere in the repo. I'd rather not widen the allowlist on a guess and risk the delivery failure its own original 🔴 comment correctly caught.

Still needs you: if you or Tim know of a deployment that actually sets EMAIL_FROM on builder.io, that's a one-line change. Otherwise this is settled. Merge remains BLOCKED only on the required approval — I haven't approved, merged, or resolved threads.



🟡 Conflict resolved and CI green at 9b8e98b; latest review is clean, with one disputed item awaiting your call on the production sender domain.

@netlify

This comment has been minimized.

@steve8708

Copy link
Copy Markdown
Contributor

@builderio-bot look at latest PR feedback and fix anything you agree with. Be skeptical. Reply to every comment (directly on the comment thread of each comment) if you fixed it or not and why. then check back every 2 minutes on a loop and see if any new feedback posted, until at least 20 minutes go by without any new feedback posted we want to address, including making sure CI passes too and no merge conflicts (make sure code is mergeable)

The Resend branch serializes from/reply_to differently from SendGrid, and its
reply_to line was only exercised indirectly.
@builder-io-integration

Copy link
Copy Markdown
Contributor

Replying to the latest review (4801456062) here since it was a top-level review body with no inline threads.

Accepted: missing Resend appSender coverage — fixed in 785c01d.

I agreed with this one after checking rather than on the reviewers word. The from/replyTo resolution happens before the provider branch, so the appSender logic itself was already covered by the SendGrid tests. But the two branches serialize differently, and if (replyTo) payload.reply_to = replyTo; on the Resend side is a line this PR actually changed and nothing exercised it. That is a real gap, small as it is.

Added carries branded sender and reply-to through the Resend payload. It also pins down a genuine behavioral difference worth having in writing: Resend receives the raw string "Agent-Native Clips" <clips@agent-native.com>, whereas SendGrid receives { name, email } after parseSendGridFrom. 13/13 passing, tsc --noEmit clean.

Everything else in the review is a pass — structured sender path, provider handling, reply-to precedence, and auth integration all confirmed correct, no new reportable bugs.

Sender-domain item stays rejected, unchanged from my previous reply. Short version: it rests on noreply@builder.io being the production sender, which appears nowhere in the repo, and this workspace demonstrably sends from agent-native.com via SendGrid today — which is exactly what resolveAppSender gates on. Detail in the thread and in my earlier comment.

Branch is also up to date with main again (clean auto-merge this time, no conflicts). CI re-running on 785c01d.

@builder-io-integration

Copy link
Copy Markdown
Contributor

There was a problem with your request, please try again later. Error id: a460a97b38da42209cfe41a31c9eb7a9

@builder-io-integration builder-io-integration Bot left a comment

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.

Builder reviewed your changes and has a few items to flag 🟡

Review Details

Incremental Code Review Summary

The latest commit adds the missing Resend appSender regression coverage. I reviewed the updated Resend and SendGrid payloads, explicit from/fromName/replyTo precedence, Better Auth call sites, and deployment fallback behavior. The implementation remains consistent across providers, and validation reports 13/13 focused email tests passing, typecheck passing, and no diff whitespace errors.

The existing first-party sender-domain issue remains open and unchanged, so it was intentionally not reposted. No new actionable issues were found in this increment.

🧪 Browser testing: Skipped — PR only modifies backend authentication email rendering and delivery, with no user-facing browser UI impact.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants