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
`,
+ 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
');
+ 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");
}