Your WordPress says the email sent. It didn't.

Your WordPress says the email sent. It didn't.

Leader 1 5
calendar_today agoschedule12 min read
— Originally published at www.mantek.io

A user tells you they never got the password-reset email. You check: WordPress says it sent. wp_mail() returned true. There is nothing in the logs, because there is nothing to log. The mail left the building and vanished.

This is the worst kind of bug. It is silent, it is invisible, and it happens in the one flow where the user is already locked out and already frustrated. And on a default WordPress install, it is not a bug at all. It is the design.

Why WordPress email is broken by default

Out of the box, wp_mail() hands your message to PHPMailer, which hands it to PHP's mail(), which hands it to sendmail on the web server. That path has no sending reputation, no SPF alignment, and no DKIM signature. To a modern mail provider, a message from a random web server, unsigned and unaligned, is indistinguishable from spam, so Gmail and Outlook quietly bin it. Often without even a bounce.

And wp_mail() reports none of this. Its entire failure vocabulary is a single false, and on the default path it usually does not even return that: sendmail accepts the message, so wp_mail() returns true, and the mail dies somewhere downstream where WordPress will never hear about it.

So the real problem is two problems. The mail is unauthenticated, and the failure is silent. Any fix has to solve both, or it has not fixed anything.

Two paths for the same message: the default drops it, the plugin signs it
WordPress builds the same message either way. On the default path it goes to PHP's mail() and out unsigned, where wp_mail() still returns true. The plugin swaps only the transport: a PHPMailer subclass hands the finished message to the SES API, signed, and it arrives aligned.

Why the usual fix is a half-fix

Search for this problem and every answer is the same: install an SMTP plugin, point it at a real mail service, done. WP Mail SMTP, Post SMTP, and a dozen others all do it, and they do fix the authentication half. Your mail now goes through a reputable server that signs it.

But look at where the credentials live. An SMTP plugin hooks phpmailer_init, sets $phpmailer->Host`, `$phpmailer->Username, and $phpmailer->Password, and it has to get those values from somewhere. That somewhere is the database, in wp_options.

Sit with what that means. Your SMTP username and password, the ones that let anything send mail as your domain, are now sitting in a table that ends up in every place a database ends up. The nightly backup. The S3 bucket that backup lands in. The staging clone a contractor pulled last month. The dump you emailed yourself to debug something. Anyone who touches any copy of that database can now send mail as you, DKIM-signed by your own domain, and there is no fingerprint that says it was not you.

For a brochure site, that is a small risk. For a newsroom whose domain reputation is an asset, it is the sort of thing that should keep you up at night. The half-fix traded a deliverability problem for a credential-management one, and then hid the trade in a settings screen.

The architecture: three moves

The fix is three decisions, and each one removes an entire class of the problem rather than mitigating it.

One: send through the SES API, not SMTP. SMTP means a username and a password, which means a secret at rest. The Amazon SES API is a signed HTTPS request. There is no long-lived password in the flow at all.

Two: authenticate with an IAM role, not a stored key. This is the move that makes the difference. If your WordPress runs on AWS compute, EC2 or ECS, the instance or task carries an IAM role, and AWS hands short-lived credentials to the machine automatically, rotated for you, never written down. The plugin reads them from the instance metadata endpoint at send time and uses them for that one request. Nothing is stored. Not in the database, not in wp-config.php, not on disk. There is no secret to leak because there is no secret at rest.

Three: replace only the transport, and reimplement nothing. This is the move that keeps the plugin honest, and it has a story behind it.

A seam, not a fork

WordPress 5.7 added a filter called pre_wp_mail. Return a non-null value from it and wp_mail() short-circuits, handing you the whole job. It is tempting to take it: hook the filter, build the SES call, return true.

It is also a trap, and I know the shape of this trap well, because I walked into a version of it once already. The last must-use plugin I wrote forked a core function, a copy of sanitize_title_with_dashes(), because core gave me no seam. That fork then drifted from core for three releases without anyone noticing, dropping an entire block of character handling, until it was mangling exactly the Arabic text it was meant to protect. The postscript on that article is a confession: a hand-copy of a core contract rots silently.

Short-circuiting pre_wp_mail would be the same mistake in a new costume. Take over wp_mail() and you now own its entire contract: header parsing, Cc and Bcc, Reply-To, content type, charset, attachments, and every wp_mail_* filter a hundred other plugins hook into. Reimplement all of that and your copy will drift from core the moment core changes, silently, exactly like last time.

So this plugin reimplements none of it. Core builds its own PHPMailer object, but only when the global is not already one:

global $phpmailer;
if ( ! ( $phpmailer instanceof PHPMailer\PHPMailer\PHPMailer ) ) {
    // ...core builds the mailer...
}

That if is the seam. Hand core a subclass of PHPMailer before it looks, and it uses yours. Core still assembles the entire message, still honours every filter, still does everything it always did. The subclass overrides one method, postSend(), the point where PHPMailer would normally talk to the transport, and ships the finished message to SES instead:

class WPSES_Mailer extends \PHPMailer\PHPMailer\PHPMailer {
    public function postSend() {
        $raw  = $this->getSentMIMEMessage();     // core built this, not us
        $sent = wpses_send_raw( $raw, /* recipients */ );

        if ( is_wp_error( $sent ) ) {
            // Throwing lands in core's own wp_mail_failed path.
            throw new \PHPMailer\PHPMailer\Exception( $sent->get_error_message() );
        }
        return true;
    }
}

We take the fully assembled MIME message that core produced and put it on the wire. We replace the transport and nothing else. There is no contract to reimplement, so there is nothing to drift. The lesson from the slug plugin, learned the expensive way, is baked into the architecture of this one: when core gives you a real seam, take the seam, never the fork.

Fail loudly, because the alternative is what we are here to fix

Go back to the password reset that vanished. The reason it vanished is that the failure was silent. So the cardinal rule of this plugin is that it is never silent.

When SES refuses a message, it tells you exactly why: an unverified sender, a sandbox restriction, a throttle. The plugin does not swallow that into a bare false. It logs the real SES error code, fires an action other code can hook, and, by throwing, lets the failure land in core's own wp_mail_failed so anything already listening for mail failures keeps working. A false is not a diagnosis. The SES error is.

And there is one thing the plugin pointedly refuses to do: fall back to PHP mail(). A fallback feels like the safe, considerate choice, and it is the exact opposite. Falling back means the mail appears to send, wp_mail() returns true, and the message lands in spam, which is the silent failure we started with, dressed up as resilience. A loud failure gets noticed and fixed. A silent success that lands in spam is never even seen.

The same principle governs what happens when the plugin is misconfigured. It does not shrug and let WordPress use sendmail. It refuses to send, and says why, on every admin screen and in the log. A must-use plugin that quietly stands aside is worse than no plugin at all, because now you believe your mail is going through SES when it is going straight to the spam folder.

A rejection you can see, versus one you cannot
Same rejected send, two outcomes. The plugin throws, so the failure lands in core's own wp_mail_failed with the real SES reason and wp_mail() returns false. The path it replaces swallows the error and returns true, and the message is gone with nothing logged.

The part every tutorial skips: bounces and complaints

Send real mail at any volume and some of it will bounce: addresses go dead, mailboxes fill up, people hit the spam button. Amazon SES watches those two numbers, your bounce rate and your complaint rate, and it is not being advisory. Cross the thresholds and it throttles your sending; stay across them and it suspends the account outright. One careless WordPress install, cheerfully emailing a list of addresses that stopped existing years ago, is enough to put a whole domain's ability to send at risk.

WordPress, left alone, will do exactly that. It has no memory of a bounce. Ask it to email a deleted address a thousand times and it will try a thousand times, because nothing in wp_mail() ever hears that the last nine hundred and ninety-nine failed.

So the plugin keeps the memory WordPress lacks. SES publishes every bounce and complaint to an SNS topic; the plugin exposes one endpoint for that topic, writes each dead or complaining address to a suppression table, and checks that table before every send. An address that hard-bounced or marked you as spam is simply never sent to again. That one feedback loop is most of the distance between a plugin that works on your blog and one you would run under a masthead. And if the plugin cannot record a bounce for a moment, a database blip mid-notification, it does not pretend it succeeded: it tells SNS so, and SNS holds the message and delivers it again, rather than letting a bounce quietly evaporate.

There is a sharp edge here that is easy to miss, and getting it wrong is worse than not having the feature at all. That SNS endpoint is a public URL. Anything on the internet can POST to it. An endpoint that believes whatever it receives is an open invitation: forge a notification and you could add any address you liked to the suppression list, silently blocking a site's own users from ever receiving its mail. So the endpoint trusts nothing by default. It pins the single SNS topic it is allowed to act on, and it verifies Amazon's cryptographic signature on every message before one row is written. A notification that is not from your topic, or whose signature does not check out, is refused. The last plugin taught the same lesson from the other side: the input you do not validate is the one that hurts you.

The traps worth naming

None of these are subtle once you know them, and every one has cost someone an afternoon. Named here so they cost you none:

  • Sandbox mode. A fresh SES account is sandboxed: it can only send to addresses you have verified, and the quota is tiny. Everything looks configured, and mail to real recipients is refused. The one-time fix is to request production access; the plugin's verify command tells you which mode you are in.
  • Region mismatch. SES identities are per-region. Verify your domain in eu-west-1, point the plugin at us-east-1, and every send fails for a reason that has nothing to do with your code. Send from the region you verified in.
  • An unverified From. SES will only send as an identity you own and have verified. The From domain has to be a verified identity, or the send is rejected outright, loudly, which is the whole point.
  • Alignment is DNS, not code. DKIM and SPF passing is what carries you to the inbox, and both are records you add, not switches the plugin can flip. Turn on Easy DKIM (the three CNAMEs SES hands you), set a custom MAIL FROM so SPF aligns, and DMARC then passes. The plugin signs the request; your DNS is what makes the result trusted.
  • The quotas are real. SES caps both a per-second rate and a rolling twenty-four-hour volume. A blast that ignores them does not queue politely, it fails. Know your limits before you lean on them.

