Guides

Testing in the Sandbox

SMTP and test keys that deliver nothing, simulated bounces, and sandbox webhooks.

The Sandbox is a fake inbox for the mail your code sends in development, staging and CI. It speaks SMTP and the send API exactly like production does, delivers nothing, and keeps every message so you can inspect it. It needs no verified domain, and it is free on every plan.

Send over SMTP

Create an inbox under Sandbox in the dashboard (or POST /api/v1/sandbox/inboxes). Each inbox has one login, a username starting sbx_ and a password, and takes mail on:

Port Security
2525 STARTTLS. Use this one; nothing blocks it.
2465 Implicit TLS
# .env, Laravel
MAIL_MAILER=smtp
MAIL_HOST=sandbox.smtp.mailyte.com
MAIL_PORT=2525
MAIL_USERNAME=sbx_...
MAIL_PASSWORD=mlt_sbx_...

The dashboard shows the host for your deployment and ready-made settings for Laravel, Django, Rails, Nodemailer, PHPMailer and smtplib.

Unlike live SMTP credentials and API keys, sandbox credentials stay viewable: read them again any time from the inbox's Credentials sheet or GET /api/v1/sandbox/inboxes/{id}/credentials (needs sandbox:write). A sandbox password can only drop mail into your own sandbox. secret is null for an inbox created before this was possible; reset its credentials to get one.

Any From address is accepted, because nothing is delivered. The sandbox login works only on the sandbox ports, and a production SMTP credential is refused there, so a staging config cannot send real mail by accident, and a production one cannot fill your sandbox.

Send with a test API key

Create an API key with Mode: Test. Test keys start mk_test_ and use the same endpoints as live keys:

curl -X POST "$MAILYTE_BASE/api/v1/messages" \
  -H "Authorization: Bearer $MAILYTE_TEST_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "to": "ada@example.com",
    "from": "hello@yourdomain.com",
    "subject": "Your receipt",
    "text": "Thanks, Ada."
  }'

A test send is validated like a live one: the scopes, the payload, the verified sender, the template rendering. If it would fail in production, it fails here with the same error. The only difference is what happens after that. The message goes into your organization's default sandbox inbox, and the receipt carries "mode": "test" with sandbox_message_id and sandbox_inbox_id.

A test send never uses credits or your rate limit, and never writes to your delivery log.

Simulate failures

Every normal recipient produces email.accepted and then email.delivered. To exercise your failure handling, send to a simulator address. A +label is allowed, such as bounce+signup@sim.mailyte.com.

Address Event
delivered@sim.mailyte.com email.accepted, then email.delivered
bounce@sim.mailyte.com email.bounced, hard, 550 5.1.1
softbounce@sim.mailyte.com email.deferred, 450 4.2.2
complaint@sim.mailyte.com delivery.complaint
suppressed@sim.mailyte.com email.dropped, reason suppressed

To get the same outcome for any recipient, add a header:

X-Mailyte-Simulate: bounce

It takes bounce, softbounce, complaint or suppressed. A simulated bounce or complaint exists only in the sandbox. It never reaches your suppression list, notifications, automations or reports.

Sandbox webhooks

Sandbox webhooks are created on the Sandbox's Webhooks tab, or with POST /api/v1/sandbox/webhooks. They are separate from live webhooks: a live webhook never receives a sandbox event, and each sandbox webhook has its own signing secret.

The body and headers match a live delivery exactly, so your handler needs no changes. Verify X-Webhook-Signature the same way, as described in Receiving webhooks. Two fields and one header are added, so you can always tell the difference:

{
  "id": "01JBT8XQ2M9WYC3K4F6R7S8T9V",
  "event": "email.bounced",
  "environment": "sandbox",
  "sandbox_inbox_id": "01JBT8W4...",
  "data": { "recipient": "bounce@sim.mailyte.com", "code": "550 5.1.1" }
}
X-Webhook-Environment: sandbox

Pointing a sandbox webhook at a production endpoint is supported, and it is how you check that a production handler behaves. Branch on environment if the handler must not act on test events.

Each attempt is retried five times with backoff (30 s, 2 min, 5 min, 15 min, 30 min) and recorded with its status code, duration and the first kilobyte of your response. The dashboard shows them on the message's Events tab. POST /api/v1/sandbox/webhooks/{id}/test sends a sandbox.test event on demand.

To exercise what happens after delivery, fire an event on a caught message: the Simulate row on its Events tab, or POST /api/v1/sandbox/messages/{id}/events with {"event": "clicked", "url": "https://..."}. It takes opened, clicked, bounced, deferred or complained and fires your sandbox webhooks as email.opened, email.clicked, email.bounced, email.deferred or delivery.complaint. recipient defaults to the first envelope recipient and must be one of them.

Test your signup email in CI

An end-to-end test that signs a user up has to wait for the confirmation email before it can click the link. Don't poll the message list: call await, which holds the request open until a matching message arrives and returns it in full, including html, text, headers and events.

GET /api/v1/sandbox/inboxes/{id}/messages/await?to=&subject=&since=&timeout=
Parameter Meaning
to A recipient address, matched against the envelope (Bcc included) and the To header, ignoring case
subject Text the subject contains, ignoring case
since ISO 8601. Only messages received after it count. Pass the moment your test started, so a message from an earlier run can never satisfy this one
timeout Seconds to wait, 1 to 30. Default 30

It answers 200 with the newest matching message, or 204 No Content when the timeout passes with no match. A 204 means "not yet", not failure, so call again. At most five waits can be open at once per organization; the sixth gets 429, so run parallel test workers against separate addresses rather than one long wait each.

