Files
SRCmail/lib/email-import.ts
T
Bernd Rodler b98ab59f0d fix: Phase 2 QA — all 25 remaining HIGH/MEDIUM/LOW issues
HIGH fixes (7):
- H1: VNCdirectory admin i18n — 30+ translation keys added
- H2: handleSave try/catch with error toast
- H3: Free/busy accountId scoping
- H4: cancelEventBookings filter by eventId
- H5: Resource picker static apiFetch import
- H6: Sharing-store toast messages via lastMessage state
- H7: roleLabel for all resource types

MEDIUM fixes (11):
- M1: identitySignatureMap cleanup on delete
- M2: Now-line relative positioning
- M3: Radial menu disabled item keyboard nav
- M4: Radial menu stable event listener via refs
- M5: cancelBooking error on missing booking
- M6: PasswordRow isMasked state flag
- M7: Extract shared rights into lib/sharing-rights.ts
- M8: VNCtalk client server-side guard
- M9: Collabora configManager instead of process.env
- M10: CONFIG_ENV_MAP VNCdirectory fields
- M11: SENSITIVE_CONFIG_KEYS field name unification

LOW fixes (7):
- L1-L3: Unused imports removed
- L4: aria-labels on close, clear, search, spinner
- L5-L7: Comments for intentional patterns, null guard
2026-08-07 14:21:07 +02:00

240 lines
6.5 KiB
TypeScript

