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 { 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> { const existing = new Set(); 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 { 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 { 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 | 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; }