Mailsac MCP server: test inboxes for AI coding agents

Give Your AI Coding Agent a Test Inbox: the Official Mailsac MCP Server

By Mailsac Engineering. Written for @mailsac/mcp 0.1.0.

AI coding agents can now build a sign-up flow in minutes. Checking that it works is still awkward: the last step happens in an inbox the agent can’t see. So the agent stops and asks you to open your email, or it marks the task done without knowing whether the confirmation email arrived, whether the link works, or whether the code is six digits or five.

The official Mailsac MCP server closes that gap. It gives any agent that speaks the Model Context Protocol (Claude Code, Cursor, VS Code, Claude Desktop and others) its own disposable test inboxes. The agent creates an address, uses it in your app, waits for the email, and reads the link or code itself.

What the agent can do

Tool What it does
create_test_address Makes a new unique address, such as signup-mf3k2a1b-9c41d2@mailsac.com. It can receive mail immediately; there is nothing to create first.
wait_for_email Waits for the email to arrive (optionally matching a subject, sender or time), then returns the subject, text, every link, the link most likely to confirm, reset or log in (actionLink), and any one-time codes.
list_emails / read_email Lists what arrived, and reads one email as text, HTML, raw source or headers.
delete_emails Cleans up after a test.
list_domains Shows your private domains, so the agent can use one instead of a public address.

Set it up in a minute

Create a free Mailsac account and an API key, then add the server.

Claude Code

claude mcp add mailsac -e MAILSAC_API_KEY=your-key -- npx -y @mailsac/mcp

Cursor (.cursor/mcp.json) or Claude Desktop

{
  "mcpServers": {
    "mailsac": {
      "command": "npx",
      "args": ["-y", "@mailsac/mcp"],
      "env": { "MAILSAC_API_KEY": "your-key" }
    }
  }
}

VS Code (.vscode/mcp.json)

{
  "servers": {
    "mailsac": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "@mailsac/mcp"],
      "env": { "MAILSAC_API_KEY": "your-key" }
    }
  }
}

The documentation lists every setting, including MAILSAC_DOMAIN for private domains.

What it looks like

Ask the agent to check a flow the way a person would:

Sign up on http://localhost:3000 with a fresh test address and confirm the account by email.

The agent calls create_test_address, fills in your form, then calls wait_for_email. When the email arrives, the tool hands back something like this (from our own test run):

{
  "subject": "Confirm your Example account",
  "from": "no-reply@example.test",
  "actionLink": "https://example.test/verify?token=e2e-abc123",
  "codes": ["731905"],
  "polls": 1
}

The agent opens actionLink (or types the code), checks that the account is confirmed, and reports back, including when something is wrong: no email within a minute, a link pointing at the wrong host, or a code the form rejects. delete_emails then tidies up.

The same works for password resets, magic-link logins, invitations and two-factor codes.

Turn the check into a test

An agent’s check proves the flow works today. A test keeps it working. Once the agent has seen the email, ask it to write an end-to-end test for the flow. Our Playwright CI example shows the pattern it can follow: a unique address per test, polling with a timeout, and assertions on the link and code, in GitHub Actions and GitLab CI. For Cypress there is @mailsac/cypress, and any language can use the REST API.

Public addresses and private domains

Addresses at @mailsac.com are public: anyone who guesses the address can read the mail. They are perfect for made-up test data and need no setup, which is why the server uses them by default. For a staging environment that sends real names or data, use a private domain: your own subdomain or a zero-setup yourteam.msdc.co subdomain. Set MAILSAC_DOMAIN and every new address uses it. Mail to a private domain is visible only to your account.

What it costs

Each API call uses one Mailsac operation. wait_for_email checks every three seconds, so an email that arrives within a few seconds typically costs two to five operations, and creating an address costs nothing. The free plan includes 1,500 operations a month, enough for a few hundred agent checks; paid plans start at 25,000.

Open source

The server is MIT-licensed on GitHub and published to npm as @mailsac/mcp. Requests from it identify themselves with a Mailsac-Client: mcp header, so we can count how many people use Mailsac through AI agents. We never look at your email content for that. Issues and pull requests are welcome.

Playwright email testing with Mailsac

Playwright Email Testing: Test Signup and Password-Reset Emails with Mailsac

By Mailsac Engineering. Tested on September 24, 2026 with @playwright/test 1.63 on Node.js 22, 24 and 26.

In short: Playwright can’t open an inbox by itself. Send your app’s signup or reset email to a Mailsac address, wait for it from the Playwright test runner with the Mailsac REST API, then type the emailed code or open the emailed link. The helper below is about 75 lines and needs no extra packages.

Get a free API key → The free plan includes an API key, 1 private address and 1,500 operations a month. Team plans with shared custom domains start at $89/month.

Playwright can fill in your signup and password-reset forms, but the step that proves they work happens outside the browser: an email arrives with a 6-digit code or a link. This tutorial adds that step to your Playwright tests. Your app sends mail through its usual provider, Mailsac receives it at a unique test address, and the test reads it through the Mailsac REST API before typing the code or opening the link.

You will build a small helper, two signup-verification tests (one types the emailed code, one follows the emailed link) and a password-reset test that signs in with the new password. The tests run side by side in parallel workers and in GitHub Actions. Every code block below was run with real Playwright against a local test app and a mock of the Mailsac API.

How Playwright email testing works

Each test follows three steps:

  • Trigger. The test signs up, or asks for a reset, with a brand-new address such as signup-3-1790000000000-1a2b3c4d@yourteam.msdc.co, and notes the time just before it clicks. There is no inbox to create first: any address at mailsac.com or on your custom domain can receive mail.
  • Wait. The test polls GET /api/addresses/{email}/messages, which lists the inbox newest first, until a message with the expected subject arrives after that time, or a 60-second deadline passes.
  • Check. The test reads the links Mailsac found in the message from GET /api/addresses/{email}/messages/{messageId}, or its plain text from GET /api/text/{email}/{messageId}. Then it types the code or opens the link in the browser.

Every request sends your API key in the Mailsac-Key header. The helper runs in Playwright’s Node.js test runner, so the key never reaches the page under test.

