# Email simulator

ReplyLayer's first-party simulator lets you exercise send outcomes, signed webhook
handling, inbound ingestion, scanning, and quarantine without contacting a real
recipient. Outbound tests use reserved recipient addresses. Inbound tests use a
dedicated API operation that creates a synthetic message in one of your mailboxes.

> **Client availability (2026-07-14).** The outbound addresses and REST operations
> on this page are available on the production API. The `rly simulate inbound`
> command in CLI source `0.7.12` and the `rl.simulator` helpers in TypeScript and
> Python SDK source `0.25.0` are release-ready but are not in the current registry
> releases. Use the REST example below until your installed client includes those
> helpers. Outbound addresses already work through every client's normal send method.

## Outbound scenarios

Send to a simulator address through the normal send, reply, draft-send, scheduled-send,
or approval flow. The synchronous send response is normal: a clean message is accepted
with `status: "sent"`. A few seconds later the selected outcome is applied, like an
asynchronous delivery-provider event.

```bash
rly send --from <mailbox> \
  --to delivered+ci-run-42@simulator.replylayer.net \
  --subject "simulator check" \
  --body "exercise the delivered path" \
  --json
```

| Recipient | Later message state | Webhook event | Event-specific result |
|---|---|---|---|
| `delivered@simulator.replylayer.net` | `delivered` | `message.delivered` | `to` contains the simulator address |
| `bounced@simulator.replylayer.net` | `bounced` | `message.bounced` | `bounce_type` is `simulator` |
| `complained@simulator.replylayer.net` | No later state transition | `recipient_blocklist.added` | `reason` is `complaint` |
| `suppressed@simulator.replylayer.net` | No later state transition | `recipient_blocklist.added` | `reason` is `suppressed` |

Append `+<label>` to any scenario local part for correlation, as in
`bounced+checkout-e2e@simulator.replylayer.net`. The label does not change the
outcome. It remains in the recipient address carried by the stored message and webhook.

The domain match is exact. Only `simulator.replylayer.net` selects this behavior.
`simulator.replylayer.ai`, a subdomain, or a longer lookalike domain is treated as a
normal recipient and may be handed to your configured transport. Do not infer aliases.

### Billing, limits, and reputation

A simulator send consumes paid usage and daily send allowance like a normal send.
Sandbox accounts may send to these addresses without first confirming the recipient,
but the send still consumes the Sandbox daily and cumulative allowances. The four exact
scenario addresses, including their `+label` forms, are exempt from Sandbox
recipient-domain concentration limits because ReplyLayer makes no network delivery.
One Sandbox account can therefore run all four scenarios in the same 24-hour period.
That exemption does not apply to other local parts, subdomains, or lookalike domains.

Authentication, API rate limits, scanning and review policy, idempotency, and normal
usage accounting continue to apply. Paid accounts are charged for simulator sends in
the same way as normal sends.

Synthetic bounces and complaints are excluded from account reputation and abuse
counters. No outbound simulator scenario writes a real do-not-contact row. In
particular, `complained@` and `suppressed@` prove webhook delivery, signature
verification, and event parsing only. A later send to the same simulator address is
not rejected by your account's suppression gate. Use the
[suppressions guide](/docs/guides/suppressions) to test real do-not-contact behavior.

## Test webhook payloads directly

You can test the supported real-shaped webhook payloads without sending a message:

```bash
rly webhook test <webhook-id> --event message.delivered
rly webhook test <webhook-id> --event message.bounced
rly webhook test <webhook-id> --event recipient_blocklist.added
```

The event-specific test still bypasses the webhook's `enabled_events` filter, just
like the default test delivery. Omitting `--event` preserves the original
`webhook.test` envelope. See [Webhooks API: Send a test event](/docs/api/webhooks#send-a-test-event)
for the REST request and payload contract.

## Inject inbound messages

Inbound simulation sends a synthetic message through the same ingestion and scanning
behavior as ordinary inbound mail. The scan result is genuine, not a caller-selected
verdict.

| Scenario | Guaranteed behavior | Expected result |
|---|---|---|
| `clean` | A clean synthetic message is scanned | Normally `available` |
| `prompt_injection_quarantined` | The stored scan contains a real `prompt-injection` finding | `quarantined` when structural non-override protection is active; otherwise the real scanner decision is returned |

Always branch on the returned `status`; do not infer it from the scenario name.
When the prompt-injection scenario returns `quarantined`, the message is releasable
through the normal quarantine API and CLI. The simulator never fabricates a terminal
state to make the scenario name come true.

### CLI

```bash
rly simulate inbound --mailbox <mailbox> --scenario clean --label ci-run-42 --json
rly simulate inbound --mailbox <mailbox> --scenario prompt_injection_quarantined --json
```

### REST API

```bash
curl -X POST https://api.replylayer.ai/v1/simulator/inbound \
  -H "Authorization: Bearer $REPLYLAYER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "mailbox_id": "support-bot",
    "scenario": "clean",
    "label": "ci-run-42"
  }'
```

### TypeScript SDK

```ts
const result = await rl.simulator.injectInbound({
  mailbox_id: mailbox.id,
  scenario: 'clean',
  label: 'ci-run-42',
});
console.log(result.status, result.message_id);
```

### Python SDK

```python
result = rl.simulator.inject_inbound({
    "mailbox_id": mailbox["id"],
    "scenario": "clean",
    "label": "ci-run-42",
})
print(result["status"], result.get("message_id"))
```

## Inbound response and follow-up

| `status` | Meaning |
|---|---|
| `available` | Ingestion and scanning finished; the message is available in the mailbox. |
| `quarantined` | Ingestion and scanning finished; use the returned `message_id` with the normal quarantine workflow. |
| `pending` | The bounded API wait ended before scanning did. `message_id` is omitted; the message continues processing. |

Pass a `label` when a test needs to recover from `pending`. After sanitization, the
label is appended to the synthetic message subject in square brackets, so
`rly inbox list --mailbox <mailbox>` can identify the eventual message.

An admin key may target any mailbox in its account. An agent key must be scoped to
the target mailbox. Repeated injections are rate-limited and can return
`429 RATE_LIMITED`.

Inbound simulation deliberately bypasses per-mailbox sender allowlist and blocklist
evaluation so it always reaches scanning. `firewall_blocked` is not a simulator
outcome. Test sender-policy behavior separately with the
[inbound firewall guide](/docs/guides/inbound-firewall).

## Related pages

- [Messages API](/docs/api/messages) - normal send, read, state, and release operations.
- [Webhooks](/docs/webhooks) - signatures, retries, delivery history, and event catalog.
- [Content scanning](/docs/guides/content-scanning) - scan findings and quarantine semantics.
- [Tiers, quotas, and Sandbox limits](/docs/limits) - send allowance and Sandbox behavior.
