---
title: "Detox and Appium | Proxy Mail QA"
description: "Test email verification codes and magic links in React Native and native apps with Detox or Appium and disposable inboxes: a small JavaScript helper."
url: https://proxymail.qa/docs/detox-appium
---

# Detox and Appium

Add this file to your tests (Node 18 or later). It uses only `fetch`:

proxymail-qa.js

```js
// Disposable inboxes for Detox and Appium tests (Node 18 or later): create a
// test address, sign up with it in your app, then wait for the code or link
// from the email it gets, and check the email itself.
// Guide: https://proxymail.qa/docs/detox-appium
//
// The API key comes from the test run's environment (PROXYMAIL_QA_API_KEY),
// never from the app. With PROXYMAIL_QA_RUN set (the GitHub Action sets it),
// the calls add to that run, named by test: Jest's (and so Detox's) current
// test, or the name given to setTest.
const BASE_URL = process.env.PROXYMAIL_QA_API_URL ?? 'https://api.proxymail.qa/v1';
let testName;

async function send(method, path, body) {
  const key = process.env.PROXYMAIL_QA_API_KEY;
  if (!key) throw new Error('Set PROXYMAIL_QA_API_KEY for the test run.');
  const run = process.env.PROXYMAIL_QA_RUN;
  const test = testName ?? globalThis.expect?.getState?.().currentTestName;
  const response = await fetch(BASE_URL + path, {
    method,
    headers: {
      Authorization: `Bearer ${key}`,
      'Content-Type': 'application/json',
      // Percent-encoded: a test's name can hold characters headers can't.
      ...(run ? { 'Proxy-Mail-QA-Run': run, ...(test ? { 'Proxy-Mail-QA-Test': encodeURIComponent(test) } : {}) } : {}),
    },
    body: body && JSON.stringify(body),
    signal: AbortSignal.timeout(40_000),
  });
  const json = await response.json();
  // A wait that finds nothing says why: read the hint before retrying.
  if (!response.ok) throw Object.assign(new Error([json.message, json.hint].filter(Boolean).join(' ')), { status: response.status });
  return json;
}

const at = (address, path, query = {}) => `/addresses/${encodeURIComponent(address)}/${path}?${new URLSearchParams(query)}`;

/** The test the next calls belong to, in the run's report. Under Jest, the current test is used without it. */
export const setTest = (name) => {
  testName = name;
};

/**
 * A new test address, with a password for the sign-up form: { address, password }. Options:
 * environment (such as 'staging') records where it's used; age ('adult', 'under18', 'under13'…) and
 * country ('GB') give the persona a date of birth and a country.
 */
export const createAddress = ({ environment, age, country } = {}) => send('POST', '/addresses', { password: true, persona: true, environment, age, country });

const waitFor = async (kind, address, since, query) => (await send('GET', at(address, kind, { since: since.toISOString(), ...query }))).value;

/** The code from the newest email received since `since`, waiting up to 30 seconds. `unused: true` skips a code already used. */
export const waitForCode = (address, since, unused = false) => waitFor('code', address, since, unused ? { unused: 'true' } : {});

/**
 * The link from the newest email received since `since`: verify, magic_signin, invite, reset or any.
 * `app: true` asks for the link that opens the app (myapp://…) when the email also has a web one.
 */
export const waitForLink = (address, since, type = 'verify', app) => waitFor('link', address, since, { type, ...(app === undefined ? {} : { app: String(app) }) });

/** How many emails arrived since `since`, to check the app sends exactly one. */
export const countEmails = async (address, since) => (await send('GET', at(address, 'count', { since: since.toISOString() }))).count;

/**
 * The checks the newest email since `since` fails: SPF, DKIM, DMARC, links to localhost or plain http,
 * a stated expiry, its size and more. Empty when it passes. `expectHost` is the domain its link should
 * open, such as your app's Universal Link or App Link domain.
 */
export const checkEmail = async (address, since, expectHost) =>
  (await send('GET', at(address, 'checks', { since: since.toISOString(), ...(expectHost ? { expect_host: expectHost } : {}) }))).failed;

/** Resolves if no email arrives within `timeout` seconds (at most 30); throws, saying what came, as soon as one does. */
export const expectNoEmail = async (address, since, timeout = 30) => {
  await send('GET', at(address, 'none', { since: since.toISOString(), timeout: String(timeout) }));
};

/**
 * A saved test account to sign in with, and its password: one with every tag given that no test has
 * checked out, the one that got mail most recently first. null when none fits. Needs an API key
 * allowed to read saved passwords.
 */
export const signInAccount = async ({ tags = [], environment } = {}) => {
  try {
    return await send('POST', '/addresses/sign-in', { tags, environment });
  } catch (err) {
    if (err.status === 404) return null;
    throw err;
  }
};

/**
 * Borrows an account with every tag given (verified, premium…) that no other test has, for `minutes`,
 * with its saved password when the key may read it: { address, password, tags, checkout }. Give it
 * back with returnAccount.
 */
export const checkOutAccount = async (tags, minutes = 10) => {
  const account = await send('POST', '/addresses/checkout', { tags, minutes });
  try {
    account.password = (await send('GET', at(account.address, 'password'))).secret;
  } catch (err) {
    if (err.status !== 403 && err.status !== 404) throw err;
  }
  return account;
};

/** Gives a borrowed account back, recording what the test changed (tags to add and remove). */
export const returnAccount = (account, { addTags = [], removeTags = [] } = {}) =>
  send('POST', `/addresses/${encodeURIComponent(account.address)}/return`, { token: account.checkout.token, add_tags: addTags, remove_tags: removeTags });
```

