- JavaScript 99.6%
- Dockerfile 0.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| src | ||
| test | ||
| .dockerignore | ||
| .editorconfig | ||
| .gitignore | ||
| .prettierrc | ||
| 059-smtp-test-harness-todo.md | ||
| 059-smtp-test-harness.md | ||
| AGENTS.md | ||
| Dockerfile | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
SMTP Test Harness
A deterministic, in-memory SMTP server for exercising email-delivery failure paths in development. It lets you decide, per recipient address, exactly what SMTP response the server sends — permanent rejection, temporary failure, a delayed accept, a mid-transaction connection drop, and more — plus a runtime-switchable global default for addresses you don't control.
No authentication on the control API. The HTTP control/observability API (
/control/*,/messages,/events) has no auth of any kind. This tool must only run on a closed development network (e.g. adocker-composenetwork). Never expose it to a public network.
Quick start
npm install
npm start # node src/main.js
npm test # runs the policy engine unit tests (node --test)
SMTP listens on SMTP_PORT (default 2525), the control API on HTTP_PORT (default 8025).
How addresses control behavior
The recipient's local-part prefix decides the SMTP response:
<verb>[<number>]-<anything>@<any-domain>
The verb is case-insensitive, the trailing number is optional (only some verbs use it), and
everything after the required - is free-form — use it to make every test address unique
(perm-abc123@test.invalid).
| Verb | Number | Stage | SMTP response | Triggers in the backend |
|---|---|---|---|---|
ok |
– | RCPT + DATA | 250 2.1.5 Accepted (RCPT) → 250 Ok: queued as <id> (DATA)* |
successful delivery → sent |
perm |
– | RCPT | 550 5.1.1 <addr>: Recipient address rejected: User unknown |
permanent 5.1.x → source quarantine, blocked |
permbox |
– | RCPT | 550 5.2.1 <addr>: Mailbox disabled |
permanent, not 5.1.x → failed, no quarantine |
permsyntax |
– | RCPT | 553 5.1.3 Bad recipient address syntax |
5.1.x edge case (not "user unknown") |
temp |
– | RCPT | 451 4.3.0 Temporary local problem, try again later |
temporary → retried, stays pending |
tempbox |
– | RCPT | 452 4.2.2 Mailbox full |
temporary, storage-flavored |
slow |
delay ms, default SMTP_DEFAULT_DELAY_MS |
RCPT | accept after the delay | drain congestion → observable processing |
flaky |
fail-until attempt, default 1 |
RCPT | 451 for the first N attempts, 250 on attempt N+1 |
retry logic: pending → eventually sent |
datafail |
– | end of DATA | 554 5.6.0 Message content rejected |
DATA-stage permanent failure |
datatemp |
– | end of DATA | 451 4.3.0 Try again later |
DATA-stage temporary failure |
drop |
– | RCPT | no response, socket closed immediately | abrupt disconnect handling |
hang |
ms, default SMTP_DEFAULT_HANG_MS |
RCPT | no response for the duration, then socket closed | client-side timeout handling |
senderdeny |
– | MAIL FROM | 550 5.7.1 Sender address rejected |
sender-based rejection (put the verb on the sender address) |
* The literal RCPT/DATA success text is controlled by the smtp-server library itself and
cannot be overridden per-address; only the numeric 250 and the DATA-stage queued id are
guaranteed.
Examples:
perm-signup@test.invalid -> 550 5.1.1 permanent rejection, triggers quarantine
permbox-signup@test.invalid -> 550 5.2.1 permanent rejection, no quarantine
flaky2-signup@test.invalid -> fails twice, succeeds on the 3rd attempt
slow5000-signup@test.invalid -> RCPT accepted after a 5s delay
hang2000-signup@test.invalid -> no response for 2s, then the connection is closed
If the local part doesn't match any verb, the global default policy decides (see below).
This is what makes lead/lead_confirmation emails testable — their recipient is an imported
agent/office address the test cannot control.
Global default policy
curl -X PUT http://localhost:8025/control/policy \
-H 'Content-Type: application/json' \
-d '{"stage":"rcpt","responseCode":550,"responseMessage":"5.1.1 Recipient address rejected: User unknown","delayMs":0}'
stage:accept(always accept, everywhere) |mailFrom|rcpt|data(reject at that specific SMTP phase) |drop|hang(drop/hang at RCPT, like the address verbs).- An address-based verb always wins over the global policy.
- Starting value:
{"stage":"accept","delayMs":0}, overridable at boot viaSMTP_DEFAULT_POLICY(same JSON shape).
Environment variables
| Variable | Default | Meaning |
|---|---|---|
SMTP_PORT |
2525 |
SMTP listen port |
SMTP_HOST |
0.0.0.0 |
SMTP bind address |
HTTP_PORT |
8025 |
Control/observability HTTP port |
HTTP_HOST |
0.0.0.0 |
HTTP bind address |
SMTP_DEFAULT_POLICY |
{"stage":"accept","delayMs":0} |
Initial global policy, JSON |
SMTP_DEFAULT_DELAY_MS |
10000 |
Default delay for the slow verb |
SMTP_DEFAULT_HANG_MS |
60000 |
Default duration for the hang verb |
SMTP_MAX_SIZE_BYTES |
10485760 |
Advertised SIZE (10 MiB) |
SMTP_AUTH_MODE |
optional |
optional | required | disabled |
SMTP_AUTH_USER |
(empty) | Empty = any username accepted |
SMTP_AUTH_PASS |
(empty) | Empty = any password accepted |
SMTP_TLS_KEY |
(empty) | Private key path; empty = STARTTLS disabled |
SMTP_TLS_CERT |
(empty) | Certificate path |
STORE_MAX_MESSAGES |
500 |
Message ring buffer size |
STORE_MAX_EVENTS |
2000 |
Event ring buffer size |
LOG_LEVEL |
info |
debug | info | silent |
Invalid values fail the process at startup with a non-zero exit code and a descriptive message — there is no silent fallback.
HTTP API
All responses are application/json.
# Health check
curl http://localhost:8025/health
# Current policy, config, attempt counters, buffer usage
curl http://localhost:8025/control/state
# Change the global default policy at runtime
curl -X PUT http://localhost:8025/control/policy \
-H 'Content-Type: application/json' \
-d '{"stage":"accept","delayMs":0}'
# Reset attempt counters, policy, and buffers to their starting state
curl -X POST http://localhost:8025/control/reset
# List stored messages (newest first), without the raw body
curl "http://localhost:8025/messages?limit=20&to=perm&subject=verification"
# Fetch one message in full, including the raw body
curl http://localhost:8025/messages/<id>
# Clear the message buffer
curl -X DELETE http://localhost:8025/messages
# List logged SMTP decisions (newest first)
curl "http://localhost:8025/events?limit=50&stage=rcpt&verb=perm"
# Clear the event buffer
curl -X DELETE http://localhost:8025/events
limit defaults to 50, max 500. Unknown routes return 404, unsupported methods on a known
route return 405, and malformed JSON bodies return 400 — always as {"error": "..."}.
Docker
docker build -t smtp-test-harness .
docker run -p 2525:2525 -p 8025:8025 smtp-test-harness
docker-compose snippet for the backend's dev environment:
smtp-harness:
build: ./tools/smtp-test-harness
ports:
- "2525:2525"
- "8025:8025"
environment:
SMTP_DEFAULT_POLICY: '{"stage":"accept","delayMs":0}'
Point the backend's SMTP configuration at smtp-harness:2525 (no TLS, AUTH optional). Changing
the backend's own configuration is out of scope for this tool — only documented here.
Common scenarios
Everything succeeds (default state): leave the global policy at
{"stage":"accept","delayMs":0} and use ok-* addresses, or any address without a verb.
Everything gets a permanent 5.1.1 (to exercise source quarantine end-to-end):
curl -X PUT http://localhost:8025/control/policy -H 'Content-Type: application/json' \
-d '{"stage":"rcpt","responseCode":550,"responseMessage":"5.1.1 Recipient address rejected: User unknown","delayMs":0}'
A slow server, to trigger drain congestion: send to slow5000-*@..., or set the global
policy to {"stage":"rcpt","responseCode":250... }-style is not needed — just use the slow
verb, or set SMTP_DEFAULT_DELAY_MS and use slow-*@....
A temporary failure that succeeds on the third try: send to flaky2-*@... — the first two
attempts get 451, the third gets 250.
Known deviation from a literal reading of the spec
The hideENHANCEDSTATUSCODES SMTP-server option is intentionally left at its library default
(true, i.e. hidden) instead of being forced to false. In smtp-server@3.19.x, enabling that
option makes the library auto-prepend an enhanced status code derived from the numeric response
code to every response — including ones we already built ourselves with the correct code
embedded in err.message. Since several verbs share the same numeric code but need different
enhanced codes (perm → 550/5.1.1 vs permbox → 550/5.2.1), enabling the option produces
a duplicated code (550 5.1.1 5.1.1 ...), which breaks the exact-string contract the backend
relies on to detect 5.1.x quarantine triggers. Every rejection message in this harness already
embeds its own enhanced code, so behavior is correct with the option left at its default; only
the EHLO capability list omits the ENHANCEDSTATUSCODES line as a cosmetic side effect.
Project layout
src/
main.js entry point: config load, startup/shutdown
config.js env parsing + validation
policy.js pure decision engine (address parsing, precedence)
state.js in-memory state: policy, ring buffers, attempt counters
smtp.js smtp-server instance and stage handlers
http.js control/observability API
log.js JSON Lines logger
test/
policy.test.js policy engine unit tests (node --test)