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:
Bernd Rodler
2026-08-07 13:32:33 +02:00
parent 83e29b3ef1
commit e7acf56753
21 changed files with 3321 additions and 43 deletions
+249
View File
@@ -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;
}