Set `PROXYMAIL_QA_API_KEY` in the environment that runs the tests, from your CI’s secrets.

## Detox

e2e/signup.test.js

```js
import { createAddress, waitForCode, waitForLink } from './proxymail-qa.js';

it('sends a working code', async () => {
  const { address, password } = await createAddress();
  await device.launchApp();

  const submitted = new Date();
  await element(by.id('email')).typeText(address);
  await element(by.id('password')).typeText(password);
  await element(by.text('Sign up')).tap();

  const code = await waitForCode(address, submitted);
  await element(by.id('code')).typeText(code);
});
```

For a verify or magic link, open it in the app after signing up. If the email has both a web link and one that opens the app (`myapp://…`), pass `true` as the fourth argument for the app one:

```js
const link = await waitForLink(address, submitted, 'verify', true);
await device.openURL({ url: link });
```

## Appium (WebdriverIO)

test/signup.e2e.js

```js
import { createAddress, waitForCode, waitForLink } from './proxymail-qa.js';

it('sends a working code', async () => {
  const { address, password } = await createAddress();
  const submitted = new Date();
  await $('~email').setValue(address);
  await $('~password').setValue(password);
  await $('~Sign up').click();

  const code = await waitForCode(address, submitted);
  await $('~code').setValue(code);
});
```

For a verify or magic link, open it in the app after signing up:

```js
const link = await waitForLink(address, submitted);
await driver.execute('mobile: deepLink', { url: link, package: 'com.example.app' }); // Android
await driver.execute('mobile: deepLink', { url: link, bundleId: 'com.example.app' }); // iOS
```

## Check the email, not just the code

```js
import { checkEmail, countEmails } from './proxymail-qa.js';

expect(await countEmails(address, submitted)).toBe(1);
expect(await checkEmail(address, submitted, 'links.example.com')).toEqual([]);
```

`checkEmail` returns the checks the email fails: SPF, DKIM and DMARC, links to localhost or plain http, where its link goes (the third argument, the domain your app’s links use), a stated expiry, its size and more ([what each check means](https://proxymail.qa/docs/rest-api#checking-an-emails-sending-and-build)). `expectNoEmail(address, since)` resolves only if nothing arrives, for “an unsubscribed user gets nothing”. After a resend, `waitForCode(address, submitted, true)` skips the code already used.

`createAddress({ environment: 'staging', age: 'under18', country: 'GB' })` records which environment the account is for and gives its persona a date of birth and a country, for forms that ask.

## Sign in as a saved account, or borrow one

For a sign-in test, `signInAccount({ tags: ['verified'] })` returns a saved test account and its password, or `null` when none fits. When tests run in parallel, `checkOutAccount(['verified'])` lends one no other test has, with its password, until you give it back:

```js
const account = await checkOutAccount(['verified']);
try {
  // Sign in with account.address and account.password.
} finally {
  await returnAccount(account);
}
```

Both read saved passwords, so the key needs **Can read saved passwords** (Project settings → API keys). Tag accounts with their state in the app to make pools: see [an account pool](https://proxymail.qa/docs/rest-api#an-account-pool-for-parallel-tests).

## A report on the pull request

With the [GitHub Action](https://proxymail.qa/docs/ci#a-report-on-the-pull-request) in the job, set `PROXYMAIL_QA_RUN` for the tests too (the action sets it for the steps after it). Under Jest, which Detox uses, each call is named after the current test. With WebdriverIO, name it yourself in a hook:

```js
import { setTest } from './proxymail-qa.js';

beforeEach(function () {
  setTest(this.currentTest.fullTitle());
});
```

[Previous Flutter](https://proxymail.qa/docs/flutter.html) [Next With a coding agent (iOS)](https://proxymail.qa/docs/ios-agents.html)
