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 2secretKeyRefenv 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_HOSTis 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’ssmtp.* 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 inkubectl --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:
Provider recipes
Defaultsport: 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
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
username is the literal string apikey; password is
your SendGrid API key. Verify the Sender Authentication for
fromEmail in the SendGrid dashboard.
Mailgun
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
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: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 withsmtp.enabled=true:
Operations
Token rotation
When you rotate the API token in your provider’s dashboard:- Update the value at the source (your secret manager).
-
ESO refreshes the K8s Secret on its
refreshInterval(1 hour by default). The Secret in the namespace will pick up the new value transparently. -
Running pods don’t notice — env vars were injected at pod start.
Roll the Deployment so new pods read the rotated value:
Auto-rolling on Secret change (Reloader)
If you rotate often (or just don’t want a manualkubectl 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:
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):
commonAnnotations form still works as a fallback:
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.
Sender-identity verification
Most providers require you to prove ownership of thefromEmail
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.
Related
mu-plugin→ SMTPMailer — the component referencechartsvalues — thesmtp.*keys- Operations → Configuration — full env-var reference