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:
@@ -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 />}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user