feat: assess invitation trust, read the iTIP method from the ICS and pick the target calendar in the invitation banner

This commit is contained in:
Linus Rath
2026-08-29 18:22:00 +02:00
parent 65c73d82a1
commit 8206893860
6 changed files with 613 additions and 72 deletions
+5 -5
View File
@@ -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/<principal>` 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.
+32 -2
View File
@@ -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<Partial<CalendarEvent>[]> {
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<Partial<CalendarEvent>[]> {
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<Partial<Calenda
return list.map(normalizeRecurrenceProperties);
}
// Download the raw text of a calendar blob (the .ics itself) so the iTIP
// METHOD line can be read — JMAP strips the `method=` Content-Type parameter.
// Best-effort: returns null when the download fails.
export async function fetchCalendarBlobText(
blobId: string,
targetAccountId?: string,
): Promise<string | null> {
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,
+214 -47
View File
@@ -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<NonNullable<InvitationTrustAssessment['reason']>, [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<RsvpStatus | null>(null);
const [notice, setNotice] = React.useState<string | null>(null);
const [busy, setBusy] = React.useState(false);
const [calendarId, setCalendarId] = React.useState<string | null>(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 (
<View style={styles.banner}>
<ActivityIndicator size="small" color={c.primary} />
<Text style={styles.loadingText}>Reading invitation…</Text>
<Text style={styles.loadingText}>{t('calendar.invitation.reading', 'Reading invitation…')}</Text>
</View>
);
}
@@ -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 (
<View style={styles.banner}>
<View style={[styles.banner, trust?.level === 'warning' && styles.bannerWarning]}>
<View style={styles.headerRow}>
<View style={styles.iconBadge}>
<CalendarDays size={18} color={c.primary} />
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.title} numberOfLines={2}>{event.title || 'Calendar invitation'}</Text>
{method === 'cancel' && <Text style={styles.cancelled}>This event was cancelled</Text>}
<Text style={styles.title} numberOfLines={2}>
{event.title || t('calendar.invitation.title', 'Calendar invitation')}
</Text>
{method === 'cancel' && (
<Text style={styles.cancelled}>{t('calendar.invitation.cancelled', 'This event was cancelled')}</Text>
)}
{method === 'reply' && (
<Text style={styles.subtitle}>{t('calendar.invitation.reply', 'A participant replied to this invitation')}</Text>
)}
</View>
</View>
{trust && (
<View style={[styles.trustRow, { borderColor: trustColor }]}>
{trust.level === 'trusted' ? (
<ShieldCheck size={14} color={trustColor} />
) : (
<ShieldAlert size={14} color={trustColor} />
)}
<Text style={[styles.trustText, { color: trustColor }]} numberOfLines={3}>
{trust.reason
? t(TRUST_REASON_KEYS[trust.reason][0], TRUST_REASON_KEYS[trust.reason][1])
: t('calendar.invitation.trust_verified', 'Sender verified')}
</Text>
</View>
)}
{dateLabel && (
<Row icon={<Clock size={15} color={c.textMuted} />} text={dateLabel} styles={styles} />
)}
@@ -171,12 +258,49 @@ export function CalendarInvitationBanner({ email }: Props) {
{videoUri ? (
<Pressable style={styles.detailRow} onPress={() => { void Linking.openURL(videoUri); }}>
<Video size={15} color={c.textMuted} />
<Text style={[styles.detailText, { color: c.primary }]} numberOfLines={1}>Join video call</Text>
<Text style={[styles.detailText, { color: c.primary }]} numberOfLines={1}>
{t('calendar.invitation.join_video', 'Join video call')}
</Text>
</Pressable>
) : null}
{organizer ? (
<Row icon={<CalendarPlus size={15} color={c.textMuted} />} text={`Organizer: ${organizer}`} styles={styles} />
<Row
icon={<CalendarPlus size={15} color={c.textMuted} />}
text={`${t('calendar.invitation.organizer', 'Organizer')}: ${organizer}`}
styles={styles}
/>
) : null}
{existing && state !== 'done' && (
<Row
icon={<Check size={15} color={c.success} />}
text={t('calendar.invitation.already_in_calendar', 'Already in your calendar')}
styles={styles}
/>
)}
{state !== 'done' && !existing && targetCalendar && candidates.length > 1 && (
<View>
<Pressable style={styles.calendarPicker} onPress={() => setPickerOpen((v) => !v)}>
<View style={[styles.calendarSwatch, { backgroundColor: getCalendarColor(targetCalendar) }]} />
<Text style={styles.calendarPickerText} numberOfLines={1}>{targetCalendar.name}</Text>
<ChevronDown size={14} color={c.textMuted} />
</Pressable>
{pickerOpen && (
<View style={styles.calendarList}>
{candidates.map((cal) => (
<Pressable
key={cal.id}
style={[styles.calendarRow, cal.id === targetCalendar.id && styles.calendarRowActive]}
onPress={() => { setCalendarId(cal.id); setPickerOpen(false); }}
>
<View style={[styles.calendarSwatch, { backgroundColor: getCalendarColor(cal) }]} />
<Text style={styles.calendarPickerText} numberOfLines={1}>{cal.name}</Text>
</Pressable>
))}
</View>
)}
</View>
)}
{notice && <Text style={styles.notice}>{notice}</Text>}
@@ -184,37 +308,37 @@ export function CalendarInvitationBanner({ email }: Props) {
<View style={styles.actions}>
{canRsvp ? (
<>
<RsvpBtn label="Yes" active={rsvpStatus === 'accepted'} activeColor={c.success}
icon={<Check size={15} color={rsvpStatus === 'accepted' ? c.textInverse : c.success} />}
<RsvpBtn label={t('calendar.invitation.accept', 'Yes')} active={currentStatus === 'accepted'} activeColor={c.success}
icon={<Check size={15} color={currentStatus === 'accepted' ? c.textInverse : c.success} />}
disabled={busy} onPress={() => ensureImportedAndRsvp('accepted')} c={c} styles={styles} />
<RsvpBtn label="Maybe" active={rsvpStatus === 'tentative'} activeColor={c.warning}
icon={<HelpCircle size={15} color={rsvpStatus === 'tentative' ? c.textInverse : c.warning} />}
<RsvpBtn label={t('calendar.invitation.tentative', 'Maybe')} active={currentStatus === 'tentative'} activeColor={c.warning}
icon={<HelpCircle size={15} color={currentStatus === 'tentative' ? c.textInverse : c.warning} />}
disabled={busy} onPress={() => ensureImportedAndRsvp('tentative')} c={c} styles={styles} />
<RsvpBtn label="No" active={rsvpStatus === 'declined'} activeColor={c.error}
icon={<X size={15} color={rsvpStatus === 'declined' ? c.textInverse : c.error} />}
<RsvpBtn label={t('calendar.invitation.decline', 'No')} active={currentStatus === 'declined'} activeColor={c.error}
icon={<X size={15} color={currentStatus === 'declined' ? c.textInverse : c.error} />}
disabled={busy} onPress={() => ensureImportedAndRsvp('declined')} c={c} styles={styles} />
</>
) : (
) : !existing && method !== 'reply' ? (
<Pressable
style={[styles.importBtn, busy && { opacity: 0.5 }]}
style={[styles.importBtn, (busy || !targetCalendar) && { opacity: 0.5 }]}
onPress={() => { void handleImport(); }}
disabled={busy}
disabled={busy || !targetCalendar}
>
{busy ? (
<ActivityIndicator size="small" color={c.primaryForeground} />
) : (
<CalendarPlus size={16} color={c.primaryForeground} />
)}
<Text style={styles.importBtnText}>Add to calendar</Text>
<Text style={styles.importBtnText}>{t('calendar.invitation.add_to_calendar', 'Add to calendar')}</Text>
</Pressable>
)}
) : null}
</View>
)}
{!writableCalendar && state !== 'done' && (
{!targetCalendar && state !== 'done' && (
<View style={styles.warnRow}>
<AlertTriangle size={14} color={c.warning} />
<Text style={styles.warnText}>No writable calendar available</Text>
<Text style={styles.warnText}>{t('calendar.invitation.no_writable_calendar', 'No writable calendar available')}</Text>
</View>
)}
</View>
@@ -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: {
@@ -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>): 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');
});
});
+246 -17
View File
@@ -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<CalendarEvent>): Record<string, string> | null {
if (event.replyTo) return event.replyTo;
if (event.organizerCalendarAddress) {
@@ -82,6 +83,38 @@ export function buildReplyTo(event: Partial<CalendarEvent>): Record<string, stri
return null;
}
// ─── Content-Type helpers ────────────────────────────────
export function parseContentType(value?: string | null): { mimeType: string; params: Record<string, string> } {
if (!value) return { mimeType: '', params: {} };
const parts = value.split(';').map((part) => part.trim()).filter(Boolean);
const [mimeType = '', ...paramParts] = parts;
const params: Record<string, string> = {};
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<CalendarEvent>): 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<CalendarEvent>): Invitation
return 'unknown';
}
// ─── Calendar attachment detection ───────────────────────
type EmailForMethod = Pick<Email, 'headers' | 'attachments' | 'textBody' | 'htmlBody'>;
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<Attachment, 'type'> | 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<Email, 'attachments'>): 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<CalendarEvent>,
options?: {
email?: EmailForMethod | null;
attachment?: Pick<Attachment, 'type'> | 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<Email, 'attachments'> & Partial<Pick<Email, 'textBody' | 'htmlBody'>>,
): 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<string, number> = {
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<Email, 'headers'> | 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<CalendarEvent>,
email?: Pick<Email, 'from' | 'replyTo' | 'headers'> | 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 };
}
+1 -1
View File
@@ -863,7 +863,7 @@ function EmailPane({
{attachmentPosition === 'below-header' && renderAttachments()}
{/* Calendar invitation (auto-detected .ics) */}
<CalendarInvitationBanner email={email} />
<CalendarInvitationBanner email={email} jmapAccountId={jmapAccountId} />
{/* Body */}
<View style={styles.bodyBlock}>