diff --git a/components/calendar/__tests__/answer-bars.test.tsx b/components/calendar/__tests__/answer-bars.test.tsx new file mode 100644 index 000000000..875232e23 --- /dev/null +++ b/components/calendar/__tests__/answer-bars.test.tsx @@ -0,0 +1,65 @@ +import { createElement } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { AnswerBars, useAnswerView } from '../answer-bars'; +import { useAuthStore } from '@/stores/auth-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { CALENDAR_PRESETS } from '@/lib/jmap/share-rights'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; +import type { AnswerTarget } from '@/lib/calendar-delegate'; + +const event = { + id: 'alice:e1', uid: 'u1', title: 'Planning', start: '2026-11-04T14:00:00', duration: 'PT1H', + calendarIds: { 'alice:b': true }, isShared: true, accountId: 'alice', + organizerCalendarAddress: 'mailto:carol@example.org', + participants: { + carol: { '@type': 'Participant', name: 'Carol', email: 'carol@example.org', roles: { owner: true }, participationStatus: 'accepted' }, + alice: { '@type': 'Participant', name: 'Alice', email: 'alice@example.org', roles: { attendee: true }, participationStatus: 'needs-action' }, + }, +} as unknown as CalendarEvent; + +const calendar = { + id: 'alice:b', originalId: 'b', name: 'Alice', accountId: 'alice', accountName: 'alice@example.org', isShared: true, + myRights: CALENDAR_PRESETS.readWrite, +} as unknown as Calendar; + +function Harness({ onRespond }: { onRespond: (target: AnswerTarget, status: string) => void }) { + const view = useAnswerView(event, calendar, ['bob@example.org'], false); + return createElement(AnswerBars, { view, onRespond }); +} + +describe('AnswerBars', () => { + afterEach(() => { + useAuthStore.setState({ client: null } as never); + }); + + it('offers "Answer for " with the owner\'s name and answers with SENT-BY', async () => { + const getAccountOwners = vi.fn(async () => ({ alice: { id: 'alice', type: 'individual' as const, name: 'alice@example.org', description: 'Alice Doe', email: 'alice@example.org' } })); + useAuthStore.setState({ client: { getAccountOwners, hasAccountCapability: () => true } } as never); + useCalendarStore.setState({ events: [event] }); + const onRespond = vi.fn(); + render(createElement(Harness, { onRespond })); + + const bar = await screen.findByTestId('answer-for-owner'); + // The i18n mock renders keys; the group is named by the label. + expect(bar).toHaveAttribute('aria-label', 'answer_for'); + expect(getAccountOwners).toHaveBeenCalledWith(['alice']); + fireEvent.click(bar.querySelectorAll('button')[0]); + expect(onRespond).toHaveBeenCalledWith(expect.objectContaining({ + kind: 'owner', eventId: 'alice:e1', participantId: 'alice', sentBy: 'mailto:bob@example.org', + }), 'accepted'); + expect(screen.queryByTestId('answer-self')).toBeNull(); + }); + + it('says the user can\'t answer for the owner with read access only', async () => { + useAuthStore.setState({ client: { getAccountOwners: async () => ({}), hasAccountCapability: () => true } } as never); + const readOnly = { ...calendar, myRights: CALENDAR_PRESETS.read } as Calendar; + function ReadOnly() { + const view = useAnswerView(event, readOnly, ['bob@example.org'], false); + return createElement(AnswerBars, { view, onRespond: vi.fn() }); + } + render(createElement(ReadOnly)); + expect(await screen.findByTestId('answer-note-no_write')).toHaveTextContent('no_write'); + expect(screen.queryByTestId('answer-for-owner')).toBeNull(); + }); +}); diff --git a/components/calendar/answer-bars.tsx b/components/calendar/answer-bars.tsx new file mode 100644 index 000000000..b0a449ec0 --- /dev/null +++ b/components/calendar/answer-bars.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { useAuthStore } from "@/stores/auth-store"; +import { useCalendarStore } from "@/stores/calendar-store"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import type { Calendar, CalendarEvent, CalendarParticipant, Principal } from "@/lib/jmap/types"; +import { accountOwnerOf, resolveAnswerTargets, type AnswerTarget, type AnswerView } from "@/lib/calendar-delegate"; +import { RsvpButtons } from "./rsvp-buttons"; + +type Status = CalendarParticipant["participationStatus"]; + +const owners = new WeakMap>>(); + +/** The principal behind a shared account, asked once per client. */ +function loadAccountOwner(client: IJMAPClient, accountId: string): Promise { + let cache = owners.get(client); + if (!cache) { + cache = new Map(); + owners.set(client, cache); + } + let owner = cache.get(accountId); + if (!owner) { + owner = client.getAccountOwners + ? client.getAccountOwners([accountId]).then((found) => found[accountId] ?? null).catch(() => null) + : Promise.resolve(null); + cache.set(accountId, owner); + } + return owner; +} + +/** + * The answer bars of an event: the user's own, and "Answer for " for + * an invitation in a calendar shared with them by its owner + * (lib/calendar-delegate.ts). + */ +export function useAnswerView( + event: CalendarEvent | null, + calendar: Calendar | null | undefined, + userEmails: string[], + canRsvpSelf: boolean, +): AnswerView { + const client = useAuthStore((s) => s.client); + const events = useCalendarStore((s) => s.events); + const sharedAccountId = calendar?.isShared ? calendar.accountId ?? null : null; + const [principal, setPrincipal] = useState<{ accountId: string; principal: Principal | null } | null>(null); + + useEffect(() => { + if (!client || !sharedAccountId) return; + let cancelled = false; + void loadAccountOwner(client, sharedAccountId).then((found) => { + if (!cancelled) setPrincipal({ accountId: sharedAccountId, principal: found }); + }); + return () => { cancelled = true; }; + }, [client, sharedAccountId]); + + return useMemo(() => { + if (!event) return { targets: [], note: null, answeredBy: null }; + const owner = sharedAccountId + ? accountOwnerOf(principal?.accountId === sharedAccountId ? principal.principal : null, calendar?.accountName) + : null; + const ownCopy = event.uid + ? events.find((e) => e.uid === event.uid && !e.isShared && e.id !== event.id && !e.recurrenceId) ?? null + : null; + // Stalwart 1.0 lists principals:owner in every account; 0.16 never does. + const server016 = !!client && !!sharedAccountId + && !client.hasAccountCapability("urn:ietf:params:jmap:principals:owner", sharedAccountId); + return resolveAnswerTargets({ event, calendar, userEmails, canRsvpSelf, owner, ownCopy, server016 }); + }, [event, calendar, userEmails, canRsvpSelf, sharedAccountId, principal, events, client]); +} + +/** + * The bars themselves: "Your response" and "Answer for ", each with + * accept, maybe and decline, and the line that says why there is no bar + * for the owner, or who answered last for them. + */ +export function AnswerBars({ view, size, onRespond, className }: { + view: AnswerView; + size?: "xs"; + onRespond: (target: AnswerTarget, status: Status) => void; + className?: string; +}) { + const t = useTranslations("calendar"); + const tDelegate = useTranslations("calendar_delegate"); + if (view.targets.length === 0 && !view.note && !view.answeredBy) return null; + return ( +
+
+ {view.targets.map((target) => { + const label = target.kind === "owner" + ? tDelegate("answer_for", { name: target.ownerName ?? "" }) + : t("participants.rsvp_label"); + return ( +
+ {label} + onRespond(target, status)} size={size} /> +
+ ); + })} +
+ {view.note && ( +

+ {tDelegate(view.note.kind, { name: view.note.owner })} +

+ )} + {view.answeredBy && ( +

+ {tDelegate("answered_by", { who: view.answeredBy })} +

+ )} +
+ ); +} diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 8af63f0a7..a5759c0b3 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -77,6 +77,7 @@ import { SchedulingDeniedError } from "@/lib/jmap/scheduling-error"; import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal"; import { CalendarSidebarHeader } from "@/components/calendar/calendar-sidebar-header"; import { getUserParticipantId, collectUserCalendarAddresses, userOrganizes, getParticipantList } from "@/lib/calendar-participants"; +import type { AnswerTarget } from "@/lib/calendar-delegate"; import type { MoveEventHandler } from "@/hooks/use-time-grid-interactions"; import type { RevealTime } from "@/hooks/use-display-hours"; import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; @@ -123,7 +124,7 @@ import { requestPrint, usePrintStore } from "@/stores/print-store"; type PendingScopeAction = | { type: "edit"; event: CalendarEvent; updates: Partial; editor?: EventEditorState; sendScheduling?: boolean; fromEditor?: boolean } | { type: "delete"; event: CalendarEvent; sendScheduling?: boolean; fromEditor?: boolean } - | { type: "rsvp"; event: CalendarEvent; participantId: string; status: CalendarParticipant["participationStatus"] }; + | { type: "rsvp"; event: CalendarEvent; participantId: string; status: CalendarParticipant["participationStatus"]; sentBy?: string }; function isRecurringEvent(event: CalendarEvent): boolean { return (event.recurrenceRules?.length ?? 0) > 0 || event.recurrenceId != null; @@ -1353,10 +1354,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = participantId: string, status: CalendarParticipant['participationStatus'], scope: 'occurrence' | 'series' = 'series', + sentBy?: string, ) => { if (!client) return; try { - if (await rsvpEvent(client, eventId, participantId, status, undefined, scope)) toast.success(t("notifications.rsvp_updated")); + if (await rsvpEvent(client, eventId, participantId, status, undefined, scope, sentBy ? { sentBy } : undefined)) toast.success(t("notifications.rsvp_updated")); } catch (error) { toast.error(describeError(error, t("notifications.rsvp_error"))); } @@ -1365,9 +1367,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const handleScopeSelect = useCallback(async (scope: RecurrenceEditScope) => { if (!client || !pendingScopeAction) { toast.error(t("notifications.event_error")); return; } if (pendingScopeAction.type === "rsvp") { - const { event, participantId, status } = pendingScopeAction; + const { event, participantId, status, sentBy } = pendingScopeAction; setPendingScopeAction(null); - await submitRsvp(event.id, participantId, status, scope === "this" ? "occurrence" : "series"); + await submitRsvp(event.id, participantId, status, scope === "this" ? "occurrence" : "series", sentBy); return; } const { type, event, sendScheduling, fromEditor } = pendingScopeAction; @@ -1473,16 +1475,16 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = }); }, [client, updateEvent, resolveContactName, describeError, t]); - const handleRsvp = useCallback(async (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => { + const handleRsvp = useCallback(async (eventId: string, participantId: string, status: CalendarParticipant['participationStatus'], sentBy?: string) => { if (!client) return; // An answer on one occurrence of a series asks whether it covers just // that occurrence or the whole series (#1086). const occurrence = events.find(e => e.id === eventId && e.recurrenceId != null); if (occurrence) { - setPendingScopeAction({ type: "rsvp", event: occurrence, participantId, status }); + setPendingScopeAction({ type: "rsvp", event: occurrence, participantId, status, sentBy }); return; } - await submitRsvp(eventId, participantId, status); + await submitRsvp(eventId, participantId, status, "series", sentBy); }, [client, events, submitRsvp]); const handleDeleteFromDetail = useCallback(() => { @@ -1571,12 +1573,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = handleDeleteEvent(event.id, hasParticipants || undefined); }, [handleDeleteEvent]); - const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus']) => { + const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus'], target?: AnswerTarget) => { if (!detailEvent || !client) return; - const participantId = getUserParticipantId(detailEvent, currentUserEmails); + // A target names the event and participant: the owner's in a calendar + // shared with the user, or the user's own copy (lib/calendar-delegate.ts). + const participantId = target?.participantId ?? getUserParticipantId(detailEvent, currentUserEmails); if (!participantId) return; closeDetail(); - await handleRsvp(detailEvent.id, participantId, status); + await handleRsvp(target?.eventId ?? detailEvent.id, participantId, status, target?.sentBy); }, [detailEvent, client, currentUserEmails, closeDetail, handleRsvp]); // `/` and the palette's "Search events" go to the search box. diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 2b0c234e1..4c995ab3b 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -19,7 +19,6 @@ import { buildRecurrenceSummary } from "./recurrence-editor"; import { formatEventAlertsLabel } from "./alert-list-editor"; import { getUserParticipantId, - getUserStatus, getParticipantList, } from "@/lib/calendar-participants"; import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability"; @@ -29,7 +28,9 @@ import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@ import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { eventToView } from "@/lib/plugin-projection"; -import { ParticipantStatusBadge, RsvpButtons } from "./rsvp-buttons"; +import { ParticipantStatusBadge } from "./rsvp-buttons"; +import { AnswerBars, useAnswerView } from "./answer-bars"; +import type { AnswerTarget } from "@/lib/calendar-delegate"; import { isEditableEventTarget } from "@/lib/keyboard"; import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts"; import { useCvdColor } from "@/hooks/use-cvd-color"; @@ -47,7 +48,8 @@ interface EventDetailPopoverProps { onPrint?: () => void; onClose: () => void; onSaveNote: (note: string) => void; - onRsvp?: (status: CalendarParticipant["participationStatus"]) => void; + /** Without a target: the user's own answer on this event. */ + onRsvp?: (status: CalendarParticipant["participationStatus"], target?: AnswerTarget) => void; onMouseEnter?: () => void; onMouseLeave?: () => void; currentUserEmails?: string[]; @@ -234,10 +236,7 @@ export function EventDetailPopover({ [event, currentUserEmails] ); - const userCurrentStatus = useMemo( - () => getUserStatus(event, currentUserEmails), - [event, currentUserEmails] - ); + const answerView = useAnswerView(event, calendar, currentUserEmails, canRsvp && !!userParticipantId); const formatTime = useCallback( (d: Date) => format(d, timeFormat === "12h" ? "h:mm a" : "HH:mm"), @@ -687,14 +686,14 @@ export function EventDetailPopover({ )} - {/* RSVP Bar (for attendees) */} - {canRsvp && onRsvp && userParticipantId && ( -
-

