Files
QOffice/src/io/outlookMail.test.ts
T
Курнат Андрей c4fd683ef3 Preserve QOutlook HTML EML bodies
2026-06-02 06:30:26 +03:00

465 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import {
attachmentToBlob,
buildEmlSource,
createMailAttachmentFromFile,
decodeMimeWords,
decodeTransferBody,
extractMessageAttachments,
extractMessageBody,
extractMessageHtmlBody,
htmlToPlainText,
importEmlFile,
isSupportedEmlFileName,
isSupportedMailFileName,
isSupportedMsgFileName,
msgDataToMailMessage,
parseContentType,
parseEmlHeaders,
parseEmlSource,
parseMultipartBody
} from "./outlookMail";
import type { MailMessage } from "../types";
describe("outlookMail helpers", () => {
it("разбирает folded EML headers", () => {
expect(parseEmlHeaders("Subject: Первая строка\r\n\tвторая строка\r\nFrom: user@example.com")).toEqual({
subject: "Первая строка вторая строка",
from: "user@example.com"
});
});
it("разделяет заголовки и тело EML", () => {
const parsed = parseEmlSource("From: a@example.com\r\nTo: b@example.com\r\n\r\nТекст письма");
expect(parsed.headers.from).toBe("a@example.com");
expect(parsed.body).toBe("Текст письма");
});
it("декодирует UTF-8 MIME words и transfer encodings", () => {
expect(decodeMimeWords("=?UTF-8?B?0J/RgNC40LLQtdGC?=")).toBe("Привет");
expect(decodeMimeWords("=?windows-1251?B?z/Do4uXy?=")).toBe("Привет");
expect(decodeMimeWords("=?windows-1251?Q?=CF=F0=E8=E2=E5=F2?=")).toBe("Привет");
expect(decodeMimeWords("=?UTF-8?B?0J/RgNC4?= =?UTF-8?B?0LLQtdGC?=")).toBe("Привет");
expect(decodeTransferBody("0J/RgNC40LLQtdGC", "base64")).toBe("Привет");
expect(decodeTransferBody("=D0=9F=D1=80=D0=B8=D0=B2=D0=B5=D1=82", "quoted-printable")).toBe("Привет");
});
it("разбирает Content-Type с quoted boundary и charset", () => {
expect(parseContentType('multipart/alternative; boundary="----=_Next;Part"; charset=utf-8')).toEqual({
mediaType: "multipart/alternative",
parameters: {
boundary: "----=_Next;Part",
charset: "utf-8"
}
});
});
it("разбирает multipart body на MIME-части", () => {
const parts = parseMultipartBody(
[
"Преамбула",
"--qoffice-boundary",
"Content-Type: text/plain; charset=utf-8",
"",
"Текстовая версия",
"--qoffice-boundary",
"Content-Type: text/html; charset=utf-8",
"",
"<p>HTML версия</p>",
"--qoffice-boundary--"
].join("\r\n"),
"qoffice-boundary"
);
expect(parts).toEqual([
{ headers: { "content-type": "text/plain; charset=utf-8" }, body: "Текстовая версия" },
{ headers: { "content-type": "text/html; charset=utf-8" }, body: "<p>HTML версия</p>" }
]);
});
it("выбирает text/plain из multipart/alternative перед HTML", () => {
const body = [
"--alt",
"Content-Type: text/html; charset=utf-8",
"Content-Transfer-Encoding: quoted-printable",
"",
"<p>=D0=92=D0=B5=D1=80=D1=81=D0=B8=D1=8F HTML</p>",
"--alt",
"Content-Type: text/plain; charset=utf-8",
"",
"Текстовая версия",
"--alt--"
].join("\r\n");
expect(extractMessageBody({ "content-type": 'multipart/alternative; boundary="alt"' }, body)).toBe("Текстовая версия");
});
it("преобразует HTML body в читаемый текст, если text/plain отсутствует", () => {
const body = [
"--alt",
"Content-Type: text/html; charset=utf-8",
"",
"<html><body><h1>Тема</h1><p>Строка&nbsp;1<br>Строка &amp; 2</p><ul><li>Пункт</li></ul></body></html>",
"--alt--"
].join("\r\n");
expect(htmlToPlainText("<p>Строка&nbsp;1<br>Строка &amp; 2</p>")).toBe("Строка 1\nСтрока & 2");
expect(extractMessageBody({ "content-type": "multipart/alternative; boundary=alt" }, body)).toBe("Тема\nСтрока 1\nСтрока & 2\n• Пункт");
});
it("извлекает MIME-вложения из multipart/mixed и не смешивает их с текстом письма", async () => {
const body = [
"--mixed",
"Content-Type: text/plain; charset=utf-8",
"",
"Текст письма",
"--mixed",
"Content-Type: text/plain; name*=utf-8''%D0%BE%D1%82%D1%87%D0%B5%D1%82.txt",
"Content-Disposition: attachment; filename*=utf-8''%D0%BE%D1%82%D1%87%D0%B5%D1%82.txt",
"Content-Transfer-Encoding: base64",
"",
"0J/RgNC40LLQtdGC",
"--mixed--"
].join("\r\n");
const headers = { "content-type": 'multipart/mixed; boundary="mixed"' };
const attachments = extractMessageAttachments(headers, body);
expect(extractMessageBody(headers, body)).toBe("Текст письма");
expect(attachments).toHaveLength(1);
expect(attachments[0]).toMatchObject({
fileName: "отчет.txt",
contentType: "text/plain",
size: new TextEncoder().encode("Привет").byteLength,
contentBase64: "0J/RgNC40LLQtdGC",
disposition: "attachment"
});
await expect(attachmentToBlob(attachments[0]).text()).resolves.toBe("Привет");
});
it("извлекает inline MIME-вложения с Content-ID и не добавляет бинарную часть в тело", async () => {
const body = [
"--related",
"Content-Type: text/html; charset=utf-8",
"",
"<p>Logo</p><img src=\"cid:logo\">",
"--related",
"Content-Type: image/png",
"Content-Disposition: inline",
"Content-ID: <logo>",
"Content-Transfer-Encoding: base64",
"",
"SGVsbG8=",
"--related--"
].join("\r\n");
const headers = { "content-type": 'multipart/related; boundary="related"' };
const attachments = extractMessageAttachments(headers, body);
expect(extractMessageBody(headers, body)).toBe("Logo");
expect(attachments).toEqual([
{
id: "attachment-вложение-1-1",
fileName: "Вложение 1",
contentType: "image/png",
size: 5,
contentBase64: "SGVsbG8=",
disposition: "inline",
contentId: "logo"
}
]);
await expect(attachmentToBlob(attachments[0]).text()).resolves.toBe("Hello");
});
it("импортирует multipart EML как письмо QOutlook", async () => {
const eml = [
"From: sender@example.com",
"To: reader@example.com",
"Cc: copy@example.com",
"Bcc: hidden@example.com",
"Reply-To: support@example.com",
"Message-ID: <message-1@example.com>",
"In-Reply-To: <parent@example.com>",
"References: <root@example.com> <parent@example.com>",
"Importance: high",
"X-Priority: 1",
"Priority: urgent",
"Subject: =?UTF-8?B?0JzRg9C70YzRgtC40L/QsNGA0YI=?=",
"Date: Mon, 01 Jun 2026 10:00:00 +0300",
'Content-Type: multipart/alternative; boundary="alt"',
"",
"--alt",
"Content-Type: text/html; charset=utf-8",
"",
"<p>HTML</p>",
"--alt",
"Content-Type: text/plain; charset=utf-8",
"Content-Transfer-Encoding: base64",
"",
"0J/RgNC40LLQtdGCLCDQv9C40YHRjNC80L4=",
"--alt--"
].join("\r\n");
const imported = await importEmlFile(new File([eml], "message.eml", { type: "message/rfc822" }));
expect(imported.subject).toBe("Мультипарт");
expect(imported.body).toBe("Привет, письмо");
expect(imported.bodyHtml).toBe("<p>HTML</p>");
expect(imported.from).toBe("sender@example.com");
expect(imported.to).toBe("reader@example.com");
expect(imported.cc).toBe("copy@example.com");
expect(imported.bcc).toBe("hidden@example.com");
expect(imported.replyTo).toBe("support@example.com");
expect(imported.messageId).toBe("<message-1@example.com>");
expect(imported.inReplyTo).toBe("<parent@example.com>");
expect(imported.references).toBe("<root@example.com> <parent@example.com>");
expect(imported.importance).toBe("high");
expect(imported.xPriority).toBe("1");
expect(imported.priority).toBe("urgent");
});
it("импортирует multipart/mixed EML с вложением", async () => {
const eml = [
"From: sender@example.com",
"To: reader@example.com",
"Subject: Отчет",
"Date: Mon, 01 Jun 2026 10:00:00 +0300",
'Content-Type: multipart/mixed; boundary="mixed"',
"",
"--mixed",
"Content-Type: text/plain; charset=utf-8",
"",
"Письмо с файлом",
"--mixed",
"Content-Type: application/octet-stream; name=\"report.bin\"",
"Content-Disposition: attachment; filename=\"report.bin\"",
"Content-Transfer-Encoding: base64",
"",
"SGVsbG8=",
"--mixed--"
].join("\r\n");
const imported = await importEmlFile(new File([eml], "message.eml", { type: "message/rfc822" }));
expect(imported.body).toBe("Письмо с файлом");
expect(imported.attachments).toEqual([
{
id: "attachment-report-bin-1",
fileName: "report.bin",
contentType: "application/octet-stream",
size: 5,
contentBase64: "SGVsbG8=",
disposition: "attachment"
}
]);
});
it("преобразует данные Outlook MSG в письмо QOutlook с вложениями", async () => {
const message = msgDataToMailMessage(
{
getAttachment: () => ({
fileName: "report.txt",
content: new TextEncoder().encode("Hello")
})
},
{
senderSmtpAddress: "sender@example.com",
subject: "MSG отчет",
headers: [
"Reply-To: support@example.com",
"Message-ID: <msg-1@example.com>",
"In-Reply-To: <msg-parent@example.com>",
"References: <msg-root@example.com> <msg-parent@example.com>",
"Importance: low",
"X-Priority: 5",
"Priority: non-urgent",
"From: sender@example.com"
].join("\r\n"),
bodyHtml: "<p>HTML&nbsp;текст</p>",
messageDeliveryTime: "Mon, 01 Jun 2026 10:00:00 GMT",
recipients: [
{ smtpAddress: "reader@example.com", recipType: "to" },
{ smtpAddress: "copy@example.com", recipType: "cc" },
{ smtpAddress: "hidden@example.com", recipType: "bcc" }
],
attachments: [{ fileName: "report.txt", attachMimeTag: "text/plain", contentLength: 5 }]
}
);
expect(message).toMatchObject({
folder: "inbox",
from: "sender@example.com",
to: "reader@example.com",
cc: "copy@example.com",
bcc: "hidden@example.com",
replyTo: "support@example.com",
messageId: "<msg-1@example.com>",
inReplyTo: "<msg-parent@example.com>",
references: "<msg-root@example.com> <msg-parent@example.com>",
importance: "low",
xPriority: "5",
priority: "non-urgent",
subject: "MSG отчет",
bodyHtml: "<p>HTML&nbsp;текст</p>",
body: "HTML текст",
time: "Mon, 01 Jun 2026 10:00:00 GMT",
read: false,
flagged: false
});
expect(message.bodyHtml).toBe("<p>HTML&nbsp;текст</p>");
expect(message.id).toMatch(/^msg-/);
expect(message.attachments).toEqual([
{
id: "attachment-report-txt-1",
fileName: "report.txt",
contentType: "text/plain",
size: 5,
contentBase64: "SGVsbG8=",
disposition: "attachment"
}
]);
await expect(attachmentToBlob(message.attachments?.[0]!).text()).resolves.toBe("Hello");
});
it("собирает EML source с UTF-8 subject encoding", () => {
const message: MailMessage = {
id: "1",
folder: "sent",
from: "sender@example.com",
to: "reader@example.com",
cc: "copy@example.com",
bcc: "hidden@example.com",
replyTo: "support@example.com",
messageId: "<message-1@example.com>",
inReplyTo: "<parent@example.com>",
references: "<root@example.com> <parent@example.com>",
importance: "high",
xPriority: "1",
priority: "urgent",
subject: "Тема",
body: "Текст",
time: "Mon, 01 Jan 2024 10:00:00 +0000",
read: true,
flagged: false
};
const eml = buildEmlSource(message);
expect(eml).toContain("From: sender@example.com");
expect(eml).toContain("Cc: copy@example.com");
expect(eml).toContain("Bcc: hidden@example.com");
expect(eml).toContain("Reply-To: support@example.com");
expect(eml).toContain("Message-ID: <message-1@example.com>");
expect(eml).toContain("In-Reply-To: <parent@example.com>");
expect(eml).toContain("References: <root@example.com> <parent@example.com>");
expect(eml).toContain("Importance: high");
expect(eml).toContain("X-Priority: 1");
expect(eml).toContain("Priority: urgent");
expect(eml).toContain("Subject: =?UTF-8?B?");
expect(eml.endsWith("Текст")).toBe(true);
});
it("exports HTML body as multipart/alternative EML", () => {
const message: MailMessage = {
id: "html-1",
folder: "sent",
from: "sender@example.com",
to: "reader@example.com",
subject: "HTML message",
body: "Plain text",
bodyHtml: "<p><strong>HTML text</strong></p>",
time: "Mon, 01 Jan 2024 10:00:00 +0000",
read: true,
flagged: false
};
const eml = buildEmlSource(message);
const parsed = parseEmlSource(eml);
expect(eml).toContain("Content-Type: multipart/alternative");
expect(extractMessageBody(parsed.headers, parsed.body)).toBe("Plain text");
expect(extractMessageHtmlBody(parsed.headers, parsed.body)).toBe("<p><strong>HTML text</strong></p>");
});
it("собирает multipart/mixed EML с вложениями и RFC 2231 filename", () => {
const message: MailMessage = {
id: "1",
folder: "sent",
from: "sender@example.com",
to: "reader@example.com",
cc: "copy@example.com",
bcc: "hidden@example.com",
replyTo: "support@example.com",
messageId: "<message-1@example.com>",
inReplyTo: "<parent@example.com>",
references: "<root@example.com> <parent@example.com>",
importance: "high",
xPriority: "1",
priority: "urgent",
subject: "Тема",
body: "Текст",
bodyHtml: "<p><strong>Текст</strong></p>",
time: "Mon, 01 Jan 2024 10:00:00 +0000",
read: true,
flagged: false,
attachments: [
{
id: "a1",
fileName: "отчет.txt",
contentType: "text/plain",
size: 5,
contentBase64: "SGVsbG8=",
disposition: "attachment"
}
]
};
const eml = buildEmlSource(message);
const parsed = parseEmlSource(eml);
const attachments = extractMessageAttachments(parsed.headers, parsed.body);
expect(eml).toContain("Content-Type: multipart/mixed");
expect(eml).toContain("Content-Type: multipart/alternative");
expect(eml).toContain("Cc: copy@example.com");
expect(eml).toContain("Bcc: hidden@example.com");
expect(eml).toContain("Reply-To: support@example.com");
expect(eml).toContain("Message-ID: <message-1@example.com>");
expect(eml).toContain("In-Reply-To: <parent@example.com>");
expect(eml).toContain("References: <root@example.com> <parent@example.com>");
expect(eml).toContain("Importance: high");
expect(eml).toContain("X-Priority: 1");
expect(eml).toContain("Priority: urgent");
expect(eml).toContain("filename*=utf-8''%D0%BE%D1%82%D1%87%D0%B5%D1%82%2E%74%78%74");
expect(extractMessageBody(parsed.headers, parsed.body)).toBe("Текст");
expect(extractMessageHtmlBody(parsed.headers, parsed.body)).toBe("<p><strong>Текст</strong></p>");
expect(attachments[0]).toMatchObject({
fileName: "отчет.txt",
contentType: "text/plain",
contentBase64: "SGVsbG8=",
size: 5
});
});
it("создает вложение из File для новых писем", async () => {
const attachment = await createMailAttachmentFromFile(new File(["Привет"], "note.txt", { type: "text/plain" }));
expect(attachment).toMatchObject({
fileName: "note.txt",
contentType: "text/plain",
size: new TextEncoder().encode("Привет").byteLength,
contentBase64: "0J/RgNC40LLQtdGC",
disposition: "attachment"
});
expect(attachment.id).toMatch(/^attachment-note-txt-/);
});
it("принимает EML и MSG filenames для импорта почты", () => {
expect(isSupportedEmlFileName("message.eml")).toBe(true);
expect(isSupportedMsgFileName("message.msg")).toBe(true);
expect(isSupportedMailFileName("message.eml")).toBe(true);
expect(isSupportedMailFileName("message.msg")).toBe(true);
expect(isSupportedEmlFileName("archive.pst")).toBe(false);
expect(isSupportedMailFileName("archive.pst")).toBe(false);
});
});