Skip to content
Merged
11 changes: 11 additions & 0 deletions .changeset/demo-client-email-branding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'ePDS': minor
---

Trusted demo client now ships with a custom branded OTP email template.

**Affects:** Client app developers, Operators

**Client app developers:** the demo client's `client-metadata.json` now advertises `email_template_uri` (pointing at `/email-template.html` on the same origin) and `email_subject_template` (`{{code}} — your {{app_name}} code`), so operators running ePDS with the demo as a trusted client see a visually coherent login + email experience out of the box. The template is a minimal Mustache-style HTML email that respects the demo's `EPDS_CLIENT_THEME` palette: the OTP box, headings, and background all match whichever theme is active on the login and consent pages. Copy the shape from `packages/demo/src/app/email-template.html/route.ts` if you want a starting point for your own client's branded template — the supported placeholders are `{{code}}`, `{{app_name}}`, `{{logo_uri}}`, `{{email}}`, and the conditional blocks `{{#is_new_user}}…{{/is_new_user}}` / `{{^is_new_user}}…{{/is_new_user}}`.

**Operators:** no env var change is required — the demo's branded email is served automatically when you run the bundled demo client as a trusted client on `PDS_OAUTH_TRUSTED_CLIENTS`. The template is served from the demo's own origin (`<demo-base-url>/email-template.html`) with `Cache-Control: public, max-age=300`, is capped at the same 100 KB / 5 s limits `makeSafeFetch` applies to any remote email template, and is only honoured for `client_id`s on the trusted-clients list (see the `gate-email-templates-on-trusted-clients` changeset). You can verify what your users will receive by opening `/preview/emails/returning-user?client_id=<demo-base-url>/client-metadata.json` on the auth service with `AUTH_PREVIEW_ROUTES=1`.
17 changes: 17 additions & 0 deletions .changeset/in-browser-email-previews.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'ePDS': minor
---

Preview the transactional emails ePDS sends, directly in your browser.

**Affects:** Client app developers, Operators

**Client app developers:** three new preview routes on the auth service render the exact email HTML real users receive, inside a sandboxed iframe:

- `/preview/emails/new-user` — welcome / email-verification code sent during signup.
- `/preview/emails/returning-user` — sign-in OTP sent when an existing user logs in to your app.
- `/preview/emails/recovery` — backup-email verification link sent when a user adds a recovery address.

Each route accepts the same `?client_id=<URL-of-your-client-metadata.json>` query param as the other preview pages, so you can see how your branded template will look without walking through a real OAuth flow. Optional extras: `?otp=<code>` to override the fixture OTP, `?app=<name>` to override the fixture app name on the returning-user template, `?verify_url=<url>` to override the backup-email verification link. Links for all three are wired into the `/preview` index page on the auth service.

**Operators:** gated by the existing `AUTH_PREVIEW_ROUTES=1` flag — no new environment variables. When the flag is off the new routes return 404, identical to the rest of `/preview/*`. The previews do not touch SMTP; they call the same template builders the real sender uses, so what renders is bit-for-bit what production would put in the envelope. Intended for preview and development environments; leave the flag off in production.
1 change: 0 additions & 1 deletion e2e/step-definitions/email.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ Then('an OTP email arrives in the mail trap', async function (this: EpdsWorld) {
this.otpCode = await extractOtp(message.ID)
})

