Skip to content

feat(preview): in-browser email previews#93

Merged
aspiers merged 9 commits intomainfrom
feat/email-preview-endpoint
Apr 20, 2026
Merged

feat(preview): in-browser email previews#93
aspiers merged 9 commits intomainfrom
feat/email-preview-endpoint

Conversation

@aspiers
Copy link
Copy Markdown
Contributor

@aspiers aspiers commented Apr 20, 2026

Summary

  • Add /preview/emails/{new-user,returning-user,recovery} to the auth service — rendering the exact HTML the real sender would deliver, inside a sandboxed iframe so email CSS can't bleed into the outer preview page
  • Extract the three transactional email templates (welcome / sign-in OTP / backup-email verification) into a pure templates.ts so the preview routes and EmailSender share one source of truth
  • Refactor: extract the client-branded email pipeline (metadata resolve → template fetch → Mustache render) from sender.ts into email/client-template.ts#buildClientBrandedEmail, so the preview routes render bit-for-bit what production sends. Preview's ?client_id=<URL> now honours the real trusted-clients gate, falling back to the default template if the client is untrusted, has no email_template_uri, or the fetch fails.
  • Demo client branding: the bundled demo client now advertises email_template_uri (pointing at /email-template.html on its own origin) and email_subject_template, so operators running the demo as a trusted client see a coherent login + email experience out of the box. The template is inline-styled HTML (no <style> block) that follows the demo's EPDS_CLIENT_THEME palette.
  • Gated by the existing AUTH_PREVIEW_ROUTES=1 flag; no new env vars on auth-service; linked from the /preview index via three new AUTH_PREVIEW_ROUTES entries

Relationship to #95

#95 (merged) closed the bug that untrusted clients' email_template_uri was being honoured. This PR is rebased on top of #95; the new buildClientBrandedEmail helper takes the same trustedClients list and enforces the gate in both production and preview paths through one code path.

