feat: offer to save an event without invitations when the server refuses to send them

This commit is contained in:
Linus Rath
2026-10-31 04:25:10 +01:00
parent d3f6d6aea3
commit 9b5692a8ab
3 changed files with 103 additions and 5 deletions
+10 -5
View File
@@ -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
@@ -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');
});
});
+56
View File
@@ -0,0 +1,56 @@
import { Alert } from 'react-native';
import { JMAPMethodError } from '../api/jmap-result';
type Translate = (key: string, fallback?: string, params?: Record<string, string | number>) => 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<boolean> {
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> | void,
send: boolean,
t: Translate,
ask: (reason: string, t: Translate) => Promise<boolean> = askSaveWithoutInvitations,
): Promise<boolean> {
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;
}
}