npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

proxymail-qa

v0.12.0

Published

Test every email your app sends from Playwright, Cypress, Jest, Vitest or any Node script: disposable inboxes, sign-up codes, links and coupons, email checks, unsubscribe tests and run reports.

Readme

proxymail-qa

Test the emails your app sends, end to end, from Playwright, Cypress, Puppeteer, Jest, Vitest or any Node script. Create a disposable address, sign up with it, and get the verification code or link in one call that waits for it. Then:

  • check an email was sent exactly once, or not at all
  • check each email: SPF, DKIM and DMARC, links to localhost or the wrong host, a stated expiry, plain text, language and phone layout
  • sign up with a test persona (name, date of birth, country) and a saved password, or an unusual address variant
  • borrow a verified test account for sign-in tests and give it back
  • wait for a coupon, record whether checkout took it, and catch an offer given twice to versions of one address
  • test unsubscribing: any marketing email from that sender afterwards fails
  • check marketing emails: unsubscribe headers, a spam score and the consent rules where the persona lives
  • check a welcome series or drip campaign came in order and on time
  • find out why an email didn't arrive
  • group tests into a run whose report says which test each email came from

Checkout results, offers given twice, unsubscribing, consent, welcome series, the marketing checks and the spam score are in Labs for now: the key's creator switches them on in the app under Account settings → Labs. Until then those methods throw with a 403 that says so, and checkEmail leaves those checks out.

Get an API key in app.proxymail.qa → Project settings → API keys (the Free plan includes it), and set it as PROXYMAIL_QA_API_KEY. Full guide: https://proxymail.qa/docs/api

import { ProxyMailQA } from 'proxymail-qa';

const qa = new ProxyMailQA(); // reads PROXYMAIL_QA_API_KEY
const { address, persona, password } = await qa.createAddress({ persona: true, password: true, label: 'Sign-up test' });
// ...sign up with the address, persona and password...
const { value: code } = await qa.waitForCode(address); // waits up to 30 s
const { value: link } = await qa.waitForLink(address, { type: 'reset', timeout: 60 });
await qa.deleteAddress(address);

With password: true the result's password is typed as a string (pass the options inline, or as const, so TypeScript sees true).

Waits return the code or link on its own, with the sending service and its expiry, never the email's text. They throw ProxyMailQAError (status 404) when nothing arrives in time.

Playwright

import { test as base, expect } from '@playwright/test';
import { ProxyMailQA } from 'proxymail-qa';

const test = base.extend<{ inbox: { address: string } }>({
  inbox: async ({}, use) => {
    const qa = new ProxyMailQA();
    const created = await qa.createAddress({ persona: true });
    await use(created);
    await qa.deleteAddress(created.address);
  },
});

test('sign-up sends a working code', async ({ page, inbox }) => {
  const submitted = new Date();
  await page.goto('/signup');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Sign up' }).click();
  const { value } = await new ProxyMailQA().waitForCode(inbox.address, { since: submitted });
  await page.getByLabel('Code').fill(value);
  await expect(page).toHaveURL(/welcome/);
});

In a run (the GitHub Action, or startRun in globalSetup), the report can say which test each email came from. Use the qa fixture from proxymail-qa/playwright, a client that names its test in every call, and add the reporter, which records failed and flaky tests and the totals:

// playwright.config.ts
reporter: [['list'], ['proxymail-qa/playwright/reporter']],

// signup.spec.ts
import { test, expect } from 'proxymail-qa/playwright';

test('sign-up sends one code', async ({ page, qa }) => {
  const { address } = await qa.createAddress();
  // …
});

Cypress

Register tasks in cypress.config.ts, since Cypress runs them in Node:

import { ProxyMailQA } from 'proxymail-qa';

export default defineConfig({
  e2e: {
    setupNodeEvents(on) {
      const qa = new ProxyMailQA();
      on('task', {
        createAddress: () => qa.createAddress({ persona: true }),
        waitForLink: (address: string) => qa.waitForLink(address, { type: 'verify' }),
      });
    },
  },
});
cy.task('createAddress').then(({ address }) => {
  cy.get('[name=email]').type(address);
  cy.contains('Sign up').click();
  cy.task('waitForLink', address, { timeout: 35000 }).then(({ value }) => cy.visit(value));
});

More

// A persona with a date of birth for an age band, and a country.
await qa.createAddress({ persona: true, age: 'under13', country: 'GB' });

// Find a test account made earlier: an under-18 in GB with a saved password.
const [teen] = await qa.listAddresses({ tags: ['age-gate'], country: 'GB', maxAge: 17, hasPassword: true });

// Sign in again as a saved test account: the one used on this site first, with
// its password (needs a key with "Can read saved passwords"); null if none fits.
const account = await qa.signInAccount({ site: 'staging.example.com', tags: ['verified'] });

// How many addresses this project can still create: Free has 500 in total
// and 50 a day (limit null on Team). Past the 500, createAddress throws a 402
// plan_required; past the day's 50, a 429 daily_limit until midnight UTC.
const { addresses } = await qa.project();

// Testing on localhost? Links to it then pass checkEmail's local-links check.
await qa.markDevHost('localhost:3000');

// The link that opens a mobile app (myapp://…) when the email also has a web one.
const { value: appLink } = await qa.waitForLink(address, { app: true });

// The email itself, for template tests (the HTML is cleaned when it arrives).
const [latest] = await qa.messages(address, { includeHtml: true, limit: 1 });

// Unusual but valid versions of the address, and invalid ones to reject.
const { delivered, invalid } = await qa.variants(address);