Before you start

  • Node.js 22, 24 or 26, the versions Playwright currently supports.
  • A Mailsac API key. Create a free account, then generate a key under API Keys & Users in the dashboard. Every plan includes API access; the free plan includes 1,500 Ops a month. A key is shown only once, so store it like a password.
  • An app that sends real email, running on your machine or in a test environment. Mailsac receives and inspects test email; it doesn’t send it. To stop a staging app from emailing real people, you can point its SMTP settings at Mailsac’s Email Capture instead; your tests read captured mail by recipient address with the same API. Captured mail is public unless you turn on private capture.
  • A place for the mail. Public @mailsac.com addresses are fine for a first try with made-up accounts. For reset links and codes that would work on a real account, use a verified private custom domain, available on Indie and higher plans. See public vs private addresses below.

Set up the project

Create a project with Playwright Test and its Chromium browser. In an existing Node.js project, skip the first two lines. @types/node gives your editor types for process and node:crypto.

mkdir email-tests && cd email-tests
npm init -y
npm install --save-dev @playwright/test @types/node
npx playwright install chromium
mkdir -p tests

Already using Playwright? Keep your config, but make sure its test timeout is longer than the email wait, as below. The config that npm init playwright generates also sets workers: 1 on CI, which runs your email tests one at a time.

Save this as playwright.config.ts in the project root:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  timeout: 90_000, // longer than the 60 s email wait, so a missing email gets a clear error
  fullyParallel: true, // every test has its own address, so tests can run side by side
  workers: process.env.CI ? 4 : undefined,
  use: { baseURL: process.env.BASE_URL ?? 'http://localhost:3000' }, // PLACEHOLDER: your app
});

  • timeout: 90_000 gives each test more time than the helper’s 60-second wait, so a missing email fails with the helper’s message instead of Playwright’s default 30-second test timeout.
  • fullyParallel, and 4 workers on CI, are safe because every test gets its own address. See parallel workers below.
  • baseURL comes from BASE_URL, so the same tests can target your laptop or a staging deployment.

Add a Mailsac helper

Save this as tests/mailsac.ts. It uses Node’s built-in fetch, so there is nothing else to install.

// tests/mailsac.ts: read test email with the Mailsac REST API and Node's built-in fetch.
// It runs in the Playwright test runner, not in the browser, so the key never reaches a page.
import { randomUUID } from 'node:crypto';
import type { TestInfo } from '@playwright/test';

const API = process.env.MAILSAC_API_URL ?? 'https://mailsac.com/api';
// @mailsac.com inboxes are public unless reserved. Set MAILSAC_DOMAIN to your private domain.
const DOMAIN = process.env.MAILSAC_DOMAIN || 'mailsac.com';

async function mailsac(path: string) {
  const key = process.env.MAILSAC_API_KEY; // a secret: keep it in the runner's environment
  if (!key) throw new Error('Set MAILSAC_API_KEY');
  const headers = { 'Mailsac-Key': key };
  const res = await fetch(API + path, { headers, signal: AbortSignal.timeout(10_000) });
  // Fail fast on errors. A 429 means the account hit its monthly Ops limit.
  if (!res.ok) throw new Error(`Mailsac API returned ${res.status} for ${path}`);
  return res;
}

// A new address for every test (and every retry), so workers never share an inbox.
export function newAddress(testInfo: TestInfo, prefix: string) {
  const id = `${testInfo.workerIndex}-${Date.now()}-${randomUUID().slice(0, 8)}`;
  return `${prefix}-${id}@${DOMAIN}`;
}

type Message = { _id: string; subject: string; received: string };
type WaitOptions = { subject: string; receivedAfter: number; timeoutMs?: number };

// Poll every 2 s until the deadline. Ignore mail received before `receivedAfter`.
export async function waitForEmail(email: string, options: WaitOptions) {
  const { subject, receivedAfter, timeoutMs = 60_000 } = options;
  const deadline = Date.now() + timeoutMs;
  while (Date.now() <= deadline) {
    const res = await mailsac(`/addresses/${email}/messages?limit=10`); // newest first
    const messages = (await res.json()) as Message[];
    const match = messages.find((m) =>
      Date.parse(m.received) >= receivedAfter && m.subject?.includes(subject));
    if (match) return match;
    await new Promise((resolve) => setTimeout(resolve, 2_000));
  }
  throw new Error(`No "${subject}" email for ${email} in ${timeoutMs / 1000} s`);
}

