Skip to main content
The runtime image deliberately ships no MTA, so PHP’s mail() fails silently and wp_mail() returns true while no email actually delivers. Every FrankenPress site lands in this silent-fail state by default. This page is the opt-in fix. The chart’s smtp.* values + the SMTPMailer mu-plugin component wire wp_mail() through any SMTP provider — Postmark, SendGrid, Mailgun, AWS SES, or an in-cluster relay. Same surface, swap the host.

How it works

  • Chart: emits 5 FP_SMTP_* env vars from the configmap (host, port, encryption, optional from-email/name) plus 2 secretKeyRef env entries (username, password) into the Deployment, the wpcron CronJob, and the install Job.
  • Mu-plugin: reads those env vars, hooks phpmailer_init, sets the global PHPMailer’s transport.
  • Site Health: when FP_SMTP_HOST is set, the mu-plugin’s SiteHealth component adds a TCP-reachability test on the wp-admin → Site Health screen.

Before you start: site image must include SMTPMailer

The chart’s smtp.* values are wiring — the actual SMTP send happens inside the WP runtime, in the SMTPMailer mu-plugin component. Flipping smtp.enabled=true against a site image that doesn’t include mu-plugin >=0.5.0 is a no-op: the env vars get injected, no filter gets registered, wp_mail() keeps falling through to broken mail(). You can confirm SMTPMailer is present in a running pod:

Quick start (manual Secret)

For a single-namespace test or a kind-cluster demo:

Production: ExternalSecret + ESO

Real deployments don’t keep credentials in kubectl --from-literal — they pull from a secret manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, 1Password Connect, etc.) via External Secrets Operator. The chart doesn’t care how smtp.auth.existingSecret got into the namespace, so any of those work. The ESO + AWS Secrets Manager recipe (with the API token stored at my/asm/path under field smtp_token) looks like:
Then in the chart values:
For multi-environment setups (mysite-staging, mysite-production), use a separate provider Server / API token per env so a staging blast doesn’t burn your production sender reputation. We recommend distinct ExternalSecrets pointing at distinct ASM keys (asm/path/staging, asm/path/production) rather than one shared Secret with Message Streams.
Add an env-suffix to fromName in your staging values (e.g. fromName: "MySite (staging)") so testing emails are immediately obvious in inboxes.

Provider recipes

Defaults port: 587 and encryption: tls (STARTTLS) work for every modern SMTP provider — most rows below only need to set host. The auth.existingSecret always points at a Secret you create with username and password keys.

Postmark

The Secret’s username and password are both your Server API token (Postmark uses the same value for both). Set the Sender Signature for fromEmail in the Postmark dashboard before sending, or messages will reject.

SendGrid

The Secret’s username is the literal string apikey; password is your SendGrid API key. Verify the Sender Authentication for fromEmail in the SendGrid dashboard.

Mailgun

The Secret’s username is the SMTP username from your Mailgun domain settings (typically postmaster@yourdomain.com); password is the SMTP password generated for that user.

AWS SES

The Secret’s username and password are the SMTP credentials generated from an IAM user with ses:SendRawEmail (use IAM → Security credentials → “Create SMTP credentials” — these are not your access key id / secret access key). Verify the fromEmail identity (or its domain) in SES.

In-cluster relay (mailpit / mailhog)

For development environments where you want to capture rather than send mail:
The chart handles unauthenticated SMTP cleanly: when auth.existingSecret is empty, no FP_SMTP_USERNAME / FP_SMTP_PASSWORD env is injected, and the mu-plugin sets SMTPAuth = false.

Failure modes

