# Email Testing API

> AI-native email testing, built for agents — the AI-first alternative to Mailosaur

## Base URL

- **Current Environment**: https://send.fixture.email

## Authentication

All `/api/*` endpoints require authentication via the `X-API-Key` header:

```
X-API-Key: {your-api-key}
```

Keys are org-scoped and **do not expire**: once minted (via the `create_api_key` MCP tool or `POST /api/keys`), a key stays valid until it is revoked (the `revoke_api_key` MCP tool or `DELETE /api/keys/{keyId}`). The plaintext key is shown only once at creation and is never recoverable.

The following endpoints do NOT require authentication:
- `GET /health` - Health check
- `GET /llms.txt` - This documentation

---

## Quick Reference - All Endpoints

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | /health | Health check (no auth) |
| GET | /llms.txt | This documentation (no auth) |
| POST | /api/mailboxes | Create mailbox |
| GET | /api/mailboxes/{mailbox} | Get mailbox state (enabled, cleanup, domain, label) |
| DELETE | /api/mailboxes/{mailbox} | Disable mailbox (does not schedule cleanup) |
| POST | /api/mailboxes/{mailbox}/safe-cleanup | Schedule cleanup without disabling |
| POST | /api/mailboxes/{mailbox}/enable | Enable mailbox |
| POST | /api/mailboxes/{mailbox}/disable | Disable mailbox |
| POST | /api/mailboxes/{mailbox}/cancel-cleanup | Cancel scheduled cleanup |
| POST | /api/send | Send email (JSON payload) |
| POST | /api/send/upload | Send email (raw .eml upload) |
| POST | /api/attachments/upload | Upload large attachment |
| GET | /api/status/{uuid} | Check email status |
| GET | /api/messages | List messages with filters |
| GET | /api/messages/{emailId}/content | Get parsed email body |
| GET | /api/messages/{emailId}/raw | Get presigned URL for .eml download |
| GET | /api/usage | Get outbound send usage vs per-org quota |
| POST | /api/keys | Create an org-scoped API key |
| GET | /api/keys | List API keys (never the secret) |
| DELETE | /api/keys/{keyId} | Revoke an API key |
| POST | /api/callbacks | Register a signed webhook callback |
| GET | /api/callbacks | List callbacks (never the signing secret) |
| DELETE | /api/callbacks/{id} | Delete a callback |

---

## Core Concepts

### Email Address Format

All emails use **subaddress-based mailbox routing** (RFC 5233):

```
{anything}+{mailbox}@fixture.email
```

- **mailbox**: 16-character lowercase alphanumeric ID (e.g., `x7k2m9pqr3s4t5u6`)
- **{anything}**: Can be any valid local part - only the mailbox ID after `+` matters

Examples:
- `test+x7k2m9pqr3s4t5u6@fixture.email`
- `signup+x7k2m9pqr3s4t5u6@fixture.email`
- `newsletter+x7k2m9pqr3s4t5u6@fixture.email`

All three addresses route to the same mailbox (`x7k2m9pqr3s4t5u6`).

### Mailboxes

Mailboxes are isolated containers for email testing:
- Create via `POST /api/mailboxes` (returns random 16-char ID)
- Each mailbox has its own storage namespace
- Only registered mailboxes can receive emails
- Delete via `DELETE /api/mailboxes/{mailbox}` to clean up

**Mailbox Deletion (Soft Delete with Grace Period):**
- Mailboxes are **disabled** (enabled = 0) when deleted via API
- Workflow timer (CLEANUP_SLEEP_DURATION) handles grace period - not database
- During grace period: In-flight emails are rejected (mailbox is disabled)
- After grace period: Mailbox and all data are permanently deleted
- This prevents race conditions when tests delete mailboxes while emails are processing

### Email Statuses

- `queued` - Email is queued for sending (initial state after POST /api/send)
- `sent` - Email has been sent successfully to external recipient
- `received` - Email has been received:
  - Inbound emails from external senders
  - **Internal routing**: Emails sent to `{anything}+{mailbox}@fixture.email` are routed internally and marked `received` in the recipient's mailbox
- `failed` - Email processing failed:
  - Sending to non-existent mailbox on test domain
  - Workflow processing errors

---

## Receiving Emails (Inbound)

To receive emails for testing (e.g., verification emails, notifications):

1. **Create a mailbox**: `POST /api/mailboxes` returns `{ "mailbox": "x7k2m9pqr3s4t5u6" }`
2. **Use the email address**: Send emails to `anything+x7k2m9pqr3s4t5u6@fixture.email`
3. **Poll for received emails**: `GET /api/messages?mailbox=x7k2m9pqr3s4t5u6&status=received`

Emails sent to addresses with unregistered mailbox IDs are silently dropped.

### Receive Email Example

```bash
# 1. Create a mailbox
MAILBOX=$(curl -s -X POST https://send.fixture.email/api/mailboxes \
  -H "X-API-Key: $API_KEY" | jq -r '.mailbox')

# 2. Use this address in your application: test+$MAILBOX@fixture.email

# 3. Poll for received emails
curl -s "https://send.fixture.email/api/messages?mailbox=$MAILBOX&status=received" \
  -H "X-API-Key: $API_KEY"
```

---

## Sending Emails (Outbound)

### Method 1: API Creation (POST /api/send)

Build email from JSON payload:

```bash
curl -X POST https://send.fixture.email/api/send \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "recipient@example.com",
    "subject": "Test Email",
    "text": "Plain text body",
    "html": "<p>HTML body</p>"
  }'
```

**Smart `from` Address Handling:**

| You Provide | API Behavior | Result |
|-------------|--------------|--------|
| Nothing | Generate random local + create new mailbox | `r4nd0m+x7k2m9pqr3s4t5u6@fixture.email` |
| Just local (e.g., `newsletter`) | Use local + create new mailbox | `newsletter+x7k2m9pqr3s4t5u6@fixture.email` |
| Full address with valid mailbox | Validate mailbox exists, use as-is | `newsletter+x7k2m9pqr3s4t5u6@fixture.email` |
| Full address with unknown mailbox | Return 400 error | - |

### Method 2: Raw .eml Upload (POST /api/send/upload)

