When an email or SMS test fails, you need to know whether the application requested the message, the message arrived, the code was extracted or the account action failed. Printing the whole response can answer some of those questions, but it also copies the OTP, reset link or message body into the build output.

MailSlurp is an email and SMS testing platform with APIs for receiving messages and completing application tests. Its inbox, phone and message IDs give you a way to locate the original delivery without reproducing its contents in every failure report. Keep those references with the failed stage and timing, then inspect the message through your authorized MailSlurp access when needed.

## Choose what the diagnostic record may contain

Build a new record from a small set of permitted fields. The example below allows only:

- a stage: `trigger`, `receive`, `extract`, `verify` or `cleanup`;
- a result: `passed` or `failed`;
- a non-negative integer duration in milliseconds;
- MailSlurp inbox, email, phone or SMS IDs with the expected UUID shape.

It omits unknown fields, malformed IDs and invalid durations. It rejects unknown stages and results with a fixed error message. UUID validation checks format only: resource IDs still belong in access-controlled test reports, and an arbitrary UUID-shaped value is not automatically appropriate to share.

There is no field for an OTP, URL, subject, recipient, body, header, webhook payload or raw error. That also avoids trying to guess which part of an authentication URL holds its token: the record excludes the whole URL.

## Keep failure reporting separate from message contents

Save this module as `message-diagnostics.mjs` in your test project. It keeps the received message and extracted value inside the test flow while writing a separate JSON line for each stage.

```javascript
const stages = new Set(['trigger', 'receive', 'extract', 'verify', 'cleanup']);
const results = new Set(['passed', 'failed']);
const idFields = ['inboxId', 'emailId', 'phoneNumberId', 'smsId'];
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

export function diagnosticLine(input) {
  if (!stages.has(input.stage) || !results.has(input.result)) {
    throw new Error('Invalid diagnostic category');
  }
  const record = { stage: input.stage, result: input.result };
  if (Number.isSafeInteger(input.elapsedMs) && input.elapsedMs >= 0) {
    record.elapsedMs = input.elapsedMs;
  }
  for (const field of idFields) {
    if (typeof input[field] === 'string' && uuid.test(input[field])) {
      record[field] = input[field];
    }
  }
  return JSON.stringify(record) + '\n';
}

export async function runMessageCheck(actions, write, ids = {}) {
  let failed = false;
  let message;
  let value;
  const metadata = {};
  for (const field of idFields) metadata[field] = ids[field];
  async function step(stage, action) {
    const start = performance.now();
    let result = 'passed';
    try {
      await action();
    } catch {
      failed = true;
      result = 'failed';
    }
    try {
      write(diagnosticLine({
        ...metadata, stage, result,
        elapsedMs: Math.max(0, Math.round(performance.now() - start)),
      }));
    } catch {
      failed = true;
      throw new Error('Could not write message diagnostics.');
    }
  }
  try {
    await step('trigger', actions.trigger);
    if (!failed) await step('receive', async () => {
      message = await actions.receive();
      metadata.emailId = message.emailId;
      metadata.smsId = message.smsId;
    });
    if (!failed) await step('extract', async () => {
      value = await actions.extract(message);
    });
    if (!failed) await step('verify', async () => {
      await actions.verify(value);
    });
  } finally {
    await step('cleanup', actions.cleanup);
  }
  if (failed) throw new Error('Message check failed; inspect diagnostic records.');
}
```

An action failure stops the later application stages, but cleanup is still attempted. A cleanup failure also fails the run. The final error has a fixed message and no original error attached as its cause, so the wrapper does not hand a raw API response or token-bearing exception to the caller.

The `write` callback must accept only the serialized line. Keep it synchronous and never log action objects or other captured data from it. If writing throws, the wrapper fails with the fixed message `Could not write message diagnostics.` and still attempts cleanup. This module controls its own records and final error; it does not intercept output produced inside your actions, SDK debugging, browser tooling or the test reporter.



### Where did that message test go wrong?

Use MailSlurp inboxes and phone numbers to receive your test messages. Keep their IDs with your application results so you have somewhere to start when a test fails.

