diff --git a/lib/__tests__/outgoing-inline-images.test.ts b/lib/__tests__/outgoing-inline-images.test.ts new file mode 100644 index 000000000..3ddcd3468 --- /dev/null +++ b/lib/__tests__/outgoing-inline-images.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { JMAPClient } from '../jmap/client'; +import { relatedBodyStructure } from '../jmap/outgoing-body'; + +/** + * #1451: given textBody/htmlBody/attachments, Stalwart puts a pasted image + * (inline, with a Content-ID) into the outer multipart/mixed, so recipients + * and our own Sent view don't show it. When the HTML shows an image by cid: + * the message is created with an explicit bodyStructure that keeps the image + * in a multipart/related beside the HTML part. + */ + +const IMAGE = { blobId: 'blob-img', name: 'image.png', type: 'image/png', size: 10, disposition: 'inline' as const, cid: 'img1@webmail' }; +const PDF = { blobId: 'blob-pdf', name: 'report.pdf', type: 'application/pdf', size: 20 }; +const HTML = '

Hello

'; + +const IMAGE_PART = { blobId: 'blob-img', name: 'image.png', type: 'image/png', disposition: 'inline', cid: 'img1@webmail' }; +const PDF_PART = { blobId: 'blob-pdf', name: 'report.pdf', type: 'application/pdf', disposition: 'attachment' }; +const ALTERNATIVE = { + type: 'multipart/alternative', + subParts: [ + { partId: 'text', type: 'text/plain' }, + { type: 'multipart/related', subParts: [{ partId: 'html', type: 'text/html' }, IMAGE_PART] }, + ], +}; + +describe('relatedBodyStructure', () => { + it('puts a referenced inline image into multipart/related beside the HTML', () => { + expect(relatedBodyStructure(HTML, [IMAGE])).toEqual(ALTERNATIVE); + }); + + it('wraps the body in multipart/mixed when there are regular attachments too', () => { + expect(relatedBodyStructure(HTML, [PDF, IMAGE])).toEqual({ + type: 'multipart/mixed', + subParts: [ALTERNATIVE, PDF_PART], + }); + }); + + it('matches a cid given in angle brackets or another case', () => { + const structure = relatedBodyStructure('', [{ ...IMAGE, cid: '' }]); + expect(structure?.type).toBe('multipart/alternative'); + }); + + it('leaves the structure to the server when nothing is shown by cid', () => { + expect(relatedBodyStructure(undefined, [IMAGE])).toBeNull(); + expect(relatedBodyStructure('

no images

