Skip to content

Commit 353383c

Browse files
committed
fix: fetch IMAP body parts individually to avoid malformed MIME
1 parent 6f62050 commit 353383c

3 files changed

Lines changed: 159 additions & 49 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 } from '../services/emailSanitizer.js';
8+
import { sanitizeEmail, stripEmailHead, hasRemoteImages, blockRemoteImages, rewriteEbayImageserUrls, rewriteAnchorHrefs, repairResidualQuotedPrintableHtml } 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 ? stripEmailHead(message.body_html) : null;
356+
let html = message.body_html ? repairResidualQuotedPrintableHtml(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(html)) : null;
414+
const safeHtml = html ? sanitizeDbText(sanitizeEmail(repairResidualQuotedPrintableHtml(html))) : null;
415415
const safeText = sanitizeDbText(text);
416416
const snip = sanitizeDbText(snippetFromBody(safeText, safeHtml || html));
417417

backend/src/services/emailSanitizer.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,33 @@ export function stripEmailHead(html) {
2424
});
2525
}
2626

27+
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+
2754
function upgradeUrl(url) {
2855
return typeof url === 'string' && url.startsWith('http://') ? 'https://' + url.slice(7) : url;
2956
}

backend/src/services/imapManager.js

Lines changed: 129 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -263,48 +263,119 @@ function extractBodyFromMsg(msg) {
263263
// Key invariant: we work with Buffers of raw bytes until the very last step so
264264
// that multi-byte sequences (e.g. =E2=80=94 → em-dash in UTF-8) are reassembled
265265
// correctly before being interpreted as any character set.
266-
function decodeBody(buf, encoding, charset) {
267-
const enc = (encoding || '').toLowerCase();
268-
// Normalise charset — TextDecoder knows aliases like 'latin-1', but strip quotes
269-
// that some mailers wrap around the value (charset="utf-8").
266+
function decodeQuotedPrintableToBuffer(input) {
267+
const qpStr = Buffer.isBuffer(input) ? input.toString('ascii') : String(input || '');
268+
const cleaned = qpStr.replace(/=\r\n/g, '').replace(/=\n/g, '');
269+
const bytes = [];
270+
let i = 0;
271+
while (i < cleaned.length) {
272+
if (cleaned[i] === '=' && i + 2 < cleaned.length) {
273+
const hex = cleaned.slice(i + 1, i + 3);
274+
if (/^[0-9A-Fa-f]{2}$/.test(hex)) {
275+
bytes.push(parseInt(hex, 16));
276+
i += 3;
277+
continue;
278+
}
279+
}
280+
bytes.push(cleaned.charCodeAt(i) & 0xFF);
281+
i++;
282+
}
283+
return Buffer.from(bytes);
284+
}
285+
286+
function decodeBytes(rawBytes, charset) {
270287
let cs = (charset || 'utf-8').toLowerCase().trim().replace(/^['"]|['"]$/g, '');
271288
if (!cs || cs === 'us-ascii' || cs === 'ascii') cs = 'utf-8'; // ASCII ⊂ UTF-8
289+
try {
290+
return new TextDecoder(cs, { fatal: false }).decode(rawBytes);
291+
} catch {
292+
return rawBytes.toString('utf8'); // unknown charset — best effort
293+
}
294+
}
295+
296+
function decodeTransferPayload(payload, encoding, charset) {
297+
const enc = (encoding || '').toLowerCase();
298+
if (enc === 'base64') {
299+
const b64 = String(payload || '').replace(/\s/g, '');
300+
try { return decodeBytes(Buffer.from(b64, 'base64'), charset); } catch { /* fall through */ }
301+
}
302+
if (enc === 'quoted-printable') {
303+
return decodeBytes(decodeQuotedPrintableToBuffer(payload), charset);
304+
}
305+
return decodeBytes(Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload || ''), 'utf8'), charset);
306+
}
272307

308+
function parseMimeHeaders(headerBlock) {
309+
const headers = {};
310+
for (const line of headerBlock.replace(/\r?\n[ \t]+/g, ' ').split(/\r?\n/)) {
311+
const m = line.match(/^([^:]+):\s*([\s\S]*)$/);
312+
if (m) headers[m[1].toLowerCase()] = m[2].trim();
313+
}
314+
return headers;
315+
}
316+
317+
// Some broken IMAP servers/messages return a whole multipart fragment when a text
318+
// part is requested: the payload starts with a MIME boundary and embedded
319+
// Content-Type/Content-Transfer-Encoding headers. If passed to the sanitizer as
320+
// HTML, users see boundary lines and quoted-printable garbage (=D0=..., =3D).
321+
function unwrapEmbeddedMimeText(decoded) {
322+
const start = String(decoded || '').trimStart();
323+
if (!/^--[^\r\n]+\r?\nContent-/i.test(start)) return decoded;
324+
325+
const firstLineEnd = start.search(/\r?\n/);
326+
if (firstLineEnd < 0) return decoded;
327+
const marker = start.slice(0, firstLineEnd).trim();
328+
const boundary = marker.replace(/^--/, '');
329+
if (!boundary) return decoded;
330+
331+
const escapedBoundary = boundary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
332+
const partRe = new RegExp(`(?:^|\\r?\\n)--${escapedBoundary}(?:--)?\\r?\\n?`, 'g');
333+
const candidates = [];
334+
335+
for (const part of start.split(partRe)) {
336+
const trimmed = part.replace(/^\r?\n/, '');
337+
const sep = trimmed.search(/\r?\n\r?\n/);
338+
if (sep < 0) continue;
339+
const headerBlock = trimmed.slice(0, sep);
340+
const payload = trimmed.slice(sep + (trimmed.slice(sep).startsWith('\r\n\r\n') ? 4 : 2));
341+
const headers = parseMimeHeaders(headerBlock);
342+
const ct = headers['content-type']?.match(/^([^;]+)([\s\S]*)$/);
343+
if (!ct) continue;
344+
const type = ct[1].toLowerCase().trim();
345+
if (type !== 'text/html' && type !== 'text/plain') continue;
346+
const charset = ct[2].match(/charset=(?:"([^"]+)"|([^;\s]+))/i)?.[1]
347+
|| ct[2].match(/charset=(?:"([^"]+)"|([^;\s]+))/i)?.[2]
348+
|| 'utf-8';
349+
candidates.push({
350+
type,
351+
text: decodeTransferPayload(payload, headers['content-transfer-encoding'] || '', charset),
352+
});
353+
}
354+
const best = candidates.find(p => p.type === 'text/html') || candidates.find(p => p.type === 'text/plain');
355+
return best ? unwrapEmbeddedMimeText(best.text) : decoded;
356+
}
357+
358+
// Decode a MIME body part from its raw Buffer.
359+
//
360+
// encoding: transfer encoding (quoted-printable, base64, 7bit, 8bit, binary)
361+
// charset: character set from Content-Type (utf-8, windows-1252, iso-8859-1, …)
362+
//
363+
// Key invariant: we work with Buffers of raw bytes until the very last step so
364+
// that multi-byte sequences (e.g. =E2=80=94 → em-dash in UTF-8) are reassembled
365+
// correctly before being interpreted as any character set.
366+
function decodeBody(buf, encoding, charset) {
367+
const enc = (encoding || '').toLowerCase();
273368
let rawBytes;
274369
if (enc === 'base64') {
275-
// base64 payload is 7-bit ASCII so toString('ascii') is safe here
276370
const b64 = (Buffer.isBuffer(buf) ? buf : Buffer.from(buf)).toString('ascii').replace(/\s/g, '');
277371
try { rawBytes = Buffer.from(b64, 'base64'); } catch { rawBytes = buf; }
278372
} else if (enc === 'quoted-printable') {
279-
const qpStr = (Buffer.isBuffer(buf) ? buf : Buffer.from(buf)).toString('ascii');
280-
const cleaned = qpStr.replace(/=\r\n/g, '').replace(/=\n/g, '');
281-
const bytes = [];
282-
let i = 0;
283-
while (i < cleaned.length) {
284-
if (cleaned[i] === '=' && i + 2 < cleaned.length) {
285-
const hex = cleaned.slice(i + 1, i + 3);
286-
if (/^[0-9A-Fa-f]{2}$/.test(hex)) {
287-
bytes.push(parseInt(hex, 16));
288-
i += 3;
289-
continue;
290-
}
291-
}
292-
bytes.push(cleaned.charCodeAt(i) & 0xFF);
293-
i++;
294-
}
295-
rawBytes = Buffer.from(bytes);
373+
rawBytes = decodeQuotedPrintableToBuffer(buf);
296374
} else {
297-
// 7bit / 8bit / binary — the buffer already holds the raw content bytes
298375
rawBytes = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
299376
}
300377

301-
// TextDecoder handles utf-8, iso-8859-*, windows-125*, koi8-r, big5, etc.
302-
// fatal:false replaces unrecognised bytes with U+FFFD rather than throwing.
303-
try {
304-
return new TextDecoder(cs, { fatal: false }).decode(rawBytes);
305-
} catch {
306-
return rawBytes.toString('utf8'); // unknown charset — best effort
307-
}
378+
return unwrapEmbeddedMimeText(decodeBytes(rawBytes, charset));
308379
}
309380

310381
function decodeAttachmentBuffer(buf, encoding) {
@@ -3900,23 +3971,35 @@ export class ImapManager {
39003971
}
39013972
}
39023973
}
3974+
}
39033975

3904-
// Per-part individual retry for any text/image part that came back missing or
3905-
// zero-length from the batched fetch. Some IMAP servers (confirmed on
3906-
// purelymail.com) return a 0-byte literal for non-empty parts when one sibling
3907-
// part in the same FETCH command happens to be empty — the batched
3908-
// BODY[1] BODY[2] response is malformed, but BODY[2] alone works correctly.
3909-
const individualParts = [...results.textParts, ...(results.inlineImages || [])];
3910-
for (const part of individualParts) {
3911-
const existing = prefetched.get(part.part);
3912-
if (existing && existing.length > 0) continue; // already have content
3913-
try {
3914-
for await (const msg of client.fetch(uidStr, { uid: true, bodyParts: [part.part] }, { uid: true })) {
3915-
const v = msg.bodyParts?.get(part.part);
3916-
if (v && v.length > 0) prefetched.set(part.part, v);
3917-
}
3918-
} catch { /* don't let a single part failure block others */ }
3919-
}
3976+
// 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.
3983+
for (const part of results.textParts) {
3984+
try {
3985+
for await (const msg of client.fetch(uidStr, { uid: true, bodyParts: [part.part] }, { uid: true })) {
3986+
const v = msg.bodyParts?.get(part.part);
3987+
if (v && v.length > 0) prefetched.set(part.part, v);
3988+
}
3989+
} catch { /* don't let a single part failure block others */ }
3990+
}
3991+
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.
3996+
for (const part of inlineImages) {
3997+
try {
3998+
for await (const msg of client.fetch(uidStr, { uid: true, bodyParts: [part.part] }, { uid: true })) {
3999+
const v = msg.bodyParts?.get(part.part);
4000+
if (v && v.length > 0) prefetched.set(part.part, v);
4001+
}
4002+
} catch { /* don't let a single part failure block others */ }
39204003
}
39214004

39224005
for (const part of results.textParts) {

0 commit comments

Comments
 (0)