Compare commits

...

2 Commits

Author SHA1 Message Date
Курнат Андрей 81a0de2ba9 Export QOutlook inline images as related CID parts 2026-06-02 12:26:58 +03:00
Курнат Андрей d86e0470c2 Embed QOutlook inline CID images 2026-06-02 12:22:22 +03:00
2 changed files with 286 additions and 5 deletions
+120
View File
@@ -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",
"",
'<p>Logo</p><img alt="logo" src="cid:Logo%40Example.com">',
"--related",
'Content-Type: image/png; name="logo.png"',
'Content-Disposition: inline; filename="logo.png"',
"Content-ID: <logo@example.com>",
"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",
"",
'<p>Document</p><img src="cid:document">',
"--related",
'Content-Type: text/plain; name="document.txt"',
'Content-Disposition: inline; filename="document.txt"',
"Content-ID: <document>",
"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(
{
@@ -381,6 +457,50 @@ describe("outlookMail helpers", () => {
expect(extractMessageHtmlBody(parsed.headers, parsed.body)).toBe("<p><strong>HTML text</strong></p>");
});
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: `<p>Logo</p><img alt="logo" src="data:image/png;base64,${imageBase64}">`,
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: <logo@example.com>");
expect(eml).toContain('Content-Disposition: inline; filename="logo.png"');
expect(extractMessageHtmlBody(parsed.headers, parsed.body)).toBe('<p>Logo</p><img alt="logo" src="cid:logo@example.com">');
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",
+166 -5
View File
@@ -81,7 +81,8 @@ export async function importEmlFile(file: File): Promise<ImportedMailMessage> {
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<ImportedMailMessage> {
time: parsed.headers.date || new Date().toLocaleString("ru-RU"),
read: false,
flagged: false,
attachments: extractMessageAttachments(parsed.headers, parsed.body)
attachments
};
}
@@ -262,6 +263,81 @@ 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<string, { contentBase64: string; contentType: string }>();
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}"`;
});
}
function htmlWithCidReferencesForInlineAttachments(html: string, attachments: MailAttachment[] = []): string {
if (!html.trim() || attachments.length === 0) {
return html;
}
const contentIdsByDataUri = new Map<string, string>();
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<MailAttachment> {
return {
id: createAttachmentId(file.name),
@@ -463,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)}`,
@@ -475,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}--`,
""
];
@@ -531,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[] {
@@ -546,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)}`] : []),
@@ -748,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";
@@ -783,6 +906,44 @@ 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/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 cleanContentId(value).toLowerCase();
}
function decodeUriComponentSafe(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function escapeHtmlAttribute(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function wrapBase64(value: string): string {
return (value.match(/.{1,76}/g) ?? [""]).join("\r\n");
}