Export QOutlook inline images as related CID parts
This commit is contained in:
@@ -457,6 +457,50 @@ describe("outlookMail helpers", () => {
|
|||||||
expect(extractMessageHtmlBody(parsed.headers, parsed.body)).toBe("<p><strong>HTML text</strong></p>");
|
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", () => {
|
it("собирает multipart/mixed EML с вложениями и RFC 2231 filename", () => {
|
||||||
const message: MailMessage = {
|
const message: MailMessage = {
|
||||||
id: "1",
|
id: "1",
|
||||||
|
|||||||
+114
-5
@@ -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<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> {
|
export async function createMailAttachmentFromFile(file: File): Promise<MailAttachment> {
|
||||||
return {
|
return {
|
||||||
id: createAttachmentId(file.name),
|
id: createAttachmentId(file.name),
|
||||||
@@ -499,6 +539,28 @@ export function buildEmlSource(message: MailMessage): string {
|
|||||||
if (attachments.length > 0) {
|
if (attachments.length > 0) {
|
||||||
const boundary = createMultipartBoundary(message);
|
const boundary = createMultipartBoundary(message);
|
||||||
const alternativeBoundary = createAlternativeBoundary(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 = [
|
const lines = [
|
||||||
`From: ${sanitizeHeaderValue(message.from)}`,
|
`From: ${sanitizeHeaderValue(message.from)}`,
|
||||||
`To: ${sanitizeHeaderValue(message.to)}`,
|
`To: ${sanitizeHeaderValue(message.to)}`,
|
||||||
@@ -511,8 +573,12 @@ export function buildEmlSource(message: MailMessage): string {
|
|||||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
||||||
"",
|
"",
|
||||||
`--${boundary}`,
|
`--${boundary}`,
|
||||||
...(hasHtmlBody ? buildAlternativePartLines(alternativeBoundary, message) : buildPlainTextPartLines(message)),
|
...(inlineRelatedAttachments.length > 0
|
||||||
...attachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)),
|
? buildRelatedPartLines(createRelatedBoundary(message), alternativeBoundary, message, inlineRelatedAttachments)
|
||||||
|
: hasHtmlBody
|
||||||
|
? buildAlternativePartLines(alternativeBoundary, message)
|
||||||
|
: buildPlainTextPartLines(message)),
|
||||||
|
...mixedAttachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)),
|
||||||
`--${boundary}--`,
|
`--${boundary}--`,
|
||||||
""
|
""
|
||||||
];
|
];
|
||||||
@@ -567,7 +633,12 @@ function buildPlainTextPartLines(message: MailMessage): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildHtmlPartLines(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[] {
|
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[] {
|
function optionalAddressHeaders(message: MailMessage): string[] {
|
||||||
return [
|
return [
|
||||||
...(message.cc?.trim() ? [`Cc: ${sanitizeHeaderValue(message.cc)}`] : []),
|
...(message.cc?.trim() ? [`Cc: ${sanitizeHeaderValue(message.cc)}`] : []),
|
||||||
@@ -784,6 +867,10 @@ function createAlternativeBoundary(message: MailMessage): string {
|
|||||||
return `${createMultipartBoundary(message)}-alternative`;
|
return `${createMultipartBoundary(message)}-alternative`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createRelatedBoundary(message: MailMessage): string {
|
||||||
|
return `${createMultipartBoundary(message)}-related`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildAttachmentPartLines(boundary: string, attachment: MailAttachment): string[] {
|
function buildAttachmentPartLines(boundary: string, attachment: MailAttachment): string[] {
|
||||||
const safeContentType = sanitizeMimeType(attachment.contentType);
|
const safeContentType = sanitizeMimeType(attachment.contentType);
|
||||||
const disposition = attachment.disposition === "inline" ? "inline" : "attachment";
|
const disposition = attachment.disposition === "inline" ? "inline" : "attachment";
|
||||||
@@ -820,11 +907,24 @@ function sanitizeMimeType(value = "application/octet-stream"): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isEmbeddableCidImageContentType(value: string): boolean {
|
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 {
|
function normalizedContentId(value = ""): string {
|
||||||
return value.trim().replace(/^<|>$/g, "").toLowerCase();
|
return cleanContentId(value).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeUriComponentSafe(value: string): string {
|
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, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
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