mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
fix: keep pasted images inside the message body for recipients #1451
This commit is contained in:
@@ -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 = '<p>Hello <img src="cid:img1@webmail"></p>';
|
||||
|
||||
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('<img src="CID:Img1@Webmail">', [{ ...IMAGE, cid: '<img1@webmail>' }]);
|
||||
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('<p>no images</p>', [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, unknown>, string];
|
||||
|
||||
function mockServer(): Array<Record<string, unknown>> {
|
||||
const creates: Array<Record<string, unknown>> = [];
|
||||
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<string, Record<string, unknown>> | 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, '<p>Hello</p>',
|
||||
[PDF],
|
||||
);
|
||||
|
||||
expect(creates[0]).not.toHaveProperty('bodyStructure');
|
||||
expect(creates[0].htmlBody).toEqual([{ partId: 'html', type: 'text/html' }]);
|
||||
expect(creates[0].attachments).toEqual([PDF_PART]);
|
||||
});
|
||||
});
|
||||
+35
-27
@@ -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<string, boolean>;
|
||||
mailboxIds: Record<string, boolean>;
|
||||
bodyValues: Record<string, { value: string }>;
|
||||
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[] = [];
|
||||
|
||||
@@ -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)] };
|
||||
}
|
||||
Reference in New Issue
Block a user