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.

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.