Temporary email for testing signup and password-reset flows

A real inbox on a real domain, created with one HTTP call. No API key, no account, no shared test mailbox that three people are reading at once.

Why test with a disposable inbox

Mocking your mailer proves that your code called the mailer. It does not prove that the message left your provider, survived a real MX lookup, arrived with the right subject and contained a link that works. Registration, email verification, magic-link login and password resets all depend on that last mile.

  • One address per test run. Every run gets a fresh inbox, so tests never pick up a message from a previous run or a parallel job.
  • Nothing to provision. No API key, no sign-up, no mail server in your CI. The API is plain JSON over HTTPS.
  • Nothing to clean up. Inboxes and their mail delete themselves after an hour. You can also delete one immediately when the test finishes.
  • Mail arrives in about a second. Poll, or hold a WebSocket open and get pushed a notification the moment the message lands.

The whole flow in four requests

  1. POST /api/inbox returns email, token and expiresAt.
  2. Your test signs up (or requests a reset) using email.
  3. GET /api/inbox with header X-Session-Token lists received messages. Repeat until one appears.
  4. GET /api/email/{id} returns the full message: subject, text and sanitized html.

curl

# 1. Create an inbox
curl -s -X POST https://shitty.email/api/inbox
# {"email":"k3v9x2m@shitty.email","token":"…","expiresAt":"…"}

# 3. List messages
curl -s -H "X-Session-Token: $TOKEN" https://shitty.email/api/inbox

# 4. Read one
curl -s -H "X-Session-Token: $TOKEN" https://shitty.email/api/email/msg_a1b2c3d4e5

Example: JavaScript / TypeScript (Playwright, Jest, Vitest)

Works in Node 18+ with the built-in fetch. Drop the helper into your test utilities.

const BASE = 'https://shitty.email';

export async function createInbox() {
  const res = await fetch(`${BASE}/api/inbox`, { method: 'POST' });
  if (!res.ok) throw new Error(`createInbox failed: ${res.status}`);
  return res.json(); // { email, token, expiresAt }
}

export async function waitForEmail(token, { timeoutMs = 60_000, intervalMs = 3_000 } = {}) {
  const headers = { 'X-Session-Token': token };
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    const { emails } = await (await fetch(`${BASE}/api/inbox`, { headers })).json();
    if (emails.length > 0) {
      const res = await fetch(`${BASE}/api/email/${emails[0].id}`, { headers });
      return res.json(); // { id, from, to, subject, text, html, date }
    }
    await new Promise((r) => setTimeout(r, intervalMs));
  }
  throw new Error('No email arrived before the timeout');
}

// In a Playwright test:
const { email, token } = await createInbox();
await page.fill('#email', email);
await page.click('button[type=submit]');

const message = await waitForEmail(token);
const code = message.text.match(/\b\d{6}\b/)?.[0];           // a 6-digit code
const link = message.text.match(/https?:\/\/\S+verify\S*/)?.[0]; // or a verification link

Example: Python (pytest, requests)

import re
import time
import requests

BASE = "https://shitty.email"

def create_inbox():
    res = requests.post(f"{BASE}/api/inbox", timeout=10)
    res.raise_for_status()
    return res.json()  # {"email": ..., "token": ..., "expiresAt": ...}

def wait_for_email(token, timeout=60, interval=3):
    headers = {"X-Session-Token": token}
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        emails = requests.get(f"{BASE}/api/inbox", headers=headers, timeout=10).json()["emails"]
        if emails:
            return requests.get(f"{BASE}/api/email/{emails[0]['id']}", headers=headers, timeout=10).json()
        time.sleep(interval)
    raise TimeoutError("No email arrived before the timeout")

def test_signup_sends_verification_code(app_client):
    inbox = create_inbox()
    app_client.sign_up(email=inbox["email"], password="correct horse battery staple")

    message = wait_for_email(inbox["token"])
    assert "Verify" in message["subject"]
    assert re.search(r"\b\d{6}\b", message["text"])

Skip the polling: WebSocket push

Connect to wss://shitty.email/ws?token=… and the server sends a new_email message the instant mail arrives, then fetch the full body by id. Send the text ping to keep an idle connection alive; the server answers pong.

const ws = new WebSocket(`wss://shitty.email/ws?token=${token}`);

ws.onmessage = async (event) => {
  if (event.data === 'pong') return;
  const msg = JSON.parse(event.data);
  if (msg.type === 'new_email') {
    // msg.payload = { id, from, subject, date }
    const res = await fetch(`https://shitty.email/api/email/${msg.payload.id}`, {
      headers: { 'X-Session-Token': token },
    });
    console.log(await res.json());
    ws.close();
  }
};

Node 22+ has WebSocket built in; on older versions use the ws package.

Things to know before you rely on it

  • Receive-only. You cannot send or reply from these addresses.
  • Messages up to 1 MB, up to 100 per inbox. Attachments are not kept; you get the subject, plain text and sanitized HTML.
  • Inboxes live one hour. POST /api/inbox/extend adds an hour, up to 24 in total. Long-running suites should create inboxes as they need them, not once at the start.
  • Rate limited per IP. 25 new inboxes and 30 API requests per minute per IP. Polling every three seconds, as in the examples above, stays under it. Parallel CI jobs behind one NAT share the same limit.
  • Only the token protects an inbox. The address alone is not enough to read it, which is what you want, but keep tokens out of public logs.
  • Your app may block it. If the product under test rejects disposable domains, allow-list shitty.email in your test environment.
  • It is a free public service, not a paid testing platform with an SLA. For a release gate that must never flake, keep a fallback.
← Get a temporary inbox