diff --git a/components/calendar/__tests__/task-modal-alerts.test.tsx b/components/calendar/__tests__/task-modal-alerts.test.tsx
index 333836ed7..29ae44617 100644
--- a/components/calendar/__tests__/task-modal-alerts.test.tsx
+++ b/components/calendar/__tests__/task-modal-alerts.test.tsx
@@ -80,3 +80,48 @@ describe('TaskModal alert preservation (#504)', () => {
expect(Object.keys(patch.alerts!)).toEqual(['a-abs']);
});
});
+
+// Stalwart 1.0 shows a task's calendar defaults in `alerts`, under the
+// default alerts' ids, while `useDefaultAlerts` is on, and drops alerts with
+// those ids on save.
+describe('TaskModal alerts of a task that uses the calendar defaults', () => {
+ const defaultsTask = () => ({
+ ...makeTask(),
+ useDefaultAlerts: true,
+ alerts: {
+ 'default-15': {
+ '@type': 'Alert',
+ trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' },
+ action: 'display',
+ acknowledged: null,
+ relatedTo: null,
+ },
+ },
+ } as unknown as CalendarTask);
+
+ it('turns the defaults off and moves the alerts to ids of their own when the alert changes on Stalwart 1.0', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ render();
+
+ fireEvent.change(screen.getByDisplayValue('tasks.alert_15min'), { target: { value: '60' } });
+ fireEvent.click(screen.getByText('tasks.save'));
+
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
+ const patch = onSave.mock.calls[0][0] as Partial;
+ expect(patch.useDefaultAlerts).toBe(false);
+ expect(Object.keys(patch.alerts!)).toEqual(['alert1']);
+ expect(patch.alerts!.alert1.trigger).toMatchObject({ offset: '-PT60M' });
+ });
+
+ it('leaves the defaults alone when the alert was not touched', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ render();
+
+ fireEvent.click(screen.getByText('tasks.save'));
+
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
+ const patch = onSave.mock.calls[0][0] as Partial;
+ expect(patch).not.toHaveProperty('alerts');
+ expect(patch).not.toHaveProperty('useDefaultAlerts');
+ });
+});
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx
index 29b9c1cfd..2a0788f0f 100644
--- a/components/calendar/event-modal.tsx
+++ b/components/calendar/event-modal.tsx
@@ -24,6 +24,7 @@ import {
buildParticipantMap,
} from "@/lib/calendar-participants";
import { canUserRsvp, getEventEditability, canCreateEventsIn } from "@/lib/calendar-editability";
+import { withOwnAlertIds } from "@/lib/calendar-alerts";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store";
import { generateUUID } from "@/lib/utils";
@@ -115,10 +116,12 @@ type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly" | "cu
type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks";
-interface AlertRow {
+export interface AlertRow {
id: string;
value: number;
unit: AlertUnit;
+ /** The event alert the row shows, and what the row read when loaded. */
+ source?: { alertId: string; value: number; unit: AlertUnit };
}
let alertRowSeq = 0;
@@ -159,6 +162,82 @@ function offsetToAlertRow(offset: string): AlertRow | null {
return null;
}
+/**
+ * The editor rows for an event's alerts, and the alerts the rows cannot
+ * show (absolute triggers, email actions, unusual offsets), which a save
+ * keeps as they are.
+ */
+export function alertRowsOf(alerts: CalendarEvent["alerts"] | undefined): {
+ rows: AlertRow[];
+ preserved: Record;
+} {
+ const rows: AlertRow[] = [];
+ const preserved: Record = {};
+ for (const [id, alert] of Object.entries(alerts ?? {})) {
+ const row = alert.trigger["@type"] === "OffsetTrigger" && alert.action === "display"
+ ? offsetToAlertRow(alert.trigger.offset)
+ : null;
+ if (!row) {
+ preserved[id] = alert;
+ continue;
+ }
+ rows.push({ ...row, source: { alertId: id, value: row.value, unit: row.unit } });
+ }
+ return { rows, preserved };
+}
+
+/**
+ * What a save sends for the alerts: nothing when they are as the event had
+ * them. Rows the user left alone keep their alert as it was, under its id;
+ * new and changed rows become new alerts.
+ *
+ * On an event that uses its calendar's default alerts, a change turns them
+ * off for the event, as the native app does: while `useDefaultAlerts` is on,
+ * Stalwart 1.0 fills the defaults into `alerts` and shows those instead of
+ * the event's own, so a change would not stick. The alerts then get ids of
+ * their own (withOwnAlertIds), away from the event's and the calendar's
+ * default ids (`reservedIds`). 0.16 stores the alerts either way.
+ */
+export function buildAlertsPatch(
+ event: Pick | null | undefined,
+ rows: AlertRow[],
+ preserved: Record,
+ reservedIds: Iterable = [],
+): Partial> {
+ const original = event?.alerts ?? {};
+ const kept: Record = { ...preserved };
+ const added: CalendarEventAlert[] = [];
+ for (const row of rows) {
+ const offset = alertRowToOffset(row);
+ if (offset === null) continue;
+ const { source } = row;
+ if (source && row.value === source.value && row.unit === source.unit && original[source.alertId]) {
+ kept[source.alertId] = original[source.alertId];
+ continue;
+ }
+ added.push({
+ "@type": "Alert",
+ trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" },
+ action: "display",
+ acknowledged: null,
+ relatedTo: null,
+ });
+ }
+
+ const keptAll = Object.keys(original).every((id) => id in kept);
+ if (added.length === 0 && keptAll) return {};
+
+ const taken = [...Object.keys(original), ...reservedIds];
+ const alerts = event?.useDefaultAlerts
+ ? withOwnAlertIds([...Object.values(kept), ...added], taken)
+ : { ...kept, ...withOwnAlertIds(added, taken) };
+ const patch: Partial> = {
+ alerts: Object.keys(alerts).length > 0 ? alerts : null,
+ };
+ if (event?.useDefaultAlerts) patch.useDefaultAlerts = false;
+ return patch;
+}
+
function formatAlertRowLabel(
row: { value: number; unit: AlertUnit },
t: ReturnType
@@ -386,24 +465,11 @@ export function EventModal({
() => (customRule ? buildRecurrenceSummary(customRule, t, locale) : null),
[customRule, t, locale]
);
+ // Alerts this UI can't represent survive a save as they are.
const preservedAlertsRef = useRef>({});
const [alertRows, setAlertRows] = useState(() => {
- if (!event?.alerts) return [];
- const rows: AlertRow[] = [];
- for (const [id, alert] of Object.entries(event.alerts)) {
- // Preserve alerts we can't represent in this UI (absolute triggers,
- // email actions, offsets with non-canonical shapes) so they survive a save.
- if (alert.trigger["@type"] !== "OffsetTrigger" || alert.action !== "display") {
- preservedAlertsRef.current[id] = alert;
- continue;
- }
- const row = offsetToAlertRow(alert.trigger.offset);
- if (!row) {
- preservedAlertsRef.current[id] = alert;
- continue;
- }
- rows.push(row);
- }
+ const { rows, preserved } = alertRowsOf(event?.alerts);
+ preservedAlertsRef.current = preserved;
return rows;
});
@@ -594,26 +660,10 @@ export function EventModal({
if (event.excludedRecurrenceRules) data.excludedRecurrenceRules = null;
}
- const builtAlerts: Record = { ...preservedAlertsRef.current };
- let alertIdx = 0;
- for (const row of alertRows) {
- const offset = alertRowToOffset(row);
- if (offset === null) continue;
- let key = `alert${++alertIdx}`;
- while (key in builtAlerts) key = `alert${++alertIdx}`;
- builtAlerts[key] = {
- "@type": "Alert",
- trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" },
- action: "display",
- acknowledged: null,
- relatedTo: null,
- };
- }
- if (Object.keys(builtAlerts).length > 0) {
- data.alerts = builtAlerts;
- } else if (event && event.alerts && Object.keys(event.alerts).length > 0) {
- data.alerts = null;
- }
+ const defaultAlertIds = calendars
+ .filter((c) => c.id === calendarId || event?.calendarIds?.[c.id])
+ .flatMap((c) => [...Object.keys(c.defaultAlertsWithTime ?? {}), ...Object.keys(c.defaultAlertsWithoutTime ?? {})]);
+ Object.assign(data, buildAlertsPatch(event, alertRows, preservedAlertsRef.current, defaultAlertIds));
if (effectiveAttendees.length > 0 && currentUserEmails.length > 0) {
const organizerEmail = currentUserEmails[0];
@@ -644,7 +694,7 @@ export function EventModal({
} finally {
setIsSaving(false);
}
- }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, resolveContactName, event, onSave, isSaving]);
+ }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, resolveContactName, event, onSave, isSaving]);
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return;
diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx
index 8e648c490..d9a5468c3 100644
--- a/components/calendar/task-modal.tsx
+++ b/components/calendar/task-modal.tsx
@@ -8,6 +8,7 @@ import { X, Trash2, CalendarDays, Bell, Flag } from "@/components/icons";
import { format, parseISO } from "date-fns";
import type { CalendarTask, Calendar, CalendarEventAlert } from "@/lib/jmap/types";
+import { withOwnAlertIds } from "@/lib/calendar-alerts";
interface TaskModalProps {
task?: CalendarTask | null;
@@ -134,7 +135,14 @@ export function TaskModal({
relatedTo: null,
};
}
- data.alerts = Object.keys(merged).length > 0 ? merged : null;
+ // A task that uses its calendar's default alerts turns them off:
+ // Stalwart 1.0 shows the defaults in `alerts` instead of the task's
+ // own while they are on, and drops alerts under the default ids.
+ const alerts = task?.useDefaultAlerts
+ ? withOwnAlertIds(Object.values(merged), Object.keys(task.alerts ?? {}))
+ : merged;
+ if (task?.useDefaultAlerts) data.useDefaultAlerts = false;
+ data.alerts = Object.keys(alerts).length > 0 ? alerts : null;
}
if (isEdit && task) {
diff --git a/lib/__tests__/calendar-alerts.test.ts b/lib/__tests__/calendar-alerts.test.ts
index c57cd2ba5..0480cdc8c 100644
--- a/lib/__tests__/calendar-alerts.test.ts
+++ b/lib/__tests__/calendar-alerts.test.ts
@@ -5,10 +5,13 @@ import {
getEffectiveAlerts,
buildAlertKey,
getPendingAlerts,
+ getPendingTaskAlerts,
+ withOwnAlertIds,
} from '../calendar-alerts';
import type {
CalendarEvent,
CalendarEventAlert,
+ CalendarTask,
Calendar,
} from '@/lib/jmap/types';
@@ -421,3 +424,43 @@ describe('getPendingAlerts', () => {
expect(result[0].calendarName).toBeNull();
});
});
+
+// Stalwart 1.0 fills a calendar's default alerts into `alerts` while
+// `useDefaultAlerts` is on, under the default alerts' own ids (for tasks as
+// well). 0.16 copied the defaults into `alerts` once, when the event was
+// created. Either way each alert fires once.
+describe('default alerts filled in by the server', () => {
+ const eventStart = new Date('2026-03-01T10:00:00Z').getTime();
+ const defaults = { 'default-5': makeAlert(), 'default-mail': makeAlert({ action: 'email' }) };
+ const calendars = [makeCalendar({ defaultAlertsWithTime: defaults })];
+
+ it('fires each default alert of an event once on Stalwart 1.0', () => {
+ const event = makeEvent({ useDefaultAlerts: true, alerts: { ...defaults } });
+ const pending = getPendingAlerts([event], calendars, new Set(), eventStart - 5 * 60 * 1000 + 1000);
+ expect(pending.map((p) => p.alertId)).toEqual(['default-5']);
+ });
+
+ it('fires each default alert of an event once on 0.16', () => {
+ // Copied in at create time, under ids of 0.16's own.
+ const event = makeEvent({ useDefaultAlerts: true, alerts: { copied: makeAlert() } });
+ const pending = getPendingAlerts([event], calendars, new Set(), eventStart - 5 * 60 * 1000 + 1000);
+ expect(pending.map((p) => p.alertId)).toEqual(['default-5']);
+ });
+
+ it('fires the default alerts filled into a task once on Stalwart 1.0', () => {
+ const task = {
+ id: 'task-1', '@type': 'Task', title: 'Pay rent', calendarIds: { 'cal-1': true },
+ due: '2026-03-01T10:00:00Z', progress: 'needs-action', useDefaultAlerts: true, alerts: { ...defaults },
+ } as unknown as CalendarTask;
+ const pending = getPendingTaskAlerts([task], calendars, new Set(), eventStart - 5 * 60 * 1000 + 1000);
+ expect(pending.map((p) => p.alertId)).toEqual(['default-5']);
+ });
+});
+
+describe('withOwnAlertIds', () => {
+ it('numbers the alerts past the ids that are taken', () => {
+ const [a, b] = [makeAlert({ trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' } }), makeAlert()];
+ expect(withOwnAlertIds([a, b], ['alert1', 'default-5'])).toEqual({ alert2: a, alert3: b });
+ expect(withOwnAlertIds([], ['alert1'])).toEqual({});
+ });
+});
diff --git a/lib/__tests__/event-modal-alerts.test.ts b/lib/__tests__/event-modal-alerts.test.ts
new file mode 100644
index 000000000..2bf82a980
--- /dev/null
+++ b/lib/__tests__/event-modal-alerts.test.ts
@@ -0,0 +1,146 @@
+import { describe, it, expect } from 'vitest';
+import { alertRowsOf, buildAlertsPatch, type AlertRow } from '@/components/calendar/event-modal';
+import type { CalendarEvent, CalendarEventAlert } from '@/lib/jmap/types';
+
+// What the event editor saves for the alerts. It used to rename every alert
+// to alert1..N and send them on every save. On Stalwart 1.0 that loses alert
+// edits on events that use their calendar's default alerts:
+// - on get, while `useDefaultAlerts` is on, the server shows the calendar's
+// defaults in `alerts` (under the default alerts' ids) instead of the
+// event's own (calendar_event/get.rs, DefaultAlertsView::Replace);
+// - on save, it drops every alert whose id is a default id
+// (calendar_event/set.rs, strip_default_alerts).
+// 0.16 copied the defaults into the event once, at create, and stores
+// whatever is sent.
+
+const offsetAlert = (offset: string, extra: Partial = {}): CalendarEventAlert => ({
+ '@type': 'Alert',
+ trigger: { '@type': 'OffsetTrigger', offset, relativeTo: 'start' },
+ action: 'display',
+ acknowledged: null,
+ relatedTo: null,
+ ...extra,
+});
+
+type Stored = { own: Record; useDefaultAlerts: boolean };
+type Patch = Partial>;
+
+const DEFAULTS = { 'default-15': offsetAlert('-PT15M') };
+
+/** What Stalwart 1.0 answers for the event's alerts. */
+function get10(stored: Stored): Pick {
+ return { useDefaultAlerts: stored.useDefaultAlerts, alerts: stored.useDefaultAlerts ? { ...DEFAULTS } : { ...stored.own } };
+}
+
+/** How Stalwart 1.0 stores an update of the event's alerts. */
+function update10(stored: Stored, patch: Patch): Stored {
+ const viewDefaults: Record = stored.useDefaultAlerts ? DEFAULTS : {};
+ let alerts = { ...stored.own, ...viewDefaults };
+ if ('alerts' in patch) alerts = { ...(patch.alerts ?? {}) };
+ const useDefaultAlerts = 'useDefaultAlerts' in patch ? !!patch.useDefaultAlerts : stored.useDefaultAlerts;
+ const newDefaults: Record = useDefaultAlerts ? DEFAULTS : {};
+ for (const id of Object.keys(alerts)) {
+ if (id in viewDefaults || id in newDefaults) delete alerts[id];
+ }
+ return { own: alerts, useDefaultAlerts };
+}
+
+const offsets = (alerts: CalendarEvent['alerts']) => Object.values(alerts ?? {})
+ .map((a) => (a.trigger as { offset: string }).offset)
+ .sort();
+
+/** The editor's rows for `alerts`, with `edit` applied to them. */
+function edit(event: Pick, change: (rows: AlertRow[]) => AlertRow[]): Patch {
+ const { rows, preserved } = alertRowsOf(event.alerts);
+ return buildAlertsPatch(event, change(rows), preserved, Object.keys(DEFAULTS));
+}
+
+const newRow = (value: number, unit: AlertRow['unit']): AlertRow => ({ id: `new-${value}-${unit}`, value, unit });
+
+describe('saving the alerts of an event that uses its calendar’s default alerts', () => {
+ it('sends nothing when they were not touched, on Stalwart 1.0', () => {
+ expect(edit(get10({ own: {}, useDefaultAlerts: true }), (rows) => rows)).toEqual({});
+ });
+
+ it('keeps a changed alert on Stalwart 1.0 by turning the defaults off', () => {
+ let stored: Stored = { own: {}, useDefaultAlerts: true };
+ const patch = edit(get10(stored), ([row]) => [{ ...row, value: 30 }]);
+
+ expect(patch.useDefaultAlerts).toBe(false);
+ stored = update10(stored, patch);
+ expect(offsets(get10(stored).alerts)).toEqual(['-PT30M']);
+
+ // What was sent before: the same alerts renamed, with the defaults left
+ // on. Stalwart 1.0 keeps showing the defaults instead.
+ const before = update10({ own: {}, useDefaultAlerts: true }, { alerts: { alert1: offsetAlert('-PT30M') } });
+ expect(offsets(get10(before).alerts)).toEqual(['-PT15M']);
+ });
+
+ it('keeps an untouched default next to a new alert on Stalwart 1.0, under ids of their own', () => {
+ let stored: Stored = { own: {}, useDefaultAlerts: true };
+ const patch = edit(get10(stored), (rows) => [...rows, newRow(1, 'hours')]);
+
+ expect(Object.keys(patch.alerts ?? {})).not.toContain('default-15');
+ stored = update10(stored, patch);
+ expect(offsets(get10(stored).alerts)).toEqual(['-PT15M', '-PT1H']);
+ });
+
+ it('removes every alert on Stalwart 1.0 when the user removes them', () => {
+ let stored: Stored = { own: {}, useDefaultAlerts: true };
+ const patch = edit(get10(stored), () => []);
+
+ expect(patch).toEqual({ alerts: null, useDefaultAlerts: false });
+ stored = update10(stored, patch);
+ expect(get10(stored).alerts).toEqual({});
+ });
+
+ it('turns the defaults off on 0.16 too, where the event holds its own copy of them', () => {
+ // 0.16 copied the calendar's defaults into the event at create time.
+ const event = { useDefaultAlerts: true, alerts: { copied: offsetAlert('-PT15M') } };
+ const patch = edit(event, ([row]) => [{ ...row, value: 45 }]);
+
+ expect(patch).toEqual({ useDefaultAlerts: false, alerts: { alert1: offsetAlert('-PT45M') } });
+ });
+});
+
+describe('saving the alerts of an event with alerts of its own', () => {
+ const event = {
+ useDefaultAlerts: false,
+ alerts: {
+ a1: offsetAlert('-PT15M'),
+ // Before the end, which the editor cannot show exactly.
+ a2: offsetAlert('PT10M', { trigger: { '@type': 'OffsetTrigger', offset: '-PT10M', relativeTo: 'end' } }),
+ abs: { ...offsetAlert('-PT1H'), trigger: { '@type': 'AbsoluteTrigger', when: '2026-09-10T08:00:00Z' } },
+ } as Record,
+ };
+
+ it('sends nothing when they were not touched', () => {
+ expect(edit(event, (rows) => rows)).toEqual({});
+ });
+
+ it('keeps untouched alerts as they were, under their ids, and adds new ones under new ids', () => {
+ const patch = edit(event, (rows) => [...rows, newRow(2, 'days')]);
+
+ expect(patch).not.toHaveProperty('useDefaultAlerts');
+ expect(patch.alerts).toEqual({
+ a1: event.alerts.a1,
+ a2: event.alerts.a2,
+ abs: event.alerts.abs,
+ alert1: offsetAlert('-P2D'),
+ });
+ });
+
+ it('sends the remaining alerts when one is removed, and null when all are', () => {
+ expect(edit(event, (rows) => rows.filter((row) => row.source?.alertId !== 'a1')).alerts)
+ .toEqual({ a2: event.alerts.a2, abs: event.alerts.abs });
+ const onlyRows = { useDefaultAlerts: false, alerts: { a1: offsetAlert('-PT15M') } };
+ expect(edit(onlyRows, () => [])).toEqual({ alerts: null });
+ });
+});
+
+describe('saving the alerts of a new event', () => {
+ it('sends the rows as new alerts, and nothing without rows', () => {
+ expect(buildAlertsPatch(null, [newRow(10, 'minutes')], {})).toEqual({ alerts: { alert1: offsetAlert('-PT10M') } });
+ expect(buildAlertsPatch(null, [], {})).toEqual({});
+ });
+});
diff --git a/lib/calendar-alerts.ts b/lib/calendar-alerts.ts
index f7b4f4c9b..e6a84cc3a 100644
--- a/lib/calendar-alerts.ts
+++ b/lib/calendar-alerts.ts
@@ -69,6 +69,12 @@ export function computeFireTime(
return baseTime + offsetMs;
}
+/**
+ * The alerts an event fires: the calendar's default alerts while the event
+ * uses them, its own otherwise - never both. Stalwart 1.0 also fills the
+ * defaults into the `alerts` of such an event, under the default alerts'
+ * ids; reading one or the other keeps each alert firing once.
+ */
export function getEffectiveAlerts(
event: CalendarEvent,
calendars: Calendar[]
@@ -94,6 +100,30 @@ export function buildAlertKey(eventId: string, alertId: string, fireTimeMs: numb
return `${eventId}:${alertId}:${fireTimeMs}`;
}
+/**
+ * `alerts` under new ids (alert1, alert2, ...), none of them in `taken`.
+ *
+ * For an event or task that turns its calendar's default alerts off: while
+ * `useDefaultAlerts` is on, Stalwart 1.0 shows the defaults in `alerts`
+ * under the default alerts' ids, and on save it drops every alert whose id
+ * is one of those. Alerts the user keeps have to be saved under ids of their
+ * own, or they vanish with the defaults.
+ */
+export function withOwnAlertIds(
+ alerts: CalendarEventAlert[],
+ taken: Iterable,
+): Record {
+ const used = new Set(taken);
+ const result: Record = {};
+ let n = 0;
+ for (const alert of alerts) {
+ let id: string;
+ do id = `alert${++n}`; while (used.has(id));
+ result[id] = alert;
+ }
+ return result;
+}
+
export function getPendingAlerts(
events: CalendarEvent[],
calendars: Calendar[],
diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts
index b4fcfe858..d1ad56be8 100644
--- a/lib/jmap/types.ts
+++ b/lib/jmap/types.ts
@@ -848,6 +848,8 @@ export interface CalendarTask {
created: string | null;
updated: string;
recurrenceRules: CalendarRecurrenceRule[] | null;
+ // While on, Stalwart 1.0 fills the calendar's default alerts into `alerts`.
+ useDefaultAlerts?: boolean;
alerts: Record | null;
relatedTo: Record | null;
}