fix(email): honour part charset and skip binary parts in list snippets - #385
Merged
Hydralerne merged 1 commit intoAug 2, 2026
Merged
Conversation
`extractListSnippet` (532753a) decodes the bounded BODY[TEXT] partial fetch with a fixed utf8 decode, on whatever leaf `resolveLeafPart` lands on, and unescapes six hard-coded HTML entities. Three consequences, all visible in the inbox list rows: 1. The MIME charset is dropped. `resolveLeafPart` returned only `{type, encoding}` even though imapflow parses `parameters.charset`, so a `text/plain; charset=ISO-8859-1` quoted-printable part rendered as "la reuni<U+FFFD>n de ma<U+FFFD>ana" in the list while `getThread`, which goes through charset-aware mailparser, rendered the same message correctly as "la reunión de mañana". 2. Non-text leaves are decoded. `resolveLeafPart` descends `childNodes[0]` unconditionally, so an S/MIME `application/pkcs7-mime` message emitted raw DER, a scan-to-email `multipart/mixed` emitted "%PDF-1.7 ...", a PGP/MIME message emitted "Version: 1", and an inline-image-first `multipart/related` emitted raw JPEG bytes - despite the function's own contract of degrading to ''. 3. Only ` & < > " &oblien#39; '` were decoded, so the numeric and typographic references that dominate real marketing and transactional HTML survived literally ("We’re excited —"). `&` was also replaced before `<`/`>`, so `&lt;` double-unescaped to `<`. Fixes: - `LeafPartInfo` carries the lowercased `parameters.charset`, and `decodeCharset` routes the bytes through `TextDecoder`. Labels TextDecoder rejects throw and fall back to today's utf8 decode, so the change only widens what decodes correctly. The ASCII labels stay on the utf8 path deliberately: WHATWG resolves `us-ascii` to windows-1252, which would turn "Cafés" into "Cafés" for the common part that declares ASCII but carries UTF-8 bytes. - `extractListSnippet` returns '' when the resolved leaf is not `text/*`. A null leaf keeps the current behaviour so an unparseable structure is not newly suppressed. - `decodeHtmlEntities` resolves decimal, hexadecimal and a table of named references in a single left-to-right pass, which also removes the double-unescape. `TextDecoder` is imported from `node:util` because bun-types narrows the global constructor to `"utf-8" | "windows-1252" | "utf-16"`; the `node:util` export takes a `string`. Runtime behaviour is identical. One measured behaviour change beyond the three above: a `multipart/mixed` whose first part is `message/rfc822` previously leaked the forwarded message's headers into the row ("From: Ops Subject: Nightly backup report Content-Type: text/plain; charset=utf-8 All volumes backed up successfully.") and now yields ''. The two consumers of `snippet` render nothing for an empty value rather than substituting anything - `mail-list.tsx:506` is `snippet ? highlight(snippet) : null` and `command-palette-context.tsx:991` is `snippet || ''` - so the snippet line goes blank. The subject is rendered separately and unconditionally at `mail-list.tsx:488`, so the row keeps its subject either way. `extractListSnippet` is exported for the test, following `formatFromAddress` in `trpc/routes/mail.ts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
extractListSnippetinapps/email/server/src/lib/imap-driver.ts(added by 532753a, 2026-07-31) decodes the boundedBODY[TEXT]partial fetch with a fixed utf8 decode, on whatever leafresolveLeafPartlands on, and unescapes six hard-coded HTML entities. That produces mojibake on non-UTF-8 mail, raw binary in inbox rows on S/MIME / PGP / attachment-first mail, and literal’/—in HTML mail.Motivation
The clearest statement of the first bug is that the same message renders two different ways in the same UI. A
text/plain; charset=ISO-8859-1quoted-printable part:extractListSnippetHola Bob, la reuni�n de ma�ana se traslada a las diez. Un saludo.getThread→simpleParser(imap-driver.ts:936onmain)Hola Bob, la reunión de mañana se traslada a las diez. Un saludo.getThreadgets it right because mailparser reads the part'scharset. The list path throws that information away:resolveLeafPartreturns only{ type, encoding }, even though imapflow already parsesparameters.charsetfor every leaf (imapflow/lib/tools.jsgetStructuredParams, keys lowercased, values preserved).Full
mainvs branch differential, produced by running both versions ofextractListSnippetside by side on the same inputs (script in the Verification section):mainsnippettext/plain; charset=ISO-8859-1, quoted-printableHola Bob, la reuni�n de ma�ana se traslada a las diez. Un saludo.Hola Bob, la reunión de mañana se traslada a las diez. Un saludo.text/plain; charset=windows-1252, base64Your order shipped � �track it� for updates.Your order shipped — “track it” for updates.application/pkcs7-mime(S/MIME), base64 DER0�\u0003 \u0006 *�H�� \u0001\u0007\u0002��\u0002�0�\u0002�\u0002\u0001\u00011\u000f0multipart/mixed,application/pdffirst (scan-to-email)%PDF-1.7 %���� 1 0 obj endobjmultipart/encrypted,application/pgp-encryptedfirstVersion: 1multipart/related,image/jpegfirst (inline logo)����\u0000\u0010JFIF\u0000\u0001\u0001\u0001\u0000H\u0000H\u0000\u0000��\u0000C\u0000\b\u0006\u0006\u0007\u0006text/html, numeric + named referencesWe’re excited — your order’s on its way. Save 20% today & tomorrow. © 2026 AcmeWe’re excited — your order’s on its way. Save 20% today & tomorrow. © 2026 Acmetext/html, already-escaped&lt;script&gt;Escaped tag: <script> stays escaped.Escaped tag: <script> stays escaped.multipart/mixed,message/rfc822first (forwarded)From: Ops Subject: Nightly backup report Content-Type: text/plain; charset=utf-8 All volumes backed up successfully.text/plain; charset=utf-8(control)Standing meeting moved to Thursday at ten.Standing meeting moved to Thursday at ten.10 cases, 9 differ, 1 identical. Cell values are the
JSON.stringify'd return value, so\u00XXsequences are literal control bytes landing in the inbox row and�is U+FFFD.The three causes:
imap-driver.ts:691onmainisdecoded.toString('utf8'), andresolveLeafPart(:606) never carried the charset to that call site.resolveLeafPartdescendschildNodes[0]unconditionally, andextractListSnippetnever checks that the leaf istext/*before base64-decoding it. The function's own JSDoc says it "always degrades to''"; these four cases are where it doesn't.:703-708handles only & < > " ' '.&is replaced before</>, so&lt;double-unescapes to<.Related issue
None.
Changes
apps/email/server/src/lib/imap-driver.ts:LeafPartInfogainscharset, populated from the leaf'sparameters.charset(trimmed, lowercased). NewdecodeCharsethelper decodes viaTextDecoder; labelsTextDecoderrejects throw and fall through to today's utf8 decode, so this can only widen what decodes correctly.us-ascii/asciideliberately stay on the utf8 path. WHATWG resolves both labels to windows-1252 (new TextDecoder('us-ascii').encoding === 'windows-1252'on bun 1.3.14 and node v26.4.0 alike), so routing them throughTextDecoderwould turn the very common part that declares ASCII but carries UTF-8 bytes fromCafés — closing at 5.intoCafés — closing at 5.— a fresh bug introduced by the fix. A test pins this.extractListSnippetreturns''when the resolved leaf is nottext/*. A null leaf keeps the current behaviour, so a body structure that can't be parsed is not newly suppressed.decodeHtmlEntities: decimal, hexadecimal and a 24-entry named table, resolved in one left-to-right pass. The single pass is what removes the double-unescape — after&is consumed, scanning resumes past its;. Unrecognised references are left verbatim.TextDecoderis imported fromnode:utilbecause bun-types narrows the global constructor toBun.Encoding = "utf-8" | "windows-1252" | "utf-16"; without the importtsc --noEmitinapps/email/serverfails witherror TS2345: Argument of type 'string' is not assignable to parameter of type 'Encoding | undefined'. Thenode:utilexport takes astringand is the same class at runtime. There is precedent fornode:imports in this workspace (src/env.ts,src/lib/crypto.ts,src/lib/branding.ts).extractListSnippetis exported so the test can call it, followingformatFromAddressinsrc/trpc/routes/mail.ts, which is exported for exactly the same reason.apps/email/server/test/list-snippet.test.ts(new, 13 tests,bun:testto match the existingtest/from-header.test.ts).Verification
Please note where this does and does not run in CI.
apps/emaildefines notestscript, soturbo run testskips it:That is already true of
test/from-header.test.tsonmain, so this PR follows the existing convention rather than changing the build — but it does mean the new tests will not turn the CI Test job red or green. They run withbun testfromapps/email/server.I deliberately did not touch
apps/email/package.json, because #219 is already proposing to wire this workspace up — it adds"test": "cd server && bun run test"toapps/email/package.jsonand"test": "vitest run"toapps/email/server/package.json. If #219 lands first I'll swap this file'sbun:testimports forvitest(test/from-header.test.tsonmainwill need the same). Happy to add the script here instead if you'd rather not wait.New tests, run locally (bun 1.3.14):
I verified the tests fail without the fix. With all three behaviour changes reverted in place (keeping only the
exportand thenode:utilimport, so the module still loads), 9 of 13 fail:Reverting each of the three independently isolates them cleanly: charset only → 2 fail; non-text guard only → 4 fail; entity decoding only → 3 fail.
The remaining 4 tests pass on
mainas well, so I want to be explicit that they are boundary assertions rather than regression tests. Each one constrains a real decision in this diff, and I proved that by mutating the fix and watching the test go red:us-ascii/asciifromUTF8_COMPATIBLE_CHARSETStry/catchindecodeCharsetif (leaf && …)→if (!leaf || …)HTML_ENTITIES[…] ?? match→?? ''Full suite, forced (no turbo cache), on this branch:
I ran the identical command on pristine
main@2d7fa591(also--force, 0 cached) and got the same seven lines, somainis fully green and there is no pre-existing failure to discount here.Typecheck. CI's Typecheck job has two steps (
.github/workflows/ci.yml:31-45),apps/apiandapps/dashboard;apps/email/serveris not typechecked in CI, so I ran it separately:Formatting:
imap-driver.tscarries pre-existing prettier drift onmain— 465 lines differ under the repo's own.prettierrc(npx prettier --checkonmainalready reports it as unformatted). Per CONTRIBUTING I did not run the formatter over it; my lines are hand-formatted to the file's own style (single quotes, ≤100 cols). The new test file is prettier-clean (npx prettier --checkpasses). This is the one honest qualification on thebun formatchecklist item below.The differential table above was produced by extracting
extractListSnippetand its helpers fromorigin/mainand from this branch into two modules and calling both on the same fixtures, in theapps/email/serverworkspace so the realimapflow(1.3.3) andmailparser(3.9.8) resolve. The reading-pane row was produced by feeding the identical RFC822 message tosimpleParserand taking(parsed.text ?? '').slice(0, 240)— whatgetThreaddoes atimap-driver.ts:936onmain(:1021on this branch). For this fixture that value carries no trailing whitespace, so the cell is verbatim; a message with a blank line before the closing boundary would return a trailing\n. I can push the script if you want it in the repo.Residuals and trade-offs — things I did not fix
message/rfc822first parts now yield an empty snippet. This is the one behaviour change beyond the three bugs, and it is a direct consequence of thetext/*guard. Measured above:mainleaksFrom: Ops Subject: Nightly backup report Content-Type: …into the row, the branch yields''.To be precise about what an empty snippet does in the UI, since this is the riskiest thing in the PR — there are exactly two consumers and neither substitutes anything:
mail-list.tsx:506—{latestMessage.snippet ? highlightText(…) : null}, so the<p>renders empty and the snippet line goes blank.command-palette-context.tsx:991—{thread.snippet || ''}, so the palette row showsSender -with nothing after the dash.The subject is unaffected: it is rendered in its own
<p>atmail-list.tsx:488, independent of the snippet. So the row goes from subject + header soup to subject + blank line, not to a subject fallback. (In the Sent folder the snippet slot is occupied by the recipient list at:498, so nothing changes there at all.)The retained JSDoc on
extractListSnippetsays "the client falls back to the subject line when snippet is empty" — that is upstream's own wording from 532753a and it does not match the client code. I left it untouched as out of scope; happy to correct it here if you want.I think blank is better than header soup, but the right answer is probably to descend into
message/rfc822the waymultipart/is descended (depth + 1lands correctly, since the inner message contributes exactly one header block). I left it out to keep this to one concern. Say the word and I'll add it here or in a follow-up.The named-entity table is a fixed 24 entries, not full HTML5 coverage.
entities@4.5.0is already inapps/email/server/node_modulesas a transitive dep ofcheerio, sodecodeHTMLfrom it would be complete and shorter — but that means promoting it to a declared dependency, which I did not want to do unasked. Happy to switch if you prefer that.Charset conversion still happens after truncation, not before. The
BODY[TEXT]window is 320 bytes and the cut can land mid-character;TextDecoderyields U+FFFD there, and the existing.replace(/�+$/, '')strips it at the tail. Unchanged frommain, just now reachable for more charsets.Bun's
TextDecodercovers fewer labels than Node's, so the win is partial under the runtime this server actually uses (apps/email/serverstarts withbun run src/main.ts). I measured both, decoding real bytes rather than just constructing the decoder — bun 1.3.14 vs node v26.4.0:iso-8859-1Hola óñHola óñwindows-1252—“”—“”koi8-uПриПриshift_jis日本日本euc-kr한국한국big5/gbk中文中文iso-8859-2Złóiso-8859-15€windows-1250/1251/1256ŚŁó/При/الkoi8-rПриx-mac-romanéEvery RangeError row falls back to
bytes.toString('utf8'), which is byte-for-byte whatmainproduces today — so nothing regresses, but Central/Eastern European, Cyrillic (koi8-r,windows-1251) and Arabic mail is still mojibake on Bun. (koi8-uworking whilekoi8-rthrows is a Bun quirk, not a typo on my part.) If you want those covered too, the options are a small hand-rolled single-byte table oriconv-lite; I'd rather you pick than assume.resolveLeafPartstill followschildNodes[0]only. Amultipart/alternativethat liststext/htmlbeforetext/plainwill use the HTML part, as onmain.Checklist
bun run test,bun run --cwd <workspace> lint, andbun formatall pass locally — with one qualification:bun run testand the typechecks pass as shown above, but I did not runbun format. It would rewrite the 465 pre-existing prettier-drifted lines inimap-driver.tsand bury a +110/-24 fix in ~465 lines of unrelated churn. My own lines are hand-formatted to the file's style; the new test file is prettier-clean. Say the word if you'd rather have the file formatted and I'll do it as a separate commit on this branch so it stays reviewable.