feat: add JMAP Calendar integration with month/week/day/agenda views

Full calendar support via JMAP Calendars (RFC 8984):
- Event create/edit/delete with recurrence rules and reminders
- Multi-day event spanning, column-based overlap layout
- Locale-aware date formatting, first day of week and time format settings
- Real-time updates via push notifications
- ARIA accessibility, input validation, color sanitization
- Keyboard shortcuts, mobile touch targets, focus trap
- ICU pluralization for all 8 supported languages
This commit is contained in:
Matthieu MALVACHE
2026-02-16 20:34:29 +01:00
committed by Matthieu MALVACHE
parent fb414bf2c9
commit e6f67c24cb
28 changed files with 3922 additions and 39 deletions
+339 -28
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter } from "./types";
// JMAP protocol types - these are intentionally flexible due to server variations
interface JMAPSession {
@@ -1594,15 +1594,28 @@ export class JMAPClient {
return this.hasCapability("urn:ietf:params:jmap:contacts");
}
supportsCalendars(): boolean {
return this.hasCapability("urn:ietf:params:jmap:calendars");
}
getContactsAccountId(): string {
const contactsAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:contacts"];
return contactsAccount || this.accountId;
}
getCalendarsAccountId(): string {
const calendarsAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:calendars"];
return calendarsAccount || this.accountId;
}
private contactUsing(): string[] {
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"];
}
private calendarUsing(): string[] {
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"];
}
async getAddressBooks(): Promise<AddressBook[]> {
try {
const accountId = this.getContactsAccountId();
@@ -1798,6 +1811,282 @@ export class JMAPClient {
}
}
async getCalendars(): Promise<Calendar[]> {
try {
const accountId = this.getCalendarsAccountId();
const response = await this.request([
["Calendar/get", { accountId }, "0"]
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
return (response.methodResponses[0][1].list || []) as Calendar[];
}
return [];
} catch (error) {
console.error('Failed to get calendars:', error);
return [];
}
}
async createCalendar(calendar: Partial<Calendar>): Promise<Calendar> {
const accountId = this.getCalendarsAccountId();
const response = await this.request([
["Calendar/set", {
accountId,
create: {
"new-calendar": calendar
}
}, "0"]
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "Calendar/set") {
const result = response.methodResponses[0][1];
if (result.notCreated?.["new-calendar"]) {
const error = result.notCreated["new-calendar"];
throw new Error(error.description || "Failed to create calendar");
}
const createdId = result.created?.["new-calendar"]?.id;
if (createdId) {
const calendars = await this.getCalendars();
const created = calendars.find(c => c.id === createdId);
if (created) return created;
}
}
throw new Error("Failed to create calendar");
}
async updateCalendar(calendarId: string, updates: Partial<Calendar>): Promise<void> {
const accountId = this.getCalendarsAccountId();
const response = await this.request([
["Calendar/set", {
accountId,
update: {
[calendarId]: updates
}
}, "0"]
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "Calendar/set") {
const result = response.methodResponses[0][1];
if (result.notUpdated?.[calendarId]) {
const error = result.notUpdated[calendarId];
throw new Error(error.description || "Failed to update calendar");
}
return;
}
throw new Error("Failed to update calendar");
}
async deleteCalendar(calendarId: string): Promise<void> {
const accountId = this.getCalendarsAccountId();
const response = await this.request([
["Calendar/set", {
accountId,
destroy: [calendarId]
}, "0"]
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "Calendar/set") {
const result = response.methodResponses[0][1];
if (result.notDestroyed?.[calendarId]) {
const error = result.notDestroyed[calendarId];
throw new Error(error.description || "Failed to delete calendar");
}
return;
}
throw new Error("Failed to delete calendar");
}
async getCalendarEvents(calendarIds?: string[]): Promise<CalendarEvent[]> {
try {
const accountId = this.getCalendarsAccountId();
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
if (calendarIds && calendarIds.length > 0) {
queryArgs.filter = { inCalendars: calendarIds };
}
const response = await this.request([
["CalendarEvent/query", queryArgs, "0"],
["CalendarEvent/get", {
accountId,
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
}, "1"]
], this.calendarUsing());
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
return (response.methodResponses[1][1].list || []) as CalendarEvent[];
}
return [];
} catch (error) {
console.error('Failed to get calendar events:', error);
return [];
}
}
async queryCalendarEvents(
filter: CalendarEventFilter,
sort?: Array<{ property: string; isAscending: boolean }>,
limit?: number
): Promise<CalendarEvent[]> {
try {
const accountId = this.getCalendarsAccountId();
const queryArgs: Record<string, unknown> = {
accountId,
filter,
limit: limit || 100,
};
if (sort) {
queryArgs.sort = sort;
}
const response = await this.request([
["CalendarEvent/query", queryArgs, "0"],
["CalendarEvent/get", {
accountId,
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
}, "1"]
], this.calendarUsing());
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
return (response.methodResponses[1][1].list || []) as CalendarEvent[];
}
return [];
} catch (error) {
console.error('Failed to query calendar events:', error);
return [];
}
}
async getCalendarEvent(id: string): Promise<CalendarEvent | null> {
try {
const accountId = this.getCalendarsAccountId();
const response = await this.request([
["CalendarEvent/get", {
accountId,
ids: [id],
}, "0"]
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
const list = response.methodResponses[0][1].list || [];
return list[0] || null;
}
return null;
} catch (error) {
console.error('Failed to get calendar event:', error);
return null;
}
}
async createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean): Promise<CalendarEvent> {
const accountId = this.getCalendarsAccountId();
const setArgs: Record<string, unknown> = {
accountId,
create: {
"new-event": event
}
};
if (sendSchedulingMessages !== undefined) {
setArgs.sendSchedulingMessages = sendSchedulingMessages;
}
const response = await this.request([
["CalendarEvent/set", setArgs, "0"]
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
const result = response.methodResponses[0][1];
if (result.notCreated?.["new-event"]) {
const error = result.notCreated["new-event"];
throw new Error(error.description || "Failed to create calendar event");
}
const createdId = result.created?.["new-event"]?.id;
if (createdId) {
const created = await this.getCalendarEvent(createdId);
if (created) return created;
}
}
throw new Error("Failed to create calendar event");
}
async updateCalendarEvent(
eventId: string,
updates: Partial<CalendarEvent>,
sendSchedulingMessages?: boolean
): Promise<void> {
const accountId = this.getCalendarsAccountId();
const setArgs: Record<string, unknown> = {
accountId,
update: {
[eventId]: updates
}
};
if (sendSchedulingMessages !== undefined) {
setArgs.sendSchedulingMessages = sendSchedulingMessages;
}
const response = await this.request([
["CalendarEvent/set", setArgs, "0"]
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
const result = response.methodResponses[0][1];
if (result.notUpdated?.[eventId]) {
const error = result.notUpdated[eventId];
throw new Error(error.description || "Failed to update calendar event");
}
return;
}
throw new Error("Failed to update calendar event");
}
async deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean): Promise<void> {
const accountId = this.getCalendarsAccountId();
const setArgs: Record<string, unknown> = {
accountId,
destroy: [eventId]
};
if (sendSchedulingMessages !== undefined) {
setArgs.sendSchedulingMessages = sendSchedulingMessages;
}
const response = await this.request([
["CalendarEvent/set", setArgs, "0"]
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
const result = response.methodResponses[0][1];
if (result.notDestroyed?.[eventId]) {
const error = result.notDestroyed[eventId];
throw new Error(error.description || "Failed to delete calendar event");
}
return;
}
throw new Error("Failed to delete calendar event");
}
async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> {
const url = this.getBlobDownloadUrl(blobId, name, type);
@@ -1851,25 +2140,32 @@ export class JMAPClient {
private async fetchCurrentStates(): Promise<void> {
try {
// Get current states from server using JMAP query
const using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'];
const methodCalls: JMAPMethodCall[] = [
['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'],
['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'],
];
if (this.supportsCalendars()) {
using.push('urn:ietf:params:jmap:calendars');
const calAccountId = this.getCalendarsAccountId();
methodCalls.push(
['Calendar/get', { accountId: calAccountId, ids: null, properties: ['id'] }, 'c'],
['CalendarEvent/get', { accountId: calAccountId, ids: [], properties: ['id'] }, 'd'],
);
}
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': this.authHeader,
},
body: JSON.stringify({
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'],
['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'],
],
}),
body: JSON.stringify({ using, methodCalls }),
});
if (response.ok) {
const data = await response.json();
// Extract states from response
for (const [method, result] of data.methodResponses) {
if (method === 'Mailbox/get' && result.state) {
this.pollingStates['Mailbox'] = result.state;
@@ -1877,6 +2173,12 @@ export class JMAPClient {
if (method === 'Email/get' && result.state) {
this.pollingStates['Email'] = result.state;
}
if (method === 'Calendar/get' && result.state) {
this.pollingStates['Calendar'] = result.state;
}
if (method === 'CalendarEvent/get' && result.state) {
this.pollingStates['CalendarEvent'] = result.state;
}
}
}
} catch {
@@ -1886,19 +2188,28 @@ export class JMAPClient {
private async checkForStateChanges(): Promise<void> {
try {
const using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'];
const methodCalls: JMAPMethodCall[] = [
['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'],
['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'],
];
if (this.supportsCalendars()) {
using.push('urn:ietf:params:jmap:calendars');
const calAccountId = this.getCalendarsAccountId();
methodCalls.push(
['Calendar/get', { accountId: calAccountId, ids: null, properties: ['id'] }, 'c'],
['CalendarEvent/get', { accountId: calAccountId, ids: [], properties: ['id'] }, 'd'],
);
}
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': this.authHeader,
},
body: JSON.stringify({
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'],
['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'],
],
}),
body: JSON.stringify({ using, methodCalls }),
});
if (response.ok) {
@@ -1907,19 +2218,19 @@ export class JMAPClient {
let hasChanges = false;
for (const [method, result] of data.methodResponses) {
if (method === 'Mailbox/get' && result.state) {
if (this.pollingStates['Mailbox'] && this.pollingStates['Mailbox'] !== result.state) {
changes['Mailbox'] = result.state;
const typeMap: Record<string, string> = {
'Mailbox/get': 'Mailbox',
'Email/get': 'Email',
'Calendar/get': 'Calendar',
'CalendarEvent/get': 'CalendarEvent',
};
const stateKey = typeMap[method];
if (stateKey && result.state) {
if (this.pollingStates[stateKey] && this.pollingStates[stateKey] !== result.state) {
changes[stateKey] = result.state;
hasChanges = true;
}
this.pollingStates['Mailbox'] = result.state;
}
if (method === 'Email/get' && result.state) {
if (this.pollingStates['Email'] && this.pollingStates['Email'] !== result.state) {
changes['Email'] = result.state;
hasChanges = true;
}
this.pollingStates['Email'] = result.state;
this.pollingStates[stateKey] = result.state;
}
}
+220
View File
@@ -266,6 +266,224 @@ export interface DeliveryStatus {
displayed: "unknown" | "yes";
}
// JMAP Calendar Types (RFC 8984 JSCalendar + RFC 9553 JMAP Calendars)
export interface Calendar {
id: string;
name: string;
description: string | null;
color: string | null;
sortOrder: number;
isSubscribed: boolean;
isVisible: boolean;
isDefault: boolean;
includeInAvailability: 'all' | 'attending' | 'none';
defaultAlertsWithTime: Record<string, CalendarEventAlert> | null;
defaultAlertsWithoutTime: Record<string, CalendarEventAlert> | null;
timeZone: string | null;
shareWith: Record<string, CalendarRights> | null;
myRights: CalendarRights;
}
export interface CalendarRights {
mayReadFreeBusy: boolean;
mayReadItems: boolean;
mayWriteAll: boolean;
mayWriteOwn: boolean;
mayUpdatePrivate: boolean;
mayRSVP: boolean;
mayAdmin: boolean;
mayDelete: boolean;
}
export interface CalendarEvent {
id: string;
calendarIds: Record<string, boolean>;
isDraft: boolean;
isOrigin: boolean;
utcStart: string | null;
utcEnd: string | null;
'@type': 'Event';
uid: string;
title: string;
description: string;
descriptionContentType: string;
created: string | null;
updated: string;
sequence: number;
start: string;
duration: string;
timeZone: string | null;
showWithoutTime: boolean;
status: 'tentative' | 'confirmed' | 'cancelled';
freeBusyStatus: 'free' | 'busy';
privacy: 'public' | 'private' | 'secret';
color: string | null;
keywords: Record<string, boolean> | null;
categories: Record<string, boolean> | null;
locale: string | null;
replyTo: Record<string, string> | null;
participants: Record<string, CalendarParticipant> | null;
mayInviteSelf: boolean;
mayInviteOthers: boolean;
hideAttendees: boolean;
recurrenceId: string | null;
recurrenceIdTimeZone: string | null;
recurrenceRules: CalendarRecurrenceRule[] | null;
recurrenceOverrides: Record<string, Partial<CalendarEvent>> | null;
excludedRecurrenceRules: CalendarRecurrenceRule[] | null;
useDefaultAlerts: boolean;
alerts: Record<string, CalendarEventAlert> | null;
locations: Record<string, CalendarLocation> | null;
virtualLocations: Record<string, CalendarVirtualLocation> | null;
links: Record<string, CalendarLink> | null;
relatedTo: Record<string, CalendarRelation> | null;
}
export interface CalendarParticipant {
'@type': 'Participant';
name: string;
email: string;
description: string | null;
sendTo: Record<string, string> | null;
kind: 'individual' | 'group' | 'location' | 'resource';
roles: Record<string, boolean>;
participationStatus: 'accepted' | 'declined' | 'tentative' | 'delegated' | 'needs-action';
participationComment: string | null;
expectReply: boolean;
scheduleAgent: 'server' | 'client' | 'none';
scheduleForceSend: boolean;
scheduleId: string | null;
scheduleSequence: number;
scheduleStatus: string[] | null;
scheduleUpdated: string | null;
invitedBy: string | null;
delegatedTo: Record<string, boolean> | null;
delegatedFrom: Record<string, boolean> | null;
memberOf: Record<string, boolean> | null;
locationId: string | null;
language: string | null;
links: Record<string, CalendarLink> | null;
}
export interface CalendarRecurrenceRule {
'@type': 'RecurrenceRule';
frequency: 'yearly' | 'monthly' | 'weekly' | 'daily' | 'hourly' | 'minutely' | 'secondly';
interval: number;
rscale: string;
skip: 'omit' | 'backward' | 'forward';
firstDayOfWeek: 'mo' | 'tu' | 'we' | 'th' | 'fr' | 'sa' | 'su';
byDay: CalendarNDay[] | null;
byMonthDay: number[] | null;
byMonth: string[] | null;
byYearDay: number[] | null;
byWeekNo: number[] | null;
byHour: number[] | null;
byMinute: number[] | null;
bySecond: number[] | null;
bySetPosition: number[] | null;
count: number | null;
until: string | null;
}
export interface CalendarNDay {
day: string;
nthOfPeriod?: number;
}
export interface CalendarEventAlert {
'@type': 'Alert';
trigger: CalendarOffsetTrigger | CalendarAbsoluteTrigger;
action: 'display' | 'email';
acknowledged: string | null;
relatedTo: Record<string, CalendarRelation> | null;
}
export interface CalendarOffsetTrigger {
'@type': 'OffsetTrigger';
offset: string;
relativeTo: 'start' | 'end';
}
export interface CalendarAbsoluteTrigger {
'@type': 'AbsoluteTrigger';
when: string;
}
export interface CalendarLocation {
'@type': 'Location';
name: string;
description: string | null;
locationTypes: Record<string, boolean> | null;
coordinates: string | null;
timeZone: string | null;
links: Record<string, CalendarLink> | null;
relativeTo: 'start' | 'end' | null;
}
export interface CalendarVirtualLocation {
'@type': 'VirtualLocation';
name: string | null;
description: string | null;
uri: string;
features: Record<string, boolean> | null;
}
export interface CalendarLink {
'@type': 'Link';
href: string;
cid: string | null;
contentType: string | null;
size: number | null;
rel: string | null;
display: string | null;
title: string | null;
}
export interface CalendarRelation {
'@type': 'Relation';
relation: Record<string, boolean> | null;
}
export interface CalendarParticipantIdentity {
id: string;
name: string;
scheduleId: string;
sendTo: Record<string, string>;
isDefault: boolean;
}
export interface CalendarEventNotification {
id: string;
created: string;
changedBy: {
name: string;
email: string;
principalId: string | null;
scheduleId: string | null;
};
comment: string | null;
type: 'created' | 'updated' | 'destroyed';
calendarEventId: string;
isDraft: boolean;
event?: CalendarEvent;
eventPatch?: Record<string, unknown>;
}
export interface CalendarEventFilter {
inCalendars?: string[];
after?: string;
before?: string;
text?: string;
title?: string;
description?: string;
location?: string;
owner?: string;
attendee?: string;
participationStatus?: string;
uid?: string;
}
// JMAP Push Notification Types (RFC 8620 Section 7)
export interface StateChange {
@@ -280,6 +498,8 @@ export interface StateChange {
Identity?: string;
ContactCard?: string;
AddressBook?: string;
Calendar?: string;
CalendarEvent?: string;
};
};
}