diff --git a/docs/parity/05-calendar.md b/docs/parity/05-calendar.md index a7f5d0a7..08ef404f 100644 --- a/docs/parity/05-calendar.md +++ b/docs/parity/05-calendar.md @@ -132,12 +132,12 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi ### Calendars -- [ ] **No calendar management UI: create, rename, colour, description, delete, clear events, copy CalDAV URL** — `P2` — `missing` +- [x] **No calendar management UI: create, rename, colour, description, delete, clear events, copy CalDAV URL** — `P2` — `missing` — fixed in dae5700 (no CalDAV URL copy: RN has no DAV base URL) - What WEB does: sidebar context menu + settings section with create (with kind picker), edit name/colour/description, clear all events (unlink-aware), delete, share, copy URL (ref `components/calendar/calendar-sidebar-panel.tsx:328-395`, `components/settings/calendar-management-settings.tsx`, `stores/calendar-store.ts:1028-1206`). - What RN does: `createCalendar` exists in the store but is only reachable through the subscription sheet (`src/stores/calendar-store.ts:378-382`, `src/stores/calendar-subscriptions-store.ts:82-84`); there is no `updateCalendar`/`removeCalendar`/`clearCalendarEvents` API or UI (`src/api/calendar.ts` has `deleteCalendar` at 417 but nothing calls it for own calendars). Long-press only offers set-default and shared-calendar recolour (`src/components/calendar/CalendarSidebarDrawer.tsx:208-211`). - Fix hint: add `Calendar/set` update + destroy (`onDestroyRemoveEvents`) to `src/api/calendar.ts`, expose in the store, and add "New calendar" + long-press "Rename / Colour / Delete / Clear" in the drawer. Deleting must be blocked for `isDefault` and shared calendars like WEB. -- [ ] **Own-calendar colour cannot be changed (only shared calendars are recolourable)** — `P3` — `partial` +- [x] **Own-calendar colour cannot be changed (only shared calendars are recolourable)** — `P3` — `partial` — fixed in dae5700 - What WEB does: colour picker writes `Calendar/set { color }` for own calendars and a local override for shared ones (ref `calendar-sidebar-panel.tsx:330, 366`, `lib/shared-calendar-colors.ts:33-40`). - What RN does: `canRecolor = !!onSetColor && !!cal.isShared` (`CalendarSidebarDrawer.tsx:209`). - Fix hint: for `!cal.isShared` call the new `updateCalendar(id, { color })`. @@ -167,7 +167,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi - What RN does: `getCalendars` and `fetchEvents` try every calendar-capable session account each time and swallow errors (`src/api/calendar.ts:140-186`, `src/stores/calendar-store.ts:194-204`). - Fix hint: keep a module-level `Set` of denied account ids, reset on reconnect. -- [ ] **Calendar sharing (JMAP `shareWith`) not available for calendars** — `P2` — `missing` +- [x] **Calendar sharing (JMAP `shareWith`) not available for calendars** — `P2` — `missing` — fixed in dae5700 - What WEB does: `shareCalendar` via `Calendar/set shareWith/` with a principal picker and share indicators (ref `stores/calendar-store.ts:1078-1100`, `lib/jmap/client.ts:4921`, changelog 1.6.x "JMAP sharing for calendars and address books", #244, #257). - What RN does: sharing exists only for Files (`src/api/files.ts:229-241`, `src/components/files/ShareSheet.tsx`); nothing for calendars (also likely nothing for address books — that belongs to the contacts audit). - Fix hint: reuse `ShareSheet` + the principal picker with a `Calendar/set` `shareWith/{principalId}` patch; request `shareWith`/`myRights` in `Calendar/get` properties. @@ -236,12 +236,12 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi - What RN does: `importEvents` skips existing UIDs and batch-creates `{ ...parsed, calendarIds }` untouched (`src/stores/calendar-store.ts:352-376`, `src/api/calendar.ts:278-297`). Whether Stalwart rejects computed properties on create depends on the parse output; WEB chose to whitelist after hitting failures ("Deduplicate UIDs during iCal import to prevent mass failures"). - Fix hint: port WEB's `prepared` mapping (`stores/calendar-store.ts:885-957`) and the link-by-`calendarIds` branch. -- [ ] **iCal subscriptions: no per-account scoping, no edit, no auto-refresh/interval, no rollback, `webcals://`, basic-auth URLs** — `P2` — `partial` +- [x] **iCal subscriptions: no per-account scoping, no edit, no auto-refresh/interval, no rollback, `webcals://`, basic-auth URLs** — `P2` — `partial` — fixed in dae5700 - What WEB does: subscriptions carry `accountId` and are skipped for other accounts (`stores/calendar-store.ts:1360-1363, 1468-1472`), editable name/colour/url/interval (`:1294-1329`), refreshed every 5 min per interval (`components/calendar/calendar-app.tsx:320-324`), rolled back when the first fetch fails (`:1274-1289`), `webcals?://` normalised (`:1240`), basic auth in the URL supported server-side (#275), stale events unlinked rather than deleted when also in another calendar (`:1401-1427`). - What RN does: `calendar-subscriptions-store` persists `{ id, name, url, color, calendarId }` globally (`src/stores/calendar-subscriptions-store.ts:16-24, 162-166`) — after switching accounts the list still shows and `syncSubscription` imports into a calendar id of the other account (or a colliding raw id); `syncAll` is never called (`:155-160`); no edit UI (`ICalSubscriptionSheet.tsx`); a failed first sync leaves the empty calendar + sub with `lastError` (`:82-116`); only `webcal://` is rewritten (`:38-40`); `fetch('https://user:pass@host')` throws in RN's WHATWG fetch so #275 URLs fail; stale events are deleted outright (`:58-74`). - Fix hint: add `accountId: jmapClient.accountId` to each sub and filter by it; call `syncAll` on calendar mount + `AppState` foreground with a per-sub interval; add an edit sheet; on first-sync failure delete the calendar; regex `/^webcals?:\/\//i`; parse credentials out of the URL into an `Authorization` header. -- [ ] **Subscription feeds are fetched on-device (security note, not a gap)** — `P3` — `partial` +- [x] **Subscription feeds are fetched on-device (security note, not a gap)** — `P3` — `partial` — fixed in dae5700 (10 MB cap) - What WEB does: `/api/fetch-ical` server-side with an SSRF guard, DNS pinning and redirect validation (GHSA-24w9, changelog 1.9.2). - What RN does: `fetch(url)` from the phone (`src/stores/calendar-subscriptions-store.ts:42-53`). No server is exposed, so the SSRF class does not apply; the device can be pointed at LAN hosts, which is the user's own network. Redirects are followed by `fetch` without validation (fine). N/A for the advisory; worth a 10 MB size cap and a `text/calendar`-ish content check (already checks `BEGIN:VCALENDAR`). - Fix hint: optional size cap. diff --git a/src/api/calendar.ts b/src/api/calendar.ts index f279305b..bb7db1e5 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -467,8 +467,13 @@ export async function rsvpEvent( } // Parse an uploaded .ics blob into one or more JSCalendar events (server-side). -export async function parseCalendarBlob(blobId: string): Promise[]> { - const accountId = jmapClient.accountId; +// `targetAccountId` routes the parse to the account that owns the blob (an +// email in a shared mailbox lives in the sharer's account). +export async function parseCalendarBlob( + blobId: string, + targetAccountId?: string, +): Promise[]> { + const accountId = targetAccountId || jmapClient.accountId; const res = await jmapClient.request( [['CalendarEvent/parse', { accountId, blobIds: [blobId] }, '0']], USING, @@ -486,6 +491,31 @@ export async function parseCalendarBlob(blobId: string): Promise { + try { + await jmapClient.ensureFreshToken(); + // Lazy: blob/client-cert pull in native Expo modules, which the pure + // JMAP layer (and its node tests) must not load eagerly. + const [{ getDownloadUrl }, { secureFetch }] = await Promise.all([ + import('./blob'), + import('../lib/client-cert'), + ]); + const url = getDownloadUrl(blobId, 'invite.ics', 'text/calendar', targetAccountId); + const res = await secureFetch(url, { headers: { Authorization: jmapClient.authHeader } }); + if (!res.ok) return null; + const text = await res.text(); + return text.length > 2 * 1024 * 1024 ? null : text; + } catch { + return null; + } +} + export async function createCalendar( name: string, color?: string, diff --git a/src/components/email/CalendarInvitationBanner.tsx b/src/components/email/CalendarInvitationBanner.tsx index 2bd2722a..e8b0f030 100644 --- a/src/components/email/CalendarInvitationBanner.tsx +++ b/src/components/email/CalendarInvitationBanner.tsx @@ -2,24 +2,29 @@ import React from 'react'; import { View, Text, StyleSheet, Pressable, ActivityIndicator, Linking } from 'react-native'; import { CalendarPlus, Check, HelpCircle, X, MapPin, Video, Clock, CalendarDays, AlertTriangle, + ShieldCheck, ShieldAlert, ChevronDown, } from 'lucide-react-native'; import { format, parseISO } from 'date-fns'; -import type { Email, CalendarEvent } from '../../api/types'; +import type { Calendar, Email, CalendarEvent } from '../../api/types'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { parseCalendarBlob } from '../../api/calendar'; +import { fetchCalendarBlobText, parseCalendarBlob } from '../../api/calendar'; import { useCalendarStore } from '../../stores/calendar-store'; import { useSettingsStore } from '../../stores/settings-store'; +import { useLocaleStore } from '../../stores/locale-store'; import { findCalendarAttachment, findParticipantByEmail, + getInvitationMethod, + getInvitationTrustAssessment, getOrganizerName, buildReplyTo, - inferInvitationMethod, type InvitationMethod, + type InvitationTrustAssessment, } from '../../lib/calendar-invitation'; import { useUserCalendarAddresses } from '../../lib/calendar-user-addresses'; import { canCreateEventsIn } from '../../lib/calendar-editability'; +import { getCalendarColor, timePattern } from '../../lib/calendar-utils'; import { useCalendarSubscriptionsStore } from '../../stores/calendar-subscriptions-store'; type BannerState = 'loading' | 'parsed' | 'done' | 'error'; @@ -27,13 +32,38 @@ type RsvpStatus = 'accepted' | 'tentative' | 'declined'; interface Props { email: Email; + // Account the email lives in (a shared mailbox's owner); the .ics blob is + // parsed against it. Undefined for the user's own mailboxes. + jmapAccountId?: string; } -export function CalendarInvitationBanner({ email }: Props) { +const TRUST_REASON_KEYS: Record, [string, string]> = { + sender_mismatch_unverified: [ + 'calendar.invitation.trust_sender_mismatch_unverified', + 'The sender does not match the organizer and the message is not authenticated.', + ], + authentication_failed: [ + 'calendar.invitation.trust_authentication_failed', + 'This message failed sender authentication (SPF/DKIM/DMARC).', + ], + sender_mismatch: [ + 'calendar.invitation.trust_sender_mismatch', + 'The sender differs from the event organizer.', + ], + authentication_missing: [ + 'calendar.invitation.trust_authentication_missing', + 'The sender could not be verified.', + ], +}; + +export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); const enabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled); + const timeFormat = useSettingsStore((s) => s.calendarTimeFormat); const calendars = useCalendarStore((s) => s.calendars); + const storeEvents = useCalendarStore((s) => s.events); const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions); const importEvents = useCalendarStore((s) => s.importEvents); const rsvpEvent = useCalendarStore((s) => s.rsvpEvent); @@ -49,6 +79,8 @@ export function CalendarInvitationBanner({ email }: Props) { const [rsvpStatus, setRsvpStatus] = React.useState(null); const [notice, setNotice] = React.useState(null); const [busy, setBusy] = React.useState(false); + const [calendarId, setCalendarId] = React.useState(null); + const [pickerOpen, setPickerOpen] = React.useState(false); React.useEffect(() => { let cancelled = false; @@ -56,7 +88,7 @@ export function CalendarInvitationBanner({ email }: Props) { setState('loading'); (async () => { try { - const events = await parseCalendarBlob(attachment.blobId); + const events = await parseCalendarBlob(attachment.blobId, jmapAccountId); if (cancelled) return; if (events.length === 0) { setState('error'); @@ -64,14 +96,45 @@ export function CalendarInvitationBanner({ email }: Props) { } const parsed = events[0]; setEvent(parsed); - setMethod(inferInvitationMethod(parsed)); + // Explicit method (Content-Type params) first; JMAP usually strips + // them, so fall back to the raw ICS METHOD line before guessing. + let detected = getInvitationMethod(parsed, { email, attachment }); + if (detected === 'unknown') { + const raw = await fetchCalendarBlobText(attachment.blobId, jmapAccountId); + if (cancelled) return; + detected = getInvitationMethod(parsed, { email, attachment, rawIcs: raw }); + } + setMethod(detected); setState('parsed'); } catch { if (!cancelled) setState('error'); } })(); return () => { cancelled = true; }; - }, [attachment, enabled]); + }, [attachment, enabled, email, jmapAccountId]); + + // Import into the account's default calendar; never into a shared calendar, + // an iCal subscription (the next feed sync would delete the event) or a + // read-only one. The user can still pick another writable calendar. + const candidates = React.useMemo(() => { + const isSubscriptionCalendar = (id: string) => subscriptions.some((s) => s.calendarId === id); + return calendars.filter((cal) => !cal.isShared && canCreateEventsIn(cal, isSubscriptionCalendar)); + }, [calendars, subscriptions]); + const targetCalendar: Calendar | undefined = + candidates.find((cal) => cal.id === calendarId) + ?? candidates.find((cal) => cal.isDefault) + ?? candidates[0]; + + // Already imported? Look for the UID among the loaded events. + const existing = React.useMemo(() => { + if (!event?.uid) return null; + return storeEvents.find((e) => e.uid === event.uid) ?? null; + }, [storeEvents, event?.uid]); + + const trust = React.useMemo( + () => (event ? getInvitationTrustAssessment(event, email, method) : null), + [event, email, method], + ); if (!attachment || !enabled || state === 'error') return null; @@ -79,7 +142,7 @@ export function CalendarInvitationBanner({ email }: Props) { return ( - Reading invitation… + {t('calendar.invitation.reading', 'Reading invitation…')} ); } @@ -90,31 +153,27 @@ export function CalendarInvitationBanner({ email }: Props) { const dateLabel = startDate && !isNaN(startDate.getTime()) ? (event.showWithoutTime ? format(startDate, 'EEEE, MMM d, yyyy') - : format(startDate, 'EEE, MMM d · HH:mm')) + : format(startDate, `EEE, MMM d · ${timePattern(timeFormat)}`)) : null; const organizer = getOrganizerName(event); const location = event.locations ? Object.values(event.locations)[0]?.name : undefined; const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined; - const me = findParticipantByEmail(event, currentUserEmails); + const me = findParticipantByEmail(existing ?? event, currentUserEmails); const canRsvp = method !== 'cancel' && method !== 'reply' && !!me; - - // Import into the account's default calendar; never into a shared calendar, - // an iCal subscription (the next feed sync would delete the event) or a - // read-only one. Fall back to the first calendar events may be created in. - const isSubscriptionCalendar = (id: string) => - subscriptions.some((s) => s.calendarId === id); - const candidates = calendars.filter( - (cal) => !cal.isShared && canCreateEventsIn(cal, isSubscriptionCalendar), - ); - const writableCalendar = candidates.find((cal) => cal.isDefault) ?? candidates[0]; + const myExistingStatus = existing && me ? existing.participants?.[me.id]?.participationStatus : undefined; + const currentStatus: RsvpStatus | null = + rsvpStatus + ?? (myExistingStatus === 'accepted' || myExistingStatus === 'tentative' || myExistingStatus === 'declined' + ? myExistingStatus + : null); const ensureImportedAndRsvp = async (status: RsvpStatus) => { - if (busy || !writableCalendar) return; + if (busy || !targetCalendar) return; setBusy(true); setNotice(null); try { // Make sure the event exists in a local calendar (dedupes by UID). - await importEvents([event], writableCalendar.id); + if (!existing) await importEvents([event], targetCalendar.id); // Re-read from the store to get the server-assigned id + participant. const stored = useCalendarStore.getState().events.find((e) => e.uid === event.uid); const participant = stored @@ -123,45 +182,73 @@ export function CalendarInvitationBanner({ email }: Props) { if (stored && participant) { await rsvpEvent(stored.id, participant.id, status, buildReplyTo(event)); setRsvpStatus(status); - setNotice('Response sent'); + setNotice(t('calendar.invitation.response_sent', 'Response sent')); } else { - setNotice('Added to calendar'); + setNotice(t('calendar.invitation.added', 'Added to calendar')); } setState('done'); } catch { - setNotice('Could not send your response'); + setNotice(t('calendar.invitation.response_error', 'Could not send your response')); } finally { setBusy(false); } }; const handleImport = async () => { - if (busy || !writableCalendar) return; + if (busy || !targetCalendar) return; setBusy(true); setNotice(null); try { - const count = await importEvents([event], writableCalendar.id); - setNotice(count > 0 ? 'Added to calendar' : 'Already in your calendar'); + const count = await importEvents([event], targetCalendar.id); + setNotice( + count > 0 + ? t('calendar.invitation.added', 'Added to calendar') + : t('calendar.invitation.already_in_calendar', 'Already in your calendar'), + ); setState('done'); } catch { - setNotice('Could not add the event'); + setNotice(t('calendar.invitation.add_error', 'Could not add the event')); } finally { setBusy(false); } }; + const trustColor = trust?.level === 'warning' ? c.error : trust?.level === 'caution' ? c.warning : c.success; + return ( - + - {event.title || 'Calendar invitation'} - {method === 'cancel' && This event was cancelled} + + {event.title || t('calendar.invitation.title', 'Calendar invitation')} + + {method === 'cancel' && ( + {t('calendar.invitation.cancelled', 'This event was cancelled')} + )} + {method === 'reply' && ( + {t('calendar.invitation.reply', 'A participant replied to this invitation')} + )} + {trust && ( + + {trust.level === 'trusted' ? ( + + ) : ( + + )} + + {trust.reason + ? t(TRUST_REASON_KEYS[trust.reason][0], TRUST_REASON_KEYS[trust.reason][1]) + : t('calendar.invitation.trust_verified', 'Sender verified')} + + + )} + {dateLabel && ( } text={dateLabel} styles={styles} /> )} @@ -171,12 +258,49 @@ export function CalendarInvitationBanner({ email }: Props) { {videoUri ? ( { void Linking.openURL(videoUri); }}> ) : null} {organizer ? ( - } text={`Organizer: ${organizer}`} styles={styles} /> + } + text={`${t('calendar.invitation.organizer', 'Organizer')}: ${organizer}`} + styles={styles} + /> ) : null} + {existing && state !== 'done' && ( + } + text={t('calendar.invitation.already_in_calendar', 'Already in your calendar')} + styles={styles} + /> + )} + + {state !== 'done' && !existing && targetCalendar && candidates.length > 1 && ( + + setPickerOpen((v) => !v)}> + + {targetCalendar.name} + + + {pickerOpen && ( + + {candidates.map((cal) => ( + { setCalendarId(cal.id); setPickerOpen(false); }} + > + + {cal.name} + + ))} + + )} + + )} {notice && {notice}} @@ -184,37 +308,37 @@ export function CalendarInvitationBanner({ email }: Props) { {canRsvp ? ( <> - } + } disabled={busy} onPress={() => ensureImportedAndRsvp('accepted')} c={c} styles={styles} /> - } + } disabled={busy} onPress={() => ensureImportedAndRsvp('tentative')} c={c} styles={styles} /> - } + } disabled={busy} onPress={() => ensureImportedAndRsvp('declined')} c={c} styles={styles} /> - ) : ( + ) : !existing && method !== 'reply' ? ( { void handleImport(); }} - disabled={busy} + disabled={busy || !targetCalendar} > {busy ? ( ) : ( )} - Add to calendar + {t('calendar.invitation.add_to_calendar', 'Add to calendar')} - )} + ) : null} )} - {!writableCalendar && state !== 'done' && ( + {!targetCalendar && state !== 'done' && ( - No writable calendar available + {t('calendar.invitation.no_writable_calendar', 'No writable calendar available')} )} @@ -276,6 +400,7 @@ function makeStyles(c: ThemePalette) { backgroundColor: c.surface, gap: spacing.xs, }, + bannerWarning: { borderColor: c.errorBorder, backgroundColor: c.errorBg }, loadingText: { ...typography.caption, color: c.textMuted }, headerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginBottom: spacing.xs }, iconBadge: { @@ -283,9 +408,51 @@ function makeStyles(c: ThemePalette) { backgroundColor: c.primaryBg, alignItems: 'center', justifyContent: 'center', }, title: { ...typography.bodySemibold, color: c.text }, + subtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 }, cancelled: { ...typography.caption, color: c.error, marginTop: 2 }, + trustRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.sm, + paddingVertical: 4, + borderRadius: radius.sm, + borderWidth: 1, + marginBottom: spacing.xs, + }, + trustText: { ...typography.caption, flex: 1 }, detailRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingVertical: 2 }, detailText: { flex: 1, ...typography.caption, color: c.textSecondary }, + calendarPicker: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.sm, + paddingVertical: 6, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.background, + marginTop: spacing.xs, + }, + calendarPickerText: { ...typography.caption, color: c.text, flex: 1 }, + calendarSwatch: { width: 10, height: 10, borderRadius: 5 }, + calendarList: { + marginTop: 4, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.sm, + backgroundColor: c.background, + overflow: 'hidden', + }, + calendarRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.sm, + }, + calendarRowActive: { backgroundColor: c.primaryBg }, notice: { ...typography.captionMedium, color: c.primary, marginTop: spacing.xs }, actions: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm }, rsvpBtn: { diff --git a/src/lib/__tests__/calendar-invitation.test.ts b/src/lib/__tests__/calendar-invitation.test.ts new file mode 100644 index 00000000..a8ba1c27 --- /dev/null +++ b/src/lib/__tests__/calendar-invitation.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import type { Email } from '../../api/types'; +import { + extractMethodFromContentType, + extractMethodFromRawIcs, + findCalendarAttachment, + getEmailAuthenticationResults, + getInvitationMethod, + getInvitationTrustAssessment, + parseAuthenticationResults, +} from '../calendar-invitation'; + +const request = { + organizerCalendarAddress: 'mailto:alice@example.com', + participants: { + a: { calendarAddress: 'mailto:alice@example.com', roles: { owner: true } }, + b: { calendarAddress: 'mailto:bob@example.com', roles: { attendee: true }, participationStatus: 'needs-action' }, + }, +}; + +function email(overrides: Partial): Email { + return { + id: 'e1', threadId: 't1', mailboxIds: {}, keywords: {}, size: 1, receivedAt: '', hasAttachment: true, + ...overrides, + } as Email; +} + +describe('method detection', () => { + it('reads method= from a Content-Type parameter', () => { + expect(extractMethodFromContentType('text/calendar; charset=utf-8; method=REQUEST')).toBe('request'); + expect(extractMethodFromContentType('text/calendar; method="CANCEL"')).toBe('cancel'); + expect(extractMethodFromContentType('text/calendar')).toBe('unknown'); + }); + + it('reads METHOD from raw ICS text', () => { + expect(extractMethodFromRawIcs('BEGIN:VCALENDAR\r\nMETHOD:REPLY\r\nBEGIN:VEVENT')).toBe('reply'); + expect(extractMethodFromRawIcs('BEGIN:VCALENDAR\r\nBEGIN:VEVENT')).toBe('unknown'); + }); + + it('prefers the explicit method over the raw ICS over the inferred one', () => { + expect(getInvitationMethod(request, { attachment: { type: 'text/calendar; method=CANCEL' } })).toBe('cancel'); + expect(getInvitationMethod(request, { rawIcs: 'METHOD:REPLY\n' })).toBe('reply'); + expect(getInvitationMethod(request)).toBe('request'); + // A REPLY without participant signals is no longer treated as a request. + expect(getInvitationMethod({ title: 'x' }, { rawIcs: 'METHOD:REPLY\n' })).toBe('reply'); + }); + + it('finds inline text/calendar body parts, not just attachments', () => { + const e = email({ + attachments: [], + textBody: [{ partId: '1', blobId: 'b1', type: 'text/plain' }], + htmlBody: [{ partId: '2', blobId: 'b2', type: 'text/calendar', name: 'invite.ics' }], + }); + expect(findCalendarAttachment(e)?.blobId).toBe('b2'); + expect(findCalendarAttachment(email({ attachments: [{ blobId: 'b3', type: 'application/ics' }] }))?.blobId).toBe('b3'); + }); +}); + +describe('authentication results', () => { + it('parses spf/dkim/dmarc from the header', () => { + const parsed = parseAuthenticationResults( + 'mx.example.com; dkim=pass header.d=example.com header.s=sel; spf=pass smtp.mailfrom=example.com; dmarc=pass header.from=example.com', + ); + expect(parsed.spf?.result).toBe('pass'); + expect(parsed.dkim).toEqual({ result: 'pass', domain: 'example.com', selector: 'sel' }); + expect(parsed.dmarc?.result).toBe('pass'); + }); + + it('lets a HELO failure escalate but not a HELO none downgrade', () => { + expect(parseAuthenticationResults('spf=pass smtp.mailfrom=a.com; spf=none smtp.helo=b.com').spf?.result).toBe('pass'); + expect(parseAuthenticationResults('spf=pass smtp.mailfrom=a.com; spf=fail smtp.helo=b.com').spf?.result).toBe('fail'); + }); + + it('reads the Authentication-Results header from the email headers', () => { + const e = email({ headers: [{ name: 'Authentication-Results', value: 'x; dmarc=fail header.from=evil.com' }] }); + expect(getEmailAuthenticationResults(e)?.dmarc?.result).toBe('fail'); + expect(getEmailAuthenticationResults(email({}))).toBeNull(); + }); +}); + +describe('getInvitationTrustAssessment', () => { + it('is trusted for an authenticated organizer', () => { + const e = email({ + from: [{ email: 'alice@example.com' }], + headers: [{ name: 'Authentication-Results', value: 'x; dkim=pass header.d=example.com' }], + }); + expect(getInvitationTrustAssessment(request, e, 'request').level).toBe('trusted'); + }); + + it('warns when the sender differs from the organizer and is unverified', () => { + const e = email({ from: [{ email: 'mallory@evil.com' }] }); + const a = getInvitationTrustAssessment(request, e, 'request'); + expect(a.level).toBe('warning'); + expect(a.reason).toBe('sender_mismatch_unverified'); + }); + + it('warns on authentication failure and cautions on a verified mismatch', () => { + const failed = email({ + from: [{ email: 'alice@example.com' }], + headers: [{ name: 'Authentication-Results', value: 'x; spf=fail smtp.mailfrom=example.com' }], + }); + expect(getInvitationTrustAssessment(request, failed, 'request').reason).toBe('authentication_failed'); + const mismatch = email({ + from: [{ email: 'assistant@example.com' }], + headers: [{ name: 'Authentication-Results', value: 'x; dmarc=pass header.from=example.com' }], + }); + expect(getInvitationTrustAssessment(request, mismatch, 'request')).toMatchObject({ level: 'caution', reason: 'sender_mismatch' }); + }); + + it('cautions when a scheduling message carries no authentication at all', () => { + const e = email({ from: [{ email: 'alice@example.com' }] }); + expect(getInvitationTrustAssessment(request, e, 'request').reason).toBe('authentication_missing'); + expect(getInvitationTrustAssessment(request, e, 'unknown').level).toBe('trusted'); + }); +}); diff --git a/src/lib/calendar-invitation.ts b/src/lib/calendar-invitation.ts index c0b4a8ce..ea3657a7 100644 --- a/src/lib/calendar-invitation.ts +++ b/src/lib/calendar-invitation.ts @@ -1,4 +1,4 @@ -import type { CalendarEvent, Participant, Email, Attachment } from '../api/types'; +import type { CalendarEvent, Participant, Email, Attachment, BodyPart, EmailAddress } from '../api/types'; // ─── Address helpers ───────────────────────────────────── @@ -69,8 +69,9 @@ export function findParticipantByEmail( return null; } -// `replyTo` to attach to an RSVP so the server knows where to deliver the iTIP -// reply (Stalwart exposes the organizer as organizerCalendarAddress). +// Where the iTIP REPLY should go. Stalwart routes it to the stored +// ORGANIZER (organizerCalendarAddress); the store only uses this to repair +// imported events that lack one. export function buildReplyTo(event: Partial): Record | null { if (event.replyTo) return event.replyTo; if (event.organizerCalendarAddress) { @@ -82,6 +83,38 @@ export function buildReplyTo(event: Partial): Record } { + if (!value) return { mimeType: '', params: {} }; + const parts = value.split(';').map((part) => part.trim()).filter(Boolean); + const [mimeType = '', ...paramParts] = parts; + const params: Record = {}; + for (const part of paramParts) { + const separatorIndex = part.indexOf('='); + if (separatorIndex === -1) continue; + const key = part.slice(0, separatorIndex).trim().toLowerCase(); + const rawValue = part.slice(separatorIndex + 1).trim(); + params[key] = rawValue.replace(/^"|"$/g, ''); + } + return { mimeType: mimeType.toLowerCase(), params }; +} + +const CAL_MIME = new Set(['text/calendar', 'application/ics', 'application/icalendar']); + +export function isCalendarMimeType(type?: string | null): boolean { + return CAL_MIME.has(parseContentType(type).mimeType); +} + +export function getHeaderValue(headers: Email['headers'] | undefined, headerName: string): string | null { + if (!headers) return null; + const target = headerName.toLowerCase(); + for (const h of headers) { + if (h.name.toLowerCase() === target) return h.value; + } + return null; +} + // ─── iMIP method detection ─────────────────────────────── export type InvitationMethod = @@ -98,6 +131,10 @@ function normalizeMethod(value?: string | null): InvitationMethod { return KNOWN_METHODS.has(v as InvitationMethod) ? (v as InvitationMethod) : 'unknown'; } +export function extractMethodFromContentType(value?: string | null): InvitationMethod { + return normalizeMethod(parseContentType(value).params.method); +} + // JMAP strips Content-Type params (RFC 8621), so `text/calendar; method=REQUEST` // arrives as `text/calendar`. The raw ICS METHOD line is the reliable source. export function extractMethodFromRawIcs(rawText: string): InvitationMethod { @@ -111,7 +148,9 @@ function looksLikeReply(event: Partial): boolean { (p) => p.roles?.attendee && !isOrganizerParticipant(p) && - (p.participationStatus !== 'needs-action' || !!p.participationComment), + (p.participationStatus !== 'needs-action' || + !!p.participationComment || + !!p.scheduleStatus?.length), ); } @@ -126,23 +165,213 @@ export function inferInvitationMethod(event: Partial): Invitation return 'unknown'; } -// ─── Calendar attachment detection ─────────────────────── +type EmailForMethod = Pick; -const CAL_MIME = new Set(['text/calendar', 'application/ics', 'application/icalendar']); - -function isCalendarType(type?: string | null): boolean { - if (!type) return false; - const base = type.split(';')[0].trim().toLowerCase(); - return CAL_MIME.has(base); +function getMethodFromEmail( + email?: EmailForMethod | null, + attachment?: Pick | null, +): InvitationMethod { + const explicit = extractMethodFromContentType(attachment?.type); + if (explicit !== 'unknown') return explicit; + if (email?.attachments) { + for (const item of email.attachments) { + if (!isCalendarMimeType(item.type)) continue; + const method = extractMethodFromContentType(item.type); + if (method !== 'unknown') return method; + } + } + for (const part of [findCalendarBodyPart(email?.textBody), findCalendarBodyPart(email?.htmlBody)]) { + const method = extractMethodFromContentType(part?.type); + if (method !== 'unknown') return method; + } + return extractMethodFromContentType(getHeaderValue(email?.headers, 'Content-Type')); } -export function findCalendarAttachment(email: Pick): Attachment | null { - if (!email.attachments) return null; - for (const att of email.attachments) { - const name = att.name?.toLowerCase() || ''; - if (isCalendarType(att.type) || name.endsWith('.ics') || name.endsWith('.ical')) { - return att; +/** + * The iTIP METHOD of an invitation: an explicit `method=` Content-Type + * parameter (attachment, inline body part or top-level header), then the raw + * ICS `METHOD:` line when the caller downloaded it, then a guess from the + * parsed event (cancelled status, attendee replies, organizer present). + */ +export function getInvitationMethod( + event: Partial, + options?: { + email?: EmailForMethod | null; + attachment?: Pick | null; + rawIcs?: string | null; + }, +): InvitationMethod { + const explicit = getMethodFromEmail(options?.email, options?.attachment); + if (explicit !== 'unknown') return explicit; + if (options?.rawIcs) { + const fromIcs = extractMethodFromRawIcs(options.rawIcs); + if (fromIcs !== 'unknown') return fromIcs; + } + return inferInvitationMethod(event); +} + +// ─── Calendar attachment detection ─────────────────────── + +function isCalendarPart(part: { type?: string | null; name?: string | null }): boolean { + const name = part.name?.toLowerCase() || ''; + return isCalendarMimeType(part.type) || name.endsWith('.ics') || name.endsWith('.ical'); +} + +type BodyPartWithSubParts = BodyPart & { subParts?: BodyPartWithSubParts[] | null }; + +// Inline text/calendar parts live in textBody/htmlBody, not `attachments`. +export function findCalendarBodyPart(parts?: BodyPart[] | null): Attachment | null { + if (!parts) return null; + for (const part of parts as BodyPartWithSubParts[]) { + if (part.blobId && isCalendarPart(part)) { + return { + blobId: part.blobId, + type: part.type || 'text/calendar', + name: part.name || 'invite.ics', + size: part.size, + disposition: part.disposition, + cid: part.cid, + }; } + const nested = findCalendarBodyPart(part.subParts ?? undefined); + if (nested) return nested; } return null; } + +export function findCalendarAttachment( + email: Pick & Partial>, +): Attachment | null { + if (email.attachments) { + for (const att of email.attachments) { + if (isCalendarPart(att)) return att; + } + } + return findCalendarBodyPart(email.textBody) || findCalendarBodyPart(email.htmlBody); +} + +// ─── Authentication-Results / trust ────────────────────── + +export interface AuthenticationResults { + spf?: { result: string; domain?: string }; + dkim?: { result: string; domain?: string; selector?: string }; + dmarc?: { result: string; domain?: string; policy?: string }; +} + +/** Parse an Authentication-Results header into SPF / DKIM / DMARC results. */ +export function parseAuthenticationResults(header: string): AuthenticationResults { + const results: AuthenticationResults = {}; + // A header can carry several SPF results (HELO and MAIL FROM); the MAIL FROM + // identity is primary, another identity may only escalate to a failure. + const spfRegex = /spf=(\w+)(?:\s+\([^)]*\))?(?:\s+smtp\.(mailfrom|helo)=([^\s;]+))?/g; + const severity: Record = { + fail: 6, softfail: 5, permerror: 4, temperror: 3, neutral: 2, none: 1, pass: 0, + }; + const spf: Array<{ result: string; identity?: string; domain?: string }> = []; + let m: RegExpExecArray | null; + while ((m = spfRegex.exec(header)) !== null) { + spf.push({ result: m[1].toLowerCase(), identity: m[2], domain: m[3] }); + } + if (spf.length > 0) { + let primary = spf.find((e) => e.identity === 'mailfrom') ?? spf[0]; + for (const cur of spf) { + const s = severity[cur.result] ?? -1; + if (s >= severity.temperror && s > (severity[primary.result] ?? -1)) primary = cur; + } + results.spf = { result: primary.result, domain: primary.domain }; + } + const dkim = header.match(/dkim=(\w+)(?:\s+header\.d=([^\s;]+))?(?:\s+header\.s=([^\s;]+))?/); + if (dkim) results.dkim = { result: dkim[1].toLowerCase(), domain: dkim[2], selector: dkim[3] }; + const dmarc = header.match(/dmarc=(\w+)(?:\s+header\.from=([^\s;]+))?(?:\s+policy\.dmarc=(\w+))?/); + if (dmarc) results.dmarc = { result: dmarc[1].toLowerCase(), domain: dmarc[2], policy: dmarc[3] }; + return results; +} + +/** Authentication results of an email, derived from its raw headers. */ +export function getEmailAuthenticationResults( + email?: Pick | null, +): AuthenticationResults | null { + if (!email?.headers) return null; + const values = email.headers + .filter((h) => h.name.toLowerCase() === 'authentication-results') + .map((h) => h.value); + if (values.length === 0) return null; + // Merge: the first (outermost, added by our own server) header wins per + // mechanism; later ones only fill gaps. + const merged: AuthenticationResults = {}; + for (const value of values) { + const parsed = parseAuthenticationResults(value); + if (!merged.spf && parsed.spf) merged.spf = parsed.spf; + if (!merged.dkim && parsed.dkim) merged.dkim = parsed.dkim; + if (!merged.dmarc && parsed.dmarc) merged.dmarc = parsed.dmarc; + } + return merged; +} + +function hasVerifiedAuthentication(auth?: AuthenticationResults | null): boolean { + return Boolean( + auth?.dmarc?.result === 'pass' || auth?.dkim?.result === 'pass' || auth?.spf?.result === 'pass', + ); +} + +function hasAuthenticationFailure(auth?: AuthenticationResults | null): boolean { + return Boolean( + auth?.dmarc?.result === 'fail' + || auth?.dkim?.result === 'fail' + || auth?.dkim?.result === 'policy' + || auth?.dkim?.result === 'permerror' + || auth?.spf?.result === 'fail' + || auth?.spf?.result === 'softfail' + || auth?.spf?.result === 'permerror', + ); +} + +function getPrimaryAddressEmail(addresses?: EmailAddress[] | null): string | null { + return normalizeEmail(addresses?.[0]?.email); +} + +export interface InvitationTrustAssessment { + level: 'trusted' | 'caution' | 'warning'; + reason: + | 'authentication_failed' + | 'authentication_missing' + | 'sender_mismatch' + | 'sender_mismatch_unverified' + | null; + senderEmail: string | null; + organizerEmail: string | null; +} + +/** + * How much to trust an invitation: DMARC/DKIM/SPF results of the carrying + * email plus whether the sender matches the event's organizer. A spoofed + * invitation from an unauthenticated sender must not look like a real one. + * Port of the webmail's getInvitationTrustAssessment. + */ +export function getInvitationTrustAssessment( + event: Partial, + email?: Pick | null, + method: InvitationMethod = inferInvitationMethod(event), +): InvitationTrustAssessment { + const organizerEmail = getOrganizerEmail(event); + const senderEmail = getPrimaryAddressEmail(email?.from) || getPrimaryAddressEmail(email?.replyTo); + const auth = getEmailAuthenticationResults(email); + const verified = hasVerifiedAuthentication(auth); + const failed = hasAuthenticationFailure(auth); + const senderMismatch = Boolean(senderEmail && organizerEmail && senderEmail !== organizerEmail); + const expectsAuthenticatedTransport = method !== 'unknown'; + + if (senderMismatch && (failed || !verified)) { + return { level: 'warning', reason: 'sender_mismatch_unverified', senderEmail, organizerEmail }; + } + if (failed) { + return { level: 'warning', reason: 'authentication_failed', senderEmail, organizerEmail }; + } + if (senderMismatch) { + return { level: 'caution', reason: 'sender_mismatch', senderEmail, organizerEmail }; + } + if (expectsAuthenticatedTransport && !verified) { + return { level: 'caution', reason: 'authentication_missing', senderEmail, organizerEmail }; + } + return { level: 'trusted', reason: null, senderEmail, organizerEmail }; +} diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index faf66af4..dda40c73 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -863,7 +863,7 @@ function EmailPane({ {attachmentPosition === 'below-header' && renderAttachments()} {/* Calendar invitation (auto-detected .ics) */} - + {/* Body */}