API Reference
API Reference
GitHub
Email Sending

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.

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 requiredIn the sandbox
A verified sender domainAny From address works
An enabled SMTP server or the shared poolNone needed
The suppression listIgnored
The plan’s hourly and daily limitsNot consumed
A registered sender address under strict_sendersNot 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 empties the whole project’s sandbox, not the inbox selected in the dropdown. The confirmation says so.

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.

SettingDefaultMeaning
sandbox_retention_days7Days before a captured message expires
sandbox_max_messages500How many a project keeps, oldest dropped first

Both are platform settings, under Admin -> Settings.

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 platform window is clamped down to it rather than refused, so no application can pin a project’s sandbox open against the operator’s setting.

Changing sandbox_retention_days 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.

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 400 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:

RoutePurpose
GET /api/v1/sandboxPage of captured messages, newest first. ?inbox=<id> narrows it to one inbox’s senders
GET /api/v1/sandbox/infoConnection details and the retention settings
GET /api/v1/sandbox/:idOne message, parsed
GET /api/v1/sandbox/:id/rawThe wire bytes as text/plain
GET /api/v1/sandbox/:id/attachments/:idxOne attachment
DELETE /api/v1/sandbox/:idDelete one message
POST /api/v1/sandbox/clearEmpty the project’s sandbox
GET /api/v1/sandbox/inboxesEvery inbox in the project
POST /api/v1/sandbox/inboxesCreate an inbox: name, description, addresses
GET /api/v1/sandbox/inboxes/:idOne inbox
PATCH /api/v1/sandbox/inboxes/:idEdit an inbox
DELETE /api/v1/sandbox/inboxes/:idDelete 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 something beyond sending.

Permissionscarried by the flag, plus whatever else you tick
POST /emails/sendcaptured
POST /emails/send-templatecaptured
/sandboxread, clear, delete
everything else under /emails403
every other resourceonly if you granted it

The delivery log, one message’s detail, batch and retry all describe or touch real mail, so a credential whose whole purpose is that its mail is not real is turned away from them by name.

This used to require emails:write, and that was a hole

A sandbox key needed emails:write to reach the send route - which also grants POST /emails/{id}/retry. Retry re-queues an existing message, so it honoured no sandbox flag and never could:there is nothing to capture, the message was already composed and addressed to a real recipient. A credential handed out precisely so it could not send real mail could put a real failed message back on the queue.

Narrowing the resource removes that by construction. A sandbox key now holds no permission on emails at all, so 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.