957 lines
31 KiB
TypeScript
957 lines
31 KiB
TypeScript
import type { MailAttachment, MailMessage } from "../types";
|
||
import { EML_MIME_TYPE } from "./fileHelpers";
|
||
|
||
type MailHeaders = Record<string, string>;
|
||
|
||
interface ParsedContentType {
|
||
mediaType: string;
|
||
parameters: Record<string, string>;
|
||
}
|
||
|
||
interface ParsedHeaderParameters {
|
||
value: string;
|
||
parameters: Record<string, string>;
|
||
}
|
||
|
||
interface MailPart {
|
||
headers: MailHeaders;
|
||
body: string;
|
||
}
|
||
|
||
export interface MsgAttachmentSummary {
|
||
fileName?: string;
|
||
fileNameShort?: string;
|
||
name?: string;
|
||
attachMimeTag?: string;
|
||
contentLength?: number;
|
||
pidContentId?: string;
|
||
attachmentHidden?: boolean;
|
||
}
|
||
|
||
export interface MsgRecipient {
|
||
name?: string;
|
||
email?: string;
|
||
smtpAddress?: string;
|
||
recipType?: "to" | "cc" | "bcc";
|
||
}
|
||
|
||
export interface MsgFileData {
|
||
senderEmail?: string;
|
||
senderSmtpAddress?: string;
|
||
senderName?: string;
|
||
subject?: string;
|
||
body?: string;
|
||
bodyHtml?: string;
|
||
html?: Uint8Array;
|
||
headers?: string;
|
||
creationTime?: string;
|
||
lastModificationTime?: string;
|
||
clientSubmitTime?: string;
|
||
messageDeliveryTime?: string;
|
||
recipients?: MsgRecipient[];
|
||
attachments?: MsgAttachmentSummary[];
|
||
error?: string;
|
||
}
|
||
|
||
export interface MsgReaderInstance {
|
||
getFileData: () => MsgFileData;
|
||
getAttachment: (attachment: MsgAttachmentSummary) => { fileName?: string; content: Uint8Array };
|
||
}
|
||
|
||
type MsgReaderConstructor = new (arrayBuffer: ArrayBuffer) => MsgReaderInstance;
|
||
|
||
export type ImportedMailMessage = MailMessage;
|
||
|
||
export async function importMailFile(file: File): Promise<ImportedMailMessage> {
|
||
if (isSupportedEmlFileName(file.name)) {
|
||
return importEmlFile(file);
|
||
}
|
||
|
||
if (isSupportedMsgFileName(file.name)) {
|
||
return importMsgFile(file);
|
||
}
|
||
|
||
throw new Error("QOutlook импортирует файлы .eml и одиночные сообщения Outlook .msg. Файлы PST сейчас не поддерживаются.");
|
||
}
|
||
|
||
export async function importEmlFile(file: File): Promise<ImportedMailMessage> {
|
||
if (!isSupportedEmlFileName(file.name)) {
|
||
throw new Error("QOutlook импортирует только файлы .eml. Используйте общий импорт QOutlook для файлов .msg.");
|
||
}
|
||
|
||
const raw = await file.text();
|
||
const parsed = parseEmlSource(raw);
|
||
const bodyHtml = extractMessageHtmlBody(parsed.headers, parsed.body);
|
||
|
||
return {
|
||
id: `eml-${Date.now()}`,
|
||
folder: "inbox",
|
||
from: decodeMimeWords(parsed.headers.from || ""),
|
||
to: decodeMimeWords(parsed.headers.to || ""),
|
||
...(parsed.headers.cc ? { cc: decodeMimeWords(parsed.headers.cc) } : {}),
|
||
...(parsed.headers.bcc ? { bcc: decodeMimeWords(parsed.headers.bcc) } : {}),
|
||
...(parsed.headers["reply-to"] ? { replyTo: decodeMimeWords(parsed.headers["reply-to"]) } : {}),
|
||
...mailThreadHeaders(parsed.headers),
|
||
...mailPriorityHeaders(parsed.headers),
|
||
subject: decodeMimeWords(parsed.headers.subject || "Без темы"),
|
||
body: extractMessageBody(parsed.headers, parsed.body),
|
||
...(bodyHtml ? { bodyHtml } : {}),
|
||
time: parsed.headers.date || new Date().toLocaleString("ru-RU"),
|
||
read: false,
|
||
flagged: false,
|
||
attachments: extractMessageAttachments(parsed.headers, parsed.body)
|
||
};
|
||
}
|
||
|
||
export async function importMsgFile(file: File): Promise<ImportedMailMessage> {
|
||
if (!isSupportedMsgFileName(file.name)) {
|
||
throw new Error("QOutlook импортирует только одиночные сообщения Outlook .msg.");
|
||
}
|
||
|
||
const MsgReader = await loadMsgReader();
|
||
const reader = new MsgReader(await file.arrayBuffer());
|
||
const data = reader.getFileData();
|
||
if (data.error) {
|
||
throw new Error(`Не удалось прочитать MSG: ${data.error}`);
|
||
}
|
||
|
||
return msgDataToMailMessage(reader, data);
|
||
}
|
||
|
||
export function msgDataToMailMessage(reader: Pick<MsgReaderInstance, "getAttachment">, data: MsgFileData): MailMessage {
|
||
const headerFields = data.headers ? parseEmlHeaders(data.headers) : {};
|
||
const cc = msgRecipientList(data.recipients, "cc") || decodeMimeWords(headerFields.cc || "");
|
||
const bcc = msgRecipientList(data.recipients, "bcc") || decodeMimeWords(headerFields.bcc || "");
|
||
const replyTo = decodeMimeWords(headerFields["reply-to"] || "");
|
||
const bodyHtml = msgBodyHtml(data);
|
||
return {
|
||
id: `msg-${Date.now()}`,
|
||
folder: "inbox",
|
||
from: firstText(data.senderSmtpAddress, data.senderEmail, data.senderName, decodeMimeWords(headerFields.from || "")),
|
||
to: msgRecipientList(data.recipients, "to") || decodeMimeWords(headerFields.to || ""),
|
||
...(cc ? { cc } : {}),
|
||
...(bcc ? { bcc } : {}),
|
||
...(replyTo ? { replyTo } : {}),
|
||
...mailThreadHeaders(headerFields),
|
||
...mailPriorityHeaders(headerFields),
|
||
subject: firstText(data.subject, decodeMimeWords(headerFields.subject || ""), "Без темы"),
|
||
body: msgBodyText(data),
|
||
...(bodyHtml ? { bodyHtml } : {}),
|
||
time: firstText(data.messageDeliveryTime, data.clientSubmitTime, data.creationTime, data.lastModificationTime, headerFields.date, new Date().toLocaleString("ru-RU")),
|
||
read: false,
|
||
flagged: false,
|
||
attachments: msgAttachments(reader, data.attachments)
|
||
};
|
||
}
|
||
|
||
export function exportEmlBlob(message: MailMessage): Blob {
|
||
return new Blob([buildEmlSource(message)], { type: EML_MIME_TYPE });
|
||
}
|
||
|
||
export function isSupportedEmlFileName(fileName: string): boolean {
|
||
return fileName.toLowerCase().endsWith(".eml");
|
||
}
|
||
|
||
export function isSupportedMsgFileName(fileName: string): boolean {
|
||
return fileName.toLowerCase().endsWith(".msg");
|
||
}
|
||
|
||
export function isSupportedMailFileName(fileName: string): boolean {
|
||
return isSupportedEmlFileName(fileName) || isSupportedMsgFileName(fileName);
|
||
}
|
||
|
||
export function parseEmlSource(source: string): { headers: MailHeaders; body: string } {
|
||
const normalized = normalizeMailNewlines(source);
|
||
const separatorIndex = normalized.indexOf("\n\n");
|
||
|
||
if (separatorIndex === -1) {
|
||
throw new Error("Не удалось прочитать EML: не найден раздел заголовков письма.");
|
||
}
|
||
|
||
return {
|
||
headers: parseEmlHeaders(normalized.slice(0, separatorIndex)),
|
||
body: normalized.slice(separatorIndex + 2)
|
||
};
|
||
}
|
||
|
||
export function parseEmlHeaders(source: string): MailHeaders {
|
||
const headers: MailHeaders = {};
|
||
let activeHeader = "";
|
||
|
||
for (const line of normalizeMailNewlines(source).split("\n")) {
|
||
if (!line) {
|
||
continue;
|
||
}
|
||
|
||
if (/^[ \t]/.test(line) && activeHeader) {
|
||
headers[activeHeader] = `${headers[activeHeader]} ${line.trim()}`;
|
||
continue;
|
||
}
|
||
|
||
const colonIndex = line.indexOf(":");
|
||
if (colonIndex === -1) {
|
||
continue;
|
||
}
|
||
|
||
activeHeader = line.slice(0, colonIndex).trim().toLowerCase();
|
||
headers[activeHeader] = line.slice(colonIndex + 1).trim();
|
||
}
|
||
|
||
return headers;
|
||
}
|
||
|
||
export function extractMessageBody(headers: MailHeaders, body: string): string {
|
||
const contentType = parseContentType(headers["content-type"]);
|
||
|
||
if (contentType.mediaType.startsWith("multipart/")) {
|
||
const boundary = contentType.parameters.boundary;
|
||
if (!boundary) {
|
||
return "";
|
||
}
|
||
|
||
const bodyParts = parseMultipartBody(body, boundary)
|
||
.filter((part) => !isAttachmentPart(part.headers))
|
||
.filter((part) => isMessageBodyPart(part.headers))
|
||
.map((part) => {
|
||
const partType = parseContentType(part.headers["content-type"]);
|
||
return {
|
||
mediaType: partType.mediaType,
|
||
text: extractMessageBody(part.headers, part.body)
|
||
};
|
||
})
|
||
.filter((part) => part.text.trim());
|
||
|
||
return (
|
||
bodyParts.find((part) => part.mediaType === "text/plain")?.text ??
|
||
bodyParts.find((part) => part.mediaType === "text/html")?.text ??
|
||
bodyParts[0]?.text ??
|
||
""
|
||
);
|
||
}
|
||
|
||
const decoded = decodeTransferBody(body, headers["content-transfer-encoding"], contentType.parameters.charset);
|
||
return contentType.mediaType === "text/html" ? htmlToPlainText(decoded) : normalizeMailNewlines(decoded).trim();
|
||
}
|
||
|
||
export function extractMessageHtmlBody(headers: MailHeaders, body: string): string {
|
||
const contentType = parseContentType(headers["content-type"]);
|
||
|
||
if (contentType.mediaType.startsWith("multipart/")) {
|
||
const boundary = contentType.parameters.boundary;
|
||
if (!boundary) {
|
||
return "";
|
||
}
|
||
|
||
return (
|
||
parseMultipartBody(body, boundary)
|
||
.filter((part) => !isAttachmentPart(part.headers))
|
||
.filter((part) => isMessageBodyPart(part.headers))
|
||
.map((part) => extractMessageHtmlBody(part.headers, part.body))
|
||
.find((html) => html.trim()) ?? ""
|
||
);
|
||
}
|
||
|
||
if (contentType.mediaType !== "text/html") {
|
||
return "";
|
||
}
|
||
|
||
return decodeTransferBody(body, headers["content-transfer-encoding"], contentType.parameters.charset).trim();
|
||
}
|
||
|
||
export function extractMessageAttachments(headers: MailHeaders, body: string): MailAttachment[] {
|
||
return collectAttachmentParts(headers, body).map((part, index) => attachmentFromPart(part, index));
|
||
}
|
||
|
||
export async function createMailAttachmentFromFile(file: File): Promise<MailAttachment> {
|
||
return {
|
||
id: createAttachmentId(file.name),
|
||
fileName: file.name || "Вложение",
|
||
contentType: file.type || "application/octet-stream",
|
||
size: file.size,
|
||
contentBase64: bytesToBase64(new Uint8Array(await file.arrayBuffer())),
|
||
disposition: "attachment"
|
||
};
|
||
}
|
||
|
||
export function attachmentToBlob(attachment: MailAttachment): Blob {
|
||
const bytes = base64ToBytes(attachment.contentBase64);
|
||
const arrayBuffer = new ArrayBuffer(bytes.byteLength);
|
||
new Uint8Array(arrayBuffer).set(bytes);
|
||
return new Blob([arrayBuffer], {
|
||
type: attachment.contentType || "application/octet-stream"
|
||
});
|
||
}
|
||
|
||
function msgBodyText(data: MsgFileData): string {
|
||
const plainBody = firstText(data.body);
|
||
if (plainBody) {
|
||
return normalizeMailNewlines(plainBody).trim();
|
||
}
|
||
|
||
const htmlBody = firstText(data.bodyHtml);
|
||
if (htmlBody) {
|
||
return htmlToPlainText(htmlBody);
|
||
}
|
||
|
||
if (data.html && data.html.byteLength > 0) {
|
||
return htmlToPlainText(decodeBytes(data.html, msgCharset(data)));
|
||
}
|
||
|
||
return "";
|
||
}
|
||
|
||
function msgBodyHtml(data: MsgFileData): string {
|
||
const htmlBody = firstText(data.bodyHtml);
|
||
if (htmlBody) {
|
||
return htmlBody;
|
||
}
|
||
|
||
if (data.html && data.html.byteLength > 0) {
|
||
return decodeBytes(data.html, msgCharset(data)).trim();
|
||
}
|
||
|
||
return "";
|
||
}
|
||
|
||
function msgCharset(data: MsgFileData) {
|
||
const headerFields = data.headers ? parseEmlHeaders(data.headers) : {};
|
||
return parseContentType(headerFields["content-type"]).parameters.charset || "utf-8";
|
||
}
|
||
|
||
function msgRecipientList(recipients: MsgRecipient[] = [], type: MsgRecipient["recipType"] = "to") {
|
||
return recipients
|
||
.filter((recipient) => (type === "to" ? !recipient.recipType || recipient.recipType === "to" : recipient.recipType === type))
|
||
.map((recipient) => firstText(recipient.smtpAddress, recipient.email, recipient.name))
|
||
.filter(Boolean)
|
||
.join(", ");
|
||
}
|
||
|
||
function msgAttachments(reader: Pick<MsgReaderInstance, "getAttachment">, attachments: MsgAttachmentSummary[] = []): MailAttachment[] {
|
||
return attachments
|
||
.filter((attachment) => !attachment.attachmentHidden)
|
||
.flatMap((attachment, index) => {
|
||
try {
|
||
const data = reader.getAttachment(attachment);
|
||
const fileName = firstText(data.fileName, attachment.fileName, attachment.fileNameShort, attachment.name, `Вложение ${index + 1}`);
|
||
return [
|
||
{
|
||
id: createAttachmentId(fileName, index),
|
||
fileName,
|
||
contentType: firstText(attachment.attachMimeTag, mimeTypeFromFileName(fileName)),
|
||
size: data.content.byteLength,
|
||
contentBase64: bytesToBase64(data.content),
|
||
disposition: "attachment" as const,
|
||
...(attachment.pidContentId ? { contentId: attachment.pidContentId } : {})
|
||
}
|
||
];
|
||
} catch {
|
||
return [];
|
||
}
|
||
});
|
||
}
|
||
|
||
function mimeTypeFromFileName(fileName: string) {
|
||
const extension = fileName.toLowerCase().split(".").pop() ?? "";
|
||
const mimeTypesByExtension: Record<string, string> = {
|
||
csv: "text/csv",
|
||
doc: "application/msword",
|
||
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
gif: "image/gif",
|
||
html: "text/html",
|
||
jpg: "image/jpeg",
|
||
jpeg: "image/jpeg",
|
||
pdf: "application/pdf",
|
||
png: "image/png",
|
||
ppt: "application/vnd.ms-powerpoint",
|
||
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||
txt: "text/plain",
|
||
xls: "application/vnd.ms-excel",
|
||
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
zip: "application/zip"
|
||
};
|
||
|
||
return mimeTypesByExtension[extension] ?? "application/octet-stream";
|
||
}
|
||
|
||
function firstText(...values: Array<string | undefined | null>) {
|
||
return values.map((value) => value?.trim() ?? "").find(Boolean) ?? "";
|
||
}
|
||
|
||
export function parseMultipartBody(body: string, boundary: string): MailPart[] {
|
||
const marker = `--${boundary}`;
|
||
const closingMarker = `--${boundary}--`;
|
||
const parts: string[] = [];
|
||
let activePart: string[] = [];
|
||
let insidePart = false;
|
||
|
||
for (const line of normalizeMailNewlines(body).split("\n")) {
|
||
if (line === marker || line === closingMarker) {
|
||
if (insidePart && activePart.length > 0) {
|
||
parts.push(activePart.join("\n"));
|
||
}
|
||
|
||
activePart = [];
|
||
insidePart = line === marker;
|
||
if (line === closingMarker) {
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (insidePart) {
|
||
activePart.push(line);
|
||
}
|
||
}
|
||
|
||
return parts.flatMap((part) => {
|
||
try {
|
||
return [parseEmlSource(part)];
|
||
} catch {
|
||
return [];
|
||
}
|
||
});
|
||
}
|
||
|
||
function collectAttachmentParts(headers: MailHeaders, body: string): MailPart[] {
|
||
const contentType = parseContentType(headers["content-type"]);
|
||
|
||
if (contentType.mediaType.startsWith("multipart/")) {
|
||
const boundary = contentType.parameters.boundary;
|
||
if (!boundary) {
|
||
return [];
|
||
}
|
||
|
||
return parseMultipartBody(body, boundary).flatMap((part) => collectAttachmentParts(part.headers, part.body));
|
||
}
|
||
|
||
return isAttachmentPart(headers) ? [{ headers, body }] : [];
|
||
}
|
||
|
||
function attachmentFromPart(part: MailPart, index: number): MailAttachment {
|
||
const contentType = parseContentType(part.headers["content-type"]);
|
||
const disposition = parseContentDisposition(part.headers["content-disposition"]);
|
||
const bytes = decodeTransferBytes(part.body, part.headers["content-transfer-encoding"]);
|
||
const fileName =
|
||
decodeMimeParameter(disposition.parameters, "filename") ??
|
||
decodeMimeParameter(contentType.parameters, "name") ??
|
||
`Вложение ${index + 1}`;
|
||
const contentId = part.headers["content-id"]?.replace(/^<|>$/g, "");
|
||
|
||
return {
|
||
id: createAttachmentId(fileName, index),
|
||
fileName,
|
||
contentType: contentType.mediaType || "application/octet-stream",
|
||
size: bytes.byteLength,
|
||
contentBase64: bytesToBase64(bytes),
|
||
disposition: disposition.value === "inline" ? "inline" : "attachment",
|
||
...(contentId ? { contentId } : {})
|
||
};
|
||
}
|
||
|
||
export function parseContentType(value = "text/plain"): ParsedContentType {
|
||
const parsed = parseHeaderParameters(value, "text/plain");
|
||
|
||
return {
|
||
mediaType: parsed.value.toLowerCase(),
|
||
parameters: parsed.parameters
|
||
};
|
||
}
|
||
|
||
export function buildEmlSource(message: MailMessage): string {
|
||
const attachments = message.attachments ?? [];
|
||
const hasHtmlBody = Boolean(message.bodyHtml?.trim());
|
||
if (attachments.length > 0) {
|
||
const boundary = createMultipartBoundary(message);
|
||
const alternativeBoundary = createAlternativeBoundary(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/mixed; boundary="${boundary}"`,
|
||
"",
|
||
`--${boundary}`,
|
||
...(hasHtmlBody ? buildAlternativePartLines(alternativeBoundary, message) : buildPlainTextPartLines(message)),
|
||
...attachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)),
|
||
`--${boundary}--`,
|
||
""
|
||
];
|
||
|
||
return lines.join("\r\n");
|
||
}
|
||
|
||
if (hasHtmlBody) {
|
||
const boundary = createAlternativeBoundary(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/alternative; boundary="${boundary}"`,
|
||
"",
|
||
`--${boundary}`,
|
||
...buildPlainTextPartLines(message),
|
||
`--${boundary}`,
|
||
...buildHtmlPartLines(message),
|
||
`--${boundary}--`,
|
||
""
|
||
];
|
||
|
||
return lines.join("\r\n");
|
||
}
|
||
|
||
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: text/plain; charset=utf-8",
|
||
"Content-Transfer-Encoding: 8bit",
|
||
"",
|
||
normalizeMailNewlines(message.body || "")
|
||
];
|
||
|
||
return lines.join("\r\n");
|
||
}
|
||
|
||
function buildPlainTextPartLines(message: MailMessage): string[] {
|
||
return ["Content-Type: text/plain; charset=utf-8", "Content-Transfer-Encoding: 8bit", "", normalizeMailNewlines(message.body || "")];
|
||
}
|
||
|
||
function buildHtmlPartLines(message: MailMessage): string[] {
|
||
return ["Content-Type: text/html; charset=utf-8", "Content-Transfer-Encoding: 8bit", "", normalizeMailNewlines(message.bodyHtml || "")];
|
||
}
|
||
|
||
function buildAlternativePartLines(boundary: string, message: MailMessage): string[] {
|
||
return [
|
||
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
||
"",
|
||
`--${boundary}`,
|
||
...buildPlainTextPartLines(message),
|
||
`--${boundary}`,
|
||
...buildHtmlPartLines(message),
|
||
`--${boundary}--`
|
||
];
|
||
}
|
||
|
||
function optionalAddressHeaders(message: MailMessage): string[] {
|
||
return [
|
||
...(message.cc?.trim() ? [`Cc: ${sanitizeHeaderValue(message.cc)}`] : []),
|
||
...(message.bcc?.trim() ? [`Bcc: ${sanitizeHeaderValue(message.bcc)}`] : []),
|
||
...(message.replyTo?.trim() ? [`Reply-To: ${sanitizeHeaderValue(message.replyTo)}`] : [])
|
||
];
|
||
}
|
||
|
||
function optionalThreadHeaders(message: MailMessage): string[] {
|
||
return [
|
||
...(message.messageId?.trim() ? [`Message-ID: ${sanitizeHeaderValue(message.messageId)}`] : []),
|
||
...(message.inReplyTo?.trim() ? [`In-Reply-To: ${sanitizeHeaderValue(message.inReplyTo)}`] : []),
|
||
...(message.references?.trim() ? [`References: ${sanitizeHeaderValue(message.references)}`] : [])
|
||
];
|
||
}
|
||
|
||
function mailThreadHeaders(headers: MailHeaders): Pick<MailMessage, "messageId" | "inReplyTo" | "references"> {
|
||
const messageId = sanitizeHeaderValue(headers["message-id"] || "");
|
||
const inReplyTo = sanitizeHeaderValue(headers["in-reply-to"] || "");
|
||
const references = sanitizeHeaderValue(headers.references || "");
|
||
|
||
return {
|
||
...(messageId ? { messageId } : {}),
|
||
...(inReplyTo ? { inReplyTo } : {}),
|
||
...(references ? { references } : {})
|
||
};
|
||
}
|
||
|
||
function optionalPriorityHeaders(message: MailMessage): string[] {
|
||
return [
|
||
...(message.importance?.trim() ? [`Importance: ${sanitizeHeaderValue(message.importance)}`] : []),
|
||
...(message.xPriority?.trim() ? [`X-Priority: ${sanitizeHeaderValue(message.xPriority)}`] : []),
|
||
...(message.priority?.trim() ? [`Priority: ${sanitizeHeaderValue(message.priority)}`] : [])
|
||
];
|
||
}
|
||
|
||
function mailPriorityHeaders(headers: MailHeaders): Pick<MailMessage, "importance" | "xPriority" | "priority"> {
|
||
const importance = sanitizeHeaderValue(headers.importance || "");
|
||
const xPriority = sanitizeHeaderValue(headers["x-priority"] || "");
|
||
const priority = sanitizeHeaderValue(headers.priority || "");
|
||
|
||
return {
|
||
...(importance ? { importance } : {}),
|
||
...(xPriority ? { xPriority } : {}),
|
||
...(priority ? { priority } : {})
|
||
};
|
||
}
|
||
|
||
export function decodeMimeWords(value: string): string {
|
||
return value
|
||
.replace(/(=\?[^?]+\?[bqBQ]\?[^?]+\?=)\s+(?==\?[^?]+\?[bqBQ]\?[^?]+\?=)/g, "$1")
|
||
.replace(/=\?([^?]+)\?([bqBQ])\?([^?]+)\?=/g, (match, charset: string, encoding: string, encoded: string) => {
|
||
const bytes = encoding.toLowerCase() === "b" ? base64ToBytes(encoded) : quotedPrintableToBytes(encoded.replace(/_/g, " "));
|
||
const decoded = decodeBytes(bytes, charset);
|
||
return decoded || match;
|
||
});
|
||
}
|
||
|
||
export function decodeTransferBody(body: string, encoding = "", charset = "utf-8"): string {
|
||
const normalizedEncoding = encoding.toLowerCase();
|
||
|
||
if (normalizedEncoding === "base64" || normalizedEncoding === "quoted-printable") {
|
||
return decodeBytes(decodeTransferBytes(body, normalizedEncoding), charset);
|
||
}
|
||
|
||
return normalizeMailNewlines(body);
|
||
}
|
||
|
||
export function htmlToPlainText(html: string): string {
|
||
const withBreaks = html
|
||
.replace(/<\s*(script|style)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, " ")
|
||
.replace(/<\s*br\s*\/?\s*>/gi, "\n")
|
||
.replace(/<\s*\/\s*(p|div|li|tr|h[1-6])\s*>/gi, "\n")
|
||
.replace(/<\s*li[^>]*>/gi, "• ")
|
||
.replace(/<[^>]+>/g, " ");
|
||
|
||
return decodeHtmlEntities(withBreaks)
|
||
.replace(/[ \t\f\v]+/g, " ")
|
||
.replace(/\n[ \t]+/g, "\n")
|
||
.replace(/[ \t]+\n/g, "\n")
|
||
.replace(/\n{3,}/g, "\n\n")
|
||
.trim();
|
||
}
|
||
|
||
export function normalizeMailNewlines(value: string): string {
|
||
return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||
}
|
||
|
||
function parseContentDisposition(value = ""): ParsedHeaderParameters {
|
||
return parseHeaderParameters(value, "");
|
||
}
|
||
|
||
function parseHeaderParameters(value: string, fallbackValue: string): ParsedHeaderParameters {
|
||
const [rawValue, ...rawParameters] = splitMimeHeaderParameters(value || fallbackValue);
|
||
const parameters: Record<string, string> = {};
|
||
|
||
rawParameters.forEach((parameter) => {
|
||
const equalsIndex = parameter.indexOf("=");
|
||
if (equalsIndex === -1) {
|
||
return;
|
||
}
|
||
|
||
const key = parameter.slice(0, equalsIndex).trim().toLowerCase();
|
||
const parameterValue = unquoteHeaderValue(parameter.slice(equalsIndex + 1).trim());
|
||
if (key) {
|
||
parameters[key] = parameterValue;
|
||
}
|
||
});
|
||
|
||
return {
|
||
value: (rawValue || fallbackValue).trim(),
|
||
parameters
|
||
};
|
||
}
|
||
|
||
function decodeMimeParameter(parameters: Record<string, string>, name: string): string | undefined {
|
||
const normalizedName = name.toLowerCase();
|
||
const encodedDirectValue = parameters[`${normalizedName}*`];
|
||
if (encodedDirectValue) {
|
||
return decodeRfc2231Value(encodedDirectValue);
|
||
}
|
||
|
||
const directValue = parameters[normalizedName];
|
||
if (directValue) {
|
||
return decodeMimeWords(directValue);
|
||
}
|
||
|
||
const continuations: Array<{ value: string; encoded: boolean }> = [];
|
||
for (let index = 0; ; index += 1) {
|
||
const encodedKey = `${normalizedName}*${index}*`;
|
||
const plainKey = `${normalizedName}*${index}`;
|
||
if (parameters[encodedKey] !== undefined) {
|
||
continuations.push({ value: parameters[encodedKey], encoded: true });
|
||
continue;
|
||
}
|
||
|
||
if (parameters[plainKey] !== undefined) {
|
||
continuations.push({ value: parameters[plainKey], encoded: false });
|
||
continue;
|
||
}
|
||
|
||
break;
|
||
}
|
||
|
||
if (continuations.length === 0) {
|
||
return undefined;
|
||
}
|
||
|
||
const combinedValue = continuations.map((part) => part.value).join("");
|
||
return continuations.some((part) => part.encoded) ? decodeRfc2231Value(combinedValue) : decodeMimeWords(combinedValue);
|
||
}
|
||
|
||
function decodeRfc2231Value(value: string): string {
|
||
const charsetMatch = /^([^']*)'[^']*'(.*)$/.exec(value);
|
||
const charset = charsetMatch?.[1] || "utf-8";
|
||
const encodedValue = charsetMatch?.[2] ?? value;
|
||
return decodeBytes(percentEncodedToBytes(encodedValue), charset);
|
||
}
|
||
|
||
function percentEncodedToBytes(value: string): Uint8Array {
|
||
const bytes: number[] = [];
|
||
|
||
for (let index = 0; index < value.length; index += 1) {
|
||
const char = value[index];
|
||
if (char === "%" && /^[\dA-Fa-f]{2}$/.test(value.slice(index + 1, index + 3))) {
|
||
bytes.push(Number.parseInt(value.slice(index + 1, index + 3), 16));
|
||
index += 2;
|
||
continue;
|
||
}
|
||
|
||
for (const byte of new TextEncoder().encode(char)) {
|
||
bytes.push(byte);
|
||
}
|
||
}
|
||
|
||
return new Uint8Array(bytes);
|
||
}
|
||
|
||
function decodeTransferBytes(body: string, encoding = ""): Uint8Array {
|
||
const normalizedEncoding = encoding.toLowerCase();
|
||
|
||
if (normalizedEncoding === "base64") {
|
||
return base64ToBytes(body.replace(/\s+/g, ""));
|
||
}
|
||
|
||
if (normalizedEncoding === "quoted-printable") {
|
||
return quotedPrintableToBytes(body);
|
||
}
|
||
|
||
return new TextEncoder().encode(normalizeMailNewlines(body));
|
||
}
|
||
|
||
function createMultipartBoundary(message: MailMessage): string {
|
||
const seed = `${message.id}-${message.subject}-${message.attachments?.length ?? 0}`;
|
||
const normalizedSeed = seed.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
||
return `qoffice-${normalizedSeed || "message"}`;
|
||
}
|
||
|
||
function createAlternativeBoundary(message: MailMessage): string {
|
||
return `${createMultipartBoundary(message)}-alternative`;
|
||
}
|
||
|
||
function buildAttachmentPartLines(boundary: string, attachment: MailAttachment): string[] {
|
||
const safeContentType = sanitizeMimeType(attachment.contentType);
|
||
const disposition = attachment.disposition === "inline" ? "inline" : "attachment";
|
||
const contentId = sanitizeHeaderValue(attachment.contentId ?? "");
|
||
return [
|
||
`--${boundary}`,
|
||
`Content-Type: ${safeContentType}; ${formatHeaderParameter("name", attachment.fileName)}`,
|
||
"Content-Transfer-Encoding: base64",
|
||
`Content-Disposition: ${disposition}; ${formatHeaderParameter("filename", attachment.fileName)}`,
|
||
...(contentId ? [`Content-ID: <${contentId}>`] : []),
|
||
"",
|
||
wrapBase64(attachment.contentBase64)
|
||
];
|
||
}
|
||
|
||
function formatHeaderParameter(name: string, value: string): string {
|
||
const sanitized = sanitizeHeaderValue(value || "Вложение");
|
||
if (/^[\x20-\x7E]*$/.test(sanitized)) {
|
||
return `${name}="${sanitized.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||
}
|
||
|
||
return `${name}*=utf-8''${percentEncodeUtf8(sanitized)}`;
|
||
}
|
||
|
||
function percentEncodeUtf8(value: string): string {
|
||
return Array.from(new TextEncoder().encode(value))
|
||
.map((byte) => `%${byte.toString(16).toUpperCase().padStart(2, "0")}`)
|
||
.join("");
|
||
}
|
||
|
||
function sanitizeMimeType(value = "application/octet-stream"): string {
|
||
const mediaType = parseContentType(value).mediaType;
|
||
return /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i.test(mediaType) ? mediaType : "application/octet-stream";
|
||
}
|
||
|
||
function wrapBase64(value: string): string {
|
||
return (value.match(/.{1,76}/g) ?? [""]).join("\r\n");
|
||
}
|
||
|
||
function createAttachmentId(fileName: string, index?: number): string {
|
||
const normalizedName = fileName.toLowerCase().replace(/[^a-z0-9а-яё]+/gi, "-").replace(/^-+|-+$/g, "");
|
||
const suffix = index === undefined ? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` : String(index + 1);
|
||
return `attachment-${normalizedName || "file"}-${suffix}`;
|
||
}
|
||
|
||
function encodeHeaderValue(value: string): string {
|
||
if (/^[\x20-\x7E]*$/.test(value)) {
|
||
return sanitizeHeaderValue(value);
|
||
}
|
||
|
||
const bytes = new TextEncoder().encode(value);
|
||
let binary = "";
|
||
for (const byte of bytes) {
|
||
binary += String.fromCharCode(byte);
|
||
}
|
||
|
||
return `=?UTF-8?B?${btoa(binary)}?=`;
|
||
}
|
||
|
||
function sanitizeHeaderValue(value: string): string {
|
||
return value.replace(/[\r\n]+/g, " ").trim();
|
||
}
|
||
|
||
function splitMimeHeaderParameters(value: string): string[] {
|
||
const segments: string[] = [];
|
||
let current = "";
|
||
let quoted = false;
|
||
let escaped = false;
|
||
|
||
for (const char of value) {
|
||
if (escaped) {
|
||
current += char;
|
||
escaped = false;
|
||
continue;
|
||
}
|
||
|
||
if (char === "\\") {
|
||
current += char;
|
||
escaped = quoted;
|
||
continue;
|
||
}
|
||
|
||
if (char === '"') {
|
||
quoted = !quoted;
|
||
current += char;
|
||
continue;
|
||
}
|
||
|
||
if (char === ";" && !quoted) {
|
||
segments.push(current.trim());
|
||
current = "";
|
||
continue;
|
||
}
|
||
|
||
current += char;
|
||
}
|
||
|
||
segments.push(current.trim());
|
||
return segments;
|
||
}
|
||
|
||
function unquoteHeaderValue(value: string): string {
|
||
if (value.startsWith('"') && value.endsWith('"')) {
|
||
return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
||
}
|
||
|
||
return value;
|
||
}
|
||
|
||
function isAttachmentPart(headers: MailHeaders): boolean {
|
||
const disposition = parseContentDisposition(headers["content-disposition"]);
|
||
const contentType = parseContentType(headers["content-type"]);
|
||
const dispositionValue = disposition.value.toLowerCase();
|
||
return (
|
||
dispositionValue === "attachment" ||
|
||
(dispositionValue === "inline" && Boolean(headers["content-id"])) ||
|
||
Boolean(decodeMimeParameter(disposition.parameters, "filename")) ||
|
||
Boolean(decodeMimeParameter(contentType.parameters, "name"))
|
||
);
|
||
}
|
||
|
||
function isMessageBodyPart(headers: MailHeaders): boolean {
|
||
const contentType = parseContentType(headers["content-type"]);
|
||
return contentType.mediaType.startsWith("multipart/") || contentType.mediaType === "text/plain" || contentType.mediaType === "text/html";
|
||
}
|
||
|
||
function decodeHtmlEntities(value: string): string {
|
||
const namedEntities: Record<string, string> = {
|
||
amp: "&",
|
||
gt: ">",
|
||
lt: "<",
|
||
nbsp: " ",
|
||
quot: '"',
|
||
apos: "'"
|
||
};
|
||
|
||
return value.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (match, entity: string) => {
|
||
const normalized = entity.toLowerCase();
|
||
if (normalized.startsWith("#x")) {
|
||
return String.fromCodePoint(Number.parseInt(normalized.slice(2), 16));
|
||
}
|
||
|
||
if (normalized.startsWith("#")) {
|
||
return String.fromCodePoint(Number.parseInt(normalized.slice(1), 10));
|
||
}
|
||
|
||
return namedEntities[normalized] ?? match;
|
||
});
|
||
}
|
||
|
||
function base64ToBytes(value: string): Uint8Array {
|
||
const binary = atob(value);
|
||
const bytes = new Uint8Array(binary.length);
|
||
|
||
for (let index = 0; index < binary.length; index += 1) {
|
||
bytes[index] = binary.charCodeAt(index);
|
||
}
|
||
|
||
return bytes;
|
||
}
|
||
|
||
function bytesToBase64(bytes: Uint8Array): string {
|
||
let binary = "";
|
||
const chunkSize = 0x8000;
|
||
|
||
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||
binary += String.fromCharCode(...bytes.slice(index, index + chunkSize));
|
||
}
|
||
|
||
return btoa(binary);
|
||
}
|
||
|
||
function quotedPrintableToBytes(value: string): Uint8Array {
|
||
const unfolded = value.replace(/=\n/g, "");
|
||
const bytes: number[] = [];
|
||
|
||
for (let index = 0; index < unfolded.length; index += 1) {
|
||
const char = unfolded[index];
|
||
if (char === "=" && /^[\dA-Fa-f]{2}$/.test(unfolded.slice(index + 1, index + 3))) {
|
||
bytes.push(Number.parseInt(unfolded.slice(index + 1, index + 3), 16));
|
||
index += 2;
|
||
continue;
|
||
}
|
||
|
||
bytes.push(char.charCodeAt(0));
|
||
}
|
||
|
||
return new Uint8Array(bytes);
|
||
}
|
||
|
||
function decodeUtf8Bytes(bytes: Uint8Array): string {
|
||
return decodeBytes(bytes, "utf-8");
|
||
}
|
||
|
||
function decodeBytes(bytes: Uint8Array, charset = "utf-8"): string {
|
||
try {
|
||
return new TextDecoder(charset || "utf-8", { fatal: false }).decode(bytes);
|
||
} catch {
|
||
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||
}
|
||
}
|
||
|
||
async function loadMsgReader(): Promise<MsgReaderConstructor> {
|
||
const module = (await import("@kenjiuno/msgreader")) as unknown as { default?: MsgReaderConstructor } & MsgReaderConstructor;
|
||
return module.default ?? module;
|
||
}
|