', [IMAGE, PDF])).toBeNull(); + expect(relatedBodyStructure(HTML, [PDF])).toBeNull(); + expect(relatedBodyStructure(HTML, [{ ...IMAGE, disposition: 'attachment' }])).toBeNull(); + }); + + it('keeps an inline part the HTML does not reference with the attachments', () => { + const unused = { ...IMAGE, blobId: 'blob-unused', cid: 'unused@webmail' }; + const structure = relatedBodyStructure(HTML, [IMAGE, unused]); + expect(structure?.type).toBe('multipart/mixed'); + expect(structure?.subParts?.[1]).toMatchObject({ blobId: 'blob-unused', cid: 'unused@webmail' }); + }); +}); + +function createClient(): JMAPClient { + const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass'); + Object.assign(client, { + apiUrl: 'https://jmap.example.com/api', + accountId: 'account-1', + username: 'user@example.com', + }); + return client; +} + +type Call = [string, Record, string]; + +function mockServer(): Array> { + const creates: Array> = []; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { + const body = JSON.parse((init as { body: string }).body) as { methodCalls: Call[] }; + const methodResponses: unknown[] = []; + for (const [method, args, callId] of body.methodCalls) { + if (method === 'Mailbox/get') { + methodResponses.push([method, { list: [{ id: 'mb-drafts', role: 'drafts' }, { id: 'mb-sent', role: 'sent' }] }, callId]); + } else if (method === 'Identity/get') { + methodResponses.push([method, { list: [{ id: 'identity-1', email: 'user@example.com' }] }, callId]); + } else if (method === 'Email/set') { + const create = args.create as Record> | undefined; + const key = create ? Object.keys(create)[0] : undefined; + if (key && create) creates.push(create[key]); + methodResponses.push([method, key ? { created: { [key]: { id: 'email-1' } } } : {}, callId]); + } else if (method === 'EmailSubmission/set') { + methodResponses.push([method, { created: { '1': { id: 'sub-1' } } }, callId]); + } + } + const payload = { methodResponses }; + return { + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify(payload)), + json: () => Promise.resolve(payload), + } as Response; + }); + return creates; +} + +describe('pasted images in drafts and sent mail (#1451)', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('createDraft keeps a pasted image inside the HTML body', async () => { + const creates = mockServer(); + await createClient().createDraft( + ['bob@example.com'], 'Subject', 'Hello', + undefined, undefined, undefined, undefined, undefined, + [PDF, IMAGE], undefined, HTML, + ); + + expect(creates[0].bodyStructure).toEqual({ type: 'multipart/mixed', subParts: [ALTERNATIVE, PDF_PART] }); + expect(creates[0].bodyValues).toEqual({ text: { value: 'Hello' }, html: { value: HTML } }); + // bodyStructure excludes the convenience properties (RFC 8621 §4.6). + expect(creates[0]).not.toHaveProperty('textBody'); + expect(creates[0]).not.toHaveProperty('htmlBody'); + expect(creates[0]).not.toHaveProperty('attachments'); + }); + + it('sendEmail keeps a pasted image inside the HTML body', async () => { + const creates = mockServer(); + await createClient().sendEmail( + ['bob@example.com'], 'Subject', 'Hello', + undefined, undefined, 'identity-1', 'user@example.com', undefined, undefined, HTML, + [IMAGE], + ); + + expect(creates[0].bodyStructure).toEqual(ALTERNATIVE); + expect(creates[0].bodyValues).toEqual({ text: { value: 'Hello' }, html: { value: HTML } }); + expect(creates[0]).not.toHaveProperty('textBody'); + expect(creates[0]).not.toHaveProperty('htmlBody'); + expect(creates[0]).not.toHaveProperty('attachments'); + }); + + it('sendEmail without cid images still lets the server build the structure', async () => { + const creates = mockServer(); + await createClient().sendEmail( + ['bob@example.com'], 'Subject', 'Hello', + undefined, undefined, 'identity-1', 'user@example.com', undefined, undefined, '

Hello

', + [PDF], + ); + + expect(creates[0]).not.toHaveProperty('bodyStructure'); + expect(creates[0].htmlBody).toEqual([{ partId: 'html', type: 'text/html' }]); + expect(creates[0].attachments).toEqual([PDF_PART]); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 02e562a71..d691da4cb 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -44,6 +44,7 @@ import { parseAuthenticationResults } from "@/lib/email-headers"; import { IS_LITE_STALWART } from "@/lib/lite"; import { toStorageQuota, type JMAPQuota, type StorageQuota } from "@/lib/quota"; import { messagePriorityHeaders, type MessagePriority } from "@/lib/message-priority"; +import { attachmentParts, HTML_PART_ID, relatedBodyStructure, TEXT_PART_ID, type OutgoingBodyPart } from "./outgoing-body"; // Cap for the follow-up Email/get issued when a displayed body part comes // back truncated at the normal 256000-byte limit (see refetchTruncatedBodyValues @@ -4703,9 +4704,10 @@ export class JMAPClient implements IJMAPClient { keywords: Record; mailboxIds: Record; bodyValues: Record; - textBody: { partId: string; type?: string }[]; + textBody?: { partId: string; type?: string }[]; htmlBody?: { partId: string; type: string }[]; - attachments?: { blobId: string; type: string; name: string; disposition: string; cid?: string }[]; + attachments?: OutgoingBodyPart[]; + bodyStructure?: OutgoingBodyPart; inReplyTo?: string[]; references?: string[]; [header: `header:${string}`]: string; @@ -4740,14 +4742,15 @@ export class JMAPClient implements IJMAPClient { ...messagePriorityHeaders(options?.priority), }; - if (attachments?.length) { - emailData.attachments = attachments.map(att => ({ - blobId: att.blobId, - type: att.type, - name: att.name, - disposition: att.disposition ?? "attachment", - ...(att.cid ? { cid: att.cid } : {}), - })); + // Images the HTML shows by cid: go into a multipart/related beside it + // (#1451), so the draft and the mail sent from it display them. + const draftStructure = relatedBodyStructure(htmlBody, attachments); + if (draftStructure) { + delete emailData.textBody; + delete emailData.htmlBody; + emailData.bodyStructure = draftStructure; + } else if (attachments?.length) { + emailData.attachments = attachmentParts(attachments); } // Destroy the previous draft version only AFTER the replacement was @@ -4941,27 +4944,32 @@ export class JMAPClient implements IJMAPClient { Object.assign(emailCreate, messagePriorityHeaders(options?.priority)); - if (htmlBody) { - // Send as multipart/alternative with both text and HTML + // Images the HTML shows by cid: go into a multipart/related beside it, + // or recipients don't see them (#1451). + const sendStructure = relatedBodyStructure(htmlBody, attachments); + if (sendStructure) { emailCreate.bodyValues = { - "text": { value: body }, - "html": { value: htmlBody }, + [TEXT_PART_ID]: { value: body }, + [HTML_PART_ID]: { value: htmlBody }, }; - emailCreate.textBody = [{ partId: "text", type: "text/plain" }]; - emailCreate.htmlBody = [{ partId: "html", type: "text/html" }]; + emailCreate.bodyStructure = sendStructure; } else { - emailCreate.bodyValues = { "1": { value: body } }; - emailCreate.textBody = [{ partId: "1", type: "text/plain" }]; - } + if (htmlBody) { + // Send as multipart/alternative with both text and HTML + emailCreate.bodyValues = { + "text": { value: body }, + "html": { value: htmlBody }, + }; + emailCreate.textBody = [{ partId: "text", type: "text/plain" }]; + emailCreate.htmlBody = [{ partId: "html", type: "text/html" }]; + } else { + emailCreate.bodyValues = { "1": { value: body } }; + emailCreate.textBody = [{ partId: "1", type: "text/plain" }]; + } - if (attachments?.length) { - emailCreate.attachments = attachments.map(att => ({ - blobId: att.blobId, - type: att.type, - name: att.name, - disposition: att.disposition ?? "attachment", - ...(att.cid ? { cid: att.cid } : {}), - })); + if (attachments?.length) { + emailCreate.attachments = attachmentParts(attachments); + } } const methodCalls: JMAPMethodCall[] = []; diff --git a/lib/jmap/outgoing-body.ts b/lib/jmap/outgoing-body.ts new file mode 100644 index 000000000..ff7b7944d --- /dev/null +++ b/lib/jmap/outgoing-body.ts @@ -0,0 +1,96 @@ +import { normalizeCid } from '@/lib/attachment-visibility'; + +/** An attachment handed to createDraft / sendEmail. */ +export interface OutgoingAttachment { + blobId: string; + name: string; + type: string; + size?: number; + disposition?: 'attachment' | 'inline'; + cid?: string; +} + +/** An Email/set bodyStructure part (RFC 8621 §4.1.4, EmailBodyPart). */ +export interface OutgoingBodyPart { + type: string; + partId?: string; + blobId?: string; + name?: string; + disposition?: string; + cid?: string; + subParts?: OutgoingBodyPart[]; +} + +/** The part ids createDraft and sendEmail give the text and HTML bodyValues. */ +export const TEXT_PART_ID = 'text'; +export const HTML_PART_ID = 'html'; + +function attachmentPart(att: OutgoingAttachment): OutgoingBodyPart { + return { + blobId: att.blobId, + type: att.type, + name: att.name, + disposition: att.disposition ?? 'attachment', + ...(att.cid ? { cid: att.cid } : {}), + }; +} + +/** The `attachments` list for an Email/set create built from textBody/htmlBody. */ +export function attachmentParts(attachments: OutgoingAttachment[]): OutgoingBodyPart[] { + return attachments.map(attachmentPart); +} + +function referencedByHtml(html: string, cid: string): boolean { + const ref = `cid:${normalizeCid(cid)}`.toLowerCase(); + const lower = html.toLowerCase(); + return lower.includes(ref) || lower.includes(encodeURI(ref)); +} + +/** + * The MIME tree for an HTML message whose body shows images by `cid:` + * reference, or null when it shows none. + * + * Given textBody, htmlBody and attachments, the server decides where each + * part goes, and Stalwart puts every attachment, inline ones included, in + * the outer multipart/mixed. Recipients then don't find the images the HTML + * points to, and Stalwart 1.0 doesn't even list them as attachments, so our + * own Sent view can't show them either (#1451). Images belong in a + * multipart/related next to the HTML part (RFC 2387): + * + * multipart/mixed (only with regular attachments) + * multipart/alternative + * text/plain + * multipart/related + * text/html + * image/png (inline, Content-ID) + * attachment + * + * The text parts refer to the `text` and `html` bodyValues. + */ +export function relatedBodyStructure( + htmlBody: string | undefined, + attachments: OutgoingAttachment[] | undefined, +): OutgoingBodyPart | null { + if (!htmlBody || !attachments?.length) return null; + const isRelated = (att: OutgoingAttachment) => + !!att.cid && att.disposition !== 'attachment' && referencedByHtml(htmlBody, att.cid); + const related = attachments.filter(isRelated); + if (related.length === 0) return null; + const others = attachments.filter((att) => !isRelated(att)); + + const alternative: OutgoingBodyPart = { + type: 'multipart/alternative', + subParts: [ + { partId: TEXT_PART_ID, type: 'text/plain' }, + { + type: 'multipart/related', + subParts: [ + { partId: HTML_PART_ID, type: 'text/html' }, + ...related.map((att) => attachmentPart({ ...att, disposition: 'inline' })), + ], + }, + ], + }; + if (others.length === 0) return alternative; + return { type: 'multipart/mixed', subParts: [alternative, ...others.map(attachmentPart)] }; +}