Send real mail. Get real numbers.

An SMTP load tester for people who have to prove their systems hold up. It resolves MX records and fails over in priority order, models retries and deferrals, and writes a machine-readable summary for every run.

Read the source
Live send with MX failover
rmorse@loadgen-03:~/bench$
Simulated activity
summary_*.json

The output is the product

A load test that only prints to a terminal tells you how things went while you were watching. Every SMTPBench run writes this instead.

{
  "run_uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "config": {
    "threads": 5, "messages": 100, "rate": null,
    "tls_mode": "starttls", "auth": false, "port": 587, "offline": false
  },
  "totals": { "sent": 495, "failed": 5, "retried": 2, "success_rate": 99.0 },
  "latency_ms": { "p50": 120, "p95": 350, "p99": 510, "max": 820 },
  "per_mx": { "mx1.example.com": { "sent": 495, "failed": 5 } }
}

Three things to know before reading one: sent plus failed is messages attempted, while retried counts retry attempts and is not part of that sum. latency_ms covers successful sends only and is null when nothing succeeded. And config.auth is a boolean — the summary records that authentication happened, never who authenticated.

Because the percentiles land in a file, a mail path can be gated in CI like any other service:

# fail the build if p95 exceeds 2s
smtpbench recipient=test@corp.example lb_host=smtp.internal port=587 \
          threads=5 messages=20 rate=5

python -c "import json,glob,os,sys; \
  f=max(glob.glob('logs/summary_*.json'), key=os.path.getmtime); \
  d=json.load(open(f)); \
  sys.exit(1 if (d['latency_ms'] or {}).get('p95',0) > 2000 else 0)"
lb_host= (unset)

Most tools make you name a host. Real senders don't get to.

If a load test always connects to the same box, it never answers the question you actually care about: does the failover work? Leave lb_host unset and SMTPBench resolves MX, walks the ladder by preference, and records which host handled what.

prio 10mx1.local.lets.qadelivered
prio 20mx2.local.lets.qadeferred
prio 30mx3.local.lets.qaidle
smtpbench --help

What else it does

Arguments are key=value — no dashes, no flags. Four are required; everything else tunes scale, realism, transport, or output.

recipient=

Walks the MX ladder

With no fixed host set, SMTPBench resolves the recipient domain, sorts MX records by preference, and tries them in order until one accepts. A pre-flight banner check fails the run early instead of producing thousands of identical errors. The summary breaks sent and failed down per MX host, so you can prove the secondary took over and see how much it absorbed.

summary_*.json

Writes a file you can gate a build on

Every run produces a summary with p50, p95, p99, and max latency, totals, and per-host counts. Percentiles in a JSON file mean a mail path can be regression-tested like any other service, instead of eyeballed in a scrollback buffer.

eml_out_dir=

Sends nothing, if you prefer

Offline mode composes each message to disk as .eml and never opens a connection. No DNS, no banner check, no delivery. It is the safe way to evaluate composition, attachments, and headers before you point the tool at anything real.

attachment_dir=

Varies without becoming unrepeatable

Attachments sampled from a corpus of real files, generated across a size range, or one fixed file. Body text drawn from a directory. Per-message selection is seeded from the run UUID, so the same run makes the same choices — realistic variety you can still reproduce when something breaks.

rate=

Caps the whole run, not each thread

A token bucket shared across every thread. Ten threads at delay=1 is roughly ten messages a second; rate=10 is ten messages a second regardless of thread count. When you are testing a relay you would rather not harm, that distinction is the whole point.

X-SMTPBench-Run-UUID

Labels every message it sends

Each message carries the run UUID plus its thread and message IDs in headers. Find one message in a downstream mailbox and tie it back to the exact log line that produced it — or filter an entire run out of a shared mailbox afterwards.