- {t("participants.rsvp_label")} -

- -
+ {/* RSVP bars: the user's own, and "Answer for " in a calendar shared with them. */} + {onRsvp && ( + onRsvp(status, target)} + /> )} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 69719494b..66462242a 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -9,7 +9,7 @@ import { LinkifiedText } from "@/components/ui/linkified-text"; import { X, Trash2, Users, CalendarDays, CopyPlus, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, AlertTriangle, MoreVertical } from "@/components/icons"; import { IconButton } from "@/components/ui/icon-button"; import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links"; -import { ParticipantStatusBadge, RsvpButtons } from "./rsvp-buttons"; +import { ParticipantStatusBadge } from "./rsvp-buttons"; import { format, parseISO, addHours, isSameDay } from "date-fns"; import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types"; import { AlertListEditor, AlertSummary, formatEventAlertsLabel } from "./alert-list-editor"; @@ -22,10 +22,11 @@ import { createRecurrenceRule, sameRecurrenceRules } from "@/lib/recurrence-rule import { ParticipantInput, type ParticipantInputHandle } from "./participant-input"; import { ParticipantAvailability } from "./participant-availability"; import { FindATime } from "./find-a-time"; +import { AnswerBars, useAnswerView } from "./answer-bars"; +import type { AnswerTarget } from "@/lib/calendar-delegate"; import { eventOrganizer, getUserParticipantId, - getUserStatus, getParticipantList, getStatusCounts, participantFieldsToSave, @@ -79,7 +80,8 @@ interface EventModalProps { onSave: (data: Partial, sendSchedulingMessages?: boolean, editor?: EventEditorState) => void | Promise; onDelete?: (id: string, sendSchedulingMessages?: boolean) => void; onDuplicate?: (data: Partial) => void; - onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void; + /** `sentBy`: answering for the calendar's owner (lib/calendar-delegate.ts). */ + onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus'], sentBy?: string) => void; onClose: () => void; onPreviewChange?: (preview: PendingEventPreview | null) => void; /** Opens the event's action menu (export, copy, ...) under the ⋮ of the event view. */ @@ -331,11 +333,6 @@ export function EventModal({ return getUserParticipantId(event, currentUserEmails); }, [event, currentUserEmails]); - const userCurrentStatus = useMemo(() => { - if (!event) return null; - return getUserStatus(event, currentUserEmails); - }, [event, currentUserEmails]); - const existingParticipants = useMemo(() => { if (!event) return []; return getParticipantList(event, { resolveName: resolveContactName }); @@ -936,11 +933,18 @@ export function EventModal({ } }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, onSave, isSaving, t]); - const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => { - if (!event || !userParticipantId || !onRsvp) return; - onRsvp(event.id, userParticipantId, status); + // The user's own answer, and the owner's in a calendar shared with them + // (lib/calendar-delegate.ts). + const eventCalendar = useMemo( + () => (event ? calendars.find((c) => event.calendarIds?.[c.id]) ?? null : null), + [event, calendars], + ); + const answerView = useAnswerView(event ?? null, eventCalendar, currentUserEmails, canRsvp && !!userParticipantId); + const handleAnswer = useCallback((target: AnswerTarget, status: CalendarParticipant['participationStatus']) => { + if (!onRsvp) return; + onRsvp(target.eventId, target.participantId, status, target.sentBy); onClose(); - }, [event, userParticipantId, onRsvp, onClose]); + }, [onRsvp, onClose]); const handleDuplicate = useCallback(() => { if (!event || !onDuplicate) return; @@ -1195,10 +1199,12 @@ export function EventModal({ The viewer owes a reply but may also edit their own copy, so the rsvp-only branch above does not apply to this event. */} - {canRsvp && onRsvp && userParticipantId && ( -
- -
+ {onRsvp && ( + )} {/* Action Bar */} @@ -1594,21 +1600,3 @@ export function EventModal({ ); } - -/** - * The accept / tentative / decline row. Shared by the rsvp-only view and by the - * read-only view that an *editable* received invite lands in - the latter is the - * case that had no way to answer an invite at all (#937). - */ -function RsvpBar({ status, onRespond, t }: { - status: CalendarParticipant['participationStatus'] | null; - onRespond: (status: CalendarParticipant['participationStatus']) => void; - t: ReturnType; -}) { - return ( -
- {t("participants.rsvp_label")} - -
- ); -} diff --git a/components/email/__tests__/calendar-invitation-banner.test.tsx b/components/email/__tests__/calendar-invitation-banner.test.tsx index b6561856a..a22638249 100644 --- a/components/email/__tests__/calendar-invitation-banner.test.tsx +++ b/components/email/__tests__/calendar-invitation-banner.test.tsx @@ -588,6 +588,31 @@ describe('CalendarInvitationBanner', () => { expect(await screen.findByText('Response sent')).toBeInTheDocument(); }); + it("answers on the user's own copy, not on a copy in a calendar shared with them (#634)", async () => { + const participants = { + user: { '@type': 'Participant', email: 'user@example.com', name: 'User', roles: { attendee: true }, participationStatus: 'needs-action' }, + boss: { '@type': 'Participant', email: 'boss@example.com', name: 'Boss', roles: { attendee: true }, participationStatus: 'needs-action' }, + }; + // The boss shared their calendar with the user, and both are invited: + // the copy in the boss's calendar is in the store first. + mocks.calendarState.events = [ + { id: 'boss:event-9', uid: 'uid-9', start: '2026-03-30T09:00:00Z', participants, isShared: true, accountId: 'boss' }, + { id: 'event-9', uid: 'uid-9', start: '2026-03-30T09:00:00Z', participants }, + ]; + mocks.clientMock.parseCalendarEvents.mockResolvedValue([ + { uid: 'uid-9', title: 'Both invited', start: '2026-03-30T09:00:00Z', participants }, + ]); + mocks.calendarState.rsvpEvent.mockResolvedValue(true); + + render(); + + fireEvent.click(await screen.findByText('Accept')); + + await waitFor(() => { + expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(mocks.clientMock, 'event-9', 'user', 'accepted', null); + }); + }); + it('says nothing was sent when a plugin stops the answer', async () => { mocks.calendarState.events = [ { diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index c0e3df709..3f858fde4 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -572,8 +572,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const allowsRsvp = method === 'request'; const allowsImport = method === 'request' || method === 'publish' || method === 'add' || method === 'unknown'; + // The user's own copy: a calendar shared with them by someone invited too + // holds that person's copy under the same uid, and answering there would + // answer for them (or, on Stalwart 0.16, for whichever address it picks; + // the duplicate attendees and wrong senders of #634). const existingEvent = parsedEvent?.uid - ? storeEvents.find((e) => e.uid === parsedEvent.uid) + ? storeEvents.find((e) => e.uid === parsedEvent.uid && !e.isShared) : null; const currentUserParticipant = existingEvent diff --git a/lib/__tests__/calendar-delegate.test.ts b/lib/__tests__/calendar-delegate.test.ts new file mode 100644 index 000000000..8f3e27de1 --- /dev/null +++ b/lib/__tests__/calendar-delegate.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { accountOwnerOf, resolveAnswerTargets, sentByPatchValue, type ResolveAnswerInput } from '../calendar-delegate'; +import { CALENDAR_PRESETS } from '@/lib/jmap/share-rights'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +const participant = (email: string, extra: Record = {}) => ({ + '@type': 'Participant', name: email.split('@')[0], email, calendarAddress: `mailto:${email}`, + roles: { attendee: true }, participationStatus: 'needs-action', ...extra, +}); + +function invitation(attendees: string[], extra: Partial = {}): CalendarEvent { + const participants: Record = { + carol: participant('carol@example.org', { roles: { owner: true }, participationStatus: 'accepted' }), + }; + for (const a of attendees) participants[a.split('@')[0]] = participant(a); + return { + id: 'alice:e1', uid: 'u1', title: 'Planning', start: '2026-11-04T14:00:00', duration: 'PT1H', + calendarIds: { 'alice:b': true }, isShared: true, accountId: 'alice', + organizerCalendarAddress: 'mailto:carol@example.org', + participants, ...extra, + } as unknown as CalendarEvent; +} + +const ALICE = accountOwnerOf({ id: 'alice', type: 'individual', name: 'alice@example.org', description: 'Alice Doe', email: 'alice@example.org' }, 'alice@example.org')!; +const TEAM = accountOwnerOf({ id: 'team', type: 'group', name: 'team@example.org', description: 'Team', email: 'team@example.org' }, 'team@example.org')!; +const write = { isShared: true, myRights: CALENDAR_PRESETS.readWrite }; +const base = (event: CalendarEvent, extra: Partial = {}): ResolveAnswerInput => ({ + event, calendar: write, userEmails: ['bob@example.org'], canRsvpSelf: false, owner: ALICE, server016: false, ...extra, +}); + +describe('resolveAnswerTargets', () => { + it('answers for the owner of a shared calendar with SENT-BY mailto:', () => { + const view = resolveAnswerTargets(base(invitation(['alice@example.org']))); + expect(view.note).toBeNull(); + expect(view.targets).toEqual([{ + kind: 'owner', eventId: 'alice:e1', participantId: 'alice', status: 'needs-action', + ownerName: 'Alice Doe', sentBy: 'mailto:bob@example.org', + }]); + }); + + it('answers as a group mailbox without SENT-BY', () => { + const view = resolveAnswerTargets(base(invitation(['team@example.org'], { accountId: 'team' }), { owner: TEAM })); + expect(view.targets).toHaveLength(1); + expect(view.targets[0]).toMatchObject({ kind: 'owner', participantId: 'team', ownerName: 'Team' }); + expect(view.targets[0].sentBy).toBeUndefined(); + }); + + it('offers no answer for the owner without edit access, and says why', () => { + for (const rights of [CALENDAR_PRESETS.read, { ...CALENDAR_PRESETS.read, mayRSVP: true }]) { + const view = resolveAnswerTargets(base(invitation(['alice@example.org']), { calendar: { isShared: true, myRights: rights } })); + expect(view.targets).toEqual([]); + expect(view.note).toEqual({ kind: 'no_write', owner: 'Alice Doe' }); + } + }); + + it('shows both answers on 1.0 when the user is invited too: the owner\'s here, the user\'s on their own copy', () => { + const shared = invitation(['alice@example.org', 'bob@example.org']); + const own = { ...invitation(['alice@example.org', 'bob@example.org']), id: 'e7', isShared: false, accountId: undefined } as CalendarEvent; + const view = resolveAnswerTargets(base(shared, { canRsvpSelf: true, ownCopy: own })); + expect(view.targets.map((t) => [t.kind, t.eventId, t.participantId])).toEqual([ + ['owner', 'alice:e1', 'alice'], + ['self', 'e7', 'bob'], + ]); + }); + + it('refuses the owner\'s answer on 0.16 when the user is invited too', () => { + // 0.16 answered "Cannot modify address of the event" live: it counts the + // delegate's and the owner's addresses as its own. + const shared = invitation(['alice@example.org', 'bob@example.org']); + const own = { ...shared, id: 'e7', isShared: false } as CalendarEvent; + const view = resolveAnswerTargets(base(shared, { canRsvpSelf: true, ownCopy: own, server016: true })); + expect(view.note).toEqual({ kind: 'also_invited', owner: 'Alice Doe' }); + expect(view.targets.map((t) => [t.kind, t.eventId])).toEqual([['self', 'e7']]); + // Only invited alone, 0.16 answers for the owner as 1.0 does. + expect(resolveAnswerTargets(base(invitation(['alice@example.org']), { server016: true })).targets[0].kind).toBe('owner'); + }); + + it('says who answered for the owner, and for the user in their own calendar', () => { + const shared = invitation(['alice@example.org']); + (shared.participants!.alice as { sentBy?: string }).sentBy = 'mailto:bob@example.org'; + expect(resolveAnswerTargets(base(shared)).answeredBy).toBe('bob@example.org'); + const own = { ...shared, isShared: false } as CalendarEvent; + const view = resolveAnswerTargets({ event: own, calendar: { isShared: false, myRights: CALENDAR_PRESETS.manager }, userEmails: ['alice@example.org'], canRsvpSelf: true, owner: null, server016: false }); + expect(view.targets.map((t) => t.kind)).toEqual(['self']); + expect(view.answeredBy).toBe('bob@example.org'); + }); + + it('keeps the existing behaviour where the owner is the organiser or not invited', () => { + const organised = invitation(['dave@example.org'], { organizerCalendarAddress: 'mailto:alice@example.org' }); + expect(resolveAnswerTargets(base(organised)).targets).toEqual([]); + const notInvited = invitation(['dave@example.org']); + expect(resolveAnswerTargets(base(notInvited)).targets).toEqual([]); + expect(resolveAnswerTargets(base(notInvited)).note).toBeNull(); + }); +}); + +describe('accountOwnerOf', () => { + it('falls back to the account name when the principal is unknown', () => { + expect(accountOwnerOf(null, 'Alice@Example.org')).toEqual({ name: 'Alice@Example.org', emails: ['alice@example.org'], isGroup: false }); + expect(accountOwnerOf(null, 'shared-calendar')).toBeNull(); + }); +}); + +describe('sentByPatchValue', () => { + it('sets, clears or leaves sentBy', () => { + expect(sentByPatchValue({}, 'mailto:bob@example.org')).toBe('mailto:bob@example.org'); + expect(sentByPatchValue({ sentBy: 'mailto:bob@example.org' }, undefined)).toBeNull(); + expect(sentByPatchValue({}, undefined)).toBeUndefined(); + }); +}); diff --git a/lib/calendar-delegate.ts b/lib/calendar-delegate.ts new file mode 100644 index 000000000..5aa8555e9 --- /dev/null +++ b/lib/calendar-delegate.ts @@ -0,0 +1,139 @@ +/** + * Answering an invitation for someone else (#634). + * + * A delegate with edit access (mayWriteAll) to another person's calendar + * answers an invitation in that calendar as its owner: the patch sets the + * owner's participationStatus with `sentBy = "mailto:"` in the + * owner's account, and Stalwart sends the organiser a REPLY from the owner + * with ATTENDEE;SENT-BY (live on 0.16 and 1.0, + * local-data/enterprise-pack-facts.md part 6 §2). mayRSVP alone is refused + * by the server. A group account's members answer as the group without + * SENT-BY: a member is not a delegate. + * + * Who "you" are depends on the calendar: in an account of another person or + * a group, its owner's addresses pick the participant, not the user's own. + * + * Stalwart 0.16 counts the delegate's and the owner's addresses as local, so + * with both invited it refuses the change ("Cannot modify address of the + * event", verified live). There the owner bar is not offered when the user is + * invited too. + */ + +import type { Calendar, CalendarEvent, CalendarParticipant, Principal } from '@/lib/jmap/types'; +import { eventHasNoOwner } from '@/lib/calendar-editability'; +import { getUserParticipantId, isOrganizer } from '@/lib/calendar-participants'; + +type Status = CalendarParticipant['participationStatus']; + +/** The owner of a shared account, as Bulwark shows and matches it. */ +export interface AccountOwner { + name: string; + /** Lower-case addresses the owner's participant may carry. */ + emails: string[]; + isGroup: boolean; +} + +/** The owner from Principal/get, else from the session's account name. */ +export function accountOwnerOf(principal: Principal | null | undefined, accountName: string | undefined): AccountOwner | null { + const emails = new Set(); + if (principal?.email) emails.add(principal.email.trim().toLowerCase()); + if (accountName?.includes('@')) emails.add(accountName.trim().toLowerCase()); + if (emails.size === 0) return null; + const name = principal?.description?.trim() || principal?.name?.trim() || accountName || [...emails][0]; + return { name, emails: [...emails], isGroup: principal?.type === 'group' }; +} + +export interface AnswerTarget { + kind: 'self' | 'owner'; + /** The store event the answer is written to. */ + eventId: string; + participantId: string; + status: Status | null; + /** `mailto:` when answering for a person; left out otherwise. */ + sentBy?: string; + /** Whose answer it is, for an owner target. */ + ownerName?: string; +} + +export interface AnswerView { + targets: AnswerTarget[]; + /** Why there is no bar for the owner, when they are invited. */ + note: { kind: 'no_write' | 'also_invited'; owner: string } | null; + /** Who answered last for the participant shown, when someone did for them (sentBy). */ + answeredBy: string | null; +} + +function addressOf(sentBy: unknown): string | null { + if (typeof sentBy !== 'string' || !sentBy.trim()) return null; + return sentBy.trim().replace(/^mailto:/i, ''); +} + +export interface ResolveAnswerInput { + event: CalendarEvent; + /** The event's calendar as the store holds it. */ + calendar?: Pick | null; + /** The signed-in user's addresses; the first is the one answers are sent by. */ + userEmails: string[]; + /** Whether the existing rules let the user answer for themselves on this event (canUserRsvp). */ + canRsvpSelf: boolean; + /** The owner of the event's account, for an event in a shared calendar. */ + owner: AccountOwner | null; + /** The user's own copy of the event (same uid, own calendar), if loaded. */ + ownCopy?: CalendarEvent | null; + /** The server is Stalwart 0.16 (see the module comment). */ + server016: boolean; +} + +/** Which answer bars an event shows, and what to say instead of one. */ +export function resolveAnswerTargets(input: ResolveAnswerInput): AnswerView { + const { event, calendar, userEmails, owner, ownCopy } = input; + const selfId = getUserParticipantId(event, userEmails); + const selfTarget = (): AnswerTarget[] => (input.canRsvpSelf && selfId + ? [{ kind: 'self', eventId: event.id, participantId: selfId, status: event.participants?.[selfId]?.participationStatus ?? null }] + : []); + const ownCopyTarget = (): AnswerTarget[] => { + if (!ownCopy) return []; + const id = getUserParticipantId(ownCopy, userEmails); + return id ? [{ kind: 'self', eventId: ownCopy.id, participantId: id, status: ownCopy.participants?.[id]?.participationStatus ?? null }] : []; + }; + + if (calendar?.isShared && owner) { + const ownerId = getUserParticipantId(event, owner.emails); + if (ownerId && ownerId !== selfId && !isOrganizer(event, owner.emails) && !eventHasNoOwner(event)) { + const participant = event.participants![ownerId]; + const answeredBy = addressOf(participant.sentBy); + if (!calendar.myRights?.mayWriteAll) { + return { targets: selfTarget(), note: { kind: 'no_write', owner: owner.name }, answeredBy }; + } + if (input.server016 && selfId) { + return { targets: ownCopyTarget(), note: { kind: 'also_invited', owner: owner.name }, answeredBy }; + } + const delegate = userEmails[0]?.trim().toLowerCase(); + const ownerTarget: AnswerTarget = { + kind: 'owner', + eventId: event.id, + participantId: ownerId, + status: participant.participationStatus ?? null, + ownerName: owner.name, + // Always the mailto: form: Stalwart passes sentBy through unchecked, + // and RFC 5545's SENT-BY needs a cal-address. + ...(!owner.isGroup && delegate ? { sentBy: `mailto:${delegate}` } : {}), + }; + return { targets: [ownerTarget, ...(selfId ? ownCopyTarget() : [])], note: null, answeredBy }; + } + } + + const answeredBy = selfId ? addressOf(event.participants?.[selfId]?.sentBy) : null; + return { targets: selfTarget(), note: null, answeredBy }; +} + +/** + * The sentBy part of an answer's patch: the delegate's address when one + * answers for the owner, null to clear an old one when the participant + * answers themselves (Stalwart keeps the old SENT-BY on the next reply + * otherwise), nothing when there is nothing to change. + */ +export function sentByPatchValue(participant: Pick | undefined, sentBy: string | undefined): string | null | undefined { + if (sentBy) return sentBy; + return participant?.sentBy ? null : undefined; +} diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 8e3ae5177..90d95dc90 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -629,6 +629,8 @@ export interface IJMAPClient { listPrincipals?(targetAccountId?: string, options?: { throwOnError?: boolean }): Promise; /** Principal type by id ("individual", "group"); null when unknown. Optional (demo client). */ getPrincipalTypes?(ids: string[]): Promise | null>; + /** The principals behind accounts (who owns a shared calendar); missing ids are left out. Optional (demo client). */ + getAccountOwners?(ids: string[]): Promise>; // Bulwark data in Inbox metadata (lib/jmap/bulwark-metadata.ts). Optional (demo client). supportsBulwarkMetadata?(accountId?: string): boolean; getBulwarkMetadata?(accountId?: string, options?: { private?: boolean }): Promise | null>; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 285bd9569..eb659cb27 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -7313,6 +7313,30 @@ export class JMAPClient implements IJMAPClient { } } + /** + * The principals behind accounts, by account id: who owns a calendar shared + * with the user, with their name and address. Principal/get answers for + * accounts the user can access even with directory queries off. A failed + * request gives an empty answer. + */ + async getAccountOwners(ids: string[]): Promise> { + if (!this.supportsPrincipals() || ids.length === 0) return {}; + try { + const response = await this.request([ + ["Principal/get", { accountId: this.accountId, ids, properties: ["id", "type", "name", "description", "email"] }, "0"], + ], this.principalsUsing()); + const [method, result] = response.methodResponses?.[0] ?? []; + if (method !== "Principal/get") return {}; + const owners: Record = {}; + for (const principal of (result as { list?: Principal[] }).list ?? []) { + if (principal.id) owners[principal.id] = principal; + } + return owners; + } catch { + return {}; + } + } + /** * Accounts whose metadata request failed, with when: not asked again for * ten minutes. A dropped connection (the pre-release 1.0 build) looks the diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 9f1db48d7..107af8909 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -778,6 +778,11 @@ export interface CalendarParticipant { scheduleStatus: string[] | null; scheduleUpdated: string | null; invitedBy: string | null; + /** + * Who answered for this participant (RFC 5545 SENT-BY), as a + * `mailto:` address. Set when a delegate answers for the calendar's owner. + */ + sentBy?: string | null; delegatedTo: Record | null; delegatedFrom: Record | null; memberOf: Record | null; diff --git a/lib/recurrence-instances.ts b/lib/recurrence-instances.ts index 285ec9862..715895d11 100644 --- a/lib/recurrence-instances.ts +++ b/lib/recurrence-instances.ts @@ -687,9 +687,15 @@ export function buildOccurrenceRsvpPatch( participantId: string, status: CalendarParticipant['participationStatus'], options: OverridePatchOptions = {}, + sentBy?: string | null, ): Partial | null { const participant = occurrence.participants?.[participantId]; if (!participant) return null; + // sentBy: who answers for the participant (lib/calendar-delegate.ts); null + // drops an old one, left out keeps what is there. + const answered: CalendarParticipant = { ...participant, participationStatus: status }; + if (sentBy) answered.sentBy = sentBy; + else if (sentBy === null) delete answered.sentBy; const patch: Record = {}; if (occurrence.sequence != null) patch.sequence = occurrence.sequence; if (options.seriesKeys && occurrence.organizerCalendarAddress) { @@ -697,7 +703,7 @@ export function buildOccurrenceRsvpPatch( } patch.participants = { ...occurrence.participants, - [participantId]: { ...participant, participationStatus: status }, + [participantId]: answered, }; return withNewOverrideDetails(occurrence, patch as Partial, options); } diff --git a/locales/ar/common.json b/locales/ar/common.json index 584b71534..b68f8bfa9 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -4845,6 +4845,12 @@ "cancelled": "ألغى {name} \"{title}\"", "untitled": "حدث بدون عنوان" }, + "calendar_delegate": { + "answer_for": "الرد نيابةً عن {name}", + "no_write": "يمكنك رؤية دعوات {name} لكن لا يمكنك الرد عليها. اطلب صلاحية التعديل.", + "also_invited": "أنت مدعو أيضًا، ولا يستطيع هذا الخادم التمييز بين ردك ورد {name}. على {name} الرد على هذه الدعوة.", + "answered_by": "ردّ عليها {who}" + }, "free_busy_share": { "button": "مشاركة أوقات توفري مع {group}", "already": "أوقات توفرك مشتركة مع {group}", diff --git a/locales/ca/common.json b/locales/ca/common.json index 5871d97c2..5aae20cf3 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -4845,6 +4845,12 @@ "cancelled": "{name} ha cancel·lat \"{title}\"", "untitled": "Esdeveniment sense títol" }, + "calendar_delegate": { + "answer_for": "Respon en nom de {name}", + "no_write": "Podeu veure les invitacions de {name}, però no podeu respondre en nom seu. Demaneu-li accés d’edició.", + "also_invited": "També esteu convidat, i aquest servidor no distingeix la vostra resposta de la de {name}. {name} ha de respondre aquesta.", + "answered_by": "Resposta de {who}" + }, "free_busy_share": { "button": "Comparteix la meva disponibilitat amb {group}", "already": "La vostra disponibilitat està compartida amb {group}", diff --git a/locales/cs/common.json b/locales/cs/common.json index 08beef52e..b61fd10de 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5283,6 +5283,12 @@ "cancelled": "{name} zrušil(a) „{title}“", "untitled": "Událost bez názvu" }, + "calendar_delegate": { + "answer_for": "Odpovědět za: {name}", + "no_write": "Vidíte pozvánky uživatele {name}, ale nemůžete za něj odpovídat. Požádejte o přístup k úpravám.", + "also_invited": "Jste pozváni také a tento server nerozliší vaši odpověď od odpovědi uživatele {name}. Na tuto musí odpovědět {name}.", + "answered_by": "Odpověděl(a): {who}" + }, "free_busy_share": { "button": "Sdílet můj volný čas se skupinou {group}", "already": "Váš volný čas je sdílen se skupinou {group}", diff --git a/locales/da/common.json b/locales/da/common.json index c1c99480f..803fbe420 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -4845,6 +4845,12 @@ "cancelled": "{name} har aflyst \"{title}\"", "untitled": "Unavngivet begivenhed" }, + "calendar_delegate": { + "answer_for": "Svar for {name}", + "no_write": "Du kan se invitationerne til {name}, men kan ikke svare for dem. Bed om redigeringsadgang.", + "also_invited": "Du er også inviteret, og denne server kan ikke skelne dit svar fra svaret fra {name}. {name} skal selv svare på denne.", + "answered_by": "Besvaret af {who}" + }, "free_busy_share": { "button": "Del min ledig/optaget med {group}", "already": "Din ledig/optaget er delt med {group}", diff --git a/locales/de/common.json b/locales/de/common.json index 11f3b2148..98f964ff0 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5283,6 +5283,12 @@ "cancelled": "{name} hat „{title}“ abgesagt", "untitled": "Unbenannter Termin" }, + "calendar_delegate": { + "answer_for": "Für {name} antworten", + "no_write": "Sie sehen die Einladungen von {name}, können aber nicht dafür antworten. Bitten Sie um Bearbeitungszugriff.", + "also_invited": "Sie sind ebenfalls eingeladen, und dieser Server kann Ihre Antwort nicht von der von {name} unterscheiden. {name} muss hier selbst antworten.", + "answered_by": "Beantwortet von {who}" + }, "free_busy_share": { "button": "Meine Frei/Gebucht-Zeiten für {group} freigeben", "already": "Ihre Frei/Gebucht-Zeiten sind für {group} freigegeben", diff --git a/locales/en/common.json b/locales/en/common.json index 116409c5f..5b8bccffa 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -4845,6 +4845,12 @@ "cancelled": "{name} cancelled \"{title}\"", "untitled": "Untitled event" }, + "calendar_delegate": { + "answer_for": "Answer for {name}", + "no_write": "You can see {name}’s invitations but can’t answer for them. Ask them for edit access.", + "also_invited": "You’re invited too, and this server can’t tell your answer from {name}’s. {name} has to answer this one.", + "answered_by": "Answered by {who}" + }, "free_busy_share": { "button": "Share my free/busy with {group}", "already": "Your free/busy is shared with {group}", diff --git a/locales/es/common.json b/locales/es/common.json index f379ec9b1..58a19e117 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5283,6 +5283,12 @@ "cancelled": "{name} ha cancelado \"{title}\"", "untitled": "Evento sin título" }, + "calendar_delegate": { + "answer_for": "Responder por {name}", + "no_write": "Puedes ver las invitaciones de {name}, pero no responder por esa persona. Pídele acceso de edición.", + "also_invited": "Tú también estás invitado y este servidor no distingue tu respuesta de la de {name}. {name} tiene que responder esta.", + "answered_by": "Respondido por {who}" + }, "free_busy_share": { "button": "Compartir mi disponibilidad con {group}", "already": "Tu disponibilidad está compartida con {group}", diff --git a/locales/et/common.json b/locales/et/common.json index 54aa09f4e..390fd7a31 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -4845,6 +4845,12 @@ "cancelled": "{name} tühistas sündmuse \"{title}\"", "untitled": "Nimetu sündmus" }, + "calendar_delegate": { + "answer_for": "Vasta isiku {name} eest", + "no_write": "Näed isiku {name} kutseid, aga ei saa tema eest vastata. Küsi temalt muutmisõigust.", + "also_invited": "Oled samuti kutsutud ja see server ei erista sinu vastust isiku {name} vastusest. Sellele peab vastama {name}.", + "answered_by": "Vastas {who}" + }, "free_busy_share": { "button": "Jaga minu vaba/hõivatud aega grupiga {group}", "already": "Sinu vaba/hõivatud aeg on jagatud grupiga {group}", diff --git a/locales/fa/common.json b/locales/fa/common.json index 33fbfb86e..d4366e843 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -4845,6 +4845,12 @@ "cancelled": "{name} «{title}» را لغو کرد", "untitled": "رویداد بدون عنوان" }, + "calendar_delegate": { + "answer_for": "پاسخ به‌جای {name}", + "no_write": "دعوت‌های {name} را می‌بینید اما نمی‌توانید به‌جای او پاسخ دهید. دسترسی ویرایش بخواهید.", + "also_invited": "شما هم دعوت شده‌اید و این سرور پاسخ شما را از پاسخ {name} تشخیص نمی‌دهد. {name} باید خودش پاسخ دهد.", + "answered_by": "پاسخ‌دهنده: {who}" + }, "free_busy_share": { "button": "هم‌رسانی آزاد/مشغول من با {group}", "already": "آزاد/مشغول شما با {group} هم‌رسانی شده است", diff --git a/locales/fr/common.json b/locales/fr/common.json index fbdb03f60..159d78f71 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5283,6 +5283,12 @@ "cancelled": "{name} a annulé « {title} »", "untitled": "Événement sans titre" }, + "calendar_delegate": { + "answer_for": "Répondre pour {name}", + "no_write": "Vous voyez les invitations de {name}, mais vous ne pouvez pas y répondre à sa place. Demandez-lui un accès en modification.", + "also_invited": "Vous êtes aussi invité, et ce serveur ne distingue pas votre réponse de celle de {name}. {name} doit répondre lui-même à celle-ci.", + "answered_by": "Réponse de {who}" + }, "free_busy_share": { "button": "Partager mes disponibilités avec {group}", "already": "Vos disponibilités sont partagées avec {group}", diff --git a/locales/he/common.json b/locales/he/common.json index 4374888e4..8dadd4140 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5255,6 +5255,12 @@ "cancelled": "{name} ביטל/ה את \"{title}\"", "untitled": "אירוע ללא כותרת" }, + "calendar_delegate": { + "answer_for": "השב בשם {name}", + "no_write": "אפשר לראות את ההזמנות של {name}, אבל אי אפשר להשיב בשמו. בקשו הרשאת עריכה.", + "also_invited": "גם אתם מוזמנים, והשרת הזה לא מבדיל בין התשובה שלכם לזו של {name}. {name} צריך להשיב בעצמו.", + "answered_by": "נענה על ידי {who}" + }, "free_busy_share": { "button": "שתף את הזמינות שלי עם {group}", "already": "הזמינות שלך משותפת עם {group}", diff --git a/locales/hu/common.json b/locales/hu/common.json index 5338b9049..4cdb86d75 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -4845,6 +4845,12 @@ "cancelled": "{name} lemondta a(z) „{title}” eseményt", "untitled": "Névtelen esemény" }, + "calendar_delegate": { + "answer_for": "Válasz {name} nevében", + "no_write": "Látja {name} meghívóit, de nem válaszolhat a nevében. Kérjen szerkesztési jogot.", + "also_invited": "Ön is meghívott, és ez a szerver nem tudja megkülönböztetni az Ön válaszát {name} válaszától. Erre {name} válaszoljon.", + "answered_by": "Válaszolt: {who}" + }, "free_busy_share": { "button": "Szabad/foglalt időim megosztása ezzel: {group}", "already": "Szabad/foglalt idői meg vannak osztva ezzel: {group}", diff --git a/locales/it/common.json b/locales/it/common.json index 0cac7d77f..7470d3233 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5283,6 +5283,12 @@ "cancelled": "{name} ha annullato \"{title}\"", "untitled": "Evento senza titolo" }, + "calendar_delegate": { + "answer_for": "Rispondi per {name}", + "no_write": "Puoi vedere gli inviti di {name} ma non rispondere al suo posto. Chiedi l’accesso in modifica.", + "also_invited": "Sei invitato anche tu e questo server non distingue la tua risposta da quella di {name}. Deve rispondere {name}.", + "answered_by": "Risposto da {who}" + }, "free_busy_share": { "button": "Condividi la mia disponibilità con {group}", "already": "La tua disponibilità è condivisa con {group}", diff --git a/locales/ja/common.json b/locales/ja/common.json index 3410dabc0..9c9345547 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5283,6 +5283,12 @@ "cancelled": "{name} さんが「{title}」をキャンセルしました", "untitled": "無題の予定" }, + "calendar_delegate": { + "answer_for": "{name} の代わりに返答", + "no_write": "{name} の招待は見られますが、代わりに返答することはできません。編集権限を依頼してください。", + "also_invited": "あなたも招待されていて、このサーバーではあなたの返答と {name} の返答を区別できません。この招待には {name} 本人が返答する必要があります。", + "answered_by": "{who} が返答しました" + }, "free_busy_share": { "button": "自分の空き時間を {group} と共有", "already": "空き時間は {group} と共有されています", diff --git a/locales/ko/common.json b/locales/ko/common.json index 2431ad99b..4594d1539 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5283,6 +5283,12 @@ "cancelled": "{name}님이 \"{title}\"을(를) 취소했습니다", "untitled": "제목 없는 일정" }, + "calendar_delegate": { + "answer_for": "{name} 대신 응답", + "no_write": "{name}의 초대는 볼 수 있지만 대신 응답할 수는 없습니다. 편집 권한을 요청하세요.", + "also_invited": "나도 초대되어 있어 이 서버는 내 응답과 {name}의 응답을 구분하지 못합니다. 이 초대는 {name}이(가) 직접 응답해야 합니다.", + "answered_by": "{who}이(가) 응답함" + }, "free_busy_share": { "button": "내 일정 가능 여부를 {group}과(와) 공유", "already": "일정 가능 여부가 {group}과(와) 공유되어 있습니다", diff --git a/locales/lv/common.json b/locales/lv/common.json index c2a66d6ee..ea1432b0d 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5283,6 +5283,12 @@ "cancelled": "{name} atcēla “{title}”", "untitled": "Notikums bez nosaukuma" }, + "calendar_delegate": { + "answer_for": "Atbildēt {name} vārdā", + "no_write": "Jūs redzat {name} ielūgumus, bet nevarat atbildēt viņa vārdā. Palūdziet rediģēšanas piekļuvi.", + "also_invited": "Arī jūs esat uzaicināts, un šis serveris nevar atšķirt jūsu atbildi no {name} atbildes. Uz šo jāatbild {name}.", + "answered_by": "Atbildēja {who}" + }, "free_busy_share": { "button": "Kopīgot manu brīvs/aizņemts ar {group}", "already": "Jūsu brīvs/aizņemts ir kopīgots ar {group}", diff --git a/locales/mn/common.json b/locales/mn/common.json index 4ccce0a76..8c20c20c8 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -4845,6 +4845,12 @@ "cancelled": "{name} \"{title}\"-г цуцаллаа", "untitled": "Нэргүй арга хэмжээ" }, + "calendar_delegate": { + "answer_for": "{name}-ийн өмнөөс хариулах", + "no_write": "Та {name}-ийн урилгыг харж болох ч өмнөөс нь хариулж чадахгүй. Засах эрх хүснэ үү.", + "also_invited": "Таныг ч бас урьсан тул энэ сервер таны болон {name}-ийн хариуг ялгаж чадахгүй. Үүнд {name} өөрөө хариулах ёстой.", + "answered_by": "{who} хариулсан" + }, "free_busy_share": { "button": "Миний чөлөөтэй/завгүй цагийг {group}-тэй хуваалцах", "already": "Таны чөлөөтэй/завгүй цаг {group}-тэй хуваалцагдсан", diff --git a/locales/nb/common.json b/locales/nb/common.json index 614217b6d..927235b9d 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -4845,6 +4845,12 @@ "cancelled": "{name} har avlyst «{title}»", "untitled": "Hendelse uten tittel" }, + "calendar_delegate": { + "answer_for": "Svar for {name}", + "no_write": "Du ser invitasjonene til {name}, men kan ikke svare for dem. Be om redigeringstilgang.", + "also_invited": "Du er også invitert, og denne serveren kan ikke skille svaret ditt fra svaret til {name}. {name} må svare på denne selv.", + "answered_by": "Besvart av {who}" + }, "free_busy_share": { "button": "Del min ledig/opptatt med {group}", "already": "Din ledig/opptatt er delt med {group}", diff --git a/locales/nl/common.json b/locales/nl/common.json index c26218436..fc6864a89 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5283,6 +5283,12 @@ "cancelled": "{name} heeft \"{title}\" geannuleerd", "untitled": "Afspraak zonder titel" }, + "calendar_delegate": { + "answer_for": "Antwoorden namens {name}", + "no_write": "Je ziet de uitnodigingen van {name}, maar kunt er niet namens hen op antwoorden. Vraag om bewerkrechten.", + "also_invited": "Je bent zelf ook uitgenodigd en deze server kan jouw antwoord niet onderscheiden van dat van {name}. {name} moet deze zelf beantwoorden.", + "answered_by": "Beantwoord door {who}" + }, "free_busy_share": { "button": "Mijn vrij/bezet delen met {group}", "already": "Je vrij/bezet is gedeeld met {group}", diff --git a/locales/pl/common.json b/locales/pl/common.json index fb953bcd7..2ff7998f1 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5283,6 +5283,12 @@ "cancelled": "{name} odwołał(a) „{title}”", "untitled": "Wydarzenie bez tytułu" }, + "calendar_delegate": { + "answer_for": "Odpowiedz w imieniu: {name}", + "no_write": "Widzisz zaproszenia użytkownika {name}, ale nie możesz za niego odpowiadać. Poproś o prawo do edycji.", + "also_invited": "Też jesteś zaproszony, a ten serwer nie odróżni Twojej odpowiedzi od odpowiedzi {name}. Na to zaproszenie musi odpowiedzieć {name}.", + "answered_by": "Odpowiedź od: {who}" + }, "free_busy_share": { "button": "Udostępnij moją dostępność grupie {group}", "already": "Twoja dostępność jest udostępniona grupie {group}", diff --git a/locales/pt/common.json b/locales/pt/common.json index 963717aaa..5a562dfb7 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5283,6 +5283,12 @@ "cancelled": "{name} cancelou \"{title}\"", "untitled": "Evento sem título" }, + "calendar_delegate": { + "answer_for": "Responder por {name}", + "no_write": "Pode ver os convites de {name}, mas não responder por essa pessoa. Peça acesso de edição.", + "also_invited": "Também foi convidado e este servidor não distingue a sua resposta da de {name}. {name} tem de responder a esta.", + "answered_by": "Respondido por {who}" + }, "free_busy_share": { "button": "Partilhar a minha disponibilidade com {group}", "already": "A sua disponibilidade está partilhada com {group}", diff --git a/locales/ro/common.json b/locales/ro/common.json index 462064a37..30ffeef54 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -4845,6 +4845,12 @@ "cancelled": "{name} a anulat „{title}”", "untitled": "Eveniment fără titlu" }, + "calendar_delegate": { + "answer_for": "Răspunde în numele {name}", + "no_write": "Vedeți invitațiile lui {name}, dar nu puteți răspunde în numele său. Cereți acces de editare.", + "also_invited": "Sunteți și dvs. invitat, iar acest server nu poate deosebi răspunsul dvs. de cel al lui {name}. {name} trebuie să răspundă la aceasta.", + "answered_by": "Răspuns de {who}" + }, "free_busy_share": { "button": "Partajează disponibilitatea mea cu {group}", "already": "Disponibilitatea dvs. este partajată cu {group}", diff --git a/locales/ru/common.json b/locales/ru/common.json index 11ebda433..0ac5b538e 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5283,6 +5283,12 @@ "cancelled": "{name} отменил(а) «{title}»", "untitled": "Событие без названия" }, + "calendar_delegate": { + "answer_for": "Ответить за {name}", + "no_write": "Вы видите приглашения {name}, но не можете отвечать за этого человека. Попросите доступ на изменение.", + "also_invited": "Вы тоже приглашены, и этот сервер не отличит ваш ответ от ответа {name}. На это приглашение должен ответить {name}.", + "answered_by": "Ответил(а): {who}" + }, "free_busy_share": { "button": "Открыть мою занятость для {group}", "already": "Ваша занятость открыта для {group}", diff --git a/locales/sk/common.json b/locales/sk/common.json index a5f5c5179..d48eec551 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -4845,6 +4845,12 @@ "cancelled": "{name} zrušil(a) „{title}“", "untitled": "Udalosť bez názvu" }, + "calendar_delegate": { + "answer_for": "Odpovedať za: {name}", + "no_write": "Vidíte pozvánky používateľa {name}, ale nemôžete za neho odpovedať. Požiadajte o prístup na úpravy.", + "also_invited": "Ste pozvaní aj vy a tento server nerozlíši vašu odpoveď od odpovede používateľa {name}. Na túto musí odpovedať {name}.", + "answered_by": "Odpovedal(a): {who}" + }, "free_busy_share": { "button": "Zdieľať môj voľný čas so skupinou {group}", "already": "Váš voľný čas je zdieľaný so skupinou {group}", diff --git a/locales/th/common.json b/locales/th/common.json index 0c07c6937..642670029 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2185,6 +2185,12 @@ "cancelled": "{name} ยกเลิกกิจกรรม “{title}”", "untitled": "กิจกรรมที่ไม่มีชื่อ" }, + "calendar_delegate": { + "answer_for": "ตอบแทน {name}", + "no_write": "คุณเห็นคำเชิญของ {name} แต่ตอบแทนไม่ได้ โปรดขอสิทธิ์แก้ไข", + "also_invited": "คุณได้รับเชิญด้วย และเซิร์ฟเวอร์นี้แยกคำตอบของคุณกับของ {name} ไม่ได้ {name} ต้องตอบเอง", + "answered_by": "ตอบโดย {who}" + }, "free_busy_share": { "button": "แชร์เวลาว่าง/ไม่ว่างของฉันกับ {group}", "already": "แชร์เวลาว่าง/ไม่ว่างของคุณกับ {group} แล้ว", diff --git a/locales/tr/common.json b/locales/tr/common.json index 4c032aebd..23543c56e 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -4845,6 +4845,12 @@ "cancelled": "{name} \"{title}\" etkinliğini iptal etti", "untitled": "Başlıksız etkinlik" }, + "calendar_delegate": { + "answer_for": "{name} adına yanıtla", + "no_write": "{name} kişisinin davetlerini görebilirsiniz ama onun adına yanıtlayamazsınız. Düzenleme erişimi isteyin.", + "also_invited": "Siz de davetlisiniz ve bu sunucu yanıtınızı {name} kişisinin yanıtından ayıramıyor. Bunu {name} yanıtlamalı.", + "answered_by": "Yanıtlayan: {who}" + }, "free_busy_share": { "button": "Müsaitlik durumumu {group} ile paylaş", "already": "Müsaitlik durumunuz {group} ile paylaşılıyor", diff --git a/locales/uk/common.json b/locales/uk/common.json index 9ebdb3f9e..5b308c23d 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5283,6 +5283,12 @@ "cancelled": "{name} скасував(ла) «{title}»", "untitled": "Подія без назви" }, + "calendar_delegate": { + "answer_for": "Відповісти за {name}", + "no_write": "Ви бачите запрошення {name}, але не можете відповідати за цю людину. Попросіть доступ на редагування.", + "also_invited": "Вас теж запрошено, і цей сервер не відрізнить вашу відповідь від відповіді {name}. На це запрошення має відповісти {name}.", + "answered_by": "Відповідь від: {who}" + }, "free_busy_share": { "button": "Поділитися моєю зайнятістю з {group}", "already": "Вашою зайнятістю поділено з {group}", diff --git a/locales/vi/common.json b/locales/vi/common.json index 93589e534..7813e61e0 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -4857,6 +4857,12 @@ "cancelled": "{name} đã hủy \"{title}\"", "untitled": "Sự kiện không có tiêu đề" }, + "calendar_delegate": { + "answer_for": "Trả lời thay {name}", + "no_write": "Bạn xem được lời mời của {name} nhưng không thể trả lời thay. Hãy xin quyền chỉnh sửa.", + "also_invited": "Bạn cũng được mời, và máy chủ này không phân biệt được câu trả lời của bạn với của {name}. {name} phải tự trả lời lời mời này.", + "answered_by": "Đã được {who} trả lời" + }, "free_busy_share": { "button": "Chia sẻ lịch rảnh/bận của tôi với {group}", "already": "Lịch rảnh/bận của bạn đã được chia sẻ với {group}", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 1b1ab0ec7..e98fbb96d 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -4845,6 +4845,12 @@ "cancelled": "{name} 取消了「{title}」", "untitled": "未命名活動" }, + "calendar_delegate": { + "answer_for": "代表 {name} 回覆", + "no_write": "你能看到 {name} 的邀請,但不能代為回覆。請向對方申請編輯權限。", + "also_invited": "你也被邀請了,此伺服器無法區分你和 {name} 的回覆。這一項需要 {name} 自己回覆。", + "answered_by": "由 {who} 回覆" + }, "free_busy_share": { "button": "與 {group} 共用我的忙碌/空閒時間", "already": "你的忙碌/空閒時間已與 {group} 共用", diff --git a/locales/zh/common.json b/locales/zh/common.json index 6fed0d972..f33727647 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5283,6 +5283,12 @@ "cancelled": "{name} 取消了“{title}”", "untitled": "无标题活动" }, + "calendar_delegate": { + "answer_for": "代表 {name} 回复", + "no_write": "你能看到 {name} 的邀请,但不能代为回复。请向对方申请编辑权限。", + "also_invited": "你也被邀请了,此服务器无法区分你和 {name} 的回复。这一项需要 {name} 自己回复。", + "answered_by": "由 {who} 回复" + }, "free_busy_share": { "button": "与 {group} 共享我的忙闲时间", "already": "你的忙闲时间已与 {group} 共享", diff --git a/stores/__tests__/calendar-store-delegate-rsvp.test.ts b/stores/__tests__/calendar-store-delegate-rsvp.test.ts new file mode 100644 index 000000000..1635e7c82 --- /dev/null +++ b/stores/__tests__/calendar-store-delegate-rsvp.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useCalendarStore } from '../calendar-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +// Answering for the calendar's owner (#634): the owner's participant gets +// the answer and sentBy = mailto: in the owner's account, and an +// answer without sentBy clears an old one, or Stalwart sends the next reply +// with the delegate's SENT-BY still on it. + +function sharedEvent(overrides: Partial = {}): CalendarEvent { + return { + id: 'alice:e1', + originalId: 'e1', + uid: 'invite@example.org', + title: 'Planning', + start: '2026-11-04T14:00:00', + duration: 'PT1H', + timeZone: 'Etc/UTC', + showWithoutTime: false, + calendarIds: { 'alice:b': true }, + accountId: 'alice', + isShared: true, + organizerCalendarAddress: 'mailto:carol@example.org', + participants: { + carol: { '@type': 'Participant', name: 'Carol', email: 'carol@example.org', roles: { owner: true }, participationStatus: 'accepted' }, + alice: { '@type': 'Participant', name: 'Alice', email: 'alice@example.org', roles: { attendee: true }, participationStatus: 'needs-action' }, + } as unknown as CalendarEvent['participants'], + ...overrides, + } as CalendarEvent; +} + +function fakeClient() { + return { updateCalendarEvent: vi.fn().mockResolvedValue(undefined), queryAllCalendarEvents: vi.fn().mockResolvedValue([]) } as unknown as IJMAPClient & { + updateCalendarEvent: ReturnType; + }; +} + +beforeEach(() => { + useCalendarStore.getState().clearState(); +}); + +describe('rsvpEvent for someone else', () => { + it('sets the owner\'s answer and sentBy in the owner\'s account, with scheduling messages', async () => { + const client = fakeClient(); + useCalendarStore.setState({ events: [sharedEvent()] }); + + await useCalendarStore.getState().rsvpEvent(client, 'alice:e1', 'alice', 'accepted', undefined, 'series', { sentBy: 'mailto:bob@example.org' }); + + expect(client.updateCalendarEvent).toHaveBeenCalledWith('e1', { + 'participants/alice/participationStatus': 'accepted', + 'participants/alice/sentBy': 'mailto:bob@example.org', + }, true, 'alice'); + const stored = useCalendarStore.getState().events[0].participants!.alice; + expect(stored.participationStatus).toBe('accepted'); + expect(stored.sentBy).toBe('mailto:bob@example.org'); + }); + + it('clears an old sentBy when the participant answers without one', async () => { + const client = fakeClient(); + const event = sharedEvent({ isShared: false, accountId: undefined, id: 'e1' }); + event.participants!.alice.sentBy = 'mailto:bob@example.org'; + useCalendarStore.setState({ events: [event] }); + + await useCalendarStore.getState().rsvpEvent(client, 'e1', 'alice', 'declined'); + + expect(client.updateCalendarEvent).toHaveBeenCalledWith('e1', { + 'participants/alice/participationStatus': 'declined', + 'participants/alice/sentBy': null, + }, true, undefined); + expect(useCalendarStore.getState().events[0].participants!.alice.sentBy).toBeUndefined(); + }); + + it('sends no sentBy when there is none to set or clear', async () => { + const client = fakeClient(); + useCalendarStore.setState({ events: [sharedEvent()] }); + + await useCalendarStore.getState().rsvpEvent(client, 'alice:e1', 'alice', 'tentative'); + + expect(client.updateCalendarEvent).toHaveBeenCalledWith('e1', { 'participants/alice/participationStatus': 'tentative' }, true, 'alice'); + }); + + it('puts sentBy on the owner\'s participant when answering one occurrence', async () => { + const client = fakeClient(); + const occurrence = sharedEvent({ + id: 'alice:maaaab', originalId: 'maaaab', baseEventId: 'e1', recurrenceId: '2026-11-04T14:00:00', + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 3 }] as unknown as CalendarEvent['recurrenceRules'], + utcStart: '2026-11-04T14:00:00Z', utcEnd: '2026-11-04T15:00:00Z', + }); + useCalendarStore.setState({ events: [occurrence] }); + + await useCalendarStore.getState().rsvpEvent(client, 'alice:maaaab', 'alice', 'accepted', undefined, 'occurrence', { sentBy: 'mailto:bob@example.org' }); + + const [, patch] = client.updateCalendarEvent.mock.calls[0]; + expect(patch.participants.alice).toMatchObject({ participationStatus: 'accepted', sentBy: 'mailto:bob@example.org' }); + expect(patch.participants.carol).toEqual(occurrence.participants!.carol); + }); +}); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index bb63755d1..d7f9c53f7 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -47,6 +47,7 @@ import { } from '@/lib/calendar-subscription-sync'; import { registerCalendarSubscriptionSyncBridge } from './settings-store'; import { currentStoreEpoch } from '@/lib/store-epoch'; +import { sentByPatchValue } from '@/lib/calendar-delegate'; /** * When the Pro shell aggregates calendars/events from every connected @@ -313,6 +314,14 @@ async function destroyOccurrence( await client.updateCalendarEvent(instance.baseEventId, fallback, sendSchedulingMessages, targetAccountId); } +/** A participant after an answer, with its sentBy set, dropped or kept (see sentByPatchValue). */ +function withAnswer(participant: CalendarParticipant, status: CalendarParticipant['participationStatus'], sentBy: string | null | undefined): CalendarParticipant { + const next: CalendarParticipant = { ...participant, participationStatus: status }; + if (sentBy) next.sentBy = sentBy; + else if (sentBy === null) delete next.sentBy; + return next; +} + /** * Answer one occurrence of a series as `participantId`. The server stores it * as a recurrence override and sends the organizer an iTIP REPLY carrying @@ -324,8 +333,9 @@ async function rsvpOccurrence( occurrence: CalendarEvent, participantId: string, status: CalendarParticipant['participationStatus'], + sentBy?: string | null, ): Promise { - const patchFor = (options: OverridePatchOptions) => buildOccurrenceRsvpPatch(occurrence, participantId, status, options); + const patchFor = (options: OverridePatchOptions) => buildOccurrenceRsvpPatch(occurrence, participantId, status, options, sentBy); const patch = patchFor({ seriesKeys: true }); if (!patch) throw new Error('Participant not found on this occurrence'); if (target.isOccurrence) { @@ -841,7 +851,13 @@ interface CalendarStore { options?: EventUpdateOptions, ) => Promise; deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise; - rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record | null, scope?: 'occurrence' | 'series') => Promise; + /** + * `options.sentBy`: answering for the calendar's owner as their delegate + * (`mailto:`, lib/calendar-delegate.ts). Without it an old sentBy + * on the participant is cleared, so the owner's own answer isn't sent + * as the delegate's. + */ + rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record | null, scope?: 'occurrence' | 'series', options?: { sentBy?: string }) => Promise; /** * Create `events` in the calendar, linking those whose UID the account * already has; resolves to how many were created or linked, or null when a @@ -1338,7 +1354,7 @@ export const useCalendarStore = create()( return true; }, - rsvpEvent: async (client, eventId, participantId, status, replyTo, scope = 'series'): Promise => { + rsvpEvent: async (client, eventId, participantId, status, replyTo, scope = 'series', options): Promise => { set({ error: null }); // JMAP participant IDs are opaque strings - they can contain @, ., :, // / etc. The id is RFC 6901-escaped below before being embedded in the @@ -1357,16 +1373,18 @@ export const useCalendarStore = create()( const { storeEvent, realId, targetAccountId, localAccountId } = target; client = resolveAccountClient(client, localAccountId); const occurrence = target.isOccurrence || target.isBrowserOccurrence ? storeEvent ?? null : null; + const sentBy = sentByPatchValue(storeEvent?.participants?.[participantId], options?.sentBy); if (occurrence) { await rsvpOccurrence( client, target, occurrence, participantId, - status as CalendarParticipant['participationStatus'], + status as CalendarParticipant['participationStatus'], sentBy, ); } else { // Escape per RFC 6901 (JSON Pointer): ~ → ~0, / → ~1 const escapedId = participantId.replace(/~/g, '~0').replace(/\//g, '~1'); const patchKey = `participants/${escapedId}/participationStatus`; const patch: Record = { [patchKey]: status }; + if (sentBy !== undefined) patch[`participants/${escapedId}/sentBy`] = sentBy; // Stalwart routes the iTIP REPLY to the stored ORGANIZER // (organizerCalendarAddress); the RFC 8984 replyTo property is retired // in jscalendarbis and ignored. Repair events that are missing the @@ -1389,7 +1407,7 @@ export const useCalendarStore = create()( ...e, participants: { ...e.participants, - [participantId]: { ...e.participants[participantId], participationStatus: status as CalendarParticipant['participationStatus'] }, + [participantId]: withAnswer(e.participants[participantId], status as CalendarParticipant['participationStatus'], sentBy), }, }; }),