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.
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
POST /mailboxes/{email_address}/importsCreate 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
Response (201)
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:
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
POST /mailboxes/{email_address}/imports/{job_id}/upload-urlIssue 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
POST /mailboxes/{email_address}/imports/{job_id}/startValidate 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 /mailboxes/{email_address}/imports/{job_id}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
GET /mailboxes/{email_address}/importsList 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
POST /mailboxes/{email_address}/imports/{job_id}/cancelCancel 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.