Inkbox

> # Documentation index
> Fetch the complete documentation index at: https://inkbox.ai/sitemap.xml
> Use this file to discover all available pages before exploring further.

---

# Mail imports
description: Import historical email from MBOX, EML, or ZIP archives

---


# Mail imports

Import historical email into an existing mailbox from an MBOX file, one EML file, or a ZIP archive. Imports run asynchronously: create a job, upload the file, start the job, then poll until it reaches a terminal state.

Imported messages keep their original `Date` header, so threads and list views sort historically. Dates that are implausible, meaning before 1990 or in the future, are clamped to the import time. Threading is rebuilt from `In-Reply-To` and `References` exactly as it is for live mail, and imported threads land in the `inbox` folder.

Imported messages appear in normal message lists and threads, but do not generate inbound-message webhooks and are not evaluated against contact rules. Messages imported by a job carry its ID in `import_job_id`.

Creating, refreshing the upload target, starting, and cancelling an import require a human session, an organization API key, or an API key scoped to a claimed agent. Reading and listing import jobs also accept API keys scoped to unclaimed agents, subject to normal mailbox visibility.

## Formats

| Format | Description |
| :--- | :--- |
| `auto` | Detect MBOX, EML, or ZIP from the uploaded file. This is the default. |
| `mbox` | A mailbox archive containing multiple messages, such as a common email export. |
| `eml` | One RFC 5322 email message. |
| `zip` | A ZIP archive of mail files at any directory depth. Entries ending `.eml` import as single messages, entries ending `.mbox` or `.mbx` are split like an MBOX, and extensionless entries that begin with RFC 5322 headers are imported. Directories, nested archives, and other files are not imported or traversed, but non-directory entries still undergo archive-wide validation and can cause the job to fail. |

Because ZIP entries ending `.mbox` are imported, a mail export archive that contains one or more `.mbox` files can be uploaded as downloaded, with no need to unpack or repack it first.

### Producing an importable file

Most providers offer a full-mailbox export that produces an MBOX file, sometimes delivered inside a ZIP archive. Upload size is capped at 1 GiB, and a full export of a long-lived mailbox often exceeds that. If yours does, narrow the export to a subset of labels or folders and run one import per file, or split the MBOX and import each part. Duplicate messages across those files are skipped rather than imported twice, so overlapping exports are safe.

## Complete SDK flow

The Python and TypeScript SDKs upload directly using the target returned by `create`, so you do not need to construct the multipart upload yourself.

**Python**

```python
from inkbox import MailImportFormat

created = inkbox.mailboxes.imports.create(
    "agent@inkboxmail.com",
    source_format=MailImportFormat.AUTO,
    original_addresses=["old-address@example.com"],
)
inkbox.mailboxes.imports.upload(created.upload, "./archive.mbox")
inkbox.mailboxes.imports.start(
    "agent@inkboxmail.com",
    str(created.job.id),
)
job = inkbox.mailboxes.imports.wait(
    "agent@inkboxmail.com",
    str(created.job.id),
    timeout=3600,
    poll_interval=5,
)
print(job.status, job.messages_imported)
```

**TypeScript**

```typescript
import { openAsBlob } from "node:fs";

const file = await openAsBlob("./archive.zip");
const created = await inkbox.mailboxes.imports.create(
    "agent@inkboxmail.com",
    {
        sourceFormat: MailImportFormat.ZIP,
        originalAddresses: ["old-address@example.com"],
    },
);
await inkbox.mailboxes.imports.upload(created.upload, file);
await inkbox.mailboxes.imports.start(
    "agent@inkboxmail.com",
    created.job.id,
);
const job = await inkbox.mailboxes.imports.wait(
    "agent@inkboxmail.com",
    created.job.id,
    { timeoutMs: 3_600_000, pollIntervalMs: 5_000 },
);
console.log(job.status, job.messagesImported);
```

**CLI**

```bash
inkbox mailbox imports run agent@inkboxmail.com ./archive.mbox \\
  --original-address old-address@example.com
```

`wait` fetches immediately, then polls every five seconds by default. It returns a job for every terminal state, including `failed` and `cancelled`; those states are results, not request errors. A local timeout only stops waiting and does not cancel the import. Call `cancel` separately when you want processing to stop.

## Direction and read state

Set `original_addresses` to every address from which you sent mail in the old mailbox. An imported message whose `From` address matches the destination mailbox or one of these addresses is classified as outbound; other messages are classified as inbound. Without `original_addresses`, sent mail from an old address may appear inbound.

Direction inference is best-effort by design. `From` headers in an export are unauthenticated, so a message that spoofed one of your addresses classifies as outbound. Do not treat `direction` on imported mail as a trustworthy signal.

