fix: hide body-embedded cid parts typed octet-stream from the attachment chips

Some senders ship the images their HTML body embeds by `cid:` as
`application/octet-stream` with no disposition and no name. The body
rendered them, but the chip filter only hid parts declared inline with
an image type, so they also showed up as nameless "Attachment" chips.

Port the webmail's rule: scan the HTML the body actually renders for
`cid:` references and hide a referenced part when it is an image or
generically typed. An explicit attachment disposition keeps its chip,
as does a referenced part with a real non-image type. A plain-text
render references nothing, so nothing extra is hidden there.
This commit is contained in:
Linus Rath
2026-09-24 18:50:55 +02:00
parent d45c7996d2
commit 1d1d6a5bfc
2 changed files with 96 additions and 10 deletions
@@ -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: '<p>Thanks</p><img src="cid:signaturImage">' } },
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: '<div>cid:signaturImage</div>' }, '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: '<logoImage>', 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', () => {
+41 -10
View File
@@ -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<string, string> = {
'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<Attachment, 'cid' | 'type' | 'disposition'>,
referencedCids: ReadonlySet<string>,
): 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<Email, 'attachments'>, opts: VisibleAttachmentOptions): Attachment[] {
export function visibleAttachments(
email: Pick<Email, 'attachments' | 'htmlBody' | 'textBody' | 'bodyValues'>,
opts: VisibleAttachmentOptions,
): Attachment[] {
const all = email.attachments ?? [];
// Scan the HTML the body actually renders, and only when a part could hide.
const bodyCids: ReadonlySet<string> = 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;
});
}