import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { expandImportableEmails } from "@/lib/eml-import";
export type ConflictResolution = "skip" | "replace" | "copy";
export interface ImportProgress {
total: number;
processed: number;
imported: number;
skipped: number;
failed: number;
currentFile: string;
}
export interface ImportResult {
imported: number;
skipped: number;
failed: number;
errors: Array<{ file: string; error: string }>;
}
interface ParsedEml {
messageId: string | null;
subject: string;
from: string;
to: string;
cc: string;
date: string;
bodyPlain: string;
bodyHtml: string;
raw: Blob;
}
async function parseEml(file: Blob): Promise<ParsedEml> {
const { default: PostalMime } = await import("postal-mime");
const buffer = await file.arrayBuffer();
const parsed = await PostalMime.parse(buffer);
return {
messageId: (parsed.messageId || null) as string | null,
subject: parsed.subject || "(No Subject)",
from: typeof parsed.from === "object" && parsed.from?.address
? `${parsed.from.name || ""} <${parsed.from.address}>`.trim()
: String(parsed.from || ""),
to: Array.isArray(parsed.to)
? parsed.to.map((r: { address?: string; name?: string }) =>
r.name ? `${r.name} <${r.address}>` : r.address || ""
).join(", ")
: "",
cc: Array.isArray(parsed.cc)
? parsed.cc.map((r: { address?: string; name?: string }) =>
r.name ? `${r.name} <${r.address}>` : r.address || ""
).join(", ")
: "",
date: parsed.date || "",
bodyPlain: parsed.text || "",
bodyHtml: parsed.html || "",
raw: file,
};
}
async function findExistingMessageIds(
client: IJMAPClient,
messageIds: string[],
): Promise<Set<string>> {
const existing = new Set<string>();
const batchSize = 50;
for (let i = 0; i < messageIds.length; i += batchSize) {
const batch = messageIds.slice(i, i + batchSize);
try {
const conditions = batch.map((id) => ({
header: ["Message-ID", `<${id}>`] as [string, string],
}));
const filter = conditions.length === 1
? conditions[0]
: { operator: "OR", conditions };
const { emails } = await client.advancedSearchEmails(filter, undefined, batchSize);
for (const email of emails) {
if (email.messageId && batch.includes(email.messageId)) {
existing.add(email.messageId);
}
}
} catch {
// best-effort dedup lookup
}
}
return existing;
}
function generateRfc822FromParsed(eml: ParsedEml): Blob {
const lines: string[] = [];
lines.push(`From: ${eml.from}`);
if (eml.to) lines.push(`To: ${eml.to}`);
if (eml.cc) lines.push(`Cc: ${eml.cc}`);
if (eml.messageId) lines.push(`Message-ID: <${eml.messageId}>`);
lines.push(`Date: ${eml.date || new Date().toUTCString()}`);
lines.push(`Subject: ${eml.subject}`);
lines.push("MIME-Version: 1.0");
if (eml.bodyHtml) {
const boundary = `----=_Boundary_${Date.now().toString(36)}`;
lines.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
lines.push("");
lines.push(`--${boundary}`);
lines.push("Content-Type: text/plain; charset=utf-8");
lines.push("Content-Transfer-Encoding: quoted-printable");
lines.push("");
lines.push(eml.bodyPlain);
lines.push(`--${boundary}`);
lines.push("Content-Type: text/html; charset=utf-8");
lines.push("Content-Transfer-Encoding: quoted-printable");
lines.push("");
lines.push(eml.bodyHtml);
lines.push(`--${boundary}--`);
} else {
lines.push("Content-Type: text/plain; charset=utf-8");
lines.push("Content-Transfer-Encoding: quoted-printable");
lines.push("");
lines.push(eml.bodyPlain);
}
return new Blob([lines.join("\r\n")], { type: "message/rfc822" });
}
async function extractMessageIdFromEml(blob: Blob): Promise<string | null> {
try {
const text = await blob.text();
const match = text.match(/^Message-ID:\s*(.+)$/im);
if (match) {
return match[1].trim().replace(/^<+/, "").replace(/>+$/, "");
}
} catch {
// best-effort message-id extraction
}
return null;
}
export async function importEmails({
client,
files,
destinationMailboxId,
conflictResolution,
onProgress,
signal,
}: {
client: IJMAPClient;
files: File[];
destinationMailboxId: string;
conflictResolution: ConflictResolution;
onProgress?: (progress: ImportProgress) => void;
signal?: AbortSignal;
}): Promise<ImportResult> {
const result: ImportResult = { imported: 0, skipped: 0, failed: 0, errors: [] };
const progress: ImportProgress = {
total: 0,
processed: 0,
imported: 0,
skipped: 0,
failed: 0,
currentFile: "",
};
const importables = await expandImportableEmails(files);
progress.total = importables.length;
onProgress?.({ ...progress });
if (importables.length === 0) {
return result;
}
const duplicateCheck = conflictResolution !== "copy";
let existingMessageIds: Set<string> | null = null;
if (duplicateCheck) {
const messageIds: string[] = [];
for (const item of importables) {
if (signal?.aborted) break;
const msgId = await extractMessageIdFromEml(item.blob);
if (msgId) messageIds.push(msgId);
}
existingMessageIds = await findExistingMessageIds(client, messageIds);
}
for (const item of importables) {
if (signal?.aborted) break;
progress.currentFile = item.name;
progress.processed++;
onProgress?.({ ...progress });
try {
const msgId = await extractMessageIdFromEml(item.blob);
if (duplicateCheck && msgId && existingMessageIds?.has(msgId)) {
if (conflictResolution === "skip") {
progress.skipped++;
result.skipped++;
onProgress?.({ ...progress });
continue;
}
}
let emlBlob = item.blob;
try {
const parsed = await parseEml(item.blob);
emlBlob = generateRfc822FromParsed(parsed);
} catch {
// best-effort dedup lookup
}
const file = new File([emlBlob], item.name, { type: "message/rfc822" });
const { blobId } = await client.uploadBlob(file);
await client.importEmail(
blobId,
{ [destinationMailboxId]: true },
{ "$seen": true },
);
progress.imported++;
result.imported++;
} catch (err) {
progress.failed++;
result.failed++;
result.errors.push({
file: item.name,
error: err instanceof Error ? err.message : "Unknown error",
});
}
onProgress?.({ ...progress });
}
return result;
}