mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
fix: show pictures apple mail sends inline as attachments #1478
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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')]);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user