Transactional email is the integration everyone has and nobody tests. The usual options are bad: point your dev environment at the real provider (now your test suite needs a production secret and every CI run risks actually emailing someone), stub the client in code (now the HTTP layer โ auth headers, payload shape, error handling โ is exactly the part you're not testing), or skip it and find out in production that the reset-password email has been silently 401ing for a week.
This guide builds a hosted mock email API in about 60 seconds: SendGrid- and Mailgun-shaped endpoints your code can POST to over real HTTPS, an inspector that shows the exact payload sent, a queryable outbox your tests can assert on, and the failure drills no real provider will do on cue โ rate limits, retry sequences, slow responses. No signup, no keys. Every command below was run against production before publishing.
BASE=https://mockbird.mockbird.workers.dev
P=$(curl -s -X POST $BASE/api/projects -H 'content-type: application/json' \
-d '{"name":"email-mock","blank":true}')
PID=$(echo $P | sed 's/.*"id": *"\([^"]*\)".*/\1/')
KEY=$(echo $P | sed 's/.*"adminKey": *"\([^"]*\)".*/\1/')
# SendGrid-shaped: 202 + empty body + X-Message-Id header
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
-H 'content-type: application/json' -d '{
"method":"POST","path":"/v3/mail/send","status":202,"body":"",
"headers":{"X-Message-Id":"{{uuid}}"}}'
# Mailgun-shaped: 200 + JSON id
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
-H 'content-type: application/json' -d '{
"method":"POST","path":"/mg/messages","status":200,
"body":"{\"id\":\"<{{uuid}}@mock.mockbird>\",\"message\":\"Queued. Thank you.\"}"}'
echo "send endpoint: $BASE/m/$PID/v3/mail/send"
Sending "an email" now behaves the way the real APIs do:
curl -si -X POST https://mockbird.mockbird.workers.dev/m/<PID>/v3/mail/send \
-H 'content-type: application/json' -d '{
"personalizations":[{"to":[{"email":"ada@example.com"}]}],
"from":{"email":"noreply@myapp.dev"},
"subject":"Welcome to MyApp",
"content":[{"type":"text/plain","value":"Hi Ada โ thanks for signing up!"}]}'
HTTP/2 202
x-message-id: fddb4133-12f1-434d-919c-58ca0cbce9ca
โ a 202 with an empty body and a message-id header, which is SendGrid's actual success behavior (and the thing that breaks naive res.json() calls in real integrations; now you can test that your client handles it). The Mailgun-shaped route answers form posts the way Mailgun does:
curl -s -X POST https://mockbird.mockbird.workers.dev/m/<PID>/mg/messages \
-F from='noreply@myapp.dev' -F to='ada@example.com' \
-F subject='Welcome' -F text='Hi Ada'
# {"id":"<c22e539b-โฆ@mock.mockbird>","message":"Queued. Thank you."}
Point your code at it with the base-URL override every HTTP email client has (or the fetch wrapper you already own): EMAIL_API_BASE=https://mockbird.mockbird.workers.dev/m/<PID>. Any auth header your code sends is accepted and recorded โ no key validation to configure.
Every request is captured in the request inspector โ method, path, status and the request body. Your test can pull it and assert on the payload your code actually produced:
curl -s https://mockbird.mockbird.workers.dev/api/projects/<PID>/requests \
-H "x-admin-key: $KEY"
{ "requests": [ {
"method": "POST", "path": "/v3/mail/send", "status": 202,
"body": "{\n \"personalizations\":[{\"to\":[{\"email\":\"ada@example.com\"}]}],\n \"from\":{\"email\":\"noreply@myapp.dev\"},\n \"subject\":\"Welcome to MyApp\", โฆ",
โฆ
} ] }
That closes the loop most email "tests" never close: not "did my function run", but "did it emit a well-formed request with the right recipient and subject". The inspector keeps the last 50 requests; write bodies are stored up to 2 KB (plenty for asserting recipients/subjects; giant HTML bodies get truncated).
For richer assertions, skip the provider-shape entirely and treat sending as writing to an emails resource. Add it to the same project:
curl -s -X POST $BASE/api/projects/$PID/resources -H "x-admin-key: $KEY" \
-H 'content-type: application/json' -d '{
"name":"emails",
"fields":[
{"name":"to","type":"email"},{"name":"from","type":"email"},
{"name":"subject","type":"sentence"},{"name":"body","type":"paragraph"},
{"name":"status","type":"oneOf","values":["queued","delivered","bounced"]}],
"seed":0}
Your app's email service POSTs to /m/<PID>/emails in test mode; the record persists with an id. Now the whole query toolkit works on your outbox:
# did we email Ada a reset link, exactly once?
curl -s "$BASE/m/$PID/emails?to=ada@example.com&subject_like=reset"
curl -si "$BASE/m/$PID/emails?limit=1" | grep -i x-total-count # โ 1
# simulate a bounce, then assert your bounce-handling query works
curl -s -X PATCH "$BASE/m/$PID/emails/1" \
-H 'content-type: application/json' -d '{"status":"bounced"}'
curl -s "$BASE/m/$PID/emails?status=bounced"
And with ?mock_validate=strict, malformed sends fail loudly instead of vanishing โ a POST missing fields gets 422 {"error":"validation_failed","fields":{"from":"required","subject":"required",โฆ}} (verified above; the exact response we got in production). That's a unit test for "every code path that sends email fills in every field" with zero test code.
All simulation params work on the provider-shaped routes and the outbox alike:
# provider rate-limits you
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
"$BASE/m/$PID/v3/mail/send?mock_status=429" -d '{}' # โ 429
# retry choreography: fail twice, then accept โ deterministic
for i in 1 2 3; do curl -s -o /dev/null -w '%{http_code} ' -X POST \
"$BASE/m/$PID/v3/mail/send?mock_seq=429,429,202&mock_seq_key=t1" -d '{}'; done
# โ 429 429 202
# slow provider (measured: 2.12s round-trip)
curl -s -o /dev/null -w '%{time_total}s\n' -X POST \
"$BASE/m/$PID/v3/mail/send?mock_delay=2000" -d '{}'
The mock_seq drill is the important one: point your send-with-retry logic at it and assert it retried exactly twice, backed off, and that a 429'd send never lands in the outbox โ failed writes are not applied, so retries can't create duplicate "sent" records. ?mock_chaos=0.3 adds probabilistic failures on top if you want soak-style testing; see testing loading & error states for the full menu.
Real providers call you back โ delivered, bounced, complained. With the outbox pattern you get that for free: attach a webhook to the project and every emails.created / emails.updated event POSTs to your handler, HMAC-signed with a whsec_ secret you can verify byte-for-byte. PATCHing a record to bounced (section 3) fires the exact "bounce notification" your handler should process. Full recipe with signature verification code: send test webhooks.
| Mailtrap sandbox | Mailpit / MailHog (OSS) | SendGrid sandbox mode | Mockbird | |
|---|---|---|---|---|
| Signup / keys needed | โ account | โ (self-host) | โ account + key | โ nothing |
| Captures real SMTP | โ | โ | โ | โ HTTP APIs only |
| Renders HTML / spam checks | โ excellent | โ web UI | โ | โ |
| Provider-exact payload validation | โ | โ | โ SendGrid's own | shape is yours |
| Fail on demand (429s, sequences, delays) | โ | โ | โ | โ query params |
| Queryable outbox + delivery-webhook simulation | API on paid-ish tiers | REST API | โ | โ |
| Reachable by CI, teammates, deployed previews | โ | your network only | โ | โ public HTTPS |
| Free tier | 50 test emails/mo, 1 sandbox, 10 emails kept | free, but you run it | needs a SendGrid account | 10k req/day, no expiry |
Straight answer on when to use which. If you send over SMTP, or the question is "does this email look right" โ HTML rendering, clients, spam scores โ use Mailtrap (polished, purpose-built; free tier is 50 test emails/month with 10 kept per sandbox, per their official pricing as of 2026) or self-host Mailpit (open source, great web UI + REST API, the modern MailHog). If you need SendGrid to validate your exact payload against their schema, their sandbox mode (mail_settings.sandbox_mode) does that โ with an account and key. Use Mockbird when the thing under test is your sending code and its failure handling โ payload assertions, retry/backoff, bounce flows, rate-limit UX โ and you want a URL any CI job or teammate can hit with zero accounts. They compose: Mailpit for "does it render", this for "does it retry".
The paste block in section 1 is the whole setup โ or open the dashboard and click it together. The same project also serves GraphQL, exports OpenAPI + TypeScript types, and snapshots its state for deterministic test fixtures.