fix: answer invitations in a group or shared mailbox as that account again #634

The banner only answered on the user's own copy since 877198a4b, so an
invitation in a group mailbox (or a boss's mailbox a delegate reads)
was imported into the user's own calendar and answered there: no
reply reached the organiser and the user got a duplicate. It now uses
the copy in the message's own account and answers as that account, as
the group without SENT-BY, as the person with SENT-BY naming the user.
The user's own answer bar on a calendar shared with them also finds the
same occurrence of their own copy of a series.
This commit is contained in:
Linus Rath
2026-11-01 06:29:13 +01:00
parent 962873800e
commit a9e1b7bf43
4 changed files with 181 additions and 29 deletions
@@ -63,3 +63,31 @@ describe('AnswerBars', () => {
expect(screen.queryByTestId('answer-for-owner')).toBeNull();
});
});
describe('useAnswerView with the user invited too', () => {
afterEach(() => {
useAuthStore.setState({ client: null } as never);
});
it("answers for the user on the same occurrence of their own copy of a series", async () => {
useAuthStore.setState({ client: { getAccountOwners: async () => ({}), hasAccountCapability: () => true } } as never);
const both = {
...event,
participants: {
...event.participants,
bob: { '@type': 'Participant', name: 'Bob', email: 'bob@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
},
} as unknown as CalendarEvent;
const occurrence = (id: string, recurrenceId: string, shared: boolean) =>
({ ...both, id, recurrenceId, isShared: shared, accountId: shared ? 'alice' : undefined } as unknown as CalendarEvent);
const shared = occurrence('alice:occ-2', '2026-11-11T14:00:00', true);
// The store holds a series as its expanded occurrences.
useCalendarStore.setState({ events: [occurrence('own-1', '2026-11-04T14:00:00', false), occurrence('own-2', '2026-11-11T14:00:00', false), shared] });
function Both() {
const view = useAnswerView(shared, calendar, ['bob@example.org'], true);
return createElement('div', { 'data-testid': 'targets' }, view.targets.map((t) => `${t.kind}:${t.eventId}`).join(','));
}
render(createElement(Both));
expect(await screen.findByTestId('targets')).toHaveTextContent('owner:alice:occ-2,self:own-2');
});
});
+26 -16
View File
@@ -6,7 +6,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { Calendar, CalendarEvent, CalendarParticipant, Principal } from "@/lib/jmap/types";
import { accountOwnerOf, resolveAnswerTargets, type AnswerTarget, type AnswerView } from "@/lib/calendar-delegate";
import { accountOwnerOf, resolveAnswerTargets, type AccountOwner, type AnswerTarget, type AnswerView } from "@/lib/calendar-delegate";
import { RsvpButtons } from "./rsvp-buttons";
type Status = CalendarParticipant["participationStatus"];
@@ -30,6 +30,26 @@ function loadAccountOwner(client: IJMAPClient, accountId: string): Promise<Princ
return owner;
}
/**
* The owner of a shared or group account (Principal/get, else the session's
* account name), or null for none.
*/
export function useAccountOwner(client: IJMAPClient | null, accountId: string | null, accountName: string | undefined): AccountOwner | null {
const [principal, setPrincipal] = useState<{ accountId: string; principal: Principal | null } | null>(null);
useEffect(() => {
if (!client || !accountId) return;
let cancelled = false;
void loadAccountOwner(client, accountId).then((found) => {
if (!cancelled) setPrincipal({ accountId, principal: found });
});
return () => { cancelled = true; };
}, [client, accountId]);
return useMemo(
() => (accountId ? accountOwnerOf(principal?.accountId === accountId ? principal.principal : null, accountName) : null),
[accountId, accountName, principal],
);
}
/**
* The answer bars of an event: the user's own, and "Answer for <owner>" for
* an invitation in a calendar shared with them by its owner
@@ -44,30 +64,20 @@ export function useAnswerView(
const client = useAuthStore((s) => s.client);
const events = useCalendarStore((s) => s.events);
const sharedAccountId = calendar?.isShared ? calendar.accountId ?? null : null;
const [principal, setPrincipal] = useState<{ accountId: string; principal: Principal | null } | null>(null);
useEffect(() => {
if (!client || !sharedAccountId) return;
let cancelled = false;
void loadAccountOwner(client, sharedAccountId).then((found) => {
if (!cancelled) setPrincipal({ accountId: sharedAccountId, principal: found });
});
return () => { cancelled = true; };
}, [client, sharedAccountId]);
const owner = useAccountOwner(client, sharedAccountId, calendar?.accountName);
return useMemo(() => {
if (!event) return { targets: [], note: null, answeredBy: null };
const owner = sharedAccountId
? accountOwnerOf(principal?.accountId === sharedAccountId ? principal.principal : null, calendar?.accountName)
: null;
// The same occurrence in the user's own calendar: the store holds a
// series as its expanded occurrences.
const ownCopy = event.uid
? events.find((e) => e.uid === event.uid && !e.isShared && e.id !== event.id && !e.recurrenceId) ?? null
? events.find((e) => e.uid === event.uid && !e.isShared && e.id !== event.id && (e.recurrenceId ?? null) === (event.recurrenceId ?? null)) ?? null
: null;
// Stalwart 1.0 lists principals:owner in every account; 0.16 never does.
const server016 = !!client && !!sharedAccountId
&& !client.hasAccountCapability("urn:ietf:params:jmap:principals:owner", sharedAccountId);
return resolveAnswerTargets({ event, calendar, userEmails, canRsvpSelf, owner, ownCopy, server016 });
}, [event, calendar, userEmails, canRsvpSelf, sharedAccountId, principal, events, client]);
}, [event, calendar, userEmails, canRsvpSelf, sharedAccountId, owner, events, client]);
}
/**
@@ -12,6 +12,10 @@ const mocks = vi.hoisted(() => {
getCalendarsAccountId: vi.fn(() => 'calendar-account'),
getCalendarEvent: vi.fn(),
queryCalendarEvents: vi.fn(async (): Promise<unknown[]> => []),
// Who owns a shared or group account (Principal/get).
getAccountOwners: vi.fn(async (ids: string[]) => Object.fromEntries(ids.map((id) => [id, id === 'team'
? { id, type: 'group', name: 'team@example.com', email: 'team@example.com' }
: { id, type: 'individual', name: id + '@example.com', email: id + '@example.com' }]))),
};
const sourceClientMock = {
@@ -613,6 +617,85 @@ describe('CalendarInvitationBanner', () => {
});
});
describe('an invitation in a shared or group mailbox (#634)', () => {
const calendarsBefore = mocks.calendarState.calendars;
const withAccountCalendar = (accountId: string) => {
mocks.calendarState.calendars = [
...calendarsBefore,
{ id: accountId + ':b', name: accountId, color: '#16a34a', isDefault: true, isShared: true, accountId, accountName: accountId + '@example.com', myRights: { mayWriteAll: true } } as never,
];
};
const invited = (address: string) => ({
[address.split('@')[0]]: { '@type': 'Participant', email: address, name: address, roles: { attendee: true }, participationStatus: 'needs-action' },
});
it("answers in the group's calendar as the group, without SENT-BY", async () => {
withAccountCalendar('team');
try {
const participants = invited('team@example.com');
mocks.calendarState.events = [{ id: 'team:event-10', uid: 'uid-10', start: '2026-03-30T09:00:00Z', participants, isShared: true, accountId: 'team' }];
mocks.clientMock.parseCalendarEvents.mockResolvedValue([{ uid: 'uid-10', title: 'Team meeting', start: '2026-03-30T09:00:00Z', participants }]);
mocks.calendarState.rsvpEvent.mockResolvedValue(true);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-10', sourceAccountId: 'team' } as Partial<Email>)} />);
await waitFor(() => expect(mocks.clientMock.getAccountOwners).toHaveBeenCalledWith(['team']));
fireEvent.click(await screen.findByText('Accept'));
await waitFor(() => {
expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(mocks.clientMock, 'team:event-10', 'team', 'accepted', null);
});
expect(mocks.calendarState.importEvents).not.toHaveBeenCalled();
} finally {
mocks.calendarState.calendars = calendarsBefore;
}
});
it("answers in the boss's calendar for the boss, naming the delegate with SENT-BY", async () => {
withAccountCalendar('boss');
try {
const participants = invited('boss@example.com');
mocks.calendarState.events = [{ id: 'boss:event-11', uid: 'uid-11', start: '2026-03-30T09:00:00Z', participants, isShared: true, accountId: 'boss' }];
mocks.clientMock.parseCalendarEvents.mockResolvedValue([{ uid: 'uid-11', title: 'Board', start: '2026-03-30T09:00:00Z', participants }]);
mocks.calendarState.rsvpEvent.mockResolvedValue(true);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-11', sourceAccountId: 'boss' } as Partial<Email>)} />);
await waitFor(() => expect(mocks.clientMock.getAccountOwners).toHaveBeenCalledWith(['boss']));
fireEvent.click(await screen.findByText('Accept'));
await waitFor(() => {
expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(
mocks.clientMock, 'boss:event-11', 'boss', 'accepted', null, undefined, { sentBy: 'mailto:user@example.com' },
);
});
} finally {
mocks.calendarState.calendars = calendarsBefore;
}
});
it("adds an invitation not yet in the group's calendar there, not to the user's own", async () => {
withAccountCalendar('team');
try {
const participants = invited('team@example.com');
mocks.calendarState.events = [];
mocks.clientMock.parseCalendarEvents.mockResolvedValue([{ uid: 'uid-12', title: 'Team offsite', start: '2026-03-30T09:00:00Z', participants }]);
mocks.calendarState.importEvents.mockImplementation(async () => {
mocks.calendarState.events = [{ id: 'team:event-12', uid: 'uid-12', start: '2026-03-30T09:00:00Z', participants, isShared: true, accountId: 'team' }];
return 1;
});
mocks.calendarState.rsvpEvent.mockResolvedValue(true);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-12', sourceAccountId: 'team' } as Partial<Email>)} />);
// The Accept button shows once the group is known as the attendee.
fireEvent.click(await screen.findByText('Accept'));
await waitFor(() => expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(mocks.clientMock, 'team:event-12', 'team', 'accepted', null));
expect(mocks.calendarState.importEvents.mock.calls[0][2]).toBe('team:b');
} finally {
mocks.calendarState.calendars = calendarsBefore;
}
});
});
it('says nothing was sent when a plugin stops the answer', async () => {
mocks.calendarState.events = [
{
+44 -13
View File
@@ -47,6 +47,7 @@ import { getEventStartDate } from '@/lib/calendar-utils';
import { sanitizeColor } from '@/components/calendar/event-card';
import { RecipientPopover } from './recipient-popover';
import { useCalendarErrorMessage } from '@/hooks/use-calendar-error-message';
import { useAccountOwner } from '@/components/calendar/answer-bars';
interface InvitationChangeItem {
label: string;
@@ -427,6 +428,31 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
return Array.from(new Set(emails));
}));
const calendarInvitationParsingEnabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const storeCalendars = useCalendarStore((s) => s.calendars);
// An invitation in a group mailbox or in a mailbox another person shared
// (a delegate reading the boss's mail) is answered in that account's
// calendar, as that account: as the group without SENT-BY, as the person
// with SENT-BY naming the user (lib/calendar-delegate.ts). Only for an
// account of the active login, whose calendars the store holds.
const mailAccountId = email.sourceAccountId ?? sharedOwnerAccountId ?? null;
const ownAccountIds = [client?.getAccountId?.(), client?.getCalendarsAccountId?.()];
const sharedMailAccount = mailAccountId && parseClient === client && !ownAccountIds.includes(mailAccountId)
? mailAccountId
: null;
const sharedAccountCalendars = sharedMailAccount ? storeCalendars.filter((c) => c.accountId === sharedMailAccount && !c.isTasksOnly) : [];
const sharedCalendar = sharedAccountCalendars.find((c) => c.isDefault) ?? sharedAccountCalendars[0] ?? null;
const mailOwner = useAccountOwner(
client,
sharedMailAccount,
sharedCalendar?.accountName ?? (sharedMailAccount ? client?.getAccountInfo?.(sharedMailAccount)?.name : undefined),
);
const answerAs = sharedMailAccount && mailOwner ? mailOwner : null;
// The addresses that pick "your" participant in the invitation.
const actingEmails = answerAs ? answerAs.emails : identityEmails;
// Answering for a person (not a group) names the user with SENT-BY.
const answerOptions = answerAs && !answerAs.isGroup && identityEmails[0]
? { sentBy: `mailto:${identityEmails[0]}` }
: undefined;
const timeFormat = useSettingsStore((s) => s.timeFormat);
const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, deleteEvent, events: storeEvents, setSelectedDate } = useCalendarStore(useShallow((state) => ({
calendars: state.calendars,
@@ -572,20 +598,22 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const allowsRsvp = method === 'request';
const allowsImport = method === 'request' || method === 'publish' || method === 'add' || method === 'unknown';
// The user's own copy: a calendar shared with them by someone invited too
// holds that person's copy under the same uid, and answering there would
// answer for them (or, on Stalwart 0.16, for whichever address it picks;
// the duplicate attendees and wrong senders of #634).
// The copy in the account the message belongs to. For the user's own
// mailbox that is their own calendar: a calendar shared with them by
// someone invited too holds that person's copy under the same uid, and
// answering there would answer for them (or, on Stalwart 0.16, for
// whichever address it picks; the duplicate attendees and wrong senders of
// #634).
const existingEvent = parsedEvent?.uid
? storeEvents.find((e) => e.uid === parsedEvent.uid && !e.isShared)
? storeEvents.find((e) => e.uid === parsedEvent.uid && (sharedMailAccount ? e.accountId === sharedMailAccount : !e.isShared))
: null;
const currentUserParticipant = existingEvent
? findParticipantByEmails(existingEvent, identityEmails)
? findParticipantByEmails(existingEvent, actingEmails)
: null;
const fallbackParsedParticipant = !currentUserParticipant && parsedEvent
? findParticipantByEmails(parsedEvent, identityEmails)
? findParticipantByEmails(parsedEvent, actingEmails)
: null;
const participantForUser = currentUserParticipant || fallbackParsedParticipant;
@@ -661,7 +689,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const handleRsvp = async (status: 'accepted' | 'tentative' | 'declined') => {
if (!client || !parsedEvent || isProcessing) return;
const calId = selectedCalendarId || calendars.find((c) => c.isDefault)?.id || calendars[0]?.id;
// An invitation of a shared or group mailbox goes into that account's calendar.
const calId = sharedMailAccount
? sharedCalendar?.id
: selectedCalendarId || calendars.find((c) => c.isDefault)?.id || calendars[0]?.id;
setActionNotice(null);
setActionError(null);
setIsProcessing(true);
@@ -677,7 +708,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
? await resolveExistingEventForRsvp()
: null;
const existingEventParticipant = eventForRsvp
? findParticipantByEmails(eventForRsvp, identityEmails)
? findParticipantByEmails(eventForRsvp, actingEmails)
: null;
const canFallbackToParsedParticipant = Boolean(
existingEvent
@@ -686,7 +717,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
);
if (eventForRsvp && existingEventParticipant) {
// false: a plugin stopped it and said why; keep the old answer.
if (!(await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp))) return;
if (!(await (answerOptions ? rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp, undefined, answerOptions) : rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp)))) return;
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
setState('parsed');
@@ -719,12 +750,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
if (imported === null) return;
if (imported > 0) {
const newEvent = useCalendarStore.getState().events.find(
(e) => e.uid === parsedEvent.uid
(e) => e.uid === parsedEvent.uid && (sharedMailAccount ? e.accountId === sharedMailAccount : !e.isShared)
);
const participant = myParticipant
|| (newEvent ? findParticipantByEmails(newEvent, identityEmails) : null);
|| (newEvent ? findParticipantByEmails(newEvent, actingEmails) : null);
if (newEvent && participant) {
if (!(await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp))) return;
if (!(await (answerOptions ? rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp, undefined, answerOptions) : rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp)))) return;
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
} else {