---
title: "Ambiguity table (version 1)"
url: https://recursiveai.org/onemail/docs/ambiguities/
description: "Real mail is full of messages that the RFCs call invalid or leave open, and the established libraries each pick their own answer. OneMail picks one…"
version: "0.3.0-preview.2"
source: https://github.com/recursive-ai-oss/onemail-csharp/blob/bb4c9543917ab4ad466b5200d132e1899cb3ca14/docs/ambiguities.md
---

# Ambiguity table (version 1)

Real mail is full of messages that the RFCs call invalid or leave open, and the established libraries each pick their own
answer. OneMail picks **one documented answer per case**, reports it as a typed defect, and keeps the original bytes so
nothing is lost whichever answer a caller prefers. This file lists each case, what three released libraries do with it, and
the resolution OneMail takes.

## How the table was made

- Each case is a small invented message in [`testdata/differential`](https://github.com/recursive-ai-oss/onemail-csharp/tree/bb4c9543917ab4ad466b5200d132e1899cb3ca14/testdata/differential) (generated by
  [`tools/corpus/make_differential.py`](https://github.com/recursive-ai-oss/onemail-csharp/blob/bb4c9543917ab4ad466b5200d132e1899cb3ca14/tools/corpus/make_differential.py); `cases.tsv` there describes every file).
- Each message was run through MimeKit 4.18.2, Python 3.13 `email` (`policy.default`, `message_from_bytes`) and Go 1.26
  `net/mail` + `mime/multipart`, through the drivers in [`tools/oracles`](https://github.com/recursive-ai-oss/onemail-csharp/tree/bb4c9543917ab4ad466b5200d132e1899cb3ca14/tools/oracles). The columns below are what
  those runs showed. Where the three agree, OneMail does the same.
- Where they differ, the rule is: prefer the answer two of the three give; otherwise prefer the answer that loses no
  bytes and follows the RFC text most closely. No mail client has been observed for any entry yet, so **every resolution
  that is not "all three agree" is provisional**: it is a default, to be revisited when a client's behaviour is observed.
  Provisional entries are marked *prov.*
- "Defect" is the typed defect the parser raises; every repair and every guess carries the byte offset. Names are the
  intended catalogue names; `Ambiguous*` marks a choice between valid readings, the others mark a repair.
- Compared forms, normalisation and the difference classes are in [`tools/oracles/README.md`](https://github.com/recursive-ai-oss/onemail-csharp/blob/bb4c9543917ab4ad466b5200d132e1899cb3ca14/tools/oracles/README.md).

Abbreviations: **MK** MimeKit, **Py** Python, **Go** Go standard library.

## Boundaries and multipart structure

| ID | Probe (`testdata/differential/…`) | MK | Py | Go | Resolution | Defect | Basis |
|---|---|---|---|---|---|---|---|
| B1 | `bnd-02` a content line `--abcdef` when the boundary is `abc` | content | content | content | A delimiter line is `--` + boundary + optional trailing whitespace; a longer line is content. | none | all agree |
| B2 | `bnd-03` trailing spaces and tabs after a delimiter | delimiter | delimiter | delimiter | Trailing space and tab are padding (RFC 2046 5.1.1). | none | all agree |
| B3 | `bnd-04` `--abc xyz`: text after the boundary | content | content | content | Not a delimiter; the line stays in the current part. | `AmbiguousBoundaryLikeLine` | all agree |
| B4 | `bnd-14` delimiter in another case (`--ABC`) | content | content | content | Boundaries are case-sensitive. | none | all agree |
| B5 | `bnd-07`, `bnd-08` one boundary is a prefix of the other | nested correctly | same | same | Longest-exact-match wins: a line closes or starts a part only if it equals a boundary in scope. | none | all agree |
| B6 | `bnd-06` text after the closing delimiter, then another `--abc` | epilogue | epilogue | epilogue | Everything after the closing delimiter is epilogue, including later delimiter-looking lines. | `EpilogueHasDelimiter` | all agree |
| B7 | `bnd-05` closing delimiter missing | last part keeps its final line break (5 bytes) | strips it (3) | strips it (3) | The last part runs to end of input and keeps every byte; no line break is assumed to belong to a delimiter that is not there. | `MissingFinalBoundary` | prov. (MK; keeps all bytes) |
| B8 | `bnd-09` multipart with no `boundary` | multipart, no parts, body not exposed | leaf, body kept | leaf, body kept | Treated as a leaf of the declared type with the body kept; no parts. | `MultipartWithoutBoundary` | 2 of 3 |
| B9 | `bnd-10` `boundary=""` | no parts | leaf | leaf | Same as B8. | `MultipartWithoutBoundary` | 2 of 3 |
| B10 | `bnd-25` multipart whose body has no delimiter at all | no parts | leaf, body kept | no parts | No parts; the whole body is the preamble, bytes preserved. | `MissingStartBoundary` | 2 of 3 |
| B11 | `bnd-11` boundary parameter that ends in a space | none found | finds a part | 2 parts | The boundary is matched literally and also with trailing space trimmed. | `BoundaryHasTrailingSpace` | prov. |
| B12 | `bnd-12` unquoted `boundary=a/b=c` | accepted whole | cut at `=` | media type rejected | The unquoted value runs to `;` or end of value. | none (not raised) | prov. (MK) |
| B13 | `bnd-13` two `boundary` parameters | first | first | media type rejected (leaf) | First occurrence wins. | `DuplicateParameter` | 2 of 3 |
| B14 | `bnd-15` multipart with only `--abc--` | 0 parts | leaf, empty | 0 parts | 0 parts. | none | 2 of 3 |
| B15 | `bnd-16` first delimiter directly after the headers, no blank line | header block never ends, 0 parts | 1 part | header error | The first line that is not a header field or continuation ends the header block and starts the body (see H5). | `MissingHeaderBodySeparator` | prov. (Py) |
| B16 | `bnd-17` LF-only delimiters inside a CRLF message | 2 parts | 2 parts | 1 part | Each line may end in LF or CRLF independently. | `MixedLineEndings` | 2 of 3 |
| B17 | `bnd-18` part with headers and no blank line before the next delimiter | 2 parts | 2 parts | 0 parts | Part with headers and an empty body. | `MissingHeaderBodySeparator` | 2 of 3 |
| B18 | `bnd-23` delimiters separated by bare CR | no parts | 1 part | no parts | A bare CR is not a line break (see L1). | `BareCarriageReturnInHeader` | 2 of 3 |
| B19 | `bnd-26` inner multipart reuses the outer boundary | nested | outer swallows inner | nested | The innermost open multipart is tried first, so the shared line closes the inner part. | `AmbiguousDuplicateBoundary` | prov. (MK, Go) |
| B20 | `bnd-19`, `bnd-20`, `bnd-21`, `bnd-22`, `bnd-24` empty part headers; blank line before delimiter; delimiter at end of file without line break; whitespace-only preamble and epilogue; 80-character boundary | same | same | same | The line break before a delimiter belongs to the delimiter; a part can have no headers (defaults apply); the boundary length limit is not enforced; whitespace-only preamble/epilogue counts as present but empty. | `BoundaryTooLong` for over 70 | all agree |

## Line endings and raw octets

| ID | Probe | MK | Py | Go | Resolution | Defect | Basis |
|---|---|---|---|---|---|---|---|
| L1 | `eol-02` whole message with CR-only line ends; `eol-05` CR inside a header value; `eol-09` CR CR LF | CR-only: one long header; CR in value dropped from the decoded value | CR is a line break | CR kept in the value | A line ends at LF or CRLF only. A lone CR is data: it stays in the raw value and is dropped from the decoded value. | `BareCarriageReturnInHeader` | prov. (MK, Go) |
| L2 | `eol-01`, `eol-03`, `eol-04`, `eol-06`, `eol-07`, `eol-08` LF-only; mixed CRLF/LF; no final newline; headers end at EOF | same | same | same | Accept any mix of LF and CRLF; a body's bytes are never changed. | `MixedLineEndings` when mixed | all agree |
| L3 | `raw-01`, `raw-09` NUL, form feed and vertical tab in a header value | kept | kept | kept | Kept in raw and decoded values. | `ControlCharacterInHeader` | all agree |
| L4 | `raw-02` NUL in a header name | header | body starts here | header | Accepted as a header name; the raw bytes are kept. | `InvalidHeaderName` | 2 of 3 |
| L5 | `raw-03`, `raw-07`, `raw-08` NUL in a body; 3000-character header; 20000-character body line | kept | kept | kept | No length limits below the configured resource limits; the defect `LineTooLong` marks lines over 998. | `LineTooLong` | all agree |
| L6 | `raw-04`, `raw-05`, `raw-11`, `adr-24` raw 8-bit octets in headers | UTF-8, else Latin-1 | `U+FFFD` for non-ASCII | invalid bytes become `U+FFFD`; one address list fails | A raw 8-bit header value is decoded as UTF-8 if valid, otherwise as Windows-1252 (RFC 6532 for the UTF-8 case). | `AmbiguousEightBitHeader` | prov. (MK) |
| L7 | `raw-10` UTF-8 byte-order mark before the first header | header found | body starts here | the BOM stays in the first name, which then goes unrecognised | The BOM is ignored when the name is read (the raw bytes keep it), so the first field is found. | `InvalidHeaderName` | prov. (MK) |
| L8 | `raw-06`, `cte-14`, `ct-14` 8-bit body declared 7bit or without charset | bytes kept | bytes kept | bytes kept | Body bytes are never altered; charset choice is the encodings layer's. | `EightBitIn7BitBody` | all agree |

## Header block

| ID | Probe | MK | Py | Go | Resolution | Defect | Basis |
|---|---|---|---|---|---|---|---|
| H1 | `hdr-01`, `hdr-02`, `hdr-03` duplicate `Content-Type`, `MIME-Version`, `Content-Transfer-Encoding` | first | first | first | The first occurrence is the one in force; all stay in the tree. | `DuplicateHeader` | all agree |
| H2 | `hdr-04`, `adr-25` duplicate `From`, `To`, `Subject`, `Date` | `From`/`To` lists merge; all `Subject`s kept | first | first | The typed accessors use the first occurrence; every header stays in the tree. | `DuplicateHeader` | 2 of 3 |
| H3 | `hdr-07` whitespace before the colon (`Subject : x`) | header | body starts here | header | Accepted; the name is trimmed (RFC 5322 4.2 obsolete syntax). | `WhitespaceBeforeHeaderColon` | 2 of 3 |
| H4 | `hdr-14` space inside a header name | header | body starts here | header | Accepted as written; the name is kept (Latin-1) and `InvalidHeaderName` is reported. | `InvalidHeaderName` | 2 of 3 |
| H5 | `hdr-05`, `hdr-06`, `hdr-09` a line without a colon in or after the headers | line skipped (or parse error on the first line) | body starts at the line | error | The first non-header line ends the header block and starts the body, so no byte is dropped. | `MissingHeaderBodySeparator` | prov. (Py) |
| H6 | `hdr-10` body line that looks like a header (`Note: x`) with no blank line | header | header | error | It is a header. | `MissingHeaderBodySeparator` | all agree where parsed |
| H7 | `hdr-08` continuation line before the first header | parse error | ignored | error | A line that starts with white space where a field should begin ends the header block and starts the body. | `HeaderContinuationWithoutHeader` | prov. |
| H8 | `hdr-11` `From ` envelope line first | skipped | skipped | skipped | Kept as the entity's envelope line (its own reader event), not a header; the header block starts after it. | none | all agree |
| H9 | `hdr-12` blank lines before the first header | empty header block | empty header block | empty header block | An empty header block; everything after is the body. | `NoHeaders` | all agree |
| H10 | `hdr-16`, `hdr-15`, `hdr-21` folding with tabs, runs of spaces and blank continuation lines | keeps white space | keeps white space | collapses to one space | Unfolding removes the line break only; interior white space stays. | none | 2 of 3 |
| H11 | `hdr-25` trailing white space on a value | trimmed | kept in the decoded value | trimmed | Trimmed in the decoded value, kept in raw. | none | 2 of 3 |
| H12 | `hdr-18` empty file (`hdr-19`, `hdr-20`: blank line only and headers only agree) | error | empty message | error | An empty message; the parser never throws. | `EmptyMessage` | prov. (Py) |
| H13 | `hdr-13`, `hdr-17`, `hdr-22`, `hdr-23`, `hdr-24`, `hdr-26` empty values, no MIME-Version, colons in values, odd case, 2000 headers, trace order | same | same | same | Names compare case-insensitively; a missing `MIME-Version` does not stop MIME parsing; order is preserved. | `MissingMimeVersion` | all agree |
| H14 | `nest-13` message cut inside a header name | error | body | error | The partial line is kept as the start of the body. | `TruncatedHeader` | prov. (Py) |

## Content-Type

| ID | Probe | MK | Py | Go | Resolution | Defect | Basis |
|---|---|---|---|---|---|---|---|
| C1 | `ct-02`, `ct-03`, `ct-04`, `ct-09` `text` without subtype, `text/`, garbage, empty value | `application/octet-stream` | `text/plain` (`text/` kept literal for `ct-03`) | `text/plain` or `text` | A value that is not `type/subtype` is treated as absent: `text/plain; charset=us-ascii`, raw header kept. | `InvalidContentType` | 2 of 3 |
| C2 | `ct-05` parameter without `=` | dropped | `charset=""` | dropped | Dropped. | `InvalidParameter` | 2 of 3 |
| C3 | `ct-07` comments in the value | one comment stays in the value | comment stays in the type | removed | Comments are removed (RFC 2045 5.1). | none | prov. (Go) |
| C4 | `ct-01`, `ct-06`, `ct-08`, `ct-10`, `ct-11`, `ct-12` odd case and spacing, unterminated quote, duplicate `charset`, `;;`, unknown charset, raw UTF-8 in a parameter | same | same | same | Lower-case types and parameter names; first duplicate wins; an unterminated quote runs to the end of the value; extra semicolons are ignored; unknown charsets are kept as written. | `UnterminatedQuote`, `DuplicateParameter` | all agree |
| C5 | `ct-13` top-level `message/rfc822` | nested message | nested message | nested message | Parsed as a message. | none | all agree |

## Content-Transfer-Encoding

| ID | Probe | MK | Py | Go | Resolution | Defect | Basis |
|---|---|---|---|---|---|---|---|
| E1 | `cte-01`, `cte-04`, `cte-19`, `cte-17`, `cte-12`, `cte-10`, `cte-11` unknown, odd-case, empty, `binary`; QP lower-case hex, soft breaks, invalid escapes | same | same | same | Unknown, empty, 7bit, 8bit, binary: identity. Names are case-insensitive and trimmed. QP: invalid escapes pass through as written. | `UnknownTransferEncoding`, `InvalidQuotedPrintable` | all agree |
| E2 | `cte-02`, `cte-03` composite type declared `base64` or `8bit` | parts parsed | parts parsed | decodes first, no parts | The declared encoding is ignored for `multipart/*`: parts are parsed from the raw body. | `AmbiguousEncodedComposite` | 2 of 3 |
| E3 | `cte-05` base64 with characters outside the alphabet | 9 bytes | 10 bytes | 3 bytes | Characters outside the alphabet are skipped, the rest is decoded as one stream. | `InvalidBase64` | prov. (Py) |
| E4 | `cte-06` base64 without padding | drops the last partial group (9) | decodes it (11) | drops it (9) | The final partial group is decoded. | `MissingBase64Padding` | prov. (Py; keeps data) |
| E5 | `cte-07`, `cte-20` white space and line breaks inside base64 | skipped | skipped | stops at white space | White space is skipped anywhere. | none | 2 of 3 |
| E6 | `cte-08` data after `=` padding | continues | continues | stops | Decoding continues after padding; each padded run is decoded on its own. | `DataAfterBase64Padding` | 2 of 3 |
| E7 | `cte-13` trailing spaces before a QP line end | kept | kept | stripped | Kept (they are data in transit; RFC 2045 6.7 rule 3 only forbids producing them). | none | 2 of 3 |
| E8 | `cte-15` `message/rfc822` declared base64 | not decoded | decoded, parsed | decoded, parsed | Stays a leaf whose body is the encoded bytes (decoding it first would break the byte-for-byte tree). | `AmbiguousEncodedComposite` | prov. (MK) |
| E9 | `cte-16` uuencode | decoded | decoded | not decoded | Decoded (`x-uuencode`, `uuencode`, `x-uue`, `uue`). | none | 2 of 3 |
| E10 | `cte-18` comment after the name (`base64 (note)`) | decoded | decoded | not decoded | Comments are removed first. | none | 2 of 3 |

## Parameters, encoded words and file names

| ID | Probe | MK | Py | Go | Resolution | Defect | Basis |
|---|---|---|---|---|---|---|---|
| N1 | `enc-01`, `enc-30` RFC 2047 encoded word inside a quoted or token parameter value | decoded | decoded (quoted only) | literal | Decoded, although RFC 2047 5 forbids it in parameters; every mail client does. | `AmbiguousEncodedWordInQuotedString` | 2 of 3 |
| N2 | `enc-04` `filename=` and `filename*=` together | extended | plain | extended | The extended (RFC 2231) value wins. | `AmbiguousParameterForms` | 2 of 3 |
| N3 | `enc-02`, `enc-03`, `enc-09`, `enc-05` RFC 2231 charset, continuations, out-of-order continuations | same | same | same | Continuations are joined by index before charset decoding (RFC 2231 3). | none | all agree |
| N4 | `enc-06` unknown charset in RFC 2231 | decoded as Latin-1 | `U+FFFD` | parameter dropped | Fall back to Windows-1252. | `AmbiguousUnknownCharset` | prov. |
| N5 | `enc-07` `filename*=` without the charset quote marks | Latin-1 mojibake | UTF-8 | parameter dropped | Percent-decode, then UTF-8 if valid, else Windows-1252. | `MalformedExtendedParameter` | prov. (Py) |
| N6 | `enc-08` continuations starting at `*1` | joined | dropped | dropped | Joined in index order. | `AmbiguousParameterSegments` | prov. (MK) |
| N7 | `enc-14` two `filename` parameters | first | first | parameter dropped | First wins. | `DuplicateParameter` | 2 of 3 |
| N8 | `enc-15` unquoted file name with a space | whole value | cut at the space | rejected | The value runs to `;` or end of value. | none (not raised) | prov. (MK) |
| N9 | `enc-10`, `enc-11` `name` versus `filename` | disposition filename, else type name | same | same | Disposition `filename` first, then Content-Type `name`. | none | all agree |
| N10 | `enc-12`, `enc-13` path separators, `..`, backslash escapes | as written | as written | as written | The name is kept as written; making it safe to write to disk is the caller's or the export layer's job. | `UnsafeFilename` | all agree |
| N11 | `enc-16`–`enc-20`, `enc-22`–`enc-24`, `enc-28`, `enc-29` encoded words: Q and B, adjacent, mixed with text, unknown charset, over-long, split by a fold, empty, in `Comments`/`Keywords` | same | same | same | RFC 2047 decoding; white space between adjacent encoded words is dropped. | `EncodedWordTooLong`, `AmbiguousSplitMultibyte` | all agree |
| N12 | `enc-21` encoded word with invalid base64 | word removed | word replaced by a space | kept literally | Left in the text as written (RFC 2047 6.3 allows displaying it as ordinary text). | `InvalidEncodedWordBase64`, `InvalidEncodedWordQuotedPrintable` | prov. (Go) |
| N13 | `enc-25` encoded word in a quoted display name | decoded | decoded | literal | Decoded. | `AmbiguousEncodedWordInQuotedString` | 2 of 3 |
| N14 | `enc-27` ISO-2022-JP encoded word | escape sequences left | decoded | not decoded | Decoded. | none | prov. (Py) |

## Addresses

| ID | Probe | MK | Py | Go | Resolution | Defect | Basis |
|---|---|---|---|---|---|---|---|
| A1 | `adr-01`, `adr-02`, `adr-27` group syntax, empty group | flattened | flattened | flattened | Groups are kept as groups in the model; the shared view flattens members. | none | all agree |
| A2 | `adr-03` unquoted comma in a display name | one mailbox, name `Doe, John` | two: `Doe` (no domain) and `John` | list rejected | One mailbox named `Doe, John`: a comma before `<` stays in the phrase. | `AmbiguousUnquotedCommaInPhrase` | prov. (MK) |
| A3 | `adr-04` bare address after plain words | second mailbox dropped | second mailbox with a wrong local part | list rejected | The words become the display name and the address is kept. | `MissingAngleBrackets` | prov. |
| A4 | `adr-05` name given as a trailing comment | name from the comment | no name | name from the comment | The comment becomes the display name when there is none. | none | 2 of 3 |
| A5 | `adr-06`, `adr-07`, `adr-20` nested comments, obsolete source route, spaces around `@` and `.` | same | same | list rejected | Comments are stripped, the route is dropped, CFWS inside the address is removed. | `ObsoleteRoute` | 2 of 2 that parse |
| A6 | `adr-08` quoted local part | quotes kept | quotes kept | unquoted | The address keeps its quoting as written; an unquoted form is available alongside. | none | 2 of 3 |
| A7 | `adr-09` empty address `<>` | mailbox dropped | kept, address `<>` | list rejected | A mailbox with an empty address and its name is kept. | `EmptyAddress` | prov. |
| A8 | `adr-10` local part without a domain | kept | kept | rejected | Kept as written: `john` and `mary@` are two mailboxes, each with `MissingDomain` (dropping a recipient loses data). | `MissingDomain` | prov. |
| A9 | `adr-11`, `adr-13`, `adr-14`, `adr-16`, `adr-21`, `adr-22`, `adr-26`, `adr-28` IP literals; empty list elements; unclosed `<`; dotted names; 50 entries; escaped quoted names; several `From`; empty `Cc`/`Bcc` | same | same | mostly same | Accepted as written; empty elements are skipped; an unclosed `<` is closed at the end of the value. | `UnclosedAngle` | all agree where parsed |
| A10 | `adr-12`, `adr-24` raw UTF-8 or Latin-1 in an address | name decoded; local part with UTF-8 dropped | `U+FFFD` | rejected | RFC 6532: UTF-8 in local part and domain is accepted; names follow L6. | `AmbiguousEightBitHeader` | prov. |
| A11 | `adr-15` unclosed quoted name | name `John`, address kept | whole value as address | rejected | Recover as MimeKit does: the open quote ends at the `<`. | `UnterminatedQuote` | prov. (MK) |
| A12 | `adr-17` `@` inside an unquoted display name | name kept | wrong address | rejected | The address is the one in angle brackets. | none | prov. (MK) |
| A13 | `adr-18` two address-specs in one mailbox (`<a@x><b@y>`) | both | first only | rejected | Both are kept as two mailboxes. | `AmbiguousAdjacentAngleAddrs` | prov. (MK) |
| A14 | `adr-19` semicolon as a separator | first only | first only | rejected | Only the first address is parsed; the remainder is kept as unparsed text in the defect. | `UnparsedAddressText` | 2 of 2 that parse |
| A15 | `adr-23` leading, trailing and doubled dots in a local part (`.a@x`, `b.@x`, `c..d@x`) | address dropped | kept | rejected | Kept as written. These are common in old corporate mail, and dropping them loses recipients. | `ObsoleteLocalPart` | prov. (Py; in about 9% of messages in a 517,000-message public corpus, MimeKit drops such recipients) |
| A16 | gateway-rewritten addresses seen in the Enron corpus: `<"a"@b.com@gw>`, `munn"."mary@gw`, `"d@x".@gw@gw` | MimeKit keeps the part up to the second `@`, or drops the address; Go rejects the list | Python gives `<>` or a mangled address | Kept as written, with `InvalidAddressSyntax`. | `InvalidAddressSyntax` | prov. (no common reading) |

## Dates

All forms are read into an instant plus the written zone; the source text is kept with the value.

| ID | Probe | MK | Py | Go | Resolution | Defect | Basis |
|---|---|---|---|---|---|---|---|
| D1 | `date-01`–`date-13`, `date-18`, `date-24`, `date-26`, `date-28`, `date-33` standard, no seconds, no weekday, wrong weekday, `EST`/`GMT`/`UT`, comment zone, lower-case month, extra white space, ±14:00 | same | same | same | Accept; the weekday is ignored; named zones follow RFC 5322 4.3 (`EST` −5, `PDT` −7 …); with two `Date` headers the first is used. | `WeekdayMismatch` | all agree |
| D2 | `date-34`–`date-37`, `date-03`–`date-05` two-digit years | 00–68 → 20xx; 69 → 2069; 70+ → 19xx | 00–68 → 20xx; 69–99 → 19xx | same as Py | 00–68 → 2000–2068, 69–99 → 1969–1999. All three libraries read `50` as 2050, which differs from RFC 5322 4.3 (50–99 → 19xx); OneMail follows the libraries. | `AmbiguousTwoDigitYear` | 2 of 3 (69) |
| D3 | `date-14` three-digit year | rejected | year 101 | rejected | Add 1900, as RFC 5322 4.3 says (101 → 2001). | `ThreeDigitYear` | prov. (RFC only) |
| D4 | `date-10`, `date-12`, `date-21`, `date-25` `-0000`, military letters, `GMT+2`, missing zone | UTC | UTC, zone unknown for `-0000` and none | `Z` and none rejected; `GMT+2` read as +2 | UTC with the zone marked unknown (RFC 5322 4.3: military zones are unreliable and read as `-0000`). | `UnknownTimeZone` | 2 of 3 |
| D5 | `date-16` seconds = 60 | 59 | rejected | rejected | Accept as a leap second, instant clamped to :59. | `LeapSecond` | prov. (MK) |
| D6 | `date-17` zone `+2400` | UTC | rejected | +24:00 | Out-of-range zones are read as unknown. | `InvalidTimeZone` | prov. |
| D7 | `date-19` `2001-05-14 23:39:00` | parsed as UTC | rejected | rejected | Rejected (not RFC 5322); the header stays as text. | `InvalidDate` | 2 of 3 |
| D8 | `date-22` hour 24 | wrong date (00:00 on the 14th) | rejected | rejected | Rejected. | `InvalidDate` | 2 of 3 |
| D9 | `date-27` year 1899 | rejected | accepted | accepted | Accepted. | none | 2 of 3 |
| D10 | `date-29` `PDT` | −7 | −7 | rejected | Accepted (obsolete zone, RFC 5322 4.3). | none | 2 of 3 |
| D11 | `date-30` fractional seconds | wrong time (00:00) | rejected | accepted | Fraction dropped. | `FractionalSeconds` | prov. (Go) |
| D12 | `date-15`, `date-20`, `date-23`, `date-31`, `date-32` garbage, ISO with `T…Z`, 31 April, empty, day 0 | rejected | rejected | rejected | Rejected; the value reads as absent and the header stays as text. | `InvalidDate` | all agree |
| D13 | `date-38` four-digit year with leading zeros (`0002`), which a client that once wrote a two-digit year can produce | 2002 | 2002 | year 2 | Read as a two-digit year (rule D2). | `AmbiguousTwoDigitYear` | 2 of 3 |

## Nested messages and structure

| ID | Probe | MK | Py | Go | Resolution | Defect | Basis |
|---|---|---|---|---|---|---|---|
| M1 | `nest-01`, `nest-02`, `nest-05`, `nest-17` `message/rfc822` plain, multipart inside, 8bit, 30 levels | nested | nested | nested | Parsed recursively, bounded by the nesting limit. | none | all agree |
| M2 | `nest-03`, `nest-04` `message/rfc822` that is empty or has no headers | message part without inner message, or body in an empty inner message | inner message with empty body | no inner message | An inner message exists whenever the part is `message/rfc822`; text without headers becomes its body. | `NoHeaders` | prov. |
| M3 | `nest-06`, `nest-07` `multipart/digest` children without a type | `message/rfc822` | `message/rfc822` | `text/plain` | `message/rfc822` (RFC 2046 5.1.5). | none | 2 of 3 |
| M4 | `nest-08`, `nest-09` `message/delivery-status`, `message/partial` | bytes | groups parsed as headers | bytes | Bytes are kept; the status groups are parsed on demand. | none | 2 of 3 |
| M5 | `nest-12` cut inside a part's header | partial part | partial part | no part | The part exists with its partial header. | `TruncatedHeader` | 2 of 3 |
| M6 | `nest-14`, `nest-15`, `nest-16` 10 and 200 nested multiparts, 3000 parts | all parsed | all parsed | all parsed | None of the three limits depth (200) or part count (3000). OneMail's limits are on by default and are listed in the resource-limit notes. | `DepthLimitExceeded`, `EntityLimitExceeded` | no oracle limit |
| M7 | `nest-10`, `nest-11`, `nest-18`, `nest-19`, `nest-20`, `nest-21` alternative/related, signed, QP HTML, inline and unknown dispositions, a lying `Content-Length`, mbox-like trailer lines | same | same | same | Structure as written; `Content-Length` is advisory and ignored; an unknown disposition is kept as written. | none | all agree |

## Defect names in this table that are not raised, and why

The decoders and the tree keep what they repair without a defect where the repair is the specified reading, or report it by another route.

| Name in the table | Where it is reported instead |
|---|---|
| `InvalidBase64`, `MissingBase64Padding`, `DataAfterBase64Padding`, `InvalidQuotedPrintable` (E1, E3, E4, E6) | Body decoding is lazy, so the decoders report `TransferIssues` flags on the decoding stream, not tree defects. |
| `DuplicateHeader` (H1, H2) | Not raised: all copies stay in the tree; only a repeated `Content-Type` or `Content-Transfer-Encoding` is a defect (`AmbiguousDuplicateContentType`, `AmbiguousDuplicateTransferEncoding`). |
| `NoHeaders`, `EmptyMessage`, `TruncatedHeader` (H9, H12, H14, M2) | Not raised: an empty or cut-off message is a valid empty tree; `MissingHeaderBodySeparator` marks a header block that ran into the end of input. |
| `MissingMimeVersion` (H13) | Not raised: a missing `MIME-Version` does not change parsing. |
| `LineTooLong`, `EightBitIn7BitBody` (L5, L8) | Not raised: body bytes are never judged. |
| `EnvelopeLine` (H8) | Not a defect: the reader emits an `EnvelopeLine` event. |
| `UnsafeFilename` (N10) | Not raised: a file name is kept as written. |
| `EncodedMessagePart` (E8) | Reported as `AmbiguousEncodedComposite`. |
| `UnquotedParameterSpecials` (B12, N8) | Not raised: an unquoted value runs to `;`. |

## Differences the differential run reports, and how each is explained

`tools/oracles/explained.tsv` maps every difference between OneMail and each oracle to an entry above, to a documented normalisation of
the comparison (`NORM-*`) or to a named limitation of that oracle (`LIMIT-*`). A difference with no line fails the run.

## Calendar and contacts (ICS, vCard)

Observed black-box on Python `icalendar` 7.3.0 (**Py-ical**) and `vobject` 0.9.9 (**Py-vobj**), over the public corpus and our probes (`tools/calendar/oracles`). Ical.Net 5.2.3 and FolkerKinzel.VCards 8.2.0 expose typed models only, so they settle value questions, not line syntax. The tree always keeps every byte (untouched input writes back identical); the rows below govern the decoded view and the defects. Every row is our default, marked provisional where fewer than two oracles agree.

| ID | Case | Py-ical | Py-vobj | Resolution | Defect | Basis |
|---|---|---|---|---|---|---|
| K1 | Line ends: LF only, lone CR, CR CR LF | accepts LF | accepts LF | CRLF, LF and lone CR all end a line. A run of two or more CRs then LF is one line end, so a fold after it still joins and no CR reaches the value. Reported once per file, at the first. | `BareLineFeed`, `BareCarriageReturn`, `AmbiguousDoubleCarriageReturn` | all agree for LF; CR prov.; CR CR LF 6 of 7 (probe below) |
| K2 | Fold with two whitespace characters after the break | one removed (`ab cd`) | one removed | Exactly one space or tab is removed (RFC 5545 §3.1); the rest is content. | none | all agree |
| K3 | Fold inside a multi-byte UTF-8 sequence | n/a | n/a | Lines are unfolded as bytes before decoding, so the character is whole. | none | RFC 5545 §3.1 |
| K4 | Line with no colon | skipped | parse error | Kept in place as a malformed line (written back as read); not a property. | `MissingColon` | prov. (lossless) |
| K5 | Space before the colon (`SUMMARY :x`) | accepts | parse error | Accepted; the name is trimmed. | `WhitespaceAroundDelimiter` | prov. (1 of 2) |
| K6 | Lower-case names and `begin:vcalendar` | accepts | accepts | Names and component names compare ASCII case-insensitively; case is kept as written. | none | all agree |
| K7 | `END:` that does not match the open component | error | error | Closes up to the matching open component; the components in between are left unclosed. With no match, the line is kept as malformed. | `MissingEnd`, `UnmatchedEnd` | prov. (both reject; ours must not) |
| K8 | RFC 6868 caret sequences in a parameter value (`^n`, `^^`, `^'`) | decoded | literal | Decoded. | `AmbiguousCaretEncoding` | prov. (1 of 2) |
| K9 | The same parameter twice (`ROLE=A,B;ROLE=C`) | last wins | merged | Both are kept in order as written; readers that want one value take the first. | none | lossless |
| K10 | Unknown text escape (`\q`, `\"`, `\:`) | `\q` and `\"` kept, `\:` becomes `:` | `\q` and `\:` kept, `\"` becomes `"` | The backslash and the character stay as written. Only `\\ \; \, \n \N` are resolved (vCard 2.1: `\\ \;`). | none | prov.; `\q` agrees |
| K11 | Escapes in `X-` properties | raw | unescaped | Unescaped like any TEXT value (RFC 5545 §3.8.8.2 gives `X-` properties the TEXT type). | none | prov. (1 of 2) |
| K12 | Invalid UTF-8 text | U+FFFD | U+FFFD | The line is read as Windows-1252, the reading clients use for legacy files, unless `CHARSET` names a charset we can decode. The source bytes are untouched. | `AmbiguousNonUtf8Text` | prov. (our default) |
| K13 | vCard 2.1 `CHARSET` we cannot decode | n/a | n/a | Read as UTF-8, or Windows-1252 when that is invalid. | `UnknownCharset` | our default |
| K14 | vCard 2.1 quoted-printable soft break (`=` at line end) | n/a | joined | Joined whenever the line is marked `QUOTED-PRINTABLE`, in vCard and ICS alike. | `QuotedPrintableSoftBreak` | prov. |
| K15 | vCard 2.1 base64 data on unindented lines, ended by an empty line | n/a | joined | Joined when the line carries `ENCODING=BASE64`/`B`/bare `BASE64` and the next line holds only base64 characters. The ending empty line belongs to the property. | `Base64ContinuationWithoutFold` | prov. |
| K16 | vCard 2.1 bare parameters (`TEL;WORK;VOICE`) | n/a | dropped on some inputs | Kept as bare parameters; the typed view lists them as types together with `TYPE=` values. | none | lossless |
| K17 | Comma lists with an empty element (`ADR` street `5,`) | n/a | element dropped | The empty element is dropped in the typed view; the raw value keeps it. | none | prov. |
| K18 | Properties inside `VCALENDAR` after `BEGIN:VCARD` junk, stray lines around components | n/a | n/a | Kept as items of the document root, in order. | `MissingColon`, `UnmatchedEnd` | lossless |
| K19 | A date-time with a `TZID` and a `Z` suffix | UTC | UTC | UTC wins; the `TZID` stays a parameter. | none | all agree |
| K20 | Nesting beyond 32 levels | unlimited | unlimited | Deeper `BEGIN`/`END` lines are kept as ordinary properties. | `DepthLimitExceeded` | limit |
| CV1 | Unknown escape in a typed vCard 3.0/4.0 value (`\:`, `\"`, `\q`) | vobject drops the backslash of `\"` only; FolkerKinzel drops that of `\:` only | | The typed view (not the raw value) drops the backslash and keeps the character, for every character after `\\ \; \, \n \N`. vCard 2.1 unescapes `\\` and `\;` only. | none | prov. (oracles disagree) |
| CV2 | `TEL;TYPE=VOICE,MSG,WORK` imported to a contact | n/a | n/a | Every matching rule fires: Business and Other (MS-OXVCARD 2.1.3.4.1 example, literally). A fax alone fills Primary Fax, `pref` fills Primary, no type fills Other. | none | spec example; no client observed |
| CV3 | `ADR` with no recognised type | n/a | n/a | Business and Other; `postal`, `dom`, `intl`, `parcel` fill Other; a fourth address is dropped. | `AdditionalValueNotMapped` | spec example; prov. |
| CV4 | A second or later `TEL`/`EMAIL` of a kind with one slot | n/a | n/a | The second work (home) number goes to Business2 (Home2); later ones are reported, not stored. The first three emails fill the slots in card order. | `AdditionalValueNotMapped`, and on export `PhoneSlotChanged`, `EmailSlotChanged` | spec; prov. |
| CV5 | `BDAY` as a partial date or with a time | n/a | n/a | `PidTagBirthday` is the date at 11:59 UTC, `PidLidBirthdayLocal` the date at 0:00 (Outlook's own pair); the time of day is dropped; a date without a year is not stored. | `TimeOfDayDropped`, `PartialDate` | spec 2.1.3.2.5; prov. |
| CV6 | An empty text property in a contact (`PidTagDepartmentName` = "") | n/a | n/a | Treated as absent: no vCard property is written and the report says so. | `EmptyValueNotWritten` | agent default |
| CV7 | CR CR LF in a vCard | vobject, ez-vcard join the fold; Ical.Net and biweekly do for iCalendar; ical.js keeps a CR in the value; FolkerKinzel cuts the value | n/a | One line end (K1): a folded value that contains it is joined. | `AmbiguousDoubleCarriageReturn` | 6 of 7 (probe below) |

**CR CR LF probe (K1, CV7).** `tools/calendar/crcrlf/run.sh` writes one iCalendar and one vCard four ways (all CRLF; every line end CR CR LF; only the end before a fold; only some other line ends) and reads them with every pinned oracle. Before the change we cut the folded value; the readers split as follows (each reads one or both of the formats):

| Reading of CR CR LF + space | Oracles | Count |
|---|---|---|
| One line end: the fold joins and no CR reaches the value | biweekly 0.6.8, Ical.Net 5.2.3, vobject 0.9.9, ez-vcard 0.12.2 | 4 |
| The fold joins but a CR stays in the value (`Hello\rWorld`; a trailing CR on plain lines) | ical.js 2.2.1, icalendar 7.3.0 (which also refuses the file when plain lines end that way) | 2 |
| The value is cut at the line end; the rest becomes another line | FolkerKinzel.VCards 8.2.0 (vCard) | 1 |

Majority: the fold joins (6 of 7), and with 4 of those the CR is not data. Resolution: a run of CRs then LF is one line end. The bytes are untouched.

### JSON and XML forms (jCal, jCard, xCal, xCard)

Observed black-box on ical.js 2.2.1, Python `icalendar` 7.3.0, biweekly 0.6.8 and ez-vcard 0.12.2 (`tools/calendar/oracles/forms`); the rows say how we resolve what the RFCs leave open or what the oracles do differently. Reasons and the full class list: [`jcal-xcal.md`](https://recursiveai.org/onemail/docs/jcal-xcal/) and `explained.tsv`.

| ID | Case | Oracles | Resolution | Defect or loss | Basis |
|---|---|---|---|---|---|
| J1 | Value that does not fit its type (`DTSTAMP:19970901T1300Z`, `TRIGGER:19980403T120000`) | ical.js, biweekly print a guessed, often garbled typed value | Carried as `unknown` with the raw text; the declared `VALUE` stays as a parameter, so the text writes back as read | `ValueNotRepresentable` | lossless |
| J2 | `DTSTART:19971102` with no `VALUE=DATE` | ical.js, biweekly: date-time with a garbled time | A date (RFC 7265 Appendix B.1 shows it) | `AmbiguousValueType` | RFC example |
| J3 | `ENCODING=BASE64` without `VALUE=BINARY` on `ATTACH` or an extension property; `VALUE=BINARY` without `ENCODING` | n/a | Both are `binary`; `ENCODING=BASE64` is added in the second case | `AmbiguousValueType` | RFC 5545 §3.3.1 |
| J4 | `TZ:-0500` | ical.js text | `utc-offset` (RFC 7095 Appendix B.1); `VALUE` is not written back | none | RFC example |
| J5 | Parameter with no value (`ATTENDEE;MAILTO:`) | ical.js, icalendar drop it | One empty value, in JSON and XML | none | lossless |
| J6 | Parameter given twice | ical.js, icalendar keep the last | Values merged in order | `DuplicateParameterMerged` | lossless |
| J7 | Same JSON object key twice | n/a | Both kept | `DuplicateKey` | lossless |
| J8 | `TYPE`, `PID`, `SORT-AS` values, also a quoted `"a,b"` | ez-vcard keeps one string | Split into arrays (RFC 7095 §3.3.1.1) | none | RFC |
| J9 | Inline vCard base64 (`PHOTO`, `KEY`) | ical.js keeps `binary` and `ENCODING` | `data:` URI; media type from `TYPE`, else `application/octet-stream` | `InlineBinaryAsDataUri` | RFC 7095 §3.5, RFC 2397 |
| J10 | Backslash escapes in a vCard URI value (`URL:http\://x`) | ical.js and ez-vcard unescape | Unescaped (`\\ \, \; \:`); a backslash cannot occur in a URI | none | 2 of 2 |
| J11 | vCard 2.1/3.0 data in xCard | ez-vcard writes 4.0 everywhere | 4.0 forms for `GEO`, `TYPE=pref`; `VERSION` dropped. jCard keeps the source version and type names | `VersionNotCarried`, `VersionSpecificForm` | RFC 6351 |
| J12 | Group prefix on iCalendar data | ical.js keeps `GROUP.NAME` as the name | Split into the `group` parameter | `GroupNotCarried` in XML | RFC 7095 §3.3.1.2 |
| J13 | Property, parameter, component or type name that is not an XML name | biweekly, ez-vcard drop or rename | Left out of xCal/xCard (JSON carries it) | `PropertyNotCarried`, `ParameterNotCarried`, `ComponentNotCarried`, `TypeNameNotCarried` | XML 1.0 |
| J14 | Character XML cannot hold (U+000C, NUL, lone surrogate) | n/a | U+FFFD | `InvalidXmlCharacter` | XML 1.0 |
| J15 | vCard 2.1 quoted-printable `=0D=0A` | ez-vcard keeps CR LF in `LABEL` | Line feed in the form; the text writer uses quoted-printable again and never folds a line after `=` | none | RFC 6350 §3.4 |
| J16 | Components outside a `VCALENDAR` (bare `VEVENT` examples) | biweekly writes calendars only | Carried at the top level of the form | none | lossless |
| J17 | Durations, UTC offsets, `TZID` values, repeated `FREEBUSY` periods, URI scheme case, local times with `TZID` | icalendar, biweekly normalise (`P7W` to `P49D`, `-00:00` to `+00:00`, local to UTC, `MAILTO:` to `mailto:`) | Kept as written | none | lossless |

## Recurrence and time zones

Ours against `recurring-ical-events` 3.8.2 on `icalendar` 7.3.0 (**rec**), `python-dateutil` 2.9.0.post0 `rrule` through our glue (**du**) and Ical.Net 5.2.3 (**ical**), black-box, over every recurring component of the public calendar corpus plus our generated fixtures: 2,384 components, each in a fixed window (`tools/calendar/run_expand_diff.sh`; method in `docs/recurrence.md`). A component is *identical* when the sorted start lists are equal, *differing* otherwise, *not expanded* when the oracle errors or times out (330 libical components have an invalid DTSTART that every oracle rejects). Counts are components. Each difference gets exactly one id, the first cause that applies; the id is the entry below. Basis as in the other sections: all agree, 2 of 3, or provisional (our default).

| Oracle | Compared | Identical | Differing | Explained | Unexplained | Not expanded |
|---|---|---|---|---|---|---|
| rec | 2,372 | 2,233 | 139 | 139 | 0 | 12 (2 broken property, 8 timeout, 2 value error) |
| du | 2,378 | 2,238 | 140 | 140 | 0 | 6 (1 broken property, 3 type error, 2 value error) |
| ical | 2,188 | 1,409 | 779 | 779 | 0 | 196 (130 timeout, 33 argument, 30 out of range, 3 other) |

| ID | Case | Oracles | Resolution | Defect | Differences (rec / du / ical) | Basis |
|---|---|---|---|---|---|---|
| R1 | `BYWEEKNO` with no `BYDAY`, `BYMONTHDAY` or `BYYEARDAY` | rec and du: every day of the week; Ical.Net: the weekday of DTSTART | The weekday of DTSTART (RFC 5545 §3.3.10: a finer part that is absent takes DTSTART's value). | none | 32 / 32 / 0 | prov. (RFC; Ical.Net agrees) |
| R2 | A part the §3.3.10 table marks N/A for the frequency (`BYWEEKNO` with MONTHLY, `BYYEARDAY` with DAILY/WEEKLY/MONTHLY, `BYMONTHDAY` with WEEKLY, `BYSETPOS` alone, ordinal `BYDAY` where §3.3.10 forbids it) | the three disagree with each other and with us on some; 3 of the Python differences are one occurrence at the COUNT boundary of `WEEKLY;BYMONTHDAY;BYSETPOS;COUNT` | The part filters, never expands. | none | 102 / 102 / 449 | prov. (the table gives no result) |
| R3 | DTSTART does not fit its own rule (§3.8.5.3: undefined) | rec and du include DTSTART; Ical.Net omits it | DTSTART is always the first instance and counts toward COUNT. | `AmbiguousUnsynchronizedStart` | 0 / 0 / 75 (rec and du give our list on all 94 such components, apart from those with an R1 or R2 part) | 2 of 3 |
| R4 | `UNTIL` of another value type than DTSTART (DATE vs date-time, UTC vs floating) | the oracles differ with each other | A UTC UNTIL is an instant for zoned and UTC series and a wall-clock value for floating ones; a DATE UNTIL covers its whole day. | `AmbiguousUntilType` | 2 / 1 / 1 | prov. (§3.3.10 requires equal types) |
| R5 | Wall times in a clock change: the skipped hour and the repeated hour | rec and du: skipped shifted, repeated first; Ical.Net differs | Skipped: shifted by the offset before the gap (RFC 5545 §3.3.5), or dropped with `SkippedTimePolicy.Skip`. Repeated: the first. | `AmbiguousSkippedLocalTime`, `AmbiguousRepeatedLocalTime` | 0 / 0 / 57 (rec and du give our list on all 85 components that hit a clock change; ical on 26 of 83) | 2 of 3 |
| R6 | `EXDATE` as a DATE on a date-time series | not in the corpus | The whole day is removed. | `AmbiguousExdateType` | 0 / 0 / 0 (unit test only) | prov. (clients treat a date as a day) |
| R7 | `WEEKLY` with `BYSETPOS` in the first, partial week | rec and du position inside the part of the week after DTSTART | Positions count in the whole week (§3.3.10), so an earlier instant of the first week takes a position and is then dropped as before DTSTART. | none | 1 / 1 / 0 | prov. (RFC) |
| Z1 | A `TZID` named both by a VTIMEZONE in the file and by the runtime's database | not compared (the oracles resolve zones in their own way; the zone fixtures use one or the other) | The VTIMEZONE wins. | none | n/a | prov. |
| Z2 | A vendor-prefixed `TZID` (`/softwarestudio.org/Olson_20011030_5/America/New_York`) | not compared | The longest tail of up to three segments that names a zone. | `AmbiguousZoneName` | n/a | prov. |
| Z3 | A Windows zone name with no VTIMEZONE | not compared; instants checked for 5 names (`W. Europe`, `Eastern`, `AUS Eastern`, `New Zealand`, `Pacific`) | The CLDR default-territory IANA zone. | `AmbiguousWindowsZone` | n/a | prov. (CLDR) |
| Z4 | A `TZID` that resolves nowhere | not compared | The values stay floating; no instant is claimed. | `UnknownTimeZone` | n/a | prov. |
| Z5 | An IANA `TZID` on a host whose runtime knows only Windows zone ids (Windows without ICU, or with `InvariantGlobalization`) | not compared | The runtime lookup is repeated under the Windows zone CLDR assigns the IANA name (default-territory row first, then any territory). The zone keeps its IANA `Id` and `ZoneSource.Iana`. Windows zone data has fewer historic rules than IANA (US dates before 2007 follow the current rules), so such instants can differ from the same file read on Linux or macOS | none | n/a | our default (CLDR) |

Limits of one oracle (not ambiguities; no entry in the resolution column):

| ID | Oracle | Behaviour | Differences |
|---|---|---|---|
| L-oracle-ignores-exrule | rec, du | EXRULE is not applied | 1 / 1 / n/a |
| L-du-glue-has-no-overrides | du | our glue expands the master only, so RECURRENCE-ID components are not applied | 0 / 3 / n/a |
| L-ical-duplicate-instants | ical | an instant produced by several BYxxx combinations is returned several times; the set of instants is ours | n/a / n/a / 10 |
| L-ical-window-wall-clock | ical | the window start is compared with the wall time of a zoned instant, so in zones east of UTC an instant before the window gets through; without those instants the lists are equal | n/a / n/a / 102 |
| L-ical-weekno-interval | ical | `YEARLY` with `BYWEEKNO` and `INTERVAL` above 1 lands off the interval grid (2026, 2029, 2031 for `INTERVAL=2`) | n/a / n/a / 8 |
| L-outvoted-by-the-other-two | any | the oracle differs and at least one other oracle gives our list | 1 / 0 / 77 |

The `AmbiguousUntilType` and `AmbiguousExdateType` resolutions are the ones most likely to change when Outlook is observed (the Outlook oracle, before Outlook writing). Counts come from one run of the 2,384 components; `tools/calendar/run_expand_diff.sh` repeats it. Zone behaviour is checked in the DST subset instead: 108 `dst.ics` components in America/New_York, Europe/Berlin, Australia/Sydney and Pacific/Auckland (both transitions each) and 9 with Windows names give our list on 108/108 and 9/9 for rec and du.

## Mailbox files (mbox, Maildir, EMLX)

Black-box runs of Python 3.13.17 `mailbox` (mbox and Maildir) and MimeKit 4.18.2's mbox mode (`MimeParser` with `MimeFormat.Mbox`;
`tools/oracles/mailbox-driver`) over small probes (`tools/mailbox/gen_mboxes.py`, `gen_stores.py`) and the dovecot-crlf file
(3,240 `From ` lines). Go has no mbox reader. Neither oracle is a mail client, so every row that is not "agree" is provisional.
The reader's behaviour is in [`mailboxes.md`](https://recursiveai.org/onemail/docs/mailboxes/). MK = MimeKit, Py = Python.

| ID | Case | Py | MK | Resolution | Defect | Basis |
|---|---|---|---|---|---|---|
| S1 | A line starting `From ` that is not an envelope line (`From the desk of x`, `From maildir-storage.c, line 325:`), unquoted in the body | splits | splits, then often fails on the next "message" | Only a line with a sender and a date after `From ` (RFC 4155) starts a message; the rest is text. `MboxFromLineMode.Any` splits like both oracles. dovecot-crlf has three such lines: Py 3,240 messages, OneMail 3,237, MK raises at message 556 | `AmbiguousFromLine` | prov.; real-world file |
| S2 | A `Content-Length` header (mboxcl/mboxcl2, or in any file) | ignored | ignored | Used when the file's variant is unknown, mboxcl or mboxcl2 and the length ends exactly at an envelope line or the end of the file (a blank line may sit between). Otherwise ignored. mboxo and mboxrd ignore it. Two different values: the first | `ContentLengthMismatch`, `AmbiguousContentLength` | prov. |
| S3 | A CRLF file: the empty line after a message | keeps the `CR LF` in the message | the message runs to the next envelope line | The empty line (LF or CRLF) before the next envelope line or the end of the file is the separator, not message text | none | prov. |
| S4 | A body that the oracle's split leaves unparseable | n/a | raises `FormatException` | The tree is OneMail's job, not the splitter's: a message is never rejected | none | oracle limit |
| S5 | A line that is only `From ` (no sender or date) | splits | does not split | Not an envelope line in strict mode; a delimiter in `Any` mode | `AmbiguousFromLine` | 1 of 2 |
| S6 | mboxcl2 (unquoted bodies, `Content-Length` only) | splits inside messages that hold envelope-looking lines | same | Read correctly through `Content-Length`; OneMail writes it only when asked for the variant | none | oracle limit |
| S7 | Bytes before the first envelope line | skipped | skipped | Kept as the store's preamble, so entries plus preamble tile the file | `PreambleBeforeFirstMessage` | all agree |
| S8 | An envelope line with no empty line before it | splits | splits | Splits; the previous message keeps its last line | `MissingSeparatorLine` | all agree |
| S9 | Unquoting a stored body when the variant is not known | n/a | n/a | mboxrd's rule (every `>+From ` loses one `>`); an entry that a `Content-Length` delimited is returned as stored, because mboxcl and mboxcl2 differ | none | our default |
| S10 | Maildir info flags not in ASCII order or repeated (`:2,SF`) | read as written | n/a | Read as written, reported; written back sorted | `UnsortedFlags` | our default |
| S11 | Maildir file in `new` | subdir `new`, no flags | n/a | Flags from the name, plus `Recent` | none | all agree |
| S12 | Mail's `flags` word and `date-sent`/`date-received` reals in EMLX plists | n/a | n/a | Bits 0, 1, 2, 4, 6, 8 map to seen, deleted, answered, flagged, draft, forwarded; the raw word is always kept. A real is Unix seconds from 1e9, otherwise seconds since 2001-01-01. **No real Mail store has been inspected**, so this is a default to check against one | none | our default |
| S13 | Courier `courierimapkeywords/:list` | n/a | n/a | Keyword names, an empty line, then `unique:n n` rows with zero-based positions. Written from Courier's published description of the directory; the line format inside the files is not in that document, so this is unchecked against Courier | `MalformedControlFile` | our default |
| S14 | DBX: the length a message record states (field 0x11) differs from the chain's | n/a | n/a | undbx 0.21 writes the chain's bytes whatever the record states (3 records: stated 10 vs 3,024 bytes, 99,999 vs 63, 0 vs 723). The chain wins; the notes warn the field "can be wrong" | `AmbiguousDbxDeclaredLength` | undbx 0.21 on generated stores, and on a real store (NIST CFReDS Hacking Case): 33 of the 34 news posts that have text state a length 5 to 380 bytes different from the chain's, and undbx's file equals the chain in all 34 |
| S15 | DBX: the character set of record strings (subject, sender, folder names) | n/a | n/a | windows-1252. The notes name none; the strings are in the system ANSI code page of the machine that wrote them, which the file does not record | none | our default; not observable through undbx |
| S16 | DBX: a message file in the store folder that `Folders.dbx` does not name | n/a | n/a | Read, as a folder named after the file | `DbxUnlistedFile` | undbx 0.21 also extracts every file in the directory; our default for the folder name |
| S17 | DBX: a message record with no text chain (a header that was never downloaded) | n/a | n/a | An empty message in the folder, flagged. undbx 0.21 writes a 0-byte file for it (its `-i` option skips empty messages) | `AmbiguousDbxNoMessageText` | undbx 0.21 on a generated store; the real store above has 2,019 header-only records among 2,053 (news headers), and undbx writes each as a 0-byte file |
| S18 | DBX: field 0x09 of a folder record | n/a | n/a | Read as the special-folder kind (1 Inbox, 2 Outbox, 3 Sent, 4 Deleted, 5 Drafts). The notes describe the field twice (a per-folder index and the kind) | none | a real `Folders.dbx` (35,527 records) has the kinds on Inbox 1, Outbox 2, Sent Items 3, Deleted Items 4, Drafts 5 and absent on the rest |

| S19 | The character before the Maildir info section (`unique:2,FS` or `unique;2,FS`) | Py `mailbox` writes and reads `:` only | n/a | Read: the first `:`, else the first `;` that is followed by `2,`. Written: `MaildirStoreOptions.InfoSeparator`, default `;` on Windows (where a file name cannot hold `:`) and `:` elsewhere; isync's `InfoDelimiter` documents the same default. No Windows mail client has been observed | none | our default (isync documentation) |

## Reports, authentication results and MHTML

Probes are `testdata/reports/…` (`cases.tsv` describes each); MK is MimeKit 4.18.2's public API, Py is Python 3.13 `email`
(plus `urllib.parse.urljoin` for MH6–MH8). Go has no report or MHTML support and is not compared. All entries are provisional
(no client observed). Method: [reports-and-mhtml.md](https://recursiveai.org/onemail/docs/reports-and-mhtml/).

| ID | Probe | MK | Py | Resolution | Defect | Basis |
|---|---|---|---|---|---|---|
| RP1 | `dsn-11`, `dsn-12`, `dsn-32` extra blank lines between or around blocks | skipped | each is an empty block | Runs of blank lines are skipped; one defect per run. | `AmbiguousReportBlankLines` | prov. (MK, RFC) |
| RP2 | MDN/ARF body with a leading blank line, or several blocks | empty first block | empty first block | The first block is empty; further blocks are kept as extra blocks. | `AmbiguousReportExtraBlocks` | MK and Py agree |
| RP3 | `dsn-13` a line without a colon in a block | kept as a field named by the line | block ends there | Skipped, reported. | `ReportMalformedLine` | prov. (no two agree) |
| RP4 | `dsn-34` a name with a space | kept as a field | block ends there | Kept under its text before the colon. | `InvalidHeaderName` | prov. |
| RP5 | `dsn-18` whitespace before the colon | trimmed | field lost | Trimmed, reported. | `WhitespaceBeforeHeaderColon` | prov. (MK, RFC 5322 obs-) |
| RP6 | `dsn-14` a fold before any field | ignored | n/a | Skipped, reported. | `ReportMalformedLine` | prov. |
| RP7 | `dsn-26` a lone CR in a value | kept | line split at CR | Stays data. | `BareCarriageReturnInHeader` | MK, same as the header reader |
| RP8 | `dsn-21` an empty delivery-status body | no report | one empty block | A report with no groups. | `ReportMissingGroup` | prov. |
| RP9 | a whitespace-only line inside a block | fold | fold | A fold, not a block end. | none | all agree |
| RP10 | repeated single-use field (`dsn-15` Action twice) | both kept in order | n/a | Both stay in the block; the typed accessor reads the first. | `AmbiguousReportDuplicateField` | prov. (lossless) |
| AR1 | `auth-26`, `auth-08` first token is `name=…` (no authserv-id) | no id | n/a | No authserv-id; the token starts the first result. | `AuthenticationResultsMissingId` | MK |
| AR2 | `auth-08` a property with no ptype (`action=none`) | dropped | n/a | Kept with an empty ptype. | `AmbiguousAuthenticationResultsProperty` | prov. (lossless) |
| AR3 | `auth-11,12,14,15,16,18,19,27` broken fields (bad version, empty clause, garbage, `none` beside results, unterminated quote or comment) | the whole field fails | n/a | The clauses that can be read are kept, each repair is a defect. | `AuthenticationResultsInvalidVersion`, `InvalidAuthenticationResults`, `AmbiguousAuthenticationResultsNone`, `UnterminatedQuote` | lossless |
| AR4 | `auth-10` quoted authserv-id, property value or reason (valid per RFC 8601 §2.2) | the field fails | n/a | Parsed. | none | RFC |
| RS1 | `spf-06,07,10` unknown result, words that are not pairs, bad `client-ip` | no oracle | no oracle | Unknown result kept as `SpfResult.Unknown` with the text; bad pairs skipped. | `InvalidReceivedSpf` | prov. (RFC 7208 §9.1 only) |
| MH1 | `mht-01` `cid:` references, with brackets, `%40`, or case | no match | n/a (no MHTML) | Matches `Content-ID` only, brackets stripped, percent-decoded, exact then ASCII case-insensitive. | none | RFC 2557 §8.3 |
| MH2 | `mht-02,06,08,09,10,11,15,19` base from the part's or an enclosing entity's `Content-Location` | base only from the multipart's `Content-Base` | n/a | Base order of RFC 2557 §5 as written in the method note. | none | RFC |
| MH3 | `mht-03` dot segments, no base | no match | n/a | RFC 3986 §5.2.4 on `thismessage:/`. | none | RFC |
| MH4 | `mht-07` percent escapes, space, case of `%hex` | exact only | n/a | Exact first, then normalised. | none | prov. |
| MH5 | `mht-17` `mid:` | no match | n/a | Needs a matching Message-ID and Content-ID. | none | RFC 2392 |
| MH6 | `urljoin` with base `thismessage:/…` | n/a | returns the reference unchanged (scheme not hierarchical for it) | Resolved per RFC 3986. | none | RFC |
| MH7 | `urljoin` of `http:g` against `http:` base | n/a | non-strict: `http://a/b/c/g` | Strict: `http:g` (RFC 3986 §5.2.2). | none | RFC |
| MH8 | `urljoin` with an upper-case scheme or host in the reference | n/a | lower-cases the scheme | Left as written; `Normalize` lower-cases when matching. | none | lossless |
| MH9 | two parts with the same location or content id (`mht-06`) | first | n/a | First wins. | `AmbiguousMhtmlDuplicateLocation`, `AmbiguousMhtmlDuplicateContentId` | prov. |
| MH10 | `start` names no part (`mht-05`) | n/a | n/a | The first part is the root. | `MhtmlStartNotFound` | RFC 2387 |

Documented oracle limits (not ambiguities; in `explained.tsv` as `LIMIT-*`): Python does not read
`message/global-delivery-status` or transfer-encoded report parts as reports.

## The shared read view (bodies and attachments)

Checked against Python 3.13 `email` with `policy.default` (`get_body`/`iter_attachments`) over the probes, the Apache James test
messages, the dovecot mbox and a sample of the CMU Enron maildir; `tools/oracles/viewcheck.sh` reproduces the run. The view's
choices below are provisional: no mail client has been observed for them yet. The view raises no defects of its own; it
chooses between readings of a valid tree. The tree always keeps every part (`docs/shared-view.md`).

| ID | Case | Python `email` | Resolution |
|---|---|---|---|
| V1 | Which parts are attachments | Immediate children of the top multipart, except the first `text/plain`, `text/html`, `multipart/related` and `multipart/alternative`; a nested multipart is one attachment; a message that is not multipart has none | A leaf part or `message/*` part that is not the chosen text or HTML body, found by walking every multipart, in document order. A message that is a single leaf part (not text-body) is its own attachment. |
| V2 | Body inside `multipart/signed` or a nested `multipart/mixed` | `get_body` does not look inside `multipart/signed`; the nested part's text is listed as an attachment | The body is searched in the signed content (the first child) and in nested mixed parts, in order; the signature part is an attachment. |
| V3 | Body in `multipart/alternative` with several text parts | The last alternative that matches | The last alternative that matches (RFC 2046 5.1.4). The other text renderings are not attachments unless marked `Content-Disposition: attachment`. |
| V4 | Inline parts | Parts of a `multipart/related` are not listed | Listed, with `IsInline` set when the part is not marked `attachment` and has a `Content-ID`, so callers can resolve `cid:` references. |
| V5 | A text part with `Content-Disposition: attachment` | An attachment | An attachment; never the body. A text part with a file name but no disposition is still a body when it is the first match (the same as Python). |
| V6 | Text body without a usable charset | `us-ascii`, bytes above 127 replaced | UTF-8 when the bytes are valid UTF-8, otherwise Windows-1252 (N5's rule). |
| V7 | Address groups in `From`/`To`/… | Group name kept | Flattened to the members, the group name dropped (A1). The tree keeps the group. |
| V8 | Attachment file name | `get_filename()`: disposition `filename`, else type `name`, with the parameter rules of N1–N8 | The same order (N9). Names are kept as written, not made safe for a file system (N10); no name is `null`, never an empty string. |
| V9 | A message with a TNEF part (winmail.dat) | Python: winmail.dat is one attachment. MimeKit (black box, `MimeKitTnef`): `MimeMessage.Attachments` lists winmail.dat; `TnefPart.ExtractAttachments` returns the body (an unnamed RTF, HTML or text entry) and the attachments | A part that qualifies under MS-OXCMAIL 2.2.3.8 (the conversion's rule, `conversion.md`) is not listed; the attachments of its TNEF take its place in document order, `RtfBody` and the bodies the MIME side lacks come from it, and the part stays reachable (`MimeMailItem.TnefParts`, the tree). A part that does not qualify stays an attachment. Outlook shows a TNEF message this way (2.2.3.8); the attachments equal MimeKit's extraction, tnefparse's, tnef's and ytnef's on 64 wrapped messages (252 checks, 0 unexplained). Prov.: no client observed. |

## MAPI and MSG

Observed on the Apache Tika and POI MSG files (55) and our generated ones, against extract-msg 0.55.0 as a black-box oracle.
No Outlook has been observed yet, so each resolution is provisional.

| ID | Case | Other reader | Resolution | Defect | Basis |
|---|---|---|---|---|---|
| X1 | Same property tag twice in one property stream | n/a | Both kept in the bag; lookups return the last | `AmbiguousDuplicateProperty` | prov. |
| X2 | Value stream with no property entry (POI fuzz-minimised file) | ignores it | Read as a property; the stream is the data | `AmbiguousOrphanValueStream` | prov., lossless |
| X3 | String type differs from the store support mask | follows the entry's type | The entry's own type decides | `AmbiguousStringType` | MS-OXMSG 2.4.2 |
| X4 | 8-bit strings, no `PidTagMessageCodepage` | fixed Latin default | Locale's ANSI page, then Internet code page, then enclosing message, then 1252 | `AmbiguousCodePage` | 36 corpus strings differ, all in files with only a locale or Internet code page |
| X5 | String size field: stream length or stream plus terminator | n/a | Both accepted | none | ANSI files store the NUL in the stream |

## PST/OST messaging and LTP

Observed on the Tika testPST files, the CFReDS OST and our generated files against `pffexport` 20180714 as a black-box oracle. No Outlook
has been observed yet, so each resolution is provisional. L-entries are the lists-tables-properties choices of `docs/pst-ltp.md`.

| ID | Case | Other reader | Resolution | Defect | Basis |
|---|---|---|---|---|---|
| P1 | Message has attachment subnodes but no attachment table | no attachments | The attachment subnodes found are used | `AmbiguousAttachmentTableMissing` | prov., lossless |
| P2 | Embedded-message object property holds a bare subnode NID, not the (NID, size) structure | n/a | The NID is used | `AmbiguousObjectReference` | MS-PST 2.3.3.5 states the structure; prov. |
| P3 | Table row identifier differs from the NID the object is stored under | trusts the node | The node decides; the mismatch is reported | `RowIdMismatch` | prov. |
| L1 | Rows per block of a subnode row matrix | n/a | Taken from the first block's own size, which equals the formula for 8 KiB blocks | none | prov.; real files agree |
| L2 | BTH key that does not increase | n/a | Skipped, so a node reached twice gives no duplicates | none | prov. |
| L3 | Property or cell of an unknown type | n/a | Read as an HNID in a PC; kept inline when one, two or eight bytes | none | prov. |
| L4 | Positions in a heap's fill-level bitmap blocks (HNBITMAPHDR) | n/a | Allocations may not start before the block's header (12, 66 or 136... bytes, 2 elsewhere) | none | exercised on our files only |

## RTF bodies (compressed RTF, de-encapsulation)

Probes are the 45 RTF bodies of the public Tika and POI `.msg` files, the specification examples (`testdata/rtf`), and 3,000
damaged streams. Oracles: compressed-rtf 1.0.7 (decompress, compress) and RTFDE 0.1.2.2 (de-encapsulation), run as released
packages; the message's own `PidTagHtml`/`PidTagBody` is a third reference. Where the specification is explicit the entry says so.
Entries without an observed Outlook-side result are provisional. Method: [rtf.md](https://recursiveai.org/onemail/docs/rtf/).

| ID | Case | Oracle / own body | Resolution | Defect | Basis |
|---|---|---|---|---|---|
| RT1 | `\par` and `\line` in the extracted text | RTFDE writes LF; own plain-text bodies use CRLF (4 of 4) | CRLF (MS-OXRTFEX 2.2.3.2 and 2.2.3.3) | none | spec; seen in 31 HTML and 4 text results vs RTFDE |
| RT2 | A literal no-break space (`\'a0`) in visible text | RTFDE writes `&nbsp;` | The character U+00A0. A string has no code page to lose it in | none | prov.; 4 files vs RTFDE |
| RT3 | The message's own `PidTagHtml` carries `<meta http-equiv=Content-Type …>` that the RTF's HTML lacks | own body | Not part of the encapsulated HTML; nothing to resolve. Callers who want the stored body read `PidTagHtml` | none | 5 of 6 own HTML bodies |
| RT4 | Own `PidTagHtml` is a re-serialised copy of the RTF's HTML (tag case, dropped `</tbody>`) | own body | The encapsulated HTML is returned as written; same text (checked by words) | none | 1 of 6 (`bug66335`); RTFDE agrees with OneMail on it |
| RT5 | `\_` | spec table: soft hyphen in `\htmltag`; RTF 1.9.1: non-breaking hyphen | U+00AD inside `\htmltag`, U+2011 elsewhere | none | spec |
| RT6 | `\htmlrtf` active when an `\htmltag` group starts; code page of `\htmltag` text | spec: content MUST be copied; default code page MUST be used | Copied whatever the state; decoded with the document's default code page, not the font's | none | spec |
| RT7 | `\uN` and `\lquote`, `\~`… inside `\htmltag` | spec table gives HTML text (`&#x20AC;`, `&lsquo;`); 2.2.3.2 says "Unicode characters" | The characters. A .NET string needs no entities, and both denote the same text | none | prov. |
| RT8 | How many characters `\ucN` skips | RTF 1.9.1 says "characters", with `\'hh` as one | Text bytes and `\'hh` escapes count; control words do not; `{` or `}` cancels the rest; `\uc` is group-scoped | none | prov. |
| RT9 | Code page of text bytes | spec: "the code page of the current font" | Font `\cpgN`, else `\fcharsetN` (0 = 1252 even under another `\ansicpg`; 1 = document), else `\ansicpg`/`\ansi`/`\mac`/`\pc`/`\pca`, else 1252; before any `\f` the `\deff` font | none | spec example uses `\fcharset0` under `\ansicpg1251` for exactly this; the corpus Cyrillic body decodes to its own body |
| RT10 | `\htmlrtf` in a `\fromtext` document | spec defines it for HTML only | Ignored | `AmbiguousHtmlRtfInPlainText` | prov. |
| RT11 | A code page with no decoder (symbol, platform gap) | spec silent | The document's default code page | `AmbiguousUnknownCodePage` | prov. |
| RT12 | Detection: `\fromhtml`/`\fromhtml0`, text before the control word, an eleventh token, CR/LF before the first token | spec 2.2.3.1 (SHOULD) | Only `\fromhtml1` or `\fromtext` within ten group-start/control-word tokens after `{\rtf`; line breaks are skipped as in RTF | `NotEncapsulated`, `NotRtf` | spec |
| RT13 | Destinations | spec: skip standard destinations without visible text, keep `\fonttbl`; ignorable `\*` groups other than `\htmltag` skipped | A fixed list of body-less destinations is skipped; an unknown destination that is not ignorable is read as text (it cannot be told from a formatting group) | none | prov. |
| RT14 | RAWSIZE far above the data (a decompression bomb) | compressed-rtf decodes the data and ignores the claim | Refused before allocating: above 256 MiB, or above 20 × the input length | `OutputLimitExceeded`, `ExpansionRatioExceeded` | 123 of 3,000 damaged streams differ for this reason only |
| RT15 | COMPSIZE disagrees with the bytes present | compressed-rtf ignores it | Contents are bytes 16 to COMPSIZE + 4 when that is within the input, else to the end; the CRC covers exactly those | `CompressedSizeMismatch` | spec 2.1.3.1.1; prov. for the extra bytes |
| RT16 | Data ends before the end marker | compressed-rtf returns what it decoded | The decoded bytes, with a defect | `MissingEndMarker` | prov. |
| RT17 | RAWSIZE disagrees with the output; `MELA` contents | spec: MELA CRC not validated, size may be read either way | Output is what the data holds (growing up to the limit), with a defect; MELA is the contents as stored | `RawSizeMismatch` | spec 2.2.3.1 |
| RT18 | Compressing nothing | spec note: Outlook writes `02 00 0D 00` | `01 0C F0` (end marker only) is written; `02 00 0D 00` is read as one NUL byte | none | spec |
| RT19 | `\fcharset0`/`\fcharset1` fonts under another `\ansicpg` (native RTF to HTML) | pandoc and unrtf decode every `\'hh` as 1252; LibreOffice 24.2.7 uses 1252 for `\fcharset0`, LibreOffice 26.2.6 follows `\ansicpg` | Follows the document's `\ansicpg` (0 and 1 both mean "the document's ANSI code page"). The de-encapsulator keeps RT9 (0 = 1252) because it reads what Outlook wrote, where the spec example shows `\fcharset0` under `\ansicpg1251`; a document authored by Word names a Central European font with its own `\fcharset238`, so `\fcharset0` there is Latin text typed under that page | none | no client observed; oracles split, the newer one agrees; provisional |
| RT20 | Header, footer, footnote and endnote destinations | LibreOffice puts them in the body; pandoc/unrtf vary | Omitted from the mail body, text and HTML | none | provisional: a message body has no page furniture |
| RT21 | Hidden text (`\v`) | unrtf writes it | Omitted | none | spec: hidden text is not displayed |
| RT22 | A table inside a table cell | oracles flatten or nest differently | Flattened into the outer cell's text | `NestedTableFlattened` | provisional |
| RT23 | Shapes (`\shp`) | pandoc shows `\shprslt` only | The `\shpinst` text box content is read; `\shprslt` is skipped when a `\shpinst` is present | none | RTF 1.9.1 (the result is the fallback for readers that skip `\shpinst`) |
| RT24 | Picture whose bytes disagree with the declared `\pngblip`/`\jpegblip` | none | The bytes decide (magic numbers); EMF/WMF are not rendered and become an attachment-style placeholder | `AmbiguousPictureFormat`, `InvalidPictureData` | provisional |
| RT25 | A `\cell` outside any row | LibreOffice joins the words on each side | A tab between them | `AmbiguousBareCell` | provisional |
| RT26 | `\object` with a result | LibreOffice drops the result picture | The result is shown; an object with only its placeholder becomes an attachment placeholder | none | provisional |
| RT27 | Empty paragraphs; content after the root group | none | An empty paragraph is `<p><br></p>`, trailing empty paragraphs are dropped; anything after the root group's closing brace is ignored | none | provisional |
| RT28 | List level beyond the list table, or an `\ls` with no list table | pandoc drops the list | Level clamped to the deepest defined; an undefined `\ls` renders as a bullet list | `AmbiguousListLevel`, `UndefinedReference` | provisional |
| RT29 | Headings | pandoc and unrtf make none from `\s` styles | A paragraph style whose stylesheet name is "heading N" becomes `<hN>`; the stylesheet is read for names only and its formatting is not inherited | none | provisional |
| RT30 | `\objattph` | none | The attachment position is a placeholder, and the single space after it is consumed | none | spec |
| RT31 | Text in a symbol font (`\fcharset2`, Symbol, Wingdings) | none | Private-use code points are kept, except the bullet | `AmbiguousSymbolFont` | provisional |
| RT32 | `HYPERLINK \l "x"` | none | `href="#x"` | none | spec (field switches) |
| RT33 | `\caps`/`\scaps` | pandoc upcases the text | The text is unchanged and a CSS `text-transform` carries the effect | none | provisional |

## TNEF

Observed on the public Apache POI and Tika TNEF files (6 distinct) and our generated ones, against tnefparse 1.4.0, tnef 1.4.18 and ytnef 2.1.2
as black-box oracles. No Outlook has been observed, so each resolution is provisional unless it follows the specification's own text.

| ID | Case | Other reader | Resolution | Defect | Basis |
|---|---|---|---|---|---|
| T1 | A message-level legacy attribute appears twice | n/a | The last one is used; both stay in the attribute layer | `AmbiguousDuplicateAttribute` | prov., lossless |
| T2 | A legacy attribute and the encapsulated property differ (e.g. attDateRecd vs PidTagMessageDeliveryTime in winmail-sample1.dat) | n/a | The encapsulated property wins; the legacy value is not added | `AmbiguousLegacyConflict` | MS-OXTNEF 2.1.3; seen in 1 corpus file |
| T3 | PtypNull value in a property list | tnef and ytnef reject it (0 and 4 bytes alike) | Takes no bytes | none | MS-OXCDATA 2.11.1; prov. |
| T4 | Legacy message class names (`IPM.Microsoft Mail.Note`) | n/a | Mapped to the modern class | `AmbiguousMessageClass` | MS-OXTNEF 2.3.3.4; the spec sample and corpus files use it |
| T5 | Wrong checksum on the message class attribute | n/a | Ignored, the data is used | `AmbiguousMessageClassChecksum` | MS-OXTNEF 2.3.3.4 |
| T6 | An embedded-message or OLE attachment also has attAttachData | n/a | The attribute is ignored | `AmbiguousAttachDataIgnored` | MS-OXTNEF 2.3.3.7 |
| T7 | attOemCodepage differs from PidTagMessageCodepage | n/a | attOemCodepage decides how 8-bit strings are read | `AmbiguousCodePage` | MS-OXTNEF 2.3.3.2 order; no corpus file has the conflict, so no client observation |
| T8 | attOwner with no meeting class | n/a | Maps to the PidTagSentRepresenting group | `AmbiguousOwner` | prov. |

## MS-OXCMAIL conversion (MAPI to MIME and back)

The rules are in [`conversion.md`](https://recursiveai.org/onemail/docs/conversion/). No Outlook has been observed yet; the two oracles are black-box MimeKit 4.18.2 and
Python 3.13 reading what we write, and the corpus is the Tika and POI MSG files and the Tika and CFReDS PST/OST files. Each
resolution is provisional until Outlook can be run.

| ID | Case | What the spec says | Resolution | Defect or report entry | Basis |
|---|---|---|---|---|---|
| OX1 | An `EX` sender or recipient has no SMTP address property | 2.1.3.1.7: look it up in the address book directory | Without a directory: the same distinguished name elsewhere in the message, then the matching transport `From`/`Sender` field (one mailbox), then a unique display name in the transport headers. Never derived from the distinguished name | `AddressUnresolved` when nothing resolves | on the 45 MSG and 25 PST/OST items, 16 of 28 EX senders and 50 of 50 EX recipients resolve; the 12 senders that do not (all MSG) have no SMTP property and no transport From field; 9 PST senders have no address at all |
| OX2 | Transport headers and properties disagree | 2.2.3.2.28 keeps the received block; 2.1.3.2 builds fields from properties | The received field stays when it agrees; the properties win when it does not (a user edit makes the properties newer) | `TransportHeaderOverridden`, `NamedPropertyValueSetAside` | 2 fields and 5 named-property values across the 70 corpus items |
| OX3 | Default values (`PidTagSensitivity` 0, false receipt flags) | 2.1.3.2.6 and 2.1.3.1.5: no field | No field; reading back leaves the property absent | `DefaultValueNotWritten` | 2.1.3.2.6, 2.1.3.1.5; 151 entries over the 70 exported items |
| OX4 | `Importance: Normal` | 2.1.3.2.5: MAY be omitted | Written whenever the property exists (0, 1, 2) | none | 2.1.3.2.5 (MAY omit); writing it keeps the property on the way back, 0 silent differences in the 70-item property round trip |
| OX5 | RTF that encapsulates nothing | 2.1.3.3: not covered | A `text/rtf` part when there is no HTML, else not written | `RtfCarriedAsTextRtf`, `RtfRedundant` | 2.1.3.3 is silent; 5 and 13 entries over the 70 exported items; no client observed |
| OX6 | Case of a `PS_INTERNET_HEADERS` name | 2.2.3.2.29: "the header name" | As the message wrote it. Stores differ (Exchange stores lower case, Outlook as written), so comparisons are case-insensitive | none | 2.2.3.2.29; the MSG and PST corpora hold both spellings, so the property round trip compares case-insensitively |
| OX7 | Missing or invalid `Date` on import | 2.2.3.2.2: the current time | No `PidTagClientSubmitTime`; a conversion must be reproducible. Export falls back to the delivery time | `DateFromDeliveryTime` | deviates from 2.2.3.2.2 on purpose (reproducible output); 4 MSG items and 1 Mime4j message hit it |
| OX8 | Non-ASCII display name | 2.1.3.1: RFC 2047 | The whole name as one run of encoded words (never leaving "@" or "(" in plain text beside them) | none | MimeKit and Python read it; a plain-text remainder broke an address in a first attempt |
| OX9 | Which MIME part is the body | 2.2.3.3.2 | The part the shared view chooses (`shared-view.md`), a named text part included | none | 2.2.3.3.2; body hashes equal the view's on 70 of 70 exported items in both oracles |
| OX10 | Composite media type on flat attachment data | not covered | `application/octet-stream` | `CompositeTypeOnFlatData` | spec silent; a `multipart/signed` without a boundary broke the parse in MimeKit and Python; 1 MSG item and 6 Mime4j messages hit it |
| OX11 | Bcc | 2.1.3.1.1.2 | Not written unless asked; a `Bcc` field on import is Bcc recipients | `BccNotWritten` | 2.1.3.1.1.2; 2 MSG items, 2 Mime4j and 1,272 Enron-sample messages hit it |
| OX12 | `multipart/signed` on import | 2.2.3.4 | Class `IPM.Note.SMIME.MultipartSigned`; content and signature become a body and an attachment | `SignatureNotVerifiable` | 2.2.3.4; 213 of 1,430 Dovecot messages; the Outlook form, while signed MIME stays bytes elsewhere |
| OX13 | Subject prefix | 2.2.3.2.6.1: one to three characters other than colon, blank or digit, then colon and blanks | As stated, with an empty `PidTagSubjectPrefix` when there is none | none | 2.2.3.2.6.1; 23 PST items carry a prefix marker the export removes (`SubjectPrefixMarkerRemoved`), none differ in the oracles |
| OX14 | Embedded message names | not covered | The attachment's own name only; the subject is not invented as a name | none | spec silent; attachment names of all 70 exported items equal the source in both oracles |
| OX15 | Reply-To entries | 2.1.3.1.2: names ignored when the counts differ | Only one-off SMTP entries are written; address-book entries need a directory | `ReplyToUnresolved` | 2.1.3.1.2; 9 entries over the 70 exported items |
| OX16 | A TNEF property that a header field also sets (subject, sender, reply-to) | 2.2.3.8 step 2.1: ignore attributes that set a property already set by the headers | Per property, and a group set by any header (subject, sender, sent-representing sender, reply-to) is the headers' whole, so two sources are never mixed in one sender or subject. Ignored values that differ are reported | `TnefPropertyIgnored` | 2.2.3.8; 152 of 1,596 properties over 52 wrapped corpus messages (none in the bare shape, which has no headers); prov. for groups |
| OX17 | Plain text in MIME, richer body in the TNEF | 2.2: the plain text MUST NOT be preferred to a richer TNEF body | A body property is added when the MIME side has none of that kind; the MIME text stays `PidTagBody`; `PidTagNativeBody` and `PidTagInternetCodepage` are the TNEF's (the author's) unless the HTML is the MIME side's, then the richest body | `TnefPropertyIgnored` for a differing body of the same kind | 2.2; prov. for the codepage rule |
| OX18 | Message class | the class is a property; header processing gives `IPM.Note` | The default `IPM.Note` gives way to the TNEF's `IPM.*` class; a class the MIME structure gave (report, S/MIME) stays | `TnefMessageClassUsed` | spec silent on defaults; needed for meeting requests carried in TNEF; prov. |
| OX19 | Recipients in the headers and in the TNEF | 2.2.3.8 step 2.1 | The headers' recipients win; the TNEF table is used only when the headers gave none | `TnefRecipientIgnored` | 2.2.3.8; prov. |
| OX20 | A TNEF stream ending in CR LF | 2.2.3.8 step 3: SHOULD ignore the stream (a SHOULD, no client observed) | Unpacked, as tnef 1.4.18, ytnef 2.1.2, tnefparse 1.4.0 and MimeKit 4.18.2 all do (4 of 4 oracles on both `bug52400` files); reported, not hidden. Prov. until a client is observed | `AmbiguousTnefTrailingCrLf` (view defect), `TnefTrailingCrLfIgnored` (`Internal` loss entry) | 2.2.3.8 step 3; the two `bug52400` corpus files; the earlier choice (keep as attachment) was reversed on the oracle evidence |
| OX21 | Correlation key vs `X-MS-TNEF-Correlator` | 2.2.3.8 step 4: a mismatch means pure MIME; the field is spelled `X-MS-TNEF-Correlator` (2.1.3.2.26) and `X-MS-Tnef-Correlation` (2.2.3.8) | Both spellings are read; the binary key matches when its bytes (trailing NULs and blanks removed) equal the field as ASCII text or as hexadecimal digits; either one missing is a match | `TnefNotUsed` | 2.1.3.2.26, 2.2.3.8; none of the corpus files has a key; prov. |
| OX22 | Writing a TNEF message | 2.1.3.7; the key is "unique" | The key of the message's own `PidTagTnefCorrelationKey` when it has one, else the first 16 bytes of SHA-256 of Message-ID (or subject) and submit time in hex; no top-level recipient table in the TNEF; an empty `text/plain` when there is no text body | `TnefWriterLimit`, `TnefPlainTextNotRendered` | 2.1.3.7, 2.1.3.1.1; 45 MSG and 25 PST/OST corpus items through `OxcMailTool rt-tnef`: 10,496 of 11,142 properties exact, 227 the same text under another string type, the rest in the loss report, 0 silent (pure MIME: 6,453 exact, 4,290 reported) |

## Outlook calendar items and MS-OXCICAL

No Outlook has been observed yet: every row is an agent default settled by the spec text and, for the expansion, by the oracles named in [`calendar-items.md`](https://recursiveai.org/onemail/docs/calendar-items/).

| ID | Case | What the spec says | Resolution | Defect or report entry | Basis |
|---|---|---|---|---|---|
| OC1 | A monthly pattern on day 29 or 30 | MS-OXOCAL 2.2.1.44.1: the instance falls on the last day of a month that is too short | The RRULE `BYMONTHDAY=29`/`30` is written (RFC 5545 skips short months); day 31 is written `BYMONTHDAY=-1` | `AmbiguousShortMonth` (Approximated) | MS-OXCICAL 2.1.3.2.2; test `MonthlyDay30IsAnAmbiguityNotASilentChange`; prov. |
| OC2 | The series start is not a day the pattern allows (a Tuesday start on a Monday-only weekly pattern) | 2.2.1.44.1.2 starts from the first valid day | The first valid day is the first instance and `DTSTART` | `AmbiguousFirstInstance` | test `AFirstInstanceOffThePatternStartsOnTheFirstAllowedDay`; prov. |
| OC3 | A floating time or a DATE value has no zone in an Outlook item | MS-OXOCAL: times are UTC plus a zone struct | UTC is assumed for the series zone and reported | `FloatingTimeAssumedUtc` | prov. |
| OC4 | `WKST` when an RRULE has none | MS-OXCICAL: a missing WKST means Sunday; RFC 5545 3.3.10: Monday | `WKST` is always written from the pattern's first day of week when exporting; a missing WKST is read as RFC 5545 says (Monday) by the shared engine | none | dateutil and recurring-ical-events agree on all 54 generated series (the two-week patterns with Sunday and Monday differ without it); test `RRuleExpansionEqualsTheSpecDayWalk` weekly-2w cases; prov. |
| OC5 | The UID of an appointment | 2.1.3.1.1.20.26: a global object ID whose data (from byte 40) starts with `vCal-Uid` and 0x01000000 carries the UID text from byte 52; otherwise the UID is the hex of the ID with the instance date zeroed | Exactly that; an item with no usable ID gets a UID made from a hash of subject and times | `UidSynthesized` (Approximated) | spec text; tests; prov. |
| OC6 | The zone of a VEVENT with a `VTIMEZONE` | 2.1.3.1.1.20.2: the zone becomes a TZDEFINITION | The rules are recovered by sampling the zone hourly in the DTSTART year: two changes are a DST rule, none a fixed offset, anything else the fixed offset at DTSTART | `UnknownTimeZone` when the TZID is neither defined nor known | prov. |


## OLM (Outlook for Mac)

Observed on the two public sample archives (Outlook 16 layout) and our generated ones, against the released olmreader 1.9.0 jar and the
olm-reader 1.0.0 npm package as black-box oracles. No Outlook for Mac has been observed, so each resolution is provisional. Details: [`olm.md`](https://recursiveai.org/onemail/docs/olm/).

| ID | Case | Other reader | Resolution | Defect | Basis |
|---|---|---|---|---|---|
| O1 | Dates have no zone (`2018-06-20T23:49:52`) | the jar returns the text and leaves the reading to the caller; the npm package reads it in the machine's zone (UTC when `TZ=UTC`, and then writes it as GMT) | UTC, `ZoneKnown` false | `AmbiguousDateZone` | prov.; the npm package agrees under `TZ=UTC` on 478 + 7 messages |
| O2 | `OPFMessageCopyBody` and `OPFMessageCopyHTMLBody` hold the same text | the npm package writes text and strips tags | `OPFMessageGetHasHTML` decides: true gives an HTML body, false a plain one; with no flag, a body that differs from the HTML element or looks like HTML is HTML | `AmbiguousBodyKind` (flag absent only) | prov.; 470 of 483 and 7 of 7 sample messages have the two elements equal |
| O3 | Two attachments of one message share a name | the jar keys bytes by name and keeps one | Both are listed, each with its own bytes | none | oracle limit (1 message in the Enron sample) |
| O4 | The declared attachment size (`OPFAttachmentContentFileSize`, written as `5.0614E4`) differs from the entry's size | not used by either oracle | The entry's size is the size; the declared one stays in the XML | none | every attachment in the Outlook-example sample differs |
| O5 | An address element has a name and no address | the npm package writes `unknown@unknown.com` for a missing sender | Name kept, address empty | `AddressMissing` | 12 recipients in the Enron sample |
| O6 | A date of year 4501 or later | n/a | No date (Outlook's "none" value) | none | MS-OXOCAL; prov. |
| O7 | Contact e-mail slot type 0, 1, 2 | n/a | work, home, other | none | prov.; the sample's addresses are named after their slot |
| O8 | Recurrence with both a count and an end date | n/a | The count (`COUNT`) when the record says it is numbered, else the end date as a UTC `UNTIL` | `RecurrenceEnd` (loss entry) | prov.; all four sample patterns are numbered |
| O9 | An attachment URL that matches no entry | the jar returns no bytes | Listed with no content | `AttachmentMissing` | prov. |
