A deterministic, in-memory SMTP server for exercising email-delivery failure paths in development
  • JavaScript 99.6%
  • Dockerfile 0.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-07-25 17:47:24 +02:00
src Initial commit 2026-07-25 17:47:24 +02:00
test Initial commit 2026-07-25 17:47:24 +02:00
.dockerignore Initial commit 2026-07-25 17:47:24 +02:00
.editorconfig Initial commit 2026-07-25 17:47:24 +02:00
.gitignore Initial commit 2026-07-25 17:47:24 +02:00
.prettierrc Initial commit 2026-07-25 17:47:24 +02:00
059-smtp-test-harness-todo.md Initial commit 2026-07-25 17:47:24 +02:00
059-smtp-test-harness.md Initial commit 2026-07-25 17:47:24 +02:00
AGENTS.md Initial commit 2026-07-25 17:47:24 +02:00
Dockerfile Initial commit 2026-07-25 17:47:24 +02:00
package-lock.json Initial commit 2026-07-25 17:47:24 +02:00
package.json Initial commit 2026-07-25 17:47:24 +02:00
README.md Initial commit 2026-07-25 17:47:24 +02:00

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. a docker-compose network). 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 via SMTP_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 (perm550/5.1.1 vs permbox550/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)