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
+180
View File
@@ -0,0 +1,180 @@
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { CalendarEvent } from "@/lib/jmap/types";
import { addMinutes } from "date-fns";
export interface FreeBusySlot {
start: Date;
end: Date;
status: "free" | "busy" | "tentative" | "unavailable" | "unknown";
}
const SLOT_MINUTES = 30;
function clampToSlotStart(d: Date): Date {
const clone = new Date(d);
clone.setSeconds(0, 0);
const mins = clone.getMinutes();
const remainder = mins % SLOT_MINUTES;
if (remainder !== 0) {
clone.setMinutes(mins - remainder, 0, 0);
}
return clone;
}
function buildSlots(start: Date, end: Date): FreeBusySlot[] {
const slots: FreeBusySlot[] = [];
let cursor = new Date(start);
while (cursor < end) {
const slotEnd = addMinutes(cursor, SLOT_MINUTES);
slots.push({
start: new Date(cursor),
end: slotEnd > end ? new Date(end) : slotEnd,
status: "unknown",
});
cursor = slotEnd;
}
return slots;
}
interface EventRange {
start: Date;
end: Date;
freeBusyStatus: CalendarEvent["freeBusyStatus"];
eventStatus: CalendarEvent["status"];
}
function getEventRange(event: CalendarEvent): EventRange {
return {
start: new Date(event.start),
end: new Date(new Date(event.start).getTime() + parseDurationMs(event.duration)),
freeBusyStatus: event.freeBusyStatus,
eventStatus: event.status,
};
}
function parseDurationMs(duration: string): number {
let ms = 0;
let sign = 1;
let s = duration;
if (s.startsWith("-")) {
sign = -1;
s = s.slice(1);
}
if (s.startsWith("+")) s = s.slice(1);
if (!s.startsWith("P")) return 0;
s = s.slice(1);
const tIdx = s.indexOf("T");
const datePart = tIdx >= 0 ? s.slice(0, tIdx) : s;
const timePart = tIdx >= 0 ? s.slice(tIdx + 1) : "";
let num = "";
for (const ch of datePart) {
if (ch >= "0" && ch <= "9") {
num += ch;
} else {
const v = parseInt(num, 10) || 0;
if (ch === "W") ms += v * 7 * 24 * 60 * 60 * 1000;
else if (ch === "D") ms += v * 24 * 60 * 60 * 1000;
num = "";
}
}
for (const ch of timePart) {
if (ch >= "0" && ch <= "9") {
num += ch;
} else {
const v = parseInt(num, 10) || 0;
if (ch === "H") ms += v * 60 * 60 * 1000;
else if (ch === "M") ms += v * 60 * 1000;
else if (ch === "S") ms += v * 1000;
num = "";
}
}
return ms * sign;
}
function eventsOverlap(eventStart: Date, eventEnd: Date, slotStart: Date, slotEnd: Date): boolean {
return eventStart < slotEnd && eventEnd > slotStart;
}
function slotStatusFromEvent(
event: CalendarEvent,
participantStatus: string | null
): FreeBusySlot["status"] {
if (event.status === "cancelled") return "free";
if (participantStatus === "declined") return "free";
if (participantStatus === "tentative") return "tentative";
if (event.freeBusyStatus === "free") return "free";
if (event.freeBusyStatus === "busy") return "busy";
if (participantStatus === "accepted") return "busy";
if (participantStatus === "needs-action") return "tentative";
return "busy";
}
export async function fetchFreeBusy(
client: IJMAPClient,
participants: { email: string }[],
start: Date,
end: Date
): Promise<Map<string, FreeBusySlot[]>> {
const result = new Map<string, FreeBusySlot[]>();
const slots = buildSlots(clampToSlotStart(start), end);
for (const p of participants) {
const key = p.email.toLowerCase();
const participantSlots: FreeBusySlot[] = slots.map((s) => ({
start: new Date(s.start),
end: new Date(s.end),
status: "unknown" as const,
}));
result.set(key, participantSlots);
}
try {
const events = await client.queryAllCalendarEvents(
{ after: start.toISOString(), before: end.toISOString() },
[{ property: "start", isAscending: true }]
);
for (const event of events) {
if (event.status === "cancelled") continue;
if (!event.participants) continue;
const range = getEventRange(event);
for (const key of result.keys()) {
const participant = Object.values(event.participants).find(
(p) => p.email.toLowerCase() === key
);
if (!participant) continue;
const status = slotStatusFromEvent(event, participant.participationStatus);
const participantSlots = result.get(key)!;
for (const slot of participantSlots) {
if (eventsOverlap(range.start, range.end, slot.start, slot.end)) {
if (status === "busy" || slot.status === "unknown") {
slot.status = status;
} else if (status === "tentative" && slot.status === "free") {
slot.status = "tentative";
}
}
}
}
}
} catch {
// Return unknown statuses for all slots on fetch failure
}
return result;
}
export function isWorkingHour(hour: number, workStart = 8, workEnd = 18): boolean {
return hour >= workStart && hour < workEnd;
}
+290
View File
@@ -0,0 +1,290 @@
import type { ContactCard, NameComponent } from "@/lib/jmap/types";
import { generateUUID } from "@/lib/utils";
export interface CsvColumnMapping {
firstName: number;
lastName: number;
email: number;
phone: number;
company: number;
jobTitle: number;
address: number;
city: number;
region: number;
postcode: number;
country: number;
website: number;
note: number;
nickname: number;
}
export interface CsvParseResult {
headers: string[];
rows: string[][];
delimiter: string;
totalRows: number;
}
function detectDelimiter(text: string): string {
const line = text.split("\n")[0] || "";
const counts: Record<string, number> = { ",": 0, ";": 0, "\t": 0 };
for (const ch of line) {
if (ch in counts) counts[ch]++;
}
const best = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
return best && best[1] > 0 ? best[0] : ",";
}
function parseCsvLine(line: string, delimiter: string): string[] {
const fields: string[] = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"') {
if (i + 1 < line.length && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = false;
}
} else {
current += ch;
}
} else if (ch === '"') {
inQuotes = true;
} else if (ch === delimiter) {
fields.push(current.trim());
current = "";
} else {
current += ch;
}
}
fields.push(current.trim());
return fields;
}
export function parseCSV(text: string): CsvParseResult {
const delimiter = detectDelimiter(text);
const rawLines = text.split(/\r?\n/);
const headers = parseCsvLine(rawLines[0] || "", delimiter);
const rows: string[][] = [];
for (let i = 1; i < rawLines.length; i++) {
const line = rawLines[i].trim();
if (!line) continue;
const fields = parseCsvLine(line, delimiter);
if (fields.length > 0 && fields.some((f) => f.length > 0)) {
rows.push(fields);
}
}
return { headers, rows, delimiter, totalRows: rows.length };
}
const NAME_PATTERNS = [
/^(?:first[\s_-]?name|given[\s_-]?name|forename|vorname|prénom|nombre|名)$/i,
];
const LAST_NAME_PATTERNS = [
/^(?:last[\s_-]?name|surname|family[\s_-]?name|nachname|nom|姓)$/i,
];
const EMAIL_PATTERNS = [
/^(?:e?-?mail|email[\s_-]?address|e?-?mail[\s_-]?address|e-mail-adresse)$/i,
];
const PHONE_PATTERNS = [
/^(?:phone|telephone|tel|mobile|cell|handy|telefon|téléphone|电话)$/i,
];
const COMPANY_PATTERNS = [
/^(?:company|organization|org|firma|unternehmen|entreprise|société|公司)$/i,
];
const JOB_TITLE_PATTERNS = [
/^(?:job[\s_-]?title|title|position|role|funktion|beruf|poste)$/i,
];
const ADDRESS_PATTERNS = [
/^(?:address|addr|street|straße|adresse|rue)$/i,
];
const CITY_PATTERNS = [
/^(?:city|town|ort|stadt|ville)$/i,
];
const REGION_PATTERNS = [
/^(?:state|province|region|bundesland|région)$/i,
];
const POSTCODE_PATTERNS = [
/^(?:zip|postal[\s_-]?code|postcode|plz|code[\s_-]?postal)$/i,
];
const COUNTRY_PATTERNS = [
/^(?:country|land|pays)$/i,
];
const WEBSITE_PATTERNS = [
/^(?:website|url|web|homepage|site)$/i,
];
const NOTE_PATTERNS = [
/^(?:note|notes|comments|bemerkung|notiz|remarque)$/i,
];
const NICKNAME_PATTERNS = [
/^(?:nickname|nick|alias|spitzname|surnom)$/i,
];
function findColumnIndex(headers: string[], patterns: RegExp[]): number {
for (const pattern of patterns) {
const idx = headers.findIndex((h) => pattern.test(h));
if (idx >= 0) return idx;
}
return -1;
}
export function autoMapColumns(headers: string[]): CsvColumnMapping {
return {
firstName: findColumnIndex(headers, NAME_PATTERNS),
lastName: findColumnIndex(headers, LAST_NAME_PATTERNS),
email: findColumnIndex(headers, EMAIL_PATTERNS),
phone: findColumnIndex(headers, PHONE_PATTERNS),
company: findColumnIndex(headers, COMPANY_PATTERNS),
jobTitle: findColumnIndex(headers, JOB_TITLE_PATTERNS),
address: findColumnIndex(headers, ADDRESS_PATTERNS),
city: findColumnIndex(headers, CITY_PATTERNS),
region: findColumnIndex(headers, REGION_PATTERNS),
postcode: findColumnIndex(headers, POSTCODE_PATTERNS),
country: findColumnIndex(headers, COUNTRY_PATTERNS),
website: findColumnIndex(headers, WEBSITE_PATTERNS),
note: findColumnIndex(headers, NOTE_PATTERNS),
nickname: findColumnIndex(headers, NICKNAME_PATTERNS),
};
}
function getCol(row: string[], colIndex: number): string {
if (colIndex < 0 || colIndex >= row.length) return "";
return row[colIndex]?.trim() || "";
}
export function mapRowToContact(
row: string[],
mapping: CsvColumnMapping,
addressBookIds: Record<string, boolean>,
): ContactCard | null {
const id = `import-csv-${generateUUID()}`;
const firstName = getCol(row, mapping.firstName);
const lastName = getCol(row, mapping.lastName);
const email = getCol(row, mapping.email);
const phone = getCol(row, mapping.phone);
const company = getCol(row, mapping.company);
const jobTitle = getCol(row, mapping.jobTitle);
const address = getCol(row, mapping.address);
const city = getCol(row, mapping.city);
const region = getCol(row, mapping.region);
const postcode = getCol(row, mapping.postcode);
const country = getCol(row, mapping.country);
const website = getCol(row, mapping.website);
const note = getCol(row, mapping.note);
const nickname = getCol(row, mapping.nickname);
if (!email && !firstName && !lastName) return null;
const components: NameComponent[] = [];
if (firstName) components.push({ kind: "given", value: firstName });
if (lastName) components.push({ kind: "surname", value: lastName });
const contact: ContactCard = {
id,
addressBookIds,
};
if (components.length > 0) {
contact.name = { components, isOrdered: true };
} else if (email) {
contact.name = { full: email.split("@")[0] };
}
if (email) {
contact.emails = {
e0: { address: email },
};
}
if (phone) {
contact.phones = {
p0: { number: phone },
};
}
if (company) {
contact.organizations = {
o0: { name: company },
};
}
if (jobTitle) {
contact.titles = {
t0: { name: jobTitle, kind: "title" },
};
}
if (address || city || region || postcode || country) {
contact.addresses = {
a0: {
street: address || undefined,
locality: city || undefined,
region: region || undefined,
postcode: postcode || undefined,
country: country || undefined,
},
};
}
if (website) {
contact.onlineServices = {
u0: { uri: website },
};
}
if (note) {
contact.notes = {
n0: { note },
};
}
if (nickname) {
contact.nicknames = {
n0: { name: nickname },
};
}
return contact;
}
export function detectDuplicatesByEmail(
existingContacts: ContactCard[],
incoming: ContactCard[],
): Map<number, string> {
const dupes = new Map<number, string>();
const existingEmails = new Map<string, string>();
for (const c of existingContacts) {
if (c.emails) {
for (const e of Object.values(c.emails)) {
existingEmails.set(e.address.toLowerCase(), c.id);
}
}
}
incoming.forEach((card, idx) => {
if (card.emails) {
for (const e of Object.values(card.emails)) {
const match = existingEmails.get(e.address.toLowerCase());
if (match) {
dupes.set(idx, match);
return;
}
}
}
});
return dupes;
}
+2
View File
@@ -1037,6 +1037,8 @@ export class DemoJMAPClient implements IJMAPClient {
return [...this.data.fileNodes];
}
async setMailboxShare(): Promise<void> { /* demo: no-op */ }
async setFileNodeShare(): Promise<void> { /* demo: no-op */ }
async getFileNodes(ids: string[] | null): Promise<FileNode[]> {
+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;
}
+95 -1
View File
@@ -13,6 +13,14 @@ function isZipName(name: string): boolean {
return /\.zip$/i.test(name);
}
function isTgzName(name: string): boolean {
return /\.(tgz|tar\.gz)$/i.test(name);
}
function isArchiveName(name: string): boolean {
return isZipName(name) || isTgzName(name);
}
async function extractEmlsFromZip(file: File): Promise<ImportableEmail[]> {
const { default: JSZip } = await import("jszip");
const zip = await JSZip.loadAsync(await file.arrayBuffer());
@@ -30,6 +38,88 @@ async function extractEmlsFromZip(file: File): Promise<ImportableEmail[]> {
return out;
}
async function gunzip(buffer: ArrayBuffer): Promise<ArrayBuffer> {
try {
const ds = new DecompressionStream("gzip");
const writer = ds.writable.getWriter();
const reader = ds.readable.getReader();
writer.write(new Uint8Array(buffer));
writer.close();
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result.buffer;
} catch {
throw new Error("Failed to decompress gzip archive");
}
}
interface TarEntry {
name: string;
type: string;
data: ArrayBuffer;
}
function parseTar(buffer: ArrayBuffer): TarEntry[] {
const entries: TarEntry[] = [];
const view = new Uint8Array(buffer);
let offset = 0;
while (offset + 512 <= view.byteLength) {
const header = new Uint8Array(buffer, offset, 512);
const name = new TextDecoder().decode(header.subarray(0, 100)).replace(/\0.*$/, "");
const type = String.fromCharCode(header[156] || 0) || "0";
if (!name) break;
let sizeStr = "";
for (let i = 124; i < 136; i++) {
const ch = String.fromCharCode(header[i]);
if (ch === "\0" || ch === " ") break;
sizeStr += ch;
}
const size = parseInt(sizeStr || "0", 8);
offset += 512;
if (size > 0 && type === "0" && isEmlName(name)) {
const data = buffer.slice(offset, offset + size);
entries.push({
name: name.split(/[\\/]/).pop() || name,
type: "file",
data,
});
}
offset += Math.ceil(size / 512) * 512;
}
return entries;
}
async function extractEmlsFromTgz(file: File): Promise<ImportableEmail[]> {
const buffer = await file.arrayBuffer();
const decompressed = await gunzip(buffer);
const entries = parseTar(decompressed);
return entries.map((entry) => ({
name: entry.name,
blob: new Blob([entry.data], { type: EMAIL_MIME }),
}));
}
export async function expandImportableEmails(
files: File[],
): Promise<ImportableEmail[]> {
@@ -39,10 +129,14 @@ export async function expandImportableEmails(
out.push(...(await extractEmlsFromZip(file)));
continue;
}
if (isTgzName(file.name) || file.type === "application/gzip" || file.type === "application/x-gtar") {
out.push(...(await extractEmlsFromTgz(file)));
continue;
}
const blob = new Blob([await file.arrayBuffer()], { type: EMAIL_MIME });
out.push({ name: file.name, blob });
}
return out;
}
export const EML_IMPORT_ACCEPT = ".eml,.zip,message/rfc822,application/zip";
export const EML_IMPORT_ACCEPT = ".eml,.zip,.tgz,.tar.gz,message/rfc822,application/zip,application/gzip";
+2 -1
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
/**
@@ -315,6 +315,7 @@ export interface IJMAPClient {
setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise<void>;
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise<void>;
setMailboxShare(mailboxId: string, principalId: string, rights: MailboxRights | null, targetAccountId?: string): Promise<void>;
// ── Accounts (primary + shared/group) ────────────────────────
getSharedAccounts(): SharedAccount[];
+29 -1
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils";
@@ -4426,6 +4426,34 @@ export class JMAPClient implements IJMAPClient {
}
}
/**
* Add, update, or remove a principal's rights on a mailbox folder.
* Pass `rights: null` to revoke access.
*/
async setMailboxShare(
mailboxId: string,
principalId: string,
rights: MailboxRights | null,
targetAccountId?: string,
): Promise<void> {
const accountId = targetAccountId || this.accountId;
const response = await this.request([
["Mailbox/set", {
accountId,
update: { [mailboxId]: { [`shareWith/${principalId}`]: rights } },
}, "0"],
]);
const result = response.methodResponses?.[0]?.[1];
if (result?.notUpdated?.[mailboxId]) {
const err = result.notUpdated[mailboxId];
throw new Error(err.description || "Failed to update mailbox share");
}
if (!result?.updated || !(mailboxId in result.updated)) {
throw new Error("Server did not confirm the share update");
}
}
private async fetchPaginatedContacts(
accountId: string,
filter?: Record<string, unknown>,
+15 -11
View File
@@ -181,6 +181,19 @@ export interface Attachment {
disposition?: string;
}
export interface MailboxRights {
mayReadItems: boolean;
mayAddItems: boolean;
mayRemoveItems: boolean;
maySetSeen: boolean;
maySetKeywords: boolean;
mayCreateChild: boolean;
mayRename: boolean;
mayDelete: boolean;
maySubmit: boolean;
mayShare?: boolean;
}
export interface Mailbox {
id: string;
originalId?: string; // Original JMAP ID (for shared mailboxes)
@@ -192,22 +205,13 @@ export interface Mailbox {
unreadEmails: number;
totalThreads: number;
unreadThreads: number;
myRights: {
mayReadItems: boolean;
mayAddItems: boolean;
mayRemoveItems: boolean;
maySetSeen: boolean;
maySetKeywords: boolean;
mayCreateChild: boolean;
mayRename: boolean;
mayDelete: boolean;
maySubmit: boolean;
};
myRights: MailboxRights;
isSubscribed: boolean;
// Shared folder support
accountId?: string;
accountName?: string;
isShared?: boolean;
shareWith?: Record<string, MailboxRights> | null;
}
export interface Thread {