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;
});
}