~/ blog/ sending-wordpress-email-through-mailtrap-api-not-smtp-2026

Sending WordPress email through Mailtrap's HTTP API (not SMTP) in 2026

Most WordPress + Mailtrap tutorials end the same way: install a generic SMTP plugin, paste your Mailtrap SMTP credentials, and you’re done. That works — but every WordPress site that does it is leaving the more interesting half of Mailtrap’s product on the shelf. The Mailtrap HTTP Send API exposes features the SMTP gateway physically cannot expose: per-message stream routing (bulk vs transactional), automatic email categorization, custom tracking variables, template UUIDs, and a live view of your suppression list inside WP-admin. Switching from SMTP to the API also fixes the slow-and-flaky problems most WordPress hosts have with outbound port 587/465.

This post explains why the SMTP path is fragile from a WordPress process, what the Mailtrap API gives you instead, and the minimum viable code to swap one for the other inside a plugin — including the gotchas around wp_mail() filter ordering, attachment encoding, and graceful fallback when the network blip happens.

Why SMTP from WordPress is fragile

PHP’s mail story on shared and managed WordPress hosts is a known mess. The default mail() binary points at a local Sendmail or Postfix that the host doesn’t actually want you using; SMTP plugins paper over it by opening an authenticated TCP session to a real provider. That session has costs that look invisible until you scale a bit:

  • Connection latency. SMTP needs MAIL FROM, RCPT TO, DATA, then your message body, then QUIT. Each round-trip adds ~50–150 ms on a healthy network. At 10 mails per page load (think order-confirmation + admin notify + analytics), you’ve added a second of wall time before the user sees a response.
  • Blocked egress. Cheaper hosts (and corporate firewalls) block outbound 587/465. Your test email works on staging, the production site silently drops mail with a “could not connect” buried in error_log. SMTP failure modes are excellent at hiding from people who don’t read PHP error logs.
  • Single-stream blindness. SMTP gives you one channel into the provider. Mailtrap (and Postmark, Sendgrid, etc.) split sending into transactional and bulk streams behind the scenes; using SMTP, you can’t address those streams independently. Marketing newsletters and password resets compete in the same queue.
  • No metadata. Suppression lists, custom tracking variables, template IDs — all communicated via SMTP through ad-hoc headers (when supported at all), which providers parse with varying enthusiasm.

The HTTP Send API solves the first three by design and the fourth by intention. One POST to https://send.api.mailtrap.io/api/send, one HTTPS round-trip, structured JSON in / structured JSON out, native fields for stream and category. WordPress already has wp_remote_post(); you don’t even need a vendor SDK.

The drop-in replacement: filter pre_wp_mail

wp_mail() is the universal mail-sending entry point in WordPress. Every plugin that sends mail goes through it — Contact Form 7, WooCommerce, BuddyPress, WP-CLI, your custom code. There are two clean ways to intercept it:

  1. Replace it via pre_wp_mail filter (added in WordPress 5.7). Returning a non-null value from this filter short-circuits the rest of wp_mail() and treats your return value as the result.
  2. Override the global phpmailer instance via the phpmailer_init action. This is the older path, used by WP Mail SMTP and friends. It still calls PHPMailer’s SMTP transport — fine for SMTP plugins, useless when your goal is to talk HTTP.

For HTTP-API routing, pre_wp_mail is unambiguously the right hook. The minimum viable adapter looks like this:

add_filter('pre_wp_mail', __NAMESPACE__ . '\\route_to_mailtrap', 10, 2);

function route_to_mailtrap($null, array $atts): bool|null {
    // $null is null when no other filter has handled the call.
    // Returning anything non-null short-circuits wp_mail().

    if (!swifttrap_is_enabled()) return null; // graceful pass-through

    [$to, $subject, $message, $headers, $attachments] = array_values($atts);

    $payload = build_mailtrap_payload($to, $subject, $message, $headers, $attachments);
    $stream  = pick_stream($payload, $headers); // bulk vs transactional

    $endpoint = $stream === 'bulk'
        ? 'https://bulk.api.mailtrap.io/api/send'
        : 'https://send.api.mailtrap.io/api/send';

    $response = wp_remote_post($endpoint, [
        'headers' => [
            'Authorization' => 'Bearer ' . get_option('swifttrap_api_token'),
            'Content-Type'  => 'application/json',
        ],
        'body'    => wp_json_encode($payload),
        'timeout' => 12,
    ]);

    if (is_wp_error($response)) {
        // Don't lose the email — let core fall back to its default handler.
        do_action('swifttrap_send_error', $response, $atts);
        return null;
    }

    $code = wp_remote_retrieve_response_code($response);
    return $code >= 200 && $code < 300;
}

That’s the whole adapter, in roughly 30 lines of plumbing. The interesting bits are in build_mailtrap_payload() and pick_stream().

Building the payload: the parts that actually matter

Mailtrap’s Send API takes a structured JSON body. The parts you’ll touch every time:

  • from: { email, name }. Must match a verified sender on your sending domain — the API rejects unknown senders.
  • to, cc, bcc: arrays of { email, name } objects.
  • subject, text, html: the message body. Send both text and html when you have them; the API will pick the right one per recipient.
  • attachments: array of { content, filename, type, disposition }. The content field is base64. WordPress passes attachments to wp_mail() as filesystem paths, so you read each file and base64_encode it.
  • category: a string. Mailtrap groups stats and filters by this value; pick one per message type (“welcome”, “password-reset”, “order-confirmation”, etc.). Auto-derive it from the subject line if you don’t want every plugin to set it manually.
  • custom_variables: an associative array. Useful for correlating sends with internal IDs (order ID, user ID, contact form submission ID).

