Latency hides in specific places: the TLS handshake, greeting delays from a busy submission server, per-RCPT latency when you fan out to many recipients, and the Nagle-vs-delayed-ACK interaction on small command writes. But the real killer isn't slowness — it's that the connection can die at *any* state and leave you not knowing whether the message queued. 4xx codes (421, 450, 451, 452) are transient and retryable; 5xx (550, 552, 554) are permanent. That taxonomy only helps when you actually *receive* a code. The stalled socket gives you nothing.
The only serious mitigation is connection pooling: keep authenticated sessions warm and pipeline commands so the per-send cost drops from ~500ms to 40–120ms. That works, and every production SMTP client should do it — but a pool is a stateful thing you now operate, monitor, and reap.
## The HTTP API path
The same send over HTTP collapses the conversation into one idempotent request:
```bash
curl -X POST https://api.evilmail.pro/v1/send \
-H "Authorization: Bearer $EVILMAIL_KEY" \
-H "Idempotency-Key: 6f1c9e2a-checkout-8842" \
-H "Content-Type: application/json" \
-d '{
"from": "[email protected]",
"to": "[email protected]",
"subject": "Your receipt",
"html": "Thanks for your order.
",
"headers": {"List-Unsubscribe": ""}
}'
# 202 Accepted
# {"id":"msg_01H...","status":"queued"}
``
One TLS session (usually resumed), one POST, one JSON response carrying a message-id. The response codes map cleanly onto retry decisions: 2xx = accepted into the queue, 4xx = your fault, do not retry (400 malformed, 401 bad key, 422 validation), 429/5xx = retry with backoff and honor Retry-After. There's exactly one thing that can hang, and it's bounded by your own client timeout.
What you give up is real. There is no SMTP-level 250 that literally means "the *next hop* took custody" — 202 means the vendor's edge accepted it, one layer earlier. You get coarser control over the envelope (some APIs won't let you set an arbitrary MAIL FROM return-path). And your payload shape is now vendor-specific: moving providers means rewriting the call, whereas SMTP relay is a config change.
## Latency: p50 is a distraction, p99 is the story
- **Cold SMTP, fresh TLS** — p50 300–900ms, p99 1s+. Tail driver: 7–9 RTT plus handshake, and mid-session stalls.
- **Pooled + pipelined SMTP** — p50 40–120ms, p99 200–400ms. Tail driver: pool exhaustion, session reauth.
- **HTTP, keep-alive** — p50 30–90ms, p99 150–300ms. Tail driver: TLS-resumption miss, hard-bounded by client timeout.
The medians are close enough that nobody should choose on p50. The tail is where they diverge. SMTP p99 blows out on connection setup and on the class of failures where a session dies half-negotiated — unbounded unless you impose aggressive socket timeouts, which then create the ambiguous-state problem below. HTTP p99 is dominated by the occasional TLS-resumption miss and is *hard-bounded* by the timeout you set. On an interactive request path — a login code, a password reset the user is staring at a spinner for — a bounded tail is worth more than a slightly better median.
## Retries and idempotency — where SMTP quietly hurts you
Here is the trap in full. A socket dies after you send CRLF.CRLF but before the 250 arrives. Did the message queue?
- If you retry, you risk a **duplicate** — the recipient gets two receipts, or worse, two "your account was deleted" mails.
- If you don't retry, you risk a **drop** — the exact silent failure from the opening incident.
SMTP has no idempotency key. There is no protocol-level way to ask "did you already accept message X?" You are guessing, every time, on every ambiguous socket death.
An HTTP API with an Idempotency-Key header turns at-least-once delivery into exactly-once-*observed*. The server dedupes on the key (typically 24h retention), so a retry after a timeout returns the *original* result instead of enqueuing a second message. Your retry loop becomes safe and boring:
python
def send_with_retry(payload, key):
base, cap, attempts = 1.0, 30.0, 5
for i in range(attempts):
try:
r = post("/v1/send", json=payload, timeout=5,
headers={"Idempotency-Key": key})
except Timeout:
time.sleep(min(cap, base * 2**i) * random.random())
continue # safe: the key dedupes a requeue
if r.status_code
Acceptance is not delivery
apptrace_id
transportSMTP 250 / HTTP 202
providerqueue
recipient MX
mailbox
synchronous ack ends here — you know the queue took it
async unknown until a webhook / DSN lands
delivered / bounce / deferred / complaint → back to app (same trace_id)
SMTP: parse DSN + grep queue-id in postfix logs | HTTP: message-id → webhook events
Both paths need event feedback to close the loop. On self-hosted SMTP you parse DSNs and chase the queue id across your MTA logs — one message is one id threading through cleanup, qmgr, smtp, and bounce:
bash
grep 4c9F2k3xYz /var/log/mail.log
# postfix/qmgr: 4c9F2k3xYz: from=, size=2143, nrcpt=1
# postfix/smtp: 4c9F2k3xYz: to=,
# relay=mx.example.com, delay=1.4, status=sent (250 2.0.0 OK)
On an HTTP API you get the same information as structured webhook events keyed by message-id:
json
{ "type": "email.delivered", "id": "msg_01H...",
"timestamp": "2026-07-04T14:02:13Z", "to": "[email protected]",
"smtp_response": "250 2.0.0 OK", "trace_id": "checkout-8842" }
Verify the webhook with an HMAC signature and a constant-time compare, then fan the event back into the same trace as the original send. The discipline that matters regardless of transport: **propagate one trace_id from the app through the send call into the webhook**, so a single email is a single distributed trace. And alert on the *deferred rate* and the *250/202-to-delivered gap*, not just your HTTP error rate — a 100% "success" rate at the transport with a widening delivery gap is exactly what a reputation problem looks like before it becomes an outage.
## Deliverability doesn't care which transport you chose
Nothing above changes the fundamentals. Whether you POST JSON or speak ESMTP, the recipient's spam filter judges the same signals:
dns
; SPF
evilmail.pro. TXT "v=spf1 include:_spf.evilmail.pro -all"
; DKIM
sel1._domainkey.evilmail.pro. TXT "v=DKIM1; k=rsa; p=MIGfMA0G..."
; DMARC — enforce, align strictly
_dmarc.evilmail.pro. TXT "v=DMARC1; p=reject; adkim=s; aspf=s; rua=mailto:[email protected]"
Plus PTR/reverse DNS that matches your HELO, a warmup ramp over 2–4 weeks on any fresh IP, and — since the Gmail/Yahoo 2024 bulk-sender rules — enforced DMARC and one-click List-Unsubscribe-Post above 5k messages/day. The one genuine difference: an API vendor manages IP reputation and blocklist delistings for you (convenient, but you inherit noisy-neighbor risk on shared IPs); self-hosted SMTP means you own the warmup and every delisting ticket yourself.
## Choosing — a decision checklist
- **Interactive/transactional request path** (OTP, password reset, receipt the user is waiting on) → **HTTP API**. Bounded p99, safe idempotent retries.
- **Batch, legacy, or self-hosted-control** workloads → **SMTP relay**. It's a config line, not a code rewrite.
- **Need vendor portability** → SMTP, or put an abstraction over the HTTP call so the payload shape isn't welded into your business logic.
- **Regulated data residency** → self-host the MTA; don't hand message bodies to a third-party edge.
- **High fan-out, latency-insensitive** → pooled, pipelined SMTP is efficient and cheap.
And the ops checklist that applies to *both* transports:
- Idempotency keys on every send (server-side for HTTP, a dedup store for SMTP).
- Exponential backoff with full jitter; honor Retry-After; never retry a non-429 4xx.
- A webhook/DSN ingestion path that closes the loop on delivered/bounce/deferred/complaint.
- One trace_id propagated app → send → webhook.
- Suppress hard bounces permanently; treat soft bounces as transient.
- Alert on deferred-rate and the acceptance-to-delivered gap — not just error rate.
Pick the transport that puts the failure boundary where you can see it. Then instrument both against the same dashboards, because the day one of them stalls after DATA`, the only thing that saves you is a log line that says which message, and whether it ever landed.