Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions .github/workflows/playwright-signup-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
name: Playwright signup email tests (playwright-signup-ci)

on:
pull_request:
paths: ["playwright-signup-ci/**", ".github/workflows/playwright-signup-ci.yml"]
push:
branches: [main]
paths: ["playwright-signup-ci/**", ".github/workflows/playwright-signup-ci.yml"]
schedule:
- cron: "17 6 * * *" # daily real-delivery check
workflow_dispatch:

# The example lives in playwright-signup-ci/; in your own repo, drop working-directory.
defaults:
run:
working-directory: playwright-signup-ci

jobs:
# 1. Every pull request: a local SMTP catcher. Fast, free, nothing leaves CI.
# Proves the app sends the right email with a working link and code.
mailpit:
runs-on: ubuntu-latest
services:
mailpit:
image: axllent/mailpit:latest
ports:
- 1025:1025
- 8025:8025
env:
INBOX: mailpit
SMTP_HOST: localhost
SMTP_PORT: "1025"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: playwright-signup-ci/package-lock.json
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report-mailpit
path: playwright-signup-ci/playwright-report

# 2. Hosted inboxes with Mailsac Email Capture: the app sends to capture.mailsac.com
# and the test reads the message over the Mailsac API from any runner.
# Needs the repository secret MAILSAC_API_KEY and variable MAILSAC_USERNAME.
mailsac-capture:
runs-on: ubuntu-latest
env:
MAILSAC_API_KEY: ${{ secrets.MAILSAC_API_KEY }}
MAILSAC_DOMAIN: ${{ vars.MAILSAC_DOMAIN }}
SMTP_HOST: capture.mailsac.com
SMTP_PORT: "5587"
SMTP_USER: ${{ vars.MAILSAC_USERNAME }}
SMTP_PASS: ${{ secrets.MAILSAC_API_KEY }}
steps:
- name: Skip when Mailsac is not configured
id: configured
working-directory: . # runs before checkout, when the example folder does not exist yet
run: echo "ok=${{ env.MAILSAC_API_KEY != '' }}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
if: steps.configured.outputs.ok == 'true'
- uses: actions/setup-node@v4
if: steps.configured.outputs.ok == 'true'
with:
node-version: 22
cache: npm
cache-dependency-path: playwright-signup-ci/package-lock.json
- run: npm ci
if: steps.configured.outputs.ok == 'true'
- run: npx playwright install --with-deps chromium
if: steps.configured.outputs.ok == 'true'
- run: npx playwright test
if: steps.configured.outputs.ok == 'true'

# 3. Real delivery, daily and on main: sign up on your deployed staging app, which sends
# through your real email provider (SES, SendGrid, Postmark...) to a Mailsac inbox.
# Catches expired provider credentials, DNS/SPF/DKIM problems and blocked sending domains.
# Needs MAILSAC_API_KEY and the variable STAGING_URL. A private Mailsac domain
# (MAILSAC_DOMAIN) keeps the mail from real signups out of public inboxes.
real-delivery:
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
env:
MAILSAC_API_KEY: ${{ secrets.MAILSAC_API_KEY }}
MAILSAC_DOMAIN: ${{ vars.MAILSAC_DOMAIN }}
APP_URL: ${{ vars.STAGING_URL }}
steps:
- name: Skip when staging is not configured
id: configured
working-directory: . # runs before checkout, when the example folder does not exist yet
run: echo "ok=${{ env.MAILSAC_API_KEY != '' && env.APP_URL != '' }}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
if: steps.configured.outputs.ok == 'true'
- uses: actions/setup-node@v4
if: steps.configured.outputs.ok == 'true'
with:
node-version: 22
cache: npm
cache-dependency-path: playwright-signup-ci/package-lock.json
- run: npm ci
if: steps.configured.outputs.ok == 'true'
- run: npx playwright install --with-deps chromium
if: steps.configured.outputs.ok == 'true'
- run: npx playwright test
if: steps.configured.outputs.ok == 'true'
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
# mailsac-integration-test-examples
# Mailsac email testing examples

This project contains an example Node.js `mocha` integration test suite.
Runnable examples of testing the emails your app sends (signup verification, password reset,
magic links, one-time codes) in integration tests and CI/CD pipelines, using
[Mailsac](https://mailsac.com) test inboxes and its REST API.

| Example | Stack | What it shows |
|---|---|---|
| [`playwright-signup-ci/`](playwright-signup-ci/) | Playwright, GitHub Actions, GitLab CI | End-to-end signup verification in CI: sign up with a fresh inbox, wait for the email, confirm with the link and the 6-digit code. A local SMTP catcher (Mailpit) on every pull request, Mailsac Email Capture and a real-delivery check against staging. |
| [`test/`](test/) (repository root) | Node.js, Mocha | Check that an email arrived with a specific link, once through the REST API ([`test/test.js`](test/test.js)) and once through the realtime WebSocket API ([`test/websocket.js`](test/websocket.js)). |

## Mocha example (repository root)

In one test, Mailsac's REST API is used to check whether an email was received with a specific
URL link in the email body. In the other test, Mailsac's realtime Web Socket API is used for
the same purpose.

## More

- Email testing API overview: https://mailsac.com/pages/email-testing-api/
- Cypress plugin: [`@mailsac/cypress`](https://www.npmjs.com/package/@mailsac/cypress)
- Playwright guide: https://blog.mailsac.com/playwright-email-testing/
- Documentation: https://docs.mailsac.com
13 changes: 13 additions & 0 deletions playwright-signup-ci/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Mailsac (free account: https://mailsac.com/register)
MAILSAC_API_KEY=
# Optional: your private Mailsac domain, e.g. test.example.com or yourteam.msdc.co.
# Defaults to public @mailsac.com inboxes (anyone can read them; use made-up data).
MAILSAC_DOMAIN=

# How the app sends mail. Mailsac Email Capture keeps every message in Mailsac:
SMTP_HOST=capture.mailsac.com
SMTP_PORT=5587
SMTP_SECURE=false
SMTP_USER=your-mailsac-username
SMTP_PASS= # your Mailsac API key
MAIL_FROM="Example App <no-reply@example.com>"
4 changes: 4 additions & 0 deletions playwright-signup-ci/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
test-results/
playwright-report/
.env
38 changes: 38 additions & 0 deletions playwright-signup-ci/.gitlab-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# GitLab CI template for this example. GitLab reads .gitlab-ci.yml from the repository root:
# copy this file there in your own project (paths below assume the example is the project root).
# The same two layers on GitLab CI. Set MAILSAC_API_KEY (masked) and MAILSAC_USERNAME
# as CI/CD variables; STAGING_URL enables the scheduled real-delivery job.
default:
image: mcr.microsoft.com/playwright:v1.63.0-noble
before_script:
- npm ci

email-tests:mailpit:
services:
- name: axllent/mailpit:latest
alias: mailpit
variables:
INBOX: mailpit
MAILPIT_URL: http://mailpit:8025
SMTP_HOST: mailpit
SMTP_PORT: "1025"
script:
- npx playwright test
artifacts:
when: on_failure
paths: [playwright-report/]

email-tests:mailsac-capture:
rules:
- if: $MAILSAC_API_KEY
variables:
SMTP_HOST: capture.mailsac.com
SMTP_PORT: "5587"
script:
- SMTP_USER="$MAILSAC_USERNAME" SMTP_PASS="$MAILSAC_API_KEY" npx playwright test

email-tests:real-delivery:
rules:
- if: $CI_PIPELINE_SOURCE == "schedule" && $MAILSAC_API_KEY && $STAGING_URL
script:
- APP_URL="$STAGING_URL" npx playwright test
103 changes: 103 additions & 0 deletions playwright-signup-ci/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Email testing in CI/CD: verify signup emails end to end

A complete, runnable example of testing a **signup verification email** in a CI/CD pipeline.
A Playwright test signs up with a fresh inbox address, waits for the verification email,
checks its content, then confirms the account with the **link** and with the **6-digit code**.
The same pattern covers password resets, magic links and one-time codes.

It runs in **GitHub Actions** and **GitLab CI** with two layers:

| Layer | Runs | What it proves | Inbox |
|---|---|---|---|
| Local SMTP catcher | every pull request | the app sends the right email with a working link and code | [Mailpit](https://mailpit.axllent.org) service container |
| Hosted test inbox | on `main`, nightly, or every PR | the email really arrives through your provider, DNS and sending domain | [Mailsac](https://mailsac.com) REST API |

The local layer is fast and never leaves CI. The hosted layer catches what a local catcher
can't: expired email-provider credentials, SPF/DKIM or DNS mistakes, a throttled or blocked
sending domain, and templates that only break in production.

## How it works

```
Playwright test ──► your app ──SMTP──► email provider / Mailsac Email Capture / Mailpit
▲ │
└──────── Mailsac REST API (or Mailpit API) ◄──┘
```

1. `inbox.newAddress()` creates a unique address per test, e.g. `signup-link-mulu72uk-e3dd48fa@mailsac.com`,
so parallel tests and re-runs never read each other's mail.
2. The test signs up in the browser.
3. `inbox.waitForEmail()` polls `GET /api/addresses/{email}/messages` with the `Mailsac-Key`
header until the message arrives (60 s timeout), then reads it with `GET /api/text/{email}/{messageId}`.
4. The test extracts the verification link and code, completes the flow, and deletes the message.

The code is in [`tests/inbox.ts`](tests/inbox.ts) (about 100 lines, no SDK needed) and
[`tests/signup.spec.ts`](tests/signup.spec.ts). The app in [`app/server.js`](app/server.js)
stands in for yours.

## Run it

```bash
cd playwright-signup-ci
npm ci
npx playwright install chromium
```

**With Mailpit (no account needed):**

```bash
docker run -d -p 1025:1025 -p 8025:8025 axllent/mailpit
INBOX=mailpit SMTP_HOST=localhost SMTP_PORT=1025 npx playwright test
```

**With Mailsac:** create a free account at [mailsac.com](https://mailsac.com/register) and an
API key, then send through [Email Capture](https://docs.mailsac.com/en/latest/services/email_capture/email_capture.html),
which keeps every message in Mailsac instead of delivering it:

```bash
export MAILSAC_API_KEY=... # your API key
export SMTP_HOST=capture.mailsac.com SMTP_PORT=5587 SMTP_USER=your-mailsac-username SMTP_PASS=$MAILSAC_API_KEY
npx playwright test
```

**Against your deployed app (real delivery):** point the test at staging, which sends through
your real email provider to a Mailsac inbox:

```bash
APP_URL=https://staging.example.com MAILSAC_API_KEY=... npx playwright test
```

## CI setup

- **GitHub Actions** ([`.github/workflows/playwright-signup-ci.yml`](../.github/workflows/playwright-signup-ci.yml) in this repository; in your own project, save it as `.github/workflows/email-tests.yml` without the `working-directory` default):
add the repository secret `MAILSAC_API_KEY` and the variables `MAILSAC_USERNAME`
(for Email Capture) and `STAGING_URL` (for the nightly real-delivery job). Jobs without their
settings skip themselves, so the Mailpit job works on a fresh fork.
- **GitLab CI** ([`.gitlab-ci.yml`](.gitlab-ci.yml), copy it to your project root): set `MAILSAC_API_KEY` (masked),
`MAILSAC_USERNAME` and `STAGING_URL` as CI/CD variables; schedule a pipeline for real delivery.

## Public inboxes vs a private test domain

Addresses at `@mailsac.com` are free, need no setup and accept mail immediately, but anyone who
guesses the address can read them, so send only made-up data. For real signups on staging or
production, use a private domain: your own subdomain (e.g. `test.example.com`) or a zero-setup
`yourteam.msdc.co` subdomain. Set `MAILSAC_DOMAIN` and nothing else changes.

## Tips for reliable email tests

- **One address per test.** Never reuse a shared inbox across tests or runs.
- **Poll with a timeout** instead of sleeping. Most messages arrive in seconds; allow up to a minute for real delivery.
- **Assert on content**, not only arrival: subject, link host, code format, unsubscribe footer.
- **Clean up** messages after each test, and keep real-delivery checks on a schedule rather than every commit.
- **Prefer push for long waits:** Mailsac can also deliver each email to a [webhook or WebSocket](https://docs.mailsac.com).

## Other frameworks

- Cypress: [`@mailsac/cypress`](https://www.npmjs.com/package/@mailsac/cypress)
- Playwright guide: [blog.mailsac.com/playwright-email-testing](https://blog.mailsac.com/playwright-email-testing/)
- Email testing API overview: [mailsac.com/pages/email-testing-api](https://mailsac.com/pages/email-testing-api/)
- REST API reference: [mailsac.com/docs/api](https://mailsac.com/docs/api)

## License

MIT
85 changes: 85 additions & 0 deletions playwright-signup-ci/app/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// A deliberately small signup app. It stands in for your application: on signup it
// sends a verification email with a link and a 6-digit code over SMTP. Point SMTP_* at
// your real email provider, at Mailsac Email Capture, or at Mailpit in CI.
const crypto = require('node:crypto');
const express = require('express');
const nodemailer = require('nodemailer');

const PORT = Number(process.env.PORT || 3000);
const APP_URL = process.env.APP_URL || `http://localhost:${PORT}`;
const MAIL_FROM = process.env.MAIL_FROM || 'Example App <no-reply@example.com>';

const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'localhost',
port: Number(process.env.SMTP_PORT || 1025),
// true for port 465 (implicit TLS); false upgrades with STARTTLS when the server offers it
secure: process.env.SMTP_SECURE === 'true',
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } : undefined,
});

// In-memory store: token -> { email, code, verified }
const pending = new Map();

const app = express();
app.use(express.urlencoded({ extended: false }));

const page = (title, body) => `<!doctype html><html><head><title>${title}</title></head>
<body><main><h1>${title}</h1>${body}</main></body></html>`;
const escape = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);

app.get('/', (req, res) => {
res.send(page('Create an account', `
<form method="post" action="/signup">
<label>Email <input type="email" name="email" required></label>
<button type="submit">Sign up</button>
</form>`));
});

app.post('/signup', async (req, res, next) => {
try {
const email = String(req.body.email || '').trim();
const token = crypto.randomBytes(16).toString('hex');
const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
pending.set(token, { email, code, verified: false });

const link = `${APP_URL}/verify?token=${token}`;
await transport.sendMail({
from: MAIL_FROM,
to: email,
subject: 'Confirm your email address',
text: `Welcome!\n\nConfirm your email address: ${link}\n\nOr enter this code: ${code}\n`,
html: `<p>Welcome!</p><p><a href="${link}">Confirm your email address</a></p>
<p>Or enter this code: <strong>${code}</strong></p>`,
});

res.send(page('Check your email', `
<p>We sent a confirmation link and code to ${escape(email)}.</p>
<form method="post" action="/verify-code">
<input type="hidden" name="token" value="${token}">
<label>6-digit code <input name="code" inputmode="numeric" required></label>
<button type="submit">Verify</button>
</form>`));
} catch (err) {
next(err);
}
});

const verified = (res, email) => res.send(page('Email verified', `<p>Your email ${escape(email)} is verified.</p>`));

app.get('/verify', (req, res) => {
const entry = pending.get(String(req.query.token || ''));
if (!entry) return res.status(400).send(page('Invalid link', '<p>This link is invalid or expired.</p>'));
entry.verified = true;
verified(res, entry.email);
});

app.post('/verify-code', (req, res) => {
const entry = pending.get(String(req.body.token || ''));
if (!entry || entry.code !== String(req.body.code || '').trim()) {
return res.status(400).send(page('Invalid code', '<p>That code is not correct.</p>'));
}
entry.verified = true;
verified(res, entry.email);
});

app.listen(PORT, () => console.log(`Example app listening on ${APP_URL}`));
Loading
Loading