Skip to content

How To: Use the Shared Non-Production Mailpit Instance

Overview

One shared, authenticated Mailpit instance catches mail for every SyRF non-production environment: staging, the S30 rehearsal environment (when it exists) and every PR preview. Production always sends through AWS SES — Mailpit is never in the production path (SyRF CLAUDE.md, global constraint G4).

  • Web UI / API: https://mailpit.staging.syrf.org.uk (HTTP basic auth, credentials below)
  • SMTP: mailpit.mailpit.svc.cluster.local:1025, from inside the cluster only, and only from namespaces the NetworkPolicy allows (syrf-staging, every preview namespace and, while the S30 rehearsal exists, syrf-rehearsal)
  • Owner: plugins/local/mailpit (chart: charts/mailpit), deployed under the plugins AppProject alongside RabbitMQ, ESO, etc.

Logging in

The UI/API login is a single shared operator credential (mailpit-admin), generated by ESO and stored in the mailpit namespace's mailpit-smtp-credentials Secret under the MP_UI_AUTH key (format username:password). Fetch it with:

kubectl -n mailpit get secret mailpit-smtp-credentials -o jsonpath='{.data.MP_UI_AUTH}' | base64 -d

(Read-only kubectl get — never edit this Secret by hand; ESO owns it.)

Per-environment tags

Every SMTP credential's username is a static, human-readable environment-class name, and Mailpit is configured with --tags-username (MP_TAGS_USERNAME=1), so every message sent with that credential is automatically tagged with the username. Filter the UI (or the /api/v1/search endpoint) with tag:<name>:

Environment class SMTP username Filter
Staging staging tag:staging
PR previews (shared) previews-shared tag:previews-shared
S30 rehearsal (isolated user, syrf-rehearsal) rehearsal tag:rehearsal

Credentials for apps to consume

No SyRF app sends through Mailpit yet — the SMTP transport code is a separate, parallel change (camaradesuk/syrf#3595). Its config contract is already fixed, so this section documents exactly what that PR's syrf-staging values wiring should use.

The API/Identity charts' emailTransport.smtp block splits config into plain (non-secret) Helm values and one Secret:

emailTransport:
  provider: smtp
  smtp:
    host: mailpit.mailpit.svc.cluster.local   # plain value — NOT secret-sourced
    port: "1025"                              # plain value
    username: staging                         # plain value — the tag (see above)
    tlsMode: None                              # plain value — Mailpit has no SMTP TLS cert
    secretName: mailpit-smtp-credentials       # Secret keys: password, fromAddress
    timeoutSeconds: "30"

Only password and fromAddress come from a Kubernetes Secret — host/port/username/ tlsMode are ordinary values set directly in the consuming service's staging values file. host is mailpit.mailpit.svc.cluster.local, not mailpit-smtp.... — the chart exposes one Service (mailpit) with both the http and smtp ports; don't assume a separate mailpit-smtp Service exists.

The mailpit-smtp-credentials Secret's source of truth is the mailpit namespace (ESO-generated, refreshPolicy: CreatedOnce so it never rotates once created). It is fanned out to other namespaces — never re-generated there, which would silently split the value — via a ClusterSecretStore (mailpit-smtp-credentials-store, ESO kubernetes provider, restricted by spec.conditions to the same namespace classes the NetworkPolicy allows) that a namespace-local ExternalSecret reads with secretStoreRef: {kind: ClusterSecretStore, name: mailpit-smtp-credentials-store} and remoteRef.key: mailpit-smtp-credentials, property: <envClass>Password.

  • syrf-staging already has this today: plugins/local/extra-secrets-staging/values.yaml (externalSecrets: entry mailpit-smtp-credentials) creates a mailpit-smtp-credentials Secret in syrf-staging with keys password and fromAddress — ready for the SMTP-transport PR to mount via secretName: mailpit-smtp-credentials, alongside the plain values above.
  • Rehearsal (S30 isolated synthetic auth rehearsal, namespace syrf-rehearsal): uses an isolated SMTP user, not a new smtpUsers entry. The shared mailpit-smtp-credentials Secret is refreshPolicy: CreatedOnce, and ESO never re-syncs a CreatedOnce ExternalSecret after its first sync, so appending a user to smtpUsers would never add its key. Instead charts/mailpit isolatedSmtpUsers gives rehearsal its own CreatedOnce Secret mailpit-smtp-rehearsal (key password), a reader that may get only that Secret, a mailpit-smtp-rehearsal-store ClusterSecretStore admitting only syrf-rehearsal, and an SMTP NetworkPolicy rule for that namespace. The Deployment appends the entry to MP_SMTP_AUTH at pod start (Kubernetes $(VAR) expansion), so staging/preview credentials are unchanged. The rehearsal's namespace-local copy is declared in plugins/local/extra-secrets-rehearsal (username: rehearsal, filter tag:rehearsal). Adding or removing an isolated user restarts the Mailpit pod once (env change); retained messages are on the PVC.
  • Previews: all previews share the one previews-shared credential (a per-PR credential isn't worth the ESO/RBAC complexity for a test-mail catcher). Every namespace labelled syrf.org.uk/environment: preview — every current and future pr-{n} namespace — already gets its own local copy declaratively, via a ClusterExternalSecret (mailpit-smtp-credentials-preview, plugins/local/mailpit/resources/preview-mailpit-smtp.yaml) pointed at mailpit-smtp-credentials-store with property: previewssharedPassword. No per-PR wiring is needed — this matches how bff-redis-preview (plugins/local/valkey-nonprod) already fans the Redis preview credential out. Every preview is switched to SMTP: syrf/environments/preview/services/{api,identity}/values.yaml set emailTransport.provider: smtp at the "one service, all previews" level (mirrors the staging entries exactly, username: previews-shared) — filter the UI with tag:previews-shared for preview mail.

Retention and storage

Messages are capped at 1000 / 7 days (MP_MAX_MESSAGES / MP_MAX_AGE) and stored on a 1Gi PVC (disk-backed SQLite), not in pod memory — the cluster is memory-bound, so keeping message data off the heap matters more than surviving a pod restart. A restart loses in-flight messages; re-trigger the email if you need it again.

Changing SMTP/UI credentials

Never hand-edit mailpit-smtp-credentials. To force a rotation:

  1. Delete the Secret in the mailpit namespace (ESO recreates it from the Password generators with a fresh random value — CreatedOnce means a manual delete is required to rotate).
  2. Delete each namespace-local fan-out copy too (e.g. mailpit-smtp-credentials in syrf-staging), or they'll keep the old password until their own refreshInterval expires — and even after it does re-sync, they'll be out of sync with Mailpit until step 3.
  3. Restart the mailpit Deployment (kubectl -n mailpit rollout restart deployment/mailpit via GitOps — e.g. bump a pod annotation in charts/mailpit/values.yaml and let ArgoCD sync it, matching the podAnnotations pattern used elsewhere in this repo, such as plugins/helm/external-secrets-operator/values.yaml's restart-timestamp). MP_SMTP_AUTH / MP_UI_AUTH are plain env vars via secretKeyRef — they do not hot-reload, so the running pod keeps authenticating with the old password until it restarts.