// Awkward names and passwords for the form's other fields, grouped by what
// the app should do with each: accept, reject, store_as_text, decide.
const names = await qa.nameVariants({ field: 'first_name', maxLength: 50 });
const passwords = await qa.passwordVariants({ minLength: 8, maxLength: 64 });

// After the app changes the test account's password, keep the saved one in step.
await qa.setPassword(address, { password: 'the-new-test-password' });

// Check the email that arrived: SPF, DKIM, DMARC, local or http links, where
// its link goes, a stated expiry and more. Verdicts only, never its content.
const result = await qa.checkEmail(address, { type: 'link', expectHost: 'staging.example.com' });
expect(result.failed).toEqual([]);

// A localised email: in German, with the user's name in it.
await qa.checkEmail(address, { expectLanguage: 'de', expectText: ['Hallo Maya'] });

// What to type or pick in each field of a sign-up form, from a new address
// made for it: a username and password that fit the rules, the date of birth
// in the field's format, a country the drop-down offers. You fill it in.
const { values } = await qa.formValues({
  fields: [
    { id: 'email', type: 'email', label: 'Email' },
    { id: 'dob', type: 'text', label: 'Date of birth', placeholder: 'DD/MM/YYYY' },
  ],
});

// The form's own questions too: the project's custom fields come back with
// kind 'custom', and fields nobody taught with kind 'guess' (check those suit
// your test). On a sign-up over several screens, send each screen's fields
// with the same address, and its site and page, so custom fields kept to them
// apply.
const { values: teamStep } = await qa.formValues({
  address,
  site: 'staging.example.com',
  page: '/signup/team',
  fields: [{ id: 'size', type: 'select', label: 'Company size', required: true, options: [{ value: '1-10' }, { value: '11-50' }] }],
});

// In a native app, send the screen's name instead of its page (not both).
await qa.formValues({ address, screen: 'Team details', fields: [{ id: 'role', type: 'text', label: 'Your role' }] });

// formValues is for sign-up forms. A sign-in or password reset form sent with
// the address you signed up with gets only the email or username; without an
// address, the API refuses it (409) and makes no account.

// Teach a field once for the project: everyone's tests and Proxy Mail QA for
// Chrome 0.9.0 and later fill it the same way. Where only leads change custom
// fields, this asks them instead (status 'requested'), unless your key's
// creator leads the project (you_lead): then it's saved straight away.
await qa.saveCustomField({ name: 'Company size', match: { on: 'words', text: 'company size' }, value: { kind: 'option', option: '11-50' } });

// No test value in mind? { kind: 'generate' } makes up one that suits the
// field, the same one for the same account, on any field but a tick box.
await qa.saveCustomField({ name: 'Job title', match: { on: 'words', text: 'job title' }, value: { kind: 'generate' } });

// On a site in several languages, one custom field can hold the label in each.
// Saving replaces the custom field, so send every phrase to keep.
await qa.saveCustomField({
  name: 'Referral code',
  match: { on: 'words', text: 'referral code', also: ['empfehlungscode', 'code de parrainage'] },
  value: { kind: 'leave' },
});

// A sign-up over several screens: each site's sign-up screens, and whether
// Proxy Mail QA for Chrome 0.9.0 and later fills each screen by itself.
// Change them only when the person asks. On a locked project these ask the
// leads instead (status 'requested'), unless your key's creator leads it
// (you_lead): then the change is made straight away.
const { sites } = await qa.sites();
await qa.addSite('staging.example.com', 'staging'); // only a site that doesn't look live
await qa.setCustomFieldEnabled('a-custom-field-id', false);
await qa.setStandardField('company', false); // that box is left empty
await qa.setMultiStep('staging.example.com', true);
const saved = await qa.saveSignupScreen({ site: 'staging.example.com', page: '/signup/team', label: 'Team details' });
await qa.removeSignupScreen(saved.screen.id);

// Borrow an account in the state the test needs. Your hooks make one when
// none is free, confirm its state and put it back; they talk to your app,
// Proxy Mail QA never does.
await qa.withAccount(
  {
    tags: ['verified', 'premium'],
    create: async (account) => signUpAndUpgrade(account.address, account.password),
    check: async (account) => (await getUser(account.address)).plan === 'premium',
    reset: async (account) => resetUser(account.address),
  },
  async (account) => {
    // …the test…
  },
);

// Offers (Labs): wait for the coupon (offers can take minutes), apply it at
// checkout on staging with test payments, then record what the app said.
const coupon = await qa.waitForCode(address, { type: 'coupon', timeout: 600 });
await qa.recordCouponResult(address, 'accepted', { code: coupon.value });

// Unsubscribing (Labs): after the test unsubscribes in the app (Proxy Mail
// QA never opens the link), marketing from that sender fails; receipts can
// still come.
await qa.markUnsubscribed(address);
await qa.expectNoEmail(address, { category: 'marketing', since: new Date(), timeout: 300 });
const { passed } = await qa.checkUnsubscribe(address);

// A run: this client's calls add what they saw, for a report on the pull
// request. In GitHub Actions, the Proxy Mail QA action does this for you and
// the client picks the run up from PROXYMAIL_QA_RUN.
await qa.startRun({ name: 'Sign-up tests' });
// …the tests…
await qa.finishRun({ failed: testsFailed });

When a wait comes back empty, the error says why as far as Proxy Mail QA can tell, for example that the app's email provider only sends to verified addresses (sandbox mode): read error.hint, or call qa.checkDelivery(address) at any time. Retrying won't help until the app can email any address.

Mobile apps (XCUITest, Espresso, Maestro, Flutter, Detox and Appium): https://proxymail.qa/docs/mobile

Source

https://github.com/aaquib90/proxymail-qa-js. Releases are published to npm from GitHub Actions with provenance, so npm shows which commit and workflow built each version.

License

MIT