feat(msg): read Outlook messages - #160
Conversation
A `.msg` is not MIME. It is an OLE compound file whose MAPI properties each sit in their own stream, named `__substg1.0_<tag><type>` after the property id and its PT_ code ([MS-OXMSG]). The repository already opens compound files with `cfb` for legacy `.doc`, so the frontend is a property reader over the same machinery and adds no dependency. Subject becomes the heading, sender and addressees a labelled envelope paragraph, and `PR_BODY` the prose: a blank line separates paragraphs and a single newline is a hard break, which is how mail is written. Strings arrive as UTF-16LE (`001F`) or in the code page the message names (`001E`); the Unicode stream wins where a producer writes both, being the one that cannot have lost characters. Recipient and attachment storages are not traversed, because `PR_DISPLAY_TO` already carries the addressees as the sending client rendered them. Detection routes on the property store rather than a content stream, which a message does not have. A message whose only body is HTML or compressed RTF reports `Unsupported` instead of emitting markup as prose. Registered in the node, python and wasm bindings, in the CLI format list, and as a fuzz target.
There was a problem hiding this comment.
2 issues found across 13 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="fuzz/fuzz_targets/msg.rs">
<violation number="1" location="fuzz/fuzz_targets/msg.rs:8">
P2: With an empty corpus, this target feeds arbitrary bytes directly to `Format::Msg`, so the compound-file gate rejects nearly every input before MAPI decoding runs. Wrap mutations in a minimal valid MSG/OLE fixture and provide it as a seed, as the existing OLE fuzz targets do.</violation>
</file>
<file name="python/anydoc/_anydoc.pyi">
<violation number="1" location="python/anydoc/_anydoc.pyi:8">
P2: The public `anydoc.Format` alias still rejects `"msg"` even though this compiled-module stub accepts it. Add `"msg"` to `python/anydoc/__init__.py` so type checking matches the runtime-supported format.</violation>
</file>
Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic
| fuzz_target!(|data: &[u8]| { | ||
| // Conversion may fail with a typed error; it must never panic, hang, | ||
| // or exhaust memory. | ||
| let _ = anydoc::to_markdown_bytes(data, anydoc::Format::Msg); |
There was a problem hiding this comment.
P2: With an empty corpus, this target feeds arbitrary bytes directly to Format::Msg, so the compound-file gate rejects nearly every input before MAPI decoding runs. Wrap mutations in a minimal valid MSG/OLE fixture and provide it as a seed, as the existing OLE fuzz targets do.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At fuzz/fuzz_targets/msg.rs, line 8:
<comment>With an empty corpus, this target feeds arbitrary bytes directly to `Format::Msg`, so the compound-file gate rejects nearly every input before MAPI decoding runs. Wrap mutations in a minimal valid MSG/OLE fixture and provide it as a seed, as the existing OLE fuzz targets do.</comment>
<file context>
@@ -0,0 +1,9 @@
+fuzz_target!(|data: &[u8]| {
+ // Conversion may fail with a typed error; it must never panic, hang,
+ // or exhaust memory.
+ let _ = anydoc::to_markdown_bytes(data, anydoc::Format::Msg);
+});
</file context>
| Format = Literal[ | ||
| "doc", "docx", "odt", "pdf", "ppt", "pptx", "rtf", "epub", "xlsx", "ods", "odp", "csv" | ||
| "doc", "docx", "odt", "pdf", "ppt", "pptx", "rtf", "epub", "xlsx", "ods", "odp", "csv", | ||
| "msg" |
There was a problem hiding this comment.
P2: The public anydoc.Format alias still rejects "msg" even though this compiled-module stub accepts it. Add "msg" to python/anydoc/__init__.py so type checking matches the runtime-supported format.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At python/anydoc/_anydoc.pyi, line 8:
<comment>The public `anydoc.Format` alias still rejects `"msg"` even though this compiled-module stub accepts it. Add `"msg"` to `python/anydoc/__init__.py` so type checking matches the runtime-supported format.</comment>
<file context>
@@ -4,7 +4,8 @@ import os
Format = Literal[
- "doc", "docx", "odt", "pdf", "ppt", "pptx", "rtf", "epub", "xlsx", "ods", "odp", "csv"
+ "doc", "docx", "odt", "pdf", "ppt", "pptx", "rtf", "epub", "xlsx", "ods", "odp", "csv",
+ "msg"
]
</file context>
Review on firecrawl#160 found six things, all of them real. `PR_MESSAGE_CODEPAGE` and `PR_INTERNET_CPID` are `PT_LONG`, so no producer writes them as string streams. Looking for one meant the code page was never found in a real file and every `PT_STRING8` property decoded as Windows-1252 whatever the message said. Read the fixed-width entries out of `__properties_version1.0` instead. The test that covered this wrote the code page as a string stream, so it passed against the bug it was meant to catch. It now builds the property store the way [MS-OXMSG] lays it out, and a second test pins that a string stream is *not* where the value lives. Also: - A safety limit reading a property stream was swallowed and conversion continued with partial content. Fatal errors propagate. - A property holding only its NUL terminator counted as present, producing an empty heading or a labelled blank line. Values are cleaned before the blank test, since the characters `clean_text` drops are what made them look non-empty. - Detection accepted any entry whose name merely opened with `__substg1.0_`, storages included. It now requires a stream and exactly the eight hex digits of a property name. - The fuzz target fed raw bytes to a frontend that opens a compound file, so nearly every input died at the container gate. It wraps them in a message the way the other OLE targets do, with seeds. - `msg` was missing from the public `Format` alias in `python/anydoc/__init__.py`; only the compiled stub had it.
|
Worked through all six. Every one was valid — pushed in d2a18fe. The code page one was the serious find. Worse, my own test wrote the code page as a string stream, so it passed against the bug it was supposed to catch. That is on me. The test harness now builds the property store the way [MS-OXMSG] 2.4.1.1 lays it out — 32-byte top-level header, then 16-byte entries of packed tag, flags and value — and there is a second test pinning that a string stream is not where the value lives, so this cannot regress back into a self-confirming shape. Added a truncation test across every length from 0 to one entry, since all of those offsets come from the file. The rest:
Three unit tests added, two detection tests, one detection test extended for hex case — 16 in the module now. |
There was a problem hiding this comment.
All reported issues were addressed across 7 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
Addresses the
.msghalf of #128.Scope, and why only half
@itsaaronngan already has
.emlwritten and validated against 142 real messages, and offered it in this comment. I have not touched.eml— that work is theirs and it would be daft to duplicate it. Their comment scopes out the other half:That is this PR. The two are independent frontends and land in either order.
Approach
A
.msgis not MIME. It is an OLE compound file whose MAPI properties each sit in their own stream, named__substg1.0_<tag><type>after the property id and its PT_ code ([MS-OXMSG]). anydoc already opens compound files withcfbfor legacy.doc, so this is a property reader over machinery that is already here — no new dependency..emlcomment proposed (headers as a metadata paragraph, not a table — these are fields of one message, not tabular data)PR_BODY→ prose, where a blank line separates paragraphs and a single newline is a hard breakDetails worth flagging
Encoding. Strings arrive as UTF-16LE (
001F) or in the code page the message names (001E, viaPR_MESSAGE_CODEPAGEorPR_INTERNET_CPID). Both are read, and the Unicode stream wins where a producer writes both, being the one that cannot have lost characters.Whitespace-only lines. Producers pad a "blank" line with a space rather than leaving it empty. Taking that for content ends the paragraph with a hard break onto nothing — the same bug @itsaaronngan hit in
.emland called out as the worst of the four they found. Handled here from the start.Sender. Outlook sets the address into the display name when the sender resolves to none, which would otherwise render
a@b <a@b>. Deduplicated.Detection. Routes on the property store rather than a content stream, since a message has no equivalent of
WordDocumentorWorkbook. Either__properties_version1.0or a__substg1.0_stream identifies one.No plain-text body. A message whose only body is HTML (
PR_HTML) or compressed RTF (PR_RTF_COMPRESSED) reportsUnsupportedrather than emitting markup as prose — the same call @itsaaronngan made, and for the same reason. Worth notingPR_RTF_COMPRESSEDcould later route into the existing RTF frontend once LZFu decompression exists; I did not want to fold that in here.Recipient and attachment storages are not traversed.
PR_DISPLAY_TOalready carries the addressees as the sending client rendered them.Output
Tests
10 unit tests covering the document shape, bold envelope labels, absent and blank fields, the sender dedupe, hard breaks vs. paragraph breaks, whitespace-only lines, code-page decoding (1251 and the Windows-1252 fallback), Unicode winning over ANSI, the unsupported-body path, and a non-message compound file being rejected. Plus two detection tests and a fuzz target.
Fixtures are synthetic — built in-test through
cfb, the waydetect.rsbuilds its OLE fixtures — since I have no corpus of real messages I can commit. I would treat that as the main gap in this PR: it is validated against the specification and against messages I generated, not against a pile of real Outlook output.cargo test --all-features(306 passed),cargo fmt --all --check,cargo clippy --workspace --all-targets --all-features -- -D warnings, andcargo clippy -p anydoc-wasm --target wasm32-unknown-unknown -- -D warningsall clean.Registered in
Node, Python and wasm binding enums,
node/cli.jsFORMATS,node/index.d.ts, the Python type stub, the README format table, andfuzz/.Note this adds a
Formatvariant, which is a breaking change for exhaustive matches — #154 proposes#[non_exhaustive]for exactly that reason.Open questions
Datebe included?PR_CLIENT_SUBMIT_TIMEis a FILETIME in__properties_version1.0rather than a string stream, so it needs that stream's fixed-length section parsed. Straightforward to add — I left it out rather than guess at whether you want it..emldecision.Summary by cubic
Adds
.msgOutlook message support for #128: previously OLE-based messages were unsupported, and they now convert to Markdown through the existingcfbreader. The converter maps the subject to a heading, the sender and displayed recipients to labelled metadata, andPR_BODYto prose; messages with only HTML or compressed RTF remain unsupported.New Features
msgin the Rust, Node.js, Python, WebAssembly, CLI, README, and fuzzing surfaces.Format::Msg, so downstream Rust code with exhaustive matches must handle the new variant.Bug Fixes
__properties_version1.0instead of a string stream, so ANSI properties decode correctly.Written for commit d2a18fe. Summary will update on new commits.