>_ emailblackhole

Test email in CI

Your app sends a signup, password-reset or receipt email. Your test needs to wait for it, then read the link or code. One API call does exactly that: no polling loop, no sleep 10, no real mailbox.

Everything on this page works with a free session. A permanent inbox only makes it last and raises the limits (Limits and plans).

The pattern (race-free)

  1. Mark: note where the inbox is now (the highest message number).
  2. Trigger: do whatever makes your app send the email.
  3. Wait: ask for mail that is newer than the mark, optionally filtered by recipient or subject.
MARK=$(curl -s -H "$AUTH" "$BASE/api/sessions/$ID/messages" | jq '[.messages[].seq] | max // 0')
./trigger-the-email
curl -s -H "$AUTH" "$BASE/api/sessions/$ID/wait?after=$MARK&subject=verify&timeout=60" | jq -r '.messages[0].links[0]'

Why a mark: the wait only considers mail newer than step 1, so a message left over from an earlier run can never satisfy this run. And mail that arrives between steps 2 and 3, before the wait starts, still counts, so there is no race the other way either.

Set-up

  1. Open the sandbox in a browser. Creating a session is protected by a bot check, so a program cannot do it for you: copy the id (the SMTP username) and the secret (the SMTP password) from the page.
  2. Store them as CI secrets, for example EBH_ID and EBH_SECRET, with the server address https://emailblackhole.io as EBH_URL.
  3. Point the app under test at the SMTP settings the page shows (Getting started).

Session lifetime. A free session ends 60 minutes after you last use it (every API call counts as use), so create it just before a run. For a pipeline that runs whenever a commit lands, use a permanent inbox: it never expires, and you can create revocable API keys for it (Limits and plans).

Authenticate every call with Authorization: Bearer <secret>. Never put the secret in a URL.

Recipes

Plain shell with curl and jq

BASE=https://emailblackhole.io; ID=YOUR_ID; AUTH="Authorization: Bearer YOUR_SECRET"
MARK=$(curl -s -H "$AUTH" "$BASE/api/sessions/$ID/messages" | jq '[.messages[].seq] | max // 0')
./trigger-the-email
curl -s -H "$AUTH" "$BASE/api/sessions/$ID/wait?after=$MARK&subject=verify&timeout=60" | jq -r '.messages[0].links[0]'

200 returns the matching messages as JSON; 204 means nothing arrived in time.

GitHub Actions

jobs:
  e2e:
    runs-on: ubuntu-latest
    env:
      EBH_URL: https://emailblackhole.io
      EBH_ID: ${{ secrets.EBH_ID }}
      EBH_SECRET: ${{ secrets.EBH_SECRET }}
    steps:
      - uses: actions/checkout@v4
      - name: Run the test and read the verification link
        run: |
          AUTH="Authorization: Bearer $EBH_SECRET"
          MARK=$(curl -fsS -H "$AUTH" "$EBH_URL/api/sessions/$EBH_ID/messages" | jq '[.messages[].seq] | max // 0')
          npm run test:e2e        # whatever triggers the email
          LINK=$(curl -sS -H "$AUTH" "$EBH_URL/api/sessions/$EBH_ID/wait?after=$MARK&subject=verify&timeout=90" | jq -r '.messages[0].links[0] // empty')
          test -n "$LINK" || { echo "no verification email arrived"; exit 1; }
          echo "verification link: $LINK"

Playwright (TypeScript)

const BASE = process.env.EBH_URL!, ID = process.env.EBH_ID!, SECRET = process.env.EBH_SECRET!
const headers = { Authorization: `Bearer ${SECRET}` }

async function mark(): Promise<number> {
  const { messages } = await (await fetch(`${BASE}/api/sessions/${ID}/messages`, { headers })).json()
  return Math.max(0, ...messages.map((m: { seq: number }) => m.seq))
}

async function waitForLink(after: number, to: string, seconds = 60): Promise<string> {
  const q = new URLSearchParams({ after: String(after), to, timeout: String(seconds) })
  const res = await fetch(`${BASE}/api/sessions/${ID}/wait?${q}`, { headers })
  if (res.status === 204) throw new Error(`no email for ${to} within ${seconds}s`)
  if (!res.ok) throw new Error(`wait failed: ${res.status}`)
  return (await res.json()).messages[0].links[0]
}

test('signup sends a verification email', async ({ page }) => {
  const m = await mark()
  await page.goto('/signup')
  await page.fill('[name=email]', 'new.user@example.com')
  await page.click('text=Sign up')
  await page.goto(await waitForLink(m, 'new.user@example.com'))
  await expect(page.getByText('Email verified')).toBeVisible()
})

pytest

import os, requests

BASE, ID, SECRET = os.environ["EBH_URL"], os.environ["EBH_ID"], os.environ["EBH_SECRET"]
H = {"Authorization": f"Bearer {SECRET}"}

def mark() -> int:
    msgs = requests.get(f"{BASE}/api/sessions/{ID}/messages", headers=H, timeout=15).json()["messages"]
    return max([m["seq"] for m in msgs], default=0)

def wait_for_code(after: int, subject: str, timeout: int = 60) -> str:
    r = requests.get(f"{BASE}/api/sessions/{ID}/wait", headers=H, timeout=timeout + 15,
                     params={"after": after, "subject": subject, "timeout": timeout})
    assert r.status_code == 200, f"no email arrived (status {r.status_code})"
    return r.json()["messages"][0]["codes"][0]

def test_password_reset_email(client):
    m = mark()
    client.post("/forgot-password", data={"email": "user@example.com"})
    assert len(wait_for_code(m, "reset")) == 6

The calls you need

Call Purpose
GET /api/sessions/{id}/messages?after=&to=&from=&subject=&source= List messages. The filters are case-insensitive substring matches, combined with AND. source is smtp or inbound.
GET /api/sessions/{id}/wait?...&timeout=60&count=1 Long-poll until enough messages match: timeout 1 to 120 seconds, count 1 to 20. 200 messages, 204 timed out.
GET /api/sessions/{id}/events A live stream of events (Server-Sent Events) for dashboards and long-running listeners.
GET /api/sessions/{id}/messages/{mid}/raw The original message source.
DELETE /api/sessions/{id}/messages Clear the inbox (see the tip below).

Every message carries links (http and mailto URLs found in the body) and codes (likely one-time codes). Codes are detected by a heuristic: 4 to 8 digits on the same line as a word like "code", "OTP", "PIN" or "passcode". Order numbers, years and phone numbers are ignored. If your emails format codes differently, read the text field instead.

The full list of calls, fields and error codes is in the API reference.

Tips