Build · Developer sandbox
Test email in your code
Point your application's outbound mail at a sandbox address and every message it sends is captured here instead of going anywhere near a real person. For a signup flow, a password reset, or a nightly job that emails a thousand people.
No account, no card · Read the raw message and its SPF, DKIM and DMARC
The whole flow, in your tests
Create, send, read the code
Create a sandbox, let the app under test send to any +suffix on it, then wait for what arrives with the one-time code already extracted. No key needed to try it by hand; a key is what makes it repeatable in CI.
# one API key, kept in your CI secrets. The secret is shown once.
curl -sX POST https://api.tmailr.io/v1/keys \
-H 'content-type: application/json' -d '{"name":"ci"}'
# -> { "key": { "prefix": "tk_...", ... }, "secret": "tk_..." }
# a sandbox to capture this run's mail
curl -sX POST https://api.tmailr.io/v1/sandboxes \
-H 'authorization: Bearer tk_...' \
-H 'content-type: application/json' -d '{"name":"checkout"}'
# -> { "sandbox": { "id": "SID", "address": "checkout@etymail.com" },
# "catchAll": "checkout+anything@etymail.com" }
# after your app sends mail, read what arrived
curl -s https://api.tmailr.io/v1/resources/SID/messages \
-H 'authorization: Bearer tk_...'
# -> { "messages": [ { "subject": "Verify", "otp": "123456" } ] }Why not just a throwaway inbox
A throwaway inbox is one address that expires within the hour. A sandbox is a catch-all that lasts a year: anything after a + lands in the same place, so yours+alice@etymail.com and yours+bob@etymail.com both arrive without creating anything first. That is what a test suite needs.
Driving it from a script
One key instead of a token per run
Every sandbox comes with its own link, which is enough by hand and awkward in CI: a suite that makes a sandbox per run would have to keep a token per run. An API key replaces all of them. Send it as Authorization: Bearer tk_… and it can create sandboxes and read their mail without any per-sandbox token.
Keys live in your dashboard, where a test key is confined to sandboxes and a live key does everything. The secret is shown once when it is issued and never again: we keep a hash and the first few characters, enough to recognise a key in the list and not enough to use it. Lose it and issue another.
A sandbox holds 500 messages and the listing returns the newest 100. When there are more, the response carries a nextBefore cursor; pass it back as ?before= to page through, up to ?limit= 200 at a time, until it comes back null.
Getting told, instead of asking
Webhooks, and checking the signature
Polling works and is what most suites do. If you would rather be told, point a webhook at your own URL and we POST each message as it lands, with the parsed body and any one-time code already extracted. Setting the endpoint returns a signing secret once; set it again to rotate it.
Your endpoint is a public URL, so anybody can post to it. Every delivery carries X-TMailr-Signature: t=<unix>,v1=<hex>, where the hex is HMAC-SHA256(secret, "<t>.<raw body>"). Verify it against the raw body, before any JSON parsing reformats a single byte, and reject anything whose t is older than a few minutes so a captured request cannot be replayed. Both SDKs do it for you, with a five-minute window and a constant-time comparison:
import { TMailr } from '@tmailr/sdk';
// rawBody is the exact bytes received, before any JSON parsing
if (!TMailr.verifyWebhook(secret, req.headers['x-tmailr-signature'], rawBody)) {
return res.status(400).end();
}
const message = JSON.parse(rawBody); // now safe to useWhat you can see
The subject and body, the raw .eml, and the SPF, DKIM and DMARC results your message actually earned on the way in. If your app's mail fails authentication here, it will fail at Gmail too, which is the point of looking.