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)] };
+}