From 44e6808a86bded83c902794b683d8d25659a1a1d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:34:31 +0100 Subject: [PATCH 01/23] fix: read attendee availability in the calendar's time zone --- .../event-modal-availability-zone.test.tsx | 59 +++++++++++++++++++ components/calendar/event-modal.tsx | 20 ++++--- 2 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 components/calendar/__tests__/event-modal-availability-zone.test.tsx diff --git a/components/calendar/__tests__/event-modal-availability-zone.test.tsx b/components/calendar/__tests__/event-modal-availability-zone.test.tsx new file mode 100644 index 000000000..50e812d2c --- /dev/null +++ b/components/calendar/__tests__/event-modal-availability-zone.test.tsx @@ -0,0 +1,59 @@ +import { render, fireEvent, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { EventModal } from '../event-modal'; +import { useSettingsStore } from '@/stores/settings-store'; +import { getBrowserTimeZone } from '@/lib/timezone'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// The event form's fields are a wall clock in the user's effective time +// zone (the save path labels them with it). The attendees' free/busy must be +// asked for the same instants, not for the wall clock read in the browser's +// own zone. + +const windows: Array<{ start: Date | null; end: Date | null }> = []; +vi.mock('../participant-availability', () => ({ + ParticipantAvailability: (props: { start: Date | null; end: Date | null }) => { + windows.push({ start: props.start, end: props.end }); + return null; + }, +})); + +const calendars = [{ + id: 'cal', name: 'Work', color: '#123456', isDefault: true, + myRights: { + mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, + mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true, + }, +}] as unknown as Calendar[]; + +const meeting = { + id: 'e1', uid: 'u1', '@type': 'Event', title: 'Planning', description: '', start: '2026-09-10T10:00:00', + duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, calendarIds: { cal: true }, + organizerCalendarAddress: 'mailto:me@example.com', + participants: { + org: { '@type': 'Participant', name: 'Me', email: 'me@example.com', calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' }, + anna: { '@type': 'Participant', name: 'Anna', email: 'anna@example.com', calendarAddress: 'mailto:anna@example.com', roles: { attendee: true }, participationStatus: 'needs-action' }, + }, + alerts: null, recurrenceRules: null, +} as unknown as CalendarEvent; + +describe('EventModal attendee availability window', () => { + afterEach(() => { + useSettingsStore.setState({ timeZone: 'auto' }); + windows.length = 0; + }); + + it('asks for the event instants when the display zone is not the browser zone', () => { + // A zone the browser running the test is not in. + const zone = getBrowserTimeZone() === 'Asia/Tokyo' ? 'America/New_York' : 'Asia/Tokyo'; + useSettingsStore.setState({ timeZone: zone }); + render( + , + ); + fireEvent.click(screen.getByText('events.edit')); + const last = windows[windows.length - 1]; + // 10:00 in Berlin on 10 September (summer time) is 08:00 UTC. + expect(last.start?.toISOString()).toBe('2026-09-10T08:00:00.000Z'); + expect(last.end?.toISOString()).toBe('2026-09-10T09:00:00.000Z'); + }); +}); diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 7e2176cb9..27978bfa0 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -17,7 +17,7 @@ import { alertRowToOffset, describeAlert, getCalendarDefaultAlerts, newAlertRow, import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor"; import { parseDuration, getEventColor } from "./event-card"; import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; -import { displayNow, getEffectiveTimeZone } from "@/lib/timezone"; +import { displayNow, fromWallClock, getEffectiveTimeZone, parseLocalDateTime } from "@/lib/timezone"; import { createRecurrenceRule, sameRecurrenceRules } from "@/lib/recurrence-rule"; import { ParticipantInput, type ParticipantInputHandle } from "./participant-input"; import { ParticipantAvailability } from "./participant-availability"; @@ -562,16 +562,22 @@ export function EventModal({ // Leaving the form of an existing event goes back to its summary. const backToView = useCallback(() => whenDiscarded(() => { resetForm(); setMode("view"); }), [whenDiscarded, resetForm]); - // The event window the attendees' free/busy is checked against. Parsed in - // local time like the save path; an all-day event spans its whole days. + // The event window the attendees' free/busy is checked against. The + // fields are a wall clock in the effective zone, as the save path labels + // them, not in the browser's; an all-day event spans its whole days. const availabilityWindow = useMemo(() => { + const zone = getEffectiveTimeZone(); + const instant = (value: string) => { + const wall = parseLocalDateTime(value); + if (!wall) return null; + const date = fromWallClock(wall, zone); + return Number.isNaN(date.getTime()) ? null : date; + }; const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime || "00:00"}:00`; const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime || "00:00"}:00`; - const start = startDate ? new Date(startStr) : null; - const end = endDate ? new Date(endStr) : null; return { - start: start && !Number.isNaN(start.getTime()) ? start : null, - end: end && !Number.isNaN(end.getTime()) ? end : null, + start: startDate ? instant(startStr) : null, + end: endDate ? instant(endStr) : null, }; }, [startDate, startTime, endDate, endTime, allDay]); From 93f02e9615456cb1d056e37c3a19d6fdd3b04fee Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:47:05 +0100 Subject: [PATCH 02/23] feat: suggest meeting times that are free for every attendee #1164 The participant availability test that expected "Free/busy isn't available on this server" when Stalwart refuses the directory listing now expects the attendee list: Stalwart 1.0.1 still answers getAvailability for the principals the user knows with directory queries off, so the list stays and names who couldn't be looked up. The 1.0 build that refuses the lookups too keeps its own case. --- .../calendar/__tests__/find-a-time.test.tsx | 212 +++++++++++++ .../participant-availability.test.ts | 31 +- components/calendar/event-modal.tsx | 23 +- components/calendar/find-a-time.tsx | 299 ++++++++++++++++++ .../calendar/free-busy-share-button.tsx | 90 ++++++ .../calendar/participant-availability.tsx | 107 ++++--- lib/__tests__/find-a-time.test.ts | 94 ++++++ lib/find-a-time.ts | 139 ++++++++ lib/free-busy-share.ts | 120 +++++++ lib/jmap/free-busy.ts | 26 +- locales/ar/common.json | 28 ++ locales/ca/common.json | 28 ++ locales/cs/common.json | 28 ++ locales/da/common.json | 28 ++ locales/de/common.json | 28 ++ locales/en/common.json | 28 ++ locales/es/common.json | 28 ++ locales/et/common.json | 28 ++ locales/fa/common.json | 28 ++ locales/fr/common.json | 28 ++ locales/he/common.json | 28 ++ locales/hu/common.json | 28 ++ locales/it/common.json | 28 ++ locales/ja/common.json | 28 ++ locales/ko/common.json | 28 ++ locales/lv/common.json | 28 ++ locales/mn/common.json | 28 ++ locales/nb/common.json | 28 ++ locales/nl/common.json | 28 ++ locales/pl/common.json | 28 ++ locales/pt/common.json | 28 ++ locales/ro/common.json | 28 ++ locales/ru/common.json | 28 ++ locales/sk/common.json | 28 ++ locales/th/common.json | 28 ++ locales/tr/common.json | 28 ++ locales/uk/common.json | 28 ++ locales/vi/common.json | 28 ++ locales/zh-TW/common.json | 28 ++ locales/zh/common.json | 28 ++ 40 files changed, 1935 insertions(+), 46 deletions(-) create mode 100644 components/calendar/__tests__/find-a-time.test.tsx create mode 100644 components/calendar/find-a-time.tsx create mode 100644 components/calendar/free-busy-share-button.tsx create mode 100644 lib/__tests__/find-a-time.test.ts create mode 100644 lib/find-a-time.ts create mode 100644 lib/free-busy-share.ts diff --git a/components/calendar/__tests__/find-a-time.test.tsx b/components/calendar/__tests__/find-a-time.test.tsx new file mode 100644 index 000000000..6f706d3ef --- /dev/null +++ b/components/calendar/__tests__/find-a-time.test.tsx @@ -0,0 +1,212 @@ +import { createElement } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { FindATime, suggestTimes, visibleCalendarOwners } from '../find-a-time'; +import { useAuthStore } from '@/stores/auth-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { BusyPeriod, Calendar, Principal, PrincipalListing } from '@/lib/jmap/types'; + +vi.mock('../free-busy-share-button', () => ({ FreeBusyShareButton: () => createElement('div', { 'data-testid': 'share-button' }) })); + +const HOUR = 3600_000; +// Monday 2 November 2026, 07:00 in Berlin. +const NOW = new Date('2026-11-02T06:00:00Z'); + +const DIRECTORY: Principal[] = [ + { id: 'me', type: 'individual', name: 'me', email: 'me@example.org' }, + { id: 'bob', type: 'individual', name: 'bob', email: 'bob@example.org' }, + { id: 'carol', type: 'individual', name: 'carol', email: 'carol@example.org' }, +]; + +function fakeClient(options: { + listing?: PrincipalListing; + periods?: Record; + errors?: Record; +}) { + const calls: string[][] = []; + const client = { + getUsername: () => 'me@example.org', + getAccountId: () => 'me', + supportsAvailability: () => true, + mayGetAvailability: async () => true, + listPrincipals: async () => options.listing ?? { principals: DIRECTORY, status: 'ok' }, + getPrincipals: async () => DIRECTORY, + getPrincipalsAvailability: async (ids: string[]) => { + calls.push(ids); + return Object.fromEntries(ids.map((id) => [id, options.errors?.[id] ? { error: options.errors[id] } : { periods: options.periods?.[id] ?? [] }])); + }, + }; + return { client: client as unknown as IJMAPClient, calls }; +} + +const busy = (utcStart: string, utcEnd: string): BusyPeriod => ({ utcStart, utcEnd, busyStatus: 'confirmed' }); +const input = (attendees: Array<{ name: string; email: string }>, visible: string[] = []) => ({ + attendees, + durationMs: HOUR, + now: NOW, + timeZone: 'Europe/Berlin', + workingDays: [1, 2, 3, 4, 5], + dayStartHour: 9, + dayEndHour: 17, + visibleOwners: new Set(visible), +}); + +describe('suggestTimes', () => { + it('proposes the first slot free for everyone, the organiser included', async () => { + const { client, calls } = fakeClient({ + periods: { + me: [busy('2026-11-02T08:00:00Z', '2026-11-02T08:30:00Z')], // me 9:00-9:30 + bob: [busy('2026-11-02T08:30:00Z', '2026-11-02T10:00:00Z')], // bob 9:30-11 + carol: [busy('2026-11-02T10:00:00Z', '2026-11-02T11:00:00Z')], // carol 11-12 + }, + }); + const outcome = await suggestTimes(client, input([ + { name: 'Bob', email: 'bob@example.org' }, + { name: 'Carol', email: 'Carol@example.org' }, + ])); + expect(outcome.kind).toBe('ok'); + if (outcome.kind !== 'ok') return; + expect(outcome.result.slots[0].start.toISOString()).toBe('2026-11-02T11:00:00.000Z'); // 12:00 Berlin + expect(outcome.result.notChecked).toEqual([]); + // One call per person over the whole window, in one batch. + expect(calls).toEqual([['me', 'bob', 'carol']]); + }); + + it('never counts an attendee without busy time as free unless their calendar is visible', async () => { + const { client } = fakeClient({ periods: { bob: [busy('2026-11-02T08:00:00Z', '2026-11-02T09:00:00Z')] } }); + const hidden = await suggestTimes(client, input([{ name: 'Bob', email: 'bob@example.org' }, { name: 'Carol', email: 'carol@example.org' }])); + expect(hidden.kind === 'ok' && hidden.result.notChecked).toEqual([{ email: 'carol@example.org', name: 'Carol', reason: 'unknown' }]); + const visible = await suggestTimes(client, input([{ name: 'Carol', email: 'carol@example.org' }], ['carol@example.org'])); + expect(visible.kind === 'ok' && visible.result.notChecked).toEqual([]); + }); + + it('names attendees outside the organisation and leaves them out', async () => { + const { client, calls } = fakeClient({}); + const outcome = await suggestTimes(client, input([{ name: '', email: 'x@outside.com' }])); + expect(outcome.kind === 'ok' && outcome.result.notChecked).toEqual([{ email: 'x@outside.com', name: '', reason: 'external' }]); + expect(calls).toEqual([['me']]); + }); + + it('says directory queries are off when the server refused the listing, and still checks the people it knows', async () => { + // Stalwart 1.0.1 with directory queries off: Principal/query refused, + // the principals the user shares an account with still known. + const { client, calls } = fakeClient({ + listing: { principals: [DIRECTORY[0], DIRECTORY[1]], status: 'restricted', directoryQueriesOff: true }, + periods: { bob: [busy('2026-11-02T08:00:00Z', '2026-11-02T09:00:00Z')] }, + }); + const outcome = await suggestTimes(client, input([{ name: 'Bob', email: 'bob@example.org' }, { name: 'Carol', email: 'carol@example.org' }])); + expect(outcome.kind).toBe('ok'); + if (outcome.kind !== 'ok') return; + expect(outcome.result.directoryQueriesOff).toBe(true); + expect(outcome.result.notChecked).toEqual([{ email: 'carol@example.org', name: 'Carol', reason: 'not_found' }]); + expect(outcome.result.slots[0].start.toISOString()).toBe('2026-11-02T09:00:00.000Z'); + expect(calls).toEqual([['me', 'bob']]); + }); + + it('says a lookup failed, and stops on forbidden', async () => { + const failed = await suggestTimes(fakeClient({ errors: { bob: 'serverUnavailable' } }).client, input([{ name: 'Bob', email: 'bob@example.org' }])); + expect(failed.kind === 'ok' && failed.result.notChecked).toEqual([{ email: 'bob@example.org', name: 'Bob', reason: 'error' }]); + const refused = await suggestTimes(fakeClient({ errors: { me: 'forbidden', bob: 'forbidden' } }).client, input([{ name: 'Bob', email: 'bob@example.org' }])); + expect(refused.kind).toBe('unavailable'); + }); + + it('refuses a search that would go over the 100 calls a minute without asking', async () => { + const { client, calls } = fakeClient({}); + const many: Principal[] = Array.from({ length: 120 }, (_, i) => ({ id: `p${i}`, type: 'individual' as const, name: `p${i}`, email: `p${i}@example.org` })); + const listing = { principals: [...DIRECTORY, ...many], status: 'ok' as const }; + Object.assign(client, { listPrincipals: async () => listing }); + const outcome = await suggestTimes(client, input(many.map((p) => ({ name: '', email: p.email! })))); + expect(outcome.kind).toBe('rate_limited'); + expect(calls).toEqual([]); + }); + + it('counts the calls of earlier searches against the minute', async () => { + const { client, calls } = fakeClient({}); + const people: Principal[] = Array.from({ length: 60 }, (_, i) => ({ id: `q${i}`, type: 'individual' as const, name: `q${i}`, email: `q${i}@example.org` })); + Object.assign(client, { listPrincipals: async () => ({ principals: [...DIRECTORY, ...people], status: 'ok' }) }); + const first = await suggestTimes(client, input(people.map((p) => ({ name: '', email: p.email! })))); + expect(first.kind).toBe('ok'); + // A different window, so nothing comes from the cache: 61 more would make 122. + const second = await suggestTimes(client, { ...input(people.map((p) => ({ name: '', email: p.email! }))), now: new Date('2026-11-03T06:00:00Z') }); + expect(second.kind).toBe('rate_limited'); + expect(calls).toHaveLength(1); + }); +}); + +describe('visibleCalendarOwners', () => { + it('lists the owners of shared calendars the user may read', () => { + const calendars = [ + { isShared: true, accountName: 'Bob@example.org', myRights: { mayReadItems: true } }, + { isShared: true, accountName: 'carol@example.org', myRights: { mayReadItems: false } }, + { isShared: false, accountName: 'me@example.org', myRights: { mayReadItems: true } }, + ] as unknown as Calendar[]; + expect([...visibleCalendarOwners(calendars)]).toEqual(['bob@example.org']); + }); +}); + +describe('FindATime', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(NOW); + useSettingsStore.setState({ timeZone: 'Europe/Berlin', calendarWorkingDays: [1, 2, 3, 4, 5], calendarDayStartHour: 9, calendarDayEndHour: 17, timeFormat: '24h' }); + useCalendarStore.setState({ calendars: [] } as never); + }); + afterEach(() => { + vi.useRealTimers(); + useAuthStore.setState({ client: null } as never); + useSettingsStore.setState({ timeZone: 'auto' }); + }); + + it('lists slots, moves the event to the one picked, and closes on Escape with the focus back on the button', async () => { + const { client } = fakeClient({ periods: { bob: [busy('2026-11-02T08:00:00Z', '2026-11-02T10:00:00Z')] } }); + useAuthStore.setState({ client } as never); + const onPick = vi.fn(); + render(createElement(FindATime, { + attendees: [{ name: 'Bob', email: 'bob@example.org' }], + start: new Date('2026-11-05T09:00:00Z'), + end: new Date('2026-11-05T09:30:00Z'), + onPick, + })); + const button = screen.getByTestId('find-a-time-button'); + fireEvent.click(button); + expect(button).toHaveAttribute('aria-expanded', 'true'); + const list = await screen.findByTestId('find-a-time-slots'); + const slots = list.querySelectorAll('button'); + expect(slots).toHaveLength(5); + // The first half hour after Bob's 9-11 (Berlin). + expect(slots[0].getAttribute('data-slot-start')).toBe('2026-11-02T10:00:00.000Z'); + fireEvent.click(slots[0]); + expect(onPick).toHaveBeenCalledWith({ start: new Date('2026-11-02T10:00:00Z'), end: new Date('2026-11-02T10:30:00Z') }); + // Bob was checked; nothing is listed as not checked. + expect(screen.queryByTestId('find-a-time-not-checked')).toBeNull(); + + fireEvent.keyDown(screen.getByTestId('find-a-time-panel'), { key: 'Escape' }); + expect(screen.queryByTestId('find-a-time-panel')).toBeNull(); + expect(document.activeElement).toBe(button); + }); + + it('names the people it could not check and offers to share free/busy', async () => { + const { client } = fakeClient({}); + useAuthStore.setState({ client } as never); + render(createElement(FindATime, { + attendees: [{ name: 'Carol', email: 'carol@example.org' }, { name: '', email: 'x@outside.com' }], + start: new Date('2026-11-05T09:00:00Z'), + end: new Date('2026-11-05T10:00:00Z'), + onPick: vi.fn(), + })); + fireEvent.click(screen.getByTestId('find-a-time-button')); + const note = await screen.findByTestId('find-a-time-not-checked'); + expect(note).toHaveTextContent('not_checked_item'); + expect(note).toHaveTextContent('unknown_note'); + expect(screen.getByTestId('share-button')).toBeInTheDocument(); + }); + + it('shows nothing without attendees', () => { + const { client } = fakeClient({}); + useAuthStore.setState({ client } as never); + const { container } = render(createElement(FindATime, { attendees: [], start: NOW, end: new Date(NOW.getTime() + HOUR), onPick: vi.fn() })); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/components/calendar/__tests__/participant-availability.test.ts b/components/calendar/__tests__/participant-availability.test.ts index 7f018ed2e..56423c785 100644 --- a/components/calendar/__tests__/participant-availability.test.ts +++ b/components/calendar/__tests__/participant-availability.test.ts @@ -344,17 +344,38 @@ describe('ParticipantAvailability', () => { expect(screen.queryByText('unavailable_on_server')).toBeNull(); }); - it('says free/busy is not available when Stalwart 1.0 lists no directory because directory queries are off', async () => { - // The own principal could not be read, so the listing's refusal is what tells. - const { client, requests } = makeClient({ - ownPrincipal: http500, + // Stalwart 1.0.1 refuses Principal/query with directory queries off but + // still answers getAvailability for the principals the user shares an + // account with (local-data/enterprise-pack-facts.md part 6 §1), so the + // list stays and says who could not be looked up. + it('keeps checking the known principals and says the rest cannot be looked up when Stalwart 1.0.1 has directory queries off', async () => { + const { client } = makeClient({ + ownPrincipal: OWN_PRINCIPAL_1_0, directory: () => [DIRECTORY_QUERIES_OFF, UNRESOLVED_REFERENCE], + known: ['Principal/get', { accountId: 'me', state: 'n', list: [DIRECTORY[0], DIRECTORY[1]], notFound: [] }, '0'], + availability: (id, callId) => ['Principal/getAvailability', { + list: id === 'anna' ? [{ utcStart: '2026-09-01T10:30:00Z', utcEnd: '2026-09-01T11:30:00Z', busyStatus: 'confirmed' }] : [], + }, callId], + }); + renderFor(client); + + await waitFor(() => expect(screen.getByText('busy')).toBeInTheDocument(), { timeout: 2000 }); + expect(screen.getByText('not_looked_up')).toBeInTheDocument(); + expect(screen.getByTestId('participant-availability-directory-off')).toHaveTextContent('directory_off'); + expect(screen.queryByText('unavailable_on_server')).toBeNull(); + }); + + it('says free/busy is not available when a 1.0 build refuses the lookups as well with directory queries off', async () => { + // The 1.0 test build (4fb4420b5) refuses every lookup with the same text. + const { client } = makeClient({ + ownPrincipal: OWN_PRINCIPAL_1_0, + directory: () => [DIRECTORY_QUERIES_OFF, UNRESOLVED_REFERENCE], + known: ['Principal/get', { accountId: 'me', state: 'n', list: [DIRECTORY[0], DIRECTORY[1]], notFound: [] }, '0'], availability: () => DIRECTORY_QUERIES_OFF, }); renderFor(client); await waitFor(() => expect(screen.getByText('unavailable_on_server')).toBeInTheDocument(), { timeout: 2000 }); expect(screen.queryByTestId('participant-availability')).toBeNull(); - expect(methodsOf(requests)).not.toContain('Principal/getAvailability'); }); }); diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 27978bfa0..69719494b 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -17,10 +17,11 @@ import { alertRowToOffset, describeAlert, getCalendarDefaultAlerts, newAlertRow, import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor"; import { parseDuration, getEventColor } from "./event-card"; import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; -import { displayNow, fromWallClock, getEffectiveTimeZone, parseLocalDateTime } from "@/lib/timezone"; +import { displayNow, fromWallClock, getEffectiveTimeZone, parseLocalDateTime, toZonedDisplayDate } from "@/lib/timezone"; 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 { eventOrganizer, getUserParticipantId, @@ -581,6 +582,18 @@ export function EventModal({ }; }, [startDate, startTime, endDate, endTime, allDay]); + // A suggested slot moves the event there; the fields are a wall clock in + // the effective zone, so the instants are turned back into one. + const handlePickSlot = useCallback((slot: { start: Date; end: Date }) => { + const zone = getEffectiveTimeZone(); + const from = toZonedDisplayDate(slot.start, zone); + const to = toZonedDisplayDate(slot.end, zone); + setStartDate(formatDateInput(from)); + setStartTime(formatTimeInput(from)); + setEndDate(formatDateInput(to)); + setEndTime(formatTimeInput(to)); + }, []); + // Plugin transform: collect conflict warnings for the current event form. // Re-runs (debounced) whenever fields that affect scheduling change. const [pluginConflictWarnings, setPluginConflictWarnings] = useState([]); @@ -1329,6 +1342,14 @@ export function EventModal({ start={availabilityWindow.start} end={availabilityWindow.end} /> + {userIsOrganizer && !allDay && ( + + )} {isEdit && statusCounts && (existingParticipants.length > 0) && (

{t("participants.status_summary", { diff --git a/components/calendar/find-a-time.tsx b/components/calendar/find-a-time.tsx new file mode 100644 index 000000000..617f91c26 --- /dev/null +++ b/components/calendar/find-a-time.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { CalendarClock, Loader2 } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { useAuthStore } from "@/stores/auth-store"; +import { useCalendarStore } from "@/stores/calendar-store"; +import { useSettingsStore } from "@/stores/settings-store"; +import { getEffectiveTimeZone } from "@/lib/timezone"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import type { BusyPeriod, Calendar } from "@/lib/jmap/types"; +import { availabilityCallsLeft, checkAvailabilityAccess, lookUpAvailability } from "@/lib/jmap/free-busy"; +import { findFreeSlots, searchWindow, type Slot } from "@/lib/find-a-time"; +import { loadAttendeeDirectory } from "./participant-availability"; +import { FreeBusyShareButton } from "./free-busy-share-button"; + +interface Attendee { + name: string; + email: string; +} + +/** Why an attendee's calendar was left out of the search. */ +export type NotCheckedReason = "external" | "unknown" | "not_found" | "error"; + +export interface SuggestionResult { + slots: Slot[]; + notChecked: Array<{ email: string; name: string; reason: NotCheckedReason }>; + /** Some attendees couldn't be looked up because directory queries are off. */ + directoryQueriesOff: boolean; +} + +export type SuggestionOutcome = + | { kind: "ok"; result: SuggestionResult } + | { kind: "rate_limited" } + | { kind: "unavailable" }; + +/** + * The addresses of people whose calendar the user can see: an account shared + * with them that holds a calendar they may read. For those, no busy time in + * the answer means free. For everyone else it may only mean "not shared" + * (Stalwart answers an empty list then too). + */ +export function visibleCalendarOwners(calendars: Pick[]): Set { + const owners = new Set(); + for (const c of calendars) { + if (c.isShared && c.myRights?.mayReadItems && c.accountName?.includes("@")) owners.add(c.accountName.trim().toLowerCase()); + } + return owners; +} + +/** + * Look up everyone Bulwark can and search the free slots. One availability + * call per attendee it can resolve plus one for the organiser, over the whole + * search window; refused up front when that would go over what is left of + * Stalwart 1.0's 100 calls a minute. + */ +export async function suggestTimes( + client: IJMAPClient, + input: { + attendees: Attendee[]; + durationMs: number; + now: Date; + timeZone: string; + workingDays: unknown; + dayStartHour: unknown; + dayEndHour: unknown; + visibleOwners: Set; + }, +): Promise { + const directory = await loadAttendeeDirectory(client); + const own = client.getUsername()?.trim().toLowerCase() ?? ""; + const ownId = directory.byEmail.get(own)?.id ?? client.getAccountId(); + const people = input.attendees.filter((a) => a.email.trim().toLowerCase() !== own); + const notChecked: SuggestionResult["notChecked"] = []; + const ids = new Map(); + let directoryQueriesOff = false; + for (const person of people) { + const email = person.email.trim().toLowerCase(); + const principal = directory.byEmail.get(email); + if (principal) { + ids.set(email, principal.id); + } else if (directory.complete) { + notChecked.push({ email, name: person.name, reason: "external" }); + } else { + if (directory.directoryQueriesOff) directoryQueriesOff = true; + notChecked.push({ email, name: person.name, reason: "not_found" }); + } + } + const principalIds = [ownId, ...ids.values()]; + if (availabilityCallsLeft(client) < principalIds.length) return { kind: "rate_limited" }; + + const window = searchWindow(input); + const answers = await lookUpAvailability(client, principalIds, window.start, window.end); + const busy: BusyPeriod[] = []; + const own_ = answers[ownId]; + if (own_ && "periods" in own_) busy.push(...own_.periods); + else if (own_ && "error" in own_ && own_.error === "forbidden") return { kind: "unavailable" }; + if (Object.values(answers).some((a) => "error" in a && a.error === "rateLimit")) return { kind: "rate_limited" }; + + for (const [email, id] of ids) { + const person = people.find((p) => p.email.trim().toLowerCase() === email)!; + const answer = answers[id]; + if (!answer || "error" in answer) { + if (answer && "error" in answer && answer.error === "forbidden") return { kind: "unavailable" }; + notChecked.push({ email, name: person.name, reason: "error" }); + continue; + } + // Stalwart answers the same empty list for someone with nothing on and + // for someone whose calendar the user may not see: only a calendar the + // user can see makes "no busy time" mean free. + if (answer.periods.length === 0 && !input.visibleOwners.has(email)) { + notChecked.push({ email, name: person.name, reason: "unknown" }); + continue; + } + busy.push(...answer.periods); + } + + const slots = findFreeSlots({ ...input, busy }); + return { kind: "ok", result: { slots, notChecked, directoryQueriesOff } }; +} + +function formatSlot(slot: Slot, locale: string, timeZone: string, hour12: boolean): { day: string; start: string; end: string } { + const day = new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric", month: "short", timeZone }).format(slot.start); + const time = new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", hour12, timeZone }); + return { day, start: time.format(slot.start), end: time.format(slot.end) }; +} + +interface FindATimeProps { + attendees: Attendee[]; + /** The event window as instants; its length is the meeting's. */ + start: Date | null; + end: Date | null; + /** Moves the event to the picked slot. */ + onPick: (slot: Slot) => void; +} + +/** + * "Suggest times" next to the attendees' availability in the event editor: + * up to five slots in the next ten working days that are free for everyone + * whose calendar Bulwark could check, and who it couldn't check and why. + * The list opens in place under the button (the editor is a full-screen + * sheet on phones already); Escape closes it and gives the focus back. + */ +export function FindATime({ attendees, start, end, onPick }: FindATimeProps) { + const t = useTranslations("find_time"); + const locale = useLocale(); + const client = useAuthStore((s) => s.client); + const calendars = useCalendarStore((s) => s.calendars); + const timeFormat = useSettingsStore((s) => s.timeFormat); + const workingDays = useSettingsStore((s) => s.calendarWorkingDays); + const dayStartHour = useSettingsStore((s) => s.calendarDayStartHour); + const dayEndHour = useSettingsStore((s) => s.calendarDayEndHour); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [outcome, setOutcome] = useState(null); + const [picked, setPicked] = useState(null); + const buttonRef = useRef(null); + const panelId = useId(); + const seq = useRef(0); + + const supported = !!client?.supportsAvailability?.() && !!client.getPrincipalsAvailability; + const durationMs = start && end ? end.getTime() - start.getTime() : 0; + const attendeesKey = attendees.map((a) => a.email.toLowerCase()).join(","); + // A new set of people or a new length is a new question. + useEffect(() => { + seq.current++; + setOutcome(null); + setBusy(false); + }, [attendeesKey, durationMs]); + + const visibleOwners = useMemo(() => visibleCalendarOwners(calendars), [calendars]); + + if (!supported || attendees.length === 0 || !(durationMs > 0)) return null; + + const close = () => { + setOpen(false); + buttonRef.current?.focus(); + }; + + const search = async () => { + if (!client) return; + const mine = ++seq.current; + setOpen(true); + setBusy(true); + setPicked(null); + const allowed = await checkAvailabilityAccess(client); + const result: SuggestionOutcome = allowed + ? await suggestTimes(client, { + attendees, + durationMs, + now: new Date(), + timeZone: getEffectiveTimeZone(), + workingDays, + dayStartHour, + dayEndHour, + visibleOwners, + }).catch((): SuggestionOutcome => ({ kind: "unavailable" })) + : { kind: "unavailable" }; + if (mine !== seq.current) return; + setOutcome(result); + setBusy(false); + }; + + const zone = getEffectiveTimeZone(); + const hour12 = timeFormat === "12h"; + const label = (slot: Slot) => { + const f = formatSlot(slot, locale, zone, hour12); + return t("slot", { day: f.day, start: f.start, end: f.end }); + }; + const result = outcome?.kind === "ok" ? outcome.result : null; + const unknownBusy = result?.notChecked.some((n) => n.reason === "unknown") ?? false; + + return ( +

+ + {open && ( +
{ + if (e.key === "Escape") { + // The editor closes on Escape too; this one is the list's. + e.stopPropagation(); + e.preventDefault(); + close(); + } + }} + > +
+ {busy && ( + + + )} + {!busy && outcome?.kind === "rate_limited" && t("rate_limited")} + {!busy && outcome?.kind === "unavailable" && t("unavailable")} + {!busy && result && result.slots.length === 0 && t("none")} + {!busy && picked && t("picked", { slot: picked })} +
+ {!busy && result && result.slots.length > 0 && ( + <> +

{t("slots_title")}

+
    + {result.slots.map((slot) => { + const text = label(slot); + return ( +
  • + +
  • + ); + })} +
+ + )} + {!busy && result && result.notChecked.length > 0 && ( +
+

{t("not_checked")}

+
    + {result.notChecked.map((n) => ( +
  • + {t("not_checked_item", { who: n.name ? `${n.name} (${n.email})` : n.email, reason: t(`reason_${n.reason}`) })} +
  • + ))} +
+ {unknownBusy &&

{t("unknown_note")}

} + {result.directoryQueriesOff &&

{t("directory_off")}

} +
+ )} + {!busy && result && unknownBusy && } +
+ )} +
+ ); +} diff --git a/components/calendar/free-busy-share-button.tsx b/components/calendar/free-busy-share-button.tsx new file mode 100644 index 000000000..b2983cc08 --- /dev/null +++ b/components/calendar/free-busy-share-button.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useEffect, useId, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Check, Loader2, Share2 } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { useAuthStore } from "@/stores/auth-store"; +import { usePolicyStore } from "@/stores/policy-store"; +import { toast } from "@/stores/toast-store"; +import { ensureFreeBusyShare, freeBusyShareGroups, freeBusyShareState } from "@/lib/free-busy-share"; +import { cn } from "@/lib/utils"; + +/** + * "Share my free/busy with ": shares the user's default calendar's + * busy times (not its events) with the admin's free/busy group, else with a + * group of the user's pick. Once shared it says so instead. Nothing shows + * when there is no group to share with. + */ +export function FreeBusyShareButton({ className }: { className?: string }) { + const t = useTranslations("free_busy_share"); + const client = useAuthStore((s) => s.client); + const adminGroup = usePolicyStore((s) => s.policy.organisation?.calendars?.freeBusyGroup ?? ""); + const groups = useMemo(() => freeBusyShareGroups(client, adminGroup), [client, adminGroup]); + const [group, setGroup] = useState(groups[0] ?? ""); + const [shared, setShared] = useState>({}); + const [saving, setSaving] = useState(false); + const selectId = useId(); + + useEffect(() => { + if (!groups.includes(group)) setGroup(groups[0] ?? ""); + }, [groups, group]); + + useEffect(() => { + if (!client || !group || group in shared) return; + let cancelled = false; + void freeBusyShareState(client, group).catch(() => null).then((state) => { + if (!cancelled) setShared((current) => ({ ...current, [group]: state })); + }); + return () => { cancelled = true; }; + }, [client, group, shared]); + + if (!client || groups.length === 0) return null; + const state = shared[group]; + + const share = async () => { + setSaving(true); + try { + const result = await ensureFreeBusyShare(client, group); + if (result === "shared" || result === "already") { + setShared((current) => ({ ...current, [group]: true })); + toast.success(t("done", { group })); + } else { + toast.error(t("error")); + } + } catch { + toast.error(t("error")); + } finally { + setSaving(false); + } + }; + + return ( +
+ {groups.length > 1 && ( + <> + + + + )} + {state === true ? ( + + + ) : ( + + )} +
+ ); +} diff --git a/components/calendar/participant-availability.tsx b/components/calendar/participant-availability.tsx index afde5753c..5a9132fc1 100644 --- a/components/calendar/participant-availability.tsx +++ b/components/calendar/participant-availability.tsx @@ -17,11 +17,13 @@ interface Attendee { /** * `unknown`: the address is not a user in a complete directory listing. + * `not_looked_up`: the server refused to list its users (directory queries + * off), so the address could not be matched to anyone. * `no_busy_times`: the server returned no busy time for the attendee's day. * It says the same when the user may not see that person's free/busy. * `error`: the lookup failed or was rate-limited, so nothing is known. */ -export type AvailabilityStatus = "free" | "busy" | "tentative" | "no_busy_times" | "unknown" | "error" | "checking"; +export type AvailabilityStatus = "free" | "busy" | "tentative" | "no_busy_times" | "unknown" | "not_looked_up" | "error" | "checking"; interface ParticipantAvailabilityProps { attendees: Attendee[]; @@ -50,10 +52,38 @@ export function availabilityFor(periods: BusyPeriod[], start: Date, end: Date): return status; } +/** Who the attendees are on this server, as far as the directory tells. */ +export interface AttendeeDirectory { + byEmail: Map; + /** The listing names every user, so an address that isn't in it is not one. */ + complete: boolean; + /** The server refused the listing because directory queries are off (Stalwart 1.0's default). */ + directoryQueriesOff: boolean; +} + +/** + * The principal directory by address. A refused listing still holds the + * principals the user shares an account with (Principal/get answers for + * those). Stalwart 1.0.1 looks up their free/busy with directory queries + * off, so they are checked; the 1.0 builds that refuse the lookups as well + * say so with `forbidden` on the lookup. + */ +export async function loadAttendeeDirectory(client: IJMAPClient): Promise { + const directory = await loadPrincipalDirectory(client); + const byEmail = new Map(); + for (const p of directory.principals) { + if (p.email) byEmail.set(p.email.toLowerCase(), p); + } + return { + byEmail, + complete: directory.status === "ok", + directoryQueriesOff: directory.status === "restricted" && !!directory.directoryQueriesOff, + }; +} + /** * The status of every address for the window, or null when the server won't - * answer free/busy for this user: Stalwart 1.0 with directory queries off, - * or a lookup refused with `forbidden`. + * answer free/busy for this user: a lookup refused with `forbidden`. */ async function lookUpStatuses( client: IJMAPClient, @@ -61,17 +91,7 @@ async function lookUpStatuses( start: Date, end: Date, ): Promise | null> { - const directory = await loadPrincipalDirectory(client); - // With directory queries off, Stalwart 1.0 refuses availability lookups as - // well. Any other refused listing is a role without the permission to list - // principals, which is not the one for free/busy (0.16 and 1.0 check them - // one by one): the principals Principal/get still returned are looked up, - // and a lookup refused in turn says it. - if (directory.status === "restricted" && directory.directoryQueriesOff) return null; - const byEmail = new Map(); - for (const p of directory.principals) { - if (p.email) byEmail.set(p.email.toLowerCase(), p); - } + const { byEmail, complete, directoryQueriesOff } = await loadAttendeeDirectory(client); const principalIds = emails.flatMap((email) => byEmail.get(email)?.id ?? []); const answers = await lookUpAvailability(client, principalIds, start, end); const statuses: Record = {}; @@ -79,7 +99,7 @@ async function lookUpStatuses( const principal = byEmail.get(email); if (!principal) { // Only a complete listing shows that an address is not a user here. - statuses[email] = directory.status === "ok" ? "unknown" : "error"; + statuses[email] = complete ? "unknown" : directoryQueriesOff ? "not_looked_up" : "error"; continue; } const answer = answers[principal.id]; @@ -104,6 +124,7 @@ async function lookUpStatuses( */ export function ParticipantAvailability({ attendees, start, end }: ParticipantAvailabilityProps) { const t = useTranslations("calendar.participants.availability"); + const tFind = useTranslations("find_time"); const client = useAuthStore((s) => s.client); const supported = !!client?.supportsAvailability?.() && !!client.getPrincipalsAvailability; const [statuses, setStatuses] = useState>({}); @@ -172,30 +193,38 @@ export function ParticipantAvailability({ attendees, start, end }: ParticipantAv } if (allowed !== true) return null; + const notLookedUp = Object.values(statuses).includes("not_looked_up"); return ( -
    - {attendees.map((a) => { - const status = statuses[a.email.trim().toLowerCase()] ?? "checking"; - return ( -
  • -
  • - ); - })} -
+ <> +
    + {attendees.map((a) => { + const status = statuses[a.email.trim().toLowerCase()] ?? "checking"; + return ( +
  • +
  • + ); + })} +
+ {notLookedUp && ( +

+ {tFind("directory_off")} +

+ )} + ); } diff --git a/lib/__tests__/find-a-time.test.ts b/lib/__tests__/find-a-time.test.ts new file mode 100644 index 000000000..7a3ac4970 --- /dev/null +++ b/lib/__tests__/find-a-time.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { findFreeSlots, searchHours, searchWindow } from '../find-a-time'; +import type { BusyPeriod } from '@/lib/jmap/types'; + +const busy = (utcStart: string, utcEnd: string, busyStatus: BusyPeriod['busyStatus'] = 'confirmed'): BusyPeriod => ({ utcStart, utcEnd, busyStatus }); +const HOUR = 3600_000; + +// Monday 2 November 2026, 07:00 in Berlin (06:00 UTC, winter time). +const monday = new Date('2026-11-02T06:00:00Z'); +const base = { + now: monday, + durationMs: HOUR, + timeZone: 'Europe/Berlin', + workingDays: [1, 2, 3, 4, 5], + dayStartHour: 9, + dayEndHour: 17, + busy: [] as BusyPeriod[], +}; +const starts = (slots: { start: Date }[]) => slots.map((s) => s.start.toISOString()); + +describe('findFreeSlots', () => { + it('starts at the first working hour in the effective zone, on a 30-minute grid', () => { + expect(starts(findFreeSlots(base))).toEqual([ + '2026-11-02T08:00:00.000Z', // 09:00 Berlin + '2026-11-02T08:30:00.000Z', + '2026-11-02T09:00:00.000Z', + '2026-11-02T09:30:00.000Z', + '2026-11-02T10:00:00.000Z', + ]); + }); + + it('reads the hours in the given zone, not the browser zone', () => { + const slots = findFreeSlots({ ...base, timeZone: 'America/New_York', now: new Date('2026-11-02T00:00:00Z'), maxSlots: 1 }); + // 09:00 in New York on Monday 2 November (EST, UTC-5). + expect(starts(slots)).toEqual(['2026-11-02T14:00:00.000Z']); + }); + + it('skips busy and tentative time of everyone and keeps slots inside the day', () => { + const slots = findFreeSlots({ + ...base, + busy: [ + busy('2026-11-02T08:00:00Z', '2026-11-02T10:00:00Z'), // Bob 9-11 Berlin + busy('2026-11-02T10:00:00Z', '2026-11-02T11:00:00Z', 'tentative'), // Carol 11-12 + ], + maxSlots: 2, + }); + expect(starts(slots)).toEqual(['2026-11-02T11:00:00.000Z', '2026-11-02T11:30:00.000Z']); + }); + + it('never ends a slot after the day ends and moves to the next working day', () => { + const lateMonday = new Date('2026-11-02T15:10:00Z'); // 16:10 Berlin + const slots = findFreeSlots({ ...base, now: lateMonday, maxSlots: 1 }); + // 16:30-17:30 would end after 17:00; Tuesday 09:00 is next. + expect(starts(slots)).toEqual(['2026-11-03T08:00:00.000Z']); + }); + + it('only uses the working days', () => { + const friday = new Date('2026-11-06T16:30:00Z'); // 17:30 Berlin, after hours + const slots = findFreeSlots({ ...base, now: friday, maxSlots: 1 }); + expect(starts(slots)).toEqual(['2026-11-09T08:00:00.000Z']); // Monday + const sundayOnly = findFreeSlots({ ...base, workingDays: [0], maxSlots: 1 }); + expect(starts(sundayOnly)).toEqual(['2026-11-08T08:00:00.000Z']); + }); + + it('falls back to Monday to Friday and 8 to 20 for unusable settings', () => { + const slots = findFreeSlots({ ...base, workingDays: [], dayStartHour: 20, dayEndHour: 8, maxSlots: 1 }); + expect(starts(slots)).toEqual(['2026-11-02T07:00:00.000Z']); // 08:00 Berlin + }); + + it('finds nothing when a meeting is longer than the working day', () => { + expect(findFreeSlots({ ...base, durationMs: 9 * HOUR })).toEqual([]); + }); + + it('finds nothing when everything in the next ten working days is busy', () => { + const slots = findFreeSlots({ ...base, busy: [busy('2026-11-01T00:00:00Z', '2026-11-30T00:00:00Z')] }); + expect(slots).toEqual([]); + }); +}); + +describe('searchWindow', () => { + it('runs from now to the end of the tenth working day', () => { + const window = searchWindow(base); + expect(window.start).toBe(monday); + // Ten working days from Monday 2 November: the last is Friday 13 November, ending 17:00 Berlin. + expect(window.end.toISOString()).toBe('2026-11-13T16:00:00.000Z'); + }); +}); + +describe('searchHours', () => { + it('keeps a whole day the user set on purpose', () => { + expect(searchHours(0, 24)).toEqual({ startMinutes: 0, endMinutes: 1440 }); + expect(searchHours(7, 19)).toEqual({ startMinutes: 420, endMinutes: 1140 }); + }); +}); diff --git a/lib/find-a-time.ts b/lib/find-a-time.ts new file mode 100644 index 000000000..ad3910176 --- /dev/null +++ b/lib/find-a-time.ts @@ -0,0 +1,139 @@ +/** + * "Suggest times" in the event editor: the next slots in which nobody whose + * calendar Bulwark could check is busy. + * + * The search runs over the organiser's working days and day hours (the + * calendar settings of #1164), in the effective time zone, on a 30-minute + * grid. The hours are used even when the calendar doesn't limit the time + * grids to them: a suggestion at three in the morning helps nobody. + */ + +import type { BusyPeriod } from '@/lib/jmap/types'; +import { fromWallClock, getWallClock } from '@/lib/timezone'; +import { resolveDisplayHours } from '@/lib/calendar-display-range'; + +export const SEARCH_WORKING_DAYS = 10; +export const MAX_SUGGESTIONS = 5; +export const GRID_MINUTES = 30; +const DEFAULT_WORKING_DAYS = [1, 2, 3, 4, 5]; +const DEFAULT_HOURS = { start: 8, end: 20 }; + +export interface SlotSearchOptions { + /** The earliest moment a slot may start. */ + now: Date; + /** The meeting's length. */ + durationMs: number; + /** The effective IANA time zone the hours and days are read in. */ + timeZone: string; + /** Weekdays as Date.getDay numbers (0 = Sunday); anything unusable means Monday to Friday. */ + workingDays: unknown; + dayStartHour: unknown; + dayEndHour: unknown; + /** Busy time of everyone who was checked, merged or not. */ + busy: BusyPeriod[]; + maxSlots?: number; + workingDayCount?: number; +} + +export interface Slot { + start: Date; + end: Date; +} + +interface Day { + year: number; + month: number; + day: number; +} + +function workingDaySet(value: unknown): Set { + const days = Array.isArray(value) ? value.filter((d): d is number => Number.isInteger(d) && d >= 0 && d <= 6) : []; + return new Set(days.length > 0 ? days : DEFAULT_WORKING_DAYS); +} + +/** The day hours to search in minutes from midnight: the user's, else 8 to 20. */ +export function searchHours(startHour: unknown, endHour: unknown): { startMinutes: number; endMinutes: number } { + const hours = resolveDisplayHours(true, startHour, endHour); + if (!hours.restricted && !(startHour === 0 && endHour === 24)) { + return { startMinutes: DEFAULT_HOURS.start * 60, endMinutes: DEFAULT_HOURS.end * 60 }; + } + return { startMinutes: hours.startMinutes, endMinutes: hours.endMinutes }; +} + +function nextDay(d: Day): Day { + const date = new Date(Date.UTC(d.year, d.month - 1, d.day + 1)); + return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1, day: date.getUTCDate() }; +} + +function weekday(d: Day): number { + return new Date(Date.UTC(d.year, d.month - 1, d.day)).getUTCDay(); +} + +function at(d: Day, minutes: number, timeZone: string): Date { + // 24:00 is midnight of the next day. + const day = minutes >= 1440 ? nextDay(d) : d; + const m = minutes % 1440; + return fromWallClock({ ...day, hour: Math.floor(m / 60), minute: m % 60, second: 0 }, timeZone); +} + +/** The working days the search covers, from today on, in `timeZone`. */ +function searchDays(now: Date, timeZone: string, workingDays: Set, count: number): Day[] { + const wall = getWallClock(now, timeZone); + let d: Day = { year: wall.year, month: wall.month, day: wall.day }; + const days: Day[] = []; + // At most a year of days, in case the set is somehow empty. + for (let i = 0; i < 366 && days.length < count; i++) { + if (workingDays.has(weekday(d))) days.push(d); + d = nextDay(d); + } + return days; +} + +/** + * The span one availability call per person has to cover: from now to the + * end of the last working day searched. + */ +export function searchWindow(options: Pick): { start: Date; end: Date } { + const days = searchDays(options.now, options.timeZone, workingDaySet(options.workingDays), options.workingDayCount ?? SEARCH_WORKING_DAYS); + const last = days[days.length - 1]; + const { endMinutes } = searchHours(options.dayStartHour, options.dayEndHour); + return { start: options.now, end: last ? at(last, endMinutes, options.timeZone) : options.now }; +} + +function overlaps(busy: Array<{ start: number; end: number }>, start: number, end: number): boolean { + for (const period of busy) { + if (period.start < end && period.end > start) return true; + } + return false; +} + +/** + * Up to `maxSlots` meeting slots of `durationMs` inside the working hours of + * the next working days, starting on the grid, that overlap no busy period. + * Tentative time counts as busy: a suggestion should not ask anyone to drop + * something. + */ +export function findFreeSlots(options: SlotSearchOptions): Slot[] { + const max = options.maxSlots ?? MAX_SUGGESTIONS; + if (!(options.durationMs > 0)) return []; + const { startMinutes, endMinutes } = searchHours(options.dayStartHour, options.dayEndHour); + const days = searchDays(options.now, options.timeZone, workingDaySet(options.workingDays), options.workingDayCount ?? SEARCH_WORKING_DAYS); + const busy = options.busy + .map((p) => ({ start: Date.parse(p.utcStart), end: Date.parse(p.utcEnd) })) + .filter((p) => Number.isFinite(p.start) && Number.isFinite(p.end) && p.end > p.start); + const nowMs = options.now.getTime(); + const slots: Slot[] = []; + for (const day of days) { + const dayEnd = at(day, endMinutes, options.timeZone).getTime(); + for (let minutes = startMinutes; minutes < endMinutes; minutes += GRID_MINUTES) { + const start = at(day, minutes, options.timeZone).getTime(); + const end = start + options.durationMs; + if (end > dayEnd) break; + if (start < nowMs) continue; + if (overlaps(busy, start, end)) continue; + slots.push({ start: new Date(start), end: new Date(end) }); + if (slots.length >= max) return slots; + } + } + return slots; +} diff --git a/lib/free-busy-share.ts b/lib/free-busy-share.ts new file mode 100644 index 000000000..640302fb7 --- /dev/null +++ b/lib/free-busy-share.ts @@ -0,0 +1,120 @@ +/** + * Sharing the user's free/busy with a group. + * + * Stalwart answers Principal/getAvailability only from calendars shared with + * the person asking (mayReadFreeBusy or mayReadItems, to them or a group they + * are in), so on a fresh server "Suggest times" sees nobody busy + * (local-data/enterprise-pack-facts.md part 6 §1). Two ways to fix that: + * + * - the admin names a group in organisation.calendars.freeBusyGroup, and + * Bulwark shares each user's default calendar with it at sign-in; + * - a user shares their own with one click (Calendar settings, the Find a + * time note). + * + * Both only add: an existing share of the group keeps its rights, with + * mayReadFreeBusy added when it lacked it, and no other share is touched. + * The share is an ordinary one, so it shows in the calendar's sharing list. + */ + +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Calendar, CalendarRights } from '@/lib/jmap/types'; +import { CALENDAR_PRESETS } from '@/lib/jmap/share-rights'; + +type ShareClient = Pick; + +export type FreeBusyShareResult = 'shared' | 'already' | 'no-calendar' | 'no-group'; + +/** The user's own default calendar: the server's default, else the first event calendar. */ +export function ownDefaultCalendar(calendars: Calendar[]): Calendar | null { + const own = calendars.filter((c) => !c.isShared && !c.isTasksOnly); + return own.find((c) => c.isDefault) ?? own[0] ?? null; +} + +/** + * The principal id of a group address: the group account in the session + * (Stalwart uses the account id as the principal id), else a directory + * lookup. Null when neither knows it. + */ +export async function groupPrincipalId(client: ShareClient, address: string): Promise { + const wanted = address.trim().toLowerCase(); + if (!wanted) return null; + const account = (typeof client.getSharedAccounts === 'function' ? client.getSharedAccounts() : []) + .find((a) => !a.isPrimary && a.name.trim().toLowerCase() === wanted && client.getAccountInfo?.(a.id)?.isPersonal === false); + if (account) return account.id; + if (!client.findPrincipalByEmail) return null; + const found = await client.findPrincipalByEmail(wanted).catch(() => null); + const principal = found?.principal; + return principal && (!principal.type || principal.type === 'group') ? principal.id : null; +} + +/** Whether `calendar` already lets the principal see its free/busy. */ +export function sharesFreeBusyWith(calendar: Pick, principalId: string): boolean { + const rights = calendar.shareWith?.[principalId]; + return !!rights && (rights.mayReadFreeBusy || rights.mayReadItems); +} + +/** The rights to write for the principal: what it has plus mayReadFreeBusy. */ +export function withFreeBusy(existing: CalendarRights | undefined): CalendarRights { + return existing ? { ...existing, mayReadFreeBusy: true } : { ...CALENDAR_PRESETS.freeBusy }; +} + +/** Share the user's default calendar's free/busy with the group at `address`. */ +export async function ensureFreeBusyShare(client: ShareClient, address: string): Promise { + const principalId = await groupPrincipalId(client, address); + if (!principalId) return 'no-group'; + const calendar = ownDefaultCalendar(await client.getAllCalendars()); + if (!calendar) return 'no-calendar'; + if (sharesFreeBusyWith(calendar, principalId)) return 'already'; + await client.setCalendarShare(calendar.originalId ?? calendar.id, principalId, withFreeBusy(calendar.shareWith?.[principalId])); + return 'shared'; +} + +/** + * Whether the user's default calendar shares free/busy with the group, or + * null when that can't be told (no calendar, unknown group). + */ +export async function freeBusyShareState(client: ShareClient, address: string): Promise { + const principalId = await groupPrincipalId(client, address); + if (!principalId) return null; + const calendar = ownDefaultCalendar(await client.getAllCalendars()); + return calendar ? sharesFreeBusyWith(calendar, principalId) : null; +} + +/** + * The groups a user can share with from the button: the admin's group when + * one is set, else the group accounts in the session. + */ +export function freeBusyShareGroups(client: Pick | null, adminGroup: string): string[] { + if (adminGroup) return [adminGroup.toLowerCase()]; + if (!client || typeof client.getSharedAccounts !== 'function') return []; + return client.getSharedAccounts() + .filter((a) => !a.isPrimary && a.name.includes('@') && client.getAccountInfo?.(a.id)?.isPersonal === false) + .map((a) => a.name.trim().toLowerCase()) + .sort(); +} + +// Once per page load and account: the admin's share is applied at sign-in, +// and a user who removes it later gets it back at the next one. +const applied = new Set(); + +/** + * Apply the admin's free/busy group to the signed-in account. Never throws; + * a failure is tried again on the next sign-in or page load. + */ +export async function applyAdminFreeBusyShare(client: ShareClient & Pick, group: string): Promise { + if (!group) return null; + const key = `${client.getAccountId()}|${group.toLowerCase()}`; + if (applied.has(key)) return null; + applied.add(key); + try { + return await ensureFreeBusyShare(client, group); + } catch { + applied.delete(key); + return null; + } +} + +/** For tests. */ +export function resetAppliedFreeBusyShares(): void { + applied.clear(); +} diff --git a/lib/jmap/free-busy.ts b/lib/jmap/free-busy.ts index 8faaf747f..c040de687 100644 --- a/lib/jmap/free-busy.ts +++ b/lib/jmap/free-busy.ts @@ -25,6 +25,14 @@ export const AVAILABILITY_TTL_MS = 3 * 60 * 1000; */ export const RATE_LIMIT_BACKOFF_MS = 60 * 1000; +/** + * Stalwart 1.0 allows this many availability calls per account a minute + * (x:Calendar.availabilityRateLimit, the default). 0.16 has no limit; counting + * there costs nothing. + */ +export const AVAILABILITY_CALLS_PER_MINUTE = 100; +const MINUTE_MS = 60 * 1000; + const access = new WeakMap(); const pendingChecks = new WeakMap>(); @@ -66,6 +74,8 @@ interface CachedLookup { interface LookupState { cache: Map; backoffUntil: number; + /** When each call of the last minute went out, oldest first. */ + sent: number[]; } const lookups = new WeakMap(); @@ -73,12 +83,25 @@ const lookups = new WeakMap(); function lookupStateOf(client: IJMAPClient): LookupState { let state = lookups.get(client); if (!state) { - state = { cache: new Map(), backoffUntil: 0 }; + state = { cache: new Map(), backoffUntil: 0, sent: [] }; lookups.set(client, state); } return state; } +/** + * How many more availability calls this tab can make within the server's + * limit right now: none while it backs off after a refusal. The limit is per + * account and other tabs and devices spend from it too, so this is an upper + * bound; a search that needs more than this is not started. + */ +export function availabilityCallsLeft(client: IJMAPClient, now = Date.now()): number { + const state = lookupStateOf(client); + if (now < state.backoffUntil) return 0; + state.sent = state.sent.filter((at) => now - at < MINUTE_MS); + return Math.max(0, AVAILABILITY_CALLS_PER_MINUTE - state.sent.length); +} + /** * The window a lookup asks the server for: the local days from `start` to * `end`, so that moving an event within its day is answered from the cache. @@ -124,6 +147,7 @@ export async function lookUpAvailability( if (missing.length > 0 && now < state.backoffUntil) { for (const id of missing) results.set(id, Promise.resolve({ error: "rateLimit" })); } else if (missing.length > 0) { + for (let i = 0; i < missing.length; i++) state.sent.push(now); const batch = client.getPrincipalsAvailability!(missing, days.start, days.end) .catch((): Record => ({})); for (const id of missing) { diff --git a/locales/ar/common.json b/locales/ar/common.json index 2116d773b..584b71534 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -4845,6 +4845,34 @@ "cancelled": "ألغى {name} \"{title}\"", "untitled": "حدث بدون عنوان" }, + "free_busy_share": { + "button": "مشاركة أوقات توفري مع {group}", + "already": "أوقات توفرك مشتركة مع {group}", + "done": "أصبحت أوقات توفرك مشتركة مع {group}.", + "error": "تعذّرت مشاركة أوقات توفرك. حاول مرة أخرى.", + "group_label": "المجموعة", + "title": "متاح/مشغول", + "description": "يرى زملاؤك في المجموعة متى تكون مشغولًا، لا تفاصيل أحداثك. تستخدمه ميزة اقتراح الأوقات." + }, + "find_time": { + "suggest": "اقتراح أوقات", + "searching": "جارٍ البحث عن أوقات متاحة", + "slots_title": "متاح لكل من استطاع Bulwark التحقق منه", + "slot": "{day}، من {start} إلى {end}", + "none": "لا يوجد وقت متاح في أيام العمل العشرة القادمة.", + "picked": "نُقل الحدث إلى {slot}", + "rate_limited": "طلبات كثيرة جدًا في الدقيقة الأخيرة. حاول مرة أخرى بعد دقيقة.", + "unavailable": "معلومات التوفر غير متاحة على هذا الخادم، لذا لا يستطيع Bulwark اقتراح أوقات.", + "not_checked": "لم يُتحقق منهم:", + "not_checked_item": "{who}: {reason}", + "reason_external": "خارج مؤسستك", + "reason_unknown": "أوقات الانشغال غير معروفة", + "reason_not_found": "غير موجود على هذا الخادم", + "reason_error": "تعذّر التحقق", + "unknown_note": "يرى Bulwark أوقات الانشغال فقط في التقويمات المشتركة معك. لهؤلاء الأشخاص لا يمكنه التمييز بين متاح وغير مشترك.", + "directory_off": "لا يستطيع Bulwark البحث عن الأشخاص على هذا الخادم. يمكن لمسؤولك تفعيل استعلامات الدليل في Stalwart.", + "not_looked_up": "تعذّر البحث" + }, "share_notifications": { "someone": "شخص ما", "shared": "شارك {name} معك {kind} \"{object}\"", diff --git a/locales/ca/common.json b/locales/ca/common.json index e023d59f5..5871d97c2 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -4845,6 +4845,34 @@ "cancelled": "{name} ha cancel·lat \"{title}\"", "untitled": "Esdeveniment sense títol" }, + "free_busy_share": { + "button": "Comparteix la meva disponibilitat amb {group}", + "already": "La vostra disponibilitat està compartida amb {group}", + "done": "Ara la vostra disponibilitat està compartida amb {group}.", + "error": "No s’ha pogut compartir la vostra disponibilitat. Torneu-ho a provar.", + "group_label": "Grup", + "title": "Disponibilitat", + "description": "Els companys del grup veuen quan esteu ocupat, no quins esdeveniments teniu. «Suggereix horaris» ho fa servir." + }, + "find_time": { + "suggest": "Suggereix horaris", + "searching": "S’estan cercant horaris lliures", + "slots_title": "Lliure per a tothom que Bulwark ha pogut comprovar", + "slot": "{day}, de {start} a {end}", + "none": "No hi ha temps lliure en els propers 10 dies laborables.", + "picked": "Esdeveniment mogut a {slot}", + "rate_limited": "Massa consultes en l’últim minut. Torneu-ho a provar d’aquí a un minut.", + "unavailable": "La disponibilitat no està disponible en aquest servidor, així que Bulwark no pot suggerir horaris.", + "not_checked": "No comprovats:", + "not_checked_item": "{who}: {reason}", + "reason_external": "fora de la vostra organització", + "reason_unknown": "hores ocupades desconegudes", + "reason_not_found": "no trobat en aquest servidor", + "reason_error": "no s’ha pogut comprovar", + "unknown_note": "Bulwark només veu les hores ocupades dels calendaris compartits amb vós. Per a aquestes persones no pot distingir lliure de no compartit.", + "directory_off": "Bulwark no pot cercar persones en aquest servidor. L’administrador pot activar les consultes de directori a Stalwart.", + "not_looked_up": "No es pot cercar" + }, "share_notifications": { "someone": "Algú", "shared": "{name} ha compartit amb tu {kind} \"{object}\"", diff --git a/locales/cs/common.json b/locales/cs/common.json index a93c1ce6a..08beef52e 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5283,6 +5283,34 @@ "cancelled": "{name} zrušil(a) „{title}“", "untitled": "Událost bez názvu" }, + "free_busy_share": { + "button": "Sdílet můj volný čas se skupinou {group}", + "already": "Váš volný čas je sdílen se skupinou {group}", + "done": "Váš volný čas je nyní sdílen se skupinou {group}.", + "error": "Volný čas se nepodařilo sdílet. Zkuste to znovu.", + "group_label": "Skupina", + "title": "Volno/obsazeno", + "description": "Kolegové ve skupině vidí, kdy jste obsazeni, ne jaké máte události. Používá to funkce Navrhnout časy." + }, + "find_time": { + "suggest": "Navrhnout časy", + "searching": "Hledají se volné časy", + "slots_title": "Volno pro všechny, které Bulwark mohl ověřit", + "slot": "{day}, {start} až {end}", + "none": "V příštích 10 pracovních dnech není volný čas.", + "picked": "Událost přesunuta na {slot}", + "rate_limited": "V poslední minutě bylo příliš mnoho dotazů. Zkuste to za minutu znovu.", + "unavailable": "Informace o volnu nejsou na tomto serveru k dispozici, takže Bulwark nemůže navrhnout časy.", + "not_checked": "Neověřeno:", + "not_checked_item": "{who}: {reason}", + "reason_external": "mimo vaši organizaci", + "reason_unknown": "obsazené časy neznámé", + "reason_not_found": "na tomto serveru nenalezen", + "reason_error": "nelze ověřit", + "unknown_note": "Bulwark vidí obsazené časy jen v kalendářích, které s vámi někdo sdílí. U těchto lidí nerozliší volno od nesdíleného kalendáře.", + "directory_off": "Bulwark na tomto serveru nemůže vyhledávat lidi. Správce může ve Stalwartu zapnout dotazy do adresáře.", + "not_looked_up": "Nelze vyhledat" + }, "share_notifications": { "someone": "Někdo", "shared": "{name} s vámi sdílí {kind} „{object}“", diff --git a/locales/da/common.json b/locales/da/common.json index 9710e03a2..c1c99480f 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -4845,6 +4845,34 @@ "cancelled": "{name} har aflyst \"{title}\"", "untitled": "Unavngivet begivenhed" }, + "free_busy_share": { + "button": "Del min ledig/optaget med {group}", + "already": "Din ledig/optaget er delt med {group}", + "done": "Din ledig/optaget er nu delt med {group}.", + "error": "Din ledig/optaget kunne ikke deles. Prøv igen.", + "group_label": "Gruppe", + "title": "Ledig/optaget", + "description": "Kolleger i gruppen ser, hvornår du er optaget, ikke hvad dine begivenheder er. Foreslå tidspunkter bruger det." + }, + "find_time": { + "suggest": "Foreslå tidspunkter", + "searching": "Leder efter ledige tidspunkter", + "slots_title": "Ledigt for alle, Bulwark kunne tjekke", + "slot": "{day}, {start} til {end}", + "none": "Ingen ledig tid i de næste 10 arbejdsdage.", + "picked": "Begivenhed flyttet til {slot}", + "rate_limited": "For mange opslag i det sidste minut. Prøv igen om et minut.", + "unavailable": "Ledig/optaget er ikke tilgængelig på denne server, så Bulwark kan ikke foreslå tidspunkter.", + "not_checked": "Ikke tjekket:", + "not_checked_item": "{who}: {reason}", + "reason_external": "uden for din organisation", + "reason_unknown": "optagede tider ukendte", + "reason_not_found": "ikke fundet på denne server", + "reason_error": "kunne ikke tjekkes", + "unknown_note": "Bulwark ser kun optagede tider i kalendere, der er delt med dig. For disse personer kan den ikke skelne ledig fra ikke delt.", + "directory_off": "Bulwark kan ikke slå personer op på denne server. Din administrator kan slå katalogforespørgsler til i Stalwart.", + "not_looked_up": "Kan ikke slås op" + }, "share_notifications": { "someone": "Nogen", "shared": "{name} har delt {kind} \"{object}\" med dig", diff --git a/locales/de/common.json b/locales/de/common.json index 04c1424a3..11f3b2148 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5283,6 +5283,34 @@ "cancelled": "{name} hat „{title}“ abgesagt", "untitled": "Unbenannter Termin" }, + "free_busy_share": { + "button": "Meine Frei/Gebucht-Zeiten für {group} freigeben", + "already": "Ihre Frei/Gebucht-Zeiten sind für {group} freigegeben", + "done": "Ihre Frei/Gebucht-Zeiten sind jetzt für {group} freigegeben.", + "error": "Ihre Frei/Gebucht-Zeiten konnten nicht freigegeben werden. Versuchen Sie es erneut.", + "group_label": "Gruppe", + "title": "Frei/Gebucht", + "description": "Kolleginnen und Kollegen in der Gruppe sehen, wann Sie belegt sind, aber nicht, welche Termine Sie haben. „Zeiten vorschlagen“ nutzt das." + }, + "find_time": { + "suggest": "Zeiten vorschlagen", + "searching": "Freie Zeiten werden gesucht", + "slots_title": "Frei für alle, die Bulwark prüfen konnte", + "slot": "{day}, {start} bis {end}", + "none": "In den nächsten 10 Arbeitstagen ist keine Zeit frei.", + "picked": "Termin verschoben auf {slot}", + "rate_limited": "Zu viele Abfragen in der letzten Minute. Versuchen Sie es in einer Minute erneut.", + "unavailable": "Frei/Gebucht ist auf diesem Server nicht verfügbar, daher kann Bulwark keine Zeiten vorschlagen.", + "not_checked": "Nicht geprüft:", + "not_checked_item": "{who}: {reason}", + "reason_external": "außerhalb Ihrer Organisation", + "reason_unknown": "belegte Zeiten unbekannt", + "reason_not_found": "auf diesem Server nicht gefunden", + "reason_error": "konnte nicht geprüft werden", + "unknown_note": "Bulwark sieht belegte Zeiten nur in Kalendern, die für Sie freigegeben sind. Bei diesen Personen kann es „frei“ nicht von „nicht freigegeben“ unterscheiden.", + "directory_off": "Bulwark kann auf diesem Server keine Personen nachschlagen. Ihre Administration kann Verzeichnisabfragen in Stalwart einschalten.", + "not_looked_up": "Nicht auffindbar" + }, "share_notifications": { "someone": "Jemand", "shared": "{name} hat {kind} „{object}“ mit Ihnen geteilt", diff --git a/locales/en/common.json b/locales/en/common.json index 89aad41a5..116409c5f 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -4845,6 +4845,34 @@ "cancelled": "{name} cancelled \"{title}\"", "untitled": "Untitled event" }, + "free_busy_share": { + "button": "Share my free/busy with {group}", + "already": "Your free/busy is shared with {group}", + "done": "Your free/busy is now shared with {group}.", + "error": "Couldn’t share your free/busy. Try again.", + "group_label": "Group", + "title": "Free/busy", + "description": "Colleagues in the group see when you’re busy, not what your events are. Suggest times uses it." + }, + "find_time": { + "suggest": "Suggest times", + "searching": "Looking for free times", + "slots_title": "Free for everyone Bulwark could check", + "slot": "{day}, {start} to {end}", + "none": "No free time in the next 10 working days.", + "picked": "Event moved to {slot}", + "rate_limited": "Too many lookups in the last minute. Try again in a minute.", + "unavailable": "Free/busy isn’t available on this server, so Bulwark can’t suggest times.", + "not_checked": "Not checked:", + "not_checked_item": "{who}: {reason}", + "reason_external": "outside your organisation", + "reason_unknown": "busy times unknown", + "reason_not_found": "not found on this server", + "reason_error": "couldn’t be checked", + "unknown_note": "Bulwark only sees busy times in calendars shared with you. For these people it can’t tell free from not shared.", + "directory_off": "Bulwark can’t look up people on this server. Your administrator can turn on directory queries in Stalwart.", + "not_looked_up": "Can’t look up" + }, "share_notifications": { "someone": "Someone", "shared": "{name} shared the {kind} \"{object}\" with you", diff --git a/locales/es/common.json b/locales/es/common.json index 4b53c9222..f379ec9b1 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5283,6 +5283,34 @@ "cancelled": "{name} ha cancelado \"{title}\"", "untitled": "Evento sin título" }, + "free_busy_share": { + "button": "Compartir mi disponibilidad con {group}", + "already": "Tu disponibilidad está compartida con {group}", + "done": "Ahora tu disponibilidad está compartida con {group}.", + "error": "No se pudo compartir tu disponibilidad. Inténtalo de nuevo.", + "group_label": "Grupo", + "title": "Disponibilidad", + "description": "Tus compañeros del grupo ven cuándo estás ocupado, no qué eventos tienes. «Sugerir horarios» lo usa." + }, + "find_time": { + "suggest": "Sugerir horarios", + "searching": "Buscando horarios libres", + "slots_title": "Libre para todos los que Bulwark pudo comprobar", + "slot": "{day}, de {start} a {end}", + "none": "No hay tiempo libre en los próximos 10 días laborables.", + "picked": "Evento movido a {slot}", + "rate_limited": "Demasiadas consultas en el último minuto. Inténtalo de nuevo en un minuto.", + "unavailable": "La disponibilidad no está disponible en este servidor, así que Bulwark no puede sugerir horarios.", + "not_checked": "Sin comprobar:", + "not_checked_item": "{who}: {reason}", + "reason_external": "fuera de tu organización", + "reason_unknown": "horas ocupadas desconocidas", + "reason_not_found": "no encontrado en este servidor", + "reason_error": "no se pudo comprobar", + "unknown_note": "Bulwark solo ve las horas ocupadas de los calendarios compartidos contigo. Para estas personas no puede distinguir libre de no compartido.", + "directory_off": "Bulwark no puede buscar personas en este servidor. Tu administrador puede activar las consultas de directorio en Stalwart.", + "not_looked_up": "No se puede buscar" + }, "share_notifications": { "someone": "Alguien", "shared": "{name} ha compartido contigo {kind} \"{object}\"", diff --git a/locales/et/common.json b/locales/et/common.json index e12342f13..54aa09f4e 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -4845,6 +4845,34 @@ "cancelled": "{name} tühistas sündmuse \"{title}\"", "untitled": "Nimetu sündmus" }, + "free_busy_share": { + "button": "Jaga minu vaba/hõivatud aega grupiga {group}", + "already": "Sinu vaba/hõivatud aeg on jagatud grupiga {group}", + "done": "Sinu vaba/hõivatud aeg on nüüd jagatud grupiga {group}.", + "error": "Vaba/hõivatud aega ei saanud jagada. Proovi uuesti.", + "group_label": "Grupp", + "title": "Vaba/hõivatud", + "description": "Grupi kolleegid näevad, millal oled hõivatud, aga mitte sinu sündmusi. Seda kasutab „Soovita aegu“." + }, + "find_time": { + "suggest": "Soovita aegu", + "searching": "Vabu aegu otsitakse", + "slots_title": "Vaba kõigile, keda Bulwark sai kontrollida", + "slot": "{day}, {start} kuni {end}", + "none": "Järgmise 10 tööpäeva jooksul pole vaba aega.", + "picked": "Sündmus viidi aega {slot}", + "rate_limited": "Viimase minuti jooksul oli liiga palju päringuid. Proovi minuti pärast uuesti.", + "unavailable": "Vaba/hõivatud teave pole selles serveris saadaval, seega ei saa Bulwark aegu soovitada.", + "not_checked": "Kontrollimata:", + "not_checked_item": "{who}: {reason}", + "reason_external": "väljaspool sinu organisatsiooni", + "reason_unknown": "hõivatud ajad teadmata", + "reason_not_found": "selles serveris ei leitud", + "reason_error": "ei saanud kontrollida", + "unknown_note": "Bulwark näeb hõivatud aegu ainult sinuga jagatud kalendrites. Nende inimeste puhul ei saa ta eristada vaba ajast jagamata kalendrit.", + "directory_off": "Bulwark ei saa selles serveris inimesi otsida. Administraator saab Stalwartis kataloogipäringud sisse lülitada.", + "not_looked_up": "Ei saa otsida" + }, "share_notifications": { "someone": "Keegi", "shared": "{name} jagas sinuga {kind} \"{object}\"", diff --git a/locales/fa/common.json b/locales/fa/common.json index f3604c96c..33fbfb86e 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -4845,6 +4845,34 @@ "cancelled": "{name} «{title}» را لغو کرد", "untitled": "رویداد بدون عنوان" }, + "free_busy_share": { + "button": "هم‌رسانی آزاد/مشغول من با {group}", + "already": "آزاد/مشغول شما با {group} هم‌رسانی شده است", + "done": "آزاد/مشغول شما اکنون با {group} هم‌رسانی شد.", + "error": "هم‌رسانی آزاد/مشغول شما انجام نشد. دوباره تلاش کنید.", + "group_label": "گروه", + "title": "آزاد/مشغول", + "description": "همکاران گروه می‌بینند چه زمانی مشغول هستید، نه اینکه رویدادهایتان چیست. «پیشنهاد زمان» از آن استفاده می‌کند." + }, + "find_time": { + "suggest": "پیشنهاد زمان", + "searching": "در حال جست‌وجوی زمان‌های آزاد", + "slots_title": "آزاد برای همهٔ کسانی که Bulwark توانست بررسی کند", + "slot": "{day}، از {start} تا {end}", + "none": "در ۱۰ روز کاری آینده زمان آزادی نیست.", + "picked": "رویداد به {slot} منتقل شد", + "rate_limited": "در دقیقهٔ گذشته درخواست‌های زیادی ارسال شد. یک دقیقهٔ دیگر دوباره تلاش کنید.", + "unavailable": "اطلاعات آزاد/مشغول در این سرور در دسترس نیست، بنابراین Bulwark نمی‌تواند زمان پیشنهاد دهد.", + "not_checked": "بررسی‌نشده:", + "not_checked_item": "{who}: {reason}", + "reason_external": "خارج از سازمان شما", + "reason_unknown": "زمان‌های مشغول نامعلوم", + "reason_not_found": "در این سرور پیدا نشد", + "reason_error": "بررسی نشد", + "unknown_note": "Bulwark فقط زمان‌های مشغول را در تقویم‌هایی می‌بیند که با شما هم‌رسانی شده‌اند. برای این افراد نمی‌تواند آزاد را از هم‌رسانی‌نشده تشخیص دهد.", + "directory_off": "Bulwark نمی‌تواند در این سرور افراد را جست‌وجو کند. مدیر شما می‌تواند پرس‌وجوی فهرست را در Stalwart روشن کند.", + "not_looked_up": "جست‌وجو ممکن نیست" + }, "share_notifications": { "someone": "کسی", "shared": "{name} {kind} «{object}» را با شما به اشتراک گذاشت", diff --git a/locales/fr/common.json b/locales/fr/common.json index f0c00df6c..fbdb03f60 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5283,6 +5283,34 @@ "cancelled": "{name} a annulé « {title} »", "untitled": "Événement sans titre" }, + "free_busy_share": { + "button": "Partager mes disponibilités avec {group}", + "already": "Vos disponibilités sont partagées avec {group}", + "done": "Vos disponibilités sont maintenant partagées avec {group}.", + "error": "Impossible de partager vos disponibilités. Réessayez.", + "group_label": "Groupe", + "title": "Disponibilités", + "description": "Les collègues du groupe voient quand vous êtes occupé, pas le contenu de vos événements. « Suggérer des créneaux » s’en sert." + }, + "find_time": { + "suggest": "Suggérer des créneaux", + "searching": "Recherche de créneaux libres", + "slots_title": "Libre pour toutes les personnes que Bulwark a pu vérifier", + "slot": "{day}, de {start} à {end}", + "none": "Aucun créneau libre dans les 10 prochains jours ouvrés.", + "picked": "Événement déplacé : {slot}", + "rate_limited": "Trop de recherches dans la dernière minute. Réessayez dans une minute.", + "unavailable": "La disponibilité n’est pas accessible sur ce serveur, Bulwark ne peut donc pas suggérer de créneaux.", + "not_checked": "Non vérifiés :", + "not_checked_item": "{who} : {reason}", + "reason_external": "hors de votre organisation", + "reason_unknown": "disponibilités inconnues", + "reason_not_found": "introuvable sur ce serveur", + "reason_error": "vérification impossible", + "unknown_note": "Bulwark ne voit les disponibilités que dans les calendriers partagés avec vous. Pour ces personnes, il ne peut pas distinguer « libre » de « non partagé ».", + "directory_off": "Bulwark ne peut pas rechercher de personnes sur ce serveur. Votre administrateur peut activer les requêtes d’annuaire dans Stalwart.", + "not_looked_up": "Recherche impossible" + }, "share_notifications": { "someone": "Quelqu'un", "shared": "{name} a partagé {kind} « {object} » avec vous", diff --git a/locales/he/common.json b/locales/he/common.json index 8f1f998a3..4374888e4 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5255,6 +5255,34 @@ "cancelled": "{name} ביטל/ה את \"{title}\"", "untitled": "אירוע ללא כותרת" }, + "free_busy_share": { + "button": "שתף את הזמינות שלי עם {group}", + "already": "הזמינות שלך משותפת עם {group}", + "done": "הזמינות שלך משותפת עכשיו עם {group}.", + "error": "לא ניתן היה לשתף את הזמינות שלך. נסו שוב.", + "group_label": "קבוצה", + "title": "פנוי/תפוס", + "description": "עמיתים בקבוצה רואים מתי אתה תפוס, לא מה האירועים שלך. \"הצע זמנים\" משתמש בזה." + }, + "find_time": { + "suggest": "הצע זמנים", + "searching": "מחפש זמנים פנויים", + "slots_title": "פנוי לכל מי ש-Bulwark הצליח לבדוק", + "slot": "{day}, {start} עד {end}", + "none": "אין זמן פנוי ב-10 ימי העבודה הבאים.", + "picked": "האירוע הועבר אל {slot}", + "rate_limited": "יותר מדי בדיקות בדקה האחרונה. נסו שוב בעוד דקה.", + "unavailable": "מידע על פנוי/תפוס אינו זמין בשרת הזה, ולכן Bulwark לא יכול להציע זמנים.", + "not_checked": "לא נבדקו:", + "not_checked_item": "{who}: {reason}", + "reason_external": "מחוץ לארגון שלך", + "reason_unknown": "זמנים תפוסים לא ידועים", + "reason_not_found": "לא נמצא בשרת הזה", + "reason_error": "לא ניתן היה לבדוק", + "unknown_note": "Bulwark רואה זמנים תפוסים רק ביומנים ששותפו איתך. אצל האנשים האלה הוא לא יכול להבדיל בין פנוי ללא משותף.", + "directory_off": "Bulwark לא יכול לחפש אנשים בשרת הזה. מנהל המערכת יכול להפעיל שאילתות ספרייה ב-Stalwart.", + "not_looked_up": "לא ניתן לחפש" + }, "share_notifications": { "someone": "מישהו", "shared": "{name} שיתף/ה איתך את {kind} \"{object}\"", diff --git a/locales/hu/common.json b/locales/hu/common.json index 6c3668404..5338b9049 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -4845,6 +4845,34 @@ "cancelled": "{name} lemondta a(z) „{title}” eseményt", "untitled": "Névtelen esemény" }, + "free_busy_share": { + "button": "Szabad/foglalt időim megosztása ezzel: {group}", + "already": "Szabad/foglalt idői meg vannak osztva ezzel: {group}", + "done": "Szabad/foglalt idői mostantól meg vannak osztva ezzel: {group}.", + "error": "Nem sikerült megosztani a szabad/foglalt időket. Próbálja újra.", + "group_label": "Csoport", + "title": "Szabad/foglalt", + "description": "A csoport tagjai látják, mikor foglalt, de az eseményeit nem. Az Időpontok javaslata ezt használja." + }, + "find_time": { + "suggest": "Időpontok javaslata", + "searching": "Szabad időpontok keresése", + "slots_title": "Szabad mindenkinek, akit a Bulwark ellenőrizni tudott", + "slot": "{day}, {start}–{end}", + "none": "A következő 10 munkanapon nincs szabad idő.", + "picked": "Az esemény áthelyezve: {slot}", + "rate_limited": "Túl sok lekérdezés az elmúlt percben. Próbálja újra egy perc múlva.", + "unavailable": "A szabad/foglalt adatok ezen a szerveren nem érhetők el, ezért a Bulwark nem tud időpontot javasolni.", + "not_checked": "Nem ellenőrzött:", + "not_checked_item": "{who}: {reason}", + "reason_external": "a szervezetén kívül", + "reason_unknown": "foglalt idők ismeretlenek", + "reason_not_found": "nem található ezen a szerveren", + "reason_error": "nem sikerült ellenőrizni", + "unknown_note": "A Bulwark csak az Önnel megosztott naptárakban látja a foglalt időket. Ezeknél a személyeknél nem tudja megkülönböztetni a szabadot a nem megosztottól.", + "directory_off": "A Bulwark ezen a szerveren nem tud személyeket keresni. A rendszergazda a Stalwartban bekapcsolhatja a címtár-lekérdezéseket.", + "not_looked_up": "Nem kereshető" + }, "share_notifications": { "someone": "Valaki", "shared": "{name} megosztotta veled a(z) {kind} „{object}” elemet", diff --git a/locales/it/common.json b/locales/it/common.json index 99afff3f1..0cac7d77f 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5283,6 +5283,34 @@ "cancelled": "{name} ha annullato \"{title}\"", "untitled": "Evento senza titolo" }, + "free_busy_share": { + "button": "Condividi la mia disponibilità con {group}", + "already": "La tua disponibilità è condivisa con {group}", + "done": "Ora la tua disponibilità è condivisa con {group}.", + "error": "Impossibile condividere la tua disponibilità. Riprova.", + "group_label": "Gruppo", + "title": "Disponibilità", + "description": "I colleghi del gruppo vedono quando sei occupato, non quali eventi hai. «Suggerisci orari» lo usa." + }, + "find_time": { + "suggest": "Suggerisci orari", + "searching": "Ricerca di orari liberi", + "slots_title": "Libero per tutti quelli che Bulwark ha potuto controllare", + "slot": "{day}, dalle {start} alle {end}", + "none": "Nessun orario libero nei prossimi 10 giorni lavorativi.", + "picked": "Evento spostato a {slot}", + "rate_limited": "Troppe richieste nell’ultimo minuto. Riprova tra un minuto.", + "unavailable": "La disponibilità non è accessibile su questo server, quindi Bulwark non può suggerire orari.", + "not_checked": "Non controllati:", + "not_checked_item": "{who}: {reason}", + "reason_external": "esterno alla tua organizzazione", + "reason_unknown": "orari occupati sconosciuti", + "reason_not_found": "non trovato su questo server", + "reason_error": "impossibile controllare", + "unknown_note": "Bulwark vede gli orari occupati solo nei calendari condivisi con te. Per queste persone non può distinguere libero da non condiviso.", + "directory_off": "Bulwark non può cercare persone su questo server. L’amministratore può attivare le query della rubrica in Stalwart.", + "not_looked_up": "Ricerca non possibile" + }, "share_notifications": { "someone": "Qualcuno", "shared": "{name} ha condiviso con te {kind} \"{object}\"", diff --git a/locales/ja/common.json b/locales/ja/common.json index 3a8dcb529..3410dabc0 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5283,6 +5283,34 @@ "cancelled": "{name} さんが「{title}」をキャンセルしました", "untitled": "無題の予定" }, + "free_busy_share": { + "button": "自分の空き時間を {group} と共有", + "already": "空き時間は {group} と共有されています", + "done": "空き時間を {group} と共有しました。", + "error": "空き時間を共有できませんでした。もう一度お試しください。", + "group_label": "グループ", + "title": "空き時間", + "description": "グループのメンバーには予定がある時間だけが見え、予定の内容は見えません。「時間を提案」で使われます。" + }, + "find_time": { + "suggest": "時間を提案", + "searching": "空いている時間を探しています", + "slots_title": "Bulwark が確認できた全員が空いている時間", + "slot": "{day} {start}~{end}", + "none": "今後 10 営業日に空き時間はありません。", + "picked": "予定を {slot} に移動しました", + "rate_limited": "直前の 1 分間の照会が多すぎます。1 分後にもう一度お試しください。", + "unavailable": "このサーバーでは空き時間情報を利用できないため、Bulwark は時間を提案できません。", + "not_checked": "確認できなかった人:", + "not_checked_item": "{who}:{reason}", + "reason_external": "組織外", + "reason_unknown": "予定ありの時間は不明", + "reason_not_found": "このサーバーに見つかりません", + "reason_error": "確認できませんでした", + "unknown_note": "Bulwark が予定ありの時間を確認できるのは、共有されたカレンダーだけです。これらの人については、空いているのか共有されていないのか区別できません。", + "directory_off": "Bulwark はこのサーバーで人を検索できません。管理者は Stalwart でディレクトリ照会をオンにできます。", + "not_looked_up": "検索できません" + }, "share_notifications": { "someone": "誰か", "shared": "{name} さんが{kind}「{object}」を共有しました", diff --git a/locales/ko/common.json b/locales/ko/common.json index 8dadb7040..2431ad99b 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5283,6 +5283,34 @@ "cancelled": "{name}님이 \"{title}\"을(를) 취소했습니다", "untitled": "제목 없는 일정" }, + "free_busy_share": { + "button": "내 일정 가능 여부를 {group}과(와) 공유", + "already": "일정 가능 여부가 {group}과(와) 공유되어 있습니다", + "done": "이제 일정 가능 여부가 {group}과(와) 공유됩니다.", + "error": "일정 가능 여부를 공유하지 못했습니다. 다시 시도하세요.", + "group_label": "그룹", + "title": "일정 가능 여부", + "description": "그룹의 동료는 내가 바쁜 시간만 보고 일정 내용은 보지 못합니다. 시간 제안에 사용됩니다." + }, + "find_time": { + "suggest": "시간 제안", + "searching": "빈 시간을 찾는 중", + "slots_title": "Bulwark가 확인할 수 있었던 모든 사람이 비어 있는 시간", + "slot": "{day} {start}~{end}", + "none": "다음 10 영업일 동안 빈 시간이 없습니다.", + "picked": "일정을 {slot}(으)로 옮겼습니다", + "rate_limited": "지난 1분 동안 조회가 너무 많았습니다. 1분 후에 다시 시도하세요.", + "unavailable": "이 서버에서는 일정 가능 여부를 사용할 수 없어 Bulwark가 시간을 제안할 수 없습니다.", + "not_checked": "확인하지 못함:", + "not_checked_item": "{who}: {reason}", + "reason_external": "조직 외부", + "reason_unknown": "바쁜 시간 알 수 없음", + "reason_not_found": "이 서버에서 찾을 수 없음", + "reason_error": "확인할 수 없음", + "unknown_note": "Bulwark는 나와 공유된 캘린더의 바쁜 시간만 볼 수 있습니다. 이 사람들은 비어 있는지 공유하지 않은 것인지 구분할 수 없습니다.", + "directory_off": "Bulwark가 이 서버에서 사람을 찾을 수 없습니다. 관리자가 Stalwart에서 디렉터리 조회를 켤 수 있습니다.", + "not_looked_up": "찾을 수 없음" + }, "share_notifications": { "someone": "누군가", "shared": "{name}님이 {kind} \"{object}\"을(를) 공유했습니다", diff --git a/locales/lv/common.json b/locales/lv/common.json index 8160cd0b0..c2a66d6ee 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5283,6 +5283,34 @@ "cancelled": "{name} atcēla “{title}”", "untitled": "Notikums bez nosaukuma" }, + "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}", + "done": "Jūsu brīvs/aizņemts tagad ir kopīgots ar {group}.", + "error": "Neizdevās kopīgot brīvs/aizņemts. Mēģiniet vēlreiz.", + "group_label": "Grupa", + "title": "Brīvs/aizņemts", + "description": "Grupas kolēģi redz, kad esat aizņemts, bet ne jūsu notikumus. To izmanto “Ieteikt laikus”." + }, + "find_time": { + "suggest": "Ieteikt laikus", + "searching": "Meklē brīvus laikus", + "slots_title": "Brīvs visiem, kurus Bulwark varēja pārbaudīt", + "slot": "{day}, {start} līdz {end}", + "none": "Nākamajās 10 darba dienās nav brīva laika.", + "picked": "Notikums pārcelts uz {slot}", + "rate_limited": "Pēdējā minūtē bija pārāk daudz vaicājumu. Mēģiniet vēlreiz pēc minūtes.", + "unavailable": "Šajā serverī nav pieejama brīvs/aizņemts informācija, tāpēc Bulwark nevar ieteikt laikus.", + "not_checked": "Nav pārbaudīti:", + "not_checked_item": "{who}: {reason}", + "reason_external": "ārpus jūsu organizācijas", + "reason_unknown": "aizņemtie laiki nav zināmi", + "reason_not_found": "šajā serverī nav atrasts", + "reason_error": "neizdevās pārbaudīt", + "unknown_note": "Bulwark redz aizņemtos laikus tikai kalendāros, kas kopīgoti ar jums. Šiem cilvēkiem tas nevar atšķirt brīvu laiku no nekopīgota kalendāra.", + "directory_off": "Bulwark šajā serverī nevar meklēt cilvēkus. Administrators var ieslēgt direktorija vaicājumus Stalwart.", + "not_looked_up": "Nevar atrast" + }, "share_notifications": { "someone": "Kāds", "shared": "{name} kopīgoja ar jums {kind} “{object}”", diff --git a/locales/mn/common.json b/locales/mn/common.json index 015e1f720..4ccce0a76 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -4845,6 +4845,34 @@ "cancelled": "{name} \"{title}\"-г цуцаллаа", "untitled": "Нэргүй арга хэмжээ" }, + "free_busy_share": { + "button": "Миний чөлөөтэй/завгүй цагийг {group}-тэй хуваалцах", + "already": "Таны чөлөөтэй/завгүй цаг {group}-тэй хуваалцагдсан", + "done": "Таны чөлөөтэй/завгүй цаг одоо {group}-тэй хуваалцагдлаа.", + "error": "Чөлөөтэй/завгүй цагийг хуваалцаж чадсангүй. Дахин оролдоно уу.", + "group_label": "Бүлэг", + "title": "Чөлөөтэй/завгүй", + "description": "Бүлгийн хамт олон таныг хэзээ завгүйг хардаг ч арга хэмжээг тань хардаггүй. “Цаг санал болгох” үүнийг ашигладаг." + }, + "find_time": { + "suggest": "Цаг санал болгох", + "searching": "Чөлөөтэй цаг хайж байна", + "slots_title": "Bulwark шалгаж чадсан бүх хүнд чөлөөтэй", + "slot": "{day}, {start}-с {end} хүртэл", + "none": "Дараагийн 10 ажлын өдөрт чөлөөтэй цаг алга.", + "picked": "Арга хэмжээг {slot} руу шилжүүллээ", + "rate_limited": "Сүүлийн нэг минутад хэт олон хайлт хийгдлээ. Нэг минутын дараа дахин оролдоно уу.", + "unavailable": "Энэ сервер дээр чөлөөтэй/завгүй мэдээлэл байхгүй тул Bulwark цаг санал болгож чадахгүй.", + "not_checked": "Шалгаагүй:", + "not_checked_item": "{who}: {reason}", + "reason_external": "таны байгууллагаас гадуур", + "reason_unknown": "завгүй цаг тодорхойгүй", + "reason_not_found": "энэ серверээс олдсонгүй", + "reason_error": "шалгаж чадсангүй", + "unknown_note": "Bulwark зөвхөн тантай хуваалцсан хуанлийн завгүй цагийг хардаг. Эдгээр хүмүүсийн хувьд чөлөөтэй эсвэл хуваалцаагүйг ялгаж чадахгүй.", + "directory_off": "Bulwark энэ серверээс хүн хайж чадахгүй. Админ тань Stalwart-д лавлахын хайлтыг асааж болно.", + "not_looked_up": "Хайх боломжгүй" + }, "share_notifications": { "someone": "Хэн нэгэн", "shared": "{name} танд {kind} \"{object}\"-г хуваалцлаа", diff --git a/locales/nb/common.json b/locales/nb/common.json index bea1f8ef8..614217b6d 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -4845,6 +4845,34 @@ "cancelled": "{name} har avlyst «{title}»", "untitled": "Hendelse uten tittel" }, + "free_busy_share": { + "button": "Del min ledig/opptatt med {group}", + "already": "Din ledig/opptatt er delt med {group}", + "done": "Din ledig/opptatt er nå delt med {group}.", + "error": "Kunne ikke dele ledig/opptatt. Prøv igjen.", + "group_label": "Gruppe", + "title": "Ledig/opptatt", + "description": "Kolleger i gruppen ser når du er opptatt, ikke hva hendelsene dine er. Foreslå tidspunkter bruker det." + }, + "find_time": { + "suggest": "Foreslå tidspunkter", + "searching": "Ser etter ledige tidspunkter", + "slots_title": "Ledig for alle Bulwark kunne sjekke", + "slot": "{day}, {start} til {end}", + "none": "Ingen ledig tid de neste 10 arbeidsdagene.", + "picked": "Hendelsen er flyttet til {slot}", + "rate_limited": "For mange oppslag det siste minuttet. Prøv igjen om et minutt.", + "unavailable": "Ledig/opptatt er ikke tilgjengelig på denne serveren, så Bulwark kan ikke foreslå tidspunkter.", + "not_checked": "Ikke sjekket:", + "not_checked_item": "{who}: {reason}", + "reason_external": "utenfor organisasjonen din", + "reason_unknown": "opptatte tider ukjent", + "reason_not_found": "ikke funnet på denne serveren", + "reason_error": "kunne ikke sjekkes", + "unknown_note": "Bulwark ser bare opptatte tider i kalendere som er delt med deg. For disse personene kan den ikke skille ledig fra ikke delt.", + "directory_off": "Bulwark kan ikke slå opp personer på denne serveren. Administratoren kan slå på katalogspørringer i Stalwart.", + "not_looked_up": "Kan ikke slås opp" + }, "share_notifications": { "someone": "Noen", "shared": "{name} har delt {kind} «{object}» med deg", diff --git a/locales/nl/common.json b/locales/nl/common.json index cf24997fc..c26218436 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5283,6 +5283,34 @@ "cancelled": "{name} heeft \"{title}\" geannuleerd", "untitled": "Afspraak zonder titel" }, + "free_busy_share": { + "button": "Mijn vrij/bezet delen met {group}", + "already": "Je vrij/bezet is gedeeld met {group}", + "done": "Je vrij/bezet is nu gedeeld met {group}.", + "error": "Kon je vrij/bezet niet delen. Probeer het opnieuw.", + "group_label": "Groep", + "title": "Vrij/bezet", + "description": "Collega’s in de groep zien wanneer je bezet bent, niet wat je afspraken zijn. ‘Tijden voorstellen’ gebruikt dit." + }, + "find_time": { + "suggest": "Tijden voorstellen", + "searching": "Vrije tijden zoeken", + "slots_title": "Vrij voor iedereen die Bulwark kon controleren", + "slot": "{day}, {start} tot {end}", + "none": "Geen vrije tijd in de komende 10 werkdagen.", + "picked": "Afspraak verplaatst naar {slot}", + "rate_limited": "Te veel opvragingen in de afgelopen minuut. Probeer het over een minuut opnieuw.", + "unavailable": "Vrij/bezet is niet beschikbaar op deze server, dus Bulwark kan geen tijden voorstellen.", + "not_checked": "Niet gecontroleerd:", + "not_checked_item": "{who}: {reason}", + "reason_external": "buiten je organisatie", + "reason_unknown": "bezette tijden onbekend", + "reason_not_found": "niet gevonden op deze server", + "reason_error": "kon niet worden gecontroleerd", + "unknown_note": "Bulwark ziet bezette tijden alleen in agenda’s die met je zijn gedeeld. Bij deze mensen kan het vrij niet onderscheiden van niet gedeeld.", + "directory_off": "Bulwark kan op deze server geen mensen opzoeken. Je beheerder kan directoryquery’s aanzetten in Stalwart.", + "not_looked_up": "Kan niet opzoeken" + }, "share_notifications": { "someone": "Iemand", "shared": "{name} heeft {kind} \"{object}\" met je gedeeld", diff --git a/locales/pl/common.json b/locales/pl/common.json index eb67c8751..fb953bcd7 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5283,6 +5283,34 @@ "cancelled": "{name} odwołał(a) „{title}”", "untitled": "Wydarzenie bez tytułu" }, + "free_busy_share": { + "button": "Udostępnij moją dostępność grupie {group}", + "already": "Twoja dostępność jest udostępniona grupie {group}", + "done": "Twoja dostępność jest teraz udostępniona grupie {group}.", + "error": "Nie udało się udostępnić dostępności. Spróbuj ponownie.", + "group_label": "Grupa", + "title": "Dostępność", + "description": "Współpracownicy z grupy widzą, kiedy jesteś zajęty, ale nie widzą Twoich wydarzeń. Korzysta z tego funkcja proponowania terminów." + }, + "find_time": { + "suggest": "Zaproponuj terminy", + "searching": "Szukanie wolnych terminów", + "slots_title": "Wolne dla wszystkich, których Bulwark mógł sprawdzić", + "slot": "{day}, od {start} do {end}", + "none": "Brak wolnego czasu w ciągu najbliższych 10 dni roboczych.", + "picked": "Wydarzenie przeniesione na {slot}", + "rate_limited": "Za dużo zapytań w ostatniej minucie. Spróbuj ponownie za minutę.", + "unavailable": "Informacje o dostępności nie są dostępne na tym serwerze, więc Bulwark nie może zaproponować terminów.", + "not_checked": "Nie sprawdzono:", + "not_checked_item": "{who}: {reason}", + "reason_external": "spoza Twojej organizacji", + "reason_unknown": "zajęte terminy nieznane", + "reason_not_found": "nie znaleziono na tym serwerze", + "reason_error": "nie udało się sprawdzić", + "unknown_note": "Bulwark widzi zajęte terminy tylko w kalendarzach udostępnionych Tobie. U tych osób nie odróżni wolnego czasu od braku udostępnienia.", + "directory_off": "Bulwark nie może wyszukiwać osób na tym serwerze. Administrator może włączyć zapytania do katalogu w Stalwart.", + "not_looked_up": "Nie można wyszukać" + }, "share_notifications": { "someone": "Ktoś", "shared": "{name} udostępnił(a) Ci {kind} „{object}”", diff --git a/locales/pt/common.json b/locales/pt/common.json index a4b70cef5..963717aaa 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5283,6 +5283,34 @@ "cancelled": "{name} cancelou \"{title}\"", "untitled": "Evento sem título" }, + "free_busy_share": { + "button": "Partilhar a minha disponibilidade com {group}", + "already": "A sua disponibilidade está partilhada com {group}", + "done": "A sua disponibilidade está agora partilhada com {group}.", + "error": "Não foi possível partilhar a sua disponibilidade. Tente novamente.", + "group_label": "Grupo", + "title": "Disponibilidade", + "description": "Os colegas do grupo veem quando está ocupado, não quais são os seus eventos. «Sugerir horários» usa isto." + }, + "find_time": { + "suggest": "Sugerir horários", + "searching": "A procurar horários livres", + "slots_title": "Livre para todos os que o Bulwark conseguiu verificar", + "slot": "{day}, das {start} às {end}", + "none": "Não há tempo livre nos próximos 10 dias úteis.", + "picked": "Evento movido para {slot}", + "rate_limited": "Demasiadas consultas no último minuto. Tente novamente daqui a um minuto.", + "unavailable": "A disponibilidade não está acessível neste servidor, por isso o Bulwark não pode sugerir horários.", + "not_checked": "Não verificados:", + "not_checked_item": "{who}: {reason}", + "reason_external": "fora da sua organização", + "reason_unknown": "horas ocupadas desconhecidas", + "reason_not_found": "não encontrado neste servidor", + "reason_error": "não foi possível verificar", + "unknown_note": "O Bulwark só vê horas ocupadas nos calendários partilhados consigo. Para estas pessoas não consegue distinguir livre de não partilhado.", + "directory_off": "O Bulwark não consegue procurar pessoas neste servidor. O administrador pode ativar as consultas ao diretório no Stalwart.", + "not_looked_up": "Não é possível procurar" + }, "share_notifications": { "someone": "Alguém", "shared": "{name} partilhou consigo {kind} \"{object}\"", diff --git a/locales/ro/common.json b/locales/ro/common.json index ffdd16db2..462064a37 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -4845,6 +4845,34 @@ "cancelled": "{name} a anulat „{title}”", "untitled": "Eveniment fără titlu" }, + "free_busy_share": { + "button": "Partajează disponibilitatea mea cu {group}", + "already": "Disponibilitatea dvs. este partajată cu {group}", + "done": "Disponibilitatea dvs. este acum partajată cu {group}.", + "error": "Disponibilitatea nu a putut fi partajată. Încercați din nou.", + "group_label": "Grup", + "title": "Disponibilitate", + "description": "Colegii din grup văd când sunteți ocupat, nu ce evenimente aveți. „Sugerează ore” folosește asta." + }, + "find_time": { + "suggest": "Sugerează ore", + "searching": "Se caută ore libere", + "slots_title": "Liber pentru toți cei pe care Bulwark i-a putut verifica", + "slot": "{day}, de la {start} la {end}", + "none": "Nu există timp liber în următoarele 10 zile lucrătoare.", + "picked": "Eveniment mutat la {slot}", + "rate_limited": "Prea multe interogări în ultimul minut. Încercați din nou peste un minut.", + "unavailable": "Disponibilitatea nu este accesibilă pe acest server, așa că Bulwark nu poate sugera ore.", + "not_checked": "Neverificați:", + "not_checked_item": "{who}: {reason}", + "reason_external": "din afara organizației dvs.", + "reason_unknown": "ore ocupate necunoscute", + "reason_not_found": "negăsit pe acest server", + "reason_error": "nu a putut fi verificat", + "unknown_note": "Bulwark vede orele ocupate doar în calendarele partajate cu dvs. Pentru aceste persoane nu poate deosebi liber de nepartajat.", + "directory_off": "Bulwark nu poate căuta persoane pe acest server. Administratorul poate activa interogările de director în Stalwart.", + "not_looked_up": "Nu se poate căuta" + }, "share_notifications": { "someone": "Cineva", "shared": "{name} a partajat cu tine {kind} „{object}”", diff --git a/locales/ru/common.json b/locales/ru/common.json index 9061aba90..11ebda433 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5283,6 +5283,34 @@ "cancelled": "{name} отменил(а) «{title}»", "untitled": "Событие без названия" }, + "free_busy_share": { + "button": "Открыть мою занятость для {group}", + "already": "Ваша занятость открыта для {group}", + "done": "Теперь ваша занятость открыта для {group}.", + "error": "Не удалось открыть занятость. Попробуйте ещё раз.", + "group_label": "Группа", + "title": "Занятость", + "description": "Коллеги из группы видят, когда вы заняты, но не видят ваши события. Это использует функция «Предложить время»." + }, + "find_time": { + "suggest": "Предложить время", + "searching": "Поиск свободного времени", + "slots_title": "Свободно у всех, кого Bulwark смог проверить", + "slot": "{day}, с {start} до {end}", + "none": "В ближайшие 10 рабочих дней свободного времени нет.", + "picked": "Событие перенесено на {slot}", + "rate_limited": "Слишком много запросов за последнюю минуту. Повторите через минуту.", + "unavailable": "Сведения о занятости недоступны на этом сервере, поэтому Bulwark не может предложить время.", + "not_checked": "Не проверены:", + "not_checked_item": "{who}: {reason}", + "reason_external": "вне вашей организации", + "reason_unknown": "занятость неизвестна", + "reason_not_found": "не найден на этом сервере", + "reason_error": "не удалось проверить", + "unknown_note": "Bulwark видит занятость только в календарях, к которым вам открыт доступ. Для этих людей он не может отличить «свободно» от «нет доступа».", + "directory_off": "Bulwark не может искать людей на этом сервере. Администратор может включить запросы к каталогу в Stalwart.", + "not_looked_up": "Не удаётся найти" + }, "share_notifications": { "someone": "Кто-то", "shared": "{name} поделился(ась) с вами {kind} «{object}»", diff --git a/locales/sk/common.json b/locales/sk/common.json index 28f6b8cd0..a5f5c5179 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -4845,6 +4845,34 @@ "cancelled": "{name} zrušil(a) „{title}“", "untitled": "Udalosť bez názvu" }, + "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}", + "done": "Váš voľný čas je teraz zdieľaný so skupinou {group}.", + "error": "Voľný čas sa nepodarilo zdieľať. Skúste to znova.", + "group_label": "Skupina", + "title": "Voľno/obsadené", + "description": "Kolegovia v skupine vidia, kedy ste obsadení, nie aké máte udalosti. Používa to funkcia Navrhnúť časy." + }, + "find_time": { + "suggest": "Navrhnúť časy", + "searching": "Hľadajú sa voľné časy", + "slots_title": "Voľno pre všetkých, ktorých Bulwark mohol overiť", + "slot": "{day}, {start} až {end}", + "none": "V nasledujúcich 10 pracovných dňoch nie je voľný čas.", + "picked": "Udalosť presunutá na {slot}", + "rate_limited": "V poslednej minúte bolo príliš veľa dopytov. Skúste to znova o minútu.", + "unavailable": "Informácie o voľnom čase nie sú na tomto serveri dostupné, takže Bulwark nemôže navrhnúť časy.", + "not_checked": "Neoverené:", + "not_checked_item": "{who}: {reason}", + "reason_external": "mimo vašej organizácie", + "reason_unknown": "obsadené časy neznáme", + "reason_not_found": "na tomto serveri sa nenašiel", + "reason_error": "nepodarilo sa overiť", + "unknown_note": "Bulwark vidí obsadené časy len v kalendároch, ktoré s vami niekto zdieľa. Pri týchto ľuďoch nerozlíši voľno od nezdieľaného kalendára.", + "directory_off": "Bulwark na tomto serveri nemôže vyhľadávať ľudí. Správca môže v Stalwarte zapnúť dopyty do adresára.", + "not_looked_up": "Nedá sa vyhľadať" + }, "share_notifications": { "someone": "Niekto", "shared": "{name} s vami zdieľa {kind} „{object}“", diff --git a/locales/th/common.json b/locales/th/common.json index 60ccca46c..0c07c6937 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2185,6 +2185,34 @@ "cancelled": "{name} ยกเลิกกิจกรรม “{title}”", "untitled": "กิจกรรมที่ไม่มีชื่อ" }, + "free_busy_share": { + "button": "แชร์เวลาว่าง/ไม่ว่างของฉันกับ {group}", + "already": "แชร์เวลาว่าง/ไม่ว่างของคุณกับ {group} แล้ว", + "done": "แชร์เวลาว่าง/ไม่ว่างของคุณกับ {group} แล้ว", + "error": "แชร์เวลาว่าง/ไม่ว่างไม่สำเร็จ ลองอีกครั้ง", + "group_label": "กลุ่ม", + "title": "ว่าง/ไม่ว่าง", + "description": "เพื่อนร่วมงานในกลุ่มเห็นว่าคุณไม่ว่างเมื่อใด แต่ไม่เห็นรายละเอียดกิจกรรม ฟีเจอร์แนะนำเวลาใช้ข้อมูลนี้" + }, + "find_time": { + "suggest": "แนะนำเวลา", + "searching": "กำลังหาเวลาว่าง", + "slots_title": "ว่างสำหรับทุกคนที่ Bulwark ตรวจสอบได้", + "slot": "{day} {start} ถึง {end}", + "none": "ไม่มีเวลาว่างใน 10 วันทำงานถัดไป", + "picked": "ย้ายกิจกรรมไปที่ {slot} แล้ว", + "rate_limited": "มีการค้นหามากเกินไปในหนึ่งนาทีที่ผ่านมา ลองอีกครั้งในอีกหนึ่งนาที", + "unavailable": "เซิร์ฟเวอร์นี้ไม่มีข้อมูลว่าง/ไม่ว่าง Bulwark จึงแนะนำเวลาไม่ได้", + "not_checked": "ไม่ได้ตรวจสอบ:", + "not_checked_item": "{who}: {reason}", + "reason_external": "อยู่นอกองค์กรของคุณ", + "reason_unknown": "ไม่ทราบเวลาที่ไม่ว่าง", + "reason_not_found": "ไม่พบในเซิร์ฟเวอร์นี้", + "reason_error": "ตรวจสอบไม่ได้", + "unknown_note": "Bulwark เห็นเวลาที่ไม่ว่างเฉพาะในปฏิทินที่แชร์กับคุณ สำหรับคนเหล่านี้จึงแยกไม่ออกว่าว่างหรือไม่ได้แชร์", + "directory_off": "Bulwark ค้นหาบุคคลในเซิร์ฟเวอร์นี้ไม่ได้ ผู้ดูแลระบบเปิดการค้นหาไดเรกทอรีได้ใน Stalwart", + "not_looked_up": "ค้นหาไม่ได้" + }, "share_notifications": { "someone": "บุคคลอื่น", "shared": "{name} แชร์ {kind} “{object}” กับคุณ", diff --git a/locales/tr/common.json b/locales/tr/common.json index 46613e997..4c032aebd 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -4845,6 +4845,34 @@ "cancelled": "{name} \"{title}\" etkinliğini iptal etti", "untitled": "Başlıksız etkinlik" }, + "free_busy_share": { + "button": "Müsaitlik durumumu {group} ile paylaş", + "already": "Müsaitlik durumunuz {group} ile paylaşılıyor", + "done": "Müsaitlik durumunuz artık {group} ile paylaşılıyor.", + "error": "Müsaitlik durumunuz paylaşılamadı. Yeniden deneyin.", + "group_label": "Grup", + "title": "Müsaitlik", + "description": "Gruptaki iş arkadaşlarınız ne zaman meşgul olduğunuzu görür, etkinliklerinizi görmez. Saat öner bunu kullanır." + }, + "find_time": { + "suggest": "Saat öner", + "searching": "Boş saatler aranıyor", + "slots_title": "Bulwark’ın kontrol edebildiği herkes için boş", + "slot": "{day}, {start} - {end}", + "none": "Önümüzdeki 10 iş gününde boş zaman yok.", + "picked": "Etkinlik {slot} zamanına taşındı", + "rate_limited": "Son bir dakikada çok fazla sorgu yapıldı. Bir dakika sonra yeniden deneyin.", + "unavailable": "Bu sunucuda müsaitlik bilgisi yok, bu yüzden Bulwark saat öneremiyor.", + "not_checked": "Kontrol edilmedi:", + "not_checked_item": "{who}: {reason}", + "reason_external": "kuruluşunuzun dışında", + "reason_unknown": "meşgul saatler bilinmiyor", + "reason_not_found": "bu sunucuda bulunamadı", + "reason_error": "kontrol edilemedi", + "unknown_note": "Bulwark meşgul saatleri yalnızca sizinle paylaşılan takvimlerde görür. Bu kişiler için boş olmayı paylaşılmamaktan ayıramaz.", + "directory_off": "Bulwark bu sunucuda kişi arayamıyor. Yöneticiniz Stalwart’ta dizin sorgularını açabilir.", + "not_looked_up": "Aranamıyor" + }, "share_notifications": { "someone": "Birisi", "shared": "{name}, \"{object}\" {kind} sizinle paylaştı", diff --git a/locales/uk/common.json b/locales/uk/common.json index 6c1397a83..9ebdb3f9e 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5283,6 +5283,34 @@ "cancelled": "{name} скасував(ла) «{title}»", "untitled": "Подія без назви" }, + "free_busy_share": { + "button": "Поділитися моєю зайнятістю з {group}", + "already": "Вашою зайнятістю поділено з {group}", + "done": "Тепер вашою зайнятістю поділено з {group}.", + "error": "Не вдалося поділитися зайнятістю. Спробуйте ще раз.", + "group_label": "Група", + "title": "Зайнятість", + "description": "Колеги з групи бачать, коли ви зайняті, але не бачать ваших подій. Цим користується «Запропонувати час»." + }, + "find_time": { + "suggest": "Запропонувати час", + "searching": "Пошук вільного часу", + "slots_title": "Вільно в усіх, кого Bulwark зміг перевірити", + "slot": "{day}, з {start} до {end}", + "none": "У найближчі 10 робочих днів вільного часу немає.", + "picked": "Подію перенесено на {slot}", + "rate_limited": "Забагато запитів за останню хвилину. Спробуйте ще раз за хвилину.", + "unavailable": "Відомості про зайнятість на цьому сервері недоступні, тому Bulwark не може запропонувати час.", + "not_checked": "Не перевірено:", + "not_checked_item": "{who}: {reason}", + "reason_external": "поза вашою організацією", + "reason_unknown": "зайнятість невідома", + "reason_not_found": "не знайдено на цьому сервері", + "reason_error": "не вдалося перевірити", + "unknown_note": "Bulwark бачить зайнятість лише в календарях, якими з вами поділилися. Для цих людей він не відрізнить «вільно» від «немає доступу».", + "directory_off": "Bulwark не може шукати людей на цьому сервері. Адміністратор може ввімкнути запити до каталогу в Stalwart.", + "not_looked_up": "Не вдається знайти" + }, "share_notifications": { "someone": "Хтось", "shared": "{name} поділився(лася) з вами {kind} «{object}»", diff --git a/locales/vi/common.json b/locales/vi/common.json index 30fac8a94..93589e534 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -4857,6 +4857,34 @@ "cancelled": "{name} đã hủy \"{title}\"", "untitled": "Sự kiện không có tiêu đề" }, + "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}", + "done": "Đã chia sẻ lịch rảnh/bận của bạn với {group}.", + "error": "Không chia sẻ được lịch rảnh/bận. Hãy thử lại.", + "group_label": "Nhóm", + "title": "Rảnh/bận", + "description": "Đồng nghiệp trong nhóm thấy khi nào bạn bận, không thấy nội dung sự kiện. Tính năng gợi ý thời gian dùng thông tin này." + }, + "find_time": { + "suggest": "Gợi ý thời gian", + "searching": "Đang tìm thời gian trống", + "slots_title": "Trống với mọi người mà Bulwark kiểm tra được", + "slot": "{day}, {start} đến {end}", + "none": "Không có thời gian trống trong 10 ngày làm việc tới.", + "picked": "Đã dời sự kiện sang {slot}", + "rate_limited": "Quá nhiều lượt tra cứu trong phút vừa qua. Hãy thử lại sau một phút.", + "unavailable": "Máy chủ này không có thông tin rảnh/bận, nên Bulwark không thể gợi ý thời gian.", + "not_checked": "Chưa kiểm tra:", + "not_checked_item": "{who}: {reason}", + "reason_external": "ngoài tổ chức của bạn", + "reason_unknown": "không rõ giờ bận", + "reason_not_found": "không tìm thấy trên máy chủ này", + "reason_error": "không kiểm tra được", + "unknown_note": "Bulwark chỉ thấy giờ bận trong các lịch được chia sẻ với bạn. Với những người này, nó không phân biệt được rảnh hay chưa chia sẻ.", + "directory_off": "Bulwark không thể tra cứu người trên máy chủ này. Quản trị viên có thể bật truy vấn thư mục trong Stalwart.", + "not_looked_up": "Không tra cứu được" + }, "share_notifications": { "someone": "Ai đó", "shared": "{name} đã chia sẻ {kind} \"{object}\" với bạn", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 1c8ada23d..1b1ab0ec7 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -4845,6 +4845,34 @@ "cancelled": "{name} 取消了「{title}」", "untitled": "未命名活動" }, + "free_busy_share": { + "button": "與 {group} 共用我的忙碌/空閒時間", + "already": "你的忙碌/空閒時間已與 {group} 共用", + "done": "你的忙碌/空閒時間現已與 {group} 共用。", + "error": "無法共用你的忙碌/空閒時間,請再試一次。", + "group_label": "群組", + "title": "忙碌/空閒", + "description": "群組中的同事能看到你何時忙碌,看不到活動內容。「建議時間」會用到它。" + }, + "find_time": { + "suggest": "建議時間", + "searching": "正在尋找空閒時間", + "slots_title": "Bulwark 能查到的所有人都有空", + "slot": "{day} {start} 至 {end}", + "none": "未來 10 個工作天內沒有空閒時間。", + "picked": "活動已移到 {slot}", + "rate_limited": "過去一分鐘內查詢過多,請一分鐘後再試。", + "unavailable": "此伺服器不提供忙碌/空閒資訊,Bulwark 無法建議時間。", + "not_checked": "未檢查:", + "not_checked_item": "{who}:{reason}", + "reason_external": "組織外部", + "reason_unknown": "忙碌時間未知", + "reason_not_found": "在此伺服器上找不到", + "reason_error": "無法檢查", + "unknown_note": "Bulwark 只能看到與你共用的行事曆中的忙碌時間。對這些人,它無法區分有空和未共用。", + "directory_off": "Bulwark 無法在此伺服器上查詢使用者。管理員可以在 Stalwart 中開啟目錄查詢。", + "not_looked_up": "無法查詢" + }, "share_notifications": { "someone": "有人", "shared": "{name} 與您共用了{kind}「{object}」", diff --git a/locales/zh/common.json b/locales/zh/common.json index 0c5b088d5..6fed0d972 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5283,6 +5283,34 @@ "cancelled": "{name} 取消了“{title}”", "untitled": "无标题活动" }, + "free_busy_share": { + "button": "与 {group} 共享我的忙闲时间", + "already": "你的忙闲时间已与 {group} 共享", + "done": "你的忙闲时间现已与 {group} 共享。", + "error": "无法共享你的忙闲时间,请重试。", + "group_label": "群组", + "title": "忙闲", + "description": "群组中的同事能看到你何时忙碌,看不到活动内容。“建议时间”会用到它。" + }, + "find_time": { + "suggest": "建议时间", + "searching": "正在查找空闲时间", + "slots_title": "Bulwark 能查到的所有人都有空", + "slot": "{day} {start} 至 {end}", + "none": "未来 10 个工作日内没有空闲时间。", + "picked": "活动已移到 {slot}", + "rate_limited": "过去一分钟内查询过多,请一分钟后再试。", + "unavailable": "此服务器不提供忙闲信息,Bulwark 无法建议时间。", + "not_checked": "未检查:", + "not_checked_item": "{who}:{reason}", + "reason_external": "组织外部", + "reason_unknown": "忙碌时间未知", + "reason_not_found": "在此服务器上找不到", + "reason_error": "无法检查", + "unknown_note": "Bulwark 只能看到与你共享的日历中的忙碌时间。对这些人,它无法区分有空和未共享。", + "directory_off": "Bulwark 无法在此服务器上查找用户。管理员可以在 Stalwart 中开启目录查询。", + "not_looked_up": "无法查找" + }, "share_notifications": { "someone": "有人", "shared": "{name} 与您共享了{kind}“{object}”", From ab2fc6454d70ce91e3b82d5a82ab76e0b5871946 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:51:30 +0100 Subject: [PATCH 03/23] feat: let admins share every user's free/busy with a group --- app/(main)/[locale]/layout.tsx | 2 + .../org/__tests__/free-busy-section.test.tsx | 79 +++++++++++++ .../admin/org/sections/free-busy-section.tsx | 80 ++++++++++++- .../__tests__/free-busy-auto-share.test.tsx | 44 +++++++ components/calendar/free-busy-auto-share.tsx | 33 ++++++ components/settings/calendar-settings.tsx | 3 + .../settings/free-busy-share-setting.tsx | 27 +++++ lib/__tests__/free-busy-share.test.ts | 107 ++++++++++++++++++ 8 files changed, 370 insertions(+), 5 deletions(-) create mode 100644 components/admin/org/__tests__/free-busy-section.test.tsx create mode 100644 components/calendar/__tests__/free-busy-auto-share.test.tsx create mode 100644 components/calendar/free-busy-auto-share.tsx create mode 100644 components/settings/free-busy-share-setting.tsx create mode 100644 lib/__tests__/free-busy-share.test.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 5014d7ea8..7f3703728 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -27,6 +27,7 @@ import { PushNotificationPrompt } from "@/components/push-notification-prompt"; import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt"; import { SessionTimeoutGuard } from "@/components/session/session-timeout-guard"; import { AccountTemplateSync } from "@/components/templates/account-template-sync"; +import { FreeBusyAutoShare } from "@/components/calendar/free-busy-auto-share"; import { IS_LITE } from "@/lib/lite"; import { setRequestLocale } from "next-intl/server"; import { locales } from "@/i18n/routing"; @@ -76,6 +77,7 @@ export default async function LocaleLayout({ + {children} diff --git a/components/admin/org/__tests__/free-busy-section.test.tsx b/components/admin/org/__tests__/free-busy-section.test.tsx new file mode 100644 index 000000000..31a996445 --- /dev/null +++ b/components/admin/org/__tests__/free-busy-section.test.tsx @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +// Organisation > Calendars > Free/busy sharing: the group every user's +// default calendar shares its free/busy with (calendars.freeBusyGroup). + +const puts: Array> = []; +let stored: Record = {}; +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.method === 'PUT') { + const body = JSON.parse(String(init.body)); + puts.push(body); + stored = body; + return new Response('{"ok":true}'); + } + return new Response(JSON.stringify(stored)); + }), + withBasePath: (p: string) => p, + getPathPrefix: () => '', +})); + +import { FreeBusySection } from '../sections/free-busy-section'; +import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft'; + +const POLICY = { + restrictions: {}, features: {}, defaults: {}, themePolicy: {}, + forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], + organisation: {}, + scopes: [{ id: 'sales', label: 'Sales', match: { groups: ['sales@example.org'] }, policy: {} }], +}; + +let latest: ReturnType | null = null; +function Harness() { + const draft = useOrgPolicyDraft('test'); + latest = draft; + if (draft.loading) return null; + return ; +} + +async function renderSection() { + render(); + await waitFor(() => expect(screen.getByTestId('org-free-busy-notes')).toBeInTheDocument()); + return screen.getByLabelText('Share free/busy with (group address)') as HTMLInputElement; +} + +beforeEach(() => { + puts.length = 0; + stored = JSON.parse(JSON.stringify(POLICY)); + latest = null; + useOrgScopeStore.setState({ scopeId: EVERYONE }); +}); + +describe('Free/busy sharing section', () => { + it('says CalDAV-only users are not covered and points at the directory setting', async () => { + await renderSection(); + const notes = screen.getByTestId('org-free-busy-notes').textContent ?? ''; + expect(notes).toContain('never sign in to Bulwark'); + expect(notes).toContain('Allow Directory Queries'); + }); + + it('saves a group address and keeps a half-typed one out of the policy', async () => { + const field = await renderSection(); + fireEvent.change(field, { target: { value: 'staff@' } }); + expect(screen.getByText('Enter a group address like staff@example.com.')).toBeInTheDocument(); + expect(latest!.orgValue('calendars', 'freeBusyGroup').value).toBe(''); + fireEvent.change(field, { target: { value: 'Staff@Example.org ' } }); + await act(async () => { await latest!.save(); }); + expect((puts[0].organisation as { calendars: { freeBusyGroup: string } }).calendars.freeBusyGroup).toBe('staff@example.org'); + }); + + it('writes a scope override only into that scope', async () => { + const field = await renderSection(); + act(() => latest!.setScopeId('sales')); + fireEvent.change(field, { target: { value: 'sales@example.org' } }); + expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ calendars: { freeBusyGroup: 'sales@example.org' } }); + expect(latest!.policy?.organisation?.calendars.freeBusyGroup).toBe(''); + }); +}); diff --git a/components/admin/org/sections/free-busy-section.tsx b/components/admin/org/sections/free-busy-section.tsx index a46bc30e9..8a1f5988a 100644 --- a/components/admin/org/sections/free-busy-section.tsx +++ b/components/admin/org/sections/free-busy-section.tsx @@ -1,13 +1,83 @@ 'use client'; -import { SettingsSection } from '@/components/settings/settings-section'; +import { useEffect, useState } from 'react'; +import { SettingItem, SettingsSection } from '@/components/settings/settings-section'; +import { isValidEmail } from '@/lib/validation'; +import { InheritNote } from '../org-page'; import type { OrgPolicyDraft } from '../use-org-policy-draft'; +const DOCS_URL = 'https://bulwarkmail.org/docs/guides/organisation/find-a-time'; + /** - * Placeholder from the enterprise-pack foundation, replaced by package B: - * A group that gets free/busy access to every user's default calendar. + * Organisation > Calendars > Free/busy sharing: a group that every user's + * default calendar shares its free/busy with, applied by Bulwark at sign-in + * (organisation.calendars.freeBusyGroup, '' is off). */ export function FreeBusySection({ draft }: { draft: OrgPolicyDraft }) { - void draft; - return {null}; + const { value, own, everyone } = draft.orgValue('calendars', 'freeBusyGroup'); + const [text, setText] = useState(value); + // The field follows the scope picked in the scope bar and a discard. + useEffect(() => setText(value), [value]); + const trimmed = text.trim().toLowerCase(); + const invalid = trimmed !== '' && !isValidEmail(trimmed); + + return ( + + +
+ { + setText(e.target.value); + const next = e.target.value.trim().toLowerCase(); + if (next === '' || isValidEmail(next)) draft.setOrgValue('calendars', 'freeBusyGroup', next); + }} + className="h-9 w-full max-w-xs rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-hidden sm:w-72" + /> + {invalid && ( +

Enter a group address like staff@example.com.

+ )} + draft.clearOrgValue('calendars', 'freeBusyGroup')} + /> +
+
+
+

+ Bulwark adds the share to each user's default calendar when they sign in. It only adds: shares users made + themselves stay, and a user who removes it gets it back at the next sign-in. It shows in the user's sharing list. +

+

+ Users who only use other apps (CalDAV, phones) and never sign in to Bulwark aren't covered. Users can also + share their own free/busy under Settings > Calendar. +

+

+ Looking people up also needs directory queries on in Stalwart (Settings > Files & Sharing > Sharing > + Allow Directory Queries).{' '} + + How to set it up + +

+
+
+ ); } diff --git a/components/calendar/__tests__/free-busy-auto-share.test.tsx b/components/calendar/__tests__/free-busy-auto-share.test.tsx new file mode 100644 index 000000000..388210b76 --- /dev/null +++ b/components/calendar/__tests__/free-busy-auto-share.test.tsx @@ -0,0 +1,44 @@ +import { act, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { FreeBusyAutoShare } from '../free-busy-auto-share'; +import { useAuthStore } from '@/stores/auth-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; + +const apply = vi.fn(async () => 'shared' as const); +vi.mock('@/lib/free-busy-share', () => ({ applyAdminFreeBusyShare: (...args: unknown[]) => apply(...(args as [])) })); + +const client = { getUsername: () => 'alice@example.org', supportsCalendars: () => true }; + +function setPolicy(group: string, subjectEmail: string | null) { + const current = usePolicyStore.getState().policy; + usePolicyStore.setState({ + loaded: true, + subject: subjectEmail ? { email: subjectEmail, groups: [] } : null, + policy: { ...current, organisation: { ...DEFAULT_ORGANISATION_POLICY, calendars: { feeds: [], freeBusyGroup: group } } }, + }); +} + +describe('FreeBusyAutoShare', () => { + afterEach(() => { + apply.mockClear(); + useAuthStore.setState({ client: null, isAuthenticated: false } as never); + }); + + it('shares with the admin group once the policy is resolved for the signed-in account', () => { + setPolicy('team@example.org', 'someone-else@example.org'); + useAuthStore.setState({ client, isAuthenticated: true } as never); + render(); + // Still resolved for another account: nothing yet. + expect(apply).not.toHaveBeenCalled(); + act(() => setPolicy('team@example.org', 'alice@example.org')); + expect(apply).toHaveBeenCalledWith(client, 'team@example.org'); + }); + + it('does nothing while the admin set no group', () => { + setPolicy('', 'alice@example.org'); + useAuthStore.setState({ client, isAuthenticated: true } as never); + render(); + expect(apply).not.toHaveBeenCalled(); + }); +}); diff --git a/components/calendar/free-busy-auto-share.tsx b/components/calendar/free-busy-auto-share.tsx new file mode 100644 index 000000000..507762546 --- /dev/null +++ b/components/calendar/free-busy-auto-share.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { useEffect } from 'react'; +import { useAuthStore } from '@/stores/auth-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { applyAdminFreeBusyShare } from '@/lib/free-busy-share'; + +/** + * Applies the admin's free/busy group (organisation.calendars.freeBusyGroup) + * to the signed-in account's default calendar, once per page load and + * account: at sign-in, on a reload, and when another account becomes the + * active one. Waits until the policy is resolved for that account, so a + * group set in a domain or group scope applies to the right people. Renders + * nothing. + */ +export function FreeBusyAutoShare() { + const client = useAuthStore((s) => s.client); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const loaded = usePolicyStore((s) => s.loaded); + const subjectEmail = usePolicyStore((s) => s.subject?.email ?? null); + const group = usePolicyStore((s) => s.policy.organisation?.calendars?.freeBusyGroup ?? ''); + + useEffect(() => { + if (!isAuthenticated || !client || !loaded || !group) return; + // The policy is resolved for the active account a moment after it + // changes; wait for that rather than apply another account's group. + if (!subjectEmail || subjectEmail !== client.getUsername()?.trim().toLowerCase()) return; + if (!client.supportsCalendars?.()) return; + void applyAdminFreeBusyShare(client, group); + }, [client, isAuthenticated, loaded, subjectEmail, group]); + + return null; +} diff --git a/components/settings/calendar-settings.tsx b/components/settings/calendar-settings.tsx index e0169c05a..78a7c0e00 100644 --- a/components/settings/calendar-settings.tsx +++ b/components/settings/calendar-settings.tsx @@ -10,6 +10,7 @@ import { toast } from '@/stores/toast-store'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { cn } from '@/lib/utils'; import { formatDisplayHour } from '@/lib/calendar-display-range'; +import { FreeBusyShareSetting } from './free-busy-share-setting'; // Indexed by Date.getDay(), which is how calendarWorkingDays stores days. const DAY_KEYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] as const; @@ -277,6 +278,8 @@ export function CalendarSettings() { /> + + {isFeatureEnabled('calendarTasksEnabled') && ( <> Calendar > Free/busy: share the default calendar's busy times + * with the admin's free/busy group or one of the user's groups. Hidden when + * there is no group to share with. + */ +export function FreeBusyShareSetting() { + const t = useTranslations('free_busy_share'); + const client = useAuthStore((s) => s.client); + const adminGroup = usePolicyStore((s) => s.policy.organisation?.calendars?.freeBusyGroup ?? ''); + const groups = useMemo(() => freeBusyShareGroups(client, adminGroup), [client, adminGroup]); + if (!client?.supportsCalendars() || groups.length === 0) return null; + return ( + + + + ); +} diff --git a/lib/__tests__/free-busy-share.test.ts b/lib/__tests__/free-busy-share.test.ts new file mode 100644 index 000000000..55c8ae542 --- /dev/null +++ b/lib/__tests__/free-busy-share.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + applyAdminFreeBusyShare, ensureFreeBusyShare, freeBusyShareGroups, groupPrincipalId, resetAppliedFreeBusyShares, +} from '../free-busy-share'; +import { CALENDAR_PRESETS } from '@/lib/jmap/share-rights'; +import type { Calendar, CalendarRights } from '@/lib/jmap/types'; + +const READ = CALENDAR_PRESETS.read; +const CAPS = { mail: true, sieve: true, calendars: true, contacts: true, filenode: true }; +const NO_FREE_BUSY: CalendarRights = { ...CALENDAR_PRESETS.read, mayReadFreeBusy: false, mayReadItems: false, mayRSVP: true }; + +function calendar(id: string, extra: Partial = {}): Calendar { + return { id, name: id, isDefault: false, shareWith: null, ...extra } as Calendar; +} + +function fakeClient(calendars: Calendar[], options: { groupAccount?: boolean; directory?: string | null } = {}) { + const setCalendarShare = vi.fn(async () => {}); + const client = { + getAccountId: () => 'alice', + getSharedAccounts: () => [ + { id: 'alice', name: 'alice@example.org', isPrimary: true, capabilities: CAPS }, + ...(options.groupAccount === false ? [] : [{ id: 'team', name: 'Team@example.org', isPrimary: false, capabilities: CAPS }]), + { id: 'bob', name: 'bob@example.org', isPrimary: false, capabilities: CAPS }, + ], + getAccountInfo: (id: string) => ({ name: id, isPersonal: id === 'alice' || id === 'bob', isReadOnly: false, accountCapabilities: {} }), + findPrincipalByEmail: vi.fn(async () => ({ + principal: options.directory ? { id: options.directory, type: 'group' as const, name: 'staff' } : null, + status: 'ok' as const, + })), + getAllCalendars: vi.fn(async () => calendars), + setCalendarShare, + }; + return { client, setCalendarShare }; +} + +describe('ensureFreeBusyShare', () => { + it('shares only free/busy of the default calendar with the group, leaving other shares alone', async () => { + const { client, setCalendarShare } = fakeClient([ + calendar('b', { isDefault: false }), + calendar('a', { isDefault: true, shareWith: { bob: READ } }), + calendar('team:x', { isDefault: true, isShared: true }), + ]); + expect(await ensureFreeBusyShare(client, 'team@example.org')).toBe('shared'); + // One shareWith/ patch: the share with bob isn't rewritten. + expect(setCalendarShare).toHaveBeenCalledTimes(1); + expect(setCalendarShare).toHaveBeenCalledWith('a', 'team', CALENDAR_PRESETS.freeBusy); + }); + + it('writes nothing when the group can see the free/busy already', async () => { + const { client, setCalendarShare } = fakeClient([calendar('a', { isDefault: true, shareWith: { team: READ } })]); + expect(await ensureFreeBusyShare(client, 'team@example.org')).toBe('already'); + expect(setCalendarShare).not.toHaveBeenCalled(); + }); + + it('adds free/busy to a share of the group that lacked it, keeping its other rights', async () => { + const { client, setCalendarShare } = fakeClient([calendar('a', { isDefault: true, shareWith: { team: NO_FREE_BUSY } })]); + expect(await ensureFreeBusyShare(client, 'team@example.org')).toBe('shared'); + expect(setCalendarShare).toHaveBeenCalledWith('a', 'team', { ...NO_FREE_BUSY, mayReadFreeBusy: true }); + }); + + it('looks the group up in the directory when it is no account of the user', async () => { + const { client, setCalendarShare } = fakeClient([calendar('a', { isDefault: true })], { directory: 'staff' }); + expect(await ensureFreeBusyShare(client, 'staff@example.org')).toBe('shared'); + expect(setCalendarShare).toHaveBeenCalledWith('a', 'staff', CALENDAR_PRESETS.freeBusy); + }); + + it('does nothing for an unknown group or without a calendar of the user', async () => { + const unknown = fakeClient([calendar('a', { isDefault: true })], { directory: null }); + expect(await ensureFreeBusyShare(unknown.client, 'nobody@example.org')).toBe('no-group'); + const none = fakeClient([calendar('team:x', { isShared: true })]); + expect(await ensureFreeBusyShare(none.client, 'team@example.org')).toBe('no-calendar'); + expect(unknown.setCalendarShare).not.toHaveBeenCalled(); + expect(none.setCalendarShare).not.toHaveBeenCalled(); + }); + + it('never takes a person\'s shared account for the group', async () => { + const { client } = fakeClient([], { groupAccount: false, directory: null }); + expect(await groupPrincipalId(client, 'bob@example.org')).toBeNull(); + }); +}); + +describe('applyAdminFreeBusyShare', () => { + beforeEach(() => resetAppliedFreeBusyShares()); + + it('applies once per page load and account, and again after a failure', async () => { + const { client, setCalendarShare } = fakeClient([calendar('a', { isDefault: true })]); + setCalendarShare.mockRejectedValueOnce(new Error('down')); + expect(await applyAdminFreeBusyShare(client, 'team@example.org')).toBeNull(); + expect(await applyAdminFreeBusyShare(client, 'team@example.org')).toBe('shared'); + expect(await applyAdminFreeBusyShare(client, 'team@example.org')).toBeNull(); + expect(setCalendarShare).toHaveBeenCalledTimes(2); + }); + + it('does nothing without a group', async () => { + const { client, setCalendarShare } = fakeClient([calendar('a', { isDefault: true })]); + expect(await applyAdminFreeBusyShare(client, '')).toBeNull(); + expect(setCalendarShare).not.toHaveBeenCalled(); + }); +}); + +describe('freeBusyShareGroups', () => { + it('offers the admin group, else the group accounts of the session', () => { + const { client } = fakeClient([]); + expect(freeBusyShareGroups(client, 'Staff@example.org')).toEqual(['staff@example.org']); + expect(freeBusyShareGroups(client, '')).toEqual(['team@example.org']); + }); +}); From 877198a4bf462825791e18986399e573c704c9a6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:05:12 +0100 Subject: [PATCH 04/23] feat: answer invitations in a calendar shared with you as its owner #634 --- .../calendar/__tests__/answer-bars.test.tsx | 65 ++++++++ components/calendar/answer-bars.tsx | 120 +++++++++++++++ components/calendar/calendar-app.tsx | 24 +-- components/calendar/event-detail-popover.tsx | 29 ++-- components/calendar/event-modal.tsx | 56 +++---- .../calendar-invitation-banner.test.tsx | 25 ++++ .../email/calendar-invitation-banner.tsx | 6 +- lib/__tests__/calendar-delegate.test.ts | 110 ++++++++++++++ lib/calendar-delegate.ts | 139 ++++++++++++++++++ lib/jmap/client-interface.ts | 2 + lib/jmap/client.ts | 24 +++ lib/jmap/types.ts | 5 + lib/recurrence-instances.ts | 8 +- locales/ar/common.json | 6 + locales/ca/common.json | 6 + locales/cs/common.json | 6 + locales/da/common.json | 6 + locales/de/common.json | 6 + locales/en/common.json | 6 + locales/es/common.json | 6 + locales/et/common.json | 6 + locales/fa/common.json | 6 + locales/fr/common.json | 6 + locales/he/common.json | 6 + locales/hu/common.json | 6 + locales/it/common.json | 6 + locales/ja/common.json | 6 + locales/ko/common.json | 6 + locales/lv/common.json | 6 + locales/mn/common.json | 6 + locales/nb/common.json | 6 + locales/nl/common.json | 6 + locales/pl/common.json | 6 + locales/pt/common.json | 6 + locales/ro/common.json | 6 + locales/ru/common.json | 6 + locales/sk/common.json | 6 + locales/th/common.json | 6 + locales/tr/common.json | 6 + locales/uk/common.json | 6 + locales/vi/common.json | 6 + locales/zh-TW/common.json | 6 + locales/zh/common.json | 6 + .../calendar-store-delegate-rsvp.test.ts | 98 ++++++++++++ stores/calendar-store.ts | 28 +++- 45 files changed, 853 insertions(+), 66 deletions(-) create mode 100644 components/calendar/__tests__/answer-bars.test.tsx create mode 100644 components/calendar/answer-bars.tsx create mode 100644 lib/__tests__/calendar-delegate.test.ts create mode 100644 lib/calendar-delegate.ts create mode 100644 stores/__tests__/calendar-store-delegate-rsvp.test.ts 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), }, }; }), From a8f9d81337bf4f898432adc0f8e6635e56ae0bd2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:19:07 +0100 Subject: [PATCH 05/23] feat: block the calendar while the out-of-office reply is on --- .../vacation-calendar-block.test.tsx | 84 ++++ .../settings/vacation-calendar-block.tsx | 101 +++++ components/settings/vacation-settings.tsx | 3 + lib/__tests__/vacation-calendar.test.ts | 233 +++++++++++ lib/vacation-calendar.ts | 375 ++++++++++++++++++ locales/ar/common.json | 7 + locales/ca/common.json | 7 + locales/cs/common.json | 7 + locales/da/common.json | 7 + locales/de/common.json | 7 + locales/en/common.json | 7 + locales/es/common.json | 7 + locales/et/common.json | 7 + locales/fa/common.json | 7 + locales/fr/common.json | 7 + locales/he/common.json | 7 + locales/hu/common.json | 7 + locales/it/common.json | 7 + locales/ja/common.json | 7 + locales/ko/common.json | 7 + locales/lv/common.json | 7 + locales/mn/common.json | 7 + locales/nb/common.json | 7 + locales/nl/common.json | 7 + locales/pl/common.json | 7 + locales/pt/common.json | 7 + locales/ro/common.json | 7 + locales/ru/common.json | 7 + locales/sk/common.json | 7 + locales/th/common.json | 7 + locales/tr/common.json | 7 + locales/uk/common.json | 7 + locales/vi/common.json | 7 + locales/zh-TW/common.json | 7 + locales/zh/common.json | 7 + .../__tests__/vacation-store-calendar.test.ts | 63 +++ stores/settings-store.ts | 8 + stores/vacation-store.ts | 5 + 38 files changed, 1082 insertions(+) create mode 100644 components/settings/__tests__/vacation-calendar-block.test.tsx create mode 100644 components/settings/vacation-calendar-block.tsx create mode 100644 lib/__tests__/vacation-calendar.test.ts create mode 100644 lib/vacation-calendar.ts create mode 100644 stores/__tests__/vacation-store-calendar.test.ts diff --git a/components/settings/__tests__/vacation-calendar-block.test.tsx b/components/settings/__tests__/vacation-calendar-block.test.tsx new file mode 100644 index 000000000..81dce2d98 --- /dev/null +++ b/components/settings/__tests__/vacation-calendar-block.test.tsx @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +const changed = vi.fn(); +const stops = vi.fn(async () => true); +vi.mock('@/lib/vacation-calendar', async (original) => ({ + ...(await original()), + vacationBlockChanged: (...args: unknown[]) => changed(...args), + ownFilterScriptStops: () => stops(), +})); + +import { VacationCalendarBlock, VacationFilterWarning } from '../vacation-calendar-block'; +import { useAuthStore } from '@/stores/auth-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useVacationStore } from '@/stores/vacation-store'; + +const client = { + supportsCalendars: () => true, + supportsSieve: () => true, + accountSupports: () => true, + getSieveScripts: vi.fn(async () => []), + getAccountId: () => 'alice', + getAllCalendars: vi.fn(async () => [{ id: 'b', isDefault: true, myRights: { mayWriteAll: true } }]), +}; + +function lock(value: boolean | null) { + const policy = usePolicyStore.getState().policy; + usePolicyStore.setState({ policy: { ...policy, restrictions: value === null ? {} : { vacationBlockCalendar: { locked: true, value } } } }); +} + +describe('VacationCalendarBlock', () => { + beforeEach(() => { + useAuthStore.setState({ client } as never); + useSettingsStore.setState({ vacationBlockCalendar: true }); + useVacationStore.setState({ isEnabled: true, fromDate: '2026-12-01T08:00:00Z', toDate: '2026-12-05T17:00:00Z', subject: 'Away' }); + }); + afterEach(() => { + lock(null); + changed.mockClear(); + useAuthStore.setState({ client: null } as never); + }); + + it('is on by default once both dates are set, and syncs when switched off', async () => { + const { rerender } = render(); + expect(screen.queryByTestId('vacation-block-calendar')).toBeNull(); + rerender(); + const box = await screen.findByTestId('vacation-block-calendar') as HTMLInputElement; + expect(box.checked).toBe(true); + fireEvent.click(box); + expect(useSettingsStore.getState().vacationBlockCalendar).toBe(false); + expect(changed).toHaveBeenCalledWith(client, undefined, { isEnabled: true, fromDate: '2026-12-01T08:00:00Z', toDate: '2026-12-05T17:00:00Z', subject: 'Away' }); + }); + + it('shows the organisation\'s lock with its value and can\'t be changed', async () => { + lock(false); + render(); + const box = await screen.findByTestId('vacation-block-calendar') as HTMLInputElement; + expect(box.checked).toBe(false); + expect(screen.getByText('org_locked')).toBeInTheDocument(); + expect(box.closest('[inert]')).not.toBeNull(); + }); + + it('says a managed account\'s calendar can\'t be blocked without write access', async () => { + client.getAllCalendars.mockResolvedValueOnce([{ id: 'boss:b', isDefault: true, isShared: true, accountId: 'boss', myRights: { mayWriteAll: false, mayWriteOwn: false } }] as never); + render(); + expect(await screen.findByTestId('vacation-block-no-access')).toHaveTextContent('no_access_managed'); + expect(screen.queryByTestId('vacation-block-calendar')).toBeNull(); + }); +}); + +describe('VacationFilterWarning', () => { + afterEach(() => useAuthStore.setState({ client: null } as never)); + + it('warns when the reply would switch off a filter script of the user\'s own', async () => { + useAuthStore.setState({ client } as never); + render(); + await waitFor(() => expect(screen.getByTestId('vacation-filter-warning')).toHaveTextContent('filter_warning')); + stops.mockResolvedValueOnce(false); + const other = render(); + await waitFor(() => expect(stops).toHaveBeenCalledTimes(2)); + expect(other.container.querySelector('[data-testid="vacation-filter-warning"]')).toBeNull(); + }); +}); diff --git a/components/settings/vacation-calendar-block.tsx b/components/settings/vacation-calendar-block.tsx new file mode 100644 index 000000000..8de77ac0d --- /dev/null +++ b/components/settings/vacation-calendar-block.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { useEffect, useId, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { AlertTriangle } from '@/components/icons'; +import { SettingItem } from './settings-section'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useVacationStore } from '@/stores/vacation-store'; +import { useRestriction } from '@/hooks/use-restriction'; +import { blockCalendar, ownFilterScriptStops, vacationBlockChanged } from '@/lib/vacation-calendar'; + +/** + * "Block my calendar" under the out-of-office dates (lib/vacation-calendar.ts): + * on by default, lockable by the organisation (vacationBlockCalendar). Shown + * once both dates are set. For a managed account the event goes into that + * account's calendar, which the user needs write access to. + */ +export function VacationCalendarBlock({ hasDates, accountId }: { hasDates: boolean; accountId?: string }) { + const t = useTranslations('vacation_calendar'); + const client = useAuthStore((s) => s.client); + const setting = useSettingsStore((s) => s.vacationBlockCalendar); + const updateSetting = useSettingsStore((s) => s.updateSetting); + const restriction = useRestriction('vacationBlockCalendar'); + const checked = restriction.locked && typeof restriction.value === 'boolean' ? restriction.value : setting !== false; + const [access, setAccess] = useState<{ key: string; ok: boolean } | null>(null); + const id = useId(); + const key = accountId ?? ''; + // Stand-in clients (tests, the demo) may not say. + const supported = typeof client?.supportsCalendars === 'function' && client.supportsCalendars(); + + useEffect(() => { + if (!client || !supported || !hasDates) return; + let cancelled = false; + void client.getAllCalendars() + .then((calendars) => !!blockCalendar(calendars, accountId ?? client.getAccountId(), client.getAccountId())) + // Not knowing is no reason to hide the option. + .catch(() => true) + .then((ok) => { if (!cancelled) setAccess({ key, ok }); }); + return () => { cancelled = true; }; + }, [client, supported, hasDates, accountId, key]); + + if (!client || !supported || !hasDates) return null; + if (access?.key === key && !access.ok) { + return ( +

+ {t(accountId ? 'no_access_managed' : 'no_calendar')} +

+ ); + } + + const onChange = (next: boolean) => { + updateSetting('vacationBlockCalendar', next); + const { isEnabled, fromDate, toDate, subject } = useVacationStore.getState(); + vacationBlockChanged(client, accountId, { isEnabled, fromDate, toDate, subject }); + }; + + return ( + + onChange(e.target.checked)} + data-testid="vacation-block-calendar" + /> + + ); +} + +/** + * The one line under the reply switch when turning it on would switch off a + * filter script of the user's own (Stalwart runs one script at a time). + */ +export function VacationFilterWarning({ accountId }: { accountId?: string }) { + const t = useTranslations('vacation_calendar'); + const client = useAuthStore((s) => s.client); + const [stops, setStops] = useState<{ key: string; value: boolean } | null>(null); + const key = accountId ?? ''; + const sieve = !!client && typeof client.getSieveScripts === 'function' + && (accountId ? client.accountSupports(accountId, 'urn:ietf:params:jmap:sieve') : client.supportsSieve()); + + useEffect(() => { + if (!client || !sieve) return; + let cancelled = false; + void ownFilterScriptStops(client, accountId).catch(() => false).then((value) => { + if (!cancelled) setStops({ key, value }); + }); + return () => { cancelled = true; }; + }, [client, sieve, accountId, key]); + + if (!stops || stops.key !== key || !stops.value) return null; + return ( +

+

+ ); +} diff --git a/components/settings/vacation-settings.tsx b/components/settings/vacation-settings.tsx index 5056d4b29..f21051912 100644 --- a/components/settings/vacation-settings.tsx +++ b/components/settings/vacation-settings.tsx @@ -18,6 +18,7 @@ import { isValidEmail } from '@/lib/validation'; import { Loader2, AlertTriangle, Eye, EyeOff, Check } from '@/components/icons'; import { toast } from '@/stores/toast-store'; import { cn } from '@/lib/utils'; +import { VacationCalendarBlock, VacationFilterWarning } from './vacation-calendar-block'; const STALWART_VACATION_LIMITS = { subject: 511, body: 2047 }; @@ -455,6 +456,7 @@ export function VacationSettings() { />
+ @@ -466,6 +468,7 @@ export function VacationSettings() { inactiveLabel={t('status.inactive')} /> + {!localEnabled && } {canNarrow && ( = {}) => ({ + isEnabled: true, fromDate: '2026-12-01T08:00:00Z', toDate: '2026-12-05T17:00:00Z', subject: 'Away', ...extra, +}); +const span = (start: string, end: string) => ({ start: new Date(start), end: new Date(end) }); +const last = { start: '2026-12-01T08:00:00.000Z', end: '2026-12-05T17:00:00.000Z' }; +const input = (extra: Partial): DecisionInput => ({ + block: true, reply: reply(), event: null, last: null, now: NOW, trigger: 'save', ...extra, +}); + +describe('decideBlock', () => { + it('creates the event for a reply with dates', () => { + expect(decideBlock(input({}))).toEqual({ action: 'create', start: new Date('2026-12-01T08:00:00Z'), end: new Date('2026-12-05T17:00:00Z') }); + }); + + it('moves its own event with the dates', () => { + const decision = decideBlock(input({ reply: reply({ toDate: '2026-12-08T17:00:00Z' }), event: span(last.start, last.end), last })); + expect(decision).toEqual({ action: 'update', start: new Date('2026-12-01T08:00:00Z'), end: new Date('2026-12-08T17:00:00Z') }); + }); + + it('leaves an event someone edited alone', () => { + const edited = span('2026-12-01T08:00:00Z', '2026-12-03T12:00:00Z'); + expect(decideBlock(input({ reply: reply({ toDate: '2026-12-08T17:00:00Z' }), event: edited, last }))).toEqual({ action: 'none' }); + expect(decideBlock(input({ reply: reply({ isEnabled: false }), event: edited, last }))).toEqual({ action: 'none' }); + }); + + it('removes an event that hasn\'t started when the reply or the block is switched off', () => { + expect(decideBlock(input({ reply: reply({ isEnabled: false }), event: span(last.start, last.end), last }))).toEqual({ action: 'destroy' }); + expect(decideBlock(input({ block: false, event: span(last.start, last.end), last }))).toEqual({ action: 'destroy' }); + }); + + it('ends a running event now when the reply is switched off early', () => { + const now = new Date('2026-12-03T10:00:00Z'); + expect(decideBlock(input({ reply: reply({ isEnabled: false }), event: span(last.start, last.end), last, now }))).toEqual({ action: 'end-now', end: now }); + }); + + it('leaves the past event of a reply that simply ran out', () => { + const now = new Date('2026-12-06T10:00:00Z'); + expect(decideBlock(input({ event: span(last.start, last.end), last, now }))).toEqual({ action: 'none' }); + expect(decideBlock(input({ now }))).toEqual({ action: 'none' }); + }); + + it('keeps a deleted event deleted on load, and brings it back when the dates change', () => { + expect(decideBlock(input({ last, trigger: 'load' }))).toEqual({ action: 'none' }); + expect(decideBlock(input({ last, trigger: 'load', reply: reply({ toDate: '2026-12-09T17:00:00Z' }) })).action).toBe('create'); + }); + + it('follows a reply set in another client on load', () => { + expect(decideBlock(input({ trigger: 'load' })).action).toBe('create'); + }); + + it('does nothing without both dates or with the end before the start', () => { + expect(decideBlock(input({ reply: reply({ toDate: null }) }))).toEqual({ action: 'none' }); + expect(decideBlock(input({ reply: reply({ toDate: '2026-11-30T00:00:00Z' }) }))).toEqual({ action: 'none' }); + }); +}); + +describe('blockTimes and eventSpan', () => { + it('writes a timed event in the zone and reads it back', () => { + const start = new Date('2026-12-01T08:00:00Z'); + const end = new Date('2026-12-05T17:30:00Z'); + const times = blockTimes(start, end, 'Europe/Berlin'); + expect(times).toEqual({ start: '2026-12-01T09:00:00', duration: 'P4DT9H30M', timeZone: 'Europe/Berlin', showWithoutTime: false }); + expect(eventSpan(times, 'Europe/Berlin')).toEqual({ start, end }); + }); + + it('writes whole days when both ends are midnight in the zone', () => { + const start = new Date('2026-11-30T23:00:00Z'); // 1 Dec 00:00 Berlin + const end = new Date('2026-12-04T23:00:00Z'); // 5 Dec 00:00 Berlin + const times = blockTimes(start, end, 'Europe/Berlin'); + expect(times).toEqual({ start: '2026-12-01T00:00:00', duration: 'P4D', timeZone: null, showWithoutTime: true }); + expect(eventSpan(times, 'Europe/Berlin')).toEqual({ start, end }); + }); + + it('formats durations', () => { + expect(isoDuration(90 * 60000)).toBe('PT1H30M'); + expect(isoDuration(2 * 86400000)).toBe('P2D'); + expect(isoDuration(0)).toBe('PT0S'); + }); +}); + +describe('effectiveBlockSetting', () => { + afterEach(() => { + usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy, restrictions: {} } }); + useSettingsStore.setState({ vacationBlockCalendar: true }); + }); + + it('is on by default, the user\'s choice otherwise, and the organisation\'s when locked', () => { + expect(effectiveBlockSetting()).toBe(true); + useSettingsStore.setState({ vacationBlockCalendar: false }); + expect(effectiveBlockSetting()).toBe(false); + usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy, restrictions: { vacationBlockCalendar: { locked: true, value: true } } } }); + expect(effectiveBlockSetting()).toBe(true); + }); +}); + +function fakeClient(found: Partial | null, calendars: Partial[] = [{ id: 'b', isDefault: true, myRights: { mayWriteAll: true } as Calendar['myRights'] }]) { + return { + getAccountId: () => 'alice', + supportsCalendars: () => true, + getAllCalendars: vi.fn(async () => calendars as Calendar[]), + queryCalendarEvents: vi.fn(async () => (found ? [found as CalendarEvent] : [])), + createCalendarEvent: vi.fn(async (e: Partial) => ({ ...e, id: 'new' }) as CalendarEvent), + updateCalendarEvent: vi.fn(async () => {}), + deleteCalendarEvent: vi.fn(async () => {}), + }; +} + +describe('syncVacationBlock', () => { + beforeEach(() => { + useSettingsStore.setState({ vacationBlockCalendar: true, vacationCalendarBlocks: {}, timeZone: 'Europe/Berlin' }); + }); + afterEach(() => useSettingsStore.setState({ timeZone: 'auto' })); + + it('creates a busy event without participants or invitations in the default calendar, and remembers it', async () => { + const client = fakeClient(null, [ + { id: 'x', isDefault: false }, + { id: 'b', isDefault: true, myRights: { mayWriteAll: true } as Calendar['myRights'] }, + { id: 'bob:c', isDefault: true, isShared: true, accountId: 'bob' }, + ]); + expect(await syncVacationBlock(client, undefined, reply(), 'save', NOW)).toBe('create'); + expect(client.queryCalendarEvents).toHaveBeenCalledWith({ uid: 'bulwark-ooo-alice' }, undefined, 1, undefined); + const [event, send, account] = client.createCalendarEvent.mock.calls[0] as unknown as [Partial, boolean, string | undefined]; + expect(event).toMatchObject({ + uid: oooUid('alice'), title: 'Away', calendarIds: { b: true }, freeBusyStatus: 'busy', privacy: 'public', + start: '2026-12-01T09:00:00', timeZone: 'Europe/Berlin', showWithoutTime: false, + }); + expect(event.participants).toBeUndefined(); + expect(send).toBe(false); + expect(account).toBeUndefined(); + expect(useSettingsStore.getState().vacationCalendarBlocks.alice).toEqual(last); + }); + + it('puts a managed account\'s event in that account\'s calendar, and only with write access', async () => { + const shared = { id: 'boss:b', originalId: 'b', isDefault: true, isShared: true, accountId: 'boss', myRights: { mayWriteAll: false, mayWriteOwn: true } as Calendar['myRights'] }; + const client = fakeClient(null, [{ id: 'own', isDefault: true }, shared]); + expect(await syncVacationBlock(client, 'boss', reply(), 'save', NOW)).toBe('create'); + expect(client.createCalendarEvent.mock.calls[0][0]).toMatchObject({ uid: 'bulwark-ooo-boss', calendarIds: { b: true } }); + expect((client.createCalendarEvent.mock.calls[0] as unknown[])[2]).toBe('boss'); + const readOnly = fakeClient(null, [{ ...shared, myRights: { mayWriteAll: false, mayWriteOwn: false } as Calendar['myRights'] }]); + expect(await syncVacationBlock(readOnly, 'boss', reply(), 'save', NOW)).toBe('no-calendar'); + expect(readOnly.createCalendarEvent).not.toHaveBeenCalled(); + }); + + it('moves and removes its own event', async () => { + useSettingsStore.setState({ vacationCalendarBlocks: { alice: last } }); + const ours = { id: 'e1', ...blockTimes(new Date(last.start), new Date(last.end), 'Europe/Berlin') }; + const moving = fakeClient(ours); + expect(await syncVacationBlock(moving, undefined, reply({ toDate: '2026-12-08T17:00:00Z' }), 'save', NOW)).toBe('update'); + expect(moving.updateCalendarEvent).toHaveBeenCalledWith('e1', expect.objectContaining({ duration: 'P7DT9H' }), false, undefined); + useSettingsStore.setState({ vacationCalendarBlocks: { alice: last } }); + const off = fakeClient(ours); + expect(await syncVacationBlock(off, undefined, reply({ isEnabled: false }), 'save', NOW)).toBe('destroy'); + expect(off.deleteCalendarEvent).toHaveBeenCalledWith('e1', false, undefined); + expect(useSettingsStore.getState().vacationCalendarBlocks.alice).toBeUndefined(); + }); + + it('never creates an event while the organisation locks the block off', async () => { + usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy, restrictions: { vacationBlockCalendar: { locked: true, value: false } } } }); + try { + const client = fakeClient(null); + expect(await syncVacationBlock(client, undefined, reply(), 'save', NOW)).toBe('none'); + expect(client.createCalendarEvent).not.toHaveBeenCalled(); + } finally { + usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy, restrictions: {} } }); + } + }); +}); + +describe('the sync scheduler', () => { + beforeEach(() => { + vi.useFakeTimers(); + resetVacationCalendarSync(); + useSettingsStore.setState({ vacationBlockCalendar: true, vacationCalendarBlocks: {} }); + }); + afterEach(() => { + vi.useRealTimers(); + resetVacationCalendarSync(); + }); + + it('syncs only when the switch, the dates or the subject changed, once after a pause', async () => { + const client = fakeClient(null); + vacationReplySaved(client, undefined, reply(), reply()); + await vi.advanceTimersByTimeAsync(5000); + expect(client.queryCalendarEvents).not.toHaveBeenCalled(); + + vacationReplySaved(client, undefined, reply(), reply({ subject: 'A' })); + vacationReplySaved(client, undefined, reply({ subject: 'A' }), reply({ subject: 'Away from' })); + await vi.advanceTimersByTimeAsync(5000); + expect(client.queryCalendarEvents).toHaveBeenCalledTimes(1); + }); + + it('reconciles a loaded reply once per page load and account', async () => { + const client = fakeClient(null); + vacationReplyLoaded(client, undefined, reply()); + await vi.advanceTimersByTimeAsync(5000); + vacationReplyLoaded(client, undefined, reply()); + await vi.advanceTimersByTimeAsync(5000); + expect(client.queryCalendarEvents).toHaveBeenCalledTimes(1); + }); +}); + +describe('ownFilterScriptStops', () => { + const sieve = (scripts: Array<{ name: string; isActive: boolean; content: string }>, extensions = ['include', 'fileinto']) => ({ + getSieveAccountId: () => 'alice', + getSieveCapabilities: () => ({ sieveExtensions: extensions }) as never, + getSieveScripts: async () => scripts.map((s, i) => ({ id: String(i), name: s.name, isActive: s.isActive, blobId: s.name })) as never, + getSieveScriptContent: async (blobId: string) => scripts.find((s) => s.name === blobId)!.content, + }); + + it('warns for an active script Bulwark can\'t keep running, and not for its own', async () => { + const own = generateScript([]); + // Edited by hand so that Bulwark can't read it back: it can't be kept next to the reply. + const handEdited = own.replace('"version"', '"version" oops'); + expect(handEdited).not.toBe(own); + expect(await ownFilterScriptStops(sieve([{ name: 'mine', isActive: true, content: handEdited }]))).toBe(true); + // Scripts of other clients that Bulwark reads keep running next to the reply. + expect(await ownFilterScriptStops(sieve([{ name: 'roundcube', isActive: true, content: 'require ["fileinto"];\r\nif address :is "from" "x@example.org" { fileinto "X"; }\r\n' }]))).toBe(false); + expect(await ownFilterScriptStops(sieve([{ name: 'filters', isActive: true, content: own }]))).toBe(false); + expect(await ownFilterScriptStops(sieve([{ name: 'filters', isActive: true, content: own }], ['fileinto']))).toBe(true); + expect(await ownFilterScriptStops(sieve([{ name: 'vacation', isActive: true, content: '' }]))).toBe(false); + }); +}); diff --git a/lib/vacation-calendar.ts b/lib/vacation-calendar.ts new file mode 100644 index 000000000..7be23521c --- /dev/null +++ b/lib/vacation-calendar.ts @@ -0,0 +1,375 @@ +/** + * Out-of-office blocks the calendar. + * + * While an out-of-office reply with a start and an end is on, Bulwark keeps + * one busy event for it in the account's default calendar. The event is + * found again by a fixed UID per account (`bulwark-ooo-`; 0.16 + * rejects vendor properties, a UID survives every CalDAV client). It is + * timed, or all-day when both ends are midnight in the user's time zone; + * busy, public, without participants, and never sends invitations. + * + * Bulwark keeps the times it last wrote (synced settings, per account). + * When the event's times no longer match them, someone edited it, and + * Bulwark leaves it alone. Switching the reply or the block off removes an + * event that hasn't started and ends one that has at once; a reply that + * simply ran out leaves its past event in place. An event deleted elsewhere + * is not put back until the reply's dates change. + * + * The sync runs after a save (the settings page and plugins both save + * through stores/vacation-store.ts) when the switch, the dates or the + * subject changed, debounced, one at a time, and once per page load and + * account when the reply is loaded (it may have been set in another + * client). A reply another client set up under a Sieve script of its own is + * invisible to VacationResponse, so it can't be followed. + */ + +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; +import { fromWallClock, getEffectiveTimeZone, getWallClock, parseLocalDateTime } from '@/lib/timezone'; +import { parseDurationSeconds } from '@/lib/calendar-event-normalization'; +import { parseScript } from '@/lib/sieve/parser'; +import { VACATION_SCRIPT_NAME } from '@/lib/sieve/generator'; +import { supportsInclude } from '@/lib/filters/account-filters'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { debug } from '@/lib/debug'; + +export const OOO_UID_PREFIX = 'bulwark-ooo-'; +export const SYNC_DEBOUNCE_MS = 1500; + +export function oooUid(accountId: string): string { + return `${OOO_UID_PREFIX}${accountId}`; +} + +/** What the vacation response says, as far as the block cares. */ +export interface ReplyState { + isEnabled: boolean; + fromDate: string | null; + toDate: string | null; + subject: string; +} + +/** The times Bulwark last wrote to the event, as ISO instants. */ +export interface WrittenBlock { + start: string; + end: string; +} + +export type BlockDecision = + | { action: 'none' } + | { action: 'create'; start: Date; end: Date } + | { action: 'update'; start: Date; end: Date } + | { action: 'destroy' } + | { action: 'end-now'; end: Date }; + +export interface DecisionInput { + /** Block my calendar (the setting, or the organisation's lock). */ + block: boolean; + reply: ReplyState; + /** The event with our UID, as instants; null when there is none. */ + event: { start: Date; end: Date } | null; + last: WrittenBlock | null; + now: Date; + /** `save`: the reply or the block just changed here. `load`: the reply was read. */ + trigger: 'save' | 'load'; +} + +const same = (a: Date, b: string | Date) => a.getTime() === (typeof b === 'string' ? Date.parse(b) : b.getTime()); + +/** What to do with the busy event, by the rules in the module comment. */ +export function decideBlock(input: DecisionInput): BlockDecision { + const { reply, event, last, now } = input; + const from = reply.fromDate ? new Date(reply.fromDate) : null; + const to = reply.toDate ? new Date(reply.toDate) : null; + const datesUsable = !!from && !!to && !Number.isNaN(from.getTime()) && !Number.isNaN(to.getTime()) && to > from; + const wanted = input.block && reply.isEnabled && datesUsable; + const ranOut = reply.isEnabled && !!to && to <= now; + + if (event) { + const ours = last ? same(event.start, last.start) && same(event.end, last.end) : false; + if (!ours) { + // Without a record, an event that already sits on the reply's dates + // needs nothing; any other was set up or changed by someone else. + return { action: 'none' }; + } + if (wanted) { + if (ranOut) return { action: 'none' }; + if (same(event.start, from!) && same(event.end, to!)) return { action: 'none' }; + return { action: 'update', start: from!, end: to! }; + } + // A reply that ran out leaves its past event; so does any past event. + if (ranOut || event.end <= now) return { action: 'none' }; + if (event.start > now) return { action: 'destroy' }; + return { action: 'end-now', end: now }; + } + + if (!wanted || ranOut) return { action: 'none' }; + if (input.trigger === 'load' && last && same(from!, last.start) && same(to!, last.end)) { + // It was there for these dates and someone deleted it: that sticks. + return { action: 'none' }; + } + return { action: 'create', start: from!, end: to! }; +} + +/** ISO 8601 duration of a span, in days, hours and minutes. */ +export function isoDuration(ms: number): string { + const totalMinutes = Math.max(0, Math.round(ms / 60000)); + const days = Math.floor(totalMinutes / 1440); + const hours = Math.floor((totalMinutes % 1440) / 60); + const minutes = totalMinutes % 60; + const time = `${hours ? `${hours}H` : ''}${minutes ? `${minutes}M` : ''}`; + if (!days && !time) return 'PT0S'; + return `P${days ? `${days}D` : ''}${time ? `T${time}` : ''}`; +} + +const pad = (n: number) => String(n).padStart(2, '0'); + +/** + * The event's time fields for a span: all-day when both ends are midnight in + * `timeZone` (whole days stay whole for people in other zones), timed in the + * zone otherwise. + */ +export function blockTimes(start: Date, end: Date, timeZone: string): Pick { + const a = getWallClock(start, timeZone); + const b = getWallClock(end, timeZone); + const midnight = (w: typeof a) => w.hour === 0 && w.minute === 0 && w.second === 0; + if (midnight(a) && midnight(b)) { + const days = Math.round((Date.UTC(b.year, b.month - 1, b.day) - Date.UTC(a.year, a.month - 1, a.day)) / 86400000); + return { start: `${a.year}-${pad(a.month)}-${pad(a.day)}T00:00:00`, duration: `P${days}D`, timeZone: null, showWithoutTime: true }; + } + return { + start: `${a.year}-${pad(a.month)}-${pad(a.day)}T${pad(a.hour)}:${pad(a.minute)}:${pad(a.second)}`, + duration: isoDuration(end.getTime() - start.getTime()), + timeZone, + showWithoutTime: false, + }; +} + +/** The instants an event covers, read the way blockTimes writes them. */ +export function eventSpan(event: Pick, timeZone: string): { start: Date; end: Date } | null { + const wall = parseLocalDateTime(event.start ?? ''); + if (!wall) return null; + const zone = event.showWithoutTime ? timeZone : event.timeZone || timeZone; + const start = fromWallClock(event.showWithoutTime ? { ...wall, hour: 0, minute: 0, second: 0 } : wall, zone); + const seconds = parseDurationSeconds(event.duration ?? 'PT0S') ?? 0; + if (event.showWithoutTime) { + // Whole days in the zone, across a DST change too. + const days = Math.round(seconds / 86400); + const endDay = new Date(Date.UTC(wall.year, wall.month - 1, wall.day + days)); + const end = fromWallClock({ year: endDay.getUTCFullYear(), month: endDay.getUTCMonth() + 1, day: endDay.getUTCDate(), hour: 0, minute: 0, second: 0 }, zone); + return { start, end }; + } + return { start, end: new Date(start.getTime() + seconds * 1000) }; +} + +/** The calendar of the account a block goes into: its default, if the user may add events there. */ +export function blockCalendar(calendars: Calendar[], accountId: string, primaryAccountId: string): Calendar | null { + const own = calendars.filter((c) => !c.isTasksOnly && (accountId === primaryAccountId ? !c.isShared : c.accountId === accountId)); + const calendar = own.find((c) => c.isDefault) ?? own[0] ?? null; + if (!calendar) return null; + const rights = calendar.myRights; + return !rights || rights.mayWriteAll || rights.mayWriteOwn ? calendar : null; +} + +/** The block setting as it applies: the organisation's lock, else the user's choice. */ +export function effectiveBlockSetting(): boolean { + const policy = usePolicyStore.getState(); + const restriction = policy.getRestriction('vacationBlockCalendar'); + if (restriction?.locked === true) { + const value = 'value' in restriction ? restriction.value : policy.getEffectiveDefault('vacationBlockCalendar'); + return typeof value === 'boolean' ? value : true; + } + return useSettingsStore.getState().vacationBlockCalendar !== false; +} + +function writtenBlocks(): Record { + const value = useSettingsStore.getState().vacationCalendarBlocks; + return value && typeof value === 'object' ? value : {}; +} + +function rememberBlock(accountId: string, block: WrittenBlock | null): void { + const next = { ...writtenBlocks() }; + if (block) next[accountId] = block; + else delete next[accountId]; + useSettingsStore.getState().updateSetting('vacationCalendarBlocks', next); +} + +type SyncClient = Pick; + +/** + * Bring the busy event of one account in line with the reply. Returns what + * it did; never throws (a failure is logged and tried again next time). + */ +export async function syncVacationBlock( + client: SyncClient, + accountId: string | undefined, + reply: ReplyState, + trigger: 'save' | 'load', + now = new Date(), +): Promise { + if (typeof client.supportsCalendars !== 'function' || !client.supportsCalendars()) return 'none'; + const primary = client.getAccountId(); + const account = accountId ?? primary; + const target = account === primary ? undefined : account; + const zone = getEffectiveTimeZone(); + try { + const [found] = await client.queryCalendarEvents({ uid: oooUid(account) }, undefined, 1, target); + const span = found ? eventSpan(found, zone) : null; + const last = writtenBlocks()[account] ?? null; + const decision = decideBlock({ block: effectiveBlockSetting(), reply, event: span, last, now, trigger }); + switch (decision.action) { + case 'none': + // A record of an event that is gone and not wanted any more is dropped. + if (!found && last && !(reply.isEnabled && effectiveBlockSetting())) rememberBlock(account, null); + return 'none'; + case 'create': { + const calendar = blockCalendar(await client.getAllCalendars(), account, primary); + if (!calendar) return 'no-calendar'; + await client.createCalendarEvent({ + '@type': 'Event', + uid: oooUid(account), + title: reply.subject.trim() || OUT_OF_OFFICE_TITLE, + calendarIds: { [calendar.originalId ?? calendar.id]: true }, + ...blockTimes(decision.start, decision.end, zone), + freeBusyStatus: 'busy', + privacy: 'public', + status: 'confirmed', + } as Partial, false, target); + rememberBlock(account, { start: decision.start.toISOString(), end: decision.end.toISOString() }); + return 'create'; + } + case 'update': + await client.updateCalendarEvent(found!.originalId ?? found!.id, { + ...blockTimes(decision.start, decision.end, zone), + title: reply.subject.trim() || OUT_OF_OFFICE_TITLE, + }, false, target); + rememberBlock(account, { start: decision.start.toISOString(), end: decision.end.toISOString() }); + return 'update'; + case 'end-now': { + const start = span!.start; + // Whole minutes: Stalwart stores the duration in them. + const end = new Date(Math.max(start.getTime() + 60000, Math.ceil(decision.end.getTime() / 60000) * 60000)); + await client.updateCalendarEvent(found!.originalId ?? found!.id, blockTimes(start, end, zone), false, target); + rememberBlock(account, { start: start.toISOString(), end: blockEnd(start, end, zone).toISOString() }); + return 'end-now'; + } + case 'destroy': + await client.deleteCalendarEvent(found!.originalId ?? found!.id, false, target); + rememberBlock(account, null); + return 'destroy'; + } + } catch (error) { + debug.error('Out-of-office calendar block failed:', error); + return 'failed'; + } +} + +/** The end as eventSpan will read it back after blockTimes wrote it. */ +function blockEnd(start: Date, end: Date, zone: string): Date { + return eventSpan(blockTimes(start, end, zone), zone)?.end ?? end; +} + +/** + * The event title when the reply has no subject. In English: the event is + * seen by colleagues in any language, and a server-side copy can't follow + * the reader's locale. + */ +export const OUT_OF_OFFICE_TITLE = 'Out of office'; + +function relevant(reply: ReplyState): string { + return JSON.stringify([reply.isEnabled, reply.fromDate, reply.toDate, reply.subject]); +} + +interface Pending { + client: SyncClient; + accountId: string | undefined; + reply: ReplyState; + trigger: 'save' | 'load'; +} + +// Kept on globalThis: the store and the settings page may be in different +// bundle copies, and two schedulers would sync the same event twice. +interface SchedulerState { + timer: ReturnType | null; + pending: Pending | null; + running: Promise | null; + loaded: Set; +} +const SCHEDULER_KEY = '__bulwarkVacationCalendarSync'; +function scheduler(): SchedulerState { + const g = globalThis as unknown as Record; + return (g[SCHEDULER_KEY] ??= { timer: null, pending: null, running: null, loaded: new Set() }); +} + +function run(): void { + const state = scheduler(); + state.timer = null; + if (state.running || !state.pending) return; + const job = state.pending; + state.pending = null; + state.running = syncVacationBlock(job.client, job.accountId, job.reply, job.trigger) + .finally(() => { + state.running = null; + if (state.pending && !state.timer) run(); + }); +} + +function schedule(job: Pending): void { + const state = scheduler(); + // A save outranks a load that waits; the latest reply wins. + if (state.pending?.trigger === 'save' && job.trigger === 'load') return; + state.pending = job; + if (state.timer) clearTimeout(state.timer); + state.timer = setTimeout(run, SYNC_DEBOUNCE_MS); +} + +/** After a saved reply: sync when the switch, the dates or the subject changed. */ +export function vacationReplySaved(client: SyncClient, accountId: string | undefined, before: ReplyState, after: ReplyState): void { + if (relevant(before) === relevant(after)) return; + schedule({ client, accountId, reply: after, trigger: 'save' }); +} + +/** The user changed the block setting: sync with the reply as it is. */ +export function vacationBlockChanged(client: SyncClient, accountId: string | undefined, reply: ReplyState): void { + schedule({ client, accountId, reply, trigger: 'save' }); +} + +/** After the reply was loaded: once per page load and account. */ +export function vacationReplyLoaded(client: SyncClient, accountId: string | undefined, reply: ReplyState): void { + const key = accountId ?? client.getAccountId(); + const state = scheduler(); + if (state.loaded.has(key)) return; + state.loaded.add(key); + schedule({ client, accountId, reply, trigger: 'load' }); +} + +/** For tests: drop what the scheduler holds. */ +export function resetVacationCalendarSync(): void { + const state = scheduler(); + if (state.timer) clearTimeout(state.timer); + state.timer = null; + state.pending = null; + state.running = null; + state.loaded.clear(); +} + +/** + * Whether turning the reply on switches off a filter script of the user's + * own: Stalwart runs one script at a time, and Bulwark keeps only its own + * filters running next to the reply (stores/filter-store.ts, through an + * include). A script it can't read, or any script on a server without + * `include`, stops while the reply is on. + */ +export async function ownFilterScriptStops( + client: Pick, + accountId?: string, +): Promise { + const account = accountId || client.getSieveAccountId(); + const scripts = await client.getSieveScripts(account); + const active = scripts.find((s) => s.isActive && s.name !== VACATION_SCRIPT_NAME); + if (!active) return false; + if (!supportsInclude(client.getSieveCapabilities(account))) return true; + const parsed = parseScript(await client.getSieveScriptContent(active.blobId, account)); + return parsed.isOpaque; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index b68f8bfa9..86fc84cbe 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -4845,6 +4845,13 @@ "cancelled": "ألغى {name} \"{title}\"", "untitled": "حدث بدون عنوان" }, + "vacation_calendar": { + "label": "حجز تقويمي", + "description": "يضيف حدثًا مشغولًا لمدة غيابك، حتى لا يحجز أحد اجتماعات معك خلالها.", + "no_access_managed": "لا يمكن حجز تقويم هذا الحساب: ليست لديك صلاحية الكتابة فيه.", + "no_calendar": "لا يمكن حجز تقويمك: لم يجد Bulwark تقويمًا لإضافة الحدث إليه.", + "filter_warning": "تشغيل هذا يوقف نص التصفية الخاص بك طوال مدة تفعيل الرد." + }, "calendar_delegate": { "answer_for": "الرد نيابةً عن {name}", "no_write": "يمكنك رؤية دعوات {name} لكن لا يمكنك الرد عليها. اطلب صلاحية التعديل.", diff --git a/locales/ca/common.json b/locales/ca/common.json index 5aae20cf3..7304563ef 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -4845,6 +4845,13 @@ "cancelled": "{name} ha cancel·lat \"{title}\"", "untitled": "Esdeveniment sense títol" }, + "vacation_calendar": { + "label": "Bloca el meu calendari", + "description": "Afegeix un esdeveniment ocupat mentre sou fora, perquè ningú us convoqui a reunions aleshores.", + "no_access_managed": "No es pot blocar el calendari d’aquest compte: no hi teniu accés d’escriptura.", + "no_calendar": "No es pot blocar el calendari: Bulwark no ha trobat cap calendari on afegir l’esdeveniment.", + "filter_warning": "Si ho activeu, el vostre propi script de filtres s’aturarà mentre la resposta estigui activa." + }, "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ó.", diff --git a/locales/cs/common.json b/locales/cs/common.json index b61fd10de..f06b2b2a0 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5283,6 +5283,13 @@ "cancelled": "{name} zrušil(a) „{title}“", "untitled": "Událost bez názvu" }, + "vacation_calendar": { + "label": "Zablokovat můj kalendář", + "description": "Přidá obsazenou událost na dobu vaší nepřítomnosti, aby vám tehdy nikdo neplánoval schůzky.", + "no_access_managed": "Kalendář tohoto účtu nelze zablokovat: nemáte do něj přístup pro zápis.", + "no_calendar": "Kalendář nelze zablokovat: Bulwark nenašel kalendář, kam událost přidat.", + "filter_warning": "Zapnutím se vypne váš vlastní skript filtrů, dokud je odpověď aktivní." + }, "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.", diff --git a/locales/da/common.json b/locales/da/common.json index 803fbe420..20ac3e1e2 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -4845,6 +4845,13 @@ "cancelled": "{name} har aflyst \"{title}\"", "untitled": "Unavngivet begivenhed" }, + "vacation_calendar": { + "label": "Bloker min kalender", + "description": "Tilføjer en optaget begivenhed, mens du er væk, så ingen booker møder med dig imens.", + "no_access_managed": "Kalenderen for denne konto kan ikke blokeres: du har ikke skriveadgang til den.", + "no_calendar": "Din kalender kan ikke blokeres: Bulwark fandt ingen kalender at tilføje begivenheden til.", + "filter_warning": "Slår du dette til, slås dit eget filterscript fra, mens svaret er aktivt." + }, "calendar_delegate": { "answer_for": "Svar for {name}", "no_write": "Du kan se invitationerne til {name}, men kan ikke svare for dem. Bed om redigeringsadgang.", diff --git a/locales/de/common.json b/locales/de/common.json index 98f964ff0..423336c2f 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5283,6 +5283,13 @@ "cancelled": "{name} hat „{title}“ abgesagt", "untitled": "Unbenannter Termin" }, + "vacation_calendar": { + "label": "Meinen Kalender blockieren", + "description": "Trägt für Ihre Abwesenheit einen belegten Termin ein, damit Ihnen niemand Besprechungen in dieser Zeit einträgt.", + "no_access_managed": "Der Kalender dieses Kontos kann nicht blockiert werden: Sie haben keinen Schreibzugriff darauf.", + "no_calendar": "Ihr Kalender kann nicht blockiert werden: Bulwark hat keinen Kalender für den Termin gefunden.", + "filter_warning": "Wenn Sie dies einschalten, ist Ihr eigenes Filterskript ausgeschaltet, solange die Antwort aktiv ist." + }, "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.", diff --git a/locales/en/common.json b/locales/en/common.json index 5b8bccffa..8f07b6ce6 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -4845,6 +4845,13 @@ "cancelled": "{name} cancelled \"{title}\"", "untitled": "Untitled event" }, + "vacation_calendar": { + "label": "Block my calendar", + "description": "Adds a busy event for the time you’re away, so nobody books meetings with you then.", + "no_access_managed": "The calendar of this account can’t be blocked: you don’t have write access to it.", + "no_calendar": "Your calendar can’t be blocked: Bulwark found no calendar to add the event to.", + "filter_warning": "Turning this on switches off your own filter script while the reply is active." + }, "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.", diff --git a/locales/es/common.json b/locales/es/common.json index 58a19e117..142117f28 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5283,6 +5283,13 @@ "cancelled": "{name} ha cancelado \"{title}\"", "untitled": "Evento sin título" }, + "vacation_calendar": { + "label": "Bloquear mi calendario", + "description": "Añade un evento ocupado mientras estás fuera, para que nadie te convoque a reuniones entonces.", + "no_access_managed": "No se puede bloquear el calendario de esta cuenta: no tienes acceso de escritura.", + "no_calendar": "No se puede bloquear tu calendario: Bulwark no encontró ningún calendario donde añadir el evento.", + "filter_warning": "Si lo activas, tu propio script de filtros se desactiva mientras la respuesta esté activa." + }, "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.", diff --git a/locales/et/common.json b/locales/et/common.json index 390fd7a31..eafff1c55 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -4845,6 +4845,13 @@ "cancelled": "{name} tühistas sündmuse \"{title}\"", "untitled": "Nimetu sündmus" }, + "vacation_calendar": { + "label": "Blokeeri minu kalender", + "description": "Lisab äraoleku ajaks hõivatud sündmuse, et keegi sel ajal sulle koosolekuid ei paneks.", + "no_access_managed": "Selle konto kalendrit ei saa blokeerida: sul pole sinna kirjutusõigust.", + "no_calendar": "Kalendrit ei saa blokeerida: Bulwark ei leidnud kalendrit, kuhu sündmus lisada.", + "filter_warning": "Selle sisselülitamine lülitab sinu oma filtriskripti välja, kuni vastus on aktiivne." + }, "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.", diff --git a/locales/fa/common.json b/locales/fa/common.json index d4366e843..c603ee3bc 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -4845,6 +4845,13 @@ "cancelled": "{name} «{title}» را لغو کرد", "untitled": "رویداد بدون عنوان" }, + "vacation_calendar": { + "label": "مسدود کردن تقویم من", + "description": "برای مدت غیبت شما یک رویداد مشغول اضافه می‌کند تا کسی در آن زمان جلسه نگذارد.", + "no_access_managed": "تقویم این حساب را نمی‌توان مسدود کرد: دسترسی نوشتن به آن ندارید.", + "no_calendar": "تقویم شما را نمی‌توان مسدود کرد: Bulwark تقویمی برای افزودن رویداد پیدا نکرد.", + "filter_warning": "روشن کردن این، اسکریپت فیلتر خودتان را تا وقتی پاسخ فعال است خاموش می‌کند." + }, "calendar_delegate": { "answer_for": "پاسخ به‌جای {name}", "no_write": "دعوت‌های {name} را می‌بینید اما نمی‌توانید به‌جای او پاسخ دهید. دسترسی ویرایش بخواهید.", diff --git a/locales/fr/common.json b/locales/fr/common.json index 159d78f71..39049d7e9 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5283,6 +5283,13 @@ "cancelled": "{name} a annulé « {title} »", "untitled": "Événement sans titre" }, + "vacation_calendar": { + "label": "Bloquer mon calendrier", + "description": "Ajoute un événement occupé pendant votre absence, pour que personne ne vous invite à une réunion à ce moment-là.", + "no_access_managed": "Le calendrier de ce compte ne peut pas être bloqué : vous n’y avez pas accès en écriture.", + "no_calendar": "Votre calendrier ne peut pas être bloqué : Bulwark n’a trouvé aucun calendrier où ajouter l’événement.", + "filter_warning": "L’activer désactive votre propre script de filtres tant que la réponse est active." + }, "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.", diff --git a/locales/he/common.json b/locales/he/common.json index 8dadd4140..d87ddf5d8 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5255,6 +5255,13 @@ "cancelled": "{name} ביטל/ה את \"{title}\"", "untitled": "אירוע ללא כותרת" }, + "vacation_calendar": { + "label": "חסום את היומן שלי", + "description": "מוסיף אירוע תפוס לזמן ההיעדרות, כדי שאף אחד לא יקבע איתך פגישות אז.", + "no_access_managed": "אי אפשר לחסום את היומן של החשבון הזה: אין לך הרשאת כתיבה אליו.", + "no_calendar": "אי אפשר לחסום את היומן: Bulwark לא מצא יומן להוסיף אליו את האירוע.", + "filter_warning": "הפעלה תכבה את סקריפט הסינון שלך כל עוד התשובה פעילה." + }, "calendar_delegate": { "answer_for": "השב בשם {name}", "no_write": "אפשר לראות את ההזמנות של {name}, אבל אי אפשר להשיב בשמו. בקשו הרשאת עריכה.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 4cdb86d75..14fbe92e8 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -4845,6 +4845,13 @@ "cancelled": "{name} lemondta a(z) „{title}” eseményt", "untitled": "Névtelen esemény" }, + "vacation_calendar": { + "label": "Naptáram foglalása", + "description": "Foglalt eseményt tesz a távollét idejére, hogy addig senki ne szervezzen Önnel megbeszélést.", + "no_access_managed": "Ennek a fióknak a naptára nem foglalható: nincs hozzá írási joga.", + "no_calendar": "A naptár nem foglalható: a Bulwark nem talált naptárt az eseményhez.", + "filter_warning": "Ha bekapcsolja, a saját szűrőszkriptje kikapcsol, amíg a válasz aktív." + }, "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.", diff --git a/locales/it/common.json b/locales/it/common.json index 7470d3233..4d084d482 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5283,6 +5283,13 @@ "cancelled": "{name} ha annullato \"{title}\"", "untitled": "Evento senza titolo" }, + "vacation_calendar": { + "label": "Blocca il mio calendario", + "description": "Aggiunge un evento occupato per il periodo di assenza, così nessuno ti fissa riunioni in quei giorni.", + "no_access_managed": "Non è possibile bloccare il calendario di questo account: non hai accesso in scrittura.", + "no_calendar": "Impossibile bloccare il calendario: Bulwark non ha trovato un calendario a cui aggiungere l’evento.", + "filter_warning": "Attivandolo, il tuo script di filtri viene disattivato finché la risposta è attiva." + }, "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.", diff --git a/locales/ja/common.json b/locales/ja/common.json index 9c9345547..acabf69c8 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5283,6 +5283,13 @@ "cancelled": "{name} さんが「{title}」をキャンセルしました", "untitled": "無題の予定" }, + "vacation_calendar": { + "label": "カレンダーをブロック", + "description": "不在の期間に予定ありの予定を追加し、その間に会議を入れられないようにします。", + "no_access_managed": "このアカウントのカレンダーはブロックできません。書き込み権限がありません。", + "no_calendar": "カレンダーをブロックできません。予定を追加できるカレンダーが見つかりませんでした。", + "filter_warning": "これをオンにすると、返信が有効な間は自分のフィルタースクリプトがオフになります。" + }, "calendar_delegate": { "answer_for": "{name} の代わりに返答", "no_write": "{name} の招待は見られますが、代わりに返答することはできません。編集権限を依頼してください。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 4594d1539..717862f6c 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5283,6 +5283,13 @@ "cancelled": "{name}님이 \"{title}\"을(를) 취소했습니다", "untitled": "제목 없는 일정" }, + "vacation_calendar": { + "label": "내 캘린더 차단", + "description": "부재 기간에 바쁨 일정을 추가해 그동안 회의가 잡히지 않게 합니다.", + "no_access_managed": "이 계정의 캘린더는 차단할 수 없습니다. 쓰기 권한이 없습니다.", + "no_calendar": "캘린더를 차단할 수 없습니다. 일정을 추가할 캘린더를 찾지 못했습니다.", + "filter_warning": "이 기능을 켜면 응답이 활성화된 동안 내 필터 스크립트가 꺼집니다." + }, "calendar_delegate": { "answer_for": "{name} 대신 응답", "no_write": "{name}의 초대는 볼 수 있지만 대신 응답할 수는 없습니다. 편집 권한을 요청하세요.", diff --git a/locales/lv/common.json b/locales/lv/common.json index ea1432b0d..6de04d08f 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5283,6 +5283,13 @@ "cancelled": "{name} atcēla “{title}”", "untitled": "Notikums bez nosaukuma" }, + "vacation_calendar": { + "label": "Bloķēt manu kalendāru", + "description": "Pievieno aizņemtu notikumu prombūtnes laikam, lai tad neviens neplānotu ar jums sapulces.", + "no_access_managed": "Šī konta kalendāru nevar bloķēt: jums nav rakstīšanas piekļuves.", + "no_calendar": "Kalendāru nevar bloķēt: Bulwark neatrada kalendāru, kur pievienot notikumu.", + "filter_warning": "To ieslēdzot, jūsu filtru skripts ir izslēgts, kamēr atbilde ir aktīva." + }, "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.", diff --git a/locales/mn/common.json b/locales/mn/common.json index 8c20c20c8..2b3141027 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -4845,6 +4845,13 @@ "cancelled": "{name} \"{title}\"-г цуцаллаа", "untitled": "Нэргүй арга хэмжээ" }, + "vacation_calendar": { + "label": "Миний хуанлийг хаах", + "description": "Таныг байхгүй үед завгүй арга хэмжээ нэмж, тэр үед хэн ч уулзалт товлохгүй болгоно.", + "no_access_managed": "Энэ дансны хуанлийг хаах боломжгүй: танд бичих эрх алга.", + "no_calendar": "Хуанлийг хаах боломжгүй: Bulwark арга хэмжээ нэмэх хуанли олсонгүй.", + "filter_warning": "Үүнийг асаавал хариу идэвхтэй байх хугацаанд таны шүүлтүүрийн скрипт унтарна." + }, "calendar_delegate": { "answer_for": "{name}-ийн өмнөөс хариулах", "no_write": "Та {name}-ийн урилгыг харж болох ч өмнөөс нь хариулж чадахгүй. Засах эрх хүснэ үү.", diff --git a/locales/nb/common.json b/locales/nb/common.json index 927235b9d..0b47650f7 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -4845,6 +4845,13 @@ "cancelled": "{name} har avlyst «{title}»", "untitled": "Hendelse uten tittel" }, + "vacation_calendar": { + "label": "Blokker kalenderen min", + "description": "Legger inn en opptatt-hendelse mens du er borte, så ingen booker møter med deg da.", + "no_access_managed": "Kalenderen til denne kontoen kan ikke blokkeres: du har ikke skrivetilgang til den.", + "no_calendar": "Kalenderen din kan ikke blokkeres: Bulwark fant ingen kalender å legge hendelsen i.", + "filter_warning": "Slår du dette på, slås ditt eget filterskript av så lenge svaret er aktivt." + }, "calendar_delegate": { "answer_for": "Svar for {name}", "no_write": "Du ser invitasjonene til {name}, men kan ikke svare for dem. Be om redigeringstilgang.", diff --git a/locales/nl/common.json b/locales/nl/common.json index fc6864a89..8ea39c2c6 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5283,6 +5283,13 @@ "cancelled": "{name} heeft \"{title}\" geannuleerd", "untitled": "Afspraak zonder titel" }, + "vacation_calendar": { + "label": "Mijn agenda blokkeren", + "description": "Zet een bezette afspraak in je agenda voor de tijd dat je weg bent, zodat niemand dan vergaderingen met je plant.", + "no_access_managed": "De agenda van dit account kan niet worden geblokkeerd: je hebt er geen schrijfrechten voor.", + "no_calendar": "Je agenda kan niet worden geblokkeerd: Bulwark vond geen agenda om de afspraak in te zetten.", + "filter_warning": "Als je dit aanzet, staat je eigen filterscript uit zolang het antwoord actief is." + }, "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.", diff --git a/locales/pl/common.json b/locales/pl/common.json index 2ff7998f1..84b79e5ec 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5283,6 +5283,13 @@ "cancelled": "{name} odwołał(a) „{title}”", "untitled": "Wydarzenie bez tytułu" }, + "vacation_calendar": { + "label": "Zablokuj mój kalendarz", + "description": "Dodaje zajęte wydarzenie na czas Twojej nieobecności, żeby nikt nie umawiał wtedy z Tobą spotkań.", + "no_access_managed": "Nie można zablokować kalendarza tego konta: nie masz do niego prawa zapisu.", + "no_calendar": "Nie można zablokować kalendarza: Bulwark nie znalazł kalendarza, do którego można dodać wydarzenie.", + "filter_warning": "Włączenie tego wyłącza Twój własny skrypt filtrów, dopóki odpowiedź jest aktywna." + }, "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.", diff --git a/locales/pt/common.json b/locales/pt/common.json index 5a562dfb7..981966bc6 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5283,6 +5283,13 @@ "cancelled": "{name} cancelou \"{title}\"", "untitled": "Evento sem título" }, + "vacation_calendar": { + "label": "Bloquear o meu calendário", + "description": "Adiciona um evento ocupado durante a sua ausência, para que ninguém marque reuniões consigo nessa altura.", + "no_access_managed": "Não é possível bloquear o calendário desta conta: não tem acesso de escrita.", + "no_calendar": "Não é possível bloquear o calendário: o Bulwark não encontrou um calendário para adicionar o evento.", + "filter_warning": "Ativar isto desliga o seu próprio script de filtros enquanto a resposta estiver ativa." + }, "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.", diff --git a/locales/ro/common.json b/locales/ro/common.json index 30ffeef54..1462cb8f3 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -4845,6 +4845,13 @@ "cancelled": "{name} a anulat „{title}”", "untitled": "Eveniment fără titlu" }, + "vacation_calendar": { + "label": "Blochează-mi calendarul", + "description": "Adaugă un eveniment ocupat pe durata absenței, ca nimeni să nu vă programeze ședințe atunci.", + "no_access_managed": "Calendarul acestui cont nu poate fi blocat: nu aveți acces de scriere la el.", + "no_calendar": "Calendarul nu poate fi blocat: Bulwark nu a găsit un calendar în care să adauge evenimentul.", + "filter_warning": "Activarea oprește scriptul dvs. de filtre cât timp răspunsul este activ." + }, "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.", diff --git a/locales/ru/common.json b/locales/ru/common.json index 0ac5b538e..101df2134 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5283,6 +5283,13 @@ "cancelled": "{name} отменил(а) «{title}»", "untitled": "Событие без названия" }, + "vacation_calendar": { + "label": "Занять мой календарь", + "description": "Добавляет событие «занят» на время отсутствия, чтобы вам не назначали встречи.", + "no_access_managed": "Календарь этой учётной записи нельзя занять: у вас нет доступа на запись.", + "no_calendar": "Не удаётся занять календарь: Bulwark не нашёл календарь для события.", + "filter_warning": "Если включить, ваш собственный скрипт фильтров будет выключен, пока ответ активен." + }, "calendar_delegate": { "answer_for": "Ответить за {name}", "no_write": "Вы видите приглашения {name}, но не можете отвечать за этого человека. Попросите доступ на изменение.", diff --git a/locales/sk/common.json b/locales/sk/common.json index d48eec551..8ac9073dc 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -4845,6 +4845,13 @@ "cancelled": "{name} zrušil(a) „{title}“", "untitled": "Udalosť bez názvu" }, + "vacation_calendar": { + "label": "Zablokovať môj kalendár", + "description": "Pridá obsadenú udalosť na čas vašej neprítomnosti, aby vám vtedy nikto neplánoval stretnutia.", + "no_access_managed": "Kalendár tohto účtu nemožno zablokovať: nemáte doň prístup na zápis.", + "no_calendar": "Kalendár nemožno zablokovať: Bulwark nenašiel kalendár, kam udalosť pridať.", + "filter_warning": "Zapnutím sa vypne váš vlastný skript filtrov, kým je odpoveď aktívna." + }, "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.", diff --git a/locales/th/common.json b/locales/th/common.json index 642670029..6d115429a 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2185,6 +2185,13 @@ "cancelled": "{name} ยกเลิกกิจกรรม “{title}”", "untitled": "กิจกรรมที่ไม่มีชื่อ" }, + "vacation_calendar": { + "label": "บล็อกปฏิทินของฉัน", + "description": "เพิ่มกิจกรรมไม่ว่างในช่วงที่คุณไม่อยู่ เพื่อไม่ให้ใครนัดประชุมกับคุณในเวลานั้น", + "no_access_managed": "บล็อกปฏิทินของบัญชีนี้ไม่ได้ เพราะคุณไม่มีสิทธิ์เขียน", + "no_calendar": "บล็อกปฏิทินไม่ได้ Bulwark ไม่พบปฏิทินสำหรับเพิ่มกิจกรรม", + "filter_warning": "การเปิดใช้จะปิดสคริปต์ตัวกรองของคุณเองตลอดช่วงที่การตอบกลับทำงาน" + }, "calendar_delegate": { "answer_for": "ตอบแทน {name}", "no_write": "คุณเห็นคำเชิญของ {name} แต่ตอบแทนไม่ได้ โปรดขอสิทธิ์แก้ไข", diff --git a/locales/tr/common.json b/locales/tr/common.json index 23543c56e..21aa0af9c 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -4845,6 +4845,13 @@ "cancelled": "{name} \"{title}\" etkinliğini iptal etti", "untitled": "Başlıksız etkinlik" }, + "vacation_calendar": { + "label": "Takvimimi kapat", + "description": "Uzakta olduğunuz süre için meşgul bir etkinlik ekler, böylece o sırada kimse sizinle toplantı ayarlamaz.", + "no_access_managed": "Bu hesabın takvimi kapatılamaz: yazma erişiminiz yok.", + "no_calendar": "Takviminiz kapatılamıyor: Bulwark etkinliği ekleyecek bir takvim bulamadı.", + "filter_warning": "Bunu açmak, yanıt etkin olduğu sürece kendi filtre betiğinizi kapatır." + }, "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.", diff --git a/locales/uk/common.json b/locales/uk/common.json index 5b308c23d..24b359a8a 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5283,6 +5283,13 @@ "cancelled": "{name} скасував(ла) «{title}»", "untitled": "Подія без назви" }, + "vacation_calendar": { + "label": "Заблокувати мій календар", + "description": "Додає подію «зайнято» на час вашої відсутності, щоб вам тоді не призначали зустрічей.", + "no_access_managed": "Календар цього облікового запису не можна заблокувати: у вас немає доступу на запис.", + "no_calendar": "Не вдається заблокувати календар: Bulwark не знайшов календаря для події.", + "filter_warning": "Якщо це ввімкнути, ваш власний скрипт фільтрів буде вимкнено, поки відповідь активна." + }, "calendar_delegate": { "answer_for": "Відповісти за {name}", "no_write": "Ви бачите запрошення {name}, але не можете відповідати за цю людину. Попросіть доступ на редагування.", diff --git a/locales/vi/common.json b/locales/vi/common.json index 7813e61e0..4445f0894 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -4857,6 +4857,13 @@ "cancelled": "{name} đã hủy \"{title}\"", "untitled": "Sự kiện không có tiêu đề" }, + "vacation_calendar": { + "label": "Chặn lịch của tôi", + "description": "Thêm một sự kiện bận trong thời gian bạn vắng mặt để không ai đặt cuộc họp với bạn lúc đó.", + "no_access_managed": "Không thể chặn lịch của tài khoản này: bạn không có quyền ghi.", + "no_calendar": "Không thể chặn lịch: Bulwark không tìm thấy lịch để thêm sự kiện.", + "filter_warning": "Bật tính năng này sẽ tắt tập lệnh lọc của riêng bạn trong thời gian trả lời tự động bật." + }, "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.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index e98fbb96d..801825c00 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -4845,6 +4845,13 @@ "cancelled": "{name} 取消了「{title}」", "untitled": "未命名活動" }, + "vacation_calendar": { + "label": "佔用我的行事曆", + "description": "在你外出期間新增一個忙碌活動,這樣別人不會在這段時間約你開會。", + "no_access_managed": "無法佔用此帳號的行事曆:你沒有寫入權限。", + "no_calendar": "無法佔用你的行事曆:Bulwark 找不到可以新增活動的行事曆。", + "filter_warning": "開啟後,在自動回覆生效期間你自己的過濾指令碼會被關閉。" + }, "calendar_delegate": { "answer_for": "代表 {name} 回覆", "no_write": "你能看到 {name} 的邀請,但不能代為回覆。請向對方申請編輯權限。", diff --git a/locales/zh/common.json b/locales/zh/common.json index f33727647..61364c418 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5283,6 +5283,13 @@ "cancelled": "{name} 取消了“{title}”", "untitled": "无标题活动" }, + "vacation_calendar": { + "label": "占用我的日历", + "description": "在你外出期间添加一个忙碌活动,这样别人不会在这段时间约你开会。", + "no_access_managed": "无法占用此账户的日历:你没有写入权限。", + "no_calendar": "无法占用你的日历:Bulwark 找不到可以添加活动的日历。", + "filter_warning": "开启后,在自动回复生效期间你自己的过滤脚本会被关闭。" + }, "calendar_delegate": { "answer_for": "代表 {name} 回复", "no_write": "你能看到 {name} 的邀请,但不能代为回复。请向对方申请编辑权限。", diff --git a/stores/__tests__/vacation-store-calendar.test.ts b/stores/__tests__/vacation-store-calendar.test.ts new file mode 100644 index 000000000..70cd31ba7 --- /dev/null +++ b/stores/__tests__/vacation-store-calendar.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const saved = vi.fn(); +const loaded = vi.fn(); +vi.mock('@/lib/vacation-calendar', () => ({ + vacationReplySaved: (...args: unknown[]) => saved(...args), + vacationReplyLoaded: (...args: unknown[]) => loaded(...args), +})); + +import { useVacationStore } from '../vacation-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// Every writer of the reply goes through the store (the settings page and +// plugins), so the out-of-office calendar block hooks in there. + +const SERVER = { + id: 'singleton', isEnabled: true, fromDate: '2026-12-01T08:00:00Z', toDate: '2026-12-05T17:00:00Z', + subject: 'Away', textBody: 'Back soon', htmlBody: null, +}; + +function mockClient() { + return { + supportsSieve: () => false, + accountSupports: () => false, + getVacationResponse: vi.fn(async () => ({ ...SERVER })), + setVacationResponse: vi.fn(async () => {}), + } as unknown as IJMAPClient; +} + +const initial = useVacationStore.getState(); +afterEach(() => { + useVacationStore.setState(initial, true); + saved.mockClear(); + loaded.mockClear(); +}); + +describe('vacation store and the calendar block', () => { + it('hands the loaded reply over for the account it was loaded for', async () => { + const client = mockClient(); + await useVacationStore.getState().fetchVacationResponse(client, 'boss'); + expect(loaded).toHaveBeenCalledWith(client, 'boss', { + isEnabled: true, fromDate: '2026-12-01T08:00:00Z', toDate: '2026-12-05T17:00:00Z', subject: 'Away', + }); + }); + + it('hands over the reply before and after a save', async () => { + const client = mockClient(); + await useVacationStore.getState().fetchVacationResponse(client); + await useVacationStore.getState().updateVacationResponse(client, { toDate: '2026-12-08T17:00:00Z' }); + expect(saved).toHaveBeenCalledWith( + client, undefined, + expect.objectContaining({ isEnabled: true, toDate: '2026-12-05T17:00:00Z', subject: 'Away' }), + { isEnabled: true, fromDate: '2026-12-01T08:00:00Z', toDate: '2026-12-08T17:00:00Z', subject: 'Away' }, + ); + }); + + it('hands nothing over when the save failed', async () => { + const client = mockClient(); + (client.setVacationResponse as ReturnType).mockRejectedValueOnce(new Error('down')); + await expect(useVacationStore.getState().updateVacationResponse(client, { isEnabled: false })).rejects.toThrow(); + expect(saved).not.toHaveBeenCalled(); + }); +}); diff --git a/stores/settings-store.ts b/stores/settings-store.ts index bea2dfa98..537bba3ed 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -623,6 +623,10 @@ interface SettingsState extends AccessibilitySettings { calendarHideNonWorkingDays: boolean; /** Weekdays as `Date.getDay` numbers (0 = Sunday). */ calendarWorkingDays: number[]; + /** An out-of-office reply with dates puts a busy event in the calendar (lib/vacation-calendar.ts). */ + vacationBlockCalendar: boolean; + /** The times of each account's out-of-office event as Bulwark last wrote them, by account id. */ + vacationCalendarBlocks: Record; // Calendar Tasks enableCalendarTasks: boolean; @@ -946,6 +950,8 @@ const DEFAULT_SETTINGS = { calendarDayEndHour: 20, calendarHideNonWorkingDays: false, calendarWorkingDays: [1, 2, 3, 4, 5] as number[], + vacationBlockCalendar: true, + vacationCalendarBlocks: {} as Record, // Calendar Tasks enableCalendarTasks: false, @@ -1386,6 +1392,8 @@ export const useSettingsStore = create()( calendarDayEndHour: state.calendarDayEndHour, calendarHideNonWorkingDays: state.calendarHideNonWorkingDays, calendarWorkingDays: state.calendarWorkingDays, + vacationBlockCalendar: state.vacationBlockCalendar, + vacationCalendarBlocks: state.vacationCalendarBlocks, toolbarPosition: state.toolbarPosition, hideAccountSwitcher: state.hideAccountSwitcher, showRailAccountList: state.showRailAccountList, diff --git a/stores/vacation-store.ts b/stores/vacation-store.ts index 14e8c841a..359177332 100644 --- a/stores/vacation-store.ts +++ b/stores/vacation-store.ts @@ -7,6 +7,7 @@ import { currentStoreEpoch } from '@/lib/store-epoch'; import { debug } from '@/lib/debug'; import { vacationHooks } from '@/lib/plugin-hooks'; import { vacationToView } from '@/lib/plugin-projection'; +import { vacationReplyLoaded, vacationReplySaved } from '@/lib/vacation-calendar'; /** What the vacation card sets for forwarding; the period comes from the vacation. */ export type VacationForwardSettings = Pick; @@ -148,6 +149,9 @@ export const useVacationStore = create()((set, get) => ({ ...(filters ? fromFilters(filters) : {}), isLoading: false, }); + // Out-of-office blocks the calendar (lib/vacation-calendar.ts): the + // reply may have been set in another client. + vacationReplyLoaded(client, accountId, { isEnabled, fromDate: vacation.fromDate, toDate: vacation.toDate, subject: vacation.subject || '' }); } catch (error) { if (stale()) return; set({ @@ -232,6 +236,7 @@ export const useVacationStore = create()((set, get) => ({ : { ...current, ...vacationUpdates, ...filtersState, isSaving: false })); // The reply itself is saved even when its filters part failed. void vacationHooks.onVacationUpdate.emit(next); + vacationReplySaved(client, accountId, before, { isEnabled: next.isEnabled, fromDate: next.fromDate, toDate: next.toDate, subject: next.subject }); if (filtersError) throw filtersError; return true; }, From 68567e08ca21b0865f5ec20e49116b1039c69d0c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:19:07 +0100 Subject: [PATCH 06/23] feat: let admins lock blocking the calendar for out-of-office replies --- .../out-of-office-calendar-section.test.tsx | 67 +++++++++++++++++ .../out-of-office-calendar-section.tsx | 75 +++++++++++++++++-- 2 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 components/admin/org/__tests__/out-of-office-calendar-section.test.tsx diff --git a/components/admin/org/__tests__/out-of-office-calendar-section.test.tsx b/components/admin/org/__tests__/out-of-office-calendar-section.test.tsx new file mode 100644 index 000000000..3fdaeedf3 --- /dev/null +++ b/components/admin/org/__tests__/out-of-office-calendar-section.test.tsx @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +// Organisation > Calendars > Out-of-office: the vacationBlockCalendar lock, +// with the value users get. + +let stored: Record = {}; +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.method === 'PUT') { + stored = JSON.parse(String(init.body)); + return new Response('{"ok":true}'); + } + return new Response(JSON.stringify(stored)); + }), + withBasePath: (p: string) => p, + getPathPrefix: () => '', +})); + +import { OutOfOfficeCalendarSection } from '../sections/out-of-office-calendar-section'; +import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft'; + +const POLICY = { + restrictions: {}, features: {}, defaults: {}, themePolicy: {}, + forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], + organisation: {}, + scopes: [{ id: 'sales', label: 'Sales', match: { groups: ['sales@example.org'] }, policy: {} }], +}; + +let latest: ReturnType | null = null; +function Harness() { + const draft = useOrgPolicyDraft('test'); + latest = draft; + if (draft.loading) return null; + return ; +} + +beforeEach(() => { + stored = JSON.parse(JSON.stringify(POLICY)); + latest = null; + useOrgScopeStore.setState({ scopeId: EVERYONE }); +}); + +describe('Out-of-office calendar section', () => { + it('lets users choose by default and locks the block on or off with its value', async () => { + render(); + await waitFor(() => expect(screen.getByTestId('org-ooo-notes')).toBeInTheDocument()); + const select = screen.getByLabelText('Out-of-office blocks the calendar') as HTMLSelectElement; + expect(select.value).toBe('users'); + fireEvent.change(select, { target: { value: 'never' } }); + expect(latest!.restriction('vacationBlockCalendar').value).toEqual({ locked: true, value: false }); + fireEvent.change(select, { target: { value: 'always' } }); + await act(async () => { await latest!.save(); }); + expect((stored.restrictions as Record).vacationBlockCalendar).toEqual({ locked: true, value: true }); + fireEvent.change(select, { target: { value: 'users' } }); + expect(latest!.restriction('vacationBlockCalendar').value).toBeUndefined(); + }); + + it('writes a scope\'s lock only into that scope', async () => { + render(); + await waitFor(() => expect(screen.getByTestId('org-ooo-notes')).toBeInTheDocument()); + act(() => latest!.setScopeId('sales')); + fireEvent.change(screen.getByLabelText('Out-of-office blocks the calendar'), { target: { value: 'never' } }); + expect(latest!.policy?.scopes?.[0].policy.restrictions).toEqual({ vacationBlockCalendar: { locked: true, value: false } }); + expect(latest!.policy?.restrictions.vacationBlockCalendar).toBeUndefined(); + }); +}); diff --git a/components/admin/org/sections/out-of-office-calendar-section.tsx b/components/admin/org/sections/out-of-office-calendar-section.tsx index e64fcfcc8..574359360 100644 --- a/components/admin/org/sections/out-of-office-calendar-section.tsx +++ b/components/admin/org/sections/out-of-office-calendar-section.tsx @@ -1,13 +1,78 @@ 'use client'; -import { SettingsSection } from '@/components/settings/settings-section'; +import { SettingItem, SettingsSection } from '@/components/settings/settings-section'; +import { InheritNote } from '../org-page'; import type { OrgPolicyDraft } from '../use-org-policy-draft'; +import type { SettingRestriction } from '@/lib/admin/types'; + +const KEY = 'vacationBlockCalendar'; +const DOCS_URL = 'https://bulwarkmail.org/docs/guides/organisation/out-of-office'; + +type Choice = 'users' | 'always' | 'never'; + +function choiceOf(r: SettingRestriction | undefined): Choice { + if (r?.locked !== true) return 'users'; + return r.value === false ? 'never' : 'always'; +} + +const LABELS: Record = { + users: 'Users choose (on until they turn it off)', + always: 'Always block the calendar', + never: 'Never block the calendar', +}; /** - * Placeholder from the enterprise-pack foundation, replaced by package B: - * Whether an out-of-office reply blocks the calendar (restriction vacationBlockCalendar). + * Organisation > Calendars > Out-of-office: whether an out-of-office reply + * with dates puts a busy event in the user's calendar. Users choose by + * default (on); a lock pins it on or off (restriction vacationBlockCalendar, + * with its value). */ export function OutOfOfficeCalendarSection({ draft }: { draft: OrgPolicyDraft }) { - void draft; - return {null}; + const { value, own, everyone } = draft.restriction(KEY); + const choice = choiceOf(value); + const set = (next: Choice) => draft.setRestriction(KEY, next === 'users' ? undefined : { locked: true, value: next === 'always' }); + + return ( + + +
+ + draft.setRestriction(KEY, undefined)} + /> +
+
+
+

+ The event is busy, visible to everyone who sees the calendar, has no attendees and sends no invitations. If a user + moves or changes it, Bulwark leaves it alone. Ending the reply early ends or removes the event. +

+

+ Bulwark follows replies set through JMAP (Bulwark, other JMAP apps). A reply another app sets up as a Sieve script of + its own isn't visible to Bulwark, and the event is only updated when the user opens Bulwark.{' '} + + More about out-of-office + +

+
+
+ ); } From 3bd8e83d60024aaa1efe9c6c17649d5ecdcb088a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:41:34 +0100 Subject: [PATCH 07/23] feat: show company calendars set by the admin --- .../__tests__/policy-feed-urls-route.test.ts | 59 +++++ app/api/admin/policy/route.ts | 10 +- app/api/fetch-ical/route.ts | 189 +++++++++----- .../company-calendars-section.test.tsx | 84 ++++++ .../sections/company-calendars-section.tsx | 179 ++++++++++++- .../calendar-sidebar-company.test.tsx | 38 +++ components/calendar/calendar-app.tsx | 48 +++- .../calendar/calendar-sidebar-panel.tsx | 14 +- components/calendar/use-company-calendars.ts | 62 +++++ lib/__tests__/company-calendars.test.ts | 116 ++++++++ lib/__tests__/fetch-ical-feed-route.test.ts | 129 +++++++++ lib/__tests__/url-guard-private-host.test.ts | 66 +++++ lib/admin/__tests__/company-feeds.test.ts | 45 ++++ .../organisation-policy-enterprise.test.ts | 9 + lib/admin/company-feed-cache.ts | 38 +++ lib/admin/company-feeds.ts | 65 +++++ lib/admin/organisation-policy.ts | 15 +- lib/admin/reachable-hosts.ts | 6 +- lib/admin/server-policy-subject.ts | 93 +++++++ lib/company-calendars.ts | 247 ++++++++++++++++++ lib/jmap/types.ts | 3 + lib/security/url-guard.ts | 73 +++++- locales/ar/common.json | 3 + locales/ca/common.json | 3 + locales/cs/common.json | 3 + locales/da/common.json | 3 + locales/de/common.json | 3 + locales/en/common.json | 3 + locales/es/common.json | 3 + locales/et/common.json | 3 + locales/fa/common.json | 3 + locales/fr/common.json | 3 + locales/he/common.json | 3 + locales/hu/common.json | 3 + locales/it/common.json | 3 + locales/ja/common.json | 3 + locales/ko/common.json | 3 + locales/lv/common.json | 3 + locales/mn/common.json | 3 + locales/nb/common.json | 3 + locales/nl/common.json | 3 + locales/pl/common.json | 3 + locales/pt/common.json | 3 + locales/ro/common.json | 3 + locales/ru/common.json | 3 + locales/sk/common.json | 3 + locales/th/common.json | 3 + locales/tr/common.json | 3 + locales/uk/common.json | 3 + locales/vi/common.json | 3 + locales/zh-TW/common.json | 3 + locales/zh/common.json | 3 + stores/settings-store.ts | 4 + 53 files changed, 1592 insertions(+), 90 deletions(-) create mode 100644 app/api/admin/__tests__/policy-feed-urls-route.test.ts create mode 100644 components/admin/org/__tests__/company-calendars-section.test.tsx create mode 100644 components/calendar/__tests__/calendar-sidebar-company.test.tsx create mode 100644 components/calendar/use-company-calendars.ts create mode 100644 lib/__tests__/company-calendars.test.ts create mode 100644 lib/__tests__/fetch-ical-feed-route.test.ts create mode 100644 lib/__tests__/url-guard-private-host.test.ts create mode 100644 lib/admin/__tests__/company-feeds.test.ts create mode 100644 lib/admin/company-feed-cache.ts create mode 100644 lib/admin/company-feeds.ts create mode 100644 lib/admin/server-policy-subject.ts create mode 100644 lib/company-calendars.ts diff --git a/app/api/admin/__tests__/policy-feed-urls-route.test.ts b/app/api/admin/__tests__/policy-feed-urls-route.test.ts new file mode 100644 index 000000000..09716346c --- /dev/null +++ b/app/api/admin/__tests__/policy-feed-urls-route.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +// Company calendar addresses can hold a token: users get the feeds without +// them (the browser fetches a feed by id), the admin pages with them. + +const who = vi.hoisted(() => ({ + identity: null as null | { admin: true } | { admin: false; addresses: string[] }, +})); +vi.mock('@/lib/auth/instance-details', () => ({ + canSeeInstanceDetails: async () => true, + signedInIdentity: async () => who.identity, +})); +vi.mock('@/lib/auth/bearer-identity', () => ({ bearerIdentity: async () => null })); + +const FEED = { id: 'hol', name: 'Holidays', url: 'https://cal.example.org/h.ics?token=secret', color: '#2563eb', refreshMinutes: 60 }; +const TEAM_FEED = { id: 'team', name: 'Team', url: 'https://intranet.example.org/team.ics?key=k', color: '#16a34a', refreshMinutes: 120 }; +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: async () => {}, + getPolicy: () => ({ + restrictions: {}, features: {}, defaults: {}, themePolicy: {}, + forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], + organisation: { calendars: { feeds: [FEED], freeBusyGroup: '' } }, + scopes: [{ id: 'team', label: 'Team', match: { domains: ['example.org'] }, policy: { organisation: { calendars: { feeds: [TEAM_FEED] } } } }], + }), + getPolicyWithVersion() { return { policy: this.getPolicy(), version: 'v1' }; }, + }, +})); +vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } })); +vi.mock('@/lib/admin/session', () => ({ requireAdminAuth: vi.fn(), getClientIP: () => '0.0.0.0' })); +vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn() })); + +import { GET } from '@/app/api/admin/policy/route'; + +const get = async () => (await GET(new NextRequest('https://mail.example/api/admin/policy'))).json(); + +beforeEach(() => { + who.identity = null; +}); + +describe('GET /api/admin/policy company calendar addresses', () => { + it('gives a signed-in user every feed without its address, in Everyone and in the scopes', async () => { + who.identity = { admin: false, addresses: ['alice@example.org'] }; + const body = await get(); + expect(body.organisation.calendars.feeds).toEqual([{ ...FEED, url: '', urlHeld: true }]); + expect(body.scopes[0].policy.organisation.calendars.feeds).toEqual([{ ...TEAM_FEED, url: '', urlHeld: true }]); + expect(JSON.stringify(body)).not.toContain('token=secret'); + expect(JSON.stringify(body)).not.toContain('key=k'); + }); + + it('gives the admin pages the addresses', async () => { + who.identity = { admin: true }; + const body = await get(); + expect(body.organisation.calendars.feeds[0].url).toBe(FEED.url); + expect(body.scopes[0].policy.organisation.calendars.feeds[0].url).toBe(TEAM_FEED.url); + }); +}); diff --git a/app/api/admin/policy/route.ts b/app/api/admin/policy/route.ts index 11c972e5e..9f796f9ad 100644 --- a/app/api/admin/policy/route.ts +++ b/app/api/admin/policy/route.ts @@ -9,6 +9,7 @@ import { bearerIdentity } from '@/lib/auth/bearer-identity'; import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable'; import { scopesVisibleTo } from '@/lib/admin/policy-scopes'; import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; +import { stripFeedUrls } from '@/lib/admin/company-feeds'; /** The policy version as an HTTP entity tag. */ function policyETag(version: string): string { @@ -83,8 +84,13 @@ export async function GET(request: NextRequest) { } } -/** The policy for a signed-in user who is not the admin, with the addresses they sign in with. */ -function userPolicyResponse(policy: SettingsPolicy, addresses: string[]): NextResponse { +/** + * The policy for a signed-in user who is not the admin, with the addresses + * they sign in with. Company calendar addresses stay here: the browser + * fetches a feed by id through /api/fetch-ical (lib/admin/company-feeds.ts). + */ +function userPolicyResponse(raw: SettingsPolicy, addresses: string[]): NextResponse { + const policy = stripFeedUrls(raw); const scopes = scopesVisibleTo(policy.scopes ?? [], addresses); // Which domains the scopes were picked for, so a browser that signs in // to an account of another domain knows to fetch again. diff --git a/app/api/fetch-ical/route.ts b/app/api/fetch-ical/route.ts index c90b2cec8..3058e129a 100644 --- a/app/api/fetch-ical/route.ts +++ b/app/api/fetch-ical/route.ts @@ -1,8 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; -import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { getStalwartCredentials, type StalwartCredentials } from '@/lib/stalwart/credentials'; import { DisallowedUrlError, fetchPublicUrl, type PublicFetchResponse } from '@/lib/security/url-guard'; import { readBodyCapped } from '@/lib/security/capped-body'; +import { configManager } from '@/lib/admin/config-manager'; +import { feedForSubject, feedHostname } from '@/lib/admin/company-feeds'; +import { companyFeedCache } from '@/lib/admin/company-feed-cache'; +import { serverPolicySubject } from '@/lib/admin/server-policy-subject'; const DEFAULT_MAX_BYTES = 25 * 1024 * 1024; // 25MB // Base budget for a default-sized feed; scaled up with the configured cap so a @@ -48,43 +52,25 @@ function extractBasicAuth(rawUrl: string): { cleanUrl: string; authHeader: strin return { cleanUrl: parsed.toString(), authHeader }; } +type FetchOutcome = + | { kind: 'ok'; buffer: ArrayBuffer; etag: string | null; lastModified: string | null } + | { kind: 'not-modified' } + | { kind: 'error'; response: NextResponse }; + /** - * POST /api/fetch-ical - * - * Server-side proxy for iCalendar subscription URLs (the browser cannot fetch - * them directly because of CORS). Only logged-in users may use it, and every - * hop - including redirect targets - goes through `fetchPublicUrl`, which - * validates the resolved address inside the socket lookup so a rebinding DNS - * server cannot steer the connection at an internal host - * (GHSA-24w9-8r42-8jwm). + * Fetch one feed. Every hop, redirect targets included, goes through + * `fetchPublicUrl`, which validates the resolved address inside the socket + * lookup so a rebinding DNS server cannot steer the connection at an + * internal host (GHSA-24w9-8r42-8jwm). `allowPrivateHost` lifts that for the + * one hostname of a company calendar the admin entered (an intranet host). */ -export async function POST(request: NextRequest) { - // CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's - // session cookie, which SameSite=Lax still sends from a same-site page. - const crossOrigin = rejectCrossOriginRequest(request); - if (crossOrigin) return crossOrigin; - const creds = await getStalwartCredentials(request); - if (!creds) { - return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); - } - - let body: { url?: string }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); - } - - const { url } = body; - - if (!url || typeof url !== 'string') { - return NextResponse.json({ error: 'URL is required' }, { status: 400 }); - } - +async function fetchIcal( + url: string, + options: { allowPrivateHost?: string; etag?: string | null; lastModified?: string | null } = {}, +): Promise { + const fail = (error: string, status: number): FetchOutcome => ({ kind: 'error', response: NextResponse.json({ error }, { status }) }); const extracted = extractBasicAuth(url); - if (!extracted) { - return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 }); - } + if (!extracted) return fail('Invalid or disallowed URL', 400); const { cleanUrl, authHeader } = extracted; @@ -106,26 +92,28 @@ export async function POST(request: NextRequest) { if (authHeader && new URL(currentUrl).origin === originalOrigin) { headers['Authorization'] = authHeader; } + if (options.etag) headers['If-None-Match'] = options.etag; + if (options.lastModified) headers['If-Modified-Since'] = options.lastModified; try { - response = await fetchPublicUrl(currentUrl, { - signal: controller.signal, - headers, - }); + const init = { signal: controller.signal, headers }; + response = options.allowPrivateHost + ? await fetchPublicUrl(currentUrl, init, { allowPrivateHost: options.allowPrivateHost }) + : await fetchPublicUrl(currentUrl, init); } catch (error) { if (error instanceof DisallowedUrlError) { - return NextResponse.json( - { error: i === 0 ? 'Invalid or disallowed URL' : 'Redirect to disallowed URL' }, - { status: 400 }, - ); + return fail(i === 0 ? 'Invalid or disallowed URL' : 'Redirect to disallowed URL', 400); } throw error; } + if (response.status === 304 && (options.etag || options.lastModified)) { + return { kind: 'not-modified' }; + } if (response.status >= 300 && response.status < 400) { const location = response.headers.get('location'); if (!location) { - return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 }); + return fail('Redirect without Location header', 502); } currentUrl = new URL(location, currentUrl).toString(); continue; @@ -134,15 +122,12 @@ export async function POST(request: NextRequest) { } if (!response || !response.ok) { - return NextResponse.json( - { error: `Remote server returned ${response?.status ?? 'unknown'}` }, - { status: 502 } - ); + return fail(`Remote server returned ${response?.status ?? 'unknown'}`, 502); } const contentLength = response.headers.get('content-length'); if (contentLength && parseInt(contentLength) > maxResponseSize) { - return tooLarge(maxResponseSize); + return { kind: 'error', response: tooLarge(maxResponseSize) }; } // Read up to the cap and no further: a chunked body with no @@ -151,22 +136,106 @@ export async function POST(request: NextRequest) { // memory with it. const buffer = await readBodyCapped(response, maxResponseSize, controller); if (!buffer) { - return tooLarge(maxResponseSize); + return { kind: 'error', response: tooLarge(maxResponseSize) }; } - - return new NextResponse(buffer, { - status: 200, - headers: { - 'Content-Type': 'text/calendar', - 'Content-Length': buffer.byteLength.toString(), - }, - }); + return { kind: 'ok', buffer, etag: response.headers.get('etag'), lastModified: response.headers.get('last-modified') }; } catch (error: unknown) { if (error instanceof Error && error.name === 'AbortError') { - return NextResponse.json({ error: 'Request timed out' }, { status: 504 }); + return fail('Request timed out', 504); } - return NextResponse.json({ error: 'Failed to fetch calendar' }, { status: 502 }); + return fail('Failed to fetch calendar', 502); } finally { clearTimeout(timeout); } } + +function icalResponse(buffer: ArrayBuffer): NextResponse { + return new NextResponse(buffer, { + status: 200, + headers: { + 'Content-Type': 'text/calendar', + 'Content-Length': buffer.byteLength.toString(), + }, + }); +} + +/** + * A company calendar by id (organisation.calendars.feeds). The address comes + * from the caller's own scoped policy, with their groups read from their + * JMAP session here, so the browser never sees it and can't ask for the + * feed of a scope it isn't in. A feed is kept per address for its refresh + * interval, so many users cost one fetch, and asked again with + * If-None-Match / If-Modified-Since. A feed that can't be fetched keeps + * answering with what it last had. + */ +async function fetchCompanyFeed(creds: StalwartCredentials, feedId: string): Promise { + await configManager.ensureLoaded(); + const subject = await serverPolicySubject(creds); + const feed = feedForSubject(configManager.getPolicy(), subject, feedId); + if (!feed) { + return NextResponse.json({ error: 'Unknown calendar' }, { status: 404 }); + } + const cached = companyFeedCache.get(feed.url); + if (cached && Date.now() - cached.fetchedAt < feed.refreshMinutes * 60_000) { + return icalResponse(cached.body); + } + const outcome = await fetchIcal(feed.url, { + allowPrivateHost: feedHostname(feed) ?? undefined, + etag: cached?.etag, + lastModified: cached?.lastModified, + }); + if (outcome.kind === 'ok') { + companyFeedCache.set(feed.url, { body: outcome.buffer, etag: outcome.etag, lastModified: outcome.lastModified, fetchedAt: Date.now() }); + return icalResponse(outcome.buffer); + } + if (cached) { + if (outcome.kind === 'not-modified') companyFeedCache.set(feed.url, { ...cached, fetchedAt: Date.now() }); + return icalResponse(cached.body); + } + return outcome.kind === 'error' + ? outcome.response + : NextResponse.json({ error: 'Failed to fetch calendar' }, { status: 502 }); +} + +/** + * POST /api/fetch-ical + * + * Server-side proxy for iCalendar subscription URLs (the browser cannot fetch + * them directly because of CORS), `{ url }`, and for company calendars by + * id, `{ feedId }`. Only logged-in users may use it. + */ +export async function POST(request: NextRequest) { + // CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's + // session cookie, which SameSite=Lax still sends from a same-site page. + const crossOrigin = rejectCrossOriginRequest(request); + if (crossOrigin) return crossOrigin; + const creds = await getStalwartCredentials(request); + if (!creds) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + let body: { url?: string; feedId?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + + if (body && body.feedId !== undefined) { + if (typeof body.feedId !== 'string' || !body.feedId) { + return NextResponse.json({ error: 'Unknown calendar' }, { status: 404 }); + } + return fetchCompanyFeed(creds, body.feedId); + } + + const { url } = body ?? {}; + + if (!url || typeof url !== 'string') { + return NextResponse.json({ error: 'URL is required' }, { status: 400 }); + } + + const outcome = await fetchIcal(url); + if (outcome.kind === 'ok') return icalResponse(outcome.buffer); + if (outcome.kind === 'error') return outcome.response; + return NextResponse.json({ error: 'Failed to fetch calendar' }, { status: 502 }); +} diff --git a/components/admin/org/__tests__/company-calendars-section.test.tsx b/components/admin/org/__tests__/company-calendars-section.test.tsx new file mode 100644 index 000000000..203937a13 --- /dev/null +++ b/components/admin/org/__tests__/company-calendars-section.test.tsx @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +// Organisation > Calendars > Company calendars: the feeds list +// (organisation.calendars.feeds), per scope, with the notes on Lite and the +// upload limit. + +let stored: Record = {}; +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.method === 'PUT') { + stored = JSON.parse(String(init.body)); + return new Response('{"ok":true}'); + } + return new Response(JSON.stringify(stored)); + }), + withBasePath: (p: string) => p, + getPathPrefix: () => '', +})); + +import { CompanyCalendarsSection } from '../sections/company-calendars-section'; +import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft'; +import { sanitizeOrganisationPolicy } from '@/lib/admin/organisation-policy'; + +const POLICY = { + restrictions: {}, features: {}, defaults: {}, themePolicy: {}, + forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], + organisation: { calendars: { feeds: [{ id: 'hol', name: 'Holidays', url: 'https://cal.example.org/h.ics', color: '#16a34a', refreshMinutes: 60 }], freeBusyGroup: '' } }, + scopes: [{ id: 'team', label: 'Team', match: { groups: ['ent-team@example.org'] }, policy: {} }], +}; + +let latest: ReturnType | null = null; +function Harness() { + const draft = useOrgPolicyDraft('test'); + latest = draft; + if (draft.loading) return null; + return ; +} + +beforeEach(() => { + stored = JSON.parse(JSON.stringify(POLICY)); + latest = null; + useOrgScopeStore.setState({ scopeId: EVERYONE }); +}); + +describe('Company calendars section', () => { + it('adds a feed with its name, address and colour', async () => { + render(); + await waitFor(() => expect(screen.getAllByTestId('org-feed-item')).toHaveLength(1)); + fireEvent.click(screen.getByTestId('org-feed-add')); + const names = screen.getAllByTestId('org-feed-name'); + fireEvent.change(names[1], { target: { value: 'Office closures' } }); + expect(screen.getByText(/Enter the address of the ICS feed/)).toBeInTheDocument(); + fireEvent.change(screen.getAllByTestId('org-feed-url')[1], { target: { value: 'webcal://intranet.example.org/closed.ics' } }); + await act(async () => { await latest!.save(); }); + const saved = sanitizeOrganisationPolicy(stored.organisation).calendars.feeds; + expect(saved.map((f) => [f.name, f.url, f.refreshMinutes])).toEqual([ + ['Holidays', 'https://cal.example.org/h.ics', 60], + ['Office closures', 'https://intranet.example.org/closed.ics', 60], + ]); + }); + + it('adds a scope\'s feeds to that scope, showing Everyone\'s as inherited', async () => { + render(); + await waitFor(() => expect(screen.getAllByTestId('org-feed-item')).toHaveLength(1)); + act(() => latest!.setScopeId('team')); + expect(screen.getByTestId('org-feed-inherited')).toHaveTextContent('Holidays'); + fireEvent.click(screen.getByTestId('org-feed-add')); + fireEvent.change(screen.getByTestId('org-feed-name'), { target: { value: 'Team' } }); + fireEvent.change(screen.getByTestId('org-feed-url'), { target: { value: 'https://cal.example.org/team.ics' } }); + const scopeFeeds = (latest!.policy?.scopes?.[0].policy.organisation as { calendars: { feeds: { name: string }[] } }).calendars.feeds; + expect(scopeFeeds.map((f) => f.name)).toEqual(['Team']); + expect(latest!.policy?.organisation?.calendars.feeds.map((f) => f.name)).toEqual(['Holidays']); + }); + + it('says what Lite needs and what a refresh costs', async () => { + render(); + const notes = await screen.findByTestId('org-feed-notes'); + expect(notes).toHaveTextContent('Access-Control-Allow-Origin'); + expect(notes).toHaveTextContent('policy.json is a public file'); + expect(notes).toHaveTextContent('1000 files or 50 MB an hour'); + expect(notes).toHaveTextContent('intranet'); + }); +}); diff --git a/components/admin/org/sections/company-calendars-section.tsx b/components/admin/org/sections/company-calendars-section.tsx index 82602702a..291c5452d 100644 --- a/components/admin/org/sections/company-calendars-section.tsx +++ b/components/admin/org/sections/company-calendars-section.tsx @@ -1,13 +1,184 @@ 'use client'; +import { useId } from 'react'; +import { Plus, Trash2 } from '@/components/icons'; import { SettingsSection } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { MIN_FEED_REFRESH_MINUTES, type CompanyFeed } from '@/lib/admin/organisation-policy'; +import { generateUUID } from '@/lib/utils'; import type { OrgPolicyDraft } from '../use-org-policy-draft'; +const inputClass = + 'block w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ' + + 'placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-hidden focus-visible:border-ring'; +const labelClass = 'mb-1 block text-xs font-medium text-foreground'; +const DOCS_URL = 'https://bulwarkmail.org/docs/guides/organisation/company-calendars'; +/** Same cap as the sanitiser (organisation-policy.ts). */ +const MAX_FEEDS = 50; +const MAX_REFRESH = 7 * 24 * 60; + +/** The address as it will be saved, or null when it can't be one. */ +function feedUrlProblem(value: string): string | null { + const v = value.trim().replace(/^webcals?:\/\//i, 'https://'); + if (!v) return 'Enter the address of the ICS feed.'; + try { + const url = new URL(v); + if (url.protocol !== 'https:' && url.protocol !== 'http:') return 'Use an https:// (or webcal://) address.'; + return null; + } catch { + return 'This isn\'t a web address.'; + } +} + +function FeedEditor({ feed, onChange, onRemove }: { feed: CompanyFeed; onChange: (feed: CompanyFeed) => void; onRemove: () => void }) { + const id = useId(); + const set = (patch: Partial) => onChange({ ...feed, ...patch }); + const urlProblem = feedUrlProblem(feed.url); + return ( +
  • +
    +
    + + set({ name: e.target.value })} /> +
    +
    + + set({ color: e.target.value })} /> +
    +
    +
    + + set({ url: e.target.value })} + /> + {urlProblem &&

    {urlProblem} A feed without one is not saved.

    } +
    +
    +
    + + { + const n = Math.round(Number(e.target.value)); + if (Number.isFinite(n)) set({ refreshMinutes: Math.min(MAX_REFRESH, Math.max(MIN_FEED_REFRESH_MINUTES, n)) }); + }} + /> +
    + +
    + {!feed.name.trim() &&

    A calendar without a name is not saved. Give it one.

    } +
  • + ); +} + /** - * Placeholder from the enterprise-pack foundation, replaced by package B: - * ICS feeds every user in scope sees read-only. + * Organisation > Calendars > Company calendars + * (policy.json `organisation.calendars.feeds`): ICS feeds every user in the + * scope sees read-only under "Organisation". A scope's feeds add to + * Everyone's by id. */ export function CompanyCalendarsSection({ draft }: { draft: OrgPolicyDraft }) { - void draft; - return {null}; + const feeds = draft.orgValue('calendars', 'feeds'); + const inScope = !!draft.scope; + const own: CompanyFeed[] = inScope ? (feeds.own ? feeds.value : []) : feeds.value; + const everyone = feeds.everyone; + const setOwn = (next: CompanyFeed[]) => { + if (inScope && next.length === 0) draft.clearOrgValue('calendars', 'feeds'); + else draft.setOrgValue('calendars', 'feeds', next); + }; + const add = () => setOwn([...own, { id: generateUUID(), name: '', url: '', color: '#2563eb', refreshMinutes: MIN_FEED_REFRESH_MINUTES }]); + const total = new Set([...everyone.map((f) => f.id), ...own.map((f) => f.id)]).size; + + return ( + +
    + {inScope && ( +
    +

    From Everyone

    + {everyone.length === 0 ? ( +

    Everyone has no company calendars.

    + ) : ( +
      + {everyone.map((feed) => ( +
    • +
    • + ))} +
    + )} +

    For this scope

    +

    Users in this scope get Everyone's calendars and these.

    +
    + )} + + {own.length === 0 ? ( +

    + {inScope ? 'Nothing added for this scope.' : 'No company calendars yet.'} +

    + ) : ( +
      + {own.map((feed, index) => ( + setOwn(own.map((f, i) => (i === index ? next : f)))} + onRemove={() => setOwn(own.filter((_, i) => i !== index))} + /> + ))} +
    + )} + +
    + + {total >= MAX_FEEDS && At most {MAX_FEEDS} company calendars.} +
    + +
    +

    + Bulwark's server fetches the feeds, at most once per refresh interval for everyone, and keeps the addresses to + itself: users never see them. A feed may be on your intranet (a private address): only the hosts entered here are + let through. They stay reachable with "No third-party traffic" on and are listed on Organisation > Security. +

    +

    + Each refresh that changed the feed uploads it once to Stalwart for every user who has Calendar open, which counts + against Stalwart's upload limit (1000 files or 50 MB an hour per user). Keep feeds small and the interval long. +

    +

    + Bulwark Lite has no server: the browser fetches the feed itself, so the feed host must send CORS headers + (Access-Control-Allow-Origin), a Lite build without third-party traffic must list the host in its connect-src, and + policy.json is a public file there, so don't put tokens in the addresses.{' '} + + How to set it up + +

    +
    +
    +
    + ); } diff --git a/components/calendar/__tests__/calendar-sidebar-company.test.tsx b/components/calendar/__tests__/calendar-sidebar-company.test.tsx new file mode 100644 index 000000000..e1d2e9170 --- /dev/null +++ b/components/calendar/__tests__/calendar-sidebar-company.test.tsx @@ -0,0 +1,38 @@ +import { render, screen, fireEvent, within } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { CalendarSidebarPanel } from '../calendar-sidebar-panel'; +import { createCompanyCalendar } from '@/lib/company-calendars'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Calendar } from '@/lib/jmap/types'; + +// Company calendars sit under "Organisation", apart from the user's own, +// and can only be shown or hidden: no menu to recolour, share or delete. + +const own = { + id: 'b', name: 'Mine', color: '#2563eb', sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: true, + includeInAvailability: 'all', timeZone: null, shareWith: null, + myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayDelete: true, mayShare: true }, +} as unknown as Calendar; +const company = createCompanyCalendar({ id: 'hol', name: 'Holidays', color: '#16a34a' }); + +describe('CalendarSidebarPanel company calendars', () => { + it('lists them under Organisation, toggles them and offers no menu', () => { + const onToggle = vi.fn(); + render( + 'me' } as unknown as IJMAPClient} + />, + ); + const section = screen.getByTestId('company-calendars-section'); + expect(section).toHaveTextContent('section'); + const item = within(section).getByText('Holidays'); + fireEvent.click(item); + expect(onToggle).toHaveBeenCalledWith(company.id); + expect(within(section).queryByRole('button', { name: 'calendar_options' })).toBeNull(); + // The user's own calendar is not in the section. + expect(within(section).queryByText('Mine')).toBeNull(); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index a5759c0b3..f69e87802 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -81,6 +81,9 @@ 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"; +import { feedIdOf, isCompanyCalendarId } from "@/lib/company-calendars"; +import { expandRecurringEvents } from "@/lib/recurrence-expansion"; +import { useCompanyCalendars } from "./use-company-calendars"; import { sharedCalendarColorKey, autoAssignSharedCalendarColors } from "@/lib/shared-calendar-colors"; import { debug } from "@/lib/debug"; import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session"; @@ -1692,10 +1695,36 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = } }, [calendars, sharedCalendarColors, setSharedCalendarColor]); + // Company calendars the admin set (lib/company-calendars.ts): read-only, + // shown under "Organisation"; hiding one is a synced setting of its own. + const company = useCompanyCalendars(client); + const hiddenCompanyCalendars = useSettingsStore((s) => s.hiddenCompanyCalendars); + const shownCalendarIds = useMemo(() => { + if (company.calendars.length === 0) return selectedCalendarIds; + const hidden = new Set(company.hiddenIds); + return [...selectedCalendarIds, ...company.calendars.map((c) => c.id).filter((id) => !hidden.has(id))]; + }, [selectedCalendarIds, company.calendars, company.hiddenIds]); + const toggleShownCalendar = useCallback((calendarId: string) => { + if (!isCompanyCalendarId(calendarId)) { + toggleCalendarVisibility(calendarId); + return; + } + const feedId = feedIdOf(calendarId); + const hidden = Array.isArray(hiddenCompanyCalendars) ? hiddenCompanyCalendars : []; + updateSetting('hiddenCompanyCalendars', hidden.includes(feedId) ? hidden.filter((id) => id !== feedId) : [...hidden, feedId]); + }, [toggleCalendarVisibility, hiddenCompanyCalendars, updateSetting]); + const companyEvents = useMemo(() => { + if (!dateRange || company.events.length === 0) return []; + const hidden = new Set(company.hiddenIds); + return expandRecurringEvents(company.events.filter((e) => !Object.keys(e.calendarIds).some((id) => hidden.has(id))), dateRange.start, dateRange.end); + }, [company.events, company.hiddenIds, dateRange]); + const allCalendars = useMemo(() => { - if (!showBirthdayCalendar) return displayCalendars; - return [...displayCalendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)]; - }, [displayCalendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]); + const withBirthdays = showBirthdayCalendar + ? [...displayCalendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)] + : displayCalendars; + return company.calendars.length > 0 ? [...withBirthdays, ...company.calendars] : withBirthdays; + }, [displayCalendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor, company.calendars]); // What the user may change, by the rights of each event's calendar // (birthdays, subscriptions and read-only shares can't be changed). @@ -1719,11 +1748,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const calIds = Object.keys(e.calendarIds); return calIds.some((id) => selectedCalendarIds.includes(id)); }); + const withCompany = companyEvents.length > 0 ? [...filtered, ...companyEvents] : filtered; if (showBirthdayCalendar && selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) { - return [...filtered, ...birthdayEvents]; + return [...withCompany, ...birthdayEvents]; } - return filtered; - }, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents]); + return withCompany; + }, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents, companyEvents]); // The tasks the task list shows; plugin buttons in its toolbar act on them. const listedTaskIds = useMemo( @@ -2000,7 +2030,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = rangeEnd={scrollWindow?.end ?? selectedDate} events={visibleEvents} calendars={allCalendars} - shownCalendars={allCalendars.filter((c) => selectedCalendarIds.includes(c.id))} + shownCalendars={allCalendars.filter((c) => shownCalendarIds.includes(c.id))} gridTasks={calendarTasks} tasks={taskStore.tasks} taskFilter={taskStore.filter} @@ -2147,8 +2177,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = { if (calendarId === BIRTHDAY_CALENDAR_ID) { updateSetting('birthdayCalendarColor', color); diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 128929162..8b14360de 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -96,6 +96,7 @@ export function CalendarSidebarPanel({ const paint = useCvdColor(); const tSub = useTranslations("calendar.subscription"); const tMgmt = useTranslations("calendar.management"); + const tCompany = useTranslations("company_calendars"); const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar); const allSubs = useCalendarStore((s) => s.icalSubscriptions); const currentAccountId = client?.getAccountId(); @@ -152,7 +153,9 @@ export function CalendarSidebarPanel({ // view but hidden from the event calendar list here. const eventCalendars = useMemo(() => calendars.filter(c => !c.isTasksOnly), [calendars]); - const personalCalendars = useMemo(() => eventCalendars.filter(c => !c.isShared), [eventCalendars]); + const personalCalendars = useMemo(() => eventCalendars.filter(c => !c.isShared && !c.isCompany), [eventCalendars]); + // Company calendars the admin set: under "Organisation", read-only. + const companyCalendars = useMemo(() => eventCalendars.filter(c => c.isCompany), [eventCalendars]); // Names for the "shared with" hint. Principal ids belong to one server, so // only calendars of the account `client` talks to are resolved. @@ -280,7 +283,8 @@ export function CalendarSidebarPanel({ const renderCalendarItem = (cal: Calendar) => { const isVisible = selectedCalendarIds.includes(cal.id); const color = paint(cal.color || "#3b82f6"); - const hasMenu = isSubscriptionCalendar(cal.id) ? !!client : true; + // A company calendar can only be shown or hidden: no menu. + const hasMenu = cal.isCompany ? false : isSubscriptionCalendar(cal.id) ? !!client : true; return (
    @@ -607,6 +611,12 @@ export function CalendarSidebarPanel({ onCreateCalendar, )} + {companyCalendars.length > 0 && ( +
    + {renderSection('__organisation__', tCompany('section'), companyCalendars)} +
    + )} + {sharedAccountGroups.length > 0 && (
    {renderSectionHeader('__others__', t('other_calendars'))} diff --git a/components/calendar/use-company-calendars.ts b/components/calendar/use-company-calendars.ts new file mode 100644 index 000000000..c1d29ca0b --- /dev/null +++ b/components/calendar/use-company-calendars.ts @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { usePolicyStore } from "@/stores/policy-store"; +import { useSettingsStore } from "@/stores/settings-store"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import type { Calendar, CalendarEvent } from "@/lib/jmap/types"; +import type { CompanyFeed } from "@/lib/admin/organisation-policy"; +import { companyCalendarId, createCompanyCalendar, loadFeedEvents, toCompanyEvents } from "@/lib/company-calendars"; + +// Feeds decide for themselves when they are due (refreshMinutes, at least +// an hour); this only looks whether one is. +const CHECK_EVERY_MS = 15 * 60 * 1000; + +const NO_FEEDS: CompanyFeed[] = []; + +/** + * The company calendars of the active account's policy, and their events + * (series not expanded). Feeds the policy no longer lists go away at once; + * feeds that arrive with a late policy load then. + */ +export function useCompanyCalendars(client: IJMAPClient | null): { + calendars: Calendar[]; + events: CalendarEvent[]; + /** Calendar ids of the feeds the user hid. */ + hiddenIds: string[]; +} { + const feeds = usePolicyStore((s) => s.policy.organisation?.calendars?.feeds ?? NO_FEEDS); + const hidden = useSettingsStore((s) => s.hiddenCompanyCalendars); + const [parsed, setParsed] = useState>({}); + const accountKey = client?.getAccountId?.() ?? ""; + const feedsKey = JSON.stringify(feeds.map((f) => [f.id, f.url, f.refreshMinutes])); + + useEffect(() => { + if (!client || feeds.length === 0) return; + let cancelled = false; + const load = () => { + for (const feed of feeds) { + void loadFeedEvents(client, accountKey, feed).then((events) => { + if (cancelled) return; + setParsed((current) => ({ ...current, [feed.id]: toCompanyEvents(feed.id, events) })); + }); + } + }; + load(); + const timer = setInterval(load, CHECK_EVERY_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + // feedsKey stands in for the feeds array identity. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [client, accountKey, feedsKey]); + + return useMemo(() => { + const calendars = feeds.map(createCompanyCalendar); + const events = feeds.flatMap((feed) => parsed[feed.id] ?? []); + const hiddenSet = new Set(Array.isArray(hidden) ? hidden : []); + const hiddenIds = feeds.filter((f) => hiddenSet.has(f.id)).map((f) => companyCalendarId(f.id)); + return { calendars, events, hiddenIds }; + }, [feeds, parsed, hidden]); +} diff --git a/lib/__tests__/company-calendars.test.ts b/lib/__tests__/company-calendars.test.ts new file mode 100644 index 000000000..5dae25705 --- /dev/null +++ b/lib/__tests__/company-calendars.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const lite = vi.hoisted(() => ({ value: false })); +vi.mock('@/lib/lite', async (original) => ({ + ...(await original()), + get IS_LITE() { return lite.value; }, +})); +const apiFetch = vi.fn(); +vi.mock('@/lib/browser-navigation', async (original) => ({ + ...(await original()), + apiFetch: (...args: unknown[]) => apiFetch(...args), +})); + +import { + companyCalendarId, createCompanyCalendar, isCompanyCalendarId, loadFeedEvents, resetCompanyCalendarMemory, toCompanyEvents, +} from '../company-calendars'; +import { purgeSignedOutData } from '../sign-out-cleanup'; +import type { CompanyFeed } from '@/lib/admin/organisation-policy'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +const FEED: CompanyFeed = { id: 'hol', name: 'Holidays', url: '', urlHeld: true, color: '#16a34a', refreshMinutes: 60 }; +const ICS = 'BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:1\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n'; +const PARSED = [{ uid: '1', title: 'Closed', start: '2026-12-24T00:00:00', duration: 'P1D', showWithoutTime: true }]; + +function fakeClient() { + return { + getCalendarsAccountId: () => 'alice', + uploadBlob: vi.fn(async () => ({ blobId: 'blob1', size: 1, type: 'text/calendar' })), + parseCalendarEvents: vi.fn(async () => PARSED as Partial[]), + }; +} + +const HOUR = 3600_000; +const T0 = Date.parse('2026-11-02T08:00:00Z'); + +beforeEach(() => { + resetCompanyCalendarMemory(); + apiFetch.mockReset(); + apiFetch.mockImplementation(async () => new Response(ICS)); + lite.value = false; + // No IndexedDB in jsdom: memory only. +}); + +describe('loadFeedEvents', () => { + it('fetches the feed by id through the server, uploads it on the XHR path and parses it', async () => { + const client = fakeClient(); + expect(await loadFeedEvents(client, 'alice', FEED, T0)).toEqual(PARSED); + expect(apiFetch).toHaveBeenCalledWith('/api/fetch-ical', expect.objectContaining({ method: 'POST', body: JSON.stringify({ feedId: 'hol' }) })); + const [, options] = client.uploadBlob.mock.calls[0] as unknown as [File, { accountId: string; onProgress?: unknown }]; + expect(options.accountId).toBe('alice'); + expect(typeof options.onProgress).toBe('function'); + expect(client.parseCalendarEvents).toHaveBeenCalledWith('alice', 'blob1'); + }); + + it('asks again only after the refresh interval, and uploads only a feed that changed', async () => { + const client = fakeClient(); + await loadFeedEvents(client, 'alice', FEED, T0); + await loadFeedEvents(client, 'alice', FEED, T0 + 30 * 60_000); + expect(apiFetch).toHaveBeenCalledTimes(1); + + await loadFeedEvents(client, 'alice', FEED, T0 + HOUR + 1); + expect(apiFetch).toHaveBeenCalledTimes(2); + expect(client.uploadBlob).toHaveBeenCalledTimes(1); + + apiFetch.mockImplementation(async () => new Response(ICS.replace('UID:1', 'UID:2'))); + await loadFeedEvents(client, 'alice', FEED, T0 + 2 * HOUR + 2); + expect(client.uploadBlob).toHaveBeenCalledTimes(2); + }); + + it('keeps the last events when the feed can\'t be fetched', async () => { + const client = fakeClient(); + await loadFeedEvents(client, 'alice', FEED, T0); + apiFetch.mockImplementation(async () => new Response('down', { status: 502 })); + expect(await loadFeedEvents(client, 'alice', FEED, T0 + 2 * HOUR)).toEqual(PARSED); + expect(await loadFeedEvents(fakeClient(), 'bob', FEED, T0)).toEqual([]); + }); + + it('fetches the address itself in Lite, where there is no server', async () => { + lite.value = true; + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(ICS)); + try { + await loadFeedEvents(fakeClient(), 'alice', { ...FEED, url: 'https://cal.example.org/h.ics', urlHeld: undefined }, T0); + expect(fetchMock).toHaveBeenCalledWith('https://cal.example.org/h.ics', expect.objectContaining({ credentials: 'omit' })); + expect(apiFetch).not.toHaveBeenCalled(); + } finally { + fetchMock.mockRestore(); + } + }); + + it('forgets every feed on a full sign-out', async () => { + const client = fakeClient(); + await loadFeedEvents(client, 'alice', FEED, T0); + purgeSignedOutData(); + await loadFeedEvents(client, 'alice', FEED, T0 + 60_000); + expect(apiFetch).toHaveBeenCalledTimes(2); + }); +}); + +describe('the company calendar and its events', () => { + it('is read-only and out of free/busy', () => { + const calendar = createCompanyCalendar(FEED); + expect(calendar).toMatchObject({ id: '__company-calendar__:hol', name: 'Holidays', color: '#16a34a', isCompany: true, includeInAvailability: 'none' }); + expect(Object.values(calendar.myRights).filter(Boolean)).toEqual([true, true]); // read free/busy and items only + expect(isCompanyCalendarId(calendar.id)).toBe(true); + expect(isCompanyCalendarId('b')).toBe(false); + }); + + it('turns parsed events into events of the calendar, with nothing to answer', () => { + const events = toCompanyEvents('hol', [ + { ...PARSED[0], participants: { a: { email: 'x@example.org' } } as never, organizerCalendarAddress: 'mailto:x@example.org' }, + { uid: 'no-start', title: 'Broken' }, + ]); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ calendarIds: { [companyCalendarId('hol')]: true }, participants: null, organizerCalendarAddress: null, title: 'Closed' }); + }); +}); diff --git a/lib/__tests__/fetch-ical-feed-route.test.ts b/lib/__tests__/fetch-ical-feed-route.test.ts new file mode 100644 index 000000000..3de311eca --- /dev/null +++ b/lib/__tests__/fetch-ical-feed-route.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Company calendars by id (POST /api/fetch-ical { feedId }): the address +// comes from the caller's own scoped policy, their groups read from their +// JMAP session on the server, the admin's host may be on the intranet, and +// a feed is fetched once per refresh interval for everyone. + +vi.mock('next/server', () => { + class NextResponse { + status: number; + headers: Headers; + body: unknown; + constructor(body: unknown, init?: { status?: number; headers?: Record }) { + this.body = body; + this.status = init?.status ?? 200; + this.headers = new Headers(init?.headers); + } + static json(data: unknown, init?: { status?: number }) { + return Object.assign(new NextResponse(data, init), { json: async () => data }); + } + } + return { NextResponse, NextRequest: class {} }; +}); + +const creds = vi.hoisted(() => ({ value: null as null | Record })); +vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: async () => creds.value })); + +const guardedFetch = vi.fn(); +vi.mock('@/lib/security/url-guard', async (importOriginal) => ({ + ...(await importOriginal()), + fetchPublicUrl: (...args: unknown[]) => guardedFetch(...args), +})); + +// The caller's JMAP session: alice is in the group ent-team and has bob's +// mailbox shared with her (a person, not a group). +const session = vi.hoisted(() => ({ + accounts: {} as Record, + types: {} as Record, +})); +vi.mock('@/lib/stalwart/jmap-api', () => ({ + fetchJmapSession: async () => ({ + apiUrl: 'https://mail.example.org/jmap/', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'alice' }, + accounts: session.accounts, + }), + rebaseApiUrl: () => 'https://mail.example.org/jmap/', + postJmap: async () => new Response(JSON.stringify({ + methodResponses: [['Principal/get', { list: Object.entries(session.types).map(([id, type]) => ({ id, type })) }, '0']], + })), +})); + +const POLICY = { + restrictions: {}, features: {}, defaults: {}, themePolicy: {}, + organisation: { calendars: { feeds: [{ id: 'hol', name: 'Holidays', url: 'https://cal.example.org/h.ics?token=t', color: '#2563eb', refreshMinutes: 60 }] } }, + scopes: [ + { id: 'team', label: 'Team', match: { groups: ['ent-team@example.org'] }, policy: { organisation: { calendars: { feeds: [{ id: 'team', name: 'Team', url: 'http://cal.intranet.example/team.ics', refreshMinutes: 120 }] } } } }, + { id: 'bob', label: 'Bob', match: { groups: ['ent-bob@example.org'] }, policy: { organisation: { calendars: { feeds: [{ id: 'bobs', name: 'Bob', url: 'https://cal.example.org/b.ics' }] } } } }, + ], +}; +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { ensureLoaded: async () => {}, getPolicy: () => POLICY }, +})); + +import { POST } from '@/app/api/fetch-ical/route'; +import { companyFeedCache } from '@/lib/admin/company-feed-cache'; +import { clearServerPolicySubjects } from '@/lib/admin/server-policy-subject'; + +type RouteResponse = { status: number; body: unknown }; +const post = async (body: unknown) => (await POST({ method: 'POST', headers: new Headers(), json: async () => body } as never)) as unknown as RouteResponse; +const ics = (status = 200, headers: Record = {}) => new Response(status === 304 ? null : 'BEGIN:VCALENDAR\nEND:VCALENDAR\n', { status, headers: { 'content-type': 'text/calendar', ...headers } }); + +beforeEach(() => { + creds.value = { serverUrl: 'https://mail.example.org', authHeader: 'Basic alice', username: 'ent-alice@example.org', accountName: 'ent-alice@example.org', trusted: true }; + session.accounts = { + alice: { name: 'ent-alice@example.org', isPersonal: true }, + team: { name: 'ent-team@example.org', isPersonal: false }, + bob: { name: 'ent-bob@example.org', isPersonal: false }, + }; + session.types = { team: 'group', bob: 'individual' }; + guardedFetch.mockReset(); + companyFeedCache.clear(); + clearServerPolicySubjects(); +}); +afterEach(() => vi.useRealTimers()); + +describe('POST /api/fetch-ical { feedId }', () => { + it('fetches a feed of Everyone and one of a group the session shows the user is in, from the policy on the server', async () => { + guardedFetch.mockImplementation(async () => ics()); + expect((await post({ feedId: 'hol' })).status).toBe(200); + expect(guardedFetch.mock.calls[0][0]).toBe('https://cal.example.org/h.ics?token=t'); + expect((await post({ feedId: 'team' })).status).toBe(200); + // The admin's own intranet host may be private, and only it. + expect(guardedFetch.mock.calls[1][0]).toBe('http://cal.intranet.example/team.ics'); + expect(guardedFetch.mock.calls[1][2]).toEqual({ allowPrivateHost: 'cal.intranet.example' }); + }); + + it('refuses a feed of a scope the user is not in, without fetching anything', async () => { + session.accounts = { alice: { name: 'ent-alice@example.org', isPersonal: true } }; + expect((await post({ feedId: 'team' })).status).toBe(404); + // A person's shared mailbox is no group, whatever it is called. + expect((await post({ feedId: 'bobs' })).status).toBe(404); + expect((await post({ feedId: 'nope' })).status).toBe(404); + expect((await post({ feedId: 42 })).status).toBe(404); + expect(guardedFetch).not.toHaveBeenCalled(); + }); + + it('answers from what it fetched within the refresh interval, and asks again with the validators after it', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-11-02T08:00:00Z')); + guardedFetch.mockResolvedValueOnce(ics(200, { etag: '"v1"', 'last-modified': 'Mon, 02 Nov 2026 07:00:00 GMT' })); + await post({ feedId: 'hol' }); + await post({ feedId: 'hol' }); + expect(guardedFetch).toHaveBeenCalledTimes(1); + + vi.setSystemTime(new Date('2026-11-02T09:01:00Z')); + guardedFetch.mockResolvedValueOnce(ics(304)); + const res = await post({ feedId: 'hol' }); + expect(res.status).toBe(200); + expect(Buffer.from(res.body as ArrayBuffer).toString()).toContain('BEGIN:VCALENDAR'); + expect(guardedFetch).toHaveBeenCalledTimes(2); + expect(guardedFetch.mock.calls[1][1].headers).toMatchObject({ 'If-None-Match': '"v1"', 'If-Modified-Since': 'Mon, 02 Nov 2026 07:00:00 GMT' }); + }); + + it('needs a signed-in user', async () => { + creds.value = null; + expect((await post({ feedId: 'hol' })).status).toBe(401); + }); +}); diff --git a/lib/__tests__/url-guard-private-host.test.ts b/lib/__tests__/url-guard-private-host.test.ts new file mode 100644 index 000000000..2ba620485 --- /dev/null +++ b/lib/__tests__/url-guard-private-host.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment node +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +// A company calendar may live on the intranet: fetchPublicUrl lets exactly +// the host the admin entered resolve to a private address, and nothing else +// (a redirect to another private host, another name), with the address still +// pinned at connect time. + +const lookup = vi.fn(); +vi.mock('node:dns/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { ...actual, lookup: (...args: unknown[]) => lookup(...args) }, + lookup: (...args: unknown[]) => lookup(...args), + }; +}); + +describe('fetchPublicUrl with allowPrivateHost', () => { + let server: Server; + let port: number; + let hits = 0; + + beforeAll(async () => { + server = createServer((_req, res) => { + hits++; + res.writeHead(200, { 'content-type': 'text/calendar' }); + res.end('BEGIN:VCALENDAR\nEND:VCALENDAR\n'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + port = (server.address() as AddressInfo).port; + }); + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + beforeEach(() => { + lookup.mockReset(); + lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]); + hits = 0; + }); + + it('reaches the admin\'s intranet host', async () => { + const { fetchPublicUrl } = await import('@/lib/security/url-guard'); + const res = await fetchPublicUrl(`http://cal.intranet.example:${port}/team.ics`, {}, { allowPrivateHost: 'cal.intranet.example' }); + expect(res.status).toBe(200); + expect(hits).toBe(1); + }); + + it('allows .internal names only for that host', async () => { + const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard'); + const res = await fetchPublicUrl(`http://calendar.corp.internal:${port}/a.ics`, {}, { allowPrivateHost: 'calendar.corp.internal' }); + expect(res.status).toBe(200); + await expect(fetchPublicUrl(`http://other.corp.internal:${port}/a.ics`, {}, { allowPrivateHost: 'calendar.corp.internal' })) + .rejects.toBeInstanceOf(DisallowedUrlError); + }); + + it('keeps refusing every other host that resolves to a private address', async () => { + const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard'); + await expect(fetchPublicUrl(`http://rebind.example:${port}/cal.ics`, {}, { allowPrivateHost: 'cal.intranet.example' })) + .rejects.toBeInstanceOf(DisallowedUrlError); + await expect(fetchPublicUrl(`http://cal.intranet.example:${port}/team.ics`)).rejects.toBeInstanceOf(DisallowedUrlError); + expect(hits).toBe(0); + }); +}); diff --git a/lib/admin/__tests__/company-feeds.test.ts b/lib/admin/__tests__/company-feeds.test.ts new file mode 100644 index 000000000..77a579132 --- /dev/null +++ b/lib/admin/__tests__/company-feeds.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { allFeeds, feedForSubject, stripFeedUrls } from '../company-feeds'; +import { listReachableHosts } from '../reachable-hosts'; +import { sanitizeOrganisationPolicy } from '../organisation-policy'; +import type { SettingsPolicy } from '../types'; + +const policy = { + restrictions: {}, features: {}, defaults: {}, themePolicy: {}, + organisation: { calendars: { feeds: [{ id: 'hol', name: 'Holidays', url: 'https://cal.example.org/h.ics?token=t', color: '#2563eb', refreshMinutes: 60 }], freeBusyGroup: '' } }, + scopes: [ + { id: 'team', label: 'Team', match: { groups: ['team@example.org'] }, policy: { organisation: { calendars: { feeds: [{ id: 'team', name: 'Team', url: 'http://intranet.example.org/team.ics', refreshMinutes: 120 }] } } } }, + { id: 'two', label: 'Org two', match: { domains: ['two.example'] }, policy: { organisation: { calendars: { feeds: [{ id: 'two', name: 'Two', url: 'https://two.example/cal.ics' }] } } } }, + ], +} as unknown as SettingsPolicy; + +describe('company feeds on the server', () => { + it('empties every address in the policy users get, and the browser keeps the feeds', () => { + const stripped = stripFeedUrls(policy); + expect(stripped.organisation?.calendars.feeds[0]).toMatchObject({ id: 'hol', name: 'Holidays', url: '', urlHeld: true }); + expect((stripped.scopes![0].policy.organisation as { calendars: { feeds: { url: string }[] } }).calendars.feeds[0].url).toBe(''); + // The original is untouched (the admin pages read it). + expect(policy.organisation?.calendars.feeds[0].url).toContain('token=t'); + // An emptied address survives the sanitiser the browser runs. + expect(sanitizeOrganisationPolicy(stripped.organisation).calendars.feeds.map((f) => [f.id, f.url])).toEqual([['hol', '']]); + }); + + it('resolves a feed id only for a subject its scope applies to', () => { + expect(feedForSubject(policy, { email: 'a@example.org', groups: ['team@example.org'] }, 'team')?.url).toBe('http://intranet.example.org/team.ics'); + expect(feedForSubject(policy, { email: 'a@example.org', groups: [] }, 'team')).toBeNull(); + expect(feedForSubject(policy, { email: 'a@example.org', groups: [] }, 'hol')?.name).toBe('Holidays'); + expect(feedForSubject(policy, { email: 'a@example.org', groups: [] }, 'two')).toBeNull(); + expect(feedForSubject(policy, { email: 'b@two.example', groups: [] }, 'two')?.name).toBe('Two'); + }); + + it('lists every scope\'s feed host as reachable', () => { + expect(allFeeds(policy).map((f) => f.id)).toEqual(['hol', 'team', 'two']); + const hosts = listReachableHosts({ get: () => undefined, policy, env: {} }) + .filter((h) => h.kind === 'Company calendar'); + expect(hosts.map((h) => [h.host, h.setIn, h.side])).toEqual([ + ['cal.example.org', 'Organisation > Calendars (Holidays)', 'server'], + ['intranet.example.org', 'Organisation > Calendars (Team)', 'server'], + ['two.example', 'Organisation > Calendars (Two)', 'server'], + ]); + }); +}); diff --git a/lib/admin/__tests__/organisation-policy-enterprise.test.ts b/lib/admin/__tests__/organisation-policy-enterprise.test.ts index 76d0cc746..91a5395ef 100644 --- a/lib/admin/__tests__/organisation-policy-enterprise.test.ts +++ b/lib/admin/__tests__/organisation-policy-enterprise.test.ts @@ -85,6 +85,15 @@ describe('enterprise sections: defaults and sanitising', () => { expect(calendars.freeBusyGroup).toBe('all-staff@example.org'); }); + it('keeps a feed whose address the server held back, and drops one without an address', () => { + const { calendars } = sanitizeOrganisationPolicy({ calendars: { feeds: [ + { id: 'kept', name: 'Holidays', url: '', urlHeld: true }, + { id: 'none', name: 'No address' }, + { id: 'empty', name: 'Typed nothing yet', url: '' }, + ] } }); + expect(calendars.feeds.map((f) => [f.id, f.url, f.urlHeld])).toEqual([['kept', '', true]]); + }); + it('keeps device switches only as booleans', () => { const { devices } = sanitizeOrganisationPolicy({ devices: { noDownloads: true, noPrint: 'yes', watermark: 1 } }); expect(devices.noDownloads).toBe(true); diff --git a/lib/admin/company-feed-cache.ts b/lib/admin/company-feed-cache.ts new file mode 100644 index 000000000..12ffee41c --- /dev/null +++ b/lib/admin/company-feed-cache.ts @@ -0,0 +1,38 @@ +/** + * Company calendar feeds as the server last fetched them, by address: the + * body, its validators and when it was fetched. In memory, once per process + * (not per bundle copy), and at most MAX_ENTRIES feeds; a replica that + * starts fetches again. + */ + +export interface CachedFeed { + body: ArrayBuffer; + etag: string | null; + lastModified: string | null; + fetchedAt: number; +} + +const MAX_ENTRIES = 200; +const KEY = Symbol.for('bulwark.companyFeedCache'); + +function store(): Map { + const g = globalThis as typeof globalThis & { [KEY]?: Map }; + g[KEY] ??= new Map(); + return g[KEY]; +} + +export const companyFeedCache = { + get(url: string): CachedFeed | undefined { + return store().get(url); + }, + set(url: string, entry: CachedFeed): void { + const map = store(); + map.delete(url); + map.set(url, entry); + // The oldest entry goes first. + while (map.size > MAX_ENTRIES) map.delete(map.keys().next().value as string); + }, + clear(): void { + store().clear(); + }, +}; diff --git a/lib/admin/company-feeds.ts b/lib/admin/company-feeds.ts new file mode 100644 index 000000000..306f17076 --- /dev/null +++ b/lib/admin/company-feeds.ts @@ -0,0 +1,65 @@ +/** + * Company calendars on the server (organisation.calendars.feeds). + * + * A feed address may carry a token, so full Bulwark never hands it to the + * browser: the policy users get has every feed's `url` emptied + * (stripFeedUrls), and /api/fetch-ical fetches a feed by id from the + * caller's own scoped policy (feedForSubject). Lite has no server: there the + * address is in the public policy.json, which the admin docs say. + */ + +import type { SettingsPolicy } from './types'; +import type { CompanyFeed } from './organisation-policy'; +import { resolvePolicy, type PolicySubject } from './policy-scopes'; + +function withoutUrls(organisation: unknown): unknown { + if (!organisation || typeof organisation !== 'object') return organisation; + const org = organisation as Record; + const calendars = org.calendars as Record | undefined; + if (!calendars || !Array.isArray(calendars.feeds)) return organisation; + return { + ...org, + calendars: { + ...calendars, + feeds: calendars.feeds.map((feed) => (feed && typeof feed === 'object' ? { ...(feed as Record), url: '', urlHeld: true } : feed)), + }, + }; +} + +/** The policy as users get it: every company calendar's address emptied, in Everyone and in every scope. */ +export function stripFeedUrls(policy: SettingsPolicy): SettingsPolicy { + return { + ...policy, + ...(policy.organisation ? { organisation: withoutUrls(policy.organisation) as SettingsPolicy['organisation'] } : {}), + ...(policy.scopes + ? { + scopes: policy.scopes.map((scope) => (scope.policy?.organisation + ? { ...scope, policy: { ...scope.policy, organisation: withoutUrls(scope.policy.organisation) as typeof scope.policy.organisation } } + : scope)), + } + : {}), + }; +} + +/** The feed `feedId` as it applies to `subject` (their domain and group scopes), or null when it doesn't. */ +export function feedForSubject(policy: SettingsPolicy, subject: PolicySubject, feedId: string): CompanyFeed | null { + const feeds = resolvePolicy(policy, subject).policy.organisation?.calendars?.feeds ?? []; + const feed = feeds.find((f) => f.id === feedId); + return feed && feed.url ? feed : null; +} + +/** Every feed of every scope, for the outgoing-hosts list. */ +export function allFeeds(policy: Pick): CompanyFeed[] { + const lists = [policy.organisation, ...(policy.scopes ?? []).map((scope) => scope.policy?.organisation)] + .map((org) => (org as { calendars?: { feeds?: unknown } } | undefined)?.calendars?.feeds); + return lists.flatMap((feeds) => (Array.isArray(feeds) ? feeds.filter((f): f is CompanyFeed => !!f && typeof f === 'object' && typeof (f as CompanyFeed).url === 'string') : [])); +} + +/** The hostnames of the admin's feeds: the only private hosts a feed fetch may reach. */ +export function feedHostname(feed: Pick): string | null { + try { + return new URL(feed.url).hostname.toLowerCase(); + } catch { + return null; + } +} diff --git a/lib/admin/organisation-policy.ts b/lib/admin/organisation-policy.ts index c6c22e93f..ad9a9f5a9 100644 --- a/lib/admin/organisation-policy.ts +++ b/lib/admin/organisation-policy.ts @@ -56,7 +56,14 @@ export interface VacationTemplate { export interface CompanyFeed { id: string; name: string; + /** The ICS address; '' when `urlHeld`. */ url: string; + /** + * Set in the policy full Bulwark hands to users: the server keeps the + * address back (it may hold a token) and fetches the feed by id + * (app/api/fetch-ical, lib/admin/company-feeds.ts). + */ + urlHeld?: boolean; /** #rrggbb */ color: string; refreshMinutes: number; @@ -407,10 +414,12 @@ function feedUrl(value: unknown): string | null { function sanitizeFeeds(value: unknown): CompanyFeed[] | undefined { return byIdList(value, MAX_FEEDS, (item, id) => { const name = line(item.name, 100); - const url = feedUrl(item.url); - if (!name || !url) return null; + // An empty address only for a feed whose address the server held back. + const held = item.urlHeld === true && item.url === ''; + const url = held ? '' : feedUrl(item.url); + if (!name || url === null) return null; const minutes = count(item.refreshMinutes, 7 * 24 * 60) ?? MIN_FEED_REFRESH_MINUTES; - return { id, name, url, color: color(item.color, '#2563eb'), refreshMinutes: Math.max(MIN_FEED_REFRESH_MINUTES, minutes) }; + return { id, name, url, color: color(item.color, '#2563eb'), refreshMinutes: Math.max(MIN_FEED_REFRESH_MINUTES, minutes), ...(held ? { urlHeld: true } : {}) }; }); } diff --git a/lib/admin/reachable-hosts.ts b/lib/admin/reachable-hosts.ts index cfae6020d..5782d6211 100644 --- a/lib/admin/reachable-hosts.ts +++ b/lib/admin/reachable-hosts.ts @@ -2,6 +2,7 @@ import type { SettingsPolicy } from './types'; import { parseJmapServers } from './jmap-servers'; import { isBuiltInHost } from '@/lib/outgoing-hosts'; import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays'; +import { allFeeds } from './company-feeds'; /** * What stays reachable with "no third-party traffic" on: the hosts the @@ -39,7 +40,7 @@ function parse(url: unknown): URL | null { export interface ReachableInput { get: (key: string) => unknown; - policy: Pick & { features?: Partial }; + policy: Pick & Partial> & { features?: Partial }; env: Record; /** The relay the browser bundle was built with (NEXT_PUBLIC_PUSH_RELAY_URL inlined at build time). */ buildRelayUrl?: string; @@ -88,6 +89,9 @@ export function listReachableHosts({ get, policy, env, buildRelayUrl = DEFAULT_R 'Set in the environment only: browsers use the relay this build was made with. Rebuild, or add it under User policy > Push relays.'); } add('Extension directory', get('extensionDirectoryUrl'), 'EXTENSION_DIRECTORY_URL', 'server'); + // Company calendars of Everyone and of every scope: this server fetches + // them (app/api/fetch-ical by feed id), whatever the switch says. + for (const feed of allFeeds(policy)) add('Company calendar', feed.url, `Organisation > Calendars (${feed.name})`, 'server'); for (const app of policy.defaultSidebarApps ?? []) add('Sidebar app', app?.url, `User policy > Sidebar apps (${app?.name ?? ''})`, 'browser'); add('Document editor (WOPI)', get('wopiClientUrl'), 'WOPI_CLIENT_URL', 'both'); add('Translation (LibreTranslate)', env.LIBRETRANSLATE_URL, 'LIBRETRANSLATE_URL', 'server'); diff --git a/lib/admin/server-policy-subject.ts b/lib/admin/server-policy-subject.ts new file mode 100644 index 000000000..4d529f1cf --- /dev/null +++ b/lib/admin/server-policy-subject.ts @@ -0,0 +1,93 @@ +/** + * Who a request is for the domain and group scopes, worked out on the + * server from the caller's own JMAP session: the account's address and the + * group accounts in its session, as policySubjectForClient + * (lib/admin/policy-subject.ts) does in the browser. Used where the server + * must apply a group scope itself (company calendars by feed id), so a + * browser can't claim a group it isn't in. + */ + +import { createHash } from 'node:crypto'; +import type { PolicySubject } from './policy-scopes'; +import type { StalwartCredentials } from '@/lib/stalwart/credentials'; +import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api'; +import { UPSTREAM_JSON_MAX_BYTES, readJsonCapped } from '@/lib/security/capped-body'; + +const TTL_MS = 5 * 60 * 1000; +const PRINCIPALS = 'urn:ietf:params:jmap:principals'; +const MAIL = 'urn:ietf:params:jmap:mail'; + +type Creds = Pick; + +interface SessionAccount { + name?: string; + isPersonal?: boolean; +} + +// One cache per process, not per bundle copy. +const CACHE_KEY = Symbol.for('bulwark.serverPolicySubjects'); +function cache(): Map }> { + const g = globalThis as typeof globalThis & { [CACHE_KEY]?: Map }> }; + g[CACHE_KEY] ??= new Map(); + return g[CACHE_KEY]; +} + +async function resolve(creds: Creds): Promise { + const email = (creds.accountName || creds.username).trim().toLowerCase(); + const session = await fetchJmapSession(creds.serverUrl, creds.authHeader, { trusted: creds.trusted }); + if (!session?.accounts) return { email, groups: [] }; + const primary = session.primaryAccounts?.[MAIL] ?? ''; + const shared = Object.entries(session.accounts as Record) + .filter(([id, account]) => id !== primary && account?.isPersonal === false && typeof account.name === 'string' && account.name.includes('@')); + if (shared.length === 0) return { email, groups: [] }; + + // A mailbox another person shared is non-personal too: the principal type + // tells groups apart. When the server won't say, every non-personal + // account counts, as in the browser. + let types: Record | null = null; + const apiUrl = rebaseApiUrl(session, creds.serverUrl); + if (apiUrl && primary) { + try { + const response = await postJmap(apiUrl, creds.authHeader, JSON.stringify({ + using: ['urn:ietf:params:jmap:core', PRINCIPALS], + methodCalls: [['Principal/get', { accountId: primary, ids: shared.map(([id]) => id), properties: ['id', 'type'] }, '0']], + }), { trusted: creds.trusted }); + if (response.ok) { + const body = await readJsonCapped<{ methodResponses?: [string, { list?: Array<{ id?: string; type?: string }> }, string][] }>(response, UPSTREAM_JSON_MAX_BYTES); + const [method, result] = body?.methodResponses?.[0] ?? []; + if (method === 'Principal/get') { + types = {}; + for (const p of result?.list ?? []) if (p.id && p.type) types[p.id] = p.type; + } + } + } catch { + types = null; + } + } + const groups = shared + .filter(([id]) => !types || types[id] === 'group') + .map(([, account]) => account.name!.trim().toLowerCase()) + .sort(); + return { email, groups }; +} + +/** The caller's subject, kept for a few minutes per credential. Never rejects. */ +export function serverPolicySubject(creds: Creds): Promise { + const key = createHash('sha256').update(`${creds.serverUrl}\n${creds.authHeader}`).digest('hex'); + const entries = cache(); + const now = Date.now(); + const hit = entries.get(key); + if (hit && now - hit.at < TTL_MS) return hit.subject; + const email = (creds.accountName || creds.username).trim().toLowerCase(); + const subject = resolve(creds).catch((): PolicySubject => ({ email, groups: [] })); + entries.set(key, { at: now, subject }); + if (entries.size > 1000) { + for (const [k, v] of entries) if (now - v.at >= TTL_MS) entries.delete(k); + } + return subject; +} + +/** For tests. */ +export function clearServerPolicySubjects(): void { + cache().clear(); +} diff --git a/lib/company-calendars.ts b/lib/company-calendars.ts new file mode 100644 index 000000000..aec6a9b5f --- /dev/null +++ b/lib/company-calendars.ts @@ -0,0 +1,247 @@ +/** + * Company calendars (organisation.calendars.feeds): ICS feeds the admin + * lists, shown to every user in scope as read-only calendars under + * "Organisation". + * + * They are virtual calendars in the browser, like the birthday calendar, not + * calendars imported into each user's account (the subscription sync in + * lib/calendar-subscription-sync.ts does that): nothing lands in anyone's + * free/busy, removing a feed removes it everywhere at once, and Lite runs the + * same code. What is reused from the subscriptions is the fetch (full + * Bulwark: /api/fetch-ical, here by feed id so the address stays on the + * server) and the parsing (CalendarEvent/parse on an uploaded blob, so there + * is no ICS parser in the bundle). + * + * The upload quota (1000 files or 50 MB an hour on Stalwart) is spared: a + * feed is fetched at most once per refresh interval (60 minutes or more), + * its text is hashed and not uploaded again while it is unchanged, and the + * parsed events are kept per account in IndexedDB, which a full sign-out + * deletes. Uploads take the XHR path, so a 429 there doesn't freeze the rest + * of the client. + */ + +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; +import type { CompanyFeed } from '@/lib/admin/organisation-policy'; +import { apiFetch } from '@/lib/browser-navigation'; +import { IS_LITE } from '@/lib/lite'; +import { onFullSignOut } from '@/lib/sign-out-cleanup'; +import { debug } from '@/lib/debug'; + +export const COMPANY_CALENDAR_PREFIX = '__company-calendar__:'; + +export function companyCalendarId(feedId: string): string { + return `${COMPANY_CALENDAR_PREFIX}${feedId}`; +} + +export function isCompanyCalendarId(id: string): boolean { + return id.startsWith(COMPANY_CALENDAR_PREFIX); +} + +export function feedIdOf(calendarId: string): string { + return calendarId.slice(COMPANY_CALENDAR_PREFIX.length); +} + +/** The read-only calendar a feed is shown as. */ +export function createCompanyCalendar(feed: Pick): Calendar { + return { + id: companyCalendarId(feed.id), + name: feed.name, + description: null, + color: feed.color || '#2563eb', + sortOrder: 1000, + isSubscribed: true, + isVisible: true, + isDefault: false, + includeInAvailability: 'none', + defaultAlertsWithTime: null, + defaultAlertsWithoutTime: null, + timeZone: null, + shareWith: null, + isCompany: true, + myRights: { + mayReadFreeBusy: true, + mayReadItems: true, + mayWriteAll: false, + mayWriteOwn: false, + mayUpdatePrivate: false, + mayRSVP: false, + mayShare: false, + mayDelete: false, + }, + }; +} + +/** The parsed events of a feed as events of its calendar: no attendees, nothing to answer. */ +export function toCompanyEvents(feedId: string, parsed: Partial[]): CalendarEvent[] { + const calendarId = companyCalendarId(feedId); + const events: CalendarEvent[] = []; + parsed.forEach((event, index) => { + if (typeof event.start !== 'string' || !event.start) return; + const rest = { ...(event as Record) }; + delete rest.method; + events.push({ + ...(rest as Partial), + id: `company-${feedId}-${index}`, + uid: event.uid || `company-${feedId}-${index}`, + title: event.title || '', + calendarIds: { [calendarId]: true }, + // A feed's ATTENDEE lines are someone else's invitation: shown as an + // event, never as one to answer. + participants: null, + replyTo: null, + organizerCalendarAddress: null, + isDraft: false, + isOrigin: false, + alerts: null, + useDefaultAlerts: false, + } as CalendarEvent); + }); + return events; +} + +export interface FeedCacheEntry { + /** SHA-256 of the feed text the events were parsed from. */ + hash: string; + fetchedAt: number; + events: Partial[]; +} + +// ── IndexedDB, per account ────────────────────────────────────────── + +const DB_NAME = 'bulwark-company-calendars'; +const STORE = 'feeds'; + +function openDb(): Promise { + if (typeof indexedDB === 'undefined') return Promise.resolve(null); + return new Promise((resolve) => { + try { + const request = indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = () => request.result.createObjectStore(STORE); + request.onsuccess = () => resolve(request.result); + request.onerror = () => resolve(null); + request.onblocked = () => resolve(null); + } catch { + resolve(null); + } + }); +} + +async function idbGet(key: string): Promise { + const db = await openDb(); + if (!db) return null; + return new Promise((resolve) => { + try { + const request = db.transaction(STORE, 'readonly').objectStore(STORE).get(key); + request.onsuccess = () => resolve((request.result as FeedCacheEntry | undefined) ?? null); + request.onerror = () => resolve(null); + } catch { + resolve(null); + } + }).finally(() => db.close()) as Promise; +} + +async function idbPut(key: string, value: FeedCacheEntry): Promise { + const db = await openDb(); + if (!db) return; + await new Promise((resolve) => { + try { + const tx = db.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).put(value, key); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + tx.onabort = () => resolve(); + } catch { + resolve(); + } + }); + db.close(); +} + +const memory = new Map(); + +/** Drop every cached feed: on a full sign-out the events are the organisation's. */ +export function clearCompanyCalendarCache(): void { + memory.clear(); + inFlight.clear(); + if (typeof indexedDB === 'undefined') return; + try { + indexedDB.deleteDatabase(DB_NAME); + } catch { + /* storage unavailable */ + } +} + +onFullSignOut(clearCompanyCalendarCache); + +async function sha256(text: string): Promise { + const subtle = globalThis.crypto?.subtle; + if (subtle) { + const digest = await subtle.digest('SHA-256', new TextEncoder().encode(text)); + return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join(''); + } + // No WebCrypto (an http origin): a plain string hash still tells a change. + let h = 0; + for (let i = 0; i < text.length; i++) h = (Math.imul(31, h) + text.charCodeAt(i)) | 0; + return `s${text.length}-${h}`; +} + +/** The feed text: by id through the server in full Bulwark, from its address in Lite. */ +async function fetchFeedText(feed: CompanyFeed): Promise { + const response = IS_LITE + ? await fetch(feed.url, { headers: { Accept: 'text/calendar, application/ics, text/plain, */*' }, credentials: 'omit' }) + : await apiFetch('/api/fetch-ical', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ feedId: feed.id }), + }); + if (!response.ok) throw new Error(`Company calendar ${feed.id}: ${response.status}`); + return response.text(); +} + +type FeedClient = Pick; + +const inFlight = new Map[]>>(); + +/** + * The parsed events of one feed for one account: from the cache while it is + * fresh, else fetched, and parsed again only when the text changed. A feed + * that can't be fetched keeps what was cached. Never rejects. + */ +export function loadFeedEvents(client: FeedClient, accountKey: string, feed: CompanyFeed, now = Date.now()): Promise[]> { + const key = `${accountKey}|${feed.id}`; + const running = inFlight.get(key); + if (running) return running; + const work = (async () => { + const cached = memory.get(key) ?? (await idbGet(key)); + if (cached) memory.set(key, cached); + if (cached && now - cached.fetchedAt < feed.refreshMinutes * 60_000) return cached.events; + try { + const text = await fetchFeedText(feed); + const hash = await sha256(text); + let events = cached?.events ?? []; + if (!cached || cached.hash !== hash) { + const accountId = client.getCalendarsAccountId(); + const file = new File([text], 'company-calendar.ics', { type: 'text/calendar' }); + // The XHR path (progress callback): a 429 is this upload's alone. + const uploaded = await client.uploadBlob(file, { accountId, onProgress: () => {} }); + events = await client.parseCalendarEvents(accountId, uploaded.blobId); + } + const entry: FeedCacheEntry = { hash, fetchedAt: now, events }; + memory.set(key, entry); + await idbPut(key, entry); + return events; + } catch (error) { + debug.warn('calendar', 'Company calendar could not be refreshed', { feed: feed.id, error }); + return cached?.events ?? []; + } + })().finally(() => inFlight.delete(key)); + inFlight.set(key, work); + return work; +} + +/** For tests. */ +export function resetCompanyCalendarMemory(): void { + memory.clear(); + inFlight.clear(); +} diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 107af8909..7d04c19e8 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -676,6 +676,9 @@ export interface Calendar { // hidden from the event calendar UI while remaining available to the tasks // view (#761). Undefined means "not determined" (treated as not tasks-only). isTasksOnly?: boolean; + // A company calendar the admin set (lib/company-calendars.ts): a read-only + // calendar that exists only in the browser. + isCompany?: boolean; } /** diff --git a/lib/security/url-guard.ts b/lib/security/url-guard.ts index b022131a3..2a60ed429 100644 --- a/lib/security/url-guard.ts +++ b/lib/security/url-guard.ts @@ -34,7 +34,7 @@ function isBlockedHostname(hostname: string): boolean { * hostnames / suffixes, and literal IP addresses. Returns the parsed URL when * those checks pass, `null` otherwise. Does NOT touch DNS. */ -function parseAllowedUrl(urlString: string): URL | null { +function parseAllowedUrl(urlString: string, privateHost?: string): URL | null { let url: URL; try { url = new URL(urlString); @@ -46,6 +46,7 @@ function parseAllowedUrl(urlString: string): URL | null { if (url.username || url.password) return null; const hostname = normalizeHostname(url.hostname); + if (privateHost && hostname === privateHost) return url; if (isBlockedHostname(hostname)) return null; if (isBlockedIpAddress(hostname)) return null; @@ -213,9 +214,50 @@ export function envProxyFor(url: URL): URL | null { } } -function lookupChecked(hostname: string): Promise { +/** + * guardedLookup, except that `privateHost` may resolve to a private address: + * an intranet host the admin entered themselves (company calendars). Every + * other name, redirect targets included, is still checked, and the address + * is still the one the socket connects to. + */ +function lookupAllowing(privateHost: string): typeof guardedLookup { + return (hostname, options, callback) => { + if (normalizeHostname(hostname) !== privateHost) { + guardedLookup(hostname, options, callback); + return; + } + const cb = (typeof options === 'function' ? options : callback) as LookupCallback; + const opts: LookupOptions = typeof options === 'function' ? {} : typeof options === 'number' ? { family: options } : (options ?? {}); + lookup(normalizeHostname(hostname), { family: opts.family, hints: opts.hints, all: true, verbatim: true }) + .then((records) => { + if (records.length === 0) { + const err: NodeJS.ErrnoException = new Error(`getaddrinfo ENOTFOUND ${hostname}`); + err.code = 'ENOTFOUND'; + cb(err); + return; + } + if (opts.all) cb(null, records); + else cb(null, records[0].address, records[0].family); + }) + .catch((err: NodeJS.ErrnoException) => cb(err)); + }; +} + +const privateHostDispatchers = new Map(); + +function getPrivateHostDispatcher(privateHost: string): Dispatcher { + let dispatcher = privateHostDispatchers.get(privateHost); + if (!dispatcher) { + dispatcher = new Agent({ connect: { lookup: lookupAllowing(privateHost) as unknown as LookupFunction } }); + privateHostDispatchers.set(privateHost, dispatcher); + } + return dispatcher; +} + +function lookupChecked(hostname: string, privateHost?: string): Promise { + const look = privateHost ? lookupAllowing(privateHost) : guardedLookup; return new Promise((resolve, reject) => { - guardedLookup(hostname, {}, (err, address) => (err ? reject(err) : resolve(address as string))); + look(hostname, {}, (err, address) => (err ? reject(err) : resolve(address as string))); }); } @@ -229,8 +271,9 @@ const proxiedDispatchers = new Map(); * address. The request keeps its Host header, and TLS inside the tunnel uses * the hostname for SNI and the certificate check. */ -function getProxiedPublicDispatcher(proxy: URL): Dispatcher { - let dispatcher = proxiedDispatchers.get(proxy.href); +function getProxiedPublicDispatcher(proxy: URL, privateHost?: string): Dispatcher { + const cacheKey = privateHost ? `${proxy.href}|${privateHost}` : proxy.href; + let dispatcher = proxiedDispatchers.get(cacheKey); if (dispatcher) return dispatcher; const proxyClient = new Client(proxy.origin); @@ -241,7 +284,7 @@ function getProxiedPublicDispatcher(proxy: URL): Dispatcher { const connect: buildConnector.connector = (opts, callback) => { const hostname = normalizeHostname(opts.hostname); const port = Number(opts.port) || (opts.protocol === 'https:' ? 443 : 80); - lookupChecked(hostname) + lookupChecked(hostname, privateHost) .then(async (address) => { const authority = `${isIP(address) === 6 ? `[${address}]` : address}:${port}`; const tunnel = await proxyClient.connect({ @@ -265,7 +308,7 @@ function getProxiedPublicDispatcher(proxy: URL): Dispatcher { .catch((err: Error) => callback(err, null)); }; dispatcher = new Agent({ connect }); - proxiedDispatchers.set(proxy.href, dispatcher); + proxiedDispatchers.set(cacheKey, dispatcher); return dispatcher; } @@ -296,18 +339,30 @@ export type PublicFetchResponse = UndiciResponse; export async function fetchPublicUrl( url: string, init: Omit = {}, + options: { + /** + * One hostname that may be a private (intranet) host: only for an + * address the admin entered (company calendars). It must equal the + * URL's hostname to make any difference. + */ + allowPrivateHost?: string; + } = {}, ): Promise { - const parsed = parseAllowedUrl(url); + const privateHost = options.allowPrivateHost ? normalizeHostname(options.allowPrivateHost) : undefined; + const parsed = parseAllowedUrl(url, privateHost); if (!parsed) { throw new DisallowedUrlError(url); } const proxy = envProxyFor(parsed); + const exempt = privateHost && normalizeHostname(parsed.hostname) === privateHost ? privateHost : undefined; try { return await undiciFetch(url, { ...init, redirect: 'manual', - dispatcher: proxy ? getProxiedPublicDispatcher(proxy) : getPublicDispatcher(), + dispatcher: proxy + ? getProxiedPublicDispatcher(proxy, exempt) + : exempt ? getPrivateHostDispatcher(exempt) : getPublicDispatcher(), }); } catch (err) { const blocked = findBlockedCause(err); diff --git a/locales/ar/common.json b/locales/ar/common.json index 86fc84cbe..f8d53bf95 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -4845,6 +4845,9 @@ "cancelled": "ألغى {name} \"{title}\"", "untitled": "حدث بدون عنوان" }, + "company_calendars": { + "section": "المؤسسة" + }, "vacation_calendar": { "label": "حجز تقويمي", "description": "يضيف حدثًا مشغولًا لمدة غيابك، حتى لا يحجز أحد اجتماعات معك خلالها.", diff --git a/locales/ca/common.json b/locales/ca/common.json index 7304563ef..de663a0b4 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -4845,6 +4845,9 @@ "cancelled": "{name} ha cancel·lat \"{title}\"", "untitled": "Esdeveniment sense títol" }, + "company_calendars": { + "section": "Organització" + }, "vacation_calendar": { "label": "Bloca el meu calendari", "description": "Afegeix un esdeveniment ocupat mentre sou fora, perquè ningú us convoqui a reunions aleshores.", diff --git a/locales/cs/common.json b/locales/cs/common.json index f06b2b2a0..2c4daa2a8 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5283,6 +5283,9 @@ "cancelled": "{name} zrušil(a) „{title}“", "untitled": "Událost bez názvu" }, + "company_calendars": { + "section": "Organizace" + }, "vacation_calendar": { "label": "Zablokovat můj kalendář", "description": "Přidá obsazenou událost na dobu vaší nepřítomnosti, aby vám tehdy nikdo neplánoval schůzky.", diff --git a/locales/da/common.json b/locales/da/common.json index 20ac3e1e2..3ecd63e8c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -4845,6 +4845,9 @@ "cancelled": "{name} har aflyst \"{title}\"", "untitled": "Unavngivet begivenhed" }, + "company_calendars": { + "section": "Organisation" + }, "vacation_calendar": { "label": "Bloker min kalender", "description": "Tilføjer en optaget begivenhed, mens du er væk, så ingen booker møder med dig imens.", diff --git a/locales/de/common.json b/locales/de/common.json index 423336c2f..6adb2ce00 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5283,6 +5283,9 @@ "cancelled": "{name} hat „{title}“ abgesagt", "untitled": "Unbenannter Termin" }, + "company_calendars": { + "section": "Organisation" + }, "vacation_calendar": { "label": "Meinen Kalender blockieren", "description": "Trägt für Ihre Abwesenheit einen belegten Termin ein, damit Ihnen niemand Besprechungen in dieser Zeit einträgt.", diff --git a/locales/en/common.json b/locales/en/common.json index 8f07b6ce6..4150db223 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -4845,6 +4845,9 @@ "cancelled": "{name} cancelled \"{title}\"", "untitled": "Untitled event" }, + "company_calendars": { + "section": "Organisation" + }, "vacation_calendar": { "label": "Block my calendar", "description": "Adds a busy event for the time you’re away, so nobody books meetings with you then.", diff --git a/locales/es/common.json b/locales/es/common.json index 142117f28..019e0d458 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5283,6 +5283,9 @@ "cancelled": "{name} ha cancelado \"{title}\"", "untitled": "Evento sin título" }, + "company_calendars": { + "section": "Organización" + }, "vacation_calendar": { "label": "Bloquear mi calendario", "description": "Añade un evento ocupado mientras estás fuera, para que nadie te convoque a reuniones entonces.", diff --git a/locales/et/common.json b/locales/et/common.json index eafff1c55..0a08e500b 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -4845,6 +4845,9 @@ "cancelled": "{name} tühistas sündmuse \"{title}\"", "untitled": "Nimetu sündmus" }, + "company_calendars": { + "section": "Organisatsioon" + }, "vacation_calendar": { "label": "Blokeeri minu kalender", "description": "Lisab äraoleku ajaks hõivatud sündmuse, et keegi sel ajal sulle koosolekuid ei paneks.", diff --git a/locales/fa/common.json b/locales/fa/common.json index c603ee3bc..a432fab35 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -4845,6 +4845,9 @@ "cancelled": "{name} «{title}» را لغو کرد", "untitled": "رویداد بدون عنوان" }, + "company_calendars": { + "section": "سازمان" + }, "vacation_calendar": { "label": "مسدود کردن تقویم من", "description": "برای مدت غیبت شما یک رویداد مشغول اضافه می‌کند تا کسی در آن زمان جلسه نگذارد.", diff --git a/locales/fr/common.json b/locales/fr/common.json index 39049d7e9..1f988f50e 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5283,6 +5283,9 @@ "cancelled": "{name} a annulé « {title} »", "untitled": "Événement sans titre" }, + "company_calendars": { + "section": "Organisation" + }, "vacation_calendar": { "label": "Bloquer mon calendrier", "description": "Ajoute un événement occupé pendant votre absence, pour que personne ne vous invite à une réunion à ce moment-là.", diff --git a/locales/he/common.json b/locales/he/common.json index d87ddf5d8..64a3f63af 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5255,6 +5255,9 @@ "cancelled": "{name} ביטל/ה את \"{title}\"", "untitled": "אירוע ללא כותרת" }, + "company_calendars": { + "section": "ארגון" + }, "vacation_calendar": { "label": "חסום את היומן שלי", "description": "מוסיף אירוע תפוס לזמן ההיעדרות, כדי שאף אחד לא יקבע איתך פגישות אז.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 14fbe92e8..76d7acaaf 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -4845,6 +4845,9 @@ "cancelled": "{name} lemondta a(z) „{title}” eseményt", "untitled": "Névtelen esemény" }, + "company_calendars": { + "section": "Szervezet" + }, "vacation_calendar": { "label": "Naptáram foglalása", "description": "Foglalt eseményt tesz a távollét idejére, hogy addig senki ne szervezzen Önnel megbeszélést.", diff --git a/locales/it/common.json b/locales/it/common.json index 4d084d482..1b7ab1a9a 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5283,6 +5283,9 @@ "cancelled": "{name} ha annullato \"{title}\"", "untitled": "Evento senza titolo" }, + "company_calendars": { + "section": "Organizzazione" + }, "vacation_calendar": { "label": "Blocca il mio calendario", "description": "Aggiunge un evento occupato per il periodo di assenza, così nessuno ti fissa riunioni in quei giorni.", diff --git a/locales/ja/common.json b/locales/ja/common.json index acabf69c8..d0eb78511 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5283,6 +5283,9 @@ "cancelled": "{name} さんが「{title}」をキャンセルしました", "untitled": "無題の予定" }, + "company_calendars": { + "section": "組織" + }, "vacation_calendar": { "label": "カレンダーをブロック", "description": "不在の期間に予定ありの予定を追加し、その間に会議を入れられないようにします。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 717862f6c..c04cfa17b 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5283,6 +5283,9 @@ "cancelled": "{name}님이 \"{title}\"을(를) 취소했습니다", "untitled": "제목 없는 일정" }, + "company_calendars": { + "section": "조직" + }, "vacation_calendar": { "label": "내 캘린더 차단", "description": "부재 기간에 바쁨 일정을 추가해 그동안 회의가 잡히지 않게 합니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index 6de04d08f..7fdddf32b 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5283,6 +5283,9 @@ "cancelled": "{name} atcēla “{title}”", "untitled": "Notikums bez nosaukuma" }, + "company_calendars": { + "section": "Organizācija" + }, "vacation_calendar": { "label": "Bloķēt manu kalendāru", "description": "Pievieno aizņemtu notikumu prombūtnes laikam, lai tad neviens neplānotu ar jums sapulces.", diff --git a/locales/mn/common.json b/locales/mn/common.json index 2b3141027..f91e264bb 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -4845,6 +4845,9 @@ "cancelled": "{name} \"{title}\"-г цуцаллаа", "untitled": "Нэргүй арга хэмжээ" }, + "company_calendars": { + "section": "Байгууллага" + }, "vacation_calendar": { "label": "Миний хуанлийг хаах", "description": "Таныг байхгүй үед завгүй арга хэмжээ нэмж, тэр үед хэн ч уулзалт товлохгүй болгоно.", diff --git a/locales/nb/common.json b/locales/nb/common.json index 0b47650f7..fcfa39195 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -4845,6 +4845,9 @@ "cancelled": "{name} har avlyst «{title}»", "untitled": "Hendelse uten tittel" }, + "company_calendars": { + "section": "Organisasjon" + }, "vacation_calendar": { "label": "Blokker kalenderen min", "description": "Legger inn en opptatt-hendelse mens du er borte, så ingen booker møter med deg da.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 8ea39c2c6..bc0031dc7 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5283,6 +5283,9 @@ "cancelled": "{name} heeft \"{title}\" geannuleerd", "untitled": "Afspraak zonder titel" }, + "company_calendars": { + "section": "Organisatie" + }, "vacation_calendar": { "label": "Mijn agenda blokkeren", "description": "Zet een bezette afspraak in je agenda voor de tijd dat je weg bent, zodat niemand dan vergaderingen met je plant.", diff --git a/locales/pl/common.json b/locales/pl/common.json index 84b79e5ec..bf49f5381 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5283,6 +5283,9 @@ "cancelled": "{name} odwołał(a) „{title}”", "untitled": "Wydarzenie bez tytułu" }, + "company_calendars": { + "section": "Organizacja" + }, "vacation_calendar": { "label": "Zablokuj mój kalendarz", "description": "Dodaje zajęte wydarzenie na czas Twojej nieobecności, żeby nikt nie umawiał wtedy z Tobą spotkań.", diff --git a/locales/pt/common.json b/locales/pt/common.json index 981966bc6..bb5a6148b 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5283,6 +5283,9 @@ "cancelled": "{name} cancelou \"{title}\"", "untitled": "Evento sem título" }, + "company_calendars": { + "section": "Organização" + }, "vacation_calendar": { "label": "Bloquear o meu calendário", "description": "Adiciona um evento ocupado durante a sua ausência, para que ninguém marque reuniões consigo nessa altura.", diff --git a/locales/ro/common.json b/locales/ro/common.json index 1462cb8f3..0944e7d7e 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -4845,6 +4845,9 @@ "cancelled": "{name} a anulat „{title}”", "untitled": "Eveniment fără titlu" }, + "company_calendars": { + "section": "Organizație" + }, "vacation_calendar": { "label": "Blochează-mi calendarul", "description": "Adaugă un eveniment ocupat pe durata absenței, ca nimeni să nu vă programeze ședințe atunci.", diff --git a/locales/ru/common.json b/locales/ru/common.json index 101df2134..d8d62cd85 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5283,6 +5283,9 @@ "cancelled": "{name} отменил(а) «{title}»", "untitled": "Событие без названия" }, + "company_calendars": { + "section": "Организация" + }, "vacation_calendar": { "label": "Занять мой календарь", "description": "Добавляет событие «занят» на время отсутствия, чтобы вам не назначали встречи.", diff --git a/locales/sk/common.json b/locales/sk/common.json index 8ac9073dc..6254c2866 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -4845,6 +4845,9 @@ "cancelled": "{name} zrušil(a) „{title}“", "untitled": "Udalosť bez názvu" }, + "company_calendars": { + "section": "Organizácia" + }, "vacation_calendar": { "label": "Zablokovať môj kalendár", "description": "Pridá obsadenú udalosť na čas vašej neprítomnosti, aby vám vtedy nikto neplánoval stretnutia.", diff --git a/locales/th/common.json b/locales/th/common.json index 6d115429a..646993b79 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2185,6 +2185,9 @@ "cancelled": "{name} ยกเลิกกิจกรรม “{title}”", "untitled": "กิจกรรมที่ไม่มีชื่อ" }, + "company_calendars": { + "section": "องค์กร" + }, "vacation_calendar": { "label": "บล็อกปฏิทินของฉัน", "description": "เพิ่มกิจกรรมไม่ว่างในช่วงที่คุณไม่อยู่ เพื่อไม่ให้ใครนัดประชุมกับคุณในเวลานั้น", diff --git a/locales/tr/common.json b/locales/tr/common.json index 21aa0af9c..a207917a2 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -4845,6 +4845,9 @@ "cancelled": "{name} \"{title}\" etkinliğini iptal etti", "untitled": "Başlıksız etkinlik" }, + "company_calendars": { + "section": "Kuruluş" + }, "vacation_calendar": { "label": "Takvimimi kapat", "description": "Uzakta olduğunuz süre için meşgul bir etkinlik ekler, böylece o sırada kimse sizinle toplantı ayarlamaz.", diff --git a/locales/uk/common.json b/locales/uk/common.json index 24b359a8a..dc05fba27 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5283,6 +5283,9 @@ "cancelled": "{name} скасував(ла) «{title}»", "untitled": "Подія без назви" }, + "company_calendars": { + "section": "Організація" + }, "vacation_calendar": { "label": "Заблокувати мій календар", "description": "Додає подію «зайнято» на час вашої відсутності, щоб вам тоді не призначали зустрічей.", diff --git a/locales/vi/common.json b/locales/vi/common.json index 4445f0894..42f959090 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -4857,6 +4857,9 @@ "cancelled": "{name} đã hủy \"{title}\"", "untitled": "Sự kiện không có tiêu đề" }, + "company_calendars": { + "section": "Tổ chức" + }, "vacation_calendar": { "label": "Chặn lịch của tôi", "description": "Thêm một sự kiện bận trong thời gian bạn vắng mặt để không ai đặt cuộc họp với bạn lúc đó.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 801825c00..dbd483fe0 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -4845,6 +4845,9 @@ "cancelled": "{name} 取消了「{title}」", "untitled": "未命名活動" }, + "company_calendars": { + "section": "組織" + }, "vacation_calendar": { "label": "佔用我的行事曆", "description": "在你外出期間新增一個忙碌活動,這樣別人不會在這段時間約你開會。", diff --git a/locales/zh/common.json b/locales/zh/common.json index 61364c418..fbbc0e5f5 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5283,6 +5283,9 @@ "cancelled": "{name} 取消了“{title}”", "untitled": "无标题活动" }, + "company_calendars": { + "section": "组织" + }, "vacation_calendar": { "label": "占用我的日历", "description": "在你外出期间添加一个忙碌活动,这样别人不会在这段时间约你开会。", diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 537bba3ed..d47af7bf8 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -627,6 +627,8 @@ interface SettingsState extends AccessibilitySettings { vacationBlockCalendar: boolean; /** The times of each account's out-of-office event as Bulwark last wrote them, by account id. */ vacationCalendarBlocks: Record; + /** Company calendars (organisation.calendars.feeds) the user hid, by feed id. */ + hiddenCompanyCalendars: string[]; // Calendar Tasks enableCalendarTasks: boolean; @@ -952,6 +954,7 @@ const DEFAULT_SETTINGS = { calendarWorkingDays: [1, 2, 3, 4, 5] as number[], vacationBlockCalendar: true, vacationCalendarBlocks: {} as Record, + hiddenCompanyCalendars: [] as string[], // Calendar Tasks enableCalendarTasks: false, @@ -1394,6 +1397,7 @@ export const useSettingsStore = create()( calendarWorkingDays: state.calendarWorkingDays, vacationBlockCalendar: state.vacationBlockCalendar, vacationCalendarBlocks: state.vacationCalendarBlocks, + hiddenCompanyCalendars: state.hiddenCompanyCalendars, toolbarPosition: state.toolbarPosition, hideAccountSwitcher: state.hideAccountSwitcher, showRailAccountList: state.showRailAccountList, From e796631389ddc304c51886ed472db474479ba94a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:53:54 +0100 Subject: [PATCH 08/23] fix: say what company calendars need in a Lite build without third-party traffic --- components/admin/org/sections/company-calendars-section.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/components/admin/org/sections/company-calendars-section.tsx b/components/admin/org/sections/company-calendars-section.tsx index 291c5452d..7b63a9a34 100644 --- a/components/admin/org/sections/company-calendars-section.tsx +++ b/components/admin/org/sections/company-calendars-section.tsx @@ -171,8 +171,9 @@ export function CompanyCalendarsSection({ draft }: { draft: OrgPolicyDraft }) {

    Bulwark Lite has no server: the browser fetches the feed itself, so the feed host must send CORS headers - (Access-Control-Allow-Origin), a Lite build without third-party traffic must list the host in its connect-src, and - policy.json is a public file there, so don't put tokens in the addresses.{' '} + (Access-Control-Allow-Origin), and policy.json is a public file there, so don't put tokens in the addresses. A + Lite build without third-party traffic only reaches the mail server: put the feed on the same host as Lite or the + mail server.{' '} How to set it up From 36160f9fbdda59efc3e6597f0c8ede792c32569e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:00:43 +0100 Subject: [PATCH 09/23] fix: answer for a group mailbox as the group when the group address is one of your identities #634 --- lib/__tests__/calendar-delegate.test.ts | 7 +++++++ lib/calendar-delegate.ts | 14 +++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/__tests__/calendar-delegate.test.ts b/lib/__tests__/calendar-delegate.test.ts index 8f3e27de1..aea6584e0 100644 --- a/lib/__tests__/calendar-delegate.test.ts +++ b/lib/__tests__/calendar-delegate.test.ts @@ -45,6 +45,13 @@ describe('resolveAnswerTargets', () => { expect(view.targets[0].sentBy).toBeUndefined(); }); + it('answers as the group also when the member has the group address as an identity', () => { + const view = resolveAnswerTargets(base(invitation(['team@example.org'], { accountId: 'team' }), { + owner: TEAM, userEmails: ['bob@example.org', 'team@example.org'], canRsvpSelf: true, + })); + expect(view.targets.map((t) => [t.kind, t.participantId, t.sentBy])).toEqual([['owner', 'team', undefined]]); + }); + 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 } })); diff --git a/lib/calendar-delegate.ts b/lib/calendar-delegate.ts index 5aa8555e9..04f63083e 100644 --- a/lib/calendar-delegate.ts +++ b/lib/calendar-delegate.ts @@ -99,27 +99,31 @@ export function resolveAnswerTargets(input: ResolveAnswerInput): AnswerView { 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]; + // Members of a group often have the group's address as an identity of + // their own: the group's participant is still answered as the group. + const ownersOwn = ownerId && (ownerId !== selfId || owner.isGroup); + if (ownersOwn && !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) { + const alsoInvited = !!selfId && selfId !== ownerId; + if (input.server016 && alsoInvited) { 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, + 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 }; + return { targets: [ownerTarget, ...(alsoInvited ? ownCopyTarget() : [])], note: null, answeredBy }; } } From 962873800ec78975c433a902419e2d216e09c762 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:05:25 +0100 Subject: [PATCH 10/23] test: cover suggest times, answering for others, the out-of-office block and company calendars against the test servers --- integration/tests/22-ent-calendar.spec.ts | 620 ++++++++++++++++++++++ 1 file changed, 620 insertions(+) create mode 100644 integration/tests/22-ent-calendar.spec.ts diff --git a/integration/tests/22-ent-calendar.spec.ts b/integration/tests/22-ent-calendar.spec.ts new file mode 100644 index 000000000..e4b16b767 --- /dev/null +++ b/integration/tests/22-ent-calendar.spec.ts @@ -0,0 +1,620 @@ +import { test, expect, type Browser, type Page } from '@playwright/test'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { + ENT, ENT_SERVER, adminContext, getPolicy, jmapAs, putPolicy, rawMessage, requireEntEnv, shot, userPage, withPolicy, +} from './helpers/ent'; +import { calendarItem, openCalendar } from './helpers/app'; + +/** + * Enterprise pack, package B (calendar) against the VM 106 test Stalwarts: + * Suggest times (6), answering for someone else (#634, 7), out-of-office + * blocks the calendar (8), company calendars (9) and the free/busy sharing + * the admin sets. Setup goes through the admin API and JMAP, so the spec + * also runs against a build of main to show where it fails there. + */ + +requireEntEnv(); +test.describe.configure({ mode: 'serial' }); + +const CORE = 'urn:ietf:params:jmap:core'; +const CAL = 'urn:ietf:params:jmap:calendars'; +const PRINC = 'urn:ietf:params:jmap:principals'; +const MAIL = 'urn:ietf:params:jmap:mail'; +const VAC = 'urn:ietf:params:jmap:vacationresponse'; +const USING = [CORE, CAL, PRINC]; +const ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone; +const TS = Date.now().toString(36); +const READ_WRITE = { + mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, + mayUpdatePrivate: true, mayRSVP: true, mayShare: false, mayDelete: false, +}; + +type J = Awaited>; + +const pad = (n: number) => String(n).padStart(2, '0'); +/** A local wall clock as a JSCalendar LocalDateTime. */ +const local = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`; + +function nextWorkingDay(from = new Date()): Date { + const d = new Date(from); + d.setHours(0, 0, 0, 0); + do d.setDate(d.getDate() + 1); while (d.getDay() === 0 || d.getDay() === 6); + return d; +} + +async function call(j: J, calls: [string, Record, string][], using = USING) { + const res = await j.request(calls, using); + for (const [name, result] of res.methodResponses) { + if (name === 'error') throw new Error(`JMAP error: ${JSON.stringify(result)}`); + } + return res.methodResponses as [string, Record, string][]; +} + +async function defaultCalendar(j: J, accountId = j.accountId): Promise<{ id: string; name: string; shareWith: Record | null }> { + const [[, r]] = await call(j, [['Calendar/get', { accountId, properties: ['name', 'isDefault', 'shareWith'] }, '0']]); + const list = r.list as Array<{ id: string; name: string; isDefault: boolean; shareWith: Record | null }>; + return list.find((c) => c.isDefault) ?? list[0]; +} + +async function share(j: J, principalId: string, rights: Record | null) { + const cal = await defaultCalendar(j); + await call(j, [['Calendar/set', { accountId: j.accountId, update: { [cal.id]: { [`shareWith/${principalId}`]: rights } } }, '0']]); +} + +async function createEvent(j: J, event: Record, send = false, accountId = j.accountId): Promise<{ id: string; uid: string }> { + const cal = await defaultCalendar(j, accountId); + const [[, r]] = await call(j, [['CalendarEvent/set', { + accountId, create: { e: { '@type': 'Event', calendarIds: { [cal.id]: true }, timeZone: ZONE, ...event } }, sendSchedulingMessages: send, + }, '0']]); + const created = (r.created as Record | undefined)?.e; + if (!created) throw new Error(`event not created: ${JSON.stringify(r)}`); + const [[, got]] = await call(j, [['CalendarEvent/get', { accountId, ids: [created.id], properties: ['uid'] }, '0']]); + return { id: created.id, uid: (got.list as Array<{ uid: string }>)[0].uid }; +} + +async function findByUid(j: J, uid: string, accountId = j.accountId): Promise { + const [[, r]] = await call(j, [['CalendarEvent/query', { accountId, filter: { uid } }, '0']]); + return ((r.ids as string[]) ?? [])[0] ?? null; +} + +async function getEvent(j: J, id: string, accountId = j.accountId) { + const [[, r]] = await call(j, [['CalendarEvent/get', { accountId, ids: [id] }, '0']]); + return (r.list as Array>)[0]; +} + +async function destroyEvents(j: J, ids: Array, accountId = j.accountId) { + const list = ids.filter((id): id is string => !!id); + if (list.length) await call(j, [['CalendarEvent/set', { accountId, destroy: list, sendSchedulingMessages: false }, '0']]).catch(() => {}); +} + +const participant = (name: string, email: string, owner = false) => ({ + '@type': 'Participant', name, email, calendarAddress: `mailto:${email}`, kind: 'individual', scheduleAgent: 'server', + roles: owner ? { owner: true } : { attendee: true }, + participationStatus: owner ? 'accepted' : 'needs-action', expectReply: !owner, +}); + +/** Carol invites `attendees` to an hour starting `hoursAhead` from now; returns her event and its uid. */ +async function carolInvites(title: string, attendees: Array<[string, string]>, hoursAhead = 26) { + const carol = await jmapAs(ENT.carol); + const start = new Date(Date.now() + hoursAhead * 3600_000); + start.setMinutes(0, 0, 0); + const participants: Record = { carol: participant('Ent Carol', ENT.carol, true) }; + attendees.forEach(([name, email], i) => { participants[`a${i}`] = participant(name, email); }); + const created = await createEvent(carol, { + title, start: local(start), duration: 'PT1H', organizerCalendarAddress: `mailto:${ENT.carol}`, participants, + }, true); + return { ...created, carol }; +} + +async function waitForCopy(j: J, uid: string, accountId = j.accountId): Promise { + let id: string | null = null; + await expect.poll(async () => (id = await findByUid(j, uid, accountId)), { timeout: 30_000 }).not.toBeNull(); + return id!; +} + +/** Mail in carol's account whose text carries `marker`, newest first. */ +async function carolMail(marker: string) { + const carol = await jmapAs(ENT.carol); + const [, [, got]] = await call(carol, [ + ['Email/query', { accountId: carol.accountId, filter: { text: marker }, sort: [{ property: 'receivedAt', isAscending: false }] }, 'q'], + ['Email/get', { accountId: carol.accountId, '#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' }, properties: ['subject', 'from', 'blobId'] }, 'g'], + ], [CORE, MAIL]); + return got.list as Array<{ id: string; subject: string; blobId: string; from: Array<{ email: string }> }>; +} + +async function destroyMail(email: string, marker: string) { + const j = await jmapAs(email); + const [[, r]] = await call(j, [['Email/query', { accountId: j.accountId, filter: { text: marker } }, '0']], [CORE, MAIL]).catch(() => [[null, { ids: [] }]] as never); + const ids = (r as { ids?: string[] }).ids ?? []; + if (ids.length) await call(j, [['Email/set', { accountId: j.accountId, destroy: ids }, '0']], [CORE, MAIL]).catch(() => {}); +} + +/** The agenda view: the tab on phones, the "a" shortcut elsewhere. */ +async function showAgenda(page: Page) { + const tab = page.getByRole('button', { name: 'Agenda', exact: true }).locator('visible=true'); + if (await tab.count()) await tab.first().click(); + else await page.locator('body').press('a'); +} + +/** Agenda view (lists what is coming up) and the event detail of `title`. */ +async function openEventInAgenda(page: Page, title: string, calendarHint?: string) { + await openCalendar(page); + await showAgenda(page); + // An agenda row names the event and its calendar. + const rows = page.locator('button', { hasText: title }); + const card = (calendarHint ? rows.filter({ hasText: calendarHint }) : rows).first(); + await expect(card).toBeVisible({ timeout: 30_000 }); + await card.click(); +} + +async function noHorizontalScroll(page: Page) { + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + expect(overflow).toBeLessThanOrEqual(1); +} + +async function closePage(page: Page) { + await page.context().close().catch(() => {}); +} + +// ── 6. Suggest times ───────────────────────────────────────────────── + +async function startEventWith(page: Page, attendees: string[]) { + await openCalendar(page); + // Toolbar on wide screens, the round + button on phones. + await page.locator('[data-tour="create-event-button"]').locator('visible=true').first().click(); + await page.getByLabel('Title').fill(`B find ${TS}`); + const input = page.getByPlaceholder('Add email address or search contacts'); + for (const email of attendees) { + await input.fill(email); + await input.press('Enter'); + } +} + +test('Suggest times proposes a slot free for every attendee whose free/busy is shared', async ({ browser }) => { + test.setTimeout(240_000); + const bob = await jmapAs(ENT.bob); + const carol = await jmapAs(ENT.carol); + const alice = await jmapAs(ENT.alice); + const teamId = await bob.principalIdByEmail(ENT.team); + // Free/busy shares, the way the docs tell users and admins to set them up: + // bob with the group alice is in, carol with alice by name. + await share(bob, teamId, { mayReadFreeBusy: true }); + await share(carol, alice.accountId, { mayReadFreeBusy: true }); + const day = nextWorkingDay(); + const at = (h: number) => { const d = new Date(day); d.setHours(h, 0, 0, 0); return d; }; + const now = new Date(); + now.setMinutes(0, 0, 0); + // Bob is busy from now until 11:00 on the next working day, carol 11 to 12. + const bobBusy = await createEvent(bob, { title: `B busy ${TS}`, start: local(now), duration: `PT${Math.round((at(11).getTime() - now.getTime()) / 60000)}M`, freeBusyStatus: 'busy' }); + const carolBusy = await createEvent(carol, { title: `B busy ${TS}`, start: local(at(11)), duration: 'PT1H', freeBusyStatus: 'busy' }); + const page = await userPage(browser, ENT.alice); + try { + await startEventWith(page, [ENT.bob, ENT.carol, ENT.dave]); + await page.getByTestId('find-a-time-button').click(); + const slots = page.getByTestId('find-a-time-slots').locator('button'); + await expect(slots.first()).toBeVisible({ timeout: 30_000 }); + const first = new Date((await slots.first().getAttribute('data-slot-start'))!); + // Free for bob and carol: not before 12:00 on the next working day. + expect(first.getTime()).toBeGreaterThanOrEqual(at(12).getTime()); + expect(first.getTime()).toBeLessThan(at(20).getTime()); + for (const [j, who] of [[bob, 'bob'], [carol, 'carol']] as const) { + const [[, r]] = await call(alice, [['Principal/getAvailability', { + accountId: alice.accountId, id: j.accountId, utcStart: new Date(Date.now() - 3600_000).toISOString().replace(/\.\d+Z$/, 'Z'), + utcEnd: new Date(at(23).getTime()).toISOString().replace(/\.\d+Z$/, 'Z'), + }, '0']], [CORE, PRINC, 'urn:ietf:params:jmap:principals:availability', CAL]); + const busy = r.list as Array<{ utcStart: string; utcEnd: string }>; + expect(busy.length, `${who} has busy time visible to alice`).toBeGreaterThan(0); + for (const p of busy) { + const overlaps = Date.parse(p.utcStart) < first.getTime() + 3600_000 && Date.parse(p.utcEnd) > first.getTime(); + expect(overlaps, `${who} busy ${p.utcStart}-${p.utcEnd}`).toBe(false); + } + } + // Dave shared nothing and has nothing on: never counted as free. + const note = page.getByTestId('find-a-time-not-checked'); + await expect(note).toContainText(ENT.dave); + await expect(note).toContainText('busy times unknown'); + await expect(page.getByTestId('free-busy-share')).toBeVisible(); + await shot(page, 'b-find-a-time'); + // Picking the slot moves the event there. + await slots.first().click(); + await expect(page.getByTestId('find-a-time-panel')).toContainText('Event moved to'); + await page.keyboard.press('Escape'); + await expect(page.getByTestId('find-a-time-panel')).toHaveCount(0); + await expect(page.getByTestId('find-a-time-button')).toBeFocused(); + } finally { + await closePage(page); + await destroyEvents(bob, [bobBusy.id]); + await destroyEvents(carol, [carolBusy.id]); + await share(bob, teamId, null).catch(() => {}); + await share(carol, alice.accountId, null).catch(() => {}); + } +}); + +test('the suggestion list fits a phone', async ({ browser }) => { + test.setTimeout(180_000); + const page = await userPage(browser, ENT.alice, true); + try { + await startEventWith(page, [ENT.bob]); + await page.getByTestId('find-a-time-button').click(); + await expect(page.getByTestId('find-a-time-panel')).toBeVisible(); + await expect(page.getByTestId('find-a-time-panel').getByRole('status')).not.toContainText('Looking', { timeout: 30_000 }); + await page.getByTestId('find-a-time-panel').scrollIntoViewIfNeeded(); + await noHorizontalScroll(page); + await shot(page, 'b-find-a-time-phone'); + } finally { + await closePage(page); + } +}); + +// ── Free/busy sharing set by the admin ─────────────────────────────── + +test('the admin\'s free/busy group is shared from a user\'s default calendar at sign-in', async ({ browser }) => { + test.setTimeout(180_000); + const admin = await adminContext(browser); + const carol = await jmapAs(ENT.carol); + const teamId = await carol.principalIdByEmail(ENT.team); + await share(carol, teamId, null).catch(() => {}); + await withPolicy(admin, { organisation: { calendars: { feeds: [], freeBusyGroup: ENT.team } } }, async () => { + const page = await userPage(browser, ENT.carol); + try { + await expect.poll(async () => { + const cal = await defaultCalendar(carol); + return (cal.shareWith?.[teamId] as { mayReadFreeBusy?: boolean } | undefined)?.mayReadFreeBusy ?? false; + }, { timeout: 45_000 }).toBe(true); + // Only free/busy, and it shows in the user's sharing list. + const cal = await defaultCalendar(carol); + expect(cal.shareWith?.[teamId]).toMatchObject({ mayReadFreeBusy: true, mayReadItems: false, mayWriteAll: false }); + await openCalendar(page); + await expect(calendarItem(page, cal.name).getByTestId('calendar-shared-indicator')).toBeVisible({ timeout: 30_000 }); + } finally { + await closePage(page); + await share(carol, teamId, null).catch(() => {}); + } + }); + await admin.close(); +}); + +// ── 7. Answering for someone else (#634) ───────────────────────────── + +test('a delegate answers for the calendar\'s owner: the organiser gets a REPLY with SENT-BY', async ({ browser }) => { + test.setTimeout(240_000); + const alice = await jmapAs(ENT.alice); + const bob = await jmapAs(ENT.bob); + const title = `B sentby ${TS}`; + const invite = await carolInvites(title, [['Ent Alice', ENT.alice]]); + const aliceCopy = await waitForCopy(alice, invite.uid); + await share(alice, bob.accountId, READ_WRITE); + const page = await userPage(browser, ENT.bob); + try { + await openEventInAgenda(page, title); + const bar = page.getByTestId('answer-for-owner'); + await expect(bar).toBeVisible({ timeout: 30_000 }); + await expect(bar).toContainText('Answer for'); + await shot(page, 'b-answer-for'); + await bar.getByRole('button', { name: 'Accept' }).click(); + + // Carol's inbox: the REPLY from alice with bob in SENT-BY. + let raw = ''; + await expect.poll(async () => { + for (const m of await carolMail(title)) { + const text = (await rawMessage(ENT.carol, m.blobId)).replace(/\r?\n[ \t]/g, ''); + if (/METHOD:REPLY/.test(text)) { raw = text; return true; } + } + return false; + }, { timeout: 60_000 }).toBe(true); + const attendee = raw.split(/\r?\n/).find((l) => l.startsWith('ATTENDEE') && /ent-alice@example\.org/i.test(l)) ?? ''; + expect(attendee).toMatch(/PARTSTAT=ACCEPTED/); + expect(attendee).toMatch(/SENT-BY="mailto:ent-bob@example\.org"/i); + expect(raw).toMatch(/^From:.*ent-alice@example\.org/im); + // Her copy changes alice. + await expect.poll(async () => { + const event = await getEvent(invite.carol, invite.id); + const p = Object.values(event.participants as Record).find((x) => x.email === ENT.alice); + return p?.participationStatus; + }, { timeout: 30_000 }).toBe('accepted'); + // Alice's own view says who answered. + const ownerView = await getEvent(alice, aliceCopy); + const aliceP = Object.values(ownerView.participants as Record).find((x) => x.email === ENT.alice); + expect(aliceP?.sentBy).toBe(`mailto:${ENT.bob}`); + } finally { + await closePage(page); + await share(alice, bob.accountId, null).catch(() => {}); + await destroyEvents(invite.carol, [invite.id]); + await destroyEvents(alice, [aliceCopy]); + await destroyMail(ENT.carol, title); + await destroyMail(ENT.alice, title); + } +}); + +test('with the delegate invited too: both answers on 1.0, the owner\'s refused on 0.16', async ({ browser }) => { + test.setTimeout(240_000); + const alice = await jmapAs(ENT.alice); + const bob = await jmapAs(ENT.bob); + const title = `B both ${TS}`; + const invite = await carolInvites(title, [['Ent Alice', ENT.alice], ['Ent Bob', ENT.bob]], 28); + const aliceCopy = await waitForCopy(alice, invite.uid); + const bobCopy = await waitForCopy(bob, invite.uid); + await share(alice, bob.accountId, READ_WRITE); + const page = await userPage(browser, ENT.bob); + try { + // The copy in alice's calendar; bob's own is listed too. + await openEventInAgenda(page, title, ENT.alice); + if (ENT_SERVER === '0.16') { + await expect(page.getByTestId('answer-note-also_invited')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId('answer-for-owner')).toHaveCount(0); + } else { + await expect(page.getByTestId('answer-for-owner')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId('answer-self')).toBeVisible(); + } + await shot(page, 'b-answer-both-invited'); + } finally { + await closePage(page); + await share(alice, bob.accountId, null).catch(() => {}); + await destroyEvents(invite.carol, [invite.id]); + await destroyEvents(alice, [aliceCopy]); + await destroyEvents(bob, [bobCopy]); + await destroyMail(ENT.carol, title); + } +}); + +test('a member answers for a group mailbox as the group, without SENT-BY', async ({ browser }) => { + test.setTimeout(240_000); + const alice = await jmapAs(ENT.alice); + const title = `B group ${TS}`; + const teamAccount = alice.accountIdByName(ENT.team); + const invite = await carolInvites(title, [['Ent Team', ENT.team]], 30); + const teamCopy = await waitForCopy(alice, invite.uid, teamAccount); + const page = await userPage(browser, ENT.alice); + try { + await openEventInAgenda(page, title); + const bar = page.getByTestId('answer-for-owner'); + await expect(bar).toBeVisible({ timeout: 30_000 }); + await bar.getByRole('button', { name: 'Accept' }).click(); + let raw = ''; + await expect.poll(async () => { + for (const m of await carolMail(title)) { + const text = (await rawMessage(ENT.carol, m.blobId)).replace(/\r?\n[ \t]/g, ''); + if (/METHOD:REPLY/.test(text)) { raw = text; return true; } + } + return false; + }, { timeout: 60_000 }).toBe(true); + const attendee = raw.split(/\r?\n/).find((l) => l.startsWith('ATTENDEE') && /ent-team@example\.org/i.test(l)) ?? ''; + expect(attendee).toMatch(/PARTSTAT=ACCEPTED/); + expect(attendee).not.toMatch(/SENT-BY/i); + expect(raw).toMatch(/^From:.*ent-team@example\.org/im); + } finally { + await closePage(page); + await destroyEvents(invite.carol, [invite.id]); + await destroyEvents(alice, [teamCopy], teamAccount); + await destroyMail(ENT.carol, title); + } +}); + +test('the answer bars fit a phone', async ({ browser }) => { + test.setTimeout(240_000); + const alice = await jmapAs(ENT.alice); + const bob = await jmapAs(ENT.bob); + const title = `B phone ${TS}`; + const invite = await carolInvites(title, [['Ent Alice', ENT.alice]], 32); + const aliceCopy = await waitForCopy(alice, invite.uid); + await share(alice, bob.accountId, READ_WRITE); + const page = await userPage(browser, ENT.bob, true); + try { + await openEventInAgenda(page, title); + await expect(page.getByTestId('answer-for-owner')).toBeVisible({ timeout: 30_000 }); + await noHorizontalScroll(page); + const box = await page.getByTestId('answer-for-owner').boundingBox(); + expect(box && box.x + box.width).toBeLessThanOrEqual(390); + await shot(page, 'b-answer-for-phone'); + } finally { + await closePage(page); + await share(alice, bob.accountId, null).catch(() => {}); + await destroyEvents(invite.carol, [invite.id]); + await destroyEvents(alice, [aliceCopy]); + await destroyMail(ENT.carol, title); + } +}); + +// ── 8. Out-of-office blocks the calendar ───────────────────────────── + +async function oooEvent(alice: J) { + const id = await findByUid(alice, `bulwark-ooo-${alice.accountId}`); + return id ? getEvent(alice, id) : null; +} + +const datetimeLocal = (d: Date) => local(d).slice(0, 16); + +test('the out-of-office busy event appears, moves with the dates and goes away when switched off', async ({ browser }) => { + test.setTimeout(240_000); + const alice = await jmapAs(ENT.alice); + const resetReply = () => call(alice, [['VacationResponse/set', { accountId: alice.accountId, update: { singleton: { isEnabled: false, fromDate: null, toDate: null } } }, '0']], [CORE, VAC]); + await resetReply(); + const leftover = await findByUid(alice, `bulwark-ooo-${alice.accountId}`); + await destroyEvents(alice, [leftover]); + const from = new Date(); from.setDate(from.getDate() + 10); from.setHours(9, 0, 0, 0); + const to = new Date(); to.setDate(to.getDate() + 12); to.setHours(17, 0, 0, 0); + const later = new Date(to); later.setDate(later.getDate() + 2); + const page = await userPage(browser, ENT.alice); + try { + await page.goto('/settings/vacation'); + const dates = page.locator('input[type="datetime-local"]'); + await dates.nth(0).fill(datetimeLocal(from)); + await dates.nth(1).fill(datetimeLocal(to)); + const block = page.getByTestId('vacation-block-calendar'); + await expect(block).toBeChecked(); + await page.locator('[data-settings-section="reply"]').getByRole('switch').first().click(); + + await expect.poll(async () => (await oooEvent(alice))?.start ?? null, { timeout: 45_000 }).toBe(local(from)); + const event = (await oooEvent(alice))!; + expect(event.freeBusyStatus).toBe('busy'); + expect(event.participants ?? null).toBeNull(); + await shot(page, 'b-ooo-settings'); + + // New end date: the event moves along. + await dates.nth(1).fill(datetimeLocal(later)); + await expect.poll(async () => (await oooEvent(alice))?.duration ?? null, { timeout: 45_000 }).toBe('P4DT8H'); + + // Reply off before it started: the event goes away. + await page.locator('[data-settings-section="reply"]').getByRole('switch').first().click(); + await expect.poll(async () => await oooEvent(alice), { timeout: 45_000 }).toBeNull(); + } finally { + await closePage(page); + await resetReply().catch(() => {}); + const left = await findByUid(alice, `bulwark-ooo-${alice.accountId}`).catch(() => null); + await destroyEvents(alice, [left]); + } +}); + +test('the out-of-office checkbox fits a phone', async ({ browser }) => { + test.setTimeout(180_000); + const alice = await jmapAs(ENT.alice); + const from = new Date(); from.setDate(from.getDate() + 20); from.setHours(9, 0, 0, 0); + const to = new Date(from); to.setDate(to.getDate() + 1); + await call(alice, [['VacationResponse/set', { accountId: alice.accountId, update: { singleton: { + isEnabled: false, fromDate: from.toISOString().replace(/\.\d+Z$/, 'Z'), toDate: to.toISOString().replace(/\.\d+Z$/, 'Z'), + } } }, '0']], [CORE, VAC]); + const page = await userPage(browser, ENT.alice, true); + try { + await page.goto('/settings/vacation'); + // Phone width opens the settings list first. + const tab = page.getByRole('button', { name: /Vacation|Out of office/i }).first(); + if (await tab.isVisible().catch(() => false)) await tab.click(); + const block = page.getByTestId('vacation-block-calendar'); + await expect(block).toBeVisible({ timeout: 30_000 }); + await block.scrollIntoViewIfNeeded(); + await noHorizontalScroll(page); + await shot(page, 'b-ooo-phone'); + } finally { + await closePage(page); + await call(alice, [['VacationResponse/set', { accountId: alice.accountId, update: { singleton: { isEnabled: false, fromDate: null, toDate: null } } }, '0']], [CORE, VAC]).catch(() => {}); + } +}); + +// ── 9. Company calendars ───────────────────────────────────────────── + +function icsServer(title: string): Promise<{ server: Server; url: string; hits: () => number }> { + let hits = 0; + const day = nextWorkingDay(); + const ymd = `${day.getFullYear()}${pad(day.getMonth() + 1)}${pad(day.getDate())}`; + const body = [ + 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Bulwark spec//EN', + 'BEGIN:VEVENT', `UID:b-company-${TS}@example.org`, `DTSTAMP:${ymd}T000000Z`, `DTSTART;VALUE=DATE:${ymd}`, `SUMMARY:${title}`, 'END:VEVENT', + 'END:VCALENDAR', '', + ].join('\r\n'); + const server = createServer((_req, res) => { + hits++; + res.writeHead(200, { 'content-type': 'text/calendar' }); + res.end(body); + }); + return new Promise((resolve) => server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as AddressInfo; + resolve({ server, url: `http://127.0.0.1:${port}/team.ics`, hits: () => hits }); + })); +} + +test('a company calendar scoped to a group shows for its members only, and its address stays on the server', async ({ browser }) => { + test.setTimeout(240_000); + const title = `B company ${TS}`; + const feed = await icsServer(title); + const admin = await adminContext(browser); + const scopes = [{ + id: 'b-team', label: 'B team', match: { groups: [ENT.team] }, + policy: { organisation: { calendars: { feeds: [{ id: 'b-team-feed', name: 'B Team calendar', url: feed.url, color: '#16a34a', refreshMinutes: 60 }] } } }, + }]; + try { + await withPolicy(admin, { scopes }, async () => { + const alicePage = await userPage(browser, ENT.alice); + try { + await openCalendar(alicePage); + await expect(alicePage.getByTestId('company-calendars-section')).toBeVisible({ timeout: 30_000 }); + await expect(calendarItem(alicePage, 'B Team calendar')).toBeVisible(); + await showAgenda(alicePage); + await expect(alicePage.getByText(title).first()).toBeVisible({ timeout: 45_000 }); + expect(feed.hits()).toBeGreaterThan(0); + // The policy alice's browser gets has no address. + const policy = await (await alicePage.context().request.get('/api/admin/policy')).text(); + expect(policy).not.toContain(feed.url); + expect(policy).toContain('B Team calendar'); + await shot(alicePage, 'b-company-calendar'); + // Hiding it takes its events off the grid. + await calendarItem(alicePage, 'B Team calendar').click(); + await expect(alicePage.getByText(title)).toHaveCount(0); + await calendarItem(alicePage, 'B Team calendar').click(); + } finally { + await closePage(alicePage); + } + const carolPage = await userPage(browser, ENT.carol); + try { + await openCalendar(carolPage); + await expect(carolPage.locator('[data-testid="calendar-item"]').first()).toBeVisible(); + await expect(calendarItem(carolPage, 'B Team calendar')).toHaveCount(0); + await expect(carolPage.getByTestId('company-calendars-section')).toHaveCount(0); + // Not by id either: the server resolves carol's scopes itself. + const res = await carolPage.context().request.post('/api/fetch-ical', { + data: { feedId: 'b-team-feed' }, headers: { Origin: new URL(carolPage.url()).origin }, + }); + expect(res.status()).toBe(404); + } finally { + await closePage(carolPage); + } + }); + } finally { + await admin.close(); + feed.server.close(); + } +}); + +// ── The admin page ─────────────────────────────────────────────────── + +async function adminPage(browser: Browser, phone: boolean): Promise { + const context = await adminContext(browser, phone); + const page = await context.newPage(); + await page.goto('/admin/org-calendars'); + await expect(page.getByText('Company calendars').first()).toBeVisible({ timeout: 30_000 }); + return page; +} + +test('Organisation > Calendars saves company calendars, the free/busy group and the out-of-office lock', async ({ browser }) => { + test.setTimeout(180_000); + const page = await adminPage(browser, false); + const saved = await getPolicy(page.context()); + try { + await page.getByTestId('org-feed-add').click(); + await page.getByTestId('org-feed-name').last().fill('B Holidays'); + await page.getByTestId('org-feed-url').last().fill('webcal://calendar.example.org/holidays.ics'); + await page.getByLabel('Share free/busy with (group address)').fill(ENT.team); + await page.getByLabel('Out-of-office blocks the calendar').selectOption('never'); + await page.getByRole('button', { name: 'Save' }).last().click(); + await expect.poll(async () => { + const policy = await getPolicy(page.context()) as { + organisation?: { calendars?: { feeds?: Array<{ name: string; url: string }>; freeBusyGroup?: string } }; + restrictions?: Record; + }; + return { + feed: policy.organisation?.calendars?.feeds?.find((f) => f.name === 'B Holidays')?.url, + group: policy.organisation?.calendars?.freeBusyGroup, + lock: policy.restrictions?.vacationBlockCalendar, + }; + }, { timeout: 20_000 }).toEqual({ + feed: 'https://calendar.example.org/holidays.ics', + group: ENT.team, + lock: { locked: true, value: false }, + }); + await shot(page, 'b-admin-calendars'); + } finally { + await putPolicy(page.context(), saved); + await page.context().close(); + } +}); + +test('Organisation > Calendars fits a phone', async ({ browser }) => { + test.setTimeout(120_000); + const page = await adminPage(browser, true); + try { + await noHorizontalScroll(page); + await shot(page, 'b-admin-calendars-phone'); + } finally { + await page.context().close(); + } +}); From a9e1b7bf435de516cf7a6e27c10eb697cf13233b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:29:13 +0100 Subject: [PATCH 11/23] fix: answer invitations in a group or shared mailbox as that account again #634 The banner only answered on the user's own copy since 877198a4b, so an invitation in a group mailbox (or a boss's mailbox a delegate reads) was imported into the user's own calendar and answered there: no reply reached the organiser and the user got a duplicate. It now uses the copy in the message's own account and answers as that account, as the group without SENT-BY, as the person with SENT-BY naming the user. The user's own answer bar on a calendar shared with them also finds the same occurrence of their own copy of a series. --- .../calendar/__tests__/answer-bars.test.tsx | 28 +++++++ components/calendar/answer-bars.tsx | 42 ++++++---- .../calendar-invitation-banner.test.tsx | 83 +++++++++++++++++++ .../email/calendar-invitation-banner.tsx | 57 ++++++++++--- 4 files changed, 181 insertions(+), 29 deletions(-) diff --git a/components/calendar/__tests__/answer-bars.test.tsx b/components/calendar/__tests__/answer-bars.test.tsx index 875232e23..7370b1859 100644 --- a/components/calendar/__tests__/answer-bars.test.tsx +++ b/components/calendar/__tests__/answer-bars.test.tsx @@ -63,3 +63,31 @@ describe('AnswerBars', () => { expect(screen.queryByTestId('answer-for-owner')).toBeNull(); }); }); + +describe('useAnswerView with the user invited too', () => { + afterEach(() => { + useAuthStore.setState({ client: null } as never); + }); + + it("answers for the user on the same occurrence of their own copy of a series", async () => { + useAuthStore.setState({ client: { getAccountOwners: async () => ({}), hasAccountCapability: () => true } } as never); + const both = { + ...event, + participants: { + ...event.participants, + bob: { '@type': 'Participant', name: 'Bob', email: 'bob@example.org', roles: { attendee: true }, participationStatus: 'needs-action' }, + }, + } as unknown as CalendarEvent; + const occurrence = (id: string, recurrenceId: string, shared: boolean) => + ({ ...both, id, recurrenceId, isShared: shared, accountId: shared ? 'alice' : undefined } as unknown as CalendarEvent); + const shared = occurrence('alice:occ-2', '2026-11-11T14:00:00', true); + // The store holds a series as its expanded occurrences. + useCalendarStore.setState({ events: [occurrence('own-1', '2026-11-04T14:00:00', false), occurrence('own-2', '2026-11-11T14:00:00', false), shared] }); + function Both() { + const view = useAnswerView(shared, calendar, ['bob@example.org'], true); + return createElement('div', { 'data-testid': 'targets' }, view.targets.map((t) => `${t.kind}:${t.eventId}`).join(',')); + } + render(createElement(Both)); + expect(await screen.findByTestId('targets')).toHaveTextContent('owner:alice:occ-2,self:own-2'); + }); +}); diff --git a/components/calendar/answer-bars.tsx b/components/calendar/answer-bars.tsx index b0a449ec0..464f0d5bb 100644 --- a/components/calendar/answer-bars.tsx +++ b/components/calendar/answer-bars.tsx @@ -6,7 +6,7 @@ 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 { accountOwnerOf, resolveAnswerTargets, type AccountOwner, type AnswerTarget, type AnswerView } from "@/lib/calendar-delegate"; import { RsvpButtons } from "./rsvp-buttons"; type Status = CalendarParticipant["participationStatus"]; @@ -30,6 +30,26 @@ function loadAccountOwner(client: IJMAPClient, accountId: string): Promise(null); + useEffect(() => { + if (!client || !accountId) return; + let cancelled = false; + void loadAccountOwner(client, accountId).then((found) => { + if (!cancelled) setPrincipal({ accountId, principal: found }); + }); + return () => { cancelled = true; }; + }, [client, accountId]); + return useMemo( + () => (accountId ? accountOwnerOf(principal?.accountId === accountId ? principal.principal : null, accountName) : null), + [accountId, accountName, principal], + ); +} + /** * 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 @@ -44,30 +64,20 @@ export function useAnswerView( 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]); + const owner = useAccountOwner(client, sharedAccountId, calendar?.accountName); return useMemo(() => { if (!event) return { targets: [], note: null, answeredBy: null }; - const owner = sharedAccountId - ? accountOwnerOf(principal?.accountId === sharedAccountId ? principal.principal : null, calendar?.accountName) - : null; + // The same occurrence in the user's own calendar: the store holds a + // series as its expanded occurrences. const ownCopy = event.uid - ? events.find((e) => e.uid === event.uid && !e.isShared && e.id !== event.id && !e.recurrenceId) ?? null + ? events.find((e) => e.uid === event.uid && !e.isShared && e.id !== event.id && (e.recurrenceId ?? null) === (event.recurrenceId ?? null)) ?? 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]); + }, [event, calendar, userEmails, canRsvpSelf, sharedAccountId, owner, events, client]); } /** diff --git a/components/email/__tests__/calendar-invitation-banner.test.tsx b/components/email/__tests__/calendar-invitation-banner.test.tsx index a22638249..f6ec9c35d 100644 --- a/components/email/__tests__/calendar-invitation-banner.test.tsx +++ b/components/email/__tests__/calendar-invitation-banner.test.tsx @@ -12,6 +12,10 @@ const mocks = vi.hoisted(() => { getCalendarsAccountId: vi.fn(() => 'calendar-account'), getCalendarEvent: vi.fn(), queryCalendarEvents: vi.fn(async (): Promise => []), + // Who owns a shared or group account (Principal/get). + getAccountOwners: vi.fn(async (ids: string[]) => Object.fromEntries(ids.map((id) => [id, id === 'team' + ? { id, type: 'group', name: 'team@example.com', email: 'team@example.com' } + : { id, type: 'individual', name: id + '@example.com', email: id + '@example.com' }]))), }; const sourceClientMock = { @@ -613,6 +617,85 @@ describe('CalendarInvitationBanner', () => { }); }); + describe('an invitation in a shared or group mailbox (#634)', () => { + const calendarsBefore = mocks.calendarState.calendars; + const withAccountCalendar = (accountId: string) => { + mocks.calendarState.calendars = [ + ...calendarsBefore, + { id: accountId + ':b', name: accountId, color: '#16a34a', isDefault: true, isShared: true, accountId, accountName: accountId + '@example.com', myRights: { mayWriteAll: true } } as never, + ]; + }; + const invited = (address: string) => ({ + [address.split('@')[0]]: { '@type': 'Participant', email: address, name: address, roles: { attendee: true }, participationStatus: 'needs-action' }, + }); + + it("answers in the group's calendar as the group, without SENT-BY", async () => { + withAccountCalendar('team'); + try { + const participants = invited('team@example.com'); + mocks.calendarState.events = [{ id: 'team:event-10', uid: 'uid-10', start: '2026-03-30T09:00:00Z', participants, isShared: true, accountId: 'team' }]; + mocks.clientMock.parseCalendarEvents.mockResolvedValue([{ uid: 'uid-10', title: 'Team meeting', start: '2026-03-30T09:00:00Z', participants }]); + mocks.calendarState.rsvpEvent.mockResolvedValue(true); + + render()} />); + await waitFor(() => expect(mocks.clientMock.getAccountOwners).toHaveBeenCalledWith(['team'])); + fireEvent.click(await screen.findByText('Accept')); + + await waitFor(() => { + expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(mocks.clientMock, 'team:event-10', 'team', 'accepted', null); + }); + expect(mocks.calendarState.importEvents).not.toHaveBeenCalled(); + } finally { + mocks.calendarState.calendars = calendarsBefore; + } + }); + + it("answers in the boss's calendar for the boss, naming the delegate with SENT-BY", async () => { + withAccountCalendar('boss'); + try { + const participants = invited('boss@example.com'); + mocks.calendarState.events = [{ id: 'boss:event-11', uid: 'uid-11', start: '2026-03-30T09:00:00Z', participants, isShared: true, accountId: 'boss' }]; + mocks.clientMock.parseCalendarEvents.mockResolvedValue([{ uid: 'uid-11', title: 'Board', start: '2026-03-30T09:00:00Z', participants }]); + mocks.calendarState.rsvpEvent.mockResolvedValue(true); + + render()} />); + await waitFor(() => expect(mocks.clientMock.getAccountOwners).toHaveBeenCalledWith(['boss'])); + fireEvent.click(await screen.findByText('Accept')); + + await waitFor(() => { + expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith( + mocks.clientMock, 'boss:event-11', 'boss', 'accepted', null, undefined, { sentBy: 'mailto:user@example.com' }, + ); + }); + } finally { + mocks.calendarState.calendars = calendarsBefore; + } + }); + + it("adds an invitation not yet in the group's calendar there, not to the user's own", async () => { + withAccountCalendar('team'); + try { + const participants = invited('team@example.com'); + mocks.calendarState.events = []; + mocks.clientMock.parseCalendarEvents.mockResolvedValue([{ uid: 'uid-12', title: 'Team offsite', start: '2026-03-30T09:00:00Z', participants }]); + mocks.calendarState.importEvents.mockImplementation(async () => { + mocks.calendarState.events = [{ id: 'team:event-12', uid: 'uid-12', start: '2026-03-30T09:00:00Z', participants, isShared: true, accountId: 'team' }]; + return 1; + }); + mocks.calendarState.rsvpEvent.mockResolvedValue(true); + + render()} />); + // The Accept button shows once the group is known as the attendee. + fireEvent.click(await screen.findByText('Accept')); + + await waitFor(() => expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(mocks.clientMock, 'team:event-12', 'team', 'accepted', null)); + expect(mocks.calendarState.importEvents.mock.calls[0][2]).toBe('team:b'); + } finally { + mocks.calendarState.calendars = calendarsBefore; + } + }); + }); + 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 3f858fde4..bb0fa77c5 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -47,6 +47,7 @@ import { getEventStartDate } from '@/lib/calendar-utils'; import { sanitizeColor } from '@/components/calendar/event-card'; import { RecipientPopover } from './recipient-popover'; import { useCalendarErrorMessage } from '@/hooks/use-calendar-error-message'; +import { useAccountOwner } from '@/components/calendar/answer-bars'; interface InvitationChangeItem { label: string; @@ -427,6 +428,31 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp return Array.from(new Set(emails)); })); const calendarInvitationParsingEnabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled); + const storeCalendars = useCalendarStore((s) => s.calendars); + // An invitation in a group mailbox or in a mailbox another person shared + // (a delegate reading the boss's mail) is answered in that account's + // calendar, as that account: as the group without SENT-BY, as the person + // with SENT-BY naming the user (lib/calendar-delegate.ts). Only for an + // account of the active login, whose calendars the store holds. + const mailAccountId = email.sourceAccountId ?? sharedOwnerAccountId ?? null; + const ownAccountIds = [client?.getAccountId?.(), client?.getCalendarsAccountId?.()]; + const sharedMailAccount = mailAccountId && parseClient === client && !ownAccountIds.includes(mailAccountId) + ? mailAccountId + : null; + const sharedAccountCalendars = sharedMailAccount ? storeCalendars.filter((c) => c.accountId === sharedMailAccount && !c.isTasksOnly) : []; + const sharedCalendar = sharedAccountCalendars.find((c) => c.isDefault) ?? sharedAccountCalendars[0] ?? null; + const mailOwner = useAccountOwner( + client, + sharedMailAccount, + sharedCalendar?.accountName ?? (sharedMailAccount ? client?.getAccountInfo?.(sharedMailAccount)?.name : undefined), + ); + const answerAs = sharedMailAccount && mailOwner ? mailOwner : null; + // The addresses that pick "your" participant in the invitation. + const actingEmails = answerAs ? answerAs.emails : identityEmails; + // Answering for a person (not a group) names the user with SENT-BY. + const answerOptions = answerAs && !answerAs.isGroup && identityEmails[0] + ? { sentBy: `mailto:${identityEmails[0]}` } + : undefined; const timeFormat = useSettingsStore((s) => s.timeFormat); const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, deleteEvent, events: storeEvents, setSelectedDate } = useCalendarStore(useShallow((state) => ({ calendars: state.calendars, @@ -572,20 +598,22 @@ 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). + // The copy in the account the message belongs to. For the user's own + // mailbox that is their own calendar: 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 && !e.isShared) + ? storeEvents.find((e) => e.uid === parsedEvent.uid && (sharedMailAccount ? e.accountId === sharedMailAccount : !e.isShared)) : null; const currentUserParticipant = existingEvent - ? findParticipantByEmails(existingEvent, identityEmails) + ? findParticipantByEmails(existingEvent, actingEmails) : null; const fallbackParsedParticipant = !currentUserParticipant && parsedEvent - ? findParticipantByEmails(parsedEvent, identityEmails) + ? findParticipantByEmails(parsedEvent, actingEmails) : null; const participantForUser = currentUserParticipant || fallbackParsedParticipant; @@ -661,7 +689,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const handleRsvp = async (status: 'accepted' | 'tentative' | 'declined') => { if (!client || !parsedEvent || isProcessing) return; - const calId = selectedCalendarId || calendars.find((c) => c.isDefault)?.id || calendars[0]?.id; + // An invitation of a shared or group mailbox goes into that account's calendar. + const calId = sharedMailAccount + ? sharedCalendar?.id + : selectedCalendarId || calendars.find((c) => c.isDefault)?.id || calendars[0]?.id; setActionNotice(null); setActionError(null); setIsProcessing(true); @@ -677,7 +708,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp ? await resolveExistingEventForRsvp() : null; const existingEventParticipant = eventForRsvp - ? findParticipantByEmails(eventForRsvp, identityEmails) + ? findParticipantByEmails(eventForRsvp, actingEmails) : null; const canFallbackToParsedParticipant = Boolean( existingEvent @@ -686,7 +717,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp ); if (eventForRsvp && existingEventParticipant) { // false: a plugin stopped it and said why; keep the old answer. - if (!(await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp))) return; + if (!(await (answerOptions ? rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp, undefined, answerOptions) : rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp)))) return; setRsvpStatus(status); setActionNotice(t('rsvp_sent')); setState('parsed'); @@ -719,12 +750,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp if (imported === null) return; if (imported > 0) { const newEvent = useCalendarStore.getState().events.find( - (e) => e.uid === parsedEvent.uid + (e) => e.uid === parsedEvent.uid && (sharedMailAccount ? e.accountId === sharedMailAccount : !e.isShared) ); const participant = myParticipant - || (newEvent ? findParticipantByEmails(newEvent, identityEmails) : null); + || (newEvent ? findParticipantByEmails(newEvent, actingEmails) : null); if (newEvent && participant) { - if (!(await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp))) return; + if (!(await (answerOptions ? rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp, undefined, answerOptions) : rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp)))) return; setRsvpStatus(status); setActionNotice(t('rsvp_sent')); } else { From f007d01c298aa7242d37e5a41375e0909fb91491 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:30:47 +0100 Subject: [PATCH 12/23] fix: keep company calendar addresses back without a session secret --- .../__tests__/policy-feed-urls-route.test.ts | 22 ++++++++++++++++++- app/api/admin/policy/route.ts | 22 +++++++++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/app/api/admin/__tests__/policy-feed-urls-route.test.ts b/app/api/admin/__tests__/policy-feed-urls-route.test.ts index 09716346c..f1cb8b08e 100644 --- a/app/api/admin/__tests__/policy-feed-urls-route.test.ts +++ b/app/api/admin/__tests__/policy-feed-urls-route.test.ts @@ -7,6 +7,10 @@ import { NextRequest } from 'next/server'; const who = vi.hoisted(() => ({ identity: null as null | { admin: true } | { admin: false; addresses: string[] }, + adminCookie: false, +})); +vi.mock('next/headers', () => ({ + cookies: async () => ({ get: (name: string) => (name === 'admin_session' && who.adminCookie ? { value: 'token' } : undefined) }), })); vi.mock('@/lib/auth/instance-details', () => ({ canSeeInstanceDetails: async () => true, @@ -29,7 +33,11 @@ vi.mock('@/lib/admin/config-manager', () => ({ }, })); vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } })); -vi.mock('@/lib/admin/session', () => ({ requireAdminAuth: vi.fn(), getClientIP: () => '0.0.0.0' })); +vi.mock('@/lib/admin/session', () => ({ + requireAdminAuth: vi.fn(), + getClientIP: () => '0.0.0.0', + verifyCurrentAdminSession: async (token: string) => (token === 'token' ? { admin: true } : null), +})); vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn() })); import { GET } from '@/app/api/admin/policy/route'; @@ -38,6 +46,7 @@ const get = async () => (await GET(new NextRequest('https://mail.example/api/adm beforeEach(() => { who.identity = null; + who.adminCookie = false; }); describe('GET /api/admin/policy company calendar addresses', () => { @@ -56,4 +65,15 @@ describe('GET /api/admin/policy company calendar addresses', () => { expect(body.organisation.calendars.feeds[0].url).toBe(FEED.url); expect(body.scopes[0].policy.organisation.calendars.feeds[0].url).toBe(TEAM_FEED.url); }); + + // No session secret: the server can't tell users apart (signedInIdentity null). + it('keeps the addresses back without a session secret unless the admin dashboard asks', async () => { + const anyone = await get(); + expect(JSON.stringify(anyone)).not.toContain('token=secret'); + expect(JSON.stringify(anyone)).not.toContain('key=k'); + expect(anyone.organisation.calendars.feeds[0]).toMatchObject({ id: 'hol', url: '', urlHeld: true }); + who.adminCookie = true; + const admin = await get(); + expect(admin.organisation.calendars.feeds[0].url).toBe(FEED.url); + }); }); diff --git a/app/api/admin/policy/route.ts b/app/api/admin/policy/route.ts index 9f796f9ad..1d3246cbf 100644 --- a/app/api/admin/policy/route.ts +++ b/app/api/admin/policy/route.ts @@ -1,9 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { completePolicy, configManager, isPolicyConflict } from '@/lib/admin/config-manager'; -import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; +import { cookies } from 'next/headers'; +import { requireAdminAuth, getClientIP, verifyCurrentAdminSession } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; import { logger } from '@/lib/logger'; -import { POLICY_DOMAINS_HEADER, POLICY_SCOPE_HEADER, type SettingsPolicy } from '@/lib/admin/types'; +import { ADMIN_SESSION_COOKIE, POLICY_DOMAINS_HEADER, POLICY_SCOPE_HEADER, type SettingsPolicy } from '@/lib/admin/types'; import { canSeeInstanceDetails, signedInIdentity } from '@/lib/auth/instance-details'; import { bearerIdentity } from '@/lib/auth/bearer-identity'; import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable'; @@ -71,6 +72,14 @@ export async function GET(request: NextRequest) { // answers with all of them (documented). const identity = await signedInIdentity(); if (identity !== null && !identity.admin) return userPolicyResponse(policy, identity.addresses); + // Without a session secret only the admin dashboard's own session tells + // the admin apart; everyone else gets the feeds without their addresses, + // which may hold tokens. + if (identity === null && !(await hasAdminSession())) { + return NextResponse.json({ ...stripFeedUrls(policy), scopes: stripFeedUrls(policy).scopes ?? [] }, { + headers: { 'Cache-Control': 'no-store' }, + }); + } // The whole policy, as the admin pages edit it: with its version, which // they send back as If-Match when they save. return NextResponse.json({ ...policy, scopes: policy.scopes ?? [] }, { @@ -84,6 +93,15 @@ export async function GET(request: NextRequest) { } } +async function hasAdminSession(): Promise { + try { + const token = (await cookies()).get(ADMIN_SESSION_COOKIE)?.value; + return !!token && (await verifyCurrentAdminSession(token)) !== null; + } catch { + return false; + } +} + /** * The policy for a signed-in user who is not the admin, with the addresses * they sign in with. Company calendar addresses stay here: the browser From 0fcdc2ece4db10086242c4ebfeea2958dc21f1a2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:35:56 +0100 Subject: [PATCH 13/23] fix: reach a company calendar on a private address only when its host names the intranet --- app/api/fetch-ical/route.ts | 21 ++++-- .../sections/company-calendars-section.tsx | 6 +- integration/tests/22-ent-calendar.spec.ts | 12 +++- lib/__tests__/fetch-ical-feed-route.test.ts | 24 +++++-- lib/__tests__/url-guard-private-host.test.ts | 66 ++++++++++++++----- lib/admin/__tests__/company-feeds.test.ts | 12 +++- lib/admin/company-feeds.ts | 13 +++- lib/security/url-guard.ts | 65 +++++++++++++++--- 8 files changed, 176 insertions(+), 43 deletions(-) diff --git a/app/api/fetch-ical/route.ts b/app/api/fetch-ical/route.ts index 3058e129a..78639ab48 100644 --- a/app/api/fetch-ical/route.ts +++ b/app/api/fetch-ical/route.ts @@ -4,7 +4,7 @@ import { getStalwartCredentials, type StalwartCredentials } from '@/lib/stalwart import { DisallowedUrlError, fetchPublicUrl, type PublicFetchResponse } from '@/lib/security/url-guard'; import { readBodyCapped } from '@/lib/security/capped-body'; import { configManager } from '@/lib/admin/config-manager'; -import { feedForSubject, feedHostname } from '@/lib/admin/company-feeds'; +import { feedForSubject, intranetFeedHost } from '@/lib/admin/company-feeds'; import { companyFeedCache } from '@/lib/admin/company-feed-cache'; import { serverPolicySubject } from '@/lib/admin/server-policy-subject'; @@ -66,7 +66,7 @@ type FetchOutcome = */ async function fetchIcal( url: string, - options: { allowPrivateHost?: string; etag?: string | null; lastModified?: string | null } = {}, + options: { allowPrivateHost?: string; etag?: string | null; lastModified?: string | null; requireCalendar?: boolean } = {}, ): Promise { const fail = (error: string, status: number): FetchOutcome => ({ kind: 'error', response: NextResponse.json({ error }, { status }) }); const extracted = extractBasicAuth(url); @@ -97,7 +97,9 @@ async function fetchIcal( try { const init = { signal: controller.signal, headers }; - response = options.allowPrivateHost + // The intranet exception holds for the admin's own address only, not + // for a redirect to another port or host. + response = options.allowPrivateHost && new URL(currentUrl).origin === originalOrigin ? await fetchPublicUrl(currentUrl, init, { allowPrivateHost: options.allowPrivateHost }) : await fetchPublicUrl(currentUrl, init); } catch (error) { @@ -138,6 +140,9 @@ async function fetchIcal( if (!buffer) { return { kind: 'error', response: tooLarge(maxResponseSize) }; } + if (options.requireCalendar && !looksLikeCalendar(buffer)) { + return fail('The address did not answer with a calendar', 502); + } return { kind: 'ok', buffer, etag: response.headers.get('etag'), lastModified: response.headers.get('last-modified') }; } catch (error: unknown) { if (error instanceof Error && error.name === 'AbortError') { @@ -149,6 +154,13 @@ async function fetchIcal( } } +/** Starts with BEGIN:VCALENDAR (after a byte order mark and blank space). */ +function looksLikeCalendar(buffer: ArrayBuffer): boolean { + // TextDecoder drops a byte order mark; trimStart the rest. + const head = new TextDecoder().decode(buffer.slice(0, 512)).trimStart(); + return /^BEGIN:VCALENDAR/i.test(head); +} + function icalResponse(buffer: ArrayBuffer): NextResponse { return new NextResponse(buffer, { status: 200, @@ -180,7 +192,8 @@ async function fetchCompanyFeed(creds: StalwartCredentials, feedId: string): Pro return icalResponse(cached.body); } const outcome = await fetchIcal(feed.url, { - allowPrivateHost: feedHostname(feed) ?? undefined, + allowPrivateHost: intranetFeedHost(feed) ?? undefined, + requireCalendar: true, etag: cached?.etag, lastModified: cached?.lastModified, }); diff --git a/components/admin/org/sections/company-calendars-section.tsx b/components/admin/org/sections/company-calendars-section.tsx index 7b63a9a34..036c4d0e2 100644 --- a/components/admin/org/sections/company-calendars-section.tsx +++ b/components/admin/org/sections/company-calendars-section.tsx @@ -162,8 +162,10 @@ export function CompanyCalendarsSection({ draft }: { draft: OrgPolicyDraft }) {

    Bulwark's server fetches the feeds, at most once per refresh interval for everyone, and keeps the addresses to - itself: users never see them. A feed may be on your intranet (a private address): only the hosts entered here are - let through. They stay reachable with "No third-party traffic" on and are listed on Organisation > Security. + itself: users never see them. A feed may be on your intranet when its address names it: an intranet IP address + (10.x, 172.16-31.x, 192.168.x), a .local or .internal name, or a name without dots. Loopback and link-local + addresses are never reached. Feeds stay reachable with "No third-party traffic" on and are listed on + Organisation > Security.

    Each refresh that changed the feed uploads it once to Stalwart for every user who has Calendar open, which counts diff --git a/integration/tests/22-ent-calendar.spec.ts b/integration/tests/22-ent-calendar.spec.ts index e4b16b767..89deb371e 100644 --- a/integration/tests/22-ent-calendar.spec.ts +++ b/integration/tests/22-ent-calendar.spec.ts @@ -1,6 +1,7 @@ import { test, expect, type Browser, type Page } from '@playwright/test'; import { createServer, type Server } from 'node:http'; import type { AddressInfo } from 'node:net'; +import { networkInterfaces } from 'node:os'; import { ENT, ENT_SERVER, adminContext, getPolicy, jmapAs, putPolicy, rawMessage, requireEntEnv, shot, userPage, withPolicy, } from './helpers/ent'; @@ -492,6 +493,8 @@ test('the out-of-office checkbox fits a phone', async ({ browser }) => { }); // ── 9. Company calendars ───────────────────────────────────────────── +// The feed is served from this PC's intranet address, the case of a feed on +// the company intranet. function icsServer(title: string): Promise<{ server: Server; url: string; hits: () => number }> { let hits = 0; @@ -507,9 +510,14 @@ function icsServer(title: string): Promise<{ server: Server; url: string; hits: res.writeHead(200, { 'content-type': 'text/calendar' }); res.end(body); }); - return new Promise((resolve) => server.listen(0, '127.0.0.1', () => { + // An intranet address of this PC: the server reaches admin feeds on the + // intranet, never on loopback. + const lan = Object.values(networkInterfaces()).flat() + .find((i) => i && i.family === 'IPv4' && !i.internal && /^(10.|192.168.|172.(1[6-9]|2d|3[01]).)/.test(i.address))?.address; + if (!lan) throw new Error('no intranet IPv4 address on this PC for the feed server'); + return new Promise((resolve) => server.listen(0, lan, () => { const { port } = server.address() as AddressInfo; - resolve({ server, url: `http://127.0.0.1:${port}/team.ics`, hits: () => hits }); + resolve({ server, url: `http://${lan}:${port}/team.ics`, hits: () => hits }); })); } diff --git a/lib/__tests__/fetch-ical-feed-route.test.ts b/lib/__tests__/fetch-ical-feed-route.test.ts index 3de311eca..f437f3526 100644 --- a/lib/__tests__/fetch-ical-feed-route.test.ts +++ b/lib/__tests__/fetch-ical-feed-route.test.ts @@ -54,7 +54,7 @@ const POLICY = { restrictions: {}, features: {}, defaults: {}, themePolicy: {}, organisation: { calendars: { feeds: [{ id: 'hol', name: 'Holidays', url: 'https://cal.example.org/h.ics?token=t', color: '#2563eb', refreshMinutes: 60 }] } }, scopes: [ - { id: 'team', label: 'Team', match: { groups: ['ent-team@example.org'] }, policy: { organisation: { calendars: { feeds: [{ id: 'team', name: 'Team', url: 'http://cal.intranet.example/team.ics', refreshMinutes: 120 }] } } } }, + { id: 'team', label: 'Team', match: { groups: ['ent-team@example.org'] }, policy: { organisation: { calendars: { feeds: [{ id: 'team', name: 'Team', url: 'http://calendar.corp.internal/team.ics', refreshMinutes: 120 }] } } } }, { id: 'bob', label: 'Bob', match: { groups: ['ent-bob@example.org'] }, policy: { organisation: { calendars: { feeds: [{ id: 'bobs', name: 'Bob', url: 'https://cal.example.org/b.ics' }] } } } }, ], }; @@ -90,9 +90,11 @@ describe('POST /api/fetch-ical { feedId }', () => { expect((await post({ feedId: 'hol' })).status).toBe(200); expect(guardedFetch.mock.calls[0][0]).toBe('https://cal.example.org/h.ics?token=t'); expect((await post({ feedId: 'team' })).status).toBe(200); - // The admin's own intranet host may be private, and only it. - expect(guardedFetch.mock.calls[1][0]).toBe('http://cal.intranet.example/team.ics'); - expect(guardedFetch.mock.calls[1][2]).toEqual({ allowPrivateHost: 'cal.intranet.example' }); + // The admin's own intranet host may be private, and only it; a public + // host gets no exception. + expect(guardedFetch.mock.calls[0]).toHaveLength(2); + expect(guardedFetch.mock.calls[1][0]).toBe('http://calendar.corp.internal/team.ics'); + expect(guardedFetch.mock.calls[1][2]).toEqual({ allowPrivateHost: 'calendar.corp.internal' }); }); it('refuses a feed of a scope the user is not in, without fetching anything', async () => { @@ -122,6 +124,20 @@ describe('POST /api/fetch-ical { feedId }', () => { expect(guardedFetch.mock.calls[1][1].headers).toMatchObject({ 'If-None-Match': '"v1"', 'If-Modified-Since': 'Mon, 02 Nov 2026 07:00:00 GMT' }); }); + it('keeps the intranet exception off a redirect to another port', async () => { + guardedFetch + .mockImplementationOnce(async () => new Response(null, { status: 302, headers: { location: 'http://calendar.corp.internal:8443/x' } })) + .mockImplementationOnce(async () => ics()); + expect((await post({ feedId: 'team' })).status).toBe(200); + expect(guardedFetch.mock.calls[0][2]).toEqual({ allowPrivateHost: 'calendar.corp.internal' }); + expect(guardedFetch.mock.calls[1]).toHaveLength(2); + }); + + it('refuses an answer that is not a calendar', async () => { + guardedFetch.mockImplementation(async () => new Response('{"secret":1}', { status: 200, headers: { 'content-type': 'application/json' } })); + expect((await post({ feedId: 'team' })).status).toBe(502); + }); + it('needs a signed-in user', async () => { creds.value = null; expect((await post({ feedId: 'hol' })).status).toBe(401); diff --git a/lib/__tests__/url-guard-private-host.test.ts b/lib/__tests__/url-guard-private-host.test.ts index 2ba620485..7cb1ac942 100644 --- a/lib/__tests__/url-guard-private-host.test.ts +++ b/lib/__tests__/url-guard-private-host.test.ts @@ -3,10 +3,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites import { createServer, type Server } from 'node:http'; import type { AddressInfo } from 'node:net'; -// A company calendar may live on the intranet: fetchPublicUrl lets exactly -// the host the admin entered resolve to a private address, and nothing else -// (a redirect to another private host, another name), with the address still -// pinned at connect time. +// A company calendar may live on the intranet: fetchPublicUrl lets the one +// host the admin entered resolve to an intranet address, and only when that +// host itself names the intranet (an intranet address, .local/.internal, a +// single-label name). Loopback, link-local and metadata addresses stay +// refused for it, a public name gets no exception at all, and the address is +// still pinned at connect time. const lookup = vi.fn(); vi.mock('node:dns/promises', async (importOriginal) => { @@ -18,6 +20,24 @@ vi.mock('node:dns/promises', async (importOriginal) => { }; }); +describe('what counts as the intranet', () => { + it('takes intranet addresses and names, never loopback, link-local, metadata or public names', async () => { + const { isIntranetAddress, isIntranetHostName } = await import('@/lib/security/url-guard'); + for (const ip of ['10.1.2.3', '172.16.0.9', '192.168.178.20', '100.64.1.1', 'fd12:3456::1', '::ffff:10.0.0.1']) { + expect(isIntranetAddress(ip), ip).toBe(true); + } + for (const ip of ['127.0.0.1', '169.254.169.254', '0.0.0.0', '::1', 'fe80::1', 'fd00:ec2::254', '::ffff:127.0.0.1', '8.8.8.8', '224.0.0.1']) { + expect(isIntranetAddress(ip), ip).toBe(false); + } + for (const host of ['calendar', 'cal.corp.internal', 'nas.local', 'srv.localdomain', '10.0.0.5', '[fd12::1]']) { + expect(isIntranetHostName(host), host).toBe(true); + } + for (const host of ['cal.example.org', 'localhost', 'app.localhost', '127.0.0.1', '169.254.169.254', '']) { + expect(isIntranetHostName(host), host).toBe(false); + } + }); +}); + describe('fetchPublicUrl with allowPrivateHost', () => { let server: Server; let port: number; @@ -37,30 +57,42 @@ describe('fetchPublicUrl with allowPrivateHost', () => { }); beforeEach(() => { lookup.mockReset(); - lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]); hits = 0; }); - it('reaches the admin\'s intranet host', async () => { - const { fetchPublicUrl } = await import('@/lib/security/url-guard'); - const res = await fetchPublicUrl(`http://cal.intranet.example:${port}/team.ics`, {}, { allowPrivateHost: 'cal.intranet.example' }); - expect(res.status).toBe(200); - expect(hits).toBe(1); + it('lets an intranet name through to an intranet address', async () => { + lookup.mockResolvedValue([{ address: '10.255.255.1', family: 4 }]); + const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard'); + // Nothing answers there: the connection is tried (the guard lets it + // through) and runs out of time. + const error = await fetchPublicUrl('http://calendar:8080/team.ics', { signal: AbortSignal.timeout(300) }, { allowPrivateHost: 'calendar' }) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(DisallowedUrlError); + expect(lookup).toHaveBeenCalled(); }); - it('allows .internal names only for that host', async () => { + it('refuses loopback and metadata addresses even for the admin\'s intranet host', async () => { const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard'); - const res = await fetchPublicUrl(`http://calendar.corp.internal:${port}/a.ics`, {}, { allowPrivateHost: 'calendar.corp.internal' }); - expect(res.status).toBe(200); - await expect(fetchPublicUrl(`http://other.corp.internal:${port}/a.ics`, {}, { allowPrivateHost: 'calendar.corp.internal' })) + lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]); + await expect(fetchPublicUrl(`http://calendar:${port}/team.ics`, {}, { allowPrivateHost: 'calendar' })) .rejects.toBeInstanceOf(DisallowedUrlError); + lookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]); + await expect(fetchPublicUrl('http://cal.corp.internal/latest/meta-data/', {}, { allowPrivateHost: 'cal.corp.internal' })) + .rejects.toBeInstanceOf(DisallowedUrlError); + await expect(fetchPublicUrl(`http://127.0.0.1:${port}/team.ics`, {}, { allowPrivateHost: '127.0.0.1' })) + .rejects.toBeInstanceOf(DisallowedUrlError); + expect(hits).toBe(0); }); - it('keeps refusing every other host that resolves to a private address', async () => { + it('gives a public host no exception, whatever its DNS points at', async () => { const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard'); - await expect(fetchPublicUrl(`http://rebind.example:${port}/cal.ics`, {}, { allowPrivateHost: 'cal.intranet.example' })) + lookup.mockResolvedValue([{ address: '10.0.0.7', family: 4 }]); + await expect(fetchPublicUrl('http://partner.example.org/cal.ics', {}, { allowPrivateHost: 'partner.example.org' })) + .rejects.toBeInstanceOf(DisallowedUrlError); + // Another intranet name than the admin's stays refused. + await expect(fetchPublicUrl('http://other.corp.internal/a.ics', {}, { allowPrivateHost: 'cal.corp.internal' })) .rejects.toBeInstanceOf(DisallowedUrlError); - await expect(fetchPublicUrl(`http://cal.intranet.example:${port}/team.ics`)).rejects.toBeInstanceOf(DisallowedUrlError); expect(hits).toBe(0); }); }); diff --git a/lib/admin/__tests__/company-feeds.test.ts b/lib/admin/__tests__/company-feeds.test.ts index 77a579132..daffdb154 100644 --- a/lib/admin/__tests__/company-feeds.test.ts +++ b/lib/admin/__tests__/company-feeds.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { allFeeds, feedForSubject, stripFeedUrls } from '../company-feeds'; +import { allFeeds, feedForSubject, intranetFeedHost, stripFeedUrls } from '../company-feeds'; import { listReachableHosts } from '../reachable-hosts'; import { sanitizeOrganisationPolicy } from '../organisation-policy'; import type { SettingsPolicy } from '../types'; @@ -32,6 +32,16 @@ describe('company feeds on the server', () => { expect(feedForSubject(policy, { email: 'b@two.example', groups: [] }, 'two')?.name).toBe('Two'); }); + it('lifts the private-address block only for a host that names the intranet', () => { + expect(intranetFeedHost({ url: 'http://calendar.corp.internal/team.ics' })).toBe('calendar.corp.internal'); + expect(intranetFeedHost({ url: 'http://intranet/team.ics' })).toBe('intranet'); + expect(intranetFeedHost({ url: 'http://10.1.2.3/team.ics' })).toBe('10.1.2.3'); + expect(intranetFeedHost({ url: 'https://cal.example.org/h.ics' })).toBeNull(); + expect(intranetFeedHost({ url: 'http://127.0.0.1:8080/h.ics' })).toBeNull(); + expect(intranetFeedHost({ url: 'http://169.254.169.254/latest' })).toBeNull(); + expect(intranetFeedHost({ url: 'http://localhost/h.ics' })).toBeNull(); + }); + it('lists every scope\'s feed host as reachable', () => { expect(allFeeds(policy).map((f) => f.id)).toEqual(['hol', 'team', 'two']); const hosts = listReachableHosts({ get: () => undefined, policy, env: {} }) diff --git a/lib/admin/company-feeds.ts b/lib/admin/company-feeds.ts index 306f17076..03121fef0 100644 --- a/lib/admin/company-feeds.ts +++ b/lib/admin/company-feeds.ts @@ -11,6 +11,7 @@ import type { SettingsPolicy } from './types'; import type { CompanyFeed } from './organisation-policy'; import { resolvePolicy, type PolicySubject } from './policy-scopes'; +import { isIntranetHostName } from '@/lib/security/url-guard'; function withoutUrls(organisation: unknown): unknown { if (!organisation || typeof organisation !== 'object') return organisation; @@ -55,10 +56,16 @@ export function allFeeds(policy: Pick return lists.flatMap((feeds) => (Array.isArray(feeds) ? feeds.filter((f): f is CompanyFeed => !!f && typeof f === 'object' && typeof (f as CompanyFeed).url === 'string') : [])); } -/** The hostnames of the admin's feeds: the only private hosts a feed fetch may reach. */ -export function feedHostname(feed: Pick): string | null { +/** + * The feed's host when it names the intranet (an intranet address, a + * .local/.internal/.localdomain name or a single-label name): the only kind + * of host a feed fetch may reach on a private address. Null for public + * names, so a public host whose DNS is later pointed inside reaches nothing. + */ +export function intranetFeedHost(feed: Pick): string | null { try { - return new URL(feed.url).hostname.toLowerCase(); + const host = new URL(feed.url).hostname.toLowerCase(); + return isIntranetHostName(host) ? host : null; } catch { return null; } diff --git a/lib/security/url-guard.ts b/lib/security/url-guard.ts index 2a60ed429..3f62988f2 100644 --- a/lib/security/url-guard.ts +++ b/lib/security/url-guard.ts @@ -10,7 +10,7 @@ import { type RequestInit as UndiciRequestInit, type Response as UndiciResponse, } from 'undici'; -import { isPrivateAddress } from './ip-ranges'; +import { isPrivateAddress, parseV6Groups } from './ip-ranges'; const BLOCKED_HOSTNAMES = new Set(['localhost']); const BLOCKED_HOSTNAME_SUFFIXES = ['.localhost', '.local', '.internal', '.arpa', '.localdomain']; @@ -29,6 +29,41 @@ function isBlockedHostname(hostname: string): boolean { return BLOCKED_HOSTNAME_SUFFIXES.some((suffix) => hostname.endsWith(suffix)); } +/** + * An intranet address: RFC 1918, CGNAT or an IPv6 unique local address, + * as such or IPv4-mapped. Never loopback, link-local (cloud metadata lives + * there), unspecified, multicast or the AWS metadata ULA. + */ +export function isIntranetAddress(ip: string): boolean { + const host = normalizeHostname(ip); + const v4 = (a: string) => /^10\./.test(a) || /^172\.(1[6-9]|2\d|3[0-1])\./.test(a) || /^192\.168\./.test(a) + || /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(a); + const family = isIP(host); + if (family === 4) return v4(host); + if (family !== 6) return false; + const g = parseV6Groups(host); + if (!g) return false; + if (g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff) { + return v4(`${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`); + } + const awsMetadata = g[0] === 0xfd00 && g[1] === 0x0ec2 && g.slice(2, 7).every((x) => x === 0) && g[7] === 0x254; + return (g[0] & 0xfe00) === 0xfc00 && !awsMetadata; +} + +/** + * Whether an admin-entered host names the intranet: an intranet address + * literal, a name under a private suffix (.local, .internal, .localdomain) + * or a single-label name. Public names and localhost never are, so a public + * host whose DNS later points inside can't be used to reach it. + */ +export function isIntranetHostName(hostname: string): boolean { + const host = normalizeHostname(hostname); + if (!host || host === 'localhost' || host.endsWith('.localhost')) return false; + if (isIP(host)) return isIntranetAddress(host); + if (['.local', '.internal', '.localdomain'].some((suffix) => host.endsWith(suffix))) return true; + return !host.includes('.'); +} + /** * Synchronous part of the guard: protocol, embedded credentials, blocked * hostnames / suffixes, and literal IP addresses. Returns the parsed URL when @@ -46,7 +81,7 @@ function parseAllowedUrl(urlString: string, privateHost?: string): URL | null { if (url.username || url.password) return null; const hostname = normalizeHostname(url.hostname); - if (privateHost && hostname === privateHost) return url; + if (privateHost && hostname === privateHost && isIntranetHostName(hostname)) return url; if (isBlockedHostname(hostname)) return null; if (isBlockedIpAddress(hostname)) return null; @@ -215,10 +250,12 @@ export function envProxyFor(url: URL): URL | null { } /** - * guardedLookup, except that `privateHost` may resolve to a private address: - * an intranet host the admin entered themselves (company calendars). Every - * other name, redirect targets included, is still checked, and the address - * is still the one the socket connects to. + * guardedLookup, except that `privateHost` may resolve to an intranet + * address (isIntranetAddress): a host on the intranet the admin entered + * themselves (company calendars). Loopback, link-local and metadata + * addresses stay refused for it too; every other name, redirect targets + * included, is checked as always; and the address is still the one the + * socket connects to. */ function lookupAllowing(privateHost: string): typeof guardedLookup { return (hostname, options, callback) => { @@ -236,6 +273,11 @@ function lookupAllowing(privateHost: string): typeof guardedLookup { cb(err); return; } + const refused = records.find((record) => isBlockedIpAddress(record.address) && !isIntranetAddress(record.address)); + if (refused) { + cb(new BlockedAddressError(hostname, refused.address)); + return; + } if (opts.all) cb(null, records); else cb(null, records[0].address, records[0].family); }) @@ -341,14 +383,17 @@ export async function fetchPublicUrl( init: Omit = {}, options: { /** - * One hostname that may be a private (intranet) host: only for an - * address the admin entered (company calendars). It must equal the - * URL's hostname to make any difference. + * One hostname on the intranet (isIntranetHostName) that may resolve to + * an intranet address: only for an address the admin entered (company + * calendars). It must equal the URL's hostname to make any difference; + * a public name here changes nothing. */ allowPrivateHost?: string; } = {}, ): Promise { - const privateHost = options.allowPrivateHost ? normalizeHostname(options.allowPrivateHost) : undefined; + const privateHost = options.allowPrivateHost && isIntranetHostName(options.allowPrivateHost) + ? normalizeHostname(options.allowPrivateHost) + : undefined; const parsed = parseAllowedUrl(url, privateHost); if (!parsed) { throw new DisallowedUrlError(url); From c89c1cb36c9e63b4d140e602ec42a2e73897113f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:37:50 +0100 Subject: [PATCH 14/23] fix: fetch company calendars for the active account when several are signed in --- .../__tests__/use-company-calendars.test.tsx | 36 +++++++++++++++++++ components/calendar/use-company-calendars.ts | 3 +- lib/__tests__/company-calendars.test.ts | 4 +++ lib/company-calendars.ts | 5 ++- 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 components/calendar/__tests__/use-company-calendars.test.tsx diff --git a/components/calendar/__tests__/use-company-calendars.test.tsx b/components/calendar/__tests__/use-company-calendars.test.tsx new file mode 100644 index 000000000..6a5c59c4e --- /dev/null +++ b/components/calendar/__tests__/use-company-calendars.test.tsx @@ -0,0 +1,36 @@ +import { createElement } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; + +const load = vi.fn(async () => []); +vi.mock('@/lib/company-calendars', async (original) => ({ + ...(await original()), + loadFeedEvents: (...args: unknown[]) => load(...(args as [])), +})); + +import { useCompanyCalendars } from '../use-company-calendars'; +import { usePolicyStore } from '@/stores/policy-store'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// Two logins can share a JMAP account id (two servers): the cached feed +// events are kept per login. + +const FEED = { id: 'hol', name: 'Holidays', url: '', urlHeld: true, color: '#16a34a', refreshMinutes: 60 }; + +function Harness({ client }: { client: IJMAPClient }) { + useCompanyCalendars(client); + return null; +} + +describe('useCompanyCalendars', () => { + afterEach(() => load.mockClear()); + + it('keys the feed cache by the login, not the JMAP account id alone', async () => { + const policy = usePolicyStore.getState().policy; + usePolicyStore.setState({ policy: { ...policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, calendars: { feeds: [FEED], freeBusyGroup: '' } } } }); + const client = { getUsername: () => 'alice@example.org', getAccountId: () => 'b' } as unknown as IJMAPClient; + render(createElement(Harness, { client })); + await waitFor(() => expect(load).toHaveBeenCalledWith(client, 'alice@example.org|b', FEED)); + }); +}); diff --git a/components/calendar/use-company-calendars.ts b/components/calendar/use-company-calendars.ts index c1d29ca0b..6da312050 100644 --- a/components/calendar/use-company-calendars.ts +++ b/components/calendar/use-company-calendars.ts @@ -28,7 +28,8 @@ export function useCompanyCalendars(client: IJMAPClient | null): { const feeds = usePolicyStore((s) => s.policy.organisation?.calendars?.feeds ?? NO_FEEDS); const hidden = useSettingsStore((s) => s.hiddenCompanyCalendars); const [parsed, setParsed] = useState>({}); - const accountKey = client?.getAccountId?.() ?? ""; + // Per login, not just per JMAP account id: two servers can reuse ids. + const accountKey = client ? `${client.getUsername?.() ?? ""}|${client.getAccountId?.() ?? ""}` : ""; const feedsKey = JSON.stringify(feeds.map((f) => [f.id, f.url, f.refreshMinutes])); useEffect(() => { diff --git a/lib/__tests__/company-calendars.test.ts b/lib/__tests__/company-calendars.test.ts index 5dae25705..2ff32ec0b 100644 --- a/lib/__tests__/company-calendars.test.ts +++ b/lib/__tests__/company-calendars.test.ts @@ -11,6 +11,8 @@ vi.mock('@/lib/browser-navigation', async (original) => ({ apiFetch: (...args: unknown[]) => apiFetch(...args), })); +vi.mock('@/lib/auth/active-account-slot', () => ({ getActiveAccountSlotHeaders: () => ({ 'X-JMAP-Cookie-Slot': '1' }) })); + import { companyCalendarId, createCompanyCalendar, isCompanyCalendarId, loadFeedEvents, resetCompanyCalendarMemory, toCompanyEvents, } from '../company-calendars'; @@ -46,6 +48,8 @@ describe('loadFeedEvents', () => { const client = fakeClient(); expect(await loadFeedEvents(client, 'alice', FEED, T0)).toEqual(PARSED); expect(apiFetch).toHaveBeenCalledWith('/api/fetch-ical', expect.objectContaining({ method: 'POST', body: JSON.stringify({ feedId: 'hol' }) })); + // The server resolves the feed for the active account's sign-in slot. + expect(apiFetch.mock.calls[0][1].headers).toMatchObject({ 'X-JMAP-Cookie-Slot': '1' }); const [, options] = client.uploadBlob.mock.calls[0] as unknown as [File, { accountId: string; onProgress?: unknown }]; expect(options.accountId).toBe('alice'); expect(typeof options.onProgress).toBe('function'); diff --git a/lib/company-calendars.ts b/lib/company-calendars.ts index aec6a9b5f..a12474262 100644 --- a/lib/company-calendars.ts +++ b/lib/company-calendars.ts @@ -24,6 +24,7 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; import type { CompanyFeed } from '@/lib/admin/organisation-policy'; import { apiFetch } from '@/lib/browser-navigation'; +import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { IS_LITE } from '@/lib/lite'; import { onFullSignOut } from '@/lib/sign-out-cleanup'; import { debug } from '@/lib/debug'; @@ -192,7 +193,9 @@ async function fetchFeedText(feed: CompanyFeed): Promise { ? await fetch(feed.url, { headers: { Accept: 'text/calendar, application/ics, text/plain, */*' }, credentials: 'omit' }) : await apiFetch('/api/fetch-ical', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + // The server resolves the feed against the active account's scopes: + // name its sign-in slot, or it picks the first signed-in account. + headers: { 'Content-Type': 'application/json', ...getActiveAccountSlotHeaders() }, body: JSON.stringify({ feedId: feed.id }), }); if (!response.ok) throw new Error(`Company calendar ${feed.id}: ${response.status}`); From cbae82b2f045970254f27b89c65ddcabe66bf6b7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:42:37 +0100 Subject: [PATCH 15/23] fix: keep the out-of-office event in step when the reply changes on another device The event counted as Bulwark's only while its times matched the record in this browser's settings, so a reply switched off on a phone, by a delegate, in Lite or with settings sync off left colleagues seeing a busy event for a cancelled absence. It now also counts as Bulwark's when it sits on the reply's dates from before the change, and it carries the keyword bulwark-out-of-office so a device without a record takes it over when the reply is loaded. Each account gets its own pending sync. --- lib/__tests__/vacation-calendar.test.ts | 79 ++++++++++++++- lib/vacation-calendar.ts | 122 ++++++++++++++++-------- 2 files changed, 159 insertions(+), 42 deletions(-) diff --git a/lib/__tests__/vacation-calendar.test.ts b/lib/__tests__/vacation-calendar.test.ts index 6b7f4a01c..4a995252c 100644 --- a/lib/__tests__/vacation-calendar.test.ts +++ b/lib/__tests__/vacation-calendar.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - blockTimes, decideBlock, ownFilterScriptStops, effectiveBlockSetting, eventSpan, isoDuration, oooUid, resetVacationCalendarSync, + blockTimes, decideBlock, ownFilterScriptStops, resolveBlockSetting, effectiveBlockSetting, eventSpan, isoDuration, oooUid, resetVacationCalendarSync, syncVacationBlock, vacationReplyLoaded, vacationReplySaved, type DecisionInput, } from '../vacation-calendar'; import { useSettingsStore } from '@/stores/settings-store'; @@ -231,3 +231,80 @@ describe('ownFilterScriptStops', () => { expect(await ownFilterScriptStops(sieve([{ name: 'vacation', isActive: true, content: '' }]))).toBe(false); }); }); + +describe('whose event it is without this device\'s record', () => { + it('follows the reply\'s previous dates: switched off or moved on another device or by a delegate', () => { + const event = span(last.start, last.end); + const previous = { fromDate: '2026-12-01T08:00:00Z', toDate: '2026-12-05T17:00:00Z' }; + expect(decideBlock(input({ reply: reply({ isEnabled: false }), event, previous }))).toEqual({ action: 'destroy' }); + expect(decideBlock(input({ reply: reply({ toDate: '2026-12-08T17:00:00Z' }), event, previous })).action).toBe('update'); + // Someone moved the event before: neither the record nor the old dates match. + expect(decideBlock(input({ reply: reply({ isEnabled: false }), event: span('2026-12-02T08:00:00Z', '2026-12-03T08:00:00Z'), previous }))) + .toEqual({ action: 'none' }); + }); + + it('takes a marked event as its own when the reply is loaded on a device with no record', () => { + const event = span(last.start, last.end); + expect(decideBlock(input({ reply: reply({ toDate: '2026-12-08T17:00:00Z' }), event, marked: true, trigger: 'load' })).action).toBe('update'); + expect(decideBlock(input({ reply: reply({ toDate: '2026-12-08T17:00:00Z' }), event, marked: false, trigger: 'load' }))).toEqual({ action: 'none' }); + expect(decideBlock(input({ reply: reply({ toDate: '2026-12-08T17:00:00Z' }), event, marked: true, trigger: 'save' }))).toEqual({ action: 'none' }); + }); + + it('marks the event it creates and keeps the mark when it moves it', async () => { + useSettingsStore.setState({ vacationBlockCalendar: true, vacationCalendarBlocks: {}, timeZone: 'Europe/Berlin' }); + const client = fakeClient(null); + await syncVacationBlock(client, undefined, reply(), 'save', NOW); + expect(client.createCalendarEvent.mock.calls[0][0]).toMatchObject({ keywords: { 'bulwark-out-of-office': true } }); + // Another device, no record: the reply\'s old dates make the event ours. + useSettingsStore.setState({ vacationCalendarBlocks: {} }); + const ours = { id: 'e1', ...blockTimes(new Date(last.start), new Date(last.end), 'Europe/Berlin') }; + const other = fakeClient(ours); + expect(await syncVacationBlock(other, undefined, reply({ isEnabled: false }), 'save', NOW, { fromDate: last.start, toDate: last.end })).toBe('destroy'); + useSettingsStore.setState({ timeZone: 'auto' }); + }); +}); + +describe('resolveBlockSetting', () => { + it('pins the organisation\'s value when locked and hides the choice when hidden', () => { + expect(resolveBlockSetting({ locked: true, value: false }, undefined, true)).toEqual({ value: false, locked: true, hidden: false }); + // A lock without a value takes the policy default, not the user's choice. + expect(resolveBlockSetting({ locked: true }, false, true)).toEqual({ value: false, locked: true, hidden: false }); + expect(resolveBlockSetting({ locked: true }, undefined, false)).toEqual({ value: true, locked: true, hidden: false }); + expect(resolveBlockSetting({ hidden: true }, undefined, false)).toEqual({ value: true, locked: false, hidden: true }); + expect(resolveBlockSetting(undefined, undefined, false)).toEqual({ value: false, locked: false, hidden: false }); + }); +}); + +describe('the sync scheduler with several accounts', () => { + beforeEach(() => { + vi.useFakeTimers(); + resetVacationCalendarSync(); + useSettingsStore.setState({ vacationBlockCalendar: true, vacationCalendarBlocks: {} }); + }); + afterEach(() => { + vi.useRealTimers(); + resetVacationCalendarSync(); + }); + + it('keeps one job per account, and the dates from before the first of several saves', async () => { + const client = fakeClient(null); + vacationReplySaved(client, undefined, reply(), reply({ subject: 'A' })); + vacationReplySaved(client, 'boss', reply(), reply({ subject: 'B' })); + await vi.advanceTimersByTimeAsync(5000); + const accounts = client.queryCalendarEvents.mock.calls.map((c) => (c as unknown[])[3]); + expect(accounts.sort()).toEqual(['boss', undefined].sort()); + }); + + it('syncs several quick saves once, against the dates from before the first', async () => { + useSettingsStore.setState({ timeZone: 'Europe/Berlin' }); + const ours = { id: 'e1', ...blockTimes(new Date(last.start), new Date(last.end), 'Europe/Berlin') }; + const client = fakeClient(ours); + const moved = reply({ toDate: '2026-12-09T17:00:00Z' }); + vacationReplySaved(client, undefined, reply(), moved); + vacationReplySaved(client, undefined, moved, { ...moved, isEnabled: false }); + await vi.advanceTimersByTimeAsync(5000); + expect(client.queryCalendarEvents).toHaveBeenCalledTimes(1); + expect(client.deleteCalendarEvent).toHaveBeenCalledWith('e1', false, undefined); + useSettingsStore.setState({ timeZone: 'auto' }); + }); +}); diff --git a/lib/vacation-calendar.ts b/lib/vacation-calendar.ts index 7be23521c..cbbd44e19 100644 --- a/lib/vacation-calendar.ts +++ b/lib/vacation-calendar.ts @@ -8,9 +8,14 @@ * timed, or all-day when both ends are midnight in the user's time zone; * busy, public, without participants, and never sends invitations. * - * Bulwark keeps the times it last wrote (synced settings, per account). - * When the event's times no longer match them, someone edited it, and - * Bulwark leaves it alone. Switching the reply or the block off removes an + * The event is Bulwark's while its times are the ones Bulwark last wrote + * (kept in the settings, per account) or the reply's dates before the change + * at hand. That second rule works on any device and for anyone who changes + * the reply (a delegate, the owner), also where the settings don't sync + * (Lite, sync off). The event also carries the keyword OOO_KEYWORD (a + * CATEGORIES value, which 0.16 and 1.0 both keep): on a device with no + * record, a marked event found when the reply is loaded counts as Bulwark's. + * When none of these hold, someone edited it, and Bulwark leaves it alone. Switching the reply or the block off removes an * event that hasn't started and ends one that has at once; a reply that * simply ran out leaves its past event in place. An event deleted elsewhere * is not put back until the reply's dates change. @@ -37,6 +42,9 @@ import { debug } from '@/lib/debug'; export const OOO_UID_PREFIX = 'bulwark-ooo-'; export const SYNC_DEBOUNCE_MS = 1500; +/** The keyword (iCalendar CATEGORIES) on the busy event. */ +export const OOO_KEYWORD = 'bulwark-out-of-office'; + export function oooUid(accountId: string): string { return `${OOO_UID_PREFIX}${accountId}`; } @@ -69,6 +77,10 @@ export interface DecisionInput { /** The event with our UID, as instants; null when there is none. */ event: { start: Date; end: Date } | null; last: WrittenBlock | null; + /** The reply's dates before the change being synced; null when unknown (a load). */ + previous?: Pick | null; + /** The event carries OOO_KEYWORD. */ + marked?: boolean; now: Date; /** `save`: the reply or the block just changed here. `load`: the reply was read. */ trigger: 'save' | 'load'; @@ -86,10 +98,12 @@ export function decideBlock(input: DecisionInput): BlockDecision { const ranOut = reply.isEnabled && !!to && to <= now; if (event) { - const ours = last ? same(event.start, last.start) && same(event.end, last.end) : false; - if (!ours) { - // Without a record, an event that already sits on the reply's dates - // needs nothing; any other was set up or changed by someone else. + const matchesLast = !!last && same(event.start, last.start) && same(event.end, last.end); + const matchesPrevious = !!input.previous?.fromDate && !!input.previous.toDate + && same(event.start, input.previous.fromDate) && same(event.end, input.previous.toDate); + const markedOnLoad = !last && !!input.marked && input.trigger === 'load'; + if (!matchesLast && !matchesPrevious && !markedOnLoad) { + // Set up or changed by someone else: left alone. return { action: 'none' }; } if (wanted) { @@ -171,15 +185,30 @@ export function blockCalendar(calendars: Calendar[], accountId: string, primaryA return !rights || rights.mayWriteAll || rights.mayWriteOwn ? calendar : null; } -/** The block setting as it applies: the organisation's lock, else the user's choice. */ +/** + * The block setting as it applies: locked, the organisation's value (the + * lock's value, else the policy default, else on); hidden, the policy default + * (else on), the user can't see the choice; otherwise the user's choice. + * The settings card and the sync both read it here. + */ +export function resolveBlockSetting( + restriction: { locked?: boolean; hidden?: boolean; value?: unknown } | undefined, + policyDefault: unknown, + userSetting: unknown, +): { value: boolean; locked: boolean; hidden: boolean } { + const orgValue = (v: unknown) => (typeof v === 'boolean' ? v : typeof policyDefault === 'boolean' ? policyDefault : true); + if (restriction?.locked === true) return { value: orgValue('value' in restriction ? restriction.value : undefined), locked: true, hidden: false }; + if (restriction?.hidden === true) return { value: orgValue(undefined), locked: false, hidden: true }; + return { value: userSetting !== false, locked: false, hidden: false }; +} + export function effectiveBlockSetting(): boolean { const policy = usePolicyStore.getState(); - const restriction = policy.getRestriction('vacationBlockCalendar'); - if (restriction?.locked === true) { - const value = 'value' in restriction ? restriction.value : policy.getEffectiveDefault('vacationBlockCalendar'); - return typeof value === 'boolean' ? value : true; - } - return useSettingsStore.getState().vacationBlockCalendar !== false; + return resolveBlockSetting( + policy.getRestriction('vacationBlockCalendar'), + policy.getEffectiveDefault('vacationBlockCalendar'), + useSettingsStore.getState().vacationBlockCalendar, + ).value; } function writtenBlocks(): Record { @@ -207,6 +236,7 @@ export async function syncVacationBlock( reply: ReplyState, trigger: 'save' | 'load', now = new Date(), + previous: Pick | null = null, ): Promise { if (typeof client.supportsCalendars !== 'function' || !client.supportsCalendars()) return 'none'; const primary = client.getAccountId(); @@ -217,7 +247,8 @@ export async function syncVacationBlock( const [found] = await client.queryCalendarEvents({ uid: oooUid(account) }, undefined, 1, target); const span = found ? eventSpan(found, zone) : null; const last = writtenBlocks()[account] ?? null; - const decision = decideBlock({ block: effectiveBlockSetting(), reply, event: span, last, now, trigger }); + const marked = !!found?.keywords?.[OOO_KEYWORD]; + const decision = decideBlock({ block: effectiveBlockSetting(), reply, event: span, last, previous, marked, now, trigger }); switch (decision.action) { case 'none': // A record of an event that is gone and not wanted any more is dropped. @@ -235,6 +266,7 @@ export async function syncVacationBlock( freeBusyStatus: 'busy', privacy: 'public', status: 'confirmed', + keywords: { [OOO_KEYWORD]: true }, } as Partial, false, target); rememberBlock(account, { start: decision.start.toISOString(), end: decision.end.toISOString() }); return 'create'; @@ -243,7 +275,8 @@ export async function syncVacationBlock( await client.updateCalendarEvent(found!.originalId ?? found!.id, { ...blockTimes(decision.start, decision.end, zone), title: reply.subject.trim() || OUT_OF_OFFICE_TITLE, - }, false, target); + [`keywords/${OOO_KEYWORD}`]: true, + } as Partial, false, target); rememberBlock(account, { start: decision.start.toISOString(), end: decision.end.toISOString() }); return 'update'; case 'end-now': { @@ -285,54 +318,61 @@ interface Pending { client: SyncClient; accountId: string | undefined; reply: ReplyState; + /** The dates before the first change still waiting to be synced. */ + previous: Pick | null; trigger: 'save' | 'load'; } // Kept on globalThis: the store and the settings page may be in different // bundle copies, and two schedulers would sync the same event twice. interface SchedulerState { - timer: ReturnType | null; - pending: Pending | null; - running: Promise | null; + /** Per account: one job waiting, one timer, one sync running. */ + timers: Map>; + pending: Map; + running: Map>; loaded: Set; } const SCHEDULER_KEY = '__bulwarkVacationCalendarSync'; function scheduler(): SchedulerState { const g = globalThis as unknown as Record; - return (g[SCHEDULER_KEY] ??= { timer: null, pending: null, running: null, loaded: new Set() }); + return (g[SCHEDULER_KEY] ??= { timers: new Map(), pending: new Map(), running: new Map(), loaded: new Set() }); } -function run(): void { +function run(key: string): void { const state = scheduler(); - state.timer = null; - if (state.running || !state.pending) return; - const job = state.pending; - state.pending = null; - state.running = syncVacationBlock(job.client, job.accountId, job.reply, job.trigger) + state.timers.delete(key); + const job = state.pending.get(key); + if (state.running.has(key) || !job) return; + state.pending.delete(key); + state.running.set(key, syncVacationBlock(job.client, job.accountId, job.reply, job.trigger, new Date(), job.previous) .finally(() => { - state.running = null; - if (state.pending && !state.timer) run(); - }); + state.running.delete(key); + if (state.pending.has(key) && !state.timers.has(key)) run(key); + })); } function schedule(job: Pending): void { const state = scheduler(); - // A save outranks a load that waits; the latest reply wins. - if (state.pending?.trigger === 'save' && job.trigger === 'load') return; - state.pending = job; - if (state.timer) clearTimeout(state.timer); - state.timer = setTimeout(run, SYNC_DEBOUNCE_MS); + const key = job.accountId ?? job.client.getAccountId(); + const waiting = state.pending.get(key); + // A save outranks a load that waits; the latest reply wins, with the + // dates from before the first change it replaces. + if (waiting?.trigger === 'save' && job.trigger === 'load') return; + state.pending.set(key, waiting?.trigger === 'save' ? { ...job, previous: waiting.previous } : job); + const timer = state.timers.get(key); + if (timer) clearTimeout(timer); + state.timers.set(key, setTimeout(() => run(key), SYNC_DEBOUNCE_MS)); } /** After a saved reply: sync when the switch, the dates or the subject changed. */ export function vacationReplySaved(client: SyncClient, accountId: string | undefined, before: ReplyState, after: ReplyState): void { if (relevant(before) === relevant(after)) return; - schedule({ client, accountId, reply: after, trigger: 'save' }); + schedule({ client, accountId, reply: after, previous: { fromDate: before.fromDate, toDate: before.toDate }, trigger: 'save' }); } /** The user changed the block setting: sync with the reply as it is. */ export function vacationBlockChanged(client: SyncClient, accountId: string | undefined, reply: ReplyState): void { - schedule({ client, accountId, reply, trigger: 'save' }); + schedule({ client, accountId, reply, previous: { fromDate: reply.fromDate, toDate: reply.toDate }, trigger: 'save' }); } /** After the reply was loaded: once per page load and account. */ @@ -341,16 +381,16 @@ export function vacationReplyLoaded(client: SyncClient, accountId: string | unde const state = scheduler(); if (state.loaded.has(key)) return; state.loaded.add(key); - schedule({ client, accountId, reply, trigger: 'load' }); + schedule({ client, accountId, reply, previous: null, trigger: 'load' }); } /** For tests: drop what the scheduler holds. */ export function resetVacationCalendarSync(): void { const state = scheduler(); - if (state.timer) clearTimeout(state.timer); - state.timer = null; - state.pending = null; - state.running = null; + for (const timer of state.timers.values()) clearTimeout(timer); + state.timers.clear(); + state.pending.clear(); + state.running.clear(); state.loaded.clear(); } From e7c8df01fd59b6ca599a572b8ed4385836231d13 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:42:37 +0100 Subject: [PATCH 16/23] fix: show the out-of-office calendar choice the way the organisation's lock applies it --- .../__tests__/vacation-calendar-block.test.tsx | 14 +++++++++++++- components/settings/vacation-calendar-block.tsx | 11 +++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/components/settings/__tests__/vacation-calendar-block.test.tsx b/components/settings/__tests__/vacation-calendar-block.test.tsx index 81dce2d98..8b9d46efa 100644 --- a/components/settings/__tests__/vacation-calendar-block.test.tsx +++ b/components/settings/__tests__/vacation-calendar-block.test.tsx @@ -26,7 +26,7 @@ const client = { function lock(value: boolean | null) { const policy = usePolicyStore.getState().policy; - usePolicyStore.setState({ policy: { ...policy, restrictions: value === null ? {} : { vacationBlockCalendar: { locked: true, value } } } }); + usePolicyStore.setState({ policy: { ...policy, defaults: {}, restrictions: value === null ? {} : { vacationBlockCalendar: { locked: true, value } } } }); } describe('VacationCalendarBlock', () => { @@ -61,6 +61,18 @@ describe('VacationCalendarBlock', () => { expect(box.closest('[inert]')).not.toBeNull(); }); + it('shows what the sync does for a lock without a value, and nothing when hidden', async () => { + const policy = usePolicyStore.getState().policy; + usePolicyStore.setState({ policy: { ...policy, defaults: { vacationBlockCalendar: false }, restrictions: { vacationBlockCalendar: { locked: true } } } }); + const view = render(); + expect(((await screen.findByTestId('vacation-block-calendar')) as HTMLInputElement).checked).toBe(false); + view.unmount(); + usePolicyStore.setState({ policy: { ...policy, defaults: {}, restrictions: { vacationBlockCalendar: { hidden: true } } } }); + const hidden = render(); + await waitFor(() => expect(client.getAllCalendars).toHaveBeenCalled()); + expect(hidden.container.querySelector('[data-testid="vacation-block-calendar"]')).toBeNull(); + }); + it('says a managed account\'s calendar can\'t be blocked without write access', async () => { client.getAllCalendars.mockResolvedValueOnce([{ id: 'boss:b', isDefault: true, isShared: true, accountId: 'boss', myRights: { mayWriteAll: false, mayWriteOwn: false } }] as never); render(); diff --git a/components/settings/vacation-calendar-block.tsx b/components/settings/vacation-calendar-block.tsx index 8de77ac0d..9911ff2b9 100644 --- a/components/settings/vacation-calendar-block.tsx +++ b/components/settings/vacation-calendar-block.tsx @@ -8,7 +8,8 @@ import { useAuthStore } from '@/stores/auth-store'; import { useSettingsStore } from '@/stores/settings-store'; import { useVacationStore } from '@/stores/vacation-store'; import { useRestriction } from '@/hooks/use-restriction'; -import { blockCalendar, ownFilterScriptStops, vacationBlockChanged } from '@/lib/vacation-calendar'; +import { blockCalendar, ownFilterScriptStops, resolveBlockSetting, vacationBlockChanged } from '@/lib/vacation-calendar'; +import { usePolicyStore } from '@/stores/policy-store'; /** * "Block my calendar" under the out-of-office dates (lib/vacation-calendar.ts): @@ -22,7 +23,9 @@ export function VacationCalendarBlock({ hasDates, accountId }: { hasDates: boole const setting = useSettingsStore((s) => s.vacationBlockCalendar); const updateSetting = useSettingsStore((s) => s.updateSetting); const restriction = useRestriction('vacationBlockCalendar'); - const checked = restriction.locked && typeof restriction.value === 'boolean' ? restriction.value : setting !== false; + const policyDefault = usePolicyStore((s) => s.policy.defaults?.vacationBlockCalendar); + // The same resolution as the sync (lib/vacation-calendar.ts). + const { value: checked, locked, hidden } = resolveBlockSetting(restriction, policyDefault, setting); const [access, setAccess] = useState<{ key: string; ok: boolean } | null>(null); const id = useId(); const key = accountId ?? ''; @@ -40,7 +43,7 @@ export function VacationCalendarBlock({ hasDates, accountId }: { hasDates: boole return () => { cancelled = true; }; }, [client, supported, hasDates, accountId, key]); - if (!client || !supported || !hasDates) return null; + if (!client || !supported || !hasDates || hidden) return null; if (access?.key === key && !access.ok) { return (

    @@ -56,7 +59,7 @@ export function VacationCalendarBlock({ hasDates, accountId }: { hasDates: boole }; return ( - + Date: Sun, 1 Nov 2026 06:44:57 +0100 Subject: [PATCH 17/23] fix: take company calendar events away when the feed is gone or the account signs out --- lib/__tests__/company-calendars.test.ts | 28 ++++++++- ...sign-out-cleanup-company-calendars.test.ts | 19 ++++++ lib/company-calendars.ts | 58 ++++++++++++++++++- lib/sign-out-cleanup.ts | 7 +++ 4 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 lib/__tests__/sign-out-cleanup-company-calendars.test.ts diff --git a/lib/__tests__/company-calendars.test.ts b/lib/__tests__/company-calendars.test.ts index 2ff32ec0b..7e4a1f8e9 100644 --- a/lib/__tests__/company-calendars.test.ts +++ b/lib/__tests__/company-calendars.test.ts @@ -16,7 +16,8 @@ vi.mock('@/lib/auth/active-account-slot', () => ({ getActiveAccountSlotHeaders: import { companyCalendarId, createCompanyCalendar, isCompanyCalendarId, loadFeedEvents, resetCompanyCalendarMemory, toCompanyEvents, } from '../company-calendars'; -import { purgeSignedOutData } from '../sign-out-cleanup'; +import { purgeSignedOutData, runBeforeSignOut } from '../sign-out-cleanup'; +import { useAccountStore } from '@/stores/account-store'; import type { CompanyFeed } from '@/lib/admin/organisation-policy'; import type { CalendarEvent } from '@/lib/jmap/types'; @@ -118,3 +119,28 @@ describe('the company calendar and its events', () => { expect(events[0]).toMatchObject({ calendarIds: { [companyCalendarId('hol')]: true }, participants: null, organizerCalendarAddress: null, title: 'Closed' }); }); }); + +describe('when a feed goes away or the user signs out', () => { + it('drops the events of a feed the server no longer gives the user, and keeps them through an outage', async () => { + const client = fakeClient(); + await loadFeedEvents(client, 'alice|a', FEED, T0); + apiFetch.mockImplementation(async () => new Response('down', { status: 503 })); + expect(await loadFeedEvents(client, 'alice|a', FEED, T0 + 2 * HOUR)).toEqual(PARSED); + apiFetch.mockImplementation(async () => new Response('{"error":"Unknown calendar"}', { status: 404 })); + expect(await loadFeedEvents(client, 'alice|a', FEED, T0 + 3 * HOUR)).toEqual([]); + // Nothing kept: the next look asks the server again. + await loadFeedEvents(client, 'alice|a', FEED, T0 + 3 * HOUR + 1); + expect(apiFetch).toHaveBeenCalledTimes(4); + }); + + it('forgets one login\'s feeds when that account signs out', async () => { + const client = fakeClient(); + await loadFeedEvents(client, 'alice@example.org|a', FEED, T0); + await loadFeedEvents(client, 'bob@example.org|b', FEED, T0); + useAccountStore.setState({ accounts: [{ id: 'acc-alice', username: 'alice@example.org' }] as never }); + await runBeforeSignOut('acc-alice'); + await loadFeedEvents(client, 'alice@example.org|a', FEED, T0 + 60_000); + await loadFeedEvents(client, 'bob@example.org|b', FEED, T0 + 60_000); + expect(apiFetch).toHaveBeenCalledTimes(3); + }); +}); diff --git a/lib/__tests__/sign-out-cleanup-company-calendars.test.ts b/lib/__tests__/sign-out-cleanup-company-calendars.test.ts new file mode 100644 index 000000000..97d48f1b7 --- /dev/null +++ b/lib/__tests__/sign-out-cleanup-company-calendars.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from 'vitest'; +import { purgeSignedOutData } from '../sign-out-cleanup'; + +// The company calendar cache (lib/company-calendars.ts) goes on a full +// sign-out even in a tab that never loaded the calendar: this file doesn't +// import it. + +describe('purgeSignedOutData', () => { + it('deletes the stored company calendars by name', () => { + const deleteDatabase = vi.fn(); + Object.defineProperty(window, 'indexedDB', { value: { deleteDatabase }, configurable: true }); + try { + purgeSignedOutData(); + expect(deleteDatabase).toHaveBeenCalledWith('bulwark-company-calendars'); + } finally { + delete (window as unknown as Record).indexedDB; + } + }); +}); diff --git a/lib/company-calendars.ts b/lib/company-calendars.ts index a12474262..12fffd9fa 100644 --- a/lib/company-calendars.ts +++ b/lib/company-calendars.ts @@ -26,7 +26,8 @@ import type { CompanyFeed } from '@/lib/admin/organisation-policy'; import { apiFetch } from '@/lib/browser-navigation'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { IS_LITE } from '@/lib/lite'; -import { onFullSignOut } from '@/lib/sign-out-cleanup'; +import { onBeforeSignOut, onFullSignOut } from '@/lib/sign-out-cleanup'; +import { useAccountStore } from '@/stores/account-store'; import { debug } from '@/lib/debug'; export const COMPANY_CALENDAR_PREFIX = '__company-calendar__:'; @@ -110,7 +111,9 @@ export interface FeedCacheEntry { // ── IndexedDB, per account ────────────────────────────────────────── -const DB_NAME = 'bulwark-company-calendars'; +/** Also deleted by name in lib/sign-out-cleanup.ts, whether this module was loaded or not. */ +export const COMPANY_CALENDAR_DB = 'bulwark-company-calendars'; +const DB_NAME = COMPANY_CALENDAR_DB; const STORE = 'feeds'; function openDb(): Promise { @@ -159,6 +162,27 @@ async function idbPut(key: string, value: FeedCacheEntry): Promise { db.close(); } +async function idbDelete(match: (key: string) => boolean): Promise { + const db = await openDb(); + if (!db) return; + await new Promise((resolve) => { + try { + const tx = db.transaction(STORE, 'readwrite'); + const store = tx.objectStore(STORE); + const request = store.getAllKeys(); + request.onsuccess = () => { + for (const key of request.result) if (typeof key === 'string' && match(key)) store.delete(key); + }; + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + tx.onabort = () => resolve(); + } catch { + resolve(); + } + }); + db.close(); +} + const memory = new Map(); /** Drop every cached feed: on a full sign-out the events are the organisation's. */ @@ -175,6 +199,28 @@ export function clearCompanyCalendarCache(): void { onFullSignOut(clearCompanyCalendarCache); +/** Drop one login's cached feeds (keys start with its address). */ +export async function forgetCompanyCalendarsOf(username: string): Promise { + const prefix = `${username}|`; + for (const key of [...memory.keys()]) if (key.startsWith(prefix)) memory.delete(key); + await idbDelete((key) => key.startsWith(prefix)); +} + +// Signing one account out takes its cached feeds along. +onBeforeSignOut(async (accountId) => { + if (accountId === 'all') return; + const username = useAccountStore.getState().getAccountById(accountId)?.username; + if (username) await forgetCompanyCalendarsOf(username); +}); + +/** The feed is gone for this user (removed, or no longer in their scope). */ +class FeedGoneError extends Error { + constructor(feedId: string) { + super(`Company calendar ${feedId} is gone`); + this.name = 'FeedGoneError'; + } +} + async function sha256(text: string): Promise { const subtle = globalThis.crypto?.subtle; if (subtle) { @@ -198,6 +244,7 @@ async function fetchFeedText(feed: CompanyFeed): Promise { headers: { 'Content-Type': 'application/json', ...getActiveAccountSlotHeaders() }, body: JSON.stringify({ feedId: feed.id }), }); + if (response.status === 404 || response.status === 410) throw new FeedGoneError(feed.id); if (!response.ok) throw new Error(`Company calendar ${feed.id}: ${response.status}`); return response.text(); } @@ -235,6 +282,13 @@ export function loadFeedEvents(client: FeedClient, accountKey: string, feed: Com await idbPut(key, entry); return events; } catch (error) { + // A feed the server no longer gives this user takes its events along; + // one that is only down keeps showing what it had. + if (error instanceof Error && error.name === 'FeedGoneError') { + memory.delete(key); + await idbDelete((k) => k === key); + return []; + } debug.warn('calendar', 'Company calendar could not be refreshed', { feed: feed.id, error }); return cached?.events ?? []; } diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index d89b38355..1bc3b8bc3 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -68,6 +68,13 @@ export function purgeSignedOutData(): void { } // Attachment bytes a plugin staged for an upload that never started. void fileStorage.clearFiles().catch(() => {}); + // Company calendar events (lib/company-calendars.ts), also when the + // calendar was never opened in this tab. + try { + window.indexedDB?.deleteDatabase('bulwark-company-calendars'); + } catch { + /* storage unavailable */ + } for (const cleanup of signedOutCleanups) { try { cleanup(); From 595f23556f9799eab30cb802eddbdc51b1639aa8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:49:29 +0100 Subject: [PATCH 18/23] fix: let Suggest times skip the edited event's own time and close on Escape from its button Also says how many times it found to screen readers, and the attendee line for an address the server won't look up reads "Couldn't look up this address". --- .../calendar/__tests__/find-a-time.test.tsx | 28 ++++++++++++++ components/calendar/event-modal.tsx | 9 ++++- components/calendar/find-a-time.tsx | 38 +++++++++++++------ locales/ar/common.json | 3 +- locales/ca/common.json | 3 +- locales/cs/common.json | 3 +- locales/da/common.json | 3 +- locales/de/common.json | 3 +- locales/en/common.json | 3 +- locales/es/common.json | 3 +- locales/et/common.json | 3 +- locales/fa/common.json | 3 +- locales/fr/common.json | 3 +- locales/he/common.json | 3 +- locales/hu/common.json | 3 +- locales/it/common.json | 3 +- locales/ja/common.json | 3 +- locales/ko/common.json | 3 +- locales/lv/common.json | 3 +- locales/mn/common.json | 3 +- locales/nb/common.json | 3 +- locales/nl/common.json | 3 +- locales/pl/common.json | 3 +- locales/pt/common.json | 3 +- locales/ro/common.json | 3 +- locales/ru/common.json | 3 +- locales/sk/common.json | 3 +- locales/th/common.json | 3 +- locales/tr/common.json | 3 +- locales/uk/common.json | 3 +- locales/vi/common.json | 3 +- locales/zh-TW/common.json | 3 +- locales/zh/common.json | 3 +- 33 files changed, 123 insertions(+), 42 deletions(-) diff --git a/components/calendar/__tests__/find-a-time.test.tsx b/components/calendar/__tests__/find-a-time.test.tsx index 6f706d3ef..90ff06739 100644 --- a/components/calendar/__tests__/find-a-time.test.tsx +++ b/components/calendar/__tests__/find-a-time.test.tsx @@ -105,6 +105,14 @@ describe('suggestTimes', () => { expect(calls).toEqual([['me', 'bob']]); }); + it('does not let the edited event hide its own time', async () => { + // The meeting being moved sits at 9:00-10:00 Berlin in everyone's calendar. + const { client } = fakeClient({ periods: { bob: [busy('2026-11-02T08:00:00Z', '2026-11-02T09:00:00Z')] } }); + const ownWindow = { start: new Date('2026-11-02T08:00:00Z'), end: new Date('2026-11-02T09:00:00Z') }; + const outcome = await suggestTimes(client, { ...input([{ name: 'Bob', email: 'bob@example.org' }]), ownWindow }); + expect(outcome.kind === 'ok' && outcome.result.slots[0].start.toISOString()).toBe('2026-11-02T08:00:00.000Z'); + }); + it('says a lookup failed, and stops on forbidden', async () => { const failed = await suggestTimes(fakeClient({ errors: { bob: 'serverUnavailable' } }).client, input([{ name: 'Bob', email: 'bob@example.org' }])); expect(failed.kind === 'ok' && failed.result.notChecked).toEqual([{ email: 'bob@example.org', name: 'Bob', reason: 'error' }]); @@ -187,6 +195,26 @@ describe('FindATime', () => { expect(document.activeElement).toBe(button); }); + it('says how many times it found, and Escape on the button closes the list without reaching the editor', async () => { + const { client } = fakeClient({}); + useAuthStore.setState({ client } as never); + const editorKeys = vi.fn(); + render(createElement('div', { onKeyDown: (e: { key: string }) => editorKeys(e.key) }, createElement(FindATime, { + attendees: [{ name: 'Bob', email: 'bob@example.org' }], + start: new Date('2026-11-05T09:00:00Z'), + end: new Date('2026-11-05T09:30:00Z'), + onPick: vi.fn(), + }))); + const button = screen.getByTestId('find-a-time-button'); + fireEvent.click(button); + await screen.findByTestId('find-a-time-slots'); + expect(screen.getByTestId('find-a-time-panel').querySelector('[role="status"]')).toHaveTextContent('found'); + button.focus(); + fireEvent.keyDown(button, { key: 'Escape' }); + expect(screen.queryByTestId('find-a-time-panel')).toBeNull(); + expect(editorKeys).not.toHaveBeenCalled(); + }); + it('names the people it could not check and offers to share free/busy', async () => { const { client } = fakeClient({}); useAuthStore.setState({ client } as never); diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 66462242a..71d61d6f7 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -17,7 +17,7 @@ import { alertRowToOffset, describeAlert, getCalendarDefaultAlerts, newAlertRow, import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor"; import { parseDuration, getEventColor } from "./event-card"; import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; -import { displayNow, fromWallClock, getEffectiveTimeZone, parseLocalDateTime, toZonedDisplayDate } from "@/lib/timezone"; +import { displayNow, fromDisplayDate, fromWallClock, getEffectiveTimeZone, parseLocalDateTime, toZonedDisplayDate } from "@/lib/timezone"; import { createRecurrenceRule, sameRecurrenceRules } from "@/lib/recurrence-rule"; import { ParticipantInput, type ParticipantInputHandle } from "./participant-input"; import { ParticipantAvailability } from "./participant-availability"; @@ -579,6 +579,12 @@ export function EventModal({ }; }, [startDate, startTime, endDate, endTime, allDay]); + // An existing event's own time, as saved: Suggest times doesn't count it + // as busy (the attendees who have it in their calendar show it so). + const savedWindow = useMemo(() => (event?.start && !event.showWithoutTime + ? { start: fromDisplayDate(getEventStartDate(event)), end: fromDisplayDate(getEventEndDate(event)) } + : null), [event]); + // A suggested slot moves the event there; the fields are a wall clock in // the effective zone, so the instants are turned back into one. const handlePickSlot = useCallback((slot: { start: Date; end: Date }) => { @@ -1354,6 +1360,7 @@ export function EventModal({ start={availabilityWindow.start} end={availabilityWindow.end} onPick={handlePickSlot} + savedWindow={savedWindow} /> )} {isEdit && statusCounts && (existingParticipants.length > 0) && ( diff --git a/components/calendar/find-a-time.tsx b/components/calendar/find-a-time.tsx index 617f91c26..38455e960 100644 --- a/components/calendar/find-a-time.tsx +++ b/components/calendar/find-a-time.tsx @@ -66,6 +66,11 @@ export async function suggestTimes( dayStartHour: unknown; dayEndHour: unknown; visibleOwners: Set; + /** + * The event being edited, as it is saved: its own time shows up as busy + * for everyone who has it in their calendar, and must not hide itself. + */ + ownWindow?: { start: Date; end: Date } | null; }, ): Promise { const directory = await loadAttendeeDirectory(client); @@ -116,7 +121,11 @@ export async function suggestTimes( busy.push(...answer.periods); } - const slots = findFreeSlots({ ...input, busy }); + const saved = input.ownWindow; + const others = saved + ? busy.filter((p) => !(Date.parse(p.utcStart) === saved.start.getTime() && Date.parse(p.utcEnd) === saved.end.getTime())) + : busy; + const slots = findFreeSlots({ ...input, busy: others }); return { kind: "ok", result: { slots, notChecked, directoryQueriesOff } }; } @@ -133,6 +142,8 @@ interface FindATimeProps { end: Date | null; /** Moves the event to the picked slot. */ onPick: (slot: Slot) => void; + /** An existing event's saved window, left out of everyone's busy time. */ + savedWindow?: { start: Date; end: Date } | null; } /** @@ -142,7 +153,7 @@ interface FindATimeProps { * The list opens in place under the button (the editor is a full-screen * sheet on phones already); Escape closes it and gives the focus back. */ -export function FindATime({ attendees, start, end, onPick }: FindATimeProps) { +export function FindATime({ attendees, start, end, onPick, savedWindow }: FindATimeProps) { const t = useTranslations("find_time"); const locale = useLocale(); const client = useAuthStore((s) => s.client); @@ -195,6 +206,7 @@ export function FindATime({ attendees, start, end, onPick }: FindATimeProps) { dayStartHour, dayEndHour, visibleOwners, + ownWindow: savedWindow ?? null, }).catch((): SuggestionOutcome => ({ kind: "unavailable" })) : { kind: "unavailable" }; if (mine !== seq.current) return; @@ -212,7 +224,18 @@ export function FindATime({ attendees, start, end, onPick }: FindATimeProps) { const unknownBusy = result?.notChecked.some((n) => n.reason === "unknown") ?? false; return ( -

    +
    { + // Escape closes the list, from the button too: the editor closes on + // Escape as well, and this one is the list's. + if (e.key === "Escape" && open) { + e.stopPropagation(); + e.preventDefault(); + close(); + } + }} + >