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/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..f1cb8b08e --- /dev/null +++ b/app/api/admin/__tests__/policy-feed-urls-route.test.ts @@ -0,0 +1,79 @@ +// @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[] }, + 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, + 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', + 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'; + +const get = async () => (await GET(new NextRequest('https://mail.example/api/admin/policy'))).json(); + +beforeEach(() => { + who.identity = null; + who.adminCookie = false; +}); + +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); + }); + + // 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 11c972e5e..1d3246cbf 100644 --- a/app/api/admin/policy/route.ts +++ b/app/api/admin/policy/route.ts @@ -1,14 +1,16 @@ 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'; 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 { @@ -70,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 ?? [] }, { @@ -83,8 +93,22 @@ 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 { +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 + * 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..033a18ef8 100644 --- a/app/api/fetch-ical/route.ts +++ b/app/api/fetch-ical/route.ts @@ -1,8 +1,13 @@ 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, intranetFeedHost } from '@/lib/admin/company-feeds'; +import { companyFeedCache, type FeedResult } from '@/lib/admin/company-feed-cache'; +import type { CompanyFeed } from '@/lib/admin/organisation-policy'; +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 +53,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; requireCalendar?: boolean } = {}, +): 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 +93,30 @@ 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 }; + // 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) { 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 +125,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 +139,131 @@ 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(), - }, - }); + 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') { - 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); } } + +/** 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, + 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); + } + // A feed that just failed isn't asked again for a minute; requests that + // arrive while it is being fetched wait for that one fetch. + const failed = companyFeedCache.failure(feed.url); + const result = failed ?? await companyFeedCache.once(feed.url, () => refreshFeed(feed)); + if ('body' in result) return icalResponse(result.body); + return NextResponse.json({ error: result.error }, { status: result.status }); +} + +/** Fetch a company feed into the cache: its body, or why there is none. */ +async function refreshFeed(feed: CompanyFeed): Promise { + const cached = companyFeedCache.get(feed.url); + const outcome = await fetchIcal(feed.url, { + allowPrivateHost: intranetFeedHost(feed) ?? undefined, + requireCalendar: true, + 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 { body: outcome.buffer }; + } + if (cached) { + // A feed that can't be fetched keeps answering with what it last had. + if (outcome.kind === 'not-modified') companyFeedCache.set(feed.url, { ...cached, fetchedAt: Date.now() }); + return { body: cached.body }; + } + const failure = outcome.kind === 'error' + ? { status: outcome.response.status, error: String(((await outcome.response.json().catch(() => ({}))) as { error?: unknown }).error ?? 'Failed to fetch calendar') } + : { status: 502, error: 'Failed to fetch calendar' }; + companyFeedCache.fail(feed.url, failure); + return failure; +} + +/** + * 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/__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/__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/company-calendars-section.tsx b/components/admin/org/sections/company-calendars-section.tsx index 82602702a..036c4d0e2 100644 --- a/components/admin/org/sections/company-calendars-section.tsx +++ b/components/admin/org/sections/company-calendars-section.tsx @@ -1,13 +1,187 @@ '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 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 + 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), 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 + +

    +
    +
    +
    + ); } 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/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 + +

    +
    +
    + ); } diff --git a/components/calendar/__tests__/answer-bars.test.tsx b/components/calendar/__tests__/answer-bars.test.tsx new file mode 100644 index 000000000..7370b1859 --- /dev/null +++ b/components/calendar/__tests__/answer-bars.test.tsx @@ -0,0 +1,93 @@ +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(); + }); +}); + +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/__tests__/calendar-app.test.tsx b/components/calendar/__tests__/calendar-app.test.tsx index c9e67b2fb..b48778c2b 100644 --- a/components/calendar/__tests__/calendar-app.test.tsx +++ b/components/calendar/__tests__/calendar-app.test.tsx @@ -6,6 +6,8 @@ import { useAuthStore } from '@/stores/auth-store'; import { useSettingsStore } from '@/stores/settings-store'; import { useToastStore } from '@/stores/toast-store'; import { useTaskStore } from '@/stores/task-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; // The whole calendar page, with the shell around it stubbed out and the // stores holding a fixed set of calendars and events. @@ -842,3 +844,23 @@ describe('CalendarApp day view tasks', () => { } }); }); + +describe('CalendarApp company calendars on a phone', () => { + it('lists them in the toolbar\'s calendar list, where hiding one is the synced choice', async () => { + media.mobile = true; + media.desktop = false; + const policy = usePolicyStore.getState().policy; + usePolicyStore.setState({ policy: { ...policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, calendars: { feeds: [{ id: 'hol', name: 'Holidays', url: '', urlHeld: true, color: '#16a34a', refreshMinutes: 60 }], freeBusyGroup: '' } } } }); + useSettingsStore.setState({ hiddenCompanyCalendars: [] } as never); + try { + renderApp(); + // The toolbar's list (the sidebar heading has the same words). + fireEvent.click(document.querySelector('button[aria-label="my_calendars"]')!); + const list = (await screen.findByRole('heading', { name: 'my_calendars' })).parentElement!; + fireEvent.click(within(list).getByRole('button', { name: 'Holidays' })); + expect(useSettingsStore.getState().hiddenCompanyCalendars).toEqual(['hol']); + } finally { + usePolicyStore.setState({ policy }); + } + }); +}); 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/__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/__tests__/find-a-time.test.tsx b/components/calendar/__tests__/find-a-time.test.tsx new file mode 100644 index 000000000..90ff06739 --- /dev/null +++ b/components/calendar/__tests__/find-a-time.test.tsx @@ -0,0 +1,240 @@ +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('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' }]); + 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('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); + 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__/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/__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/__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/answer-bars.tsx b/components/calendar/answer-bars.tsx new file mode 100644 index 000000000..464f0d5bb --- /dev/null +++ b/components/calendar/answer-bars.tsx @@ -0,0 +1,130 @@ +"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 AccountOwner, 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 owner of a shared or group account (Principal/get, else the session's + * account name), or null for none. + */ +export function useAccountOwner(client: IJMAPClient | null, accountId: string | null, accountName: string | undefined): AccountOwner | null { + const [principal, setPrincipal] = useState<{ accountId: string; principal: Principal | null } | null>(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 + * (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 owner = useAccountOwner(client, sharedAccountId, calendar?.accountName); + + return useMemo(() => { + if (!event) return { targets: [], note: null, answeredBy: 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) === (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, owner, 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..b38a7d574 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -77,9 +77,13 @@ 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"; +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"; @@ -123,7 +127,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 +1357,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 +1370,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 +1478,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 +1576,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. @@ -1688,10 +1695,42 @@ 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]); + + // The phone toolbar's calendar list: the user's own and the company calendars. + const toolbarCalendars = useMemo( + () => (company.calendars.length > 0 ? [...displayCalendars, ...company.calendars] : displayCalendars), + [displayCalendars, company.calendars], + ); + 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). @@ -1715,11 +1754,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( @@ -1996,7 +2036,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} @@ -2027,9 +2067,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onPrint={() => { void requestPrint({ kind: "calendar-view" }); }} isMobile={isMobile} onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined} - calendars={displayCalendars} - selectedCalendarIds={selectedCalendarIds} - onToggleVisibility={toggleCalendarVisibility} + calendars={toolbarCalendars} + selectedCalendarIds={shownCalendarIds} + onToggleVisibility={toggleShownCalendar} enableCalendarTasks={enableCalendarTasks} onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : isMobile ? undefined : toggleSidebarCollapsed} sidebarOpen={isNarrow ? narrowSidebarOpen : !sidebarCollapsed} @@ -2143,8 +2183,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/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 7e2176cb9..71d61d6f7 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"; @@ -17,14 +17,16 @@ 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, 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"; +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, @@ -78,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. */ @@ -330,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 }); @@ -562,19 +560,43 @@ 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]); + // 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 }) => { + 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([]); @@ -917,11 +939,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; @@ -1176,10 +1205,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 */} @@ -1323,6 +1354,15 @@ export function EventModal({ start={availabilityWindow.start} end={availabilityWindow.end} /> + {userIsOrganizer && !allDay && ( + + )} {isEdit && statusCounts && (existingParticipants.length > 0) && (

    {t("participants.status_summary", { @@ -1567,21 +1607,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/calendar/find-a-time.tsx b/components/calendar/find-a-time.tsx new file mode 100644 index 000000000..38455e960 --- /dev/null +++ b/components/calendar/find-a-time.tsx @@ -0,0 +1,315 @@ +"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; + /** + * 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); + 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 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 } }; +} + +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; + /** An existing event's saved window, left out of everyone's busy time. */ + savedWindow?: { start: Date; end: Date } | null; +} + +/** + * "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, savedWindow }: 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, + ownWindow: savedWindow ?? null, + }).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 ( +
    { + // 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(); + } + }} + > + + {open && ( +
    +
    + {busy && ( + + + )} + {!busy && outcome?.kind === "rate_limited" && t("rate_limited")} + {!busy && outcome?.kind === "unavailable" && t("unavailable")} + {!busy && result && result.slots.length === 0 && t("none")} + {!busy && result && result.slots.length > 0 && !picked && {t("found", { count: result.slots.length })}} + {!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-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/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/components/calendar/use-company-calendars.ts b/components/calendar/use-company-calendars.ts new file mode 100644 index 000000000..6da312050 --- /dev/null +++ b/components/calendar/use-company-calendars.ts @@ -0,0 +1,63 @@ +"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>({}); + // 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(() => { + 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/components/email/__tests__/calendar-invitation-banner.test.tsx b/components/email/__tests__/calendar-invitation-banner.test.tsx index b6561856a..a010d1c90 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 = { @@ -588,6 +592,132 @@ 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); + }); + }); + + 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("finds the group's copy on the server when the calendar wasn't loaded, and answers there", async () => { + withAccountCalendar('team'); + try { + const participants = invited('team@example.com'); + mocks.calendarState.events = []; + mocks.clientMock.parseCalendarEvents.mockResolvedValue([{ uid: 'uid-13', title: 'Team call', start: '2026-03-30T09:00:00Z', participants }]); + mocks.clientMock.queryCalendarEvents.mockImplementation(async (...args: unknown[]) => (args[3] === 'team' + ? [{ id: 'event-13', uid: 'uid-13', start: '2026-03-30T09:00:00Z', participants, calendarIds: { b: true } }] + : [])); + mocks.calendarState.rsvpEvent.mockResolvedValue(true); + + render()} />); + fireEvent.click(await screen.findByText('Accept')); + + await waitFor(() => expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(mocks.clientMock, 'team:event-13', 'team', 'accepted', null)); + expect(mocks.clientMock.queryCalendarEvents).toHaveBeenCalledWith({ uid: 'uid-13' }, undefined, undefined, 'team'); + expect(mocks.calendarState.importEvents).not.toHaveBeenCalled(); + } 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 c0e3df709..007880c61 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,33 @@ 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. + // Set below, once the function exists; the parse callback reads it late. + const loadSharedCopyRef = useRef<(uid: string) => Promise>(async () => null); + 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, @@ -503,7 +531,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp // only needed for the "already in calendar" pill, must not block the banner. // Filter by UID server-side; the previous unfiltered query fetched up to // 1000 events plus multiple /get batches just to find one match. - if (parsed.uid && supportsCalendar) { + if (parsed.uid && supportsCalendar && sharedMailAccount) { + // The copy in the shared or group account the message belongs to. + void loadSharedCopyRef.current(parsed.uid).catch(() => { /* ignore lookup failure */ }); + } else if (parsed.uid && supportsCalendar) { const storeHasIt = useCalendarStore.getState().events.some((e) => e.uid === parsed.uid); if (!storeHasIt) { client.queryCalendarEvents({ uid: parsed.uid }) @@ -522,7 +553,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp setParseError(err instanceof Error ? err.message : String(err)); setState('error'); } - }, [calendarInvitationParsingEnabled, client, parseClient, blobAccountId, attachment, supportsCalendar]); + }, [calendarInvitationParsingEnabled, client, parseClient, blobAccountId, attachment, supportsCalendar, sharedMailAccount]); useEffect(() => { if (attachment && calendarInvitationParsingEnabled) { @@ -572,16 +603,53 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const allowsRsvp = method === 'request'; const allowsImport = method === 'request' || method === 'publish' || method === 'add' || method === 'unknown'; + /** Finds the invitation's copy in the shared or group account and adds it to the store. */ + async function loadSharedCopy(uid: string): Promise { + if (!client || !sharedMailAccount) return null; + const known = useCalendarStore.getState().events.find((e) => e.uid === uid && e.accountId === sharedMailAccount); + if (known) return known; + const found = await client.queryCalendarEvents({ uid }, undefined, undefined, sharedMailAccount); + // As the store holds a shared account's events: prefixed ids, store calendar ids. + const accountCalendars = useCalendarStore.getState().calendars.filter((c) => c.accountId === sharedMailAccount); + const mapped = found.map((e): CalendarEvent => ({ + ...e, + id: `${sharedMailAccount}:${e.id}`, + originalId: e.id, + originalCalendarIds: e.calendarIds, + calendarIds: Object.fromEntries(Object.entries(e.calendarIds ?? {}).map(([id, on]) => [ + accountCalendars.find((c) => (c.originalId ?? c.id) === id)?.id ?? `${sharedMailAccount}:${id}`, on, + ])), + accountId: sharedMailAccount, + accountName: accountCalendars[0]?.accountName, + isShared: true, + })); + if (mapped.length === 0) return null; + useCalendarStore.setState((s) => { + const ids = new Set(s.events.map((e) => e.id)); + const added = mapped.filter((e) => !ids.has(e.id)); + return added.length > 0 ? { events: [...s.events, ...added] } : s; + }); + return mapped.find((e) => !e.recurrenceId) ?? mapped[0]; + } + + loadSharedCopyRef.current = loadSharedCopy; + + // 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) + ? 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; @@ -635,6 +703,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const cancellationOutdated = isCancellation && cancelRemoval?.kind === 'outdated'; const resolveExistingEventForRsvp = async () => { + if (sharedMailAccount) return existingEvent; if (!client || !existingEvent) { return existingEvent; } @@ -657,7 +726,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); @@ -669,11 +741,13 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp // and Stalwart queues the iTIP REPLY itself. A manual client-side reply // here produced duplicate emails. try { + // A copy in a shared or group account may not be loaded yet: look it up + // there before adding the invitation again. const eventForRsvp = existingEvent ? await resolveExistingEventForRsvp() - : null; + : sharedMailAccount && parsedEvent.uid ? await loadSharedCopy(parsedEvent.uid) : null; const existingEventParticipant = eventForRsvp - ? findParticipantByEmails(eventForRsvp, identityEmails) + ? findParticipantByEmails(eventForRsvp, actingEmails) : null; const canFallbackToParsedParticipant = Boolean( existingEvent @@ -682,7 +756,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'); @@ -715,12 +789,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 { 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..8b9d46efa --- /dev/null +++ b/components/settings/__tests__/vacation-calendar-block.test.tsx @@ -0,0 +1,96 @@ +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, defaults: {}, 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('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(); + 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/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/components/settings/vacation-calendar-block.tsx b/components/settings/vacation-calendar-block.tsx new file mode 100644 index 000000000..9911ff2b9 --- /dev/null +++ b/components/settings/vacation-calendar-block.tsx @@ -0,0 +1,104 @@ +'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, 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): + * 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 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 ?? ''; + // 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 || hidden) 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 && ( >; + +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 statusIn(j: J, eventId: string, email: string): Promise { + const event = await getEvent(j, eventId); + const p = Object.values((event?.participants ?? {}) as Record).find((x) => x.email === email); + return p?.participationStatus; +} + +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'); + if (ENT_SERVER !== '0.16') { + // Both answers: alice accepts through bob, bob declines for himself. + await page.getByTestId('answer-for-owner').getByRole('button', { name: 'Accept' }).click(); + await expect.poll(async () => statusIn(invite.carol, invite.id, ENT.alice), { timeout: 60_000 }).toBe('accepted'); + await openEventInAgenda(page, title, ENT.alice); + await page.getByTestId('answer-self').getByRole('button', { name: 'Decline' }).click(); + // Carol's copy changed the right attendee each time. + await expect.poll(async () => statusIn(invite.carol, invite.id, ENT.bob), { timeout: 60_000 }).toBe('declined'); + expect(await statusIn(invite.carol, invite.id, ENT.alice)).toBe('accepted'); + } + } 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('an invitation in the team mailbox is answered from the banner as the team, with no copy in the member\'s own calendar', async ({ browser }) => { + test.setTimeout(240_000); + const alice = await jmapAs(ENT.alice); + const title = `B banner ${TS}`; + const teamAccount = alice.accountIdByName(ENT.team); + const invite = await carolInvites(title, [['Ent Team', ENT.team]], 34); + const teamCopy = await waitForCopy(alice, invite.uid, teamAccount); + const page = await userPage(browser, ENT.alice); + try { + await expandSharedFolders(page, ENT.team); + await folderRow(page, { role: 'inbox', shared: true }).first().click(); + const row = page.locator('[data-testid="email-list-item"]', { hasText: title }).first(); + await expect(row).toBeVisible({ timeout: 60_000 }); + await row.click(); + await page.getByRole('button', { name: 'Accept', exact: true }).first().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); + // No duplicate in alice's own calendar (#634). + expect(await findByUid(alice, invite.uid)).toBeNull(); + } finally { + await closePage(page); + await destroyEvents(invite.carol, [invite.id]); + await destroyEvents(alice, [teamCopy], teamAccount); + const stray = await findByUid(alice, invite.uid).catch(() => null); + await destroyEvents(alice, [stray]); + 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(() => {}); + } +}); + +test('an organisation lock that never blocks the calendar shows locked and creates no event', async ({ browser }) => { + test.setTimeout(180_000); + const alice = await jmapAs(ENT.alice); + const admin = await adminContext(browser); + const from = new Date(); from.setDate(from.getDate() + 15); from.setHours(9, 0, 0, 0); + const to = new Date(from); to.setDate(to.getDate() + 2); + const resetReply = () => call(alice, [['VacationResponse/set', { accountId: alice.accountId, update: { singleton: { isEnabled: false, fromDate: null, toDate: null } } }, '0']], [CORE, VAC]); + await destroyEvents(alice, [await findByUid(alice, `bulwark-ooo-${alice.accountId}`)]); + 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]); + try { + await withPolicy(admin, { restrictions: { vacationBlockCalendar: { locked: true, value: false } } }, async () => { + const page = await userPage(browser, ENT.alice); + try { + await page.goto('/settings/vacation'); + const block = page.getByTestId('vacation-block-calendar'); + await expect(block).toBeVisible({ timeout: 30_000 }); + await expect(block).not.toBeChecked(); + await expect(page.getByText('Set by your organisation').first()).toBeVisible(); + await page.locator('[data-settings-section="reply"]').getByRole('switch').first().click(); + await expect.poll(async () => (await call(alice, [['VacationResponse/get', { accountId: alice.accountId, ids: ['singleton'] }, '0']], [CORE, VAC]))[0][1].list[0].isEnabled, { timeout: 30_000 }).toBe(true); + // Well past the sync's pause: still no busy event. + await page.waitForTimeout(6000); + expect(await findByUid(alice, `bulwark-ooo-${alice.accountId}`)).toBeNull(); + await shot(page, 'b-ooo-locked'); + } finally { + await closePage(page); + } + }); + } finally { + await admin.close(); + await resetReply().catch(() => {}); + await destroyEvents(alice, [await findByUid(alice, `bulwark-ooo-${alice.accountId}`).catch(() => null)]); + } +}); + +// ── 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; + 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); + }); + // 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://${lan}:${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(); + } +}); diff --git a/lib/__tests__/calendar-delegate.test.ts b/lib/__tests__/calendar-delegate.test.ts new file mode 100644 index 000000000..aea6584e0 --- /dev/null +++ b/lib/__tests__/calendar-delegate.test.ts @@ -0,0 +1,117 @@ +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('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 } })); + 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/__tests__/company-calendars.test.ts b/lib/__tests__/company-calendars.test.ts new file mode 100644 index 000000000..7e4a1f8e9 --- /dev/null +++ b/lib/__tests__/company-calendars.test.ts @@ -0,0 +1,146 @@ +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), +})); + +vi.mock('@/lib/auth/active-account-slot', () => ({ getActiveAccountSlotHeaders: () => ({ 'X-JMAP-Cookie-Slot': '1' }) })); + +import { + companyCalendarId, createCompanyCalendar, isCompanyCalendarId, loadFeedEvents, resetCompanyCalendarMemory, toCompanyEvents, +} from '../company-calendars'; +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'; + +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' }) })); + // 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'); + 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' }); + }); +}); + +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__/fetch-ical-feed-route.test.ts b/lib/__tests__/fetch-ical-feed-route.test.ts new file mode 100644 index 000000000..c0fe6202d --- /dev/null +++ b/lib/__tests__/fetch-ical-feed-route.test.ts @@ -0,0 +1,168 @@ +// @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://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' }] } } } }, + ], +}; +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; 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 () => { + 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('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('fetches a feed once for requests that arrive together', async () => { + let release: () => void = () => {}; + guardedFetch.mockImplementation(() => new Promise((resolve) => { release = () => resolve(ics()); })); + const both = Promise.all([post({ feedId: 'hol' }), post({ feedId: 'hol' })]); + await vi.waitFor(() => expect(guardedFetch).toHaveBeenCalledTimes(1)); + release(); + expect((await both).map((r) => r.status)).toEqual([200, 200]); + expect(guardedFetch).toHaveBeenCalledTimes(1); + }); + + it('answers a feed that just failed with its error for a minute without asking again', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-11-02T08:00:00Z')); + guardedFetch.mockImplementation(async () => new Response('down', { status: 500 })); + expect((await post({ feedId: 'hol' })).status).toBe(502); + expect((await post({ feedId: 'hol' })).status).toBe(502); + expect(guardedFetch).toHaveBeenCalledTimes(1); + vi.setSystemTime(new Date('2026-11-02T08:01:01Z')); + guardedFetch.mockImplementation(async () => ics()); + expect((await post({ feedId: 'hol' })).status).toBe(200); + expect(guardedFetch).toHaveBeenCalledTimes(2); + }); + + it('needs a signed-in user', async () => { + creds.value = null; + expect((await post({ feedId: 'hol' })).status).toBe(401); + }); +}); 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/__tests__/free-busy-share.test.ts b/lib/__tests__/free-busy-share.test.ts new file mode 100644 index 000000000..8882e2373 --- /dev/null +++ b/lib/__tests__/free-busy-share.test.ts @@ -0,0 +1,116 @@ +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 shares with a directory entry that is no group', async () => { + const { client, setCalendarShare } = fakeClient([calendar('a', { isDefault: true })], { groupAccount: false }); + client.findPrincipalByEmail.mockResolvedValue({ principal: { id: 'p', name: 'x' } as never, status: 'ok' }); + expect(await ensureFreeBusyShare(client, 'person@example.org')).toBe('no-group'); + client.findPrincipalByEmail.mockResolvedValue({ principal: { id: 'p', type: 'individual', name: 'x' } as never, status: 'ok' }); + expect(await ensureFreeBusyShare(client, 'person@example.org')).toBe('no-group'); + expect(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']); + }); +}); 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/__tests__/url-guard-private-host.test.ts b/lib/__tests__/url-guard-private-host.test.ts new file mode 100644 index 000000000..7cb1ac942 --- /dev/null +++ b/lib/__tests__/url-guard-private-host.test.ts @@ -0,0 +1,98 @@ +// @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 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) => { + const actual = await importOriginal(); + return { + ...actual, + default: { ...actual, lookup: (...args: unknown[]) => lookup(...args) }, + lookup: (...args: unknown[]) => lookup(...args), + }; +}); + +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; + 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(); + hits = 0; + }); + + 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('refuses loopback and metadata addresses even for the admin\'s intranet host', async () => { + const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard'); + 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('gives a public host no exception, whatever its DNS points at', async () => { + const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard'); + 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); + expect(hits).toBe(0); + }); +}); diff --git a/lib/__tests__/vacation-calendar.test.ts b/lib/__tests__/vacation-calendar.test.ts new file mode 100644 index 000000000..4a995252c --- /dev/null +++ b/lib/__tests__/vacation-calendar.test.ts @@ -0,0 +1,310 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + blockTimes, decideBlock, ownFilterScriptStops, resolveBlockSetting, effectiveBlockSetting, eventSpan, isoDuration, oooUid, resetVacationCalendarSync, + syncVacationBlock, vacationReplyLoaded, vacationReplySaved, type DecisionInput, +} from '../vacation-calendar'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; +import { generateScript } from '@/lib/sieve/generator'; + +const NOW = new Date('2026-11-10T12:00:00Z'); +const reply = (extra: Partial = {}) => ({ + 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); + }); +}); + +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/admin/__tests__/company-feeds.test.ts b/lib/admin/__tests__/company-feeds.test.ts new file mode 100644 index 000000000..daffdb154 --- /dev/null +++ b/lib/admin/__tests__/company-feeds.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { allFeeds, feedForSubject, intranetFeedHost, 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('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: {} }) + .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..32b0fca18 --- /dev/null +++ b/lib/admin/company-feed-cache.ts @@ -0,0 +1,80 @@ +/** + * 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; +} + +/** What a fetch of a feed gave: its body, or the error to answer with. */ +export type FeedResult = { body: ArrayBuffer } | { status: number; error: string }; + +const MAX_ENTRIES = 200; +/** How long a failed feed answers with its error before it is asked again. */ +export const FAILURE_TTL_MS = 60_000; +const KEY = Symbol.for('bulwark.companyFeedCache'); + +interface CacheState { + feeds: Map; + failures: Map; + inFlight: Map>; +} + +function cacheState(): CacheState { + const g = globalThis as typeof globalThis & { [KEY]?: CacheState }; + g[KEY] ??= { feeds: new Map(), failures: new Map(), inFlight: new Map() }; + return g[KEY]; +} + +function store(): Map { + return cacheState().feeds; +} + +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); + }, + /** The error a feed failed with less than a minute ago, if it did. */ + failure(url: string): { status: number; error: string } | null { + const failures = cacheState().failures; + const entry = failures.get(url); + if (!entry) return null; + if (Date.now() - entry.at >= FAILURE_TTL_MS) { + failures.delete(url); + return null; + } + return entry.result; + }, + fail(url: string, result: { status: number; error: string }): void { + cacheState().failures.set(url, { at: Date.now(), result }); + }, + /** Run `fetch` for a feed once at a time: callers meanwhile share its result. */ + once(url: string, fetch: () => Promise): Promise { + const inFlight = cacheState().inFlight; + let running = inFlight.get(url); + if (!running) { + running = fetch().finally(() => inFlight.delete(url)); + inFlight.set(url, running); + } + return running; + }, + clear(): void { + const state = cacheState(); + state.feeds.clear(); + state.failures.clear(); + state.inFlight.clear(); + }, +}; diff --git a/lib/admin/company-feeds.ts b/lib/admin/company-feeds.ts new file mode 100644 index 000000000..03121fef0 --- /dev/null +++ b/lib/admin/company-feeds.ts @@ -0,0 +1,72 @@ +/** + * 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'; +import { isIntranetHostName } from '@/lib/security/url-guard'; + +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 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 { + const host = new URL(feed.url).hostname.toLowerCase(); + return isIntranetHostName(host) ? host : null; + } 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/calendar-delegate.ts b/lib/calendar-delegate.ts new file mode 100644 index 000000000..04f63083e --- /dev/null +++ b/lib/calendar-delegate.ts @@ -0,0 +1,143 @@ +/** + * 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); + // 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 }; + } + 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!, + 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, ...(alsoInvited ? 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/company-calendars.ts b/lib/company-calendars.ts new file mode 100644 index 000000000..12fffd9fa --- /dev/null +++ b/lib/company-calendars.ts @@ -0,0 +1,304 @@ +/** + * 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 { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; +import { IS_LITE } from '@/lib/lite'; +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__:'; + +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 ────────────────────────────────────────── + +/** 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 { + 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(); +} + +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. */ +export function clearCompanyCalendarCache(): void { + memory.clear(); + inFlight.clear(); + if (typeof indexedDB === 'undefined') return; + try { + indexedDB.deleteDatabase(DB_NAME); + } catch { + /* storage unavailable */ + } +} + +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) { + 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', + // 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.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(); +} + +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) { + // 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 ?? []; + } + })().finally(() => inFlight.delete(key)); + inFlight.set(key, work); + return work; +} + +/** For tests. */ +export function resetCompanyCalendarMemory(): void { + memory.clear(); + inFlight.clear(); +} 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..a34a4e0ab --- /dev/null +++ b/lib/free-busy-share.ts @@ -0,0 +1,122 @@ +/** + * 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; + // Only a principal that says it is a group: an address typed by mistake + // must not share everyone's free/busy with one person. + return 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/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/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/lib/jmap/types.ts b/lib/jmap/types.ts index 9f1db48d7..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; } /** @@ -778,6 +781,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/lib/security/url-guard.ts b/lib/security/url-guard.ts index b022131a3..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,12 +29,47 @@ 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 * 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 +81,7 @@ function parseAllowedUrl(urlString: string): URL | null { if (url.username || url.password) return null; const hostname = normalizeHostname(url.hostname); + if (privateHost && hostname === privateHost && isIntranetHostName(hostname)) return url; if (isBlockedHostname(hostname)) return null; if (isBlockedIpAddress(hostname)) return null; @@ -213,9 +249,57 @@ export function envProxyFor(url: URL): URL | null { } } -function lookupChecked(hostname: string): Promise { +/** + * 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) => { + 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; + } + 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); + }) + .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 +313,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 +326,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 +350,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 +381,33 @@ export type PublicFetchResponse = UndiciResponse; export async function fetchPublicUrl( url: string, init: Omit = {}, + options: { + /** + * 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 parsed = parseAllowedUrl(url); + const privateHost = options.allowPrivateHost && isIntranetHostName(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/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(); diff --git a/lib/vacation-calendar.ts b/lib/vacation-calendar.ts new file mode 100644 index 000000000..cbbd44e19 --- /dev/null +++ b/lib/vacation-calendar.ts @@ -0,0 +1,415 @@ +/** + * 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. + * + * 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. + * + * 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; + +/** 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}`; +} + +/** 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; + /** 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'; +} + +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 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) { + 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: 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(); + return resolveBlockSetting( + policy.getRestriction('vacationBlockCalendar'), + policy.getEffectiveDefault('vacationBlockCalendar'), + useSettingsStore.getState().vacationBlockCalendar, + ).value; +} + +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(), + previous: Pick | null = null, +): 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 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. + 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', + keywords: { [OOO_KEYWORD]: true }, + } 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, + [`keywords/${OOO_KEYWORD}`]: true, + } as Partial, 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; + /** 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 { + /** 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] ??= { timers: new Map(), pending: new Map(), running: new Map(), loaded: new Set() }); +} + +function run(key: string): void { + const state = scheduler(); + 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.delete(key); + if (state.pending.has(key) && !state.timers.has(key)) run(key); + })); +} + +function schedule(job: Pending): void { + const state = scheduler(); + 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, 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, previous: { fromDate: reply.fromDate, toDate: reply.toDate }, 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, previous: null, trigger: 'load' }); +} + +/** For tests: drop what the scheduler holds. */ +export function resetVacationCalendarSync(): void { + const state = scheduler(); + for (const timer of state.timers.values()) clearTimeout(timer); + state.timers.clear(); + state.pending.clear(); + state.running.clear(); + 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 2116d773b..4cf40542a 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -4845,6 +4845,51 @@ "cancelled": "ألغى {name} \"{title}\"", "untitled": "حدث بدون عنوان" }, + "company_calendars": { + "section": "المؤسسة" + }, + "vacation_calendar": { + "label": "حجز تقويمي", + "description": "يضيف حدثًا مشغولًا لمدة غيابك، حتى لا يحجز أحد اجتماعات معك خلالها.", + "no_access_managed": "لا يمكن حجز تقويم هذا الحساب: ليست لديك صلاحية الكتابة فيه.", + "no_calendar": "لا يمكن حجز تقويمك: لم يجد Bulwark تقويمًا لإضافة الحدث إليه.", + "filter_warning": "تشغيل هذا يوقف نص التصفية الخاص بك طوال مدة تفعيل الرد." + }, + "calendar_delegate": { + "answer_for": "الرد نيابةً عن {name}", + "no_write": "يمكنك رؤية دعوات {name} لكن لا يمكنك الرد عليها. اطلب صلاحية التعديل.", + "also_invited": "أنت مدعو أيضًا، ولا يستطيع هذا الخادم التمييز بين ردك ورد {name}. على {name} الرد على هذه الدعوة.", + "answered_by": "ردّ عليها {who}" + }, + "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": "تعذّر العثور على هذا العنوان", + "found": "الأوقات المتاحة التي وُجدت: {count}" + }, "share_notifications": { "someone": "شخص ما", "shared": "شارك {name} معك {kind} \"{object}\"", diff --git a/locales/ca/common.json b/locales/ca/common.json index e023d59f5..788b0ccbf 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -4845,6 +4845,51 @@ "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.", + "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ó.", + "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}", + "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 s’ha pogut cercar aquesta adreça", + "found": "Horaris lliures trobats: {count}" + }, "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..1169c5353 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5283,6 +5283,51 @@ "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.", + "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.", + "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}", + "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": "Adresu nelze vyhledat", + "found": "Nalezené volné časy: {count}" + }, "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..587317f1e 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -4845,6 +4845,51 @@ "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.", + "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.", + "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}", + "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": "Adressen kunne ikke slås op", + "found": "Ledige tidspunkter fundet: {count}" + }, "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..5d939cba5 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5283,6 +5283,51 @@ "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.", + "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.", + "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", + "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": "Adresse nicht gefunden", + "found": "Freie Zeiten gefunden: {count}" + }, "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..5f5507dfe 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -4845,6 +4845,51 @@ "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.", + "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.", + "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}", + "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": "Couldn’t look up this address", + "found": "Free times found: {count}" + }, "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..9882934e1 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5283,6 +5283,51 @@ "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.", + "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.", + "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}", + "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 pudo buscar esta dirección", + "found": "Horarios libres encontrados: {count}" + }, "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..39bbc270f 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -4845,6 +4845,51 @@ "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.", + "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.", + "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}", + "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": "Seda aadressi ei leitud", + "found": "Leitud vabu aegu: {count}" + }, "share_notifications": { "someone": "Keegi", "shared": "{name} jagas sinuga {kind} \"{object}\"", diff --git a/locales/fa/common.json b/locales/fa/common.json index f3604c96c..ad8328a19 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -4845,6 +4845,51 @@ "cancelled": "{name} «{title}» را لغو کرد", "untitled": "رویداد بدون عنوان" }, + "company_calendars": { + "section": "سازمان" + }, + "vacation_calendar": { + "label": "مسدود کردن تقویم من", + "description": "برای مدت غیبت شما یک رویداد مشغول اضافه می‌کند تا کسی در آن زمان جلسه نگذارد.", + "no_access_managed": "تقویم این حساب را نمی‌توان مسدود کرد: دسترسی نوشتن به آن ندارید.", + "no_calendar": "تقویم شما را نمی‌توان مسدود کرد: Bulwark تقویمی برای افزودن رویداد پیدا نکرد.", + "filter_warning": "روشن کردن این، اسکریپت فیلتر خودتان را تا وقتی پاسخ فعال است خاموش می‌کند." + }, + "calendar_delegate": { + "answer_for": "پاسخ به‌جای {name}", + "no_write": "دعوت‌های {name} را می‌بینید اما نمی‌توانید به‌جای او پاسخ دهید. دسترسی ویرایش بخواهید.", + "also_invited": "شما هم دعوت شده‌اید و این سرور پاسخ شما را از پاسخ {name} تشخیص نمی‌دهد. {name} باید خودش پاسخ دهد.", + "answered_by": "پاسخ‌دهنده: {who}" + }, + "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": "این نشانی پیدا نشد", + "found": "زمان‌های آزاد پیدا شده: {count}" + }, "share_notifications": { "someone": "کسی", "shared": "{name} {kind} «{object}» را با شما به اشتراک گذاشت", diff --git a/locales/fr/common.json b/locales/fr/common.json index f0c00df6c..bd0edd0a4 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5283,6 +5283,51 @@ "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à.", + "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.", + "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}", + "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": "Adresse introuvable", + "found": "Créneaux libres trouvés : {count}" + }, "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..4eaa76960 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5255,6 +5255,51 @@ "cancelled": "{name} ביטל/ה את \"{title}\"", "untitled": "אירוע ללא כותרת" }, + "company_calendars": { + "section": "ארגון" + }, + "vacation_calendar": { + "label": "חסום את היומן שלי", + "description": "מוסיף אירוע תפוס לזמן ההיעדרות, כדי שאף אחד לא יקבע איתך פגישות אז.", + "no_access_managed": "אי אפשר לחסום את היומן של החשבון הזה: אין לך הרשאת כתיבה אליו.", + "no_calendar": "אי אפשר לחסום את היומן: Bulwark לא מצא יומן להוסיף אליו את האירוע.", + "filter_warning": "הפעלה תכבה את סקריפט הסינון שלך כל עוד התשובה פעילה." + }, + "calendar_delegate": { + "answer_for": "השב בשם {name}", + "no_write": "אפשר לראות את ההזמנות של {name}, אבל אי אפשר להשיב בשמו. בקשו הרשאת עריכה.", + "also_invited": "גם אתם מוזמנים, והשרת הזה לא מבדיל בין התשובה שלכם לזו של {name}. {name} צריך להשיב בעצמו.", + "answered_by": "נענה על ידי {who}" + }, + "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": "לא ניתן היה לחפש את הכתובת", + "found": "זמנים פנויים שנמצאו: {count}" + }, "share_notifications": { "someone": "מישהו", "shared": "{name} שיתף/ה איתך את {kind} \"{object}\"", diff --git a/locales/hu/common.json b/locales/hu/common.json index 6c3668404..ba382be6a 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -4845,6 +4845,51 @@ "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.", + "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.", + "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}", + "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": "A cím nem kereshető", + "found": "Talált szabad időpontok: {count}" + }, "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..337d6c200 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5283,6 +5283,51 @@ "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.", + "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.", + "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}", + "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": "Impossibile cercare questo indirizzo", + "found": "Orari liberi trovati: {count}" + }, "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..ae2774b8f 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5283,6 +5283,51 @@ "cancelled": "{name} さんが「{title}」をキャンセルしました", "untitled": "無題の予定" }, + "company_calendars": { + "section": "組織" + }, + "vacation_calendar": { + "label": "カレンダーをブロック", + "description": "不在の期間に予定ありの予定を追加し、その間に会議を入れられないようにします。", + "no_access_managed": "このアカウントのカレンダーはブロックできません。書き込み権限がありません。", + "no_calendar": "カレンダーをブロックできません。予定を追加できるカレンダーが見つかりませんでした。", + "filter_warning": "これをオンにすると、返信が有効な間は自分のフィルタースクリプトがオフになります。" + }, + "calendar_delegate": { + "answer_for": "{name} の代わりに返答", + "no_write": "{name} の招待は見られますが、代わりに返答することはできません。編集権限を依頼してください。", + "also_invited": "あなたも招待されていて、このサーバーではあなたの返答と {name} の返答を区別できません。この招待には {name} 本人が返答する必要があります。", + "answered_by": "{who} が返答しました" + }, + "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": "このアドレスを検索できません", + "found": "見つかった空き時間:{count}" + }, "share_notifications": { "someone": "誰か", "shared": "{name} さんが{kind}「{object}」を共有しました", diff --git a/locales/ko/common.json b/locales/ko/common.json index 8dadb7040..c0c174b28 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5283,6 +5283,51 @@ "cancelled": "{name}님이 \"{title}\"을(를) 취소했습니다", "untitled": "제목 없는 일정" }, + "company_calendars": { + "section": "조직" + }, + "vacation_calendar": { + "label": "내 캘린더 차단", + "description": "부재 기간에 바쁨 일정을 추가해 그동안 회의가 잡히지 않게 합니다.", + "no_access_managed": "이 계정의 캘린더는 차단할 수 없습니다. 쓰기 권한이 없습니다.", + "no_calendar": "캘린더를 차단할 수 없습니다. 일정을 추가할 캘린더를 찾지 못했습니다.", + "filter_warning": "이 기능을 켜면 응답이 활성화된 동안 내 필터 스크립트가 꺼집니다." + }, + "calendar_delegate": { + "answer_for": "{name} 대신 응답", + "no_write": "{name}의 초대는 볼 수 있지만 대신 응답할 수는 없습니다. 편집 권한을 요청하세요.", + "also_invited": "나도 초대되어 있어 이 서버는 내 응답과 {name}의 응답을 구분하지 못합니다. 이 초대는 {name}이(가) 직접 응답해야 합니다.", + "answered_by": "{who}이(가) 응답함" + }, + "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": "이 주소를 찾을 수 없음", + "found": "찾은 빈 시간: {count}" + }, "share_notifications": { "someone": "누군가", "shared": "{name}님이 {kind} \"{object}\"을(를) 공유했습니다", diff --git a/locales/lv/common.json b/locales/lv/common.json index 8160cd0b0..aa9237d5a 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5283,6 +5283,51 @@ "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.", + "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.", + "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}", + "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": "Šo adresi neizdevās atrast", + "found": "Atrasti brīvi laiki: {count}" + }, "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..308fc4af1 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -4845,6 +4845,51 @@ "cancelled": "{name} \"{title}\"-г цуцаллаа", "untitled": "Нэргүй арга хэмжээ" }, + "company_calendars": { + "section": "Байгууллага" + }, + "vacation_calendar": { + "label": "Миний хуанлийг хаах", + "description": "Таныг байхгүй үед завгүй арга хэмжээ нэмж, тэр үед хэн ч уулзалт товлохгүй болгоно.", + "no_access_managed": "Энэ дансны хуанлийг хаах боломжгүй: танд бичих эрх алга.", + "no_calendar": "Хуанлийг хаах боломжгүй: Bulwark арга хэмжээ нэмэх хуанли олсонгүй.", + "filter_warning": "Үүнийг асаавал хариу идэвхтэй байх хугацаанд таны шүүлтүүрийн скрипт унтарна." + }, + "calendar_delegate": { + "answer_for": "{name}-ийн өмнөөс хариулах", + "no_write": "Та {name}-ийн урилгыг харж болох ч өмнөөс нь хариулж чадахгүй. Засах эрх хүснэ үү.", + "also_invited": "Таныг ч бас урьсан тул энэ сервер таны болон {name}-ийн хариуг ялгаж чадахгүй. Үүнд {name} өөрөө хариулах ёстой.", + "answered_by": "{who} хариулсан" + }, + "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": "Энэ хаягийг хайж чадсангүй", + "found": "Олдсон чөлөөтэй цаг: {count}" + }, "share_notifications": { "someone": "Хэн нэгэн", "shared": "{name} танд {kind} \"{object}\"-г хуваалцлаа", diff --git a/locales/nb/common.json b/locales/nb/common.json index bea1f8ef8..7ebbdf1d1 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -4845,6 +4845,51 @@ "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.", + "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.", + "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}", + "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": "Adressen kunne ikke slås opp", + "found": "Ledige tidspunkter funnet: {count}" + }, "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..b3145d86d 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5283,6 +5283,51 @@ "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.", + "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.", + "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}", + "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": "Dit adres kon niet worden opgezocht", + "found": "Vrije tijden gevonden: {count}" + }, "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..25a74acaf 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5283,6 +5283,51 @@ "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ń.", + "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.", + "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}", + "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 udało się wyszukać adresu", + "found": "Znalezione wolne terminy: {count}" + }, "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..06b42881a 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5283,6 +5283,51 @@ "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.", + "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.", + "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}", + "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 foi possível procurar este endereço", + "found": "Horários livres encontrados: {count}" + }, "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..2636c6532 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -4845,6 +4845,51 @@ "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.", + "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.", + "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}", + "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": "Adresa nu a putut fi căutată", + "found": "Ore libere găsite: {count}" + }, "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..ffd7f4e89 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5283,6 +5283,51 @@ "cancelled": "{name} отменил(а) «{title}»", "untitled": "Событие без названия" }, + "company_calendars": { + "section": "Организация" + }, + "vacation_calendar": { + "label": "Занять мой календарь", + "description": "Добавляет событие «занят» на время отсутствия, чтобы вам не назначали встречи.", + "no_access_managed": "Календарь этой учётной записи нельзя занять: у вас нет доступа на запись.", + "no_calendar": "Не удаётся занять календарь: Bulwark не нашёл календарь для события.", + "filter_warning": "Если включить, ваш собственный скрипт фильтров будет выключен, пока ответ активен." + }, + "calendar_delegate": { + "answer_for": "Ответить за {name}", + "no_write": "Вы видите приглашения {name}, но не можете отвечать за этого человека. Попросите доступ на изменение.", + "also_invited": "Вы тоже приглашены, и этот сервер не отличит ваш ответ от ответа {name}. На это приглашение должен ответить {name}.", + "answered_by": "Ответил(а): {who}" + }, + "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": "Не удалось найти этот адрес", + "found": "Найдено свободных окон: {count}" + }, "share_notifications": { "someone": "Кто-то", "shared": "{name} поделился(ась) с вами {kind} «{object}»", diff --git a/locales/sk/common.json b/locales/sk/common.json index 28f6b8cd0..631d88822 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -4845,6 +4845,51 @@ "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.", + "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.", + "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}", + "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": "Adresu sa nepodarilo vyhľadať", + "found": "Nájdené voľné časy: {count}" + }, "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..b6b89d9f5 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2185,6 +2185,51 @@ "cancelled": "{name} ยกเลิกกิจกรรม “{title}”", "untitled": "กิจกรรมที่ไม่มีชื่อ" }, + "company_calendars": { + "section": "องค์กร" + }, + "vacation_calendar": { + "label": "บล็อกปฏิทินของฉัน", + "description": "เพิ่มกิจกรรมไม่ว่างในช่วงที่คุณไม่อยู่ เพื่อไม่ให้ใครนัดประชุมกับคุณในเวลานั้น", + "no_access_managed": "บล็อกปฏิทินของบัญชีนี้ไม่ได้ เพราะคุณไม่มีสิทธิ์เขียน", + "no_calendar": "บล็อกปฏิทินไม่ได้ Bulwark ไม่พบปฏิทินสำหรับเพิ่มกิจกรรม", + "filter_warning": "การเปิดใช้จะปิดสคริปต์ตัวกรองของคุณเองตลอดช่วงที่การตอบกลับทำงาน" + }, + "calendar_delegate": { + "answer_for": "ตอบแทน {name}", + "no_write": "คุณเห็นคำเชิญของ {name} แต่ตอบแทนไม่ได้ โปรดขอสิทธิ์แก้ไข", + "also_invited": "คุณได้รับเชิญด้วย และเซิร์ฟเวอร์นี้แยกคำตอบของคุณกับของ {name} ไม่ได้ {name} ต้องตอบเอง", + "answered_by": "ตอบโดย {who}" + }, + "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": "ค้นหาที่อยู่นี้ไม่ได้", + "found": "พบเวลาว่าง: {count}" + }, "share_notifications": { "someone": "บุคคลอื่น", "shared": "{name} แชร์ {kind} “{object}” กับคุณ", diff --git a/locales/tr/common.json b/locales/tr/common.json index 46613e997..0492d468d 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -4845,6 +4845,51 @@ "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.", + "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.", + "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", + "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": "Bu adres aranamadı", + "found": "Bulunan boş saatler: {count}" + }, "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..1245df517 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5283,6 +5283,51 @@ "cancelled": "{name} скасував(ла) «{title}»", "untitled": "Подія без назви" }, + "company_calendars": { + "section": "Організація" + }, + "vacation_calendar": { + "label": "Заблокувати мій календар", + "description": "Додає подію «зайнято» на час вашої відсутності, щоб вам тоді не призначали зустрічей.", + "no_access_managed": "Календар цього облікового запису не можна заблокувати: у вас немає доступу на запис.", + "no_calendar": "Не вдається заблокувати календар: Bulwark не знайшов календаря для події.", + "filter_warning": "Якщо це ввімкнути, ваш власний скрипт фільтрів буде вимкнено, поки відповідь активна." + }, + "calendar_delegate": { + "answer_for": "Відповісти за {name}", + "no_write": "Ви бачите запрошення {name}, але не можете відповідати за цю людину. Попросіть доступ на редагування.", + "also_invited": "Вас теж запрошено, і цей сервер не відрізнить вашу відповідь від відповіді {name}. На це запрошення має відповісти {name}.", + "answered_by": "Відповідь від: {who}" + }, + "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": "Не вдалося знайти цю адресу", + "found": "Знайдено вільних проміжків: {count}" + }, "share_notifications": { "someone": "Хтось", "shared": "{name} поділився(лася) з вами {kind} «{object}»", diff --git a/locales/vi/common.json b/locales/vi/common.json index 30fac8a94..d207d1ef2 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -4857,6 +4857,51 @@ "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 đó.", + "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.", + "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}", + "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 địa chỉ này", + "found": "Số khung giờ trống tìm thấy: {count}" + }, "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..327767e44 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -4845,6 +4845,51 @@ "cancelled": "{name} 取消了「{title}」", "untitled": "未命名活動" }, + "company_calendars": { + "section": "組織" + }, + "vacation_calendar": { + "label": "佔用我的行事曆", + "description": "在你外出期間新增一個忙碌活動,這樣別人不會在這段時間約你開會。", + "no_access_managed": "無法佔用此帳號的行事曆:你沒有寫入權限。", + "no_calendar": "無法佔用你的行事曆:Bulwark 找不到可以新增活動的行事曆。", + "filter_warning": "開啟後,在自動回覆生效期間你自己的過濾指令碼會被關閉。" + }, + "calendar_delegate": { + "answer_for": "代表 {name} 回覆", + "no_write": "你能看到 {name} 的邀請,但不能代為回覆。請向對方申請編輯權限。", + "also_invited": "你也被邀請了,此伺服器無法區分你和 {name} 的回覆。這一項需要 {name} 自己回覆。", + "answered_by": "由 {who} 回覆" + }, + "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": "無法查詢此地址", + "found": "找到的空閒時間:{count}" + }, "share_notifications": { "someone": "有人", "shared": "{name} 與您共用了{kind}「{object}」", diff --git a/locales/zh/common.json b/locales/zh/common.json index 0c5b088d5..5233ea14d 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5283,6 +5283,51 @@ "cancelled": "{name} 取消了“{title}”", "untitled": "无标题活动" }, + "company_calendars": { + "section": "组织" + }, + "vacation_calendar": { + "label": "占用我的日历", + "description": "在你外出期间添加一个忙碌活动,这样别人不会在这段时间约你开会。", + "no_access_managed": "无法占用此账户的日历:你没有写入权限。", + "no_calendar": "无法占用你的日历:Bulwark 找不到可以添加活动的日历。", + "filter_warning": "开启后,在自动回复生效期间你自己的过滤脚本会被关闭。" + }, + "calendar_delegate": { + "answer_for": "代表 {name} 回复", + "no_write": "你能看到 {name} 的邀请,但不能代为回复。请向对方申请编辑权限。", + "also_invited": "你也被邀请了,此服务器无法区分你和 {name} 的回复。这一项需要 {name} 自己回复。", + "answered_by": "由 {who} 回复" + }, + "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": "无法查找此地址", + "found": "找到的空闲时间:{count}" + }, "share_notifications": { "someone": "有人", "shared": "{name} 与您共享了{kind}“{object}”", 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/__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/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), }, }; }), diff --git a/stores/settings-store.ts b/stores/settings-store.ts index bea2dfa98..d47af7bf8 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -623,6 +623,12 @@ 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; + /** Company calendars (organisation.calendars.feeds) the user hid, by feed id. */ + hiddenCompanyCalendars: string[]; // Calendar Tasks enableCalendarTasks: boolean; @@ -946,6 +952,9 @@ const DEFAULT_SETTINGS = { calendarDayEndHour: 20, calendarHideNonWorkingDays: false, calendarWorkingDays: [1, 2, 3, 4, 5] as number[], + vacationBlockCalendar: true, + vacationCalendarBlocks: {} as Record, + hiddenCompanyCalendars: [] as string[], // Calendar Tasks enableCalendarTasks: false, @@ -1386,6 +1395,9 @@ export const useSettingsStore = create()( calendarDayEndHour: state.calendarDayEndHour, calendarHideNonWorkingDays: state.calendarHideNonWorkingDays, calendarWorkingDays: state.calendarWorkingDays, + vacationBlockCalendar: state.vacationBlockCalendar, + vacationCalendarBlocks: state.vacationCalendarBlocks, + hiddenCompanyCalendars: state.hiddenCompanyCalendars, 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; },