TechEarl

WordPress: Sending HTML Formatted Emails Using the wp_mail() Function

How to send HTML emails from WordPress with wp_mail and the wp_mail_content_type filter. Covers SMTP setup, deliverability with SPF, DKIM, and DMARC, and modern transactional providers like SendGrid, Postmark, and Resend.

Ishan KarunaratneIshan Karunaratne⏱️ 15 min readUpdated
How to send HTML emails from WordPress with wp_mail and the wp_mail_content_type filter. Covers SMTP setup, deliverability with SPF, DKIM, and DMARC, and modern transactional providers like SendGrid, Postmark, and Resend.

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:

php
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

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:

php
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:

php
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

php
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

php
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

php
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 with cellpadding, cellspacing, and border="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:

php
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:

RecordWhat it doesWhere it lives
SPF (v=spf1 include:sendgrid.net -all)Lists the IPs/hosts allowed to send mail FROM your domainTXT record on the bare domain
DKIMCryptographic signature added to every outgoing message; recipient verifies it via public key in your DNSTXT 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 reportsTXT 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:

  1. 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.
  2. 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.
  3. DMARC: start with p=none and a rua= reporting address. Wait two weeks. Review aggregate reports. Once everything passes, move to p=quarantine, then p=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:

SymptomCauseFix
HTML rendered as escaped text in recipient inboxFilter not registered before wp_mail() callMove add_filter line above wp_mail
Password reset link disappearsGlobal text/html filter clashes with <URL> bracketsScope filter to specific call, use remove_filter after
Characters mangled in subject linesMissing charsetAdd Content-Type: text/html; charset=UTF-8
Emails sent but never arrivePHP mail() to badly-configured sendmailSwitch to SMTP via WP Mail SMTP or phpmailer_init
Gmail says "via amazonses.com" or similarDKIM not configured for your domainAdd DKIM CNAME records from your provider
Emails go to spamSPF / DKIM / DMARC missing or misalignedVerify with DNS health check
Images don't displayRecipient's client blocks remote images by defaultInline critical images via Content-ID, or accept that images load only after the recipient clicks "display images"
Email looks fine in Gmail, broken in OutlookOutlook's MSO-specific HTML renderingUse 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:

ProviderStrengthsWhen to use
ResendModern API, React Email templating, generous free tierDefault for new projects — clean DX
PostmarkBest-in-class deliverability, separate transactional/broadcast streamsWhen transactional reliability is critical
SendGridMature, large free tier, establishedLarge-volume sends with existing template library
MailgunStrong API, EU region option, good logsEU-resident customer base
Amazon SESCheapest at scale (~$0.10/1000 sends), no UIHigh-volume, dev-team-owned infrastructure
MailerSendSolid transactional + bulk hybridMixed 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:

FAQ

TagsWordPressEmailHTML Emailwp_mailSMTPSPFDKIMDMARCDeliverability
Share
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts