@@ -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 ? \n C o n t e n t - / 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 / (?: < h t m l | < ! d o c t y p e | < s t y l e | C o n t e n t - T y p e : | C o n t e n t - T r a n s f e r - E n c o d i n g : | = D 0 | = D 1 | = 3 D | & l t ; h t m l | & l t ; s t y l e ) / i. test ( sample ) ;
386+ }
387+
381388function 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