Skip to content

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_USERNAME enabled, 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-credentials Secret (see required secrets), and both keys are optional. The Identity chart must never render an address-shaped value (check-redacted-evidence.sh rule 0), so the sender address cannot be a plain value. Without fromAddress the sender is noreply@syrf.org.uk.
  • TLS. tlsMode: None sends AUTH in cleartext inside the cluster. Mailpit then needs MP_SMTP_AUTH_ALLOW_INSECURE=true, plus MP_SMTP_AUTH_ACCEPT_ANY=true or 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 QUIT after 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:

  1. In their own desktop browser, sign out of https://rehearsal.syrf.org.uk, then sign in with Google.
  2. Confirm they land signed in and that https://rehearsal.syrf.org.uk/api/auth/me returns 200.
  3. 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 plus mfa,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.

  1. Run from an approved worktree, never main. live-smoke.sh calls scripts/auth-migration/assert-worktree.sh. That accepts only a checkout directly under .worktrees/, pr/ or agents/, and refuses the main checkout. S30 used a dedicated agents/s30-live worktree, detached at origin/main before each run.
  2. 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.sh runs "$PLAYWRIGHT_BIN" --dir <repo>/e2e exec playwright test … when PLAYWRIGHT_BIN is set. The repository ships no such wrapper: it is a small operator-local script that:
  3. runs playwright test inside the official mcr.microsoft.com/playwright:<harness version>-noble image (--init --ipc=host, running as the invoking user);
  4. bind-mounts the repository and AUTH_SMOKE_STATE_DIR at the same paths;
  5. passes through only the AUTH_SMOKE_* and CI variables, using a temporary env file;
  6. 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.