PosthasteDocsGet an API key

Sending

SMTP relay

Point your existing app’s mailer at Posthaste instead of switching it to the HTTP API. Every message you submit goes through the exact same accept path as a direct API send — the same suppression check, the same warmup ladder, the same attachment limits — just arriving over SMTP instead of JSON.

Connect

SettingValue
Hostsmtp.posthastemail.dev
Ports587, 465, 2587, 2465 — see ports below.
Usernameposthaste, literally. It is not your account name.
PasswordAny API key with the emails:send scope — a ph_live_… value, the same kind of key the HTTP API takes as a Bearer token.
AUTHPLAIN and LOGIN, and only after TLS is established. A key is never accepted in the clear.
Max message size25 MiB, the whole message including attachments once encoded on the wire.
Max recipients50 RCPT TO commands per message. Each accepted recipient counts as one send against your plan, identical to the API.

Ports

PortTLSUse it when
587STARTTLSthe standard mail submission port.
465Implicit, from byte oneyour client or library defaults to it instead of STARTTLS.
2587STARTTLS587 is blocked outbound on your host or network.
2465Implicit, from byte one465 is blocked outbound on your host or network.

All four ports are live. 587 is the conventional choice; the others exist for hosts that block it.

The envelope decides delivery, the header decides identity

Two addresses matter in an SMTP conversation, and Posthaste keeps them as separate as the HTTP API does. RCPT TO decides delivery — it is the mailbox the message actually reaches, and it is what suppression and the recipient count check against. The From: header decides identity — it is what the recipient reads, what DKIM signs, and what has to be on one of the account’s verified domains, exactly the rule Send an email documents for the from field. MAIL FROM is never consulted for authorization — checking it would open the exact “authorize on a domain you own, display one you don’t” spoofing hole this split closes.

A message carrying two From headers is rejected outright rather than having one silently picked — the classic DMARC-confusion shape, and a parser that resolves it quietly turns it into a spoofing primitive instead of a refusal.

The message you send is parsed into fields, and the wire message is rebuilt from those fields — your raw bytes never travel further than this relay. A handful of headers are dropped rather than relayed, because they are trace/trust material every mail submission agent strips at the door: Received, DKIM-Signature, Return-Path, Authentication-Results, and the ARC set. Posthaste writes its own DKIM-Signature, Message-ID, Date, Return-Path and Feedback-ID on the way out — a header you supply under any of those names is replaced, not merged. Custom headers pass through untouched: X-* headers, In-Reply-To and References all reach the recipient exactly as you wrote them.

Retrying safely

Your own Message-ID header is what makes a retry safe, the same role idempotencyKey plays on the HTTP API — see idempotency. The relay derives a key from your Message-ID and the recipient together, so if your connection drops after DATA and your client’s queue re-offers the identical message to the identical recipient, the second attempt gets 250 again — nothing is sent twice. Most mailers set a Message-ID automatically; what matters is that yours does not change between attempts at the same send.

Suppression is per recipient

A suppressed address is refused at RCPT TO, before DATA, and only that recipient is refused — the rest of the envelope is unaffected. Send to five addresses with one on the suppression list: that one gets 550 5.7.1 with the reason at RCPT time, and the other four still go through DATA and deliver normally. This is SMTP working the way it is supposed to — a well-behaved client’s queue narrows its own recipient list on a 5xx and keeps the rest, instead of one bad address on a list sinking the whole message.

The same waybill as an API send

An accepted SMTP message shows up in the dashboard exactly like an API send: the same message record, the same event chain, the same 30-day retention window. The one visible difference is on the accepted event itself — its payload carries "source": "smtp" instead of "api", which also reaches your webhook if you listen for that event. Attachments work identically too: up to 10 files, 10 MiB combined once decoded, and the same executable-type denylist the HTTP API checks.

One thing does not carry over: scheduled sending is API and SDK only. Every message submitted here sends immediately regardless of its Date: header — that header is display metadata for the recipient’s mail client, not an instruction to us.

Reply codes

A queue-based mailer’s whole debugging surface is the reply it got back, so this is the complete set — every code this relay can return, grouped by where in the conversation it happens.

Connecting and authenticating

