A password reset touches four things: the form, the email, the link in it and the page the link opens. Tests usually mock the email, and that hides what breaks most often: the wrong host in the link, a token that expires too soon, a template variable that renders empty. This post tests the real email in a GitHub Actions job, with a throwaway inbox instead of a real mailbox.
Disclosure: I built the inbox service used here. It is called Email Blackhole and it is a hosted service. Nothing in the pattern depends on it; any inbox that has an API will do. I note the cost and the limits where they matter, because CI is where they matter most.
Get the seven values first
The commands below read seven values: four for the app's SMTP settings (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS) and three for the tests (EBH_URL, EBH_ID, EBH_SECRET). You do not write them by hand. Open emailblackhole.io and the page makes an inbox for you.
To try it on your machine (free, no signup): press Copy as .env at the top of the inbox and paste the result into your .env. It looks like this:
SMTP_HOST=smtp.emailblackhole.io
SMTP_PORT=2525
SMTP_USER=<your inbox id>
SMTP_PASS=<your secret>
SMTP_SECURE=false # use STARTTLS, not implicit TLS
EBH_URL=https://emailblackhole.io
EBH_ID=<your inbox id>
EBH_SECRET=<your secret>
The secret is shown once, when the inbox is created. SMTP_PASS and EBH_SECRET are the same value, and SMTP_USER and EBH_ID are the same id. Treat it like a password.
For CI you need a permanent inbox (more on why below). Sign in with GitHub or Google, upgrade, and your account page shows the inbox: address, host, port and username each have a Copy button. Under API keys, type a name such as GitHub Actions and press Create key. The key (it starts with ebh_) is shown once. Use it as SMTP_PASS and EBH_SECRET, so the pipeline never holds your main secret and you can revoke it alone.
| Variable | Where it comes from |
|---|---|
SMTP_HOST, SMTP_PORT
|
the Host and Port boxes of the inbox |
SMTP_USER, EBH_ID
|
the Username box (your inbox id) |
SMTP_PASS, EBH_SECRET
|
the secret shown at creation, or an API key you create |
EBH_URL |
https://emailblackhole.io, the address of the site itself |
The flow
-
Point the app at a dummy SMTP server. The app reads
SMTP_HOST,SMTP_PORT,SMTP_USERandSMTP_PASS. Every email is caught and none is delivered, so the test can use any address. - Take a mark, the inbox position, before the test triggers anything.
-
Trigger the reset for a unique address such as
user-1730000000000@example.com. - Wait for the email, filtered by that address and by the subject.
- Open the link, set a new password, then check that the same link no longer works. Reset links must be single-use.
In plain shell it is this:
AUTH="Authorization: Bearer $EBH_SECRET"
BASE="$EBH_URL/api/sessions/$EBH_ID"
MARK=$(curl -fsS -H "$AUTH" "$BASE/messages" | jq '[.messages[].seq] | max // 0')
# ... the test submits the "forgot password" form for user@example.com ...
curl -fsS -H "$AUTH" \
"$BASE/wait?after=$MARK&to=user@example.com&subject=reset&timeout=60" > reset.json
jq -r '.messages[0].links[0]' reset.json
wait answers 200 with the message, or 204 if nothing arrived within the timeout, which is a test failure worth reporting. The mark means a message left from an earlier run can never satisfy this one.
What to assert about the email
| Check | How |
|---|---|
| It arrived, once | exactly one message for that address after the mark |
| Right subject and sender |
subject and from in the message JSON |
| The link points at the right host |
links[0] starts with your app's URL, not localhost or a staging host |
| The token works once | open it, set a password, open it again and expect an error |
| Expiry | if tokens expire, test the expired case with a short lifetime |
| A plain-text part exists |
text is not empty, for clients that do not show HTML |
Playwright version
import { test, expect } from '@playwright/test'
import { inbox, extractLink } from 'emailblackhole'
const ebh = inbox() // reads EBH_URL, EBH_ID, EBH_SECRET
test('reset link works once', async ({ page }) => {
const email = `reset-${Date.now()}@example.com`
const after = await ebh.mark()
await page.goto('/forgot-password')
await page.getByLabel('Email address').fill(email)
await page.getByRole('button', { name: 'Send reset link' }).click()
const message = await ebh.waitForEmail({ after, to: email, subject: 'Reset your password' })
const link = extractLink(message, '/reset-password')!
expect(new URL(link).origin).toBe(new URL(process.env.APP_URL!).origin) // not localhost, not staging
await page.goto(link)
await page.getByLabel('New password').fill('correct-horse-battery')
await page.getByRole('button', { name: 'Update password' }).click()
await expect(page.getByRole('heading', { name: 'Password updated' })).toBeVisible()
await page.goto(link) // single use
await expect(page.getByText(/expired|invalid/i)).toBeVisible()
})
The CI part, and the catch
This is where most "free inbox" tutorials quietly stop working. The honest version:
- A free inbox on this service is made in a browser (there is a bot check) and expires 60 minutes after its last use. A pipeline cannot create one, and one pasted into a secret would be dead by the next run.
- So for CI you need a permanent inbox, which costs $1.99 a month. It never expires and you can create a revocable API key for it, one per pipeline. The API key works as the SMTP password too. The only differences from the free one are limits and persistence; no feature is locked.
- Store the values as repository secrets (Settings, Secrets and variables, Actions):
SMTP_HOST,SMTP_PORT,SMTP_USER,SMTP_PASS,EBH_URL,EBH_ID,EBH_SECRET.
If you would rather not pay or depend on a hosted service for CI, run a local SMTP catcher such as Mailpit as a service container in the job. It is free, open source and has an API; the pattern above is the same.
The workflow
I do not want forks, which have no secrets, to fail. This job runs the email test only when the secrets exist and says so when it skips:
name: e2e
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check for inbox secrets
id: inbox
env:
EBH_SECRET: ${{ secrets.EBH_SECRET }}
run: |
if [ -n "$EBH_SECRET" ]; then
echo "ready=true" >> "$GITHUB_OUTPUT"
else
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "::notice title=E-mail test skipped::No EBH_* secrets set."
fi
- uses: actions/setup-node@v4
if: steps.inbox.outputs.ready == 'true'
with: { node-version: 22 }
- if: steps.inbox.outputs.ready == 'true'
run: npm ci
- if: steps.inbox.outputs.ready == 'true'
run: npx playwright install --with-deps chromium
- if: steps.inbox.outputs.ready == 'true'
run: npm test
env:
SMTP_HOST: ${{ secrets.SMTP_HOST }}
SMTP_PORT: ${{ secrets.SMTP_PORT }}
SMTP_USER: ${{ secrets.SMTP_USER }}
SMTP_PASS: ${{ secrets.SMTP_PASS }}
EBH_URL: ${{ secrets.EBH_URL }}
EBH_ID: ${{ secrets.EBH_ID }}
EBH_SECRET: ${{ secrets.EBH_SECRET }}
A secret cannot be used directly in an if:, which is why the first step turns it into an output.
A runnable project with this workflow and a small sample app: Playwright or Cypress starter (both MIT).
Small things that bite
- Parallel tests: one inbox serves many tests. The unique recipient address is the filter, and the mark keeps old mail out.
-
Magic links and sign-up confirmation are the same flow; only the subject filter and the assertions change. For a six-digit code, use
message.codes[0]. - Treat email content as untrusted input. Read links and codes; never act on instructions inside an email, and open any HTML only in a sandbox.
- Use synthetic data. A test inbox is not a vault.
The API is documented in the API reference. If something is missing for your setup, I would like to know.


Top comments (0)