Merge remote-tracking branch 'origin/dev' into sync-github-and-ci-fix
This commit is contained in:
+6
-1
@@ -236,10 +236,15 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
||||
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
|
||||
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', type: 'string', defaultValue: '' },
|
||||
extensionDirectoryUrl: { envVar: 'EXTENSION_DIRECTORY_URL', type: 'url', defaultValue: 'https://extensions.bulwarkmail.org' },
|
||||
vnctalkServerUrl: { envVar: 'VNCTALK_SERVER_URL', type: 'url', defaultValue: '' },
|
||||
collaboraServerUrl: { envVar: 'COLLABORA_SERVER_URL', type: 'url', defaultValue: '' },
|
||||
vncdirectoryEnabled: { envVar: 'VNCDIRECTORY_ENABLED', type: 'boolean', defaultValue: false },
|
||||
vncdirectoryApiUrl: { envVar: 'VNCDIRECTORY_API_URL', type: 'url', defaultValue: '' },
|
||||
vncdirectorySamlEnabled: { envVar: 'VNCDIRECTORY_SAML_ENABLED', type: 'boolean', defaultValue: false },
|
||||
};
|
||||
|
||||
/** Keys that should never be exposed to the client config endpoint */
|
||||
export const SENSITIVE_CONFIG_KEYS = new Set(['oauthClientSecret', 'sessionSecret']);
|
||||
export const SENSITIVE_CONFIG_KEYS = new Set(['oauthClientSecret', 'sessionSecret', 'vncdirectoryApiKey', 'vncdirectoryLdapPassword']);
|
||||
|
||||
/** Admin session cookie name */
|
||||
export const ADMIN_SESSION_COOKIE = 'admin_session';
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readFile, writeFile, rename } from 'node:fs/promises';
|
||||
import { ensureStateDir, getStatePath } from '@/lib/admin/paths';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export interface VncDirectoryConfig {
|
||||
enabled: boolean;
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
samlEnabled: boolean;
|
||||
samlIdpUrl: string;
|
||||
samlSpCert: string;
|
||||
samlIssuer: string;
|
||||
ldapEnabled: boolean;
|
||||
ldapUri: string;
|
||||
ldapBindDn: string;
|
||||
ldapBindPassword: string;
|
||||
ldapSearchBase: string;
|
||||
ldapType: 'openldap' | 'ms-ad';
|
||||
tfaEnabled: boolean;
|
||||
oidcEnabled: boolean;
|
||||
oidcClientId: string;
|
||||
oidcDiscoveryUrl: string;
|
||||
sessionTtl: number;
|
||||
federatedApps: Record<string, string>;
|
||||
}
|
||||
|
||||
export const DEFAULT_VNCDIRECTORY_CONFIG: VncDirectoryConfig = {
|
||||
enabled: false,
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
samlEnabled: false,
|
||||
samlIdpUrl: '',
|
||||
samlSpCert: '',
|
||||
samlIssuer: '',
|
||||
ldapEnabled: false,
|
||||
ldapUri: '',
|
||||
ldapBindDn: '',
|
||||
ldapBindPassword: '',
|
||||
ldapSearchBase: '',
|
||||
ldapType: 'openldap',
|
||||
tfaEnabled: false,
|
||||
oidcEnabled: false,
|
||||
oidcClientId: '',
|
||||
oidcDiscoveryUrl: '',
|
||||
sessionTtl: 28800,
|
||||
federatedApps: {},
|
||||
};
|
||||
|
||||
/** Keys that should be masked when returning config to clients */
|
||||
export const VNCDIRECTORY_SENSITIVE_KEYS = new Set(['apiKey', 'ldapBindPassword']);
|
||||
|
||||
function applyEnvOverrides(config: VncDirectoryConfig): VncDirectoryConfig {
|
||||
const envEnabled = process.env.VNCDIRECTORY_ENABLED;
|
||||
if (envEnabled !== undefined) {
|
||||
config.enabled = envEnabled === 'true' || envEnabled === '1';
|
||||
}
|
||||
const envApiUrl = process.env.VNCDIRECTORY_API_URL;
|
||||
if (envApiUrl !== undefined) {
|
||||
config.apiUrl = envApiUrl;
|
||||
}
|
||||
const envSamlEnabled = process.env.VNCDIRECTORY_SAML_ENABLED;
|
||||
if (envSamlEnabled !== undefined) {
|
||||
config.samlEnabled = envSamlEnabled === 'true' || envSamlEnabled === '1';
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
async function readJsonFile(filename: string): Promise<Record<string, unknown> | null> {
|
||||
const filePath = getStatePath(filename);
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf-8');
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
logger.warn(`Failed to read ${filename} from state dir`, {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJsonFile(filename: string, data: Record<string, unknown>): Promise<void> {
|
||||
await ensureStateDir();
|
||||
const targetPath = getStatePath(filename);
|
||||
const tmpPath = targetPath + '.tmp';
|
||||
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
await rename(tmpPath, targetPath);
|
||||
}
|
||||
|
||||
export async function getVncDirectoryConfig(): Promise<VncDirectoryConfig> {
|
||||
const fileConfig = await readJsonFile('vncdirectory.json');
|
||||
const base = fileConfig
|
||||
? { ...DEFAULT_VNCDIRECTORY_CONFIG, ...fileConfig }
|
||||
: { ...DEFAULT_VNCDIRECTORY_CONFIG };
|
||||
return applyEnvOverrides(base);
|
||||
}
|
||||
|
||||
export async function saveVncDirectoryConfig(
|
||||
config: Partial<VncDirectoryConfig>,
|
||||
): Promise<void> {
|
||||
const current = await getVncDirectoryConfig();
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(DEFAULT_VNCDIRECTORY_CONFIG)) {
|
||||
const k = key as keyof VncDirectoryConfig;
|
||||
if (k in config) {
|
||||
merged[key] = config[k];
|
||||
} else {
|
||||
merged[key] = current[k];
|
||||
}
|
||||
}
|
||||
await writeJsonFile('vncdirectory.json', merged);
|
||||
}
|
||||
|
||||
export async function isVncDirectoryEnabled(): Promise<boolean> {
|
||||
const cfg = await getVncDirectoryConfig();
|
||||
return cfg.enabled;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
|
||||
export async function getCollaboraEditUrl(
|
||||
fileId: string,
|
||||
fileName: string
|
||||
): Promise<string> {
|
||||
const serverUrl =
|
||||
configManager.get<string>("collaboraServerUrl") ||
|
||||
process.env.COLLABORA_SERVER_URL ||
|
||||
"";
|
||||
|
||||
if (!serverUrl) {
|
||||
throw new Error("COLLABORA_SERVER_URL is not configured");
|
||||
}
|
||||
|
||||
const base = serverUrl.replace(/\/+$/, "");
|
||||
const fileExt = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
// Collabora WOPI host discovery endpoint
|
||||
const response = await fetch(`${base}/hosting/discovery`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Collabora discovery failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const discovery = await response.json();
|
||||
|
||||
// Find the WOPI action URL for the file extension
|
||||
let actionUrl: string | null = null;
|
||||
const mimeMap: Record<string, string> = {
|
||||
docx: "text",
|
||||
doc: "text",
|
||||
odt: "text",
|
||||
xlsx: "spreadsheet",
|
||||
xls: "spreadsheet",
|
||||
ods: "spreadsheet",
|
||||
pptx: "presentation",
|
||||
ppt: "presentation",
|
||||
odp: "presentation",
|
||||
};
|
||||
const docType = mimeMap[fileExt] || "text";
|
||||
|
||||
if (discovery.net?.zone) {
|
||||
const zones = Array.isArray(discovery.net.zone)
|
||||
? discovery.net.zone
|
||||
: [discovery.net.zone];
|
||||
for (const zone of zones) {
|
||||
const apps = Array.isArray(zone.app) ? zone.app : zone.app ? [zone.app] : [];
|
||||
for (const app of apps) {
|
||||
if (
|
||||
app.name &&
|
||||
docType &&
|
||||
app.name.toLowerCase().includes(docType.toLowerCase())
|
||||
) {
|
||||
const actions = Array.isArray(app.action)
|
||||
? app.action
|
||||
: app.action
|
||||
? [app.action]
|
||||
: [];
|
||||
for (const action of actions) {
|
||||
if (action.name === "edit" && action.urlsrc) {
|
||||
actionUrl = action.urlsrc;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (actionUrl) break;
|
||||
}
|
||||
if (actionUrl) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!actionUrl) {
|
||||
// Fallback: construct URL manually
|
||||
actionUrl = `${base}/loleaflet/dist/loleaflet.html`;
|
||||
}
|
||||
|
||||
// For now, return the base edit URL. A full WOPI implementation would
|
||||
// generate a WOPI src URL with an access token pointing back to this server.
|
||||
const wopiSrcUrl = `${actionUrl}?WOPISrc=${encodeURIComponent(
|
||||
`${process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`}/api/collabora/wopi/files/${encodeURIComponent(fileId)}`
|
||||
)}`;
|
||||
|
||||
return wopiSrcUrl;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
export interface Resource {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
type: 'room' | 'vehicle' | 'equipment' | 'other';
|
||||
location?: string;
|
||||
capacity?: number;
|
||||
description?: string;
|
||||
contactEmail?: string;
|
||||
isActive: boolean;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ResourceBooking {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
eventId?: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
bookedBy: string;
|
||||
}
|
||||
|
||||
interface ResourceRow {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
location: string | null;
|
||||
capacity: number | null;
|
||||
description: string | null;
|
||||
contact_email: string | null;
|
||||
is_active: boolean;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface BookingRow {
|
||||
id: string;
|
||||
resource_id: string;
|
||||
event_id: string | null;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
booked_by: string;
|
||||
}
|
||||
|
||||
function rowToResource(row: ResourceRow): Resource {
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId: row.tenant_id,
|
||||
name: row.name,
|
||||
type: row.type as Resource['type'],
|
||||
location: row.location ?? undefined,
|
||||
capacity: row.capacity ?? undefined,
|
||||
description: row.description ?? undefined,
|
||||
contactEmail: row.contact_email ?? undefined,
|
||||
isActive: row.is_active,
|
||||
metadata: row.metadata ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function rowToBooking(row: BookingRow): ResourceBooking {
|
||||
return {
|
||||
id: row.id,
|
||||
resourceId: row.resource_id,
|
||||
eventId: row.event_id ?? undefined,
|
||||
startTime: row.start_time,
|
||||
endTime: row.end_time,
|
||||
bookedBy: row.booked_by,
|
||||
};
|
||||
}
|
||||
|
||||
let pool: { query: (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }> } | null = null;
|
||||
|
||||
async function getPool(): Promise<{ query: (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }> } | null> {
|
||||
if (pool) return pool;
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (url) {
|
||||
try {
|
||||
// @ts-expect-error - pg is an optional runtime dependency, not in package.json
|
||||
const pg = (await import('pg')) as unknown as { Pool?: new (cfg: { connectionString: string; max: number }) => unknown; default?: { Pool?: new (cfg: { connectionString: string; max: number }) => unknown } };
|
||||
const PoolConstructor = (pg.Pool ?? pg.default?.Pool ?? null);
|
||||
if (PoolConstructor) {
|
||||
pool = new PoolConstructor({ connectionString: url, max: 10 }) as typeof pool;
|
||||
}
|
||||
console.log('[resources] PostgreSQL pool created');
|
||||
return pool;
|
||||
} catch {
|
||||
console.warn('[resources] pg module not available, falling back to in-memory store');
|
||||
}
|
||||
}
|
||||
console.warn('[resources] DATABASE_URL not set, using in-memory store');
|
||||
return null;
|
||||
}
|
||||
|
||||
const memoryResources: Map<string, ResourceRow> = new Map();
|
||||
const memoryBookings: Map<string, BookingRow> = new Map();
|
||||
|
||||
export async function listResources(tenantId: string, type?: string): Promise<Resource[]> {
|
||||
const db = await getPool();
|
||||
if (db) {
|
||||
let query = 'SELECT * FROM resources WHERE tenant_id = $1 AND is_active = true';
|
||||
const params: string[] = [tenantId];
|
||||
if (type) {
|
||||
query += ' AND type = $2';
|
||||
params.push(type);
|
||||
}
|
||||
query += ' ORDER BY name ASC';
|
||||
const result = await db.query(query, params);
|
||||
return (result.rows as ResourceRow[]).map(rowToResource);
|
||||
}
|
||||
|
||||
let resources = Array.from(memoryResources.values()).filter(r => r.tenant_id === tenantId && r.is_active);
|
||||
if (type) {
|
||||
resources = resources.filter(r => r.type === type);
|
||||
}
|
||||
resources.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return resources.map(rowToResource);
|
||||
}
|
||||
|
||||
export async function getResource(id: string): Promise<Resource | null> {
|
||||
const db = await getPool();
|
||||
if (db) {
|
||||
const result = await db.query('SELECT * FROM resources WHERE id = $1', [id]);
|
||||
if (result.rows.length === 0) return null;
|
||||
return rowToResource(result.rows[0] as ResourceRow);
|
||||
}
|
||||
|
||||
const row = memoryResources.get(id);
|
||||
return row ? rowToResource(row) : null;
|
||||
}
|
||||
|
||||
export async function createResource(
|
||||
tenantId: string,
|
||||
data: { name: string; type: Resource['type']; location?: string; capacity?: number; description?: string; contactEmail?: string; metadata?: Record<string, unknown> }
|
||||
): Promise<Resource> {
|
||||
const db = await getPool();
|
||||
if (db) {
|
||||
const result = await db.query(
|
||||
`INSERT INTO resources (id, tenant_id, name, type, location, capacity, description, contact_email, metadata)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
[tenantId, data.name, data.type, data.location ?? null, data.capacity ?? null, data.description ?? null, data.contactEmail ?? null, JSON.stringify(data.metadata ?? {})]
|
||||
);
|
||||
return rowToResource(result.rows[0] as ResourceRow);
|
||||
}
|
||||
|
||||
const id = generateUUID();
|
||||
const row: ResourceRow = {
|
||||
id,
|
||||
tenant_id: tenantId,
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
location: data.location ?? null,
|
||||
capacity: data.capacity ?? null,
|
||||
description: data.description ?? null,
|
||||
contact_email: data.contactEmail ?? null,
|
||||
is_active: true,
|
||||
metadata: data.metadata ?? {},
|
||||
};
|
||||
memoryResources.set(id, row);
|
||||
return rowToResource(row);
|
||||
}
|
||||
|
||||
export async function checkAvailability(
|
||||
resourceId: string,
|
||||
start: string,
|
||||
end: string,
|
||||
): Promise<{ available: boolean; conflicts: ResourceBooking[] }> {
|
||||
const db = await getPool();
|
||||
if (db) {
|
||||
const result = await db.query(
|
||||
`SELECT * FROM resources_bookings
|
||||
WHERE resource_id = $1
|
||||
AND start_time < $3::timestamptz
|
||||
AND end_time > $2::timestamptz
|
||||
ORDER BY start_time ASC`,
|
||||
[resourceId, start, end],
|
||||
);
|
||||
const conflicts = (result.rows as BookingRow[]).map(rowToBooking);
|
||||
return { available: conflicts.length === 0, conflicts };
|
||||
}
|
||||
|
||||
const conflicts = Array.from(memoryBookings.values())
|
||||
.filter(b => b.resource_id === resourceId && b.start_time < end && b.end_time > start)
|
||||
.sort((a, b) => a.start_time.localeCompare(b.start_time))
|
||||
.map(rowToBooking);
|
||||
return { available: conflicts.length === 0, conflicts };
|
||||
}
|
||||
|
||||
export async function bookResource(
|
||||
resourceId: string,
|
||||
start: string,
|
||||
end: string,
|
||||
bookedBy: string,
|
||||
eventId?: string,
|
||||
): Promise<ResourceBooking> {
|
||||
const db = await getPool();
|
||||
if (db) {
|
||||
const result = await db.query(
|
||||
`INSERT INTO resources_bookings (id, resource_id, event_id, start_time, end_time, booked_by)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3::timestamptz, $4::timestamptz, $5) RETURNING *`,
|
||||
[resourceId, eventId ?? null, start, end, bookedBy],
|
||||
);
|
||||
return rowToBooking(result.rows[0] as BookingRow);
|
||||
}
|
||||
|
||||
const id = generateUUID();
|
||||
const row: BookingRow = {
|
||||
id,
|
||||
resource_id: resourceId,
|
||||
event_id: eventId ?? null,
|
||||
start_time: start,
|
||||
end_time: end,
|
||||
booked_by: bookedBy,
|
||||
};
|
||||
memoryBookings.set(id, row);
|
||||
return rowToBooking(row);
|
||||
}
|
||||
|
||||
export async function cancelBooking(bookingId: string): Promise<void> {
|
||||
const db = await getPool();
|
||||
if (db) {
|
||||
await db.query('DELETE FROM resources_bookings WHERE id = $1', [bookingId]);
|
||||
return;
|
||||
}
|
||||
|
||||
memoryBookings.delete(bookingId);
|
||||
}
|
||||
|
||||
export async function getBookingsForResource(resourceId: string): Promise<ResourceBooking[]> {
|
||||
const db = await getPool();
|
||||
if (db) {
|
||||
const result = await db.query(
|
||||
'SELECT * FROM resources_bookings WHERE resource_id = $1 ORDER BY start_time ASC',
|
||||
[resourceId],
|
||||
);
|
||||
return (result.rows as BookingRow[]).map(rowToBooking);
|
||||
}
|
||||
|
||||
return Array.from(memoryBookings.values())
|
||||
.filter(b => b.resource_id === resourceId)
|
||||
.sort((a, b) => a.start_time.localeCompare(b.start_time))
|
||||
.map(rowToBooking);
|
||||
}
|
||||
|
||||
export async function getBookingsForEvent(eventId: string): Promise<ResourceBooking[]> {
|
||||
const db = await getPool();
|
||||
if (db) {
|
||||
const result = await db.query(
|
||||
'SELECT * FROM resources_bookings WHERE event_id = $1 ORDER BY start_time ASC',
|
||||
[eventId],
|
||||
);
|
||||
return (result.rows as BookingRow[]).map(rowToBooking);
|
||||
}
|
||||
|
||||
return Array.from(memoryBookings.values())
|
||||
.filter(b => b.event_id === eventId)
|
||||
.sort((a, b) => a.start_time.localeCompare(b.start_time))
|
||||
.map(rowToBooking);
|
||||
}
|
||||
|
||||
export async function cancelBookingsForEvent(eventId: string): Promise<void> {
|
||||
const db = await getPool();
|
||||
if (db) {
|
||||
await db.query('DELETE FROM resources_bookings WHERE event_id = $1', [eventId]);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [id, booking] of memoryBookings) {
|
||||
if (booking.event_id === eventId) {
|
||||
memoryBookings.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateBookingEventId(bookingId: string, eventId: string): Promise<void> {
|
||||
const db = await getPool();
|
||||
if (db) {
|
||||
await db.query('UPDATE resources_bookings SET event_id = $2 WHERE id = $1', [bookingId, eventId]);
|
||||
return;
|
||||
}
|
||||
|
||||
const row = memoryBookings.get(bookingId);
|
||||
if (row) {
|
||||
row.event_id = eventId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE IF NOT EXISTS resources (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('room', 'vehicle', 'equipment', 'other')),
|
||||
location TEXT,
|
||||
capacity INTEGER,
|
||||
description TEXT,
|
||||
contact_email TEXT,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS resources_bookings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
|
||||
event_id TEXT,
|
||||
start_time TIMESTAMPTZ NOT NULL,
|
||||
end_time TIMESTAMPTZ NOT NULL,
|
||||
booked_by TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_resources_tenant ON resources(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_resources_type ON resources(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_resource_time ON resources_bookings(resource_id, start_time, end_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_time ON resources_bookings(start_time, end_time);
|
||||
@@ -0,0 +1,56 @@
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
|
||||
export interface CreateVncMeetingParams {
|
||||
name: string;
|
||||
start: string;
|
||||
end: string;
|
||||
invitees: string[];
|
||||
password?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CreateVncMeetingResult {
|
||||
meetingUrl: string;
|
||||
meetingId: string;
|
||||
}
|
||||
|
||||
export async function createVncMeeting(
|
||||
params: CreateVncMeetingParams
|
||||
): Promise<CreateVncMeetingResult> {
|
||||
const serverUrl = configManager.get<string>("vnctalkServerUrl") || process.env.VNCTALK_SERVER_URL || "";
|
||||
|
||||
if (!serverUrl) {
|
||||
throw new Error("VNCTALK_SERVER_URL is not configured");
|
||||
}
|
||||
|
||||
const endpoint = `${serverUrl.replace(/\/+$/, "")}/api/createnewmeeting`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: params.name,
|
||||
start: params.start,
|
||||
end: params.end,
|
||||
invitees: params.invitees,
|
||||
password: params.password,
|
||||
description: params.description,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`VNCtalk API error ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const meetingUrl: string = data.meetingUrl || data.meeting_url || data.url || "";
|
||||
const meetingId: string = data.meetingId || data.meeting_id || data.id || "";
|
||||
|
||||
if (!meetingUrl) {
|
||||
throw new Error("VNCtalk API did not return a meeting URL");
|
||||
}
|
||||
|
||||
return { meetingUrl, meetingId };
|
||||
}
|
||||
Reference in New Issue
Block a user