feat: answer invitations in a calendar shared with you as its owner #634

This commit is contained in:
Linus Rath
2026-11-01 05:05:12 +01:00
parent ab2fc6454d
commit 877198a4bf
45 changed files with 853 additions and 66 deletions
@@ -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 <owner>" 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();
});
});
+120
View File
@@ -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<IJMAPClient, Map<string, Promise<Principal | null>>>();
/** The principal behind a shared account, asked once per client. */
function loadAccountOwner(client: IJMAPClient, accountId: string): Promise<Principal | null> {
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 <owner>" 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 <owner>", 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 (
<div className={className} data-testid="answer-bars">
<div className="space-y-3">
{view.targets.map((target) => {
const label = target.kind === "owner"
? tDelegate("answer_for", { name: target.ownerName ?? "" })
: t("participants.rsvp_label");
return (
<div
key={`${target.kind}:${target.eventId}`}
role="group"
aria-label={label}
className="flex flex-wrap items-center justify-between gap-2"
data-testid={target.kind === "owner" ? "answer-for-owner" : "answer-self"}
>
<span className={size === "xs" ? "text-xs font-medium text-muted-foreground" : "text-sm font-medium"}>{label}</span>
<RsvpButtons status={target.status} onRespond={(status) => onRespond(target, status)} size={size} />
</div>
);
})}
</div>
{view.note && (
<p className="mt-2 text-xs text-muted-foreground" role="note" data-testid={`answer-note-${view.note.kind}`}>
{tDelegate(view.note.kind, { name: view.note.owner })}
</p>
)}
{view.answeredBy && (
<p className="mt-2 text-xs text-muted-foreground" data-testid="answered-by">
{tDelegate("answered_by", { who: view.answeredBy })}
</p>
)}
</div>
);
}
+14 -10
View File
@@ -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<CalendarEvent>; 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.
+14 -15
View File
@@ -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({
</div>
)}
{/* RSVP Bar (for attendees) */}
{canRsvp && onRsvp && userParticipantId && (
<div className="px-4 py-3 border-t border-border">
<p className="text-xs font-medium text-muted-foreground mb-2">
{t("participants.rsvp_label")}
</p>
<RsvpButtons status={userCurrentStatus} onRespond={onRsvp} size="xs" />
</div>
{/* RSVP bars: the user's own, and "Answer for <owner>" in a calendar shared with them. */}
{onRsvp && (
<AnswerBars
view={answerView}
size="xs"
className="px-4 py-3 border-t border-border empty:hidden"
onRespond={(target, status) => onRsvp(status, target)}
/>
)}
</div>
+22 -34
View File
@@ -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<CalendarEvent>, sendSchedulingMessages?: boolean, editor?: EventEditorState) => void | Promise<void>;
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
onDuplicate?: (data: Partial<CalendarEvent>) => 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 && (
<div className="px-6 py-3 border-t border-border flex-shrink-0">
<RsvpBar status={userCurrentStatus} onRespond={handleRsvp} t={t} />
</div>
{onRsvp && (
<AnswerBars
view={answerView}
className="px-6 py-3 border-t border-border flex-shrink-0 empty:hidden"
onRespond={handleAnswer}
/>
)}
{/* Action Bar */}
@@ -1594,21 +1600,3 @@ export function EventModal({
</div>
);
}
/**
* 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<typeof useTranslations>;
}) {
return (
<div className="flex items-center justify-between gap-2 flex-wrap">
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
<RsvpButtons status={status} onRespond={onRespond} />
</div>
);
}
@@ -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(<CalendarInvitationBanner email={makeEmail({ id: 'email-9' })} />);
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 = [
{
@@ -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
+110
View File
@@ -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<string, unknown> = {}) => ({
'@type': 'Participant', name: email.split('@')[0], email, calendarAddress: `mailto:${email}`,
roles: { attendee: true }, participationStatus: 'needs-action', ...extra,
});
function invitation(attendees: string[], extra: Partial<CalendarEvent> = {}): CalendarEvent {
const participants: Record<string, unknown> = {
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> = {}): 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:<delegate>', () => {
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();
});
});
+139
View File
@@ -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:<delegate>"` 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<string>();
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:<user>` 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<Calendar, 'isShared' | 'myRights'> | 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<CalendarParticipant, 'sentBy'> | undefined, sentBy: string | undefined): string | null | undefined {
if (sentBy) return sentBy;
return participant?.sentBy ? null : undefined;
}
+2
View File
@@ -629,6 +629,8 @@ export interface IJMAPClient {
listPrincipals?(targetAccountId?: string, options?: { throwOnError?: boolean }): Promise<PrincipalListing>;
/** Principal type by id ("individual", "group"); null when unknown. Optional (demo client). */
getPrincipalTypes?(ids: string[]): Promise<Record<string, string> | null>;
/** The principals behind accounts (who owns a shared calendar); missing ids are left out. Optional (demo client). */
getAccountOwners?(ids: string[]): Promise<Record<string, Principal>>;
// Bulwark data in Inbox metadata (lib/jmap/bulwark-metadata.ts). Optional (demo client).
supportsBulwarkMetadata?(accountId?: string): boolean;
getBulwarkMetadata?(accountId?: string, options?: { private?: boolean }): Promise<Record<string, unknown> | null>;
+24
View File
@@ -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<Record<string, Principal>> {
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<string, Principal> = {};
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
+5
View File
@@ -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<string, boolean> | null;
delegatedFrom: Record<string, boolean> | null;
memberOf: Record<string, boolean> | null;
+7 -1
View File
@@ -687,9 +687,15 @@ export function buildOccurrenceRsvpPatch(
participantId: string,
status: CalendarParticipant['participationStatus'],
options: OverridePatchOptions = {},
sentBy?: string | null,
): Partial<CalendarEvent> | 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<string, unknown> = {};
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<CalendarEvent>, options);
}
+6
View File
@@ -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}",
+6
View File
@@ -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}",
+6
View File
@@ -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}",
+6
View File
@@ -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}",
+6
View File
@@ -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",
+6
View File
@@ -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}",
+6
View File
@@ -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}",
+6
View File
@@ -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}",
+6
View File
@@ -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} هم‌رسانی شده است",
+6
View File
@@ -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}",
+6
View File
@@ -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}",
+6
View File
@@ -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}",
+6
View File
@@ -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}",
+6
View File
@@ -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} と共有されています",
+6
View File
@@ -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}과(와) 공유되어 있습니다",
+6
View File
@@ -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}",
+6
View File
@@ -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}-тэй хуваалцагдсан",
+6
View File
@@ -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}",
+6
View File
@@ -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}",
+6
View File
@@ -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}",
+6
View File
@@ -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}",
+6
View File
@@ -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}",
+6
View File
@@ -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}",
+6
View File
@@ -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}",
+6
View File
@@ -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} แล้ว",
+6
View File
@@ -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",
+6
View File
@@ -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}",
+6
View File
@@ -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}",
+6
View File
@@ -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} 共用",
+6
View File
@@ -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} 共享",
@@ -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:<delegate> 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> = {}): 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<typeof vi.fn>;
};
}
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);
});
});
+23 -5
View File
@@ -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<void> {
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<boolean>;
deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<boolean>;
rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null, scope?: 'occurrence' | 'series') => Promise<boolean>;
/**
* `options.sentBy`: answering for the calendar's owner as their delegate
* (`mailto:<user>`, 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<string, string> | null, scope?: 'occurrence' | 'series', options?: { sentBy?: string }) => Promise<boolean>;
/**
* 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<CalendarStore>()(
return true;
},
rsvpEvent: async (client, eventId, participantId, status, replyTo, scope = 'series'): Promise<boolean> => {
rsvpEvent: async (client, eventId, participantId, status, replyTo, scope = 'series', options): Promise<boolean> => {
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<CalendarStore>()(
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<string, unknown> = { [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<CalendarStore>()(
...e,
participants: {
...e.participants,
[participantId]: { ...e.participants[participantId], participationStatus: status as CalendarParticipant['participationStatus'] },
[participantId]: withAnswer(e.participants[participantId], status as CalendarParticipant['participationStatus'], sentBy),
},
};
}),