For attachments specifically:

$attachment_payloads = array_map(function (string $path) {
    if (!is_readable($path)) return null;
    $size = filesize($path);
    if ($size > 25 * 1024 * 1024) return null; // Mailtrap caps at 25 MB
    return [
        'content'     => base64_encode(file_get_contents($path)),
        'filename'    => basename($path),
        'type'        => mime_content_type($path) ?: 'application/octet-stream',
        'disposition' => 'attachment',
    ];
}, $attachments);
$attachment_payloads = array_filter($attachment_payloads);

Two non-obvious points: don’t wp_mail() an inline image with disposition: inline and the same content_id headers you’d use over SMTP — the API has its own disposition: inline field but the lookup by Content-ID is brittle. And the 25 MB ceiling is hard; size-check before you base64-encode, or you’ll OOM the PHP worker on a multi-MB binary attachment.

Stream routing: bulk vs transactional

The single biggest reason to use the API over SMTP is access to Mailtrap’s two streams:

  • send.api.mailtrap.io is the transactional stream. Higher priority, low-volume-friendly, what you want for password resets and order confirmations.
  • bulk.api.mailtrap.io is the bulk stream. Optimized for high-volume promotional traffic; rate limits and reputation are managed separately so a marketing burst doesn’t poison your transactional reputation.

Routing decision needs to be per-message, not per-site. A clean pattern is to derive it from category:

function pick_stream(array $payload, array $headers): string {
    $marketing_categories = ['marketing', 'newsletter', 'campaign', 'promotional'];
    $category = $payload['category'] ?? '';
    if (in_array(strtolower($category), $marketing_categories, true)) return 'bulk';

    // Allow plugins to opt into bulk per-message via a filter.
    return apply_filters('swifttrap_use_bulk_stream', false, $payload, $headers)
        ? 'bulk'
        : 'transactional';
}

The apply_filters() hook is the integration seam. A custom newsletter plugin doesn’t need a settings page — it just sets add_filter('swifttrap_use_bulk_stream', '__return_true') for the duration of its sending loop and you’re routed to the bulk endpoint.

Suppression list visibility

Most deliverability problems aren’t “emails fail” — they’re “emails succeed and then a recipient marks them as spam, and three weeks later your sender reputation has drifted enough that the next batch sees a 30% open rate.” The fix is to see your suppression list inside WP-admin, not only in the Mailtrap dashboard.

The /api/accounts/{account_id}/sending_domains/{domain_id}/suppressions endpoint returns the live list: bounces, complaints, manual blocks, unsubscribes. Cache it for ~5 minutes (don’t hit the API on every admin pageview), render it on a Stats page, and let the user export or clear individual entries. Now the deliverability problem becomes visible before the next campaign batch.

function fetch_suppressions(): array {
    $cached = get_transient('swifttrap_suppressions');
    if ($cached !== false) return $cached;

    $response = wp_remote_get(sprintf(
        'https://mailtrap.io/api/accounts/%d/sending_domains/%d/suppressions',
        get_option('swifttrap_account_id'),
        get_option('swifttrap_domain_id'),
    ), [
        'headers' => ['Authorization' => 'Bearer ' . get_option('swifttrap_api_token')],
        'timeout' => 8,
    ]);

    if (is_wp_error($response)) return [];
    $data = json_decode(wp_remote_retrieve_body($response), true) ?: [];

    set_transient('swifttrap_suppressions', $data, 5 * MINUTE_IN_SECONDS);
    return $data;
}

Five lines of plumbing, one feature your users won’t get from any SMTP plugin.

Graceful fallback and observability

Two failure modes to handle:

Token misconfigured / expired. Don’t drop the email — return null from pre_wp_mail so core’s PHPMailer takes over. The user gets a working mail (probably via the host’s mail()), and you log a WP_Error for the admin to triage. The worst outcome is a slow log line; the best is no user impact.

Network failure. Same play. wp_remote_post() returns a WP_Error on timeout or DNS failure. Pass it to a do_action('swifttrap_send_error', ...) hook, fall through to core, and let the admin see the error rate via the email log table.

The email log itself is worth shipping. Store one row per wp_mail() call: timestamp, recipient (first only — privacy), subject, status code, latency, category, stream. Keep it in a custom table with a configurable retention window (default 30 days). Now you have an audit trail without depending on Mailtrap’s dashboard, and “did that order email actually go out?” becomes a one-click answer.

When SMTP is still the right choice

Honest framing: not every WordPress site should use the Mailtrap API. If you might switch providers next month (Mailtrap → SendGrid → Postmark → SES is a common path while sites scale), the SMTP plugin abstracts over all of them and saves you a swap. WP Mail SMTP and Post SMTP are the right tools for that. The HTTP API is the right choice when you’ve decided on Mailtrap and want every feature it offers — categories, streams, suppression visibility, templates — without hand-rolling the integration.

If you don’t want to write the adapter yourself, SwiftTrap for Mailtrap is the packaged version of the patterns above. ~30 KB, no vendor SDK, free, GPL, on the WordPress.org directory. It implements pre_wp_mail interception, stream routing, the suppression-list page, and a local email log out of the box. If you’d rather read the code first, the source is on GitHub.