WordPress Email Deliverability Engineering: Transactional Email Architecture, SPF/DKIM/DMARC, and High-Volume Sending
WordPress sends email for everything. Password resets, order confirmations, comment notifications, plugin alerts, form submissions, subscription receipts. Every single one of those messages passes through a function called wp_mail(), and by default, that function relies on PHP’s built-in mail() function. This is where the trouble starts.
On a typical shared hosting environment, PHP mail() hands the message off to the server’s local mail transfer agent, usually Sendmail or Postfix. That message leaves the server with no authentication, no cryptographic signature, and no alignment between the sending domain and the server’s actual hostname. Gmail, Outlook, Yahoo, and every other major inbox provider will look at that message and see a stranger knocking on the door with no ID.
The result is predictable. Messages land in spam folders. Password reset links never arrive. WooCommerce order confirmations vanish into the void. Site owners blame WordPress, but WordPress is not the problem. The problem is infrastructure: authentication records, sending reputation, and transport-layer configuration.
This article covers the full engineering stack for WordPress email deliverability. We will examine why the default sending path fails, how DNS-based authentication works at the protocol level, how to architect transactional and marketing email streams separately, and how to build production-grade sending infrastructure with queues, bounce processing, and monitoring.
Why wp_mail() With PHP mail() Fails
To understand the failure, you need to understand what happens when wp_mail() fires. The function lives in wp-includes/pluggable.php, meaning any plugin can replace it entirely. In its default form, it creates a PHPMailer instance and calls PHPMailer::send(), which by default uses PHP’s mail() function.
Here is the chain of events:
- WordPress core or a plugin calls
wp_mail( $to, $subject, $message, $headers, $attachments ) wp_mail()instantiates PHPMailer, sets the from address (default:[email protected]), and configures the message- PHPMailer calls PHP’s
mail()function - PHP’s
mail()invokes the system’s sendmail binary (or equivalent) - The local MTA attempts to deliver the message directly to the recipient’s mail server
Every step in this chain introduces a failure point. The “From” address is often [email protected], which is a mailbox that does not exist. The envelope sender (the MAIL FROM in the SMTP transaction) is typically the web server’s system user, something like [email protected]. This mismatch between the header “From” and the envelope sender is the first red flag for spam filters.
The second problem is the sending IP address. Shared hosting servers send email from IP addresses shared with hundreds of other accounts. If any one of those accounts sends spam, the IP reputation drops for everyone. Major blocklist operators like Spamhaus, Barracuda, and SpamCop maintain real-time databases of flagged IPs. A single bad neighbor can put your transactional email into spam folders across the entire internet.
The third problem is authentication. When your message arrives at Gmail’s mail servers, Gmail asks three questions:
- Is the sending IP authorized to send email for this domain? (SPF check)
- Is the message cryptographically signed by this domain? (DKIM check)
- What should we do if these checks fail? (DMARC policy)
With default PHP mail(), the answer to all three questions is either “no” or “we don’t know.” The message has no SPF alignment because the sending server’s IP is not listed in your domain’s SPF record. There is no DKIM signature because the local MTA does not sign messages with your domain’s private key. And if you have no DMARC record, receiving servers have no policy guidance and will make their own judgment call, which is almost always “spam.”
Let us look at what a failed delivery looks like in the SMTP transaction log. When your server connects to Gmail’s MX server, the conversation goes like this:
220 mx.google.com ESMTP d2si7843946pgs.394
EHLO server47.hostingcompany.com
250-mx.google.com at your service
MAIL FROM:<[email protected]>
250 2.1.0 OK
RCPT TO:<[email protected]>
250 2.1.5 OK
DATA
354 Go ahead
From: WordPress <[email protected]>
To: [email protected]
Subject: Your order has shipped
...
.
250 2.0.0 OK 1658400000 d2si7843946pgs.394
Gmail accepted the message (250 OK), but that does not mean it will reach the inbox. After acceptance, Gmail’s internal filters evaluate SPF, DKIM, and DMARC. The envelope sender is [email protected], but the “From” header says [email protected]. SPF for server47.hostingcompany.com might pass (the hosting company’s SPF record authorizes that IP), but it is irrelevant because the domain does not match the “From” header domain. DKIM? No signature present. DMARC for yourstore.com? Either absent or failing alignment. The message goes to spam.
Email Authentication: SPF Records
SPF (Sender Policy Framework) is a DNS TXT record that lists the IP addresses and hostnames authorized to send email on behalf of your domain. When a receiving mail server gets a message claiming to be from your domain, it looks up your SPF record and checks whether the sending server’s IP address is listed.
An SPF record is published as a TXT record on your domain’s DNS. Here is a real-world example for a domain that uses Google Workspace for corporate email and Amazon SES for transactional email:
yourstore.com. 300 IN TXT "v=spf1 include:_spf.google.com include:amazonses.com -all"
Breaking this down:
v=spf1declares this is an SPF record (version 1, the only version)include:_spf.google.comauthorizes all IPs that Google’s SPF record authorizes (for Google Workspace sending)include:amazonses.comauthorizes Amazon SES sending IPs-allis the enforcement mechanism: reject (hard fail) any server not listed above
The -all vs ~all distinction matters. A hard fail (-all) tells receivers to reject unauthorized senders outright. A soft fail (~all) says “this is suspicious but don’t reject it.” In practice, most receivers treat soft fail and hard fail similarly for spam scoring purposes, but -all is the stronger signal and the recommended setting once you have confirmed all your legitimate sending sources are included.
SPF has a critical limitation: the 10-lookup limit. Each include: mechanism triggers a DNS lookup, and SPF allows a maximum of 10 DNS lookups total (including nested lookups within included records). Google’s _spf.google.com alone consumes 3-4 lookups. Adding SendGrid, Mailchimp, your help desk, and your marketing platform can easily exceed 10 lookups, causing SPF to return a “permerror” result, which many receivers treat as a failure.
You can check your SPF lookup count with command-line tools:
# Check the raw SPF record
dig +short TXT yourstore.com | grep spf
# Count lookups recursively
# Each include, a, mx, ptr, exists, and redirect counts as one lookup
nslookup -type=txt _spf.google.com
If you hit the 10-lookup limit, the solution is to flatten your SPF record by replacing include: mechanisms with explicit IP ranges. Services like SPF Flattening tools can automate this, but be aware that provider IP ranges change, so flattened records need periodic updates.
SPF Alignment
SPF alone is not enough. DMARC requires “alignment,” meaning the domain in the SPF check (the envelope sender/MAIL FROM domain) must match the domain in the “From” header. If your envelope sender is [email protected] and your “From” header is [email protected], SPF alignment passes in “relaxed” mode (the organizational domains match) but fails in “strict” mode (the exact domains differ).
Most email service providers set the envelope sender to a subdomain of your domain. For example, SendGrid uses something like em1234.yourstore.com, and you add an SPF record for that subdomain. Amazon SES lets you configure a custom MAIL FROM domain like mail.yourstore.com. This is critical for SPF alignment with DMARC.
Email Authentication: DKIM Signing
DKIM (DomainKeys Identified Mail) adds a cryptographic signature to outgoing messages. The sending server signs the message headers and body with a private key, and the receiving server verifies the signature using a public key published in DNS.
A DKIM signature is added as a header in the email message. It looks like this:
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=yourstore.com; s=ses202207;
h=from:to:subject:date:message-id:content-type:mime-version;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=AuUoFEfDxTDkHlLXSZEpZj79LICEps6eda7W3deTVFOk2yFUjhfW
FsJn4SRzqHWlgHBUNjKrDmRMSDnvFSBhVnFYGkDglnH...
The key components:
d=yourstore.comis the signing domains=ses202207is the selector, used to look up the public key in DNSh=lists which headers are included in the signaturebh=is the hash of the message bodyb=is the actual cryptographic signature
The receiving server takes the selector (ses202207) and domain (yourstore.com) and looks up the DNS record at ses202207._domainkey.yourstore.com:
ses202207._domainkey.yourstore.com. 300 IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1Z3..."
Using the public key (p=), the receiver decrypts the signature and compares it against its own hash of the signed headers and body. If they match, the message has not been tampered with in transit and was genuinely sent by someone holding the private key for that domain.
DKIM is particularly powerful because it survives forwarding. Unlike SPF, which breaks when a message is forwarded (the forwarding server’s IP is not in the original domain’s SPF record), DKIM signatures remain intact as long as the signed headers and body are not modified.
Each email service provider gives you DKIM records to add to your DNS. Here are real examples of DNS CNAME records for common providers:
# Amazon SES DKIM (using Easy DKIM with three CNAME records)
abc123._domainkey.yourstore.com CNAME abc123.dkim.amazonses.com
def456._domainkey.yourstore.com CNAME def456.dkim.amazonses.com
ghi789._domainkey.yourstore.com CNAME ghi789.dkim.amazonses.com
# SendGrid DKIM
s1._domainkey.yourstore.com CNAME s1.domainkey.u1234567.wl042.sendgrid.net
s2._domainkey.yourstore.com CNAME s2.domainkey.u1234567.wl042.sendgrid.net
# Postmark DKIM
20220721._domainkey.yourstore.com TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3..."
# Mailgun DKIM
smtp._domainkey.yourstore.com TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3..."
DMARC Policies: Tying SPF and DKIM Together
DMARC (Domain-based Message Authentication, Reporting, and Conformance) is the policy layer that sits on top of SPF and DKIM. It tells receiving mail servers what to do when authentication fails and provides a reporting mechanism so you can monitor authentication results across all mail sent from your domain.
A DMARC record is a DNS TXT record at _dmarc.yourstore.com:
_dmarc.yourstore.com. 300 IN TXT "v=DMARC1; p=reject; rua=mailto:[email protected]; ruf=mailto:[email protected]; adkim=r; aspf=r; pct=100"
Each tag has a specific meaning:
v=DMARC1identifies this as a DMARC recordp=rejectis the policy: reject messages that fail authentication. Options arenone(monitor only),quarantine(send to spam), orreject(bounce the message)rua=mailto:[email protected]is where aggregate reports are sent (daily XML reports from receivers)ruf=mailto:[email protected]is where forensic (per-message failure) reports are sentadkim=rsets DKIM alignment to “relaxed” (organizational domain match is sufficient)aspf=rsets SPF alignment to “relaxed”pct=100applies the policy to 100% of messages (useful for gradual rollout)
The recommended deployment sequence for DMARC is:
- Start with
p=noneto collect reports without affecting delivery - Analyze aggregate reports for 2-4 weeks to identify all legitimate sending sources
- Fix SPF and DKIM for all legitimate sources
- Move to
p=quarantine; pct=25to quarantine 25% of failing messages - Gradually increase
pctto 100 - Move to
p=rejectonce you are confident all legitimate mail is authenticated
DMARC aggregate reports are XML files that look like this (abbreviated):
<feedback>
<report_metadata>
<org_name>google.com</org_name>
<date_range>
<begin>1658275200</begin>
<end>1658361599</end>
</date_range>
</report_metadata>
<record>
<row>
<source_ip>198.51.100.42</source_ip>
<count>47</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>pass</spf>
</policy_evaluated>
</row>
</record>
<record>
<row>
<source_ip>203.0.113.99</source_ip>
<count>3</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>fail</dkim>
<spf>fail</spf>
</policy_evaluated>
</row>
</record>
</feedback>
The first record shows 47 messages from IP 198.51.100.42 that passed both DKIM and SPF. This is probably your legitimate email provider. The second record shows 3 messages from IP 203.0.113.99 that failed both checks. This could be a spoofing attempt, or it could be a legitimate service you forgot to authorize. Tools like Postmark’s DMARC monitoring, dmarcian, or Valimail can parse these reports into readable dashboards.
Transactional vs Marketing Email Separation
One of the most consequential architecture decisions for WordPress email is separating transactional and marketing email streams. These two types of email have fundamentally different characteristics, and mixing them on the same sending infrastructure is a recipe for deliverability problems.
Transactional emails are triggered by a user action and contain information the recipient is expecting: password resets, order confirmations, shipping notifications, account alerts. They have high open rates (typically 60-80%), very low spam complaint rates, and recipients actively want them.
Marketing emails are sent in bulk to a list: newsletters, promotions, product announcements, abandoned cart sequences. They have lower open rates (15-25% is considered good), higher unsubscribe rates, and inevitably generate some spam complaints.
The problem with sending both types from the same infrastructure is reputation contamination. If your marketing emails generate a 0.3% spam complaint rate (which is above the 0.1% threshold that Google enforces), that complaint rate affects the reputation of the IP addresses and domain used for sending. Your transactional emails, which are mission-critical, start landing in spam because they share reputation with your marketing stream.
The solution is to use separate sending infrastructure for each stream:
- Transactional: Use a provider optimized for transactional email (Postmark, Amazon SES, or a dedicated SendGrid subuser) sending from a subdomain like
mail.yourstore.com - Marketing: Use a marketing platform (Mailchimp, ConvertKit, ActiveCampaign) sending from a different subdomain like
news.yourstore.com
Each subdomain gets its own SPF, DKIM, and DMARC records. Each builds its own sending reputation. If the marketing stream takes a reputation hit, the transactional stream continues unaffected.
In WordPress, this separation is implemented at the wp_mail() level. Your SMTP plugin (WP Mail SMTP, FluentSMTP, or a custom implementation) handles all wp_mail() calls through your transactional provider. Marketing email is handled entirely outside of wp_mail() by the marketing platform’s own API or plugin.
For WooCommerce sites that need both, the architecture looks like this:
WordPress wp_mail()
|
+--> SMTP Plugin --> Postmark (transactional)
| - Order confirmations
| - Shipping notifications
| - Password resets
| - Account emails
|
Marketing Plugin (Mailchimp/ConvertKit)
|
+--> Provider API --> Mailchimp (marketing)
- Newsletters
- Promotions
- Abandoned cart emails
- Win-back campaigns
Provider Comparison: Amazon SES, Postmark, SendGrid, and Mailgun
Choosing a transactional email provider is a decision that affects deliverability, cost, and operational complexity. Here is a detailed comparison of the four major providers used with WordPress, based on real production experience.
Amazon SES
Amazon Simple Email Service is the lowest-cost option by a wide margin. Pricing is $0.10 per 1,000 emails with no monthly minimum. If your WordPress site sends 50,000 transactional emails per month, that is $5.00. If you are running on EC2, the first 62,000 emails per month are free.
SES requires more setup than other providers. You must verify your sending domain, request production access (new accounts start in a sandbox that only allows sending to verified addresses), configure a custom MAIL FROM domain, and set up three DKIM CNAME records. The API is powerful but lower-level than competitors.
SES provides built-in bounce and complaint handling through SNS (Simple Notification Service) topics. You create an SNS topic, subscribe an HTTPS endpoint to it, and SES publishes bounce and complaint notifications to that topic. This is essential for maintaining sending reputation, as we will cover in the bounce processing section.
The WordPress integration typically uses the WP Offload SES plugin from Delicious Brains or a custom implementation using the AWS SDK for PHP.
Postmark
Postmark is purpose-built for transactional email and actively refuses to send marketing email. This policy is their strongest selling point: because every customer on their platform sends only transactional email, the shared IP pools maintain extremely high reputation. Postmark consistently achieves the fastest inbox delivery times in independent tests, often placing messages in Gmail’s inbox within 1-3 seconds of sending.
Pricing starts at $15/month for 10,000 emails. Additional emails cost $1.50 per 1,000. For a site sending 50,000 emails per month, the cost is approximately $75. More expensive than SES, but you are paying for superior deliverability and a dedicated transactional-only infrastructure.
Postmark provides an official WordPress plugin that replaces wp_mail() with API-based sending. It also offers message streams (separate transactional and broadcast streams with independent reputation), detailed delivery analytics, and webhook-based bounce and spam complaint processing.
SendGrid
SendGrid (now owned by Twilio) offers both transactional and marketing email, which is both its strength and its weakness. The free tier includes 100 emails per day. Paid plans start at $19.95/month for 50,000 emails. The Essentials plan at that tier includes basic analytics, and the Pro plan (starting at $89.95/month) adds dedicated IP addresses, which are critical for controlling your own sending reputation.
Because SendGrid serves both transactional and marketing senders, the shared IP pools have more variable reputation than Postmark’s transactional-only pools. If you are serious about deliverability on SendGrid, you need the Pro plan with a dedicated IP, which means you are responsible for warming that IP gradually (start with low volume and increase over weeks).
SendGrid’s WordPress integration is mature. The official plugin handles SMTP and API-based sending. Their event webhook provides detailed delivery data including opens, clicks, bounces, and spam reports.
Mailgun
Mailgun (owned by Sinch) positions itself as a developer-friendly email API. The Flex plan offers 5,000 free emails per month for 3 months, then charges $0.80 per 1,000 emails. The Foundation plan at $35/month includes 50,000 emails, and the Scale plan at $90/month includes 100,000 emails with dedicated IPs.
Mailgun’s standout feature is its email validation API, which can verify email addresses before you send to them. This reduces bounces and improves list quality. Their routing rules are also powerful, allowing you to process incoming email programmatically.
For WordPress, the Mailgun plugin from the WordPress.org repository has had inconsistent maintenance. Many developers prefer using WP Mail SMTP’s Mailgun integration or building a custom integration with the Mailgun PHP SDK.
Cost Comparison at Scale
Here is what each provider costs at common sending volumes:
| Monthly Volume | Amazon SES | Postmark | SendGrid | Mailgun |
|---|---|---|---|---|
| 10,000 | $1.00 | $15.00 | $0 (free tier) | $0 (Flex) |
| 50,000 | $5.00 | $75.00 | $19.95 | $35.00 |
| 100,000 | $10.00 | $130.00 | $19.95 | $90.00 |
| 500,000 | $50.00 | $500.00 | $89.95 (Pro) | $350.00 |
Amazon SES is the clear winner on price. Postmark is the premium choice for deliverability. SendGrid offers the best mid-range value if you need both transactional and marketing. Mailgun sits in between with strong developer tooling.
Building an Email Queue With Action Scheduler
WordPress sites that send high volumes of email (WooCommerce stores with hundreds of daily orders, membership sites with notification emails, multisite networks) face a throughput problem. Calling wp_mail() synchronously during a page request adds latency, and if the SMTP connection times out, the request fails entirely. A user placing an order should not have to wait 3 seconds for the order confirmation email to send before seeing the “thank you” page.
The solution is an email queue: instead of sending email immediately, you store the message in a queue and process it asynchronously in the background. WordPress has a built-in cron system (wp_cron), but it is unreliable for time-sensitive tasks because it only fires on page visits. A much better option is Action Scheduler, the battle-tested job queue library that WooCommerce itself uses internally.
Action Scheduler is a scalable, traceable job queue built on WordPress custom post types (or custom tables in newer versions). It handles retries, failure logging, and concurrent processing. Here is how to build an email queue with it.
First, create a function that queues email instead of sending it immediately:
<?php
/**
* Queue an email for background sending via Action Scheduler.
*
* @param string $to Recipient email address.
* @param string $subject Email subject line.
* @param string $message Email body (HTML or plain text).
* @param array|string $headers Optional. Additional headers.
* @param array $attachments Optional. Files to attach.
* @return int The action ID from Action Scheduler.
*/
function wpkite_queue_email( $to, $subject, $message, $headers = '', $attachments = array() ) {
$email_data = array(
'to' => $to,
'subject' => $subject,
'message' => $message,
'headers' => $headers,
'attachments' => $attachments,
'queued_at' => current_time( 'mysql' ),
);
// Schedule for immediate async processing.
$action_id = as_enqueue_async_action(
'wpkite_send_queued_email',
array( $email_data ),
'wpkite-email-queue'
);
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( sprintf(
'WPKite Email Queue: Queued email to %s (subject: %s) as action #%d',
$to,
$subject,
$action_id
) );
}
return $action_id;
}
/**
* Process a queued email. This runs in the background via Action Scheduler.
*
* @param array $email_data The email parameters.
*/
function wpkite_process_queued_email( $email_data ) {
$sent = wp_mail(
$email_data['to'],
$email_data['subject'],
$email_data['message'],
$email_data['headers'],
$email_data['attachments']
);
if ( ! $sent ) {
$error = $GLOBALS['phpmailer']->ErrorInfo ?? 'Unknown error';
error_log( sprintf(
'WPKite Email Queue: Failed to send email to %s. Error: %s',
$email_data['to'],
$error
) );
// Throw exception so Action Scheduler marks this as failed and retries.
throw new Exception( 'Email sending failed: ' . $error );
}
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( sprintf(
'WPKite Email Queue: Successfully sent email to %s (subject: %s)',
$email_data['to'],
$email_data['subject']
) );
}
}
add_action( 'wpkite_send_queued_email', 'wpkite_process_queued_email', 10, 1 );
Now you can replace direct wp_mail() calls with wpkite_queue_email(). The email data is stored in the Action Scheduler tables and processed on the next background run, typically within 60 seconds.
For higher throughput, you can configure Action Scheduler to process more actions per batch and run on a real system cron instead of WordPress pseudo-cron:
// In wp-config.php: Disable WordPress pseudo-cron
define( 'DISABLE_WP_CRON', true );
// In your server's crontab (runs every minute):
// * * * * * cd /var/www/yourstore.com && wp action-scheduler run --force 2>&1
// In your theme's functions.php: Increase batch size
add_filter( 'action_scheduler_queue_runner_batch_size', function() {
return 50; // Process up to 50 emails per batch (default is 25).
} );
// Set maximum concurrent batches
add_filter( 'action_scheduler_queue_runner_concurrent_batches', function() {
return 3; // Allow 3 concurrent batch processors.
} );
Action Scheduler handles retry logic automatically. By default, failed actions are retried up to 3 times with exponential backoff (5 minutes, 20 minutes, 60 minutes). You can customize this:
// Customize retry behavior for email actions.
add_filter( 'action_scheduler_failure_period', function( $period, $action_id ) {
$action = ActionScheduler::store()->fetch_action( $action_id );
if ( $action && $action->get_hook() === 'wpkite_send_queued_email' ) {
return 120; // Retry after 2 minutes instead of default 5 minutes.
}
return $period;
}, 10, 2 );
add_filter( 'action_scheduler_max_attempts', function( $max, $action_id ) {
$action = ActionScheduler::store()->fetch_action( $action_id );
if ( $action && $action->get_hook() === 'wpkite_send_queued_email' ) {
return 5; // Allow 5 retry attempts for emails.
}
return $max;
}, 10, 2 );
For rate limiting (important when your provider has per-second sending limits), add a delay between queued actions:
/**
* Queue a batch of emails with rate limiting.
* Spaces emails 100ms apart to stay under SES's 14/second limit.
*
* @param array $emails Array of email data arrays.
*/
function wpkite_queue_email_batch( $emails ) {
$delay = 0;
foreach ( $emails as $email_data ) {
as_schedule_single_action(
time() + $delay,
'wpkite_send_queued_email',
array( $email_data ),
'wpkite-email-queue'
);
$delay++; // 1-second spacing between each email.
}
}
Bounce and Complaint Processing: Webhook Handlers
Every email provider sends bounce and complaint notifications. Processing these notifications is not optional. If you continue sending email to addresses that have bounced or to users who have marked your email as spam, your sending reputation degrades rapidly. Amazon SES will suspend your account if your bounce rate exceeds 5% or your complaint rate exceeds 0.1%.
There are two types of bounces:
- Hard bounces: The address does not exist or the domain does not accept email. These are permanent. You must stop sending to these addresses immediately.
- Soft bounces: The mailbox is full, the server is temporarily unavailable, or the message is too large. These are temporary. You can retry a few times, but if the address soft-bounces repeatedly, treat it as a hard bounce.
Complaints (also called “feedback loop” reports) occur when a recipient clicks the “Report Spam” button in their email client. Gmail, Yahoo, and Outlook all participate in feedback loop programs that notify senders when this happens.
Here is a webhook handler for processing Amazon SES bounce and complaint notifications delivered via SNS:
<?php
/**
* Webhook endpoint for Amazon SES bounce/complaint notifications via SNS.
* Register this endpoint at: https://yourstore.com/wp-json/wpkite/v1/ses-webhook
*/
add_action( 'rest_api_init', function() {
register_rest_route( 'wpkite/v1', '/ses-webhook', array(
'methods' => 'POST',
'callback' => 'wpkite_handle_ses_webhook',
'permission_callback' => '__return_true', // SNS cannot authenticate.
) );
} );
function wpkite_handle_ses_webhook( WP_REST_Request $request ) {
$body = $request->get_body();
$data = json_decode( $body, true );
if ( ! $data ) {
return new WP_REST_Response( 'Invalid JSON', 400 );
}
// Handle SNS subscription confirmation (required on first setup).
if ( isset( $data['Type'] ) && $data['Type'] === 'SubscriptionConfirmation' ) {
$subscribe_url = $data['SubscribeURL'] ?? '';
if ( $subscribe_url ) {
wp_remote_get( $subscribe_url );
return new WP_REST_Response( 'Subscription confirmed', 200 );
}
}
// Handle notification messages.
if ( isset( $data['Type'] ) && $data['Type'] === 'Notification' ) {
$message = json_decode( $data['Message'], true );
if ( ! $message ) {
return new WP_REST_Response( 'Invalid message payload', 400 );
}
$notification_type = $message['notificationType'] ?? '';
switch ( $notification_type ) {
case 'Bounce':
wpkite_process_ses_bounce( $message['bounce'] );
break;
case 'Complaint':
wpkite_process_ses_complaint( $message['complaint'] );
break;
case 'Delivery':
wpkite_process_ses_delivery( $message['delivery'] );
break;
}
}
return new WP_REST_Response( 'OK', 200 );
}
/**
* Process a bounce notification from SES.
*
* @param array $bounce Bounce data from SES notification.
*/
function wpkite_process_ses_bounce( $bounce ) {
$bounce_type = $bounce['bounceType'] ?? 'Unknown';
$bounced_emails = $bounce['bouncedRecipients'] ?? array();
foreach ( $bounced_emails as $recipient ) {
$email = sanitize_email( $recipient['emailAddress'] ?? '' );
$status = $recipient['status'] ?? '';
$action = $recipient['action'] ?? '';
if ( empty( $email ) ) {
continue;
}
if ( $bounce_type === 'Permanent' ) {
// Hard bounce: suppress this address immediately.
wpkite_suppress_email_address( $email, 'hard_bounce', sprintf(
'Permanent bounce (status: %s, action: %s)',
$status,
$action
) );
} else {
// Soft bounce: log it, suppress after 3 occurrences.
wpkite_record_soft_bounce( $email, $status );
}
error_log( sprintf(
'WPKite SES Bounce: %s bounce for %s (status: %s)',
$bounce_type,
$email,
$status
) );
}
}
/**
* Process a complaint notification from SES.
*
* @param array $complaint Complaint data from SES notification.
*/
function wpkite_process_ses_complaint( $complaint ) {
$complained_emails = $complaint['complainedRecipients'] ?? array();
$feedback_type = $complaint['complaintFeedbackType'] ?? 'unknown';
foreach ( $complained_emails as $recipient ) {
$email = sanitize_email( $recipient['emailAddress'] ?? '' );
if ( empty( $email ) ) {
continue;
}
// Any complaint means immediate suppression.
wpkite_suppress_email_address( $email, 'complaint', sprintf(
'Spam complaint (feedback type: %s)',
$feedback_type
) );
error_log( sprintf(
'WPKite SES Complaint: %s reported spam (type: %s)',
$email,
$feedback_type
) );
}
}
/**
* Add an email address to the suppression list.
*
* @param string $email The email address to suppress.
* @param string $reason Reason for suppression (hard_bounce, complaint, etc).
* @param string $detail Additional detail about the suppression.
*/
function wpkite_suppress_email_address( $email, $reason, $detail = '' ) {
global $wpdb;
$table = $wpdb->prefix . 'wpkite_email_suppressions';
$wpdb->replace(
$table,
array(
'email' => $email,
'reason' => $reason,
'detail' => $detail,
'suppressed_at' => current_time( 'mysql' ),
),
array( '%s', '%s', '%s', '%s' )
);
// Also unsubscribe from newsletter if applicable.
$wpdb->update(
$wpdb->prefix . 'wpkite_subscribers',
array( 'status' => 'unsubscribed' ),
array( 'email' => $email ),
array( '%s' ),
array( '%s' )
);
}
/**
* Check if an email address is suppressed before sending.
*
* @param string $email The email address to check.
* @return bool True if the address is suppressed.
*/
function wpkite_is_email_suppressed( $email ) {
global $wpdb;
$table = $wpdb->prefix . 'wpkite_email_suppressions';
$suppressed = $wpdb->get_var( $wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE email = %s",
$email
) );
return (int) $suppressed > 0;
}
// Hook into wp_mail to check suppression list before sending.
add_filter( 'wp_mail', function( $args ) {
$to = is_array( $args['to'] ) ? $args['to'] : array( $args['to'] );
foreach ( $to as $key => $email ) {
$clean_email = sanitize_email( $email );
if ( wpkite_is_email_suppressed( $clean_email ) ) {
unset( $to[ $key ] );
error_log( sprintf(
'WPKite Email Suppression: Blocked send to suppressed address %s',
$clean_email
) );
}
}
$args['to'] = array_values( $to );
return $args;
} );
The suppression table schema for this system:
CREATE TABLE wp_wpkite_email_suppressions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
email VARCHAR(255) NOT NULL,
reason ENUM('hard_bounce', 'soft_bounce', 'complaint', 'unsubscribe', 'manual') NOT NULL,
detail TEXT,
suppressed_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY email (email),
KEY reason (reason),
KEY suppressed_at (suppressed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
For Postmark, the webhook payload is simpler but the logic is identical. Postmark sends bounce notifications to a webhook URL you configure in your server settings. The payload includes the bounce type, email address, and error description. SendGrid and Mailgun have similar webhook systems with their own payload formats.
HTML Email Templating With wp_mail() Filters
By default, wp_mail() sends plain text emails. To send HTML email, you need to set the content type header. WordPress provides several filters that let you customize email behavior without modifying core files.
The most common approach is to use the wp_mail_content_type filter:
// Set HTML content type for all emails.
add_filter( 'wp_mail_content_type', function() {
return 'text/html';
} );
// Set a proper "From" name and address.
add_filter( 'wp_mail_from', function() {
return '[email protected]';
} );
add_filter( 'wp_mail_from_name', function() {
return 'YourStore';
} );
However, setting HTML content type globally can cause problems with plain text emails from plugins. A better approach is to build an email template system that wraps messages in HTML only when needed:
<?php
/**
* Email template wrapper for WPKite transactional emails.
*
* @param string $subject The email subject.
* @param string $body_html The email body content (HTML).
* @param string $preheader Optional preview text shown in inbox list.
* @return string Complete HTML email document.
*/
function wpkite_email_template( $subject, $body_html, $preheader = '' ) {
$logo_url = get_theme_file_uri( 'assets/images/logo.svg' );
$site_name = get_bloginfo( 'name' );
$site_url = home_url();
$year = date( 'Y' );
ob_start();
?>
<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="x-apple-disable-message-reformatting">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title><?php echo esc_html( $subject ); ?></title>
<style>
/* Reset */
body, table, td { margin: 0; padding: 0; }
img { border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
table { border-collapse: collapse !important; }
body { width: 100% !important; height: 100% !important; margin: 0 !important; padding: 0 !important; }
/* Typography */
body, td, p { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.6; color: #1a1a2e; }
h1 { font-size: 24px; line-height: 1.3; margin: 0 0 16px 0; color: #1a1a2e; }
h2 { font-size: 20px; line-height: 1.3; margin: 0 0 12px 0; color: #1a1a2e; }
a { color: #2563eb; text-decoration: underline; }
/* Button */
.btn { display: inline-block; padding: 14px 28px; background-color: #2563eb; color: #ffffff !important; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px; }
/* Dark mode */
@media (prefers-color-scheme: dark) {
body, td, p { color: #e0e0e0 !important; }
.email-body { background-color: #1a1a2e !important; }
.email-content { background-color: #2d2d44 !important; }
h1, h2, h3 { color: #ffffff !important; }
}
</style>
</head>
<body class="email-body" style="background-color: #f4f4f7; margin: 0; padding: 0;">
<?php if ( $preheader ) : ?>
<div style="display: none; max-height: 0; overflow: hidden; font-size: 1px; line-height: 1px; color: #f4f4f7;">
<?php echo esc_html( $preheader ); ?>
<!-- Padding to push Gmail's snippet text out -->
‌ ‌ ‌ ‌ ‌
</div>
<?php endif; ?>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f7;">
<tr>
<td align="center" style="padding: 40px 20px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width: 600px; width: 100%;">
<!-- Header -->
<tr>
<td align="center" style="padding: 0 0 30px 0;">
<a href="<?php echo esc_url( $site_url ); ?>">
<img src="<?php echo esc_url( $logo_url ); ?>" alt="<?php echo esc_attr( $site_name ); ?>" width="150" style="width: 150px;">
</a>
</td>
</tr>
<!-- Content -->
<tr>
<td class="email-content" style="background-color: #ffffff; border-radius: 8px; padding: 40px 32px;">
<?php echo $body_html; ?>
</td>
</tr>
<!-- Footer -->
<tr>
<td align="center" style="padding: 30px 0 0 0;">
<p style="font-size: 13px; color: #8c8ca1; margin: 0;">
© <?php echo $year; ?> <?php echo esc_html( $site_name ); ?>. All rights reserved.
</p>
<p style="font-size: 13px; color: #8c8ca1; margin: 8px 0 0 0;">
You received this email because of your account at
<a href="<?php echo esc_url( $site_url ); ?>" style="color: #8c8ca1;"><?php echo esc_html( $site_name ); ?></a>.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
<?php
return ob_get_clean();
}
/**
* Send a branded transactional email.
*
* @param string $to Recipient email.
* @param string $subject Email subject.
* @param string $body_html Body content (will be wrapped in template).
* @param string $preheader Optional inbox preview text.
* @return bool Whether the email was sent successfully.
*/
function wpkite_send_email( $to, $subject, $body_html, $preheader = '' ) {
$html = wpkite_email_template( $subject, $body_html, $preheader );
$headers = array(
'Content-Type: text/html; charset=UTF-8',
'From: WPKite Support <[email protected]>',
);
return wp_mail( $to, $subject, $html, $headers );
}
A few critical details about HTML email that trip up developers. Email clients strip the <head> tag in many cases, so critical styles must be inlined. Gmail clips messages longer than 102KB, so keep your templates lean. Outlook on Windows uses the Word rendering engine (yes, Microsoft Word) to display HTML email, which means floats, flexbox, and grid do not work at all. Tables are the only reliable layout mechanism for email.
The preheader text trick is worth noting separately. The preheader is the snippet of text that appears next to the subject line in inbox list views. Without an explicit preheader, email clients pull the first text content from the body, which is often “View in browser” or worse. By placing hidden text at the very beginning of the body, you control what appears in the inbox preview. The zero-width non-joiner characters (‌) after the preheader text prevent the email client from pulling additional body text into the preview.
For WooCommerce sites, the wp_mail filter provides a powerful hook to wrap all outgoing email in your branded template:
/**
* Automatically wrap all wp_mail() HTML emails in the branded template.
* Checks for Content-Type header to avoid wrapping plain text emails.
*/
add_filter( 'wp_mail', function( $args ) {
$is_html = false;
if ( is_array( $args['headers'] ) ) {
foreach ( $args['headers'] as $header ) {
if ( stripos( $header, 'text/html' ) !== false ) {
$is_html = true;
break;
}
}
} elseif ( is_string( $args['headers'] ) ) {
$is_html = stripos( $args['headers'], 'text/html' ) !== false;
}
if ( $is_html ) {
$args['message'] = wpkite_email_template(
$args['subject'],
$args['message']
);
}
return $args;
} );
Monitoring Deliverability: Open Rates, Bounces, and Spam Complaints
Sending email without monitoring deliverability is like running a web server without checking access logs. You need to know what is happening to your messages after they leave your server. The key metrics are:
- Delivery rate: Percentage of emails accepted by the receiving server (should be 98%+ for transactional email)
- Bounce rate: Percentage of emails that bounced (keep under 2% hard bounce rate)
- Complaint rate: Percentage of recipients who clicked “Report Spam” (must stay under 0.1% for Gmail, 0.3% for most others)
- Open rate: Percentage of recipients who opened the email (60-80% for transactional, note that Apple Mail Privacy Protection inflates this metric)
- Inbox placement rate: Percentage of emails that reached the inbox vs spam folder (requires seed testing with tools like GlockApps or Inbox Monster)
Each email provider offers its own dashboard for these metrics. Amazon SES publishes metrics to CloudWatch. Postmark provides a real-time dashboard with per-message delivery status. SendGrid offers an analytics dashboard with 30-day trends. But relying solely on provider dashboards has limitations: they tell you whether the message was accepted, not whether it reached the inbox.
For a WordPress-native monitoring solution, you can log email events in a custom table and build a simple admin dashboard:
<?php
/**
* Log all outgoing emails and their status.
*/
add_action( 'wp_mail_succeeded', function( $mail_data ) {
global $wpdb;
$wpdb->insert(
$wpdb->prefix . 'wpkite_email_log',
array(
'to_address' => is_array( $mail_data['to'] ) ? implode( ', ', $mail_data['to'] ) : $mail_data['to'],
'subject' => $mail_data['subject'],
'status' => 'sent',
'sent_at' => current_time( 'mysql' ),
),
array( '%s', '%s', '%s', '%s' )
);
} );
add_action( 'wp_mail_failed', function( WP_Error $error ) {
global $wpdb;
$mail_data = $error->get_error_data();
$wpdb->insert(
$wpdb->prefix . 'wpkite_email_log',
array(
'to_address' => is_array( $mail_data['to'] ) ? implode( ', ', $mail_data['to'] ) : ( $mail_data['to'] ?? 'unknown' ),
'subject' => $mail_data['subject'] ?? 'unknown',
'status' => 'failed',
'error_info' => $error->get_error_message(),
'sent_at' => current_time( 'mysql' ),
),
array( '%s', '%s', '%s', '%s', '%s' )
);
error_log( 'WPKite Email Failed: ' . $error->get_error_message() );
} );
/**
* Admin page showing email delivery statistics.
*/
function wpkite_email_stats_page() {
global $wpdb;
$table = $wpdb->prefix . 'wpkite_email_log';
$total_sent = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE status = 'sent'" );
$total_failed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE status = 'failed'" );
$total_bounced = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->prefix}wpkite_email_suppressions WHERE reason = 'hard_bounce'"
);
$total_complaints = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->prefix}wpkite_email_suppressions WHERE reason = 'complaint'"
);
$total = $total_sent + $total_failed;
$delivery_rate = $total > 0 ? round( ( $total_sent / $total ) * 100, 2 ) : 0;
$bounce_rate = $total_sent > 0 ? round( ( $total_bounced / $total_sent ) * 100, 3 ) : 0;
$complaint_rate = $total_sent > 0 ? round( ( $total_complaints / $total_sent ) * 100, 3 ) : 0;
// Recent failed emails for debugging.
$recent_failures = $wpdb->get_results(
"SELECT to_address, subject, error_info, sent_at
FROM {$table}
WHERE status = 'failed'
ORDER BY sent_at DESC
LIMIT 20"
);
?>
<div class="wrap">
<h1>Email Deliverability Dashboard</h1>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin: 20px 0;">
<div style="background: #fff; padding: 20px; border: 1px solid #ccd0d4; border-radius: 4px;">
<h3 style="margin: 0 0 8px 0; color: #646970;">Delivery Rate</h3>
<p style="font-size: 32px; font-weight: 700; margin: 0; color: <?php echo $delivery_rate >= 98 ? '#00a32a' : '#d63638'; ?>">
<?php echo $delivery_rate; ?>%
</p>
</div>
<div style="background: #fff; padding: 20px; border: 1px solid #ccd0d4; border-radius: 4px;">
<h3 style="margin: 0 0 8px 0; color: #646970;">Bounce Rate</h3>
<p style="font-size: 32px; font-weight: 700; margin: 0; color: <?php echo $bounce_rate < 2 ? '#00a32a' : '#d63638'; ?>">
<?php echo $bounce_rate; ?>%
</p>
</div>
<div style="background: #fff; padding: 20px; border: 1px solid #ccd0d4; border-radius: 4px;">
<h3 style="margin: 0 0 8px 0; color: #646970;">Complaint Rate</h3>
<p style="font-size: 32px; font-weight: 700; margin: 0; color: <?php echo $complaint_rate < 0.1 ? '#00a32a' : '#d63638'; ?>">
<?php echo $complaint_rate; ?>%
</p>
</div>
<div style="background: #fff; padding: 20px; border: 1px solid #ccd0d4; border-radius: 4px;">
<h3 style="margin: 0 0 8px 0; color: #646970;">Total Sent</h3>
<p style="font-size: 32px; font-weight: 700; margin: 0;">
<?php echo number_format( $total_sent ); ?>
</p>
</div>
</div>
<?php if ( $recent_failures ) : ?>
<h2>Recent Failures</h2>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th>Recipient</th>
<th>Subject</th>
<th>Error</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<?php foreach ( $recent_failures as $failure ) : ?>
<tr>
<td><?php echo esc_html( $failure->to_address ); ?></td>
<td><?php echo esc_html( $failure->subject ); ?></td>
<td><?php echo esc_html( $failure->error_info ); ?></td>
<td><?php echo esc_html( $failure->sent_at ); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php
}
Google provides a free tool called Postmaster Tools (postmaster.google.com) that shows your domain’s reputation with Gmail, spam rate, authentication results, and encryption status. This is the single most valuable deliverability monitoring tool available because Gmail represents roughly 30-40% of all email recipients. To use it, you verify your domain ownership through a DNS TXT record and then access the dashboard.
For systematic inbox placement testing, you send emails to seed addresses at major providers (Gmail, Outlook, Yahoo, Apple Mail) and check whether they land in inbox or spam. Services like GlockApps, Mail-Tester, and Inbox Monster automate this process. Mail-Tester.com is a quick free option: send an email to their test address, visit the URL, and get a 0-10 score with detailed feedback on authentication, content, and blocklist status.
WooCommerce Email Customization and Architecture
WooCommerce has its own email system built on top of wp_mail(). Understanding its architecture is essential if you are running an online store, because WooCommerce emails are the most critical transactional messages your site sends. A customer who does not receive their order confirmation will contact support or, worse, initiate a chargeback.
WooCommerce emails are class-based. Each email type extends the WC_Email base class. The core email types include:
WC_Email_New_Order(sent to admin when a new order is placed)WC_Email_Customer_Processing_Order(sent to customer after payment)WC_Email_Customer_Completed_Order(sent when order status changes to completed)WC_Email_Customer_Invoice(sent manually from the order admin page)WC_Email_Customer_Note(sent when a note is added to an order)WC_Email_Customer_Reset_Password(password reset)WC_Email_Customer_New_Account(welcome email after registration)
Each email class defines its trigger (which WooCommerce hook fires it), subject line, heading, template file, and recipient. The templates live in woocommerce/templates/emails/ and can be overridden by copying them to yourtheme/woocommerce/emails/.
The email rendering pipeline works like this:
- A WooCommerce action fires (e.g.,
woocommerce_order_status_processing) - The corresponding
WC_Emailsubclass’strigger()method runs - The email content is generated by loading a PHP template from the theme or plugin directory
- The content is wrapped in the WooCommerce email header and footer templates
- Inline CSS is applied via the Emogrifier library (converts stylesheet rules to inline styles for email client compatibility)
wp_mail()is called with the final HTML
To customize WooCommerce emails at the code level, you have several hooks available:
<?php
/**
* Customize WooCommerce email subjects dynamically.
*/
add_filter( 'woocommerce_email_subject_customer_processing_order', function( $subject, $order ) {
return sprintf(
'Order #%s confirmed - we are on it, %s!',
$order->get_order_number(),
$order->get_billing_first_name()
);
}, 10, 2 );
/**
* Add custom content after the order table in processing emails.
*/
add_action( 'woocommerce_email_after_order_table', function( $order, $sent_to_admin, $plain_text, $email ) {
// Only add to customer processing order emails.
if ( $email->id !== 'customer_processing_order' ) {
return;
}
if ( $plain_text ) {
echo "\n\nEstimated delivery: 3-5 business days\n";
return;
}
echo '<div style="padding: 16px; background: #f0f7ff; border-radius: 6px; margin: 16px 0;">';
echo '<h3 style="margin: 0 0 8px 0;">What happens next?</h3>';
echo '<p style="margin: 0;">We are preparing your order right now. ';
echo 'You will receive a shipping confirmation email with tracking information ';
echo 'within 24 hours. Estimated delivery: 3-5 business days.</p>';
echo '</div>';
}, 10, 4 );
/**
* Add a custom email class to WooCommerce.
* This example adds a "Subscription Activated" email for a membership site.
*/
add_filter( 'woocommerce_email_classes', function( $email_classes ) {
$email_classes['WC_Email_Subscription_Activated'] = new WC_Email_Subscription_Activated();
return $email_classes;
} );
/**
* Custom WooCommerce email class for subscription activation.
*/
class WC_Email_Subscription_Activated extends WC_Email {
public function __construct() {
$this->id = 'subscription_activated';
$this->title = 'Subscription Activated';
$this->description = 'Sent to customers when their subscription is activated.';
$this->customer_email = true;
$this->heading = 'Your subscription is active!';
$this->subject = 'Welcome aboard - your {site_title} subscription is live';
$this->template_html = 'emails/subscription-activated.php';
$this->template_plain = 'emails/plain/subscription-activated.php';
$this->template_base = get_stylesheet_directory() . '/woocommerce/';
// Trigger on custom action.
add_action( 'wpkite_subscription_activated_notification', array( $this, 'trigger' ), 10, 2 );
parent::__construct();
}
public function trigger( $user_id, $subscription_data ) {
$this->setup_locale();
$user = get_userdata( $user_id );
if ( ! $user ) {
return;
}
$this->recipient = $user->user_email;
$this->placeholders['{user_name}'] = $user->display_name;
$this->placeholders['{plan_name}'] = $subscription_data['plan'] ?? 'Standard';
if ( $this->is_enabled() && $this->get_recipient() ) {
$this->send(
$this->get_recipient(),
$this->get_subject(),
$this->get_content(),
$this->get_headers(),
$this->get_attachments()
);
}
$this->restore_locale();
}
public function get_content_html() {
return wc_get_template_html(
$this->template_html,
array(
'email_heading' => $this->get_heading(),
'user_name' => $this->placeholders['{user_name}'],
'plan_name' => $this->placeholders['{plan_name}'],
'sent_to_admin' => false,
'plain_text' => false,
'email' => $this,
),
'',
$this->template_base
);
}
public function get_content_plain() {
return wc_get_template_html(
$this->template_plain,
array(
'email_heading' => $this->get_heading(),
'user_name' => $this->placeholders['{user_name}'],
'plan_name' => $this->placeholders['{plan_name}'],
'sent_to_admin' => false,
'plain_text' => true,
'email' => $this,
),
'',
$this->template_base
);
}
}
One WooCommerce-specific deliverability issue deserves mention: email throttling during high-traffic sales events. If your store processes 500 orders in an hour during a flash sale, that means 500+ order confirmation emails, 500+ admin notification emails, and potentially 500+ payment receipt emails. That is 1,500 emails in one hour, or about 25 per minute. Most transactional email providers handle this volume without issue, but if you are still using PHP mail() on shared hosting, the server’s mail queue will back up and emails will be delayed by hours or simply dropped.
The email queue approach described in the Action Scheduler section solves this problem. By queuing WooCommerce emails instead of sending them synchronously, the checkout process remains fast and emails are dispatched in controlled batches in the background.
Debugging Delivery Failures: SMTP Transaction Logging
When emails fail to deliver, you need to see the actual SMTP conversation between your server and the receiving mail server. Error messages from wp_mail() are often vague (“Could not instantiate mail function” is the classic unhelpful message). Real debugging requires SMTP-level logging.
PHPMailer, which WordPress uses internally, supports debug output at multiple levels:
- Level 0: No output (default)
- Level 1: Client messages only
- Level 2: Client and server messages
- Level 3: Client, server, and connection messages
- Level 4: All messages plus low-level data
You can enable SMTP debug logging in WordPress using the phpmailer_init action:
<?php
/**
* Enable detailed SMTP logging for debugging delivery failures.
* IMPORTANT: Disable this in production. SMTP logs may contain sensitive information.
*/
add_action( 'phpmailer_init', function( PHPMailer\PHPMailer\PHPMailer $phpmailer ) {
// Only enable in development or when explicitly debugging.
if ( ! defined( 'WPKITE_SMTP_DEBUG' ) || ! WPKITE_SMTP_DEBUG ) {
return;
}
$phpmailer->SMTPDebug = 3; // Show client + server + connection messages.
// Log to file instead of outputting to browser.
$phpmailer->Debugoutput = function( $str, $level ) {
$log_file = WP_CONTENT_DIR . '/smtp-debug.log';
$timestamp = current_time( 'Y-m-d H:i:s' );
file_put_contents(
$log_file,
"[{$timestamp}] [{$level}] {$str}\n",
FILE_APPEND | LOCK_EX
);
};
} );
Add the debug constant to wp-config.php when you need to troubleshoot:
// Enable SMTP debug logging (remove after debugging).
define( 'WPKITE_SMTP_DEBUG', true );
Here is what a typical SMTP debug log looks like for a successful delivery via Amazon SES:
[2022-07-21 11:30:15] [connection] Connection: opening to email-smtp.us-east-1.amazonaws.com:587
[2022-07-21 11:30:15] [server] 220 email-smtp.amazonaws.com ESMTP SimpleEmailService
[2022-07-21 11:30:15] [client] EHLO yourstore.com
[2022-07-21 11:30:15] [server] 250-email-smtp.amazonaws.com
[2022-07-21 11:30:15] [server] 250-8BITMIME
[2022-07-21 11:30:15] [server] 250-STARTTLS
[2022-07-21 11:30:15] [server] 250-AUTH PLAIN LOGIN
[2022-07-21 11:30:15] [server] 250 Ok
[2022-07-21 11:30:15] [client] STARTTLS
[2022-07-21 11:30:15] [server] 220 Ready to start TLS
[2022-07-21 11:30:15] [client] EHLO yourstore.com
[2022-07-21 11:30:15] [server] 250-email-smtp.amazonaws.com
[2022-07-21 11:30:15] [server] 250 Ok
[2022-07-21 11:30:15] [client] AUTH LOGIN
[2022-07-21 11:30:15] [server] 334 VXNlcm5hbWU6
[2022-07-21 11:30:15] [client] [credentials hidden]
[2022-07-21 11:30:16] [server] 235 Authentication successful.
[2022-07-21 11:30:16] [client] MAIL FROM:<[email protected]>
[2022-07-21 11:30:16] [server] 250 Ok
[2022-07-21 11:30:16] [client] RCPT TO:<[email protected]>
[2022-07-21 11:30:16] [server] 250 Ok
[2022-07-21 11:30:16] [client] DATA
[2022-07-21 11:30:16] [server] 354 End data with <CR><LF>.<CR><LF>
[2022-07-21 11:30:16] [client] [message data sent]
[2022-07-21 11:30:16] [server] 250 Ok 0102017f8a9b0c01-abcdef12-3456-7890-abcd-ef1234567890-000000
And here is what a failed delivery looks like when the SMTP credentials are wrong:
[2022-07-21 11:35:22] [connection] Connection: opening to email-smtp.us-east-1.amazonaws.com:587
[2022-07-21 11:35:22] [server] 220 email-smtp.amazonaws.com ESMTP SimpleEmailService
[2022-07-21 11:35:22] [client] EHLO yourstore.com
[2022-07-21 11:35:22] [server] 250 Ok
[2022-07-21 11:35:22] [client] STARTTLS
[2022-07-21 11:35:22] [server] 220 Ready to start TLS
[2022-07-21 11:35:22] [client] AUTH LOGIN
[2022-07-21 11:35:22] [server] 334 VXNlcm5hbWU6
[2022-07-21 11:35:22] [client] [credentials hidden]
[2022-07-21 11:35:22] [server] 535 Authentication Credentials Invalid
[2022-07-21 11:35:22] [client] QUIT
SMTP ERROR: Password command failed: 535 Authentication Credentials Invalid
Common SMTP errors and their causes:
- 535 Authentication Credentials Invalid: Wrong SMTP username or password. For SES, make sure you are using SMTP credentials (not IAM access keys).
- 454 Throttling failure: You have exceeded the provider’s sending rate limit. Implement the email queue described earlier.
- 550 5.7.1 Message rejected: The receiving server rejected the message based on content or reputation. Check your content for spam triggers and your sending IP reputation.
- 421 Too many concurrent SMTP connections: Your server is opening too many simultaneous connections. Reduce batch concurrency.
- Connection timeout: Firewall blocking port 587 or 465. Common on shared hosting. Try port 25 (usually blocked too) or use the provider’s HTTP API instead of SMTP.
For production monitoring, you should also log the PHPMailer error on every failed send:
/**
* Capture and log PHPMailer errors with full context.
*/
add_action( 'wp_mail_failed', function( WP_Error $wp_error ) {
$data = $wp_error->get_error_data();
$message = $wp_error->get_error_message();
$to = $data['to'] ?? 'unknown';
$subject = $data['subject'] ?? 'unknown';
$phpmailer = $data['phpmailer_exception_code'] ?? 'none';
error_log( sprintf(
'WPKite SMTP Failure | To: %s | Subject: %s | Error: %s | PHPMailer Code: %s',
is_array( $to ) ? implode( ', ', $to ) : $to,
$subject,
$message,
$phpmailer
) );
} );
A useful debugging technique for persistent delivery issues is to test the SMTP connection independently of WordPress. Use a command-line tool like swaks (Swiss Army Knife for SMTP):
# Install swaks
apt-get install swaks # Debian/Ubuntu
brew install swaks # macOS
# Test SMTP connection to Amazon SES
swaks \
--to [email protected] \
--from [email protected] \
--server email-smtp.us-east-1.amazonaws.com \
--port 587 \
--tls \
--auth LOGIN \
--auth-user "AKIAIOSFODNN7EXAMPLE" \
--auth-password "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" \
--header "Subject: SMTP Test" \
--body "This is a test message from swaks."
# Test with verbose output to see full SMTP conversation
swaks --to [email protected] --server smtp.yourprovider.com --port 587 --tls --auth --auth-user user --auth-password pass -v
If swaks succeeds but WordPress fails, the problem is in your WordPress configuration (plugin conflict, wrong settings, firewall rules for the PHP process). If swaks also fails, the problem is at the network or provider level.
Putting It All Together: A Production Checklist
Setting up production-grade email deliverability for WordPress is a multi-step process that touches DNS, your email provider, your WordPress configuration, and your monitoring tools. Here is a step-by-step checklist that covers every piece:
DNS Configuration
- Choose a transactional email provider (Postmark, SES, SendGrid, or Mailgun)
- Add the provider’s SPF include to your domain’s SPF record
- Verify your SPF record has 10 or fewer DNS lookups total
- Add the provider’s DKIM records (CNAME or TXT) to your DNS
- Publish a DMARC record starting with
p=noneto collect data - Configure a custom MAIL FROM domain (e.g.,
mail.yourstore.com) with its own SPF record - Wait for DNS propagation (check with
digor MXToolbox)
Provider Configuration
- Verify your sending domain with the provider
- If using SES, request production access and set up SNS topics for bounces and complaints
- Configure bounce and complaint webhook URLs pointing to your WordPress REST API endpoints
- If using a dedicated IP, plan a 2-4 week warm-up schedule starting with low volume
WordPress Configuration
- Install and configure an SMTP plugin (or build custom integration)
- Set the “From” address to a real, monitored mailbox on your domain
- Disable WordPress pseudo-cron and set up a real system cron job
- Implement the email queue using Action Scheduler for high-volume sites
- Add the suppression list check to
wp_mailfilter - Implement email logging (both successes and failures)
- Send a test email and verify DKIM signature and SPF pass in the message headers
Monitoring Setup
- Register your domain with Google Postmaster Tools
- Set up DMARC report processing (use a service like dmarcian or Postmark’s free DMARC tool)
- Monitor bounce rate (keep under 2%) and complaint rate (keep under 0.1%)
- Run an initial inbox placement test with Mail-Tester or GlockApps
- Set up alerts for delivery failures in your email log
Ongoing Maintenance
- Review DMARC aggregate reports weekly until you reach
p=reject - Audit your SPF record whenever you add a new service that sends email
- Prune your suppression list quarterly (some providers allow re-enabling addresses after 90 days if the underlying issue is resolved)
- Run inbox placement tests monthly, especially after any changes to your email templates or sending infrastructure
- Monitor Google Postmaster Tools for reputation changes
Email deliverability is not a one-time setup. It is an ongoing operational discipline. Your sending reputation is built over months and can be destroyed in hours by a single misconfiguration, a bad import of email addresses, or a plugin that starts sending unexpected email. The infrastructure described in this article gives you the foundation to send reliably and the monitoring tools to catch problems before they escalate.
The investment pays for itself. A WooCommerce store that sends order confirmations to the inbox instead of spam sees fewer support tickets, fewer chargebacks, and higher customer satisfaction. A membership site that reliably delivers password reset emails reduces account lockouts and support burden. A publisher whose newsletter actually reaches subscribers maintains engagement and revenue. Email infrastructure is invisible when it works. The goal is to make it invisible permanently.
Nadia Okafor
Full-stack WordPress developer with a focus on internationalization, transactional email, and background processing. Works with clients across 15 countries.