`@ranger-testing/inbox-relay` is a wrapper around the Ranger API which allows tests to receive messages and make assertions on their content, including One-Time-Passowords (OTP), Magic Auth Links or Confirm Email Links, and transactional messages.
npm install @ranger-testing/inbox-relay@ranger-testing/inbox-relay is a wrapper around the Ranger API which allows tests to receive
messages and make assertions on their content, including One-Time-Passowords (OTP), Magic Auth Links
or Confirm Email Links, and transactional messages.
The client library is the only way tests can interact with inboxes.
- Getting Started
- Concepts
- Permanent Inboxes
- Temporary Inboxes
- Locking an Inbox and Reading Messages
- Session API
- Considerations
- Use Case Examples
- Magic Link Auth: Permanent Inbox
- Transactional Emails: Permanent Inbox
- Sign Up Flow: Temporary Inbox
- SMS OTP with Multiple Messages: Session API
- InboxMessage Contents
For working locally:
```
npm install @ranger-testing/inbox-relay
Permanent Inboxes are managed through the Debug Tool, and should be created to correspond with a
Permanent Test User within a third party system. These inboxes are best used for tests which do not
involve the creation and disposal of new user accounts.
It is not possible to create or delete a Permanent Inbox during a test.
Temporary Inboxes are created during a test, or during the beforeAll for an entire suite of tests.
They are automatically cleaned-up after a configurable expiration period.
Temporary Inboxes are created with InboxRelayClient.createTemporaryInbox().
- An inbox must be locked before messages can be received.
- This guarantees that messages from parallel tests do not conflict.
- All locks have a (configurable) expiration, so that an inbox cannot remain locked indefinitely due
to errors.
When you need to use an inbox, you must first request a lock with InboxRelayClient.lockInbox().timeout
This method retries and waits for a lock until one is available. If the inbox is not already locked,
this is immediate. Otherwise, the method waits until the previous lock is released, up to a
configured .
Once your lock is successful, you can perform actions that will cause emails to be sent to the
inbox. Once you are finished, you can await and retrieve all messages delivered to the inbox **since
your lock began**. Messages are retrieved with InboxRelayClient.unlockAndReadMessages().
The inbox lock is automatically released after you retrive messages. It is not possible to retrieve
messages that were received before you locked the inbox, or after you unlock it. You cannot unlock
the same inbox twice without locking it again.
If you need to retrieve messages more than once (because later test steps depend on message
content), you must lock the inbox more than once.
For complex test scenarios where you need to:
- Wait for a specific message (e.g., OTP) while other messages may arrive
- Keep the inbox locked for the entire duration of a multi-step test
- Avoid race conditions when re-locking an inbox
Use the Session API with createPhoneSession() or createEmailSession().
A session holds the lock for its entire lifetime and provides a waitForMessage(predicate) method
that polls for messages matching your criteria without releasing the lock.
`ts
const session = await inboxes.createPhoneSession({ ttlMs: 600_000 })
try {
// Session holds the lock - use session.address for the phone number
const otpMessage = await session.waitForMessage(msg => msg.otp !== null)
// ... use OTP ...
// Can check for other messages later, still holding the lock
const allMessages = await session.getMessages()
} finally {
await session.release()
}
`
Key differences from lockInbox() + unlockAndReadMessages():release()
- Lock persists: The lock is held until you explicitly call (or it expires)waitForMessage()
- Polling with predicates: polls the inbox until a matching message arrivesgetMessages()
- Multiple reads: You can call or waitForMessage() multiple times
- No race conditions: No window where another test could grab your inbox
When to use Session API vs standard lock/unlock:
| Use Case | Recommended Approach |
|----------|---------------------|
| Simple OTP/magic link (one message expected) | lockInbox() + unlockAndReadMessages() |unlockAndReadMessages({ expectedMessages: N })
| Multiple messages, need a specific one | Session API |
| Need to verify messages after test actions | Session API |
| Transactional emails with known count | |
> ⚠️ Use sparingly: Sessions hold phone numbers/inboxes for longer periods, reducing
> availability for parallel tests. Only use sessions when you genuinely need predicate-based
> waiting or multiple message checks. For simple single-message flows, prefer the standard
> lockInbox() + unlockAndReadMessages() pattern which releases the lock immediately.
- Locking prevents an entire class of test-flake issues, especially when we are reusing a single
Test User for multiple tests.
- In most cases, email should not take very long, and tests will not spend much time waiting.
- Multiple inboxes can be used to spend less time waiting for locks. There is no downside to
creating multiple test users with dedicated inboxes if a use case calls for it.
- Because of this, Inboxes do not need to be unique per-test.
> Customer has asked Ranger to test Magic Link login with an existing user account.
1. Ranger creates a Permanent Inbox for login-test@inbox.useranger.com using the Debug Tool.
1. Ranger or Customer creates the Test User in the Customer environment using that email address.
1. Ranger tests can assert proper magic-link login behavior:
`ts
import { InboxRelayClient } from '@ranger-testing/inbox-relay'
const API_TOKEN = process.env.API_TOKEN
const PERMANENT_INBOX = 'login-test@inbox.useranger.com'
const inboxes = new InboxRelayClient({ apiToken: API_TOKEN })
export async function run(page: Page) {
// >>> Request a lock for the (already-existing) permanent inbox:
await inboxes.lockInbox({ address: PERMANENT_INBOX })
// >>> Next, perform the steps that send the magic-link auth email:
const usernameInput = page.getByRole('textbox', { name: 'Username' })
await usernameInput.fill(PERMANENT_INBOX)
const loginButton = page.getByRole('button', { name: 'Log In' })
await loginButton.click()
// >>> Unlock the inbox and retrieve the message:
const message = (await inboxes.unlockAndReadMessages())[0]
// >>> Expect that the magic link works:
await page.goto(message.magicLink)
expect(page.locator('button[aria-label="Profile Menu"]')).toContainText('Ranger Test User')
}
`
> Customer has asked Ranger to test transactional Task Notification Emails, which are sent when a
> task is assigned, completed, and reviewed.
1. Ranger creates a Permanent Inbox for task-notifications-test@inbox.useranger.com using the
Debug Tool.
1. Ranger or Customer creates a test user in the Customer environment using that email adddress.
1. Ranger tests can assert proper transactional email behavior:
`ts
import { InboxRelayClient } from '@ranger-testing/inbox-relay'
const API_TOKEN = process.env.API_TOKEN
const PERMANENT_INBOX = 'task-notifications-test@inbox.useranger.com'
const inboxes = new InboxRelayClient({ apiToken: API_TOKEN })
export async function run(page: Page) {
// >>> Request a lock for the (already-existing) permanent inbox:
await inboxes.lockInbox({ address: PERMANENT_INBOX })
// >>> Next, perform the steps that send one or more emails:
const assigneeDropdown = page.getByRole('combobox', { name: 'Assignee' })
await assigneeDropdown.selectOption(PERMANENT_INBOX)
const assignButton = page.getByRole('button', { name: 'Assign' })
await assignButton.click()
// >>> Unlock the inbox and retrieve messages:
const message = (await inboxes.unlockAndReadMessages())[0]
// >>> Expect the message to contain the right content:
expect(message.content).toMatch(/You were assigned task "\w*?"/)
// >>> Optionally assert on the number of attachments
expect(message.attachmentCount).toBeGreaterThan(0)
}
`
> Customer has asked Ranger to test their new-user signup flow. The user should receive an a
> confirm-email link email as well as a welcome email.
This test case requires us to create a temporary inbox before completing the signup flow:
`ts
import { InboxRelayClient } from '@ranger-testing/inbox-relay'
const API_TOKEN = process.env.API_TOKEN
const inboxes = new InboxRelayClient({ apiToken: API_TOKEN })
export async function run(page: Page) {
// >>> Create and lock the temporary inbox:
const { address } = await inboxes.createTemporaryInbox()
await inboxes.lockInbox({ address })
// >>> Next, sign up the new user:
const newUserEmailInput = page.getByRole('textbox', { name: 'Email Address' })
await newUserEmailInput.fill(TEMPORARY_INBOX)
const submitButton = page.getByRole('button', { name: 'Sign Up' })
await submitButton.click()
// >>> Unlock the inbox and retrieve messages:
const messages = await inboxes.unlockAndReadMessages({
// We are expecting both a welcome email and a confirm-email link
expectedMessages: 2
})
// Find the message which contains a confirm link
const confirmEmail = messages.find((message) => !!message.magicLink)
// >>> Expect the confirm-email link to work
await page.goto(confirmEmail.magicLink)
expect(page.getByTestId('success-callout')).toContainText('confirmed successfully')
}
`
Contents`text/plain
export interface InboxMessage {
/* The sender of the message. /
sender: string | null
/* The subject of the message. /
subject: string | null
/* The body of the message. /
content: string | null
/* Some HTML emails include a alternative. If so, it is provided here. /`
contentText: string | null
/* The instant the message was received. /
receivedTimestamp: Date
/* An alphanumeric one-time-password, if one was found. /
otp: string | null
/* Any links that were found in the body of the message. /
links: string[]
/* Our best guess as to which link is a magic link, if one is found. /
magicLink: string | null
/* Number of attachments in the message. /
attachmentCount: number
}
> Customer has asked Ranger to test that invoice PDFs are properly generated and emailed to users after purchase completion.
This test case demonstrates downloading and parsing PDF attachments:
`ts
import { InboxRelayClient } from '@ranger-testing/inbox-relay'
import pdf from 'pdf-parse'
const API_TOKEN = process.env.API_TOKEN
const inboxes = new InboxRelayClient({ apiToken: API_TOKEN })
export async function run(page: Page) {
// >>> Create and lock the temporary inbox:
const { address } = await inboxes.createTemporaryInbox()
await inboxes.lockInbox({ address })
// >>> Complete a purchase that generates an invoice email:
const emailInput = page.getByRole('textbox', { name: 'Email' })
await emailInput.fill(address)
const purchaseButton = page.getByRole('button', { name: 'Complete Purchase' })
await purchaseButton.click()
// >>> Unlock the inbox and retrieve messages with attachments:
const messages = await inboxes.unlockAndReadMessages({
includeAttachments: true
})
// >>> Download and parse the PDF attachment:
const pdfAttachment = messages
.flatMap((msg) => msg.attachments)
.find((att) => att?.name.endsWith('.pdf'));
expect(pdfAttachment).toBeTruthy();
const buffer = Buffer.from(pdfAttachment.contentBase64, 'base64');
const pdfData = await pdf(buffer);
// >>> Assert on PDF content:
expect(pdfData.text).toContain('Invoice #')
expect(pdfData.text).toContain('Total Amount: $')
expect(pdfData.text).toContain(address) // Customer email should be in invoice
}
`
This test case demonstrates using findAndLockPhoneNumber to automatically find and lock an available phone number for receiving SMS messages:
`ts
import { InboxRelayClient } from '@ranger-testing/inbox-relay'
const API_TOKEN = process.env.API_TOKEN
const inboxes = new InboxRelayClient({ apiToken: API_TOKEN })
export async function run(page: Page) {
// >>> Find and lock an available phone number:
const { address: phoneNumber } = await inboxes.findAndLockPhoneNumber()
// >>> Enter the phone number in the 2FA setup form:
const phoneInput = page.getByRole('textbox', { name: 'Phone Number' })
await phoneInput.fill(phoneNumber)
const sendCodeButton = page.getByRole('button', { name: 'Send Code' })
await sendCodeButton.click()
// >>> Unlock the phone number and retrieve the SMS message:
const message = (await inboxes.unlockAndReadMessages())[0]
// >>> Extract the OTP and complete verification:
const otpInput = page.getByRole('textbox', { name: 'Verification Code' })
await otpInput.fill(message.otp!)
const verifyButton = page.getByRole('button', { name: 'Verify' })
await verifyButton.click()
// >>> Expect successful verification:
expect(page.getByTestId('success-message')).toContainText('Phone verified successfully')
}
`
> Customer is a medical appointment platform that sends multiple SMS messages during signup:
> a welcome message, appointment reminders, and an OTP code. Tests need to wait for the OTP
> specifically, but can't predict when it will arrive relative to other messages.
This test case uses the Session API to hold the phone number locked while waiting for a specific
message, avoiding race conditions:
`ts
import { InboxRelayClient } from '@ranger-testing/inbox-relay'
const API_TOKEN = process.env.API_TOKEN
const inboxes = new InboxRelayClient({ apiToken: API_TOKEN })
export async function run(page: Page) {
// >>> Create a phone session that holds the lock for the entire test:
const session = await inboxes.createPhoneSession({ ttlMs: 600_000 })
try {
const phoneNumber = session.address
// >>> Navigate to signup and enter the phone number:
await page.goto('https://example.com/signup')
const phoneInput = page.getByTestId('phone-input')
await phoneInput.fill(phoneNumber)
const submitButton = page.getByTestId('signup-submit')
await submitButton.click()
// >>> Wait for the page to request OTP verification:
await expect(page.getByRole('heading', { name: 'Check your texts' })).toBeVisible()
// >>> Wait specifically for a message with an OTP (ignores welcome messages, etc.):
const otpMessage = await session.waitForMessage(
msg => msg.otp !== null,
{ timeoutMs: 120_000 }
)
console.log('Received OTP:', otpMessage.otp)
// >>> Enter the OTP and complete signup:
const otpInput = page.getByTestId('sms-code-input')
await otpInput.fill(otpMessage.otp!)
const verifyButton = page.getByTestId('verify-code-submit')
await verifyButton.click()
// >>> Complete remaining signup steps...
await expect(page.getByRole('heading', { name: 'Date of birth' })).toBeVisible()
// ... fill out DOB, name, etc ...
// >>> After signup completes, verify we also received a welcome message:
const allMessages = await session.getMessages()
const hasWelcomeMessage = allMessages.some(
msg => msg.content?.toLowerCase().includes('welcome')
)
expect(hasWelcomeMessage).toBe(true)
} finally {
// >>> Always release the session when done:
await session.release()
}
}
`
Why use Session API here?
With the standard unlockAndReadMessages() approach:
1. Lock phone → trigger signup → unlock to read OTP
2. Problem: You get the "welcome" message first, not the OTP
3. Try to re-lock → Race condition: Another test might grab the phone number
4. Even if you re-lock, messages received during the gap may be lost
With the Session API:
1. Create session (locks phone for entire test)
2. waitForMessage(msg => msg.otp !== null) polls until OTP arrivesgetMessages()`
3. Phone stays locked the whole time - no race conditions
4. Can verify other messages arrived later with