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

957 lines
31 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 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;
}