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
| Setting | Value |
|---|---|
| Host | smtp.posthastemail.dev |
| Ports | 587, 465, 2587, 2465 — see ports below. |
| Username | posthaste, literally. It is not your account name. |
| Password | Any API key with the emails:send scope — a ph_live_… value, the same kind of key the HTTP API takes as a Bearer token. |
| AUTH | PLAIN and LOGIN, and only after TLS is established. A key is never accepted in the clear. |
| Max message size | 25 MiB, the whole message including attachments once encoded on the wire. |
| Max recipients | 50 RCPT TO commands per message. Each accepted recipient counts as one send against your plan, identical to the API. |
Ports
| Port | TLS | Use it when |
|---|---|---|
| 587 | STARTTLS | the standard mail submission port. |
| 465 | Implicit, from byte one | your client or library defaults to it instead of STARTTLS. |
| 2587 | STARTTLS | 587 is blocked outbound on your host or network. |
| 2465 | Implicit, from byte one | 465 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
| Status | When |
|---|---|
| 421 4.7.0 | Too 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.8 | The 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.1 | The 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
| Status | When |
|---|---|
| 530 5.7.0 | MAIL 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.3 | The recipient address is not a usable address. |
| 452 4.5.3 | More than 50 recipients on one transaction. Split the message across more than one. |
| 550 5.7.1 | One 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)
| Status | When |
|---|---|
| 552 5.3.4 | The 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.0 | An attachment’s file type is on the executable denylist — the same one the HTTP API enforces, checked against the final extension. |
| 554 5.6.1 | More than 10 attachments on one message. |
| 554 5.6.0 | The 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.3 | Recipients × attachment bytes would write more than the relay allows in one transaction — fewer recipients or a smaller attachment fixes it. |
| 451 4.3.0 | Something transient on our side — a database blip, an internal error. Retry. |
Success
| Status | When |
|---|---|
| 250 2.0.0 | Accepted, 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 waybill →Read back a message, its status, its events and the SMTP conversation.