From 9b5692a8aba668e702eef541df45d8b27c3c5eaa Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:25:10 +0100 Subject: [PATCH] feat: offer to save an event without invitations when the server refuses to send them --- src/components/calendar/EventModal.tsx | 15 +++-- .../calendar-scheduling-denied.test.ts | 37 ++++++++++++ src/lib/calendar-scheduling-denied.ts | 56 +++++++++++++++++++ 3 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 src/lib/__tests__/calendar-scheduling-denied.test.ts create mode 100644 src/lib/calendar-scheduling-denied.ts diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index f3d58ad5..108eb870 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -80,6 +80,7 @@ import { useKeyboardObstruction } from '../../lib/use-keyboard-obstruction'; import { editorKeyboardInset } from '../../lib/calendar-editor-keyboard'; import ToggleSwitch from '../ToggleSwitch'; import { askBeforeDiscard, editorFormChanged, editorFormSnapshot } from '../../lib/calendar-editor-discard'; +import { saveOfferingWithoutInvitations } from '../../lib/calendar-scheduling-denied'; type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'; @@ -435,14 +436,18 @@ export function EventModal({ for (const key of Object.keys(data) as (keyof CalendarEvent)[]) { if (data[key] === undefined) delete data[key]; } - await onSave(data, calendarId, { - // Only the organizer sends: on an invitation someone else sent, a - // save would reach the organizer as a reply. - sendSchedulingMessages: organizes && attendees.length > 0 && sendInvitations, + // Only the organizer sends: on an invitation someone else sent, a + // save would reach the organizer as a reply. + const send = organizes && attendees.length > 0 && sendInvitations; + // A server that refuses to send the invitations refuses the whole + // save: offer to keep the event without them. + const saved = await saveOfferingWithoutInvitations((sendSchedulingMessages) => onSave(data, calendarId, { + sendSchedulingMessages, // The dates as shown: a series moves by the occurrence's move, on // its own clock (lib/recurrence-scope-edit). edit: { attendees, when: { allDay, start, end }, ruleChanged }, - }); + }), send, t); + if (!saved) return; onClose(); } catch (err) { // The account changed under the save: the event is the previous diff --git a/src/lib/__tests__/calendar-scheduling-denied.test.ts b/src/lib/__tests__/calendar-scheduling-denied.test.ts new file mode 100644 index 00000000..1388301f --- /dev/null +++ b/src/lib/__tests__/calendar-scheduling-denied.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('react-native', () => ({ Alert: { alert: vi.fn() } })); + +import { JMAPSetError } from '../../api/jmap-result'; +import { saveOfferingWithoutInvitations, schedulingDeniedReason } from '../calendar-scheduling-denied'; + +const t = (_k: string, fallback?: string) => fallback ?? ''; +// As api/calendar throws Stalwart 0.16.21+'s refusal of the invitations. +const denied = () => new JMAPSetError('forbidden', 'Failed to update event e1: forbidden – Scheduling not allowed'); + +describe('schedulingDeniedReason', () => { + it('tells a refusal of the invitations from other failures', () => { + expect(schedulingDeniedReason(denied(), true)).toBe('Scheduling not allowed'); + expect(schedulingDeniedReason(denied(), false)).toBeNull(); + expect(schedulingDeniedReason(new JMAPSetError('invalidProperties', 'x'), true)).toBeNull(); + expect(schedulingDeniedReason(new Error('offline'), true)).toBeNull(); + }); +}); + +describe('saveOfferingWithoutInvitations', () => { + it('saves again without the invitations when the user agrees', async () => { + const save = vi.fn().mockRejectedValueOnce(denied()).mockResolvedValueOnce(undefined); + const ask = vi.fn().mockResolvedValue(true); + expect(await saveOfferingWithoutInvitations(save, true, t, ask)).toBe(true); + expect(save.mock.calls).toEqual([[true], [false]]); + expect(ask).toHaveBeenCalledWith('Scheduling not allowed', t); + }); + + it('keeps the editor open when the user declines, and passes other errors on', async () => { + const save = vi.fn().mockRejectedValue(denied()); + expect(await saveOfferingWithoutInvitations(save, true, t, vi.fn().mockResolvedValue(false))).toBe(false); + expect(save).toHaveBeenCalledTimes(1); + const failing = vi.fn().mockRejectedValue(new Error('offline')); + await expect(saveOfferingWithoutInvitations(failing, true, t, vi.fn())).rejects.toThrow('offline'); + }); +}); diff --git a/src/lib/calendar-scheduling-denied.ts b/src/lib/calendar-scheduling-denied.ts new file mode 100644 index 00000000..57a992b7 --- /dev/null +++ b/src/lib/calendar-scheduling-denied.ts @@ -0,0 +1,56 @@ +import { Alert } from 'react-native'; +import { JMAPMethodError } from '../api/jmap-result'; + +type Translate = (key: string, fallback?: string, params?: Record) => string; + +/** + * Why the server refused a save because of the invitations it would send, or + * null for any other failure. Stalwart 0.16.21+ fails the whole + * CalendarEvent/set with `forbidden` when the account may not send + * scheduling messages; the same change without them can still be saved + * (webmail lib/jmap/scheduling-error.ts). + */ +export function schedulingDeniedReason(err: unknown, sentScheduling: boolean): string | null { + if (!sentScheduling || !(err instanceof JMAPMethodError) || err.type !== 'forbidden') return null; + // The thrower's message carries the server's description after the type. + const description = err.message.split(' – ').slice(1).join(' – ').trim(); + return description || err.message || 'forbidden'; +} + +/** Ask whether to save the event without its invitations; resolves true for "Save without invitations". */ +export function askSaveWithoutInvitations(reason: string, t: Translate): Promise { + return new Promise((resolve) => { + Alert.alert( + t('calendar.notifications.invitations_denied_title', "Invitations can't be sent"), + t('calendar.notifications.invitations_denied', 'The server refused to send the invitations: {reason}. Save the event without sending them?', { reason }), + [ + { text: t('common.cancel', 'Cancel'), style: 'cancel', onPress: () => resolve(false) }, + { text: t('calendar.notifications.save_without_invitations', 'Save without invitations'), onPress: () => resolve(true) }, + ], + { cancelable: true, onDismiss: () => resolve(false) }, + ); + }); +} + +/** + * Save with the invitations `send` asks for; when the server refuses to send + * them, ask and save again without. Resolves false when the user kept the + * editor open instead. + */ +export async function saveOfferingWithoutInvitations( + save: (send: boolean) => Promise | void, + send: boolean, + t: Translate, + ask: (reason: string, t: Translate) => Promise = askSaveWithoutInvitations, +): Promise { + try { + await save(send); + return true; + } catch (err) { + const reason = schedulingDeniedReason(err, send); + if (reason === null) throw err; + if (!(await ask(reason, t))) return false; + await save(false); + return true; + } +}