feat: calendar invitations RSVP, trust assessment, file preview

This commit is contained in:
Linus Rath
2026-03-16 22:00:11 +01:00
parent cde1d61d02
commit 0f5d030d5f
32 changed files with 21143 additions and 279 deletions
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useSettingsStore } from '../settings-store';
describe('settings-store attachment action', () => {
beforeEach(() => {
useSettingsStore.getState().resetToDefaults();
});
it('defaults to preview when settings are reset', () => {
expect(useSettingsStore.getState().mailAttachmentAction).toBe('preview');
});
it('includes the attachment action in exported settings', () => {
useSettingsStore.getState().updateSetting('mailAttachmentAction', 'download');
const exported = JSON.parse(useSettingsStore.getState().exportSettings()) as {
mailAttachmentAction?: string;
};
expect(exported.mailAttachmentAction).toBe('download');
});
it('includes calendar invitation parsing in exported settings', () => {
useSettingsStore.getState().updateSetting('calendarInvitationParsingEnabled', false);
const exported = JSON.parse(useSettingsStore.getState().exportSettings()) as {
calendarInvitationParsingEnabled?: boolean;
};
expect(exported.calendarInvitationParsingEnabled).toBe(false);
});
});
+17 -5
View File
@@ -36,7 +36,7 @@ interface CalendarStore {
createEvent: (client: JMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>;
updateEvent: (client: JMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<void>;
deleteEvent: (client: JMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
rsvpEvent: (client: JMAPClient, eventId: string, participantId: string, status: string) => Promise<void>;
rsvpEvent: (client: JMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null) => Promise<void>;
importEvents: (client: JMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
updateCalendar: (client: JMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
createCalendar: (client: JMAPClient, calendar: Partial<Calendar>) => Promise<Calendar | null>;
@@ -138,17 +138,27 @@ export const useCalendarStore = create<CalendarStore>()(
}
},
rsvpEvent: async (client, eventId, participantId, status) => {
rsvpEvent: async (client, eventId, participantId, status, replyTo) => {
set({ error: null });
if (!/^[a-zA-Z0-9_-]+$/.test(participantId)) {
// JMAP participant IDs are opaque strings — they can contain @, ., :, / etc.
// Only reject empty or obviously malicious values (path traversal).
if (!participantId || participantId.includes('..')) {
set({ error: 'Invalid participant ID' });
throw new Error('Invalid participant ID');
}
try {
const patchKey = `participants/${participantId}/participationStatus`;
// Escape per RFC 6901 (JSON Pointer): ~ → ~0, / → ~1
const escapedId = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
const patchKey = `participants/${escapedId}/participationStatus`;
const patch: Record<string, unknown> = { [patchKey]: status };
// Include replyTo so the server knows where to deliver the iTIP reply
// (may be missing if the event was imported or auto-created without it).
if (replyTo) {
patch.replyTo = replyTo;
}
await client.updateCalendarEvent(
eventId,
{ [patchKey]: status } as unknown as Partial<CalendarEvent>,
patch as unknown as Partial<CalendarEvent>,
true
);
set((state) => ({
@@ -183,6 +193,7 @@ export const useCalendarStore = create<CalendarStore>()(
'@type': 'Participant',
name: p.name,
email: p.email,
calendarAddress: p.calendarAddress,
description: p.description,
sendTo: p.sendTo,
kind: p.kind,
@@ -224,6 +235,7 @@ export const useCalendarStore = create<CalendarStore>()(
keywords: src.keywords,
categories: src.categories,
locale: src.locale,
replyTo: src.replyTo || (src.organizerCalendarAddress ? { imip: src.organizerCalendarAddress } : undefined),
locations: src.locations,
virtualLocations: src.virtualLocations,
links: src.links,
+7
View File
@@ -28,6 +28,7 @@ export type DateFormat = 'regional' | 'iso' | 'custom';
export type TimeFormat = '12h' | '24h';
export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday
export type ExternalContentPolicy = 'ask' | 'block' | 'allow';
export type MailAttachmentAction = 'preview' | 'download';
export type ToolbarPosition = 'top' | 'below-subject';
export interface KeywordDefinition {
@@ -81,6 +82,7 @@ interface SettingsState {
showPreview: boolean;
emailsPerPage: number;
externalContentPolicy: ExternalContentPolicy;
mailAttachmentAction: MailAttachmentAction;
// Composer
autoSaveDraftInterval: number; // milliseconds
@@ -94,6 +96,7 @@ interface SettingsState {
// Calendar Notifications
calendarNotificationsEnabled: boolean;
calendarNotificationSound: boolean;
calendarInvitationParsingEnabled: boolean;
// Layout
toolbarPosition: ToolbarPosition;
@@ -160,6 +163,7 @@ const DEFAULT_SETTINGS = {
showPreview: true,
emailsPerPage: 50,
externalContentPolicy: 'ask' as ExternalContentPolicy,
mailAttachmentAction: 'preview' as MailAttachmentAction,
// Composer
autoSaveDraftInterval: 60000, // 1 minute
@@ -173,6 +177,7 @@ const DEFAULT_SETTINGS = {
// Calendar Notifications
calendarNotificationsEnabled: true,
calendarNotificationSound: true,
calendarInvitationParsingEnabled: true,
// Layout
toolbarPosition: 'top' as ToolbarPosition,
@@ -237,6 +242,7 @@ export const useSettingsStore = create<SettingsState>()(
showPreview: state.showPreview,
emailsPerPage: state.emailsPerPage,
externalContentPolicy: state.externalContentPolicy,
mailAttachmentAction: state.mailAttachmentAction,
trustedSenders: state.trustedSenders,
autoSaveDraftInterval: state.autoSaveDraftInterval,
sendConfirmation: state.sendConfirmation,
@@ -244,6 +250,7 @@ export const useSettingsStore = create<SettingsState>()(
sessionTimeout: state.sessionTimeout,
calendarNotificationsEnabled: state.calendarNotificationsEnabled,
calendarNotificationSound: state.calendarNotificationSound,
calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled,
toolbarPosition: state.toolbarPosition,
senderFavicons: state.senderFavicons,
folderIcons: state.folderIcons,