mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
fix: let attendees on 0.16 take a series change after one occurrence changed
Stalwart 0.16 (0.16.6 to 0.16.23) sends a change of one occurrence of a meeting with that occurrence's SEQUENCE raised, but does not keep the raise itself: in its organizer update code the list of components to raise is shadowed for changes of single occurrences. A later change of every occurrence that does not raise the number again - a new title, another description - then went out with the old number for that occurrence, and the attendee's server refused the whole message as out of sequence. The attendee got the "Updated invitation" mail, but their calendar stayed as it was (live on 0.16.23; 1.0 raises and keeps the number itself). A change of every occurrence that the user as organizer sends to the attendees, of something they see, now raises the SEQUENCE of the series and of each changed occurrence by one, on a series that has changed occurrences. One up from what the organizer's copy holds is at least what the attendees got, and it keeps the series and its occurrences on equal numbers, which 0.16 needs to list an occurrence once. 1.0 raises the number of every occurrence the same way for a change it sends, and keeps a number it would have given: the result there is the same (live).
This commit is contained in:
@@ -744,8 +744,12 @@ describe('saveOccurrenceEdit for every occurrence', () => {
|
||||
expect(saved).toBe(true);
|
||||
expect(deps.findMasterEvent).toHaveBeenCalledWith(occurrence);
|
||||
// What the user changed, and only that: the calendar, the description
|
||||
// and the rule the editor wrote as they were stay the series' own.
|
||||
expect(deps.updateEvent).toHaveBeenCalledWith(deps.client, 'b', { title: 'Planning, room 2' }, true);
|
||||
// and the rule the editor wrote as they were stay the series' own. The
|
||||
// attendees are told, and 19 October has an override: the SEQUENCE goes
|
||||
// up (see below).
|
||||
expect(deps.updateEvent).toHaveBeenCalledWith(deps.client, 'b', {
|
||||
title: 'Planning, room 2', sequence: 1, 'recurrenceOverrides/2026-10-19T10:00:00/sequence': 1,
|
||||
}, true, { keepSequence: true });
|
||||
});
|
||||
|
||||
it('reports a series it cannot find', async () => {
|
||||
@@ -762,6 +766,64 @@ describe('saveOccurrenceEdit for every occurrence', () => {
|
||||
});
|
||||
|
||||
|
||||
// 0.16 sends the change of one occurrence with its SEQUENCE raised, without
|
||||
// keeping the raise itself. A later rename of every occurrence carried the
|
||||
// lower number for it, and the attendee's server refused the whole change:
|
||||
// the mail arrived, the copy stayed as it was (live on 0.16.23). 1.0 raises
|
||||
// every occurrence's SEQUENCE the same way itself for a change it sends.
|
||||
describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('the SEQUENCE of a changed series on Stalwart %s', (_label, version) => {
|
||||
// Five Mondays from 5 October, 19 October with an override: on 0.16 it
|
||||
// holds a sequence of its own (copied when it got the override), on 1.0
|
||||
// the PatchObject leaves it out while it is the series'.
|
||||
const withSequences = (overrides: CalendarEvent['recurrenceOverrides'] = series(version).recurrenceOverrides) => ({
|
||||
...series(version), sequence: 2,
|
||||
recurrenceOverrides: version === '1.0'
|
||||
? overrides
|
||||
: Object.fromEntries(Object.entries(overrides ?? {}).map(([key, entry]) => [key, { ...entry, sequence: 2 }])),
|
||||
}) as CalendarEvent;
|
||||
const save = (master: CalendarEvent, change: Partial<CalendarEvent>, extra: Partial<Parameters<typeof saveOccurrenceEdit>[1]> = {}, editor = {}) => {
|
||||
const occurrence = { ...serverOccurrence(version), id: 'maaaaad', start: '2026-10-26T10:00:00', recurrenceId: '2026-10-26T10:00:00', utcStart: '2026-10-26T09:00:00Z', utcEnd: '2026-10-26T10:00:00Z' } as CalendarEvent;
|
||||
const deps = actions(master);
|
||||
return saveOccurrenceEdit('all', {
|
||||
event: occurrence, updates: editorUpdates(occurrence, change), editor: editorOn(occurrence, editor),
|
||||
sendScheduling: true, userEmails: [ORGANIZER], ...extra,
|
||||
}, deps).then(() => deps.updateEvent.mock.calls[0]);
|
||||
};
|
||||
|
||||
it('raises the series\' and each changed occurrence\'s by one on a new title', async () => {
|
||||
const [, id, patch, send, options] = await save(withSequences(), { title: 'Planning, room 2' });
|
||||
|
||||
expect([id, send, options]).toEqual(['b', true, { keepSequence: true }]);
|
||||
expect(patch).toEqual({ title: 'Planning, room 2', sequence: 3, 'recurrenceOverrides/2026-10-19T10:00:00/sequence': 3 });
|
||||
});
|
||||
|
||||
it('raises them inside the overrides when the series moves', async () => {
|
||||
const occurrenceStart = getEventStartDate({ ...serverOccurrence(version), start: '2026-10-26T10:00:00', utcStart: '2026-10-26T09:00:00Z' } as CalendarEvent);
|
||||
const retitled = withSequences({ '2026-10-19T10:00:00': { title: 'Planning, special' } } as never);
|
||||
const [, , patch, , options] = await save(retitled, {}, {}, {
|
||||
when: { allDay: false, start: addHours(occurrenceStart, 1), end: addHours(occurrenceStart, 2) },
|
||||
});
|
||||
|
||||
expect(options).toEqual({ keepSequence: true });
|
||||
expect(patch.sequence).toBe(3);
|
||||
expect(patch.recurrenceOverrides['2026-10-19T11:00:00'].sequence).toBe(3);
|
||||
expect(Object.keys(patch).some((key: string) => key.startsWith('recurrenceOverrides/'))).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves them alone when nothing goes out, nothing the attendees see changes, or no occurrence has an override', async () => {
|
||||
const quiet = await save(withSequences(), { title: 'Planning, room 2' }, { sendScheduling: false });
|
||||
const alerts = await save(withSequences(), {
|
||||
alerts: { a1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } } as never,
|
||||
});
|
||||
const plain = await save({ ...withSequences(), recurrenceOverrides: null } as CalendarEvent, { title: 'Planning, room 2' });
|
||||
|
||||
for (const [, , patch, , options] of [quiet, alerts, plain]) {
|
||||
expect(patch).not.toHaveProperty('sequence');
|
||||
expect(options).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// "This and the following occurrences" ends the series before the
|
||||
// occurrence and makes a new series of the rest. The new series kept the
|
||||
// answers, and Stalwart invites to a new event only attendees without one:
|
||||
|
||||
@@ -52,7 +52,8 @@ export interface CalendarEventUpdateOptions {
|
||||
/**
|
||||
* Send `sequence` instead of stripping it with the other server-managed
|
||||
* fields. Only for a patch that creates a recurrence override and must carry
|
||||
* the series' sequence (see buildOccurrenceRsvpPatch).
|
||||
* the series' sequence (see buildOccurrenceRsvpPatch), or a change of a
|
||||
* whole series that raises it (see withRaisedSequences).
|
||||
*/
|
||||
keepSequence?: boolean;
|
||||
}
|
||||
|
||||
@@ -506,6 +506,63 @@ export interface EventUpdateOptions {
|
||||
* (see the calendar store's updateEvent).
|
||||
*/
|
||||
asOrganizer?: boolean;
|
||||
/** The update sets `sequence` itself (withRaisedSequences); it is not stripped. */
|
||||
keepSequence?: boolean;
|
||||
}
|
||||
|
||||
/** Changes of a series its attendees see - Stalwart announces them, and 1.0 raises the SEQUENCE for them. */
|
||||
const ANNOUNCED_KEYS = [
|
||||
'title', 'description', 'locations', 'virtualLocations', 'status', 'participants', 'recurrenceRules',
|
||||
'excludedRecurrenceRules', 'recurrenceOverrides', ...WHEN_KEYS,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* `series`, a change to the series `master` its attendees are told of, with
|
||||
* the SEQUENCE of the series and of each of its changed occurrences one up.
|
||||
*
|
||||
* 0.16 (0.16.6 to 0.16.23) sends the change of one occurrence with that
|
||||
* occurrence's SEQUENCE raised, but does not keep the raise itself (in
|
||||
* organizer.rs the list of components to raise is shadowed for such
|
||||
* changes). A later change of every occurrence that does not raise the
|
||||
* number again - a new title - then carried a lower SEQUENCE for that
|
||||
* occurrence than the attendee's copy has, and the attendee's server
|
||||
* refused the whole change as out of sequence: the mail arrived, the copy
|
||||
* stayed as it was (live on 0.16.23). One up from what the organizer's
|
||||
* copy holds is at least what the attendees got. 1.0 raises every
|
||||
* occurrence's SEQUENCE the same way itself for a change it announces, and
|
||||
* keeps a number it would have given (live: the same result with and
|
||||
* without).
|
||||
*/
|
||||
function withRaisedSequences(series: Partial<CalendarEvent>, master: CalendarEvent): Partial<CalendarEvent> {
|
||||
const base = master.sequence ?? 0;
|
||||
const raised: Record<string, unknown> = { ...series, sequence: base + 1 };
|
||||
const raise = (entry: OverrideEntry) => (entry.excluded ? entry : { ...entry, sequence: Number(entry.sequence ?? base) + 1 });
|
||||
if (series.recurrenceOverrides !== undefined) {
|
||||
if (series.recurrenceOverrides) {
|
||||
raised.recurrenceOverrides = Object.fromEntries(Object.entries(series.recurrenceOverrides)
|
||||
.map(([key, entry]) => [key, raise(entry as OverrideEntry)]));
|
||||
}
|
||||
} else {
|
||||
for (const [key, entry] of Object.entries(master.recurrenceOverrides ?? {})) {
|
||||
if (!(entry as OverrideEntry).excluded) raised[`recurrenceOverrides/${key}/sequence`] = raise(entry as OverrideEntry).sequence;
|
||||
}
|
||||
}
|
||||
return raised as Partial<CalendarEvent>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether saving `series` for every occurrence of `master` raises their
|
||||
* SEQUENCE (withRaisedSequences): the user organizes the series and tells
|
||||
* its attendees of a change they see, and it has occurrences of its own a
|
||||
* change of one of them may have left behind.
|
||||
*/
|
||||
function raisesSequences(series: Partial<CalendarEvent>, master: CalendarEvent, edit: OccurrenceEdit): boolean {
|
||||
return !!edit.sendScheduling
|
||||
&& !!edit.editor
|
||||
&& Object.keys(master.participants ?? {}).length > 0
|
||||
&& userOrganizes(master, edit.userEmails)
|
||||
&& Object.values(master.recurrenceOverrides ?? {}).some((entry) => !(entry as OverrideEntry).excluded)
|
||||
&& ANNOUNCED_KEYS.some((key) => key in series);
|
||||
}
|
||||
|
||||
/** What saving an occurrence edit works with: the calendar store's actions and the app's series lookup. */
|
||||
@@ -915,6 +972,10 @@ export async function saveOccurrenceEdit(
|
||||
seriesUpdates = { ...updates };
|
||||
delete seriesUpdates.recurrenceId;
|
||||
}
|
||||
if (raisesSequences(seriesUpdates, master, edit)) {
|
||||
await updateEvent(client, master.id, withRaisedSequences(seriesUpdates, master), sendScheduling, { keepSequence: true });
|
||||
return true;
|
||||
}
|
||||
await updateEvent(client, master.id, seriesUpdates, sendScheduling);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -191,6 +191,18 @@ describe('updateEvent on an expanded occurrence', () => {
|
||||
expect(client.queryAllCalendarEvents).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// A change of every occurrence that raises their SEQUENCE sends it (see
|
||||
// withRaisedSequences in lib/recurrence-scope-edit.ts).
|
||||
it('keeps the sequence of a change to the whole series when asked to', async () => {
|
||||
const client = fakeClient();
|
||||
client.queryAllCalendarEvents.mockResolvedValue([occurrence()]);
|
||||
await useCalendarStore.getState().fetchEvents(client, RANGE[0], RANGE[1]);
|
||||
|
||||
await useCalendarStore.getState().updateEvent(client, 'b', { title: 'Whole series', sequence: 3 }, true, { keepSequence: true });
|
||||
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledWith('b', { title: 'Whole series', sequence: 3 }, true, undefined, { keepSequence: true });
|
||||
});
|
||||
|
||||
it('does not reload after changing an ordinary event', async () => {
|
||||
const client = fakeClient();
|
||||
const plain = occurrence({ id: 'x', originalId: 'x', baseEventId: 'x', recurrenceId: null, recurrenceRules: null });
|
||||
|
||||
@@ -165,19 +165,23 @@ async function updateBaseEvent(
|
||||
updates: Partial<CalendarEvent>,
|
||||
sendSchedulingMessages: boolean | undefined,
|
||||
targetAccountId: string | undefined,
|
||||
options?: CalendarEventUpdateOptions,
|
||||
): Promise<void> {
|
||||
const write = (patch: Partial<CalendarEvent>) => (options
|
||||
? client.updateCalendarEvent(id, patch, sendSchedulingMessages, targetAccountId, options)
|
||||
: client.updateCalendarEvent(id, patch, sendSchedulingMessages, targetAccountId));
|
||||
const writesOverrides = Object.keys(updates).some((key) => key.split('/', 1)[0] === 'recurrenceOverrides');
|
||||
if (writesOverrides && overrideSeriesKeysRejected.has(client)) {
|
||||
await client.updateCalendarEvent(id, withoutOverrideSeriesKeys(updates), sendSchedulingMessages, targetAccountId);
|
||||
await write(withoutOverrideSeriesKeys(updates));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.updateCalendarEvent(id, updates, sendSchedulingMessages, targetAccountId);
|
||||
await write(updates);
|
||||
} catch (error) {
|
||||
if (!writesOverrides || !isOverrideSeriesKeyRejection(error)) throw error;
|
||||
overrideSeriesKeysRejected.add(client);
|
||||
debug.log('calendar', 'Server refuses series keys inside recurrence overrides; writing them without');
|
||||
await client.updateCalendarEvent(id, withoutOverrideSeriesKeys(updates), sendSchedulingMessages, targetAccountId);
|
||||
await write(withoutOverrideSeriesKeys(updates));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1044,7 +1048,10 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
await updateBrowserOccurrence(client, target, cleanUpdates, sendSchedulingMessages);
|
||||
}
|
||||
} else {
|
||||
await updateBaseEvent(client, realId, cleanUpdates, sendSchedulingMessages, targetAccountId);
|
||||
await updateBaseEvent(
|
||||
client, realId, cleanUpdates, sendSchedulingMessages, targetAccountId,
|
||||
options?.keepSequence ? { keepSequence: true } : undefined,
|
||||
);
|
||||
}
|
||||
set((state) => ({
|
||||
events: state.events.map(e => {
|
||||
|
||||
Reference in New Issue
Block a user