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 wp-login.php, retrieve_password()

Password changed, email changed

The user (and the admin for password changes)

Profile or Users screen, wp_update_user()

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

moderation_notify, comments_notify options

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 RECOVERY_MODE_EMAIL

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.

flowchart TD A["wp_mail( to, subject, message, headers, attachments, embeds )"] --> B["filter: wp_mail (change any argument)"] B --> C{"filter: pre_wp_mail returns non-null?"} C -- yes --> R1["return that value; nothing is sent"] C -- no --> D["global $phpmailer (WP_PHPMailer) reset; headers parsed"] D --> E["From: wp_mail_from / wp_mail_from_name
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:

  1. The wp_mail filter receives all six arguments as an array and may change any of them.

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

  3. The global $phpmailer is created once per request as a WP_PHPMailer (6.8), a subclass of PHPMailer\PHPMailer\PHPMailer whose error messages are translatable, and cleared before each message. Addresses are validated with is_email() (5.5) instead of PHPMailer’s own validator.

  4. Headers given as a string or array are parsed: From, Cc, Bcc, Reply-To and Content-Type are turned into PHPMailer calls; other headers are added verbatim.

  5. When no From header was given, the sender is wordpress@ plus the host of network_home_url() with any leading www. removed (wordpress@lumenpress.test for the local site), and the name is WordPress. The wp_mail_from and wp_mail_from_name filters change both.

  6. The content type defaults to text/plain (wp_mail_content_type changes it) and the charset to the site’s blog_charset (wp_mail_charset).

  7. $phpmailer->isMail() selects PHP’s mail() function as the transport, then phpmailer_init fires with the fully built PHPMailer object. This is the one place to change the transport.

  8. send() either succeeds and fires wp_mail_succeeded (5.9), or throws, which fires wp_mail_failed (4.4) with a WP_Error carrying the message data, and returns false.

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 );
}

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 BusyBox sendmail, which tries an SMTP server on 127.0.0.1:25 and finds none. In both cases wp_mail() returns false and wp_mail_failed reports Could 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 From domain 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 aligned From domains from bulk senders.

  • The sender address is not real. wordpress@lumenpress.example usually 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:

  1. 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 using mail() and needs no change, and the MTA queues and retries;

  2. a local MTA that delivers directly, only for administrators prepared to manage IP reputation, PTR records and blocklists;

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

isSMTP()

Use PHPMailer’s own SMTP client instead of mail().

Host, Port

The relay. Port 587 with STARTTLS (submission) is the norm; 465 is implicit TLS; 25 is server-to-server and often blocked.

SMTPSecure

tls for STARTTLS, ssl for implicit TLS, empty for none. With an empty value PHPMailer still upgrades to STARTTLS when the server offers it (SMTPAutoTLS, on by default).

SMTPAuth, Username, Password

Credentials for the relay, usually an API key issued by the provider.

Timeout

Seconds to wait for the server (default 300). A low value keeps a dead relay from hanging the request.

Hostname

Name used in HELO/EHLO and on the right of the Message-ID. Without it, PHPMailer uses the machine name; in a container that is the container ID (Message-ID: <...@e71c3977bf28> was observed).

Sender

The envelope sender, which becomes Return-Path. SPF is checked against this domain, and DMARC requires it (or the DKIM domain) to match the From domain.

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 phpmailer_init SMTP setup, no sendmail binary (Apache image)

Could not instantiate mail function.

SMTP relay unreachable (wrong port, container stopped)

SMTP Error: Could not connect to SMTP host. Failed to connect to server

Invalid recipient (not-an-address)

You must provide at least one recipient email address.

Relay rejects the credentials

SMTP Error: Could not authenticate.

[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 v=spf1

DKIM (RFC 6376)

A signature over selected headers and the body, made with a private key whose public key is in DNS under the d= domain

TXT record at <selector>._domainkey.<domain>

DMARC (RFC 9989, which replaced RFC 7489 in 2026)

That SPF or DKIM passed and its domain aligns with the From header domain; tells receivers what to do otherwise and where to send reports

TXT record at _dmarc.<domain>

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=spf1 TXT records make SPF return permerror. 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, redirect and others count), and each provider’s include uses some.

  • ~all versus -all. Softfail (~all) is usual while DMARC does the enforcing; -all asks for outright rejection.

  • Alignment. DMARC passes if SPF passes for a Return-Path domain that matches From, or DKIM passes with a d= domain that matches From (relaxed alignment accepts subdomains). A provider that uses its own bounce domain makes SPF unaligned, so DKIM with d=lumenpress.example is what makes DMARC pass; many providers offer a custom Return-Path subdomain (for example bounces.lumenpress.example) to align SPF too.

  • Roll out DMARC gradually. Start with p=none and aggregate reports (rua), check that all legitimate sources pass, then move to p=quarantine and p=reject. RFC 9989 removed the old pct percentage tag; a policy is now tested with t=y. Receivers still understand records written for RFC 7489, so the v=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
);

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 phpmailer_init plugin above, no provider code) or HTTP API (needs the provider’s plugin or SDK; fewer round trips, no outbound SMTP port needed)

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

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

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 &#038; 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_mail on non-production copies only when LUMEN_SMTP_HOST is 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_mail filter can capture $atts for 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 WP_ADMIN_EMAIL constant in wp-config.php sets the admin address (one older book)

No such constant exists. The address is the admin_email option, changed through Settings > General (with email confirmation), the REST API settings endpoint or wp option update admin_email. Only recovery-mode mail has a constant, RECOVERY_MODE_EMAIL.

Configure SMTP by editing SMTP and smtp_port in php.ini

Those directives apply only to PHP on Windows; on Linux mail() always uses sendmail_path. Use an MTA or phpmailer_init.

Require wp-includes/class-phpmailer.php and create new PHPMailer()

Since 5.5 the class is PHPMailer\PHPMailer\PHPMailer in wp-includes/PHPMailer/, and the old file is a deprecated shim. Configure the shared instance in phpmailer_init instead.

"Install an SMTP plugin" as the whole answer

A plugin only changes the transport. Deliverability also needs a real From mailbox on a domain with SPF, DKIM and a DMARC record.

wp_mail() returned true, so the message was delivered

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 wp_mail_failed logging first; then the From domain’s SPF, DKIM and DMARC; then the spam folder and the provider’s suppression list.

A plugin’s mail settings are ignored

Another plugin has defined the pluggable wp_mail() first, or a later phpmailer_init callback overrides the settings. Only one replacement of wp_mail() can be active.

SMTP credentials in the plugin’s settings screen

They are stored in wp_options, readable by anyone with database access or a database backup. Prefer constants or environment variables.

Mail from staging reaches real customers

A copy of production contains real addresses. Suppress or capture mail on every non-production environment, as above.