From 2a40668e291f191fb155b810694762eea911a741 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:08:56 +0100 Subject: [PATCH] fix: show pictures apple mail sends inline as attachments #1478 --- lib/__tests__/body-media-attachments.test.ts | 106 +++++++++++++++++++ lib/__tests__/list-attachments.test.ts | 6 +- lib/jmap/body-media-attachments.ts | 54 ++++++++++ lib/jmap/client.ts | 12 ++- 4 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 lib/__tests__/body-media-attachments.test.ts create mode 100644 lib/jmap/body-media-attachments.ts diff --git a/lib/__tests__/body-media-attachments.test.ts b/lib/__tests__/body-media-attachments.test.ts new file mode 100644 index 000000000..611dd8052 --- /dev/null +++ b/lib/__tests__/body-media-attachments.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { addBodyMediaToAttachments } from '../jmap/body-media-attachments'; +import { JMAPClient } from '../jmap/client'; +import type { EmailBodyPart } from '../jmap/types'; + +// The part lists Stalwart 1.0.1 returned for Apple Mail messages (#1478). +const part = (partId: string, type: string, extra: Partial = {}): EmailBodyPart => + ({ partId, blobId: `blob-${partId}`, size: 100, type, ...extra }); + +const photo = part('2', 'image/webp', { disposition: 'inline', name: 'photo.webp' }); + +/** Plain text: mixed { text, image, text }. The picture is only in the body lists. */ +function applePlain() { + const body = [part('1', 'text/plain'), photo, part('3', 'text/plain')]; + return { textBody: body, htmlBody: [...body], attachments: [] as EmailBodyPart[] }; +} + +/** Rich text: alternative { text, mixed { html, image, html } }. Stalwart lists the picture as an attachment too. */ +function appleHtml() { + const jpg = part('4', 'image/jpeg', { disposition: 'inline', name: 'photo.jpg' }); + return { + textBody: [part('1', 'text/plain')], + htmlBody: [part('3', 'text/html'), jpg, part('5', 'text/html')], + attachments: [jpg], + }; +} + +describe('addBodyMediaToAttachments (#1478)', () => { + it('lists a picture that only sits between the text parts, once', () => { + const email = addBodyMediaToAttachments(applePlain()); + expect(email.attachments).toEqual([ + { partId: '2', blobId: 'blob-2', size: 100, type: 'image/webp', name: 'photo.webp', disposition: 'inline' }, + ]); + }); + + it('leaves a picture that is already an attachment alone', () => { + const email = addBodyMediaToAttachments(appleHtml()); + expect(email.attachments.map((a) => a.partId)).toEqual(['4']); + }); + + it('adds audio and video too, but never text or other parts', () => { + const email = addBodyMediaToAttachments({ + textBody: [part('1', 'text/plain'), part('2', 'audio/mpeg'), part('3', 'video/mp4'), part('4', 'text/calendar'), part('5', 'application/pdf')], + htmlBody: [], + attachments: [part('9', 'application/zip')], + }); + expect(email.attachments.map((a) => a.partId)).toEqual(['9', '2', '3']); + }); + + it('keeps a Content-ID only when the HTML links to it', () => { + const linked = part('2', 'image/png', { disposition: 'inline', cid: '' }); + const unlinked = part('3', 'image/png', { disposition: 'inline', cid: 'b@x' }); + const email = addBodyMediaToAttachments({ + textBody: [], + htmlBody: [part('1', 'text/html'), linked, unlinked], + attachments: [] as EmailBodyPart[], + bodyValues: { '1': { value: '' } }, + }); + expect(email.attachments.map((a) => [a.partId, a.cid])).toEqual([['2', ''], ['3', undefined]]); + }); + + it('copes with missing lists', () => { + expect(addBodyMediaToAttachments({} as { attachments?: EmailBodyPart[] }).attachments).toBeUndefined(); + }); +}); + +describe('JMAPClient lists Apple Mail pictures as attachments (#1478)', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); }); + afterEach(() => { fetchSpy.mockRestore(); }); + + const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); + + async function client(): Promise { + fetchSpy.mockResolvedValueOnce(json({ + capabilities: { 'urn:ietf:params:jmap:core': {} }, + accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' }, + apiUrl: 'https://mail.example.com/jmap/api', + downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}', + uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/', + eventSourceUrl: 'https://mail.example.com/jmap/eventsource', + })); + const c = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123'); + await c.connect(); + fetchSpy.mockReset(); + return c; + } + + it('getEmail', async () => { + const c = await client(); + fetchSpy.mockResolvedValueOnce(json({ methodResponses: [['Email/get', { list: [{ id: 'm1', ...applePlain(), bodyValues: {} }] }, '0']] })); + const email = await c.getEmail('m1'); + expect(email?.attachments?.map((a) => a.name)).toEqual(['photo.webp']); + }); + + it('getEmailAttachments asks for the body lists and returns the picture', async () => { + const c = await client(); + fetchSpy.mockResolvedValueOnce(json({ methodResponses: [['Email/get', { list: [{ id: 'm1', ...applePlain() }] }, '0']] })); + const map = await c.getEmailAttachments(['m1']); + const sent = JSON.parse(String((fetchSpy.mock.calls[0][1] as RequestInit).body)); + expect(sent.methodCalls[0][1].properties).toEqual(expect.arrayContaining(['attachments', 'textBody', 'htmlBody'])); + expect(map.get('m1')?.map((a) => a.name)).toEqual(['photo.webp']); + }); +}); diff --git a/lib/__tests__/list-attachments.test.ts b/lib/__tests__/list-attachments.test.ts index 6825d48de..df42cf1b0 100644 --- a/lib/__tests__/list-attachments.test.ts +++ b/lib/__tests__/list-attachments.test.ts @@ -45,11 +45,13 @@ describe('list requests', () => { }); describe('getEmailAttachments', () => { - it('asks only for attachment parts, in server-sized batches', async () => { + it('asks only for part lists, in server-sized batches', async () => { const { client, gets } = recordingClient(2); const found = await client.getEmailAttachments(['a', 'b', 'c'], 'owner'); expect(gets.map(g => g.ids)).toEqual([['a', 'b'], ['c']]); - expect(gets[0]).toMatchObject({ accountId: 'owner', properties: ['id', 'attachments'] }); + expect(gets[0]).toMatchObject({ accountId: 'owner', properties: ['id', 'attachments', 'textBody', 'htmlBody'] }); + expect(gets[0].fetchTextBodyValues).toBeFalsy(); + expect(gets[0].fetchHTMLBodyValues).toBeFalsy(); expect(gets[0].bodyProperties).toEqual(expect.arrayContaining(['partId', 'blobId', 'name', 'type', 'cid', 'disposition'])); expect(found.get('c')).toEqual([pdf('b-c')]); }); diff --git a/lib/jmap/body-media-attachments.ts b/lib/jmap/body-media-attachments.ts new file mode 100644 index 000000000..d0754be7c --- /dev/null +++ b/lib/jmap/body-media-attachments.ts @@ -0,0 +1,54 @@ +import { collectReferencedCids, normalizeCid } from '@/lib/attachment-visibility'; +import type { Attachment, EmailBodyPart } from './types'; + +const INLINE_MEDIA = /^(image|audio|video)\//i; + +interface EmailParts { + textBody?: EmailBodyPart[] | null; + htmlBody?: EmailBodyPart[] | null; + attachments?: Attachment[] | null; + bodyValues?: Record | null; +} + +/** + * Lists inline images, audio and video from the body as attachments. + * + * Apple Mail sends a picture as an inline part between text parts of a + * multipart/mixed. RFC 8621 (section 4.1.4) puts such a part into textBody + * and htmlBody and leaves it out of `attachments` when both body lists can + * show it. Stalwart 0.16 listed it as an attachment as well; 1.0 follows the + * RFC. The viewers render only the first text part of a body, so the picture + * showed nowhere (#1478). Each such part is added once, after the real + * attachments, in body order. + * + * Its Content-ID is kept only when the HTML body links to it: the viewers + * hide declared inline images that have one, and an unlinked part would be + * hidden from the one place it shows. + * + * Changes `email` in place and returns it. + */ +export function addBodyMediaToAttachments(email: T): T { + const known = new Set(); + for (const att of email.attachments ?? []) known.add(att.partId || att.blobId); + const htmlPartId = email.htmlBody?.[0]?.partId; + const linked = collectReferencedCids(htmlPartId ? email.bodyValues?.[htmlPartId]?.value : null); + const added: Attachment[] = []; + for (const part of [...(email.textBody ?? []), ...(email.htmlBody ?? [])]) { + if (!part || !INLINE_MEDIA.test(part.type || '')) continue; + const key = part.partId || part.blobId; + if (!key || known.has(key)) continue; + known.add(key); + added.push({ + partId: part.partId, + blobId: part.blobId, + size: part.size, + type: part.type, + ...(part.name ? { name: part.name } : {}), + ...(part.charset ? { charset: part.charset } : {}), + ...(part.cid && linked.has(normalizeCid(part.cid)) ? { cid: part.cid } : {}), + ...(part.disposition ? { disposition: part.disposition } : {}), + }); + } + if (added.length) email.attachments = [...(email.attachments ?? []), ...added]; + return email; +} diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index ae878398b..54e40b8fc 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -46,6 +46,7 @@ import { toStorageQuota, type JMAPQuota, type StorageQuota } from "@/lib/quota"; import { messagePriorityHeaders, type MessagePriority } from "@/lib/message-priority"; import { customHeaderProperties } from "@/lib/outgoing/custom-headers"; import { attachmentParts, HTML_PART_ID, relatedBodyStructure, TEXT_PART_ID, type OutgoingBodyPart } from "./outgoing-body"; +import { addBodyMediaToAttachments } from "./body-media-attachments"; // Cap for the follow-up Email/get issued when a displayed body part comes // back truncated at the normal 256000-byte limit (see refetchTruncatedBodyValues @@ -1649,15 +1650,17 @@ export class JMAPClient implements IJMAPClient { ["Email/get", { accountId: targetAccountId, ids: batchIds, - properties: ["id", "attachments"], + // The body lists come from the same parse as attachments and cost + // nothing more; they hold Apple Mail's inline pictures (#1478). + properties: ["id", "attachments", "textBody", "htmlBody"], bodyProperties: [...LIST_ATTACHMENT_BODY_PROPERTIES], }, "0"], ]); if (response.methodResponses?.[0]?.[0] !== "Email/get") { throw new Error("Email/get for list attachments failed"); } - const list = (response.methodResponses[0][1]?.list || []) as Pick[]; - for (const email of list) result.set(email.id, email.attachments ?? []); + const list = (response.methodResponses[0][1]?.list || []) as Pick[]; + for (const email of list) result.set(email.id, addBodyMediaToAttachments(email).attachments ?? []); } return result; } @@ -2988,6 +2991,7 @@ export class JMAPClient implements IJMAPClient { } await this.refetchTruncatedBodyValues([email], targetAccountId); + addBodyMediaToAttachments(email); if (email.headers) { await this.parseEmailHeaders(email); @@ -4470,6 +4474,7 @@ export class JMAPClient implements IJMAPClient { // to avoid an N+1 request pattern on threads with several oversized // messages (#884). await this.refetchTruncatedBodyValues(emails, targetAccountId); + for (const email of emails) addBodyMediaToAttachments(email); if (accountId && accountId !== this.accountId) { namespaceMailboxIds(emails, accountId); @@ -11591,6 +11596,7 @@ export class JMAPClient implements IJMAPClient { ]); const fetchedEmails = (emailResponse.methodResponses?.[0]?.[1]?.list ?? []) as Email[]; await this.refetchTruncatedBodyValues(fetchedEmails, accountId); + for (const email of fetchedEmails) addBodyMediaToAttachments(email); emailsByAccount.set(accountId, new Map(fetchedEmails.map(email => [email.id, email]))); } const emails = pageSubmissions