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
+16
View File
@@ -35,6 +35,8 @@ import {
SwatchBook,
Download,
Sparkles,
Upload,
Share2,
X,
type LucideIcon,
} from 'lucide-react';
@@ -71,6 +73,8 @@ import { PluginsSettings } from '@/components/settings/plugins-settings';
import { AiAssistantSettings } from '@/components/settings/ai-assistant-settings';
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { ImportSettings } from '@/components/settings/import-settings';
import { SharingSettings } from '@/components/settings/sharing-settings';
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
@@ -115,6 +119,8 @@ type Tab =
| 'about_data'
| 'themes'
| 'plugins'
| 'import'
| 'sharing'
| 'ai_assistant'
| 'debug';
@@ -159,6 +165,8 @@ const tabIcons: Record<Tab, LucideIcon> = {
about_data: Info,
themes: SwatchBook,
plugins: Puzzle,
import: Upload,
sharing: Share2,
ai_assistant: Sparkles,
debug: Bug,
};
@@ -243,6 +251,8 @@ const tabSearchPaths: Record<Tab, string[]> = {
themes: [],
plugins: [],
ai_assistant: [],
import: ['settings.importer'],
sharing: ['sharing'],
debug: ['settings.advanced'],
};
@@ -275,6 +285,8 @@ const tabKeywords: Record<Tab, string> = {
themes: 'custom theme css skin appearance',
plugins: 'extensions addons',
ai_assistant: 'assistant ask model llm ollama chatbot',
import: 'import email eml zip tgz mbox csv vcard contacts',
sharing: 'share shared folder calendar address book permission',
debug: 'logs developer console diagnostic',
};
@@ -624,6 +636,7 @@ export default function SettingsPage() {
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
{ id: 'sharing', label: t('tabs.sharing'), icon: tabIcons.sharing, group: 'general' },
{ id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' },
// Appearance
@@ -641,6 +654,7 @@ export default function SettingsPage() {
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []),
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' },
{ id: 'import', label: t('tabs.import'), icon: tabIcons.import, group: 'mail' },
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []),
// Privacy & Security
@@ -772,6 +786,8 @@ export default function SettingsPage() {
{effectiveActiveTab === 'filters' && <FilterSettings />}
{effectiveActiveTab === 'templates' && <TemplateSettings />}
{effectiveActiveTab === 'folders' && <FolderSettings />}
{effectiveActiveTab === 'import' && <ImportSettings />}
{effectiveActiveTab === 'sharing' && <SharingSettings />}
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
+361
View File
@@ -0,0 +1,361 @@
import type { NextRequest } from "next/server";
type JmapMethodCall = [string, Record<string, unknown>, string];
async function jmapRequest(
serverUrl: string,
authHeader: string,
methodCalls: JmapMethodCall[],
using?: string[],
) {
const sessionResp = await fetch(`${serverUrl}/.well-known/jmap`, {
headers: { Authorization: authHeader },
});
if (!sessionResp.ok) {
return { error: `Session fetch failed: ${sessionResp.status}` };
}
const session = await sessionResp.json();
const apiUrl = session.apiUrl;
if (!apiUrl) {
return { error: "No API URL in JMAP session" };
}
const body = {
using: using || [
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:ietf:params:jmap:principals",
],
methodCalls,
};
const resp = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: authHeader,
},
body: JSON.stringify(body),
});
if (!resp.ok) {
return { error: `JMAP request failed: ${resp.status}` };
}
return await resp.json();
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const action = searchParams.get("action");
const serverUrl = request.headers.get("X-JMAP-Server-Url");
const authHeader = request.headers.get("Authorization");
if (!serverUrl || !authHeader) {
return Response.json(
{ error: "Missing server URL or auth header" },
{ status: 400 },
);
}
if (action !== "principals") {
return Response.json(
{ error: "Invalid action" },
{ status: 400 },
);
}
const result = await jmapRequest(serverUrl, authHeader, [
["Principal/query", { accountId: "" }, "0"],
["Principal/get", {
accountId: "",
"#ids": {
resultOf: "0",
name: "Principal/query",
path: "/ids",
},
}, "1"],
]);
if ("error" in result) {
return Response.json(result, { status: 502 });
}
const getResp = (result as Record<string, unknown>).methodResponses as Array<[string, Record<string, unknown>, string]> | undefined;
const principals = getResp?.find((r) => r[0] === "Principal/get")?.[1]
?.list ?? [];
return Response.json({ principals });
}
export async function POST(request: NextRequest) {
const serverUrl = request.headers.get("X-JMAP-Server-Url");
const authHeader = request.headers.get("Authorization");
if (!serverUrl || !authHeader) {
return Response.json(
{ error: "Missing server URL or auth header" },
{ status: 400 },
);
}
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { kind, resourceId, principalId, role } = body;
if (!kind || !resourceId || !principalId) {
return Response.json(
{ error: "Missing required fields: kind, resourceId, principalId" },
{ status: 400 },
);
}
let method: string;
let shareProperty: string;
switch (kind) {
case "mailbox":
method = "Mailbox/set";
shareProperty = "shareWith";
break;
case "calendar":
method = "Calendar/set";
shareProperty = "shareWith";
break;
case "addressBook":
method = "AddressBook/set";
shareProperty = "shareWith";
break;
case "file":
method = "FileNode/set";
shareProperty = "shareWith";
break;
default:
return Response.json(
{ error: `Invalid kind: ${kind}` },
{ status: 400 },
);
}
const patchValue = role === null ? null : buildRights(kind as string, role as string);
const methodCalls: JmapMethodCall[] = [
[
method,
{
accountId: "",
update: {
[resourceId as string]: {
[`${shareProperty}/${principalId}`]: patchValue,
},
},
},
"0",
],
];
const result = await jmapRequest(
serverUrl,
authHeader,
methodCalls,
);
if ("error" in result) {
return Response.json(result, { status: 502 });
}
const responses = (result as Record<string, unknown>).methodResponses as Array<[string, Record<string, unknown>, string]> | undefined;
const setResult = responses?.[0]?.[1];
if (
setResult &&
typeof setResult === "object" &&
"notUpdated" in setResult &&
setResult.notUpdated &&
typeof setResult.notUpdated === "object" &&
(resourceId as string) in setResult.notUpdated
) {
const err = (setResult.notUpdated as Record<string, Record<string, unknown>>)[resourceId as string];
return Response.json(
{ error: err.description || "Failed to update share" },
{ status: 400 },
);
}
return Response.json({ ok: true });
}
function buildRights(
kind: string,
role: string,
): Record<string, boolean> | null {
if (role === null) return null;
switch (kind) {
case "mailbox":
return mailboxRights(role);
case "calendar":
return calendarRights(role);
case "addressBook":
return addressBookRights(role);
case "file":
return fileRights(role);
default:
return readRights();
}
}
function mailboxRights(role: string): Record<string, boolean> {
switch (role) {
case "read":
return {
mayReadItems: true,
mayAddItems: false,
mayRemoveItems: false,
maySetSeen: false,
maySetKeywords: false,
mayCreateChild: false,
mayRename: false,
mayDelete: false,
maySubmit: false,
};
case "readWrite":
return {
mayReadItems: true,
mayAddItems: true,
mayRemoveItems: false,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: false,
mayRename: false,
mayDelete: false,
maySubmit: true,
};
case "manager":
return {
mayReadItems: true,
mayAddItems: true,
mayRemoveItems: true,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: true,
mayRename: true,
mayDelete: true,
maySubmit: true,
mayShare: true,
};
default:
return mailboxRights("read");
}
}
function calendarRights(role: string): Record<string, boolean> {
switch (role) {
case "read":
return {
mayReadFreeBusy: true,
mayReadItems: true,
mayWriteAll: false,
mayWriteOwn: false,
mayUpdatePrivate: false,
mayRSVP: false,
mayShare: false,
mayDelete: false,
};
case "readWrite":
return {
mayReadFreeBusy: true,
mayReadItems: true,
mayWriteAll: true,
mayWriteOwn: true,
mayUpdatePrivate: true,
mayRSVP: true,
mayShare: false,
mayDelete: false,
};
case "manager":
return {
mayReadFreeBusy: true,
mayReadItems: true,
mayWriteAll: true,
mayWriteOwn: true,
mayUpdatePrivate: true,
mayRSVP: true,
mayShare: true,
mayDelete: true,
};
default:
return calendarRights("read");
}
}
function addressBookRights(role: string): Record<string, boolean> {
switch (role) {
case "read":
return {
mayRead: true,
mayWrite: false,
mayShare: false,
mayDelete: false,
};
case "readWrite":
return {
mayRead: true,
mayWrite: true,
mayShare: false,
mayDelete: false,
};
case "manager":
return {
mayRead: true,
mayWrite: true,
mayShare: true,
mayDelete: true,
};
default:
return addressBookRights("read");
}
}
function fileRights(role: string): Record<string, boolean> {
switch (role) {
case "read":
return {
mayRead: true,
mayAddChildren: false,
mayRename: false,
mayDelete: false,
mayModifyContent: false,
mayShare: false,
};
case "readWrite":
return {
mayRead: true,
mayAddChildren: true,
mayRename: true,
mayDelete: true,
mayModifyContent: true,
mayShare: false,
};
case "manager":
return {
mayRead: true,
mayAddChildren: true,
mayRename: true,
mayDelete: true,
mayModifyContent: true,
mayShare: true,
};
default:
return fileRights("read");
}
}
function readRights(): Record<string, boolean> {
return { mayRead: true };
}