diff --git a/components/calendar/__tests__/event-modal-organizer.test.tsx b/components/calendar/__tests__/event-modal-organizer.test.tsx
index 5105ad1dc..46590fd68 100644
--- a/components/calendar/__tests__/event-modal-organizer.test.tsx
+++ b/components/calendar/__tests__/event-modal-organizer.test.tsx
@@ -232,6 +232,80 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal answers after a
});
});
+// userc shares a calendar with the user (read & write) and organizes a
+// meeting in it. Stalwart sends a change to it from userc, the owner of the
+// account (isOrigin: true, live on both versions). The editor treated the
+// user as an attendee there: no participants written, no "Send
+// invitations", the attendees never told of a move.
+describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal on the owner\'s meeting in a shared calendar on Stalwart %s', (version) => {
+ const shared = [{
+ id: 'f:bz', originalId: 'bz', name: 'Team', color: '#3366cc', isShared: true, accountId: 'f',
+ myRights: {
+ mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
+ mayUpdatePrivate: true, mayRSVP: true, mayShare: false, mayDelete: false,
+ },
+ }] as unknown as Calendar[];
+ // The user (userb) attends the meeting as well.
+ const inShared = (organizer: string, isOrigin: boolean) => ({
+ ...meeting(organizer, {
+ owner: participant(version, organizer, true, 'accepted'),
+ anna: participant(version, 'usera@example.org', false, 'accepted'),
+ bea: participant(version, 'userb@example.org', false, 'tentative'),
+ }),
+ id: 'f:fj', calendarIds: { 'f:bz': true }, isShared: true, accountId: 'f', isOrigin,
+ }) as CalendarEvent;
+
+ async function saveAs(event: CalendarEvent, edit: (container: HTMLElement) => void = () => {}) {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ const { container } = render(
+ ,
+ );
+ fireEvent.click(screen.getByText('events.edit'));
+ edit(container);
+ fireEvent.click(screen.getByText('form.save'));
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
+ return { data: onSave.mock.calls[0][0] as Partial, send: onSave.mock.calls[0][1] };
+ }
+
+ it('writes the owner\'s participants, the user among them, and sends the change', async () => {
+ const { data, send } = await saveAs(inShared('userc@example.org', true));
+
+ const participants = data.participants as Record;
+ expect(participants.owner).toMatchObject({ calendarAddress: 'mailto:userc@example.org', roles: { owner: true } });
+ expect(Object.keys(participants).sort()).toEqual(['anna', 'bea', 'owner']);
+ expect(participants.bea.participationStatus).toBe('tentative');
+ expect(data).not.toHaveProperty('organizerCalendarAddress');
+ expect(send).toBe(true);
+ });
+
+ it('offers invitations and attendee changes', () => {
+ render();
+ fireEvent.click(screen.getByText('events.edit'));
+
+ expect(screen.getByText('participants.send_invitations')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('email_placeholder')).toBeEnabled();
+ });
+
+ it('asks the attendees again when the user moves it', async () => {
+ const { data } = await saveAs(inShared('userc@example.org', true), (container) => {
+ const [startTime] = container.querySelectorAll('input[type="time"]');
+ fireEvent.change(startTime, { target: { value: '11:00' } });
+ });
+
+ const participants = data.participants as Record;
+ expect(participants.owner.participationStatus).toBe('accepted');
+ expect(participants.anna.participationStatus).toBe('needs-action');
+ expect(participants.bea.participationStatus).toBe('needs-action');
+ });
+
+ it('leaves an invitation the owner got alone', async () => {
+ const { data, send } = await saveAs(inShared('boss@example.org', false));
+
+ expect(data).not.toHaveProperty('participants');
+ expect(send).toBe(false);
+ });
+});
+
// A save for every occurrence of a series applies what the user changed on
// the occurrence the editor shows (lib/recurrence-scope-edit.ts): the save
// carries the attendees the editor opened and saved with, and its dates.
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx
index e8e226873..54ae0fe93 100644
--- a/components/calendar/event-modal.tsx
+++ b/components/calendar/event-modal.tsx
@@ -16,6 +16,7 @@ import { createRecurrenceRule, sameRecurrenceRules } from "@/lib/recurrence-rule
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
import { ParticipantAvailability } from "./participant-availability";
import {
+ eventOrganizer,
getUserParticipantId,
getUserStatus,
getParticipantList,
@@ -320,7 +321,8 @@ export function EventModal({
// Only the organizer invites: someone else's invitation keeps its
// attendees as the organizer sent them, and a save of the user's own copy
- // sends no messages.
+ // sends no messages. The owner's meeting in a calendar shared with the
+ // user is the organizer's copy (userOrganizes).
const userIsOrganizer = useMemo(() => {
if (!event?.participants) return true;
return userOrganizes(event, currentUserEmails);
@@ -504,10 +506,14 @@ export function EventModal({
if (!event?.participants) return [];
// Never seed the invite list with the organizer or with a repeated address:
// handleSave writes the organizer separately, so either would round-trip
- // into a duplicate entry every time the event is saved (#731).
+ // into a duplicate entry every time the event is saved (#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 stays
+ // on its list.
const excluded = new Set();
- if (userIsOrganizer && currentUserEmails[0]) {
- excluded.add(currentUserEmails[0].toLowerCase());
+ const organizerAddress = (event && eventOrganizer(event)?.email) || (userIsOrganizer ? currentUserEmails[0] : '');
+ if (organizerAddress) {
+ excluded.add(organizerAddress.toLowerCase());
}
return existingParticipants
.filter(p => !p.isOrganizer)
diff --git a/hooks/__tests__/use-time-grid-interactions.test.ts b/hooks/__tests__/use-time-grid-interactions.test.ts
index 692413075..1af53ec72 100644
--- a/hooks/__tests__/use-time-grid-interactions.test.ts
+++ b/hooks/__tests__/use-time-grid-interactions.test.ts
@@ -80,11 +80,14 @@ describe.each<["0.16" | "1.0"]>([["0.16"], ["1.0"]])("useTimeGridInteractions mo
const original = useCalendarStore.getState().updateEvent;
let updateEvent: ReturnType;
- function meeting(organizer: string): CalendarEvent {
+ // isOrigin as the server says it: whether the organizer is an address of
+ // the account the event is in.
+ function meeting(organizer: string, extra: Partial = {}): CalendarEvent {
return {
id: "e1", uid: "u1", title: "Planning", start: "2026-09-03T10:00:00", duration: "PT1H",
timeZone: "Europe/Berlin", showWithoutTime: false, calendarIds: { cal: true },
- organizerCalendarAddress: `mailto:${organizer}`,
+ organizerCalendarAddress: `mailto:${organizer}`, isOrigin: organizer === "me@example.com",
+ ...extra,
participants: {
org: { "@type": "Participant", calendarAddress: `mailto:${organizer}`, roles: { owner: true }, participationStatus: "accepted" },
bob: {
@@ -144,6 +147,22 @@ describe.each<["0.16" | "1.0"]>([["0.16"], ["1.0"]])("useTimeGridInteractions mo
expect(send).toBeUndefined();
});
+ // userc's meeting in userc's calendar, shared with the user read & write:
+ // Stalwart sends the move from userc. It went out to nobody and the
+ // attendees kept the old time (live on 1.0 and 0.16.23).
+ it("asks the attendees again when the user drags the owner's meeting in a shared calendar", async () => {
+ useCalendarStore.setState({ events: [meeting("userc@example.org", { id: "f:e1", isOrigin: true, isShared: true, accountId: "f" })] });
+ const result = interactions();
+
+ await act(() => result.current.handleColumnDrop(drop("f:e1"), new Date(2026, 8, 3)));
+
+ const [, id, updates, send] = updateEvent.mock.calls[0];
+ expect(id).toBe("f:e1");
+ expect(updates.participants.org.participationStatus).toBe("accepted");
+ expect(updates.participants.bob.participationStatus).toBe("needs-action");
+ expect(send).toBe(true);
+ });
+
it("asks the attendees again when the user resizes their meeting", async () => {
useCalendarStore.setState({ events: [meeting("me@example.com")] });
const result = interactions();
diff --git a/lib/__tests__/calendar-participants.test.ts b/lib/__tests__/calendar-participants.test.ts
index 2a93dff1c..00494f260 100644
--- a/lib/__tests__/calendar-participants.test.ts
+++ b/lib/__tests__/calendar-participants.test.ts
@@ -794,17 +794,35 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('participantFieldsToSave fo
});
it('never asks again for an event someone else organizes', () => {
+ // The user's copy of an invitation: not the organizer's (isOrigin).
const invitation = makeEvent({
boss: organizer('boss@example.org'),
mine: attendee('me@example.com', 'accepted'),
bob: attendee('bob@example.org', 'declined'),
- }, { organizerCalendarAddress: 'mailto:boss@example.org' });
+ }, { organizerCalendarAddress: 'mailto:boss@example.org', isOrigin: false });
const fields = participantFieldsToSave(invitation, rows('me@example.com', 'bob@example.org'), { ...me, rescheduled: true });
expect(fields.participants!.mine.participationStatus).toBe('accepted');
expect(fields.participants!.bob.participationStatus).toBe('declined');
});
+
+ // userc shares a calendar with the user (read & write): userc's meeting in
+ // it is the organizer's copy, and a move there goes out from userc.
+ it('asks again when the user moves the owner\'s meeting in a calendar shared with them', () => {
+ const shared = makeEvent({
+ owner: organizer('userc@example.org'),
+ anna: attendee('usera@example.org', 'accepted'),
+ mine: attendee('me@example.com', 'tentative'),
+ }, { organizerCalendarAddress: 'mailto:userc@example.org', isOrigin: true, isShared: true, accountId: 'f' });
+
+ const fields = participantFieldsToSave(shared, rows('usera@example.org', 'me@example.com'), { ...me, rescheduled: true });
+
+ expect(fields.participants!.owner).toMatchObject({ calendarAddress: 'mailto:userc@example.org', participationStatus: 'accepted' });
+ expect(fields.participants!.anna.participationStatus).toBe('needs-action');
+ expect(fields.participants!.mine.participationStatus).toBe('needs-action');
+ expect(fields).not.toHaveProperty('organizerCalendarAddress');
+ });
});
// What Stalwart sends: a new event, and an occurrence that gets its own
@@ -862,11 +880,13 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('movedEventUpdates on Stalw
'@type': 'Participant', calendarAddress: `mailto:${address}`, email: address, participationStatus,
...(version === '1.0' ? { roles: { attendee: true } } : {}),
}) as Partial;
- const meeting = (organizer: string) => makeEvent({
+ // isOrigin as the server says it: whether the organizer is an address of
+ // the account the event is in.
+ const meeting = (organizer: string, extra: Partial = {}) => makeEvent({
org: { '@type': 'Participant', calendarAddress: `mailto:${organizer}`, roles: { owner: true }, participationStatus: 'accepted' },
bob: attendee('bob@example.org', 'accepted'),
me: attendee('me@example.com', 'tentative'),
- }, { organizerCalendarAddress: `mailto:${organizer}` });
+ }, { organizerCalendarAddress: `mailto:${organizer}`, isOrigin: organizer.toLowerCase() === 'me@example.com', ...extra });
const MOVE = { start: '2026-03-01T11:00:00' };
it('asks the attendees again and tells them when the user organizes the event', () => {
@@ -883,6 +903,25 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('movedEventUpdates on Stalw
.toEqual({ updates: MOVE, sendSchedulingMessages: undefined });
});
+ // userc's meeting in userc's calendar, shared with the user read & write:
+ // Stalwart sends the move from userc. It went out to nobody, and the
+ // attendees kept the old time (live on 1.0 and 0.16.23).
+ it('asks the attendees again and tells them when the user moves the owner\'s meeting in a shared calendar', () => {
+ const { updates, sendSchedulingMessages } = movedEventUpdates(
+ meeting('userc@example.org', { isOrigin: true, isShared: true, accountId: 'f' }), MOVE, ['me@example.com'],
+ );
+
+ expect(sendSchedulingMessages).toBe(true);
+ expect(updates.participants!.org.participationStatus).toBe('accepted');
+ expect(updates.participants!.bob.participationStatus).toBe('needs-action');
+ expect(updates.participants!.me.participationStatus).toBe('needs-action');
+ });
+
+ it('moves the owner\'s copy of someone else\'s meeting in a shared calendar quietly', () => {
+ expect(movedEventUpdates(meeting('boss@example.org', { isShared: true, accountId: 'f' }), MOVE, ['me@example.com']))
+ .toEqual({ updates: MOVE, sendSchedulingMessages: undefined });
+ });
+
it('moves an event without participants quietly', () => {
expect(movedEventUpdates(makeEvent(null), MOVE, ['me@example.com'])).toEqual({ updates: MOVE, sendSchedulingMessages: undefined });
expect(movedEventUpdates(undefined, MOVE, ['me@example.com'])).toEqual({ updates: MOVE, sendSchedulingMessages: undefined });
@@ -900,10 +939,31 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('movedEventUpdates on Stalw
describe('userOrganizes', () => {
it('is the user under any of their addresses, or anyone for an event without an organizer', () => {
- const own = makeEvent({ org: { '@type': 'Participant', calendarAddress: 'mailto:Alias@Example.com', roles: { owner: true } } });
+ const own = makeEvent({ org: { '@type': 'Participant', calendarAddress: 'mailto:Alias@Example.com', roles: { owner: true } } }, { isOrigin: false });
expect(userOrganizes(own, ['me@example.com', 'alias@example.com'])).toBe(true);
expect(userOrganizes(own, ['me@example.com'])).toBe(false);
- expect(userOrganizes(makeEvent({ bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.org' } }), ['me@example.com'])).toBe(true);
+ expect(userOrganizes(makeEvent({ bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.org' } }, { isOrigin: false }), ['me@example.com'])).toBe(true);
+ });
+
+ // What userb sees in userc's calendar, shared read & write, as each version
+ // returns it (live): isOrigin is true where userc organizes, false for an
+ // invitation userc got, for the event and for each expanded occurrence.
+ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('in a calendar shared with the user on Stalwart %s', (version) => {
+ const inShared = (organizer: string, isOrigin: boolean) => makeEvent({
+ org: { '@type': 'Participant', calendarAddress: `mailto:${organizer}`, roles: { owner: true }, participationStatus: 'accepted' },
+ b: {
+ '@type': 'Participant', calendarAddress: 'mailto:userb@example.org', participationStatus: 'accepted',
+ ...(version === '1.0' ? { roles: { attendee: true } } : {}),
+ },
+ }, { organizerCalendarAddress: `mailto:${organizer}`, isOrigin, isShared: true, accountId: 'f' });
+
+ it('is the owner\'s meeting: the server schedules it from the owner', () => {
+ expect(userOrganizes(inShared('userc@example.org', true), ['userb@example.org'])).toBe(true);
+ });
+
+ it('is not an invitation the owner got', () => {
+ expect(userOrganizes(inShared('usera@example.org', false), ['userb@example.org'])).toBe(false);
+ });
});
});
diff --git a/lib/__tests__/recurrence-scope-edit.test.ts b/lib/__tests__/recurrence-scope-edit.test.ts
index 291a70b6d..83295eb6b 100644
--- a/lib/__tests__/recurrence-scope-edit.test.ts
+++ b/lib/__tests__/recurrence-scope-edit.test.ts
@@ -24,6 +24,8 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
type Version = '0.16' | '1.0';
const ORGANIZER = 'userc@example.org';
const ATTENDEE = 'userb@example.org';
+// Someone userc shares the calendar with, read & write; not invited.
+const SHAREE = 'usera@example.org';
// Participants as each version stores them: 0.16.23 drops an attendee's
// roles, 1.0 keeps them.
@@ -222,7 +224,7 @@ describe.each(occurrences)('changing every occurrence from one of them on Stalwa
it('moves an attendee\'s own copy of the series and leaves its participants alone', () => {
const occurrence = make();
const updates = seriesUpdatesFromOccurrence(
- occurrence, series(version), editorUpdates(occurrence, { participants: undefined }),
+ occurrence, { ...series(version), isOrigin: false }, editorUpdates(occurrence, { participants: undefined }),
editorOn(occurrence, { when: { allDay: false, start: addHours(getEventStartDate(occurrence), 1), end: addHours(getEventEndDate(occurrence), 1) } }),
{ userEmail: ATTENDEE, userEmails: [ATTENDEE] },
);
@@ -230,6 +232,22 @@ describe.each(occurrences)('changing every occurrence from one of them on Stalwa
expect(updates.start).toBe('2026-10-05T11:00:00');
expect(updates).not.toHaveProperty('participants');
});
+
+ // The series in userc's calendar, shared with usera read & write: Stalwart
+ // sends usera's change from userc (isOrigin: true).
+ it('asks again when the user moves the owner\'s series in a calendar shared with them', () => {
+ const occurrence = make();
+ const updates = seriesUpdatesFromOccurrence(
+ occurrence, { ...series(version), isOrigin: true, isShared: true, accountId: 'f' }, editorUpdates(occurrence),
+ editorOn(occurrence, { when: { allDay: false, start: addHours(getEventStartDate(occurrence), 1), end: addHours(getEventEndDate(occurrence), 1) } }),
+ { userEmail: SHAREE, userEmails: [SHAREE] },
+ );
+
+ expect(updates.start).toBe('2026-10-05T11:00:00');
+ expect(statusOf(updates.participants, ORGANIZER)).toBe('accepted');
+ expect(statusOf(updates.participants, ATTENDEE)).toBe('needs-action');
+ expect(statusOf(updates.participants, SHAREE)).toBeUndefined();
+ });
});
describe('changing every occurrence of a series in another time zone', () => {
@@ -347,7 +365,7 @@ describe.each(occurrences)('saving this and the following occurrences on Stalwar
it('ends an attendee\'s own copy quietly', async () => {
const occurrence = make();
- const deps = actions(series(version));
+ const deps = actions({ ...series(version), isOrigin: false });
await saveOccurrenceEdit('this_and_future', {
event: occurrence, updates: editorUpdates(occurrence), editor: editorOn(occurrence), sendScheduling: true, userEmails: [ATTENDEE],
@@ -355,6 +373,25 @@ describe.each(occurrences)('saving this and the following occurrences on Stalwar
expect(deps.updateEvent).toHaveBeenCalledWith(deps.client, 'b', { recurrenceRules: truncated }, undefined);
});
+
+ // The series in userc's calendar, shared with usera read & write: the
+ // split ended it without a word, and the rest had no attendees - the
+ // editor wrote none for someone it took for an attendee.
+ it('tells the attendees and asks them to the new series when the user splits the owner\'s series in a shared calendar', async () => {
+ const occurrence = make();
+ const deps = actions({ ...series(version), isOrigin: true, isShared: true, accountId: 'f' });
+
+ await saveOccurrenceEdit('this_and_future', {
+ event: occurrence, updates: editorUpdates(occurrence, { title: 'Planning, room 2' }), editor: editorOn(occurrence),
+ sendScheduling: true, userEmails: [SHAREE],
+ }, deps);
+
+ expect(deps.updateEvent).toHaveBeenCalledWith(deps.client, 'b', { recurrenceRules: truncated }, true);
+ const [, created, send] = deps.createEvent.mock.calls[0];
+ expect(send).toBe(true);
+ expect(statusOf(created.participants, ORGANIZER)).toBe('accepted');
+ expect(statusOf(created.participants, ATTENDEE)).toBe('needs-action');
+ });
});
describe('the new series from this and the following occurrences', () => {
diff --git a/lib/calendar-participants.ts b/lib/calendar-participants.ts
index c4d1cb2dc..c0be48dc1 100644
--- a/lib/calendar-participants.ts
+++ b/lib/calendar-participants.ts
@@ -383,7 +383,7 @@ export function buildParticipantMap(
* `organizerCalendarAddress` or the retired `replyTo`. Null for an event
* without one.
*/
-function eventOrganizer(event: CalendarEvent): { name: string; email: string } | null {
+export 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];
@@ -392,20 +392,29 @@ function eventOrganizer(event: CalendarEvent): { name: string; email: string } |
}
/**
- * Whether the user, under any of `userEmails`, organizes `event`, or would
- * on saving it with attendees: an event that names no organizer gets the
- * user (participantFieldsToSave). Decides whether a change of when the
- * event happens asks the attendees again, in the editor and elsewhere.
+ * Whether the user writes `event` as its organizer: they organize it under
+ * any of `userEmails`, or would on saving it with attendees (an event that
+ * names no organizer gets the user, participantFieldsToSave), or the event
+ * is its 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 event's organizer is one of them (0.16 and 1.0 alike): a
+ * sharee's move of the owner's meeting goes out from the owner. An
+ * invitation in that account is `isOrigin: false` and stays the account's
+ * own answer to make. Decides whether a change of when the event happens
+ * asks the attendees again, and whether it is sent, in the editor and
+ * elsewhere.
*/
export function userOrganizes(event: CalendarEvent, userEmails: string[]): boolean {
- return !eventOrganizer(event) || isOrganizer(event, userEmails);
+ return !eventOrganizer(event) || isOrganizer(event, userEmails) || event.isOrigin === true;
}
/**
* A move that does not go through the editor - a drag, a resize - saved
* the way the editor saves one (participantFieldsToSave): when the user
- * organizes the event, its attendees are asked again, and the move goes out
- * to them; anyone else moves their own copy without messages. Stalwart's
+ * organizes the event (userOrganizes: the owner's copy in a calendar shared
+ * with the user counts), its attendees are asked again, and the move goes
+ * out to them; anyone else moves their own copy without messages. Stalwart's
* update carries each attendee's stored answer, and one occurrence that
* gets its own override reaches only attendees without an answer: a dragged
* occurrence reached no attendee who had answered, a dragged event kept
diff --git a/lib/demo/fixtures/calendars.ts b/lib/demo/fixtures/calendars.ts
index 68f493512..cc368ac1f 100644
--- a/lib/demo/fixtures/calendars.ts
+++ b/lib/demo/fixtures/calendars.ts
@@ -181,6 +181,8 @@ export function createDemoCalendarEvents(): CalendarEvent[] {
id: 'demo-event-5',
calendarIds: { 'demo-calendar-work': true },
uid: 'demo-event-5@example.com',
+ // Alice's invitation: the demo user's copy is not the organizer's.
+ isOrigin: false,
title: 'Quarterly Review',
description: 'Q4 performance review and planning session',
created: demoDate(-14),