// The links Mailsac found in the message's text and HTML bodies.
export async function getLinks(email: string, id: string) {
  const res = await mailsac(`/addresses/${email}/messages/${id}`);
  const { links } = (await res.json()) as { links?: string[] | null };
  // Plain-text links can keep trailing punctuation, e.g. <url> or [url]; drop it.
  return [...new Set((links ?? []).map((url) => url.replace(/[>\]).,;:!?'"*]+$/, '')))];
}

// The plain-text body. Mailsac generates one when the email is HTML-only.
export async function getText(email: string, id: string) {
  const res = await mailsac(`/text/${email}/${id}`);
  return res.text();
}

// Exactly one distinct link with the same origin and path as `url`, or an error.
export function extractLink(links: string[], url: string) {
  const want = new URL(url);
  const found = new Set(links.filter((link) => {
    if (!URL.canParse(link)) return false;
    const { origin, pathname } = new URL(link);
    return origin === want.origin && pathname === want.pathname;
  }));
  if (found.size !== 1) throw new Error(`Expected 1 link to ${url}, found ${found.size}`);
  return [...found][0];
}

// Exactly one distinct standalone 6-digit code, or an error.
export function extractCode(text: string) {
  const codes = new Set(text.match(/\b\d{6}\b/g) ?? []);
  if (codes.size !== 1) throw new Error(`Expected 1 six-digit code, found ${codes.size}`);
  return [...codes][0];
}

What each part does:

  • The API key comes from the environment. MAILSAC_API_KEY is read inside the test runner process. Never put it in browser code, in a variable your frontend build embeds, or in your repository. MAILSAC_API_URL is optional; leave it unset unless you point the helper at a mock API in your own tests.
  • newAddress builds a new address for every test from Playwright’s testInfo.workerIndex, a timestamp and a random suffix. The worker index and timestamp make addresses easy to find in logs, and the random part keeps addresses unique even when two CI jobs start in the same millisecond. A retry runs the test body again, so it gets a new address too.
  • waitForEmail lists the 10 newest messages every 2 seconds. It returns the first one whose subject contains your text and whose received time is at or after receivedAfter. The inbox endpoint can’t return only the mail received after a given time, so the helper filters in the test. After 60 seconds it throws an error that names the subject and the address.
  • getLinks reads the message metadata, where Mailsac lists the HTTP(S) links it found in the text and HTML bodies. A link in plain text can keep punctuation such as a closing >, so the helper trims it and removes duplicates.
  • getText returns the plain-text body. If the email only has HTML, Mailsac generates the text.
  • extractLink and extractCode return exactly one match or throw. A test that picks one of two codes can pass for the wrong reason; a test that fails loudly tells you the email changed.
  • Errors stop the test at once. A 401 means Mailsac didn’t accept the key. A 429 means the account has used its monthly Ops, so retrying won’t help.

Test signup verification with a code or a link

Save this as tests/signup.spec.ts. Both tests sign up with a new address. The first reads the 6-digit code from the email and types it in; the second opens the verification link instead. Keep whichever matches your app, or both if your email offers both.

import { test, expect, type Page } from '@playwright/test';
import {
  newAddress, waitForEmail, getText, getLinks, extractCode, extractLink,
} from './mailsac';

// PLACEHOLDER: your signup page's labels, button and heading.
async function signUp(page: Page, email: string) {
  await page.goto('/signup');
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill('a-test-password-123');
  const receivedAfter = Date.now(); // just before the app sends the email
  await page.getByRole('button', { name: 'Create account' }).click();
  await expect(page.getByRole('heading', { name: 'Check your email' })).toBeVisible();
  return receivedAfter;
}

test('signup: the emailed 6-digit code verifies the account', async ({ page }, testInfo) => {
  const email = newAddress(testInfo, 'signup');
  const receivedAfter = await signUp(page, email);

  const message = await waitForEmail(email, { subject: 'Verify your email', receivedAfter });
  const code = extractCode(await getText(email, message._id));

  await page.getByLabel('Verification code').fill(code);
  await page.getByRole('button', { name: 'Verify' }).click();
  await expect(page.getByRole('heading', { name: 'Email verified' })).toBeVisible();
});

test('signup: the emailed link verifies the account', async ({ page, baseURL }, testInfo) => {
  const email = newAddress(testInfo, 'signup');
  const receivedAfter = await signUp(page, email);

  const message = await waitForEmail(email, { subject: 'Verify your email', receivedAfter });
  const links = await getLinks(email, message._id);

  await page.goto(extractLink(links, new URL('/verify', baseURL).href));
  await expect(page.getByRole('heading', { name: 'Email verified' })).toBeVisible();
});

Change the paths, labels, button names, headings and email subject to match your app. Two details matter more than they look:

  • Take receivedAfter just before the click that makes your app send the email. Taken after the click, it could skip an email that arrives quickly.
  • extractLink compares origin and path. It keeps only links whose origin and path match new URL('/verify', baseURL), so footer links, help pages and links to other sites are ignored.

Test the full password-reset flow

Save this as tests/password-reset.spec.ts. It creates an account, requests a reset, opens the emailed link, sets a new password and signs in with it.

import { test, expect } from '@playwright/test';
import { newAddress, waitForEmail, getLinks, extractLink } from './mailsac';

test('password reset: the emailed link works', async ({ page, baseURL }, testInfo) => {
  const email = newAddress(testInfo, 'reset');

  // An account to reset. PLACEHOLDER: or create one through your app's API or seed data.
  await page.goto('/signup');
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill('an-old-test-password');
  await page.getByRole('button', { name: 'Create account' }).click();
  await expect(page.getByRole('heading', { name: 'Check your email' })).toBeVisible();

  await page.goto('/forgot-password');
  await page.getByLabel('Email').fill(email);
  const receivedAfter = Date.now(); // just before the app sends the email
  await page.getByRole('button', { name: 'Send reset link' }).click();
  await expect(page.getByRole('heading', { name: 'Check your email' })).toBeVisible();

  // The inbox also holds the signup email; the subject and time filter skip it.
  const message = await waitForEmail(email, { subject: 'Reset your password', receivedAfter });
  const links = await getLinks(email, message._id);

  await page.goto(extractLink(links, new URL('/reset', baseURL).href));
  await page.getByLabel('New password').fill('a-new-test-password');
  await page.getByRole('button', { name: 'Update password' }).click();
  await expect(page.getByRole('heading', { name: 'Password updated' })).toBeVisible();

  // The new password works.
  await page.goto('/login');
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill('a-new-test-password');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page.getByRole('heading', { name: 'Signed in' })).toBeVisible();
});

By the time the reset email arrives, the inbox also holds the signup email. The subject and receivedAfter filters skip it, just as they would skip older messages in a real inbox. If your app can create accounts through an API or seed data, use that instead of the signup form; the reset part stays the same. If your app only allows a reset or sign-in after the email is verified, verify the account first with the steps from the signup test.

Run the tests

Set your key, your private domain and your app’s URL, then run the suite with four workers:

export MAILSAC_API_KEY=your_api_key      # PLACEHOLDER: from your Mailsac dashboard
export MAILSAC_DOMAIN=yourteam.msdc.co   # PLACEHOLDER: your private domain
export BASE_URL=http://localhost:3000    # PLACEHOLDER: an app that sends real email
npx playwright test --workers=4

When each test finds its email, Playwright reports 3 passed. To try the helper without a custom domain, leave MAILSAC_DOMAIN unset: it then uses public @mailsac.com addresses, which suit made-up accounts only.

Run email tests in parallel workers

Email tests run in parallel safely when no two tests share an inbox:

  • One address per test. Each test calls newAddress, so its inbox only receives mail for that test. Workers, retries, shards and separate CI jobs never see each other’s email.
  • Parallel inside files too. fullyParallel: true lets the two signup tests in one file run at the same time, and --workers=4 (or the CI setting in the config) runs up to four tests at once.
  • Shared Ops. Parallel tests share your account’s monthly Ops. Each API call is one Op, and each email received by a private address or custom domain is one more. On a private domain, when the email is already there on the first poll, the code test and the link test use 3 Ops each, and the reset test uses 4 because it receives two emails. Mail to public @mailsac.com addresses isn’t counted, so there each test uses 2. Each extra 2-second poll adds 1, so a test that times out uses about 30.
  • Throttling. Public addresses are throttled at lower volumes: delivery slows by up to about a minute, then mail is deferred. Busy suites belong on a verified custom domain.
  • Fixed accounts. If some tests must share one address, such as a pre-seeded account, give them the same Playwright test lock, for example { lock: 'seeded-account' }. They then never run at the same time, while the rest of the suite stays parallel. receivedAfter still stops them from reading older mail in that inbox.

Run the tests in GitHub Actions

Add your key as a repository secret: in your repository’s Settings, open Secrets and variables, then Actions, choose New repository secret and name it MAILSAC_API_KEY. Then save this workflow as .github/workflows/email-tests.yml:

name: Email tests
on:
  push:
    branches: [main]
  pull_request:
permissions:
  contents: read
jobs:
  playwright:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: lts/*
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test
        env:
          MAILSAC_API_KEY: ${{ secrets.MAILSAC_API_KEY }}
          MAILSAC_DOMAIN: yourteam.msdc.co # PLACEHOLDER: your private domain
          BASE_URL: https://staging.example.com # PLACEHOLDER: your deployed test app

  • GitHub Actions sets CI, so the config runs 4 workers.
  • The workflow runs on pushes to main (change it to your default branch) and on pull requests, so a pull request doesn’t run the suite, and spend Ops, twice.
  • BASE_URL points at a deployed test environment whose mail provider sends real email. To test an app started inside the job instead, start it before the test step, or use Playwright’s webServer option, and make sure it can send mail.
  • MAILSAC_DOMAIN isn’t secret, so it can live in the file. The key stays in GitHub’s secret store.
  • GitHub doesn’t pass Actions secrets to workflows triggered from a forked repository or by Dependabot, so those pull requests fail with Set MAILSAC_API_KEY. Run email tests on branches in your own repository, and add the key as a Dependabot secret too if you want Dependabot’s pull requests tested.
  • This config doesn’t record traces, screenshots or videos. The config that npm init playwright generates records a trace on the first retry, and traces record the codes your tests type and the URLs they open, including reset links. Keep reports and artifacts private.

Public vs private addresses and custom domains

Where the test email goes decides who else can read it.

  • Public @mailsac.com addresses need no setup and work on every plan, including the free plan. Anyone can view a public inbox on the Mailsac website without an account, and any Mailsac API key can read it or delete individual messages from it. Public inboxes are temporary, and their messages may be recycled quickly.
  • Private addresses are reserved in the dashboard or with POST /api/addresses/{email}. Only your account and team can read their mail. The free plan includes one. A private address suits one fixed test account, not a new address for every test.
  • Custom domains, on Indie and higher plans (not the free plan), give every test its own private address. A zero-setup subdomain of msdc.co, such as yourteam.msdc.co, receives mail right away with no DNS changes. You can also bring your own domain: verify it with a TXT record and point its MX records to Mailsac. Mail to a domain that isn’t verified yet is public. Every address on the domain receives mail without setup, so newAddress works unchanged.

@mailsac.com inboxes are public unless you reserve them as private addresses. Anyone who knows or guesses a public address can read its mail, including reset links and codes. The helper falls back to public addresses only so you can try it on the free plan with made-up accounts. Before you test links or codes that would work on a real account, or emails that contain real personal data, set MAILSAC_DOMAIN to your verified private domain. Plan limits for private addresses and custom domains are on the pricing page.

Troubleshooting

  • Set MAILSAC_API_KEY: the variable isn’t set in the process that runs Playwright. In GitHub Actions, check the secret’s name, and remember that pull requests from forks and from Dependabot don’t receive Actions secrets.
  • Mailsac API returned 401: Mailsac didn’t accept the key. Look for extra spaces or an old key. A key is shown only once, so create a new one if you’ve lost it.
  • Mailsac API returned 403: the address is a private address owned by another Mailsac account. Check MAILSAC_DOMAIN, and use a key from the account that owns your private addresses and domain.
  • Mailsac API returned 429: the account has used its monthly Ops. The limit is soft: Mailsac emails warnings first, but if usage keeps going, API requests return 429 until the next month, which stops your email tests. Ops reset on the first of each month (UTC). To keep testing, add Ops or move to a larger plan.
  • Other HTTP errors, such as a 5xx: the helper stops at the first error. If they show up in CI, retry them within your deadline.
  • No "…" email for … in 60 s: compare the address in the error with the one your app actually sent to, and check that the subject text matches (the match is case-sensitive). Check your email provider’s logs too, because some test or sandbox modes accept mail without delivering it. Keep the test machine’s clock in sync, because the helper compares its own time with Mailsac’s received time. On public @mailsac.com addresses, throttling can delay a busy suite’s mail by up to about a minute, so run busy suites on a verified custom domain. The missing mail guide covers other causes.
  • Expected 1 link to …, found 0: no link in the email has that origin and path. Common causes: your email provider’s click tracking rewrote the link (turn tracking off for test mail), the token is part of the path, as in /verify/abc123, or BASE_URL doesn’t match the host in your emails.
  • Expected 1 link to …, found 2: the email has two different links to that path, for example because the sender hard-wrapped a long link in the plain-text part. Fix the template, or filter the links before calling extractLink.
  • Expected 1 six-digit code: found 0 usually means the code is formatted differently, such as 482 913. Found 2 means another 6-digit number, such as an order number or postcode, is in the email. Make the pattern in extractCode match your email’s wording.
  • The link opens but your app rejects it: check the plain-text part of the email for HTML leftovers such as &amp; inside the link.
  • Tests pass alone but fail in parallel: two tests share an address or a fixed account. Call newAddress in every test.
  • Reading mail through the Mailsac website instead of the API? Since September 17, 2026, the website shows email HTML in a sandboxed iframe, so page-level selectors won’t find content inside it. The API calls in this tutorial need no selectors. If you automate the website anyway, use Playwright’s frame locators with the preview’s iframe title, as described in the email preview update.

Next steps

  • Using Cypress? The Cypress email testing tutorial covers the same password-reset flow with the @mailsac/cypress plugin.
  • See the whole workflow. The Email Testing API page explains the trigger, wait and check loop, how Ops add up, and includes a dependency-free Node.js example.
  • Look up endpoints. The API reference lists every endpoint, and the Mailsac documentation covers private addresses, custom domains, webhooks and WebSockets.
  • Skip polling. Turn on webhook or WebSocket forwarding for a private address, or for a catch-all address on your custom domain, and Mailsac pushes each new email to you. Domain-wide WebSockets need a Business or Enterprise plan, and pushed messages use Ops.
  • Record the UI steps. Playwright Codegen writes the form-filling part of these tests for you.

Create a free Mailsac account to get an API key, then point these tests at your own app.

Running Selenium Tests in GitHub actions email test cover image

Run Selenium Tests in GitHub Actions – Email Testing

Welcome to the second part of the two-part series on running selenium tests with GitHub Actions. In the first article, we outlined how to get started and how to set up your repo for Actions. In this guide, we’ll outline how to run email integration tests and pass secrets to GitHub Actions.

Looking for up-to-date test code? Our Selenium email testing guide has a signup verification test for Node.js and headless Chrome. It uses a unique address per run, waits only for new mail, and comes with a ready-to-use GitHub Actions workflow. This post walks through the GitHub Actions setup step by step.

To start… Why would you want to test emails with live email services in the first place? Can’t you simply write to a log file or standard out? Yes… and no. Writing to log files or standard out is ok at the beginning of the application lifecycle. Building out the email send feature takes a back seat to ensure the application actually works.

But let’s say your application is almost ready for its initial release. You want to test that you can even connect to an SMTP server. Or test to ensure the right message can go to the right inbox. Testing via log files or standard out starts to get a bit limiting in that regard. Additionally, say you want to be really sure that the contents of an email you send are what you expect them to be. At this point, you’d like to simulate as much of the email delivery as possible.

You can accomplish this with a disposable email service like Mailsac.

Allow us to plug our mail service

At Mailsac we focus on the developer experience around email automation and testing. That’s why we’ve made it so you can test out the API mentioned in this guide with a free account. You can sign up here.

So let’s lay out our testing goal:

  1. Simulate an email send with our sample application.
    1. Refer to our Guide To Stress Free Email Testing with Next.js for more information on how we created this sample application
    2. Use Selenium to drive the form and hit send
  2. Use our capture service API to send the email
  3. Use Mailsac’s API to read the email from the destination inbox
  4. Verify the contents of said email
  5. Do all this on GitHub Actions

Let’s get started.

API Credentials

If you don’t already have one, go ahead and create a mailsac account and generate an API key.

Plug those API keys in a file called .env at the root of the project:

MAILSAC_USERNAME=$MAILSAC_USERNAME
MAILSAC_API_KEY=$MAILSAC_GENERATED_KEY
MAILSAC_HOST=capture.mailsac.com
MAILSAC_PORT=5587

.env

Craft the Email

Let’s craft the email by driving Selenium through the form. Start by crafting a selenium test:

const chrome = require('selenium-webdriver/chrome');
const {Builder, Browser, By } = require('selenium-webdriver');

const screen = {
  width: 1920,
  height: 1080
};

(async function emailSendTest() {
    
  let driver = await new Builder()
    .forBrowser(Browser.CHROME)
    // .setChromeOptions(new chrome.Options().headless().windowSize(screen))
    .build();

  try {
    await driver.get('<http://localhost:3000>');
    let didSendButtonRender = await driver.findElement(By.id('sendbutton')).isDisplayed()
    
    if (!didSendButtonRender){
      throw new Error(`Send button was not rendered properly.`);
    }
 
    await driver.findElement(By.id('email')).sendKeys("[email protected]");
    await driver.findElement(By.id('comment')).sendKeys("This is some text from our Selenium test.");
    await driver.findElement(By.id('sendbutton')).click();

  } finally {
    await driver.quit();
  }
})();

tests/email-send.js

Note: I left the headless option off for this first test. You’ll want to turn the headless option back on for the test run via our continuous integration environment.

You can do a local test by running

node tests/email-send.js

And checking the inbox we tested ([email protected]) manually:

Success! Though that’s part of the way there. Let’s assert that the email contents match via the API.

Read the Email via Mailsac’s API

Thankfully, Mailsac has not only a robust API but also lots of sample code inside the docs. We’ll lift the sample email read from here:

https://docs.mailsac.com/en/latest/services/reading_mail/reading_mail.html

Note that you’ll need to install a couple of dev packages to get this to work: dotenv and superagent. dotenv is needed in this instance since our tests don’t load the entire next framework and as such, we need a method to read your .env file. superagent is a small client-side HTTP request library for doing quick HTTP calls like the one we’re about to do.

So go ahead and add them to your developer dependencies:

npm install --save-dev dotenv superagent

And add our own text comparison to Mailsac’s sample code:

require('dotenv').config()

const superagent = require('superagent')
const mailsac_api_key = process.env.MAILSAC_API_KEY
const expected_message = 'This is some text from our Selenium test.'

superagent
  .get('<https://mailsac.com/api/addresses/[email protected]/messages>')
  .set('Mailsac-Key', mailsac_api_key)
  .then((messages) => {
      const messageId = messages.body[0]._id
      superagent
          .get('<https://mailsac.com/api/text/[email protected]/>' + messageId)
          .set('Mailsac-Key', mailsac_api_key)
              .then((messageText) => {
                  if (messageText.text !== expected_message)  {
                    throw new Error(`Message '${messageText.text}' does not match expected text '${expected_message}'`)
                  }
                  else{
                    console.log("Message comparison passed");
                  }
              })
  })
  .catch(err => {
      console.log(err.message)
			process.exit(-1)
  })

tests/email-read.js

Running the test locally should result in a passing test:

$ node tests/email-read.js
Message comparison passed

Of course, if we’ll run these tests many times we’ll also want to ensure that we delete the email contents after our successful read. Let’s add a cleanup step to our read test:

require('dotenv').config()

const superagent = require('superagent')
const mailsac_api_key = process.env.MAILSAC_API_KEY;
const expected_message = 'This is some text from our Selenium test.';
const testInbox = '[email protected]';

superagent
.get(`https://mailsac.com/api/addresses/${testInbox}/messages`)
  .set('Mailsac-Key', mailsac_api_key)
  .then((messages) => {
      const messageId = messages.body[0]._id
      superagent
          .get(`https://mailsac.com/api/text/${testInbox}/` + messageId)
          .set('Mailsac-Key', mailsac_api_key)
              .then((messageText) => {
                  if (messageText.text !== expected_message)  {
                    throw new Error(`Message to delete '${messageText.text}' does not match expected text '${expected_message}'`)
                  }
                  else{
                    console.log("API Read Op: Message comparison passed");
                    superagent
                    .delete(`https://mailsac.com/api/addresses/${testInbox}/messages/${messageId}`)
                    .set('Mailsac-Key', mailsac_api_key)
                    .then((messageResponse) => {
                        console.log(`API Deletion Op: ${messageResponse.body.message}`)
                    })
                  }
              })
  })
  .catch(err => {
      console.log(err.message)
      process.exit(-1)
  })

tests/email-read.js

Let’s add it to a test script and our workflow YAML file:

"scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "external-tests": "node tests/external-login.js",
    "test": "node tests/button-render.js && npm run mail-tests",
    "mail-tests": "node tests/email-send.js && node tests/email-read.js",
    "e2e-test": "start-server-and-test dev <http://localhost:3000> test"
  },

package.json

Note that we added a run mail-tests script to our end-to-end testing.

Try your end-to-end script locally to ensure it works:

End to end sample test

GitHub Action Test

Now that you have a working test on your local workstation, it’s time to push it up so GitHub Actions can start running your tests. If you haven’t already, read through the first article to catch up on GitHub Actions configuration and initialization.

As a reminder, this is our main.yml workflow file:

on: [push]

jobs:
  tests:
    runs-on: ubuntu-latest
    name: Run Selenium Tests
    steps:
    - name: Start selenoid
      uses: Xotabu4/selenoid-github-action@v2

    - uses: actions/checkout@v1
    - run: npm ci  

    - name: Run end to end tests
      run: npm run e2e-test

    - name: Run external login test
      run: npm run external-tests

.github/workflows/main.yml

With all that said, let’s try and see if this email test sends an email on GitHub.

Do a git push and check your results on GitHub:

A gitHub test failure

Looks like a failure. On closer inspection:

GitHub specific failure with details

Ah, that’s right! We forgot to set our API keys at the GitHub level. Let’s go ahead and do that.

GitHub Actions Secrets

You can find it under Settings in your repo:

Github walkthrough image : Click settings

Then under Secrets → Actions click New repository secret

Add each secret that will be needed:

GitHub Secrets listing for your Actions.

Finally, ensure you add it to the workflow file:

- name: Run end to end tests
      run: npm run e2e-test
      env:
        MAILSAC_API_KEY:  ${{secrets.MAILSAC_API_KEY}}
        MAILSAC_USERNAME: ${{ secrets.MAILSAC_USERNAME }}
        MAILSAC_HOST:     ${{ secrets.MAILSAC_HOST }}
        MAILSAC_PORT:     ${{ secrets.MAILSAC_PORT }}

.github/workflows/main.yml

Do a git commit and git push and see the results on GitHub:

Successful email test with GitHub actions.

Success! You can check the details to ensure the API read and write options fire fired off:

Github actions success detailed view.

Conclusion

GitHub Actions is a powerful CI/CD tool and we have only scratched the surface of its capabilities with Selenium. We’ve also shown the power of email testing and how you can ensure the contents of every email are intentional.

We hope you enjoyed the guide, and hope to hear from you on our forums or follow our other content on LinkedIn.

Running Selenium Tests in GitHub actions Cover image

Run Selenium Tests in GitHub Actions

Browser automation is an invaluable tool. At a personal level, you can use it to automate repetitive tasks online. But browser automation can deliver so much more. At its best, it can run tasks with consistent results. Tasks that need lots of manual execution and complexity. Tasks like checking button placement, evaluating user login, and registration workflows. And in our modern era, we have our pick of browser automation frameworks.

We’ve covered browser automation before, but in this guide, we’ll do a deep dive into one of those frameworks. Additionally, we’ll cover using those testing frameworks alongside a specific continuous integration application.

In this guide we’ll walk through three testing scenarios using Selenium and GitHub Actions:

  • Check if a button rendered
  • Test a login workflow
  • Ensure the contents of a sent email

We’ll use our sample application to make our examples concrete, informative, and useful.

Repo URL: https://github.com/mailsac/mailsac-capture-service-example-nextjs

With all that said, let’s get started!

First, let’s talk about what you need to enable GitHub Actions.

Getting started with GitHub Actions

First, if you’d like a primer on GitHub Actions their page is a great resource. To summarize, it’s an easy way to run tasks when someone takes an “action” against your repo.

Actions can be:

  • Pull request merges
  • Commits
  • Repo pushes

GitHub takes your action and runs a task like integration tests, unit tests, etc. All you need to do is create a .github/workflows/main.yml file. Note: Running tasks is just one of the many possibilities for GitHub Actions. You can encompass entire workflows and even produce packages.

We’ll introduce more details further along the guide. For now, you can start by adding an empty file at:

.github/workflows/main.yml

The Sample Application We’ll Use

We’ll fork our sample application at the URL:

https://github.com/mailsac/mailsac-capture-service-example-nextjs

We’ll use this repo to walk through the examples mentioned in the intro. These sample scenarios are not meant to be comprehensive, of course. They’re meant to kick-start your journey with GitHub Actions.

To start, you’ll need to run the standard node installation and run the command:

npm install
npm run dev

Navigate to http://localhost:3000 and see our application in action:

Our initial application

With the preliminary steps done, on to the tests.

First Selenium Test: Check if the button rendered

Let’s start with the simplest test, a button render assertion test.

For this first section, we will:

  1. Add the node packages required to test
  2. Add our selenium test
  3. Add testing scripts to our package.json so that GitHub Actions can call our tests
  4. Add a simple Github Actions workflow file

Let’s start by adding a new branch (or you can fork the repo if you’d like):

git checkout -B cicd-selenium-app-test

Now let’s move on to our sample packages.

Packages: Selenium WebDriver & Start-Server-And-Test

We’ll need to add selenium and a start-server-and-test package. start-server-and-test starts our application. Then it calls our selenium tests so they can run against our running application.

npm install --save-dev start-server-and-test selenium-webdriver

start-server-and-test is one of our most straightforward ways to add live server testing capabilities. If you need more flexible frameworks, you may want to investigate mocha or cypress.

We’ll just focus on the core GitHub actions and selenium testing in this guide.

Selenium Test: Button Render

On to the test itself.

Let’s create a test under tests/button-render.js using selenium and headless chrome:

const chrome = require('selenium-webdriver/chrome');
const {Builder, Browser, By } = require('selenium-webdriver');

const screen = {
  width: 1920,
  height: 1080
};

(async function buttonRender() {
  let driver = await new Builder()
    .forBrowser(Browser.CHROME)
    .setChromeOptions(new chrome.Options().headless().windowSize(screen))
    .build();

  try {
    await driver.get('<http://localhost:3000>');
    let didSendButtonRender = await driver.findElement(By.id('sendbutton')).isDisplayed()
    if (!didSendButtonRender){
      throw new Error(`Send button was not rendered properly.`);
    }
 
  } finally {
    await driver.quit();
  }
})();

tests/button-render.js

You can find the full usage guide on selenium’s documentation page.

To help the test out, we’ll add an ID attribute to the Send button:

...
<button
    onClick={sendEmail}
    id="sendbutton"
    className="mt-3 w-full inline-flex items-center justify-center px-4 py-2 border border-transparent shadow-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
  >
    Send
</button>
...

pages/index.js

Telling GitHub Actions How to Run Our Tests

We’ll need to add a couple of testing scripts to package.json:

...
"scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "test": "node tests/button-render.js",
    "e2e-test": "start-server-and-test dev <http://localhost:3000> test"
  },
...

package.json

Now let’s add the workflow main.yml file to the repo. This is the main file that kicks off a GitHub Action workflow. To keep things simple, we’ll execute our actions based on when a comitter pushes code. You can find the full list over at GitHub’s documentation pages.

on: [push]

jobs:
  tests:
    runs-on: ubuntu-latest
    name: Run Selenium Tests
    steps:
    - name: Start selenoid
      uses: Xotabu4/selenoid-github-action@v2

    - uses: actions/checkout@v1
    - run: npm ci  

    - name: Run end to end tests
      run: npm run e2e-test

.github/workflows/main.yml

For our project, we’ll use solenoid-github-action, a GitHub Action that starts a selenium grid instance in a docker container. Solenoid is a golang reimplementation of Selenium. It makes it very easy to integrate with any continuous integration/deployment environment.

In the last portion of the file, the npm run e2e-test section kicks off the end-to-end test that starts our server and runs the selenium tests.

That’s it! Before you commit and push your code, try running it locally:

npm run e2e-test

The test should pass in your local environment. If it fails due to chrome driver issues you can find a full guide on browser driver installations here.

Send it over to GitHub via git push.

git push --set-upstream origin cicd-selenium-app-test

Head over to your repo on GitHub and under the Actions tab you should now see a selenium test run

A GitHub Actions workflow results page

You can drill down and see where our specific tests ran

Congrats! That completes our first successful test using selenium and GitHub actions. Let’s move on to something a bit more useful.

Second Selenium Test: Open and login into a web app

Let’s expand on our tests a bit. Since we’re aiming for simplicity in this guide, I won’t add a whole authentication workflow to our application. Instead, let’s focus on an existing website and attempt (and fail) to log in.

We’ll start by adding a new test to our repo.

Selenium Test: Login to Dev.to

Let’s bring in our external login Selenium test, but slightly modified:

const chrome = require('selenium-webdriver/chrome');
const {Builder, Browser, By, until } = require('selenium-webdriver');

const screen = {
    width: 1920,
    height: 1080
  };

(async function externalLogin() {
  let driver = await new Builder()
  .forBrowser(Browser.CHROME)
  .setChromeOptions(new chrome.Options().headless().windowSize(screen))
  .build();

  try {
    await driver.get('<https://dev.to>');
    await driver.findElement(By.linkText('Log in')).click();
    await driver.wait(until.titleContains('Welcome - DEV Community'), 3000);
    await driver.findElement(By.name('commit')).click();
    await driver.wait(until.titleIs(''), 3000);
    let errorBox = await driver.findElement(By.className('registration__error-notice'));
    await driver.wait(until.elementIsVisible(errorBox));
    let errorText = await errorBox.getText();

    if (!errorText.includes('Unable to login')){
      throw new Error(`Error text does not contain expected value "${errorText}"`);
    }

  }
  finally {
    await driver.quit();
  }
})();

tests/external-login.js

Only real changes are that we added a headless option for chrome.

Modifying Our Tests

Keeping things simple, let’s just add a new test script:

....
"test": "node tests/button-render.js",
"external-tests": "node tests/external-login.js",
...

package.json

And add a separate testing task to our workflow:

...
- name: Run external login test
      run: npm run external-tests
...

.github/workflows/main.yml

Let’s check our result on GitHub:

GitHub Action Failure example

Looks like a failure due to an incorrect title.

Let’s make the match less precise and rerun the test:

...
await driver.findElement(By.linkText('Log in')).click();
await driver.wait(until.titleContains('Welcome! - DEV Community'), 3000);
await driver.findElement(By.name('commit')).click();
...

tests/external-login.js

Let’s try another git push and..

A full successful external login test screen/

Success!

Conclusion

This was the first part of a two-part series about diving into GitHub Actions and Selenium tests. In the second part, we’ll run through:

  1. running our application in GitHub actions
  2. sending emails via our web form
  3. reading and comparing the email contents
  4. deleting the email contents afterward

All are driven by our selenium tests.

We hope you enjoyed the guide, and hope to hear from you on our forums or follow our other content on LinkedIn.

Blog Cover

Guide To Stress Free Email Testing with Next.js

Developing an application that sends emails is straightforward but not without its risks. Ensuring deliverability but not actually having any of those emails land inside real inboxes is a top concern for any developer. Which leads to questions like: “How do you test your application’s outbound email capabilities?” or “How do I manage email testing for free?”

Enter email capture services. While the term “email capture service” tends to focus on the marketing aspects (capturing information from your calls to action, ensuring emails don’t get caught in spam, etc) they also include SMTP deliverability. Mailsac offers an email capture service that addresses the deliverability aspect, specifically not delivering any email to its intended recipient. Effectively a “black hole” where no email should escape to the outside world.

In this post, we’ll walk through a sample application in Next.js that will generate emails and have those emails captured by Mailsac’s email sandboxing service.

Do I Really Need To Do Email Testing?

Some frameworks do come with email previewing capabilities like Rails’ ActionMailer. Said frameworks don’t actually attempt to send anything but instead preview the email on your machine. We recommend real testing during the development and quality assurance phase by using an external SMTP server to mimic the application’s behavior in production.

Testing that capability has to be done safely unless you want to land on Twitter’s trending page for accidentally sending customers an integration test email.

Test Email Sending With A Next.js Application

For the rest of this guide, we’ll focus on wiring up a simple application that will allow users to send an email when a button is pushed from a UI. We’ll then demonstrate the capture of those emails in our development environment.

The components we’ll use are:

Application Creation

While the focus of this guide isn’t a line-by-line walkthrough of the sample code, we’ll focus on the key aspects of the application that mainly involve emailing capabilities.

The application source can be found in our git repository.

1. Application setup

Let’s start by creating a quick next app with tailwind support:

mailsac % npx create-next-app
…
Success! Created nodejs-send-email at /Users/mailsac/code/nodejs-send-email

cd nodejs-send-email
npm install -D tailwindcss postcss autoprefixer @tailwindcss/forms
npm install @headlessui/react@latest @heroicons/react
npx tailwindcss init -p

The above is the recommended way to install tailwind on Next.js according to their guide.

Configure tailwind.config.js by adding the highlighted lines:

module.exports = {
  content: [
	"./pages/**/*.{js,ts,jsx,tsx}",
	"./components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
	extend: {},
  },
  plugins: [
    require('@tailwindcss/forms'),
  ],
}

