mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user