npm.io
0.0.20 • Published 3d ago

@ranger-testing/inbox-relay

Licence
UNLICENSED
Version
0.0.20
Deps
0
Size
74 kB
Vulns
0
Weekly
0

Ranger Inbox Relay Client

@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.

Table of Contents

Getting Started

For working locally:

npm install @ranger-testing/inbox-relay

Publishing a New Version

The version bump is included in the same PR as the change being published, so the merge commit on main corresponds 1:1 with the version on npm.

  1. In your PR, bump version in both package.json and package-lock.json (patch-level for non-breaking additions, e.g. 0.0.170.0.18). The two files must stay in lockstep.

  2. After the PR merges, from a clean checkout of main, run:

    npm run publish

    This runs the build script (compiles index.js and emits .d.ts files) and then npm publish. You will need to be logged in to npm under an account with write access to the @ranger-testing scope (npm login if needed).

Concepts

Permanent Inboxes

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

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().

Locking an Inbox and Reading Messages
  • 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(). 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 timeout.

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.

Sending Emails (Outbound)

Some applications trigger workflows when they receive an email (e.g. email-to-ticket, document ingestion). Tests for these flows send an email with InboxRelayClient.sendEmail() and then assert the resulting behavior in the application UI.

Sending is subject to a few rules:

  • The from inbox must be send-capable. Send capability is opt-in at inbox creation (createTemporaryInbox({ sendCapable: true }), or the "Send-capable" checkbox when creating a Permanent Inbox in the Debug Tool) and cannot be changed after creation. Inboxes are receive-only by default.
  • Recipients are restricted to an allowlist. Every recipient's domain must be in your organization's allowed outbound email domains (configured by Ranger in the org settings). Sends to any other domain are rejected.
  • Send from the identity the app knows. Applications usually match the sender address to an existing account, so send from the same inbox address that is registered as the test user.
  • No lock is needed to send. Locks arbitrate concurrent reads; sendEmail() can be called at any time from an inbox your org owns.

Attachments are supported as base64 (attachments: [{ name, contentType, contentBase64 }]), and the body is HTML by default (isHtml: false for plain text).

Session API

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.

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():

  • Lock persists: The lock is held until you explicitly call release() (or it expires)
  • Polling with predicates: waitForMessage() polls the inbox until a matching message arrives
  • Multiple reads: You can call getMessages() 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()
Multiple messages, need a specific one Session API
Need to verify messages after test actions Session API
Transactional emails with known count unlockAndReadMessages({ expectedMessages: N })

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.

Considerations
  • 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.

Use Case Examples

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.
  2. Ranger or Customer creates the Test User in the Customer environment using that email address.
  3. Ranger tests can assert proper magic-link login behavior:
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')
}
Transactional Emails: Permanent Inbox

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.
  2. Ranger or Customer creates a test user in the Customer environment using that email adddress.
  3. Ranger tests can assert proper transactional email behavior:
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)
}
Sign Up Flow: Temporary Inbox

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:

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')
}
Email Ingestion: Send-Capable Inbox

Customer's application creates a support ticket whenever an email arrives at their ingestion address, attributed to the user whose address sent it.

  1. Ranger creates a send-capable Permanent Inbox using the Debug Tool (or the test creates a temporary one with sendCapable: true).
  2. Ranger or Customer registers a test user with that email address.
  3. Ranger asks the Customer which recipient domains to allow, and configures the org's outbound email domain allowlist.
  4. Ranger tests can trigger and assert the ingestion workflow:
import { InboxRelayClient } from '@ranger-testing/inbox-relay'

const API_TOKEN = process.env.API_TOKEN
const SEND_CAPABLE_INBOX = 'ticket-sender-test@inbox.useranger.com'
const INGESTION_ADDRESS = 'support@customer.app'
const inboxes = new InboxRelayClient({ apiToken: API_TOKEN })

export async function run(page: Page) {
  // >>> Send the email that should trigger the workflow:
  const subject = `Printer on fire ${Date.now()}`
  await inboxes.sendEmail({
    from: SEND_CAPABLE_INBOX,
    to: [INGESTION_ADDRESS],
    subject,
    body: '<p>Please help, the printer is on fire.</p>',
    attachments: [
      {
        name: 'printer.pdf',
        contentType: 'application/pdf',
        contentBase64: PDF_FIXTURE_BASE64
      }
    ]
  })

  // >>> Assert the application ingested it:
  await page.goto('https://customer.app/tickets')
  const ticketRow = page.getByRole('row', { name: subject })
  await expect(ticketRow).toBeVisible()
  // Attributed to the test user matching the sender address
  await expect(ticketRow).toContainText('Ranger Test User')
}

InboxMessage Contents

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 `text/plain` 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
}
PDF Attachment Processing: Temporary Inbox

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:

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: 
SMS OTP Verification: Phone Number

Customer has asked Ranger to test SMS OTP verification for two-factor authentication.

This test case demonstrates using findAndLockPhoneNumber to automatically find and lock an available phone number for receiving SMS messages:

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')
}
SMS OTP with Multiple Messages: Session API

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:

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 arrives
  3. Phone stays locked the whole time - no race conditions
  4. Can verify other messages arrived later with getMessages()
) expect(pdfData.text).toContain(address) // Customer email should be in invoice }
SMS OTP Verification: Phone Number

Customer has asked Ranger to test SMS OTP verification for two-factor authentication.

This test case demonstrates using __INLINE_CODE_43__ to automatically find and lock an available phone number for receiving SMS messages:

__CODE_BLOCK_9__
SMS OTP with Multiple Messages: Session API

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:

__CODE_BLOCK_10__

Why use Session API here?

With the standard __INLINE_CODE_44__ 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. __INLINE_CODE_45__ polls until OTP arrives
  3. Phone stays locked the whole time - no race conditions
  4. Can verify other messages arrived later with __INLINE_CODE_46__