Email Delivery
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the
version to run in production), which bundles PHPMailer 7.1.1, with WP-CLI 2.12.0 and Mailpit 1.32.0, and checked on
2026-10-28. WordPress 7.1 requires PHP 7.4 at minimum, and PHP 8.3 or newer is
recommended; WordPress 7.2 is in beta and due in December 2026. Every message, header and error message quoted below
was produced by a WordPress 7.1.3 site in Docker (official wordpress:7.1.3-php8.4-apache and wordpress:cli-php8.4
images, MariaDB 11.8) sending to a Mailpit container and read back through Mailpit’s REST API. The DNS records in
Authenticating the domain: SPF, DKIM and DMARC are illustrative and were not published anywhere.
Lumen Press, the fictitious publisher used throughout this sub-section, depends on email more than it seems: editors
reset their passwords by email, the moderation queue announces itself by email, the admin address receives update
results and fatal-error recovery links, and the lumen-catalog plugin notifies the editors of new manuscript
submissions. None of that works reliably until the site has a deliberate mail setup. WordPress itself does not deliver
mail: it builds a message and hands it to something that does.
What WordPress sends
Core calls wp_mail() from about thirty places. The ones a site owner notices:
| Message | Recipient | Trigger |
|---|---|---|
Password reset link |
The user |
Lost your password? on |
Password changed, email changed |
The user (and the admin for password changes) |
Profile or Users screen, |
New user registration |
The new user and the admin email |
Users > Add New User with Send User Notification, open registration |
Comment held for moderation, new comment |
Admin email, post author |
|
Note mention (7.1) |
The mentioned user |
A block note that mentions a user |
Automatic update results |
Admin email |
Background updates of core, plugins, themes and translations |
Fatal error recovery mode link |
Admin email or |
A fatal error caused by a plugin or theme (5.2) |
New admin email confirmation |
The new address |
Settings > General, Administration Email Address |
Personal data request confirmation and export link |
The requester |
Tools > Export / Erase Personal Data |
New site and new user notices (Multisite) |
Network admin, site admin |
Site and user signup on a network |
The messages are plain text by default, and every one passes through the same function and the same hooks, so a single
must-use plugin configures delivery for all of them and for every plugin that calls wp_mail() too.
How wp_mail() works
wp_mail( $to, $subject, $message, $headers, $attachments, $embeds ) lives in wp-includes/pluggable.php. It is a
pluggable function: it is only defined if no plugin has defined wp_mail() first, which is how some delivery plugins
replace it entirely. The sixth parameter, $embeds, arrived in 6.9 for inline images referenced as cid: URLs, with a
wp_mail_embed_args filter for their Content-ID.
Content-Type: wp_mail_content_type
charset: wp_mail_charset"] E --> F["isMail(): default transport is PHP mail()"] F --> G["action: phpmailer_init (switch to SMTP, DKIM, ...)"] G --> H{"PHPMailer::send()"} H -- ok --> S["action: wp_mail_succeeded; return true"] H -- exception --> X["action: wp_mail_failed( WP_Error ); return false"]
The steps, as WordPress 7.1.3 runs them:
-
The
wp_mailfilter receives all six arguments as an array and may change any of them. -
pre_wp_mail(5.7) short-circuits the function: any non-null return value is returned as is, and nothing is sent. Test suites and staging copies use it to silence mail. -
The global
$phpmaileris created once per request as aWP_PHPMailer(6.8), a subclass ofPHPMailer\PHPMailer\PHPMailerwhose error messages are translatable, and cleared before each message. Addresses are validated withis_email()(5.5) instead of PHPMailer’s own validator. -
Headers given as a string or array are parsed:
From,Cc,Bcc,Reply-ToandContent-Typeare turned into PHPMailer calls; other headers are added verbatim. -
When no
Fromheader was given, the sender iswordpress@plus the host ofnetwork_home_url()with any leadingwww.removed (wordpress@lumenpress.testfor the local site), and the name isWordPress. Thewp_mail_fromandwp_mail_from_namefilters change both. -
The content type defaults to
text/plain(wp_mail_content_typechanges it) and the charset to the site’sblog_charset(wp_mail_charset). -
$phpmailer->isMail()selects PHP’smail()function as the transport, thenphpmailer_initfires with the fully built PHPMailer object. This is the one place to change the transport. -
send()either succeeds and fireswp_mail_succeeded(5.9), or throws, which fireswp_mail_failed(4.4) with aWP_Errorcarrying the message data, and returnsfalse.
true means only that the transport accepted the message. With mail() that is the local sendmail binary; with SMTP
it is the first server. Neither says that the message reached an inbox. Bounces go to the envelope sender
(Return-Path) and never come back to WordPress.
The PHPMailer library moved in 5.5 to wp-includes/PHPMailer/ under the PHPMailer\PHPMailer namespace. The old
wp-includes/class-phpmailer.php and class-smtp.php files still exist only as deprecated shims that load the new
classes and alias PHPMailer; code that requires them triggers a deprecation notice. The phpmailer_init hook receives
the instance, so code should not instantiate PHPMailer itself.
The lumen-catalog plugin sends an HTML notice to the editors when a manuscript is submitted. Headers are passed as an
array, one header per element:
<?php
/**
* Notifies the editors of a new submission. Part of the lumen-catalog plugin.
*/
function lumen_catalog_notify_submission( string $title, string $author_email ): bool {
$editors = array( 'editors@lumenpress.test' );
$subject = sprintf(
/* translators: %s: manuscript title. */
__( 'New submission: %s', 'lumen-catalog' ),
$title
);
$message = sprintf(
'<p>%s</p><p><a href="%s">%s</a></p>',
esc_html__( 'A new manuscript was submitted.', 'lumen-catalog' ),
esc_url( admin_url( 'edit.php?post_type=lp_book&post_status=pending' ) ),
esc_html__( 'Review pending submissions', 'lumen-catalog' )
);
$headers = array(
'Content-Type: text/html; charset=UTF-8',
'Reply-To: ' . sanitize_email( $author_email ),
);
return wp_mail( $editors, $subject, $message, $headers );
}
Source: Code Reference: wp_mail()
Setting the content type per call through the header, as above, is safer than a global wp_mail_content_type filter:
a filter left in place turns every core message (all written as plain text) into HTML with collapsed line breaks.
User-supplied values that end up in headers must be sanitized, because a newline in a header value starts a new
header.
Why the default mail() path fails
The Advanced Administration Handbook is explicit that WordPress is neither a mail user agent nor a mail transfer agent
(MTA): it wraps PHPMailer, and by default PHPMailer calls PHP’s mail(), which pipes the message to the binary in
sendmail_path (default /usr/sbin/sendmail -t -i). Whether anything leaves the server is the host’s business. On a
typical modern setup it fails for one of these reasons:
-
There is no MTA. Container images ship without one. The official Apache image has no
/usr/sbin/sendmail; the Alpine-based CLI image has BusyBoxsendmail, which tries an SMTP server on127.0.0.1:25and finds none. In both caseswp_mail()returnsfalseandwp_mail_failedreportsCould not instantiate mail function. -
Outbound port 25 is blocked. Many cloud providers block or throttle it on new accounts to fight spam, so even an installed MTA cannot deliver directly.
-
The message is not authenticated. Mail sent directly from a web server’s IP address, with a
Fromdomain whose DNS does not authorize that server and without a DKIM signature, fails SPF, DKIM and DMARC checks. Large mailbox providers now reject or junk such mail: Gmail requires SPF or DKIM from every sender, valid reverse DNS (PTR) for the sending IP and TLS, and SPF, DKIM, DMARC and alignedFromdomains from bulk senders. -
The sender address is not real.
wordpress@lumenpress.exampleusually has no mailbox; some relays refuse to send from it, and bounces addressed to it are lost.
The handbook lists three ways out, in this order of preference for a self-managed server:
-
a local MTA that relays to a mail provider (for example Postfix configured with a relay host, or
msmtp-mta, which provides a sendmail-compatible binary that forwards to a remote SMTP server); WordPress keeps usingmail()and needs no change, and the MTA queues and retries; -
a local MTA that delivers directly, only for administrators prepared to manage IP reputation, PTR records and blocklists;
-
SMTP from PHP itself, configured through
phpmailer_init, which is the simplest and the usual choice in containers and on managed hosts, at the cost of opening a network connection inside the request that sends the mail.
Managed WordPress hosts often provide their own relay or a mail plugin; their documentation comes first.
Sending through SMTP with phpmailer_init
Lumen Press configures SMTP in a must-use plugin, so no administrator can deactivate it, and reads the connection details from constants or environment variables, so no credential is stored in the database or in Git. Locally the host is Mailpit; in production it is the relay of whichever transactional service the publisher contracts.
<?php
/**
* Plugin Name: Lumen Mail
* Description: Sends all wp_mail() messages through SMTP and logs failures.
* Version: 0.1.0
* Requires PHP: 7.4
*/
defined( 'ABSPATH' ) || exit;
/**
* Reads a setting from a constant (wp-config.php) or, failing that, the environment.
*/
function lumen_mail_setting( string $name, string $fallback = '' ): string {
if ( defined( $name ) ) {
return (string) constant( $name );
}
$value = getenv( $name );
return ( false === $value || '' === $value ) ? $fallback : $value;
}
add_action(
'phpmailer_init',
static function ( $phpmailer ) {
$host = lumen_mail_setting( 'LUMEN_SMTP_HOST' );
if ( '' === $host ) {
return; // Not configured: keep PHP mail().
}
$phpmailer->isSMTP();
$phpmailer->Host = $host;
$phpmailer->Port = (int) lumen_mail_setting( 'LUMEN_SMTP_PORT', '587' );
$phpmailer->SMTPSecure = lumen_mail_setting( 'LUMEN_SMTP_SECURE' ); // '', 'tls' or 'ssl'.
$phpmailer->Timeout = 10;
$phpmailer->Hostname = (string) wp_parse_url( home_url(), PHP_URL_HOST ); // HELO and Message-ID domain.
$user = lumen_mail_setting( 'LUMEN_SMTP_USER' );
if ( '' !== $user ) {
$phpmailer->SMTPAuth = true;
$phpmailer->Username = $user;
$phpmailer->Password = lumen_mail_setting( 'LUMEN_SMTP_PASS' );
}
// Envelope sender (Return-Path) on the same domain as From, so SPF and DMARC align.
$phpmailer->Sender = $phpmailer->From;
}
);
add_filter(
'wp_mail_from',
static function ( $from ) {
return lumen_mail_setting( 'LUMEN_MAIL_FROM', $from );
}
);
add_filter(
'wp_mail_from_name',
static function () {
return 'Lumen Press';
}
);
add_action(
'wp_mail_failed',
static function ( WP_Error $error ) {
$data = $error->get_error_data();
error_log(
sprintf(
'[lumen-mail] failed to %s, subject "%s": %s',
implode( ', ', (array) ( $data['to'] ?? array() ) ),
$data['subject'] ?? '',
$error->get_error_message()
)
);
}
);
The file goes in wp-content/mu-plugins/lumen-mail.php. The PHPMailer properties it sets:
| Property or call | Meaning |
|---|---|
|
Use PHPMailer’s own SMTP client instead of |
|
The relay. Port 587 with STARTTLS (submission) is the norm; 465 is implicit TLS; 25 is server-to-server and often blocked. |
|
|
|
Credentials for the relay, usually an API key issued by the provider. |
|
Seconds to wait for the server (default 300). A low value keeps a dead relay from hanging the request. |
|
Name used in |
|
The envelope sender, which becomes |
From must be an address on a domain the relay is allowed to send for. Most providers reject a message whose From
domain has not been verified in their console. In production, the values come from wp-config.php, which reads them
from the environment or a secrets file mounted by the platform, never from the repository:
// wp-config.php (production). Values are injected by the platform; nothing secret is committed.
define( 'LUMEN_SMTP_HOST', getenv( 'LUMEN_SMTP_HOST' ) ?: 'smtp.<provider>.example' );
define( 'LUMEN_SMTP_PORT', 587 );
define( 'LUMEN_SMTP_SECURE', 'tls' );
define( 'LUMEN_SMTP_USER', getenv( 'LUMEN_SMTP_USER' ) );
define( 'LUMEN_SMTP_PASS', getenv( 'LUMEN_SMTP_PASS' ) ); // <your-smtp-api-key>, never in Git.
define( 'LUMEN_MAIL_FROM', 'no-reply@lumenpress.example' );
The plugin keeps Reply-To untouched: WordPress itself sets it only on comment notifications (to the commenter),
and callers such as lumen_catalog_notify_submission() above pass their own. no-reply@ is a common sender, but a
mailbox that a person reads gets better engagement and catches bounces and complaints that the provider forwards.
SMTP from PHP is synchronous. Each wp_mail() call opens a TLS connection, authenticates and sends, which adds
hundreds of milliseconds to the request that triggers it (a comment submission, a password reset). For bulk mail,
such as a newsletter, use a dedicated mailing service rather than a loop over wp_mail(); for many transactional
messages, a local relaying MTA or a provider’s HTTP API plugin that queues sends is faster.
Logging failures: wp_mail_failed and wp_mail_succeeded
wp_mail() returns false on failure but core ignores the return value in almost every call, so a broken mail setup is
silent unless something listens to wp_mail_failed. The WP_Error it receives has the PHPMailer message as its error
message and the to, subject, message, headers, attachments and embeds of the failed message as error data,
plus phpmailer_exception_code. The plugin above writes one line to the PHP error log, which is wp-content/debug.log
when WP_DEBUG and WP_DEBUG_LOG are on, and otherwise the server’s error log (the container’s standard error in the
official images). These are the messages observed on the test site:
| Situation | Logged message |
|---|---|
No |
|
SMTP relay unreachable (wrong port, container stopped) |
|
Invalid recipient ( |
|
Relay rejects the credentials |
|
[28-Oct-2026 10:44:57 UTC] [lumen-mail] failed to editors@lumenpress.test, subject "Inbox down": SMTP Error: Could not connect to SMTP host. Failed to connect to server
The log line deliberately leaves out the message body: password reset and data-export emails contain secrets and
personal data, and a log is the wrong place for them. For deeper SMTP debugging, $phpmailer->SMTPDebug = 2 in
phpmailer_init prints the client-server dialogue (including AUTH exchanges, so only on a development copy), and
$phpmailer->Debugoutput = 'error_log' sends it to the log instead of the response.
wp_mail_succeeded (5.9) receives the same data array after a successful send and is the place to count messages or
record an audit trail. The authentication-failure row in the table above is the message defined in WP_PHPMailer and was not
reproduced, because the local Mailpit accepts any login.
Authenticating the domain: SPF, DKIM and DMARC
Receiving servers decide whether to trust a message from three DNS-published mechanisms. They are configured at the DNS
provider and at the mail provider, not in WordPress; WordPress only has to send with a From address on the
authenticated domain.
| Mechanism | What it checks | Published as |
|---|---|---|
SPF (RFC 7208) |
That the connecting server’s IP address is authorized to send for the envelope sender domain (the Return-Path) |
One TXT record at the domain, starting |
DKIM (RFC 6376) |
A signature over selected headers and the body, made with a private key whose public key is in DNS under the |
TXT record at |
DMARC (RFC 9989, which replaced RFC 7489 in 2026) |
That SPF or DKIM passed and its domain aligns with the |
TXT record at |
The records below are illustrative for the production domain lumenpress.example, with a fictitious provider host;
they were not published or queried. A transactional service supplies the exact include: value and DKIM keys (often
as CNAME records pointing at keys it rotates):
; Illustrative zone fragment for lumenpress.example (not published, not executed).
lumenpress.example. 3600 IN TXT "v=spf1 include:spf.mailprovider.example ~all"
lumen2026._domainkey.lumenpress.example. 3600 IN TXT "v=DKIM1; k=rsa; p=<base64-public-key>"
pm1._domainkey.lumenpress.example. 3600 IN CNAME pm1.dkim.mailprovider.example.
_dmarc.lumenpress.example. 3600 IN TXT "v=DMARC1; p=none; rua=mailto:dmarc-reports@lumenpress.example; adkim=r; aspf=r"
Rules that cause most failures:
-
One SPF record per domain. Two
v=spf1TXT records make SPF returnpermerror. Every sender (the office mail provider, the newsletter service, the transactional service) is merged into one record. SPF allows at most ten DNS lookups (include,a,mx,redirectand others count), and each provider’sincludeuses some. -
~allversus-all. Softfail (~all) is usual while DMARC does the enforcing;-allasks for outright rejection. -
Alignment. DMARC passes if SPF passes for a Return-Path domain that matches
From, or DKIM passes with ad=domain that matchesFrom(relaxed alignment accepts subdomains). A provider that uses its own bounce domain makes SPF unaligned, so DKIM withd=lumenpress.exampleis what makes DMARC pass; many providers offer a custom Return-Path subdomain (for examplebounces.lumenpress.example) to align SPF too. -
Roll out DMARC gradually. Start with
p=noneand aggregate reports (rua), check that all legitimate sources pass, then move top=quarantineandp=reject. RFC 9989 removed the oldpctpercentage tag; a policy is now tested witht=y. Receivers still understand records written for RFC 7489, so thev=DMARC1; p=...; rua=...form above is valid under both.
When the site sends through a local MTA or directly over SMTP without a provider’s signing, PHPMailer can add the DKIM
signature itself in phpmailer_init. Lumen Press keeps the private key in a file mounted outside the web root:
<?php
/**
* Plugin Name: Lumen Mail DKIM
* Description: Signs outgoing mail with DKIM when a private key is configured.
*/
defined( 'ABSPATH' ) || exit;
add_action(
'phpmailer_init',
static function ( $phpmailer ) {
if ( ! defined( 'LUMEN_DKIM_KEY_FILE' ) || ! is_readable( LUMEN_DKIM_KEY_FILE ) ) {
return;
}
$phpmailer->DKIM_domain = 'lumenpress.example';
$phpmailer->DKIM_selector = 'lumen2026'; // DNS name: lumen2026._domainkey.lumenpress.example.
$phpmailer->DKIM_private = LUMEN_DKIM_KEY_FILE; // Outside the web root, readable by PHP only.
$phpmailer->DKIM_identity = $phpmailer->From;
$phpmailer->DKIM_copyHeaderFields = false;
},
20
);
Source: PHPMailer on GitHub
The key pair is generated once with OpenSSL; the public half, base64 without the PEM header lines, is the p= value
of the selector’s TXT record:
openssl genrsa -out lumen2026.private 2048
openssl rsa -in lumen2026.private -pubout -outform der | openssl base64 -A # value for p=
With the key mounted and LUMEN_DKIM_KEY_FILE defined, the message captured on the test site carried a header such as
DKIM-Signature: v=1; d=lumenpress.example; s=lumen2026; a=rsa-sha256; c=relaxed/simple; .... The signature itself
can only be verified once the public key is in DNS. After publishing real records, dig +short TXT _dmarc.<domain>
and dig +short TXT <selector>._domainkey.<domain> confirm what receivers see, and the Authentication-Results
header of a message received at a large mailbox provider shows spf=pass, dkim=pass and dmarc=pass.
Transactional email services
A transactional email service is a provider that sends application-generated messages (resets, receipts, notifications) through its own reputable IP addresses, signs them with DKIM for the customer’s domain, processes bounces and complaints, and reports delivery events. Well-known providers include Amazon SES, Brevo, Mailgun, Mailjet, Postmark, SendGrid and SMTP2GO, and the mail services of Google Workspace and Microsoft 365 can relay low volumes too. This page does not recommend one; they differ in the points that matter to a WordPress site:
| Criterion | What to check |
|---|---|
Interface |
SMTP relay (works with the |
Domain setup |
Required DNS records (SPF include, DKIM keys or CNAMEs, custom Return-Path), and whether DMARC alignment is achievable |
Separation of streams |
Separate IPs or streams for transactional and marketing mail, so a newsletter complaint does not hurt password resets |
Events |
Bounce, complaint and delivery webhooks; suppression lists; message logs and their retention |
Region and privacy |
Data processing location, a data processing agreement, retention of message content (relevant to the privacy policy) |
Limits and price |
Free tier, per-message price, sending rate limits and warm-up for new accounts |
On the WordPress side there are two kinds of plugins. Generic SMTP plugins from the WordPress.org directory configure
PHPMailer through phpmailer_init and store host and password in the database, usually with an option to read them
from constants instead. Provider-specific plugins often replace the pluggable wp_mail() to call an HTTP API; only one
plugin can define wp_mail(), so two of them conflict silently, and Site Health lists neither conflict. A short
must-use plugin such as lumen-mail.php has no settings screen to misconfigure but needs a deployment to change.
Testing with Mailpit in Docker Compose
Mailpit is a small SMTP server and web inbox for development: it accepts every message on port 1025, delivers nothing,
and shows the messages at port 8025 together with a REST API for automated checks. Lumen Press adds it to the standard
local stack from Local Development Environments with an
override file, so the base compose.yaml stays unchanged:
# compose.mail.yaml: adds Mailpit to the Lumen Press local stack.
services:
mailpit:
image: axllent/mailpit:v1.32.0
ports:
- "127.0.0.1:8025:8025" # web inbox and REST API; SMTP (1025) stays on the Compose network
environment:
MP_MAX_MESSAGES: 500
wordpress:
depends_on:
mailpit:
condition: service_started
environment: &mail-env
LUMEN_SMTP_HOST: mailpit
LUMEN_SMTP_PORT: "1025"
LUMEN_MAIL_FROM: no-reply@lumenpress.test
cli:
environment: *mail-env
Source: Mailpit documentation: Docker
Compose merges the environment maps of the two files, so the wordpress and cli services keep their database
variables and gain the mail ones; lumen-mail.php is in the mu-plugins directory that the base file already mounts.
COMPOSE_FILE saves repeating -f on every command:
export COMPOSE_FILE=compose.yaml:compose.mail.yaml
docker compose up -d --wait
# Send a message and trigger two core emails.
docker compose run --rm cli wp eval '
var_dump( wp_mail( "editors@lumenpress.test", "Overlay test", "Hello from the overlay" ) );
var_dump( retrieve_password( "admin" ) );'
# List, search and read messages through the REST API.
curl -s http://localhost:8025/api/v1/messages | jq -r '.messages[] | "\(.Subject) | \(.From.Address)"'
curl -s --get --data-urlencode 'query=subject:"Password Reset" to:admin@lumenpress.test' \
http://localhost:8025/api/v1/search | jq -r '.messages_count'
curl -s http://localhost:8025/api/v1/message/latest | jq -r '.Subject, .ReturnPath, .MessageID'
curl -s http://localhost:8025/api/v1/message/latest/raw | head -12
# Empty the inbox between test runs.
curl -s -X DELETE http://localhost:8025/api/v1/messages
Source: Mailpit documentation: API v1
The output on the test site (the message ID shortened, and the container names in the Received header replaced
by <...>):
bool(true)
bool(true)
[Lumen Press] Password Reset | no-reply@lumenpress.test
Overlay test | no-reply@lumenpress.test
1
[Lumen Press] Password Reset
no-reply@lumenpress.test
pX7hA2Gx...@lumenpress.test
Return-Path: <no-reply@lumenpress.test>
Received: from lumenpress.test (<cli-container>.lumen-mail_default. [172.20.0.5])
by <mailpit-container> (Mailpit) with SMTP
for <admin@lumenpress.test>; Wed, 28 Oct 2026 10:48:35 +0000 (UTC)
Date: Wed, 28 Oct 2026 10:48:35 +0000
To: admin@lumenpress.test
From: Lumen Press <no-reply@lumenpress.test>
Subject: [Lumen Press] Password Reset
Message-ID: <pX7hA2Gx...@lumenpress.test>
X-Mailer: PHPMailer 7.1.1 (https://github.com/PHPMailer/PHPMailer)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
The Return-Path comes from Sender, and the HELO name (from lumenpress.test) and the Message-ID domain from
Hostname in lumen-mail.php. The Text field of the reset message contains
https://lumenpress.test/wp-login.php?login=admin&key=<key>&action=rp&wp_lang=en_US, so an end-to-end test can
extract the link and complete the reset in a browser (see E2E Testing in the Browser).
The submission notice from lumen_catalog_notify_submission() arrives with Content-Type: text/html; charset=UTF-8,
the Reply-To of the author and the & in the admin link encoded as & by esc_url().
The special message ID latest returns the newest message; /api/v1/message/{ID}/headers returns parsed headers and
/api/v1/message/{ID}/html-check scores the HTML for email-client compatibility. The web inbox at
http://localhost:8025 shows the same messages for manual review.
Two interactions with other local setups:
-
The Lumen Environment Guard must-use plugin in Local Development Environments short-circuits
pre_wp_mailon non-production copies only whenLUMEN_SMTP_HOSTis empty, so a copy that sends to Mailpit still delivers to it. On staging, where real addresses exist in the database, the guard (or a Mailpit relay with an allow list) stays on. -
Automated PHP tests do not need a server at all: the WordPress test suite replaces PHPMailer with a mock that records messages, and a plain
pre_wp_mailfilter can capture$attsfor assertions, as covered in Testing.
The administration email and its confirmations
The Administration Email Address (admin_email) in Settings > General receives most of the messages in the first
table, so it should be a monitored shared mailbox (for example webmaster@lumenpress.example) rather than one
person’s address. Changing it on the screen does not take effect until the link mailed to the new address is followed
(since 4.9); the pending value waits in new_admin_email, which is why a site that cannot send mail can never change it
from the screen. The message as captured by Mailpit:
Subject: [Lumen Press] New Admin Email Address
A site administrator (admin) recently requested to have the
administration email address changed on this site:
https://lumenpress.test
To confirm this change, please click on the following link:
https://lumenpress.test/wp-admin/options.php?adminhash=<hash>
The option storage and the WP-CLI commands that bypass the confirmation are covered in Settings Screens. The user’s own email address follows the same confirm-by-link rule on the profile screen, described in Users, Roles and Profiles.
Since 5.3, an administrator who logs in is also asked every six months to confirm that the admin email is still
correct (the "Administration email verification" screen, wp-login.php?action=confirm_admin_email). The deadline is the
admin_email_lifespan option; Remind me later postpones it by three days. Two filters change the intervals, and
returning 0 from admin_email_check_interval turns the screen off, which suits sites where the address is managed by
configuration:
<?php
/**
* Plugin Name: Lumen Admin Email Check
* Description: Asks administrators to confirm the admin email once a year instead of every six months.
*/
defined( 'ABSPATH' ) || exit;
add_filter(
'admin_email_check_interval',
static function () {
return YEAR_IN_SECONDS; // Return 0 to disable the verification screen entirely.
}
);
add_filter(
'admin_email_remind_interval',
static function () {
return WEEK_IN_SECONDS; // "Remind me later" postpones by a week.
}
);
Fatal-error recovery links go to admin_email unless the RECOVERY_MODE_EMAIL constant in wp-config.php names
another valid address, which is useful when the admin mailbox is on the same broken server. There is no constant for
the admin email itself.
Pitfalls and corrections to older books
| Older advice or symptom | Current behaviour |
|---|---|
A |
No such constant exists. The address is the |
Configure SMTP by editing |
Those directives apply only to PHP on Windows; on Linux |
Require |
Since 5.5 the class is |
"Install an SMTP plugin" as the whole answer |
A plugin only changes the transport. Deliverability also needs a real |
|
It means the first hop accepted it. Delivery problems show up in the provider’s logs or in bounces to the Return-Path. |
Password reset emails never arrive |
Check |
A plugin’s mail settings are ignored |
Another plugin has defined the pluggable |
SMTP credentials in the plugin’s settings screen |
They are stored in |
Mail from staging reaches real customers |
A copy of production contains real addresses. Suppress or capture mail on every non-production environment, as above. |