# Storage & Attachments

Source: https://molted.email/docs/storage

How storage is tracked, attachment handling, plan limits, and retention windows.

Molted Email tracks storage usage per tenant and stores attachments in secure object storage.

## Storage model

Storage is split into two categories:

- **Message bodies** -- the text and HTML content of inbound messages, stored with your tenant data.
- **Attachments** -- binary files in secure object storage, with metadata tracked alongside.

Both categories count toward your plan's storage limit.

## Plan limits

| Plan | Storage limit | Retention |
| - | - | - |
| **Trial** (and a paid plan during its 3-day trial) | 50 MB | 7 days |
| **Free** | 100 MB | 14 days |
| **Solo** (and legacy Starter) | 3 GB | 30 days |
| **Team** (and legacy Growth) | 10 GB | 90 days |
| **Enterprise** | Unlimited | 365 days |

When your account is in the **expired** state it keeps its previous storage limit, and its retention becomes the previous retention plus 30 days, so nothing is purged the moment a plan lapses. Uploads are refused (see [Uploads on an expired plan](#uploads-on-an-expired-plan)), but attachments of email you receive are still stored.

## Retention windows

Attachments older than your plan's retention window are automatically cleaned up daily, up to 1,000 per tenant per run:

1. The file is removed from storage.
2. The attachment row is marked `deleted`.
3. Storage usage counters are decremented.

Attachments of a message held for the inbound allowance are never expired while it is held; once it's released, retention counts from the release.

The plan's retention window above covers attachment files only. Message text is not removed by it.

### Message content retention

There is a second, separate daily pass that clears message content, but it only runs for a workspace that has a **content retention period** set. For such a workspace it empties, for every message older than the period, the bodies (plain text and encrypted), the raw inbound message, the stored send payloads and the humanizer's before and after text. Subjects, addresses, timestamps and delivery status are kept, so threads and lists still render, and the body search index entries for those messages are deleted with them.

No plan sets a content retention period, and nothing you can reach sets one: not the portal, the API, the CLI or MCP. So today, message text is kept until you delete it yourself. To remove it, use [Deleting message content](https://molted.email/docs/privacy.md#deleting-message-content) for everything, or [Deleting contacts](https://molted.email/docs/privacy.md#deleting-contacts) for one person's messages.

## Checking storage usage

Storage usage is included in the billing status endpoint:

```bash title="curl"
curl https://mailbox.molted.email/v1/billing/status \
  -H "Authorization: Bearer YOUR_API_KEY"
```

The response includes a `storage` field:

```json title="Storage usage"
{
  "storage": {
    "messageBodyBytes": 1048576,
    "attachmentBytes": 5242880,
    "totalBytes": 6291456,
    "limitBytes": 3221225472,
    "retentionDays": 30
  }
}
```

A `null` limitBytes means unlimited storage (Enterprise plan). A signed-in human sees the same numbers at `GET /v1/me/storage`, which returns `{ storage: { messageBodyBytes, attachmentBytes, totalBytes, limitBytes, remaining, retentionDays }, mailboxes: { used, limit } }`.

## Attachment API

### Upload an attachment

```
POST https://mailbox.molted.email/v1/attachments
```

```bash title="CLI"
molted attachments upload ./invoice.pdf --mailbox MAILBOX_ID
```

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/attachments \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "filename": "invoice.pdf",
    "contentType": "application/pdf",
    "contentBase64": "JVBERi0xLjQK..."
  }'
```

Body: `filename`, `contentType`, `contentBase64` (all required) and `mailboxId` (optional UUID). The decoded file can be at most 10 MB (`400` otherwise). Executable and script extensions are refused with `400`: `.exe .bat .cmd .scr .pif .com .vbs .vbe .js .jse .wsf .wsh .msi .msp .cpl .hta .inf .reg .ps1 .lnk .dll .sys`. The content type is sniffed from the file's bytes; when it differs from what you declared, your value is kept in `contentTypeDeclared`. A mailbox-scoped key needs `send` on the mailbox.

```json title="Response"
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "filename": "invoice.pdf",
  "contentType": "application/pdf",
  "contentTypeDeclared": null,
  "sizeBytes": 204800,
  "checksumSha256": "...",
  "status": "uploaded"
}
```

Pass the returned `id` in a send's `attachments` array (at most 20 per send). An attachment that hasn't finished its scan is refused with `400 Attachment(s) not yet scanned`. There is no MCP upload tool.

### List attachments for a message

```bash title="CLI"
molted attachments list --message-id MSG_ID --message-type inbound
```

```bash title="curl"
curl "https://mailbox.molted.email/v1/attachments?messageId=MSG_ID&messageType=inbound" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

`messageType` is `inbound` or `thread` (anything else is a `400`); a non-UUID `messageId` returns `[]`. Attachments of a message that is held, still pending classification, or quarantined are left out (quarantined ones are listed for a key with the `operator` scope). In MCP, a thread's attachments come with `get_thread`.

### Response

```json title="Response"
[
  {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "filename": "invoice.pdf",
    "contentType": "application/pdf",
    "sizeBytes": 204800,
    "status": "uploaded",
    "createdAt": "2026-03-01T12:00:00Z"
  }
]
```

### Download an attachment

```bash title="CLI"
molted attachments download ATTACHMENT_ID --output invoice.pdf
```

```bash title="curl"
curl "https://mailbox.molted.email/v1/attachments/ATTACHMENT_ID/download" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

MCP: `get_attachment` (`attachment_id`).

Returns a presigned URL valid for 1 hour, plus the attachment's own metadata (added alongside `url`, so an
existing integration that reads only `url` is unaffected):

```json title="Response"
{
  "url": "https://storage.molted.email/tenants/tenant-abc/attachments/2026/03/550e8400...?X-Amz-Signature=...",
  "expiresInSeconds": 3600,
  "filename": "invoice.pdf",
  "contentType": "application/pdf",
  "sizeBytes": 204800,
  "status": "uploaded"
}
```

`filename` comes verbatim from the sender's email on an inbound attachment: treat it as untrusted, third-party
data, not as a trusted file path. `status: "uploaded"` means the [file scan](#what-the-file-scan-checks) has not finished yet (still
downloadable); `"scan_passed"` means it passed the file scan, which is a file-type check, not antivirus. The statuses are `pending`, `uploaded`, `scan_passed`, `scan_failed` and `deleted`; a `deleted` or `scan_failed` attachment is a `404`. A `403` carries `pending_classification`, `inbound_quota_held` or `quarantined_requires_operator` as its message when the message it belongs to isn't readable yet.

A signed-in human downloads through `GET /v1/me/attachments/:id/download`, which streams the file, never serves a quarantined attachment, and is a `404` for an archived mailbox.

## What the file scan checks

Every attachment, uploaded by an agent or received on an inbound email, goes through two checks.

- **Extension.** The executable and script extensions listed under [Upload an attachment](#upload-an-attachment) are refused. An upload gets a `400`; a received attachment with one of them is dropped on arrival (the message itself is kept).
- **File bytes.** Once stored, the file's first bytes are compared with known executable and binary formats: Windows executables and DLLs, Linux (ELF) and macOS (Mach-O) binaries, Java class files, Windows shortcuts, and OLE compound files (legacy `.doc` and `.xls`, `.msi` installers). A match sets `scan_failed`: the file is never served or sent, whatever it is named.

It is a file-type check, not an antivirus engine. It does not look for malware in other formats (PDFs, images, modern Office files, HTML or SVG files), and it does not unpack archives such as `.zip`. It does not read a file's text either: the [safety checks](https://molted.email/docs/safety-settings.md#what-screening-does-not-cover) screen the subject and body, so instructions hidden inside an attachment are not detected.

## What happens at the storage limit

When you exceed your plan's storage limit, attachment uploads are rejected with a `403 Forbidden` response:

```json title="Error response"
{
  "statusCode": 403,
  "message": "Storage limit exceeded. Used: 3221225472 bytes, limit: 3221225472 bytes.",
  "error": "Forbidden"
}
```

An inbound attachment that would pass the limit is dropped (and logged), while the message itself is kept. Existing attachments remain accessible. To free up space, wait for the retention cleanup or upgrade your plan.

## Uploads on an expired plan

Once a plan has ended (or a failed payment's grace period ran out), uploads are refused with `402 Payment Required`:

```json title="Error response"
{
  "error": "subscription_expired",
  "message": "Your subscription has expired. Reactivate a plan to upload attachments."
}
```

Listing and downloading attachments keep working, so you can export your data. Attachments of email you receive are still stored. A new account that hasn't activated a plan yet can upload.