Imported messages are marked read by default. Set `mark_as_read` to `false` in the API, `mark_as_read=False` in Python, `markAsRead: false` in TypeScript, or pass `--mark-unread` to the CLI to import them unread.

---

## Create import `POST`


Create a job in `pending_upload` and receive an upload target.

### Request body

| Field | Type | Default | Description |
| :--- | :--- | :--- | :--- |
| `source_format` | string | `auto` | `auto`, `mbox`, `eml`, or `zip` |
| `original_addresses` | string[] \| null | `null` | Addresses used to send mail from the old mailbox (maximum 20) |
| `mark_as_read` | boolean | `true` | Whether imported messages start read |

### Request example

```json
{
    "source_format": "auto",
    "original_addresses": ["old-address@example.com"],
    "mark_as_read": true
}
```

### Response (201)

```json
{
    "job": {
        "id": "5ca8f9bb-ec2f-41db-88d2-5cba388df619",
        "mailbox_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
        "status": "pending_upload",
        "source_format": "auto",
        "original_addresses": ["old-address@example.com"],
        "mark_as_read": true,
        "upload_size_bytes": null,
        "messages_processed": 0,
        "messages_imported": 0,
        "messages_skipped_duplicate": 0,
        "messages_failed": 0,
        "messages_rejected_unsafe": 0,
        "error_detail": null,
        "created_at": "2026-07-24T12:00:00Z",
        "updated_at": "2026-07-24T12:00:00Z",
        "started_at": null,
        "finished_at": null
    },
    "upload": {
        "url": "https://uploads.example.com/",
        "fields": {
            "<field>": "<value>",
            "...": "..."
        },
        "expires_in_seconds": 300
    }
}
```

Only one active import is allowed per mailbox. Creating another while a job is `pending_upload`, `queued`, or `running` returns `409`.

### Error responses

| Status | `detail.error` | Description |
| :--- | :--- | :--- |
| 404 | — | The mailbox does not exist or is not visible to your key |
| 409 | `mail_import_already_in_flight` | Another import for this mailbox is `pending_upload`, `queued`, or `running` |
| 429 | `mail_import_quota_exceeded` | The organization's rolling 24-hour job quota is exhausted. The response carries a `Retry-After` header in seconds |

Import errors use a structured `detail` object rather than a plain string:

```json
{
    "detail": {
        "error": "mail_import_quota_exceeded",
        "message": "Import job quota reached (20 per 24 hours)."
    }
}
```

---

## Upload file

Send a `multipart/form-data` POST to `upload.url`. Include every value from `upload.fields` as a form field, in the order returned and unmodified, followed by the file in a field named `file`. The field set is opaque and may change, so copy it verbatim rather than hard-coding names. The SDK `upload` methods handle this directly.

---

## Refresh upload target `POST`


Issue a replacement upload target when the previous one lapses before the file is uploaded. The response is a new `upload` object. Python uses `refresh_upload_target(email_address, job_id)` and TypeScript uses `refreshUploadTarget(emailAddress, jobId)`.

### Error responses

| Status | `detail.error` | Description |
| :--- | :--- | :--- |
| 404 | — | Unknown job for this mailbox |
| 409 | `mail_import_wrong_state` | The job is past the upload phase; upload targets exist only while it is `pending_upload` |

---

## Start import `POST`


Validate the completed upload and queue the job. Starting a job that is already `queued` or `running` is idempotent. The response is the updated import job.

Starting also records the uploaded file's identity. Replacing that file afterward does not restart or extend the import: the job fails instead of processing different data. If an upload was interrupted, re-upload before calling `start`, never after.

### Error responses

| Status | `detail.error` | Description |
| :--- | :--- | :--- |
| 404 | — | Unknown job, or no file has been uploaded yet. The message distinguishes the two |
| 409 | `mail_import_wrong_state` | The job is in a terminal state and cannot be started |
| 409 | `mail_import_upload_identity_unavailable` | The upload could not be verified; upload the file again |
| 413 | `mail_import_upload_too_large` | The uploaded file exceeds the 1 GiB cap |

---

## Get import `GET`


Get the current status, counters, timestamps, and any job-level error. Poll this endpoint while the job is active. An unknown job for this mailbox returns `404`.

---

## List imports `GET`


List jobs newest first with cursor pagination.

| Parameter | Type | Default | Description |
| :--- | :--- | :--- | :--- |
| `cursor` | string | — | Opaque cursor from `next_cursor` |
| `limit` | integer | 50 | Jobs per page (1–100) |

A `cursor` that was not produced by this endpoint returns `400`.

---

## Cancel import `POST`