StatusWhen
421 4.7.0Too many connections from your address right now, three failed AUTH attempts on this connection, or a key/IP already in a short lockout from earlier failures. All transient — reconnect after a short wait rather than retrying immediately.
535 5.7.8The key is missing, malformed, unknown, revoked, expired, or the username isn’t posthaste — one uniform reply for all of them, deliberately, so a failure can’t be used to tell which half was wrong.
535 5.7.1The key authenticated fine but does not carry the emails:send scope. Named, unlike the reply above, because the caller has already proven possession of the key.

MAIL FROM and RCPT TO

StatusWhen
530 5.7.0MAIL FROM, RCPT TO or DATA arrived before AUTH succeeded. This listener never accepts unauthenticated mail — there is no open-relay path through it.
553 5.1.3The recipient address is not a usable address.
452 4.5.3More than 50 recipients on one transaction. Split the message across more than one.
550 5.7.1One of three permanent refusals sharing this code: the From header’s domain is not verified — or is not on this account at all — the recipient is on the suppression list (the reply names the reason), or the message resembles a bulk send within a short window (off by default platform-wide; most accounts never see it). None of the three is worth retrying — fix the underlying thing instead.

The message itself (DATA)

StatusWhen
552 5.3.4The message, or its attachments combined and decoded, is over the size cap — 25 MiB for the whole message, 10 MiB combined for attachments.
552 5.7.0An attachment’s file type is on the executable denylist — the same one the HTTP API enforces, checked against the final extension.
554 5.6.1More than 10 attachments on one message.
554 5.6.0The message is structurally unusable: no From header, two From headers, no text or html body, an attachment with a malformed content type, or a header that decodes to a value containing a line break.
452 4.5.3Recipients × attachment bytes would write more than the relay allows in one transaction — fewer recipients or a smaller attachment fixes it.
451 4.3.0Something transient on our side — a database blip, an internal error. Retry.

Success

StatusWhen
250 2.0.0Accepted, and only after the message — and every recipient’s own copy of it — is durably committed. A 250 is proof of durability, never sent early.
250 2.0.0 (Already accepted as msg_…)The idempotency case above: the same Message-ID was re-sent to the same recipient. Nothing new was sent.

Clients with no retry queue

Most application mailers have no queue of their own: PHPMailer and wp_mail() on WordPress, Python’s smtplib used directly, Rails ActionMailer’s default deliver_now, and a bare nodemailer transport all send once, synchronously, and either raise once or — in wp_mail()’s case — return false and move on. To code shaped like that, a 4xx is not “retry later”, it is a lost message: nothing downstream is watching for it to run again.

A real MTA’s queue rides out a 452 without you noticing; a queueless client drops it on the floor. Where you can, prefer a client that keeps a send log and can resend — on WordPress that is FluentSMTP rather than a bare WP Mail SMTP install; on a Node stack, wrap sendMail in your own retry-with-backoff instead of calling it inline.

Watch your account’s daily warmup allowance in the dashboard if your client has no queue — a new account starts on the first rung, and a burst that clears it turns into 452 4.2.1 on every message after, with nothing on your side noticing until a customer asks where their email went.

Connect your app

WordPress plugins take the same five fields as everything else on this page:

# WP Mail SMTP or FluentSMTP → the plugin's SMTP settings screen
SMTP Host        smtp.posthastemail.dev
SMTP Port        587
Encryption       TLS (STARTTLS)
Authentication   On
SMTP Username    posthaste
SMTP Password    <an API key with the emails:send scope>

# "From Email" in the plugin IS the From: header — it must be an address on
# one of your verified domains, checked the same way as an API send.
# Field names differ a little between plugins; the values above don't.

Everywhere else, it is a normal SMTP transport configuration:

import nodemailer from 'nodemailer'

const transport = nodemailer.createTransport({
  host: 'smtp.posthastemail.dev',
  port: 587,
  secure: false,     // 587 (and 2587) are STARTTLS, not implicit TLS
  requireTLS: true,  // refuse to AUTH if STARTTLS didn't happen
  auth: {
    user: 'posthaste',
    pass: process.env.POSTHASTE_KEY,
  },
})

await transport.sendMail({
  from: '[email protected]',
  to: '[email protected]',
  subject: 'Invoice 2026-114',
  text: 'Your invoice is attached to your account.',
})

NextMessages and the waybillRead back a message, its status, its events and the SMTP conversation.