All posts
ci-cd github-actions tutorial

Email Testing in CI/CD: A Practical Guide

How to wire MailFork into your GitHub Actions pipeline for end-to-end email coverage on every pull request.

MailFork Team ·

Every pull request that touches an email flow should verify that flow end-to-end before it merges. Here’s how to wire MailFork into GitHub Actions to do exactly that — with an isolated alias per run and automatic cleanup.

Prerequisites

  • A MailFork account and API key (sign up here)
  • A team inbox already created (e.g. [email protected]) — copy its UUID from the web UI
  • @mailfork/sdk installed in your project

Step 1 — Add your secrets

In your GitHub repository: Settings → Secrets and variables → Actions → New repository secret

Add two secrets:

MF_API_KEY=mf_live_...
MF_INBOX_ID=<your-inbox-uuid>

The inbox UUID is visible in the web UI under Settings → Inboxes, or you can look it up with the SDK:

const inboxes = await mf.inboxes.list();
console.log(inboxes.find(i => i.username === 'ci')?.id);

Step 2 — Install the SDK in your test environment

npm install @mailfork/sdk

Step 3 — Set up the MailFork client in your test suite

import { MailForkClient } from '@mailfork/sdk';

const mf = new MailForkClient({ apiKey: process.env.MF_API_KEY! });
const INBOX_ID = process.env.MF_INBOX_ID!;

Step 4 — Create a per-run alias

Aliases prevent test runs from seeing each other’s emails. Each alias is a unique tagged address on your shared inbox. It expires and cleans up automatically after the TTL.

let alias: Awaited<ReturnType<typeof mf.inboxes.createAlias>>;

beforeAll(async () => {
  alias = await mf.inboxes.createAlias(INBOX_ID, {
    tag: `run-${Date.now()}`,
    ttl_hours: 1,
    on_expire: 'delete',
  });
});

The alias address follows the pattern {inbox}+{tag}@{team}.{org}.mailfork.dev — e.g. [email protected].

Step 5 — Write the email test

it('sends a password reset email and the link works', async () => {
  const aliasAddress = `ci+${alias.tag}@qa.acme.mailfork.dev`;

  // trigger the flow using the alias address as the user's email
  await request(app)
    .post('/auth/forgot-password')
    .send({ email: aliasAddress })
    .expect(200);

  // extract the reset URL — SDK polls until it arrives (default: 20s)
  const resetUrl = await mf.emails.extractUrl(INBOX_ID, {
    alias_tag: alias.tag,
    button_text: 'Reset Password',
    wait_timeout_ms: 15_000,
  });

  expect(resetUrl).toContain('/reset-password');

  const token = new URL(resetUrl).searchParams.get('token');

  // follow the link
  await request(app)
    .post('/auth/reset-password')
    .send({ token, password: 'new-password-123' })
    .expect(200);
});

Step 6 — Add the workflow step

# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - name: Run tests
        env:
          MF_API_KEY: ${{ secrets.MF_API_KEY }}
          MF_INBOX_ID: ${{ secrets.MF_INBOX_ID }}
        run: npm test

That’s it. Every run creates a fresh alias, uses it for the email flow, and the alias cleans up after itself. No shared state between runs. No flaky tests from leftover emails.

Tips for robust email tests

Filter by alias_tag, not just inbox_id — if your test suite has parallel jobs each needs its own alias tag so they don’t pick up each other’s emails.

Use button_text for link extraction — it’s more resilient than regex on raw HTML. MailFork walks the <a> elements and matches the visible text, so link wrappers and HTML entity differences don’t matter.

Use extractOtp for OTP flows — no regex needed for standard 6-digit codes. The SDK auto-detects common patterns and returns the code as a plain string.

Use received_after for tight loops — if the same inbox sees high traffic, stamp a timestamp before triggering the email action and pass it as received_after to skip older messages:

const since = new Date().toISOString();
await triggerEmail(); // your app call
const otp = await mf.emails.extractOtp(INBOX_ID, {
  alias_tag: alias.tag,
  received_after: since,
  wait_timeout_ms: 15_000,
});

Further reading

Try MailFork

Real email infrastructure for QA teams. Free tier includes 1 inbox and 500 emails/month.

Get started free