Initial QOffice implementation

This commit is contained in:
Курнат Андрей
2026-06-01 05:31:05 +03:00
commit 9bf4dd3672
44 changed files with 16854 additions and 0 deletions
+797
View File
@@ -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;
}