From d86e0470c2c0352a8c4d38a0432cbba91e746a8a 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:22:22 +0300 Subject: [PATCH] Embed QOutlook inline CID images --- src/io/outlookMail.test.ts | 76 ++++++++++++++++++++++++++++++++++++++ src/io/outlookMail.ts | 56 +++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/src/io/outlookMail.test.ts b/src/io/outlookMail.test.ts index c708f7e..5e2aeff 100644 --- a/src/io/outlookMail.test.ts +++ b/src/io/outlookMail.test.ts @@ -254,6 +254,82 @@ describe("outlookMail helpers", () => { ]); }); + it("embeds inline CID images in imported EML HTML body", async () => { + const imageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; + const eml = [ + "From: sender@example.com", + "To: reader@example.com", + "Subject: Inline logo", + "Date: Mon, 01 Jun 2026 10:00:00 +0300", + 'Content-Type: multipart/related; boundary="related"', + "", + "--related", + "Content-Type: text/html; charset=utf-8", + "", + '

Logo

logo', + "--related", + 'Content-Type: image/png; name="logo.png"', + 'Content-Disposition: inline; filename="logo.png"', + "Content-ID: ", + "Content-Transfer-Encoding: base64", + "", + imageBase64, + "--related--" + ].join("\r\n"); + + const imported = await importEmlFile(new File([eml], "inline.eml", { type: "message/rfc822" })); + + expect(imported.body).toBe("Logo"); + expect(imported.bodyHtml).toContain(`src="data:image/png;base64,${imageBase64}"`); + expect(imported.bodyHtml).not.toContain("cid:"); + expect(imported.attachments).toEqual([ + { + id: "attachment-logo-png-1", + fileName: "logo.png", + contentType: "image/png", + size: 68, + contentBase64: imageBase64, + disposition: "inline", + contentId: "logo@example.com" + } + ]); + }); + + it("keeps non-image CID references in imported EML HTML body", async () => { + const eml = [ + "From: sender@example.com", + "To: reader@example.com", + "Subject: Inline document", + "Date: Mon, 01 Jun 2026 10:00:00 +0300", + 'Content-Type: multipart/related; boundary="related"', + "", + "--related", + "Content-Type: text/html; charset=utf-8", + "", + '

Document

', + "--related", + 'Content-Type: text/plain; name="document.txt"', + 'Content-Disposition: inline; filename="document.txt"', + "Content-ID: ", + "Content-Transfer-Encoding: base64", + "", + "SGVsbG8=", + "--related--" + ].join("\r\n"); + + const imported = await importEmlFile(new File([eml], "inline.eml", { type: "message/rfc822" })); + + expect(imported.bodyHtml).toContain('src="cid:document"'); + expect(imported.bodyHtml).not.toContain("data:text/plain"); + expect(imported.attachments?.[0]).toMatchObject({ + fileName: "document.txt", + contentType: "text/plain", + contentBase64: "SGVsbG8=", + disposition: "inline", + contentId: "document" + }); + }); + it("преобразует данные Outlook MSG в письмо QOutlook с вложениями", async () => { const message = msgDataToMailMessage( { diff --git a/src/io/outlookMail.ts b/src/io/outlookMail.ts index 609b869..d94dd3f 100644 --- a/src/io/outlookMail.ts +++ b/src/io/outlookMail.ts @@ -81,7 +81,8 @@ export async function importEmlFile(file: File): Promise { const raw = await file.text(); const parsed = parseEmlSource(raw); - const bodyHtml = extractMessageHtmlBody(parsed.headers, parsed.body); + const attachments = extractMessageAttachments(parsed.headers, parsed.body); + const bodyHtml = htmlWithEmbeddedCidAttachments(extractMessageHtmlBody(parsed.headers, parsed.body), attachments); return { id: `eml-${Date.now()}`, @@ -99,7 +100,7 @@ export async function importEmlFile(file: File): Promise { time: parsed.headers.date || new Date().toLocaleString("ru-RU"), read: false, flagged: false, - attachments: extractMessageAttachments(parsed.headers, parsed.body) + attachments }; } @@ -262,6 +263,41 @@ export function extractMessageAttachments(headers: MailHeaders, body: string): M return collectAttachmentParts(headers, body).map((part, index) => attachmentFromPart(part, index)); } +export function htmlWithEmbeddedCidAttachments(html: string, attachments: MailAttachment[] = []): string { + if (!html.trim() || attachments.length === 0) { + return html; + } + + const inlineImagesByContentId = new Map(); + attachments.forEach((attachment) => { + const contentId = normalizedContentId(attachment.contentId); + const contentType = sanitizeMimeType(attachment.contentType); + const contentBase64 = attachment.contentBase64.trim(); + if (!contentId || attachment.disposition !== "inline" || !contentBase64 || !isEmbeddableCidImageContentType(contentType)) { + return; + } + + if (!inlineImagesByContentId.has(contentId)) { + inlineImagesByContentId.set(contentId, { contentBase64, contentType }); + } + }); + + if (inlineImagesByContentId.size === 0) { + return html; + } + + return html.replace(/\bsrc\s*=\s*(?:(["'])\s*cid:([^"'>\s]+)\1|cid:([^\s"'<>]+))/gi, (match, quote: string | undefined, quotedCid: string | undefined, unquotedCid: string | undefined) => { + const contentId = normalizedContentId(decodeUriComponentSafe(quotedCid ?? unquotedCid ?? "")); + const attachment = inlineImagesByContentId.get(contentId); + if (!attachment) { + return match; + } + + const dataUri = `data:${attachment.contentType};base64,${attachment.contentBase64}`; + return quote ? `src=${quote}${dataUri}${quote}` : `src="${dataUri}"`; + }); +} + export async function createMailAttachmentFromFile(file: File): Promise { return { id: createAttachmentId(file.name), @@ -783,6 +819,22 @@ function sanitizeMimeType(value = "application/octet-stream"): string { return /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i.test(mediaType) ? mediaType : "application/octet-stream"; } +function isEmbeddableCidImageContentType(value: string): boolean { + return ["image/png", "image/jpeg", "image/jpg", "image/gif", "image/bmp", "image/webp"].includes(value.toLowerCase()); +} + +function normalizedContentId(value = ""): string { + return value.trim().replace(/^<|>$/g, "").toLowerCase(); +} + +function decodeUriComponentSafe(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + function wrapBase64(value: string): string { return (value.match(/.{1,76}/g) ?? [""]).join("\r\n"); }