and then add tailwind itself to the global CSS file inside styles/global.css and comment out some default CSS created by npx:

/* @media (prefers-color-scheme: dark) {
  html {
    color-scheme: dark;
  }
  body {
    color: white;
    background: black;
  }
}
*/

@tailwind base;
@tailwind components;
@tailwind utilities;

Tailwind is strictly optional but recommended for easy styling of the frontend.

2. Add the front page UI

Feel free to add your custom frontend code or use the index.js and components/notifications.js react component samples inside our repo.

index.js

import { useEffect, useState } from "react";
import Notification from "../components/notifications";

export default function Index() {
  const [sentEmail, setSentEmail] = useState(false);
  const [emailTo, setEmailTo] = useState("");
  const [emailBody,setEmailBody] = useState("");
  const [resultMessage, setResultMessage] = useState("");

  const sendEmail = () => {
    setSentEmail(true);
  }

  useEffect( () => {
    fetch('/api/send-email',{
      method: 'POST',
      body: JSON.stringify({ to: emailTo, body: emailBody})
    })
    .then( res => res.json())
    .then(response => {
      setResultMessage(response.message)
    })
    .catch(error => console.log(error));    

  },[sentEmail]);

  const SentEmailBanner = sentEmail === true? <Notification message={resultMessage} /> : null;

The second line brings in a component that takes in a message and formats it as a pop-up notification. The useEffect() method sends your email recipient and body input to the backend, which will forward that data to Mailsac’s servers.

3. Sign up for Mailsac’s Email Capture

Mailsac has a free email capture service. All you need is to sign up for an account and generate a key:

Mailsac dashboard
Mailsac Dashboard

4. Add a backend mail handler route

Once you’ve generated and saved your keys, you can place them in a .env file:

.env

MAILSAC_USERNAME=lcanal
MAILSAC_API_KEY=Key generated from above

Following our email capture documentation, we’ll create a backend API route for Next to handle the request:

pages/api/send-email.js

const nodemailer = require("nodemailer");

export default async function handler(req, res) {
  let emailEnvelope = JSON.parse(req.body)
  if (
        req.method === 'POST' 
        && typeof(emailEnvelope.to) !== 'undefined' 
        && emailEnvelope.to !== ''
  ){
    const mailsaUserName = process.env.MAILSAC_USERNAME
    const mailsacAPIKey  = process.env.MAILSAC_API_KEY
  
    const transporter = nodemailer.createTransport({
      host: 'capture.mailsac.com',
      port: 5587,
      // will use TLS by upgrading later in the connection with STARTTLS
      secure: false,
      auth: {
        user: mailsaUserName,
        pass: mailsacAPIKey
      }
    })
  
    try {
      const results = await transporter.sendMail({
        from: '"Sample App" [email protected]',
        to: emailEnvelope.to,
        subject: 'Sample App Send',
        text: emailEnvelope.body
      })
      res.status(200).json(
        { 
          message: "You should now see an email in Mailsac's capture service", 
          response: results.data 
        }
      )
    } catch (error){
      console.log(`ERROR ${error}`)
      res.status(500).json({ message: `${error.response}`, response: error })
    }
  } else {
    return res.status(200).json({message: "No data"});
  }
}

In the highlighted line, we’re ensuring the useEffect() hook gets called with input data before we allow the rest of the function to continue. useEffect() gets called a variety of times in the component lifecycle, and this check is to ensure it was initiated by an end user and not as part of the component mounting.

5. Test driving the app

Fire up the application via

npm run dev

Navigate to http://localhost:3000 and type a text message:

Walking through our sample application form.

Then navigate over to mailsac.com to view the message

Checking the inbox at mailsac.com

6. Capturing other email domains

While that works well as a contrived example, the real value comes when using any arbitrary email in the recipient field:

Full application walkthrough demo, with a free check at mailsac.

Capturing emails outside the mailsac.com domain is extremely valuable when switching between different environments. For example, in the demo application example above, the .env environment file could instead look like

MAILSAC_USERNAME=$MAILSAC_NAME
MAILSAC_API_KEY=$MAILSAC_KEY
MAILSAC_HOST=capture.mailsac.com
MAILSAC_PORT=5587

With the updated send-email.js

const nodemailer = require("nodemailer");

export default async function handler(req, res) {
  let emailEnvelope = JSON.parse(req.body)
  if (
        req.method === 'POST' 
        && typeof(emailEnvelope.to) !== 'undefined' 
        && emailEnvelope.to !== ''
  ){
    const mailsaUserName = process.env.MAILSAC_USERNAME
    const mailsacAPIKey  = process.env.MAILSAC_API_KEY
  
    const transporter = nodemailer.createTransport({
      host: process.env.MAILSAC_HOST,
      port: process.env.MAILSAC_PORT,
      // will use TLS by upgrading later in the connection with STARTTLS
      secure: false,
      auth: {
        user: mailsaUserName,
        pass: mailsacAPIKey
      }
    })
  
    try {
      const results = await transporter.sendMail({
        from: '"Sample App" [email protected]',
        to: emailEnvelope.to,
        subject: 'Sample App Send',
        text: emailEnvelope.body
      })
      res.status(200).json(
        { 
          message: "You should now see an email in Mailsac's capture service", 
          response: results.data 
        }
      )
    } catch (error){
      res.status(500).json({ message: `${error.response}`, response: error })
    }
  } else {
    return res.status(200).json({message: "No data"});
  }
}

The above edits would allow you to deploy to a testing or production environment and the only changes required would be in the .env file. Specifically, the SMTP host and authentication settings.

Conclusion

The above guide just scratches the surface of what you can do with our email services. We provide a unified inbox that allows testers to view their bulk email testing in one unified view and custom domains for those who do not have their own domains with zero setup configurations.

Additionally, we have a guide on using both custom domains and a unified inbox in your system testing. Check it out and tell us what you think on our forums!