What’s not in the platform

  • No queueing / retry / async delivery. Request-time send only. If you need transactional reliability for high volumes, install a queue plugin (Action Scheduler, etc.) or have the provider handle retries.
  • No transactional email templates. Site authors handle in WP itself or via plugin.
  • No DKIM / SPF / DMARC setup. Operator-side DNS concern, owned by whoever sets up the provider account. Without these your provider may quietly send-but-spam-folder.
  • No provider-specific API mode. SMTP only — universal across all providers. Sites that need a provider’s API mode can install the provider’s official WordPress plugin alongside; it’ll override SMTPMailer’s config (last-writer wins on phpmailer_init).

Verify it’s working

After deploying with smtp.enabled=true:
wp_mail() returning true does not mean the email was delivered. It means PHPMailer’s SMTP handshake completed and your provider accepted the message — the message can still bounce, get filtered to spam, fail content-policy checks, or never reach the recipient at all. Always cross-check by:
  1. Looking at the recipient inbox (and the spam folder).
  2. Looking at the provider’s Activity / Messages dashboard (Postmark → Activity, SES → SMTP logs, Mailgun → Logs, SendGrid → Activity Feed). The provider’s own dashboard is the authoritative “did the message actually leave” check.
If wp_mail() returns false, look at pod stderr — SMTPMailer logs [FrankenPress\\SMTPMailer] errors with the SMTP response. Most common first-time culprit is sender-identity rejection (Postmark Sender Signature, SES verified identity, etc.).

Operations

Token rotation

When you rotate the API token in your provider’s dashboard:
  1. Update the value at the source (your secret manager).
  2. ESO refreshes the K8s Secret on its refreshInterval (1 hour by default). The Secret in the namespace will pick up the new value transparently.
  3. Running pods don’t notice — env vars were injected at pod start. Roll the Deployment so new pods read the rotated value:
The wpcron CronJob and the post-install/post-upgrade Job each pull fresh env on every invocation, so they always use the latest token. Only the long-running Deployment needs the manual roll.

Auto-rolling on Secret change (Reloader)

If you rotate often (or just don’t want a manual kubectl rollout restart in your runbook), install Stakater Reloader cluster-wide. It watches Secret and ConfigMap objects and triggers a rolling update on associated workloads when their content changes — closing the gap between “ESO refreshed the Secret” and “running pods see the new value”. Install via the Helm chart on Artifact Hub:
Then opt the site workload into auto-reloads. Chart v0.13.0+ ships a typed reloader block — set enabled: true and list the Secret names you want watched, and the chart emits the correct secret.reloader.stakater.com/reload: "<csv>" annotation on the Deployment object (where Reloader actually looks):
For broad “watch every referenced Secret/ConfigMap” coverage, the pre-v0.13 commonAnnotations form still works as a fallback:
The typed block is the recommended path because it puts the annotation on the Deployment’s metadata.annotations automatically — Reloader ignores annotations on the pod template (spec.template.metadata.annotations), which is the gotcha that commonAnnotations exists to work around.
commonAnnotations is applied to every resource the chart renders (Service, ConfigMap, Secret, Job, CronJob, ServiceMonitor, etc.), not only the Deployment. The Reloader annotations on non-workload resources are harmless — Reloader only acts on Deployments, StatefulSets, and DaemonSets — but they will be visible if you kubectl describe other objects.
Reloader is platform-wide infrastructure, not SMTP-specific. Once installed it solves the same “manual rollout-restart” problem for every Secret the chart references — the database password, the WP keys+salts, S3 credentials, and the SMTP token. Worth installing once for the whole platform if any of those rotate on a schedule.
The wpcron CronJob and install Job don’t need Reloader — they’re short-lived and pull fresh env on every invocation.

Sender-identity verification

Most providers require you to prove ownership of the fromEmail address (or its domain) before they’ll accept sends. Skipping this is the single most common first-deploy gotcha — the SMTP handshake succeeds, auth succeeds, but the provider rejects MAIL FROM with a 5xx and wp_mail() returns false. Domain-level verification is preferred over per-address — one DNS config, all addresses on that domain become valid fromEmail values forever. Plus DKIM signing only works at the domain level.