fix: send invitations with a server-scheduled organizer and match RSVPs against identities and aliases (#731)

This commit is contained in:
Linus Rath
2026-08-29 17:52:26 +02:00
parent 26a34d5900
commit 5dc40706fa
11 changed files with 532 additions and 79 deletions
+4 -4
View File
@@ -22,7 +22,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
- What RN does: `updateEvent` maps only `e.id === id` (`src/stores/calendar-store.ts:315-317`), so sibling occurrences keep the old title/time until a pull-to-refresh; `deleteEvent` removes only the tapped occurrence from state while the server destroyed the master (`:320-329`).
- Fix hint: after mutating a `recurrenceId`/`recurrenceRules` event call `get().refresh()` (RN already has it) like WEB's `refetchAfterOccurrenceMutation`.
- [ ] **RSCALE=GREGORIAN;SKIP=OMIT still emitted by the custom recurrence editor (#805)** — `P2` — `bugfix-parity`
- [x] **RSCALE=GREGORIAN;SKIP=OMIT still emitted by the custom recurrence editor (#805)** — `P2` — `bugfix-parity` — fixed in 58a6f45
- What WEB does: `createRecurrenceRule` deliberately omits `rscale`/`skip` because Stalwart serialises them into the RRULE and DAVx5 rejects it (ref `lib/recurrence-rule.ts:5-33`, changelog 1.9.2 #805).
- What RN does: `buildRuleFromEditorValue` sets `rscale: 'gregorian', skip: 'omit'` on every custom rule (ref `src/lib/recurrence.ts:220-221`). RN affected: any custom recurrence created on mobile breaks Android CalDAV sync.
- Fix hint: delete the two properties in `buildRuleFromEditorValue`; the simple presets in `EventModal.tsx:143-146` are already clean.
@@ -32,7 +32,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
- What RN does: always fetches raw events and expands on the client (`src/stores/calendar-store.ts:228`, `src/lib/recurrence-expansion.ts`). Functionally fine on all server versions; RN's expansion file is a line-for-line port of WEB's (diffed: only comments, formatting and the `isServerRecurrenceInstance` pass-through differ).
- Fix hint: optional. If adopted, port `recurrence-instances.ts` wholesale and the `isServerRecurrenceInstance` guard in `expandRecurringEvents`.
- [ ] **Removing recurrence / participants / reminders / location on edit does not persist** — `P2` — `bug`
- [x] **Removing recurrence / participants / reminders / location on edit does not persist** — `P2` — `bug` — fixed in 58a6f45
- What WEB does: sends explicit `null` for `recurrenceRules` (+ `recurrenceOverrides`, `excludedRecurrenceRules`), `alerts`, `participants` (+ `replyTo`, `organizerCalendarAddress`), `locations`, `virtualLocations` when the field was cleared (ref `components/calendar/event-modal.tsx:528-549, 560-563, 588-592`).
- What RN does: `handleSave` emits `undefined` for cleared fields (`recurrenceRules: undefined`, `participants: undefined`, `alerts: remindersToAlerts([]) === undefined`, `locations: undefined`) which JSON serialisation drops, so the server keeps the old value (ref `src/components/calendar/EventModal.tsx:262-281`, `src/lib/calendar-alerts.ts:72-73`). Setting "Does not repeat" on a recurring event, removing all attendees, deleting the last reminder or clearing the location silently does nothing.
- Fix hint: in edit mode compare against `event` and send `null` for fields that existed and are now empty; RN's `cleanRecurrenceRules` already turns `[]`/`null` into `recurrenceRule: null` (`src/api/calendar.ts:115-118`), so `recurrenceRules: []` works today for recurrence.
@@ -43,12 +43,12 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
### Events: create / edit
- [ ] **All-day events are created and saved one day too long** — `P1` — `rn-only-bug`
- [x] **All-day events are created and saved one day too long** — `P1` — `rn-only-bug` — fixed in 58a6f45
- What WEB does: seeds the end field from `getEventDisplayEndDate` (inclusive, end - 1 ms) for all-day events and builds `buildAllDayDuration(start, inclusiveEnd)` (ref `components/calendar/event-modal.tsx:274-280, 471-477`).
- What RN does: edit seeds `end` with `getEventEndDate(event)` which is the exclusive end (start + P1D = next day 00:00) (`src/components/calendar/EventModal.tsx:198`); the all-day toggle effect pushes `end` to the next day whenever `end <= start` (`:231-242`); `handleSave` then calls `buildAllDayDuration(start, end)` which is inclusive (`:269`, `src/lib/calendar-utils.ts:183-189`), yielding `P2D` for a one-day event. Every create-via-toggle and every re-save of an all-day event adds a day.
- Fix hint: seed/display the inclusive end (`getEventDisplayEndDate`) for all-day events, and in the all-day effect set `e = s` (same day) when `end <= start`; keep `buildAllDayDuration` inclusive.
- [ ] **Events created without `timeZone` (floating time)** — `P1` — `bug`
- [x] **Events created without `timeZone` (floating time)** — `P1` — `bug` — fixed in 58a6f45
- What WEB does: every timed event carries `timeZone: getEffectiveTimeZone()` (all-day: `null`) so other clients and invitees resolve the wall-clock correctly (ref `components/calendar/event-modal.tsx:494-502`, `lib/timezone.ts:64-75`).
- What RN does: `handleSave` never sets `timeZone` (`src/components/calendar/EventModal.tsx:262-281`) and `createEvent` does not add one (`src/api/calendar.ts:248-275`). Stalwart stores a floating DTSTART; attendees in other zones, CalDAV clients, and the user after travelling see wrong times; iMIP invites sent for such events are ambiguous.
- Fix hint: set `timeZone: allDay ? null : Intl.DateTimeFormat().resolvedOptions().timeZone` (reuse `getUserTimeZone` from `src/api/calendar.ts:15`); once a custom time-zone setting exists (see below) use that.
+6 -2
View File
@@ -328,11 +328,15 @@ export async function deleteEvents(
// RSVP to an invitation: patch the participant's participationStatus via a JSON
// Pointer (RFC 6901) and let Stalwart send the iTIP REPLY (sendSchedulingMessages).
// Stalwart routes the REPLY to the stored ORGANIZER (organizerCalendarAddress);
// the RFC 8984 replyTo property is retired in jscalendarbis and ignored. The
// caller passes `repairOrganizerAddress` only for events that lack an
// organizer (e.g. imported ones) — attendees may not modify an existing one.
export async function rsvpEvent(
eventId: string,
participantId: string,
status: 'accepted' | 'declined' | 'tentative',
replyTo?: Record<string, string> | null,
repairOrganizerAddress?: string | null,
targetAccountId?: string,
): Promise<void> {
const accountId = targetAccountId || jmapClient.accountId;
@@ -341,7 +345,7 @@ export async function rsvpEvent(
const patch: Record<string, unknown> = {
[`participants/${escaped}/participationStatus`]: status,
};
if (replyTo) patch.replyTo = replyTo;
if (repairOrganizerAddress) patch.organizerCalendarAddress = repairOrganizerAddress;
const res = await jmapClient.request(
[['CalendarEvent/set', { accountId, update: { [eventId]: patch }, sendSchedulingMessages: true }, '0']],
USING,
+34 -18
View File
@@ -35,16 +35,17 @@ import { useColors } from '../../theme/colors';
import {
eventTimeRange,
getEventColor,
getEventDisplayEndDate,
getPrimaryCalendarId,
timePattern,
type TimeFormat,
} from '../../lib/calendar-utils';
import { alertsToReminders, formatReminder } from '../../lib/calendar-alerts';
import {
findParticipantByEmail,
isOrganizerParticipant,
} from '../../lib/calendar-invitation';
import { useAccountStore } from '../../stores/account-store';
getParticipantList,
getUserParticipantId,
isOrganizer,
} from '../../lib/calendar-participants';
import { useSheetDrag } from '../../lib/use-sheet-drag';
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
@@ -53,6 +54,8 @@ interface EventDetailSheetProps {
event: CalendarEvent | null;
calendars: Calendar[];
timeFormat?: TimeFormat;
// Login address + identities + aliases; finds "me" among the participants.
currentUserEmails?: string[];
onClose: () => void;
onEdit?: (event: CalendarEvent) => void;
onDelete?: (event: CalendarEvent) => void;
@@ -61,7 +64,10 @@ interface EventDetailSheetProps {
}
function formatRange(event: CalendarEvent, timeFormat?: TimeFormat): string {
const { start, end, allDay } = eventTimeRange(event);
const { start, allDay } = eventTimeRange(event);
// All-day events store an exclusive end (next day 00:00); show the
// inclusive last day so a one-day event doesn't read as two (#318).
const end = allDay ? getEventDisplayEndDate(event) : eventTimeRange(event).end;
if (allDay) {
if (
start.getFullYear() === end.getFullYear() &&
@@ -105,6 +111,7 @@ export function EventDetailSheet({
event,
calendars,
timeFormat,
currentUserEmails = [],
onClose,
onEdit,
onDelete,
@@ -112,7 +119,6 @@ export function EventDetailSheet({
onRsvp,
}: EventDetailSheetProps) {
const c = useColors();
const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null);
const [rsvpBusy, setRsvpBusy] = React.useState(false);
const styles = React.useMemo(() => makeStyles(c), [c]);
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
@@ -164,21 +170,24 @@ export function EventDetailSheet({
const range = formatRange(event, timeFormat);
const recurrence = recurrenceLabel(event);
const reminders = alertsToReminders(event.alerts);
const participants = event.participants ? Object.values(event.participants) : [];
const participants = getParticipantList(event);
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
const isCancelled = event.status === 'cancelled';
// Can the signed-in user RSVP? Only when they appear as a non-organizer
// participant and the caller wired an onRsvp handler.
const me = activeEmail ? findParticipantByEmail(event, [activeEmail]) : null;
const canRsvp = Boolean(onRsvp && me && !isOrganizerParticipant(me.participant));
const myStatus = me?.participant.participationStatus;
// participant (matched against every address of theirs: login, identities,
// aliases) and the caller wired an onRsvp handler.
const myParticipantId = getUserParticipantId(event, currentUserEmails);
const userIsOrganizer = isOrganizer(event, currentUserEmails);
const canRsvp = Boolean(onRsvp && myParticipantId && !userIsOrganizer);
const myStatus = myParticipantId ? event.participants?.[myParticipantId]?.participationStatus : undefined;
const doRsvp = async (status: RsvpStatus) => {
if (!onRsvp || !me || rsvpBusy) return;
if (!onRsvp || !myParticipantId || rsvpBusy) return;
setRsvpBusy(true);
try {
await onRsvp(event, me.id, status);
await onRsvp(event, myParticipantId, status);
} finally {
setRsvpBusy(false);
}
@@ -206,7 +215,9 @@ export function EventDetailSheet({
<View style={styles.header}>
<View style={[styles.colorBar, { backgroundColor: color }]} />
<View style={styles.headerText}>
<Text style={styles.title}>{event.title || 'Untitled'}</Text>
<Text style={[styles.title, isCancelled && styles.titleCancelled]}>
{event.title || 'Untitled'}
</Text>
{calendar && <Text style={styles.subtitle}>{calendar.name}</Text>}
</View>
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
@@ -303,21 +314,24 @@ export function EventDetailSheet({
{participants.length} participants
</Text>
</View>
{participants.map((p, idx) => (
<View key={idx} style={styles.participantRow}>
{participants.map((p) => (
<View key={p.id} style={styles.participantRow}>
<View
style={[
styles.participantDot,
{
backgroundColor: statusColor(c, p.participationStatus),
backgroundColor: statusColor(c, p.status),
},
]}
/>
<Text style={styles.participantName} numberOfLines={1}>
{p.name || p.email || 'Unknown'}
{p.isOrganizer ? (
<Text style={styles.participantOrganizer}> (organizer)</Text>
) : null}
</Text>
<Text style={styles.participantStatus}>
{statusLabel(p.participationStatus)}
{p.isOrganizer ? '' : statusLabel(p.status)}
</Text>
</View>
))}
@@ -508,6 +522,7 @@ function makeStyles(c: ThemePalette) {
colorBar: { width: 4, alignSelf: 'stretch', borderRadius: 2 },
headerText: { flex: 1 },
title: { ...typography.h3, color: c.text },
titleCancelled: { textDecorationLine: 'line-through', color: c.textMuted },
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 },
closeBtn: { padding: 4 },
@@ -567,6 +582,7 @@ function makeStyles(c: ThemePalette) {
},
participantDot: { width: 8, height: 8, borderRadius: 4 },
participantName: { flex: 1, ...typography.body, color: c.text },
participantOrganizer: { ...typography.caption, color: c.textMuted },
participantStatus: { ...typography.caption, color: c.textMuted },
actions: {
+38 -11
View File
@@ -29,7 +29,6 @@ import { addHours, addMinutes, format } from 'date-fns';
import type {
Calendar,
CalendarEvent,
Participant,
EventLocation,
RecurrenceRule,
VirtualLocation,
@@ -53,6 +52,12 @@ import {
type Reminder,
} from '../../lib/calendar-alerts';
import { buildRecurrenceSummary, isSimpleRecurrenceRule } from '../../lib/recurrence';
import {
buildParticipantMap,
getParticipantList,
seedAttendees,
type Attendee,
} from '../../lib/calendar-participants';
import { Button } from '..';
import { ParticipantInput } from './ParticipantInput';
import { RecurrenceEditor } from './RecurrenceEditor';
@@ -74,6 +79,9 @@ interface EventModalProps {
calendars: Calendar[];
defaultDate?: Date;
defaultCalendarId?: string;
// Login address + identities + aliases; the first one is the organizer
// address of invitations sent from this editor.
currentUserEmails?: string[];
onSave: (data: Partial<CalendarEvent>, calendarId: string) => void | Promise<void>;
onDelete?: (event: CalendarEvent) => void;
onClose: () => void;
@@ -153,6 +161,7 @@ export function EventModal({
calendars,
defaultDate,
defaultCalendarId,
currentUserEmails = [],
onSave,
onDelete,
onClose,
@@ -174,7 +183,8 @@ export function EventModal({
|| calendars[0]?.id
|| '';
const [calendarId, setCalendarId] = React.useState<string>(fallbackCalendarId);
const [participants, setParticipants] = React.useState<Record<string, Participant>>({});
// Attendee rows only — the organizer participant is rebuilt on save.
const [attendees, setAttendees] = React.useState<Attendee[]>([]);
const [recurrence, setRecurrence] = React.useState<RecurrenceOption>('none');
const [customRule, setCustomRule] = React.useState<RecurrenceRule | null>(null);
const [recurrenceEditorOpen, setRecurrenceEditorOpen] = React.useState(false);
@@ -202,7 +212,7 @@ export function EventModal({
// grows the event by one day.
setEnd(event.showWithoutTime ? getEventDisplayEndDate(event) : getEventEndDate(event));
setCalendarId(getPrimaryCalendarId(event) || calendars[0]?.id || '');
setParticipants(event.participants || {});
setAttendees(seedAttendees(event, currentUserEmails));
const detected = detectRecurrence(event);
setRecurrence(detected);
setCustomRule(detected === 'custom' ? event.recurrenceRules?.[0] ?? null : null);
@@ -223,7 +233,7 @@ export function EventModal({
|| calendars[0]?.id
|| '',
);
setParticipants({});
setAttendees([]);
setRecurrence('none');
setCustomRule(null);
setRecurrenceEditorOpen(false);
@@ -231,7 +241,7 @@ export function EventModal({
setLocation('');
setVideoUrl('');
}
}, [visible, event, defaultDate, defaultCalendarId, calendars]);
}, [visible, event, defaultDate, defaultCalendarId, calendars, currentUserEmails]);
React.useEffect(() => {
if (allDay) {
@@ -295,16 +305,30 @@ export function EventModal({
// every viewer; label the wall-clock with the zone it was entered in.
// All-day events are date-only and carry no zone.
timeZone: allDay ? null : getEffectiveTimeZone(),
participants: clearedOr(
Object.keys(participants).length > 0 ? participants : undefined,
'participants',
),
recurrenceRules: clearedOr(recurrenceRules, 'recurrenceRules'),
alerts: clearedOr(remindersToAlerts(reminders), 'alerts'),
useDefaultAlerts: reminders.length > 0 ? false : undefined,
locations: clearedOr(buildLocations(location), 'locations'),
virtualLocations: clearedOr(buildVirtualLocations(videoUrl), 'virtualLocations'),
};
const organizerEmail = currentUserEmails[0];
if (attendees.length > 0 && organizerEmail) {
const organizerName =
(event ? getParticipantList(event).find((p) => p.isOrganizer)?.name : '') || '';
data.participants = buildParticipantMap(
{ name: organizerName, email: organizerEmail },
attendees,
);
// Stalwart (calcard) derives the iCalendar ORGANIZER property solely from
// organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP
// scheduling is silently skipped, so no invites are sent.
data.organizerCalendarAddress = `mailto:${organizerEmail}`;
} else if (attendees.length === 0 && isEdit && had('participants')) {
data.participants = null;
// Also clear the retired replyTo that older releases wrote.
if (event?.replyTo) data.replyTo = null;
if (event?.organizerCalendarAddress) data.organizerCalendarAddress = null;
}
if (data.recurrenceRules === null) {
// Dropping the rule leaves per-occurrence data dangling.
if (had('recurrenceOverrides')) data.recurrenceOverrides = null;
@@ -619,8 +643,11 @@ export function EventModal({
label="Participants"
>
<ParticipantInput
participants={participants}
onChange={setParticipants}
attendees={attendees}
onAdd={(a) => setAttendees((prev) => [...prev, a])}
onRemove={(email) =>
setAttendees((prev) => prev.filter((a) => a.email.toLowerCase() !== email.toLowerCase()))
}
/>
</Section>
+19 -37
View File
@@ -11,11 +11,13 @@ import { X } from 'lucide-react-native';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useContactsStore } from '../../stores/contacts-store';
import type { Participant } from '../../api/types';
import { useLocaleStore } from '../../stores/locale-store';
import type { Attendee } from '../../lib/calendar-participants';
interface ParticipantInputProps {
participants: Record<string, Participant>;
onChange: (next: Record<string, Participant>) => void;
attendees: Attendee[];
onAdd: (attendee: Attendee) => void;
onRemove: (email: string) => void;
}
interface Suggestion {
@@ -27,10 +29,6 @@ function emailRegex(): RegExp {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
}
function generateParticipantId(): string {
return `p-${Math.random().toString(36).slice(2, 10)}`;
}
function flattenContactEmails(): Suggestion[] {
const contacts = useContactsStore.getState().contacts;
const out: Suggestion[] = [];
@@ -44,15 +42,18 @@ function flattenContactEmails(): Suggestion[] {
return out;
}
export function ParticipantInput({ participants, onChange }: ParticipantInputProps) {
// Attendee rows only: the organizer participant is added by
// buildParticipantMap on save (see lib/calendar-participants).
export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInputProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const [draft, setDraft] = React.useState('');
const [allSuggestions] = React.useState(() => flattenContactEmails());
const existingEmails = React.useMemo(() => {
return new Set(Object.values(participants).map((p) => p.email?.toLowerCase()));
}, [participants]);
return new Set(attendees.map((a) => a.email.toLowerCase()));
}, [attendees]);
const filteredSuggestions = React.useMemo(() => {
const q = draft.trim().toLowerCase();
@@ -75,44 +76,24 @@ export function ParticipantInput({ participants, onChange }: ParticipantInputPro
setDraft('');
return;
}
const id = generateParticipantId();
const next: Record<string, Participant> = {
...participants,
[id]: {
email: trimmed,
name,
sendTo: { imip: `mailto:${trimmed}` },
roles: { attendee: true },
participationStatus: 'needs-action',
expectReply: true,
},
};
onChange(next);
onAdd({ name: name || '', email: trimmed });
setDraft('');
};
const removeParticipant = (id: string) => {
const next = { ...participants };
delete next[id];
onChange(next);
};
const handleSubmit = () => {
if (draft.trim()) addParticipant(draft);
};
const entries = Object.entries(participants);
return (
<View style={styles.container}>
{entries.length > 0 && (
{attendees.length > 0 && (
<View style={styles.chips}>
{entries.map(([id, p]) => (
<View key={id} style={styles.chip}>
{attendees.map((a) => (
<View key={a.email.toLowerCase()} style={styles.chip}>
<Text style={styles.chipText} numberOfLines={1}>
{p.name || p.email || 'Unknown'}
{a.name || a.email}
</Text>
<Pressable onPress={() => removeParticipant(id)} hitSlop={6}>
<Pressable onPress={() => onRemove(a.email)} hitSlop={6}>
<X size={12} color={c.textMuted} />
</Pressable>
</View>
@@ -123,12 +104,13 @@ export function ParticipantInput({ participants, onChange }: ParticipantInputPro
<TextInput
value={draft}
onChangeText={setDraft}
placeholder="Add participant by email"
placeholder={t('calendar.event_modal.participant_placeholder', 'Add participant by email')}
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
onSubmitEditing={handleSubmit}
onBlur={handleSubmit}
returnKeyType="done"
style={styles.input}
/>
@@ -9,7 +9,6 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors';
import { parseCalendarBlob } from '../../api/calendar';
import { useCalendarStore } from '../../stores/calendar-store';
import { useAccountStore } from '../../stores/account-store';
import { useSettingsStore } from '../../stores/settings-store';
import {
findCalendarAttachment,
@@ -19,6 +18,7 @@ import {
inferInvitationMethod,
type InvitationMethod,
} from '../../lib/calendar-invitation';
import { useUserCalendarAddresses } from '../../lib/calendar-user-addresses';
type BannerState = 'loading' | 'parsed' | 'done' | 'error';
type RsvpStatus = 'accepted' | 'tentative' | 'declined';
@@ -34,7 +34,9 @@ export function CalendarInvitationBanner({ email }: Props) {
const calendars = useCalendarStore((s) => s.calendars);
const importEvents = useCalendarStore((s) => s.importEvents);
const rsvpEvent = useCalendarStore((s) => s.rsvpEvent);
const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null);
// Login address + identities + aliases, so invitations addressed to an
// alias still show the RSVP buttons.
const currentUserEmails = useUserCalendarAddresses();
const attachment = React.useMemo(() => findCalendarAttachment(email), [email]);
@@ -90,7 +92,7 @@ export function CalendarInvitationBanner({ email }: Props) {
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 = activeEmail ? findParticipantByEmail(event, [activeEmail]) : null;
const me = findParticipantByEmail(event, currentUserEmails);
const canRsvp = method !== 'cancel' && method !== 'reply' && !!me;
const writableCalendar = calendars.find((cal) => !cal.myRights || cal.myRights.mayWrite !== false);
@@ -104,8 +106,8 @@ export function CalendarInvitationBanner({ email }: Props) {
await importEvents([event], writableCalendar.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 && activeEmail
? findParticipantByEmail(stored, [activeEmail])
const participant = stored
? findParticipantByEmail(stored, currentUserEmails)
: me;
if (stored && participant) {
await rsvpEvent(stored.id, participant.id, status, buildReplyTo(event));
@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest';
import type { CalendarEvent } from '../../api/types';
import {
buildParticipantMap,
collectUserCalendarAddresses,
getParticipantList,
getStatusCounts,
getUserParticipantId,
isOrganizer,
seedAttendees,
} from '../calendar-participants';
const event: Partial<CalendarEvent> = {
organizerCalendarAddress: 'mailto:Alice@example.com',
participants: {
// Stalwart rebuilds ORGANIZER into a roles-less participant (#731).
org: { calendarAddress: 'mailto:alice@example.com', name: 'Alice' },
bob: { email: 'bob@example.com', roles: { attendee: true }, participationStatus: 'accepted' },
carol: { calendarAddress: 'mailto:carol@example.com', roles: { attendee: true } },
},
};
describe('buildParticipantMap', () => {
it('emits an owner-only organizer plus server-scheduled attendees, deduped (#731)', () => {
const map = buildParticipantMap({ name: 'Alice', email: 'alice@example.com' }, [
{ name: 'Bob', email: 'bob@example.com' },
{ name: 'Alice again', email: 'ALICE@example.com' },
{ name: 'Bob dup', email: 'Bob@example.com' },
]);
const list = Object.values(map);
expect(list).toHaveLength(2);
const organizer = list.find((p) => p.roles?.owner)!;
expect(organizer.roles).toEqual({ owner: true });
expect(organizer.calendarAddress).toBe('mailto:alice@example.com');
expect(organizer.scheduleAgent).toBe('server');
expect(organizer.sendTo).toBeUndefined();
const bob = list.find((p) => p.email === 'bob@example.com')!;
expect(bob.roles).toEqual({ attendee: true });
expect(bob.calendarAddress).toBe('mailto:bob@example.com');
expect(bob.scheduleAgent).toBe('server');
expect(bob.expectReply).toBe(true);
expect(bob.participationStatus).toBe('needs-action');
});
});
describe('collectUserCalendarAddresses', () => {
it('merges groups, dedupes case-insensitively and drops blanks', () => {
expect(
collectUserCalendarAddresses(['Me@example.com'], ['me@example.com', ' ', null, 'alias@example.com']),
).toEqual(['Me@example.com', 'alias@example.com']);
});
});
describe('organizer / participant detection', () => {
it('recognises the organizer via organizerCalendarAddress and an alias', () => {
expect(isOrganizer(event, ['alice@example.com'])).toBe(true);
expect(isOrganizer(event, ['bob@example.com'])).toBe(false);
expect(isOrganizer(event, [])).toBe(false);
});
it('finds the user participant by calendarAddress', () => {
expect(getUserParticipantId(event, ['CAROL@example.com'])).toBe('carol');
expect(getUserParticipantId(event, ['nobody@example.com'])).toBeNull();
});
it('marks the roles-less organizer participant as organizer', () => {
const list = getParticipantList(event);
expect(list.find((p) => p.id === 'org')?.isOrganizer).toBe(true);
expect(list.find((p) => p.id === 'bob')?.isOrganizer).toBe(false);
expect(list.find((p) => p.id === 'carol')?.email).toBe('carol@example.com');
});
it('does not count the organizer in status totals', () => {
expect(getStatusCounts(event)).toEqual({ accepted: 1, declined: 0, tentative: 0, 'needs-action': 1 });
});
});
describe('seedAttendees', () => {
it('never seeds the organizer or a repeated address (#731)', () => {
const attendees = seedAttendees(event, ['alice@example.com']);
expect(attendees.map((a) => a.email)).toEqual(['bob@example.com', 'carol@example.com']);
});
it('round-trips through buildParticipantMap without duplicating the organizer', () => {
const attendees = seedAttendees(event, ['alice@example.com']);
const map = buildParticipantMap({ name: 'Alice', email: 'alice@example.com' }, attendees);
const emails = Object.values(map).map((p) => p.email?.toLowerCase());
expect(emails.filter((e) => e === 'alice@example.com')).toHaveLength(1);
expect(emails).toHaveLength(3);
});
});
+249
View File
@@ -0,0 +1,249 @@
import type { CalendarEvent, Participant } from '../api/types';
import { generateUUID } from './uuid';
// Port of the webmail's lib/calendar-participants.ts.
export interface ParticipantInfo {
id: string;
name: string;
email: string;
status: Participant['participationStatus'];
isOrganizer: boolean;
}
export interface StatusCounts {
accepted: number;
declined: number;
tentative: number;
'needs-action': number;
}
/**
* Check if a participant matches any of the given email addresses.
* Checks p.email, p.calendarAddress (mailto:...), and p.sendTo values.
*/
function participantMatchesEmail(p: Participant, lowerEmails: string[]): boolean {
if (p.email && lowerEmails.includes(p.email.toLowerCase())) return true;
if (p.calendarAddress) {
const addr = p.calendarAddress.replace(/^mailto:/i, '').toLowerCase();
if (addr && lowerEmails.includes(addr)) return true;
}
if (p.sendTo) {
for (const addr of Object.values(p.sendTo)) {
const normalized = addr.replace(/^mailto:/i, '').toLowerCase();
if (normalized && lowerEmails.includes(normalized)) return true;
}
}
return false;
}
/** Best-effort scheduling address for a participant, without the mailto: scheme. */
export function getParticipantEmail(p: Participant): string {
if (p.email) return p.email;
if (p.calendarAddress) return p.calendarAddress.replace(/^mailto:/i, '');
if (p.sendTo?.imip) return p.sendTo.imip.replace(/^mailto:/i, '');
return '';
}
/**
* Collects the event-level organizer calendar address(es).
* Stalwart conveys the organizer via `organizerCalendarAddress` / `replyTo`
* rather than a participant `roles.owner` flag, so self-organized events
* imported from another server have no owner participant to match against.
*/
export function getEventOrganizerEmails(event: Partial<CalendarEvent>): string[] {
const emails: string[] = [];
if (event.organizerCalendarAddress) {
emails.push(event.organizerCalendarAddress.replace(/^mailto:/i, '').toLowerCase());
}
if (event.replyTo) {
for (const addr of Object.values(event.replyTo)) {
emails.push(addr.replace(/^mailto:/i, '').toLowerCase());
}
}
return emails.filter(Boolean);
}
/**
* Merge the user's calendar addresses (login address, identities, account
* aliases) so isOrganizer() recognises alias-organized events as the user's
* own. De-duplicated case-insensitively (first casing kept); blanks dropped.
*/
export function collectUserCalendarAddresses(
...groups: Array<ReadonlyArray<string | null | undefined>>
): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const group of groups) {
for (const raw of group) {
const trimmed = raw?.trim();
if (!trimmed) continue;
const key = trimmed.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(trimmed);
}
}
return out;
}
export function isOrganizer(event: Partial<CalendarEvent>, userEmails: string[]): boolean {
if (userEmails.length === 0) return false;
const lower = userEmails.map((e) => e.toLowerCase());
if (event.participants) {
const ownerMatch = Object.values(event.participants).some(
(p) => p.roles?.owner && participantMatchesEmail(p, lower),
);
if (ownerMatch) return true;
}
// Fall back to the event-level organizer address (Stalwart / imported events
// mark the organizer here instead of via a participant `owner` role).
return getEventOrganizerEmails(event).some((email) => lower.includes(email));
}
export function getUserParticipantId(
event: Partial<CalendarEvent>,
userEmails: string[],
): string | null {
if (!event.participants) return null;
const lower = userEmails.map((e) => e.toLowerCase());
for (const [id, p] of Object.entries(event.participants)) {
if (participantMatchesEmail(p, lower)) return id;
}
return null;
}
export function getUserStatus(
event: Partial<CalendarEvent>,
userEmails: string[],
): Participant['participationStatus'] | null {
if (!event.participants) return null;
const lower = userEmails.map((e) => e.toLowerCase());
for (const p of Object.values(event.participants)) {
if (participantMatchesEmail(p, lower)) return p.participationStatus;
}
return null;
}
export function getParticipantList(event: Partial<CalendarEvent>): ParticipantInfo[] {
if (!event.participants) return [];
// Stalwart rebuilds the ORGANIZER line into a participant that carries only
// `calendarAddress` — no `roles` at all — so `roles.owner` alone would treat
// the organizer as a plain attendee on every re-read (#731).
const organizerEmails = getEventOrganizerEmails(event);
return Object.entries(event.participants).map(([id, p]) => {
const email = getParticipantEmail(p);
return {
id,
name: p.name || '',
email,
status: p.participationStatus || 'needs-action',
isOrganizer:
!!p.roles?.owner || (!!email && organizerEmails.includes(email.toLowerCase())),
};
});
}
export function getStatusCounts(event: Partial<CalendarEvent>): StatusCounts {
const counts: StatusCounts = { accepted: 0, declined: 0, tentative: 0, 'needs-action': 0 };
if (!event.participants) return counts;
const organizerEmails = getEventOrganizerEmails(event);
for (const p of Object.values(event.participants)) {
// The organizer is not awaiting their own reply; counting the roles-less
// participant Stalwart derives from ORGANIZER inflates the pending total.
const email = getParticipantEmail(p).toLowerCase();
if (p.roles?.owner || (email && organizerEmails.includes(email))) continue;
const s = p.participationStatus || 'needs-action';
if (s in counts) counts[s as keyof StatusCounts]++;
}
return counts;
}
export function getParticipantCount(event: Partial<CalendarEvent>): number {
if (!event.participants) return 0;
return Object.keys(event.participants).length;
}
export interface Attendee {
name: string;
email: string;
}
export function buildParticipantMap(
organizer: { name: string; email: string },
attendees: Attendee[],
): Record<string, Participant> {
const participants: Record<string, Participant> = {};
const generateId = () => generateUUID();
// calendarAddress is the scheduling address in draft-ietf-calext-jscalendarbis
// (implemented by Stalwart); the RFC 8984 sendTo property is retired there and
// stored as an inert JSPROP, so it is intentionally not sent.
participants[generateId()] = {
'@type': 'Participant',
name: organizer.name,
email: organizer.email,
calendarAddress: `mailto:${organizer.email}`,
// owner only, NOT attendee: with roles.attendee set, Stalwart's server-side
// scheduling emits the organizer as an ATTENDEE line in addition to the
// ORGANIZER line, so the recipient sees the organizer listed twice.
roles: { owner: true },
participationStatus: 'accepted',
scheduleAgent: 'server',
expectReply: false,
kind: 'individual',
};
// The organizer already has an entry above, and an address must not appear
// twice in the invite list, so drop both cases case-insensitively (#731).
const seen = new Set<string>([organizer.email.trim().toLowerCase()]);
attendees.forEach((a) => {
const email = a.email.trim();
const key = email.toLowerCase();
if (!key || seen.has(key)) return;
seen.add(key);
participants[generateId()] = {
'@type': 'Participant',
name: a.name,
email,
calendarAddress: `mailto:${email}`,
roles: { attendee: true },
participationStatus: 'needs-action',
scheduleAgent: 'server',
expectReply: true,
kind: 'individual',
};
});
return participants;
}
/**
* The attendee rows an editor should start from: every participant except the
* organizer (who is re-added by buildParticipantMap on save), without
* duplicate addresses (#731).
*/
export function seedAttendees(
event: Partial<CalendarEvent> | null | undefined,
userEmails: string[],
): Attendee[] {
if (!event?.participants) return [];
const excluded = new Set<string>();
if (isOrganizer(event, userEmails) && userEmails[0]) {
excluded.add(userEmails[0].toLowerCase());
}
const out: Attendee[] = [];
for (const p of getParticipantList(event)) {
if (p.isOrganizer) continue;
const key = p.email.trim().toLowerCase();
if (!key || excluded.has(key)) continue;
excluded.add(key);
out.push({ name: p.name, email: p.email.trim() });
}
return out;
}
+70
View File
@@ -0,0 +1,70 @@
import React from 'react';
import { useAccountStore } from '../stores/account-store';
import { useSettingsStore } from '../stores/settings-store';
import { jmapClient } from '../api/jmap-client';
import { fetchPrincipal } from '../api/account-security';
import { collectUserCalendarAddresses } from './calendar-participants';
// Account aliases come from x:Account/get (Stalwart's principal object) and
// only change when an admin edits the account, so they're fetched once per
// JMAP account and remembered for the session. Failure (older server, no
// permission) simply leaves the list at login address + identities.
const aliasCache = new Map<string, string[]>();
const aliasInFlight = new Map<string, Promise<string[]>>();
const aliasListeners = new Set<() => void>();
function loadAliases(accountId: string): Promise<string[]> {
const cached = aliasCache.get(accountId);
if (cached) return Promise.resolve(cached);
const pending = aliasInFlight.get(accountId);
if (pending) return pending;
const p = fetchPrincipal()
.then((info) => info.emails)
.catch(() => [] as string[])
.then((emails) => {
aliasCache.set(accountId, emails);
aliasInFlight.delete(accountId);
for (const l of aliasListeners) l();
return emails;
});
aliasInFlight.set(accountId, p);
return p;
}
/** Test hook: forget cached aliases (also useful after re-login). */
export function resetUserCalendarAddressCache(): void {
aliasCache.clear();
aliasInFlight.clear();
}
/**
* Every address the signed-in user can be addressed at for scheduling: the
* login address, the sending identities and the account aliases. Used to
* find "me" among an event's participants (RSVP), to detect alias-organized
* events as the user's own, and as the organizer address of new invites.
*/
export function useUserCalendarAddresses(): string[] {
const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null);
const identities = useSettingsStore((s) => s.identities);
const [, bump] = React.useReducer((n: number) => n + 1, 0);
const accountId = jmapClient.isConnected ? jmapClient.accountId : null;
React.useEffect(() => {
if (!accountId) return;
if (aliasCache.has(accountId)) return;
aliasListeners.add(bump);
void loadAliases(accountId);
return () => { aliasListeners.delete(bump); };
}, [accountId]);
const aliases = accountId ? aliasCache.get(accountId) ?? [] : [];
return React.useMemo(
() => collectUserCalendarAddresses(
[activeEmail],
identities.map((i) => i.email),
aliases,
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[activeEmail, identities, aliases.join('|')],
);
}
+4
View File
@@ -73,6 +73,7 @@ import {
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar';
import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store';
import { useUserCalendarAddresses } from '../lib/calendar-user-addresses';
import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types';
type ViewMode = 'month' | 'week' | 'agenda';
@@ -126,6 +127,7 @@ export default function CalendarScreen() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const currentUserEmails = useUserCalendarAddresses();
const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView);
const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek);
const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
@@ -627,6 +629,7 @@ export default function CalendarScreen() {
event={detailEvent}
calendars={calendars}
timeFormat={calendarTimeFormat}
currentUserEmails={currentUserEmails}
onClose={() => setDetailEvent(null)}
onEdit={handleEditFromDetail}
onDelete={handleDeleteFromDetail}
@@ -651,6 +654,7 @@ export default function CalendarScreen() {
event={modalEvent}
calendars={eventCalendars}
defaultDate={modalDate}
currentUserEmails={currentUserEmails}
onSave={handleSave}
onDelete={handleDeleteFromModal}
onClose={() => setModalVisible(false)}
+10 -2
View File
@@ -375,12 +375,20 @@ export const useCalendarStore = create<CalendarState>()(
},
rsvpEvent: async (eventId, participantId, status, replyTo) => {
if (!participantId || participantId.includes('..')) {
// JMAP participant ids are opaque strings (they can contain @, ., :, /);
// the api layer RFC 6901-escapes them, so only reject empty values.
if (!participantId) {
throw new Error('Invalid participant ID');
}
const storeEvent = get().events.find((e) => e.id === eventId);
const realId = storeEvent?.originalId || eventId;
await apiRsvpEvent(realId, participantId, status, replyTo, storeEvent?.accountId);
// Repair events that are missing the organizer (e.g. imported ones) so
// Stalwart can route the REPLY; never touch an existing one.
const repair =
replyTo?.imip && storeEvent && !storeEvent.organizerCalendarAddress
? replyTo.imip
: undefined;
await apiRsvpEvent(realId, participantId, status, repair, storeEvent?.accountId);
set({
events: get().events.map((e) => {
if (e.id !== eventId || !e.participants?.[participantId]) return e;