mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user