Cancel a `pending_upload`, `queued`, or `running` job. Processing stops at the next item or control boundary, so a running import may continue briefly. Messages already imported remain in the mailbox.

### Error responses

| Status | `detail.error` | Description |
| :--- | :--- | :--- |
| 404 | — | Unknown job for this mailbox |
| 409 | `mail_import_wrong_state` | The job is already `completed`, `failed`, or `cancelled` |

## Statuses

| Status | Meaning |
| :--- | :--- |
| `pending_upload` | Waiting for the file upload and `start` request |
| `queued` | Upload accepted and waiting for processing |
| `running` | Messages are being processed |
| `completed` | Processing finished, possibly with skipped, failed, or rejected messages |
| `failed` | The job could not continue; inspect `error_detail` |
| `cancelled` | The job was cancelled or its upload was never started before expiry |

The terminal states are `completed`, `failed`, and `cancelled`.

## Counters

| Field | Description |
| :--- | :--- |
| `messages_processed` | Messages examined so far |
| `messages_imported` | Messages successfully added to the mailbox |
| `messages_skipped_duplicate` | Messages skipped because that Message-ID already exists in the mailbox |
| `messages_failed` | Messages that could not be imported, including messages over 50 MiB. An extensionless ZIP entry over 50 MiB is ignored because its content cannot be classified without reading it |
| `messages_rejected_unsafe` | Messages rejected as unsafe before being added to the mailbox |

Unsafe rejection is per message: the rejected message is counted in `messages_rejected_unsafe`, and the import continues when possible.

Counters are durable checkpoints, not a percentage. They can remain unchanged while a large message is processed, and they stay cumulative if processing restarts: a restart resumes from the last checkpoint rather than recounting. Use `status` to determine completion and display the counters as independent totals.

## Job fields

| Field | Type | Description |
| :--- | :--- | :--- |
| `id` | UUID | Import job identifier, also stamped on each imported message as `import_job_id` |
| `mailbox_id` | UUID | Mailbox the messages are imported into |
| `status` | string | One of the six statuses above |
| `source_format` | string | `auto`, `mbox`, `eml`, or `zip` as requested at create time |
| `original_addresses` | string[] \| null | Addresses supplied at create time for direction inference |
| `mark_as_read` | boolean | Whether imported messages start read |
| `upload_size_bytes` | integer \| null | Size of the uploaded file; `null` until the job is started |
| `messages_processed` | integer | See the counters above |
| `messages_imported` | integer | See the counters above |
| `messages_skipped_duplicate` | integer | See the counters above |
| `messages_failed` | integer | See the counters above |
| `messages_rejected_unsafe` | integer | See the counters above |
| `error_detail` | string \| null | Why a `failed` or expired job stopped; `null` otherwise |
| `created_at` | string | Creation timestamp (ISO 8601) |
| `updated_at` | string | Last time the job record changed (ISO 8601) |
| `started_at` | string \| null | When processing began; `null` while `pending_upload` or `queued` |
| `finished_at` | string \| null | When the job reached a terminal state; `null` while active |

## Limits

| Limit | Value |
| :--- | :--- |
| Upload size | 1 GiB |
| Individual message size | 50 MiB |
| Messages per job | 100,000 |
| ZIP entries | 65,000 |
| ZIP total uncompressed size | 1 GiB across every entry, whether or not it is importable |
| ZIP central directory | 64 MiB |
| ZIP compression ratio | 100:1 per entry |
| Import jobs | 20 per organization per rolling 24 hours by default |
| Concurrent imports | One per mailbox, and one running import per organization |
| Upload target lifetime | 5 minutes; request a replacement while the job is `pending_upload` |
| Unstarted upload expiry | 24 hours |

ZIP imports support stored or deflated, unencrypted, single-disk archives. ZIP64 archives are not supported, and the archive must contain at least one importable mail entry.

Imported messages count toward the mailbox storage allowance.

## Queueing and terminal states

Queued jobs are processed oldest first, with at most one running import per organization. Imports also share service-wide processing capacity, so a queued job may wait while imports for other organizations run. A job that sits in `queued` with unchanging counters is waiting its turn, not stuck.

A job ends `failed`, with the reason in `error_detail`, when it cannot continue. The common causes are:

- The upload contained no importable messages, including a ZIP with no usable mail entries.
- The next message would exceed the mailbox storage allowance. Messages imported before that point remain.
- The uploaded file was replaced after `start`.
- The archive violated one of the safety limits above.

Terminal jobs are not resumable. To retry, create a new job and upload again. Re-importing the same mail is safe: uniqueness is based on Message-ID within the mailbox, so previously imported messages are counted in `messages_skipped_duplicate` instead of being duplicated.
