Email Sandbox
Capture mail from a staging application instead of delivering it
The sandbox captures mail instead of sending it. A staging application points at Mailyard with a sandbox credential, and every message it produces is stored in full - text part, HTML part, headers, raw bytes, attachments - and delivered to nobody.
It exists for the moment before a feature is real: a signup flow being wired up, a password reset being reworded, a template being checked against a dozen locales. All of that wants to see the message. None of it wants a customer to receive one.
The credential decides, not the code
A sandbox credential is an ordinary API key or SMTP submission credential with one flag set at creation. That is the whole configuration.
SMTP_HOST=mail.example.com
SMTP_PORT=587
SMTP_USER=smtp_a1b2c3... # <- the only line that differs
SMTP_PASS=...
The application is byte for byte the same as production, which is what makes testing it worth anything. The
alternative - a flag in the request body - eventually gets left true on a production deploy, or false on staging,
and neither mistake is visible until it is expensive.
The switch is one-way. A sandbox credential cannot ask to send for real:
{
"from": "...",
"to": [
"..."
],
"sandbox": false
}
400 this api key is sandbox-only, so sandbox cannot be set to false -
use a key without the sandbox flag to send for real
Refused rather than quietly ignored, so nobody walks away believing a message went out. An ordinary credential may still opt in for a single message, which is the direction that cannot leak real mail.
Opting in writes to the sandbox, so it needs the sandbox:write permission like any other sandbox write. An API key
holding only emails:write that sets "sandbox": true is answered 403 permission sandbox:write required, and over
SMTP an API key without it that sets X-Mailyard-Sandbox is refused with 550 5.7.1. A submission credential carries
no permission list and may always opt in.
Creating a sandbox credential
Either kind works, and both are created the same way as their live counterparts:
- Developers -> API Keys -> Create, tick Sandbox key
- Developers -> SMTP Submission -> Create, tick Sandbox credential
The flag cannot be changed afterwards. Create a second credential rather than flipping one, so that no single edit can turn every test into a real send.
What is not checked
A sandbox message skips the entire delivery pipeline. It is captured before validation, which means none of the following apply:
| Normally required | In the sandbox |
|---|---|
| A verified sender domain | Any From address works |
| An enabled SMTP server or the shared pool | None needed |
| The suppression list | Ignored |
| The plan’s hourly and daily limits | Not consumed |
A registered sender address under strict_senders | Not enforced |
That is the point rather than a shortcut. A developer testing a signup flow in a project with nothing configured yet fails every one of those checks, and refusing them would be refusing the feature.
The consequence worth knowing: a sandbox message proves nothing about deliverability. It says what your application composed. Whether a real receiver would accept it is a question only a real send answers.
Reading a captured message
Developers -> Email Sandbox. Each message opens on five views:
- HTML - rendered in a fully sandboxed frame, with no scripts and no access to the console’s origin. It is your application’s markup, and it is being displayed inside the tool you are signed in to.
- Text - the plain text alternative, if the message carries one.
- Headers - everything parsed out, including your own
X-headers. - Attachments - name, type and size, each downloadable.
- Raw - the wire bytes exactly as submitted.
The detail page shows the SMTP envelope separately from the From and To
headers. They routinely differ - a Bcc recipient appears in the envelope and in no header at all - and that difference
is usually the thing being debugged.
Splitting the sandbox into inboxes
One sandbox per project, and often more than one application sending into it. An inbox splits the list without a second credential: it is a name over a list of sender addresses, and the sandbox page gets a dropdown - All mail, then one entry per inbox - that narrows the list to captures whose envelope sender is on that inbox’s list.
Open Inboxes on the sandbox page to create one. Addresses match exactly and without regard to case. A domain on its own does not match anything.
An inbox holds no mail. It is a saved filter, decided every time the list is read:
- Editing the address list changes what the inbox shows immediately, captures already held included.
- Deleting an inbox deletes no message. Everything it showed is still under All mail.
- Empty sandbox asks what to remove: every capture in the project, or only the mail of the inboxes you tick. Mail whose sender is in no inbox is only removed with everything.
Inboxes are sandbox configuration and are gated on the same permissions as the captures: sandbox:read to see them,
sandbox:write to create and edit, sandbox:delete to remove.
How long a message is kept
Two limits, and the second is the one that matters in practice. A sandbox belongs to a project, so both are decided by the project’s plan and the project itself. The platform settings are only the default for a project that has not chosen and a plan that says nothing.
| Limit | Decided by |
|---|---|
| Retention | The project’s sandbox_retention_days (project settings), never longer than the plan’s max_sandbox_retention_days. A longer value is clamped to the plan’s ceiling on save. Unset, the platform setting sandbox_retention_days (default 7) applies. |
| Message cap | The plan’s max_sandbox_messages, oldest dropped first. Unset, the platform setting sandbox_max_messages (default 500) applies. |
The day window alone does not bound anything useful: a test suite can write ten thousand messages in a morning, and a seven-day window does nothing about that until day seven. The per-project cap is what actually holds the table down, and it is applied on every capture.
Shortening the window for one message
A sender may ask for less retention, never more:
{
"from": "noreply@example.com",
"to": [
"test@example.com"
],
"subject": "CI run 4821",
"text": "...",
"sandbox_retention_days": 1
}
Over SMTP the same thing rides on a header:
X-Mailyard-Sandbox-Retention: 1
A value longer than the project’s window is clamped down to it rather than refused, so no application can pin a project’s sandbox open against its plan.
Changing the retention window governs new messages. What is already captured keeps the expiry it was given, so a settings change cannot delete a message somebody is in the middle of reading.
Sending into the sandbox
Over SMTP
Exactly as SMTP Submission
describes, with a sandbox credential. The server answers
250 - the message was accepted, by a credential whose meaning is “accept and keep”, so anything else would make every
test look like a failure to the library that sent it.
An ordinary credential can opt in for one message:
X-Mailyard-Sandbox: true
Neither control header is stripped from the stored message, unlike on the sending path. The sandbox exists to show exactly what went on the wire, and quietly editing that would hide the header you are trying to confirm you set.
Over HTTP
POST /api/v1/emails/send and POST /api/v1/emails/send-template both capture.
With a sandbox key the request is the ordinary one - that is the entire point. Nothing in the body says sandbox, so the application under test is untouched and only the credential it was given differs:
curl -X POST https://mail.example.com/api/v1/emails/send \
-H "Authorization: Bearer myk_your_sandbox_key" \
-H "Content-Type: application/json" \
-d '{
"from": "noreply@example.com",
"to": ["customer@example.com"],
"subject": "Order #42 confirmed",
"html": "<p>Thanks for your order.</p>"
}'
An ordinary key can opt one message in with a body field. This is the ad-hoc case
- a single call you want to inspect - not the way to run a test suite:
curl -X POST https://mail.example.com/api/v1/emails/send \
-H "Authorization: Bearer myk_your_key" \
-H "Content-Type: application/json" \
-d '{
"from": "noreply@example.com",
"to": ["customer@example.com"],
"subject": "Order #42 confirmed",
"html": "<p>Thanks for your order.</p>",
"sandbox": true
}'
There is no X-Mailyard-Sandbox header on this surface. That header belongs to SMTP submission above, where a message
has headers and no JSON body to put a field in.
The response is a 201 carrying the sandbox row rather than an email row:
{
"sandboxed": true,
"sandbox_email": {
"id": "3f7c...",
"source": "api",
"sender": "noreply@example.com",
"recipients": [
"test@example.com"
],
"subject": "Welcome",
"size": 1284,
"expires_at": "2026-08-15T09:12:00Z",
"received_at": "2026-08-08T09:12:00Z"
}
}
Deliberately not shaped like a send: the id belongs to the sandbox, and looking it up under /api/v1/emails returns
404. A response that invited that mistake would be worse than one that plainly says what happened.
sender and recipients are the envelope, and hold bare addresses: a from of "Acme" <noreply@example.com> is
stored as noreply@example.com, exactly as a submission over SMTP would be, so inbox filters and match=exact find it.
The display name is in the From header of the raw message.
An Idempotency-Key header works as on a real send: a retry carrying the same key answers 200 with the capture the
first request stored and replayed: true, instead of storing a second one. The capture is handed back only to a
caller holding sandbox:read, and a key that produced a real message answers a sandbox credential 409.
A template send is rendered first and then captured, so what you read is what the template produced for that data - placeholders resolved, template attachments included.
Batch is not supported
POST /api/v1/emails/batch refuses a sandbox key with a 403 rather than falling through to a real send. Send the
items individually.
Reading it from the API
The sandbox has a console API under /api/v1/sandbox, session-authenticated like the rest of the console:
| Route | Purpose |
|---|---|
GET /api/v1/sandbox | Page of captured messages, newest first, ?limit= (50, at most 200) and ?offset=. ?inbox=<id> narrows it to one inbox’s senders, ?sender=, ?recipient= and ?subject= match part of a value, ?match=exact makes the two addresses whole, ?since= keeps what was received after a date (2026-08-01) or an RFC 3339 instant |
GET /api/v1/sandbox/info | Connection details and the retention settings |
GET /api/v1/sandbox/:id | One message, parsed |
GET /api/v1/sandbox/:id/raw | The wire bytes as text/plain |
GET /api/v1/sandbox/:id/eml | The same bytes as an .eml download |
GET /api/v1/sandbox/:id/attachments/:idx | One attachment |
DELETE /api/v1/sandbox/:id | Delete one message |
POST /api/v1/sandbox/clear | Empty the project’s sandbox. A body of senders or recipients keeps it to captures from or to those addresses, older_than to what was received before that date or instant |
GET /api/v1/sandbox/credentials | The project’s sandbox credentials |
POST /api/v1/sandbox/credentials | Mint one: name. The password is returned once |
GET /api/v1/sandbox/credentials/:id | One credential |
PATCH /api/v1/sandbox/credentials/:id | Rename it |
POST /api/v1/sandbox/credentials/:id/revoke | Retire it |
GET /api/v1/sandbox/inboxes | Every inbox in the project |
POST /api/v1/sandbox/inboxes | Create an inbox: name, description, addresses |
GET /api/v1/sandbox/inboxes/:id | One inbox |
PATCH /api/v1/sandbox/inboxes/:id | Edit an inbox |
DELETE /api/v1/sandbox/inboxes/:id | Delete an inbox. No captured mail is removed |
What a sandbox credential may do
A sandbox key is judged on the sandbox resource, not on emails, and that is what makes it safe to hand out.
Ticking the flag is the whole thing. The key carries sandbox:read, sandbox:write
and sandbox:delete by itself - send, read back what was sent, clear between runs - so a key created with an empty
permission list and "sandbox": true works. Add permissions only if the application under test needs to read something
beyond the sandbox.
| Route | A sandbox key |
|---|---|
POST /emails/send | captured |
POST /emails/send-template | captured |
/sandbox | read, clear, delete, inboxes, sandbox credentials |
everything else under /emails | 403, reads included |
/api-keys, /smtp-credentials | 403, reads included |
any other GET on /api/v1 | only if you granted the permission |
POST /templates/preview | only if you granted the permission |
POST /templates/:id/versions/:v/preview | only if you granted the permission |
POST /campaigns/:id/preview | only if you granted the permission |
POST /subscriber-lists/preview-segment | only if you granted the permission |
any other write on /api/v1 | 403, whatever it holds |
POST /api/relay-nodes/register | 401, a sandbox key enrols no relay node |
A sandbox key writes nowhere but the sandbox and the two accepting routes. Members, invitations, roles, webhooks and
their tests, SMTP servers, relay nodes, domains, senders, templates, campaigns, subscribers, suppressions, project
settings, erasure and imports are all refused with 403 a sandbox key reaches only the sandbox, captured sends and reads, even when the key was given those permissions. The four previews above are the exception: each is a POST
that renders or counts and stores nothing, so it is treated as the read it is and answers to the same permission as
one (templates:read, campaigns:read, subscribers:read). The permissions you add can widen what it reads, never
what it changes.
The delivery log, one message’s detail, batch and retry all describe or touch real mail, so it is turned away from them
by name, reads included. The same goes for the credential routes, where it could otherwise mint a credential that is not
a sandbox one. It mints through /sandbox/credentials, which makes only sandbox credentials.
A sandbox key holds nothing on emails
emails:write also grants POST /emails/{id}/retry, which re-queues an existing message - one already composed and
addressed to a real recipient, with nothing left to capture. A credential handed out precisely so it cannot send real
mail must not be able to put a real failed message back on the queue, so a sandbox key holds no permission on emails
at all and there is nothing on that surface for it to spend.
Who can see it
Anybody holding sandbox:read, and that is worth granting on its own. A role holding the sandbox and nothing else - no
email log, no contacts, no templates, no domains, no export - is exactly what a contractor wiring up a signup flow
needs. See
Roles
.
The sandbox screen recognises that shape and becomes the whole console for whoever carries it, rather than sitting
behind a navigation of links that all answer 403.
Handing a contractor a sandbox-only role and a sandbox credential gives them everything they need to build against Mailyard and no view of a single message the company actually sent.
What it is not
- Not a preview. Template preview renders a template without any of your application in the loop. The sandbox is the other half: your application composed the message, and this is what it produced.
- Not a deliverability check. Nothing here touched DNS, SPF, DKIM or a receiving server.
- Not an inbox. Inbound Email receives mail addressed to a domain you verified, from anyone. The sandbox holds mail your own application submitted.