Two kinds of waiting
Long-poll. The test makes one request and the server answers when the email is there, or when its own limit runs out. Zyntra's GET /emails/last works this way for an address that has no mail yet: it checks once a second for up to 30 seconds, then answers 404.
Client-side polling. The test asks, gets an answer at once, waits, and asks again. You need this when the address already has mail: /emails/last then returns the newest email immediately, and the test has to check whether it is the new one.
Long-poll is cheaper (one request instead of ten) and faster (the answer comes the moment the email is stored, not at the next tick of your interval). Use it whenever the address is fresh.
Timeouts: three layers
A test that waits for email has three clocks, and the outer one must be the longest:
- The server's wait: up to 30 seconds per
/emails/lastcall. - Your HTTP client's timeout: it must outlast the server, say 45 seconds. Cypress's
responseTimeoutis 30 seconds by default, socy.requestneeds an explicittimeout. - The test's timeout: Playwright gives each test 30 seconds by default, which a single email wait can use up. Raise it for email tests with
test.setTimeout().
import { defineConfig } from "@playwright/test";
export default defineConfig({
projects: [
// Email tests get 90 s; everything else keeps the default.
{ name: "email", testMatch: /email.*\.spec\.ts/, timeout: 90_000 },
{ name: "rest", testIgnore: /email.*\.spec\.ts/ },
],
});How long is enough? Most transactional emails arrive within seconds, but providers queue under load. Put the overall limit at 60–90 seconds: long enough to absorb a slow minute, short enough that a broken template fails the build quickly.
Polling for a second email
When an address already has mail, poll with an interval of about two seconds and a predicate that recognises the email you want. Shorter intervals add requests without making the test noticeably faster; the email is rarely there in under a second anyway.
export async function waitForEmail(
address: string,
match: (e: { subject: string }) => boolean,
{ timeoutMs = 60_000, intervalMs = 2_000 } = {},
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(
`https://api.zyntra.app/emails/last?email=${encodeURIComponent(address)}`,
{ headers: { "X-API-Key": process.env.ZYNTRA_API_KEY! } },
);
if (res.ok) {
const email = await res.json();
if (match(email)) return email;
} else if (res.status !== 404) {
throw new Error(`API ${res.status}`);
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`No matching email for ${address}`);
}Counting requests before you pick a plan
Every call with an API key counts as one request: reads, lists, attachment downloads and deletes. So:
requests per month = runs per month × requests per run
A suite with four email tests, each reading one email from a fresh address, makes four requests per run. Five runs a day on 22 working days is 110 runs, or 440 requests: inside Zyntra's free 500. Add a password-reset test that polls for a second email ten times, and the same schedule needs about 1,700. That is the point where a suite running in CI every day belongs on a paid plan.
Cheap ways to stay lower: a fresh address per test (one long-poll instead of a loop), a two-second interval rather than a 200 ms one, and no cleanup calls for mail that expires on its own.