mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: tell attendees when someone with write access changes the owner's meeting
Someone who writes to a calendar shared with them (read & write) changes the owner's meeting there on the owner's behalf: Stalwart sends the change from the addresses of the account the event is in. The app decided who organizes by the user's own addresses only, so it took the sharee for an attendee (0.16 and 1.0 alike): - the editor wrote no participants, hid "Send invitations" and sent nothing, so the attendees kept the old time and their answers - "All events" asked nobody again, and "This and following events" made the rest a new series that kept the old answers, which Stalwart sends to nobody who had answered The app now reads `isOrigin`, which both versions compute from the addresses of the account the event is in: true for the owner's meeting and every expanded occurrence of it, false for an invitation the owner got. `userOrganizes` counts such a copy as the organizer's; an invitation is still changed without messages. "This and following" asks by the old series, since the new one carries no `isOrigin` yet. The editor no longer leaves the user's own main address off the attendees it opens with, only the organizer's (or the user's, for an event that names none yet): a user who invited their main address to a meeting organized under an alias lost it on the next save.
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
+1
-1
@@ -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',
|
||||
|
||||
@@ -509,6 +509,11 @@ export interface CalendarEvent {
|
||||
// retired in jscalendarbis and only kept here so old values round-trip.
|
||||
replyTo?: Record<string, string> | 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<string, Partial<CalendarEvent>> | null;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<CalendarEvent> => ({
|
||||
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<CalendarEvent>);
|
||||
const me = { userEmail: ME, userEmails: [ME] };
|
||||
const statuses = (fields: ReturnType<typeof participantFieldsToSave>) => 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<CalendarEvent> = {
|
||||
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<CalendarEvent> = {
|
||||
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']);
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -388,12 +388,17 @@ function eventOrganizer(event: Partial<CalendarEvent>): { 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<CalendarEvent> | 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<CalendarEvent> | null | undefined,
|
||||
@@ -549,9 +557,8 @@ export function seedAttendees(
|
||||
): Attendee[] {
|
||||
if (!event?.participants) return [];
|
||||
const excluded = new Set<string>();
|
||||
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;
|
||||
|
||||
@@ -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<CalendarEvent> {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user