@@ -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 - 9 A - F a - 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 ? \n C o n t e n t - / 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 ( / c h a r s e t = (?: " ( [ ^ " ] + ) " | ( [ ^ ; \s ] + ) ) / i) ?. [ 1 ]
347+ || ct [ 2 ] . match ( / c h a r s e t = (?: " ( [ ^ " ] + ) " | ( [ ^ ; \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 - 9 A - F a - 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
310381function 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