> ## Documentation Index
> Fetch the complete documentation index at: https://docs.devhelm.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Your First Test Email

> Get a DevHelm testing address, inject an email, wait for it, and assert on the OTP code, with no mail provider needed

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.

<Accordion title="Prerequisites">
  * 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:

  ```bash theme={null}
  export DEVHELM_API_TOKEN=dh_live_xxxxxxxx
  curl https://api.devhelm.io/api/v1/workspaces -H "Authorization: Bearer $DEVHELM_API_TOKEN"
  export DEVHELM_WORKSPACE_ID=<workspace-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:

  ```bash theme={null}
  -H "Authorization: Bearer $DEVHELM_API_TOKEN" \
  -H "x-phelm-workspace-id: $DEVHELM_WORKSPACE_ID"
  ```
</Accordion>

## Receive a message

<Steps>
  <Step title="Get an address">
    <CodeGroup>
      ```bash CLI theme={null}
      devhelm email address --label first-email -o json > address.json
      ADDRESS=$(jq -r .email address.json)
      ```

      ```bash API theme={null}
      DOMAIN=$(curl -s https://api.devhelm.io/api/v1/email/domains \
        -H "Authorization: Bearer $DEVHELM_API_TOKEN" \
        -H "x-phelm-workspace-id: $DEVHELM_WORKSPACE_ID" \
        | jq -r '[.data[] | select(.kind == "assigned" and .status == "active")][0].name')
      if [ "$DOMAIN" = "null" ]; then
        DOMAIN=$(curl -s -X POST https://api.devhelm.io/api/v1/email/domains \
          -H "Authorization: Bearer $DEVHELM_API_TOKEN" \
          -H "x-phelm-workspace-id: $DEVHELM_WORKSPACE_ID" \
          -H "Content-Type: application/json" \
          -d '{}' | jq -r .data.name)
      fi
      LOCAL_PART="first-email-$(openssl rand -hex 4)"
      ADDRESS="$LOCAL_PART@$DOMAIN"
      ```
    </CodeGroup>

    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.
  </Step>

  <Step title="Record the start time, then inject">
    <CodeGroup>
      ```bash CLI theme={null}
      SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
      devhelm email receive --to "$ADDRESS" --from noreply@example.com \
        --subject "Your code" --text "Your verification code is 482913."
      ```

      ```bash API theme={null}
      SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
      curl -s -X POST "https://api.devhelm.io/api/v1/email/domains/$DOMAIN/messages/inject" \
        -H "Authorization: Bearer $DEVHELM_API_TOKEN" \
        -H "x-phelm-workspace-id: $DEVHELM_WORKSPACE_ID" \
        -H "Content-Type: application/json" \
        -d "{\"to\": \"$ADDRESS\", \"from\": \"noreply@example.com\", \"subject\": \"Your code\", \"text\": \"Your verification code is 482913.\"}"
      ```
    </CodeGroup>

    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.
  </Step>

  <Step title="Wait and assert on the code">
    <CodeGroup>
      ```bash CLI theme={null}
      devhelm email wait --to "$ADDRESS" --received-after "$SINCE" -o json > message.json
      jq -e '.otp[0].value == "482913"' message.json
      ```

      ```bash API theme={null}
      curl -s -X POST https://api.devhelm.io/api/v1/email/wait \
        -H "Authorization: Bearer $DEVHELM_API_TOKEN" \
        -H "x-phelm-workspace-id: $DEVHELM_WORKSPACE_ID" \
        -H "Content-Type: application/json" \
        -d "{\"to\": \"$ADDRESS\", \"receivedAfter\": \"$SINCE\", \"timeoutMs\": 30000}" \
        | jq .message > message.json
      jq -e '.otp[0].value == "482913"' message.json
      ```
    </CodeGroup>

    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](/testing/email/wait) shows both.
  </Step>

  <Step title="Clear the address">
    <CodeGroup>
      ```bash CLI theme={null}
      devhelm email clear --to "$ADDRESS" -y
      ```

      ```bash API theme={null}
      curl -s -X DELETE "https://api.devhelm.io/api/v1/email/domains/$DOMAIN/inboxes/$LOCAL_PART" \
        -H "Authorization: Bearer $DEVHELM_API_TOKEN" \
        -H "x-phelm-workspace-id: $DEVHELM_WORKSPACE_ID"
      ```
    </CodeGroup>

    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.
  </Step>
</Steps>

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

<CodeGroup>
  ```typescript first-email.test.ts theme={null}
  import { Devhelm, type EmailMessageDto } from "@devhelm/sdk";
  import { expect, test } from "vitest";

  const client = new Devhelm({ token: process.env.DEVHELM_API_TOKEN! });

  test("reads the verification code", async () => {
    const address = await client.email.address({ label: "first-email" });
    try {
      const since = new Date().toISOString();

      await address.receive({
        from: "noreply@example.com",
        subject: "Your code",
        text: "Your verification code is 482913.",
      });

      const message = await address.wait({ receivedAfter: since, timeoutMs: 30_000 });
      // The 1.9.0 types need this cast.
      const mail = message as unknown as EmailMessageDto & { text?: string | null; html?: string | null };

      expect(mail.subject).toBe("Your code");
      expect(mail.otp?.[0]?.value).toBe("482913");
    } finally {
      await address.clear();
    }
  }, 45_000);
  ```

  ```python test_first_email.py theme={null}
  from datetime import datetime, timezone

  from devhelm import Devhelm

  client = Devhelm()


  def test_reads_verification_code():
      address = client.email.address(label="first-email")
      try:
          since = datetime.now(timezone.utc)

          address.receive(
              from_="noreply@example.com",
              subject="Your code",
              text="Your verification code is 482913.",
          )

          message = address.wait(received_after=since, timeout_ms=30_000)

          assert message.subject == "Your code"
          assert message.otp, "no code extracted"
          assert message.otp[0].value == "482913"
      finally:
          address.clear()
  ```
</CodeGroup>

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](/guides/test-signup-email-flow) covers provider sandboxes and click tracking.

## Troubleshooting

<AccordionGroup>
  <Accordion title="The wait returns 408 WAIT_TIMEOUT">
    * `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](/testing/limits).
  </Accordion>

  <Accordion title="403 INBOUND_EMAIL_DOMAIN_LIMIT_REACHED">
    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.
  </Accordion>

  <Accordion title="otp is empty">
    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](/testing/email/otp-and-links).
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={3}>
  <Card title="How testing mailboxes work" icon="envelope" href="/testing/email/overview">
    Domains, stored fields, search, source, and raw downloads.
  </Card>

  <Card title="Read OTP codes and links" icon="key" href="/testing/email/otp-and-links">
    Several codes, picking a link, and click-tracking redirects.
  </Card>

  <Card title="Test a sign-up email flow" icon="user-plus" href="/guides/test-signup-email-flow">
    Playwright signs up, your app sends real mail, the test reads the code.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.