mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: show moved occurrences on 0.16.23 and keep organizers on save
- Stalwart 0.16.23 answers `utcStart` and `utcEnd` of a counted series' master with "32548-12-04T15:30:07Z" once every occurrence is overridden or excluded, and with the next plain occurrence's times once the first one is; 1.0 answers the master's own. Expanding such a series in the browser took the occurrences' length from those instants and copied them onto the occurrences, so a moved occurrence fell out of the range. The master's instants now count only where they fit its wall clock, the length falls back to the JSCalendar duration, the range check ignores instants that do not parse or fit, and no occurrence gets the master's instants, so the alerts of an all-day series expanded in the browser go off before each day, not before the first. - The event editor rebuilt the participants around the user's first address on every save, set `organizerCalendarAddress` to it and put every attendee back to needs-action. An invitation the user saved in their own calendar became theirs and lost their answer, and one occurrence of a series organized under another address or letter case got a different owner; Stalwart 1.0 refuses a changed `organizerCalendarAddress` there. The editor now keeps the event's own organizer, writes `organizerCalendarAddress` only for an event without one, and keeps the id and answer of every attendee who stays.
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { EventModal } from '../event-modal';
|
||||
import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
||||
|
||||
// The editor rewrote the organizer on every save of an event with attendees,
|
||||
// to the user's first address, and put every attendee back to needs-action.
|
||||
// An invitation the user saved in their own calendar became the user's, and
|
||||
// their answer was gone. It now keeps the event's organizer and the answers
|
||||
// of attendees who stay.
|
||||
|
||||
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
|
||||
|
||||
const calendars = [{
|
||||
id: 'cal', name: 'Personal', color: '#123456', isDefault: true,
|
||||
myRights: {
|
||||
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
|
||||
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
|
||||
},
|
||||
}] as unknown as Calendar[];
|
||||
|
||||
// Participants as each version stores them: 0.16.23 drops an attendee's
|
||||
// roles, 1.0 keeps them.
|
||||
function participant(version: '0.16' | '1.0', address: string, owner: boolean, status: string) {
|
||||
return {
|
||||
'@type': 'Participant', calendarAddress: `mailto:${address}`, email: address,
|
||||
...(owner ? { roles: { owner: true } } : version === '1.0' ? { roles: { attendee: true } } : {}),
|
||||
participationStatus: status, scheduleAgent: 'server', expectReply: !owner, kind: 'individual',
|
||||
};
|
||||
}
|
||||
|
||||
function meeting(organizer: string, participants: Record<string, unknown>): CalendarEvent {
|
||||
return {
|
||||
id: 'e1', uid: 'u1', '@type': 'Event', title: 'Planning', description: '', start: '2026-10-12T10:00:00',
|
||||
duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, calendarIds: { cal: true },
|
||||
organizerCalendarAddress: `mailto:${organizer}`, participants, alerts: null, recurrenceRules: null,
|
||||
} as unknown as CalendarEvent;
|
||||
}
|
||||
|
||||
async function saveUnchanged(event: CalendarEvent): Promise<Partial<CalendarEvent>> {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<EventModal
|
||||
event={event}
|
||||
calendars={calendars}
|
||||
onSave={onSave}
|
||||
onClose={vi.fn()}
|
||||
currentUserEmails={['me@example.com']}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('events.edit'));
|
||||
fireEvent.click(screen.getByText('form.save'));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
return onSave.mock.calls[0][0] as Partial<CalendarEvent>;
|
||||
}
|
||||
|
||||
describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal organizer and answers on Stalwart %s', (version) => {
|
||||
it('keeps the sender of an invitation and the user\'s answer', async () => {
|
||||
const data = await saveUnchanged(meeting('boss@example.org', {
|
||||
boss: participant(version, 'boss@example.org', true, 'accepted'),
|
||||
mine: participant(version, 'me@example.com', false, 'accepted'),
|
||||
bob: participant(version, 'bob@example.org', false, 'declined'),
|
||||
}));
|
||||
|
||||
expect(data).not.toHaveProperty('organizerCalendarAddress');
|
||||
const participants = data.participants as Record<string, CalendarParticipant>;
|
||||
expect(Object.keys(participants).sort()).toEqual(['bob', 'boss', 'mine']);
|
||||
expect(participants.boss).toMatchObject({ calendarAddress: 'mailto:boss@example.org', roles: { owner: true } });
|
||||
expect(participants.mine.participationStatus).toBe('accepted');
|
||||
expect(participants.bob.participationStatus).toBe('declined');
|
||||
});
|
||||
|
||||
it('keeps the letter case a series is organized under', async () => {
|
||||
const data = await saveUnchanged(meeting('ME@Example.com', {
|
||||
org: participant(version, 'ME@Example.com', true, 'accepted'),
|
||||
guest: participant(version, 'guest@example.net', false, 'tentative'),
|
||||
}));
|
||||
|
||||
expect(data).not.toHaveProperty('organizerCalendarAddress');
|
||||
const participants = data.participants as Record<string, CalendarParticipant>;
|
||||
expect(participants.org.calendarAddress).toBe('mailto:ME@Example.com');
|
||||
expect(participants.guest.participationStatus).toBe('tentative');
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
getUserStatus,
|
||||
getParticipantList,
|
||||
getStatusCounts,
|
||||
buildParticipantMap,
|
||||
participantFieldsToSave,
|
||||
} from "@/lib/calendar-participants";
|
||||
import { canUserRsvp, getEventEditability, canCreateEventsIn } from "@/lib/calendar-editability";
|
||||
import { withOwnAlertIds } from "@/lib/calendar-alerts";
|
||||
@@ -668,27 +668,13 @@ export function EventModal({
|
||||
.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];
|
||||
// On create there are no existing participants, so a fresh event would
|
||||
// store an empty organizer name — fall back to the contact card.
|
||||
const organizerName =
|
||||
existingParticipants.find(p => p.isOrganizer)?.name || resolveContactName(organizerEmail) || "";
|
||||
data.participants = buildParticipantMap(
|
||||
{ name: organizerName, email: organizerEmail },
|
||||
effectiveAttendees
|
||||
) as Record<string, CalendarParticipant>;
|
||||
// Stalwart (calcard) derives the iCalendar ORGANIZER property solely from
|
||||
// organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP
|
||||
// scheduling is silently skipped (NoSchedulingInfo), so no invites are sent.
|
||||
// The RFC 8984 replyTo property is retired in jscalendarbis and ignored.
|
||||
data.organizerCalendarAddress = `mailto:${organizerEmail}`;
|
||||
} else if (effectiveAttendees.length === 0 && event?.participants) {
|
||||
data.participants = null;
|
||||
// Also clear the retired replyTo that older releases (<= 1.7.6) wrote.
|
||||
data.replyTo = null;
|
||||
data.organizerCalendarAddress = null;
|
||||
}
|
||||
// Keeps the event's own organizer - an invitation's sender, the exact
|
||||
// address a series is organized under - and the answers of attendees who
|
||||
// stay; the user organizes an event that has no organizer yet.
|
||||
Object.assign(data, participantFieldsToSave(event, effectiveAttendees, {
|
||||
userEmail: currentUserEmails[0],
|
||||
resolveName: resolveContactName,
|
||||
}));
|
||||
|
||||
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
||||
setIsSaving(true);
|
||||
@@ -697,7 +683,7 @@ export function EventModal({
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, 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, resolveContactName, event, onSave, isSaving]);
|
||||
|
||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||
if (!event || !userParticipantId || !onRsvp) return;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getStatusCounts,
|
||||
getParticipantCount,
|
||||
buildParticipantMap,
|
||||
participantFieldsToSave,
|
||||
participantLimitOf,
|
||||
} from '@/lib/calendar-participants';
|
||||
import { jmapError, jmapSetError } from '@/lib/jmap/jmap-error';
|
||||
@@ -642,6 +643,110 @@ describe('buildParticipantMap', () => {
|
||||
expect(Object.keys(map)).toHaveLength(2);
|
||||
expect(Object.values(map).some(p => p.name === 'Nobody')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the id and answer of an address the event already lists, whatever its case', () => {
|
||||
const map = buildParticipantMap(
|
||||
{ name: 'Alice', email: 'alice@example.com' },
|
||||
[{ name: 'Bob', email: 'bob@example.com' }, { name: 'Carol', email: 'carol@example.com' }],
|
||||
{
|
||||
org: { '@type': 'Participant', calendarAddress: 'mailto:alice@example.com' },
|
||||
b1: { '@type': 'Participant', calendarAddress: 'mailto:BOB@Example.com', participationStatus: 'declined' },
|
||||
} as unknown as Record<string, CalendarParticipant>,
|
||||
);
|
||||
|
||||
// The organizer, listed without an answer, stays accepted.
|
||||
expect(map.org).toMatchObject({ calendarAddress: 'mailto:alice@example.com', participationStatus: 'accepted' });
|
||||
expect(map.b1).toMatchObject({ email: 'bob@example.com', participationStatus: 'declined' });
|
||||
const carol = Object.entries(map).find(([, p]) => p.email === 'carol@example.com')!;
|
||||
expect(['org', 'b1']).not.toContain(carol[0]);
|
||||
expect(carol[1].participationStatus).toBe('needs-action');
|
||||
});
|
||||
|
||||
it('takes the real answer of an address listed twice', () => {
|
||||
const map = buildParticipantMap(
|
||||
{ name: 'Alice', email: 'alice@example.com' },
|
||||
[{ name: 'Bob', email: 'bob@example.com' }],
|
||||
{
|
||||
first: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.com', participationStatus: 'needs-action' },
|
||||
second: { '@type': 'Participant', email: 'bob@example.com', participationStatus: 'tentative' },
|
||||
} as unknown as Record<string, CalendarParticipant>,
|
||||
);
|
||||
expect(map.first.participationStatus).toBe('tentative');
|
||||
expect(map).not.toHaveProperty('second');
|
||||
});
|
||||
});
|
||||
|
||||
// The editor rewrote the organizer on every save of an event with attendees:
|
||||
// participants rebuilt around the user's first address, organizerCalendarAddress
|
||||
// set to it, and every attendee back to needs-action. On an invitation that
|
||||
// made the user its organizer; on a series organized under another address or
|
||||
// letter case it gave one occurrence another owner (Stalwart 1.0 refuses the
|
||||
// organizerCalendarAddress there: "This property cannot be modified on a
|
||||
// single occurrence."), and every save dropped the answers.
|
||||
describe('participantFieldsToSave', () => {
|
||||
const me = { userEmail: 'me@example.com', resolveName: (email: string) => (email === 'me@example.com' ? 'Me Myself' : undefined) };
|
||||
const rows = (...emails: string[]) => emails.map((email) => ({ name: '', email }));
|
||||
|
||||
it('keeps the sender of an invitation as its organizer and everyone\'s answer', () => {
|
||||
const invitation = makeEvent({
|
||||
boss: { '@type': 'Participant', calendarAddress: 'mailto:boss@example.org' },
|
||||
mine: { '@type': 'Participant', calendarAddress: 'mailto:me@example.com', roles: { attendee: true }, participationStatus: 'accepted' },
|
||||
bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.org', roles: { attendee: true }, participationStatus: 'declined' },
|
||||
}, { organizerCalendarAddress: 'mailto:boss@example.org' });
|
||||
|
||||
const fields = participantFieldsToSave(invitation, rows('me@example.com', 'bob@example.org'), me);
|
||||
|
||||
expect(fields).not.toHaveProperty('organizerCalendarAddress');
|
||||
expect(Object.keys(fields.participants!).sort()).toEqual(['bob', 'boss', 'mine']);
|
||||
expect(fields.participants!.boss).toMatchObject({ calendarAddress: 'mailto:boss@example.org', roles: { owner: true } });
|
||||
expect(fields.participants!.mine.participationStatus).toBe('accepted');
|
||||
expect(fields.participants!.bob.participationStatus).toBe('declined');
|
||||
});
|
||||
|
||||
it('keeps the address a series is organized under, letter case included (Stalwart 1.0 checks it on one occurrence)', () => {
|
||||
const series = makeEvent({
|
||||
org: { '@type': 'Participant', calendarAddress: 'mailto:ME@Example.com', roles: { owner: true }, participationStatus: 'accepted' },
|
||||
guest: { '@type': 'Participant', calendarAddress: 'mailto:guest@example.net', roles: { attendee: true }, participationStatus: 'accepted' },
|
||||
}, { organizerCalendarAddress: 'mailto:ME@Example.com' });
|
||||
|
||||
const fields = participantFieldsToSave(series, rows('guest@example.net'), me);
|
||||
|
||||
expect(fields).not.toHaveProperty('organizerCalendarAddress');
|
||||
expect(fields.participants!.org).toMatchObject({ calendarAddress: 'mailto:ME@Example.com', roles: { owner: true } });
|
||||
expect(fields.participants!.guest.participationStatus).toBe('accepted');
|
||||
});
|
||||
|
||||
it('takes the organizer from organizerCalendarAddress when no participant is marked as one', () => {
|
||||
const event = makeEvent({
|
||||
guest: { '@type': 'Participant', calendarAddress: 'mailto:guest@example.net', roles: { attendee: true } },
|
||||
}, { organizerCalendarAddress: 'mailto:alias@example.com' });
|
||||
|
||||
const fields = participantFieldsToSave(event, rows('guest@example.net'), me);
|
||||
|
||||
const organizer = Object.values(fields.participants!).find((p) => p.roles?.owner);
|
||||
expect(organizer?.calendarAddress).toBe('mailto:alias@example.com');
|
||||
expect(fields).not.toHaveProperty('organizerCalendarAddress');
|
||||
});
|
||||
|
||||
it('makes the user the organizer of an event that has none', () => {
|
||||
for (const event of [null, makeEvent(null)]) {
|
||||
const fields = participantFieldsToSave(event, rows('guest@example.net'), me);
|
||||
|
||||
expect(fields.organizerCalendarAddress).toBe('mailto:me@example.com');
|
||||
const organizer = Object.values(fields.participants!).find((p) => p.roles?.owner);
|
||||
expect(organizer).toMatchObject({ name: 'Me Myself', calendarAddress: 'mailto:me@example.com', participationStatus: 'accepted' });
|
||||
}
|
||||
});
|
||||
|
||||
it('removes the participants, the organizer and replyTo with the last attendee', () => {
|
||||
const event = makeEvent({
|
||||
org: { '@type': 'Participant', calendarAddress: 'mailto:me@example.com', roles: { owner: true } },
|
||||
guest: { '@type': 'Participant', calendarAddress: 'mailto:guest@example.net', roles: { attendee: true } },
|
||||
}, { organizerCalendarAddress: 'mailto:me@example.com', replyTo: { imip: 'mailto:me@example.com' } });
|
||||
|
||||
expect(participantFieldsToSave(event, [], me)).toEqual({ participants: null, organizerCalendarAddress: null, replyTo: null });
|
||||
expect(participantFieldsToSave(makeEvent(null), [], me)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('participantLimitOf', () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { expandRecurringEvents } from '../recurrence-expansion';
|
||||
import { computeFireTime } from '../calendar-alerts';
|
||||
import type { CalendarEvent, CalendarRecurrenceRule } from '@/lib/jmap/types';
|
||||
|
||||
/**
|
||||
@@ -427,6 +429,110 @@ describe('expandRecurringEvents', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// A master's utcStart/utcEnd that are not its own
|
||||
// -----------------------------------------------------------------------
|
||||
// 0.16.23 answered utcStart = utcEnd = "32548-12-04T15:30:07Z" for the
|
||||
// master of a counted series once every occurrence was overridden or
|
||||
// excluded, and shifted them to the next plain occurrence once the first
|
||||
// one was. 1.0 answers the master's own times. The expansion used to take
|
||||
// the occurrences' length from those instants, copy them onto occurrences
|
||||
// it could not time otherwise, and drop a moved occurrence whose copied
|
||||
// end did not parse.
|
||||
describe("a master's instants that are not its own", () => {
|
||||
const BOGUS = '32548-12-04T15:30:07Z';
|
||||
// Read back from the test servers: a daily series of five in Berlin
|
||||
// (UTC+2 in October), every occurrence overridden or excluded.
|
||||
const countedSeries = (instants: Pick<CalendarEvent, 'utcStart' | 'utcEnd'>) => makeEvent({
|
||||
start: '2026-10-05T10:00:00',
|
||||
timeZone: 'Europe/Berlin',
|
||||
duration: 'PT1H',
|
||||
...instants,
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily', count: 5 })],
|
||||
recurrenceOverrides: {
|
||||
'2026-10-05T10:00:00': { title: 'Device renamed' },
|
||||
'2026-10-06T10:00:00': { title: 'Renamed occurrence' },
|
||||
'2026-10-07T10:00:00': { excluded: true } as Partial<CalendarEvent> & { excluded?: boolean },
|
||||
'2026-10-08T10:00:00': { start: '2026-10-08T14:00:00', title: 'Moved standup' },
|
||||
'2026-10-09T10:00:00': { title: 'With organizer' },
|
||||
},
|
||||
});
|
||||
|
||||
it.each<[string, Pick<CalendarEvent, 'utcStart' | 'utcEnd'>]>([
|
||||
['0.16.23', { utcStart: BOGUS, utcEnd: BOGUS }],
|
||||
['1.0', { utcStart: '2026-10-05T08:00:00Z', utcEnd: '2026-10-05T09:00:00Z' }],
|
||||
])('times every occurrence from its own start and the duration on Stalwart %s', (_version, instants) => {
|
||||
const result = expand(countedSeries(instants), '2026-10-01T00:00:00', '2026-10-31T00:00:00');
|
||||
|
||||
expect(result.map(e => [e.recurrenceId, e.title, e.utcStart, e.utcEnd])).toEqual([
|
||||
['2026-10-05T10:00:00', 'Device renamed', '2026-10-05T08:00:00.000Z', '2026-10-05T09:00:00.000Z'],
|
||||
['2026-10-06T10:00:00', 'Renamed occurrence', '2026-10-06T08:00:00.000Z', '2026-10-06T09:00:00.000Z'],
|
||||
// Moved to 14:00; it was dropped while its end was the master's.
|
||||
['2026-10-08T10:00:00', 'Moved standup', '2026-10-08T12:00:00.000Z', '2026-10-08T13:00:00.000Z'],
|
||||
['2026-10-09T10:00:00', 'With organizer', '2026-10-09T08:00:00.000Z', '2026-10-09T09:00:00.000Z'],
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives no occurrence the master's instants when it cannot time one (0.16.23)", () => {
|
||||
// No time zone: occurrences are timed from the master's offset, which
|
||||
// these instants do not give.
|
||||
const floating = { ...countedSeries({ utcStart: BOGUS, utcEnd: BOGUS }), timeZone: null };
|
||||
|
||||
const result = expand(floating, '2026-10-01T00:00:00', '2026-10-31T00:00:00');
|
||||
|
||||
expect(result.map(e => e.recurrenceId)).toEqual([
|
||||
'2026-10-05T10:00:00', '2026-10-06T10:00:00', '2026-10-08T10:00:00', '2026-10-09T10:00:00',
|
||||
]);
|
||||
for (const occurrence of result) {
|
||||
expect(occurrence).not.toHaveProperty('utcStart');
|
||||
expect(occurrence).not.toHaveProperty('utcEnd');
|
||||
}
|
||||
});
|
||||
|
||||
it.each<[string, string]>([
|
||||
['shifted a day to the next plain occurrence (0.16.23)', '2026-10-06T08:00:00Z'],
|
||||
['that parse but are far off', '2999-01-01T08:00:00Z'],
|
||||
])('takes no offset from instants %s', (_label, utcStart) => {
|
||||
const floating = makeEvent({
|
||||
start: '2026-10-05T10:00:00',
|
||||
timeZone: null,
|
||||
utcStart,
|
||||
utcEnd: utcStart.replace('T08', 'T09'),
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily', count: 3 })],
|
||||
});
|
||||
|
||||
const result = expand(floating, '2026-10-01T00:00:00', '2026-10-31T00:00:00');
|
||||
|
||||
expect(result.map(e => [e.start, e.utcStart, e.utcEnd])).toEqual([
|
||||
['2026-10-05T10:00:00', undefined, undefined],
|
||||
['2026-10-06T10:00:00', undefined, undefined],
|
||||
['2026-10-07T10:00:00', undefined, undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the master's instants off the other occurrences of an all-day series", () => {
|
||||
const event = makeEvent({
|
||||
start: '2025-01-06T00:00:00',
|
||||
showWithoutTime: true,
|
||||
duration: 'P1D',
|
||||
utcStart: '2025-01-05T23:00:00Z',
|
||||
utcEnd: '2025-01-06T23:00:00Z',
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
|
||||
});
|
||||
|
||||
const result = expand(event, '2025-01-06T00:00:00', '2025-01-21T00:00:00');
|
||||
|
||||
expect(result.map(e => [e.start, e.utcStart])).toEqual([
|
||||
['2025-01-06T00:00:00', undefined],
|
||||
['2025-01-13T00:00:00', undefined],
|
||||
['2025-01-20T00:00:00', undefined],
|
||||
]);
|
||||
// An alert goes off before its own day, not before the series' first.
|
||||
expect(computeFireTime(result[1], { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' }))
|
||||
.toBe(parseISO('2025-01-13T00:00:00').getTime() - 15 * 60_000);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// excludedRecurrenceRules (EXRULE)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -246,18 +246,52 @@ export function getParticipantCount(event: CalendarEvent): number {
|
||||
return count;
|
||||
}
|
||||
|
||||
type ParticipationStatus = CalendarParticipant['participationStatus'];
|
||||
|
||||
/**
|
||||
* An event's participants by address (lowercased): the id each was first
|
||||
* listed under, and its answer - a real one over a missing or needs-action
|
||||
* one when an address is listed twice (see getParticipantList).
|
||||
*/
|
||||
function participantsByAddress(
|
||||
participants: CalendarEvent['participants'] | null | undefined,
|
||||
): Map<string, { id: string; status: ParticipationStatus | undefined }> {
|
||||
const known = new Map<string, { id: string; status: ParticipationStatus | undefined }>();
|
||||
for (const [id, p] of Object.entries(participants ?? {})) {
|
||||
const key = getParticipantEmail(p).trim().toLowerCase();
|
||||
if (!key) continue;
|
||||
const seen = known.get(key);
|
||||
if (!seen) known.set(key, { id, status: p.participationStatus });
|
||||
else if ((!seen.status || seen.status === 'needs-action') && p.participationStatus) seen.status = p.participationStatus;
|
||||
}
|
||||
return known;
|
||||
}
|
||||
|
||||
/**
|
||||
* The participants an editor save writes: the organizer, then each attendee
|
||||
* once. `existing` is the event's current map. An address already in it
|
||||
* keeps its participant id and its answer, so a save neither resets the
|
||||
* attendees' answers - the user's own on an invitation included - nor leaves
|
||||
* a recurrence override pointing at a participant id that is gone.
|
||||
*/
|
||||
export function buildParticipantMap(
|
||||
organizer: { name: string; email: string },
|
||||
attendees: { name: string; email: string }[]
|
||||
attendees: { name: string; email: string }[],
|
||||
existing?: CalendarEvent['participants'] | null,
|
||||
): Record<string, Partial<CalendarParticipant>> {
|
||||
const participants: Record<string, Partial<CalendarParticipant>> = {};
|
||||
|
||||
const generateId = () => generateUUID();
|
||||
const known = participantsByAddress(existing);
|
||||
const idFor = (key: string) => {
|
||||
const id = known.get(key)?.id;
|
||||
return id && !(id in participants) ? id : generateUUID();
|
||||
};
|
||||
|
||||
// calendarAddress is the scheduling address in draft-ietf-calext-jscalendarbis
|
||||
// (implemented by Stalwart); the RFC 8984 sendTo property is retired there and
|
||||
// stored as an inert JSPROP, so it is intentionally not sent.
|
||||
participants[generateId()] = {
|
||||
const organizerKey = organizer.email.trim().toLowerCase();
|
||||
const organizerStatus = known.get(organizerKey)?.status;
|
||||
participants[idFor(organizerKey)] = {
|
||||
'@type': 'Participant',
|
||||
name: organizer.name,
|
||||
email: organizer.email,
|
||||
@@ -266,7 +300,7 @@ export function buildParticipantMap(
|
||||
// scheduling emits the organizer as an ATTENDEE line in addition to the
|
||||
// ORGANIZER line, so the recipient sees the organizer listed twice.
|
||||
roles: { owner: true },
|
||||
participationStatus: 'accepted',
|
||||
participationStatus: organizerStatus && organizerStatus !== 'needs-action' ? organizerStatus : 'accepted',
|
||||
scheduleAgent: 'server',
|
||||
expectReply: false,
|
||||
kind: 'individual',
|
||||
@@ -274,7 +308,7 @@ export function buildParticipantMap(
|
||||
|
||||
// The organizer already has an entry above, and an address must not appear
|
||||
// twice in the invite list, so drop both cases case-insensitively (#731).
|
||||
const seen = new Set<string>([organizer.email.trim().toLowerCase()]);
|
||||
const seen = new Set<string>([organizerKey]);
|
||||
|
||||
attendees.forEach((a) => {
|
||||
const email = a.email.trim();
|
||||
@@ -282,13 +316,13 @@ export function buildParticipantMap(
|
||||
if (!key || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
|
||||
participants[generateId()] = {
|
||||
participants[idFor(key)] = {
|
||||
'@type': 'Participant',
|
||||
name: a.name,
|
||||
email,
|
||||
calendarAddress: `mailto:${email}`,
|
||||
roles: { attendee: true },
|
||||
participationStatus: 'needs-action',
|
||||
participationStatus: known.get(key)?.status ?? 'needs-action',
|
||||
scheduleAgent: 'server',
|
||||
expectReply: true,
|
||||
kind: 'individual',
|
||||
@@ -297,3 +331,67 @@ export function buildParticipantMap(
|
||||
|
||||
return participants;
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's own organizer: the organizer participant, else the address in
|
||||
* `organizerCalendarAddress` or the retired `replyTo`. Null for an event
|
||||
* without one.
|
||||
*/
|
||||
function eventOrganizer(event: CalendarEvent): { name: string; email: string } | null {
|
||||
const participant = getParticipantList(event).find((p) => p.isOrganizer && p.email.trim());
|
||||
if (participant) return { name: participant.name, email: participant.email.trim() };
|
||||
const address = event.organizerCalendarAddress || Object.values(event.replyTo ?? {})[0];
|
||||
const email = address?.replace(/^mailto:/i, '').trim();
|
||||
return email ? { name: '', email } : null;
|
||||
}
|
||||
|
||||
export interface ParticipantSaveContext {
|
||||
/** The user's first calendar address: the organizer of an event that has none yet. */
|
||||
userEmail: string | undefined;
|
||||
/** A name for an organizer the event data carries none for (the contact card). */
|
||||
resolveName?: (email: string) => string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The participant fields an editor save writes.
|
||||
*
|
||||
* With attendees, the participants are rebuilt around the event's own
|
||||
* organizer: an invitation keeps whoever sent it, and a series the exact
|
||||
* address it is organized under (Stalwart 1.0 checks it on single
|
||||
* occurrences). Only an event without an organizer gets the user, and only
|
||||
* then is `organizerCalendarAddress` written: Stalwart derives the
|
||||
* iCalendar ORGANIZER solely from it, so without one no invitations go out.
|
||||
* Participants that stay keep their id and answer (buildParticipantMap).
|
||||
*
|
||||
* Without attendees, an event that had participants loses them, its
|
||||
* organizer and the retired `replyTo` older releases wrote.
|
||||
*/
|
||||
export function participantFieldsToSave(
|
||||
event: CalendarEvent | null | undefined,
|
||||
attendees: { name: string; email: string }[],
|
||||
context: ParticipantSaveContext,
|
||||
): Partial<Pick<CalendarEvent, 'participants' | 'organizerCalendarAddress' | 'replyTo'>> {
|
||||
const { userEmail, resolveName } = context;
|
||||
if (attendees.length > 0 && userEmail) {
|
||||
const organizer = (event ? eventOrganizer(event) : null) ?? { name: '', email: userEmail };
|
||||
const fields: Partial<Pick<CalendarEvent, 'participants' | 'organizerCalendarAddress'>> = {
|
||||
participants: buildParticipantMap(
|
||||
// A new event has no participants yet, so it would store an empty
|
||||
// organizer name.
|
||||
{ name: organizer.name || resolveName?.(organizer.email) || '', email: organizer.email },
|
||||
attendees,
|
||||
event?.participants,
|
||||
) as Record<string, CalendarParticipant>,
|
||||
};
|
||||
if (!event?.organizerCalendarAddress) fields.organizerCalendarAddress = `mailto:${organizer.email}`;
|
||||
return fields;
|
||||
}
|
||||
if (attendees.length === 0 && event?.participants && Object.keys(event.participants).length > 0) {
|
||||
return {
|
||||
participants: null,
|
||||
...(event.replyTo ? { replyTo: null } : {}),
|
||||
...(event.organizerCalendarAddress ? { organizerCalendarAddress: null } : {}),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
+66
-22
@@ -160,16 +160,58 @@ function isInRange(
|
||||
): boolean {
|
||||
const moved = !!override && Object.keys(override).some((key) => TIMING_KEYS.has(key.split('/', 1)[0]));
|
||||
if (!moved) return date >= rangeStart && date < rangeEnd;
|
||||
const timed = !occurrence.showWithoutTime;
|
||||
const start = timed && occurrence.utcStart ? parseISO(occurrence.utcStart) : parseISO(occurrence.start);
|
||||
if (isNaN(start.getTime())) return false;
|
||||
const end = timed && occurrence.utcEnd
|
||||
? parseISO(occurrence.utcEnd)
|
||||
: new Date(start.getTime() + (parseDurationSeconds(occurrence.duration) ?? 0) * 1000);
|
||||
const own = occurrence.showWithoutTime ? null : plausibleInstants(occurrence);
|
||||
const start = own?.start ?? validDate(occurrence.start);
|
||||
if (!start) return false;
|
||||
const end = own?.end ?? new Date(start.getTime() + (parseDurationSeconds(occurrence.duration) ?? 0) * 1000);
|
||||
if (end <= start) return start >= rangeStart && start < rangeEnd;
|
||||
return start < rangeEnd && end > rangeStart;
|
||||
}
|
||||
|
||||
// UTC-12 to UTC+14: the furthest an instant is from the wall clock it names.
|
||||
const MAX_ZONE_OFFSET_MS = 14 * 60 * 60 * 1000;
|
||||
|
||||
/** `value` parsed, or null when it is missing or does not parse. */
|
||||
function validDate(value: string | null | undefined): Date | null {
|
||||
if (!value) return null;
|
||||
const date = parseISO(value);
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
/** The wall-clock fields of a parsed LocalDateTime, read as UTC. */
|
||||
function wallAsUtc(wall: Date): number {
|
||||
return Date.UTC(
|
||||
wall.getFullYear(), wall.getMonth(), wall.getDate(),
|
||||
wall.getHours(), wall.getMinutes(), wall.getSeconds(),
|
||||
);
|
||||
}
|
||||
|
||||
/** `value` parsed, when it is within a time zone's offset of the wall clock `wallMs`. */
|
||||
function instantNear(value: string | null | undefined, wallMs: number): Date | null {
|
||||
const instant = validDate(value);
|
||||
return instant && Math.abs(instant.getTime() - wallMs) <= MAX_ZONE_OFFSET_MS ? instant : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* An event's utcStart and utcEnd, where they can be its own: each within a
|
||||
* time zone's offset of the wall-clock start and end, the end not before the
|
||||
* start. `end` is null when only the start passes. For a series' master
|
||||
* 0.16.23 has answered both with "32548-12-04T15:30:07Z" once every
|
||||
* occurrence was overridden or excluded, and with the next plain
|
||||
* occurrence's times once the first one was.
|
||||
*/
|
||||
function plausibleInstants(
|
||||
event: Pick<CalendarEvent, 'start' | 'duration' | 'utcStart' | 'utcEnd'>,
|
||||
): { start: Date; end: Date | null } | null {
|
||||
const wall = validDate(event.start);
|
||||
if (!wall) return null;
|
||||
const wallStart = wallAsUtc(wall);
|
||||
const start = instantNear(event.utcStart, wallStart);
|
||||
if (!start) return null;
|
||||
const end = instantNear(event.utcEnd, wallStart + (parseDurationSeconds(event.duration) ?? 0) * 1000);
|
||||
return { start, end: end && end >= start ? end : null };
|
||||
}
|
||||
|
||||
// Cache Intl formatters per IANA timezone id - constructing them is expensive
|
||||
// and expansion runs over hundreds of occurrences. `null` marks ids the
|
||||
// runtime rejected so we don't retry them.
|
||||
@@ -218,13 +260,10 @@ function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number
|
||||
function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null {
|
||||
const formatter = getTzFormatter(timeZone);
|
||||
if (!formatter) return null;
|
||||
const wallAsUtc = Date.UTC(
|
||||
wall.getFullYear(), wall.getMonth(), wall.getDate(),
|
||||
wall.getHours(), wall.getMinutes(), wall.getSeconds(),
|
||||
);
|
||||
let guess = wallAsUtc;
|
||||
const wallMs = wallAsUtc(wall);
|
||||
let guess = wallMs;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
guess = wallAsUtc - (wallClockAsUtcTimestamp(formatter, guess) - guess);
|
||||
guess = wallMs - (wallClockAsUtcTimestamp(formatter, guess) - guess);
|
||||
}
|
||||
return new Date(guess);
|
||||
}
|
||||
@@ -255,14 +294,18 @@ function createOccurrence(
|
||||
let utcEnd: string | undefined;
|
||||
const wall = parseISO(start);
|
||||
if (!occurrence.showWithoutTime && !isNaN(wall.getTime())) {
|
||||
// Unless the override sets its own duration, the master's length.
|
||||
// Unless the override sets its own duration, the master's length: from
|
||||
// its instants when they are its own, else from its duration.
|
||||
const masterInstants = plausibleInstants(master);
|
||||
let durationMs: number | null = null;
|
||||
const ownDuration = override && 'duration' in override ? parseDurationSeconds(occurrence.duration) : null;
|
||||
if (ownDuration !== null) {
|
||||
durationMs = ownDuration * 1000;
|
||||
} else if (master.utcStart && master.utcEnd) {
|
||||
const ms = parseISO(master.utcEnd).getTime() - parseISO(master.utcStart).getTime();
|
||||
if (!isNaN(ms)) durationMs = ms;
|
||||
} else if (masterInstants?.end) {
|
||||
durationMs = masterInstants.end.getTime() - masterInstants.start.getTime();
|
||||
} else {
|
||||
const seconds = parseDurationSeconds(master.duration);
|
||||
if (seconds !== null) durationMs = seconds * 1000;
|
||||
}
|
||||
|
||||
// Convert the occurrence's wall time using the event's own timezone, so
|
||||
@@ -275,12 +318,10 @@ function createOccurrence(
|
||||
if (durationMs !== null) {
|
||||
utcEnd = new Date(zoned.getTime() + durationMs).toISOString();
|
||||
}
|
||||
} else if (master.utcStart && master.start) {
|
||||
} else if (masterInstants) {
|
||||
// Floating events (or an unrecognized timezone id): keep the master's
|
||||
// offset, which by definition doesn't vary.
|
||||
const masterLocal = parseISO(master.start);
|
||||
const masterUtc = parseISO(master.utcStart);
|
||||
const offsetMs = masterUtc.getTime() - masterLocal.getTime();
|
||||
const offsetMs = masterInstants.start.getTime() - parseISO(master.start).getTime();
|
||||
utcStart = new Date(wall.getTime() + offsetMs).toISOString();
|
||||
if (durationMs !== null) {
|
||||
utcEnd = new Date(wall.getTime() + offsetMs + durationMs).toISOString();
|
||||
@@ -288,8 +329,11 @@ function createOccurrence(
|
||||
}
|
||||
}
|
||||
|
||||
// The master's utcStart/utcEnd place its first occurrence, not this one:
|
||||
// an occurrence carries the instants worked out for it, or none.
|
||||
const { utcStart: _masterStart, utcEnd: _masterEnd, ...series } = occurrence;
|
||||
return {
|
||||
...occurrence,
|
||||
...series,
|
||||
id: `${master.id}:${recurrenceId}`,
|
||||
originalId: master.originalId || master.id,
|
||||
uid: master.uid,
|
||||
@@ -301,7 +345,7 @@ function createOccurrence(
|
||||
recurrenceRules: master.recurrenceRules,
|
||||
recurrenceOverrides: master.recurrenceOverrides,
|
||||
excludedRecurrenceRules: master.excludedRecurrenceRules,
|
||||
};
|
||||
} as CalendarEvent;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user