[Try email and SMS testing](https://app.mailslurp.com/sign-up/?onboardingRequest=testing)



## Try a failure without sending a message

Save the following as `demo.mjs` beside the module and run it with Node.js 22 or later. It uses an invented SMS and local functions; it does not call MailSlurp, send a text or verify a real account. The optional argument chooses the stage that fails.

```javascript
import { runMessageCheck } from './message-diagnostics.mjs';

const fakeOtp = '073491';
const phoneNumberId = '11111111-1111-4111-8111-111111111111';
function failAt(stage) {
  if (process.argv[2] === stage) {
    throw new Error(`Demo failure containing code ${fakeOtp}`);
  }
}

try {
  await runMessageCheck({
    trigger: async () => { failAt('trigger'); },
    receive: async () => {
      failAt('receive');
      return {
        smsId: '22222222-2222-4222-8222-222222222222',
        body: `Your code: ${fakeOtp}`,
      };
    },
    extract: async message => {
      failAt('extract');
      const match = /^Your code: ([0-9]{6})$/.exec(message.body);
      if (!match) throw new Error('Unexpected demo message');
      return match[1];
    },
    verify: async code => {
      failAt('verify');
      if (code !== fakeOtp) throw new Error('Unexpected demo code');
    },
    cleanup: async () => { failAt('cleanup'); },
  }, line => process.stdout.write(line), { phoneNumberId });
} catch {
  process.stderr.write('Message check failed; inspect diagnostic records.\n');
  process.exitCode = 1;
}
```

Run a passing check, then a failed verification:

```bash
node demo.mjs
node demo.mjs verify > diagnostics.jsonl 2> diagnostics.stderr.txt
```

The second command intentionally exits with status 1. Its JSON lines should show `trigger`, `receive` and `extract` passing, `verify` failing, and `cleanup` passing. The saved stderr file contains the fixed failure message. Neither output should contain the demo code, even though the thrown verification error included it.

Repeat with `trigger`, `receive`, `extract` and `cleanup` to check the other failure paths. Cleanup failures should remain visible even when every earlier stage passed. These local checks cover the wrapper's reporting and flow control; you still need separate tests for live delivery, account verification and your browser reporter's output.

## Connect the stages to your MailSlurp test

The functions passed to `runMessageCheck` are application adapters that you implement. They must await the work they represent and reject when it fails. Use them as follows:

- **Trigger:** capture `since` immediately before submitting the signup, reset or SMS request. Assert that the application accepted the intended action.
- **Receive:** wait in the test's isolated inbox or exclusively assigned phone with that timestamp and a bounded timeout. Return an object with the diagnostic ID explicitly mapped: for email, `{ emailId: email.id, body: email.body }`; for SMS, `{ smsId: sms.id, body: sms.body }`. The SDK's `id` field is not renamed automatically.
- **Extract:** select the intended code or action link from that message. Reject missing or ambiguous candidates. Keep a code as a string so leading zeroes survive.
- **Verify:** submit the value through the application and assert the intended account or action. Returning after a click or receiving an HTTP response is insufficient.
- **Cleanup:** delete test-owned inboxes, release phone-pool leases and remove application test data as appropriate. Releasing a lease is different from deleting the provisioned phone number.

Create or reserve the messaging resources before calling the wrapper, and pass the `inboxId` or `phoneNumberId` in its third argument. Handle setup failures in your surrounding fixture; the wrapper cannot clean up resources it does not know about. Each adapter needs its own deadline so a hung action cannot prevent the run from reaching teardown.

Use the [Playwright email testing guide](/blog/playwright-email-testing/) for message matching and browser continuation, or the [SMS API guide](/mobile/sms-api/) for the assigned-phone workflow. The [resource lifecycle guide](/docs/test-resource-lifecycle/) covers inbox expiry and lease cleanup. When a diagnostic record points to `receive`, follow [wait troubleshooting](/docs/wait-troubleshooting/) to distinguish absent delivery from the wrong filters.

## Check browser artifacts and runner output separately

A clean diagnostic JSON file does not establish that the rest of the build is free of authentication data. Playwright traces can include DOM snapshots, errors, console output and network request/response details, including bodies. Inspect traces and screenshots for the account data or code visible during the flow, and restrict their access and retention. See the [Playwright Trace Viewer documentation](https://playwright.dev/docs/trace-viewer).

GitHub Actions also cautions that automatic secret redaction is not guaranteed and recommends reviewing output after valid and invalid inputs. Check both stdout and stderr from the actual commands and reporter you run. A test-created OTP or reset link needs its own handling; storing the API key as a CI secret does not review the other artifacts for you. See [GitHub's secure-use reference](https://docs.github.com/en/actions/reference/security/secure-use).

Before adopting the wrapper, run controlled failures through your real test runner and inspect its console output, saved diagnostics and attachments. Use synthetic markers in a code, message body, URL and error to check where those values appear. Keep this local output check separate from the live email or SMS test that proves your application's delivery and verification flow.
