Embed QOutlook inline CID images
This commit is contained in:
@@ -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 () => {
|
it("преобразует данные Outlook MSG в письмо QOutlook с вложениями", async () => {
|
||||||
const message = msgDataToMailMessage(
|
const message = msgDataToMailMessage(
|
||||||
{
|
{
|
||||||
|
|||||||
+54
-2
@@ -81,7 +81,8 @@ export async function importEmlFile(file: File): Promise<ImportedMailMessage> {
|
|||||||
|
|
||||||
const raw = await file.text();
|
const raw = await file.text();
|
||||||
const parsed = parseEmlSource(raw);
|
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 {
|
return {
|
||||||
id: `eml-${Date.now()}`,
|
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"),
|
time: parsed.headers.date || new Date().toLocaleString("ru-RU"),
|
||||||
read: false,
|
read: false,
|
||||||
flagged: 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));
|
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}"`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function createMailAttachmentFromFile(file: File): Promise<MailAttachment> {
|
export async function createMailAttachmentFromFile(file: File): Promise<MailAttachment> {
|
||||||
return {
|
return {
|
||||||
id: createAttachmentId(file.name),
|
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";
|
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 {
|
function wrapBase64(value: string): string {
|
||||||
return (value.match(/.{1,76}/g) ?? [""]).join("\r\n");
|
return (value.match(/.{1,76}/g) ?? [""]).join("\r\n");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user