- 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
362 lines
8.4 KiB
TypeScript
362 lines
8.4 KiB
TypeScript
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 };
|
|
}
|