Docs Sign in Get started

Email capture

An SMTP listener that accepts mail for your sandboxes and never delivers it anywhere.

Try it in one command

Everything below follows from this working, so start here. Create a sandbox and paste its id and secret in:

curl smtp://smtp.trapit.dev:587 \
  --user "YOUR-SANDBOX-ID:YOUR-SANDBOX-SECRET" \
  --mail-from "dev@example.com" \
  --mail-rcpt "anything@example.com" \
  --upload-file - <<'EOF'
Subject: Hello from curl

It's trapped.
EOF

Pointing your app at it

Same four values, wherever your app already reads its SMTP settings from:

host smtp.trapit.dev
port 587
user <sandbox-id>
pass <sandbox-secret>
tls  required (STARTTLS before AUTH)

Both credentials come from the sandbox — the username is its id, the same string its capture host is built from, and the password is its secret.

If port 587 doesn't suit you

587 is the submission port and the one to reach for. If your mailer wants TLS from the first byte rather than a STARTTLS upgrade, use 465 instead. Port 25 also answers, and behaves identically — it is there for senders already pointed at it.

Authentication is required on every port and never offered in the clear, so a sandbox secret only ever crosses an encrypted connection. Worth knowing if your app runs on a cloud host: outbound port 25 is blocked by default on Azure VMs and by plenty of other networks, which is exactly why 587 is the one quoted everywhere.

The recipient address doesn't matter

This is the part worth reading twice. The sandbox is decided by the credentials that authenticated, not by who the mail is addressed to. Send to anything@example.com, to a customer's real address, to whatever your fixtures already generate — it is trapped either way, by the sandbox that signed in.

So there is nothing to create before sending, no address to derive, and no address to rewrite in the app under test. You point it at TrapIt's SMTP settings and change nothing else. That is the whole reason this is credential-routed: the address is your application's business, and it stays that way.

The flip side: mail TrapIt cannot authenticate is refused. A third party sending on your behalf — a transactional email provider, say — cannot be trapped here, because it connects with its own credentials, not your sandbox's. Point the app at TrapIt directly instead.

Isolating parallel runs

Give each run its own sandbox. An ephemeral one is minted in a single call, expires on a TTL, and takes everything it captured with it — the usual choice in CI. Two runs then share nothing at all: not a mailbox, not a filter, not a naming convention they both have to honour. How to create one →

Within one sandbox, wait_for_email(to: "…") matches the envelope recipient exactly, so a run that does want to sort its own mail out by address can. That is a filter over what was trapped, though — not what decided to trap it.

What gets recorded

  • Envelope sender and recipients, plus the parsed From, To and Subject.
  • Every address the message was accepted for, which is what to: matches on — worth knowing, because the To header is only ever what the sender wrote.
  • Both bodies — text/plain and text/html — when the message carries them.
  • Attachments, downloadable individually.
  • The original RFC 822 source, kept verbatim, so you can inspect headers the parser did not surface.

Messages over 10 MB are rejected at the protocol level and never stored. The limit is advertised over ESMTP SIZE, so a well-behaved sender knows before it uploads.

Ingress is rate limited at 120 messages per 60 s per source IP and 600 per workspace. Over the limit the listener answers a 421 temp-fail — a well-behaved sender queues and retries, so bursts are delayed rather than dropped, and back-to-back sends (an invite plus an immediate resend, say) are fine well inside those numbers. The full table of limits is on the REST API page.

Reading it back

In the dashboard, or over the API — GET https://trapit.dev/api/v1/sandboxes/{sandboxId}/messages for the list (filterable by capturedFor, since and isRead) and …/messages/{messageId}/raw for the original source. Rather than polling, both programmatic surfaces can block until a matching message arrives: the MCP wait_for_email tool, and GET …/messages/wait over REST.

REST API → · MCP tools →