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.
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.