Upload a raw RFC 822 .eml file. Supports replaying external emails (e.g., saved from Gmail):

```bash
# Upload your own .eml file
curl -X POST https://send.fixture.email/api/send/upload \
  -H "X-API-Key: $API_KEY" \
  -F "file=@email.eml"

# Replay external .eml with overridden From/To
curl -X POST https://send.fixture.email/api/send/upload \
  -H "X-API-Key: $API_KEY" \
  -F "file=@saved-gmail.eml" \
  -F "from=sender@fixture.email" \
  -F "to=recipient@example.com"
```

**How External .eml Replay Works:**
- The original `.eml` file is stored **unmodified** (preserves source for audit/reuse)
- From/To overrides are stored as metadata
- At send time, the workflow extracts body, subject, attachments from the original `.eml`
- Rebuilds MIME message using the override From/To addresses
- External From addresses (e.g., `someone@gmail.com`) are transformed to `{local}+{mailbox}@fixture.email`
- Multiple recipients supported: `to=alice@example.com, bob@example.com`

### Large Attachments

For attachments >= 1MB, upload first then reference by ID:

```bash
# 1. Upload attachment
ATTACHMENT=$(curl -s -X POST https://send.fixture.email/api/attachments/upload \
  -H "X-API-Key: $API_KEY" \
  -F "file=@large-file.pdf" \
  -F "mailbox=$MAILBOX" | jq -r '.attachmentId')

# 2. Send email with attachment reference
curl -X POST https://send.fixture.email/api/send \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "sender+'$MAILBOX'@fixture.email",
    "to": "recipient@example.com",
    "subject": "Email with large attachment",
    "text": "See attached file",
    "attachments": [{
      "name": "large-file.pdf",
      "attachmentId": "'$ATTACHMENT'",
      "contentType": "application/pdf"
    }]
  }'
```

---

## Internal Routing (Test-to-Test)

When you send an email **to** an address on the test domain (`{anything}+{mailbox}@fixture.email`), the email is routed internally:

1. Email is NOT sent externally
2. Status becomes `received` (not `sent`)
3. Email appears in the **recipient's** mailbox (not sender's)

This enables end-to-end email testing without external delivery.

### Internal Routing Example

```bash
# Create RECIPIENT mailbox (this is where email will be delivered)
RECIPIENT_MAILBOX=$(curl -s -X POST https://send.fixture.email/api/mailboxes \
  -H "X-API-Key: $API_KEY" | jq -r '.mailbox')

# Send to the recipient's test domain address
EMAIL_ID=$(curl -s -X POST https://send.fixture.email/api/send \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "test+'$RECIPIENT_MAILBOX'@fixture.email",
    "subject": "Internal Test",
    "text": "This is routed internally"
  }' | jq -r '.emailId')

# Poll for RECEIVED status (not sent!)
while true; do
  STATUS=$(curl -s "https://send.fixture.email/api/status/$EMAIL_ID" \
    -H "X-API-Key: $API_KEY" | jq -r '.status')
  if [ "$STATUS" = "received" ]; then
    echo "Email received in recipient mailbox!"
    break
  fi
  if [ "$STATUS" = "failed" ]; then
    echo "Email failed - check if recipient mailbox exists"
    break
  fi
  sleep 3
done

# Email is now in RECIPIENT's mailbox
curl -s "https://send.fixture.email/api/messages?mailbox=$RECIPIENT_MAILBOX&status=received" \
  -H "X-API-Key: $API_KEY" | jq '.messages[0]'
```

**Important**: If the recipient mailbox doesn't exist, the email status becomes `failed`.

---

## API Reference

### Health Check

```
GET /health
```

No authentication required.

**Response:**
```json
{
  "status": "ok",
  "service": "email-test-send",
  "environment": "production"
}
```

---

### Create Mailbox

```
POST /api/mailboxes
```

Creates a new mailbox with a random 16-character ID. No request body needed.

**Response:**
```json
{
  "mailbox": "x7k2m9pqr3s4t5u6"
}
```

---

### Get Mailbox

```
GET /api/mailboxes/{mailbox}
```

Get a mailbox's current state.

**Response:**
```json
{
  "mailbox": "x7k2m9pqr3s4t5u6",
  "domain": "fixture.email",
  "label": null,
  "enabled": true,
  "scheduledForCleanup": false,
  "createdAt": 1730961190634
}
```

- `label`: optional mailbox label (null if unset)
- `scheduledForCleanup`: `true` once `POST /api/mailboxes/{mailbox}/safe-cleanup` has been called
- `createdAt`: Unix timestamp in **milliseconds**

**Status Codes:**
- `200` - Mailbox found
- `404` - Mailbox not found
- `401` - Missing or invalid API key

---

### Delete Mailbox

```
DELETE /api/mailboxes/{mailbox}
```

Disables the mailbox. This does NOT schedule cleanup - cleanup must be scheduled separately via `/safe-cleanup` endpoint.

**Behavior:**
1. Disables the mailbox (disabled mailboxes reject new emails immediately)
2. Takes effect immediately
3. Does NOT trigger cleanup
4. Does NOT schedule the mailbox for cleanup

**Response:**
```json
{
  "success": true
}
```

**Status Codes:**
- `200` - Mailbox disabled
- `404` - Mailbox not found

**Note**: If mailbox is already disabled, returns success (idempotent). To schedule cleanup, use `POST /api/mailboxes/{mailbox}/safe-cleanup`.

---

### Schedule Mailbox Cleanup

```
POST /api/mailboxes/{mailbox}/safe-cleanup
```

Schedules mailbox cleanup workflow without disabling the mailbox. Allows in-flight emails to complete before deletion.

**Request Body**: Required when using `Content-Type: application/json`. Send at least `{}` (empty object). Sending no body with that header returns `400 Malformed JSON`.

```json
{}
```

Or with optional duration:
```json
{
  "duration": "30 minutes"
}
```

- `duration` (optional): Duration override (e.g., "30 minutes", "1 hour", "2 days")
- If not provided, uses `CLEANUP_SLEEP_DURATION` from environment
- Duration format: `"{number} {unit}"` (e.g., "30 minutes", "1 hour")
- Supported units: second, minute, hour, day, week

