diff --git a/components/calendar/__tests__/answer-bars.test.tsx b/components/calendar/__tests__/answer-bars.test.tsx index 875232e23..7370b1859 100644 --- a/components/calendar/__tests__/answer-bars.test.tsx +++ b/components/calendar/__tests__/answer-bars.test.tsx @@ -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'); + }); +}); diff --git a/components/calendar/answer-bars.tsx b/components/calendar/answer-bars.tsx index b0a449ec0..464f0d5bb 100644 --- a/components/calendar/answer-bars.tsx +++ b/components/calendar/answer-bars.tsx @@ -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(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 " 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]); } /** diff --git a/components/email/__tests__/calendar-invitation-banner.test.tsx b/components/email/__tests__/calendar-invitation-banner.test.tsx index a22638249..f6ec9c35d 100644 --- a/components/email/__tests__/calendar-invitation-banner.test.tsx +++ b/components/email/__tests__/calendar-invitation-banner.test.tsx @@ -12,6 +12,10 @@ const mocks = vi.hoisted(() => { getCalendarsAccountId: vi.fn(() => 'calendar-account'), getCalendarEvent: vi.fn(), queryCalendarEvents: vi.fn(async (): Promise => []), + // 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()} />); + 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()} />); + 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()} />); + // 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 = [ { diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 3f858fde4..bb0fa77c5 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -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 {