fix: show shared and group calendars in the Calendar Agenda

The agenda sidecar queried only the session's primary calendar account,
so events in a shared or group account (a family or team calendar) never
reached the Calendar Agenda plugin, although the calendar app shows them.

Read every account the calendar app reads: the primary account plus
accounts that advertise the calendar capability or are not personal,
mirroring the client's getCalendarCapableAccountIds(). Event and
calendar ids repeat across accounts, so the DTO id is scoped as
<accountId>:<id> and carries accountId; calendar colours are looked up
per account. A shared account whose calendars cannot be read is skipped
instead of failing the whole agenda; a failure of the primary account is
still reported.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
sud
2026-10-06 06:24:17 +02:00
co-authored by Claude Fable 5.1
parent 85b3bb86ed
commit 61fc130a74
2 changed files with 258 additions and 78 deletions
+119 -77
View File
@@ -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: `<accountId>:<event id>`. */
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<string, { name: string; color: string | null }>();
const calResp = findResponse(queryRes, 'Calendar/get', 'c');
for (const cal of ((calResp?.list as Array<Record<string, unknown>>) || [])) {
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<CalendarEvent & { accountId: string }> = [];
if (ids.length === 0) {
return NextResponse.json({ events: [], generatedAt: now.toISOString() });
}
// ── 2) Fetch full event objects (batched) ──
const raw: Array<Record<string, unknown>> = [];
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<Record<string, unknown>>));
};
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<Record<string, unknown>>) || [])) {
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<Record<string, unknown>> = [];
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<Record<string, unknown>>));
}
const normalized = raw
.map((e) => normalizeCalendarEventLike(e as Partial<CalendarEvent>))
.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<CalendarEvent>))
.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<string, string>;
accounts?: Record<string, unknown>;
}): 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<string, unknown> };
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;
+139 -1
View File
@@ -27,14 +27,70 @@ const mockPost = postJmap as unknown as Mock;
const CALENDAR_CAP = 'urn:ietf:params:jmap:calendars';
function makeSession(capabilities: Record<string, unknown>) {
function makeSession(capabilities: Record<string, unknown>, accounts?: Record<string, unknown>) {
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, unknown>, 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<string, {
events?: ReturnType<typeof upcomingEvent>[];
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<typeof POST>[0] {
return { method: 'POST', headers: new Headers(), json: async () => ({}) } as unknown as Parameters<typeof POST>[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<Record<string, unknown>> };
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<Record<string, unknown>> };
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);
});
});