Skip to content

Commit 36dd4f2

Browse files
committed
fix: narrow IMAP MIME body repair
1 parent 353383c commit 36dd4f2

3 files changed

Lines changed: 27 additions & 43 deletions

File tree

backend/src/routes/mail.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const archiver = require('archiver');
55
import { query } from '../services/db.js';
66
import { requireAuth } from '../middleware/auth.js';
77
import { imapManager } from '../index.js';
8-
import { sanitizeEmail, stripEmailHead, hasRemoteImages, blockRemoteImages, rewriteEbayImageserUrls, rewriteAnchorHrefs, repairResidualQuotedPrintableHtml } from '../services/emailSanitizer.js';
8+
import { sanitizeEmail, stripEmailHead, hasRemoteImages, blockRemoteImages, rewriteEbayImageserUrls, rewriteAnchorHrefs } from '../services/emailSanitizer.js';
99
import { snippetFromBody, decodeMimeWords, parseRawHeaders, buildHeadersFromMessage } from '../services/messageParser.js';
1010
import { resolveTrashFolder, resolveAllTrashPaths, resolveAllDraftsPaths, resolveArchiveFolder, isAllMailFolder, resolveSpamFolder, resolveAllSpamPaths, getDeleteStrategy, adjustFolderCounts, fanOutReadToSiblings, fanOutStarToSiblings, fanOutBulkReadToSiblings } from '../utils/mailUtils.js';
1111
import { emitGtdIfRelevant } from '../services/gtdSections.js';
@@ -353,7 +353,7 @@ router.get('/messages/:id/body', async (req, res) => {
353353
: [];
354354
// Apply head-stripping to already-cached HTML so emails stored before this
355355
// fix was deployed are cleaned up immediately on first view.
356-
let html = message.body_html ? repairResidualQuotedPrintableHtml(stripEmailHead(message.body_html)) : null;
356+
let html = message.body_html ? stripEmailHead(message.body_html) : null;
357357
if (html !== message.body_html) {
358358
// Update cache so subsequent views don't need to re-strip
359359
query('UPDATE messages SET body_html = $1 WHERE id = $2', [sanitizeDbText(html), id]).catch(() => {});
@@ -411,7 +411,7 @@ router.get('/messages/:id/body', async (req, res) => {
411411
BODY_FETCH_TIMEOUT_MS
412412
);
413413

414-
const safeHtml = html ? sanitizeDbText(sanitizeEmail(repairResidualQuotedPrintableHtml(html))) : null;
414+
const safeHtml = html ? sanitizeDbText(sanitizeEmail(html)) : null;
415415
const safeText = sanitizeDbText(text);
416416
const snip = sanitizeDbText(snippetFromBody(safeText, safeHtml || html));
417417

backend/src/services/emailSanitizer.js

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -25,32 +25,6 @@ export function stripEmailHead(html) {
2525
}
2626

2727

28-
// Best-effort repair for bodies that were cached/fetched with leftover
29-
// quoted-printable fragments inside otherwise valid HTML. This happens with
30-
// malformed multipart messages where an IMAP BODY part contains an embedded
31-
// MIME subpart. Decode only explicit uppercase =HH runs so normal text like
32-
// "name=value" or email domains such as "example.=com" are not damaged.
33-
export function repairResidualQuotedPrintableHtml(html) {
34-
if (!html || !/(?:=[0-9A-F]{2}|==[0-9A-F]{2}|Content-Transfer-Encoding|@[^\s<]+\.=[A-Za-z]{2,}|src=["']data:image\/[^"']*&lt;)/.test(html)) return html;
35-
let out = String(html)
36-
.replace(/^--[^\r\n]+\r?\nContent-Type:[\s\S]*?\r?\n\r?\n/i, '')
37-
.replace(/=(?==[0-9A-F]{2})/g, '');
38-
39-
out = out.replace(/(?:=[0-9A-F]{2})+/g, (run) => {
40-
const bytes = [];
41-
for (let i = 0; i < run.length; i += 3) bytes.push(parseInt(run.slice(i + 1, i + 3), 16));
42-
try { return new TextDecoder('utf-8', { fatal: false }).decode(Uint8Array.from(bytes)); }
43-
catch { return run; }
44-
});
45-
46-
return out
47-
.replace(/<img\b[^>]*src=["']data:image\/[^"']*&lt;[^"']*["'][^>]*>/gi, '')
48-
.replace(/&lt;=([A-Za-z/])/g, '&lt;$1')
49-
.replace(/\bp=adding\b/g, 'padding')
50-
.replace(/@([A-Za-z0-9.-]+)\.=([A-Za-z]{2,})\b/g, '@$1.$2')
51-
.replace(/@([A-Za-z0-9.-]+)\.com\b/g, '@$1.com');
52-
}
53-
5428
function upgradeUrl(url) {
5529
return typeof url === 'string' && url.startsWith('http://') ? 'https://' + url.slice(7) : url;
5630
}

backend/src/services/imapManager.js

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,8 @@ function parseMimeHeaders(headerBlock) {
318318
// part is requested: the payload starts with a MIME boundary and embedded
319319
// Content-Type/Content-Transfer-Encoding headers. If passed to the sanitizer as
320320
// HTML, users see boundary lines and quoted-printable garbage (=D0=..., =3D).
321-
function unwrapEmbeddedMimeText(decoded) {
321+
function unwrapEmbeddedMimeText(decoded, depth = 0) {
322+
if (depth >= 5) return decoded;
322323
const start = String(decoded || '').trimStart();
323324
if (!/^--[^\r\n]+\r?\nContent-/i.test(start)) return decoded;
324325

@@ -352,7 +353,7 @@ function unwrapEmbeddedMimeText(decoded) {
352353
});
353354
}
354355
const best = candidates.find(p => p.type === 'text/html') || candidates.find(p => p.type === 'text/plain');
355-
return best ? unwrapEmbeddedMimeText(best.text) : decoded;
356+
return best ? unwrapEmbeddedMimeText(best.text, depth + 1) : decoded;
356357
}
357358

358359
// Decode a MIME body part from its raw Buffer.
@@ -378,6 +379,12 @@ function decodeBody(buf, encoding, charset) {
378379
return unwrapEmbeddedMimeText(decodeBytes(rawBytes, charset));
379380
}
380381

382+
function looksLikeTextPayload(buf) {
383+
if (!buf || buf.length === 0) return false;
384+
const sample = Buffer.isBuffer(buf) ? buf.subarray(0, 512).toString('ascii') : String(buf).slice(0, 512);
385+
return /(?:<html|<!doctype|<style|Content-Type:|Content-Transfer-Encoding:|=D0|=D1|=3D|&lt;html|&lt;style)/i.test(sample);
386+
}
387+
381388
function decodeAttachmentBuffer(buf, encoding) {
382389
const enc = (encoding || '').toLowerCase();
383390
if (enc === 'base64') {
@@ -3974,12 +3981,13 @@ export class ImapManager {
39743981
}
39753982

39763983
// Per-part individual fetch for text parts. Some IMAP servers return a
3977-
// whole multipart wrapper for speculative/batched sibling requests while
3978-
// BODY[2.1] alone is correct; accepting the batched value leaks MIME
3979-
// boundaries and quoted-printable fragments into the UI. Do this even
3980-
// when speculative fetch already returned the part, so the direct result
3981-
// overwrites any malformed batched value. Inline images keep the batched
3982-
// value because they are binary and are not parsed as HTML.
3984+
// non-empty but malformed text payload for speculative/batched sibling
3985+
// requests while BODY[2.1] alone is correct; accepting the batched value
3986+
// leaks MIME boundaries and quoted-printable fragments into the UI. Do
3987+
// this even when speculative fetch already returned the part, so the
3988+
// direct text result overwrites any malformed batched value. Inline
3989+
// images keep the batched value because they are binary and are not
3990+
// parsed as HTML.
39833991
for (const part of results.textParts) {
39843992
try {
39853993
for await (const msg of client.fetch(uidStr, { uid: true, bodyParts: [part.part] }, { uid: true })) {
@@ -3989,17 +3997,19 @@ export class ImapManager {
39893997
} catch { /* don't let a single part failure block others */ }
39903998
}
39913999

3992-
// Per-part individual fetch for inline images too. Some servers can also
3993-
// return the wrong sibling payload for BODY[2.2] in a multi-part batch;
3994-
// if that HTML fragment is used as the CID image, it becomes a broken
3995-
// data:image URL and leaks escaped HTML into the rendered message.
4000+
// Inline images normally keep the batched value for performance. Retry
4001+
// only the suspicious ones: some servers return a text/html sibling for
4002+
// an image part in a multi-part batch, producing data:image URLs that
4003+
// contain escaped HTML/QP text and leak quoted-message garbage.
39964004
for (const part of inlineImages) {
4005+
const existing = prefetched.get(part.part);
4006+
if (!looksLikeTextPayload(existing)) continue;
39974007
try {
39984008
for await (const msg of client.fetch(uidStr, { uid: true, bodyParts: [part.part] }, { uid: true })) {
39994009
const v = msg.bodyParts?.get(part.part);
40004010
if (v && v.length > 0) prefetched.set(part.part, v);
40014011
}
4002-
} catch { /* don't let a single part failure block others */ }
4012+
} catch { /* keep the batched value if the direct retry fails */ }
40034013
}
40044014

40054015
for (const part of results.textParts) {
@@ -4016,7 +4026,7 @@ export class ImapManager {
40164026
for (const img of inlineImages) {
40174027
if (!img.cid) continue;
40184028
const buf = prefetched.get(img.part);
4019-
if (!buf) continue;
4029+
if (!buf || looksLikeTextPayload(buf)) continue;
40204030
const enc = (img.encoding || '').toLowerCase();
40214031
const b64 = enc === 'base64'
40224032
? buf.toString('ascii').replace(/\s/g, '')

0 commit comments

Comments
 (0)