Skip to content
PigeonProbe

Local Node.js tutorial

Test email flows with Playwright

Use your SMTP provider to send two real emails from a local sample app. Playwright confirms signup, resets the password, and checks that the same user can sign in again.

Using Supabase Auth? Follow the Supabase signup and password-reset guide.

Download exampleStart free trial

Connect one sender and one test mailbox

You need Node.js 22.9 or newer, a PigeonProbe mailbox, and SMTP credentials from your sending provider. Make sure that provider permits delivery to your test address; its normal limits apply.

  1. Prepare PigeonProbe. Use your first mailbox or create a dedicated one. As an organization administrator with read access to that mailbox, issue a mailbox-scoped service credential with email:read and addresses:manage.
  2. Prepare your sender. Use its authenticated SMTP host, username and password, and a From address it permits. The sample uses port 587 with required STARTTLS, or port 465 with TLS from the start. It does not require outbound port 25.
  3. Configure the sample. Download and extract it, copy .env.example to .env, and fill in the values below. Keep credentials in this local file or your CI secret store, never browser code.
SMTP_MODE=provider
SMTP_HOST=smtp.your-provider.example
SMTP_PORT=587
SMTP_USER=your-smtp-username
SMTP_PASS=your-smtp-password
SMTP_FROM=qa@your-verified-domain.example
DEMO_RECIPIENT_DOMAIN=your-team.pigeonprobe.net
PIGEON_CLIENT_ID=your-client-id
PIGEON_CLIENT_SECRET=your-client-secret
PIGEON_MAILBOX_ID=your-mailbox-id

Use the exact recipient domain assigned to the mailbox. The sample only sends to that configured domain. PigeonProbe supplies receiving addresses and API access; SMTP credentials come from your sending provider.

Already have an application that sends through an API? Keep that code and use the single-message receipt check, then add the test helper to your existing suite.

Watch a local test run

The recording shows the local sample app completing signup and password reset. PigeonProbe receives and checks both messages.

One local application, two real emails

  1. Create a fresh PigeonProbe address for this run.
  2. Sign up a local user at that address. The Node.js sample submits a confirmation message through your configured SMTP transport.
  3. Read the received message through the PigeonProbe API, validate its local link, and confirm the user in Playwright.
  4. Request recovery for the same user, read the new reset message, and update the password.
  5. Sign out, then sign in as the same user with the new password.
  6. Delete only the local user created by this run and revoke only its temporary PigeonProbe address.

The application, authentication state and browser run stay on 127.0.0.1. PigeonProbe supplies the actual receiving mailbox and API evidence. The sample does not require Supabase or a new sending service. Use a test-scoped SMTP credential where your existing provider supports one.

Run the example

Open a terminal in the extracted local-email-testing folder. After configuring .env, install the locked dependencies and Chromium:

npm ci
npx playwright install chromium

In one terminal, start the loopback application:

npm start

In a second terminal, open the same extracted folder and run the full email journey:

npm run test:live

npm test runs local checks only. It does not send or receive a message. Use npm run test:live when you intend to exercise PigeonProbe delivery.

Why this test catches more than a successful send

An SMTP acceptance tells you the sender accepted a message. This test waits for the received email, follows its intended link, and checks the account state afterward. These are the parts to carry into your own suite.

1. Give every run its own address

The helper creates a temporary address with a one-hour lifetime. Leave DEMO_ADDRESS_LOCAL_PART blank for a fresh suffix on every run. Parallel workers then have separate recipients, and yesterday's confirmation cannot satisfy today's assertion.

In workflow.mjs, the address is created before any browser action. The helper connects with the mailbox-scoped credential:

const inbox = await connect({
  clientId: env.PIGEON_CLIENT_ID,
  clientSecret: env.PIGEON_CLIENT_SECRET,
  mailboxId: env.PIGEON_MAILBOX_ID
});
const address = await inbox.createAddress(addressLocalPart);

2. Match the recipient, time and purpose

Capture the time immediately before submitting the form. The signup step then waits for the confirmation message sent to this run's address:

const since = new Date().toISOString();
await page.locator('#submit').click();
await status('Confirmation requested.');
const message = await inbox.waitForMessage({
  address: address.address,
  since,
  subject: 'Confirm your local demo account',
  timeoutMs: 120_000
});

The subject filter matches text contained in the subject. The helper polls until a matching message arrives or the deadline expires; it does not sleep for a fixed two minutes. Recovery uses a new timestamp and a different subject so the confirmation message cannot satisfy the reset step.

3. Validate a link before opening it

The sample's verificationLink function accepts exactly one link with the expected local origin, path and token format. This prevents a footer link or a rewritten tracking URL from being treated as the confirmation link.

const link = verificationLink(message, {
  origin: origin.origin,
  path: '/confirmed'
});
await page.goto(link);
await status('Email confirmed.');

Turn off click tracking for this sample's emails. When adapting the test, change the allowed origin and path to your application's expected destination rather than following the first link in the message.

4. Prove the new password works

