From 3898d0c1cb6cd050b4690a644182eed2338f4c9a Mon Sep 17 00:00:00 2001
From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com>
Date: Mon, 10 Aug 2026 08:06:05 +0200
Subject: [PATCH 1/2] fix(calendar): rights-first event editability, incl.
alias organizers
Event editability was inferred from identity (isOrigin + organizer match)
and never from the calendar's write rights. That opened events in read-only
shared / iCal-subscription calendars for editing, and once the address list
was widened with account aliases any alias-organized event became editable -
even in calendars the user cannot write.
- lib/calendar-editability.ts: getEventEditability() -> editable | rsvp-only
| read-only, gating on the calendar's myRights (mayWriteAll / mayWriteOwn /
mayRSVP) first, then organizer/participant identity. Subscriptions are
read-only. Aliases now only refine the write-own / RSVP branches, so they
can no longer over-open events (Stalwart: organizer identity does not
confer calendar write; the ACL does).
- collectUserCalendarAddresses(): merge identities + account aliases so an
alias organizer is recognised as the user's own; calendar-app fetches the
principal aliases and passes isSubscriptionCalendar through.
- event-detail-popover / event-modal: derive Edit/Delete/RSVP/read-only from
the resolver (three render states); read-only is new - no edit, no RSVP.
Tests: calendar-editability (26 cases over rights x role x subscription x
origin, incl. the alias-in-read-only regression) + calendar-participants.
tsc + eslint clean.
---
components/calendar/calendar-app.tsx | 26 +-
components/calendar/event-detail-popover.tsx | 62 ++---
components/calendar/event-modal.tsx | 29 ++-
lib/__tests__/calendar-editability.test.ts | 243 +++++++++++++++++++
lib/__tests__/calendar-participants.test.ts | 51 ++++
lib/calendar-editability.ts | 62 +++++
lib/calendar-participants.ts | 24 ++
7 files changed, 456 insertions(+), 41 deletions(-)
create mode 100644 lib/__tests__/calendar-editability.test.ts
create mode 100644 lib/calendar-editability.ts
diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx
index 211a641f8..f674984c0 100644
--- a/components/calendar/calendar-app.tsx
+++ b/components/calendar/calendar-app.tsx
@@ -16,6 +16,7 @@ import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store";
+import { useAccountSecurityStore } from "@/stores/account-security-store";
import { usePolicyStore } from "@/stores/policy-store";
import { toast } from "@/stores/toast-store";
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
@@ -59,7 +60,7 @@ import { ShareCollectionDialog } from "@/components/settings/share-collection-di
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
-import { getUserParticipantId } from "@/lib/calendar-participants";
+import { getUserParticipantId, collectUserCalendarAddresses } from "@/lib/calendar-participants";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-calendar-colors";
import { debug } from "@/lib/debug";
@@ -106,7 +107,7 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) {
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar,
removeCalendar, clearCalendarEvents,
- refreshAllSubscriptions, icalSubscriptions,
+ refreshAllSubscriptions, icalSubscriptions, isSubscriptionCalendar,
} = useCalendarStore();
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
@@ -119,9 +120,21 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) {
const contacts = useContactStore((s) => s.contacts);
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
- const currentUserEmails = useMemo(() =>
- identities.map(id => id.email).filter(Boolean),
- [identities]
+ // Aliases live on the principal, not on identities; fetch them so an
+ // alias-organized event is recognised as the user's own (see isOrganizer).
+ const accountEmails = useAccountSecurityStore((s) => s.emails);
+ const fetchPrincipal = useAccountSecurityStore((s) => s.fetchPrincipal);
+ const principalFetchedRef = useRef(false);
+ useEffect(() => {
+ if (principalFetchedRef.current) return;
+ principalFetchedRef.current = true;
+ if (accountEmails.length > 0) return; // already loaded elsewhere
+ void fetchPrincipal();
+ }, [accountEmails, fetchPrincipal]);
+
+ const currentUserEmails = useMemo(
+ () => collectUserCalendarAddresses(identities.map(id => id.email), accountEmails),
+ [identities, accountEmails]
);
const [showEventModal, setShowEventModal] = useState(false);
@@ -1553,6 +1566,7 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) {
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
onPreviewChange={setPendingPreview}
currentUserEmails={currentUserEmails}
+ isSubscriptionCalendar={isSubscriptionCalendar}
isMobile={false}
/>
@@ -1662,6 +1676,7 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) {
onMouseEnter={() => { if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } }}
onMouseLeave={handleHoverLeave}
currentUserEmails={currentUserEmails}
+ isSubscriptionCalendar={isSubscriptionCalendar}
timeFormat={timeFormat}
isMobile={isMobile}
/>
@@ -1682,6 +1697,7 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) {
onRsvp={handleRsvp}
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
currentUserEmails={currentUserEmails}
+ isSubscriptionCalendar={isSubscriptionCalendar}
isMobile={true}
/>
)}
diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx
index bfc613bd4..d0b0389bf 100644
--- a/components/calendar/event-detail-popover.tsx
+++ b/components/calendar/event-detail-popover.tsx
@@ -15,11 +15,11 @@ import { parseDuration, getEventColor } from "./event-card";
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { buildRecurrenceSummary } from "./recurrence-editor";
import {
- isOrganizer,
getUserParticipantId,
getUserStatus,
getParticipantList,
} from "@/lib/calendar-participants";
+import { getEventEditability } from "@/lib/calendar-editability";
import { useFormatEventDate } from "@/hooks/use-format-event-date";
interface EventDetailPopoverProps {
@@ -35,6 +35,7 @@ interface EventDetailPopoverProps {
onMouseEnter?: () => void;
onMouseLeave?: () => void;
currentUserEmails?: string[];
+ isSubscriptionCalendar?: (calendarId: string) => boolean;
timeFormat?: "12h" | "24h";
isMobile?: boolean;
}
@@ -120,6 +121,7 @@ export function EventDetailPopover({
onMouseEnter,
onMouseLeave,
currentUserEmails = [],
+ isSubscriptionCalendar,
timeFormat = "24h",
isMobile,
}: EventDetailPopoverProps) {
@@ -156,15 +158,17 @@ export function EventDetailPopover({
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t, locale), [event, t, locale]);
const alertLabel = useMemo(() => getAlertLabel(event, t), [event, t]);
- const userIsOrganizer = useMemo(() => {
- if (!event.participants) return true;
- return isOrganizer(event, currentUserEmails);
- }, [event, currentUserEmails]);
-
- const isAttendeeMode = useMemo(() => {
- if (!event.participants) return false;
- return !event.isOrigin && !userIsOrganizer;
- }, [event, userIsOrganizer]);
+ // Gate affordances on calendar rights, not identity (see calendar-editability).
+ const editability = useMemo(() => {
+ const calendarsById = new Map(calendar ? [[calendar.id, calendar]] : []);
+ return getEventEditability(event, {
+ calendarsById,
+ userCalendarAddresses: currentUserEmails,
+ isSubscriptionCalendar: isSubscriptionCalendar ?? (() => false),
+ });
+ }, [event, calendar, currentUserEmails, isSubscriptionCalendar]);
+ const canEditBody = editability === "editable";
+ const rsvpMode = editability === "rsvp-only";
const userParticipantId = useMemo(
() => getUserParticipantId(event, currentUserEmails),
@@ -195,14 +199,14 @@ export function EventDetailPopover({
const tag = target?.tagName?.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") return;
if (target?.getAttribute("contenteditable") === "true") return;
- if (e.key === "e" && !noteExpanded) {
+ if (e.key === "e" && !noteExpanded && canEditBody) {
e.preventDefault();
onEdit();
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
- }, [onClose, onEdit, noteExpanded]);
+ }, [onClose, onEdit, noteExpanded, canEditBody]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
@@ -483,7 +487,7 @@ export function EventDetailPopover({
{/* Quick Note */}
- {!isAttendeeMode && (
+ {canEditBody && (
{noteExpanded ? (
@@ -533,7 +537,7 @@ export function EventDetailPopover({
)}
{/* RSVP Bar (for attendees) */}
- {isAttendeeMode && onRsvp && userParticipantId && (
+ {rsvpMode && onRsvp && userParticipantId && (
{t("participants.rsvp_label")}
@@ -608,10 +612,12 @@ export function EventDetailPopover({
) : (
<>
-
+ {canEditBody && (
+
+ )}
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx
index 5e0cfc145..3ad964dcc 100644
--- a/components/calendar/event-modal.tsx
+++ b/components/calendar/event-modal.tsx
@@ -19,6 +19,7 @@ import {
getStatusCounts,
buildParticipantMap,
} from "@/lib/calendar-participants";
+import { getEventEditability } from "@/lib/calendar-editability";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store";
import { generateUUID } from "@/lib/utils";
@@ -48,6 +49,7 @@ interface EventModalProps {
onClose: () => void;
onPreviewChange?: (preview: PendingEventPreview | null) => void;
currentUserEmails?: string[];
+ isSubscriptionCalendar?: (calendarId: string) => boolean;
isMobile?: boolean;
}
@@ -177,6 +179,7 @@ export function EventModal({
onClose,
onPreviewChange,
currentUserEmails = [],
+ isSubscriptionCalendar,
isMobile = false,
}: EventModalProps) {
const t = useTranslations("calendar");
@@ -193,10 +196,18 @@ export function EventModal({
return isOrganizer(event, currentUserEmails);
}, [event, currentUserEmails]);
- const isAttendeeMode = useMemo(() => {
- if (!event || !event.participants) return false;
- return !event.isOrigin && !userIsOrganizer;
- }, [event, userIsOrganizer]);
+ // Gate affordances on calendar rights, not identity (see calendar-editability).
+ const editability = useMemo(() => {
+ if (!event) return "editable" as const;
+ const calendarsById = new Map(calendars.map((c) => [c.id, c]));
+ return getEventEditability(event, {
+ calendarsById,
+ userCalendarAddresses: currentUserEmails,
+ isSubscriptionCalendar: isSubscriptionCalendar ?? (() => false),
+ });
+ }, [event, calendars, currentUserEmails, isSubscriptionCalendar]);
+ const canEditBody = editability === "editable";
+ const rsvpMode = editability === "rsvp-only";
const userParticipantId = useMemo(() => {
if (!event) return null;
@@ -601,12 +612,12 @@ export function EventModal({
}
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
- if (!isAttendeeMode) handleSave();
+ if (canEditBody) handleSave();
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
- }, [onClose, handleSave, isAttendeeMode, mode, isEdit]);
+ }, [onClose, handleSave, canEditBody, mode, isEdit]);
useEffect(() => {
const modal = modalRef.current;
@@ -634,7 +645,7 @@ export function EventModal({
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
- if (isAttendeeMode && event) {
+ if (rsvpMode && event) {
const startD = getEventStartDate(event);
const endD = getEventEndDate(event);
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
@@ -942,7 +953,7 @@ export function EventModal({
{/* Action Bar */}
- {onDelete && (
+ {onDelete && canEditBody && (
showDeleteConfirm ? (
{t("form.delete_confirm")}
@@ -967,7 +978,7 @@ export function EventModal({
)}
- {!showDeleteConfirm && (
+ {!showDeleteConfirm && canEditBody && (
setMode("edit")}>
{t("events.edit")}
diff --git a/lib/__tests__/calendar-editability.test.ts b/lib/__tests__/calendar-editability.test.ts
new file mode 100644
index 000000000..596381873
--- /dev/null
+++ b/lib/__tests__/calendar-editability.test.ts
@@ -0,0 +1,243 @@
+import { describe, it, expect } from 'vitest';
+import type { CalendarEvent, CalendarParticipant, CalendarRights } from '@/lib/jmap/types';
+import {
+ getEventEditability,
+ eventHasNoOwner,
+ type EventEditability,
+ type EditabilityContext,
+} from '@/lib/calendar-editability';
+
+function makeEvent(overrides: Partial = {}): CalendarEvent {
+ return {
+ '@type': 'Event',
+ id: 'ev1',
+ uid: 'uid-ev1',
+ calendarIds: { cal1: true },
+ title: 'Test Event',
+ description: '',
+ descriptionContentType: 'text/plain',
+ start: '2026-03-01T10:00:00',
+ duration: 'PT1H',
+ timeZone: 'UTC',
+ showWithoutTime: false,
+ status: 'confirmed',
+ freeBusyStatus: 'busy',
+ privacy: 'public',
+ keywords: null,
+ categories: null,
+ color: null,
+ recurrenceId: null,
+ recurrenceIdTimeZone: null,
+ recurrenceRules: null,
+ recurrenceOverrides: null,
+ excludedRecurrenceRules: null,
+ useDefaultAlerts: false,
+ alerts: null,
+ locations: null,
+ virtualLocations: null,
+ links: null,
+ relatedTo: null,
+ utcStart: null,
+ utcEnd: null,
+ isDraft: false,
+ isOrigin: true,
+ sequence: 0,
+ created: '2026-03-01T09:00:00Z',
+ updated: '2026-03-01T09:00:00Z',
+ locale: null,
+ replyTo: null,
+ organizerCalendarAddress: null,
+ participants: null,
+ mayInviteSelf: false,
+ mayInviteOthers: false,
+ hideAttendees: false,
+ ...overrides,
+ };
+}
+
+function makeRights(overrides: Partial = {}): CalendarRights {
+ return {
+ mayReadFreeBusy: false,
+ mayReadItems: false,
+ mayWriteAll: false,
+ mayWriteOwn: false,
+ mayUpdatePrivate: false,
+ mayRSVP: false,
+ mayShare: false,
+ mayDelete: false,
+ ...overrides,
+ };
+}
+
+const ALL_RIGHTS = makeRights({
+ mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
+ mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
+});
+const READ_ONLY = makeRights({ mayReadFreeBusy: true, mayReadItems: true });
+const WRITE_OWN = makeRights({
+ mayReadFreeBusy: true, mayReadItems: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true,
+});
+const RSVP_ONLY = makeRights({
+ mayReadFreeBusy: true, mayReadItems: true, mayUpdatePrivate: true, mayRSVP: true,
+});
+
+const SELF = 'alice@example.com';
+const ALIAS = 'info@example.com';
+const OTHER = 'carol@example.com';
+
+const owner = (email: string): Partial => ({
+ '@type': 'Participant', name: email, email, roles: { owner: true, attendee: true },
+ sendTo: { imip: `mailto:${email}` }, participationStatus: 'accepted', kind: 'individual',
+});
+const attendee = (email: string): Partial => ({
+ '@type': 'Participant', name: email, email, roles: { attendee: true },
+ sendTo: { imip: `mailto:${email}` }, participationStatus: 'needs-action', kind: 'individual',
+});
+
+/** Build a context whose single calendar `cal1` carries the given rights. */
+function ctx(
+ rights: CalendarRights | null,
+ addresses: string[] = [SELF],
+ subscriptions: string[] = [],
+): EditabilityContext {
+ const calendarsById = new Map(rights ? [['cal1', { myRights: rights }]] : []);
+ return {
+ calendarsById,
+ userCalendarAddresses: addresses,
+ isSubscriptionCalendar: (id) => subscriptions.includes(id),
+ };
+}
+
+describe('eventHasNoOwner', () => {
+ it('is true for a plain event with no participants/organizer', () => {
+ expect(eventHasNoOwner(makeEvent())).toBe(true);
+ });
+ it('is false when an owner-role participant exists', () => {
+ expect(eventHasNoOwner(makeEvent({ participants: { a: owner(SELF) } as never }))).toBe(false);
+ });
+ it('is false when an event-level organizer address exists', () => {
+ expect(eventHasNoOwner(makeEvent({ organizerCalendarAddress: `mailto:${OTHER}` }))).toBe(false);
+ });
+ it('is false when a reply destination exists', () => {
+ expect(eventHasNoOwner(makeEvent({ replyTo: { imip: `mailto:${OTHER}` } }))).toBe(false);
+ });
+ it('is true when only non-owner participants exist', () => {
+ expect(eventHasNoOwner(makeEvent({ participants: { b: attendee(OTHER) } as never }))).toBe(true);
+ });
+});
+
+describe('getEventEditability - calendar rights gate', () => {
+ it('mayWriteAll -> editable even when the user is not the organizer', () => {
+ const ev = makeEvent({ isOrigin: false, organizerCalendarAddress: `mailto:${OTHER}`,
+ participants: { a: owner(OTHER), b: attendee(SELF) } as never });
+ expect(getEventEditability(ev, ctx(ALL_RIGHTS))).toBe('editable');
+ });
+
+ it('read-only calendar -> read-only even for an event the user organizes', () => {
+ const ev = makeEvent({ organizerCalendarAddress: `mailto:${SELF}`,
+ participants: { a: owner(SELF) } as never });
+ expect(getEventEditability(ev, ctx(READ_ONLY))).toBe('read-only');
+ });
+
+ it('mayWriteOwn + user is organizer -> editable', () => {
+ const ev = makeEvent({ participants: { a: owner(SELF), b: attendee(OTHER) } as never });
+ expect(getEventEditability(ev, ctx(WRITE_OWN))).toBe('editable');
+ });
+
+ it('mayWriteOwn + user is a mere attendee -> rsvp-only (cannot edit body)', () => {
+ const ev = makeEvent({ isOrigin: false, organizerCalendarAddress: `mailto:${OTHER}`,
+ participants: { a: owner(OTHER), b: attendee(SELF) } as never });
+ expect(getEventEditability(ev, ctx(WRITE_OWN))).toBe('rsvp-only');
+ });
+
+ it('mayWriteOwn + ownerless event -> editable', () => {
+ const ev = makeEvent({ participants: { b: attendee(SELF) } as never });
+ expect(getEventEditability(ev, ctx(WRITE_OWN))).toBe('editable');
+ });
+
+ it('RSVP-only rights + participant -> rsvp-only', () => {
+ const ev = makeEvent({ isOrigin: false, organizerCalendarAddress: `mailto:${OTHER}`,
+ participants: { a: owner(OTHER), b: attendee(SELF) } as never });
+ expect(getEventEditability(ev, ctx(RSVP_ONLY))).toBe('rsvp-only');
+ });
+
+ it('RSVP-only rights + user is NOT a participant -> read-only', () => {
+ const ev = makeEvent({ isOrigin: false, organizerCalendarAddress: `mailto:${OTHER}`,
+ participants: { a: owner(OTHER), b: attendee('dave@example.com') } as never });
+ expect(getEventEditability(ev, ctx(RSVP_ONLY))).toBe('read-only');
+ });
+
+ it('no write / no rsvp rights -> read-only', () => {
+ const ev = makeEvent({ participants: { a: owner(SELF) } as never });
+ expect(getEventEditability(ev, ctx(READ_ONLY))).toBe('read-only');
+ });
+});
+
+describe('getEventEditability - subscription calendars', () => {
+ it('an event in a client-side iCal subscription is read-only regardless of rights/organizer', () => {
+ const ev = makeEvent({ organizerCalendarAddress: `mailto:${SELF}`,
+ participants: { a: owner(SELF) } as never });
+ // Even if the (irrelevant) rights map says all-writable, the subscription wins.
+ expect(getEventEditability(ev, ctx(ALL_RIGHTS, [SELF], ['cal1']))).toBe('read-only');
+ });
+});
+
+describe('getEventEditability - unknown calendar rights', () => {
+ it('own origin copy is editable when rights are not yet loaded', () => {
+ expect(getEventEditability(makeEvent({ isOrigin: true }), ctx(null))).toBe('editable');
+ });
+ it('a delivered (non-origin) copy stays read-only until rights load', () => {
+ expect(getEventEditability(makeEvent({ isOrigin: false }), ctx(null))).toBe('read-only');
+ });
+});
+
+describe('getEventEditability - alias over-permissiveness regression', () => {
+ it('organizer == account alias in a READ-ONLY shared calendar -> read-only (not editable)', () => {
+ // The alias branch would have matched isOrganizer and opened the editor; the
+ // rights gate must keep it read-only.
+ const ev = makeEvent({ isOrigin: false, organizerCalendarAddress: `mailto:${ALIAS}`,
+ participants: { a: owner(ALIAS) } as never });
+ expect(getEventEditability(ev, ctx(READ_ONLY, [SELF, ALIAS]))).toBe('read-only');
+ });
+
+ it('organizer == account alias in a WRITE-OWN calendar -> editable (legitimate alias edit)', () => {
+ const ev = makeEvent({ organizerCalendarAddress: `mailto:${ALIAS}`,
+ participants: { a: owner(ALIAS) } as never });
+ expect(getEventEditability(ev, ctx(WRITE_OWN, [SELF, ALIAS]))).toBe('editable');
+ });
+});
+
+describe('getEventEditability - multi-calendar membership', () => {
+ it('requires the right on EVERY calendar (a read-only member blocks edit)', () => {
+ const ev = makeEvent({ calendarIds: { cal1: true, cal2: true },
+ participants: { a: owner(SELF) } as never });
+ const context: EditabilityContext = {
+ calendarsById: new Map([
+ ['cal1', { myRights: ALL_RIGHTS }],
+ ['cal2', { myRights: READ_ONLY }],
+ ]),
+ userCalendarAddresses: [SELF],
+ isSubscriptionCalendar: () => false,
+ };
+ expect(getEventEditability(ev, context)).toBe('read-only');
+ });
+});
+
+describe('getEventEditability - matrix sanity', () => {
+ const cases: Array<[string, CalendarRights, boolean, EventEditability]> = [
+ ['writeAll/organizer', ALL_RIGHTS, true, 'editable'],
+ ['writeAll/attendee', ALL_RIGHTS, false, 'editable'],
+ ['writeOwn/organizer', WRITE_OWN, true, 'editable'],
+ ['writeOwn/attendee', WRITE_OWN, false, 'rsvp-only'],
+ ['rsvpOnly/attendee', RSVP_ONLY, false, 'rsvp-only'],
+ ['readOnly/organizer', READ_ONLY, true, 'read-only'],
+ ['readOnly/attendee', READ_ONLY, false, 'read-only'],
+ ];
+ it.each(cases)('%s -> %s', (_label, rights, userIsOrganizer, expected) => {
+ const ev = userIsOrganizer
+ ? makeEvent({ participants: { a: owner(SELF), b: attendee(OTHER) } as never })
+ : makeEvent({ isOrigin: false, organizerCalendarAddress: `mailto:${OTHER}`,
+ participants: { a: owner(OTHER), b: attendee(SELF) } as never });
+ expect(getEventEditability(ev, ctx(rights))).toBe(expected);
+ });
+});
diff --git a/lib/__tests__/calendar-participants.test.ts b/lib/__tests__/calendar-participants.test.ts
index 341410a61..0b287ae5a 100644
--- a/lib/__tests__/calendar-participants.test.ts
+++ b/lib/__tests__/calendar-participants.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
import {
isOrganizer,
+ collectUserCalendarAddresses,
getUserParticipantId,
getUserStatus,
getParticipantList,
@@ -179,6 +180,56 @@ describe('isOrganizer', () => {
event.organizerCalendarAddress = 'mailto:someoneelse@example.com';
expect(isOrganizer(event, ['alice@example.com'])).toBe(false);
});
+
+ // Regression: an event organized under one of the account's ALIAS addresses
+ // is still the user's own event and must be recognised as such (otherwise the
+ // calendar UI flips it to a read-only invite). The primary address alone does
+ // not match; the alias has to be part of the user's address list.
+ it('does NOT match an alias organizer when only the primary address is known', () => {
+ const event = makeEvent({ att1: attendeeParticipant });
+ event.organizerCalendarAddress = 'mailto:info@example.com'; // an account alias
+ expect(isOrganizer(event, ['alice@example.com'])).toBe(false);
+ });
+
+ it('matches an alias organizer once the alias is included in the address list', () => {
+ const event = makeEvent({ att1: attendeeParticipant });
+ event.organizerCalendarAddress = 'mailto:info@example.com';
+ const addresses = collectUserCalendarAddresses(['alice@example.com'], ['info@example.com']);
+ expect(isOrganizer(event, addresses)).toBe(true);
+ });
+
+ it('matches an alias owner participant once the alias is included', () => {
+ const event = makeEvent({
+ org: { ...orgParticipant, email: 'info@example.com', sendTo: { imip: 'mailto:info@example.com' } },
+ });
+ const addresses = collectUserCalendarAddresses(['alice@example.com'], ['INFO@example.com']);
+ expect(isOrganizer(event, addresses)).toBe(true);
+ });
+});
+
+describe('collectUserCalendarAddresses', () => {
+ it('merges identity and alias addresses', () => {
+ expect(collectUserCalendarAddresses(['alice@example.com'], ['info@example.com', 'sales@example.com']))
+ .toEqual(['alice@example.com', 'info@example.com', 'sales@example.com']);
+ });
+
+ it('de-duplicates case-insensitively, keeping the first-seen casing', () => {
+ expect(collectUserCalendarAddresses(['Alice@Example.com'], ['alice@example.com', 'INFO@example.com']))
+ .toEqual(['Alice@Example.com', 'INFO@example.com']);
+ });
+
+ it('drops empty, undefined and whitespace-only entries', () => {
+ expect(collectUserCalendarAddresses(['alice@example.com', '', ' ', undefined, null], []))
+ .toEqual(['alice@example.com']);
+ });
+
+ it('trims surrounding whitespace', () => {
+ expect(collectUserCalendarAddresses([' alice@example.com '], [])).toEqual(['alice@example.com']);
+ });
+
+ it('returns an empty array when given no addresses', () => {
+ expect(collectUserCalendarAddresses([], [])).toEqual([]);
+ });
});
describe('getUserParticipantId', () => {
diff --git a/lib/calendar-editability.ts b/lib/calendar-editability.ts
new file mode 100644
index 000000000..3de37d9e9
--- /dev/null
+++ b/lib/calendar-editability.ts
@@ -0,0 +1,62 @@
+import type { CalendarEvent, Calendar, CalendarRights } from '@/lib/jmap/types';
+import { isOrganizer, getUserParticipantId } from '@/lib/calendar-participants';
+
+export type EventEditability = 'editable' | 'rsvp-only' | 'read-only';
+
+export interface EditabilityContext {
+ /** Viewer's calendars by id; only their `myRights` is read. */
+ calendarsById: ReadonlyMap>;
+ /** Identities + aliases; only refines owner/participant detection, never the gate. */
+ userCalendarAddresses: string[];
+ /** Client-side external iCal (webcal/ICS) subscriptions - always read-only. */
+ isSubscriptionCalendar: (calendarId: string) => boolean;
+}
+
+/** Ownerless (plain, non-scheduled) events are editable under `mayWriteOwn`. */
+export function eventHasNoOwner(event: CalendarEvent): boolean {
+ if (event.organizerCalendarAddress) return false;
+ if (event.replyTo && Object.keys(event.replyTo).length > 0) return false;
+ if (event.participants) {
+ for (const p of Object.values(event.participants)) {
+ if (p.roles?.owner) return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * Editability gated on the calendar's `myRights` FIRST, then identity - so
+ * read-only/subscription calendars stay read-only and an alias organizer can't
+ * over-open events the user can't write. (Stalwart: organizer identity does not
+ * confer calendar write; the ACL does.)
+ */
+export function getEventEditability(
+ event: CalendarEvent,
+ ctx: EditabilityContext,
+): EventEditability {
+ const calIds = Object.keys(event.calendarIds ?? {});
+
+ // Subscriptions have no write path and myRights can't describe them.
+ if (calIds.some((id) => ctx.isSubscriptionCalendar(id))) return 'read-only';
+
+ const rights = calIds
+ .map((id) => ctx.calendarsById.get(id)?.myRights)
+ .filter((r): r is CalendarRights => Boolean(r));
+
+ // Rights not loaded: allow only the user's own origin copy for now.
+ if (rights.length === 0) return event.isOrigin ? 'editable' : 'read-only';
+
+ // Every calendar must grant the right (a read-only member blocks edit).
+ const may = (k: keyof CalendarRights) => rights.every((r) => r[k]);
+ const isOwner =
+ isOrganizer(event, ctx.userCalendarAddresses) || eventHasNoOwner(event);
+
+ if (may('mayWriteAll')) return 'editable';
+ if (may('mayWriteOwn') && isOwner) return 'editable';
+
+ const isParticipant =
+ getUserParticipantId(event, ctx.userCalendarAddresses) != null;
+ if (may('mayRSVP') && isParticipant) return 'rsvp-only';
+
+ return 'read-only';
+}
diff --git a/lib/calendar-participants.ts b/lib/calendar-participants.ts
index fea07a6de..1f0d32434 100644
--- a/lib/calendar-participants.ts
+++ b/lib/calendar-participants.ts
@@ -62,6 +62,30 @@ export function getEventOrganizerEmails(event: CalendarEvent): string[] {
return emails.filter(Boolean);
}
+/**
+ * Merge the user's calendar addresses (identities + account aliases) so
+ * isOrganizer() recognises alias-organized events as the user's own. Identities
+ * alone carry only one address each, so an alias organizer looked foreign.
+ * De-duplicated case-insensitively (first casing kept); blanks dropped.
+ */
+export function collectUserCalendarAddresses(
+ ...groups: Array>
+): string[] {
+ const seen = new Set();
+ 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: CalendarEvent, userEmails: string[]): boolean {
if (userEmails.length === 0) return false;
const lower = userEmails.map(e => e.toLowerCase());
From 06784581aad2bb655d20171f24134a6ee66dc0fc Mon Sep 17 00:00:00 2001
From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com>
Date: Mon, 10 Aug 2026 09:22:26 +0200
Subject: [PATCH 2/2] fix(calendar): exclude subscription/read-only calendars
from event creation (#762)
The rights-first resolver already made EXISTING events in subscription /
read-only calendars read-only, but the create path still offered them:
event-modal's calendar dropdown listed every calendar. iCal subscriptions are
real Stalwart calendars (client-side feed written into a created calendar) with
writable myRights, so they appeared as selectable create destinations.
- canCreateEventsIn(calendar, isSubscriptionCalendar): writable AND not a
subscription. Exported from lib/calendar-editability.
- event-modal: the calendar picker now lists only creatable calendars (plus the
event's own calendar when editing), and new events never default into a
subscription / read-only calendar.
+4 unit tests. tsc + eslint clean; calendar-editability suite 30 green.
---
components/calendar/event-modal.tsx | 18 ++++++++++++++----
lib/__tests__/calendar-editability.test.ts | 19 +++++++++++++++++++
lib/calendar-editability.ts | 14 ++++++++++++++
3 files changed, 47 insertions(+), 4 deletions(-)
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx
index 3ad964dcc..b7f947ca5 100644
--- a/components/calendar/event-modal.tsx
+++ b/components/calendar/event-modal.tsx
@@ -19,7 +19,7 @@ import {
getStatusCounts,
buildParticipantMap,
} from "@/lib/calendar-participants";
-import { getEventEditability } from "@/lib/calendar-editability";
+import { getEventEditability, canCreateEventsIn } from "@/lib/calendar-editability";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store";
import { generateUUID } from "@/lib/utils";
@@ -272,8 +272,18 @@ export function EventModal({
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId;
const defaultCal = calendars.find(c => c.isDefault);
- return defaultCal?.id || calendars[0]?.id || "";
+ // Never default new events into a subscription / read-only calendar (#762).
+ return defaultCal?.id
+ || calendars.find(c => canCreateEventsIn(c, isSubscriptionCalendar))?.id
+ || calendars[0]?.id || "";
});
+
+ // Calendars offered as create/move targets: writable, non-subscription, plus
+ // the event's current calendar so an in-place edit never blanks the select.
+ const selectableCalendars = useMemo(
+ () => calendars.filter(c => canCreateEventsIn(c, isSubscriptionCalendar) || c.id === calendarId),
+ [calendars, isSubscriptionCalendar, calendarId]
+ );
const [recurrence, setRecurrence] = useState(() => {
if (!event?.recurrenceRules?.length) return "none";
const rule = event.recurrenceRules[0];
@@ -1165,7 +1175,7 @@ export function EventModal({
)}
- {calendars.length > 1 && (
+ {selectableCalendars.length > 1 && (