Send non-production email to Mailpit¶
SyRF sends transactional email through AWS SES by default. Non-production environments (staging,
PR previews) can instead send every message to the shared Mailpit capture server over SMTP, so
testers read the mail in Mailpit's UI and nothing reaches a real mailbox. Production always stays on
SES. The chart refuses emailTransport.provider: smtp when environment.name is production. As a
runtime backstop, the API, Identity and the campaign CLI also refuse to start on SMTP when their
RuntimeEnvironment setting (rendered from environment.name) is production. This check does not
use the ASP.NET host environment, because preview APIs run with the default Production host
environment.
The transport covers the three senders:
| Sender | SES (default) | SMTP (provider: smtp) |
|---|---|---|
| Identity Endpoint (reset, verification, step-up, notifications, recovery outbox) | AwsIdentityEmailService |
SmtpIdentityEmailService: identical subject and HTML |
Identity migration campaign Job (campaign-canary, campaign-batch) |
AwsCampaignEmailService |
SmtpCampaignEmailService: identical subject and HTML |
| API (account, project, helpdesk, admin mail) | AwsEmailService (SES stored templates) |
SmtpEmailService: the real SES template, rendered by SES and delivered over SMTP (see API templated mail) |
The transport is deployment configuration, not a runtime feature flag. Omitting emailTransport
(or provider: ses) keeps the exact SES behaviour every environment had before.
Configure an environment¶
In the environment's api and identity values in cluster-gitops:
emailTransport:
provider: smtp
smtp:
host: mailpit.mailpit.svc.cluster.local # required; the shared Mailpit Service
port: 1025 # Mailpit's SMTP port (chart default 587)
tlsMode: None # None | StartTls (default) | StartTlsWhenAvailable | SslOnConnect
username: staging # per environment class: staging, previews-shared
secretName: mailpit-smtp-credentials # chart default smtp-credentials; keys password, fromAddress
timeoutSeconds: 30
For Identity, also set ses.enabled: false if the environment should not need SES credentials. Its
"a real mailer is required outside Development" startup check accepts the SMTP transport, and its
readiness probe then reports email from the SMTP settings.
The API still needs its SES settings and Secret in SMTP mode, and its SES identity must be allowed
ses:TestRenderEmailTemplate. The API always binds SESSettings, because SES template administration
and the DevEmail/RestrictEmailToDev routing read them. In SMTP mode it also renders every templated
message through SES (see below). Keep the existing ses values and aws-ses Secret for the API when
switching its mail to SMTP, and point them at the SES account and region that hold the templates.
Only delivery changes.
- Username. When it is set, the client authenticates with it. With
MP_TAGS_USERNAMEenabled, Mailpit tags every captured message with the username, so one shared Mailpit separates environments by tag. When it is empty, no SMTP AUTH is sent. - Password and sender. Both come only from the
smtp-credentialsSecret (see required secrets), and both keys are optional. The Identity chart must never render an address-shaped value (check-redacted-evidence.shrule 0), so the sender address cannot be a plain value. WithoutfromAddressthe sender isnoreply@syrf.org.uk. - TLS.
tlsMode: Nonesends AUTH in cleartext inside the cluster. Mailpit then needsMP_SMTP_AUTH_ALLOW_INSECURE=true, plusMP_SMTP_AUTH_ACCEPT_ANY=trueor a real auth file.
The resulting .NET configuration keys (environment SYRF__EmailTransport__…) are
EmailTransport:Provider and EmailTransport:Smtp:{Host,Port,TlsMode,Username,Password,FromAddress,TimeoutSeconds}.
They are defined in src/charts/syrf-common/env-mapping.yaml (section emailTransport) and
SyRF.SharedKernel.Email.EmailTransportOptions.
Behaviour and failure semantics¶
- One SMTP connection and one attempt per message (MailKit). The sender never retries, so the Identity recovery outbox's rule of never resubmitting an ambiguously accepted message holds on this transport too.
- A rejected or failed send throws, the same as an SES SDK exception. Every caller keeps its existing
failure handling: the outbox, registration compensation and campaign failure accounting are
unchanged. A failed
QUITafter the server has accepted the message is treated as success, so a caller retry cannot duplicate the message. - Invalid SMTP settings (missing host, a port outside 1–65535, an unknown
tlsMode, a malformed sender) fail startup and name the key, never its value. The API and Identity each fail at startup. The campaign Job fails before it sends anything. - Nothing on the SMTP path logs a recipient, link, host, username, password or sender. The existing
Pii*-only logging rules are unchanged.
Live auth smoke against Mailpit¶
e2e/tests/auth-migration-live.helpers.ts reads reset links through Mailpit's REST API when
AUTH_SMOKE_MAILBOX_KIND=mailpit:
| Variable | Adapter mode (default) | Mailpit mode |
|---|---|---|
AUTH_SMOKE_MAILBOX_KIND |
unset or adapter |
mailpit |
AUTH_SMOKE_MAILBOX_ENDPOINT |
adapter URL (?recipient=) |
Mailpit base URL (HTTPS). The suite calls /api/v1/search and /api/v1/message/{ID} |
AUTH_SMOKE_MAILBOX_TOKEN_FILE (or _FD via live-smoke.sh) |
Bearer token | username:password for Mailpit's UI/API Basic auth (MP_UI_AUTH) |
AUTH_SMOKE_MAILBOX_TAG |
unused | optional. Narrows the search to the environment's username tag |
The search is to:"<reset address>" (plus tag:"…"), newest 20 messages. Links come from each
message's HTML hrefs and plain-text URLs. The link-extraction unit tests run with
node --test e2e/tests/auth-migration-live.mailbox.test.ts.
S30 isolated rehearsal¶
The S30 rehearsal (namespace syrf-rehearsal) sends through its own isolated Mailpit SMTP user
rehearsal. It is not a smtpUsers entry: the shared credentials Secret is refreshPolicy: CreatedOnce
and never gains new keys. See cluster-gitops charts/mailpit isolatedSmtpUsers and
docs/how-to/shared-mailpit-non-production-email.md. With live-smoke.sh --isolated-rehearsal and
AUTH_SMOKE_MAILBOX_KIND=mailpit, the wrapper forces AUTH_SMOKE_MAILBOX_TAG=rehearsal and refuses any
other tag, so the run can only read rehearsal mail. --require-forwarded-header-matrix also
requests a reset through a browser context that sends untrusted X-Forwarded-Host/-Proto, and
requires the emailed link to stay on the issuer origin. Those helpers are unit-tested with
node --test e2e/tests/auth-migration-live.forwarded.test.ts.
Dropped post-login navigations (live harness)¶
On the shared CI host, Docker network churn can make Chromium drop the redirect after a login
submit (net::ERR_NETWORK_CHANGED, page left on chrome-error://chromewebdata/). The live harness
(passwordLogin, googleLogin's final submit in
e2e/tests/auth-migration-live.helpers.ts) therefore waits at most 30 s for the return to the app
origin, then resumes once by navigating to /api/auth/login (or the end-session URL). It reuses
signInWithRecovery from the authority lane (#3897). The resume only navigates: a password or Google
credential is never submitted again automatically, so Identity's anonymous password budget (20 per
5 minutes) is not spent by recovery. A second failure fails the step with the cause. The logout end-session navigation gets one retry after a dropped network, then waits as before. The operator
checkpoint path for Google is unchanged. Unit tests:
node --test e2e/tests/auth-migration-live.navigation.test.ts (also run by
.github/scripts/test-e2e-concurrency.sh).
Operator-attested Google row (headless host)¶
When the harness runs on a remote headless server there is no headed browser for the Google operator
checkpoint, and Google usually blocks automated sign-in. Set AUTH_SMOKE_GOOGLE_MODE=operator-attested
(closed set: automated, the default, or operator-attested; anything else is refused). In that mode the
automated Google spec is skipped, AUTH_SMOKE_GOOGLE_EMAIL/AUTH_SMOKE_GOOGLE_PASSWORD are not required,
and live-smoke.sh writes assertions.google: false, assertions.googleJourney: "operator-attested" and
assertions.googleJourneyRecordedAt (UTC) into the evidence, so the row can never be read as automated.
Redaction still runs on the evidence. The operator performs this check by hand:
- In their own desktop browser, sign out of https://rehearsal.syrf.org.uk, then sign in with Google.
- Confirm they land signed in and that https://rehearsal.syrf.org.uk/api/auth/me returns 200.
- On https://identity.rehearsal.syrf.org.uk/Account/Manage/ExternalLogins, confirm Google is listed.
Mode parsing is unit-tested with node --test e2e/tests/auth-migration-live.google-mode.test.ts; the
evidence shape by scripts/auth-migration/tests/scripts.bats.
Opt-in extended rows (S09/S27)¶
AUTH_SMOKE_EXTENDED_ROWS adds rows that the S30 rehearsal never exercised live. It is a closed, comma-separated set; empty (the default) runs only the base matrix. live-smoke.sh and the spec both refuse an unknown or repeated name. The evidence records extended_rows: {<row>: true} for each requested row.
| Row | What it proves | How |
|---|---|---|
swagger |
Swagger uses only the public PKCE client syrf-swagger (syrf#3992) |
The anonymous /swagger/index.html carries no non-empty client credential and names only syrf-swagger. The OpenAPI document's OAuth endpoints are on the issuer. An authorization-code + PKCE grant without any client credential is accepted by the API (200 on the protected and admin APIs). The same call anonymously gets 401 |
claims |
The BFF identity matches the designated accounts | /api/auth/me for the administrator, then for the reset account. Only booleans are compared (user id present, email match, administrator group as expected). The admin API refuses the non-administrator (403). Needs the reset row's new credential |
mfa |
Optional two-step verification and recovery codes | On a dedicated synthetic account (AUTH_SMOKE_MFA_EMAIL / AUTH_SMOKE_MFA_PASSWORD, required for this row): enable with the setup key (TOTP computed in-process, RFC 6238), sign in with a code, sign in with a recovery code, prove that code is single-use, then turn two-step off again |
passkey |
Passkey registration, sign-in and removal | A Chromium virtual authenticator (CTAP2, resident key, user verification) on the password account. It proves the protocol path; one real-device passkey stays operator-attested |
google-unlink |
Unlink with an emailed step-up | The operator links the designated Google account beforehand (the evidence records google_link_before_unlink: "operator-attested"). The row requests the emailed step-up, opens it from Mailpit, confirms, removes the Google sign-in, and proves it is gone. The operator re-links before the next run |
- Extended rows need the BFF journey and are refused with
--expect-bff-disabled. - Identity allows about 20 anonymous password attempts per 5 minutes. Split a full run into two invocations, at least 5 minutes apart: base plus
swagger,claims, then base plusmfa,passkey,google-unlink. - Test titles avoid the evidence redaction shapes (#3951), so the Playwright list output stays redaction-clean.
Operator run lessons (S30, 2026-10-03)¶
The passing S30 run needed three things. Do the same for S09/S27-style runs.
- Run from an approved worktree, never
main.live-smoke.shcallsscripts/auth-migration/assert-worktree.sh. That accepts only a checkout directly under.worktrees/,pr/oragents/, and refuses themaincheckout. S30 used a dedicatedagents/s30-liveworktree, detached atorigin/mainbefore each run. - On a CI host, run Playwright in a container. This host's runners create and remove Docker
networks all the time. A host Chromium sees those network changes and aborts navigations with
net::ERR_NETWORK_CHANGED, even with the resume logic above.live-smoke.shruns"$PLAYWRIGHT_BIN" --dir <repo>/e2e exec playwright test …whenPLAYWRIGHT_BINis set. The repository ships no such wrapper: it is a small operator-local script that: - runs
playwright testinside the officialmcr.microsoft.com/playwright:<harness version>-nobleimage (--init --ipc=host, running as the invoking user); - bind-mounts the repository and
AUTH_SMOKE_STATE_DIRat the same paths; - passes through only the
AUTH_SMOKE_*andCIvariables, using a temporary env file; - tees the output to a private (
umask 077) log.
Chromium then gets its own network namespace. Keep the wrapper and its log outside the
repository, and never print the env file.
3. Use the operator-attested Google mode on headless servers (see above). The operator does the
three manual Google checks in a desktop browser just before the run. The evidence then records
googleJourney: "operator-attested" with the time.
Also: test titles end up in the evidence. A title such as "password reset" matches the redaction pattern
(client[_-]?secret|password)[[:space:]"=:]. check-redacted-evidence.sh reports that as "rule 8",
the 0-based index of the pattern, and aborts the run (#3951).
API templated mail on SMTP¶
The API's messages are SES stored templates. On SMTP the API asks SES to render the real template
with the real template data, using SES v2 TestRenderEmailTemplate (IAM action
ses:TestRenderEmailTemplate). That call returns the complete MIME message and sends nothing. The
API then delivers the rendered message over SMTP, so Mailpit shows what production would send:
- The subject, HTML and text parts are SES's rendering.
- From is the same sender the SES path uses (SyRF <no-reply@syrf.org.uk>, or SyRF Application for
simple mail).
- To is the same recipient list, including the DevEmail/RestrictEmailToDev routing.
- The only addition is an X-Tags header carrying the message type. SES keeps that as a message tag
outside the email.
Rendering failures produce the SES send path's outcome, and nothing is delivered:
| Cause | Outcome |
|---|---|
| Template missing, IAM denied, account suspended, SES unreachable | The same SES SDK exception the send path would throw (for example NotFoundException) propagates to the caller |
| A non-success SES status | Returned unchanged |
| An empty render | Treated as an error |
| A malformed render (MIME that MimeKit cannot parse) | FormatException (MimeKit ParseException derives from it) propagates, as an SES exception would |
Each failure is logged as a warning naming only the template, the message type and the SES error code, never recipients, template data or the exception text. There is no fallback to any other rendering, so a missing template or permission shows up in non-production instead of hiding behind a substitute message.
Bulk sends render every entry (the default data overlaid by that entry's data) before sending any. A refused template therefore sends nothing, and the rest become one SMTP message per recipient. The SES bulk path sends them in one call.
Template administration (/api/admin-email) still talks to SES in both modes.