fix: find the invited attendee under any sending identity when answering an invite #1283

This commit is contained in:
Linus Rath
2026-10-19 18:41:05 +02:00
parent 6b0c345cf7
commit b5964ac544
3 changed files with 71 additions and 12 deletions
@@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => {
const authState = {
client: clientMock,
primaryIdentity: { email: 'user@example.com' },
identities: [{ email: 'user@example.com' }] as Array<{ email: string }>,
getClientForAccount: vi.fn((id: string) => (id === 'login-b' ? sourceClientMock : undefined)),
};
@@ -188,6 +189,7 @@ describe('CalendarInvitationBanner', () => {
mocks.sourceClientMock.fetchBlob.mockReset();
mocks.sourceClientMock.fetchBlob.mockRejectedValue(new Error('no blob'));
mocks.authState.getClientForAccount.mockClear();
mocks.authState.identities = [{ email: 'user@example.com' }];
mocks.emailState.viewingAccountId = null;
mocks.emailState.selectedMailbox = 'inbox';
mocks.emailState.mailboxes = [];
@@ -546,6 +548,41 @@ describe('CalendarInvitationBanner', () => {
expect(await screen.findByText('Response sent')).toBeInTheDocument();
});
it('finds the user under an alias identity the invite was sent to (#1283)', async () => {
mocks.authState.identities = [{ email: 'user@example.com' }, { email: 'Alias@example.com' }];
const participants = {
aliasAttendee: {
'@type': 'Participant',
email: 'alias@example.com',
name: 'User',
roles: { attendee: true },
participationStatus: 'needs-action',
},
};
mocks.calendarState.events = [
{ id: 'event-8', uid: 'uid-8', start: '2026-03-30T09:00:00Z', participants },
];
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
{ uid: 'uid-8', title: 'Alias Event', start: '2026-03-30T09:00:00Z', participants },
]);
mocks.calendarState.rsvpEvent.mockResolvedValue(true);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-8' })} />);
fireEvent.click(await screen.findByText('Accept'));
await waitFor(() => {
expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(
mocks.clientMock,
'event-8',
'aliasAttendee',
'accepted',
null,
);
});
expect(await screen.findByText('Response sent')).toBeInTheDocument();
});
it('says nothing was sent when a plugin stops the answer', async () => {
mocks.calendarState.events = [
{
+18 -12
View File
@@ -34,7 +34,7 @@ import {
getInvitationTrustAssessment,
formatEventSummary,
formatInvitationSchedule,
findParticipantByEmail,
findParticipantByEmails,
extractMethodFromRawIcs,
type EventSummary,
type InvitationMethod,
@@ -390,7 +390,14 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const viewedMailbox = viewMailboxes.find((m) => m.id === selectedMailboxId);
const sharedOwnerAccountId = viewedMailbox?.isShared ? viewedMailbox.accountId : undefined;
const blobAccountId = email.sourceAccountId ?? sharedOwnerAccountId ?? parseClient?.getAccountId();
const currentUserEmail = useAuthStore((s) => s.primaryIdentity?.email);
// Every address the user sends as, primary first: an invite may be
// addressed to an alias identity (#1283).
const identityEmails = useAuthStore(useShallow((s) => {
const emails = [s.primaryIdentity?.email, ...s.identities.map((identity) => identity.email)]
.filter((address): address is string => Boolean(address))
.map((address) => address.toLowerCase());
return Array.from(new Set(emails));
}));
const calendarInvitationParsingEnabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, events: storeEvents, setSelectedDate } = useCalendarStore(useShallow((state) => ({
@@ -524,12 +531,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
? storeEvents.find((e) => e.uid === parsedEvent.uid)
: null;
const currentUserParticipant = existingEvent && currentUserEmail
? findParticipantByEmail(existingEvent, currentUserEmail)
const currentUserParticipant = existingEvent
? findParticipantByEmails(existingEvent, identityEmails)
: null;
const fallbackParsedParticipant = !currentUserParticipant && parsedEvent && currentUserEmail
? findParticipantByEmail(parsedEvent, currentUserEmail)
const fallbackParsedParticipant = !currentUserParticipant && parsedEvent
? findParticipantByEmails(parsedEvent, identityEmails)
: null;
const participantForUser = currentUserParticipant || fallbackParsedParticipant;
@@ -545,9 +552,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|| myParticipant?.participant.participationStatus
|| null;
const userIsOrganizer = Boolean(isOnlyOrganizer);
const userIsSender = Boolean(
currentUserEmail && email.from?.[0]?.email?.toLowerCase() === currentUserEmail.toLowerCase()
);
const senderAddress = email.from?.[0]?.email?.toLowerCase();
const userIsSender = Boolean(senderAddress && identityEmails.includes(senderAddress));
const bannerTitle = getBannerTitle(t, method);
const bannerInfo = getBannerInfo(t, method, userIsOrganizer, supportsCalendar);
const trustAssessment = parsedEvent && !userIsSender ? getInvitationTrustAssessment(parsedEvent, email, method) : null;
@@ -602,8 +608,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const eventForRsvp = existingEvent
? await resolveExistingEventForRsvp()
: null;
const existingEventParticipant = eventForRsvp && currentUserEmail
? findParticipantByEmail(eventForRsvp, currentUserEmail)
const existingEventParticipant = eventForRsvp
? findParticipantByEmails(eventForRsvp, identityEmails)
: null;
const canFallbackToParsedParticipant = Boolean(
existingEvent
@@ -646,7 +652,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
(e) => e.uid === parsedEvent.uid
);
const participant = myParticipant
|| (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null);
|| (newEvent ? findParticipantByEmails(newEvent, identityEmails) : null);
if (newEvent && participant) {
if (!(await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp))) return;
setRsvpStatus(status);
+16
View File
@@ -702,6 +702,22 @@ export function formatInvitationSchedule(
return range.end ? `${start} – ${formatPoint(range.end, true)}` : start;
}
/**
* The participant matching the first of `emails` found in the event. The
* user's identities, primary first: an invite may be addressed to an alias
* identity rather than the primary address (#1283).
*/
export function findParticipantByEmails(
event: Partial<CalendarEvent>,
emails: readonly string[]
): { id: string; participant: CalendarParticipant } | null {
for (const email of emails) {
const found = findParticipantByEmail(event, email);
if (found) return found;
}
return null;
}
export function findParticipantByEmail(
event: Partial<CalendarEvent>,
email: string