**Behavior:**
1. Schedules the mailbox for cleanup after the grace period
2. Mailbox remains enabled during the grace period (allows in-flight emails)
3. After the grace period, all stored emails and metadata for the mailbox are permanently deleted
4. Can be cancelled via `POST /api/mailboxes/{mailbox}/cancel-cleanup` any time before the grace period ends

**Response:**
```json
{
  "success": true,
  "duration": "30 minutes"
}
```

**Status Codes:**
- `200` - Cleanup scheduled
- `400` - Malformed JSON (e.g. `Content-Type: application/json` with no body—send at least `{}`), or invalid duration format
- `404` - Mailbox not found

---

### Enable Mailbox

```
POST /api/mailboxes/{mailbox}/enable
```

Immediately enables mailbox (`enabled = 1`). This is an independent operation that does NOT affect cleanup scheduling.

**Response:**
```json
{
  "success": true,
  "warning": "Mailbox enabled, but this mailbox is scheduled for removal"
}
```

The `warning` field is only present if the mailbox is scheduled for cleanup (`scheduledForCleanup = 1`).

**Status Codes:**
- `200` - Mailbox enabled
- `404` - Mailbox not found (cleanup completed, mailbox removed)

---

### Disable Mailbox

```
POST /api/mailboxes/{mailbox}/disable
```

Immediately disables mailbox (`enabled = 0`). This is an independent operation that does NOT schedule cleanup.

**Response:**
```json
{
  "success": true
}
```

**Status Codes:**
- `200` - Mailbox disabled
- `404` - Mailbox not found (cleanup completed, mailbox removed)

---

### Cancel Scheduled Cleanup

```
POST /api/mailboxes/{mailbox}/cancel-cleanup
```

Cancels scheduled mailbox cleanup by setting `scheduledForCleanup = 0`. Only works before cleanup workflow reaches deletion steps.

**Response:**
```json
{
  "success": true
}
```

**Behavior:**
- Cancels the scheduled cleanup for the mailbox
- If cancelled before the grace period ends, no emails or metadata are deleted
- Once the grace period has elapsed, cancellation may no longer take effect

**Status Codes:**
- `200` - Cleanup cancelled
- `404` - Mailbox not found (cleanup completed, mailbox removed)

---

### Send Email (API Creation)

```
POST /api/send
```

**Request Body:**
```json
{
  "from": "sender+mailbox@fixture.email",
  "to": "recipient@example.com",
  "subject": "Test Email",
  "text": "Plain text body",
  "html": "<p>HTML body</p>",
  "cc": "cc@example.com",
  "bcc": ["bcc1@example.com", "bcc2@example.com"],
  "attachments": [
    {
      "name": "small-file.txt",
      "content": "base64-encoded-content",
      "contentType": "text/plain"
    },
    {
      "name": "large-file.pdf",
      "attachmentId": "uuid-from-upload",
      "contentType": "application/pdf"
    }
  ],
  "headers": {
    "X-E2E-Config": "value"
  }
}
```

**Fields:**
- `from` (optional): See smart from handling above
- `to` (required): Recipient email address
- `subject` (required): Email subject
- `text` (optional): Plain text body
- `html` (optional): HTML body
- `cc` (optional): CC recipient(s); single email or array of emails
- `bcc` (optional): BCC recipient(s); single email or array of emails
- `attachments` (optional): Array of attachments
  - `name`: Filename
  - `contentType`: MIME type
  - `content`: Base64-encoded content (for files < 1MB)
  - `attachmentId`: ID from upload endpoint (for files >= 1MB)
- `headers` (optional): `Record<string, string>` of custom MIME headers (e.g. `X-E2E-Config`). Persisted in the .eml and returned by `GET /api/messages/{emailId}/content`.

**Response:**
```json
{
  "emailId": "550e8400-e29b-41d4-a716-446655440000",
  "mailbox": "x7k2m9pqr3s4t5u6",
  "from": "sender+x7k2m9pqr3s4t5u6@fixture.email"
}
```

**Status Codes:**
- `200` - Email queued successfully
- `400` - Invalid payload or unknown mailbox
- `403` - A recipient is on your suppression list, or your account is suspended for excessive bounces/complaints
- `413` - Base64 attachment exceeds 1MB limit

**Bounces, complaints & suppression:**
A real external bounce or spam complaint adds that recipient to your org's suppression list and emits a `email.bounced` / `email.complained` callback. A later send to a suppressed recipient is rejected with `403` (and emits `email.suppressed`). Sustained high bounce/complaint rates auto-suspend the org — all sends then return `403` until support restores it.

**Simulated outcome addresses (testing):**
Send to a reserved address on `fixture.email` to exercise outcome handling WITHOUT a real send (no deliverability or reputation impact). The message is never delivered — a terminal status is synthesized and the matching callback event fires:
- `delivered@fixture.email` → status `sent`, event `email.sent`
- `bounced@fixture.email` → status `failed`, event `email.bounced`
- `complained@fixture.email` → status `sent`, event `email.complained`
- `suppressed@fixture.email` → status `failed`, event `email.suppressed`
Sub-addressing labels work (`delivered+signup@fixture.email`); an address that carries a valid mailbox subaddress (`delivered+<mailbox>@fixture.email`) routes to that mailbox as normal instead. Simulated sinks can't be mixed with real recipients in one send, and never count against your send quota.

---

### Send Email (Raw Upload)

```
POST /api/send/upload
```

Upload raw .eml file (RFC 822 format). Supports replaying external emails with From/To overrides.

**Request:** `multipart/form-data`
- `file`: Raw .eml file (required)
- `from`: Override From address (optional, uses .eml header if not provided)
- `to`: Override To address(es) - comma-separated for multiple recipients (optional, uses .eml header if not provided)

**From Address Handling:**
- If `from` has valid mailbox subaddress → validates mailbox exists
- If `from` is external (e.g., `someone@gmail.com`) → auto-creates mailbox, sends from `{local}+{mailbox}@fixture.email`
- If `from` not provided → parses from .eml header with same logic