A reset email alone is not a completed recovery. After submitting the new password, the test signs out, signs in with that password, and compares the account identifier with the one captured during signup. An unexpected account switch fails the run.

These snippets explain the downloaded workflow; they are excerpts, not a separate test file. live-test.mjs runs the complete sequence:

await workflow.prepare();
await workflow.signup();
await workflow.reset();
await workflow.changePassword();
await workflow.finish();

Assert the user outcome

The live run creates its address and local cleanup handle before signup. It matches each received message to that address, the current step and its receipt window before following a link. The run passes only after the confirmed user requests recovery, updates the password, signs out, and signs in as the same user with the new password.

Cleanup is scoped to the run: the local application removes users bearing that run ID, and the receipt helper revokes the address it created. An interrupted run may still need its exact test address revoked in PigeonProbe. Keep passwords, links, raw messages and service credentials out of shared logs.

Move the pattern into your own application

Keep your application's existing email sender, whether it uses SMTP or an HTTP API. Replace the sample's local forms with your own signup and recovery pages; PigeonProbe stays on the receiving side.

  1. Reuse the receipt helper. Copy email-check.mjs into your Node.js test project and connect with a credential restricted to the test mailbox.
  2. Create a recipient before signup. Pass the returned address into your application's form. Use a fresh address for every parallel test.
  3. Wait at the point where mail matters. Capture since before the action that sends mail, then match the recipient and the expected subject text.
  4. Assert your own account state. Validate the expected link destination, complete confirmation or recovery, and check the user-visible result and stable account identity.
  5. Clean up in a finally block. Remove only the user created by the test and revoke the exact address object returned by createAddress. Keep that object's ID and version for the revoke operation.

In CI, put credentials in the runner's secret store, start your test application before the browser run, and retain the receipt as a private artifact. Avoid publishing raw email, passwords or reset links. The CI integration guide covers the shared helper; the use-case guides explain where received-mail tests fit into a team workflow.

Where to make changes

live-test.mjs
Browser startup, workflow order, failure exit code and final cleanup.
workflow.mjs
Form actions, message subjects, allowed links, account assertions and receipt output.
email-check.mjs
Service authentication, temporary addresses and bounded message polling.
smtp.mjs
The sample's SMTP submission. Your existing application's sender can take its place.
server.mjs
The disposable local application. Its in-memory authentication is for this example; keep your own authentication implementation.

Find the failing step

After a workflow step fails, the runner prints the location of receipt.json. Startup or configuration failures may occur before a receipt path is available. Inspect its phase, completed checks and cleanup fields before rerunning. It records message identifiers and timestamps without saving message bodies or link tokens.

The workflow cannot start
Check Node.js 22.9+, the four required PigeonProbe environment values, installed Chromium, and the server running at http://127.0.0.1:4188. Run both terminals from the extracted folder so they load the same .env.
SMTP submission fails
Check the provider host, credentials and permitted From address. Use port 587 or 465. If the provider restricts recipients in a sandbox, authorize your test recipient according to its settings.
No matching mail arrives before the deadline
Check the sender's delivery log first. Then confirm the exact recipient domain, active address, organization quota and mailbox access. For an adapted app, make sure the subject filter matches the message it actually sends.
A message arrives, but link validation fails
Disable the sender's click tracking and check the destination origin and path. The sample expects /confirmed or /reset on its loopback origin, with one token parameter.
The address already exists
Clear DEMO_ADDRESS_LOCAL_PART to restore fresh names. A fixed name is useful for a recording, but the test intentionally refuses to reuse an existing address.
Checks pass, but cleanup needs attention
The runner exits with a failure if cleanup is incomplete. Revoke only the address recorded for that run in PigeonProbe. Stopping the sample server clears its in-memory users and sessions. Revoking an address stops future reception; it does not delete previously received messages.

Delivery and advanced transports

With SMTP_MODE=provider, the sample submits mail over port 587 or 465 and your provider delivers it to PigeonProbe. TLS certificates are verified; authentication failures do not fall back to direct SMTP. If a run times out, check your provider's sending logs and recipient restrictions.

Direct SMTP and operator tunnels

For a machine that can deliver directly, explicitly choose SMTP_MODE=direct. This advanced path does not need a sender subscription.

Direct SMTP targets the PigeonProbe receiver on port 25 and requires the machine or network running the sample to permit outbound TCP 25. TLS is required and the receiver certificate is verified. The local authentication code exists to make the email journey runnable; it is not reusable production authentication.

An operator may provide a loopback tunnel with SMTP_MODE=tunnel, SMTP_HOST=127.0.0.1 and an explicit SMTP_PORT when outbound port 25 is unavailable. That tunnel is an internal fallback, not a public-user prerequisite, and a tunneled run is not evidence of direct SMTP reachability from the test machine.

A message received by PigeonProbe proves what reached this receiver. It does not prove Gmail or Outlook inbox placement or deliverability. For another framework or an application that already has a sender, use the CI integration guide and keep that application's normal sending path.