📤 Date-Windowed Offload¶
📋 Overview¶
An offload appends archived mail newer than a cutoff into another mailbox, and leaves everything older in the archive. It exists for migrations where the new mailbox should hold only recent mail while the full history stays reachable through Mail Archiver.
It differs from Copy All Emails to Another Mailbox in five ways:
| Copy All Emails | Offload | |
|---|---|---|
| Date filter | none, all or nothing | cutoff, with an optional upper bound |
| Repeatable | no, appends everything again | yes, skips what the target already holds |
| Folder exclusions | no | yes, configurable |
| Folder renaming | no | yes, configurable |
| Preview | no | dry run reports per folder without writing |
The offload only ever appends to the target. It never deletes from the archive and never touches the source mailbox.
🔁 Repeating a run is safe¶
Before the first append, the whole target mailbox is indexed and every message is checked against it. A repeated run therefore appends nothing that already arrived, and reports it as already present instead. That has three practical consequences:
- An interrupted run is recovered by simply running it again.
- A finished run can be repeated as a verification pass. A correct result is nothing appended and everything already present.
- Messages deleted on the target come back on the next run, because they are no longer in the index.
Two criteria decide whether a message is already present, in this order:
- Message-ID, normalised. This does nearly all of the work.
- A fingerprint over sender, recipients, subject and send time within two seconds. This covers the messages whose stored Message-ID is missing or malformed. Such messages cannot be restored with their original Message-ID at all, so without this second criterion they would duplicate on every single run.
The job reports how many matches came from the fingerprint separately, because it is the criterion that could fail quietly.
⚙️ Configuration¶
A new Offload section, with defaults that reproduce the previous behaviour:
"Offload": {
"MaxConcurrentJobs": 1,
"PrefetchMaxMessages": 500000,
"ExcludedSourceFolders": [],
"FolderRenameMap": {},
"MarkAsSeen": true
}
| Key | Default | Purpose |
|---|---|---|
MaxConcurrentJobs |
1 |
How many restore or offload jobs may run at once. One keeps the strictly serial behaviour the job queue always had. At most one job per target mailbox runs regardless of this value. |
PrefetchMaxMessages |
500000 |
Upper bound on how many messages are indexed from a target mailbox. Above it the duplicate check narrows to a single folder and logs that it did. |
ExcludedSourceFolders |
empty | Source folders never offloaded. Matched before renaming, and covering subfolders. |
FolderRenameMap |
empty | Rewrites the leading segments of a source folder path. |
MarkAsSeen |
true |
Whether appended mail is flagged as read. |
Both folder settings ship empty on purpose. Rewriting or dropping folders without being asked would surprise anyone already using the restore path, so a run with no configuration migrates everything, spam folders included, and creates a second set of special folders next to the target's own.
Folder exclusions and renaming¶
For an Exchange to Dovecot migration the two settings usually look like this:
"ExcludedSourceFolders": ["Junk E-Mail", "Deleted Items"],
"FolderRenameMap": {
"Sent Items": "Sent",
"Deleted Items": "Trash",
"Junk E-Mail": "Junk"
}
Without the rename map every migrated mailbox ends up with both Sent and Sent Items, because
Dovecot pre-creates its own special-use folders.
Four things about how these are applied are worth knowing, because each one is a way to be surprised:
- Exclusions are matched first, against the source name. If renaming happened first, an
exclusion on
Deleted Itemswould never fire once it had already becomeTrash. - Renaming rewrites the longest matching path prefix, on segment boundaries.
Sent ItemsbecomingSentalso turnsSent Items/2019intoSent/2019, and leavesSent Items Archivealone. - Matching is case insensitive, and both
/and\count as separators. - Two source folders may collapse onto one target.
Sent ItemsandSentboth becomingSentis fine and does not open the target folder twice.
Changing the rename map between two runs does not duplicate anything, because the duplicate check covers the whole target mailbox rather than one resolved folder.
🖥️ Running it from the interface¶
On an account's detail page, Offload to Another Mailbox opens a form with the target mailbox, the target root folder, whether to preserve the folder structure, the date window, a dry run switch, and whether to mark appended mail as read. The configured exclusions and rename map are shown read-only, so a run is never a surprise.
The job is queued and its progress and per-folder report appear on the job status page and under Jobs. The per-folder report names source folders, so it is shown only to the user who started the job and to administrators.
The target mailbox may be a disabled IMAP account, e.g. to provision a migration target before it goes live; the run fails loudly on connect if the target is unreachable. Microsoft 365 accounts cannot be offload targets; the Graph restore path is unchanged.
Who may run it¶
Administrators can offload between any two accounts. A self-manager can offload only between the accounts assigned to them, at both ends: the source, because the account page is already scoped that way, and the target, because the target list is narrowed to the same set and the request is checked against it again when the job is started. A self-manager with a single assigned account therefore has no eligible target and is told so.
Scoping comes from IAccountAccessResolver, the same resolver the REST API and the MCP server
use, so there is one definition of who may use which mailbox. The decision itself lives in
OffloadTargetEligibility, which both the target list and the request check call, so what the
form offers and what the server accepts cannot drift apart.
⌨️ Running it from the command line¶
docker compose exec mailarchive-app dotnet MailArchiver.dll \
--offload --source-account-id 3 --target-account-id 7 \
--since 2025-08-01 \
[--until 2026-08-01] [--target-folder INBOX] \
[--preserve-folders] [--dry-run] [--no-mark-seen]
| Argument | Meaning |
|---|---|
--source-account-id |
Account to read archived mail from. Required. |
--target-account-id |
IMAP account to append into. Required, and must differ from the source. |
--since |
Inclusive lower bound on the send date, YYYY-MM-DD. Required. |
--until |
Optional inclusive upper bound. |
--target-folder |
Root folder in the target mailbox. Defaults to INBOX. |
--preserve-folders |
Recreate the source folder structure below the root. |
--dry-run |
Report what would happen, append nothing. |
--no-mark-seen |
Append without the Seen flag. |
Exit codes:
| Code | Meaning |
|---|---|
0 |
Everything was appended or already present. |
1 |
At least one message failed, or the run itself failed. |
2 |
The invocation was wrong: bad or missing arguments, unknown account, target not IMAP, source equal to target. |
One invocation handles one mailbox, which makes a fleet of mailboxes scriptable from a CSV of source, target and cutoff.
Running several at once. The one-job-per-target rule only exists inside a single process, so two concurrent command line invocations against the same target mailbox could each take their duplicate index before the other starts writing, and could then both append the same mail. Partition the work so that every concurrent invocation has a different target, which is the normal case when each row of a CSV is a different mailbox.
🗓️ How the cutoff is interpreted¶
- The filter is on the send date, not on when a message was archived. The archived-at timestamp is the time of the archiving run and carries no meaning for a cutoff.
- A relative window such as "the last twelve months" is resolved to a fixed date when the job is created. A job may run for a long time and be repeated afterwards, and it has to select the same mail every time; a stored relative window would drift.
- Cutoffs are interpreted in the configured display timezone, the same one the search screen
shows, and the lower bound is inclusive from 00:00 of that day. An upper bound includes the
whole of its day. Relative windows ("the last N months") are resolved in that display
timezone as well, matching how
SentDateis stored.
Known limitation¶
- When the duplicate index hits
Offload:PrefetchMaxMessages, the scope is reduced and logged rather than retried against a single folder. TherestrictToFolderfallback the index builder offers is currently not used by the offload path; raising the limit is the supported remedy.
📋 A migration, end to end¶
- Create the target mailboxes. For many at once see the CSV bulk import in Account Import.
- Configure
Offload:ExcludedSourceFoldersandOffload:FolderRenameMap. Both are empty by default, so skipping this migrates spam folders and creates duplicated special folders. - Confirm each source mailbox is fully archived before offloading it.
- Dry run everything. Check the counts and the resolved target folder names before anything is written.
- Real run. Start with
MaxConcurrentJobsat 2 and raise it only after watching how the target server copes; Dovecot limits concurrent connections per user and per IP. - Repeat the run to verify. Expect nothing appended and everything already present.
- Disable the source accounts.
Throughput is capped by BatchOperation:PauseBetweenEmailsMs, which is 50 ms by default and so
allows at most twenty appends per second before server latency is counted.
🔍 What the counters mean¶
| Counter | Meaning |
|---|---|
| Appended | Written to the target. On a dry run, what would have been written. |
| Already present | The target already held it, by either criterion. |
| of those by fingerprint | How many were recognised by the second criterion rather than by Message-ID. |
| Excluded folder | Skipped because the source folder is excluded. |
| Failed | Could not be appended. |
A warning is reported if the duplicate index did not cover the whole target mailbox, which
happens above PrefetchMaxMessages or when a folder could not be read. In that state a repeated
run may append mail that is already present in the parts that were not indexed.
⚠️ Notes and limits¶
- Restored mail now carries its original delivery time as the IMAP internal date, taken from
the message's
Receivedchain and falling back to the send date. It previously carried the time of the archiving run, which broke server side sorting by arrival and any age based rule on the target. - Because a repeated run skips what is already present, a wrong internal date cannot be corrected by re-running: the messages are recognised and skipped. Get this right before a production run, or delete the affected messages on the target and run again.
- Microsoft 365 is not supported as an offload target.
- Duplicate detection can only recognise what it can see in the target mailbox. Mail that reached the target by some other route is caught only insofar as the two criteria happen to match it.