diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index 58498cfd..645a2729 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -219,6 +219,20 @@ describe('calendar operations', () => { expect(mockRequest.mock.calls[0][0][0][1].properties).toContain('baseEventId'); expect(mockRequest.mock.calls[1][0][0][1].properties).not.toContain('baseEventId'); }); + + // Whether the event is its organizer's own copy (0.16 and 1.0 compute it + // for the event and each expanded occurrence): the owner's meeting in a + // calendar shared with the user is theirs to change, and Stalwart sends + // the change from the owner. + it('asks for isOrigin for stored events and expanded occurrences alike', async () => { + mockRequest.mockResolvedValue(getAnswer([])); + + await getEvents(['a'], undefined, { expanded: true }); + await getEvents(['a']); + + expect(mockRequest.mock.calls[0][0][0][1].properties).toContain('isOrigin'); + expect(mockRequest.mock.calls[1][0][0][1].properties).toContain('isOrigin'); + }); }); describe('queryEvents', () => { diff --git a/src/api/calendar.ts b/src/api/calendar.ts index d332f090..f763d3dd 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -204,7 +204,7 @@ const CALENDAR_EVENT_PROPERTIES = [ 'utcStart', 'utcEnd', 'status', 'freeBusyStatus', 'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRule', 'excludedRecurrenceRule', 'recurrenceId', - 'replyTo', 'organizerCalendarAddress', 'sequence', + 'replyTo', 'organizerCalendarAddress', 'isOrigin', 'sequence', 'locations', 'virtualLocations', 'progress', 'due', 'priority', 'percentComplete', 'links', 'created', 'updated', diff --git a/src/api/types.ts b/src/api/types.ts index 3486b7a2..43aad98a 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -509,6 +509,11 @@ export interface CalendarEvent { // retired in jscalendarbis and only kept here so old values round-trip. replyTo?: Record | null; organizerCalendarAddress?: string | null; + // Server-set, never written: the event's organizer is an address of the + // account the event is in - the organizer's own copy, and every expanded + // occurrence of it - as opposed to an invitation that account got (0.16 + // and 1.0 alike). + isOrigin?: boolean; sequence?: number; recurrenceRules?: RecurrenceRule[] | null; recurrenceOverrides?: Record> | null; diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index cd8e471f..52219ea1 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -305,8 +305,9 @@ export function EventModal({ } }; - // An event without an organizer, or organized under one of the user's - // addresses; not an invitation someone else sent. + // An event without an organizer, organized under one of the user's + // addresses, or the owner's meeting in a calendar shared with the user + // (isOrigin); not an invitation someone else sent. const organizes = userOrganizes(event, currentUserEmails); const handleSave = async () => { diff --git a/src/lib/__tests__/calendar-participants.test.ts b/src/lib/__tests__/calendar-participants.test.ts index aeaf2c8c..bdb0e855 100644 --- a/src/lib/__tests__/calendar-participants.test.ts +++ b/src/lib/__tests__/calendar-participants.test.ts @@ -413,6 +413,102 @@ describe('seriesParticipantFields (all occurrences)', () => { }); }); +// userc shares a calendar with the user (userb, read & write) and organizes a +// meeting in it; userb attends it too. Stalwart sends a change to it from +// userc, the owner of the account the event is in, and says so with +// `isOrigin` - true for that meeting and each expanded occurrence, false for +// an invitation userc got (live on 1.0 and 0.16.23). The app took userb for +// an attendee there: the editor wrote no participants and sent nothing, and +// a move asked nobody again. +describe.each(['0.16.23', '1.0'])('the owner\'s meeting in a calendar shared with the user on Stalwart %s', (version) => { + const ME = 'userb@example.org'; + // 0.16.23 drops an attendee's roles; 1.0 keeps them. + const attendee = (address: string, participationStatus: string) => ({ + calendarAddress: `mailto:${address}`, participationStatus, + ...(version === '1.0' ? { roles: { attendee: true } } : {}), + }); + const inShared = (organizer: string, isOrigin: boolean): Partial => ({ + id: 'f:fj', accountId: 'f', isShared: true, isOrigin, + organizerCalendarAddress: `mailto:${organizer}`, + participants: { + owner: { calendarAddress: `mailto:${organizer}`, roles: { owner: true }, participationStatus: 'accepted' }, + anna: attendee('usera@example.org', 'accepted'), + bea: attendee(ME, 'tentative'), + }, + } as Partial); + const me = { userEmail: ME, userEmails: [ME] }; + const statuses = (fields: ReturnType) => Object.fromEntries( + Object.entries(fields.participants ?? {}).map(([id, p]) => [id, p.participationStatus]), + ); + + it('is the owner\'s to change: the server schedules it from the owner', () => { + expect(userOrganizes(inShared('userc@example.org', true), [ME])).toBe(true); + expect(userOrganizes(inShared('usera@example.org', false), [ME])).toBe(false); + }); + + it('keeps the user on the list they open, and writes the owner\'s participants', () => { + const meeting = inShared('userc@example.org', true); + const rows = seedAttendees(meeting, [ME]); + + expect(rows.map((a) => a.email)).toEqual(['usera@example.org', ME]); + const fields = participantFieldsToSave(meeting, rows, me); + expect(fields.participants?.owner).toMatchObject({ calendarAddress: 'mailto:userc@example.org', roles: { owner: true } }); + expect(statuses(fields)).toEqual({ owner: 'accepted', anna: 'accepted', bea: 'tentative' }); + expect(fields).not.toHaveProperty('organizerCalendarAddress'); + }); + + it('asks the attendees again when the user moves it', () => { + const meeting = inShared('userc@example.org', true); + + const fields = participantFieldsToSave(meeting, seedAttendees(meeting, [ME]), { ...me, rescheduled: true }); + + expect(statuses(fields)).toEqual({ owner: 'accepted', anna: 'needs-action', bea: 'needs-action' }); + }); + + it('leaves an invitation the owner got alone', () => { + const invitation = inShared('boss@example.org', false); + + expect(participantFieldsToSave(invitation, seedAttendees(invitation, [ME]), { ...me, rescheduled: true })).toEqual({}); + }); + + it('asks the series\' attendees again when the user moves every occurrence', () => { + const meeting = inShared('userc@example.org', true); + + const fields = seriesParticipantFields(meeting, meeting, seedAttendees(meeting, [ME]), { ...me, rescheduled: true }); + + expect(statuses(fields)).toEqual({ owner: 'accepted', anna: 'needs-action', bea: 'needs-action' }); + }); +}); + +describe('seedAttendees for a meeting organized under an alias', () => { + // The user organizes under alias@ and invited their own main address too: + // only the organizer's entry is left out, not the user's main address. + it('keeps the user\'s main address on the list', () => { + const meeting: Partial = { + organizerCalendarAddress: 'mailto:alias@example.com', + participants: { + org: { calendarAddress: 'mailto:alias@example.com', roles: { owner: true }, participationStatus: 'accepted' }, + main: { calendarAddress: 'mailto:me@example.com', roles: { attendee: true }, participationStatus: 'accepted' }, + bob: { calendarAddress: 'mailto:bob@example.com', roles: { attendee: true } }, + }, + }; + + expect(seedAttendees(meeting, ['me@example.com', 'alias@example.com']).map((a) => a.email)) + .toEqual(['me@example.com', 'bob@example.com']); + }); + + it('leaves the user out of an event that names no organizer: they become it on save', () => { + const plain: Partial = { + participants: { + a: { calendarAddress: 'mailto:me@example.com' }, + b: { calendarAddress: 'mailto:bob@example.com' }, + }, + }; + + expect(seedAttendees(plain, ['me@example.com']).map((a) => a.email)).toEqual(['bob@example.com']); + }); +}); + describe('participantLimitOf (Stalwart 1.0 maxParticipantsPerEvent)', () => { it('reads the limit from a 1.0 refusal and ignores other failures', () => { const refusal = new JMAPSetError('invalidProperties', 'An event cannot have more than 20 participants.', ['participants']); diff --git a/src/lib/__tests__/recurrence-overrides.test.ts b/src/lib/__tests__/recurrence-overrides.test.ts index b6400a68..9b75cc86 100644 --- a/src/lib/__tests__/recurrence-overrides.test.ts +++ b/src/lib/__tests__/recurrence-overrides.test.ts @@ -176,6 +176,33 @@ describe('series edits from an occurrence the user organizes', () => { expect(Object.values(data.participants ?? {}).map((p) => p.participationStatus)).toEqual(['accepted', 'accepted']); }); + // The series in the owner's calendar, shared with the user read & write: + // Stalwart sends the user's change from the owner (isOrigin, 0.16 and 1.0). + it('asks the attendees again when the user changes the owner\'s series in a shared calendar', () => { + const owners = { + ...series, + isOrigin: true, + organizerCalendarAddress: 'mailto:owner@example.com', + participants: { ...participants, p0: { ...participants!.p0, calendarAddress: 'mailto:owner@example.com' } }, + } as CalendarEvent; + const occurrenceOfOwners = { ...fourth, isOrigin: true, organizerCalendarAddress: owners.organizerCalendarAddress, participants: { + ...owners.participants, p1: { ...owners.participants!.p1, participationStatus: 'declined' }, + } } as CalendarEvent; + const sharee = { userEmail: 'sharee@example.com', userEmails: ['sharee@example.com'] }; + const rows = seedAttendees(occurrenceOfOwners, ['sharee@example.com']); + + const following = buildFutureSeriesEdit(owners, owners.recurrenceRules!, occurrenceOfOwners, { + participants: owners.participants, + }, ['sharee@example.com']); + const moved = buildAllScopeEdit({ ...formSave, start: '2026-03-05T10:00:00' }, occurrenceOfOwners, owners, { + attendees: rows, timesMoved: true, ruleChanged: false, + }, sharee); + + expect(Object.values(following.participants ?? {}).map((p) => p.participationStatus)).toEqual(['accepted', 'needs-action']); + expect(Object.values(moved.participants ?? {}).map((p) => [p.calendarAddress, p.participationStatus])) + .toEqual([['mailto:owner@example.com', 'accepted'], ['mailto:bob@example.com', 'needs-action']]); + }); + it('keeps writing the times when the flags are not given', () => { expect(buildAllScopeUpdates({ start: '2026-03-05T09:00:00', duration: 'PT1H' }, fourth, series)) .toEqual({ duration: 'PT1H' }); diff --git a/src/lib/calendar-participants.ts b/src/lib/calendar-participants.ts index 977dc3b3..2661ef65 100644 --- a/src/lib/calendar-participants.ts +++ b/src/lib/calendar-participants.ts @@ -388,12 +388,17 @@ function eventOrganizer(event: Partial): { name: string; email: s /** * Whether a save of `event` is the user's as its organizer: an event without * an organizer yet becomes the user's, and so is one organized under any of - * the user's addresses. An invitation someone else sent is not: its - * organizer asks, the user only answers. + * the user's addresses. So is the organizer's own copy in someone else's + * account the user writes to - a calendar shared with them, a group's: + * Stalwart schedules a change from the addresses of the account the event is + * in, and `isOrigin` is its word that the organizer is one of them (0.16 and + * 1.0 alike), so a sharee's change of the owner's meeting goes out from the + * owner. An invitation someone else sent is not, in the user's account or + * another (`isOrigin: false`): its organizer asks, the user only answers. */ export function userOrganizes(event: Partial | null | undefined, userEmails: string[]): boolean { const organizer = event ? eventOrganizer(event) : null; - if (!organizer) return true; + if (!organizer || event?.isOrigin === true) return true; const key = organizer.email.toLowerCase(); return userEmails.some((address) => address.trim().toLowerCase() === key); } @@ -541,7 +546,10 @@ export function hasAnsweredAttendees(participants: CalendarEvent['participants'] /** * The attendee rows an editor should start from: every participant except the * organizer (who is re-added by buildParticipantMap on save), without - * duplicate addresses (#731). + * duplicate addresses (#731). The organizer is the event's own, or the user + * for an event without one - not the user as such: a sharee who attends the + * owner's meeting, or a user who invited their own main address to a + * meeting organized under an alias, stays on the list. */ export function seedAttendees( event: Partial | null | undefined, @@ -549,9 +557,8 @@ export function seedAttendees( ): Attendee[] { if (!event?.participants) return []; const excluded = new Set(); - if (isOrganizer(event, userEmails) && userEmails[0]) { - excluded.add(userEmails[0].toLowerCase()); - } + const organizerAddress = eventOrganizer(event)?.email || userEmails[0]?.trim(); + if (organizerAddress) excluded.add(organizerAddress.toLowerCase()); const out: Attendee[] = []; for (const p of getParticipantList(event)) { if (p.isOrganizer) continue; diff --git a/src/lib/recurrence-overrides.ts b/src/lib/recurrence-overrides.ts index 0cff9c4f..67f9652f 100644 --- a/src/lib/recurrence-overrides.ts +++ b/src/lib/recurrence-overrides.ts @@ -208,7 +208,10 @@ export function buildAllScopeEdit( * The new series of "This and following events", as buildFutureSeriesData * makes it. It is a new event (a new UID) to its attendees, and Stalwart * sends a new event only to attendees who have not answered: when the user - * organizes it, everyone but the organizer is asked anew. + * organizes it, everyone but the organizer is asked anew. Whether they do is + * the old series' to say: the new one carries no `isOrigin` yet, and the + * owner's meeting in a calendar shared with the user is theirs to change + * (userOrganizes). */ export function buildFutureSeriesEdit( master: CalendarEvent, @@ -218,7 +221,7 @@ export function buildFutureSeriesEdit( userEmails: string[], ): Partial { const data = buildFutureSeriesData(master, originalRules, occurrence, updates); - if (data.participants && userOrganizes(data, userEmails)) { + if (data.participants && userOrganizes({ ...data, isOrigin: master.isOrigin }, userEmails)) { data.participants = askAttendeesAgain(data.participants, getEventOrganizerEmails(data)); } return data;