From 81a0de2ba9576fb829139507247351e97a2c4f83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Tue, 2 Jun 2026 12:26:58 +0300 Subject: [PATCH] Export QOutlook inline images as related CID parts --- src/io/outlookMail.test.ts | 44 ++++++++++++++ src/io/outlookMail.ts | 119 +++++++++++++++++++++++++++++++++++-- 2 files changed, 158 insertions(+), 5 deletions(-) diff --git a/src/io/outlookMail.test.ts b/src/io/outlookMail.test.ts index 5e2aeff..b42cc24 100644 --- a/src/io/outlookMail.test.ts +++ b/src/io/outlookMail.test.ts @@ -457,6 +457,50 @@ describe("outlookMail helpers", () => { expect(extractMessageHtmlBody(parsed.headers, parsed.body)).toBe("

HTML text

"); }); + it("exports inline data images as CID references in related EML", () => { + const imageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; + const message: MailMessage = { + id: "inline-image", + folder: "sent", + from: "sender@example.com", + to: "reader@example.com", + subject: "Inline image", + body: "Logo", + bodyHtml: `

Logo

logo`, + time: "Mon, 01 Jan 2024 10:00:00 +0000", + read: true, + flagged: false, + attachments: [ + { + id: "logo", + fileName: "logo.png", + contentType: "image/png", + size: 68, + contentBase64: imageBase64, + disposition: "inline", + contentId: "logo@example.com" + } + ] + }; + + const eml = buildEmlSource(message); + const parsed = parseEmlSource(eml); + const attachments = extractMessageAttachments(parsed.headers, parsed.body); + + expect(eml).toContain("Content-Type: multipart/related"); + expect(eml).toContain("Content-ID: "); + expect(eml).toContain('Content-Disposition: inline; filename="logo.png"'); + expect(extractMessageHtmlBody(parsed.headers, parsed.body)).toBe('

Logo

logo'); + expect(extractMessageHtmlBody(parsed.headers, parsed.body)).not.toContain("data:image/png;base64"); + expect(attachments[0]).toMatchObject({ + fileName: "logo.png", + contentType: "image/png", + contentBase64: imageBase64, + disposition: "inline", + contentId: "logo@example.com" + }); + }); + it("собирает multipart/mixed EML с вложениями и RFC 2231 filename", () => { const message: MailMessage = { id: "1", diff --git a/src/io/outlookMail.ts b/src/io/outlookMail.ts index d94dd3f..249442a 100644 --- a/src/io/outlookMail.ts +++ b/src/io/outlookMail.ts @@ -298,6 +298,46 @@ export function htmlWithEmbeddedCidAttachments(html: string, attachments: MailAt }); } +function htmlWithCidReferencesForInlineAttachments(html: string, attachments: MailAttachment[] = []): string { + if (!html.trim() || attachments.length === 0) { + return html; + } + + const contentIdsByDataUri = new Map(); + attachments.forEach((attachment) => { + const contentId = cleanContentId(attachment.contentId); + const contentType = sanitizeMimeType(attachment.contentType); + const contentBase64 = attachment.contentBase64.trim(); + if (!contentId || attachment.disposition !== "inline" || !contentBase64 || !isEmbeddableCidImageContentType(contentType)) { + return; + } + + const key = cidImageDataUriKey(contentType, contentBase64); + if (!contentIdsByDataUri.has(key)) { + contentIdsByDataUri.set(key, contentId); + } + }); + + if (contentIdsByDataUri.size === 0) { + return html; + } + + return html.replace( + /\bsrc\s*=\s*(?:(["'])\s*data:(image\/(?:png|jpe?g|gif|bmp|webp));base64,([^"'>\s]+)\1|data:(image\/(?:png|jpe?g|gif|bmp|webp));base64,([^\s"'<>]+))/gi, + (match, quote: string | undefined, quotedType: string | undefined, quotedBase64: string | undefined, unquotedType: string | undefined, unquotedBase64: string | undefined) => { + const contentType = quotedType ?? unquotedType ?? ""; + const contentBase64 = (quotedBase64 ?? unquotedBase64 ?? "").trim(); + const contentId = contentIdsByDataUri.get(cidImageDataUriKey(contentType, contentBase64)); + if (!contentId) { + return match; + } + + const cidReference = escapeHtmlAttribute(`cid:${contentId}`); + return quote ? `src=${quote}${cidReference}${quote}` : `src="${cidReference}"`; + } + ); +} + export async function createMailAttachmentFromFile(file: File): Promise { return { id: createAttachmentId(file.name), @@ -499,6 +539,28 @@ export function buildEmlSource(message: MailMessage): string { if (attachments.length > 0) { const boundary = createMultipartBoundary(message); const alternativeBoundary = createAlternativeBoundary(message); + const inlineRelatedAttachments = hasHtmlBody ? attachments.filter(isInlineRelatedAttachment) : []; + const mixedAttachments = inlineRelatedAttachments.length > 0 ? attachments.filter((attachment) => !isInlineRelatedAttachment(attachment)) : attachments; + if (hasHtmlBody && inlineRelatedAttachments.length > 0 && mixedAttachments.length === 0) { + const relatedBoundary = createRelatedBoundary(message); + const lines = [ + `From: ${sanitizeHeaderValue(message.from)}`, + `To: ${sanitizeHeaderValue(message.to)}`, + ...optionalAddressHeaders(message), + `Subject: ${encodeHeaderValue(message.subject || "Без темы")}`, + `Date: ${sanitizeHeaderValue(message.time || new Date().toUTCString())}`, + ...optionalThreadHeaders(message), + ...optionalPriorityHeaders(message), + "MIME-Version: 1.0", + `Content-Type: multipart/related; boundary="${relatedBoundary}"`, + "", + ...buildRelatedBodyLines(relatedBoundary, alternativeBoundary, message, inlineRelatedAttachments), + "" + ]; + + return lines.join("\r\n"); + } + const lines = [ `From: ${sanitizeHeaderValue(message.from)}`, `To: ${sanitizeHeaderValue(message.to)}`, @@ -511,8 +573,12 @@ export function buildEmlSource(message: MailMessage): string { `Content-Type: multipart/mixed; boundary="${boundary}"`, "", `--${boundary}`, - ...(hasHtmlBody ? buildAlternativePartLines(alternativeBoundary, message) : buildPlainTextPartLines(message)), - ...attachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)), + ...(inlineRelatedAttachments.length > 0 + ? buildRelatedPartLines(createRelatedBoundary(message), alternativeBoundary, message, inlineRelatedAttachments) + : hasHtmlBody + ? buildAlternativePartLines(alternativeBoundary, message) + : buildPlainTextPartLines(message)), + ...mixedAttachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)), `--${boundary}--`, "" ]; @@ -567,7 +633,12 @@ function buildPlainTextPartLines(message: MailMessage): string[] { } function buildHtmlPartLines(message: MailMessage): string[] { - return ["Content-Type: text/html; charset=utf-8", "Content-Transfer-Encoding: 8bit", "", normalizeMailNewlines(message.bodyHtml || "")]; + return [ + "Content-Type: text/html; charset=utf-8", + "Content-Transfer-Encoding: 8bit", + "", + normalizeMailNewlines(htmlWithCidReferencesForInlineAttachments(message.bodyHtml || "", message.attachments ?? [])) + ]; } function buildAlternativePartLines(boundary: string, message: MailMessage): string[] { @@ -582,6 +653,18 @@ function buildAlternativePartLines(boundary: string, message: MailMessage): stri ]; } +function buildRelatedPartLines(boundary: string, alternativeBoundary: string, message: MailMessage, inlineAttachments: MailAttachment[]): string[] { + return [`Content-Type: multipart/related; boundary="${boundary}"`, "", ...buildRelatedBodyLines(boundary, alternativeBoundary, message, inlineAttachments)]; +} + +function buildRelatedBodyLines(boundary: string, alternativeBoundary: string, message: MailMessage, inlineAttachments: MailAttachment[]): string[] { + return [`--${boundary}`, ...buildAlternativePartLines(alternativeBoundary, message), ...inlineAttachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)), `--${boundary}--`]; +} + +function isInlineRelatedAttachment(attachment: MailAttachment): boolean { + return attachment.disposition === "inline" && Boolean(cleanContentId(attachment.contentId)); +} + function optionalAddressHeaders(message: MailMessage): string[] { return [ ...(message.cc?.trim() ? [`Cc: ${sanitizeHeaderValue(message.cc)}`] : []), @@ -784,6 +867,10 @@ function createAlternativeBoundary(message: MailMessage): string { return `${createMultipartBoundary(message)}-alternative`; } +function createRelatedBoundary(message: MailMessage): string { + return `${createMultipartBoundary(message)}-related`; +} + function buildAttachmentPartLines(boundary: string, attachment: MailAttachment): string[] { const safeContentType = sanitizeMimeType(attachment.contentType); const disposition = attachment.disposition === "inline" ? "inline" : "attachment"; @@ -820,11 +907,24 @@ function sanitizeMimeType(value = "application/octet-stream"): string { } function isEmbeddableCidImageContentType(value: string): boolean { - return ["image/png", "image/jpeg", "image/jpg", "image/gif", "image/bmp", "image/webp"].includes(value.toLowerCase()); + return ["image/png", "image/jpeg", "image/gif", "image/bmp", "image/webp"].includes(normalizedCidImageContentType(value)); +} + +function normalizedCidImageContentType(value: string): string { + const contentType = value.toLowerCase(); + return contentType === "image/jpg" ? "image/jpeg" : contentType; +} + +function cidImageDataUriKey(contentType: string, contentBase64: string): string { + return `${normalizedCidImageContentType(contentType)}:${contentBase64.trim()}`; +} + +function cleanContentId(value = ""): string { + return value.trim().replace(/^<|>$/g, ""); } function normalizedContentId(value = ""): string { - return value.trim().replace(/^<|>$/g, "").toLowerCase(); + return cleanContentId(value).toLowerCase(); } function decodeUriComponentSafe(value: string): string { @@ -835,6 +935,15 @@ function decodeUriComponentSafe(value: string): string { } } +function escapeHtmlAttribute(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(//g, ">"); +} + function wrapBase64(value: string): string { return (value.match(/.{1,76}/g) ?? [""]).join("\r\n"); }