Emails
Everything about a captured message: the listing, the parsed detail, the raw bytes, its attachments, what it links to, and how it would fare in a real inbox.
List the emails of an inbox
GET /api/v1/inboxes/{inbox_id}/emails?limit=50&cursor=…&query=… Scope: emails:read Response: { "data": [ { "id": "e9v2h6r4ldq1a7c3", "subject": "Reset your password", "from_name": "Acme", "from_email": "noreply@acme.dev", "excerpt": "Click the link below to choose a new password…", "has_attachments": false, "attachment_count": 0, "received_at": "2026-09-02T09:14:22.000000Z" } ], "meta": { "total": 128, "next_cursor": "e9v2h6r4ldq1a7c3" } }
| Parameter | Accepts |
|---|---|
limit | Integer, 1–100, default 50 |
cursor | The previous page's meta.next_cursor — see pagination |
query | String, ≤ 255 — full-text search over subject, sender and body, the same index the app's search box uses |
Search across inboxes
GET /api/v1/emails?query=invoice&inbox[]={inbox_id}&inbox[]={inbox_id} Scope: emails:read
Same response shape and parameters as the listing, over every inbox the token reaches. inbox[] narrows it to some of them; an id the token cannot reach is simply not searched.
Read one email
GET /api/v1/emails/{email_id} Scope: emails:read Response: { "id": "e9v2h6r4ldq1a7c3", "subject": "Reset your password", "from_name": "Acme", "from_email": "noreply@acme.dev", "recipients": [{ "type": "to", "email": "user@example.com", "name": "" }], "text_body": "Click the link below…", "html_body": "<!doctype html>…", "headers": { "message-id": "<…>", "dkim-signature": "…", "…": "…" }, "attachment_count": 1, "attachments": [ { "id": "a4d8k1p2m7x9c3v6", "filename": "receipt.pdf", "mime_type": "application/pdf", "size": 48213, "inline": false, "download_url": "https://app.mailclutch.com/api/v1/emails/e9v2h6r4ldq1a7c3/attachments/a4d8k1p2m7x9c3v6" } ], "received_at": "2026-09-02T09:14:22.000000Z", "inbox": { "id": "k3n8x2p9qa7c1m4z", "name": "Acme — staging" } }
The bodies are returned as the message carried them, after MIME parsing. An attachment's download_url is null when the file was recorded but not stored — too large, or a type the pipeline refuses.
The raw message and its attachments
GET /api/v1/emails/{email_id}/raw Scope: emails:read → message/rfc822, byte for byte as captured GET /api/v1/emails/{email_id}/attachments/{attachment_id} Scope: emails:read → the file, with its own Content-Type and a Content-Disposition filename
The raw endpoint is the export: save it as .eml, open it in a mail client, feed it to another tool. Both are served with X-Content-Type-Options: nosniff.
Links and one-time codes
GET /api/v1/emails/{email_id}/extract Scope: emails:read Response: { "email_id": "e9v2h6r4ldq1a7c3", "links": [ { "url": "https://app.acme.dev/reset/8f3k2…", "text": "Reset password", "host": "app.acme.dev", "is_external": true } ], "codes": [ { "code": "482913", "score": 9, "context": "Your verification code is 482913" } ], "primary_code": "482913" }
For driving a sign-up, reset or two-factor flow onward. Codes are found by how they are worded, so they come as a ranked list; primary_code is set only when one candidate clearly wins, and null when it does not — a null beats a confident wrong answer. The expect call returns the same extraction alongside its verdict, so a test rarely needs this endpoint on its own.
Deliverability analysis
GET /api/v1/emails/{email_id}/analysis Scope: analysis:run Response: { "email_id": "e9v2h6r4ldq1a7c3", "deliverability": { "score": 82, "by_category": { "authentication": 30, "content": 22, "…": … }, "factors": [ { "key": "dkim", "label": "DKIM signature", "status": "pass", "…": … }, { "key": "list_unsubscribe", "label": "List-Unsubscribe header", "status": "warning", "…": … } ], "spam_assassin": { "score": 1.2, "threshold": 5, "rules": [ … ] } }, "authentication": { "spf": { … }, "dkim": { … }, "dmarc": { … } }, "bimi": { … } }
A score out of 100 and what drags it down: SPF, DKIM, DMARC, BIMI, List-Unsubscribe and a spam-filter pass. It describes the message as captured and the host you sent from — a sandbox is not your production sending path, so this is about the message, not your reputation.
Delete one email, or empty an inbox
DELETE /api/v1/emails/{email_id} Scope: emails:delete → { "deleted": true } DELETE /api/v1/inboxes/{inbox_id}/emails Scope: emails:purge → { "deleted": 128 }
Emptying is handy at the start of a run on a permanent inbox, so it contains only what the run produced. With an ephemeral inbox per run there is nothing to empty.