feat: P2.3 Folder Sharing + P2.5 Email Import + P2.6 Contact Import + P2.7 Free/Busy
- 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
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user