From 0f5d030d5f403e2411d7cba39969b1eaa1e9dbdf Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:00:11 +0100 Subject: [PATCH] feat: calendar invitations RSVP, trust assessment, file preview --- app/[locale]/files/page.tsx | 4 +- app/[locale]/page.tsx | 38 + .../calendar-invitation-banner.test.tsx | 592 ++ .../email/calendar-invitation-banner.tsx | 1036 +- components/email/email-viewer.tsx | 21 +- components/email/thread-conversation-view.tsx | 12 +- components/files/file-preview-modal.tsx | 72 +- components/settings/calendar-settings.tsx | 19 +- components/settings/email-settings.tsx | 12 + lib/__tests__/calendar-alerts.test.ts | 1 + lib/__tests__/calendar-invitation.test.ts | 210 + lib/__tests__/calendar-participants.test.ts | 1 + lib/__tests__/calendar-utils.test.ts | 1 + lib/__tests__/file-preview.test.ts | 32 + lib/calendar-invitation.ts | 461 +- lib/file-preview.ts | 66 + lib/jmap/client.ts | 180 +- lib/jmap/types.ts | 2 + locales/de/common.json | 63 +- locales/en/common.json | 63 +- locales/es/common.json | 63 +- locales/fr/common.json | 63 +- locales/it/common.json | 63 +- locales/ja/common.json | 63 +- locales/nl/common.json | 63 +- locales/pt/common.json | 63 +- specifications/calendar/rfc5545.txt | 9411 +++++++++++++++++ specifications/calendar/rfc5546.txt | 7451 +++++++++++++ specifications/calendar/rfc6047.txt | 1235 +++ .../settings-store-attachments.test.ts | 32 + stores/calendar-store.ts | 22 +- stores/settings-store.ts | 7 + 32 files changed, 21143 insertions(+), 279 deletions(-) create mode 100644 components/email/__tests__/calendar-invitation-banner.test.tsx create mode 100644 lib/__tests__/file-preview.test.ts create mode 100644 lib/file-preview.ts create mode 100644 specifications/calendar/rfc5545.txt create mode 100644 specifications/calendar/rfc5546.txt create mode 100644 specifications/calendar/rfc6047.txt create mode 100644 stores/__tests__/settings-store-attachments.test.ts diff --git a/app/[locale]/files/page.tsx b/app/[locale]/files/page.tsx index 74670265..e660838d 100644 --- a/app/[locale]/files/page.tsx +++ b/app/[locale]/files/page.tsx @@ -452,8 +452,8 @@ export default function FilesPage() { setPreviewFile(null)} - onDownload={handleDownload} - getFileContent={getFileContent} + onDownload={() => handleDownload(previewFile)} + getFileContent={() => getFileContent(previewFile)} /> )} diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 99fc6bc7..22f3aef6 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -36,6 +36,8 @@ import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils"; import { WelcomeBanner } from "@/components/ui/welcome-banner"; import { NavigationRail } from "@/components/layout/navigation-rail"; import { Input } from "@/components/ui/input"; +import { FilePreviewModal } from "@/components/files/file-preview-modal"; +import { isFilePreviewable } from "@/lib/file-preview"; import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square } from "lucide-react"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { Button } from "@/components/ui/button"; @@ -59,6 +61,7 @@ export default function Home() { const [conversationThread, setConversationThread] = useState(null); const [conversationEmails, setConversationEmails] = useState([]); const [isLoadingConversation, setIsLoadingConversation] = useState(false); + const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null); const markAsReadTimeoutRef = useRef(null); const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost } = useAuthStore(); const { identities } = useIdentityStore(); @@ -746,12 +749,38 @@ export default function Home() { if (!client) return; try { + const { mailAttachmentAction } = useSettingsStore.getState(); + + if (mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) { + setPreviewAttachment({ blobId, name, type }); + return; + } + await client.downloadBlob(blobId, name, type); } catch (error) { console.error("Failed to download attachment:", error); } }; + const handlePreviewAttachmentDownload = useCallback(async () => { + if (!client || !previewAttachment) return; + + await client.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type); + }, [client, previewAttachment]); + + const getPreviewAttachmentContent = useCallback(async () => { + if (!client || !previewAttachment) { + throw new Error('No attachment selected'); + } + + const blob = await client.fetchBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type); + + return { + blob, + contentType: previewAttachment.type || blob.type || 'application/octet-stream', + }; + }, [client, previewAttachment]); + const handleQuickReply = async (body: string) => { if (!client || !selectedEmail) return; @@ -1498,6 +1527,15 @@ export default function Home() { onClose={() => setShowShortcutsModal(false)} /> + {previewAttachment && ( + setPreviewAttachment(null)} + onDownload={handlePreviewAttachmentDownload} + getFileContent={getPreviewAttachmentContent} + /> + )} + {/* Screen reader live region for dynamic status announcements */}
diff --git a/components/email/__tests__/calendar-invitation-banner.test.tsx b/components/email/__tests__/calendar-invitation-banner.test.tsx new file mode 100644 index 00000000..e711a5fe --- /dev/null +++ b/components/email/__tests__/calendar-invitation-banner.test.tsx @@ -0,0 +1,592 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CalendarInvitationBanner } from '../calendar-invitation-banner'; +import type { Email } from '@/lib/jmap/types'; + +const mocks = vi.hoisted(() => { + const pushMock = vi.fn(); + const clientMock = { + parseCalendarEvents: vi.fn(), + getCalendarsAccountId: vi.fn(() => 'calendar-account'), + getCalendarEvent: vi.fn(), + queryCalendarEvents: vi.fn(async () => []), + }; + + const authState = { + client: clientMock, + primaryIdentity: { email: 'user@example.com' }, + }; + + const settingsState = { + calendarInvitationParsingEnabled: true, + }; + + const calendarState = { + calendars: [{ id: 'cal-1', name: 'Primary', color: '#2563eb', isDefault: true }], + supportsCalendar: true, + importEvents: vi.fn(), + rsvpEvent: vi.fn(), + updateEvent: vi.fn(), + events: [] as Array>, + setSelectedDate: vi.fn(), + }; + + const useCalendarStoreMock = ((selector?: (state: typeof calendarState) => unknown) => ( + typeof selector === 'function' ? selector(calendarState) : calendarState + )) as { + (selector?: (state: typeof calendarState) => unknown): unknown; + getState: () => typeof calendarState; + setState: (updater: Partial | ((state: typeof calendarState) => Partial | typeof calendarState)) => void; + }; + + useCalendarStoreMock.getState = () => calendarState; + useCalendarStoreMock.setState = (updater) => { + const nextState = typeof updater === 'function' ? updater(calendarState) : updater; + Object.assign(calendarState, nextState); + }; + + return { + pushMock, + clientMock, + authState, + settingsState, + calendarState, + useCalendarStoreMock, + }; +}); + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => { + const strings: Record = { + loading: 'Loading event details…', + title: 'Calendar Invitation', + organizer: 'Organized by {name}', + attendees: 'attendees', + add_to_calendar: 'Add to calendar', + added: 'Added to calendar', + rsvp_sent: 'Response sent', + already_in_calendar: 'Already in your calendar', + your_response: 'Your response: {status}', + response_accepted: 'Accepted', + response_needed: 'Needs response', + actor_response_info: '{name} responded {status}.', + actor_sent_info: 'Sent by {name}.', + actor_counter_info: '{name} proposed changes to this event.', + actor_refresh_info: '{name} asked for the latest event details.', + actor_declined_counter_info: '{name} declined the counter proposal.', + actor_note: 'Note: {comment}', + actor_unknown: 'Someone', + action_failed: 'Could not complete that calendar action.', + proposal_applied: 'Proposed changes applied.', + proposed_changes: 'Proposed changes', + change_title: 'Title', + change_time: 'Time', + change_location: 'Location', + change_description: 'Description', + change_empty: 'None', + change_from_to: '{before} -> {after}', + apply_proposal: 'Apply proposed changes', + review_proposal: 'Review proposal', + review_request: 'Review request', + view_in_calendar: 'View in calendar', + organizer_role: 'You organize this event', + accept: 'Accept', + maybe: 'Maybe', + decline: 'Decline', + select_calendar: 'Select calendar', + }; + + let message = strings[key] ?? key; + if (values) { + for (const [name, value] of Object.entries(values)) { + message = message.replace(`{${name}}`, String(value)); + } + } + return message; + }, + useFormatter: () => ({ + dateTime: (date: Date) => date.toISOString(), + }), +})); + +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push: mocks.pushMock }), +})); + +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: (selector: (state: typeof mocks.authState) => unknown) => selector(mocks.authState), +})); + +vi.mock('@/stores/settings-store', () => ({ + useSettingsStore: (selector: (state: typeof mocks.settingsState) => unknown) => selector(mocks.settingsState), +})); + +vi.mock('@/stores/calendar-store', () => ({ + useCalendarStore: mocks.useCalendarStoreMock, +})); + +function makeEmail(overrides: Partial = {}): Email { + return { + id: 'email-1', + threadId: 'thread-1', + mailboxIds: { inbox: true }, + keywords: {}, + size: 512, + receivedAt: '2026-03-16T10:00:00Z', + hasAttachment: true, + attachments: [ + { + partId: '1', + blobId: 'blob-1', + size: 256, + type: 'text/calendar; method=REQUEST', + name: 'invite.ics', + }, + ], + ...overrides, + }; +} + +describe('CalendarInvitationBanner', () => { + beforeEach(() => { + mocks.pushMock.mockReset(); + mocks.settingsState.calendarInvitationParsingEnabled = true; + mocks.clientMock.parseCalendarEvents.mockReset(); + mocks.clientMock.getCalendarEvent.mockReset(); + mocks.clientMock.getCalendarEvent.mockResolvedValue(null); + mocks.clientMock.queryCalendarEvents.mockReset(); + mocks.clientMock.queryCalendarEvents.mockResolvedValue([]); + mocks.calendarState.importEvents.mockReset(); + mocks.calendarState.rsvpEvent.mockReset(); + mocks.calendarState.updateEvent.mockReset(); + mocks.calendarState.setSelectedDate.mockReset(); + mocks.calendarState.events = []; + }); + + it('does not parse invitations when invitation parsing is disabled', () => { + mocks.settingsState.calendarInvitationParsingEnabled = false; + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + expect(mocks.clientMock.parseCalendarEvents).not.toHaveBeenCalled(); + }); + + it('shows existing event status, current response, and view-in-calendar action', async () => { + mocks.calendarState.events = [ + { + id: 'event-1', + uid: 'uid-1', + start: '2026-03-20T09:00:00Z', + participants: { + attendee: { + '@type': 'Participant', + email: 'user@example.com', + name: 'User', + roles: { attendee: true }, + participationStatus: 'accepted', + }, + }, + }, + ]; + + mocks.clientMock.parseCalendarEvents.mockResolvedValue([ + { + uid: 'uid-1', + title: 'Team Sync', + start: '2026-03-20T09:00:00Z', + participants: { + attendee: { + '@type': 'Participant', + email: 'user@example.com', + name: 'User', + roles: { attendee: true }, + participationStatus: 'accepted', + }, + }, + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Already in your calendar')).toBeInTheDocument(); + }); + + expect(screen.getByText('Your response: Accepted')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('View in calendar')); + + expect(mocks.calendarState.setSelectedDate).toHaveBeenCalledTimes(1); + expect(mocks.pushMock).toHaveBeenCalledWith('/calendar'); + }); + + it('keeps the invitation summary visible after importing', async () => { + mocks.calendarState.importEvents.mockResolvedValue(1); + mocks.clientMock.parseCalendarEvents.mockResolvedValue([ + { + uid: 'uid-2', + title: 'Planning Session', + start: '2026-03-22T13:00:00Z', + participants: { + attendee: { + '@type': 'Participant', + email: 'user@example.com', + name: 'User', + roles: { attendee: true }, + participationStatus: 'needs-action', + }, + }, + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Planning Session' })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Add to calendar')); + + await waitFor(() => { + expect(screen.getByText('Added to calendar')).toBeInTheDocument(); + }); + + expect(screen.getByRole('heading', { name: 'Planning Session' })).toBeInTheDocument(); + }); + + it('does not show RSVP actions when the current user is the organizer', async () => { + mocks.clientMock.parseCalendarEvents.mockResolvedValue([ + { + uid: 'uid-3', + title: 'Organizer Review', + start: '2026-03-25T11:00:00Z', + participants: { + organizer: { + '@type': 'Participant', + email: 'user@example.com', + name: 'User', + roles: { owner: true }, + participationStatus: 'accepted', + }, + attendee: { + '@type': 'Participant', + email: 'alice@example.com', + name: 'Alice', + roles: { attendee: true }, + participationStatus: 'needs-action', + }, + }, + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('You organize this event')).toBeInTheDocument(); + }); + + expect(screen.queryByText('Accept')).not.toBeInTheDocument(); + expect(screen.queryByText('Maybe')).not.toBeInTheDocument(); + expect(screen.queryByText('Decline')).not.toBeInTheDocument(); + }); + + it('keeps the banner visible when an import action fails', async () => { + mocks.calendarState.importEvents.mockResolvedValue(0); + mocks.clientMock.parseCalendarEvents.mockResolvedValue([ + { + uid: 'uid-4', + title: 'Failed Import Event', + start: '2026-03-26T15:00:00Z', + participants: { + attendee: { + '@type': 'Participant', + email: 'user@example.com', + name: 'User', + roles: { attendee: true }, + participationStatus: 'needs-action', + }, + }, + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Failed Import Event' })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Add to calendar')); + + await waitFor(() => { + expect(screen.getByText('Could not complete that calendar action.')).toBeInTheDocument(); + }); + + expect(screen.getByRole('heading', { name: 'Failed Import Event' })).toBeInTheDocument(); + }); + + it('hydrates sparse existing events before sending RSVP', async () => { + mocks.calendarState.events = [ + { + id: 'event-6', + uid: 'uid-6', + start: '2026-03-28T09:00:00Z', + }, + ]; + + mocks.clientMock.parseCalendarEvents.mockResolvedValue([ + { + uid: 'uid-6', + title: 'Sparse Event', + start: '2026-03-28T09:00:00Z', + participants: { + parsedAttendee: { + '@type': 'Participant', + email: 'user@example.com', + name: 'User', + roles: { required: true }, + participationStatus: 'needs-action', + }, + }, + }, + ]); + + mocks.clientMock.getCalendarEvent.mockResolvedValue({ + id: 'event-6', + uid: 'uid-6', + start: '2026-03-28T09:00:00Z', + participants: { + canonicalAttendee: { + '@type': 'Participant', + email: 'user@example.com', + name: 'User', + roles: { attendee: true }, + participationStatus: 'needs-action', + }, + }, + }); + mocks.calendarState.rsvpEvent.mockResolvedValue(undefined); + + render(); + + await waitFor(() => { + expect(screen.getByText('Accept')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Accept')); + + await waitFor(() => { + expect(mocks.clientMock.getCalendarEvent).toHaveBeenCalledWith('event-6'); + }); + + expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith( + mocks.clientMock, + 'event-6', + 'canonicalAttendee', + 'accepted', + null, + ); + }); + + it('falls back to organizerCalendarAddress when replyTo is missing', async () => { + mocks.calendarState.events = [ + { + id: 'event-7', + uid: 'uid-7', + start: '2026-03-29T09:00:00Z', + participants: { + attendee: { + '@type': 'Participant', + calendarAddress: 'mailto:user@example.com', + name: 'User', + roles: { attendee: true }, + participationStatus: 'needs-action', + }, + }, + }, + ]; + + mocks.clientMock.parseCalendarEvents.mockResolvedValue([ + { + uid: 'uid-7', + title: 'Invite Without Reply-To', + start: '2026-03-29T09:00:00Z', + organizerCalendarAddress: 'mailto:organizer@example.com', + participants: { + attendee: { + '@type': 'Participant', + calendarAddress: 'mailto:user@example.com', + name: 'User', + roles: { required: true }, + participationStatus: 'needs-action', + }, + }, + }, + ]); + mocks.calendarState.rsvpEvent.mockResolvedValue(undefined); + + render(); + + await waitFor(() => { + expect(screen.getByText('Accept')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Accept')); + + await waitFor(() => { + expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith( + mocks.clientMock, + 'event-7', + 'attendee', + 'accepted', + { imip: 'mailto:organizer@example.com' }, + ); + }); + }); + + it('repairs sparse existing events with parsed participants before RSVPing', async () => { + mocks.calendarState.events = [ + { + id: 'event-8', + uid: 'uid-8', + start: '2026-03-30T09:00:00Z', + }, + ]; + + mocks.clientMock.getCalendarEvent.mockResolvedValue({ + id: 'event-8', + uid: 'uid-8', + start: '2026-03-30T09:00:00Z', + }); + mocks.clientMock.parseCalendarEvents.mockResolvedValue([ + { + uid: 'uid-8', + title: 'Sparse Stored Event', + start: '2026-03-30T09:00:00Z', + organizerCalendarAddress: 'mailto:organizer@example.com', + participants: { + organizer: { + '@type': 'Participant', + calendarAddress: 'mailto:organizer@example.com', + name: 'Organizer', + roles: { required: true }, + participationStatus: 'accepted', + }, + attendee: { + '@type': 'Participant', + calendarAddress: 'mailto:user@example.com', + name: 'User', + roles: { required: true }, + participationStatus: 'needs-action', + }, + }, + }, + ]); + mocks.calendarState.updateEvent.mockResolvedValue(undefined); + + render(); + + await waitFor(() => { + expect(screen.getByText('Accept')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Accept')); + + await waitFor(() => { + expect(mocks.calendarState.updateEvent).toHaveBeenCalledWith( + mocks.clientMock, + 'event-8', + expect.objectContaining({ + replyTo: { imip: 'mailto:organizer@example.com' }, + participants: expect.objectContaining({ + attendee: expect.objectContaining({ + participationStatus: 'accepted', + }), + }), + }), + true, + ); + }); + + expect(mocks.calendarState.rsvpEvent).not.toHaveBeenCalled(); + }); + + it('shows counter changes and lets organizers apply the proposal', async () => { + mocks.calendarState.updateEvent = vi.fn().mockResolvedValue(undefined); + mocks.calendarState.events = [ + { + id: 'event-5', + uid: 'uid-5', + title: 'Original Sync', + start: '2026-03-27T09:00:00Z', + duration: 'PT1H', + locations: { + room: { '@type': 'Location', name: 'Room A' }, + }, + participants: { + organizer: { + '@type': 'Participant', + email: 'user@example.com', + name: 'User', + roles: { owner: true }, + participationStatus: 'accepted', + }, + attendee: { + '@type': 'Participant', + email: 'alice@example.com', + name: 'Alice', + roles: { attendee: true }, + participationStatus: 'tentative', + participationComment: 'Could we move this later?', + }, + }, + }, + ]; + + mocks.clientMock.parseCalendarEvents.mockResolvedValue([ + { + uid: 'uid-5', + title: 'Original Sync', + start: '2026-03-27T10:00:00Z', + duration: 'PT1H', + locations: { + room: { '@type': 'Location', name: 'Room B' }, + }, + participants: { + organizer: { + '@type': 'Participant', + email: 'user@example.com', + name: 'User', + roles: { owner: true }, + participationStatus: 'accepted', + }, + attendee: { + '@type': 'Participant', + email: 'alice@example.com', + name: 'Alice', + roles: { attendee: true }, + participationStatus: 'tentative', + participationComment: 'Could we move this later?', + }, + }, + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Proposed changes')).toBeInTheDocument(); + }); + + expect(screen.getByText(/Location/)).toBeInTheDocument(); + expect(screen.getByText('Review proposal')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Apply proposed changes')); + + await waitFor(() => { + expect(mocks.calendarState.updateEvent).toHaveBeenCalledTimes(1); + }); + + expect(screen.getByText('Proposed changes applied.')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index e903af58..89818d8c 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { + ArrowRight, Calendar, CalendarCheck, CalendarX, @@ -16,18 +17,369 @@ import { ChevronDown, } from 'lucide-react'; import { useTranslations, useFormatter } from 'next-intl'; +import { useRouter } from '@/i18n/navigation'; import { useAuthStore } from '@/stores/auth-store'; import { useCalendarStore } from '@/stores/calendar-store'; +import { useSettingsStore } from '@/stores/settings-store'; import type { Email, CalendarEvent } from '@/lib/jmap/types'; import { findCalendarAttachment, getInvitationMethod, + getInvitationActorSummary, + getInvitationTrustAssessment, formatEventSummary, findParticipantByEmail, + extractMethodFromRawIcs, + type InvitationMethod, + type InvitationTrustAssessment, } from '@/lib/calendar-invitation'; import { cn } from '@/lib/utils'; import { sanitizeColor } from '@/components/calendar/event-card'; +interface InvitationChangeItem { + label: string; + before: string; + after: string; +} + +function getBannerTitle(t: ReturnType, method: InvitationMethod): string { + switch (method) { + case 'publish': + return t('published_title'); + case 'reply': + return t('response_title'); + case 'add': + return t('update_title'); + case 'counter': + return t('counter_title'); + case 'refresh': + return t('refresh_title'); + case 'declinecounter': + return t('declined_counter_title'); + case 'cancel': + return t('cancelled_title'); + case 'request': + case 'unknown': + default: + return t('title'); + } +} + +function getActorMessage( + t: ReturnType, + method: InvitationMethod, + actorName: string, + actorStatus: string | null, +): string | null { + switch (method) { + case 'reply': + return actorStatus + ? t('actor_response_info', { name: actorName, status: actorStatus }) + : t('actor_sent_info', { name: actorName }); + case 'counter': + return t('actor_counter_info', { name: actorName }); + case 'refresh': + return t('actor_refresh_info', { name: actorName }); + case 'declinecounter': + return t('actor_declined_counter_info', { name: actorName }); + case 'request': + case 'publish': + case 'add': + case 'cancel': + return t('actor_sent_info', { name: actorName }); + default: + return null; + } +} + +function getBannerInfo( + t: ReturnType, + method: InvitationMethod, + userIsOrganizer: boolean, + supportsCalendar: boolean, +): string | null { + if (!supportsCalendar) { + return t('no_calendar'); + } + + switch (method) { + case 'request': + return t('request_info'); + case 'publish': + return t('published_info'); + case 'reply': + return t(userIsOrganizer ? 'response_info_organizer' : 'response_info'); + case 'add': + return t('update_info'); + case 'cancel': + return t('cancel_info'); + case 'counter': + return t(userIsOrganizer ? 'counter_info_organizer' : 'counter_info'); + case 'refresh': + return t(userIsOrganizer ? 'refresh_info_organizer' : 'refresh_info'); + case 'declinecounter': + return t('declined_counter_info'); + default: + return null; + } +} + +function getTrustMessage( + t: ReturnType, + trustAssessment: InvitationTrustAssessment, +): string | null { + switch (trustAssessment.reason) { + case 'authentication_failed': + return t('authentication_failed_info'); + case 'authentication_missing': + return t('authentication_missing_info'); + case 'sender_mismatch': + return t('sender_mismatch_info', { + sender: trustAssessment.senderEmail ?? '', + organizer: trustAssessment.organizerEmail ?? '', + }); + case 'sender_mismatch_unverified': + return t('sender_mismatch_unverified_info', { + sender: trustAssessment.senderEmail ?? '', + organizer: trustAssessment.organizerEmail ?? '', + }); + default: + return null; + } +} + +function getParticipationLabel( + t: ReturnType, + status: string | null, +): string | null { + switch (status) { + case 'accepted': + return t('response_accepted'); + case 'tentative': + return t('response_tentative'); + case 'declined': + return t('response_declined'); + case 'delegated': + return t('response_delegated'); + case 'needs-action': + return t('response_needed'); + default: + return null; + } +} + +function getParticipationTone(status: string | null): string { + switch (status) { + case 'accepted': + return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'; + case 'tentative': + return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'; + case 'declined': + return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'; + default: + return 'bg-muted text-muted-foreground'; + } +} + +function hasMeaningfulDifference(left: T | null | undefined, right: T | null | undefined): boolean { + return JSON.stringify(left ?? null) !== JSON.stringify(right ?? null); +} + +function getViewActionLabel( + t: ReturnType, + method: InvitationMethod, + userIsOrganizer: boolean, +): string { + if (method === 'counter' && userIsOrganizer) { + return t('review_proposal'); + } + + if (method === 'refresh' && userIsOrganizer) { + return t('review_request'); + } + + return t('view_in_calendar'); +} + +function buildProposalPatch( + currentEvent: Partial | null, + proposedEvent: Partial | null, +): Partial | null { + if (!currentEvent || !proposedEvent) { + return null; + } + + const patch: Partial = {}; + + if (typeof proposedEvent.title === 'string' && proposedEvent.title !== currentEvent.title) { + patch.title = proposedEvent.title; + } + + if (typeof proposedEvent.description === 'string' && proposedEvent.description !== currentEvent.description) { + patch.description = proposedEvent.description; + } + + if (typeof proposedEvent.descriptionContentType === 'string' && proposedEvent.descriptionContentType !== currentEvent.descriptionContentType) { + patch.descriptionContentType = proposedEvent.descriptionContentType; + } + + if (typeof proposedEvent.start === 'string' && proposedEvent.start !== currentEvent.start) { + patch.start = proposedEvent.start; + } + + if (typeof proposedEvent.duration === 'string' && proposedEvent.duration !== currentEvent.duration) { + patch.duration = proposedEvent.duration; + } + + if ((proposedEvent.timeZone ?? null) !== (currentEvent.timeZone ?? null)) { + patch.timeZone = proposedEvent.timeZone ?? null; + } + + if ((proposedEvent.showWithoutTime ?? false) !== (currentEvent.showWithoutTime ?? false)) { + patch.showWithoutTime = proposedEvent.showWithoutTime ?? false; + } + + if (hasMeaningfulDifference(proposedEvent.locations, currentEvent.locations)) { + patch.locations = proposedEvent.locations ?? null; + } + + if (hasMeaningfulDifference(proposedEvent.virtualLocations, currentEvent.virtualLocations)) { + patch.virtualLocations = proposedEvent.virtualLocations ?? null; + } + + return Object.keys(patch).length > 0 ? patch : null; +} + +function buildInvitationChangeItems( + t: ReturnType, + currentEvent: Partial | null, + proposedEvent: Partial | null, + formatDateTime: (dateStr: string | null) => string, +): InvitationChangeItem[] { + if (!currentEvent || !proposedEvent) { + return []; + } + + const currentSummary = formatEventSummary(currentEvent); + const proposedSummary = formatEventSummary(proposedEvent); + const changes: InvitationChangeItem[] = []; + + if (currentSummary.title !== proposedSummary.title && proposedSummary.title) { + changes.push({ + label: t('change_title'), + before: currentSummary.title || t('change_empty'), + after: proposedSummary.title, + }); + } + + const currentSchedule = currentSummary.start + ? `${formatDateTime(currentSummary.start)}${currentSummary.end ? ` - ${formatDateTime(currentSummary.end)}` : ''}` + : t('change_empty'); + const proposedSchedule = proposedSummary.start + ? `${formatDateTime(proposedSummary.start)}${proposedSummary.end ? ` - ${formatDateTime(proposedSummary.end)}` : ''}` + : t('change_empty'); + if (currentSchedule !== proposedSchedule && proposedSummary.start) { + changes.push({ + label: t('change_time'), + before: currentSchedule, + after: proposedSchedule, + }); + } + + if ((currentSummary.location ?? '') !== (proposedSummary.location ?? '') && proposedSummary.location) { + changes.push({ + label: t('change_location'), + before: currentSummary.location || t('change_empty'), + after: proposedSummary.location, + }); + } + + if ((currentEvent.description ?? '') !== (proposedEvent.description ?? '') && proposedEvent.description) { + changes.push({ + label: t('change_description'), + before: currentEvent.description || t('change_empty'), + after: proposedEvent.description, + }); + } + + return changes; +} + +function buildParticipantsForRsvp( + event: Partial, + participantId: string, + status: 'accepted' | 'tentative' | 'declined', +): Record[string]> | null { + if (!event.participants) { + return null; + } + + return Object.fromEntries( + Object.entries(event.participants).map(([id, participant]) => [ + id, + { + ...participant, + participationStatus: id === participantId ? status : participant.participationStatus, + }, + ]), + ); +} + +function getMethodAccentClass(method: InvitationMethod, actorStatus?: string | null): string { + switch (method) { + case 'cancel': + case 'declinecounter': + return 'border-l-red-500 dark:border-l-red-400'; + case 'request': + case 'add': + return 'border-l-blue-500 dark:border-l-blue-400'; + case 'counter': + return 'border-l-amber-500 dark:border-l-amber-400'; + case 'reply': + switch (actorStatus) { + case 'accepted': return 'border-l-green-500 dark:border-l-green-400'; + case 'tentative': return 'border-l-amber-500 dark:border-l-amber-400'; + case 'declined': return 'border-l-red-500 dark:border-l-red-400'; + default: return 'border-l-blue-500 dark:border-l-blue-400'; + } + default: + return 'border-l-slate-400 dark:border-l-slate-500'; + } +} + +function getMethodBadgeClass(method: InvitationMethod): string { + switch (method) { + case 'cancel': + case 'declinecounter': + return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'; + case 'request': + case 'add': + return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'; + case 'counter': + return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'; + case 'reply': + return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'; + case 'publish': + case 'refresh': + default: + return 'bg-muted text-muted-foreground'; + } +} + +function getMethodBadgeLabel(method: InvitationMethod): string | null { + switch (method) { + case 'publish': return 'PUBLISH'; + case 'request': return 'REQUEST'; + case 'reply': return 'REPLY'; + case 'add': return 'ADD'; + case 'cancel': return 'CANCEL'; + case 'refresh': return 'REFRESH'; + case 'counter': return 'COUNTER'; + case 'declinecounter': return 'DECLINE-COUNTER'; + default: return null; + } +} + interface CalendarInvitationBannerProps { email: Email; } @@ -37,27 +389,46 @@ type BannerState = 'loading' | 'parsed' | 'rsvp-sent' | 'imported' | 'error'; export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProps) { const t = useTranslations('email_viewer.calendar_invitation'); const format = useFormatter(); + const router = useRouter(); const client = useAuthStore((s) => s.client); const currentUserEmail = useAuthStore((s) => s.primaryIdentity?.email); - const { calendars, supportsCalendar, importEvents, rsvpEvent, events: storeEvents } = useCalendarStore(); + const calendarInvitationParsingEnabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled); + const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, events: storeEvents, setSelectedDate } = useCalendarStore(); const [state, setState] = useState('loading'); const [parsedEvent, setParsedEvent] = useState | null>(null); const [rsvpStatus, setRsvpStatus] = useState(null); + const [actionNotice, setActionNotice] = useState(null); + const [actionError, setActionError] = useState(null); const [isProcessing, setIsProcessing] = useState(false); const [showCalendarPicker, setShowCalendarPicker] = useState(false); const [selectedCalendarId, setSelectedCalendarId] = useState(''); + const [rawIcsMethod, setRawIcsMethod] = useState('unknown'); const attachment = findCalendarAttachment(email); const parseEvent = useCallback(async () => { - if (!client || !attachment) return; + if (!client || !attachment || !calendarInvitationParsingEnabled) return; setState('loading'); + setActionNotice(null); + setActionError(null); try { const events = await client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId); if (events.length > 0) { const parsed = events[0]; setParsedEvent(parsed); + + // JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST + // is lost. Fetch raw ICS to extract METHOD as a reliable fallback. + try { + const blob = await client.fetchBlob(attachment.blobId, 'invite.ics', 'text/calendar'); + const rawText = await blob.text(); + const icsMethod = extractMethodFromRawIcs(rawText); + if (icsMethod !== 'unknown') { + setRawIcsMethod(icsMethod); + } + } catch { /* ignore - fall back to heuristic detection */ } + if (parsed.uid && supportsCalendar) { const storeHasIt = useCalendarStore.getState().events.some((e) => e.uid === parsed.uid); if (!storeHasIt) { @@ -81,13 +452,13 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp } catch { setState('error'); } - }, [client, attachment, supportsCalendar]); + }, [calendarInvitationParsingEnabled, client, attachment, supportsCalendar]); useEffect(() => { - if (attachment) { + if (attachment && calendarInvitationParsingEnabled) { parseEvent(); } - }, [email.id]); // eslint-disable-line react-hooks/exhaustive-deps + }, [calendarInvitationParsingEnabled, email.id]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (calendars.length > 0 && !selectedCalendarId) { @@ -96,41 +467,156 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp } }, [calendars, selectedCalendarId]); - if (!attachment) return null; + if (!attachment || !calendarInvitationParsingEnabled) return null; - const method = parsedEvent ? getInvitationMethod(parsedEvent) : 'unknown'; + const detectedMethod = parsedEvent ? getInvitationMethod(parsedEvent, { email, attachment }) : 'unknown'; + const method = detectedMethod !== 'unknown' ? detectedMethod : rawIcsMethod; const summary = parsedEvent ? formatEventSummary(parsedEvent) : null; const isCancellation = method === 'cancel'; + const isResponseOnly = method === 'reply' || method === 'refresh' || method === 'counter' || method === 'declinecounter'; + const allowsRsvp = method === 'request'; + const allowsImport = method === 'request' || method === 'publish' || method === 'add' || method === 'unknown'; const existingEvent = parsedEvent?.uid ? storeEvents.find((e) => e.uid === parsedEvent.uid) : null; - const myParticipantParsed = parsedEvent && currentUserEmail - ? findParticipantByEmail(parsedEvent, currentUserEmail) - : null; - - const myParticipantServer = existingEvent && currentUserEmail + const currentUserParticipant = existingEvent && currentUserEmail ? findParticipantByEmail(existingEvent, currentUserEmail) : null; - const myParticipant = myParticipantServer || myParticipantParsed; + const fallbackParsedParticipant = !currentUserParticipant && parsedEvent && currentUserEmail + ? findParticipantByEmail(parsedEvent, currentUserEmail) + : null; + + const participantForUser = currentUserParticipant || fallbackParsedParticipant; + // Accept participant if they have attendee role OR if they are not exclusively an organizer + // Some JMAP servers may not set roles.attendee explicitly in CalendarEvent/parse results + const isOnlyOrganizer = participantForUser?.participant.roles + ? (participantForUser.participant.roles.owner || participantForUser.participant.roles.chair) + && !participantForUser.participant.roles.attendee + : false; + const myParticipant = participantForUser && !isOnlyOrganizer ? participantForUser : null; const currentRsvp = rsvpStatus - || myParticipantServer?.participant.participationStatus - || myParticipantParsed?.participant.participationStatus + || myParticipant?.participant.participationStatus || null; + const userIsOrganizer = Boolean(isOnlyOrganizer); + const bannerTitle = getBannerTitle(t, method); + const bannerInfo = getBannerInfo(t, method, userIsOrganizer, supportsCalendar); + const trustAssessment = parsedEvent ? getInvitationTrustAssessment(parsedEvent, email, method) : null; + const trustMessage = trustAssessment ? getTrustMessage(t, trustAssessment) : null; + const participationLabel = getParticipationLabel(t, currentRsvp); + const actorSummary = parsedEvent ? getInvitationActorSummary(parsedEvent, method) : null; + const actorName = actorSummary?.name || actorSummary?.email || t('actor_unknown'); + const actorStatus = getParticipationLabel(t, actorSummary?.participationStatus ?? null); + const actorMessage = actorSummary ? getActorMessage(t, method, actorName, actorStatus) : null; + const actionFeedback = actionNotice; + // For REQUEST method, allow RSVP even if we can't find the user in participants: + // the email was sent TO the user, so they are an attendee. handleRsvp handles + // the import-then-find-participant flow for this case. + const canRespond = supportsCalendar && allowsRsvp && !isResponseOnly && !userIsOrganizer + && (Boolean(myParticipant) || method === 'request'); + const proposalPatch = existingEvent && parsedEvent ? buildProposalPatch(existingEvent, parsedEvent) : null; + + const resolveExistingEventForRsvp = async () => { + if (!client || !existingEvent) { + return existingEvent; + } + + if (existingEvent.participants && Object.keys(existingEvent.participants).length > 0) { + return existingEvent; + } + + const hydratedEvent = await client.getCalendarEvent(existingEvent.id); + if (!hydratedEvent) { + return existingEvent; + } + + useCalendarStore.setState((state) => ({ + events: state.events.map((event) => event.id === hydratedEvent.id ? hydratedEvent : event), + })); + + return hydratedEvent; + }; const handleRsvp = async (status: 'accepted' | 'tentative' | 'declined') => { if (!client || !parsedEvent || isProcessing) return; const calId = selectedCalendarId || calendars.find((c) => c.isDefault)?.id || calendars[0]?.id; + setActionNotice(null); + setActionError(null); setIsProcessing(true); + const replyToForRsvp = parsedEvent.replyTo + || (parsedEvent.organizerCalendarAddress ? { imip: parsedEvent.organizerCalendarAddress } : null); + + const imipStatus = status.toUpperCase() as 'ACCEPTED' | 'TENTATIVE' | 'DECLINED'; + const organizerEmail = parsedEvent?.replyTo?.imip?.replace('mailto:', '') + || parsedEvent?.organizerCalendarAddress?.replace('mailto:', '') + || summary?.organizerEmail + || null; + + // Send the iMIP REPLY email to the organizer (client-side scheduling). + // Called after updating the local calendar event. Best-effort — if it + // fails we still report the RSVP as sent since the calendar was updated. + const sendImipReply = async () => { + if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) { + return; + } + + try { + await client.sendImipReply({ + organizerEmail, + organizerName: summary?.organizer || undefined, + attendeeEmail: currentUserEmail, + attendeeName: myParticipant?.participant.name || undefined, + uid: parsedEvent.uid, + summary: parsedEvent.title, + dtStart: parsedEvent.start || undefined, + dtEnd: summary?.end || undefined, + timeZone: parsedEvent.timeZone || undefined, + sequence: parsedEvent.sequence, + status: imipStatus, + }); + } catch { + // Best-effort: don't block the RSVP success notification + } + }; try { - if (existingEvent && myParticipant) { - await rsvpEvent(client, existingEvent.id, myParticipant.id, status); + const eventForRsvp = existingEvent + ? await resolveExistingEventForRsvp() + : null; + const existingEventParticipant = eventForRsvp && currentUserEmail + ? findParticipantByEmail(eventForRsvp, currentUserEmail) + : null; + const canFallbackToParsedParticipant = Boolean( + existingEvent + && myParticipant + && (!eventForRsvp?.participants || Object.keys(eventForRsvp.participants).length === 0) + ); + if (eventForRsvp && existingEventParticipant) { + await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp); + await sendImipReply(); setRsvpStatus(status); - setState('rsvp-sent'); + setActionNotice(t('rsvp_sent')); + setState('parsed'); + } else if (eventForRsvp && canFallbackToParsedParticipant && myParticipant) { + const repairedParticipants = buildParticipantsForRsvp(parsedEvent, myParticipant.id, status); + + if (!repairedParticipants) { + setActionError(t('action_failed')); + } else { + await updateEvent(client, eventForRsvp.id, { + participants: repairedParticipants, + replyTo: replyToForRsvp ?? undefined, + }, true); + await sendImipReply(); + setRsvpStatus(status); + setActionNotice(t('rsvp_sent')); + setState('parsed'); + } + } else if (eventForRsvp) { + setActionError(t('action_failed')); } else if (calId) { const imported = await importEvents(client, [parsedEvent], calId); if (imported > 0) { @@ -140,39 +626,64 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const participant = myParticipant || (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null); if (newEvent && participant) { - await rsvpEvent(client, newEvent.id, participant.id, status); + await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp); + await sendImipReply(); + setRsvpStatus(status); + setActionNotice(t('rsvp_sent')); + } else { + setActionNotice(t('added')); } - setRsvpStatus(status); - setState('rsvp-sent'); + setState('parsed'); } else { - setState('error'); + setActionError(t('action_failed')); } } else { - setState('error'); + setActionError(t('action_failed')); } - } catch { - setState('error'); + } catch (err) { + console.error('[CalendarInvitation] RSVP failed:', err); + setActionError(t('action_failed')); } finally { setIsProcessing(false); } }; + const handleViewInCalendar = () => { + const targetDate = existingEvent?.utcStart + || existingEvent?.start + || parsedEvent?.utcStart + || parsedEvent?.start; + + if (targetDate) { + const parsedDate = new Date(targetDate); + if (!Number.isNaN(parsedDate.getTime())) { + setSelectedDate(parsedDate); + } + } + + setShowCalendarPicker(false); + router.push('/calendar'); + }; + const handleImport = async (calendarId?: string) => { const calId = calendarId || selectedCalendarId || calendars.find((c) => c.isDefault)?.id || calendars[0]?.id; if (!client || !parsedEvent || !calId || isProcessing) { - if (!calId) setState('error'); + if (!calId) setActionError(t('action_failed')); return; } + setActionNotice(null); + setActionError(null); setIsProcessing(true); try { const count = await importEvents(client, [parsedEvent], calId); if (count > 0) { - setState('imported'); + setActionNotice(t('added')); + setState('parsed'); } else { - setState('error'); + setActionError(t('action_failed')); } } catch { - setState('error'); + setActionError(t('action_failed')); } finally { setIsProcessing(false); } @@ -191,11 +702,48 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp }); }; + const proposedChanges = method === 'counter' && existingEvent && parsedEvent + ? buildInvitationChangeItems(t, existingEvent, parsedEvent, formatDateTime) + : []; + const canApplyProposal = Boolean( + client + && existingEvent?.id + && userIsOrganizer + && method === 'counter' + && proposalPatch + ); + const viewActionLabel = getViewActionLabel(t, method, userIsOrganizer); + + const handleApplyProposal = async () => { + if (!client || !existingEvent?.id || !proposalPatch || isProcessing) { + return; + } + + setActionNotice(null); + setActionError(null); + setIsProcessing(true); + + try { + await updateEvent(client, existingEvent.id, proposalPatch, true); + setActionNotice(t('proposal_applied')); + setState('parsed'); + setRsvpStatus(null); + } catch { + setActionError(t('action_failed')); + } finally { + setIsProcessing(false); + } + }; + + const methodBadgeLabel = getMethodBadgeLabel(method); + const accentClass = getMethodAccentClass(method, actorSummary?.participationStatus); + const badgeClass = getMethodBadgeClass(method); + if (state === 'loading') { return ( -
- - +
+ + {t('loading')}
); @@ -203,187 +751,299 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp if (state === 'error') { return ( -
- +
+ {t('parse_error')}
); } - if (state === 'imported') { - return ( -
- - {t('added')} -
- ); - } - - if (state === 'rsvp-sent') { - return ( -
- - {t('rsvp_sent')} -
- ); - } - return ( -
- {/* Event info row */} -
- {isCancellation ? ( - - ) : ( - +
+ {/* Header */} +
+
+ {isCancellation ? ( + + ) : ( + + )} + {bannerTitle} +
+ {methodBadgeLabel && ( + + iTIP: {methodBadgeLabel} + )} +
-
-
- + {/* Event title */} + {summary?.title && ( +
+

- {isCancellation ? t('cancelled_title') : t('title')} - {summary?.title && `: ${summary.title}`} - + {summary.title} +

+ {parsedEvent?.sequence != null && parsedEvent.sequence > 0 && ( + + {t('event_updated', { sequence: parsedEvent.sequence })} + + )}
+ )} -
- {summary?.start && ( - - + {/* Event details */} +
+ {summary?.start && ( +
+ + {formatDateTime(summary.start)} {summary.end && ` – ${formatDateTime(summary.end)}`} - )} - {summary?.location && ( - - - {summary.location} - - )} +
+ )} + {summary?.location && ( +
+ + {summary.location} +
+ )} +
{summary?.organizer && ( - {t('organizer', { name: summary.organizer })} + + + {t('organizer', { name: summary.organizer })} + )} {summary && summary.attendeeCount > 0 && ( - - - {t('attendees', { count: summary.attendeeCount })} - + {t('attendees', { count: summary.attendeeCount })} )}
+ + {/* Status badges */} + {(existingEvent || userIsOrganizer || (participationLabel && myParticipant) || actionFeedback || (parsedEvent?.status && parsedEvent.status !== 'confirmed')) && ( +
+ {parsedEvent?.status && parsedEvent.status !== 'confirmed' && ( + + {t(`event_status_${parsedEvent.status}`)} + + )} + {existingEvent && ( + + {t('already_in_calendar')} + + )} + {userIsOrganizer && ( + + {t('organizer_role')} + + )} + {participationLabel && myParticipant && ( + + {t('your_response', { status: participationLabel })} + + )} + {actionFeedback && ( + + {actionFeedback} + + )} +
+ )} + + {/* Info text */} + {bannerInfo && ( +

{bannerInfo}

+ )} + + {/* Actor message */} + {actorMessage && ( +

{actorMessage}

+ )} + + {/* Actor comment */} + {actorSummary?.participationComment && ( +

+ {t('actor_note', { comment: actorSummary.participationComment })} +

+ )} + + {/* Proposed changes */} + {proposedChanges.length > 0 && ( +
+
{t('proposed_changes')}
+
+ {proposedChanges.map((change) => ( +
+ {change.label}: + {t('change_from_to', { before: change.before, after: change.after })} +
+ ))} +
+
+ )} + + {/* Trust warning */} + {trustMessage && trustAssessment && ( +
+ + {trustMessage} +
+ )} + + {/* Action error */} + {actionError && ( +
+ + {actionError} +
+ )}
- {/* Action row */} - {!isCancellation && ( -
- {supportsCalendar && myParticipant && ( - <> - - - - -
- - )} - - {supportsCalendar && existingEvent && !myParticipant && ( - - - {t('already_in_calendar')} - - )} - - {supportsCalendar && !existingEvent && ( -
- - - {showCalendarPicker && calendars.length > 1 && ( -
-
- {t('select_calendar')} -
- {calendars.map((cal) => ( - - ))} -
+ {/* Actions */} +
+ {canRespond && ( + <> +
- )} + > + + {t('accept')} + + + - {!supportsCalendar && ( - {t('no_calendar')} - )} -
- )} +
+ + )} + + {supportsCalendar && !existingEvent && allowsImport && !isResponseOnly && !isCancellation && ( +
+ + + {showCalendarPicker && calendars.length > 1 && ( +
+
+ {t('select_calendar')} +
+ {calendars.map((cal) => ( + + ))} +
+ )} +
+ )} + + {canApplyProposal && ( + + )} + + {supportsCalendar && (existingEvent || parsedEvent) && ( + + )} + + {!supportsCalendar && ( + {t('no_calendar')} + )} + + {isProcessing && ( + + )} +
); } diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 8605fdd2..e7d9511a 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -33,6 +33,7 @@ import { FileAudio, FileArchive, File, + Eye, Shield, Image, Tag, @@ -76,6 +77,7 @@ import { UnsubscribeBanner } from "./unsubscribe-banner"; import { CalendarInvitationBanner } from "./calendar-invitation-banner"; import { findCalendarAttachment } from "@/lib/calendar-invitation"; import { RecipientPopover } from "./recipient-popover"; +import { isFilePreviewable } from "@/lib/file-preview"; interface EmailViewerProps { email: Email | null; @@ -483,12 +485,15 @@ export function EmailViewer({ const t = useTranslations('email_viewer'); const tNotifications = useTranslations('notifications'); const tCommon = useTranslations('common'); + const tFiles = useTranslations('files'); const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); + const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction); const addTrustedSender = useSettingsStore((state) => state.addTrustedSender); const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); const emailKeywords = useSettingsStore((state) => state.emailKeywords); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); + const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled); // Detect if current mailbox is Junk folder const isInJunkFolder = currentMailboxRole === 'junk'; @@ -1139,7 +1144,9 @@ export function EmailViewer({ listHeaders?.listUnsubscribe?.preferred && !dismissedUnsubBanners.has(email?.messageId || ''); - const hasCalendarInvitation = email ? !!findCalendarAttachment(email) : false; + const hasCalendarInvitation = email + ? calendarInvitationParsingEnabled && !!findCalendarAttachment(email) + : false; // Show loading skeleton while email is being fetched if (isLoading && !email) { @@ -2798,11 +2805,13 @@ export function EmailViewer({
{email.attachments.map((attachment, i) => { const FileIcon = getFileIcon(attachment.name, attachment.type); + const isPreviewable = isFilePreviewable(attachment.name, attachment.type); + const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; return (
- + {opensPreview ? ( + + ) : ( + + )} ); })} @@ -2931,7 +2944,7 @@ export function EmailViewer({ {/* Calendar Invitation Banner */} {hasCalendarInvitation && ( -
+
)} diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 11248e0a..d534eac7 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -26,10 +26,12 @@ import { FileAudio, FileArchive, File, + Eye, } from "lucide-react"; import { useTranslations } from "next-intl"; import { useSettingsStore } from "@/stores/settings-store"; import { useAuthStore } from "@/stores/auth-store"; +import { isFilePreviewable } from "@/lib/file-preview"; interface ThreadConversationViewProps { thread: ThreadGroup; @@ -222,6 +224,7 @@ function EmailCard({ const t = useTranslations(); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const density = useSettingsStore((state) => state.density); + const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction); const sender = email.from?.[0]; const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; @@ -526,6 +529,8 @@ function EmailCard({
{email.attachments.map((attachment, idx) => { const Icon = getFileIcon(attachment.name, attachment.type); + const isPreviewable = isFilePreviewable(attachment.name, attachment.type); + const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; return ( ); })} diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx index c021804d..2ef4d7d3 100644 --- a/components/files/file-preview-modal.tsx +++ b/components/files/file-preview-modal.tsx @@ -4,32 +4,13 @@ import { useState, useEffect } from "react"; import { useTranslations } from "next-intl"; import { X, Download, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { getFilePreviewKind } from "@/lib/file-preview"; interface FilePreviewModalProps { name: string; onClose: () => void; - onDownload: (name: string) => Promise; - getFileContent: (name: string) => Promise<{ blob: Blob; contentType: string }>; -} - -const TEXT_EXTENSIONS = new Set([ - "txt", "md", "markdown", "json", "xml", "html", "htm", "css", "js", "ts", - "jsx", "tsx", "py", "rb", "java", "c", "cpp", "h", "hpp", "go", "rs", - "sh", "bash", "zsh", "yaml", "yml", "toml", "ini", "cfg", "conf", "env", - "log", "csv", "sql", "graphql", "vue", "svelte", "astro", "php", "pl", - "swift", "kt", "scala", "r", "lua", "vim", -]); - -function getFileType(name: string): "text" | "pdf" | "audio" | "video" | "markdown" | "unknown" { - const ext = name.split(".").pop()?.toLowerCase() || ""; - const baseName = name.toLowerCase(); - - if (ext === "md" || ext === "markdown") return "markdown"; - if (ext === "pdf") return "pdf"; - if (["mp3", "wav", "ogg", "flac", "aac", "m4a", "wma", "opus"].includes(ext)) return "audio"; - if (["mp4", "webm", "ogv", "mov", "avi", "mkv", "m4v"].includes(ext)) return "video"; - if (TEXT_EXTENSIONS.has(ext) || ["dockerfile", "makefile", "readme", "license", "changelog"].includes(baseName)) return "text"; - return "unknown"; + onDownload: () => Promise | void; + getFileContent: () => Promise<{ blob: Blob; contentType: string }>; } function SimpleMarkdown({ content }: { content: string }) { @@ -129,24 +110,35 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }: const [objectUrl, setObjectUrl] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); + const [resolvedFileType, setResolvedFileType] = useState(() => getFilePreviewKind(name)); - const fileType = getFileType(name); + const fileType = resolvedFileType; useEffect(() => { let cancelled = false; + let revokeUrl: string | null = null; + + setContent(null); + setObjectUrl(null); + setLoading(true); + setError(false); + setResolvedFileType(getFilePreviewKind(name)); async function load() { try { - const { blob, contentType } = await getFileContent(name); + const { blob, contentType } = await getFileContent(); if (cancelled) return; - if (fileType === "text" || fileType === "markdown") { + const previewType = getFilePreviewKind(name, contentType || blob.type); + setResolvedFileType(previewType); + + if (previewType === "text" || previewType === "markdown") { const text = await blob.text(); if (!cancelled) setContent(text); } else { - const url = URL.createObjectURL(blob); - if (!cancelled) setObjectUrl(url); + revokeUrl = URL.createObjectURL(blob); + if (!cancelled) setObjectUrl(revokeUrl); } } catch { if (!cancelled) setError(true); @@ -159,9 +151,11 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }: return () => { cancelled = true; - if (objectUrl) URL.revokeObjectURL(objectUrl); + if (revokeUrl) { + URL.revokeObjectURL(revokeUrl); + } }; - }, [name]); + }, [getFileContent, name]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -176,7 +170,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
e.stopPropagation()}>

{name}

-
)} + {!loading && !error && fileType === "image" && objectUrl && ( + {name} + )} + + {!loading && !error && fileType === "html" && objectUrl && ( +