Node.js client
proxymail-qa wraps the REST API for JavaScript and TypeScript: Playwright, Cypress tasks, Vitest, Jest, Detox or a plain script. It has no dependencies, ships its own types, and runs on Node 18 or later (or anywhere with fetch). The source is at github.com/aaquib90/proxymail-qa-js, and each release is published from GitHub Actions with npm provenance.
npm install --save-dev proxymail-qaCreate an API key and set it as PROXYMAIL_QA_API_KEY, or pass it in.
Quick start
Section titled “Quick start”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 });const submitted = new Date();// …sign up in your app with address, persona and password…
const { value: code } = await qa.waitForCode(address, { since: submitted });// or: const { value: link } = await qa.waitForLink(address, { type: 'verify', since: submitted });
await qa.deleteAddress(address);Waits return the code or link on its own, with the service that sent it and when it expires, never the email’s text. A wait holds for up to 30 seconds and longer timeouts repeat it. Full examples: Playwright and Cypress.
Methods
Section titled “Methods”| Method | What it does |
|---|---|
new ProxyMailQA({ apiKey?, baseUrl?, fetch?, run? }) |
The client. apiKey defaults to PROXYMAIL_QA_API_KEY, and run to PROXYMAIL_QA_RUN |
createAddress(options?) |
A new test address. Options: label, name (a custom name before the @), persona, password (returned once, and typed as a string when you pass password: true), lifetimeDays, domain, age or dateOfBirth, country, and where it’s used: site and environment (new in 0.9.0) |
waitForCode(address, options?) |
The newest verification or login code. Options: since, service, timeout (seconds, default 30), and unused (skip codes already used: returned by an earlier wait, or copied in the app; new in 0.5.1) |
waitForLink(address, options?) |
The same for a link. Also type (verify, magic_signin, invite, reset or any, or a list) and app (true for the link that opens a mobile app) |
messages(address, options?) |
Emails with their codes and links. includeText and includeHtml add the content, for template tests; limit |
countEmails(address, options?) |
How many emails arrived, each summarised without its content. Options: since, service, type (code or link) and subtype |
expectNoEmail(address, options?) |
Resolves if no matching email arrives within timeout seconds (default 30); throws ProxyMailQAError (409, email_arrived) as soon as one does. Same filters, and since defaults to now |
forTest(name), recordTest(result), recordTestTotals(totals) |
A client that names its test in every call, and test results for the run’s report; proxymail-qa/playwright and its reporter use them. New in 0.8.0 (more) |
formValues({ fields, address?, age?, country?, … }) |
What to type or pick in each field of a sign-up form, from a new address made for it. New in 0.7.0 (more) |
checkEmail(address, options?) |
Checks the newest matching email (or emailId): SPF, DKIM, DMARC, local and plain http links, where its link goes (expectHost), a stated expiry, a plain-text part, size and image-only emails. From 0.7.0, phrases it should contain (expectText) and its language (expectLanguage). Verdicts only, never its content. New in 0.4.0 |
checkDelivery(address) |
What reached the address and what was refused, without waiting |
variants(address) |
Unusual versions of the address that still get its mail, and invalid ones (more) |
getAddress(address), listAddresses({ limit?, before?, tags?, country?, minAge?, maxAge?, hasPassword?, search?, site?, environment? }) |
Read addresses, or find ones made earlier: every filter given must match |
updateAddress(address, patch) |
Change label, notes, tags, first_name, last_name, username, date_of_birth or country; null clears one |
setPassword(address, { password?, label? }) |
Save the password the test account uses now, after the app changed or reset it. Without one, a strong password is generated and returned once |
savedPassword(address, label?) |
Read a saved password. Needs a key with “Can read saved passwords”; every read is logged |
signInAccount({ site?, environment?, tags? }) |
The saved test account to sign in with, and its password, in one call: the one used on this site first; null when none fits. Needs a key with “Can read saved passwords”. New in 0.9.0 (more) |
markDevHost(host) |
Marks a host such as localhost:3000 as a dev environment of the project, so checkEmail passes links to it. New in 0.9.0 |
deleteAddress(address) |
Delete the address and its mail |
project() |
The key’s project: its mail domains, email prefix and limits |
checkOutAccount({ tags?, country?, minAge?, maxAge?, hasPassword?, search?, site?, environment?, minutes? }), extendCheckout(account, minutes?), returnAccount(account, { addTags?, removeTags? }), withAccount(options, fn) |
The account pool: borrow an account in a known state that no other test has, and give it back. New in 0.6.0 (more). From 0.7.0, withAccount takes create, check and reset hooks: accounts in a known state |
startRun({ name?, source?, meta? }), finishRun({ failed? }), runReport(), addRunNote(text, { failed? }), getRun(), listRuns() |
A run: this client’s calls add what they saw, for a report on the pull request. New in 0.5.0 |
Every method takes a plain address or a variant of it. Waiting on a +tag address only finds mail sent to that tag, so one address can serve a whole test run: see Running in CI.
Values for a sign-up form
Section titled “Values for a sign-up form”formValues gives what to type or pick in each field of a form, from a new address made for it: a username and password that fit the fields’ rules, the date of birth in the field’s format, a country the drop-down offers. In Playwright, read the fields from the page and fill each one in:
const form = page.locator('form#sign-up');const fields = await form.locator('input, select').evaluateAll((elements) => elements.map((el, i) => { const input = el as HTMLInputElement & HTMLSelectElement; input.dataset.field = String(i); return { id: String(i), type: input.tagName === 'SELECT' ? 'select' : input.type, name: input.name, label: input.labels?.[0]?.textContent ?? input.getAttribute('aria-label') ?? '', placeholder: input.placeholder, autocomplete: input.getAttribute('autocomplete') ?? '', pattern: input.pattern || undefined, minLength: input.minLength > 0 ? input.minLength : undefined, maxLength: input.maxLength > 0 ? input.maxLength : undefined, options: input.tagName === 'SELECT' ? Array.from(input.options, (o) => ({ value: o.value, label: o.text })) : undefined, }; }),);const { address, values } = await qa.formValues({ fields });for (const { id, kind, value } of values) { if (value === null) continue; const field = form.locator(`[data-field="${id}"]`); if (await field.evaluate((el) => el.tagName === 'SELECT')) await field.selectOption(value); else await field.fill(value);}Pass address to use an account made earlier, and age or country for a new one. More in the REST API guide.
Checking that nothing arrives, or one email does
Section titled “Checking that nothing arrives, or one email does”// An unsubscribed user gets no newsletter.const sent = new Date();await sendNewsletter();await qa.expectNoEmail(unsubscribed.address, { since: sent, timeout: 20 });
// A sign-up sends one verification email, not two.await qa.waitForLink(address, { since: submitted });await new Promise((resolve) => setTimeout(resolve, 10_000)); // time for a duplicateexpect((await qa.countEmails(address, { since: submitted, type: 'link' })).count).toBe(1);Neither returns what the emails say, only when each arrived, who sent it and the kind of its code or link. More in the REST API guide.
Checking an email’s sending and build
Section titled “Checking an email’s sending and build”await qa.waitForLink(address, { since: submitted });const result = await qa.checkEmail(address, { since: submitted, type: 'link', expectHost: 'staging.example.com' });expect(result.failed).toEqual([]); // e.g. ['local_links'] when the link goes to localhost
// A localised email: the right language, with the user's name in it.const german = await qa.checkEmail(address, { since: submitted, expectLanguage: 'de', expectText: [persona.first_name] });expect(german.failed).toEqual([]);Each check passes, fails, warns or is skipped, with a one-line reason; what each means is in the REST API guide. Testing an app that runs on your machine? await qa.markDevHost('localhost:3000') once, and links to it pass local_links; links to any other localhost still fail.
An account pool for parallel tests
Section titled “An account pool for parallel tests”Tag accounts with their state, then let each test borrow one. withAccount checks one out, runs your function and always gives it back; return { addTags, removeTags } to record what the test changed:
await qa.withAccount({ tags: ['verified', 'premium'] }, async (account) => { const { secret } = await qa.savedPassword(account.address); await signIn(page, account.address, secret); await cancelSubscription(page); return { addTags: ['cancelled'], removeTags: ['premium'] };});No other test gets the same account while it’s checked out (10 minutes unless you pass minutes), and one that’s never returned frees itself when its time runs out.
Accounts in a known state
Section titled “Accounts in a known state”When a test needs an account in a particular state in your app (verified, on the premium plan, with no orders), give withAccount three hooks. Your test code runs them with its own access to your app; Proxy Mail QA never signs in to it. Here app stands for your own code that talks to your app: an admin API, a database seed or the UI.
await qa.withAccount( { tags: ['verified', 'premium'], // When none is free: sign up with the new address, verify it, upgrade it. create: async (account) => { const submitted = new Date(); await app.signUp(account.address, account.password); const { value: link } = await qa.waitForLink(account.address, { type: 'verify', since: submitted }); await app.open(link); await app.admin.setPlan(account.address, 'premium'); }, // Is it still in that state? Return false or throw if not. check: async (account) => (await app.admin.user(account.address)).plan === 'premium', // After the test: put it back. reset: async (account) => app.admin.resetUser(account.address, { plan: 'premium', orders: [] }), }, async (account) => { // …the test… },);- check runs before the test gets the account. If it fails, reset runs and it’s checked again; if it still fails, the next account is tried (up to
attempts, 3), and the one that failed loses the pool’s tags and is taggedcheck-failed. - create runs when no account matches or every match is checked out. The new address is held for this test from the start and gets the tags once create and check succeed. Pass
newAccountfor its age, label or domain; it gets the filter’s country. - reset runs after the test, whether it passed or not. If reset fails, the account loses the pool’s tags and is tagged
needs-reset, so no other test gets it in an unknown state.
In a run, a new account, a failed check and a failed reset each add a line to the report.
A report of the run
Section titled “A report of the run”Calls made while a run is open add what they saw to it: the addresses, which emails arrived and how long after since, the codes, where each link goes, checks, and emails that never came. In GitHub Actions, the Proxy Mail QA action starts and finishes the run and posts the report on the pull request; the client picks the run up from PROXYMAIL_QA_RUN with no changes to your tests. Elsewhere, run it yourself:
const run = await qa.startRun({ name: 'Sign-up tests', meta: { branch: process.env.BRANCH } });// …the tests, using qa…await qa.finishRun({ failed: testsFailed });console.log(await qa.runReport(run.id)); // Markdown, for a pull request commentOther processes join the run when you pass them its id as PROXYMAIL_QA_RUN.
To show which test each email came from, use a client for each test: qa.forTest('sign-up › sends one code') names the test in every call. In Playwright, proxymail-qa/playwright gives every test a qa fixture that does this, and proxymail-qa/playwright/reporter records failed and flaky tests and the totals in the run: see Running in CI. Other test runners can send results themselves with qa.recordTest({ name, status, durationMs, error }) and qa.recordTestTotals({ passed, failed, skipped, flaky }). New in 0.8.0.
Errors
Section titled “Errors”Failed calls throw ProxyMailQAError with the HTTP status, a code and a readable message. When a wait finds nothing, the message also says why as far as Proxy Mail QA can tell (error.hint), for example that the app’s email provider only sends to verified addresses, and error.refused lists mail turned away for being too large or over a limit:
import { ProxyMailQAError } from 'proxymail-qa';
try { await qa.waitForCode(address, { since: submitted });} catch (error) { if (error instanceof ProxyMailQAError && error.status === 404) console.log(error.hint); throw error;}Retrying won’t help until the app can send to any address. See Test email not arriving?
Finding a test account again
Section titled “Finding a test account again”Tests and agents can come back to an account they made earlier instead of creating another. Every filter given must match; ages are whole years today, from the persona’s date of birth, and search looks in the address, label, notes, name and username.
// An under-18 in the UK, made for the age-gate tests, with a saved password.const [teen] = await qa.listAddresses({ tags: ['age-gate'], country: 'GB', maxAge: 17, hasPassword: true });
// Record where an account is used, then sign in with it again later: the// account used on this site first, with its password (needs a key with// "Can read saved passwords").await qa.createAddress({ password: true, site: 'staging.example.com', environment: 'staging' });const account = await qa.signInAccount({ site: 'staging.example.com', tags: ['verified'] });