Skip to content

feat(pg-js): one MIME reader, shared by both add-ins - #157

Merged
rubenhensen merged 1 commit into
mainfrom
feat/shared-mime-reader
Jul 30, 2026
Merged

feat(pg-js): one MIME reader, shared by both add-ins#157
rubenhensen merged 1 commit into
mainfrom
feat/shared-mime-reader

Conversation

@rubenhensen

Copy link
Copy Markdown
Contributor

Part of #129 (the second of its three parts). apps/outlook-addon carried 225 lines of hand-rolled multipart parsing and apps/tb-addon carried its own detection, so the two hosts agreed on the shape they exchange only by coincidence. Both now read the envelope through the SDK.

Lands on top of the envelope-compat gate (#153) deliberately: the gate is live for every commit here, which is the whole point of that ordering.

Moved into pg-js

parseDecryptedMime, readMimeHeader, bodyFromMime, isMultipart, detectPostGuard, extractArmoredCiphertext, looksLikeArmoredPostGuard, POSTGUARD_ENCRYPTED_FILENAME, plus the ParsedMessage / ParsedAttachment / DetectPostGuardInput types.

Additive only — the API report diff is 44 insertions, no removals, so the changeset is minor.

The armor asymmetry, resolved rather than deleted

extract.ts said body-armor extraction was "no longer supported" while Outlook still shipped extractArmoredCiphertext and used it as a live fallback in tryFindCiphertext. Both were right about different things: the comment described the emitter (pg-js >= 1.1 stopped writing armor — the change behind postguard-tb-addon#85) but stated it as a property of the reader.

COMPATIBILITY.md's archival guarantee is unconditional — "read support for stored artifacts is not part of this window; it never drops" — so deleting Outlook's copy would have been the tb#85 change again in the other direction. Armor reading is promoted into the SDK as an explicitly archival export instead, with a comment saying not to add a caller that produces it.

Related: EmailHelpers.extractCiphertext's JSDoc claimed it read "attachment or armored body". It has only ever read the attachment. Corrected.

Two bugs the move surfaced

Quoted-printable was decoded one character per octet. decodeQuotedPrintable mapped each =XX through String.fromCharCode, so caf=C3=A9 came back as café — every accented character in a quoted-printable body was mojibake. The byte path then UTF-8-encoded that string, doubling every octet above 0x7f and silently corrupting quoted-printable attachments. Octets are now collected and decoded once, and both halves are pinned by tests:

expect(parseDecryptedMime(mime).plainBody).toContain("café")
expect(Array.from(att.data)).toEqual([0x00, 0xc3, 0xa9, 0xff])

Caught by the round-trip test, not by reading the code — it was moved verbatim first, and the test failed.

postguard.encrypted was a literal in five places, including one in tb-addon independent of the SDK's own. One exported constant now, used by the emitter and every reader.

What stayed host-specific

  • guessContentType is Office.js glue: it exists because Office.js hands back an attachment name and no Content-Type. No other host has that gap.
  • detection.ts keeps its WebExtension calls, and still checks attachments before pulling the body — folding both into one detectPostGuard call would fetch the full message every time, and would turn a getFull failure on a message that does carry the attachment into "not encrypted".

Tests

The moved parser had no tests in the add-on, which is part of how the two copies were free to drift. 25 added, including a buildMimeparseDecryptedMime round-trip that holds the two halves of the format together, nested-multipart recursion, and a corrupt-base64 case asserting the readable part of a message survives an unreadable attachment.

pnpm -r typecheck, build and test green: 330 pg-js (was 305), 39 outlook, 212 tb-addon, 20 website. Envelope gate green, API report matches.

Still open in #129

Part 3, the x-postguard semantics. Four producers disagree and detection is a bare presence check, so no test can arbitrate it until the meaning is decided — untouched here.

apps/outlook-addon carried 225 lines of hand-rolled multipart parsing and
apps/tb-addon carried its own detection, so the two hosts agreed on the shape
they exchange only by coincidence. Both now read the envelope through the SDK.

Moved into packages/pg-js and exported: parseDecryptedMime, readMimeHeader,
bodyFromMime, isMultipart, detectPostGuard, extractArmoredCiphertext,
looksLikeArmoredPostGuard, POSTGUARD_ENCRYPTED_FILENAME, and the ParsedMessage
/ ParsedAttachment / DetectPostGuardInput types. Additive: the API report diff
is 44 insertions and no removals.

The armor asymmetry is resolved deliberately rather than by deleting it.
extract.ts said body-armor extraction was "no longer supported" while Outlook
still shipped extractArmoredCiphertext and used it as a live fallback in
tryFindCiphertext. The comment described the EMITTER — pg-js >= 1.1 stopped
writing armor, which is what broke tb#85 — but stated it as a property of the
reader. COMPATIBILITY.md's archival guarantee is unconditional, so armor
reading is promoted into the SDK as an explicitly archival export instead. The
JSDoc on EmailHelpers.extractCiphertext claimed "attachment or armored body",
which the implementation has never done; corrected.

Two bugs surfaced by the move:

- Quoted-printable decoded every `=XX` through String.fromCharCode, one
  character per octet, so `caf=C3=A9` read back as `café`. Any accented
  character in a quoted-printable body was mojibake. The byte path then
  UTF-8-encoded that string, doubling every octet above 0x7f and silently
  corrupting quoted-printable attachments. Octets are now collected and decoded
  once, and both halves are pinned by tests.
- tb-addon hardcoded "postguard.encrypted" separately from the SDK's own
  literal. It is one exported constant now, used by the emitter and every
  reader.

What stayed host-specific: guessContentType is Office.js glue (it exists
because Office.js hands back a name and no Content-Type), and detection.ts
keeps its WebExtension calls, checking attachments before pulling the body so a
tier-1/2 message is still decided without a getFull.

The moved parser arrived with no tests — it had none in the add-on, which is
part of why the two copies could drift. 25 added, including a buildMime →
parseDecryptedMime round-trip that holds the two halves of the format together.

pnpm -r typecheck, build and test green; 330 pg-js, 39 outlook, 212 tb-addon,
20 website. Envelope gate green.

Part of #129.
@dobby-coder
dobby-coder Bot requested a review from Iso5786 July 30, 2026 17:15
@rubenhensen
rubenhensen merged commit 5fd30b7 into main Jul 30, 2026
30 checks passed
@dobby-coder
dobby-coder Bot deleted the feat/shared-mime-reader branch August 2, 2026 20:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant