# onemail docs, all pages (OneMail 0.3.0-preview.2) Every page of https://recursiveai.org/onemail/docs/ and https://recursiveai.org/onemail/cookbook/ as markdown, getting started first. Index: https://recursiveai.org/onemail/llms.txt # Getting started with OneMail Install OneMail 0.3.0-preview.2 for .NET 10, parse an email, and open an mbox, an Outlook MSG file and a PST store with the same call. Pure C#, with no Outlook, COM or native library. ## Install OneMail is a preview: the API can change before 1.0. The packages target `net10.0`, so you need the .NET 10 SDK, and `dotnet add package` needs `--prerelease` or the exact version. ```shell dotnet new console -o OneMailStart cd OneMailStart dotnet add package RecursiveAI.OneMail --version 0.3.0-preview.2 ``` Three packages are on NuGet at 0.3.0-preview.2: - **`RecursiveAI.OneMail`**: the mail formats, the read view, conversion and display. It brings in `RecursiveAI.OneMail.Calendar`, `RecursiveAI.OneArchive` and `RecursiveAI.OneHtml`. - **`RecursiveAI.OneMail.Calendar`**: ICS and vCard (and jCal, jCard, xCal, xCard), recurrence and time zones. It depends only on .NET. - **`RecursiveAI.OneMail.Cli`**: the `onemail` command-line tool. Install it with `dotnet tool install -g RecursiveAI.OneMail.Cli --prerelease`. ## A first program Download four public test files: an `.eml` and an mbox from Apache Tika, an Outlook message from Apache POI, and Tika’s PST store. ```shell curl -LO https://raw.githubusercontent.com/apache/tika/4.2.0/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-mail-module/src/test/resources/test-documents/testRFC822_multipart_attachments.eml curl -LO https://raw.githubusercontent.com/apache/tika/4.2.0/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-mail-module/src/test/resources/test-documents/complex.mbox curl -LO https://raw.githubusercontent.com/apache/poi/REL_5_4_1/test-data/hsmf/attachment_test_msg.msg curl -LO https://raw.githubusercontent.com/apache/tika/4.2.0/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testPST.pst ``` Put this in `Program.cs` and run `dotnet run`: ```csharp using RecursiveAI.OneMail; // One message from bytes IMailItem eml = Mail.Parse(File.ReadAllBytes("testRFC822_multipart_attachments.eml")); Console.WriteLine($"{eml.Subject} from {eml.From[0].Address}"); Console.WriteLine($"{eml.Attachments.Count} attachments"); // Any file: the content decides the format, not the extension string[] files = ["complex.mbox", "attachment_test_msg.msg", "testPST.pst"]; foreach (string path in files) { using MailContainer mail = Mail.Open(path); Console.WriteLine($"{path}: {mail.Format}"); foreach (IMailItem item in mail.Enumerate().Take(3)) Console.WriteLine($" {item.Sent?.Value:yyyy-MM-dd} {item.Subject}"); } ``` ```text EML 1 from Bugs.Bunny@acme.com 2 attachments complex.mbox: Mbox 2009-06-01 Re: question about when shuffle/sort start working 2009-05-31 Re: question about when shuffle/sort start working 2009-06-01 Re: question about when shuffle/sort start working attachment_test_msg.msg: Msg 2009-04-22 test pièce jointe 1 testPST.pst: Pst 2014-02-26 Re: Feature Generators 2014-02-25 Re: init tokenizer fails: "Bad type in putfield/putstatic" 2014-02-25 Feature Generators ``` The second mbox date reads May 31 because the line prints each date in the sender’s own time zone; `{item.Sent?.Value:u}` prints UTC. ## The two entry points - **`Mail.Parse(bytes)`** parses one MIME message (`.eml`) and returns an `IMailItem`. - **`Mail.Open(path)`** reads the first bytes to decide the format, then returns a `MailContainer`. A single message (`.eml`, `.msg`, `winmail.dat`) holds one item. A store (mbox, Maildir, PST, OST, `.dbx`) holds many, read as you enumerate. Every item has the same read view, `IMailItem`: `Subject`, `From`, `ToRecipients`, `Cc`, `Sent`, `TextBody`, `HtmlBody`, `RtfBody`, `Attachments`, `Defects` and `Provenance`. Accessors never throw on malformed input: each repair is a typed defect with its byte offset. Under the view, every layer is public: the MIME reader and writer, the mailbox stores, the PST layers, MSG, TNEF and the MAPI property model. ## Next - Task by task: the [cookbook](https://recursiveai.org/onemail/cookbook/), with recipes for [MSG](https://recursiveai.org/onemail/cookbook/read-msg-file/), [PST](https://recursiveai.org/onemail/cookbook/read-pst-file/), [OLM](https://recursiveai.org/onemail/cookbook/read-olm-file/), [MSG to EML](https://recursiveai.org/onemail/cookbook/convert-msg-to-eml/), [PST to mbox](https://recursiveai.org/onemail/cookbook/convert-pst-to-mbox/), [attachments](https://recursiveai.org/onemail/cookbook/extract-attachments/) and [calendars](https://recursiveai.org/onemail/cookbook/read-ics-file/). - What is checked, and every known gap: [Fidelity and known gaps](https://recursiveai.org/onemail/docs/compatibility/). - Not in this preview: writing MSG or PST, SMTP, IMAP and POP3, and DKIM, S/MIME and OpenPGP. # OneMail An email library for .NET with no dependency beyond the .NET base class library and its sibling libraries (OneArchive, OneHtml, OneRender). It reads and writes the formats mail lives in, keeps what it reads lossless, and says what it repaired instead of throwing. > **Early preview.** MIME, the mailbox stores, ICS/vCard trees with recurrence and time zones, and Outlook items on read (MSG/OFT, PST/OST, TNEF, compressed RTF, MAPI↔MIME conversion) work today; Outlook writing, protocols and cryptography are still to come. Nothing here is a stable API yet. What is checked, and the known gaps: [docs/compatibility.md](https://recursiveai.org/onemail/docs/compatibility/). > > **Display HTML is not yet independently reviewed.** The sanitiser behind `ToDisplayHtml` (`RecursiveAI.OneMail.Display`) has not had its independent security review. Do not rely on it to make untrusted mail safe to show until a release says the review has passed. ```csharp using RecursiveAI.OneMail; IMailItem message = Mail.Parse(File.ReadAllBytes("message.eml")); Console.WriteLine($"{message.Subject} from {message.From[0].Address}"); using MailContainer mbox = Mail.Open("archive.mbox"); foreach (IMailItem item in mbox.Enumerate()) { Console.WriteLine(item.Subject); } ``` ## Goals - **Reads:** MIME/EML, MHTML, TNEF, MSG/OFT, PST and OST, MBOX variants, Maildir, EMLX, OLM, DBX, ICS and vCard (and their JSON/XML forms). - **Writes:** the exchange formats, MSG/OFT and Unicode PST. - **Lossless and lenient:** untouched MIME, ICS and vCard write back byte for byte; Outlook data round-trips property for property; every repair of malformed input is a typed defect with its byte offset. - **Every layer public:** random-access sources, the MIME reader and writer, the PST layers and the raw protocol layer, with a one-line happy path on top. - **Protocols and cryptography** in separate packages: SMTP, IMAP, POP3, SASL; DKIM, ARC, S/MIME and OpenPGP. - **NativeAOT, trimming and browser WebAssembly** (format core) are supported targets from the start. ## Packages All packages target `net10.0` and share one version. | Package | Contents | Depends on | |---|---|---| | `RecursiveAI.OneMail.Calendar` | ICS, vCard, JSON/XML forms, recurrence, time zones | .NET only | | `RecursiveAI.OneMail` | the formats, the models and the one-line entry points | `.Calendar`, `RecursiveAI.OneArchive`, `RecursiveAI.OneHtml` | | `RecursiveAI.OneMail.Cli` | the `onemail` command-line tool | `RecursiveAI.OneMail` | These three are on nuget.org. `RecursiveAI.OneMail.Client` (SMTP, IMAP, POP3, SASL), `.Cryptography` (DKIM, ARC, S/MIME, OpenPGP) and `.Render` (a message to PDF or an image) come in later previews. ## Clean room OneMail is written from public specifications (RFCs, Microsoft's Open Specifications, WHATWG) and from observing released programs as black boxes. The source of other mail libraries is not read. ## Not affiliated This project is unaffiliated with other products named OneMail. ## Building See [CONTRIBUTING.md](https://github.com/recursive-ai-oss/onemail-csharp/blob/bb4c9543917ab4ad466b5200d132e1899cb3ca14/CONTRIBUTING.md). ## Licence MIT OR Apache-2.0, at your option: [LICENSE-MIT](https://github.com/recursive-ai-oss/onemail-csharp/tree/bb4c9543917ab4ad466b5200d132e1899cb3ca14/LICENSE-MIT), [LICENSE-APACHE](https://github.com/recursive-ai-oss/onemail-csharp/tree/bb4c9543917ab4ad466b5200d132e1899cb3ca14/LICENSE-APACHE). Copyright (c) Recursive AI, LLC. # The shared read view and `Mail.*` Every item OneMail reads (a MIME message, an MSG, a PST/OST message; calendar items and others as their readers arrive) implements one read-only interface, `IMailItem`, in the root namespace `RecursiveAI.OneMail`. It is a **projection**: it copies nothing from the model underneath, parses only what the caller asks for, and never throws on malformed content. Code that only needs to read mail depends on the view; code that needs every byte and every defect goes to the model (for MIME, `MimeMailItem.Entity`). ## The view | Member | Meaning | |---|---| | `Kind` | `Message`, `Appointment`, `Contact`, `Task`, `Note`, `Journal`, `DistributionList`, `Other`. MIME is always `Message`. | | `Subject` | Decoded subject, or null. | | `Sender`, `From`, `ToRecipients`, `Cc`, `Bcc`, `ReplyTo` | `MailAddress(Name, Address, X500Address)` (`X500Address` is only set for Outlook items whose address is an Exchange address); groups flattened (V7). Only the first field of each name is read (H2). | | `Sent`, `TryGetDate(source, out value)` | A date and where it comes from: `DateHeader`, `ClientSubmit`, `Delivery`, `Creation`. MIME has `DateHeader`; a store adds `Delivery` (mbox envelope date, Maildir modification time, Apple Mail `date-received`). `ZoneKnown` is false when the source states no zone. MAPI items add the other two. | | `MessageId`, `InReplyTo`, `References` | Without angle brackets. | | `ConversationIndex`, `ConversationTopic` | MAPI properties; for MIME the `Thread-Index` (base64) and `Thread-Topic` headers when present, else empty/null. | | `TextBody`, `HtmlBody`, `RtfBody` | `MailBody`: `ContentType`, `Charset`, `Open()` (decoded bytes), `ReadText()`. **Raw candidates, not the best body**: choosing between them is later work. For an Outlook item whose RTF was written as RTF, `HtmlBody` and `TextBody` are converted from it when the item has no such body of its own (`MailBody.Origin` is `RtfConverted`; [`rtf.md`](https://recursiveai.org/onemail/docs/rtf/)). MIME has no RTF. | | `Attachments` | `IMailAttachment`: `FileName`, `ContentType`, `ContentId`, `IsInline`, `Size` (decoded; computed on first use by decoding once), `Open()`, `Embedded` (an `IMailItem` for `message/rfc822`). Built on first use. `ContentLocation` (`Content-Location`, or `PidTagAttachContentLocation`) is the other name HTML can use for a part (RFC 2557). `cid:` and location references are resolved against `ContentId`/`ContentLocation` by `ToDisplayHtml` ([`display-html.md`](https://recursiveai.org/onemail/docs/display-html/)). | | `Defects` | One shape (`MailDefect`: family, code, offset, ambiguity flag) over each family's type: `mime` (the tree's defects, then those of the structured headers read), `mailbox` (the store reader's bits for this message). Evaluated on each enumeration. | | `Provenance` | Format of the container, location, folder, byte offset and length of the item in its container file (-1 when not a range), index. | Offsets in a `mime` defect are relative to the item's own first byte (what the MIME reader sees), not to the container file; add `Provenance.Offset` for a file position (for an mboxrd store whose body lines were unquoted, positions after the first unquoted line shift by the removed `>` bytes). ### Bodies and attachments for MIME - **Text/HTML**: `multipart/alternative`: the last matching child (RFC 2046 5.1.4); `multipart/related` and `multipart/signed`: the first child only; any other multipart: the first matching child in order; a `message/*` part is not searched. A text part with `Content-Disposition: attachment` is never a body. Text decoding: the stated charset; if absent or unknown, UTF-8 when valid, else Windows-1252. - **Attachments**: every leaf part and `message/*` part that is not the chosen text/HTML body, in document order, except a text part directly in a `multipart/alternative` that is not marked `attachment` (that is another rendering of the body). A message that is one leaf part is its own attachment. `IsInline` is true for a part that is not marked `attachment` and has a `Content-ID`. The resolutions are V1–V8 in [`ambiguities.md`](https://recursiveai.org/onemail/docs/ambiguities/). - An embedded `message/rfc822` is an `IMailItem` with the same view, parsed from the same bytes. - **A TNEF part** (`application/ms-tnef`, winmail.dat, found by content) that qualifies under MS-OXCMAIL 2.2.3.8 (`conversion.md`, TNEF parts) is shown the way Outlook shows it, not as a file (V9): the part is not listed as an attachment; the attachments the TNEF holds (embedded messages included, each an item with its own view) take its place in document order; `RtfBody` is the TNEF's compressed RTF decompressed; `TextBody` and `HtmlBody` are the TNEF's (an HTML property, or the HTML or text de-encapsulated from its RTF) when the MIME side has none of that kind. The MIME parts always win. A part that does not qualify (damaged stream, correlation key mismatch) stays one ordinary attachment. `MimeMailItem.TnefParts` lists the unpacked parts (the raw bytes are `part.GetDecodedContent()`; the tree is untouched) and `TnefMessages` their MAPI messages. A stream ending in CR LF is unpacked and the item reports the defect `AmbiguousTnefTrailingCrLf` (family `tnef`). Header fields are never taken from a TNEF: the view of a MIME item reads its headers only. ## MAPI items (MSG, PST, OST) `MapiMailItem` projects a `MapiMessage` (`docs/mapi.md`) onto the same view. Nothing is looked up outside the item. | Member | From | |---|---| | `Kind` | `PidTagMessageClass` by whole dot-separated segment, case-insensitive: `IPM.Appointment` Appointment, `IPM.Contact` Contact, `IPM.Task` Task, `IPM.StickyNote` Note, `IPM.Activity` Journal, `IPM.DistList` DistributionList; `IPM.Note`, `IPM.Post`, `IPM.Schedule`, `IPM.TaskRequest`, `IPM.Recall`, `IPM.Outlook.Recall`, `IPM.Sharing` and `REPORT` Message; no class Message; anything else Other. So `IPM.TaskRequest` is a message and not a task. | | `Subject` | `PidTagSubject` with the leading U+0001 plus one-character pair removed (the marker of the normalised-subject split, MS-OXPROPS). | | `From` | `PidTagSentRepresenting*`, else `PidTagSender*` (MS-OXCMAIL 2.1.3.1). | | `Sender` | `PidTagSender*`, only when a representing sender exists and has a different address; else null. | | address of a person | the SMTP address (`Address`). An Exchange (`EX`) address is resolved without a directory, in the order of [`conversion.md`](https://recursiveai.org/onemail/docs/conversion/) (OX1): its own `PidTagSmtpAddress` family property; another sender or recipient of the message with the same distinguished name; the transport `From`/`Sender` field's single mailbox for the sender groups; a unique display-name match in the transport headers. The distinguished name stays in `X500Address`. When nothing resolves, `Address` is that distinguished name (as before) and `X500Address` is the same. An address of another type (X.400, fax) is the stored address. The name is the display name (`PidTagRecipientDisplayName` for a recipient row without one). | | `ToRecipients`, `Cc`, `Bcc` | recipient table rows by `PidTagRecipientType` 1, 2, 3, in table order. `ReplyTo` is read from `PidTagReplyRecipientEntries`: one-off EntryIDs (MS-OXCDATA 2.2.5.1) give name, type and address; other entry kinds need a directory and are left out. | | `Sent`, `TryGetDate` | `PidTagClientSubmitTime` (`ClientSubmit`), `PidTagMessageDeliveryTime` (`Delivery`), `PidTagCreationTime` (`Creation`). FILETIME in UTC, so `ZoneKnown` is false. | | `MessageId`, `InReplyTo`, `References` | `PidTagInternetMessageId` (0x1035), `PidTagInReplyToId` (0x1042), `PidTagInternetReferences` (0x1039), angle brackets removed; when the property is absent, the same-named field of `PidTagTransportMessageHeaders`. | | `ConversationIndex`, `ConversationTopic` | the two properties. | | `TextBody` | `PidTagBody`; when absent and the RTF body is a plain-text encapsulation (MS-OXRTFEX), the text from the RTF (UTF-8). `HtmlBody`: `PidTagHtml` (binary) or `PidTagBodyHtml` (string); when absent and the RTF body encapsulates HTML, the HTML from the RTF (UTF-8). `RtfBody`: `PidTagRtfCompressed` **decompressed** (MS-OXRTFCP), absent when it does not decompress. A body is absent when nothing gives it. | | body text and charset | A string property is UTF-16LE (`Charset` utf-16le). An 8-bit text body uses the message's code page rule (`docs/msg.md`); binary HTML uses `PidTagInternetCodepage` (1200 means UTF-16LE), else the same rule; RTF has no charset. | | `Attachments` | the attachment table in order. `FileName` long name, else short name, else display name. `ContentType` `PidTagAttachMimeTag` lower-cased, `message/rfc822` for an embedded message, else `application/octet-stream` unless the file name's extension is in the built-in table (see Attachment content types). `ContentId` without angle brackets. `IsInline`: has a content ID and `PidTagAttachContentDisposition` is not `attachment`. `Size`: length of `PidTagAttachDataBinary`, else `PidTagAttachSize`. `Embedded`: the embedded message as a `MapiMailItem`. | | `Defects` | the model's `MapiDefect`s (message, recipients, attachments, embedded messages), family `mapi`; ambiguity flag from the kind. | | `Provenance` | PST/OST: format, file, folder path, index in enumeration order; offset and length are -1 (a message is not a byte range). MSG: format, file, offset 0, the file's length. | PST/OST enumeration through `Mail.Open` yields ordinary messages of non-search folders in folder order (`docs/pst-messaging.md`). ## Attachment content types `IMailAttachment.ContentType` is the stored type whenever the item stored a specific one. Only a missing type or the generic `application/octet-stream` (MIME part, `PidTagAttachMimeTag`, OLM `OPFAttachmentContentType`; TNEF attachments are read as MAPI attachments) is replaced, and only when the file name has an extension in a small built-in table: office and PDF, text, calendar and contact, message, image, audio, video and archive types. The last extension counts, case-insensitively; a directory part of the name is ignored. There is no operating-system lookup, so the answer is the same on every platform and in the browser. When the type was guessed this way `ContentTypeInferred` is true; otherwise (including when nothing in the table matches and the type stays `application/octet-stream`) it is false. An embedded message is always `message/rfc822`. The raw entity, property bag or element is untouched: only the view infers, so round-trips and conversions that read the model keep the stored bytes. ## Opening things: `Mail.*` | Call | Result | |---|---| | `Mail.Parse(bytes \| stream, MimeReaderOptions?)` | One `IMailItem`. | | `Mail.Open(path \| stream \| source, MimeReaderOptions?, MboxReaderOptions?)` | A `MailContainer`: the sniffed `Format`, `IsStore`, `Enumerate()` and `await foreach`. A single message (EML, MSG) is a container of one; a PST or OST is a store. A PST/OST or MSG that is such by content but cannot be read at all throws `InvalidDataException`. | Each `Mail.*` method is one call into the layer that does the work; the options are those layers' own option types. `MailContainer` lifetime: the container owns the file it opened from a path (a stream or source passed in stays the caller's). Items read their bytes when first used, so they are valid until the container is disposed, after which reading an unread item throws `ObjectDisposedException`. Enumeration holds one message's state at a time. A source without synchronous reads is handled by the async enumeration (mbox messages are loaded before the item is yielded). ### Sniffing `MailFormatSniffer.Sniff(source)` and `.SniffDirectory(path)` decide by content; the file name is never read. | Evidence | Format | |---|---| | `D0 CF 11 E0 A1 B1 1A E1`, and the UTF-16 name `__properties_version1.0` in the first 16 MiB | `Msg` (MS-OXMSG); otherwise `CompoundFile` | | `!BDN`, client magic `SO` at offset 8 / other | `Ost` / `Pst` | | `78 9F 3E 22` | `Tnef` | | first line is an mbox envelope line (`From ` + sender + date) | `Mbox`; `EMLX`: byte count, message, then end of file or a property list | | `BEGIN:VCALENDAR` / `BEGIN:VCARD` (any case, after an optional BOM and blanks) | `Ics` / `VCard` | | first line is a header field: printable ASCII name, optional blanks, `:` | `Eml` | | directory: `cur` and `new`; dot-prefixed Maildir subfolders; `Messages` directories of `.emlx` | `MaildirFolder`, `MaildirRoot`, `AppleMailBundle` | A message with no header block (starts with a blank line or with text that is not a header field) cannot be told from any text file and is `Unknown`; `Mail.Parse` still reads it. A recognised format whose reader is not in the library yet (`Ics`, `VCard`, `CompoundFile`) makes `Open` throw `UnsupportedMailFormatException` with its `Format`; `Unknown` throws the same exception with `Format == Unknown`. DBX files and DBX store folders (`Dbx`, `DbxStore`) and Outlook for Mac archives (`Olm`) are recognised and opened. ## The CLI: `onemail inspect` `onemail inspect [--json] [--sizes] [--defects] ...` prints the format and every item. A directory that is not a store is walked and each file inspected on its own (a tree of loose messages); a file that is not readable is reported, not fatal. Text: one line per item (date, first sender, subject, attachment names, defect count). `--json`: one object per line, a `container` record then an `item` record per message with the whole view and provenance. `--sizes` decodes attachments to measure them. It streams: one item is held at a time. ## How it is checked - `tools/oracles/viewcheck.sh`: runs `onemail inspect --json`, cuts each item's bytes out of its container, runs the OneMail oracle driver (built on the tree API, not the view) and compares subject, addresses, date, Message-ID/In-Reply-To/References and attachment lists per item; then compares attachment names and counts with Python `email`, grouping differences by signature, each tied to a V entry or an N entry. - Allocation: enumerating an mbox of small messages creates one ~150-byte item per message and reads nothing; reading subject, from and date of each costs about 5.4 KB per message, almost all of it the MIME tree (~2.7 KB) and the address and date parsers, not the view. # Reading MIME This is the behaviour of `MimeReader` (`RecursiveAI.OneMail.Mime.Reading`) and the entity tree built on it (`RecursiveAI.OneMail.Mime`). It is written to be implemented the same way in another language. ## Events cover the input exactly The reader turns a message into a flat sequence of events. Every input byte belongs to exactly one event, in order, so concatenating the bytes of all events reproduces the input — including malformed input. The tree stores the same ranges, so an unchanged tree writes back byte for byte. | Event | Bytes it covers | |---|---| | `EntityStart`, `EntityEnd`, `Defect` | none (a position) | | `EnvelopeLine` | an mbox `From ` line at the very start of the message, with its line end (not a header field) | | `Header` | one header field: name, colon, value, folded lines, and the field's own line end | | `HeadersEnd` | the empty separator line (`CRLF` or `LF`), or nothing if the input has none | | `Preamble` / `Body` / `Epilogue` | a chunk of text; chunk sizes depend on buffering and carry no meaning | | `Delimiter` | the line end that precedes the delimiter line, the delimiter line `--boundary[--]`, its transport padding, and its own line end | A multipart body is: preamble, then for each part a `Delimiter`, the part, and finally the closing `Delimiter` and the epilogue. The line end before a delimiter belongs to the delimiter (RFC 2046 §5.1.1), so a part's body does not include it. ## Structure - A header block ends at the first empty line. A line with no colon also ends it: the line starts the body and `MissingHeaderBodySeparator` is reported. Any line with a colon is a header field, even when the name has spaces, control bytes or bytes above 0x7E (`InvalidHeaderName`; MimeKit and Go do the same). A folded line (starting with space or tab) continues the field before it (RFC 5322 §2.2.3); one with no field before it ends the header block. A first line that starts with `From ` is an mbox envelope line: the reader reports it as its own event and the header block starts after it. - Two delimiter lines in a row (`--b` then `--b`) leave an empty part with no headers between them. The reader reports it; the entity tree folds it into the delimiters (Python and MimeKit produce no part there), so writing is unchanged. - An entity is a **multipart** when `Content-Type` is `multipart/*` with a usable `boundary`, and the transfer encoding is 7bit, 8bit or binary. It is a **message** when the type is `message/rfc822` or `message/global` (or it has no `Content-Type` inside `multipart/digest`, RFC 2046 §5.1.5), under the same encoding rule. Everything else is a **leaf**. - A delimiter line is `--` + boundary, optionally followed by `--`, then optional spaces and tabs, then the line end or the end of the input. Active boundaries are tried innermost first. A delimiter of an enclosing multipart ends everything nested inside it (RFC 2046 §5.1.2); each multipart cut off this way reports `MissingFinalBoundary`. - The first `Content-Type` and the first `Content-Transfer-Encoding` of an entity decide; later ones are reported and ignored. ## Defects Defects are events, reported after the event that raised them, with the byte offset. They never stop reading. | Kind | Meaning | Reading used | |---|---|---| | `MissingHeaderBodySeparator` | header block ended by end of input or a non-header line | the offending line starts the body | | `HeaderContinuationWithoutHeader` | folded line with no field to continue | ends the header block; the line starts the body | | `BareCarriageReturnInHeader` | CR not followed by LF inside a field | left in place | | `NulInHeader` | NUL inside a field | left in place | | `Utf8Header` | bytes above 0x7F that are valid UTF-8 (RFC 6532) | informational | | `AmbiguousEightBitHeader` | bytes above 0x7F that are not UTF-8 | kept raw; the text layer picks a charset | | `InvalidHeaderName` | a field name with a space, a control byte or a byte above 0x7E | still a header; `Name` is read as Latin-1, a leading UTF-8 BOM is ignored | | `ControlCharacterInHeader` | a control character other than NUL, tab, CR, LF inside a field | left in place | | `MixedLineEndings` | LF and CRLF both used in the headers and delimiters | each line ends either way; reported once per message | | `MissingStartBoundary` | a multipart whose body has no delimiter line | no parts; the whole body is its preamble | | `EpilogueHasDelimiter` | after the closing delimiter, a line that looks like a delimiter | stays epilogue text; reported once per entity | | `BoundaryHasTrailingSpace` | a `boundary` parameter ending in blanks | matched trimmed, which also matches the literal one | | `InvalidParameter` | a `Content-Type` or `Content-Disposition` parameter without `=` | dropped | | `WhitespaceBeforeHeaderColon` | `Name : value` (obsolete syntax, RFC 5322 §4.2) | name is trimmed | | `AmbiguousDuplicateContentType` | more than one `Content-Type` | first wins | | `AmbiguousDuplicateTransferEncoding` | more than one `Content-Transfer-Encoding` | first wins | | `InvalidContentType` | not `type/subtype` | `text/plain` | | `UnknownTransferEncoding` | not 7bit/8bit/binary/base64/quoted-printable | identity | | `MultipartWithoutBoundary`, `EmptyBoundary`, `BoundaryTooLong` | unusable boundary | the entity is a leaf | | `AmbiguousDuplicateBoundary` | a multipart reuses an enclosing multipart's boundary | innermost wins | | `AmbiguousBoundaryLikeLine` | a line starts with `--boundary` but continues with other text | body text, not a delimiter | | `AmbiguousEncodedComposite` | a multipart or message type declared base64 or quoted-printable | a multipart is still split into parts from the raw body; a message stays a leaf | | `MissingFinalBoundary` | multipart without closing delimiter | the multipart ends at the enclosing delimiter or the end of input | Header decoding reports the text layer's issues as defects too (`InvalidCharsetBytes`, `AmbiguousUnknownCharset`, `MalformedEncodedWord`, `InvalidEncodedWordBase64`, `InvalidEncodedWordQuotedPrintable`, `EncodedWordTooLong`, `AmbiguousEncodedWordInQuotedString`, `AmbiguousSplitMultibyte`, `InvalidParameterEscape`, `MalformedExtendedParameter`, `AmbiguousParameterSegments`, `DuplicateParameterSegment`, `AmbiguousParameterForms`, `DuplicateParameter`, `TextLimitExceeded`), and so do the structured-header parsers (addresses: `AmbiguousUnquotedCommaInPhrase`, `MissingAngleBrackets`, `ObsoleteRoute`, `EmptyAddress`, `MissingDomain`, `UnclosedAngle`, `UnterminatedQuote`, `AmbiguousAdjacentAngleAddrs`, `UnparsedAddressText`, `ObsoleteLocalPart`, `InvalidAddressSyntax`; dates: `WeekdayMismatch`, `AmbiguousTwoDigitYear`, `ThreeDigitYear`, `UnknownTimeZone`, `InvalidTimeZone`, `LeapSecond`, `FractionalSeconds`, `InvalidDate`; identifiers: `InvalidMessageId`). Their offsets are into the parsed input. The resolutions are in [ambiguities.md](https://recursiveai.org/onemail/docs/ambiguities/). `Ambiguous*` marks places where mail clients disagree. The reading above is the library's choice; it has not yet been confirmed against how particular clients behave, and may change once it is. Reports, MHTML and authentication results add the defects below (details in [reports-and-mhtml.md](https://recursiveai.org/onemail/docs/reports-and-mhtml/)): `ReportMalformedLine`, `ReportMissingField`, `ReportInvalidFieldValue`, `AmbiguousReportDuplicateField`, `ReportMissingGroup`, `AmbiguousReportExtraBlocks`, `AmbiguousReportBlankLines`, `ReportStructureInvalid`, `ReportLimitExceeded`, `MhtmlStartNotFound`, `InvalidContentLocation`, `AmbiguousMhtmlDuplicateLocation`, `AmbiguousMhtmlDuplicateContentId`, `AuthenticationResultsMissingId`, `AuthenticationResultsInvalidVersion`, `InvalidAuthenticationResults`, `AmbiguousAuthenticationResultsNone`, `AmbiguousAuthenticationResultsProperty`, `InvalidReceivedSpf`. ## Resource limits (on by default) Reaching a limit reports a defect and keeps the rest of the input as body text, so nothing is lost and nothing is allocated for it. | Limit | Default | Defect | Effect | |---|---|---|---| | `MaxDepth` | 64 | `DepthLimitExceeded` | a deeper multipart/message is a leaf | | `MaxEntityCount` | 10,000 | `EntityLimitExceeded` | no more delimiters are recognised; the rest of the input is body of the current entity | | `MaxHeaderBlockBytes` | 1 MiB | `HeaderLimitExceeded` | the header block ends; the rest is body | | `MaxHeaderFieldBytes` | 256 KiB | `HeaderFieldLimitExceeded` | the header block ends; the rest is body | | `MaxHeadersPerEntity` | 10,000 | `HeaderLimitExceeded` | the header block ends; the rest is body | | `MaxBoundaryLength` | 255 | `BoundaryTooLong` | the entity is a leaf | | `MaxTotalBytes` | 2 GiB − 1 | `TotalSizeLimitExceeded` | reading stops; `IsTruncated` is set | The read buffer is pooled and grows only to hold one header field, never past twice `MaxHeaderBlockBytes`. ## Transfer encodings `Base64Decoder` and `QuotedPrintableDecoder` are lenient and report what they repaired as `TransferIssues`. - base64: whitespace is ignored; characters outside the alphabet are skipped; padding ends a group and data after it starts a new one (concatenated base64 blocks decode); a final partial group yields its complete bytes. - quoted-printable: `=XX` in either hex case; soft breaks (`=` + line end, also with blanks in between) vanish; blanks before a hard line end are kept (they are data; Python and MimeKit keep them); a malformed `=` stays literal, together with the blanks after it. Hard line ends are copied as they are. - uuencode (`x-uuencode`, `uuencode`, `x-uue`, `uue`): text before the `begin` line is ignored and decoding stops at `end`. ## Writing `MimeWriter` copies reader events byte for byte, and generates headers (folded at word boundaries at 78 columns) and delimiters. Non-ASCII text in free-text fields (`Subject`, `Comments`, `Keywords`, `Content-Description` and unknown fields) is written as RFC 2047 encoded words; fields with their own syntax (addresses, identifiers, dates, `Content-*`) keep it as UTF-8 (RFC 6532), and parameters are built with `MimeHeaderValue.Parameter`, which writes RFC 2231 extended values. A header name or value that could start a new header (a colon in the name, a line break in the value) is refused. A modified tree re-serialises only the entities on the path to a change; untouched entities, bodies, preambles and epilogues are copied from the source as ranges. # Text encodings: charsets, encoded words, parameter values Normative behaviour of the text layer (`RecursiveAI.OneMail.Text`). It sits under the MIME code and knows nothing about messages: it turns bytes into text and text into bytes, and says what it had to repair. ## Contract - Decoders never throw on malformed input. They return the best text and report each repair or guess as a `TextIssue` (kind, byte offset, byte length). Offsets are into the span given to the decoder; for parameters the offset is the one the caller passed with the parameter. - Kinds named `Ambiguous*` are the documented resolutions in the table below. Others are plain damage. - Decoding is lossless where the input allows it: CR and LF survive, a byte order mark is kept as U+FEFF, U+FFFD is written only for bytes the charset cannot map. - Passing no issue list costs nothing: the paths allocate the result string, and for encoded words one pooled byte buffer. ## Charsets `MailEncoding.ForLabel` ignores ASCII case and surrounding white space and looks, in order, at 1. the mail-only labels: `utf-7` and its aliases, `utf-32*`, `iso-2022-kr`, `hz-gb-2312`, `iso-2022-jp-1/-2/-3`, `cp50220/50221/50222` (also `windows-` and `x-windows-` forms), the DOS code pages (`cp437`, `ibm850` ...), `johab`; 2. a code page number in a Windows-style spelling (`cp1252`, `windows-1251`, `codepage-1250`) that the table knows; 3. the WHATWG Encoding Standard label table, decoded by OneHtml's tables. `replacement` and the labels the standard maps to it (ISO-2022-CN, ISO-2022-CN-EXT) are unknown (`null`); `unknown-8bit` and anything else not in the lists are unknown. Callers apply the *unknown charset* rule below. Because the Encoding Standard is the table, `us-ascii`, `iso-8859-1` and `iso-8859-9` decode as windows-1252 / windows-1254 and the bytes 0x80..0x9F map to the printable characters, as every mail client in practice does. A strict Latin-1 reading would turn those bytes into C1 controls. `MailEncoding.ForCodePage` is the reverse map for MAPI's `PidTagInternetCodepage`/`PidTagMessageCodepage`: 874, 932, 936, 949, 950, 1200, 1201, 1250-1258, 12000, 12001, 20127, 20866, 21866, 28591-28606, 38598, 437/737/775/850/852/855/857/858/860-865/869, 10000, 10007, 866, 50220-50222, 50225, 51932, 51949, 52936, 54936, 65000, 65001, 1361. `Name` is the spelling to write in a header (`windows-1252`, `shift_jis`, `gb2312` for 936, `ks_c_5601-1987` for 949, `iso-8859-1` for 28591). The name, not the decoder, differs for aliases: 20127 and 28591 decode like windows-1252. Encoding (`GetBytes`) uses the runtime's code-page tables (UTF-8 and UTF-7 are ours). A character the charset lacks becomes `?` and is reported (`Unencodable`). The stateful ISO-2022 charsets are encoded by the runtime's 50220/50221/50222/50225/52936 encoders, one word at a time. ### Decoding details - Table-driven charsets are decoded by OneHtml. Two properties of its HTML-oriented API are worked around here so mail text is not changed: a leading byte order mark would override the charset (each piece is decoded with a leading space that is removed afterwards) and CR/CRLF would become LF (the input is cut at every CR byte, which is a character on its own in all these charsets, and the CRs are put back). Pure ASCII input skips OneHtml. - UTF-8: the Encoding Standard's maximal-subpart replacement. UTF-16/32: the runtime's, with replacement. - Unknown charset (`AmbiguousUnknownCharset`): the bytes are decoded as UTF-8 when they are valid UTF-8, otherwise as windows-1252. ### UTF-7 (RFC 2152) and IMAP modified UTF-7 (RFC 3501 §5.1.3) - UTF-7 decoding accepts any ASCII byte in direct mode; `+-` is `+`; a base64 run ends at `-` (absorbed) or at a non-base64 byte (kept). A byte of 0x80 or above, a run with unpaired surrogates, non-zero leftover bits, a leftover of six or more bits, or an empty run (`+` followed by a non-base64, non-`-` byte) is `InvalidUtf7`; the damaged part becomes U+FFFD where it carries text. - UTF-7 encoding writes letters, digits, `'(),-./:?`, space, tab, CR and LF directly and everything else in base64 (also the "optional direct" set); `-` closes a run only when the next character could continue it. - Modified UTF-7 decoding: `&-` is `&`; a run is base64 with `,` for `/` and ends at `-`. A run without its `-` still decodes (`InvalidImapUtf7`). Bytes outside 0x20..0x7E in direct mode become U+FFFD. Encoding writes printable ASCII directly (`&` as `&-`) and other runs as `&base64-`, always closed. ### ISO-2022 family (ISO-2022-JP, -1, -2, -3, CP50221/50222, ISO-2022-KR, HZ) One tolerant decoder serves the whole ISO-2022-JP family. A text that uses a character set outside the label's repertoire is still decoded and reported as `AmbiguousIso2022Repertoire`. | Label | Repertoire | |---|---| | `iso-2022-jp` (cp50220) | ASCII, JIS-Roman (`ESC ( J`), JIS C 6226-1978 (`ESC $ @`), JIS X 0208-1983 (`ESC $ B`) | | `iso-2022-jp-1` | + JIS X 0212 (`ESC $ ( D`) | | `iso-2022-jp-2` | + GB 2312 (`ESC $ A`), KS C 5601 (`ESC $ ( C`), ISO-8859-1 and ISO-8859-7 high parts as G2 (`ESC . A`, `ESC . F`, `ESC N`) | | `iso-2022-jp-3` | base set + JIS X 0213 plane 1 (`ESC $ ( O` / `Q`, read as JIS X 0208) and plane 2 (`ESC $ ( P`: each pair becomes U+FFFD with `UnsupportedCharacterSet`) | | `cp50221`, `cp50222` | base set + half-width katakana (50221: `ESC ( I`; 50222: SO/SI) | | `iso-2022-kr` | ASCII + KS C 5601 through SO/SI; the `ESC $ ) C` header is consumed | | `hz-gb-2312` | `~{ ... ~}`, `~~`, `~` LF | The two-byte sets are rewritten to their EUC form and decoded by OneHtml's EUC-JP / GBK / EUC-KR tables. A line end (CR or LF) returns to ASCII in every variant, so a missing reset cannot swallow the following line. Unknown escapes, SO/SI where the variant has none, bytes ≥ 0x80 and unpaired bytes become U+FFFD (`InvalidIso2022`). JIS X 0213 beyond plane 1 and ISO-2022-CN are not decoded. ## Encoded words (RFC 2047) `Rfc2047.DecodeText` decodes header text, `Rfc2047.Encode` produces it. Decode rules: - A fold (CRLF, or a bare CR/LF, followed by space or tab) is removed first; the white space after it stays (RFC 5322 §2.2.3). - White space between two adjacent encoded words is dropped; between a word and plain text it stays (RFC 2047 §6.2). - `charset*language` is accepted; the language is ignored (RFC 2231 §5). - B: characters outside the alphabet and data after padding are skipped; wrong or missing padding and a one-character tail are `InvalidBase64`; the bytes that can be decoded are kept. - Q: `_` is a space; `=XX` is a byte; `=` without two hex digits stays a literal `=` (`InvalidQuotedPrintable`). - A word is `=?charset?B|Q?text?=` with the text ending at the first `?=`. White space (space, tab) inside the text is accepted and reported (`MalformedEncodedWord`); CR/LF inside, an encoding other than B/Q (case-insensitive) or a missing `?=` make it not a word: the characters stay literal (`MalformedEncodedWord`) and scanning resumes two bytes later. An empty or space-containing charset, or a missing `?` structure, is not recognised as a word at all: literal text, no issue. - Longer than 75 characters: decoded, `EncodedWordTooLong`. - Plain 8-bit text: UTF-8 if valid (RFC 6532), else the fallback charset (default windows-1252), `AmbiguousRaw8Bit`. Ambiguities and their resolutions: | Case | Resolution | Issue | |---|---|---| | Charset not known | UTF-8 if valid else windows-1252 | `AmbiguousUnknownCharset` | | Adjacent words with the same charset (case-insensitive) | the bytes of the run are decoded together, so a multibyte character or an ISO-2022 shift split between words survives; words with different charsets are decoded separately | `AmbiguousSplitMultibyte` (UTF-8 only: reported when a word alone is not valid UTF-8) | | Encoded word in a quoted string or parameter value | decoded | `AmbiguousEncodedWordInQuotedString` | | White space inside an encoded word | decoded | `MalformedEncodedWord` | | Invalid base64 / Q escape | repaired as above | `InvalidBase64` / `InvalidQuotedPrintable` | | `=?` that does not start a word, tried again inside | the scan resumes at the next `=?`, so `=?=?utf-8?Q?abc?=?=` decodes the inner word | none | Encode rules: text without characters outside printable ASCII (and without `=?`) is returned unchanged. Otherwise each maximal group of tokens that need encoding, together with the white space between them (a decoder drops it), becomes encoded words of at most 75 characters, never cutting a character (or a surrogate pair), B or Q whichever is shorter. Q uses only `A-Za-z0-9!*+-/` plus `_` for space, so the result is valid in phrases, comments and unstructured text. Words are separated by one space; folding there is legal. ## Parameters (RFC 2231) `ParameterCollector` takes the parameters of one header in source order (name as written, value unquoted) and `Resolve` returns one `DecodedParameter` per name (case-insensitive, first spelling kept). - `name*` is an extended value `charset'language'percent-encoded`; `name*N` and `name*N*` are continuation segments; only segment 0 carries the charset; `*N*` segments are percent-decoded, `*N` segments are literal. - Segments are ordered by number (stable) and joined **as bytes**, then decoded once with the charset, so a character split between segments survives. - No charset (value without two `'`, or an empty charset): the percent-decoded bytes are read as UTF-8 if valid else the fallback (`MalformedExtendedValue` when the quotes are missing; `AmbiguousRaw8Bit` for the fallback). - Percent escape that is not `%XX`: kept literally (`InvalidPercentEscape`). - Plain `name=value`: UTF-8 if valid else fallback; a value containing an encoded word is decoded as one (`AmbiguousEncodedWordInQuotedString`). | Case | Resolution | Issue | |---|---|---| | Segment numbers have a gap or do not start at 0 | every segment present is joined in numeric order | `AmbiguousParameterSegments` | | Same segment number twice | both are kept, in source order | `DuplicateParameterSegment` | | `name=` and `name*=`/`name*0=` both present | the RFC 2231 form wins, wherever it appears | `AmbiguousParameterForms` | | Same plain name twice | the first | `DuplicateParameter` | | More than 1024 parameters, segment number ≥ 256 | the excess is ignored | `LimitExceeded` | `Rfc2231.Encode` returns the parts to write: a token value as `name=value`; other printable ASCII as a quoted string; anything else as `name*=charset''%XX...`, or `name*0*`, `name*1*` ... for values longer than the part length (default 50), each part holding whole characters; the charset appears in the first part only. ## How this is checked `tools/text-diff` generates ≥ 10k encoded-word and parameter cases (valid, adjacent, split, malformed), plus charset cases (UTF-7, IMAP UTF-7, ISO-2022 family, HZ) and values harvested from the CMU Enron maildir, runs them through this library and through Python `email` (`decode_header`, `Message.get_param` with `collapse_rfc2231_value`), Go `mime` (`WordDecoder`, `ParseMediaType`, with `golang.org/x/text` charsets) and MimeKit's public decoding API, and compares decoded values. Each difference must match a rule in `explain.py` that names the resolution above; anything else is a failure. The Python and Go oracles cannot decode IMAP modified UTF-7, so those cases check against an independent reference encoder in the generator. # Reports, MHTML and authentication results Typed views over the MIME entity tree (`RecursiveAI.OneMail.Mime.Reports`, `.Mhtml`, `.Authentication`). The tree stays the lossless store: a view reads it, never changes it, so an untouched message still writes back byte for byte. Every repair is a typed defect with the byte offset. The ambiguity entries (RP, AR, RS, MH) are in [ambiguities.md](https://recursiveai.org/onemail/docs/ambiguities/). ## multipart/report (RFC 6522) `MimeReport.Find(tree)` returns the first `multipart/report` (searching nested message/rfc822 as well); `TryCreate(entity)` tests one entity. The view exposes the human-readable part, the machine-readable part, and the original (a message, or headers only for `text/rfc822-headers` and `message/rfc822-headers`). `GetDeliveryStatus`, `GetDispositionNotification` and `GetFeedbackReport` parse the machine part and add their defects to a caller's list. | Report | Media type | Spec | Class | |---|---|---|---| | Delivery status | `message/delivery-status`, `message/global-delivery-status` | RFC 3464, 6533 | `DeliveryStatusReport` (per-message block, per-recipient `RecipientStatus`) | | Disposition notification | `message/disposition-notification` | RFC 8098 (3798) | `DispositionNotification`, `MdnDisposition` | | Feedback | `message/feedback-report` | RFC 5965, 6591, 6650 | `FeedbackReport` | - **Field syntax** (`ReportFieldParser`): RFC 5322 header syntax, a block ends at an empty line. A line of only blanks is a fold, not an empty line. Values are not RFC 2047-decoded (the RFCs do not use encoded words here); bytes are read as UTF-8 if valid, else windows-1252. A lone CR stays in the value (`BareCarriageReturnInHeader`). - **Typed values**: `DsnTypedValue` (`type; value`), `EnhancedStatusCode` (RFC 3463; trailing comments tolerated), `DsnAction`, dates as `MimeDate`. A value that does not fit stays readable as text and raises `ReportInvalidFieldValue`. - **Kind**: the machine part's own media type decides; a `report-type` that disagrees or is missing raises `ReportStructureInvalid`. - **Limits**: a report body over 16 MiB or more than 100,000 fields raises `ReportLimitExceeded`; the rest is not parsed. - **Writing**: `MimeReport.CreateDeliveryStatus / CreateDispositionNotification / CreateFeedbackReport` build the `multipart/report` (boundary overridable for reproducible output); `ToBytes` writes fields folded at 78 columns, UTF-8 for non-ASCII (the caller picks the `global` media type with `RequiresGlobal`). ## MHTML (RFC 2557) `MhtmlDocument.Find / TryCreate / Open / Load` view a `multipart/related` (or a lone HTML part) as a root resource plus resources. `Resolve(reference, htmlBase)` returns the resource a reference in the HTML names; `ResolveToAbsolute` gives the URI. `MhtmlBuilder` writes a `.mht`. `MhtmlUri` is RFC 3986 §5.2 resolution, with `thismessage:/` as the base of last resort. Base order (RFC 2557 §5): the HTML's own base argument, the legacy `Content-Base`, an absolute `Content-Location` of the part and then of its enclosing entities, else `thismessage:/`. Lookup is exact first, then after `Normalize` (scheme and host case, percent-hex case, space as `%20`, default ports, dot segments). `cid:` matches `Content-ID` only (RFC 2557 §8.3: a `Content-Location` holding a `cid:` URL never satisfies it), with angle brackets stripped and percent-decoding; `mid:` needs both a content id and the message id. A lookup falls through to enclosing `multipart/related` entities. Duplicates: the first wins, and the ambiguity is reported. ## Authentication-Results and Received-SPF `MimeEntity.GetAuthenticationResults()` and `GetReceivedSpf()` parse every such field (RFC 8601; RFC 7208 §9.1) into `AuthenticationResults` (authserv-id, version, `AuthenticationMethodResult` with method, version, result, comment, reason and `AuthenticationProperty` ptype.name=value) and `ReceivedSpf`. This is parsing only: nothing is evaluated or trusted, and callers must still check the authserv-id against their own boundary. `MimeEntity.GetAuthenticationResults` takes the header text as the tree stores it (comments and folds kept in the raw value). ## Method `tools/oracles/reports` runs ours, MimeKit 4.18.2 (public API) and Python 3.13 `email` over `testdata/reports` (119 messages, generated by `tools/reports/make_fixtures.py`, with RFC 3464, 8601, 7208 and 2557 examples) and over the corpus messages that carry the headers (`corpus.sh`). Python has no Authentication-Results parser; Received-SPF has no oracle (RFC examples only). MHTML reference resolution is also run against Python `urllib.parse.urljoin` on 218 base/reference pairs. A difference must match a line of `explained.tsv` (naming an ambiguity entry or a documented oracle limit) or the run fails. # Mailbox stores How OneMail enumerates and writes mbox files, Maildir folders and Apple Mail (EMLX) stores, and reads Outlook Express (DBX) stores. The code is in `RecursiveAI.OneMail.Stores` (shared types, file-system abstraction, content sniffing) and its `.Mbox`, `.Maildir`, `.Emlx` and `.Dbx` namespaces. Text here is written to be implemented the same way in another language. The cases where programs disagree are listed with their resolutions in [`ambiguities.md`](https://recursiveai.org/onemail/docs/ambiguities/) (S1–S18). ## The store contract A store reader does not parse messages. It yields **entries**: where a message is (a byte range of a source, or a file of a directory store), what the store knows about it (flags, keywords, dates, UID) and what was irregular (**defects**). The MIME layer parses the bytes on demand. - An entry is plain data and holds no reference to its source. Opening the message is a call on the reader, taking the entry. - File stores (mbox, EMLX) read from an `IRandomAccessSource`. Directory stores (Maildir, Apple Mail bundles) read from an `IStoreFileSystem`: list, open for reading, create new, rename, delete. A directory on disk, memory (tests, browser WebAssembly) and any storage mapped onto those calls all work. Paths use `/` and are relative to the file system's root. - Enumeration: `Read()`/`ReadAsync(ct)` on the mbox reader, `Enumerate`/`EnumerateAsync(ct)` everywhere else. The synchronous and asynchronous paths are the same code. - Nothing throws on malformed content. Defects are `StoreDefects` bits on the entry and, with the reader's `DefectHandler`, `(kind, byte offset)` pairs. `Ambiguous*` marks a choice between valid readings. - Sniffing is by content: `StoreSniffer.Sniff(source)` (mbox, EMLX), `StoreSniffer.SniffDirectory(fs, path)` (Maildir folder, Maildir++ root, Apple Mail bundle), `MboxSniffer.Detect(source)` (variant). Extensions are never consulted. ## mbox ### Records tile the file A file is `[preamble][record]…`. A record is `[envelope line][message][separator]`. | Part | Bytes | |---|---| | Preamble | everything before the first envelope line (`PreambleLength`; empty for a normal file) | | Envelope line | from `From ` through its line ending (LF or CRLF); the last line of a file may lack one | | Message | the stored bytes, headers first, **still quoted** | | Separator | one empty line (LF or CRLF), only if the message ends with one: the file's last record included | `Offset + RecordLength` is the next record's `Offset` (or the file's end), so copying every record (and the preamble) reproduces the file. `MessageLength` excludes the separator; a record whose message ends in an empty line and has no separator after it loses that line to the separator rule (an unavoidable ambiguity of the format), and a message that does not end in a line ending has no separator. ### Where a message ends 1. **Envelope lines.** A line starting `From ` at a line start begins a message when it reads as an envelope line (RFC 4155 §3): after the sender there is a month name next to a one- or two-digit day, and later a time containing a colon. That accepts `From a@b Mon Jan 1 00:00:00 2024`, `From 123@xxx Mon Jan 01 00:00:00 +0000 2024` (Gmail Takeout) and `From - Wed Jan 1 …`. Any other `From ` line is message text and sets `AmbiguousFromLine` (S1, S5). `MboxFromLineMode.Any` takes every `From ` line. Envelope lines longer than `MaxEnvelopeLineBytes` (4096) are text in strict mode, an envelope with `OverlongEnvelopeLine` in `Any` mode. 2. **`Content-Length`.** Unless the variant is mboxo or mboxrd, the reader looks at the header block (at most `MaxHeaderBytes`, 64 KiB) for the first `Content-Length`. The message body is the length that many bytes after the empty line ending the headers. If what follows is the end of the file, or an envelope line, or one empty line and then either, the length is **valid**: the message ends there and its body is not scanned (so an unquoted envelope-looking line inside is kept). Otherwise the length is ignored and the message ends at the next envelope line, with `ContentLengthMismatch` (S2). Two differing values: the first wins, `AmbiguousContentLength`. 3. An envelope line with no empty line before it still begins a message: `MissingSeparatorLine` on the message before it (S8). ### Variants | Variant | Body line quoting when writing | Reading | Notes | |---|---|---|---| | mboxo | `From ` → `>From ` | unquote exactly one `>` | not reversible: a stored `>From ` could have been either | | mboxrd | `>*From ` gets one more `>` | unquote one `>` from `>+From ` | reversible; the writer's default | | mboxcl | as mboxo, plus `Content-Length` | as mboxo; length found by the rule above | length counts the stored body | | mboxcl2 | none, plus `Content-Length` | stored bytes as they are | only the length protects the text | | Unknown (reading) | | non-length entries unquoted as mboxrd; entries ended by a valid length returned as stored (S9) | | `MboxSniffer.Detect` guesses the variant from the first messages (up to 8 MiB or 2,000): a valid `Content-Length` means mboxcl, or mboxcl2 when a length-delimited body holds an unquoted envelope line; a `>>From ` line means mboxrd; a lone `>From ` is taken as mboxrd. No evidence leaves it Unknown. Reading never depends on this call. ### Memory One pooled window (default 64 KiB) holds the bytes being scanned. A scan looks for the vectorised pattern `\nFrom ` and checks each hit; a length-delimited message is skipped without being read. Enumeration allocates no objects per message (entries are structs); the measured numbers are in the benchmark notes. ### Writing `MboxWriter` appends one message at a time: the envelope line (`From `, UTC; sender and date default to `MAILER-DAEMON` and the clock, both overridable per call), the message with the variant's quoting, a line ending if the message lacks one, and an empty line. The file is complete after every call. For mboxcl and mboxcl2 an existing `Content-Length` header is removed and a correct one added as the last header; a body without a final line ending gets one (the length counts it). A header block over 1 MiB is refused. `AppendToExisting` looks at the last bytes of a seekable file and adds a missing line ending or separator before the first new envelope line. `AppendRecord` and `WritePreamble` copy bytes of another file verbatim, so read→write of untouched records is byte-identical. ### Metadata `MboxMetadata` reads hints from the header block: `Status` `R` (seen); `X-Status` `A F D T` (answered, flagged, deleted, draft); `X-Gmail-Labels` (comma separated, quoted labels may hold commas, encoded words decoded); `X-GM-THRID`; `X-Keywords`; `Content-Length`. `MboxEnvelope` parses sender and date from the envelope line (no zone means UTC). ## Maildir A Maildir folder holds `cur`, `new` and `tmp`. `MaildirStore` reads `cur` then `new`, each by file name (ordinal); `tmp` is never listed; names starting with `.` inside `cur` and `new` are ignored. - **Names.** `unique[:2,flags[,extra]]`. The separator before `2,` is `:` in the Maildir specification, which Windows file names cannot hold; Windows tools use `;` (isync's `InfoDelimiter` defaults to `;` on Windows and `:` elsewhere). **Reading accepts both on every platform** (a `;` counts only when `2,` follows it, so other semicolons stay in the unique part; a colon wins when both occur). **Writing** uses `MaildirStoreOptions.InfoSeparator`, default `;` on Windows and `:` elsewhere; only those two are allowed (S19). `dovecot-uidlist` file names are matched with either separator. Standard flags `D F P R S T` map to draft, flagged, forwarded ("passed"), answered, seen, trashed (`Deleted`). Lowercase `a`–`z` are Dovecot keyword slots. A file in `new` also carries `Recent`. `,S=` and `,W=` in the unique part are read as sizes. Any other info version (`:1,…`) is kept in `RawInfo` and not interpreted. Names are built with flags in ASCII order (S10). - **Keywords.** Dovecot: `dovecot-keywords` lines ` `, letter `a` is index 0. Courier: `courierimapkeywords/:list` (S13). A letter without a name appears as itself with `UnknownKeywordLetter`. A folder holds at most 26 Dovecot keywords. - **UIDs.** `dovecot-uidlist`: a header line, then ` [fields] :`; the entry's `Uid` is matched by the unique part. - **Folders.** Maildir++: the root is the inbox (folder name `""`); a subfolder is a directory `.Name` holding `cur`/`new`, and the dot is the hierarchy separator (`.Archive.2024` is folder `Archive.2024`). Nested layouts (a folder inside a folder directory, as Python's `add_folder` makes on a subfolder) are not Maildir++ and are not read. - **Delivery** writes the message under `tmp` with a unique name, then renames it into `new`, or into `cur` with `2,flags` when it has flags or keywords. Default unique names are `seconds.MPQ.` with `/` and `:` in the host escaped; supply a factory for reproducible names. The message bytes are written unchanged. - **`SetFlags`** renames into `cur` with the new info section; the unique part and the content do not change. - **`CopyToAsync`** copies a store through the model: every message file under its exact name, and the folder control files byte for byte. ## EMLX and Apple Mail bundles An `.emlx` file is a decimal byte count and a line feed, that many bytes of message, and (usually) an XML property list. `EmlxReader.Read` returns the three ranges; a bad count line gives no message and the whole file as trailer (`BadByteCount`), a count past the end of the file is cut there (`TruncatedMessage`). The property list gives the flag word, dates, remote id and conversation id (S12); `Plist.TryParse` reads XML property lists only, ignores DTDs (nothing is fetched or expanded), and is bounded (1 MiB, 32 levels, 100,000 values). `.partial.emlx` files hold messages without their attachments, which Mail keeps in `Attachments//` beside the `Messages` directory; the bundle reader names that directory and does not read it. `AppleMailBundle` finds `Messages` directories at any depth up to eight below the bundle (older layouts hold `Messages` directly, newer ones nest it under `/Data/…`) and lists their `.emlx` files by number. `EmlxWriter.Write` writes a message with a new property list; `WriteFrom` rewrites a file from its parts (canonical count line, message, original trailer bytes), so a file with a canonical count line is reproduced exactly. ## Outlook Express (DBX) An Outlook Express 5/6 store is a directory of `.dbx` files: `Folders.dbx` (the folder hierarchy), one message database per folder (`Inbox.dbx`, `Sent Items.dbx`, user folders) and helpers (`Pop3uidl.dbx`, `Offline.dbx`) that hold no mail. Reading is read-only and never writes. Facts are from the oedbx DBX format notes (Arne Schloh, updated by Zvonko Tesic); the constants and structures here are our own statement of them. **File.** Every database starts with `cf ad 12 fe` and a class id: `c5 fd 74 6f` message database, `c6 fd 74 6f` `Folders.dbx`, `c7 fd 74 6f` `Pop3uidl.dbx`, `30 9d fe 26` `Offline.dbx`. Sniffing reads those eight bytes only (never the extension). The fixed header is 0x24bc bytes, the file info follows (length at header 0x1c), then the middle sector of objects. Every object starts with its own absolute file offset (its *marker*); a pointer is an absolute offset. A file shorter than the header reads as nothing (`DbxTruncatedHeader`). **Index tree.** Header 0xe4 points at the root of a B-tree of 0x27c-byte nodes: 0x18 bytes (marker, unused, left child, parent, node id, entry count, unused, left subtree count) and up to 0x33 entries of (value, right child, subtree count). Values are pointers to records. The walk is in order: left child, then each value followed by its child, which gives the records in index order. Header 0xc4 repeats the entry count. The walk keeps one 0x27c buffer per level on an explicit stack, stops at `MaxTreeDepth` (32) and at a node budget of `file length / 0x27c + 1` visits (`DbxTreeLoop`), skips a node whose address is outside the file or whose marker is wrong (`DbxBadTreeNode`), clamps an entry count above 0x33, and reports a final count different from the header's (`DbxEntryCountMismatch`, the tree wins). The other two trees (watched or ignored threads; sorted-by-parent folder lists) are not read: the main tree already holds every record. **Records.** A record (indexed info) is a 12-byte header (marker, body length, object length, field count, change count), an index of field-count 4-byte entries, and a data area. An entry is a field byte (low 7 bits the field number, top bit "value is in the entry") and three value bytes: a direct value is that 24-bit integer; otherwise it is an offset into the data area, and the field runs to the next stored offset or the end of the area. Strings end at a zero byte and are decoded as windows-1252 (S15). Times are Windows FILETIMEs. A body length over `MaxInfoBytes` (1 MiB) or past the end of the file is a defect (`DbxBadIndexedInfo`; a record cut by the end of the file keeps what is there), and a field offset outside the data area reads as absent. Message record fields used: 0x00 index, 0x01 flag word, 0x02 sent time, 0x03 line count, 0x04 pointer to the first text segment, 0x05 subject without prefixes, 0x06 saved time, 0x07 message id, 0x08 subject, 0x09/0x0d/0x0e sender, 0x0a message id answered, 0x10 priority, 0x11 stated length, 0x12 received time, 0x13/0x14 first recipient, 0x1a account. Any field is reachable by number (`DbxRecord.TryGetInt32`, `GetString`, `GetBytes`). Folder record fields: 0x00 index, 0x01 parent (-1 for the root "Outlook Express"), 0x02 name, 0x03 file name, 0x06 flags, 0x07 and 0x08 counts, 0x09 special-folder kind (unverified, S18). **Messages.** The text is a singly linked chain of 0x210-byte segments: marker, body length (0x200), text length, next pointer, then the text; the last has next 0. Concatenating the text lengths gives the message exactly as Outlook Express stored it, with the line ends it received (usually CR LF); nothing is converted. The chain is followed lazily: `OpenMessage` is a forward-only stream, `MeasureMessage` walks only the 16-byte headers (3 % of the bytes), `ReadMessage` fills an array of exactly the measured length. A bad pointer or marker ends the chain (`DbxChainBroken`), a text length over the body length or the file is cut (`DbxBadSegmentLength`), and a chain that revisits a segment (found with Brent's method: two variables, no set) or exceeds the file or `MaxMessageBytes` (2 GiB) ends with `DbxChainLoop`; a loop is noticed within one more lap, so up to a lap of its text may repeat. The stated length (field 0x11) is advisory (S14). A record with no chain pointer yields an empty message (S17). Free (deleted) objects are never followed; their recovery is a forensic mode. **Store folders.** `DbxStoreFolder` reads `Folders.dbx` (found ignoring case), pairs each folder with its message file by the stored name (ignoring case) and enumerates folder by folder in index order, then message files no folder names, as `` folders with `DbxUnlistedFile`. The folder path joins names up the parent chain and leaves out the top-level folders (those with parent -1: the "Outlook Express" root, "Local Folders" and each account), so `Inbox` and `alt.2600` are paths, climbing at most 64 levels; two accounts holding a folder of the same name give the same path (the record itself says which account through `ParentIndex`). `Mail.Open` takes a `.dbx` file (`MailFormat.Dbx`; `Folders.dbx` and the helpers enumerate as empty) or a store directory (`MailFormat.DbxStore`, recognised by a `Folders.dbx` with the folder class id). An item holds no open file: it reopens its message file to read. **Memory.** Enumeration holds one record (its few hundred bytes) and the tree stack; messages are read when asked. The source is read synchronously (an async-only source is refused); `EnumerateMessagesAsync` runs the same code between cancellation checks. ## Defects | Defect | Where | Meaning | |---|---|---| | `PreambleBeforeFirstMessage` | mbox | bytes before the first envelope line, kept as the preamble | | `MissingSeparatorLine` | mbox | no empty line before the next envelope line | | `AmbiguousFromLine` | mbox | a `From ` line that is not an envelope line, kept as text (strict) | | `ContentLengthMismatch` | mbox | `Content-Length` does not end at an envelope line; ignored | | `AmbiguousContentLength` | mbox | two different `Content-Length` values; the first is used | | `OverlongEnvelopeLine` | mbox | envelope line over the limit | | `UnrecognisedFileName` | Maildir | info section is not version 2 | | `UnsortedFlags` | Maildir | flag letters out of order or repeated | | `UnknownKeywordLetter` | Maildir | a keyword letter with no name in the folder's keyword file | | `MalformedControlFile` | Maildir | a keyword, UID or Courier list line that could not be read | | `BadByteCount`, `TruncatedMessage` | EMLX | as described above | | `DbxTruncatedHeader` | DBX | the file is shorter than the 0x24bc-byte header; nothing is enumerated | | `DbxBadPointer`, `DbxBadObjectMarker` | DBX | a record pointer outside the file or inside the header; an object not starting with its own offset. The object is skipped | | `DbxBadTreeNode`, `DbxTreeLoop`, `DbxEntryCountMismatch` | DBX | an unusable index node (skipped); a tree deeper than the limit or revisiting nodes (the walk stops); a count different from the header's (the tree wins) | | `DbxBadIndexedInfo` | DBX | a record whose lengths do not fit (skipped when unreadable, kept when only cut by the end of the file) | | `DbxChainBroken`, `DbxChainLoop`, `DbxBadSegmentLength` | DBX | a message chain ending at a bad object; a cycle or an over-long chain; a text length larger than the body or the file | | `AmbiguousDbxDeclaredLength` | DBX | the record's stated length differs from the chain's; the chain wins (S14) | | `AmbiguousDbxNoMessageText` | DBX | the record has no chain; the message is empty (S17) | | `DbxUnlistedFile` | DBX | a message file `Folders.dbx` does not name (S16) | ## Checks `tools/mailbox/run.sh` is the whole check (see its header). The method: - **Boundaries.** `compare_mbox.py` runs the file through OneMail in an oracle-compatible mode (`Any`, Content-Length ignored), Python's `mailbox` and MimeKit's mbox parser (`tools/oracles/mailbox-driver`), and compares the message count, each envelope offset and message end. Each difference is tied to a table entry (S3, S4, S5); OneMail's default mode is compared with its own compatible mode, and every difference must carry the defect that explains it (S1, S2). - **Ground truth.** `gen_mboxes.py` writes every variant, LF and CRLF, and edge shapes (no separators, preamble, wrong lengths, Takeout headers), with independent writers (hand-written quoting, Python's `mailbox.mbox`) and a sidecar holding the SHA-256 of each message as given. `OneMailRanges verify` reads them back. - **Round trips.** Record copy of every mbox, a message-level rewrite (unquote, append again with the same variant, sender and date), Maildir copy through the model, EMLX rewrite from parts. Files that a message-level rewrite changes are listed with the reason (no separators or no final separator in the source, a preamble, a wrong length corrected, an unquoted writer, a Takeout envelope with a zone). - **Writers.** `OneMailRanges write` makes every variant and a Maildir with flags and keywords; `py_check_written.py` reads them with Python's `mailbox` (mboxcl2 excepted, S6). - **DBX against undbx.** `tools/dbx/check.sh` writes stores with the test-side builder (`DbxTestBuilder`: header, file info, conditions, shuffled or contiguous chains, trees of any fan-out, record variants), extracts every message with OneMail and with undbx 0.21 (a distribution package run as a program), and compares each folder's messages as a multiset of SHA-256 hashes (`compare.py`). Real stores (`tools/corpus/fetch-dbx.sh`) go through the same comparison. Unit tests cover the builder round trip over fan-outs, shuffles and sizes, the hostile cases and the happy path. - **Fuzz.** Targets `mbox`, `emlx` and `dbx` (`fuzz/`): the window size must not change the entries, entries must tile the file, copying records must reproduce it, mboxrd quoting must be reversible, a DBX message read into an array must equal the streamed read and the measured chain, and nothing may throw. # Replies and forwards `RecursiveAI.OneMail.Composition` makes the replies and forwards a mail client would, from any item in the shared view (MIME, MSG, PST/OST, TNEF, OLM, DBX). `item.CreateReply()`, `item.CreateReplyAll()` and `item.CreateForward()` are one call each over `MessageComposer`. The result (`ComposedMessage`) holds the new message as a MIME entity tree, and, for Outlook originals, the same message in the MAPI property model. Nothing is sent, and the original is not changed. The same inputs and options always give the same bytes when the clock (`TimeProvider`) and the random source (`RandomBytes`) are fixed. ## Subject One prefix, never stacked. Every leading prefix of the original is removed (`Re: RE: Fwd: AW: topic` becomes `topic`), then the new prefix is put on: `Re: ` for replies, `Fwd: ` for forwards (both can be replaced in the options). A prefix is a label from `SubjectPrefixes.Labels`, ignoring case, an optional counter in square brackets or parentheses (`Re[2]:`, `Re(3):`), optional blanks, and a colon (ASCII or full-width). A word that is not in the list (`Status: x`) is part of the subject. A subject that is only prefixes becomes empty. Control characters and line separators in the original's subject become spaces, and trailing white space is dropped, so a decoded subject can never start a new header. The labels are the ones language packs of common clients write; they are an agent default and not a specification: Re, Fw, Fwd (English); AW, WG (German); SV, Svar, VS, VB, ATB (Scandinavian); Antw, Doorst (Dutch); TR, Ré, Réf, Rép (French); RV, PD, Reenv (Spanish); ENC, RES, Resp (Portuguese); R, RIF, Inoltro (Italian); Odp (Polish); VL (Finnish); YNT, ILT (Turkish). Labels in non-Latin scripts are not recognised. MAPI: the MS-OXCMAIL import splits the subject (MS-OXCMAIL 2.2.3.2.6.1), so `PidTagSubjectPrefix` is `Re: ` or `Fwd: ` and `PidTagNormalizedSubject` is the rest. ## Threading headers (RFC 5322 §3.6.4) Replies only. A forward starts no reply thread: it has no `In-Reply-To` or `References` (agent default; clients differ), but keeps the conversation topic and index. - `In-Reply-To`: the original's `Message-ID`. - `References`: the original's `References`, or its `In-Reply-To` when it has none, then its `Message-ID` (not repeated when it is already last). Identifiers that cannot be written (white space, angle brackets, control characters, longer than 512 characters) are dropped with `InvalidMessageIdDropped`. - Too long a chain is cut to `MaxReferences` (default 20, at least 2) by keeping the first identifier and the most recent ones, and raises `ReferencesTruncated`. - An original without a `Message-ID` gets no `In-Reply-To` (`ParentHasNoMessageId`); `References` holds what it had. - The new `Message-ID` comes from `MessageIdFactory`, or `random@MessageIdDomain`; with neither, none is written and the sending server adds one. ## Conversation topic and index (MS-OXOMSG 2.2.1.3) `Thread-Topic` and `Thread-Index` (MAPI `PidTagConversationTopic`, `PidTagConversationIndex`) are written when the original is an Outlook item or already has an index, or when `ConversationIndexMode.Always` is set; MIME messages from other clients get none by default. - The topic is the original's topic, else its normalised subject. - A valid index (six bytes of FILETIME, a 16-byte GUID, then five-byte levels) gets one more level: a delta code bit and 31 bits of time delta from the header's time, then a random byte. The delta is `TimeDiff >> 18` when the high 32 bits of the difference AND `0x00FE0000` are zero (under about 1.7 years), else `TimeDiff >> 23` with the delta code set. A clock before the header's time gives delta 0 (`ConversationIndexTimeClamped`). - No index, or one that is not in the form (`ConversationIndexInvalid`), starts a new thread: a header block with the original's delivery or sent time (never later than now) and a new GUID, then the level. - An index that has reached 255 bytes is kept as it is (`ConversationIndexFull`). `ConversationIndex.TryParse`, `CreateHeader` and `AppendLevel` are public and decode and extend indexes by themselves. **Observed against the spec.** MS-OXOMSG draws the header as a reserved byte 0x01 followed by a 40-bit time (the high 32 bits of the FILETIME and the top 8 bits of its low half). Indexes in real Outlook items (45 MSG files and the CFReDS OST) carry the time from the first byte on: `01 CD 50 A5 70 16` is the FILETIME's top six bytes, whose first byte is 0x01 for every date between 1829 and 2057. OneMail reads and writes that form, so the header time has 2^16 FILETIME ticks (6.5 ms) of resolution. The spec's delta rules are followed as written. ## Recipients - **Reply**: `Mail-Reply-To` if present (MIME items), else `Reply-To`, else `From` (RFC 5322 §3.6.2). Replying to a message the user sent (every address is one of `OwnAddresses`) goes to that message's `To` instead. - **Reply all**: the reply target plus the original's `To`, then `Cc`; the user's own addresses are removed; each address appears once, `To` taking precedence over `Cc`. A `Mail-Followup-To` header (MIME items) replaces the whole list. If only `Cc` is left it becomes `To`. If nobody is left, the people the user wrote to are used. - Addresses are compared ignoring case (the local part is technically case-sensitive; no real server treats it so). - Recipients without an SMTP form (an Exchange distinguished name only) are never written and never lost silently: they are in `ComposedMessage.Unaddressable` with `UnaddressableRecipient`. An address that is not usable (no single `@`, white space, specials, longer than 320 characters) is dropped with `InvalidAddressDropped`. More than `MaxRecipients` (default 500) stops the list with `TooManyRecipients`. No recipient at all raises `NoReplyTarget`; the message is still made so the caller can address it. - Display names are written as atoms, quoted strings or RFC 2047 encoded words as needed; control characters become spaces. ## Quoting Top posting: the author's `Text`/`Html` (if given), then the quote. - **Text**: the attribution line (`On Mon, 4 Mar 2024 09:00:00 +0100, Ann wrote:` by default; `Attribution` replaces it), then every line of the original's text body prefixed with `> ` (`>` alone for an empty line). A `format=flowed` original gives a `format=flowed` reply (RFC 3676 §4.5): quoted lines get `>` with no space, soft line breaks (trailing space) survive, our own lines are space-stuffed, and `delsp` is kept. - **HTML**: `
attribution
…
`, the original's content between `` and `` (all of it when there is no body element). The quoted HTML is not sanitised: it is as hostile as the original, and anything that shows it goes through the display sanitiser. - A body larger than `MaxQuoteCharacters` (default 1,048,576) is cut at a line end (`QuoteTruncated`). - Both representations are quoted when the original has both (`multipart/alternative`); one when it has one. `QuoteBodyFormat.TextOnly` or `HtmlOnly` quotes only that one; if it does not exist, nothing is quoted (`BodyNotAvailable`): HTML is not turned into text here. - Inline images (`cid:`) that the quoted HTML refers to are carried in a `multipart/related`. A reply carries other attachments only with `IncludeAttachments`. - **Inline forward**: the banner `---------- Forwarded message ----------` and the header block (From, Date, Subject, To, Cc), then the original's body unquoted, with all attachments carried. Embedded messages (`message/rfc822`) are carried as messages. - **Attachment forward**: a `message/rfc822` part with `Content-Disposition: attachment; filename=".eml"`. MIME originals are embedded as their own bytes (a fresh parse of the item's message gives back exactly the source bytes, malformed input included). Outlook items are exported by the MS-OXCMAIL mapping. An original that is neither (an OLM record) is rebuilt from the shared view with `ParentSynthesized`. - At most `MaxCarriedBytes` (default 64 MiB) of attachments and embedded originals are carried; the rest is skipped with `AttachmentSkipped`. ## MAPI result When the original is an Outlook item (or `CreateMapi` is true) the finished MIME message is imported by `MimeToMapiConverter`, so the MAPI message is exactly what the MS-OXCMAIL mapping makes of the MIME one, and `ComposedMessage.Report` is that import's loss report. `PidTagInReplyToId` holds the one identifier, `PidTagInternetReferences` the chain, `PidTagConversationTopic` and `PidTagConversationIndex` the topic and the index. An attachment forward carries the original as an embedded message. ## Not covered Sending, signing, sanitising the quote, HTML to text, meeting responses (accept/decline), drafts' `X-Unsent`, `Disposition-Notification-To`, and translated attribution or banner text (pass your own). ## How it is checked `tools/composition/run.sh`: replies, reply-alls and both forwards of every sampled item, parsed back by OneMail, MimeKit, Python `email` and Go `net/mail`, with subject, From, To, Cc, In-Reply-To and References compared to what the composer says it wrote. See `docs/compatibility.md` for the latest numbers. `fuzz/` has the `composition` target. # MSG and OFT reading `RecursiveAI.OneMail.Msg` reads Outlook message files (`.msg`) and templates (`.oft`) into the MAPI model (`docs/mapi.md`). The container is MS-CFB, read through OneArchive's compound file reader; the content is MS-OXMSG. An OFT is an MSG whose message class is a template; the same reader opens it. ## Using it `MsgFile.TryOpen(path | memory | stream | source, ...)` returns false with typed defects when nothing can be read, otherwise a `MsgFile` with `Message`. `ReadEverything()` loads every recipient, attachment and embedded message and returns the defects found, so a caller can validate a file in one call. `MsgFile.IsMsg(source)` is the content sniff used by `MailFormatSniffer`: a compound file whose root has a `__properties_version1.0` stream. The file extension is never consulted. Opening reads the directory and the top-level property stream only. Value streams, attachment data and children are read on demand: opening a 2 MB file with 1,321 recipients allocates 4.7 MB; a 3.5 KB file about 21 KB. ## Layout read (MS-OXMSG 2.1-2.4) - Root: `__properties_version1.0`, `__substg1.0_` value streams, `__recip_version1.0_#XXXXXXXX` and `__attach_version1.0_#XXXXXXXX` storages, `__nameid_version1.0`. - Property stream headers: 32 bytes for the top-level message, **24 for an embedded message** (2.4.1.2), 8 for recipients and attachments. Entries are 16 bytes: tag, flags, then 8 bytes of value (fixed types) or size and reserved (variable types). - Embedded messages and object attachments are in `__substg1.0_3701000D`; as a storage it is read as a message (or raw storage for other objects), as a stream its bytes are the value. - Named properties: the GUID, entry and string streams of `__nameid_version1.0` map IDs from 0x8000 to names (2.2.3). The entry's own index is ignored in favour of its position (`NamedPropertyIndexMismatch`). Embedded messages use the top-level map; their own is ignored (`NestedNamedPropertyMap`). - Multi-valued properties: fixed-length elements in one stream; variable-length ones as a length stream plus one value stream per element (2.1.3). The length stream is read at open. ## Code page for 8-bit strings Resolution order, first hit wins: the object's own `PidTagMessageCodepage`, the ANSI page of `PidTagMessageLocaleId`, `PidTagInternetCodepage` (unless the strings are Unicode), the enclosing message's answer, then 1252. Reported as `AmbiguousCodePage` when there is no `PidTagMessageCodepage` and 8-bit strings exist. The order was fixed by the Apache POI and Tika files that carry only a locale or an Internet code page (a Big5 file, a Cyrillic file, a German file in UTF-8 form). `MapiObject.String8EncodingSource` says which rule applied. An unknown page falls back to 1252 (`UnsupportedCodePage`). ## Size field of strings MS-OXMSG says a string's size is the stream length plus the terminator for Unicode (2.4.2.2). ANSI files written by some producers store the terminating NUL inside the stream. Either is accepted; any other mismatch is `ValueSizeMismatch`, and the stream's bytes are the value. ## Damage policy | Situation | Result | |---|---| | Not a compound file / damaged container | Open fails: `NotACompoundFile`, `CompoundFileInvalid` (OneArchive's reader is strict, see below) | | No `__properties_version1.0` | `NotAMessage` | | Entry without its value stream | property kept, empty: `ValueStreamMissing` | | Value stream without an entry | read as a property: `AmbiguousOrphanValueStream` | | Property stream truncated | whole entries kept: `PropertyStreamTruncated` | | Same tag twice | both kept, last wins: `AmbiguousDuplicateProperty` | | Recipient/attachment counts differ from header | storages found win: `RecipientCountMismatch`, `AttachmentCountMismatch` | | Strings of the other kind than the store mask says | read by their own type: `AmbiguousStringType` | | Unknown streams | ignored: `UnreferencedStream` | The full list is `MapiDefectKind`. Truncated or damaged compound files that OneArchive's reader rejects cannot be opened at all; two minimised fuzzer files in the Apache POI set are in that class. ## Limits (`MsgReaderOptions`) Properties per object 1,048,576; recipients and attachments 65,536 each (the specification says 2,048; real files exceed it); embedding depth 32; values per multi-valued property 1,048,576; bytes per value 256 MiB; kept defects 10,000. Sizes are checked before anything is allocated. The compound-file reader has its own total-size limit (1 GiB by default). ## Property-exact re-encoding `MsgStreams.Encode` re-encodes an object's property stream and value streams from the model. For all 53 openable corpus files and 7 generated fixtures, every object's property stream and every value stream re-encode byte for byte (see below), so a writer can be built on the model without loss. ## How it is checked - `tests/OneMail.Tests/Msg`: generated fixtures (test-side builder, `testdata/msg/generated`, regenerate with `tools/msg/MsgTool gen`) and the public corpus when fetched (`tools/corpus/fetch-msg.sh`). - `tools/msg/MsgTool streams ` compares the re-encoded streams with the file's. - `tools/msg/compare_extract_msg.py` compares, per object, property tags, flags, entry bytes, value stream bytes, decoded strings and named-property names against the Python package extract-msg, run as a released package (version in `tools/msg/README.md`). Differences are either listed with a reason in the script or reported as unexplained. - Fuzz target `msg` (`fuzz/run.sh msg `): patches a valid fixture and opens it, enumerating everything. Results (62 files: 55 corpus + 7 generated): 60 open, 60 re-encode identically; 58 compared, 1,516 objects, 15,609 properties, 9,822 value streams, 6,131 decoded strings, 955 named properties, **0 unexplained**. Explained: 36 strings in files with no message code page (the oracle uses a fixed default; ours follows the rule above), one orphan stream, one signed attachment the oracle exposes no properties for. Not compared: the two refused files, a generated empty file and a generated file with an OLE attachment (the oracle cannot open them). # PST/OST messaging layer What `RecursiveAI.OneMail.Pst.Messaging` reads, how it treats damage, and what was checked. The structures are MS-PST 2.4 (store, folders, messages, attachments, recipients, named properties) on the lists-tables-properties layer (`docs/pst-ltp.md`). The same code reads ANSI, Unicode, 4 KiB and OST files; the differences live in the node database (`docs/pst-ndb.md`). Every object it returns is a `RecursiveAI.OneMail.Mapi` property bag, so a PST message, a folder and an MSG message are read with the same accessors (`docs/mapi.md`). ## Types | Type | Spec | What it is | |---|---|---| | `PstStore` | MS-PST 2.4.3 | `TryOpen` over a path, stream, byte array or random-access source. `Root`, the store's `Properties`, `PasswordCrc`, `TryGetNamedProperty`, `EnumerateFolders`, `EnumerateMessages(PstMessageSet)` (and `Async` twins), `TryOpenMessage(nid)`, `ReadEverything()` (walks and reads every object, returns `PstWalkCounts`). | | `PstFolder` | 2.4.4 | Properties, `Name`, `Path`, `Depth`, `ParentFolder`, `EnumerateSubfolders`, `EnumerateMessages(PstMessageSet)` and `EnumerateMessageIds`, `IsSearchFolder`. | | `PstMessage` | 2.4.5 | A `MapiMessage` with `Recipients`, `Attachments`, defects, `IsEmbedded`, `IsAssociated`. | | `PstRecipient`, `PstAttachment` | 2.4.5.3, 2.4.6 | Rows of the recipient and attachment tables with their properties; an attachment may carry an embedded `PstMessage`. | | `PstReaderOptions` | | Limits (below) and the options of the layers underneath. | `MapiMailItem` (root namespace) adapts any `MapiMessage` to the shared view (`docs/shared-view.md`); `Mail.Open` uses it for PST, OST and MSG files. ## How things are loaded - **Names.** The named-property map (NID 0x61, MS-PST 2.4.7) is read once at open; property IDs of 0x8000 and above are resolved to GUID plus numeric ID or string name through it. A missing or unreadable map leaves those properties unnamed and raises `NodeMissing` or `ContextUnreadable`; nothing is dropped. - **Folders.** From the root folder's hierarchy table, depth first. A folder reached twice, or deeper than `MaxFolderDepth`, is not entered again (`FolderLoop`). Contents tables list the messages; a message listed in a table whose node is missing is reported (`NodeMissing`) and skipped. - **Search folders** (their contents are links to other folders' messages) and **associated (FAI) messages** (rules, views, settings) are not enumerated by default: `PstMessageSet.Normal` is the ordinary contents of ordinary folders; `Associated` and `All` add the hidden messages. Search folders are listed by `EnumerateFolders` (`IsSearchFolder`) but their contents are not enumerated, so no message appears twice. `Mail.Open` uses `Normal`. - **Messages.** The property context is read when the message is opened. Values held in the heap are copied at that point; values held in subnodes (large bodies, attachment data, big tables) stay on disk and are read when asked, by range (`PstValueStream` for streams). Items therefore need no disposal and stay valid until the store is disposed. - **Recipients and attachments.** Each table is a table context in a subnode of the message. The row's identifier is checked against the subnode the object is stored under (`RowIdMismatch`). A row whose properties are in a separate subnode (attachments) is merged with it; the table's bookkeeping columns (0x67F2, 0x67F3) are not exposed as properties. - **Embedded messages.** An attachment of method `ATTACH_EMBEDDED_MSG` holds its message as an object property: an 8-byte structure (subnode NID, size) in the heap (MS-PST 2.3.3.5). A bare subnode NID is accepted too (ambiguity P2). The embedded message is read the same way as a top-level one, to `MaxEmbeddingDepth`. - **Large tables.** A recipient or attachment table whose row matrix is in a data tree is read through the node database's data tree, row by row. - **Password and obfuscation.** `PasswordCrc` returns the stored CRC. It is not protection: the store reads whatever the value. Block obfuscation is decoded by the node database for every file. ## Limits | Limit | Option | Default | |---|---|---| | Folder depth | `MaxFolderDepth` | 256 | | Properties in one object | `MaxProperties` | 1,048,576 | | Recipients of a message | `MaxRecipients` | 65,536 | | Attachments of a message | `MaxAttachments` | 65,536 | | Embedded-message depth | `MaxEmbeddingDepth` | 32 | | Named properties | `MaxNamedProperties` | 32,768 | | Defects kept (the rest are counted in `DroppedDefects`) | `MaxDefects` | 10,000 | Reading never throws on malformed content. Beyond the limits, rows and objects are skipped and `LimitReached` is raised (`DefectLimitReached` when the defect list itself is full). `TryOpen` returns false with defects only when no usable header or root exists. ## Defects `MapiDefect` carries a kind, a message, the node where known, and an offset when the lower layer gave one. Added by this layer: `NodeMissing`, `ContextUnreadable`, `ValueUnreadable`, `LowerLayerDefect` (a node database or LTP defect met while reading an object), `FolderLoop`, `RowIdMismatch`, and the two ambiguities `AmbiguousAttachmentTableMissing` and `AmbiguousObjectReference` (P1, P2 in `docs/ambiguities.md`). Store-wide node database defects are on `DatabaseDefects`. ## How it is checked Worker, libpff `pffexport` 20180714 (black box), `tests/PstOracle`: - Real files (testPST, testPST_variousBodyTypes, the CFReDS OST): folders, items, recipients, attachments (names, sizes, content), embedded messages, subject, sender, body text/HTML/RTF values and times, against `pffexport -f all -m all`: 0 differences. Items libpff does not export (31 folder-associated messages of the OST) are counted separately. - Property-exact: every property bag against the raw LTP entries (tag, type, value bytes): 0 differences on 27 objects / 959 properties (testPST), 16 / 796 (variousBodyTypes), 109 / 3,763 (OST). - Generated files from the test builder in ANSI, Unicode and 4 KiB layouts (up to 128 items, a 300-recipient message, 110-row tables in data trees, 3-deep embedded messages, large attachments in subnodes): 0 differences from `pffexport` (except the 4 KiB case below) and 0 from the property-exact check. - **libpff limitation (4 KiB files).** libpff 20180714 does not read a table whose row matrix lies in a data tree in a 4 KiB file: on the generated 4 KiB store of 215 items it reads 18 items, and the 300-recipient message gives 0 recipients (tried with data block sizes of 8,168 and 65,512 bytes). Our reader reads all of them, and the property-exact check agrees with the raw entries. The same libpff agrees with us on every small table of the real 4 KiB OST. So the 4 KiB large-table case has no differential until another oracle (Outlook) exists. - Fuzzing: the `pst-messaging` target (bounded run recorded in the unit report). - Memory: `PstOracle pstalloc ` prints bytes allocated per message and the working set after each pass. # PST/OST lists, tables and properties (LTP) layer What `RecursiveAI.OneMail.Pst.Ltp` reads, how it treats damage, and what was settled by looking at real files. The structures are MS-PST 2.3 (heap-on-node, BTree-on-Heap, property context, table context). The layer sits directly on the node database layer (`docs/pst-ndb.md`) and gives values no meaning: it returns property IDs, property types and **references to bytes**. Strings, names, folders, messages and attachments belong to the layers above. LTP is the same in ANSI, Unicode and 4 KiB files; the differences (block sizes, compression, encoding) are the node database's. ## Types | Type | Spec name | What it is | |---|---|---| | `HeapNode` | HN (2.3.1) | A node's data read as heap blocks of numbered allocations. Resolves HIDs, owns the subnode tree, reads values. | | `Hid`, `Hnid` | HID, HNID (2.3.1.1, 2.3.3.2) | 32-bit heap address; heap-or-subnode address. | | `Bth`, `BthCursor` | BTH (2.3.2) | The tree: `TryFind` by key, a cursor over leaf records in key order. | | `PropertyContext`, `PcProperty` | PC (2.3.3) | Properties by ID: `TryGet`, `foreach`, `ReadValue`. | | `TableContext`, `TcColumn`, `TableRow` | TC (2.3.4) | Columns, a row index, rows copied into the caller's buffer, cells as references. | | `LtpValueRef` | | Where a value's bytes are: `Empty`, `Inline` (up to eight bytes in the reference), `Heap` (an allocation) or `Subnode` (a subnode's data). Nothing is read until you ask. | | `LtpValues` | | Fixed-size scalars (`TryGetInt16`, `Int32`, `Int64`, `Boolean`, `Single`, `Double`, `FileTime`, `Guid`) and multi-valued layouts (`TryGetMultiValueCount`, `TryGetMultiValueElement`). | | `LtpPropertyTypes` | MS-OXCDATA 2.11.1 | The type codes, which sizes are fixed, the multi-value flag. | Open a context from a node (`PropertyContext.TryOpen(file, nid, ...)`), a subnode entry, or a heap you already hold (`HeapNode.TryOpenSubnode` gives the heap of a message's recipient table, for instance). Contexts opened from a file or subnode own their heap and dispose it; a context made over your `HeapNode` leaves it to you. Opening a heap whose `ClientSignature` is the wrong one for the context asks for is refused with `HeapClientSignatureMismatch`. Value access is the same everywhere: `GetValueLength(ref)`, `ReadValue(ref, offset, buffer)` (a range, so large subnode values stream), and `ReadValue(ref)` for a whole value as an array (null beyond `LtpOptions.MaxValueBytes`, default 256 MiB). ## How values are found - **PC** (2.3.3.3): a BTH with 2-byte keys (property ID) and 6-byte entries (type, `dwValueHnid`). A fixed-size type of at most four bytes (Integer16, Integer32, Floating32, ErrorCode, Boolean) is in the entry itself and comes out as `Inline`. Everything else is an HNID: a zero HNID is `Empty`, an HID is `Heap`, a subnode NID is `Subnode`. - **TC** (2.3.4.4): a cell of 1, 2, 4 or 8 bytes whose size equals the column type's fixed size is inline; a four-byte cell for any other type (strings, binary, objects, GUIDs, multi-valued) is an HNID, resolved as above. A cell that does not exist (its bit in the cell-existence bitmap is clear) gives no reference; the slot's bytes are not interpreted (2.3.4.4.3). - **Rows** (2.3.4.4): the row matrix is one heap allocation (small tables) or the data of a subnode. In a subnode, rows never straddle a block, so row *N* is in block *N / rowsPerBlock* at *(N % rowsPerBlock) × rowSize*. - **Multi-valued** (2.3.3.4): fixed-size elements are stored back to back; String8, String and Binary elements are a 4-byte count, one 4-byte offset per element from the start of the value, then the data. ## Reading never throws `TryOpen` returns false and a typed list only when the structure is unusable. Everything else is an `LtpDefect` (kind, file offset of the heap block, plain-language detail) in the heap's `Defects`, capped by `LtpOptions.MaxDefects` (1,000 per open heap; the rest are counted in `DroppedDefects`). Problems in the blocks themselves (checksums, missing blocks, bad compression) are `NdbDefect`s on the `NdbFile`; a block with a bad checksum is still returned by the node database layer, so LTP reads through it and the two lists together say what was wrong. | Group | Kinds | |---|---| | Nodes | `NodeNotFound`, `HeapUnreadable` (no data blocks) | | Heap | `HeapBadHeader`, `HeapBadSignature` (bSig is not 0xEC: the node is not a heap), `HeapClientSignatureMismatch`, `HeapPageMapInvalid`, `HeapBlockMissing`, `HidInvalid` | | Subnodes and values | `SubnodeMissing`, `SubnodesUnreadable`, `ValueUnreadable` | | BTH | `BthBadHeader`, `BthTooDeep`, `BthNodeSize`, `BthKeyOrder`, `BthTooManyNodes` | | PC | `PcBadShape` | | TC | `TcBadInfo`, `TcBadLayout`, `TcRowIndexShape`, `TcRowOutOfRange`, `TcCellShape` | `LtpValues` has no defect list: multi-valued data whose count or offsets do not fit returns false. Lookups that fail return false, `-1` (a length) or null (a whole value) rather than a partial answer. ### Limits and hostile-input rules | Rule | Value | |---|---| | BTH index levels | 8 (`LtpOptions.MaxBthLevels`); real trees have 1 to 3 | | BTH nodes visited by one cursor | at most (heap length / 2) + 16: a node cannot be smaller than two bytes, so a well-formed tree cannot exceed this, and several parents pointing at one child cannot multiply the work | | BTH key order | strictly increasing; a record that does not follow its predecessor is skipped with `BthKeyOrder` | | `cbKey` / `cbEnt` | 2, 4, 8 or 16 / 1 to 32 (2.3.2.1); anything else is `BthBadHeader` | | HID | type bits zero, index 1 to `cAlloc`, block inside the heap, allocation inside its block's data area (after the block header, before the page map) | | Page map | `ibHnpm` leaves room for the map and the map fits the block; checked once per loaded block | | Row access | row index and row size are checked against the matrix before any copy | | Whole-value reads | `LtpOptions.MaxValueBytes` | No allocation size is trusted before it is checked against the block it lives in; every read is a bounds-checked slice of the decoded block. The layer has no `unsafe` code. ## Provisional resolutions All are provisional defaults; none was contradicted by a real file. 1. **Rows per block of a subnode row matrix** (2.3.4.4 gives `floor((blockSize − trailer) / rowSize)` for 8 KiB blocks): taken from the first block's own size (`size / rowSize`), which equals the formula for 8 KiB blocks and adapts if 4 KiB files lay rows out in larger blocks. A single block holds all rows. 2. **BTH index keys** are not checked against the first key of the child they point to; lookups trust the key order and the level count. 3. **A BTH key that does not increase** is skipped, not returned, so a node referenced twice cannot produce duplicates. 4. **Allocation lower bound:** an allocation may not start before its block's header (12 bytes in block 0, 66 in blocks 8, 136, 264... which carry a fill-level bitmap, 2 elsewhere). The bitmap-header blocks (2.3.1.4) were only exercised on files we wrote: no real file we have holds a heap of more than two blocks. 5. **Boolean and Integer16 in a PC** are inline (the entry's low one or two bytes); MS-PST states "up to four bytes". 6. **Unknown types** are read as HNIDs in a PC (a four-byte cell in a TC); a one-, two- or eight-byte cell of an unknown type is kept inline. 7. **`rgibAlloc` is zero-based while HID indexes are one-based:** HID index *i* is the allocation from `rgibAlloc[i − 1]` to `rgibAlloc[i]` (the page map's wording is ambiguous; every HID in the three real files resolves this way). 8. **Subnode lookup** compares the whole 32-bit NID, type bits included, as the HNID carries it. ## Real files Checked on the NIST CFReDS Outlook 2013 OST (4 KiB pages, compressed blocks), Apache Tika's `testPST.pst` and `testPST_variousBodyTypes.pst` (Unicode, 512-byte pages, permute-encoded), all fetched by `tools/corpus/fetch-pst.sh`. `tools/oracles/libpff-ltp.sh ` reads every property and table context, then walks folders, items, recipients and attachments the way a messaging layer would and compares with the libpff tools' export. | File | PCs | TCs | Properties | Rows | Cells | LTP defects | NDB defects | |---|---|---|---|---|---|---|---| | CFReDS OST | 106 | 206 | 3,771 | 309 | 2,627 | 0 | 0 | | `testPST.pst` | 21 | 38 | 998 | 25 | 360 | 0 | 0 | | `testPST_variousBodyTypes.pst` | 14 | 34 | 733 | 15 | 252 | 0 | 0 | (Nodes that are not heaps at all, such as search queues and internal lists, fail to open as `HeapBadSignature`, `HeapBadHeader` or `HeapUnreadable`; the tool counts them separately and they are not defects of any LTP structure.) Against `pffexport` (20180714) the folder hierarchy, items per folder, subjects, sizes, flags, three timestamps, sender, conversation topic, recipients (name, address, type) and attachments (count and data size; an embedded message counts as one with no size) agree for all three files: 48 / 6 / 6 folders, 14 / 7 / 4 items, 0 differences. - The only systematic difference is a normalisation: a subject may begin with U+0001 and one more character (a prefix such as "RE: "); libpff drops both, the raw layer keeps them, the comparison strips them. - `pffexport` reports no complaint in the live tree for any of the three files. All of its complaints (96, 40, 0) come from its final pass over *recovered* items, nodes found by scanning for old index entries. The twelve offsets it fails to read in the OST are the "unable to read data block" lines of that pass ("mismatch in data size"): five are not the start of any block in the file today, and seven hold blocks of a different size from the one it expected (three of them are live blocks that belong to other nodes now). The stale entries point at space that has been reused. Our reader never visits them, because nothing in the live node tree points there. ## Memory On the CFReDS OST (release build, one thread): opening a property context allocates about 2.7 KB, opening a table context 2.6 KB, enumerating a context's properties 144 B; `TryGet`, `ReadValue` into a buffer, `TryReadRow`, reading every cell of a row, and `TryFindRow` allocate nothing. Heap blocks are rented from the array pool and returned on dispose. Reading every PC and TC in the file 200 times in a row keeps the managed heap at 339 to 355 KB after collection and the peak working set at about 60 MB (of which the runtime is most), flat from the 75th pass on. # PST/OST node database (NDB) layer What `RecursiveAI.OneMail.Pst.Ndb` reads, how it treats damage, and what was settled by looking at real files. The structures are MS-PST 2.2.2 (the NDB layer); where MS-PST is silent (4 KiB pages) the facts below come from a real Outlook 2013 OST and from the libyal PFF format notes (GFDL 1.3: cited, nothing copied). ## Geometry | | ANSI (wVer 14, 15) | Unicode (wVer 21, 23) | Unicode 4 KiB (wVer 36; 37 decided by the root page) | |---|---|---|---| | page | 512 | 512 | 4096 | | page trailer | 12 | 16 | 24 | | block trailer | 12 | 16 | 24 | | block alignment | 64 | 64 | **512** | | largest block data | 8192 − 12 | 8192 − 16 | 65536 − 24 | | id size (NID/BID/IB) | 4 | 8 | 8 | | B-tree footer at | 496 | 488 | 4056 (cEnt and cEntMax are 16 bits) | | density list | 0x4200 | 0x4200 | 0x21000 | | first AMap | 0x4400 | 0x4400 | 0x22000 | | AMap bits | 496 bytes, one bit per 64 | 496 bytes, one bit per 64 | 4072 bytes, **one bit per 512** | | first block | 0x4800 | 0x4800 | 0x23000 | Observed on a real 4 KiB OST (a 16 MB Outlook 2013 offline store, wVer 36, `SO`, no encoding): - Blocks are padded to 512 bytes and the 24-byte trailer sits at the end of the padded extent. A 56-byte block occupies 512. - The trailer after `bid` carries two more 16-bit fields: a constant `2` (meaning not known) and the **uncompressed size**; 4 bytes follow. - A block is compressed when its uncompressed size differs from the BBT's size. All 123 compressed blocks in the file are zlib streams (header `78 xx`), CRC computed over the stored (compressed) bytes. Internal blocks (XBLOCK, SLBLOCK) are never compressed. The reader accepts raw deflate too. - The file has one AMap, the density list page (empty) and no PMap, FMap or FPMap pages. The AMap's free-byte total equals `cbAMapFree` in the header exactly when a bit stands for 512 bytes. - The 4 KiB header root is laid out as the Unicode one (offsets 180..252). Seen on real files since: Apache Tika's `testPST.pst` and `testPST_variousBodyTypes.pst` are Unicode files with 512-byte pages (wVer 23) and permute encoding (`bCryptMethod` 1). The first has nine AMap pages and two PMap pages; both open with no defect, scan mode finds every block the block B-tree lists, and the LTP layer reads every property and table context in them (`docs/pst-ltp.md`). Not yet seen on a real file: ANSI files (no public one was found), 4 KiB files with more than one AMap, and encoded 4 KiB files. Those paths are tested against files written from the specification by the test-side builder only. ## Sources `NdbFile.TryOpen(IRandomAccessSource source, bool leaveOpen, ...)` opens a file over any source of the sources layer (a file, a stream, memory, or caller-supplied ranged reads); the path, stream and memory overloads are shortcuts for it. The node database reads with synchronous positioned reads, so a source that does not support them (`SupportsSyncRead` false) is refused with `ArgumentException`; short reads from a source are retried until the buffer is full or the source ends. Paths open with `FileShare.ReadWrite | FileShare.Delete`, so a file another program has open can still be read. ## Reading never throws `NdbFile.TryOpen` returns false only when no usable header exists (too short, wrong magic, unknown version); everything else is a typed `NdbDefect` with the file offset. I/O errors (the file cannot be opened) throw `IOException` as usual. Defects (`NdbDefectKind`), grouped: - **Header:** FileTooShort, BadMagic, UnknownClientMagic, UnsupportedVersion, HeaderPartialCrcMismatch, HeaderFullCrcMismatch, BadSentinel, UnknownEncryption, ProtectedContent (bCryptMethod 0x10, contents not decodable), AMapFlagInvalid, FileSizeMismatch, RootOutOfRange, AmbiguousPageSize. - **Pages:** PageOutOfRange, PageTruncated, PageTypeMismatch, PageTypeRepeatMismatch, PageSignatureMismatch, PageCrcMismatch, PageBidMismatch. - **B-trees:** BTreeBadGeometry, BTreeLevelMismatch, BTreeTooDeep, BTreeKeyOrder (entry skipped), BTreeTooManyPages. - **Blocks:** BlockOutOfRange, BlockTruncated, BlockTooLarge, BlockSizeMismatch, BlockSignatureMismatch, BlockCrcMismatch, BlockBidMismatch, BlockNotFound, BlockDecompressionFailed, AmbiguousCompressedEncodedBlock. - **Data and subnode trees:** DataTreeBadHeader, DataTreeTooDeep, DataTreeTooLarge, DataTreeSizeMismatch, DataTreeChildMissing, SubnodeBadHeader, SubnodeTooLarge, SubnodeChildMissing. - **Allocation:** AllocationPageInvalid, AMapFreeSpaceMismatch, DensityListInvalid. - **DefectLimitReached:** the last slot of the list when `NdbOptions.MaxDefects` (100,000) is hit; later defects are only counted. A block whose trailer checks fail is still returned (the defect says why), so a reader can show what it can; a block that cannot be read at all (outside the file, larger than a block can be, bad deflate data) yields nothing and a defect. ### Ambiguities (provisional resolutions) - **Page size of wVer 37** (and any version above 36): decided by which trailer position repeats `ptype` on the NBT root page; if neither or both do, 512 with `AmbiguousPageSize`. - **Compressed and encoded block together:** MS-PST does not say which comes first. The reader inflates, then decodes, and reports `AmbiguousCompressedEncodedBlock`. No real file has both. - **ANSI allocation page CRC:** computed over the 496 map bytes (after 4 bytes of padding) by some writers and over the whole page body by others; both are accepted. Not yet observed on a real ANSI file. ## Resource limits (defaults, `NdbOptions`) | Limit | Default | Why | |---|---|---| | defects kept | 100,000 | a damaged 50 GB file must not exhaust memory with its own complaints | | leaf blocks per data tree | 4,194,304 | a data tree is at most 2 levels (XXBLOCK); this is 32 GiB of 8 KiB blocks | | entries per subnode tree | 1,048,576 | | | pages visited per B-tree walk | file length / page size + 8 | two parents pointing at one child cannot multiply the work | | B-tree levels | 8 | MS-PST 2.2.2.7.7 | Decompression is capped by the uncompressed size in the trailer (at most one block), so a compressed block cannot expand beyond 64 KiB. ## Scan mode `NdbFile.Scan` finds pages and blocks without using either B-tree: a page is a page-sized unit whose last bytes are a valid page trailer (ptype 0x80..0x86 repeated, signature, CRC); a block is an extent ending, on a block-alignment boundary, in a trailer with a plausible size, a signature that matches its own offset and BID, and a matching CRC. One forward pass over a 1 MiB window; memory does not grow with the file. `IncludeDamaged` also reports structures that fail only one of signature or CRC. Results carry the offset, size, BID and which checks passed; this is the substrate for recovery. On the real OST: all 381 BBT blocks and both root pages are found, plus 101 further clean blocks that no tree references (stale versions Outlook left behind). ## Comparison with the libpff tools (20180714, black box) `tools/oracles/libpff-ndb.sh ` prints what `pffinfo` and `pffexport` say next to `PstOracle dump` for the same file. On the 4 KiB OST: both report `64-bit with 4k page`, `Offline Storage Tables (OST)`, no encryption, file size 16,818,176. pffexport builds the folder tree and exports messages, but logs "unable to read table" for 12 items in this file (its 2018 build and 4 KiB stores); our reader finds no defect anywhere in the file. Item-level agreement belongs to the LTP/Messaging layers. # The MAPI property model What `RecursiveAI.OneMail.Mapi` is: the in-memory form of an Outlook item (message, recipient, attachment), independent of the file it came from. MSG files fill it today (`docs/msg.md`); the PST/OST messaging layer fills the same types (`docs/pst-messaging.md`). Types and tags are MS-OXCDATA 2.11.1 and MS-OXPROPS; nothing here is taken from another implementation. ## Objects - `MapiMessage`, `MapiRecipient`, `MapiAttachment` derive from `MapiObject`, which owns a `MapiPropertyBag`, a list of `MapiDefect`s and a link to its parent. - Recipients and attachments load on first use. An attachment that is an embedded message exposes it as `EmbeddedMessage` (a `MapiMessage` of its own); other object attachments expose `ObjectStorage`, a `MapiRawStorage` that keeps the storage's streams byte for byte so nothing is lost. - `MapiMessage.Subject`, `Body`, `MessageClass`, recipient and attachment accessors are conveniences over `Properties`; the bag is the truth. ## The property bag - A `MapiProperty` is `(tag, flags, value)`. The tag is a 16-bit ID plus a 16-bit `MapiType`; IDs 0x8000 and up are named properties and carry their `MapiNamedProperty` (property-set GUID plus a number or a string). - **The raw value is always kept**: the bytes of the entry or value stream, read lazily through a `MapiValueSource`. Typed accessors (`AsInt32`, `AsDouble`, `AsBoolean`, `AsFileTime`, `AsGuid`, `AsString(encoding)`, `CurrencyUnits` ...) decode on demand and never change what is stored. Round-tripping a property never goes through a typed value. - The bag keeps insertion order and duplicates. Lookups by ID (optionally restricted to types) return the **last** entry with that tag (`AmbiguousDuplicateProperty`). `Version` increments on change so callers can cache. - A string property is `String8` (code page, see `docs/msg.md`) or `UnicodeString` (UTF-16LE). Asking for a string by ID accepts either type. ## Names `tools/mapi/properties.tsv` lists the properties the readers use (271 tagged, 75 named), each with its canonical MS-OXPROPS name. `tools/mapi/gen_properties.py` turns it into the generated `MapiPid.g.cs` constants and the `MapiPropertyNames` lookup. The table is a static array: no reflection, so trimming and AOT keep it. Edit the TSV, never the generated file. ## Defects `MapiDefect` has a `Kind`, a message and a location (object path plus stream name). Kinds named `Ambiguous*` are documented resolutions of known ambiguities (`docs/ambiguities.md`, section MAPI/MSG); the rest are repairs. The reader never throws on damaged content. Defect lists are capped by `MsgReaderOptions.MaxDefects`; later ones are only counted. # TNEF (winmail.dat) Normative notes for `RecursiveAI.OneMail.Tnef`, written from MS-OXTNEF (v20250520) and checked against public files. Layers: | Type | Layer | |---|---| | `TnefDocument`, `TnefAttributeRecord`, `TnefWriter` | Attribute stream: signature, legacy key, then attributes (level, id, length, data, checksum). Lazy data. | | `TnefPropertyReader` | A property list (attMsgProps, attAttachment, recipient rows): types, named properties, multi-valued values. | | `TnefMessage`, `TnefAttachment` | A `MapiMessage` (same shape as MSG and PST items), with embedded messages as lazy slices. | | `TnefMessageWriter` | `MapiMessage` -> TNEF. Returns warnings naming each property the format cannot carry. | `MimeEntity.EnumerateTnefParts()` finds `application/*` parts and `winmail.dat`/`*.tnef` names; `IsTnef`/`ReadTnef` open one. `Mail.Open` opens a bare TNEF file as one item. ## Fidelity - **Attribute layer is byte-identical.** `TnefDocument.WriteTo` reproduces any input: wrong checksums, unknown attributes, a truncated last attribute and trailing bytes are kept as they were. - **MAPI layer is property-exact.** Tag, type, named-property GUID and name, and raw value survive write then read. Properties TNEF cannot carry (server IDs, named properties without a name) are reported by name in the writer's warnings rather than dropped silently. - Legacy attributes (subject, dates, body, recipients, sender, priority, ...) become properties per MS-OXTNEF 2.1.3.3. The writer emits the legacy attributes old readers look for, but only for text the code page can hold; the rest stays in the Unicode property. ## Defects Typed, in `TnefMessage.TnefDefects` (kind, byte offset in the stream concerned, detail) and mirrored as `LowerLayerDefect` in the MAPI defect list. Parsers never throw on content. Kinds named `Ambiguous*` are the documented resolutions below; the others are repairs (`AttributeTruncated`, `TrailingBytes`, `InvalidLevel`, `ChecksumMismatch`, `LimitReached`, `UnexpectedVersion`, `MissingCodePage`, ...). ## Limits (defaults, on) | Limit | Default | |---|---| | attribute data | 1 GiB | | attributes in a stream | 1,000,000 | | attachments, recipients | 65,536 each | | properties in a list, values in a multi-valued property | 1,000,000 | | embedding depth | 16 | | announced decompressed RTF size | 256 MiB (header check only; RTF stays compressed) | | values read eagerly | 64 KiB; larger ones stay lazy | Reaching a limit raises `LimitReached` and stops that part. The public file `oom.tnef` (a property count of 873,267,203) opens in about 3 KB of allocations with `ChecksumMismatch`, `LimitReached`, `MissingCodePage` and `UnexpectedVersion`. ## Code pages 8-bit strings are read with attOemCodepage when present and nonzero (MS-OXTNEF 2.3.3.2, rule 2), reported as `MapiCodePageSource.Declared`; otherwise the shared model's rules apply. Rule 1 (a MIME charset on the enclosing part) is not applied: `MimeEntity.ReadTnef` does not pass it down. The writer uses the message's resolved code page, falling back to windows-1252, writes it as attOemCodepage and reads back to the same message with no extra property. Ambiguity entries are in `ambiguities.md` (T1-T8). ## TNEF inside MIME `MimeEntity.EnumerateTnefParts()` and `ReadTnef` read the part on their own. `MimeToMapiConverter` merges a qualifying part into the `MapiMessage` (MS-OXCMAIL 2.2.3.8; the rules and loss codes are in [`conversion.md`](https://recursiveai.org/onemail/docs/conversion/)) and `MimeMailItem` shows its attachments and bodies in place of winmail.dat ([`shared-view.md`](https://recursiveai.org/onemail/docs/shared-view/), V9). A part qualifies when the stream has none of `NotTnef`, `UnexpectedVersion`, `AttributeTruncated`, `ChecksumMismatch` or `LimitReached`, and its `PidTagTnefCorrelationKey` matches the message's `X-MS-TNEF-Correlator`. Of the six corpus files, only `oom.tnef` (damaged) is not unpacked. `bug52400-winmail-simple.dat` and `bug52400-winmail-with-attachments.dat` end in CR LF outside the attributes; MS-OXCMAIL 2.2.3.8 says to ignore such a stream but all four oracles unpack them, so they are unpacked and reported (`AmbiguousTnefTrailingCrLf`, OX20). `MapiToMimeConverter` writes a TNEF message on request (`OxcMailExportOptions.Tnef`). # RTF bodies: compressed RTF and de-encapsulation `RecursiveAI.OneMail.Rtf` reads and writes what Outlook stores in `PidTagRtfCompressed` and recovers the HTML or plain text that Outlook wraps inside an RTF body. The namespace uses only the .NET base class library: it does not reference any other part of OneMail, so it can be lifted out as the seed of a general RTF reader. Nothing in it throws on malformed input: damage is a typed `RtfDefect` with a byte offset, and the ambiguity entries (RT1–RT33) are in [ambiguities.md](https://recursiveai.org/onemail/docs/ambiguities/). | Piece | Type | Spec | |---|---|---| | Tokenizer | `RtfReader` (`ref struct`) | RTF 1.9.1, lexical rules | | Decompress | `Compression.RtfDecompressor` | MS-OXRTFCP 2.2 | | Compress | `Compression.RtfCompressor` | MS-OXRTFCP 2.3 | | HTML/text out of RTF | `RtfDeencapsulator` | MS-OXRTFEX 2.2 | | HTML/text into RTF | `RtfEncapsulator` | MS-OXRTFEX 2.1 | ## Compressed RTF (MS-OXRTFCP) The stream is a 16-byte header (COMPSIZE, RAWSIZE, COMPTYPE, CRC, little-endian) and contents. `LZFu` contents are LZ77 over a 4096-byte circular dictionary that starts with 207 bytes of common RTF text; a control byte tells which of the next eight tokens are literals (1 byte) or references (2 bytes, big-endian: 12-bit dictionary offset, 4-bit length minus two). A reference to the dictionary's write position ends the data. `MELA` contents are the RTF itself. The CRC is the reflected CRC-32 polynomial (0xEDB88320) with a zero start value and no final inversion, over the contents (padding included). **Decompression** (`RtfDecompressor.Decompress`) checks, in this order, and reports each failure without stopping unless it cannot continue: | Check | Defect | Output | |---|---|---| | fewer than 16 bytes | `HeaderTruncated` | empty | | type neither `LZFu` nor `MELA` | `UnknownCompressionType` | empty | | COMPSIZE + 4 differs from the bytes present | `CompressedSizeMismatch` | the declared range, or what is there | | RAWSIZE above `MaxOutputBytes` (256 MiB) | `OutputLimitExceeded` | empty | | RAWSIZE above 20 × the input length | `ExpansionRatioExceeded` | empty | | data ends before the end marker | `MissingEndMarker` | everything decoded | | reference to a dictionary position nothing was written to | `ReferenceBeyondDictionary` | zero bytes used | | CRC differs | `CrcMismatch` | everything decoded | | bytes produced differ from RAWSIZE | `RawSizeMismatch` | everything decoded (the buffer grows up to the limit) | The size and ratio checks run before the output buffer is allocated. Honest data cannot expand more than 17 bytes per 2-byte reference (8.5 : 1), so the ratio limit of 20 never refuses real data. `MELA` copies the contents and does not validate the CRC (MS-OXRTFCP 2.2.3.1). `RtfDecompressionResult.CrcVerified` is true only for `LZFu` data whose CRC matched. **Compression** (`RtfCompressor.Compress`) searches the 4095 bytes before the cursor through a hash chain of two-byte pairs (at most 128 candidates per position), takes the longest match of 2 to 17 bytes (the older one on a tie, as the specification's oldest-first scan does), and writes the end marker. Matches may overlap the bytes being written, which is what the decoder's byte-at-a-time copy expects. Output decompresses back to the input exactly with `RtfDecompressor` and with the oracle; it reproduces both worked examples of the specification byte for byte. It is not required to equal Outlook's own bytes (see Measurements). An empty input becomes `01 0C F0` (an end marker only); Outlook's writer adds a NUL byte there, which the reader treats as one byte of output (RT18). ## The tokenizer (`RtfReader`) A forward-only reader over a `ReadOnlySpan`: `GroupStart`, `GroupEnd`, `ControlWord` (name, optional signed number, one optional delimiting space that belongs to the word), `ControlSymbol` (`\*`, `\~`, `\{` …), `HexEscape` (`\'hh`), `Text` (a run of bytes), and `Binary` (the `N` bytes after `\binN`, not parsed). CR and LF between tokens are skipped, as RTF requires. A backslash before a line break is a control symbol that readers treat as `\par`. It never interprets code pages, destinations or Unicode: those are the caller's. Defects: `UnbalancedGroupEnd`, `UnclosedGroups`, `ParameterOverflow` (clamped to `int.MaxValue`), `InvalidHexEscape`, `BinaryDataTruncated`, `TruncatedToken`, `NestingLimitExceeded` (default depth 1000, then reading stops). ## De-encapsulation (MS-OXRTFEX 2.2) `Detect` follows 2.2.3.1: the document starts `{\rtf`, and among its first ten tokens (all group starts or control words) `\fromhtml1` or `\fromtext` appears. `\fromhtml`, `\fromhtml0` and any other form do not count (RT12). `Deencapsulate` returns the text as a .NET string, with the document's default code page. All bytes are decoded, so there is no byte-oriented result to mis-label. HTML (`\fromhtml1`): - `{\*\htmltag…}` group content is copied, regardless of `\htmlrtf` (RT6); the numeric parameter is ignored. `\*\mhtmltag` and every other ignorable destination are skipped. - Outside `\htmltag`, text and control words under `\htmlrtf` (or `\htmlrtf1`) are dropped until `\htmlrtf0`. The state is saved on `{` and restored on `}`. The current font is tracked even in suppressed text. - `\par` and `\line` become CRLF, `\tab` a tab. Other control words are ignored. - Destinations that hold no body text are skipped (`\colortbl`, `\stylesheet`, `\info`, `\pict`, `\object`, headers and footers, `\fldinst`, list and paragraph-number groups, bookmarks…); `\fonttbl` is read for code pages only (RT13). Plain text (`\fromtext`): the same walk with no `\htmltag` groups; `\htmlrtf` is ignored (RT10). **Characters.** `\uN` is a signed 16-bit UTF-16 unit; the next `\ucN` characters (default 1) are its fallback and are skipped. A skipped character is a text byte or a `\'hh` escape; control words do not count, and `{` or `}` ends the skipping (RT8). `\uc` is scoped to its group. An unpaired surrogate becomes U+FFFD (`LoneSurrogate`). `\'hh` and text bytes are decoded as runs in the code page of the current font: `\cpgN` of the font, else the charset of `\fcharsetN` (0 is windows-1252 whatever `\ansicpg` says; 1 means the document's code page), else `\ansicpgN` (or what `\ansi`, `\mac`, `\pc`, `\pca` imply, else 1252) (RT9). `\htmltag` content always uses the document's default code page. Runs are decoded together so that two-byte characters split across `\'hh\'hh` come out whole. Code pages come from `RtfDeencapsulationOptions.CodePageDecoder`, or by default from the base library's encodings and `CodePagesEncodingProvider` (no global registration); an unknown code page uses the default one and reports `AmbiguousUnknownCodePage` (RT11). Control words and symbols with a character meaning: `\lquote \rquote \ldblquote \rdblquote \bullet \endash \emdash \enspace \emspace \qmspace \zwj \zwnj \ltrmark \rtlmark`, `\~` (U+00A0), and `\_` (U+00AD inside `\htmltag`, U+2011 elsewhere) (RT5, RT7). `\{ \} \\` are the literal characters. ## Encapsulation (MS-OXRTFEX 2.1) `EncapsulateHtml` writes `{\rtf1\ansi\ansicpg1252\fromhtml1 \deff0{\fonttbl…}`, the empty `{\*\htmltag64}`, then walks the HTML: markup (tags, comments, doctype, and `