Question
Split out of #259, which bundled this with a detection change under one "envelope format 2". They are two different formats at two different layers, with different readers and different rollout gates, and separating them is what let the detection half ship without a flag day.
This is the layer where the double-base64 actually lives.
The measurement
#259 blamed the hidden postguard-armor body div. That is stale: it was deleted in pg-js 1.1 (packages/pg-js/src/email/envelope.ts:67-70 records why — it pushed bodies past Outlook's 1 000 000-char setAsync limit). The overhead that remains is structural:
- pg-js base64s user attachments into the inner MIME —
packages/pg-js/src/email/mime.ts:85-94, arrayBufferToBase64 then Content-Transfer-Encoding: base64 with 76-column CRLF wrapping.
- The result is encrypted (streaming AEAD, expansion <0.02%).
- The host mail client base64s the binary ciphertext again as the outer attachment's CTE. pg-js does not write this and cannot avoid it — the payload is binary.
Measured for a 1 MB original attachment: inner base64 1 368 424 → ciphertext ~1 370 578 → outer base64 1 875 532. That is ≈1.87× actual (1.78× theoretical, the rest is line breaks) against ≈1.37× for a single pass. A single-pass inner format cuts ~28% off every attachment-bearing encrypted mail.
Why the size matters
- The tier thresholds exist because of a delivery limit:
packages/pg-js/src/email/extract.ts:7-12 picks 10 MB as "comfortably below typical 25 MB Exchange tenant message-size limits". At 1.87×, a 13.4 MB attachment already exceeds 25 MB on the wire; at 1.37× that headroom goes to ~18.8 MB.
apps/tb-addon/src/background/encryption-flow.ts:39-41 hardcodes a 5 MiB cap for SMTP relays that "refuse mail over 5 MB". At 1.87× that corresponds to ≈2.8 MB of user attachment; at 1.37×, ≈3.8 MB — a 36% increase in what a Thunderbird user can send before hitting #219, which is live data loss in exactly that band.
What has to be decided
- What replaces base64 in the inner MIME.
Content-Transfer-Encoding: binary is MIME-legal, but the inner message is re-parsed by real parsers, so "legal" is not the bar — see the blast radius below. The alternative is abandoning MIME for the inner container entirely.
- The blast radius, confirmed before building. The inner message is read by
packages/pg-js/src/email/parse.ts:151-187, re-imported as a real message by Thunderbird (apps/tb-addon/src/background/background.ts:658-662, browser.messages.import), and parsed by postal-mime on the website (apps/website/src/lib/components/fallback/email.js:15-19). All three must accept whatever replaces base64.
- The version field, which must not be
X-PostGuard. The inner MIME already writes X-PostGuard: 0.1 (packages/pg-js/src/email/mime.ts:72), unread by anything. That name is doing four jobs across two layers — the apps write x-postguard: 0.1.0 on the outer message, tb-addon writes x-postguard: "decrypted" (a state) at background.ts:653, and cryptify writes the pg-core crate version on notification mail — and every consumer tests presence only, so all four pass. postguard-js#159 owns what the outer name means and is itself undecided. Claim a distinct name rather than a contested one; conflating layers under one name is what #259 had to be untangled from.
- Note the inner MIME also emits no CTE at all for body parts (
mime.ts:78-80, mime.ts:97-99) while carrying raw UTF-8 — implicitly 7bit. It never crosses SMTP, but it is parsed by real MUAs, so decide whether this change fixes that too.
Rollout
Write-side and reader-visible after decryption, so @e4a/pg-js 1.x's parse.ts is the constraint. COMPATIBILITY.md keeps 1.x in the window until telemetry shows no 1.x traffic, which is postguard-ops#64 and is not running. Readers first: read support ships a release ahead of the write flip.
Part of #247 (workstream G).
Question
Split out of #259, which bundled this with a detection change under one "envelope format 2". They are two different formats at two different layers, with different readers and different rollout gates, and separating them is what let the detection half ship without a flag day.
This is the layer where the double-base64 actually lives.
The measurement
#259blamed the hiddenpostguard-armorbody div. That is stale: it was deleted in pg-js 1.1 (packages/pg-js/src/email/envelope.ts:67-70records why — it pushed bodies past Outlook's 1 000 000-charsetAsynclimit). The overhead that remains is structural:packages/pg-js/src/email/mime.ts:85-94,arrayBufferToBase64thenContent-Transfer-Encoding: base64with 76-column CRLF wrapping.Measured for a 1 MB original attachment: inner base64 1 368 424 → ciphertext ~1 370 578 → outer base64 1 875 532. That is ≈1.87× actual (1.78× theoretical, the rest is line breaks) against ≈1.37× for a single pass. A single-pass inner format cuts ~28% off every attachment-bearing encrypted mail.
Why the size matters
packages/pg-js/src/email/extract.ts:7-12picks 10 MB as "comfortably below typical 25 MB Exchange tenant message-size limits". At 1.87×, a 13.4 MB attachment already exceeds 25 MB on the wire; at 1.37× that headroom goes to ~18.8 MB.apps/tb-addon/src/background/encryption-flow.ts:39-41hardcodes a 5 MiB cap for SMTP relays that "refuse mail over 5 MB". At 1.87× that corresponds to ≈2.8 MB of user attachment; at 1.37×, ≈3.8 MB — a 36% increase in what a Thunderbird user can send before hitting #219, which is live data loss in exactly that band.What has to be decided
Content-Transfer-Encoding: binaryis MIME-legal, but the inner message is re-parsed by real parsers, so "legal" is not the bar — see the blast radius below. The alternative is abandoning MIME for the inner container entirely.packages/pg-js/src/email/parse.ts:151-187, re-imported as a real message by Thunderbird (apps/tb-addon/src/background/background.ts:658-662,browser.messages.import), and parsed bypostal-mimeon the website (apps/website/src/lib/components/fallback/email.js:15-19). All three must accept whatever replaces base64.X-PostGuard. The inner MIME already writesX-PostGuard: 0.1(packages/pg-js/src/email/mime.ts:72), unread by anything. That name is doing four jobs across two layers — the apps writex-postguard: 0.1.0on the outer message, tb-addon writesx-postguard: "decrypted"(a state) atbackground.ts:653, and cryptify writes the pg-core crate version on notification mail — and every consumer tests presence only, so all four pass. postguard-js#159 owns what the outer name means and is itself undecided. Claim a distinct name rather than a contested one; conflating layers under one name is what#259had to be untangled from.mime.ts:78-80,mime.ts:97-99) while carrying raw UTF-8 — implicitly 7bit. It never crosses SMTP, but it is parsed by real MUAs, so decide whether this change fixes that too.Rollout
Write-side and reader-visible after decryption, so
@e4a/pg-js1.x'sparse.tsis the constraint.COMPATIBILITY.mdkeeps 1.x in the window until telemetry shows no 1.x traffic, which is postguard-ops#64 and is not running. Readers first: read support ships a release ahead of the write flip.Part of #247 (workstream G).