Verifying it, and why the checker refuses to lie

A must-use plugin that sends mail silently is the problem. A plugin that tells you it is configured when it is not is the same problem wearing a badge. So the plugin ships a command whose entire job is to refuse to lie about its own health:

wp ses verify

It signs two real requests to SES with the exact code the send path uses, so a green result is proof the signing works, not a mock of it. It reports the things that actually break delivery: which region it is talking to, where the credentials came from (a role, ideally, not a stray key shadowing it), whether the From identity is verified, whether DKIM is on, whether the account is still sandboxed, the current quota, and whether the bounce topic is wired up. And it exits non-zero when any of that is wrong, so it composes:

wp ses verify || send-me-an-alert

Put that line in cron and a drifted config pages you before your users find it. This is the same discipline as the verify command in the slug plugin: a checker earns the right to say PASS by exercising the real thing, and it reports a failure it has not disproven rather than a reassuring green it has not earned. A check that cannot fail is not a check.

The plugin

It is one file. Drop it into wp-content/mu-plugins/, add a region and a From address to wp-config.php, give the machine an IAM role that can call SES, and WordPress mail goes through SES from the next send. No AWS SDK, no settings screen, no credentials in the database.

One honest word on where it is in its life: it is new. It is feature-complete and has been hardened through repeated adversarial review, with the SES signing checked against AWS's own test vectors, but I am shipping it as 0.9.0 while I run it end-to-end against a live SES account. So it is out in the open under the GPL, on GitHub and on Packagist, being finished in daylight rather than behind a curtain:

composer require mantekio/wp-ses-mail

The repository is github.com/mantekio/wp-ses-mail. It is the same loop as the last one: the article explains the thinking, the repository is the working plugin, the package is the one-line install, and each points at the other two.

Known limits

Being honest about what a tool does not do is part of the tool. This one has edges worth stating plainly:

  • It is a transport, not a mailing-list engine. It sends the mail WordPress already wants to send, one message at a time, well. A newsletter blast to tens of thousands of subscribers wants a queue and probably a dedicated bulk sender; leaning on this for that just meets SES throttling as a failed send.
  • No retry or backoff yet. A send SES refuses fails loudly and stops. It does not queue and try again later. For transactional mail that is usually the right behaviour (you want to know now); if you need durable retries, that is a queue in front of the plugin, not the plugin.
  • There is a message-size ceiling. SES caps the size of a single message. A mail with very large inline attachments can exceed it, and that send will fail rather than be silently truncated.
  • Alignment is yours to configure. The plugin signs every message, but DKIM, SPF and DMARC alignment are DNS decisions it cannot make for you. It can tell you they are missing; it cannot add the records.
  • It needs WordPress 5.7 or newer. The whole clean design rests on pre_wp_mail, which arrived in 5.7. On anything older there is no seam to take: mail falls through to sendmail, and the plugin can warn you but cannot stop it. If you are on a WordPress that old, the mail path is not the first thing to fix.

Sending an email from WordPress is easy. Running mail you can trust for a newsroom, where a lost password reset is a locked-out journalist and a suspended domain is every address on it going quiet, is a different job, and the difference lives entirely in the boring parts. No secret at rest, because there is no secret. A failure you can see, because silence is the bug. A memory of every bounce, because SES keeps score whether or not you do. None of it is clever. All of it is the distance between mail that works on a blog and mail that holds up under a masthead.

That is the whole of how we treat WordPress on AWS: take the seam core gives you, store nothing you would mind leaking, and fail where someone can see it. If your WordPress mail is a mystery you would rather it were not, let's talk.

🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

Just completed another large-scale WordPress migration — and the client left this

saqib_devmorph - Apr 7

Your AI Doesn't Just Write Tests. It Runs Them Too.

Kevin Martinez - May 12

AWS Certifications Are a Building Block, Not the Final Destination

Ijay - Jun 16

Why Your WordPress Site Is Slow (It Is Not Always Hosting)

ApogeeWatcherverified - Jul 13

Your Backup Data Knows More Than You Think. HYCU aiR Is Finally Asking It the Right Questions.

Tom Smithverified - May 14
chevron_left
769 Points6 Badges
Dubai, UAEmantek.io
2Posts
1Comments
4Connections
Founder of ManTek Technologies (Dubai). I build full-stack WordPress and AWS systems for Arabic news... Show more

Related Jobs

View all jobs →

Commenters (This Week)

3 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!