import type { MailAttachment, MailMessage } from "../types"; import { EML_MIME_TYPE } from "./fileHelpers"; type MailHeaders = Record; interface ParsedContentType { mediaType: string; parameters: Record; } interface ParsedHeaderParameters { value: string; parameters: Record; } 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 { 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 { 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 { 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, 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 { 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, 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 = { 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) { 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 { 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 { 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 = {}; 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, 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 = { 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 { const module = (await import("@kenjiuno/msgreader")) as unknown as { default?: MsgReaderConstructor } & MsgReaderConstructor; return module.default ?? module; }