Test Emails in CI
Check signup, login, magic-link and password-reset emails from an end-to-end test: sign up with an inbox address, wait for the email through the Email Inboxes API, then use the code or link it contains.
Before you start
- An API key. Create one in Organization Settings > API Keys and store it as a CI secret (
HOOKLISTENER_API_KEY). API keys come with paid plans. On Free, use an inbox without an account instead. - One inbox, created once. Create it in the dashboard, with an agent, or with the API, and store its ID and address as CI secrets. Creating an inbox per run would quickly reach your plan's inbox limit; plus-addresses give every run its own address instead.
curl -sf -X POST https://app.hooklistener.com/api/v1/inboxes \
-H "Authorization: Bearer $HOOKLISTENER_API_KEY" \
-H "Content-Type: application/json" -d '{"name": "ci-signup"}' \
| jq '.data | {id, email_address}'
# Save them as HOOKLISTENER_INBOX_ID and HOOKLISTENER_INBOX_ADDRESS
The pattern
Every test follows the same four steps:
- Build a unique plus-address for the run (or the test):
[email protected]. - Record
since: the current time, before you trigger the email. - Trigger the email: sign up, request a login code, reset a password.
- Wait for it with
GET /api/v1/inboxes/:id/emails/wait?to=<address>&since=<time>, then readcodeorverification_link.
to keeps parallel runs from reading each other's mail. since returns an email that arrived after that moment, even one that came before the wait started, so a fast email is never missed and older mail to the same address is ignored.
The wait answers 200 with the full email as soon as one matches, or 204 No Content once timeout seconds (up to 60) pass without one. Call it again to keep waiting.
Read a one-time code (curl)
API=https://app.hooklistener.com/api/v1
AUTH="Authorization: Bearer $HOOKLISTENER_API_KEY"
ADDRESS=$(echo "$HOOKLISTENER_INBOX_ADDRESS" | sed "s/@/+run-$GITHUB_RUN_ID@/")
SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
./scripts/sign-up.sh "$ADDRESS"
CODE=$(curl -sf -G "$API/inboxes/$HOOKLISTENER_INBOX_ID/emails/wait" -H "$AUTH" \
--data-urlencode "to=$ADDRESS" --data-urlencode "since=$SINCE" \
-d timeout=60 | jq -r '.data.code // empty')
test -n "$CODE" || { echo "no verification email"; exit 1; }
curl -f treats 204 as success, so check that the output isn't empty.
Open a magic or confirmation link (curl)
LINK=$(curl -sf -G "$API/inboxes/$HOOKLISTENER_INBOX_ID/emails/wait" -H "$AUTH" \
--data-urlencode "to=$ADDRESS" --data-urlencode "since=$SINCE" \
-d timeout=60 | jq -r '.data.verification_link // empty')
test -n "$LINK" || { echo "no confirmation link"; exit 1; }
curl -sf -o /dev/null "$LINK"
verification_link is the link most likely to confirm the address, sign in, reset a password or accept an invite, and null when no link looks like one. All links are in links if you need a different one.
Playwright
import { test, expect } from "@playwright/test";
const API = "https://app.hooklistener.com/api/v1";
const headers = { authorization: `Bearer ${process.env.HOOKLISTENER_API_KEY}` };
const INBOX_ID = process.env.HOOKLISTENER_INBOX_ID!;
const INBOX_ADDRESS = process.env.HOOKLISTENER_INBOX_ADDRESS!;
async function waitForEmail(params: Record<string, string>) {
const query = new URLSearchParams({ timeout: "60", ...params });
for (let attempt = 0; attempt < 2; attempt++) {
const res = await fetch(`${API}/inboxes/${INBOX_ID}/emails/wait?${query}`, { headers });
if (res.status === 200) return (await res.json()).data;
if (res.status !== 204) throw new Error(`wait failed: ${res.status}`);
}
throw new Error("no email arrived");
}
test("signup emails a working code", async ({ page }) => {
// Two 60-second waits plus the signup; Playwright's default is 30 seconds.
test.setTimeout(150_000);
const address = INBOX_ADDRESS.replace("@", `+${Date.now()}@`);
const since = new Date().toISOString();
await page.goto("http://localhost:3000/signup");
await page.getByLabel("Email").fill(address);
await page.getByRole("button", { name: "Create account" }).click();
const email = await waitForEmail({ to: address, since });
expect(email.subject).toContain("verification code");
await page.getByLabel("Verification code").fill(email.code);
await expect(page.getByText("Verified")).toBeVisible();
});
test("magic link signs the user in", async ({ page }) => {
test.setTimeout(150_000);
const address = INBOX_ADDRESS.replace("@", `+${Date.now()}@`);
const since = new Date().toISOString();
await page.goto("http://localhost:3000/login");
await page.getByLabel("Email").fill(address);
await page.getByRole("button", { name: "Email me a link" }).click();
const email = await waitForEmail({ to: address, since });
await page.goto(email.verification_link);
await expect(page.getByText("Signed in")).toBeVisible();
});
Cypress and other runners work the same way: call the wait endpoint from a task or a plain fetch.
Check content and attachments
The email from the wait has everything you may want to assert on:
EMAIL=$(curl -sf -G "$API/inboxes/$HOOKLISTENER_INBOX_ID/emails/wait" -H "$AUTH" \
--data-urlencode "to=$ADDRESS" --data-urlencode "since=$SINCE" \
--data-urlencode "subject_contains=invoice" -d timeout=60)
echo "$EMAIL" | jq -r '.data.text' | grep -q "Thanks for your order"
echo "$EMAIL" | jq '.data.attachments[] | {index, filename, content_type, size_bytes}'
# Download the first attachment with the same API key
curl -sf -H "$AUTH" -o invoice.pdf "$(echo "$EMAIL" | jq -r '.data.attachments[0].download_url')"
textis the text body, or the HTML body rendered as plain text when there is none.attachmentslists attachments and inline images, withfilename,content_type,size_bytes,disposition(attachmentorinline) anddownload_url.headersandheader_listhold the parsed headers, for checks such asList-Unsubscribeor a customX-header.
from and subject_contains filters narrow the wait further when one action sends several emails.
Clean up
- Delete the email you read when the test ends:
DELETE /api/v1/inboxes/:id/emails/:email_id. - Clear the whole inbox with
DELETE /api/v1/inboxes/:id/emails, for example in a nightly job. On Free this frees the 10-emails-per-24-hours allowance at once. Avoid clearing while other runs are waiting: their emails would be deleted too. - Emails also expire on their own after your plan's retention window.
Without an account
On the Free plan, or before anyone has signed up, each run can create its own inbox without an account. It needs no API key: the create response returns a viewer_token for reading it.
INBOX=$(curl -sf -X POST https://app.hooklistener.com/api/v1/anon/inboxes \
-H "Content-Type: application/json" -d '{"ttl_seconds": 900}')
INBOX_ID=$(echo "$INBOX" | jq -r .id)
ADDRESS=$(echo "$INBOX" | jq -r .email_address)
VIEWER_TOKEN=$(echo "$INBOX" | jq -r .viewer_token)
SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
./scripts/sign-up.sh "$ADDRESS"
CODE=$(curl -sf -G "https://app.hooklistener.com/api/v1/anon/inboxes/$INBOX_ID/emails/wait" \
-H "Authorization: Bearer $VIEWER_TOKEN" \
--data-urlencode "since=$SINCE" -d timeout=60 | jq -r '.data.code // empty')
One IP can create 10 of these per hour and 30 per day, so this suits small suites and occasional runs. Busier pipelines should use an inbox in an account.