ravenhook

JavaScript

There is no SDK to install. The API is plain HTTP, so fetch and about forty lines of helper are the whole integration. Here is what that looks like end to end.

Setup

No dependencies — fetch and crypto.randomUUID are built into Node 18 and later. Set two environment variables, taking the domain once from GET /namespace:

RAVENHOOK_TOKEN=rh_your_token_here
RAVENHOOK_DOMAIN=ex4mple0ns.inbox.ravenhook.dev

The helper

Write this once and forget it. The important part is waitForMail: it holds the connection open server-side, so there is no polling loop and no arbitrary sleep to tune.

// ravenhook.js
const API = "https://api.ravenhook.dev";
const TOKEN = process.env.RAVENHOOK_TOKEN;
const DOMAIN = process.env.RAVENHOOK_DOMAIN;

const headers = { Authorization: `Bearer ${TOKEN}` };

/** A fresh address for this run. Nothing is registered. */
export function newAddress(prefix = "test") {
  return `${prefix}-${crypto.randomUUID()}@${DOMAIN}`;
}

/** Block until a message arrives, or throw if none does. */
export async function waitForMail(address, seconds = 25) {
  const params = new URLSearchParams({ address, wait: String(seconds) });
  const res = await fetch(`${API}/messages/latest?${params}`, { headers });

  if (!res.ok) {
    throw new Error(`Ravenhook returned ${res.status}`);
  }

  const { message } = await res.json();
  if (message === null) {
    throw new Error(`No mail reached ${address} in ${seconds}s`);
  }
  return message;
}

/** Wait for mail and pull the first code out of its body. */
export async function waitForCode(address, pattern = /\b\d{6}\b/) {
  const message = await waitForMail(address);
  const match = message.text_body.match(pattern);

  if (!match) {
    throw new Error(`No code matching ${pattern} in: ${message.subject}`);
  }
  return match[0];
}

A Playwright test

// signup.spec.js
import { test, expect } from "@playwright/test";
import { newAddress, waitForCode } from "./ravenhook.js";

test("a user can verify their email", async ({ page }) => {
  const inbox = newAddress("signup");

  await page.goto("https://yourapp.test/register");
  await page.getByLabel("Email").fill(inbox);
  await page.getByRole("button", { name: "Sign up" }).click();

  const code = await waitForCode(inbox);

  await page.getByLabel("Verification code").fill(code);
  await page.getByRole("button", { name: "Verify" }).click();

  await expect(page.getByText("Welcome")).toBeVisible();
});

Without a browser

Same helper, driving your API directly:

// signup.test.js  (vitest, jest, node:test - all the same)
import { newAddress, waitForCode } from "./ravenhook.js";

test("registering sends a verification code", async () => {
  const inbox = newAddress("signup");

  await api.post("/register", { email: inbox, password: "hunter2" });

  const code = await waitForCode(inbox);
  const res = await api.post("/verify", { email: inbox, code });

  expect(res.status).toBe(200);
});

Running in parallel

This is the part that does not work with a shared mailbox. Ten tests reading one address cannot tell whose code is whose — the messages are genuinely indistinguishable, so no filtering rescues it.

Because addresses cost nothing and need no setup call, each test just makes its own:

// Playwright's fullyParallel works with no coordination at all,
// because every test invents its own address.

test("password reset", async ({ page }) => {
  const inbox = newAddress("reset");
  // ...
});

test("team invite", async ({ page }) => {
  const inbox = newAddress("invite");
  // ...
});

No fixtures to serialise, no lock, no cleanup between runs. Old mail expires on its own within 24 hours.

Pulling out a link instead of a code

We return the whole body rather than guessing which part you wanted, so extracting a confirmation URL is the same shape as extracting a code:

const message = await waitForMail(inbox);

// the confirmation link, rather than a numeric code
const url = message.text_body.match(/https:\/\/\S+\/confirm\/\S+/)[0];
await page.goto(url);

Notes

  • If you wrap fetch with your own timeout, set it above the wait value. A 25 second wait behind a 10 second client timeout fails every time and looks like the mail never arrived.
  • Playwright's own expect timeout does not apply here — this is a bare fetch, not a locator assertion.
  • wait is capped at 25 seconds. Ask for more and you get 422 rather than a silent downgrade.
  • A 429 means too many simultaneous waiting requests. Back off rather than retrying harder — see About 429.