diff --git a/src/lib/__tests__/attachment-display.test.ts b/src/lib/__tests__/attachment-display.test.ts index f56a1460..6653d0fb 100644 --- a/src/lib/__tests__/attachment-display.test.ts +++ b/src/lib/__tests__/attachment-display.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { getAttachmentDisplayName, visibleAttachments, previewKindFor, isReportPart, isRfc822Attachment, + isEmbeddedInBody, } from '../attachment-display'; import { buildForwardAsAttachmentPayload } from '../forward-as-attachment'; import type { Email } from '../../api/types'; @@ -36,6 +37,60 @@ describe('visibleAttachments', () => { const shown = visibleAttachments(email, { hideInlineImageAttachments: false, calendarBannerShown: false }); expect(shown.map((a) => a.blobId)).toEqual(['1', '2', '4', '5', '6']); }); + + // Octet-stream, no disposition, no name, rendered in the body via cid:. + const sloppy = { + htmlBody: [{ partId: '1', type: 'text/html' }], + textBody: [{ partId: '1', type: 'text/html' }], + bodyValues: { '1': { value: '

Thanks

' } }, + attachments: [ + { blobId: 'a', type: 'application/octet-stream', cid: 'signaturImage' }, + { blobId: 'b', type: 'application/octet-stream', name: 'data.bin' }, + { blobId: 'c', type: 'application/octet-stream', cid: 'orphan' }, + ], + }; + + it('hides generically typed parts the rendered HTML body embeds by cid', () => { + const shown = visibleAttachments(sloppy, { hideInlineImageAttachments: true, calendarBannerShown: false }); + expect(shown.map((a) => a.blobId)).toEqual(['b', 'c']); + const all = visibleAttachments(sloppy, { hideInlineImageAttachments: false, calendarBannerShown: false }); + expect(all.map((a) => a.blobId)).toEqual(['a', 'b', 'c']); + }); + + it('hides nothing by reference when the message renders as plain text', () => { + const text = { + ...sloppy, + textBody: [{ partId: '2', type: 'text/plain' }], + // A minimal wrapper around the text alternative renders as text. + bodyValues: { '1': { value: '
cid:signaturImage
' }, '2': { value: 'Thanks' } }, + }; + const shown = visibleAttachments(text, { hideInlineImageAttachments: true, calendarBannerShown: false }); + expect(shown.map((a) => a.blobId)).toEqual(['a', 'b', 'c']); + }); +}); + +describe('isEmbeddedInBody', () => { + const body = new Set(['signaturImage', 'logoImage']); + + it('hides referenced octet-stream or untyped parts, with or without angle brackets', () => { + expect(isEmbeddedInBody({ cid: 'signaturImage', type: 'application/octet-stream' }, body)).toBe(true); + expect(isEmbeddedInBody({ cid: 'logoImage', type: '' }, body)).toBe(true); + expect(isEmbeddedInBody({ cid: '', type: 'application/octet-stream' }, body)).toBe(true); + expect(isEmbeddedInBody({ cid: 'logoImage', type: 'image/png' }, body)).toBe(true); + }); + + it('keeps hiding declared inline images even without a reference', () => { + expect(isEmbeddedInBody({ cid: 'unreferenced', type: 'image/png', disposition: 'inline' }, body)).toBe(true); + }); + + it('keeps explicit attachments, referenced non-image types and unreferenced parts', () => { + expect(isEmbeddedInBody({ cid: 'logoImage', type: 'image/png', disposition: 'attachment' }, body)).toBe(false); + expect(isEmbeddedInBody({ cid: 'logoImage', type: 'application/pdf' }, body)).toBe(false); + expect(isEmbeddedInBody({ cid: 'orphan', type: 'application/octet-stream' }, body)).toBe(false); + expect(isEmbeddedInBody({ cid: 'orphan', type: 'image/png' }, body)).toBe(false); + expect(isEmbeddedInBody({ type: 'image/png', disposition: 'inline' }, body)).toBe(false); + expect(isEmbeddedInBody({ cid: 'logoImage', type: 'application/octet-stream' }, new Set())).toBe(false); + }); }); describe('previewKindFor / classification', () => { diff --git a/src/lib/attachment-display.ts b/src/lib/attachment-display.ts index f8486996..d69bc60d 100644 --- a/src/lib/attachment-display.ts +++ b/src/lib/attachment-display.ts @@ -5,6 +5,8 @@ import type { Attachment, Email } from '../api/types'; import { findCalendarAttachment } from './calendar-invitation'; +import { pickEmailBody, selectRenderableHtml } from './email-body'; +import { extractCidRefs } from './email-html'; const MIME_TYPE_LABELS: Record = { 'application/pdf': 'Document.pdf', @@ -71,6 +73,34 @@ export function isTnefAttachment(name?: string | null, type?: string | null): bo ); } +/** + * True when a part is embedded in the rendered body and stays out of the chip + * list while "hide inline images" is on. Port of the webmail's + * `isEmbeddedInBody` (`lib/attachment-visibility.ts`): + * - declared inline images hide, referenced or not; + * - an explicit `attachment` disposition always keeps its chip; + * - otherwise a part hides only when the body references its Content-ID AND + * it is an image or generically typed (octet-stream, no type). Some senders + * ship the images their HTML embeds that way, with no disposition and no + * name, which showed up as nameless "Attachment" chips. A referenced part + * with a real non-image type keeps its chip, its only download. + * + * `referencedCids` are the body's `cid:` references without angle brackets, + * as {@link extractCidRefs} returns them. + */ +export function isEmbeddedInBody( + att: Pick, + referencedCids: ReadonlySet, +): boolean { + if (!att.cid) return false; + const base = (att.type || '').split(';')[0].trim().toLowerCase(); + const isImage = base.startsWith('image/'); + if (att.disposition === 'inline' && isImage) return true; + if (att.disposition === 'attachment') return false; + if (!isImage && base && base !== 'application/octet-stream') return false; + return referencedCids.has(att.cid.replace(/^<|>$/g, '')); +} + export interface VisibleAttachmentOptions { hideInlineImageAttachments: boolean; /** True when the calendar-invitation banner renders for this message. */ @@ -81,25 +111,26 @@ export interface VisibleAttachmentOptions { /** * The attachments that get a chip. Mirrors the webmail filter chain: - * - inline cid images are hidden only when their disposition really is - * `inline` (an image with a Content-ID but `disposition: attachment` is a - * real attachment the body may never reference); + * - parts the rendered HTML body embeds are hidden (see + * {@link isEmbeddedInBody}); a plain-text render embeds nothing; * - calendar parts are hidden while the invitation banner shows them; * - MDN/DSN report parts are never listed; * - winmail.dat is hidden once its content was extracted. */ -export function visibleAttachments(email: Pick, opts: VisibleAttachmentOptions): Attachment[] { +export function visibleAttachments( + email: Pick, + opts: VisibleAttachmentOptions, +): Attachment[] { const all = email.attachments ?? []; + // Scan the HTML the body actually renders, and only when a part could hide. + const bodyCids: ReadonlySet = opts.hideInlineImageAttachments && all.some((att) => att.cid) + ? new Set(extractCidRefs(selectRenderableHtml(pickEmailBody(email)) ?? '')) + : new Set(); return all.filter((att) => { if (isReportPart(att.type)) return false; if (opts.calendarBannerShown && isCalendarMimeType(att.type)) return false; if (opts.tnefUnpacked && isTnefAttachment(att.name, att.type)) return false; - if ( - opts.hideInlineImageAttachments - && att.cid - && att.disposition === 'inline' - && (att.type || '').toLowerCase().startsWith('image/') - ) return false; + if (opts.hideInlineImageAttachments && isEmbeddedInBody(att, bodyCids)) return false; return true; }); }