fix: show pictures apple mail sends inline as attachments #1478

This commit is contained in:
Linus Rath
2026-10-31 22:18:11 +01:00
parent fca08981a1
commit 2a40668e29
4 changed files with 173 additions and 5 deletions
@@ -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> = {}): 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: '<a@x>' });
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: '<img src="cid:a@x">' } },
});
expect(email.attachments.map((a) => [a.partId, a.cid])).toEqual([['2', '<a@x>'], ['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<typeof vi.spyOn>;
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<JMAPClient> {
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']);
});
});
+4 -2
View File
@@ -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')]);
});
+54
View File
@@ -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<string, { value?: string }> | 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<T extends EmailParts>(email: T): T {
const known = new Set<string>();
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;
}
+9 -3
View File
@@ -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<Email, "id" | "attachments">[];
for (const email of list) result.set(email.id, email.attachments ?? []);
const list = (response.methodResponses[0][1]?.list || []) as Pick<Email, "id" | "attachments" | "textBody" | "htmlBody">[];
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