Use a test key (mk_test_…) with the sandbox:read scope, and give every test run its own address so parallel jobs never see each other's mail. Plus-addressing works: signup+<run id>@example.com.

curl, in any CI:

SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
EMAIL="signup+${GITHUB_RUN_ID}@example.com"
./scripts/sign-up.sh "$EMAIL"          # whatever triggers the email in your app

for attempt in 1 2 3 4; do             # up to 2 minutes
  STATUS=$(curl -s -o message.json -w '%{http_code}' -G \
    "$MAILYTE_BASE/api/v1/sandbox/inboxes/$SANDBOX_INBOX_ID/messages/await" \
    -H "Authorization: Bearer $MAILYTE_TEST_KEY" \
    --data-urlencode "to=$EMAIL" \
    --data-urlencode "subject=Confirm your email" \
    --data-urlencode "since=$SINCE" \
    --data-urlencode "timeout=30")
  [ "$STATUS" = "200" ] && break
  [ "$STATUS" = "204" ] || { echo "await failed: HTTP $STATUS"; cat message.json; exit 1; }
done
[ "$STATUS" = "200" ] || { echo "no confirmation email within 2 minutes"; exit 1; }

CONFIRM_URL=$(jq -r '.data.html' message.json | grep -o 'https://app.example.com/confirm/[^"]*' | head -1)

A helper for Node test runners. Both examples below use it; it needs Node 18 or later for fetch.

// test/support/sandbox.ts
const BASE = process.env.MAILYTE_BASE ?? 'https://api.mailyte.com';

export interface SandboxMessage {
  id: string;
  subject: string | null;
  html: string | null;
  text: string | null;
  envelope_rcpts: string[];
}

/** Wait for the email to `to`, up to `totalSeconds`. Throws if it never comes. */
export async function awaitEmail(
  opts: { to: string; subject?: string; since: Date },
  totalSeconds = 90,
): Promise<SandboxMessage> {
  const deadline = Date.now() + totalSeconds * 1000;
  while (Date.now() < deadline) {
    const timeout = Math.max(1, Math.min(30, Math.ceil((deadline - Date.now()) / 1000)));
    const params = new URLSearchParams({
      to: opts.to,
      since: opts.since.toISOString(),
      timeout: String(timeout),
      ...(opts.subject ? { subject: opts.subject } : {}),
    });
    const res = await fetch(
      `${BASE}/api/v1/sandbox/inboxes/${process.env.SANDBOX_INBOX_ID}/messages/await?${params}`,
      { headers: { Authorization: `Bearer ${process.env.MAILYTE_TEST_KEY}` } },
    );
    if (res.status === 200) return (await res.json()).data as SandboxMessage;
    if (res.status === 429) {
      await new Promise((r) => setTimeout(r, 1000)); // too many waits open; back off
      continue;
    }
    if (res.status !== 204) throw new Error(`await failed: HTTP ${res.status} ${await res.text()}`);
  }
  throw new Error(`No email to ${opts.to} within ${totalSeconds}s`);
}

/** The first link in the HTML that starts with `prefix`. */
export function linkFrom(message: SandboxMessage, prefix: string): string {
  const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\#x26;');
  const match = (message.html ?? message.text ?? '').match(new RegExp(`${escaped}[^"'\\s<>]*`));
  if (!match) throw new Error(`No ${prefix} link in "${message.subject}"`);
  return match[0].replace(/&amp;/g, '&');
}

Playwright:

// e2e/signup.spec.ts
import { test, expect } from '@playwright/test';
import { awaitEmail, linkFrom } from '../test/support/sandbox';

test('a new user confirms their email', async ({ page }, testInfo) => {
  const since = new Date();
  const email = `signup+${testInfo.testId}-${Date.now()}@example.com`;

  await page.goto('/signup');
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill('correct horse battery staple');
  await page.getByRole('button', { name: 'Create account' }).click();

  const message = await awaitEmail({ to: email, subject: 'Confirm your email', since });
  expect(message.envelope_rcpts).toContain(email);

  await page.goto(linkFrom(message, 'https://app.example.com/confirm/'));
  await expect(page.getByText('Your email is confirmed')).toBeVisible();
});

Jest, for an API-level test with no browser:

// signup.test.ts
import { awaitEmail, linkFrom } from './support/sandbox';

jest.setTimeout(120_000);

test('signup sends a confirmation email with a working link', async () => {
  const since = new Date();
  const email = `signup+${Date.now()}@example.com`;

  const res = await fetch(`${process.env.APP_URL}/api/signup`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password: 'correct horse battery staple' }),
  });
  expect(res.status).toBe(201);

  const message = await awaitEmail({ to: email, subject: 'Confirm your email', since });
  expect(message.subject).toBe('Confirm your email');

  const confirm = await fetch(linkFrom(message, `${process.env.APP_URL}/confirm/`));
  expect(confirm.ok).toBe(true);
});

Point the app under test at the Sandbox the usual way, with the inbox's SMTP login or a test API key, and it needs no test-only code: the email your CI reads is the email production would have sent.

Limits

Limit Value
Inboxes per organization 3
Messages kept per inbox newest 50 (adjustable down)
Retention 30 days (adjustable down)
Messages accepted per month 1,000 per organization
Rate 5 per second per inbox, bursts of 20
Message size 10 MB
Recipients per message 50

Over a rate limit, SMTP answers 4xx so your client retries. Over the size, recipient or monthly limit it answers 5xx with a sentence saying which. The API returns the same refusal as 429, 413 or 422. GET /api/v1/sandbox/usage reports where you stand this month.

The API routes are under /api/v1/sandbox and need the sandbox:read or sandbox:write scope.