fix: offer to save an event when the server refuses its invitations

From 0.16.21 Stalwart fails the whole CalendarEvent/set with
`forbidden` when the account may not send scheduling messages, so an
event with attendees was not saved at all and the calendar showed only
"Failed to save event".

Such a refusal now surfaces as SchedulingDeniedError, and the calendar
names the server's reason and offers to save the event without sending
the invitations.
This commit is contained in:
Linus Rath
2026-09-22 21:25:20 +02:00
parent fa33351f8d
commit d7fdd7ce61
32 changed files with 193 additions and 35 deletions
+50 -35
View File
@@ -63,6 +63,7 @@ import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } fro
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { SchedulingDeniedError } from "@/lib/jmap/scheduling-error";
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
import { getUserParticipantId, collectUserCalendarAddresses } from "@/lib/calendar-participants";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
@@ -842,45 +843,59 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
jumpTo(eventDate);
}, [jumpTo]);
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => {
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>, requestedScheduling?: boolean) => {
if (!client) { toast.error(t("notifications.event_error")); return; }
try {
if (editEvent) {
if (isRecurringEvent(editEvent)) {
setPendingScopeAction({
type: "edit",
event: editEvent,
updates: data,
sendScheduling: sendSchedulingMessages,
});
setShowEventModal(false);
setEditEvent(null);
return;
}
await updateEvent(client, editEvent.id, data, sendSchedulingMessages);
if (data.start) {
focusCalendarOnEvent({ start: data.start });
}
toast.success(t("notifications.event_updated"));
} else {
const created = await createEvent(client, data, sendSchedulingMessages);
if (!created) {
toast.error(t("notifications.event_error"));
return;
}
focusCalendarOnEvent(created);
if (sendSchedulingMessages) {
toast.success(t("notifications.invitation_sent"));
const save = async (sendSchedulingMessages: boolean | undefined): Promise<void> => {
try {
if (editEvent) {
if (isRecurringEvent(editEvent)) {
setPendingScopeAction({
type: "edit",
event: editEvent,
updates: data,
sendScheduling: sendSchedulingMessages,
});
setShowEventModal(false);
setEditEvent(null);
return;
}
await updateEvent(client, editEvent.id, data, sendSchedulingMessages);
if (data.start) {
focusCalendarOnEvent({ start: data.start });
}
toast.success(t("notifications.event_updated"));
} else {
toast.success(t("notifications.event_created"));
const created = await createEvent(client, data, sendSchedulingMessages);
if (!created) {
toast.error(t("notifications.event_error"));
return;
}
focusCalendarOnEvent(created);
if (sendSchedulingMessages) {
toast.success(t("notifications.invitation_sent"));
} else {
toast.success(t("notifications.event_created"));
}
}
setShowEventModal(false);
setEditEvent(null);
} catch (error) {
// The server refuses to send the invitations (Stalwart 0.16.21+ fails
// the whole save then). Offer to keep the event without them.
if (error instanceof SchedulingDeniedError && sendSchedulingMessages) {
const ok = await confirmAction({
title: t("notifications.invitations_denied_title"),
message: t("notifications.invitations_denied", { reason: error.reason }),
confirmText: t("notifications.save_without_invitations"),
});
if (ok) await save(false);
return;
}
toast.error(t("notifications.event_error"));
}
setShowEventModal(false);
setEditEvent(null);
} catch {
toast.error(t("notifications.event_error"));
}
}, [client, editEvent, createEvent, updateEvent, focusCalendarOnEvent, t]);
};
await save(requestedScheduling);
}, [client, editEvent, createEvent, updateEvent, focusCalendarOnEvent, confirmAction, t]);
const handleDuplicateEvent = useCallback(async (data: Partial<CalendarEvent>) => {
if (!client) { toast.error(t("notifications.event_error")); return; }
@@ -0,0 +1,38 @@
import { describe, it, expect, vi } from 'vitest';
import { JMAPClient, SchedulingDeniedError } from '../jmap/client';
type Call = [string, Record<string, unknown>, string];
// Stalwart 0.16.21+ fails the whole CalendarEvent/set with `forbidden` when
// the account may not send iTIP messages; the UI can then save without them.
function clientRefusing(type: string) {
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
Object.assign(client, { accountId: 'me' });
vi.spyOn(client as unknown as { getCalendarsAccountId: () => string }, 'getCalendarsAccountId').mockReturnValue('me');
vi.spyOn(client as unknown as { request: (c: Call[]) => Promise<unknown> }, 'request')
.mockImplementation(async (calls: Call[]) => {
const [, args, id] = calls[0];
const error = { type, description: 'Scheduling is not allowed for this account' };
return {
methodResponses: [['CalendarEvent/set', args.create
? { notCreated: { 'new-event': error } }
: { notUpdated: Object.fromEntries(Object.keys(args.update as object).map((k) => [k, error])) }, id]],
};
});
return client;
}
describe('calendar scheduling refusals', () => {
it('reports a refused invitation as SchedulingDeniedError', async () => {
const client = clientRefusing('forbidden');
await expect(client.createCalendarEvent({ title: 'Meet' }, true)).rejects.toBeInstanceOf(SchedulingDeniedError);
await expect(client.updateCalendarEvent('e1', { title: 'Meet' }, true)).rejects.toThrow(SchedulingDeniedError);
});
it('keeps other failures as plain errors', async () => {
await expect(clientRefusing('forbidden').createCalendarEvent({ title: 'Meet' }, false))
.rejects.not.toBeInstanceOf(SchedulingDeniedError);
await expect(clientRefusing('invalidProperties').createCalendarEvent({ title: 'Meet' }, true))
.rejects.not.toBeInstanceOf(SchedulingDeniedError);
});
});
+9
View File
@@ -14,6 +14,9 @@ import { DEFAULT_CALENDAR_COMPONENTS, mkCalendarCollection, newCalendarCollectio
import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox";
import { decodeFileNodeName } from "./filenode-name";
import { contactFromWire, contactToWire } from "./contact-wire";
import { SchedulingDeniedError } from "./scheduling-error";
export { SchedulingDeniedError };
import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from "@/lib/file-name-rules";
import { getEffectiveTimeZone, toLocalDateTime } from "@/lib/timezone";
import { buildEmailSort, compareEmails, hasKeywordLevels, type KeywordSortPolarity, type SortLevel } from "@/lib/message-list-order";
@@ -6719,6 +6722,9 @@ export class JMAPClient implements IJMAPClient {
debug.warn('calendar', 'CalendarEvent/create invalid properties', error.properties);
debug.warn('calendar', 'CalendarEvent/create sent keys', Object.keys(cleanEvent));
debug.groupEnd();
if (sendSchedulingMessages && error.type === 'forbidden') {
throw new SchedulingDeniedError(error.description || 'forbidden');
}
throw new Error(error.description || "Failed to create calendar event");
}
@@ -6909,6 +6915,9 @@ export class JMAPClient implements IJMAPClient {
if (result.notUpdated?.[eventId]) {
const error = result.notUpdated[eventId];
debug.error('CalendarEvent/set notUpdated', { eventId, error });
if (sendSchedulingMessages && error.type === 'forbidden') {
throw new SchedulingDeniedError(error.description || 'forbidden');
}
throw new Error(error.description || "Failed to update calendar event");
}
debug.log('calendar', 'CalendarEvent/set update full response', { methodName, result });
+12
View File
@@ -0,0 +1,12 @@
/**
* The server refused the change because of the iTIP messages it would send
* (Stalwart 0.16.21+ fails the whole CalendarEvent/set with `forbidden`
* when the account may not send scheduling messages). The same change
* without `sendSchedulingMessages` can still be saved.
*/
export class SchedulingDeniedError extends Error {
constructor(readonly reason: string) {
super(reason);
this.name = 'SchedulingDeniedError';
}
}
+3
View File
@@ -3035,6 +3035,9 @@
"alert_now": "يبدأ الآن",
"alert_in_minutes": "خلال {count} دقيقة",
"invitation_sent": "تم إرسال الدعوات",
"invitations_denied_title": "تعذر إرسال الدعوات",
"invitations_denied": "رفض الخادم إرسال الدعوات: {reason}. هل تريد حفظ الحدث دون إرسالها؟",
"save_without_invitations": "حفظ دون دعوات",
"rsvp_updated": "تم تحديث الرد",
"rsvp_error": "فشل تحديث الرد",
"event_duplicated": "تم تكرار الحدث",
+3
View File
@@ -3035,6 +3035,9 @@
"alert_now": "Comença ara",
"alert_in_minutes": "D'aquí a {count} min",
"invitation_sent": "Invitacions enviades",
"invitations_denied_title": "No es poden enviar les invitacions",
"invitations_denied": "El servidor ha refusat enviar les invitacions: {reason}. Voleu desar l'esdeveniment sense enviar-les?",
"save_without_invitations": "Desa sense invitacions",
"rsvp_updated": "Resposta actualitzada",
"rsvp_error": "No s'ha pogut actualitzar la resposta",
"event_duplicated": "Esdeveniment duplicat",
+3
View File
@@ -3009,6 +3009,9 @@
"alert_now": "Začíná nyní",
"alert_in_minutes": "Za {count} min",
"invitation_sent": "Pozvánky byly odeslány",
"invitations_denied_title": "Pozvánky nelze odeslat",
"invitations_denied": "Server odmítl odeslat pozvánky: {reason}. Uložit událost bez jejich odeslání?",
"save_without_invitations": "Uložit bez pozvánek",
"rsvp_updated": "Odpověď byla aktualizována",
"rsvp_error": "Aktualizace odpovědi selhala",
"event_duplicated": "Událost byla duplikována",
+3
View File
@@ -3035,6 +3035,9 @@
"alert_now": "Starter nu",
"alert_in_minutes": "Om {count} min",
"invitation_sent": "Invitationer sendt",
"invitations_denied_title": "Invitationerne kan ikke sendes",
"invitations_denied": "Serveren afviste at sende invitationerne: {reason}. Gem begivenheden uden at sende dem?",
"save_without_invitations": "Gem uden invitationer",
"rsvp_updated": "Svar opdateret",
"rsvp_error": "Kunne ikke opdatere svar",
"event_duplicated": "Begivenhed duplikeret",
+3
View File
@@ -3009,6 +3009,9 @@
"alert_now": "Beginnt jetzt",
"alert_in_minutes": "In {count} Min.",
"invitation_sent": "Einladungen gesendet",
"invitations_denied_title": "Einladungen können nicht gesendet werden",
"invitations_denied": "Der Server hat das Senden der Einladungen abgelehnt: {reason}. Termin ohne Einladungen speichern?",
"save_without_invitations": "Ohne Einladungen speichern",
"rsvp_updated": "Antwort aktualisiert",
"rsvp_error": "Antwort konnte nicht aktualisiert werden",
"event_duplicated": "Termin dupliziert",
+3
View File
@@ -3035,6 +3035,9 @@
"alert_now": "Starting now",
"alert_in_minutes": "In {count} min",
"invitation_sent": "Invitations sent",
"invitations_denied_title": "Invitations can't be sent",
"invitations_denied": "The server refused to send the invitations: {reason}. Save the event without sending them?",
"save_without_invitations": "Save without invitations",
"rsvp_updated": "Response updated",
"rsvp_error": "Failed to update response",
"event_duplicated": "Event duplicated",
+3
View File
@@ -3009,6 +3009,9 @@
"alert_now": "Comienza ahora",
"alert_in_minutes": "En {count} min",
"invitation_sent": "Invitaciones enviadas",
"invitations_denied_title": "No se pueden enviar las invitaciones",
"invitations_denied": "El servidor rechazó enviar las invitaciones: {reason}. ¿Guardar el evento sin enviarlas?",
"save_without_invitations": "Guardar sin invitaciones",
"rsvp_updated": "Respuesta actualizada",
"rsvp_error": "Error al actualizar la respuesta",
"event_duplicated": "Evento duplicado",
+3
View File
@@ -3035,6 +3035,9 @@
"alert_now": "همین الان شروع می‌شود",
"alert_in_minutes": "{count} دقیقه دیگر",
"invitation_sent": "دعوت‌نامه‌ها ارسال شد",
"invitations_denied_title": "ارسال دعوت‌نامه‌ها ممکن نیست",
"invitations_denied": "سرور از ارسال دعوت‌نامه‌ها خودداری کرد: {reason}. رویداد بدون ارسال آن‌ها ذخیره شود؟",
"save_without_invitations": "ذخیره بدون دعوت‌نامه",
"rsvp_updated": "پاسخ به‌روزرسانی شد",
"rsvp_error": "به‌روزرسانی پاسخ ناموفق بود",
"event_duplicated": "رویداد کپی شد",
+3
View File
@@ -3035,6 +3035,9 @@
"alert_now": "Commence maintenant",
"alert_in_minutes": "Dans {count} min",
"invitation_sent": "Invitations envoyées",
"invitations_denied_title": "Impossible d'envoyer les invitations",
"invitations_denied": "Le serveur a refusé d'envoyer les invitations : {reason}. Enregistrer l'événement sans les envoyer ?",
"save_without_invitations": "Enregistrer sans invitations",
"rsvp_updated": "Réponse mise à jour",
"rsvp_error": "Échec de la mise à jour de la réponse",
"event_duplicated": "Événement dupliqué",
+3
View File
@@ -2933,6 +2933,9 @@
"alert_now": "מתחיל עכשיו",
"alert_in_minutes": "ב-{count}דקות",
"invitation_sent": "נשלחו הזמנות",
"invitations_denied_title": "לא ניתן לשלוח את ההזמנות",
"invitations_denied": "השרת סירב לשלוח את ההזמנות: {reason}. לשמור את האירוע בלי לשלוח אותן?",
"save_without_invitations": "שמור בלי הזמנות",
"rsvp_updated": "התגובה עודכנה",
"rsvp_error": "עדכון התגובה נכשל",
"event_duplicated": "האירוע שוכפל",
+3
View File
@@ -3035,6 +3035,9 @@
"alert_now": "Most kezdődik",
"alert_in_minutes": "{count} perc múlva",
"invitation_sent": "Meghívók elküldve",
"invitations_denied_title": "A meghívók nem küldhetők el",
"invitations_denied": "A kiszolgáló nem küldte el a meghívókat: {reason}. Mentsük az eseményt a meghívók nélkül?",
"save_without_invitations": "Mentés meghívók nélkül",
"rsvp_updated": "Válasz frissítve",
"rsvp_error": "Nem sikerült frissíteni a választ",
"event_duplicated": "Esemény duplikálva",
+3
View File
@@ -3009,6 +3009,9 @@
"alert_now": "Inizia ora",
"alert_in_minutes": "Tra {count} min",
"invitation_sent": "Inviti inviati",
"invitations_denied_title": "Impossibile inviare gli inviti",
"invitations_denied": "Il server ha rifiutato di inviare gli inviti: {reason}. Salvare l'evento senza inviarli?",
"save_without_invitations": "Salva senza inviti",
"rsvp_updated": "Risposta aggiornata",
"rsvp_error": "Impossibile aggiornare la risposta",
"event_duplicated": "Evento duplicato",
+3
View File
@@ -3009,6 +3009,9 @@
"alert_now": "まもなく開始",
"alert_in_minutes": "{count}分後",
"invitation_sent": "招待を送信しました",
"invitations_denied_title": "招待状を送信できません",
"invitations_denied": "サーバーが招待状の送信を拒否しました: {reason}。招待状を送らずに予定を保存しますか?",
"save_without_invitations": "招待状なしで保存",
"rsvp_updated": "回答を更新しました",
"rsvp_error": "回答の更新に失敗しました",
"event_duplicated": "予定を複製しました",
+3
View File
@@ -3009,6 +3009,9 @@
"alert_now": "지금 시작해요",
"alert_in_minutes": "{count}분 후 시작",
"invitation_sent": "초대장이 전송되었어요",
"invitations_denied_title": "초대장을 보낼 수 없어요",
"invitations_denied": "서버가 초대장 발송을 거부했어요: {reason}. 초대장 없이 일정을 저장할까요?",
"save_without_invitations": "초대장 없이 저장",
"rsvp_updated": "응답이 업데이트되었어요",
"rsvp_error": "응답을 업데이트하지 못했어요",
"event_duplicated": "일정이 복제되었어요",
+3
View File
@@ -3008,6 +3008,9 @@
"alert_now": "Sākas tagad",
"alert_in_minutes": "Pēc {count} min",
"invitation_sent": "Uzaicinājumi nosūtīti",
"invitations_denied_title": "Ielūgumus nevar nosūtīt",
"invitations_denied": "Serveris atteicās nosūtīt ielūgumus: {reason}. Saglabāt notikumu, tos nenosūtot?",
"save_without_invitations": "Saglabāt bez ielūgumiem",
"rsvp_updated": "Atbilde atjaunināta",
"rsvp_error": "Neizdevās atjaunināt atbildi",
"event_duplicated": "Pasākums dublēts",
+3
View File
@@ -3035,6 +3035,9 @@
"alert_now": "Одооноос эхлэн",
"alert_in_minutes": "{count} мин",
"invitation_sent": "Урилга илгээсэн",
"invitations_denied_title": "Урилга илгээх боломжгүй",
"invitations_denied": "Сервер урилга илгээхээс татгалзлаа: {reason}. Урилга илгээхгүйгээр үйл явдлыг хадгалах уу?",
"save_without_invitations": "Урилгагүйгээр хадгалах",
"rsvp_updated": "Хариулт шинэчлэгдсэн",
"rsvp_error": "Хариултыг шинэчилж чадсангүй",
"event_duplicated": "Үйл явдал давхардсан",
+3
View File
@@ -3035,6 +3035,9 @@
"alert_now": "Starter nå",
"alert_in_minutes": "Om {count} min",
"invitation_sent": "Invitasjonene er sendt",
"invitations_denied_title": "Invitasjonene kan ikke sendes",
"invitations_denied": "Serveren nektet å sende invitasjonene: {reason}. Lagre hendelsen uten å sende dem?",
"save_without_invitations": "Lagre uten invitasjoner",
"rsvp_updated": "Svaret er oppdatert",
"rsvp_error": "Kunne ikke oppdatere svaret",
"event_duplicated": "Hendelsen er duplisert",
+3
View File
@@ -3009,6 +3009,9 @@
"alert_now": "Begint nu",
"alert_in_minutes": "Over {count} min",
"invitation_sent": "Uitnodigingen verzonden",
"invitations_denied_title": "Uitnodigingen kunnen niet worden verzonden",
"invitations_denied": "De server weigerde de uitnodigingen te verzenden: {reason}. Het evenement opslaan zonder ze te verzenden?",
"save_without_invitations": "Opslaan zonder uitnodigingen",
"rsvp_updated": "Reactie bijgewerkt",
"rsvp_error": "Reactie kon niet worden bijgewerkt",
"event_duplicated": "Evenement gedupliceerd",
+3
View File
@@ -3009,6 +3009,9 @@
"alert_now": "Zaczyna się teraz",
"alert_in_minutes": "Za {count} min",
"invitation_sent": "Zaproszenia wysłano",
"invitations_denied_title": "Nie można wysłać zaproszeń",
"invitations_denied": "Serwer odmówił wysłania zaproszeń: {reason}. Zapisać wydarzenie bez ich wysyłania?",
"save_without_invitations": "Zapisz bez zaproszeń",
"rsvp_updated": "Odpowiedź zaktualizowano",
"rsvp_error": "Nie udało się zaktualizować odpowiedzi",
"event_duplicated": "Wydarzenie zduplikowano",
+3
View File
@@ -3035,6 +3035,9 @@
"alert_now": "Começa agora",
"alert_in_minutes": "Em {count} min",
"invitation_sent": "Convites enviados",
"invitations_denied_title": "Não é possível enviar os convites",
"invitations_denied": "O servidor recusou enviar os convites: {reason}. Guardar o evento sem os enviar?",
"save_without_invitations": "Guardar sem convites",
"rsvp_updated": "Resposta atualizada",
"rsvp_error": "Falha ao atualizar resposta",
"event_duplicated": "Evento duplicado",
+3
View File
@@ -3035,6 +3035,9 @@
"alert_now": "Începând de acum",
"alert_in_minutes": "În {count} min",
"invitation_sent": "Invitații trimise",
"invitations_denied_title": "Invitațiile nu pot fi trimise",
"invitations_denied": "Serverul a refuzat trimiterea invitațiilor: {reason}. Salvezi evenimentul fără a le trimite?",
"save_without_invitations": "Salvează fără invitații",
"rsvp_updated": "Răspuns actualizat",
"rsvp_error": "Nu s-a putut actualiza răspunsul",
"event_duplicated": "Eveniment duplicat",
+3
View File
@@ -3009,6 +3009,9 @@
"alert_now": "Начинается сейчас",
"alert_in_minutes": "Через {count} мин",
"invitation_sent": "Приглашения отправлены",
"invitations_denied_title": "Не удаётся отправить приглашения",
"invitations_denied": "Сервер отказался отправить приглашения: {reason}. Сохранить событие без их отправки?",
"save_without_invitations": "Сохранить без приглашений",
"rsvp_updated": "Ответ обновлён",
"rsvp_error": "Не удалось обновить ответ",
"event_duplicated": "Событие дублировано",
+3
View File
@@ -3035,6 +3035,9 @@
"alert_now": "Začína teraz",
"alert_in_minutes": "Za {count} min",
"invitation_sent": "Pozvánky boli odoslané",
"invitations_denied_title": "Pozvánky nie je možné odoslať",
"invitations_denied": "Server odmietol odoslať pozvánky: {reason}. Uložiť udalosť bez ich odoslania?",
"save_without_invitations": "Uložiť bez pozvánok",
"rsvp_updated": "Odpoveď bola aktualizovaná",
"rsvp_error": "Aktualizácia odpovede zlyhala",
"event_duplicated": "Udalosť bola duplikovaná",
+3
View File
@@ -3035,6 +3035,9 @@
"alert_now": "Şimdi başlıyor",
"alert_in_minutes": "{count} dk içinde",
"invitation_sent": "Davetler gönderildi",
"invitations_denied_title": "Davetler gönderilemiyor",
"invitations_denied": "Sunucu davetleri göndermeyi reddetti: {reason}. Etkinlik davet gönderilmeden kaydedilsin mi?",
"save_without_invitations": "Davetsiz kaydet",
"rsvp_updated": "Yanıt güncellendi",
"rsvp_error": "Yanıt güncellenemedi",
"event_duplicated": "Etkinlik çoğaltıldı",
+3
View File
@@ -3009,6 +3009,9 @@
"alert_now": "Починаючи зараз",
"alert_in_minutes": "Через {count} хв",
"invitation_sent": "Запрошення надіслано",
"invitations_denied_title": "Не вдається надіслати запрошення",
"invitations_denied": "Сервер відмовився надіслати запрошення: {reason}. Зберегти подію без їх надсилання?",
"save_without_invitations": "Зберегти без запрошень",
"rsvp_updated": "Відповідь оновлено",
"rsvp_error": "Не вдалося оновити відповідь",
"event_duplicated": "Подія дублюється",
+3
View File
@@ -3035,6 +3035,9 @@
"alert_now": "現在開始",
"alert_in_minutes": "{count} 分鐘後",
"invitation_sent": "已傳送邀請",
"invitations_denied_title": "無法傳送邀請",
"invitations_denied": "伺服器拒絕傳送邀請:{reason}。是否不傳送邀請直接儲存活動?",
"save_without_invitations": "不傳送邀請並儲存",
"rsvp_updated": "回覆已更新",
"rsvp_error": "無法更新回覆",
"event_duplicated": "已建立活動副本",
+3
View File
@@ -3009,6 +3009,9 @@
"alert_now": "现在开始",
"alert_in_minutes": "{count} 分钟后",
"invitation_sent": "已发送邀请",
"invitations_denied_title": "无法发送邀请",
"invitations_denied": "服务器拒绝发送邀请:{reason}。是否不发送邀请直接保存活动?",
"save_without_invitations": "不发送邀请并保存",
"rsvp_updated": "回复已更新",
"rsvp_error": "无法更新回复",
"event_duplicated": "活动已复制",
+3
View File
@@ -8,6 +8,7 @@ import { displayNow } from '@/lib/timezone';
import { parseDuration } from '@/components/calendar/event-card';
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
import { SchedulingDeniedError } from '@/lib/jmap/scheduling-error';
import {
baseEventStoreId,
buildFallbackExcludePatch,
@@ -711,6 +712,8 @@ export const useCalendarStore = create<CalendarStore>()(
} catch (error) {
debug.error('Failed to create event:', error);
set({ error: 'Failed to create event' });
// The caller can offer to save it without the invitations.
if (error instanceof SchedulingDeniedError) throw error;
return null;
}
},