diff --git a/app/api/calendar-agenda/route.ts b/app/api/calendar-agenda/route.ts new file mode 100644 index 00000000..4f39e455 --- /dev/null +++ b/app/api/calendar-agenda/route.ts @@ -0,0 +1,334 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization'; +import { expandRecurringEvents } from '@/lib/recurrence-expansion'; +import { parseISO } from 'date-fns'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +/** + * POST /api/calendar-agenda + * + * Sidecar for the "Calendar Agenda" plugin. Resolves the caller's calendar + * account from the stored Stalwart auth context, queries upcoming + * CalendarEvents over JMAP, expands recurring series server-side, and returns + * a slim, structured-cloneable agenda the plugin can render directly. + * + * Credentials never leave the server — the sandboxed plugin only ever sees + * the resulting agenda DTOs. + * + * Body: { days?: number (1-90, default 7), limit?: number (1-200, default 50) } + */ + +const CALENDAR_CAP = 'urn:ietf:params:jmap:calendars'; +const PRINCIPALS_CAP = 'urn:ietf:params:jmap:principals'; + +// Mirror of lib/jmap/client.ts CALENDAR_EVENT_PROPERTIES, trimmed to what the +// agenda actually needs (start/recurrence/display fields). +const EVENT_PROPERTIES = [ + 'id', '@type', 'uid', 'calendarIds', 'title', 'start', 'duration', 'timeZone', + 'showWithoutTime', 'utcStart', 'utcEnd', 'status', 'freeBusyStatus', 'color', + 'locations', 'recurrenceId', 'recurrenceIdTimeZone', 'recurrenceRule', + 'recurrenceOverrides', 'excludedRecurrenceRule', +] as const; + +interface JmapSession { + apiUrl?: string; + primaryAccounts?: Record; + capabilities?: Record; +} + +interface AgendaEvent { + id: string; + uid: string | null; + title: string; + start: string; + end: string; + allDay: boolean; + status: string | null; + color: string | null; + location: string | null; + calendarId: string | null; +} + +// Pure, server-safe equivalents of lib/calendar-utils' getEventStartDate / +// getEventEndDate. We can't import that module here because it transitively +// pulls in a "use client" calendar component (which throws at load on the +// server). Logic mirrors the originals. +function parseDurationMinutes(duration: string | undefined): number { + if (!duration) return 0; + let total = 0; + const week = duration.match(/(\d+)W/); + const day = duration.match(/(\d+)D/); + const hour = duration.match(/(\d+)H/); + const min = duration.match(/(\d+)M/); + if (week) total += parseInt(week[1], 10) * 7 * 24 * 60; + if (day) total += parseInt(day[1], 10) * 24 * 60; + if (hour) total += parseInt(hour[1], 10) * 60; + if (min) total += parseInt(min[1], 10); + return total; +} + +function eventStart(event: Partial): Date { + if (!event.showWithoutTime && event.utcStart) { + const utc = parseISO(event.utcStart); + if (!isNaN(utc.getTime())) return utc; + } + return parseISO(event.start as string); +} + +function eventEnd(event: Partial): Date { + if (!event.showWithoutTime && event.utcEnd) { + const utc = parseISO(event.utcEnd); + if (!isNaN(utc.getTime())) return utc; + } + const start = eventStart(event); + if (!event.duration) return start; + return new Date(start.getTime() + parseDurationMinutes(event.duration) * 60000); +} + +function firstLocationName(event: Partial): string | null { + const locations = event.locations; + if (!locations || typeof locations !== 'object') return null; + for (const loc of Object.values(locations)) { + const name = (loc as { name?: unknown })?.name; + if (typeof name === 'string' && name.trim()) return name.trim(); + } + return null; +} + +function firstCalendarId(event: Partial): string | null { + const ids = event.calendarIds; + if (!ids || typeof ids !== 'object') return null; + const keys = Object.keys(ids); + return keys.length > 0 ? keys[0] : null; +} + +export async function POST(request: NextRequest) { + try { + const creds = await getStalwartCredentials(request); + if (!creds) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + let body: { days?: unknown; limit?: unknown } = {}; + try { + body = await request.json(); + } catch { + /* empty body is fine */ + } + const days = clampInt(body.days, 1, 90, 7); + const limit = clampInt(body.limit, 1, 200, 50); + + // ── Resolve the calendar account from the JMAP session ── + // Hit Stalwart's canonical session endpoint on the SAME host as serverUrl. + // We deliberately avoid /.well-known/jmap: it 301s to the server's + // configured public hostname, which the host process may not be able to + // resolve (the browser client rewrites those URLs back to the origin for + // the same reason). Fall back to /.well-known/jmap for non-Stalwart servers. + const session = await fetchJmapSession(creds.serverUrl, creds.authHeader); + if (!session) { + return NextResponse.json({ error: 'JMAP session fetch failed' }, { status: 502 }); + } + const accountId = session.primaryAccounts?.[CALENDAR_CAP]; + if (!accountId) { + // No calendar account for this user — return an empty agenda, not an error. + return NextResponse.json({ events: [], generatedAt: new Date().toISOString() }); + } + + const using = ['urn:ietf:params:jmap:core', CALENDAR_CAP]; + if (session.capabilities && PRINCIPALS_CAP in session.capabilities) { + using.push('urn:ietf:params:jmap:principals:owner'); + } + + // Send method calls to the same-origin JMAP endpoint the app's passthrough + // uses — never to session.apiUrl's (possibly unreachable) public host. + const apiUrl = `${creds.serverUrl}/jmap/`; + + const now = new Date(); + const horizon = new Date(now.getTime() + days * 24 * 60 * 60 * 1000); + // Expand from the start of today so all-day / already-running events still + // show in the agenda. + const windowStart = new Date(now); + windowStart.setHours(0, 0, 0, 0); + + // ── 1) Query event IDs in range + load calendars (colours) ── + const queryReq = { + using, + methodCalls: [ + [ + 'CalendarEvent/query', + { + accountId, + // Mirror the app's calendar store: an { after, before } window lets + // Stalwart evaluate recurrence so masters with occurrences in range + // are returned (a `before`-only filter can drop unbounded series). + filter: { after: windowStart.toISOString(), before: horizon.toISOString() }, + limit: 1000, + }, + '0', + ], + [ + 'Calendar/get', + { accountId, ids: null, properties: ['id', 'name', 'color'] }, + 'c', + ], + ], + }; + + const queryRes = await jmapPost(apiUrl, creds.authHeader, queryReq); + const queryResp = findResponse(queryRes, 'CalendarEvent/query', '0'); + if (!queryResp) { + const err = findResponse(queryRes, 'error', '0'); + return NextResponse.json( + { error: (err?.description as string) || 'CalendarEvent/query failed' }, + { status: 502 }, + ); + } + const ids = (queryResp.ids as string[]) || []; + + const calColors = new Map(); + const calResp = findResponse(queryRes, 'Calendar/get', 'c'); + for (const cal of ((calResp?.list as Array>) || [])) { + if (typeof cal.id === 'string') { + calColors.set(cal.id, { + name: typeof cal.name === 'string' ? cal.name : '', + color: typeof cal.color === 'string' ? cal.color : null, + }); + } + } + + if (ids.length === 0) { + return NextResponse.json({ events: [], generatedAt: now.toISOString() }); + } + + // ── 2) Fetch full event objects (batched) ── + const raw: Array> = []; + const BATCH = 100; + for (let i = 0; i < ids.length; i += BATCH) { + const batch = ids.slice(i, i + BATCH); + const getRes = await jmapPost(apiUrl, creds.authHeader, { + using, + methodCalls: [ + ['CalendarEvent/get', { accountId, ids: batch, properties: EVENT_PROPERTIES }, '0'], + ], + }); + const getResp = findResponse(getRes, 'CalendarEvent/get', '0'); + if (getResp?.list) raw.push(...(getResp.list as Array>)); + } + + // ── 3) Normalize + expand recurrences server-side ── + const normalized = raw + .map((e) => normalizeCalendarEventLike(e as Partial)) + .filter((e) => (e['@type'] ?? 'Event') === 'Event') + // Drop malformed events without a parseable start (would crash format()/ + // parseISO downstream) — mirrors the calendar store guard (#316). + .filter((e) => typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())) as CalendarEvent[]; + + const expanded = expandRecurringEvents( + normalized, + windowStart.toISOString(), + horizon.toISOString(), + ); + + // ── 4) Keep ongoing/upcoming, sort, slice, map to DTOs ── + const agenda: AgendaEvent[] = expanded + .filter((e) => eventEnd(e).getTime() >= now.getTime()) + .sort((a, b) => eventStart(a).getTime() - eventStart(b).getTime()) + .slice(0, limit) + .map((e) => { + const calId = firstCalendarId(e); + const cal = calId ? calColors.get(calId) : undefined; + return { + id: String(e.id ?? ''), + uid: e.uid ?? null, + title: (e.title ?? '').trim() || '(no title)', + start: eventStart(e).toISOString(), + end: eventEnd(e).toISOString(), + allDay: !!e.showWithoutTime, + status: e.status ?? null, + color: e.color || cal?.color || null, + location: firstLocationName(e), + calendarId: calId, + }; + }); + + return NextResponse.json({ events: agenda, generatedAt: now.toISOString() }); + } catch (error) { + // `fetch failed` from undici is too generic to debug — the real reason + // (ENOTFOUND, ECONNREFUSED, self-signed TLS, …) lives on `error.cause`. + const err = error as Error & { cause?: { code?: string; message?: string } }; + logger.error('Calendar agenda error', { + error: err?.message ?? 'Unknown', + causeCode: err?.cause?.code, + causeMessage: err?.cause?.message, + }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +function clampInt(value: unknown, min: number, max: number, fallback: number): number { + const n = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, Math.round(n))); +} + +/** + * Fetch the JMAP session from the same host as `serverUrl`. Tries Stalwart's + * canonical /jmap/session first (no redirect), then /.well-known/jmap as a + * fallback for other servers. Returns null if neither yields a usable session. + */ +async function fetchJmapSession( + serverUrl: string, + authHeader: string, +): Promise { + const candidates = [`${serverUrl}/jmap/session`, `${serverUrl}/.well-known/jmap`]; + for (const url of candidates) { + try { + const res = await fetch(url, { + method: 'GET', + headers: { Authorization: authHeader }, + redirect: 'follow', + }); + if (!res.ok) continue; + const session = (await res.json()) as JmapSession; + if (session && typeof session === 'object' && session.primaryAccounts) { + return session; + } + } catch { + // Try the next candidate (e.g. canonical path 404s on a non-Stalwart server). + } + } + return null; +} + +async function jmapPost( + apiUrl: string, + authHeader: string, + payload: unknown, +): Promise { + const res = await fetch(apiUrl, { + method: 'POST', + headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + throw new Error(`JMAP request failed (${res.status})`); + } + return res.json(); +} + +function findResponse( + res: unknown, + name: string, + callId: string, +): Record | null { + const responses = (res as { methodResponses?: unknown[] })?.methodResponses; + if (!Array.isArray(responses)) return null; + for (const entry of responses) { + if (Array.isArray(entry) && entry[0] === name && entry[2] === callId) { + return entry[1] as Record; + } + } + return null; +} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 75dafef2..3a1fc8c4 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1034,12 +1034,29 @@ export function EmailViewer({ const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState([]); const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false); - // Plugin detail sidebar state + // Plugin detail sidebar state. Collapsed/width persist across opens and + // sessions so the panel reopens the way the user last left it. const detailSlots = usePluginSlotOffers('email-detail-sidebar'); const hasDetailSidebar = detailSlots.length > 0; - const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(false); - const [detailSidebarWidth, setDetailSidebarWidth] = useState(280); - const detailSidebarWidthRef = useRef(280); + const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(() => { + if (typeof window === 'undefined') return false; + try { return localStorage.getItem('emailDetailSidebarCollapsed') === '1'; } catch { return false; } + }); + const [detailSidebarWidth, setDetailSidebarWidth] = useState(() => { + if (typeof window === 'undefined') return 280; + try { + const n = parseInt(localStorage.getItem('emailDetailSidebarWidth') ?? '', 10); + return Number.isFinite(n) ? Math.max(200, Math.min(500, n)) : 280; + } catch { return 280; } + }); + const detailSidebarWidthRef = useRef(detailSidebarWidth); + + useEffect(() => { + try { localStorage.setItem('emailDetailSidebarCollapsed', detailSidebarCollapsed ? '1' : '0'); } catch { /* ignore */ } + }, [detailSidebarCollapsed]); + useEffect(() => { + try { localStorage.setItem('emailDetailSidebarWidth', String(detailSidebarWidth)); } catch { /* ignore */ } + }, [detailSidebarWidth]); // Ensure S/MIME key records are loaded from IndexedDB useLayoutEffect(() => {