Initial QOffice implementation
This commit is contained in:
@@ -0,0 +1,797 @@
|
||||
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);
|
||||
|
||||
return {
|
||||
id: `eml-${Date.now()}`,
|
||||
folder: "inbox",
|
||||
from: decodeMimeWords(parsed.headers.from || ""),
|
||||
to: decodeMimeWords(parsed.headers.to || ""),
|
||||
subject: decodeMimeWords(parsed.headers.subject || "Без темы"),
|
||||
body: extractMessageBody(parsed.headers, parsed.body),
|
||||
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) : {};
|
||||
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 || ""),
|
||||
subject: firstText(data.subject, decodeMimeWords(headerFields.subject || ""), "Без темы"),
|
||||
body: msgBodyText(data),
|
||||
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))
|
||||
.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 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 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) => !recipient.recipType || 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 ?? [];
|
||||
if (attachments.length > 0) {
|
||||
const boundary = createMultipartBoundary(message);
|
||||
const lines = [
|
||||
`From: ${sanitizeHeaderValue(message.from)}`,
|
||||
`To: ${sanitizeHeaderValue(message.to)}`,
|
||||
`Subject: ${encodeHeaderValue(message.subject || "Без темы")}`,
|
||||
`Date: ${sanitizeHeaderValue(message.time || new Date().toUTCString())}`,
|
||||
"MIME-Version: 1.0",
|
||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
||||
"",
|
||||
`--${boundary}`,
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
"Content-Transfer-Encoding: 8bit",
|
||||
"",
|
||||
normalizeMailNewlines(message.body || ""),
|
||||
...attachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)),
|
||||
`--${boundary}--`,
|
||||
""
|
||||
];
|
||||
|
||||
return lines.join("\r\n");
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`From: ${sanitizeHeaderValue(message.from)}`,
|
||||
`To: ${sanitizeHeaderValue(message.to)}`,
|
||||
`Subject: ${encodeHeaderValue(message.subject || "Без темы")}`,
|
||||
`Date: ${sanitizeHeaderValue(message.time || new Date().toUTCString())}`,
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
"Content-Transfer-Encoding: 8bit",
|
||||
"",
|
||||
normalizeMailNewlines(message.body || "")
|
||||
];
|
||||
|
||||
return lines.join("\r\n");
|
||||
}
|
||||
|
||||
export function decodeMimeWords(value: string): string {
|
||||
return value.replace(/=\?([^?]+)\?([bqBQ])\?([^?]+)\?=/g, (_match, charset: string, encoding: string, encoded: string) => {
|
||||
const normalizedCharset = charset.toLowerCase();
|
||||
if (normalizedCharset !== "utf-8" && normalizedCharset !== "utf8") {
|
||||
return _match;
|
||||
}
|
||||
|
||||
if (encoding.toLowerCase() === "b") {
|
||||
return decodeUtf8Bytes(base64ToBytes(encoded));
|
||||
}
|
||||
|
||||
return decodeUtf8Bytes(quotedPrintableToBytes(encoded.replace(/_/g, " ")));
|
||||
});
|
||||
}
|
||||
|
||||
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 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"]);
|
||||
return (
|
||||
disposition.value.toLowerCase() === "attachment" ||
|
||||
Boolean(decodeMimeParameter(disposition.parameters, "filename")) ||
|
||||
Boolean(decodeMimeParameter(contentType.parameters, "name"))
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user