# An email sandbox for AI agents

> Test mode in Pidgeon is the same API with a different key: the whole pipeline runs, nothing leaves, nothing counts, and you can cause a bounce, a refusal or a lost answer on purpose.

## The code that handles failure never runs

An agent that sends email has a happy path and a dozen others: a recipient the provider refuses, a bounce that arrives a minute later, a rate limit, an answer that never comes back. Those are the branches most likely to be wrong, and a working email API never exercises them. Testing against real mail is worse: it sends to real people and spends a real plan.

Pidgeon has a test mode for exactly this. It is the same API with a different key, not a separate sandbox to keep in step.

## A test key runs the whole pipeline

Mint a key with `"test": true`, or pick Test in the dashboard. Its token starts `pgn_test_`, and everything works as it does in production: validation, threading, events and signed webhooks, delivery receipts, `Idempotency-Key` and the Pidgeon Firewall. What changes is where the mail goes:

- **Nothing leaves Pidgeon.** Mail to your own account's addresses is delivered there, marked `test`. Anything else is recorded as sent and goes nowhere.
- **Nothing counts against your plan.** Rate limits still apply, in a bucket of their own.
- **It cannot touch production.** A test key mints only test keys, and cannot change domains, live addresses or app passwords.

The SDKs, the CLI and the MCP server take a test key like any other, so your agent runs unchanged.

## Test addresses that clean up after themselves

A test key can create addresses of its own. They are marked `test`, count against no plan (up to 100 at once), take only test mail, and are **deleted a day after they are created**, so a CI run can make fresh addresses every time without leaving any behind.

## Simulated inbound mail

To test what your agent does when mail arrives, send it some:

```sh
curl https://pidgeon.ai/api/v1/test/inbound \
  -H "Authorization: Bearer $PIDGEON_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "'"$TEST_ADDRESS"'",
    "from": "customer@example.com",
    "subject": "Where is my order?",
    "text": "It was due on Monday."
  }'
```

It goes through the real inbound path: sender rules, the spam filter, the Firewall, threading, `message.received` and your webhooks. Add `in_reply_to` and it joins an existing thread.

## Failures on demand

With a test key you can cause each failure on purpose:

| Do this                                  | And get                                                                                   |
| ---------------------------------------- | ----------------------------------------------------------------------------------------- |
| Send to `rejected@` any domain           | The recipient comes back in the send's `rejected`, with a reason; the others still get it |
| Send to `bounce@` any domain             | Accepted, then a hard bounce and `message.bounced` a few seconds later                    |
| Header `Pidgeon-Test-Fail: 429` or `503` | That error with `Retry-After`, and nothing done                                           |
| Header `Pidgeon-Test-Fail: delay=20s`    | The work done at once, the answer 20 seconds later                                        |
| Header `Pidgeon-Test-Fail: drop`         | The work done, and `504 test_dropped` instead of the answer                               |

`delay` and `drop` do the work before they misbehave, so a retry with the same `Idempotency-Key` gets the original answer back. That is the case your retry logic has to get right, and now you can watch it do so. A `+tag` on the magic addresses (`bounce+run-41@example.com`) keeps parallel runs apart.

## Start

Test mode is on every plan, including Free. The [test mode page](https://pidgeon.ai/test-mode) has the short version and the [guide](https://pidgeon.ai/docs/test-mode.md) every trigger and limit; [sign up](https://pidgeon.ai/signup), mint a test key, and point your test suite at it.
