465 lines
17 KiB
TypeScript
465 lines
17 KiB
TypeScript
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>Строка 1<br>Строка & 2</p><ul><li>Пункт</li></ul></body></html>",
|
||
"--alt--"
|
||
].join("\r\n");
|
||
|
||
expect(htmlToPlainText("<p>Строка 1<br>Строка & 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 текст</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 текст</p>",
|
||
body: "HTML текст",
|
||
time: "Mon, 01 Jun 2026 10:00:00 GMT",
|
||
read: false,
|
||
flagged: false
|
||
});
|
||
expect(message.bodyHtml).toBe("<p>HTML текст</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);
|
||
});
|
||
});
|