MBOX exports now preserve Gmail labels, Thunderbird read, starred, and attachment flags, along with sent, archived, spam, and trashed states. Export progress, results, and notifications separately report blobless drafts when drafts are omitted. MBOX imports recognize Thunderbird read and starred status and record missing timestamps for trashed and archived messages. Improved handling of escaped From lines, message separators, duplicate messages, and label headers. Then update MBOX import and export documentation with format behavior, label mappings, compatibility details, and roundtrip limitations.
18 KiB
MBOX Import/Export Format
This document describes how Messages handles MBOX files for importing and exporting emails, including how labels, flags, and metadata are preserved.
Overview
MBOX is a standard format for storing email messages in a single file. Messages uses the mboxrd variant, the reversible one, which is compatible with most email clients and tools including:
- Google Takeout
- Thunderbird (via ImportExportTools NG)
- Dovecot
- OfflineIMAP
- Apple Mail
- mu4e / notmuch
Export Format
When exporting a mailbox, Messages creates a gzip-compressed MBOX file (.mbox.gz) containing all messages with their metadata preserved in standard headers.
Drafts written in Messages are not exported: they have no MIME representation yet (the body lives in a JSON blob). When there are any, the notification email counts them on their own line, apart from messages that were skipped because something went wrong. Drafts that came in through an import do have their MIME form, and are exported with X-Status: T and the Drafts label.
Message Flags
Message flags are exported using the standard mbox Status and X-Status headers, which carry IMAP flags and nothing else:
| Header | Flag | Meaning |
|---|---|---|
Status: R |
R | Read (seen) |
Status: O |
O | Old (not recent) - always set for exports |
X-Status: F |
F | Flagged (starred) |
X-Status: T |
T | Draft |
Examples:
Status: RO # Read message
Status: O # Unread message
X-Status: F # Starred message
X-Status: A (Answered) is never written: it means "this message was replied to", which we do not track. It is in particular not a marker for sent mail.
X-Status: D (Deleted) is never written either, trashed messages included: mutt and Dovecot read it as \Deleted and purge the message for good on the next expunge. Trashed messages carry the Trash label instead.
Thunderbird Flags
X-Mozilla-Status (4 hex digits) and X-Mozilla-Status2 (8 hex digits) carry read and starred state in the only form Thunderbird reads. It keeps flags in its .msf index rather than in the mbox, writing them into the file only when a folder is compacted, and it ignores X-Keywords and X-Gmail-Labels entirely, so a file without these headers imports as entirely unread.
Flag values come from Thunderbird's nsMsgMessageFlags.idl:
| Header | Bit | Meaning | Written when |
|---|---|---|---|
X-Mozilla-Status |
0x0001 |
Read | the thread is read in this mailbox |
X-Mozilla-Status |
0x0004 |
Marked | the thread is starred in this mailbox |
X-Mozilla-Status2 |
0x10000000 |
Attachment | has_attachments |
X-Mozilla-Status: 0005
X-Mozilla-Status2: 00000000
Deliberately never written: 0x0008 Expunged, which means "deleted, pending folder compaction" and lets Thunderbird drop the message for good on the next compact. Trashed messages carry the Trash label instead. 0x0002 Replied and 0x1000 Forwarded are states we do not track.
X-Mozilla-Keys (Thunderbird's tags) is stripped on export but never written: its keys are IMAP mod-UTF-7 transformations of tag names, and a key absent from the reader's profile does not display as a tag anyway.
On import, X-Mozilla-Status is read back for the same two bits, which is the only way a Thunderbird mbox carries that state. Two restrictions apply:
- It is only read from file imports (mbox, eml). IMAP and PST report flags themselves and are authoritative, even for a message with no flag set at all.
- It is ignored when the message has an
X-Gmail-Labelsheader. Takeout files and our own exports carry the mailbox state there, and anX-Mozilla-Statusnext to it came with the message from whoever sent it (Mozilla bug 196749).
Its position is not a signal: Thunderbird writes its own after X-Account-Key and X-UIDL for POP3 mail, and when a message already carries one, it keeps that one and updates it in place. Only the first occurrence is read, even when it is empty. In a plain .eml file or a third-party mbox, a sender-written header can therefore mark a message read or starred, exactly as it does in Thunderbird itself.
Labels
Two headers are written, with different audiences:
| Header | Contents | Read by |
|---|---|---|
X-Keywords |
The user's own labels only | OfflineIMAP, mu4e and other tools reading it comma-separated |
X-Gmail-Labels |
System labels, then the user's own labels, except those spelled like a system label | Google Takeout tooling, and our own importer |
A user label spelled like a system label (Trash, Unread, Inbox…) is left out of X-Gmail-Labels, where the importer would read it back as state, and so is one starting with INBOX/ or INBOX., which the importer strips as a folder prefix there. It still goes to X-Keywords, which the importer takes as plain labels, so it survives a roundtrip.
Format: comma-separated list, with quoted strings for labels containing commas, whitespace or double quotes (see Label Header Encoding).
Dovecot's mbox driver reads X-Keywords as a space-separated list, so it would take work, and important as the keywords of X-Keywords: work, important. The comma format is kept for OfflineIMAP and mu, and for our own importer, which reads both.
X-Keywords: work, important, "project alpha"
X-Gmail-Labels: Inbox, Opened, work, important, "project alpha"
System Labels
mbox has no way to say which folder a message came from: Status/X-Status only carry IMAP flags, and IMAP has no per-message "sent" flag (a sent folder is a mailbox attribute, \Sent in RFC 6154). Google Takeout works around this with pseudo-labels in X-Gmail-Labels, and we do the same:
| Label | Written when |
|---|---|
Drafts |
is_draft |
Sent |
is_sender |
Trash |
is_trashed |
Spam |
is_spam |
Archived |
is_archived |
Inbox |
none of the above |
Starred |
the thread is starred in this mailbox |
Opened / Unread |
read state of the thread in this mailbox |
These are not IMAP keywords, which is why they stay out of X-Keywords: Sent there would show up as one of the user's own tags in Dovecot or mu4e.
Complete Example
A read, starred, archived message with labels has these headers injected:
Status: RO
X-Status: F
X-Mozilla-Status: 0005
X-Mozilla-Status2: 00000000
X-Keywords: work, important
X-Gmail-Labels: Archived, Starred, Opened, work, important
Import Format
When importing MBOX files, Messages recognizes and processes the following headers:
Labels Headers
| Header | Source | Format |
|---|---|---|
X-Gmail-Labels |
Google Takeout | Comma-separated, quoted strings |
X-Keywords |
Dovecot/OfflineIMAP/mu4e | Comma or space-separated |
Labels from X-Gmail-Labels go through the special handling below. Labels from X-Keywords are always kept as plain labels: that header holds IMAP keywords, i.e. tags, never folders. Both are parsed by gmail_labels() in core/mda/utils.py, which handles:
- Comma-separated values:
label1, label2, label3 - Space-separated values:
label1 label2 label3(Dovecot format), used when no comma appears outside quoted strings, so"Project, Q3" urgentis two labels - Quoted strings:
"label with spaces", simple-label, where a backslash escapes the next character; anything unquoted is taken literally - RFC 2047 encoded-words in
X-Gmail-Labels, which Takeout uses for non-ASCII label names.X-Keywordsis taken as is, as Dovecot does
Status and X-Status are not read on mbox import. Read and starred state come from the labels below, or from X-Mozilla-Status for a Thunderbird file, which is why the exporter writes that state in three places.
Special Label Handling
Certain labels are mapped to message state instead of being stored as labels (see IMAP_LABEL_TO_MESSAGE_FLAG in core/services/importer/labels.py):
| Label Names | Maps To |
|---|---|
Drafts, [Gmail]/Drafts, DRAFT |
is_draft flag |
Sent, [Gmail]/Sent Mail, OUTBOX |
is_sender flag |
Trash, [Gmail]/Corbeille |
is_trashed flag (with trashed_at) |
Spam, QUARANTAINE |
is_spam flag |
Archived |
is_archived flag (with archived_at) |
Starred, [Gmail]/Starred |
starred_at on the thread's ThreadAccess |
Opened / Unread |
read_at on the thread's ThreadAccess |
Labels like INBOX, Promotions, Social, [Gmail]/Important, and [Gmail]/All Mail are ignored. Each table entry has French spellings too (Messages envoyés, Corbeille, Ouvert…), since those are what a French Takeout or IMAP account produces.
A label in X-Gmail-Labels (or an IMAP/PST folder) that matches one of these names is consumed as state, so a label literally named Sent or Trash there does not survive as a label. In X-Keywords it does.
Sent mail and drafts are imported as read (and so is a message whose From is the mailbox it is imported into). For a file import (mbox, eml), an explicit Opened or Unread label in the message takes precedence, and our own exports always carry one. IMAP and PST imports keep the rule unconditionally.
IMAP Flags
When importing via IMAP, standard IMAP flags are also recognized:
\Seen- marks message as read\Draft- marks message as draft\Flagged- marks message as starred
Roundtrip Compatibility
Messages exported from this system can be re-imported with their state intact, all of it through the label headers:
| State | Carried by |
|---|---|
| Labels | X-Keywords and X-Gmail-Labels |
| Sent | X-Gmail-Labels: Sent |
| Imported drafts | X-Gmail-Labels: Drafts |
| Trashed / spam / archived | X-Gmail-Labels: Trash / Spam / Archived |
| Read / unread | X-Gmail-Labels: Opened / Unread |
| Starred | X-Gmail-Labels: Starred |
Message bodies come back byte for byte, including lines starting with From and trailing empty lines (see MBOX variant).
Covered end to end in core/tests/exporter/test_export_task.py by test_export_reimport_roundtrip_preserves_flags (state) and test_export_reimport_roundtrip_preserves_body_bytes (content), both exporting a mailbox and re-importing it into another one.
Known limitations:
- Drafts written in Messages are not exported, so they cannot come back.
- A message whose last line has no line ending comes back with one: mbox cannot express its absence.
- Each label header carries at most 8 KiB of label names (dozens of 255-character labels, hundreds of ordinary ones); labels past that are left out of it. Without the cap, the importer's parser would reject the whole message (it refuses any header value over 100 KiB), and the
Dateit orders messages by would sit past the 64 KiB it reads. - A label too long for one 998-octet
X-Keywordsline (only possible with multi-byte characters, a label being at most 255 of them) travels inX-Gmail-Labelsalone. If it also starts withINBOX/orINBOX., it does not come back.
Compatibility Notes
Google Takeout
Google Takeout exports use the X-Gmail-Labels header. When importing Google Takeout files:
- All Gmail labels are recognized and imported
- System labels like
[Gmail]/Draftsare mapped to flags - Custom labels are preserved as-is
Thunderbird
Read and starred state survives, through X-Mozilla-Status (see Thunderbird Flags). Tags do not:
- Thunderbird can import our MBOX files
- Use ImportExportTools NG for best results
- Its native tags are IMAP keywords (
$label1,$label2, …) stored inX-Mozilla-Keys, which we do not write, so our labels reach it inX-Keywordsand do not display as tags - Everything lands in a single folder: we export one flat file, where Thunderbird expects one mbox file per folder
Dovecot
Dovecot uses X-Keywords with space-separated values. Our importer handles both formats:
- Space-separated:
X-Keywords: work important urgent - Comma-separated:
X-Keywords: work, important, urgent
Apple Mail
Apple Mail's export format (.mbox packages) can be imported. However, Apple Mail does not preserve labels in its exports, so label information may be lost when migrating from Apple Mail.
Technical Details
MBOX Variant
We use the mboxrd format, where:
- Messages are separated by "From " lines at the start of a line
- "From " at the beginning of a line is escaped as ">From ", and an already escaped
>Frombecomes>>From, and so on - Each message ends with exactly one blank line of separation, added after its own last line, even when the body already ends with empty lines
Escaping the already-escaped forms is what makes mboxrd reversible: a reader strips exactly one > and gets the original bytes back. mboxo, which escapes only the bare From , cannot be reversed, because a line the author really did start with >From is indistinguishable from an escaped one.
On import we strip one > from any >+From line, and drop the blank line that separates two messages (the message's byte range runs up to the next From line, so it ends with that blank line). Together with the export side, a message body survives a roundtrip byte for byte, which keeps DKIM body hashes valid. The header block does not: the metadata headers are added and stale copies removed (see Header Injection).
The one place where the mboxo ambiguity still bites is a third-party mboxo file, Google Takeout's among them: a body line that genuinely started with >From there loses its >. Not unescaping at all is worse, since then every line the writer escaped keeps a > it never had.
Line Endings
RFC 4155 says an mbox database MUST use a bare LF and MUST NOT use CRLF. We do not convert: messages are written with the line endings they were stored with, which for anything that arrived over SMTP means CRLF.
This is deliberate. Converting to LF is one-way (the original bytes cannot be restored on the way back in) and it breaks the DKIM body hash, so byte fidelity wins over conformance here. It also costs nothing in practice: Google Takeout writes CRLF too, so anything that reads a Takeout archive reads ours. Our own test fixture core/tests/resources/messages.mbox is a real Takeout file and contains zero bare LFs.
Separator Detection
A postmark is a From line with a sender (possibly empty) and a date: a weekday later followed by a month name and a digit, which covers ctime (From <sender> Mon May 26 20:18:05 2025, as Takeout, Thunderbird, mutt and Python write it) and RFC 822 dates (Mon, 26 May 2025), or an ISO 8601 timestamp. A bare sender line is From followed by an address, - or MAILER-DAEMON alone. A header line is <name>:, with or without a space after the colon (Subject:x counts), but not <name>://, so that a URL is not taken for one.
A From line is treated as a message separator when it is followed by a header line, and either holds:
- It sits at the start of the file or directly after an empty line, and it is a postmark or a bare sender line
- It sits anywhere else and is a postmark
The first separator of the file is the exception: at the start of the file or after an empty line, a postmark followed by any non-empty line is enough. There is no message yet for it to split, and rejecting it would drop its message.
Lines over 1000 bytes (line ending included) are never separators, since a real one is a sender and a date. Only their first 1000 bytes are read, which is enough to tell a header line, so the scan keeps bounded memory and linear time whatever the line lengths in the file.
Without these checks, a body paragraph beginning "From my point of view..." after a blank line splits the message in two, and the fragment is delivered as its own message with no error anywhere. That is the oldest bug in the format (Mozilla bug 355237). Rule 2 covers writers that put no empty line between messages: rejecting those separators would merge each message into the body of the previous one. Rejected From lines are counted and logged once per file, since a writer that failed to escape them usually failed for all of them.
What cannot be told apart: an unescaped body line that is itself a genuine postmark (someone pasting a raw mbox excerpt, or prose with a word, then a weekday, a month and a number, like "From me Mon to Fri in May 2024") followed by a header-like line (Note: read this) splits the message, and so does a bare sender line followed by one after an empty line; a separator in none of these shapes is not recognised and its message merges into the previous one (both are logged); and in a file without empty separator lines, a body that ends on an empty line loses it.
Header Injection
When exporting, the metadata headers (Status, X-Status, X-Mozilla-Status, X-Mozilla-Status2, X-Keywords, X-Gmail-Labels) are prepended at the top of the message, before the Received: chain, so the chain keeps its chronological descending order.
Any copy of those headers already present in the stored message is stripped first, along with X-Mozilla-Keys, and so are continuation lines at the very top that belong to no header, which would otherwise extend the last prepended one. A lone CR counts as a line break there, as it does for the message parser. Only the header block is touched. A message imported from Takeout or an IMAP server keeps the headers it arrived with, the mailbox state has moved on since, and the importer reads every occurrence of the label headers: without stripping, a re-import would resurrect stale labels and flags.
Label Header Encoding
- Labels containing commas, whitespace, or double quotes are enclosed in double quotes, with
\and"escaped by a backslash (RFC 5322 quoted-pair) - Non-ASCII label names are RFC 2047 encoded in
X-Gmail-Labels, as Takeout does, and so is a value containing=?, which would otherwise be decoded on import - They stay raw UTF-8 in
X-Keywords: keyword readers such as Dovecot take that header as is, and would see an encoded value as an opaque keyword - Long values are folded: RFC 5322 caps a line at 998 octets, and a thread with a handful of labels goes past that on one line. Folding happens between labels (or between encoded-words), never inside one: unfolding on import collapses a run of spaces a fold lands on
- Control characters in a label name (C0 and C1, and the Unicode line and paragraph separators) are replaced with a space.
Label.nameis user input with no validation, and a raw CR/LF there would end the header and turn the rest of the name into forged headers, which a re-import would read back as real ones