**Response:**
```json
{
  "emailId": "550e8400-e29b-41d4-a716-446655440000",
  "mailbox": "x7k2m9pqr3s4t5u6",
  "from": "sender+x7k2m9pqr3s4t5u6@fixture.email"
}
```

**Status Codes:**
- `200` - Email queued
- `400` - Invalid .eml format or missing To header
- `413` - File exceeds 25MB limit

---

### Upload Attachment

```
POST /api/attachments/upload
```

Upload large attachment before sending email.

**Request:** `multipart/form-data`
- `file`: File to upload (max 25MB)
- `mailbox`: Mailbox ID (required)

**Response:**
```json
{
  "attachmentId": "550e8400-e29b-41d4-a716-446655440000",
  "filename": "document.pdf",
  "size": 1048576,
  "contentType": "application/pdf"
}
```

---

### Check Email Status

```
GET /api/status/{uuid}
```

Get status and metadata for a specific email.

**Response:**
```json
{
  "emailId": "550e8400-e29b-41d4-a716-446655440000",
  "status": "sent",
  "mailbox": "x7k2m9pqr3s4t5u6",
  "from": "sender@example.com",
  "to": ["recipient@example.com"],
  "subject": "Test Email",
  "time": 1730961190634,
  "messageId": "msg-abc123",
  "subaddress": "test123",
  "spfResult": "pass",
  "dkimResult": "pass",
  "dmarcResult": "pass"
}
```

- `time`: Unix timestamp in **milliseconds** (e.g. `Date.now()` in JavaScript)
- `spfResult` / `dkimResult` / `dmarcResult` (optional): verdicts parsed from the inbound `Authentication-Results` header. Omitted for outbound/uploaded mail.

**Status Codes:**
- `200` - Email found
- `404` - Email not found

**Polling Recommendations:**
- Poll every 2-3 seconds
- Allow 20-30 retries for external sends (~60-90 seconds total)
- For internal routing, status changes faster (10-20 retries usually sufficient)
- Check for `failed` status to handle errors early

---

### Get Email Content

```
GET /api/messages/{emailId}/content
```

Retrieve the parsed body content of an email, including extracted links.

**Response:**
```json
{
  "html": "<html>Full HTML content...</html>",
  "text": "Plain text content...",
  "subject": "Email Subject",
  "from": "sender@example.com",
  "to": ["recipient@example.com"],
  "links": ["https://example.com/verify?token=abc", "https://example.com/unsubscribe"],
  "headers": {
    "Message-ID": "<abc123@example.com>",
    "X-E2E-Config": "..."
  },
  "spfResult": "pass",
  "dkimResult": "pass",
  "dmarcResult": "pass"
}
```

- `html`: HTML body (null if email has no HTML part). Server-sanitized: `<script>` tags, `on*` event handlers, and `javascript:`/`data:`/`vbscript:` URL schemes are stripped. Consumers rendering this into a DOM should still re-sanitize with a full library.
- `text`: Plain text body (null if email has no text part)
- `links`: All href values extracted from the **sanitized** HTML (entities like `&amp;` automatically decoded; dangerous URL schemes already filtered)
- `headers`: `Record<string, string>` of MIME headers from the stored .eml (e.g. `Message-ID`, custom `X-E2E-Config` from POST /api/send)
- `spfResult` / `dkimResult` / `dmarcResult`: nullable strings; null for outbound/uploaded mail.

**Status Codes:**
- `200` - Content retrieved
- `404` - Email not found

---

### Get Raw Email (.eml)

```
GET /api/messages/{emailId}/raw
```

Get a presigned URL to download the raw .eml file.

**Response:**
```json
{
  "url": "https://<download-host>/...",
  "expiresIn": 900
}
```

The URL can be used to download the complete RFC 822 .eml file directly. No authentication required for the download URL, but it expires after 15 minutes (bounded leak window).

**Status Codes:**
- `200` - URL generated
- `404` - Email not found

---

### List Messages

```
GET /api/messages
```

List messages with optional filters.

**Query Parameters:**
- `mailbox` (required): Mailbox ID
- `status` (optional): `received` | `queued` | `sent` | `failed`
- `to` (optional): Filter by recipient email address
- `from` (optional): Filter by sender email address
- `subaddress` (optional): Filter by subaddress
- `subject` (optional): Filter by subject: literal substring match (subject contains this exact text; no wildcards). Max length: 500 characters after trim; empty / whitespace-only is rejected (400).
- `receivedAfter` (optional): ISO 8601 timestamp; supports optional subsecond precision (e.g. `2026-03-08T06:33:10.634Z`) for millisecond-precision filtering

**URL Encoding for Special Characters:**

Query parameters with special characters must be URL-encoded. The server automatically decodes them.

**Best Practices:**
- **JavaScript/TypeScript**: Use `URLSearchParams` (automatically encodes) or `encodeURIComponent()`
- **Python**: Use `urllib.parse.urlencode()` or `urllib.parse.quote()`
- **cURL**: Use `--data-urlencode` with `-G` flag or manually encode with `%` encoding
- **Bash**: Use `printf '%s' "value" | jq -sRr @uri` or manual encoding

**Special Character Encoding Examples:**

| Character | Encoded | Example Subject | Encoded URL |
|-----------|---------|-----------------|-------------|
| `%` | `%25` | `100% Off` | `subject=100%25%20Off` |
| `_` | `_` (no encoding) | `Test_Email` | `subject=Test_Email` |
| ` ` (space) | `%20` or `+` | `Test Email` | `subject=Test%20Email` |
| `[` | `%5B` | `[Important]` | `subject=%5BImportant%5D` |
| `]` | `%5D` | `[Important]` | `subject=%5BImportant%5D` |
| `?` | `%3F` | `Is This A Scam?` | `subject=Is%20This%20A%20Scam%3F` |

**Example Requests with Special Characters:**

```bash
# Using URLSearchParams (JavaScript/TypeScript) - RECOMMENDED
const params = new URLSearchParams({
  mailbox: 'x7k2m9pqr3s4t5u6',
  subject: 'Is This A Scam? - Subscription Cancellation Scheduled [100% Off]',
  to: 'test+x7k2m9pqr3s4t5u6@fixture.email'
});
fetch(`/api/messages?${params.toString()}`);

# Using cURL with manual encoding
curl -X GET "https://send.fixture.email/api/messages?mailbox=x7k2m9pqr3s4t5u6&subject=Is%20This%20A%20Scam%3F%20-%20Subscription%20Cancellation%20Scheduled%20%5B100%25%20Off%5D" \
  -H "X-API-Key: $API_KEY"

# Using cURL with --data-urlencode (recommended)
curl -G "https://send.fixture.email/api/messages" \
  --data-urlencode "mailbox=x7k2m9pqr3s4t5u6" \
  --data-urlencode "subject=Is This A Scam? - Subscription Cancellation Scheduled [100% Off]" \
  --data-urlencode "to=test+x7k2m9pqr3s4t5u6@fixture.email" \
  -H "X-API-Key: $API_KEY"

# Python example
from urllib.parse import urlencode
params = {
  'mailbox': 'x7k2m9pqr3s4t5u6',
  'subject': 'Is This A Scam? - Subscription Cancellation Scheduled [100% Off]',
  'to': 'test+x7k2m9pqr3s4t5u6@fixture.email'
}
url = f"/api/messages?{urlencode(params)}"
```

**Note**: The subject filter uses literal substring matching: messages are returned when the subject contains the given text exactly. No wildcards or pattern matching. URL-encode special characters in query parameters.

**Response:**
```json
{
  "messages": [
    {
      "emailId": "550e8400-e29b-41d4-a716-446655440000",
      "status": "received",
      "mailbox": "x7k2m9pqr3s4t5u6",
      "from": "sender@example.com",
      "to": ["recipient@example.com"],
      "subject": "Test Email",
      "time": 1730961190634,
      "subaddress": "test123"
    }
  ]
}
```

- `time`: Unix timestamp in **milliseconds** (use with millisecond-precision `receivedAfter` so filtering does not exclude valid messages)

**Status Codes:**
- `200` - Messages retrieved
- `400` - Invalid query parameters (e.g., malformed URL encoding)
- `401` - Missing or invalid API key
- `500` - Internal server error

---

## Usage & Quota

### Get Usage

```
GET /api/usage
```

Return this organization's outbound send usage against its quota. Only **external** sends count; internal test-domain routing is never counted. The response is discriminated by `plan`: a **paid** org reports `day` + `month` buckets; a **trial** org reports `day` + a whole-trial `total` bucket. A send that would exceed the applicable cap is rejected with `429` before it is queued.

- **Paid:** 500 external sends/day, 15000/month.
- **Trial:** 50/day, 250 total across the whole trial. A trial-cap `429` includes a `checkoutUrl` to upgrade.

```bash
curl -X GET "https://send.fixture.email/api/usage" \
  -H "X-API-Key: $API_KEY"
```

**Response (paid):**
```json
{
  "plan":  "paid",
  "day":   { "period": "2026-06-17", "sent": 3,  "limit": 500,   "remaining": 497 },
  "month": { "period": "2026-06",    "sent": 12, "limit": 15000, "remaining": 14988 },
  "reputation": { "month": { "bounces": 0, "complaints": 0 } }
}
```

**Response (trial):**
```json
{
  "plan":  "trial",
  "day":   { "period": "2026-06-17", "sent": 2,  "limit": 50, "remaining": 48 },
  "total": { "period": "all",        "sent": 40, "limit": 250, "remaining": 210 },
  "reputation": { "month": { "bounces": 0, "complaints": 0 } }
}
```

- `plan`: `paid` or `trial` — selects which cap set applies.
- `day`: external emails `sent` today, the `limit`, and `remaining` headroom (`max(0, limit - sent)`).
- `month` (paid) / `total` (trial): the longer-window bucket — calendar month for paid, the whole trial (`period: "all"`) for a trial org.
- `reputation.month`: hard bounces and spam complaints recorded this month (an org that exceeds the published bounce/complaint thresholds is auto-suspended).

**Status Codes:**
- `200` - Usage returned
- `401` - Missing or invalid API key
- `500` - Internal server error

---

## API Keys

Org-scoped API keys for direct HTTP access — mint extra keys (e.g. one per CI pipeline), list them, and revoke them. Keys **do not expire**; they stay valid until revoked. The same operations are available as the `create_api_key` / `list_api_keys` / `revoke_api_key` MCP tools.

### Create API Key

```
POST /api/keys
```

**Request Body:**
```json
{
  "name": "CI pipeline"
}
```

- `name` (optional): display name shown in the key list

**Response (201):**
```json
{
  "apiKey": "cfei_...",
  "keyId": "key_abc123",
  "start": "cfei_a",
  "name": "CI pipeline",
  "note": "Save this key now - shown once and never recoverable."
}
```

- `apiKey`: the plaintext key — returned **once** and never recoverable. Send it as the `X-API-Key` header.
- `start`: display prefix; the only part of the key the list endpoint shows again

**Status Codes:**
- `201` - Key created
- `401` - Missing or invalid API key
- `409` - Per-organization key limit reached (revoke one first)

### List API Keys

```
GET /api/keys
```

Lists your org's keys. The secret is never returned — only display metadata.

**Response:**
```json
{
  "keys": [
    {
      "keyId": "key_abc123",
      "name": "CI pipeline",
      "start": "cfei_a",
      "enabled": true,
      "source": "api",
      "createdAt": "2026-06-17T00:00:00.000Z",
      "lastRequest": "2026-06-17T01:23:45.000Z"
    }
  ]
}
```

- `source`: where the key was minted (`mcp` | `api` | null)
- `lastRequest`: ISO 8601 timestamp of last use, or null

### Revoke API Key

```
DELETE /api/keys/{keyId}
```

Disables a key by its `keyId` (from the list endpoint). A recently-used key may keep working for up to ~60s while the verification cache expires.

**Response:**
```json
{
  "keyId": "key_abc123",
  "revoked": true,
  "note": "API key revoked."
}
```

**Status Codes:**
- `200` - Key revoked (idempotent)
- `404` - Key not found in your organization
- `401` - Missing or invalid API key

---

## Webhooks (Callbacks)

Register an HTTPS endpoint to receive **signed**, at-least-once email-lifecycle events instead of polling `GET /api/status/{uuid}`. The same operations are available as the `register_callback` / `list_callbacks` / `delete_callback` MCP tools.

**Event types:** `email.sent`, `email.failed`, `email.received`, `email.bounced`, `email.complained`, `email.suppressed`.

### Register Callback

```
POST /api/callbacks
```

**Request Body:**
```json
{
  "url": "https://your-app.example.com/webhooks/email",
  "events": ["email.received", "email.bounced"]
}
```

- `url` (required): HTTPS URL to POST events to. Private/reserved hosts are rejected.
- `events` (optional): subset of the event types above. Omit to subscribe to all events.

**Response (201):**
```json
{
  "id": "cb_abc123",
  "url": "https://your-app.example.com/webhooks/email",
  "events": ["email.received", "email.bounced"],
  "secret": "whsec_...",
  "enabled": true,
  "createdAt": "2026-06-17T00:00:00.000Z",
  "note": "Save this secret now - shown once and never recoverable."
}
```

- `secret`: HMAC signing secret — returned **once** and never recoverable. Use it to verify the `X-Webhook-Signature` header (below).

**Status Codes:**
- `201` - Callback registered
- `400` - Invalid or unsafe callback URL
- `409` - Per-organization callback limit reached
- `401` - Missing or invalid API key

### List Callbacks

```
GET /api/callbacks
```

**Response:**
```json
{
  "callbacks": [
    {
      "id": "cb_abc123",
      "url": "https://your-app.example.com/webhooks/email",
      "events": ["email.received", "email.bounced"],
      "enabled": true,
      "createdAt": "2026-06-17T00:00:00.000Z"
    }
  ]
}
```

The signing secret is never returned.

### Delete Callback

```
DELETE /api/callbacks/{id}
```

**Response:**
```json
{
  "id": "cb_abc123",
  "deleted": true
}
```

**Status Codes:**
- `200` - Callback deleted (idempotent)
- `404` - Callback not found in your organization

### Delivery format & signature verification

Each subscribed event is delivered as an HTTP `POST` to your URL with this JSON body:

```json
{
  "id": "del_...",
  "event": "email.received",
  "occurredAt": "2026-06-17T00:00:00.000Z",
  "data": {
    "emailId": "550e8400-e29b-41d4-a716-446655440000",
    "organizationId": "org_...",
    "mailbox": "x7k2m9pqr3s4t5u6",
    "status": "received"
  }
}
```

- `data.mailbox` and `data.status` are present when known for the event.
- Delivery is **at-least-once** — dedupe on the `X-Webhook-Id` header (a redelivery of the same event reuses the id).

Every delivery carries these headers:
- `X-Webhook-Id` - stable delivery id (dedupe on this)
- `X-Webhook-Timestamp` - signing timestamp (epoch milliseconds)
- `X-Webhook-Event` - the event type
- `X-Webhook-Signature` - `sha256=<hex>`

The signature is computed over the timestamp and the raw body:

```
X-Webhook-Signature = "sha256=" + hex( HMAC_SHA256( secret, timestamp + "." + rawBody ) )
```

To verify: read the `X-Webhook-Timestamp` header and the raw request body, recompute `HMAC-SHA256(secret, timestamp + "." + rawBody)`, hex-encode it, prefix `sha256=`, and constant-time-compare to the value in `X-Webhook-Signature`. Reject deliveries whose timestamp is far from your clock to limit replay.

---

## Error Responses

All errors follow this format:

```json
{
  "error": "Error message describing the problem"
}
```

**Common Status Codes:**
- `400` - Bad Request (invalid payload)
- `401` - Unauthorized (missing or invalid API key)
- `404` - Not Found
- `413` - Payload Too Large
- `500` - Internal Server Error

---

## Workflow Examples

### 1. End-to-End Email Test

Send an email and verify it was sent:

```bash
# Create mailbox
MAILBOX=$(curl -s -X POST https://send.fixture.email/api/mailboxes \
  -H "X-API-Key: $API_KEY" | jq -r '.mailbox')

# Send email
EMAIL_ID=$(curl -s -X POST https://send.fixture.email/api/send \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "test+'$MAILBOX'@fixture.email",
    "to": "recipient@example.com",
    "subject": "Test Email",
    "text": "Hello from test!"
  }' | jq -r '.emailId')

# Poll for sent status
while true; do
  STATUS=$(curl -s "https://send.fixture.email/api/status/$EMAIL_ID" \
    -H "X-API-Key: $API_KEY" | jq -r '.status')
  if [ "$STATUS" = "sent" ]; then
    echo "Email sent successfully!"
    break
  fi
  sleep 1
done
```

### 2. Receive External Emails (Email Verification Testing)

Test your app's email verification flow:

```bash
# Create mailbox for testing
MAILBOX=$(curl -s -X POST https://send.fixture.email/api/mailboxes \
  -H "X-API-Key: $API_KEY" | jq -r '.mailbox')

EMAIL_ADDRESS="user+$MAILBOX@fixture.email"
echo "Use this email in your app signup: $EMAIL_ADDRESS"

# Poll for the verification email
while true; do
  MESSAGES=$(curl -s "https://send.fixture.email/api/messages?mailbox=$MAILBOX&status=received" \
    -H "X-API-Key: $API_KEY")
  COUNT=$(echo $MESSAGES | jq '.messages | length')
  if [ "$COUNT" -gt 0 ]; then
    echo "Received verification email!"
    echo $MESSAGES | jq '.messages[0]'
    break
  fi
  sleep 2
done
```

### 3. Mailbox Management

**Disable mailbox** (does NOT schedule cleanup):

```bash
curl -X DELETE "https://send.fixture.email/api/mailboxes/$MAILBOX" \
  -H "X-API-Key: $API_KEY"
```

**Schedule cleanup** (without disabling, allows in-flight emails). When using `Content-Type: application/json`, you must send a JSON body—use `{}` if no options, or include `duration` to override the default grace period:

```bash
# Default grace period (uses server CLEANUP_SLEEP_DURATION)
curl -X POST "https://send.fixture.email/api/mailboxes/$MAILBOX/safe-cleanup" \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'

# Optional: override duration (e.g., "30 minutes", "1 hour")
curl -X POST "https://send.fixture.email/api/mailboxes/$MAILBOX/safe-cleanup" \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"duration": "30 minutes"}'
```

**Enable mailbox**:

```bash
curl -X POST "https://send.fixture.email/api/mailboxes/$MAILBOX/enable" \
  -H "X-API-Key: $API_KEY"
```

**Disable mailbox** (independent operation):

```bash
curl -X POST "https://send.fixture.email/api/mailboxes/$MAILBOX/disable" \
  -H "X-API-Key: $API_KEY"
```

**Cancel scheduled cleanup**:

```bash
curl -X POST "https://send.fixture.email/api/mailboxes/$MAILBOX/cancel-cleanup" \
  -H "X-API-Key: $API_KEY"
```

**Note**: Enable/disable and cleanup scheduling are independent operations. DELETE only disables the mailbox - cleanup must be scheduled separately via `/safe-cleanup`.

### 4. Retrieve Email Content After Receiving

Get the full body content of a received email:

```bash
EMAIL_ID="..."  # From list messages response

# Get parsed content (HTML and text)
curl -s "https://send.fixture.email/api/messages/$EMAIL_ID/content" \
  -H "X-API-Key: $API_KEY" | jq '.'

# Or download the raw .eml file
RAW_URL=$(curl -s "https://send.fixture.email/api/messages/$EMAIL_ID/raw" \
  -H "X-API-Key: $API_KEY" | jq -r '.url')

curl -o email.eml "$RAW_URL"
```

---

## TypeScript Examples

All examples use the `EmailTestApiClient` class from `tests/helpers/api-client.ts`.

### Setup

```typescript
import { EmailTestApiClient } from './helpers/api-client';

const apiClient = new EmailTestApiClient('https://send.fixture.email', 'your-api-key');
const baseDomain = 'fixture.email';
```

### Create Mailbox and Send Email

```typescript
// Create mailbox
const mailboxResponse = await apiClient.createMailbox();
const mailbox = mailboxResponse.mailbox;
// Returns: { mailbox: "x7k2m9pqr3s4t5u6" }

// Send email (auto-creates mailbox if from is not specified)
const sendResponse = await apiClient.sendEmail({
  to: 'recipient@example.com',
  subject: 'Test Email',
  text: 'Hello from TypeScript!',
});

// Response includes emailId, mailbox, and resolved from address
console.log(sendResponse);
// {
//   emailId: "550e8400-e29b-41d4-a716-446655440000",
//   mailbox: "x7k2m9pqr3s4t5u6",
//   from: "r4nd0m+x7k2m9pqr3s4t5u6@fixture.email"
// }
```

### Send Email with Smart From Handling

```typescript
// Option 1: No from - API auto-creates mailbox
const response1 = await apiClient.sendEmail({
  to: 'recipient@example.com',
  subject: 'Auto-created mailbox',
  text: 'Email body',
});
// Creates new mailbox, from: "r4nd0m+x7k2m9pqr3s4t5u6@fixture.email"

// Option 2: Local part only - API creates new mailbox
const response2 = await apiClient.sendEmail({
  from: 'newsletter',
  to: 'recipient@example.com',
  subject: 'Newsletter',
  text: 'Email body',
});
// Creates new mailbox, from: "newsletter+x7k2m9pqr3s4t5u6@fixture.email"

// Option 3: Full address with existing mailbox
const mailbox = await apiClient.createMailbox();
const response3 = await apiClient.sendEmail({
  from: `sender+${mailbox.mailbox}@fixture.email`,
  to: 'recipient@example.com',
  subject: 'From existing mailbox',
  text: 'Email body',
});
// Uses existing mailbox, validates it exists
```

### Poll for Email Status

```typescript
// Wait for email to reach 'sent' status (external) or 'received' status (internal)
const status = await apiClient.waitForEmailStatus(emailId, 'sent', {
  maxRetries: 30,    // Default: 10
  retryDelay: 2000,  // Default: 2000ms (2 seconds)
});

console.log(status);
// {
//   emailId: "550e8400-e29b-41d4-a716-446655440000",
//   status: "sent",
//   mailbox: "x7k2m9pqr3s4t5u6",
//   from: "sender+x7k2m9pqr3s4t5u6@fixture.email",
//   to: ["recipient@example.com"],
//   subject: "Test Email",
//   time: 1730961190634,  // Unix ms
//   messageId: "msg-abc123"
// }

// For internal routing (test-to-test), wait for 'received' status
const internalStatus = await apiClient.waitForEmailStatus(emailId, 'received', {
  maxRetries: 20,
  retryDelay: 2000,
});
```

### Receive Emails (Internal Routing)

```typescript
// Create mailbox to receive emails
const mailboxResponse = await apiClient.createMailbox();
const mailbox = mailboxResponse.mailbox;

// Generate recipient address (any local part works)
const recipient = `test+${mailbox}@fixture.email`;

// Send email to test domain address (routes internally)
const sendResponse = await apiClient.sendEmail({
  from: `sender+${mailbox}@fixture.email`,
  to: recipient,
  subject: 'Internal Test',
  text: 'This is routed internally',
});

// Wait for 'received' status (not 'sent')
const status = await apiClient.waitForEmailStatus(sendResponse.emailId, 'received', {
  maxRetries: 20,
  retryDelay: 2000,
});

// Email is now in recipient's mailbox
const messages = await apiClient.listMessages({
  mailbox,
  status: 'received',
});
```

### Get Email Content and Extract Links

```typescript
// Get parsed content (HTML, text, and extracted links)
const content = await apiClient.getEmailContent(emailId);

console.log(content);
// {
//   html: "<html>Full HTML content...</html>",
//   text: "Plain text content...",
//   subject: "Email Subject",
//   from: "sender@example.com",
//   to: ["recipient@example.com"],
//   links: ["https://example.com/verify?token=abc", "https://example.com/unsubscribe"]
// }

// Links are already extracted and decoded (e.g., &amp; → &)
const verifyLink = content.links.find(link => link.includes('/verify'));

// Get presigned URL for raw .eml file download
const rawResponse = await apiClient.getEmailRawUrl(emailId);
// { url: "https://...", expiresIn: 3600 }

// Download .eml (no auth needed for presigned URL)
const emlContent = await fetch(rawResponse.url).then(r => r.text());
```

### List Messages with Filters

```typescript
// List messages with filters (URLSearchParams handles encoding automatically)
const messages = await apiClient.listMessages({
  mailbox: 'x7k2m9pqr3s4t5u6',
  status: 'received',
  to: `test+x7k2m9pqr3s4t5u6@fixture.email`,
  subject: 'Is This A Scam? - Subscription Cancellation Scheduled [100% Off]',
  receivedAfter: '2024-01-01T00:00:00.000Z', // ISO 8601; subseconds (e.g. .634Z) enable millisecond-precision filtering
});

console.log(messages.messages);
// [
//   {
//     emailId: "550e8400-e29b-41d4-a716-446655440000",
//     status: "received",
//     mailbox: "x7k2m9pqr3s4t5u6",
//     from: "sender@example.com",
//     to: ["test+x7k2m9pqr3s4t5u6@fixture.email"],
//     subject: "Is This A Scam? - Subscription Cancellation Scheduled [100% Off]",
//     time: 1730961190634,  // Unix ms
//     subaddress: "test"
//   }
// ]

// Wait for email to appear in messages list
const message = await apiClient.waitForEmailInMessages(emailId, {
  maxRetries: 20,
  retryDelay: 2000,
  filters: {
    mailbox: 'x7k2m9pqr3s4t5u6',
    status: 'received',
  },
});
```

### Send Email with Attachments

```typescript
// Small attachment (< 1MB) - use base64
const textEncoder = new TextEncoder();
const bytes = textEncoder.encode('Test attachment content');
const attachmentContent = btoa(String.fromCharCode(...bytes));

const sendResponse = await apiClient.sendEmail({
  to: 'recipient@example.com',
  subject: 'Email with Attachment',
  text: 'See attached file',
  attachments: [
    {
      name: 'test.txt',
      content: attachmentContent,
      contentType: 'text/plain',
    },
  ],
});

// Large attachment (>= 1MB) - upload first, then reference
const mailbox = await apiClient.createMailbox();
const fileContent = 'Large file content...';
const file = new Blob([fileContent], { type: 'text/plain' });

// Upload attachment
const uploadResponse = await apiClient.uploadAttachment(
  mailbox.mailbox,
  file,
  'large-file.txt'
);
// Returns: { attachmentId: "uuid", filename: "large-file.txt", size: 1234, contentType: "text/plain" }

// Send email with attachmentId reference
const sendResponse2 = await apiClient.sendEmail({
  from: `sender+${mailbox.mailbox}@fixture.email`,
  to: 'recipient@example.com',
  subject: 'Email with Large Attachment',
  text: 'See attached file',
  attachments: [
    {
      name: 'large-file.txt',
      attachmentId: uploadResponse.attachmentId,
      contentType: 'text/plain',
    },
  ],
});
```

### Send Raw .eml File (Replay External Emails)

```typescript
// Read .eml file content
const emlContent = await fs.promises.readFile('saved-email.eml', 'utf-8');

// Send with optional From/To overrides (useful for replaying external emails)
const sendResponse = await apiClient.sendRawEmail(emlContent, {
  from: `sender+x7k2m9pqr3s4t5u6@fixture.email`,
  to: 'recipient@example.com',
});

// Original .eml is preserved in R2, From/To overrides are applied at send time
```

### Complete Example: Email Verification Flow

```typescript
import { EmailTestApiClient } from './helpers/api-client';

const apiClient = new EmailTestApiClient('https://send.fixture.email', process.env.API_KEY!);
const baseDomain = 'fixture.email';

// 1. Create mailbox for testing
const mailboxResponse = await apiClient.createMailbox();
const mailbox = mailboxResponse.mailbox;
const testEmail = `user+${mailbox}@fixture.email`;

console.log(`Use this email in your app: ${testEmail}`);

// 2. Poll for verification email to arrive
let verificationEmail = null;
for (let i = 0; i < 30; i++) {
  const messages = await apiClient.listMessages({
    mailbox,
    status: 'received',
  });
  
  if (messages.messages.length > 0) {
    verificationEmail = messages.messages[0];
    break;
  }
  
  await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2 seconds
}

if (!verificationEmail) {
  throw new Error('Verification email not received');
}

// 3. Get email content and extract verification link
const content = await apiClient.getEmailContent(verificationEmail.emailId);
const verifyLink = content.links.find(link => 
  link.includes('/verify') || link.includes('token=')
);

if (verifyLink) {
  console.log(`Verification link: ${verifyLink}`);
  // Click the link or extract token for your test
}
```

### Cleanup

```typescript
// Disable mailbox (does NOT schedule cleanup)
await apiClient.deleteMailbox(mailbox);

// Schedule cleanup (without disabling, allows in-flight emails).
// Call with no options to use server default duration (sends {}); or pass { duration: '30 minutes' } to override.
await apiClient.scheduleMailboxCleanup(mailbox);
await apiClient.scheduleMailboxCleanup(mailbox, { duration: '30 minutes' });

// Enable mailbox
await apiClient.enableMailbox(mailbox);

// Disable mailbox (independent operation)
await apiClient.disableMailbox(mailbox);

// Cancel scheduled cleanup
await apiClient.cancelMailboxCleanup(mailbox);
// Returns: { success: true }

// Note: Mailbox is disabled immediately, but data is permanently deleted
// after CLEANUP_SLEEP_DURATION (typically 30 minutes) to allow in-flight
// emails to complete processing.
```

---

## Additional Resources

- **OpenAPI Spec**: `GET /api/openapi.json` - Machine-readable API specification (requires `X-API-Key`)
- **Swagger UI**: `GET /api/docs` - Interactive API documentation (requires `X-API-Key`)
- **Test Examples**: See `tests/` directory for complete TypeScript test examples

---

## Limits

- Maximum email size: 25MB
- Maximum base64 attachment in request body: 1MB (use upload endpoint for larger files)
- Maximum uploaded attachment: 25MB
