Largely improves user & operator visibility on imports, resiliency with retries, direct-to-offload storage, import cancellation (deleting imported messages) & more. Remove the modal import and manage all within the import tab in the settings modal
15 KiB
Resumable message import
Users import existing mail into a mailbox from an EML, MBOX or PST file, or directly from an IMAP account. Imports can be large and long, so they are designed to survive worker restarts, deploys and crashes and to resume where they left off — without ever creating duplicate messages.
Gated behind FEATURE_IMPORT_MESSAGES (grants the CAN_IMPORT_MESSAGES
mailbox ability). Tuning env vars live in env.md.
Model: an import is a channel
Every import is a Channel with type=import, scoped to the destination
mailbox (scope_level=mailbox). Modelling it as a durable row (instead of
tracking Celery task state) means the run is listable, resumable and cancellable
by id long after any worker has forgotten it.
Durable state on the channel:
settings["import"]— the run config and terminal snapshot:source_type(eml/mbox/pst/imap),mode(oneshot/continuous),file_key, finalstatus/success/failure/total. Deliberately lightweight: the per-folder IMAP watermark is Redis-only (folder names are remote-controlled, so its size must never bloat the Postgres row — an eviction just costs a re-scan). The continuous poll cadence is not stored here either — it is the globalMESSAGES_IMPORT_IMAP_POLL_INTERVALsetting.encrypted_settings["imap"]— IMAP credentials (server, port, username, password, SSL). Stored encrypted; kept for the life of the channel so a run can resume or (once continuous) keep polling. Credentials are intentionally not scrubbed on completion — a finished oneshot can be re-armed as continuous later without re-entering them. Deleting the channel frees them.is_active—Truewhile the run should still make progress. Flipped toFalsewhen a oneshot finishes (or any import fails/is cancelled). AcontinuousIMAP channel staysis_active=Truebetween polls.last_used_at— durable heartbeat, advanced as the run flushes progress. Drives the scheduler (below).
Live progress lives in Redis (read_state/write_state), keyed by channel
id: status, running success/failure/total, and the resume watermark
(cursor for files, folders for IMAP). The serializer reads merged_state()
= durable snapshot overlaid with the live Redis values, so the resource still
reads correctly after the cache is evicted (it simply falls back to the durable
terminal snapshot, or resumes from the start if a run was mid-flight).
The run: one idempotent, resumable task
run_import_task(channel_id) runs (or resumes) a single import to completion.
It is idempotent and safe to re-dispatch:
- Skip if the channel is gone or
is_active=False(a finished oneshot must never be re-run by a stray scheduler tick). - Acquire a run-lock (Redis, TTL =
MESSAGES_IMPORT_STALL_TIMEOUT) so a poll and a crash-recovery can't run concurrently. The lock is renewed before every message and periodically through the initial file scan (so a live-but-slow run never lets it lapse), and released infinally. - Advance the heartbeat, then dispatch to the per-source runner
(
run_mbox/run_eml/run_pst/run_imap), handing it the Redis watermark so it starts from where the last run stopped. mark_finishedwrites the terminal status to both Redis and the durable snapshot and (for oneshot) setsis_active=False.
The runner beats the durable heartbeat and renews the lock before every message
(and through the pre-scan); it flushes counts + watermark to Redis every
~FLUSH_EVERY messages.
Deduplication
Delivery goes through the normal inbound path with is_import=True. A re-run
(or a message present in two overlapping sources) is de-duplicated by
Message-ID when present, else by the SHA-256 of the raw bytes (blob.sha256),
both scoped to the destination mailbox. So resuming is always safe even if the
watermark rewinds.
Storage tier
Imported bodies are written directly to the object-storage tier
(create_blob(prefer_offloaded=True)) instead of parking in the Postgres hot
tier until the periodic offload — a multi-gigabyte archive never bloats the
blobs table. The regular offload policy (master switch, size floor) still
applies, and without configured object storage (or on an upload failure) the
blob falls back to Postgres, exactly the pre-existing behavior.
See tiered-storage.md.
File resume vs. IMAP resume
The two resume paths are deliberately asymmetric:
-
File imports (EML/MBOX/PST) resume by re-scanning from a positional cursor. A resume re-indexes the mbox / re-walks the PST from the start and skips messages before
cursor. This re-scan is not optimized on purpose: a file import only ever restarts on a deploy or a worker kill (a bounded, rare event), the ordering is deterministic so the rebuilt plan is identical, and dedup makes any overlap harmless. Simplicity wins over saving a one-off scan. -
IMAP imports resume incrementally. IMAP is the source that may run continuously (polling forever), so its resume must be cheap. State is a per-folder watermark
{folder: {uidvalidity, last_uid}}; each run onlyUID SEARCH UID <last_uid+1>:*(the range is pushed to the server). A changedUIDVALIDITYinvalidates the stored UID and triggers a full re-scan of that folder (dedup keeps it duplicate-free). The watermark lives in Redis only — folder names come from the remote server, so mirroring it durably would let a malicious server bloat the Postgres row. If Redis is evicted, the next run simply re-scans the whole account; dedup keeps that safe.The watermark advances only past a UID that was actually handled (fetched, then delivered or permanently rejected). A UID whose fetch fails even after retries is treated as transient: the watermark is left below it and the run ends as
TransientImportError— leftis_active, so the scheduler re-dispatches and resumes at that UID rather than silently skipping it. SingleFETCHtimeouts are absorbed in-line by a bounded exponential-backoff retry (a fixed few attempts on the same connection). Connect-time network failures and unselectable folders map to the sameTransientImportError(never a silent skip — an unselectable folder would otherwise let a oneshot complete with its mail missing).Transient is not forever:
run_import_taskkeeps a cross-run stuck budget (STUCK_RETRY_LIMITS) — too many consecutive runs dying at the same watermark turn the run into a durableFAILEDwith the underlying error, so a permanently broken source surfaces instead of retrying invisibly. Any progress (or a completed pass, or a re-arm) resets the budget. The budget is sized by source: file imports get a few tries (FILE_STUCK_RETRIES, a brief storage/S3 blip); IMAP is sized from the poll cadence (IMAP_STUCK_TIMEOUT ÷ MESSAGES_IMPORT_IMAP_POLL_INTERVAL, ~5 days of polls) so a continuous poller rides out a multi-day server outage instead of disabling itself. (Storage/S3 errors during a file import are mapped to the sameTransientImportError, so a bucket blip resumes rather than failing the run on the first hiccup.)
Scope: an importer, not a (full) syncer
Continuous IMAP is a one-way, append-only importer, not a two-way mailbox
sync. It only ever pulls new messages (UID > last_uid per folder) — the
same UID/UIDVALIDITY watermarking a desktop/mobile client uses to detect new
mail. What a real mail client also does and we deliberately do not:
- No push (IDLE). Clients hold a connection open and
IDLE(RFC 2177) so the server pushes* n EXISTSthe instant mail arrives (near-zero latency, one long-lived login). We poll on a fixed cadence (MESSAGES_IMPORT_IMAP_POLL_INTERVAL) with a fresh connect+login+SELECTeach tick. New-mail latency is therefore up to one poll interval — fine for archival import, not for a live mirror. - No flag / expunge sync (CONDSTORE/QRESYNC/MODSEQ). Clients track a per-folder
MODSEQand useSELECT … (QRESYNC …)(RFC 7162) to learn, in one round trip, which already-seen messages had flags changed (read/starred) or were expunged (deleted) on the source. We never revisit a UID once imported: a message later read, flagged, or deleted upstream is not reflected here. This is correct for "copy my old mail in once (and keep catching new arrivals)" and is the defining difference from maintaining a bidirectional replica.
Adding IDLE (lower latency) or QRESYNC (flag/expunge sync) would move this from importer toward syncer; both are intentionally out of scope for now.
Scheduling: crash-recovery and continuous polling
schedule_imports_task (periodic Beat task) scans active import channels and
re-dispatches those that are "due" — using the same durable last_used_at
heartbeat for both jobs:
- oneshot: due when the heartbeat is stale beyond
MESSAGES_IMPORT_STALL_TIMEOUT→ a crashed run to resume. - continuous IMAP: due when the heartbeat is older than the global
MESSAGES_IMPORT_IMAP_POLL_INTERVALseconds → the next poll. The interval is a boot-validated positive integer (values.PositiveIntegerValue); the whole due-check is one SQL query, so one malformed channel can't abort the scan.
The scheduler does not force-release the run-lock: a genuinely crashed
holder's lock self-expires within the stall window (lock TTL == stall), while a
live-but-slow run keeps renewing it every message (beat), so a redundant
dispatch simply bails on ALREADY_RUNNING. That closes the window where
force-releasing a live run's lock allowed a second runner to double-write it.
Orphaned uploads
Because upload keys are unique per upload (nothing is overwritten), an abandoned
or superseded upload simply leaves a dead object behind. An archive uploaded to
the message-imports bucket but never imported (or a multipart upload started
and never completed) is reclaimed by the bucket's own lifecycle rule —
object Expiration + AbortIncompleteMultipartUpload, both set by
create_bucket — not by an application task. The lifecycle window matches the
import resume window (7 days).
Once an import has run, its source object is no longer needed: Message.channel
holds the delivered mail, and a resume dedups on mime_id/sha256.
The object store must actually apply the lifecycle rule. Some S3-compatible stores (e.g. rustfs, the dev store) accept and persist it but don't implement
ListMultipartUploads, so there is no app-side fallback for dangling multipart uploads — the nativeAbortIncompleteMultipartUploadrule is the only path.
Cancelling
Cancel is cooperative and returns immediately. The API flips the run to
cancelled (mark_cancelled: is_active=False, status, and a Redis cancel
flag) and dispatches cancel_import_task to delete the import's messages and
clean orphaned threads. A run that is currently executing polls that flag
every message (beat) and unwinds via ImportCancelled — so it stops promptly
instead of running to completion and overwriting cancelled with completed.
run_import_task then purges anything it delivered after the API's own purge
snapshot, so no orphaned messages survive. Both the deletion task and the purge
are idempotent.
A cancelled run also disappears from /imports/ entirely: once the purge has
settled, its channel row is deleted (_finish_cancelled_run). Whoever ends the
run removes it — the live worker after its own post-cancel purge, or
cancel_import_task when no worker holds the run lock (the row must survive
while a worker is mid-abort, or its late deliveries would be orphaned). A row a
crashed worker leaves behind is harmless (is_active=False, hidden by the UI).
The purge spares imported messages whose thread has gathered non-import activity since the import (a reply that arrived, a draft or sent reply from the app): cancelling undoes the import, but deleting the anchor of a live conversation would orphan its replies. Messages from other import runs don't count as activity, so cancelling overlapping imports still cleans everything.
API
Imports are a mailbox-nested viewset — IsMailboxAdmin required:
| Method / path | Action |
|---|---|
POST /api/v1.0/mailboxes/{mailbox_id}/imports/ |
Start an import. source=file needs the file_key from the upload endpoint plus filename; source=imap needs the connection fields. 202 |
GET …/imports/ · GET …/imports/{id}/ |
List / read run state (from merged_state) |
POST …/imports/{id}/cancel/ |
Cancel + purge messages, then remove the run from the list (async). 202 |
PATCH …/imports/{id}/ |
Change how it runs: {"mode": "continuous"} (re-)arms an IMAP poller (add "is_active": false to arm it paused), {"mode": "oneshot"} demotes one, {"is_active": false} pauses one (continuous only — pausing a one-shot would strand a running import with no way to resume it) |
DELETE …/imports/{id}/ |
Forget a settled run, keeping its messages (opposite of cancel). Rejects a running or still-polling run. 204 |
Continuous mode is only valid for source=imap: the create serializer rejects
mode=continuous for file sources, and PATCH rejects it for non-IMAP imports.
The poll cadence itself is the global MESSAGES_IMPORT_IMAP_POLL_INTERVAL setting,
not settable per import.
Uploading a file first
A file import is two steps. The client uploads the archive to the
message-imports bucket via POST /api/v1.0/mailboxes/{mailbox_id}/imports/upload/
(direct presigned PUT, or ?multipart for large files with part/, complete,
abort sub-calls) — nested under the same mailbox and gated by the same
IsMailboxAdmin as the import it feeds — then passes the returned file_key
to POST …/imports/. The key
is server-minted and unique per upload (<user-prefix>/<uuid>): nothing in
the bucket is ever overwritten, so a re-upload can't swap the bytes under a
resumable import, and every endpoint re-validates that the key was minted for
the calling user before it touches S3.
Code map
core/services/importer/service.py—start_file_import/start_imap_import: validate + detect format, create the channel, dispatch the run. Called only by the imports API and deliberately does not authorize (the viewset'sIsMailboxAdminowns that); see permissions.md.core/services/importer/channel.py— channel creation, Redis state, lifecycle transitions (mark_started/record_progress/mark_finished/heartbeat), run-lock, cancel/purge, continuous controls.core/services/importer/tasks.py— orchestration: the Celery tasks (run_import_task,cancel_import_task,schedule_imports_task) and the_RUNNERSsource→runner dispatch table.core/services/importer/utils.py— shared, format-agnostic runner primitives (deliver,beat,imports_storage,FLUSH_EVERY,TransientImportError). A leaf module so the format runners import it without a cycle.core/services/importer/{mbox,eml,pst,imap}.py— one per source: each owns itsrun_<format>()resumable pass plus that format's parsing/indexing helpers.