From 7772da8463ba5447f784d5be616c6bdb0eddca5d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:00:21 +0100 Subject: [PATCH] fix: leave bounce and receipt report parts out of the list's attachment chips --- src/lib/__tests__/list-report-parts.test.ts | 28 +++++++++++++++++++++ src/lib/attachment-display.ts | 18 ++++++++++--- src/lib/list-attachments.ts | 6 +++-- 3 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 src/lib/__tests__/list-report-parts.test.ts diff --git a/src/lib/__tests__/list-report-parts.test.ts b/src/lib/__tests__/list-report-parts.test.ts new file mode 100644 index 00000000..301d4f34 --- /dev/null +++ b/src/lib/__tests__/list-report-parts.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { isReportPart } from '../attachment-display'; +import { realAttachments } from '../list-attachments'; +import type { Attachment } from '../../api/types'; + +// A bounce's delivery status and a read receipt's disposition showed as +// attachment chips in the list (the reader already hid them), and the RFC +// 6533 `global-` forms counted as real files everywhere (webmail 8a09ba573). + +const part = (type: string, name: string): Attachment => ({ blobId: `b-${name}`, type, name, size: 1 } as Attachment); + +describe('report parts in list chips', () => { + it('counts the global- forms as report parts', () => { + expect(isReportPart('message/global-delivery-status')).toBe(true); + expect(isReportPart('message/global-disposition-notification')).toBe(true); + expect(isReportPart('Message/Delivery-Status; charset=utf-8')).toBe(true); + expect(isReportPart('message/rfc822')).toBe(false); + }); + + it('leaves report parts out of the list chips', () => { + const parts = [ + part('message/delivery-status', 'details.txt'), + part('message/global-disposition-notification', 'receipt'), + part('application/pdf', 'invoice.pdf'), + ]; + expect(realAttachments(parts).map((a) => a.name)).toEqual(['invoice.pdf']); + }); +}); diff --git a/src/lib/attachment-display.ts b/src/lib/attachment-display.ts index 6031c94f..124329dd 100644 --- a/src/lib/attachment-display.ts +++ b/src/lib/attachment-display.ts @@ -56,10 +56,22 @@ export function isCalendarMimeType(type?: string | null): boolean { return CAL_MIME.has(type.split(';')[0].trim().toLowerCase()); } -/** Machine-readable report parts (MDN receipts, DSN bounces) - never real attachments. */ +/** + * Machine-readable report parts: the delivery status of a bounce (RFC 3464), + * the disposition of a read receipt (RFC 8098) and their RFC 6533 UTF-8 + * forms. A multipart/report needs them, but they are not files anyone + * attached, so no view lists them and a forward leaves them out (webmail + * lib/attachment-visibility.ts). + */ +const REPORT_PART_TYPES: ReadonlySet = new Set([ + 'message/delivery-status', + 'message/disposition-notification', + 'message/global-delivery-status', + 'message/global-disposition-notification', +]); + export function isReportPart(type?: string | null): boolean { - const base = (type || '').split(';')[0].trim().toLowerCase(); - return base === 'message/disposition-notification' || base === 'message/delivery-status'; + return REPORT_PART_TYPES.has((type || '').split(';')[0].trim().toLowerCase()); } export function isRfc822Attachment(att: Pick): boolean { diff --git a/src/lib/list-attachments.ts b/src/lib/list-attachments.ts index 675cc145..aa24b102 100644 --- a/src/lib/list-attachments.ts +++ b/src/lib/list-attachments.ts @@ -1,4 +1,5 @@ import type { Attachment, Email } from '../api/types'; +import { isReportPart } from './attachment-display'; /** * Attachment chips for message-list rows (webmail 0e1c34cd, #1089). @@ -14,11 +15,12 @@ import type { Attachment, Email } from '../api/types'; /** * The parts the sender actually attached. Anything marked inline or * referenced by a Content-ID (signature logos, spacers, tracking pixels) is - * dropped, as are parts without a filename, which can't be labelled or saved. + * dropped, as are parts without a filename, which can't be labelled or saved, + * and the report parts of a bounce or a read receipt. */ export function realAttachments(attachments?: readonly Attachment[] | null): Attachment[] { if (!attachments?.length) return []; - return attachments.filter((a) => a.disposition !== 'inline' && !a.cid && !!a.name); + return attachments.filter((a) => a.disposition !== 'inline' && !a.cid && !!a.name && !isReportPart(a.type)); } /**