diff --git a/components/email/__tests__/calendar-invitation-banner.test.tsx b/components/email/__tests__/calendar-invitation-banner.test.tsx index 37d9be2d5..efef720a8 100644 --- a/components/email/__tests__/calendar-invitation-banner.test.tsx +++ b/components/email/__tests__/calendar-invitation-banner.test.tsx @@ -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(); + + 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 = [ { diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index cbc1a18b2..53919235a 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -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); diff --git a/lib/calendar-invitation.ts b/lib/calendar-invitation.ts index a467068b4..2ff7a22ea 100644 --- a/lib/calendar-invitation.ts +++ b/lib/calendar-invitation.ts @@ -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, + 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, email: string