← All posts

How to Test Email Flows in GitHub Actions

A
Admin
·

Your email tests pass locally but fail in CI. Or worse — they pass in CI by mocking the mailer and then break in production because the actual SMTP integration was never tested. Here's how to fix that.

Why Email Testing in CI Is Hard

Three problems make email testing in CI pipelines unreliable:

  • Real SMTP is slow and rate-limited. Sending through production email APIs adds 1-3 seconds per email and most providers throttle CI-scale traffic.
  • Shared inboxes cause race conditions. If two parallel CI jobs send to the same inbox, assertions on "latest email" become non-deterministic.
  • Credential leakage risk. Accidentally sending test emails to real addresses with production SMTP credentials is a data-leak incident.

The solution: use a dedicated SMTP testing sandbox with per-branch inboxes and a REST API for assertions.

The Two Testing Patterns

Pattern A: Mock the Mailer (Unit Tests)

Replace your email client with a mock and assert that the right function was called with the right arguments. This is fast and good for testing business logic, but it doesn't verify that your email actually sends correctly via SMTP.

// Jest example — mock only
jest.mock("../lib/mailer");
const { sendPasswordReset } = require("../lib/mailer");

test("password reset sends email", async () => {
  await requestPasswordReset("user@test.com");
  expect(sendPasswordReset).toHaveBeenCalledWith(
    "user@test.com",
    expect.stringContaining("/reset?token=")
  );
});

Pattern B: Real SMTP Capture (Integration Tests)

Send through a real SMTP server that captures the email, then query the API to verify it arrived with the correct content. This tests the full stack — template rendering, SMTP transport, headers, and content.

Complete GitHub Actions Workflow

name: Email Integration Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    env:
      SMTP_HOST: smtp.mailhog.site
      SMTP_PORT: 2525
      SMTP_USER: ${{ secrets.MAILHOG_SMTP_USER }}
      SMTP_PASS: ${{ secrets.MAILHOG_SMTP_PASS }}
      MAILHOG_API: https://mailhog.site/api/v1
      MAILHOG_API_KEY: ${{ secrets.MAILHOG_API_KEY }}

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm ci
      - run: npm run test:email

Node.js Assertion Example

// test/email.integration.test.js
const { describe, it, expect } = require("@jest/globals");

async function getLatestEmail(recipient) {
  const maxAttempts = 10;
  for (let i = 0; i < maxAttempts; i++) {
    const res = await fetch(
      `${process.env.MAILHOG_API}/emails?to=${recipient}&limit=1`,
      { headers: { Authorization: `Bearer ${process.env.MAILHOG_API_KEY}` } }
    );
    const data = await res.json();
    if (data.length > 0) return data[0];
    await new Promise(r => setTimeout(r, 500)); // poll every 500ms
  }
  throw new Error(`No email found for ${recipient} after ${maxAttempts} attempts`);
}

describe("Password Reset Flow", () => {
  it("sends a reset email with a valid link", async () => {
    // Trigger the flow
    await fetch("http://localhost:3000/api/auth/forgot", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email: "testuser@example.com" }),
    });

    // Assert on captured email
    const email = await getLatestEmail("testuser@example.com");
    expect(email.subject).toContain("Reset your password");
    expect(email.html_body).toContain("/reset?token=");
    expect(email.from_email).toBe("noreply@yourapp.com");
  });
});

Python Assertion Example

import httpx, time, pytest

MAILHOG_API = "https://mailhog.site/api/v1"

def get_latest_email(recipient: str, timeout: int = 5):
    for _ in range(timeout * 2):
        r = httpx.get(f"{MAILHOG_API}/emails",
            params={"to": recipient, "limit": 1},
            headers={"Authorization": f"Bearer {MAILHOG_API_KEY}"})
        emails = r.json()
        if emails:
            return emails[0]
        time.sleep(0.5)
    pytest.fail(f"No email for {recipient}")

def test_welcome_email():
    # Register a user
    httpx.post("http://localhost:8000/api/register",
        json={"email": "new@test.com", "password": "test1234"})

    email = get_latest_email("new@test.com")
    assert "Welcome" in email["subject"]
    assert "new@test.com" in email["to_email"]

Avoiding Common Failures

  • Race conditions: Always poll with retries instead of sleep(2). Email delivery takes variable time.
  • Shared inbox pollution: Use separate inboxes per CI branch or use unique recipient addresses per test run (e.g., test-{uuid}@example.com).
  • Secret leakage: Never hardcode SMTP credentials. Use GitHub's encrypted secrets and reference them via secrets. context.

FAQ

Can I use MailHog.site in parallel CI runs?

Yes. Use multiple inboxes — one per CI environment — so parallel runs don't interfere with each other's assertions. Each inbox has its own SMTP credentials.

How fast is email capture compared to real SMTP?

Capture is near-instant — typically under 200ms from SMTP send to API availability. Real SMTP delivery through providers like SendGrid can take 1-10 seconds depending on the provider's queue depth.

Should I mock the mailer or use real SMTP in tests?

Use both. Mock the mailer for fast unit tests that verify business logic (who gets emailed, when). Use real SMTP capture for integration tests that verify the full email pipeline — template rendering, headers, deliverability. See our Node.js SMTP guide for setup details.

← Back to all posts