- P2.3: Folder sharing system — ShareFolderDialog, sharing-store, sharing-settings - P2.5: Email import (.eml, .tgz, .zip) with dedup and progress - P2.6: Contact import (vCard + CSV) with auto-mapping - P2.7: Free/Busy view grid with color-coded slots
250 lines
6.8 KiB
TypeScript
250 lines
6.8 KiB
TypeScript
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
|
import type { Mailbox } from "@/lib/jmap/types";
|
|
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 }>;
|
|
}
|
|
|
|
function toBase64(buffer: ArrayBuffer): string {
|
|
let binary = "";
|
|
const bytes = new Uint8Array(buffer);
|
|
for (let i = 0; i < bytes.byteLength; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
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;
|
|
}
|