Files
SRCmail/app/api/sharing/route.ts
T
Bernd Rodler e7acf56753 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
2026-08-07 13:32:33 +02:00

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 };
}