Running it
Testing
Two tiers. Unit tests run anywhere with no setup; integration tests need a
Postgres and a Redis and skip themselves when those are absent, so
npm test is always useful.
npm test # unit tests, plus integration if the infra env vars are set
npm run typecheck # tsc --noEmit across every workspace
npm run lint
Unit tests — no infrastructure
npx vitest run tests/unit
| File | Covers |
|---|---|
crypto.test.ts | AES-256-GCM round-trip and tamper detection, scrypt passwords, API key generation and hashing, DKIM key pairs, webhook signature verification and replay window. |
dns-records.test.ts | Record generation is derived per domain, SPF is a single record, required vs recommended, domain validation. |
dns-verify.test.ts | SPF/DKIM/DMARC/return-path checks against a mocked resolver — missing records, duplicate SPF, suffix-matching attacks, chunked TXT values, key mismatch, DMARC policy staging. |
validation.test.ts | Address parsing, header-injection rejection, reserved headers, password strength, webhook URL and event validation. |
bounce-parse.test.ts | RFC 3464 DSN and RFC 5965 ARF parsing: hard vs soft classification, folded Diagnostic-Code, complaint detection, message correlation. |
maillog-parse.test.ts | Postfix log line parsing, queue-id extraction, 4xx/5xx permanence. |
The DNS tests mock node:dns/promises with an in-memory zone, so they are fast
and deterministic — no network, no flakiness.
Integration tests — Postgres and Redis required
# Start throwaway infrastructure
docker run -d --name mail-test-pg \
-e POSTGRES_USER=mail -e POSTGRES_PASSWORD=mail -e POSTGRES_DB=mail_test \
-p 5433:5432 postgres:17-alpine
docker run -d --name mail-test-redis -p 6380:6379 redis:7-alpine
# Apply the schema
DATABASE_URL='postgresql://mail:mail@localhost:5433/mail_test?schema=public' \
npm run db:deploy
# Run everything
export TEST_DATABASE_URL='postgresql://mail:mail@localhost:5433/mail_test?schema=public'
export TEST_REDIS_URL='redis://localhost:6380/15'
npm test
On PowerShell:
$env:TEST_DATABASE_URL='postgresql://mail:mail@localhost:5433/mail_test?schema=public'
$env:TEST_REDIS_URL='redis://localhost:6380/15'
npm test
Teardown:
docker rm -f mail-test-pg mail-test-redis
Redis database 15 is used deliberately — the suite writes rate-limit counters and queue jobs there. Point
TEST_REDIS_URLat a database you do not mind losing.
What the integration tests exercise
They drive the real Fastify app through app.inject(), against a real database
and a real Redis. Nothing about the request path is stubbed.
auth.test.ts — registration, duplicate rejection, weak passwords; login
returning identical errors for a wrong password and a non-existent account;
valid, invalid and revoked API keys; API keys refusing to mint other API keys;
suspended accounts blocked on every route; passwords and key secrets never
appearing in a response or a database column.
domains.test.ts — adding a domain returns all four records with a
per-domain DKIM key; two domains never share a key; the private key is absent
from every response and stored as v1:-prefixed ciphertext that the signing
service can still decrypt; shared mailbox providers and malformed names
rejected; duplicates rejected; verification against a name with no DNS reports
exactly what is missing and persists FAILED.
emails.test.ts — a valid send returns 202 with a queued row, recipients,
a queued event, and a BullMQ job keyed by the email id; cc/bcc roles recorded;
unowned and unverified sending domains rejected; invalid recipients and subject
header-injection rejected; suppressed recipients rejected before any row is
written; recipient cap enforced; quota counted per recipient rather than per
request; a rejected request consumes no quota.
queue.test.ts — the worker's send processor against a mocked transport:
correct VERP envelope sender, per-domain DKIM signing material, X-Mail-Id
header and Message-ID; queue id parsed from the 250 response; a re-run job never
sends twice; a transient error defers and rethrows for retry; a 5xx fails
permanently without retrying; an exhausted attempt budget parks the message on
the staged-retry queue; staged retries eventually give up; an address suppressed
after queueing is dropped; a domain that loses verification stops sending.
webhooks.test.ts — the signing secret is shown once and stored encrypted;
delivery carries a signature the documented verification code accepts; a wrong
secret fails verification; a failing endpoint is retried and then marked failed;
disabled endpoints receive nothing; endpoints only receive subscribed events.
multi-tenancy.test.ts — the isolation suite. A second tenant cannot read,
modify, delete or enumerate the first tenant's domains, emails, API keys,
webhooks, suppressions or statistics. Missing and other-tenant resources both
return 404, so the API never confirms that someone else's resource exists.
Suppression lists are per-tenant: an address one account suppressed can still be
mailed by another.
Test data
Integration accounts use @integration.test addresses and random subdomains of
.integration.test, and cleanupTestData() deletes them by suffix after each
file. Cascading deletes remove everything they own. The suite never touches rows
it did not create.
Files run serially (fileParallelism: false) because they share one schema.
Manual end-to-end check
With the full stack running:
# 1. Account and key
TOKEN=$(curl -s -X POST http://localhost:4000/v1/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"dev@example.test","password":"DevPassword123"}' | jq -r .token)
KEY=$(curl -s -X POST http://localhost:4000/v1/api-keys \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"local"}' | jq -r .token)
# 2. Domain — returns the records to publish
curl -s -X POST http://localhost:4000/v1/domains \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"domain":"example.com"}' | jq '.records'
# 3. After publishing them
curl -s -X POST http://localhost:4000/v1/domains/<id>/verify \
-H "Authorization: Bearer $TOKEN" | jq
# 4. Send
curl -s -X POST http://localhost:4000/v1/emails \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"from":"hello@example.com","to":["you@gmail.com"],"subject":"Test","html":"<p>Hello</p>"}' | jq
# 5. Follow it
docker compose logs -f worker postfix
curl -s http://localhost:4000/v1/emails/<emailId> -H "Authorization: Bearer $KEY" | jq '.status, .events'
Testing bounce handling without a real bounce
Send to bounce@simulator.amazonses.com if you relay through SES, or to a
known-dead address on a domain you control. To exercise the parser directly:
curl -X POST 'http://localhost:4000/internal/bounces?sender=&recipient=bounce%2Bemail_abc%40bounce.yourservice.com' \
-H "X-Internal-Token: $INTERNAL_API_TOKEN" \
-H 'Content-Type: message/rfc822' \
--data-binary @- <<'EOF'
To: bounce+email_abc@bounce.yourservice.com
Content-Type: multipart/report; report-type=delivery-status; boundary="B"
--B
Content-Type: message/delivery-status
Final-Recipient: rfc822; nobody@example.com
Action: failed
Status: 5.1.1
Diagnostic-Code: smtp; 550 5.1.1 User unknown
--B--
EOF
The recipient should end up on the suppression list with type hard_bounce, and
any subscribed webhook should receive email.bounced.