Test plan

  • pnpm format:check clean
  • pnpm lint clean
  • pnpm typecheck clean
  • pnpm test — 457/457 tests pass, including 5 builder tests in email-templates.test.ts and 4 new integration tests in preview-emails.test.ts covering the ?client_id branded path (trusted returning-user, trusted new-user with {{#is_new_user}} conditional, untrusted fallback, trusted-without-template fallback)
  • Coverage: preview-emails.ts 100% stmts, client-template.ts 63.88% stmts, sender.ts up to 86.66% after refactor
  • With AUTH_PREVIEW_ROUTES=1 set, hit /preview/emails/new-user, /preview/emails/returning-user, /preview/emails/recovery in a browser and visually check the rendered iframe + headers
  • With AUTH_PREVIEW_ROUTES unset, confirm all three routes return 404 like the rest of /preview/*
  • ?otp=<code>, ?app=<name>, ?verify_url=<url> overrides render as expected
  • With the demo on PDS_OAUTH_TRUSTED_CLIENTS, hit /preview/emails/returning-user?client_id=<demo-base-url>/client-metadata.json and confirm the branded template renders with demo theme colours
  • Flip EPDS_CLIENT_THEME (e.g. ocean) and confirm the branded email preview picks up the new palette

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • In-browser preview pages for transactional emails (new-user, returning-user, recovery) — render production-equivalent HTML in a sandboxed iframe and respect the preview flag.
    • Support for client-provided branded email templates and subject templating; demo client now ships a themed OTP template and advertises template/subject URLs.
  • Documentation

    • Changesets and release notes updated describing preview routes, demo client branding, and template behavior.
  • Tests

    • Added unit/integration tests for template builders, branded rendering, preview endpoints, and header-injection hardening; updated e2e feature expectations for branded subjects.

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 20, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
epds-demo Ready Ready Preview, Comment Apr 20, 2026 8:21pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 20, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds sandboxed in-browser previews for three transactional auth emails, extracts email rendering into pure builders, implements client-branded template fetching/rendering with cache and safety checks, refactors the sender to use builders/branded templates, updates demo client metadata/template, and adds unit/integration tests and changesets.

Changes

Cohort / File(s) Summary
Email template builders & tests
packages/auth-service/src/email/templates.ts, packages/auth-service/src/__tests__/email-templates.test.ts
New pure builders (buildSignInCodeEmail, buildWelcomeCodeEmail, buildBackupEmailVerificationEmail) returning { subject, text, html }; unit tests validate subjects, text/html, OTP formatting, and HTML-escaping.
Client-branded template handling
packages/auth-service/src/email/client-template.ts
New module implementing safe fetch, allowlist/validation, in-memory TTL cache, template rendering (Mustache-like conditionals and escaped HTML placeholders), subject rendering, and buildClientBrandedEmail; exports test helpers to seed/clear cache.
Email sender refactor
packages/auth-service/src/email/sender.ts
Delegates subject/text/html generation to template builders and buildClientBrandedEmail; removes inline assembly and local template-fetch/render infra; re-exports template-cache test helpers.
Preview routes + integration tests
packages/auth-service/src/routes/preview-emails.ts, packages/auth-service/src/__tests__/preview-emails.test.ts, packages/auth-service/src/index.ts
Adds createPreviewEmailsRouter(ctx) mounted under /preview/emails, gated by AUTH_PREVIEW_ROUTES==='1'; renders escaped headers, plaintext, and email HTML inside a sandboxed iframe srcdoc; responses set Content-Type: text/html; charset=utf-8 and Cache-Control: no-store; integration tests cover gating, headers, branded/fallback behavior, and iframe content.
Preview UI index update
packages/shared/src/preview-ui.ts
Adds three AUTH_PREVIEW_ROUTES entries so /preview/emails/* links appear in the preview index.
Demo client template & metadata
packages/demo/src/app/email-template.html/route.ts, packages/demo/src/app/client-metadata.json/route.ts
Demo serves email-template.html (Mustache-like HTML with inline styles, CORS, 300s cache) and advertises email_template_uri and email_subject_template in client metadata.
Changelogs & features/e2e tweaks
.changeset/in-browser-email-previews.md, .changeset/demo-client-email-branding.md, features/*.feature, e2e/step-definitions/email.steps.ts
Added changesets documenting previews and demo branding; updated feature/e2e expectations to match demo-branded subject format; removed a stray step comment.
Tests: sender header-hardening
packages/auth-service/src/__tests__/email-template.test.ts
Added test confirming CR/LF are stripped from subject/from fields when using client-provided subject/from values.

Sequence Diagram(s)

sequenceDiagram
    participant Browser
    participant AuthService
    participant DemoClient
    participant TemplateCache

    Browser->>AuthService: GET /preview/emails/returning-user?client_id=<meta_url>&otp=123456
    AuthService->>AuthService: check AUTH_PREVIEW_ROUTES === '1'
    AuthService->>AuthService: parse query (otp, client_id, app, verify_url)
    AuthService->>TemplateCache: lookup cached client metadata/template
    alt cached
        TemplateCache-->>AuthService: return cached template/subject
    else not cached
        AuthService->>DemoClient: fetch client metadata (meta_url)
        DemoClient-->>AuthService: metadata (includes email_template_uri, subject template)
        AuthService->>TemplateCache: fetchTemplate(email_template_uri) via safe fetch
        TemplateCache-->>AuthService: fetched template HTML or null
    end
    AuthService->>AuthService: renderTemplate / renderSubjectTemplate with vars (code, app_name, logo_uri, is_new_user, email)
    AuthService-->>Browser: 200 text/html (preview page with escaped headers + iframe srcdoc containing rendered HTML)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • Kzoeps

Poem

🐇
I hopped through bytes and polished prose,
Sandboxed letters where the iframe grows,
Branded hops and codes in tidy rows,
Pure builders hum as the preview shows,
A rabbit cheers — secure mail freshly sown! 🥕📧

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(preview): in-browser email previews' accurately summarizes the primary change—adding preview routes to render transactional emails in-browser. It is concise, specific, and directly reflects the main objective.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/email-preview-endpoint

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@railway-app
Copy link
Copy Markdown

railway-app Bot commented Apr 20, 2026

🚅 Deployed to the ePDS-pr-93 environment in ePDS

Service Status Web Updated (UTC)
@certified-app/pds-core ✅ Success (View Logs) Web Apr 20, 2026 at 8:22 pm
@certified-app/demo untrusted ✅ Success (View Logs) Web Apr 20, 2026 at 8:22 pm
@certified-app/demo ✅ Success (View Logs) Web Apr 20, 2026 at 8:22 pm
@certified-app/auth-service ✅ Success (View Logs) Web Apr 20, 2026 at 8:21 pm

@coveralls-official
Copy link
Copy Markdown

coveralls-official Bot commented Apr 20, 2026

Coverage Report for CI Build 24688371860

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage increased (+1.8%) to 38.629%

Details

  • Coverage increased (+1.8%) from the base build.
  • Patch coverage: 39 uncovered changes across 3 files (114 of 153 lines covered, 74.51%).
  • 103 coverage regressions across 1 file.

Uncovered Changes

File Changed Covered %
packages/auth-service/src/email/client-template.ts 72 45 62.5%
packages/demo/src/app/email-template.html/route.ts 11 0 0.0%
packages/auth-service/src/index.ts 1 0 0.0%

Coverage Regressions

103 previously-covered lines in 1 file lost coverage.

File Lines Losing Coverage Coverage
packages/auth-service/src/routes/account-settings.ts 103 0.0%

Coverage Stats

Coverage Status
Relevant Lines: 2123
Covered Lines: 852
Line Coverage: 40.13%
Relevant Branches: 1232
Covered Branches: 444
Branch Coverage: 36.04%
Branches in Coverage %: Yes
Coverage Strength: 3.37 hits per line

💛 - Coveralls

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/auth-service/src/routes/preview-emails.ts`:
- Around line 16-22: The preview handlers in preview-emails.ts advertise
supporting ?client_id but never read it, so branded templates (clients with
email_template_uri) aren't used; update the preview endpoints to reuse the same
branded-template rendering path used by sendOtpCode (the custom-template branch)
— e.g., lookup the client by client_id, check trusted-client gating and
email_template_uri, and call the branded/template renderer instead of the
default builders; alternatively remove the ?client_id mention from the JSDoc and
release note if you prefer not to implement branded previews. Ensure you update
the functions referenced in preview-emails.ts to pass through the same
client/template selection logic used by sendOtpCode and related email builder
functions so previews match real-sender output.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3b82ac00-8231-4c3b-a2f9-a2d2e1b72021

📥 Commits

Reviewing files that changed from the base of the PR and between 1a4be35 and a8f364d.

📒 Files selected for processing (7)
  • .changeset/in-browser-email-previews.md
  • packages/auth-service/src/__tests__/email-templates.test.ts
  • packages/auth-service/src/email/sender.ts
  • packages/auth-service/src/email/templates.ts
  • packages/auth-service/src/index.ts
  • packages/auth-service/src/routes/preview-emails.ts
  • packages/shared/src/preview-ui.ts

Comment thread packages/auth-service/src/routes/preview-emails.ts Outdated
Comment thread packages/auth-service/src/routes/preview-emails.ts
Comment thread packages/auth-service/src/routes/preview-emails.ts
Comment thread packages/auth-service/src/email/templates.ts
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/auth-service/src/email/client-template.ts (1)

127-136: Optional: make renderSubjectTemplate resilient to placeholders appearing inside substituted values.

replaceAll applied iteratively means if one variable's value contains another variable's {{placeholder}}, it gets substituted in a later iteration. For current callers the inputs are controlled (client metadata + OTP), so this isn't exploitable, but a single-pass regex replace would make the function robust against future callers.

Defer if you'd rather keep it as-is.

♻️ Single-pass variant
 export function renderSubjectTemplate(
   template: string,
   vars: Record<string, string>,
 ): string {
-  let subject = template
-  for (const [key, value] of Object.entries(vars)) {
-    subject = subject.replaceAll(`{{${key}}}`, value)
-  }
-  return subject
+  return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) =>
+    Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] : match,
+  )
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/auth-service/src/email/client-template.ts` around lines 127 - 136,
renderSubjectTemplate currently iterates replaceAll over vars which allows a
substituted value containing another placeholder to be reprocessed; change it to
a single-pass replacement by building a single regex that matches all {{key}}
placeholders and use a single replacement callback that looks up the key in vars
(falling back to original text or empty) so replacements happen atomically.
Update the implementation inside renderSubjectTemplate to compute the combined
pattern from Object.keys(vars) and call string.replace with a replacer that
returns vars[key], preventing substituted values from being rescanned.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/auth-service/src/__tests__/preview-emails.test.ts`:
- Around line 54-67: Save the original process.env.AUTH_PREVIEW_ROUTES before
mutating it (e.g., const _origAuthPreview_ROUTES =
process.env.AUTH_PREVIEW_ROUTES at module scope), then restore it in the
afterAll teardown used with server.close by setting
process.env.AUTH_PREVIEW_ROUTES back to the saved value (or deleting it if
undefined); keep existing calls to clearClientMetadataCache() and
_clearTemplateCacheForTest() unchanged and perform the restore alongside
server.close callback so the environment is not leaked to other tests.

In `@packages/auth-service/src/email/client-template.ts`:
- Around line 193-203: The subject branch is inconsistent: templated subjects
receive the raw code while fallbacks use formatOtpPlain(code), causing different
OTP display; update the renderSubjectTemplate call so templates get a formatted
OTP too (e.g. pass both code and code_formatted or replace code with
formatOtpPlain(code)), by changing the metadata.email_subject_template branch
where renderSubjectTemplate is called (keep renderSubjectTemplate,
metadata.email_subject_template, formatOtpPlain and code identifiers) —
preferred: pass both keys (code: raw code, code_formatted: formatOtpPlain(code))
so templates can opt in, and update the demo template to use {{code_formatted}}.

---

Nitpick comments:
In `@packages/auth-service/src/email/client-template.ts`:
- Around line 127-136: renderSubjectTemplate currently iterates replaceAll over
vars which allows a substituted value containing another placeholder to be
reprocessed; change it to a single-pass replacement by building a single regex
that matches all {{key}} placeholders and use a single replacement callback that
looks up the key in vars (falling back to original text or empty) so
replacements happen atomically. Update the implementation inside
renderSubjectTemplate to compute the combined pattern from Object.keys(vars) and
call string.replace with a replacer that returns vars[key], preventing
substituted values from being rescanned.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c34dd85e-9b50-49a4-83f6-0c655c432e2b

📥 Commits

Reviewing files that changed from the base of the PR and between a8f364d and 3213bd3.

📒 Files selected for processing (15)
  • .changeset/demo-client-email-branding.md
  • .changeset/in-browser-email-previews.md
  • e2e/step-definitions/email.steps.ts
  • features/email-delivery.feature
  • features/passwordless-authentication.feature
  • packages/auth-service/src/__tests__/email-templates.test.ts
  • packages/auth-service/src/__tests__/preview-emails.test.ts
  • packages/auth-service/src/email/client-template.ts
  • packages/auth-service/src/email/sender.ts
  • packages/auth-service/src/email/templates.ts
  • packages/auth-service/src/index.ts
  • packages/auth-service/src/routes/preview-emails.ts
  • packages/demo/src/app/client-metadata.json/route.ts
  • packages/demo/src/app/email-template.html/route.ts
  • packages/shared/src/preview-ui.ts
💤 Files with no reviewable changes (1)
  • e2e/step-definitions/email.steps.ts
✅ Files skipped from review due to trivial changes (4)
  • features/email-delivery.feature
  • packages/shared/src/preview-ui.ts
  • .changeset/in-browser-email-previews.md
  • packages/auth-service/src/tests/email-templates.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/auth-service/src/routes/preview-emails.ts
  • packages/auth-service/src/email/templates.ts
  • packages/auth-service/src/email/sender.ts

Comment thread packages/auth-service/src/__tests__/preview-emails.test.ts
Comment thread packages/auth-service/src/email/client-template.ts
aspiers and others added 6 commits April 20, 2026 17:43
Add /preview/emails/{new-user,returning-user,recovery} to the
auth-service. Each route renders the exact HTML the real sender
would deliver, inside a sandboxed iframe so the email's CSS can't
bleed into or read from the outer preview page.

Extract the three email builders (welcome code, sign-in code,
backup-email verification) into a pure templates.ts so the
preview routes and EmailSender share one source of truth — what
renders in the browser is bit-for-bit what production would put
in the envelope.

Gated by the existing AUTH_PREVIEW_ROUTES=1 flag; no new env vars.
Linked from the /preview index via three new AUTH_PREVIEW_ROUTES
entries.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Coveralls flagged the in-browser email previews commit for a -0.3%
coverage delta: preview-emails.ts was 0% covered because the existing
auth-service tests unit-test pure functions only.

Bring up a real express app with the router mounted on an ephemeral
port and hit it with fetch, covering:

- the AUTH_PREVIEW_ROUTES=1 gate (404 when unset);
- each of the three email routes rendering an iframe with srcdoc and
  cache-control: no-store;
- the from/to/subject headers in the preview shell;
- the otp / app / verify_url / to query-param overrides.

Raises preview-emails.ts coverage to 100% / 85.71% / 100% / 100%.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pulls `fetchTemplate`, `renderTemplate`, `renderSubjectTemplate`,
the template cache, and the branded-mail assembly out of
`sender.ts` into a new `email/client-template.ts`, behind a single
`buildClientBrandedEmail({ clientId, code, isNewUser, toEmail,
fallbackAppName, fallbackFromName, trustedClients })` entry point.

- Trust-list gating lives on `buildClientBrandedEmail` now, so any
  caller must pass `trustedClients` explicitly — there is no code
  path that renders a branded email without going through the
  gate. `sender.ts` and the `/preview/emails/*` routes both go
  through this function.
- `sender.ts` re-exports `_seedTemplateCacheForTest` /
  `_clearTemplateCacheForTest` from `client-template.js` for the
  existing test, but new code should import from the new module.
- `/preview/emails/new-user` and `/preview/emails/returning-user`
  now accept `?client_id=<URL>`. When the client is on the
  trusted-clients list and advertises an `email_template_uri`, the
  preview renders the branded template end-to-end — so what you
  see in the browser is bit-for-bit what the real sender will
  emit.

Arrow-shorthand lint fixes in preview-emails.test.ts are
unrelated drive-by corrections (the rule was already triggering
on rebase).
…ient

- `/email-template.html` route on the demo: minimal Mustache-
  style HTML email using the active `EPDS_CLIENT_THEME` palette
  (bg / surface / primary / text / border). Inline styles only,
  since several popular clients strip or mangle `<style>`.
- `/client-metadata.json` now advertises `email_template_uri` and
  `email_subject_template` (`{{code}} — your {{app_name}} code`).
- Integration tests for the `/preview/emails/*?client_id=<URL>`
  branded path: trusted client renders branded HTML + subject,
  {{#is_new_user}} conditionals honoured on the new-user route,
  untrusted client gets default PDS template (metadata is seeded
  but never surfaces), trusted client with no template URI falls
  back cleanly.

Changeset added.
Demo client now advertises email_subject_template, so OTP subjects are
shaped "{{code}} — your {{app_name}} code" for both welcome and sign-in
emails. The welcome vs sign-in distinction is still asserted by preview
integration tests and template unit tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- preview pdsIdentity now uses config.email.fromName (mirrors
  better-auth.ts which derives pdsName from SMTP_FROM_NAME).
  Keeps preview subject/footer bit-for-bit with production.
- backup-email template wrapped in <!DOCTYPE html>/<html>/<body>
  so the preview iframe renders standards-mode like the other two
  builders. Added a DOCTYPE assertion to the builder test.
- AUTH_PREVIEW_ROUTES env var snapshot/restore in preview-emails
  tests so flipping/deleting it cannot leak into sibling workers.
- buildClientBrandedEmail exposes both {{code}} (raw) and
  {{code_formatted}} (grouped) to subject templates so clients can
  opt into the PDS-style readable grouping without us forcing it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@aspiers aspiers force-pushed the feat/email-preview-endpoint branch from 9e4f532 to de01a60 Compare April 20, 2026 17:43
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 20, 2026

🦋 Changeset detected

Latest commit: d02ff54

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/auth-service/src/email/client-template.ts (1)

127-136: renderSubjectTemplate substitutes values raw — consider stripping CRLF defensively.

app_name here is metadata.client_name which, while sourced from trusted-client metadata, is still fetched over the network. A CR/LF in that value would be injected verbatim into the Subject: header. Nodemailer generally sanitizes headers, but a tiny value.replace(/[\r\n]+/g, ' ') inside the substitution loop would make this robust independent of the transport. Low priority given the trusted-clients gate, but cheap insurance.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/auth-service/src/email/client-template.ts` around lines 127 - 136,
renderSubjectTemplate currently inserts vars raw into the Subject which can
allow embedded CR/LF from remote metadata (e.g., metadata.client_name) to affect
headers; inside the substitution loop in renderSubjectTemplate, sanitize each
replacement by stripping CR and LF (e.g., replace any [\r\n]+ with a single
space) before calling subject.replaceAll so the substituted values cannot inject
newlines into the Subject header.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/auth-service/src/email/client-template.ts`:
- Line 210: The plain-text body in client-template.ts is currently a single-line
string (text = `Your code for ${appName} is: ${code}...`) which drops the PDS
footer and differs from the default builders; update the plain-text assembly
used by the branded path to mirror buildSignInCodeEmail / buildWelcomeCodeEmail
by creating a multi-line text alternative that includes the expiry sentence, the
"If you didn't request this..." ignore-this-email copy, and the signature footer
formatted as "-- \n${pdsName} (${pdsDomain})", interpolating appName and code;
ensure this plain-text is produced alongside the remote HTML (not derived from
it) so branded templates control only HTML while text remains PDS-controlled.

---

Nitpick comments:
In `@packages/auth-service/src/email/client-template.ts`:
- Around line 127-136: renderSubjectTemplate currently inserts vars raw into the
Subject which can allow embedded CR/LF from remote metadata (e.g.,
metadata.client_name) to affect headers; inside the substitution loop in
renderSubjectTemplate, sanitize each replacement by stripping CR and LF (e.g.,
replace any [\r\n]+ with a single space) before calling subject.replaceAll so
the substituted values cannot inject newlines into the Subject header.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d29d57c3-13ea-4fd3-b938-75375cda2437

📥 Commits

Reviewing files that changed from the base of the PR and between 3213bd3 and de01a60.

📒 Files selected for processing (15)
  • .changeset/demo-client-email-branding.md
  • .changeset/in-browser-email-previews.md
  • e2e/step-definitions/email.steps.ts
  • features/email-delivery.feature
  • features/passwordless-authentication.feature
  • packages/auth-service/src/__tests__/email-templates.test.ts
  • packages/auth-service/src/__tests__/preview-emails.test.ts
  • packages/auth-service/src/email/client-template.ts
  • packages/auth-service/src/email/sender.ts
  • packages/auth-service/src/email/templates.ts
  • packages/auth-service/src/index.ts
  • packages/auth-service/src/routes/preview-emails.ts
  • packages/demo/src/app/client-metadata.json/route.ts
  • packages/demo/src/app/email-template.html/route.ts
  • packages/shared/src/preview-ui.ts
💤 Files with no reviewable changes (1)
  • e2e/step-definitions/email.steps.ts
✅ Files skipped from review due to trivial changes (3)
  • packages/shared/src/preview-ui.ts
  • .changeset/in-browser-email-previews.md
  • packages/auth-service/src/email/sender.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/auth-service/src/index.ts
  • features/email-delivery.feature
  • packages/auth-service/src/tests/email-templates.test.ts

Comment thread packages/auth-service/src/email/client-template.ts Outdated
The branded path only owns HTML. Plain-text alternative now mirrors
the default builders: branches on isNewUser, includes expiry and
ignore-this-email copy, and ends with the `-- \n${pdsName} (${pdsDomain})`
footer. Thread pdsName/pdsDomain through buildClientBrandedEmail from
both call sites (sender.sendOtpCode, preview-emails.brandedOrFallback).
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/auth-service/src/email/client-template.ts`:
- Around line 52-55: The logger currently includes the raw template URL (uri)
which may contain secrets; update the warn calls in client-template.ts (the
logger.warn invocation near the Email template domain not in allowlist message
and the similar block at lines 73-90) to stop logging the full `uri` value and
instead log only non-sensitive parsed components (e.g., `hostname` and
`protocol`) or a redacted indicator; locate the calls by the message text or the
`logger.warn` usage in this module and replace the structured payload so it
omits `uri` and only emits safe fields like `hostname`/`protocol` (or a boolean
`redacted: true`) to avoid leaking signed URLs or credentials.
- Around line 197-249: The subject and fromName returned by
buildClientBrandedEmail are derived from client metadata
(metadata.email_subject_template via renderSubjectTemplate and
metadata.client_name) and must be normalized to prevent SMTP header injection;
strip CR and LF (e.g., remove '\r' and '\n') and trim the resulting subject and
fromName after rendering (including the fallback subject branches that use
formatOtpPlain) before returning them so only header-safe values propagate to
sender.sendMail.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3f1dbd13-0cdd-47fa-a4b3-5562bc44199a

📥 Commits

Reviewing files that changed from the base of the PR and between de01a60 and 9328f8c.

📒 Files selected for processing (3)
  • packages/auth-service/src/email/client-template.ts
  • packages/auth-service/src/email/sender.ts
  • packages/auth-service/src/routes/preview-emails.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/auth-service/src/email/sender.ts

Comment thread packages/auth-service/src/email/client-template.ts
Comment thread packages/auth-service/src/email/client-template.ts Outdated
Two hardenings in client-template.ts:

- fetchTemplate now logs only parsed hostname/protocol (via a new
  templateLogContext helper), not the full `uri`. Template URIs can be
  signed URLs or carry credentials in the query string, so the full URI
  is sensitive and must stay out of application logs.
- buildClientBrandedEmail now runs `subject` and `fromName` through a
  sanitizeHeaderValue helper that strips CR/LF. Even trusted clients'
  remotely-fetched metadata could be compromised, and a newline in a
  header value would let an attacker inject additional SMTP headers
  (Bcc, Reply-To, …) after it.

Added a header-injection regression test seeding metadata with CR/LF in
client_name and email_subject_template, and asserting the outgoing
Subject / From display-name contain no CR/LF.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/auth-service/src/email/client-template.ts (1)

129-143: $-substitution hazard in boolean block replacement.

html.replace(showRegex, value ? '$1' : '') uses a string replacement, so if any trusted client's branded template happens to include $&, $', $`, or $<n> sequences inside a {{#is_new_user}}…{{/is_new_user}} block, String.prototype.replace will interpret them and the rendered HTML will be wrong (e.g. $& expands to the whole match, re-duplicating the conditional). Prefer a function replacement so the captured group is emitted verbatim.

♻️ Proposed fix
-      html = html.replace(showRegex, value ? '$1' : '')
-      html = html.replace(hideRegex, value ? '' : '$1')
+      html = html.replace(showRegex, (_m, inner: string) => (value ? inner : ''))
+      html = html.replace(hideRegex, (_m, inner: string) => (value ? '' : inner))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/auth-service/src/email/client-template.ts` around lines 129 - 143,
The boolean-block replacements in the loop use string replacements that allow
$-expansion (html.replace(showRegex, value ? '$1' : '') and hideRegex
similarly); change both replacements to use a function callback so the captured
group is returned verbatim (use the replace callback parameters to access the
first capture group and return it when value dictates, otherwise return an empty
string), updating the replacements for showRegex and hideRegex inside the
Object.entries(vars) loop that references html, showRegex, and hideRegex.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/auth-service/src/email/client-template.ts`:
- Around line 129-143: The boolean-block replacements in the loop use string
replacements that allow $-expansion (html.replace(showRegex, value ? '$1' : '')
and hideRegex similarly); change both replacements to use a function callback so
the captured group is returned verbatim (use the replace callback parameters to
access the first capture group and return it when value dictates, otherwise
return an empty string), updating the replacements for showRegex and hideRegex
inside the Object.entries(vars) loop that references html, showRegex, and
hideRegex.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9dce0473-49c0-4a28-a2ca-861903f0d26c

📥 Commits

Reviewing files that changed from the base of the PR and between 9328f8c and 7524c26.

📒 Files selected for processing (2)
  • packages/auth-service/src/__tests__/email-template.test.ts
  • packages/auth-service/src/email/client-template.ts

On pull_request, `actions/checkout`'s default is the GitHub-synthesized
merge commit (refs/pull/N/merge = base + head). Railway, however, deploys
the PR branch head — Railway has no native way to deploy merge commits
(https://docs.railway.com/deployments/github-autodeploys).

The mismatch means when main advances between rebases, E2E test code
from the merge commit can assert against UI/behaviour that exists in
main but not yet in the PR branch's Railway deployment. Symptom: tests
newly added on main fail on unrelated PRs until the PR is rebased
(e.g. the "Current Handle:" row introduced by #99 broke #93's CI).

Pin checkout to `github.event.pull_request.head.sha` so test code and
deployed runtime share one SHA. Empty on push / workflow_dispatch falls
back to actions/checkout's default (GITHUB_SHA for the triggering
event), verified against the v6.0.2 source.

Trade-off: PR CI no longer catches main-incompatibilities until the
branch is rebased, but rebase was the mitigation anyway — this just
makes the contract explicit instead of surfacing as confusing
unrelated failures.
@railway-app railway-app Bot temporarily deployed to ePDS / ePDS-pr-93 April 20, 2026 20:20 Destroyed
@sonarqubecloud
Copy link
Copy Markdown

@aspiers aspiers merged commit eb88c7f into main Apr 20, 2026
15 checks passed
@aspiers aspiers deleted the feat/email-preview-endpoint branch April 20, 2026 20:32
@coderabbitai coderabbitai Bot mentioned this pull request Apr 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant