# Messages API

Send email, list inbox messages, and retrieve individual messages.

## GET /v1/mailboxes/:id/messages

**GET** `/v1/mailboxes/:id/messages`

List messages for a mailbox with optional filters.

**Query parameters**

| Name | Type | Description |
|------|------|-------------|
| `direction` | `"INBOUND" \| "OUTBOUND"` | Filter by direction |
| `threadId` | `string` | Filter by thread UUID |
| `since` | `string` | ISO 8601 timestamp — only messages after this time |
| `limit` | `number` | 1-100 (default 50) |
| `offset` | `number` | Pagination offset (default 0) |

```response — 200 OK
{
  "messages": [
    {
      "id": "b2c3d4e5-6789-4abc-def0-222222222222",
      "direction": "INBOUND",
      "messageId": "<unique@example.com>",
      "fromAddress": "sender@example.com",
      "toAddresses": ["myagent@robotomail.co"],
      "subject": "Hello",
      "bodyText": "Hi there!",
      "bodyHtml": "<p>Hi there!</p>",
      "status": "RECEIVED",
      "hasAttachments": true,
      "attachmentsDropped": false,
      "attachmentsDroppedReason": null,
      "threadId": "c3d4e5f6-789a-4bcd-ef01-333333333333",
      "createdAt": "2026-03-11T12:00:00.000Z",
      "attachments": [
        {
          "id": "f7e8d9c0-1234-4abc-def0-555555555555",
          "filename": "invoice.pdf",
          "contentType": "application/pdf",
          "sizeBytes": 12345,
          "contentId": null,
          "createdAt": "2026-03-11T12:00:00.000Z"
        }
      ]
    }
  ],
  "metadata": {
    "overLimitCount": 3,
    "upgrade": {
      "browserUrl": "https://robotomail.com/billing",
      "apiEndpoint": { "method": "POST", "path": "/v1/billing/upgrade" },
      "hint": "POST /v1/billing/upgrade for a programmatic checkout URL, or visit browserUrl to sign in and upgrade"
    },
    "limitHint": {
      "current": 80,
      "limit": 100,
      "percentage": 80,
      "reset_date": "2026-05-01T00:00:00.000Z",
      "status": "near"
    }
  }
}
```

**Note:** the embedded `attachments` array on a message read does NOT include a presigned `url`. To download an attachment, call [`GET /v1/attachments/:id`](https://robotomail.com/docs/api/attachments#get) with the attachment's `id`. The webhook/SSE event payloads DO include a fresh `download_url` per attachment — see the [event payloads reference](https://robotomail.com/docs/api/events#payloads).

**Inbound-limit gating.** When the account has exceeded its monthly inbound cap, over-limit messages are filtered out of this list and counted in `metadata.overLimitCount`. The field is `0` under normal operation. Upgrading the plan or the first-of-month reset unlocks every gated message in place — a subsequent call to this endpoint returns them with full content. See [402 `INBOUND_LIMIT_EXCEEDED`](https://robotomail.com/docs/api/errors#inbound-limit-exceeded).

### Metadata envelope

- `overLimitCount` — number of messages hidden by the current filter (0 under normal operation).
- `upgrade` — always present. Carries `browserUrl`, `apiEndpoint`, and a `hint` string. Surface the URL to end a quota block immediately.
- `limitHint` — appears once monthly inbound usage reaches 50%. Snake-case shape (`current`, `limit`, `percentage`, `reset_date`, `status`) matching the `inbound_usage` block on webhook / SSE payloads, so the same countdown renderer works on both transports. `status` is one of `approaching` (≥ 50%), `near` (≥ 80%), or `limit_reached`.


## POST /v1/mailboxes/:id/messages

**POST** `/v1/mailboxes/:id/messages`

Send an email from the specified mailbox. The message is sent immediately via Resend.

**Request body**

| Name | Type | Description |
|------|------|-------------|
| `to`* | `string[]` | Recipient email addresses |
| `cc` | `string[]` | CC recipients |
| `subject`* | `string` | Email subject (1-998 chars) |
| `bodyText`* | `string` | Plain text body |
| `bodyHtml` | `string` | HTML body |
| `inReplyTo` | `string` | RFC 5322 Message-ID to reply to |
| `attachments` | `string[]` | Attachment UUIDs from /v1/attachments |
| `headers` | `object` | Custom headers (x-custom-* or x-robotomail-* only) |

```curl
curl -X POST https://api.robotomail.com/v1/mailboxes/a1b2c3d4-5678-4def-abcd-111111111111/messages \
  -H "Authorization: Bearer $ROBOTOMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["recipient@example.com"],
    "subject": "Project update",
    "bodyText": "Here is the latest status...",
    "bodyHtml": "<p>Here is the latest status...</p>"
  }'
```

```response — 201 Created
{
  "message": {
    "id": "b2c3d4e5-6789-4abc-def0-222222222222",
    "direction": "OUTBOUND",
    "fromAddress": "myagent@robotomail.co",
    "toAddresses": ["recipient@example.com"],
    "subject": "Project update",
    "status": "SENT",
    "externalMessageId": "0102abc...",
    "threadId": "c3d4e5f6-789a-4bcd-ef01-333333333333",
    "createdAt": "2026-03-11T12:30:00.000Z"
  }
}
```

### Errors

- `400` — Invalid input, mailbox inactive, address on the [suppression list](https://robotomail.com/docs/api/suppressions), or message validation failed (e.g. exceeds 40 MB, invalid address, invalid attachment)
- `403` — Account suspended (response includes `{ suspended: true, reason }`), email not verified, or scoped key referencing out-of-scope attachments
- `429` — Daily/monthly send limit exceeded, velocity limit exceeded (30/min per mailbox, 60/min per account), or upstream rate limit
- `500` — Unexpected send failure


## GET /v1/mailboxes/:id/messages/:msgId

**GET** `/v1/mailboxes/:id/messages/:msgId`

Retrieve a single message with full body content and attachment metadata.

```response — 200 OK
{
  "message": {
    "id": "b2c3d4e5-6789-4abc-def0-222222222222",
    "mailboxId": "a1b2c3d4-5678-4def-abcd-111111111111",
    "direction": "INBOUND",
    "messageId": "<unique@example.com>",
    "inReplyTo": null,
    "threadId": "c3d4e5f6-789a-4bcd-ef01-333333333333",
    "fromAddress": "sender@example.com",
    "toAddresses": ["myagent@robotomail.co"],
    "ccAddresses": [],
    "subject": "Hello",
    "bodyText": "Hi there!",
    "bodyHtml": "<p>Hi there!</p>",
    "headers": {},
    "status": "RECEIVED",
    "hasAttachments": true,
    "attachmentsDropped": false,
    "attachmentsDroppedReason": null,
    "createdAt": "2026-03-11T12:00:00.000Z",
    "attachments": [
      {
        "id": "f7e8d9c0-1234-4abc-def0-555555555555",
        "filename": "invoice.pdf",
        "contentType": "application/pdf",
        "sizeBytes": 12345,
        "contentId": null,
        "createdAt": "2026-03-11T12:00:00.000Z"
      }
    ]
  }
}
```

**Note:** API responses use **camelCase** field names (e.g. `bodyText`, `fromAddress`). Webhook and SSE event payloads use **snake_case** field names (e.g. `body_text`, `from`). See the [event payloads reference](https://robotomail.com/docs/api/events#payloads) for the webhook/SSE schema.

The embedded `attachments` array does NOT include a presigned `url`. To download an attachment, call [`GET /v1/attachments/:id`](https://robotomail.com/docs/api/attachments#get) with the attachment's `id`.

### Errors

- `402` — `INBOUND_LIMIT_EXCEEDED`: the requested inbound message is gated because the account has exceeded its monthly inbound cap. Outbound messages and under-limit inbound messages are unaffected. See [402 `INBOUND_LIMIT_EXCEEDED`](https://robotomail.com/docs/api/errors#inbound-limit-exceeded)
- `404` — Mailbox or message not found (or not owned by the caller)


## Inbound attachments

When a message arrives at one of your mailboxes, Robotomail extracts any MIME attachments, sanitizes the filenames, uploads the bytes to R2, and links them to the `Message` row. Inbound attachments are charged against your [storage quota](https://robotomail.com/docs/api/account#storage).

### Limits

- **25 MB** per attachment
- **20 attachments** per message

When a message exceeds either limit, the message body is still ingested but the over-cap part(s) are dropped. The message row has `attachmentsDropped: true` and `attachmentsDroppedReason` set to one of:

- `"size"` — at least one attachment exceeded the 25 MB cap
- `"count"` — the message had more than 20 attachment parts
- `"both"` — both conditions

### Attachment object

```response shape
{
  "id": "f7e8d9c0-1234-4abc-def0-555555555555",
  "messageId": "b2c3d4e5-6789-4abc-def0-222222222222",
  "userId": "u1234",
  "filename": "invoice.pdf",       // sanitized
  "contentType": "application/pdf",
  "sizeBytes": 12345,
  "r2Key": "u1234/inbound/f7e8.../invoice.pdf",
  "contentId": null,               // Content-ID for inline images, else null
  "url": "https://r2.example.com/...",  // presigned, valid 24h
  "createdAt": "2026-03-11T12:00:00.000Z"
}
```

### Inline images

Inline images (parts referenced by `cid:` URIs in the HTML body, e.g. `<img src="cid:logo@acme">`) are extracted as normal attachments with `contentId` set to the Content-ID header value (without angle brackets — e.g. `"logo@acme"`).

The `bodyHtml` field is stored verbatim — Robotomail does NOT rewrite `cid:` URLs. If you render the HTML directly, substitute each `cid:foo`with the matching attachment's download URL at render time. The webhook payload includes`download_url` per attachment, which you can use directly:

```webhook flow
function renderInboundHtmlFromWebhook(data) {
  let html = data.body_html;
  for (const att of data.attachments) {
    if (att.content_id) {
      html = html.replaceAll(`cid:${att.content_id}`, att.download_url);
    }
  }
  return html;
}
```

For the REST API flow, fetch each inline attachment via `GET /v1/attachments/:id` to get a fresh presigned `url`:

```REST API flow
async function renderInboundHtmlFromApi(mailboxId, messageId, client) {
  const { message } = await client.get(
    `/v1/mailboxes/${mailboxId}/messages/${messageId}`,
  );
  let html = message.bodyHtml;
  for (const att of message.attachments) {
    if (att.contentId) {
      const fresh = await client.get(`/v1/attachments/${att.id}`);
      html = html.replaceAll(`cid:${att.contentId}`, fresh.url);
    }
  }
  return html;
}
```

**Caching note:** presigned URLs are valid for 24 hours. If you cache the rendered HTML, re-fetch before the cached copy expires — storing pre-rendered HTML for longer than the URL TTL means stale broken images.


## Message statuses

- `QUEUED` — Message is queued for sending
- `SENT` — Accepted for delivery
- `DELIVERED` — Confirmed delivered to recipient's mail server
- `BOUNCED` — Permanently bounced (address doesn't exist or rejected)
- `COMPLAINED` — Recipient reported the message as spam
- `FAILED` — Sending failed
- `RECEIVED` — Inbound message stored successfully


---

Previous: [Mailboxes](https://robotomail.com/docs/api/mailboxes.md) | Next: [Threads](https://robotomail.com/docs/api/threads.md)
