Skip to main content
By the end of this guide, you’ll have a testing address that received an email, and a Vitest or pytest test that waits for it and reads the one-time code. Inject puts the message in the mailbox through the API, so no mail provider is needed.
  • The CLI (npm install -g devhelm) or curl, plus jq, for the steps; @devhelm/sdk with Vitest (ESM only), or devhelm with pytest, for the test
  • Room for an email domain, or an assigned domain that already exists in the workspace. Domains are counted per organization: 1 on the Free and Developer testing plans
The testing API needs an API key and a workspace ID. Export the key, look up the workspace, then export its ID:
The CLI and Python SDK read both variables. The TypeScript SDK reads DEVHELM_WORKSPACE_ID and takes the key in its constructor. With curl, send the workspace as a header:

Receive a message

1

Get an address

The address looks like first-email-1a2b3c4d@defaultworks-4j4n.devhelmmail.com. The first call in a workspace creates its assigned domain, and later calls reuse it. Read the domain from the helper or devhelm email domains list; do not build the name yourself. There is no mailbox to create: every local part on the domain is its own inbox. The helper adds a random 8-hex suffix, so each call gives a fresh address.
2

Record the start time, then inject

Take SINCE before anything sends: wait returns the oldest message received at or after it. Without it, the CLI and SDKs use the clock at the moment you call wait, and a message that already arrived is missed.Inject stands in for your app’s mail provider and runs the same parsing as real mail, including code and link extraction. It answers 202 with {eventId, receivedAt, inbox}: accepted, not yet stored. Injected messages count toward the monthly event quota.
3

Wait and assert on the code

Wait blocks until a matching message is stored, then returns it. If nothing arrives within timeoutMs (default 30000), the API returns 408 with code WAIT_TIMEOUT. REST returns {"message": {...}} with no data wrapper. CLI email wait --to takes the full address.otp is a list of {value, source}, where source is text or html. A code that appears in both parts is listed once. Use otp[0] when the email has one code, and match on value when it has several. Labelled codes are extracted, as in “verification code is 482913” or “Code: 482913”; years and phone numbers are ignored.The message also carries from, to, subject, headers, bodyPreview, and links ({href, text}). text and html are null on wait and list responses: get the message by ID for the body, or in Python call message.source(), since the Python SDK has no text or html. Wait for an email in a test shows both.
4

Clear the address

Clearing deletes the address’s messages. Leave the assigned domain in place: deleting it and calling the helper again gives the workspace a domain with a new name, which breaks any address you stored.

Write the test

The same flow as one test file, with cleanup. receive stands in for the code under test: replace it with the call that makes your app send its email.
Run it with npx vitest run first-email.test.ts or pytest test_first_email.py. One test passes. The runner timeout (45 s) is longer than the wait timeout (30 s), so a missing email fails with the API’s 408 instead of the runner killing the test. Each run gets a new address, so an earlier message cannot match.

Send real mail from your app

The address also receives real mail. devhelmmail.com has a wildcard MX record, so your app’s mail provider can deliver to the address like any other, and the same wait picks the message up. Point your sign-up form at an address from the helper, take since before you submit, and wait. Test a sign-up email flow covers provider sandboxes and click tracking.

Troubleshooting

  • since was taken after the inject, or not passed. Take it before the trigger.
  • The address differs from the one you sent to. CLI --to takes the full address, not the local part.
  • Your organization hit its monthly event quota. Inject can still answer 202 and then drop the message. See Limits, quotas, and errors.
The domain cap counts the whole organization. On Free or Developer, a domain in another workspace blocks creating one here. Use the workspace that has the domain.
The code has no label near it. Put “code” or “verification code” next to it in your template, or read the body and match it yourself. See Read OTP codes and links.

Next steps

How testing mailboxes work

Domains, stored fields, search, source, and raw downloads.

Read OTP codes and links

Several codes, picking a link, and click-tracking redirects.

Test a sign-up email flow

Playwright signs up, your app sends real mail, the test reads the code.