wp_mail() is WordPress's wrapper around PHPMailer, and by default it sends every message as text/plain. Switching to HTML takes one filter on wp_mail_content_type — but if your HTML emails are landing in spam, the filter is the easy 10% of the problem. The harder 90% is deliverability: SPF, DKIM, DMARC, and not sending transactional mail through your shared host's PHP mail() binary.
How do I send HTML emails with wp_mail() in WordPress?
Add a filter on wp_mail_content_type that returns text/html:
function te_set_html_content_type() {
return 'text/html';
}
add_filter( 'wp_mail_content_type', 'te_set_html_content_type' );After that filter is registered, every subsequent wp_mail() call sends with the text/html MIME type and any HTML in the $message parameter renders as HTML in the recipient's inbox. For per-call control (instead of changing every email site-wide), pass the content-type header directly in the $headers array of the wp_mail() call. Both approaches end up at the same place — PHPMailer::$ContentType getting set to text/html before the message is handed off to the mail transport.
The filter approach is global. The header approach is per-call. Use the filter when your site is HTML-by-default; use the header when one specific transactional email needs HTML and the rest stay text.
Jump to:
- The wp_mail content type filter
- Three patterns for setting the content type
- HTML email best practices
- SMTP setup beats PHP mail()
- Modern deliverability: SPF, DKIM, DMARC
- Common pitfalls
- Transactional vs marketing
- Modern alternatives to wp_mail
- What to do next
- FAQ
The wp_mail content type filter
wp_mail() constructs a PHPMailer instance, applies a chain of filters to fill in headers, body, and attachments, then calls PHPMailer::send(). The wp_mail_content_type filter sits between "default to text/plain" and "send the message", and whatever you return is used verbatim as the MIME type.
The simplest filter:
function te_set_html_content_type() {
return 'text/html';
}
add_filter( 'wp_mail_content_type', 'te_set_html_content_type' );The gotcha: applying this globally affects every email WordPress sends. That includes the password reset email, which wraps the reset URL in <...> (angle brackets) by default. As text/html, those brackets get parsed as an empty HTML tag and the reset link disappears from the rendered email.
The fix is to scope the filter to the specific places you actually want HTML:
function te_send_html_welcome( $user_id ) {
$user = get_userdata( $user_id );
add_filter( 'wp_mail_content_type', 'te_set_html_content_type' );
wp_mail(
$user->user_email,
'Welcome to the site',
'<h1>Welcome, ' . esc_html( $user->display_name ) . '</h1><p>Glad to have you.</p>'
);
remove_filter( 'wp_mail_content_type', 'te_set_html_content_type' );
}
add_action( 'user_register', 'te_send_html_welcome' );The remove_filter() after the send is the important part. Without it, the password reset email that fires when a new user clicks "Set my password" goes out as text/html and the link is invisible.
Three patterns for setting the content type
Three places this logic can live, in order of how I reach for them:
Pattern 1: dedicated filter function with explicit add/remove
function te_wp_mail_html() {
return 'text/html';
}
function te_form_submission_process() {
// Your submission processing
add_filter( 'wp_mail_content_type', 'te_wp_mail_html' );
wp_mail( $to, $subject, $message, $headers, $attachments );
remove_filter( 'wp_mail_content_type', 'te_wp_mail_html' );
}
add_action( 'some_event_action', 'te_form_submission_process' );Best for code organization — the filter function te_wp_mail_html can be reused across multiple call sites, and the add/remove pair is explicit.
Pattern 2: anonymous closures inline
function te_form_submission_process() {
// Your submission processing
add_filter( 'wp_mail_content_type', function() {
return 'text/html';
} );
wp_mail( $to, $subject, $message, $headers, $attachments );
add_filter( 'wp_mail_content_type', function() {
return 'text/plain';
} );
}Compact, but harder to remove individual filters cleanly. The "reset to text/plain" pattern works but leaves both closures registered on the filter, with the more recent one winning. Acceptable for one-off scripts; not great for plugin code that needs predictable filter state.
Pattern 3: per-call header
function te_form_submission_process() {
$headers = array( 'Content-Type: text/html; charset=UTF-8' );
wp_mail( $to, $subject, $message, $headers, $attachments );
}The cleanest for one-off HTML emails. No filter to add or remove — the content type is set just for this single call, and the rest of WordPress continues sending text emails. I default to this pattern unless I have a reason to set the content type globally.
The charset=UTF-8 part matters more than it looks. Without it, some MUAs (mostly older Outlook) fall back to the system locale, which mangles non-ASCII characters in subject lines or bodies.
HTML email best practices
HTML email is HTML stuck in 1999. Browser CSS rules don't apply; what works in a modern web page often doesn't work in an inbox. The conventions that still hold:
- Inline all CSS. Many email clients (especially Outlook Desktop and the corporate Gmail web UI in some configurations) strip
<style>blocks. Run your template through a CSS inliner (emogrifier,juice, or Premailer) as part of the build. - Table-based layout for structural HTML.
<div>and flexbox don't render reliably across Outlook 2007–2019. The pattern that works everywhere: nested<table>elements withcellpadding,cellspacing, andborder="0"set explicitly. - Responsive via media queries with fallback for non-supporters. Outlook ignores
<style>media queries. Use a fluid table layout as the base; let media queries be progressive enhancement. - Web-safe fonts or webfont declarations with fallbacks. Arial, Helvetica, Georgia, Times New Roman, Verdana — these render everywhere. Webfonts via Google Fonts work in Gmail web, Apple Mail, iOS Mail, but not Outlook.
- Don't rely on background images. Outlook's VML hack works but is fragile. Use solid colors or embedded images.
- Test in a litmus / email-on-acid sandbox before any high-volume send. Real-device testing catches the things the inline-CSS validator misses.
If you're using WordPress to send marketing email at scale, don't roll your own HTML — use a transactional provider's template engine (see Modern alternatives).
SMTP setup beats PHP mail()
The default WordPress mail transport is PHP's mail() function, which on most servers is a thin wrapper around sendmail or postfix. That works on a properly configured mail server, but most shared hosting and most cloud VMs ship with poorly-configured (or completely-absent) outbound mail.
The symptoms of bad transport:
- Emails to Gmail and Outlook land in spam or get bounced silently.
- Emails to your own custom domain work, but Gmail/Outlook recipients see nothing.
- The site logs say "email sent successfully" but recipients report nothing arriving.
The fix is to route wp_mail() through a real SMTP transport. Two approaches:
WP Mail SMTP plugin (or equivalent)
The path-of-least-resistance for non-developers. Install WP Mail SMTP, FluentSMTP, Post SMTP, or a similar plugin. Configure with your provider's SMTP credentials (SendGrid, Mailgun, Postmark, Amazon SES, Resend, Brevo, MailerSend). The plugin overrides wp_mail to send through SMTP instead of mail().
For most sites this is the right answer. The plugin handles connection pooling, retries, and most provider-specific quirks. WP Mail SMTP also adds an "email log" feature that's invaluable for debugging "did this email actually go out?".
phpmailer_init action for direct SMTP
If you need fine-grained control or don't want a plugin, the phpmailer_init action gives you the PHPMailer instance right before it sends:
add_action( 'phpmailer_init', function( $phpmailer ) {
$phpmailer->isSMTP();
$phpmailer->Host = 'smtp.sendgrid.net';
$phpmailer->Port = 587;
$phpmailer->SMTPSecure = 'tls';
$phpmailer->SMTPAuth = true;
$phpmailer->Username = 'apikey'; // SendGrid's username is literally "apikey"
$phpmailer->Password = getenv( 'SENDGRID_API_KEY' );
$phpmailer->From = 'noreply@example.com';
$phpmailer->FromName = 'Example Site';
} );Store credentials in environment variables, not in wp-config.php committed to git. The plugin-based approach handles all of this with a UI; the action-based approach is for environments where you want zero plugin dependencies.
Modern deliverability: SPF, DKIM, DMARC
Switching to SMTP gets your mail off shared-host IPs, but the recipient's spam filter still wants three DNS records that prove you authorized this mail server to send on your domain's behalf:
| Record | What it does | Where it lives |
|---|---|---|
SPF (v=spf1 include:sendgrid.net -all) | Lists the IPs/hosts allowed to send mail FROM your domain | TXT record on the bare domain |
| DKIM | Cryptographic signature added to every outgoing message; recipient verifies it via public key in your DNS | TXT record at a provider-specific selector (e.g., s1._domainkey.example.com) |
DMARC (v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com) | Tells recipients what to do with mail that fails SPF or DKIM, and where to send aggregate reports | TXT record at _dmarc.example.com |
Without all three, Gmail and Yahoo (as of February 2024) will throttle or reject bulk senders, and recipient spam filters will treat your messages as suspect even at low volume.
The honest checklist:
- SPF: add a TXT record at your domain root. Include the SPF mechanism of every service that sends mail for you (Google Workspace, transactional provider, marketing platform). Wrap with
-all(hard fail) once you're confident;~all(soft fail) is the safer default during rollout. - DKIM: copy the DNS record your transactional provider gives you. SendGrid, Mailgun, Postmark all have a "domain authentication" flow that generates the selector and public key.
- DMARC: start with
p=noneand arua=reporting address. Wait two weeks. Review aggregate reports. Once everything passes, move top=quarantine, thenp=reject.
To verify your SPF, DKIM, and DMARC records are configured correctly, use the DNS health check tool — it runs all three checks plus MX, NS, and SOA in one pass and flags exactly which record is missing or misformed.
Common pitfalls
The recurring problems with HTML email from WordPress:
| Symptom | Cause | Fix |
|---|---|---|
| HTML rendered as escaped text in recipient inbox | Filter not registered before wp_mail() call | Move add_filter line above wp_mail |
| Password reset link disappears | Global text/html filter clashes with <URL> brackets | Scope filter to specific call, use remove_filter after |
| Characters mangled in subject lines | Missing charset | Add Content-Type: text/html; charset=UTF-8 |
| Emails sent but never arrive | PHP mail() to badly-configured sendmail | Switch to SMTP via WP Mail SMTP or phpmailer_init |
| Gmail says "via amazonses.com" or similar | DKIM not configured for your domain | Add DKIM CNAME records from your provider |
| Emails go to spam | SPF / DKIM / DMARC missing or misaligned | Verify with DNS health check |
| Images don't display | Recipient's client blocks remote images by default | Inline critical images via Content-ID, or accept that images load only after the recipient clicks "display images" |
| Email looks fine in Gmail, broken in Outlook | Outlook's MSO-specific HTML rendering | Use table-based layout, MSO conditional comments, test in Litmus |
Transactional vs marketing
These are different products that look similar.
Transactional email is one message per user action: password resets, order confirmations, comment notifications, two-factor codes. Volume per send is low, but timeliness matters — a password reset that arrives 20 minutes late is broken. Transactional providers (Postmark, SendGrid Transactional, Mailgun, SES) optimize for sub-minute delivery and low spam-fold risk.
Marketing email is one message to many users at once: newsletters, product announcements, drip campaigns. Volume per send is high, timeliness is loose, but the recipient relationship is fragile (CAN-SPAM Act and GDPR apply). Marketing providers (Mailchimp, ConvertKit, Klaviyo, Brevo) optimize for list management, segmentation, A/B testing, and unsubscribe handling.
wp_mail() is fine for transactional. For marketing, route through a dedicated marketing platform's API rather than WordPress. Don't blast a newsletter through wp_mail() even if you can — the volume will trip spam filters and you'll end up on a blocklist.
Modern alternatives to wp_mail
If you're building a new WordPress integration in 2026, consider whether wp_mail() is the right abstraction at all. A few alternatives, in order of how often I reach for them:
| Provider | Strengths | When to use |
|---|---|---|
| Resend | Modern API, React Email templating, generous free tier | Default for new projects — clean DX |
| Postmark | Best-in-class deliverability, separate transactional/broadcast streams | When transactional reliability is critical |
| SendGrid | Mature, large free tier, established | Large-volume sends with existing template library |
| Mailgun | Strong API, EU region option, good logs | EU-resident customer base |
| Amazon SES | Cheapest at scale (~$0.10/1000 sends), no UI | High-volume, dev-team-owned infrastructure |
| MailerSend | Solid transactional + bulk hybrid | Mixed transactional/marketing workload |
The pattern: send via the provider's REST API directly when you control the integration, fall back to wp_mail() (via WP Mail SMTP plugin → provider SMTP gateway) for code you don't own (plugins, themes, WordPress core).
For administrative recovery related to email-locked-out scenarios — when your wp_mail() setup is broken and the password reset email never arrives — see how to change a WordPress password. The database method works without needing email delivery.
What to do next
For related deliverability and WordPress reliability work:
- DNS health check — verify SPF, DKIM, DMARC, MX, and the rest of your mail-related DNS in one pass. The tool I check before every production cutover.
- Change a WordPress password — admin recovery when email delivery is broken and the password reset flow can't help.
- PHP memory limit reference — relevant if bulk email sends are exhausting PHP memory.
- wp_insert_post memory deep-dive — adjacent reference for bulk-WordPress operations.
- WordPress: moderate comments using regular expressions — comment-notification emails benefit from the same
wp_mailfilters described here. - WPScan usage and man page — security audit your WordPress install if you suspect a compromised plugin is sending unauthorized email through
wp_mail().





