DEV Community

Naga Bandaru
Naga Bandaru

Posted on Originally published at emailblackhole.io

Test your password-reset email end to end in GitHub Actions

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>
Enter fullscreen mode Exit fullscreen mode

The Email Blackhole home page with the Copy as .env button

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.

The permanent inbox and API keys panels on the account page

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

  1. Point the app at a dummy SMTP server. The app reads SMTP_HOST, SMTP_PORT, SMTP_USER and SMTP_PASS. Every email is caught and none is delivered, so the test can use any address.
  2. Take a mark, the inbox position, before the test triggers anything.
  3. Trigger the reset for a unique address such as user-1730000000000@example.com.
  4. Wait for the email, filtered by that address and by the subject.
  5. 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
Enter fullscreen mode Exit fullscreen mode

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()
})
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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)