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)
- Mark: note where the inbox is now (the highest message number).
- Trigger: do whatever makes your app send the email.
- 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
- 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.
- Store them as CI secrets, for example
EBH_IDandEBH_SECRET, with the server addresshttps://emailblackhole.ioasEBH_URL. - 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
- Filter narrowly. Pass the exact
toaddress your test used, so parallel tests do not take each other's mail. Plus-addressing (temp_<id>+job42@mail.emailblackhole.io) gives every job its own recipient inside one inbox. - Always pass
after. It is what makes runs independent. - Prefer marks to clearing. Clearing the inbox races with parallel jobs; a mark never does.
- Give real providers time. Mail that goes through SendGrid, SES or similar is slower than a local server: use a 60 to 120 second timeout.
- Respect the limits. A session allows 10 simultaneous waits and open browser tabs together. Too many get
429; a restarting server answers503with aRetry-Afterheader, so retry after that many seconds.