// "Welcome" for new users, "Sign-in" for returning users
Then(
'the email subject contains {string}',
function (this: EpdsWorld, expected: string) {
Expand Down
8 changes: 6 additions & 2 deletions features/email-delivery.feature
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,21 @@ Feature: Email delivery
Given the ePDS test environment is running
And a mail trap is capturing outbound emails

# The demo client is on PDS_OAUTH_TRUSTED_CLIENTS and advertises its own
# email_subject_template ("{{code}} — your {{app_name}} code"), so the
# subject is the same shape for new and returning users. The welcome vs
# sign-in distinction is asserted by the preview + unit tests, not here.
Scenario: New user receives a welcome OTP email
When the user requests an OTP for a unique test email
Then an OTP email arrives in the mail trap for the test email
And the email subject contains "Welcome"
And the email subject contains "ePDS Demo"
And the email body contains an OTP code matching the configured charset

Scenario: Returning user receives a sign-in OTP email
Given a returning user has a PDS account
When the user requests an OTP for the test email
Then an OTP email arrives in the mail trap for the test email
And the email subject contains "Sign-in"
And the email subject contains "ePDS Demo"

Scenario: Backup email verification link is delivered
Given the user is logged into account settings
Expand Down
9 changes: 5 additions & 4 deletions features/passwordless-authentication.feature
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ Feature: Passwordless authentication via email OTP
And the login page displays an email input form
When the user enters a unique test email and submits
Then an OTP email arrives in the mail trap for the test email
# "Welcome" for new users, "Sign-in" for returning users
And the email subject contains "Welcome"
# Demo is a trusted client with its own branded email_subject_template
# ("{{code}} — your {{app_name}} code"), so subject contains app name.
And the email subject contains "ePDS Demo"
And the login page shows an OTP verification form
When the user enters the OTP code
And the user picks a handle
Expand All @@ -31,7 +32,7 @@ Feature: Passwordless authentication via email OTP
When the demo client initiates an OAuth login
And the user enters the test email on the login page
Then an OTP email arrives in the mail trap
And the email subject contains "Sign-in"
And the email subject contains "ePDS Demo"
When the user enters the OTP code
Then the browser is redirected back to the demo client with a valid session

Expand All @@ -41,7 +42,7 @@ Feature: Passwordless authentication via email OTP
When the demo client initiates an OAuth login
And the user enters the test email on the login page
Then an OTP email arrives in the mail trap
And the email subject contains "Sign-in"
And the email subject contains "ePDS Demo"
When the user enters the OTP code
Then the browser is redirected back to the demo client with a valid session

Expand Down
83 changes: 83 additions & 0 deletions packages/auth-service/src/__tests__/email-templates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Tests for the pure email builders used by both the real sender and the
* /preview/emails/* routes. Covers subject, plain-text alternative, and
* HTML shape — the preview routes render this HTML verbatim inside an
* iframe, so it's worth pinning.
*/
import { describe, it, expect } from 'vitest'
import {
buildSignInCodeEmail,
buildWelcomeCodeEmail,
buildBackupEmailVerificationEmail,
} from '../email/templates.js'

const PDS = { pdsName: 'Test PDS', pdsDomain: 'pds.example' }

describe('buildSignInCodeEmail', () => {
it('puts the OTP and app name in the subject, text, and html', () => {
const { subject, text, html } = buildSignInCodeEmail({
code: '12345678',
clientAppName: 'My App',
...PDS,
})
expect(subject).toContain('Test PDS')
expect(subject).toContain('1234 5678')
expect(text).toContain('My App')
expect(text).toContain('12345678')
expect(html).toContain('My App')
// formatOtpHtmlGrouped renders each digit in its own span.
expect(html).toContain('<!DOCTYPE html>')
})

it('escapes HTML in the app name and pds identity', () => {
const { html } = buildSignInCodeEmail({
code: '123456',
clientAppName: '<script>x</script>',
pdsName: '<b>Name</b>',
pdsDomain: 'a&b.example',
})
expect(html).not.toContain('<script>x</script>')
expect(html).toContain('&lt;script&gt;x&lt;/script&gt;')
expect(html).toContain('&lt;b&gt;Name&lt;/b&gt;')
expect(html).toContain('a&amp;b.example')
})
})

describe('buildWelcomeCodeEmail', () => {
it('uses the welcome subject and includes the verification code', () => {
const { subject, text, html } = buildWelcomeCodeEmail({
code: '654321',
...PDS,
})
expect(subject).toContain('Welcome to Test PDS')
expect(subject).toContain('654321')
expect(text).toContain('Welcome to Test PDS')
expect(text).toContain('654321')
expect(html).toContain('Welcome to Test PDS')
})
})

describe('buildBackupEmailVerificationEmail', () => {
it('puts the verify URL in both the text and the html anchor', () => {
const url = 'https://auth.example/account/verify?t=abc'
const { subject, text, html } = buildBackupEmailVerificationEmail({
verifyUrl: url,
...PDS,
})
expect(subject).toBe('Verify your backup email - Test PDS')
expect(text).toContain(url)
expect(html).toContain(`href="${url}"`)
// Complete-document shell so the preview iframe renders in standards
// mode, matching the other two builders.
expect(html).toContain('<!DOCTYPE html>')
})

it('escapes a malicious verify URL', () => {
const { html } = buildBackupEmailVerificationEmail({
verifyUrl: 'https://x.example/"><script>alert(1)</script>',
...PDS,
})
expect(html).not.toContain('<script>alert(1)</script>')
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;')
})
})
Loading
Loading