diff --git a/app/api/calendar-agenda/route.ts b/app/api/calendar-agenda/route.ts index 4415a0ed6..7c2f199ca 100644 --- a/app/api/calendar-agenda/route.ts +++ b/app/api/calendar-agenda/route.ts @@ -12,10 +12,15 @@ 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 + * accounts 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. * + * Events come from every account that has calendars: the primary calendar + * account plus shared and group accounts (a family or team calendar), the + * same set the calendar app shows. Ids are scoped by account, since event + * and calendar ids repeat across accounts. + * * Credentials never leave the server — the sandboxed plugin only ever sees * the resulting agenda DTOs. * @@ -35,7 +40,9 @@ const EVENT_PROPERTIES = [ ] as const; interface AgendaEvent { + /** Unique across accounts: `:`. */ id: string; + accountId: string; uid: string | null; title: string; start: string; @@ -131,8 +138,8 @@ export async function POST(request: NextRequest) { if (!session) { return NextResponse.json({ error: 'JMAP session fetch failed' }, { status: 502 }); } - const accountId = session.primaryAccounts?.[CALENDAR_CAP]; - if (!accountId) { + const accountIds = calendarAccountIds(session); + if (accountIds.length === 0) { // No calendar account for this user — return an empty agenda, not an error. return NextResponse.json({ events: [], generatedAt: new Date().toISOString() }); } @@ -153,85 +160,94 @@ export async function POST(request: NextRequest) { 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, fetchOptions); - 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[]) || []; - + // ── 1–3) Per account: query ids in range, load calendars (colours), + // fetch the events, normalize and expand recurrences server-side ── 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, - }); - } - } + const expanded: Array = []; - 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, { + for (const accountId of accountIds) { + const queryReq = { using, methodCalls: [ - ['CalendarEvent/get', { accountId, ids: batch, properties: EVENT_PROPERTIES }, '0'], + [ + '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', + ], ], - }, fetchOptions); - const getResp = findResponse(getRes, 'CalendarEvent/get', '0'); - if (getResp?.list) raw.push(...(getResp.list as Array>)); + }; + + const queryRes = await jmapPost(apiUrl, creds.authHeader, queryReq, fetchOptions); + const queryResp = findResponse(queryRes, 'CalendarEvent/query', '0'); + if (!queryResp) { + const err = findResponse(queryRes, 'error', '0'); + if (accountId !== accountIds[0]) { + // A shared account the caller can see but not read calendars from + // (Stalwart answers forbidden/accountNotSupported) must not empty + // the agenda for the accounts that do work. + logger.debug('Calendar agenda: skipping account', { + accountId, + error: (err?.type as string) || 'CalendarEvent/query failed', + }); + continue; + } + return NextResponse.json( + { error: (err?.description as string) || 'CalendarEvent/query failed' }, + { status: 502 }, + ); + } + const ids = (queryResp.ids as string[]) || []; + + const calResp = findResponse(queryRes, 'Calendar/get', 'c'); + for (const cal of ((calResp?.list as Array>) || [])) { + if (typeof cal.id === 'string') { + calColors.set(scopedId(accountId, cal.id), { + name: typeof cal.name === 'string' ? cal.name : '', + color: typeof cal.color === 'string' ? cal.color : null, + }); + } + } + + if (ids.length === 0) continue; + + 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'], + ], + }, fetchOptions); + const getResp = findResponse(getRes, 'CalendarEvent/get', '0'); + if (getResp?.list) raw.push(...(getResp.list as Array>)); + } + + 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[]; + + for (const e of expandRecurringEvents(normalized, windowStart.toISOString(), horizon.toISOString())) { + expanded.push({ ...e, accountId }); + } } - // ── 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()) @@ -239,9 +255,10 @@ export async function POST(request: NextRequest) { .slice(0, limit) .map((e) => { const calId = firstCalendarId(e); - const cal = calId ? calColors.get(calId) : undefined; + const cal = calId ? calColors.get(scopedId(e.accountId, calId)) : undefined; return { - id: String(e.id ?? ''), + id: scopedId(e.accountId, String(e.id ?? '')), + accountId: e.accountId, uid: e.uid ?? null, title: (e.title ?? '').trim() || '(no title)', start: eventStart(e).toISOString(), @@ -268,6 +285,31 @@ export async function POST(request: NextRequest) { } } +/** + * The accounts to read calendars from, primary first. Mirrors the client's + * getCalendarCapableAccountIds(): shared and group accounts count even when + * Stalwart does not advertise the calendar capability on them. + */ +function calendarAccountIds(session: { + primaryAccounts?: Record; + accounts?: Record; +}): string[] { + const primary = session.primaryAccounts?.[CALENDAR_CAP]; + const ids: string[] = primary ? [primary] : []; + for (const [id, value] of Object.entries(session.accounts ?? {})) { + if (id === primary || !value || typeof value !== 'object') continue; + const account = value as { isPersonal?: unknown; accountCapabilities?: Record }; + if (account.accountCapabilities?.[CALENDAR_CAP] || account.isPersonal === false) { + ids.push(id); + } + } + return ids; +} + +function scopedId(accountId: string, id: string): string { + return `${accountId}:${id}`; +} + 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; diff --git a/lib/__tests__/calendar-agenda-route.test.ts b/lib/__tests__/calendar-agenda-route.test.ts index bb1264526..a59749083 100644 --- a/lib/__tests__/calendar-agenda-route.test.ts +++ b/lib/__tests__/calendar-agenda-route.test.ts @@ -27,14 +27,70 @@ const mockPost = postJmap as unknown as Mock; const CALENDAR_CAP = 'urn:ietf:params:jmap:calendars'; -function makeSession(capabilities: Record) { +function makeSession(capabilities: Record, accounts?: Record) { return { capabilities, primaryAccounts: { [CALENDAR_CAP]: 'acct-1' }, + accounts, apiUrl: 'https://mail.example.com/jmap/', }; } +const BASE_CAPS = { 'urn:ietf:params:jmap:core': {}, [CALENDAR_CAP]: {} }; + +/** An event tomorrow, as Stalwart returns it (local date-time plus zone). */ +function upcomingEvent(id: string, title: string, calendarId: string) { + const start = new Date(Date.now() + 24 * 60 * 60 * 1000); + start.setSeconds(0, 0); + return { + id, + '@type': 'Event', + uid: `${id}@example.com`, + title, + calendarIds: { [calendarId]: true }, + start: start.toISOString().slice(0, 19), + timeZone: 'Etc/UTC', + duration: 'PT1H', + }; +} + +type Call = [string, Record, string]; + +/** + * Answers each JMAP request from a per-account fixture; `accountId` is read + * off the method call so the test sees which account the route asked for. + */ +function answerPerAccount(fixtures: Record[]; + calendars?: Array<{ id: string; name: string; color: string | null }>; + queryError?: string; +}>) { + mockPost.mockImplementation(async (_url: string, _auth: string, body: string) => { + const { methodCalls } = JSON.parse(body) as { methodCalls: Call[] }; + const methodResponses = methodCalls.map(([name, args, callId]) => { + const fixture = fixtures[args.accountId as string] ?? {}; + if (name === 'CalendarEvent/query') { + if (fixture.queryError) return ['error', { type: fixture.queryError }, callId]; + return [name, { ids: (fixture.events ?? []).map((e) => e.id) }, callId]; + } + if (name === 'Calendar/get') return [name, { list: fixture.calendars ?? [] }, callId]; + if (name === 'CalendarEvent/get') { + const ids = args.ids as string[]; + return [name, { list: (fixture.events ?? []).filter((e) => ids.includes(e.id)) }, callId]; + } + return ['error', { type: 'unknownMethod' }, callId]; + }); + return { ok: true, json: async () => ({ methodResponses }) }; + }); +} + +function queriedAccountIds(): string[] { + return mockPost.mock.calls.flatMap((call) => { + const { methodCalls } = JSON.parse(call[2] as string) as { methodCalls: Call[] }; + return methodCalls.filter(([name]) => name === 'CalendarEvent/query').map(([, args]) => args.accountId as string); + }); +} + function makeReq(): Parameters[0] { return { method: 'POST', headers: new Headers(), json: async () => ({}) } as unknown as Parameters[0]; } @@ -86,3 +142,85 @@ describe('calendar-agenda route (principals:owner declaration)', () => { expect(using).toContain('urn:ietf:params:jmap:principals:owner'); }); }); + +describe('calendar-agenda route (shared and group accounts)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreds.mockResolvedValue({ + serverUrl: 'https://mail.example.com', + username: 'user@example.com', + authHeader: 'Basic abc', + }); + }); + + it('includes events from a shared group account next to the primary account', async () => { + mockSession.mockResolvedValue(makeSession(BASE_CAPS, { + 'acct-1': { name: 'user@example.com', isPersonal: true, accountCapabilities: { [CALENDAR_CAP]: {} } }, + // Stalwart does not advertise the calendar capability on group accounts + 'acct-family': { name: 'family@example.com', isPersonal: false, accountCapabilities: {} }, + })); + answerPerAccount({ + 'acct-1': { + events: [upcomingEvent('ev-1', 'Dentist', 'cal-1')], + calendars: [{ id: 'cal-1', name: 'Personal', color: '#3b82f6' }], + }, + 'acct-family': { + events: [upcomingEvent('ev-1', 'Holiday', 'cal-1')], + calendars: [{ id: 'cal-1', name: 'Family', color: '#a855f7' }], + }, + }); + + const res = await POST(makeReq()); + expect(res.status).toBe(200); + const { events } = await res.json() as { events: Array> }; + + expect(queriedAccountIds()).toEqual(['acct-1', 'acct-family']); + expect(events.map((e) => e.title).sort()).toEqual(['Dentist', 'Holiday']); + // Ids repeat across accounts, so each event is scoped by its account and + // the colour comes from that account's calendar, not a same-named one. + const holiday = events.find((e) => e.title === 'Holiday')!; + expect(holiday.id).toBe('acct-family:ev-1'); + expect(holiday.accountId).toBe('acct-family'); + expect(holiday.color).toBe('#a855f7'); + expect(events.find((e) => e.title === 'Dentist')!.color).toBe('#3b82f6'); + }); + + it('leaves out personal accounts without calendars and the primary account itself', async () => { + mockSession.mockResolvedValue(makeSession(BASE_CAPS, { + 'acct-1': { isPersonal: true, accountCapabilities: { [CALENDAR_CAP]: {} } }, + 'acct-mail-only': { isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } }, + 'acct-shared': { isPersonal: true, accountCapabilities: { [CALENDAR_CAP]: {} } }, + })); + answerPerAccount({}); + + const res = await POST(makeReq()); + expect(res.status).toBe(200); + expect(queriedAccountIds()).toEqual(['acct-1', 'acct-shared']); + }); + + it('skips a shared account whose calendars cannot be read instead of failing the agenda', async () => { + mockSession.mockResolvedValue(makeSession(BASE_CAPS, { + 'acct-1': { isPersonal: true, accountCapabilities: { [CALENDAR_CAP]: {} } }, + 'acct-team': { isPersonal: false }, + })); + answerPerAccount({ + 'acct-1': { events: [upcomingEvent('ev-1', 'Standup', 'cal-1')] }, + 'acct-team': { queryError: 'forbidden' }, + }); + + const res = await POST(makeReq()); + expect(res.status).toBe(200); + const { events } = await res.json() as { events: Array> }; + expect(events.map((e) => e.title)).toEqual(['Standup']); + }); + + it('still reports a failure of the primary account', async () => { + mockSession.mockResolvedValue(makeSession(BASE_CAPS, { + 'acct-1': { isPersonal: true, accountCapabilities: { [CALENDAR_CAP]: {} } }, + })); + answerPerAccount({ 'acct-1': { queryError: 'serverFail' } }); + + const res = await POST(makeReq()); + expect(res.status).toBe(502); + }); +});