([]);
@@ -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 ? close() : void search())}
+ aria-expanded={open}
+ aria-controls={open ? panelId : undefined}
+ data-testid="find-a-time-button"
+ >
+
+ {t("suggest")}
+
+ {open && (
+
+
+ {busy && (
+
+
+ {t("searching")}
+
+ )}
+ {!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 (
+
+ {
+ onPick(slot);
+ setPicked(text);
+ }}
+ data-slot-start={slot.start.toISOString()}
+ >
+ {text}
+
+
+ );
+ })}
+
+ >
+ )}
+ {!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 && (
+ <>
+ {t("group_label")}
+ setGroup(e.target.value)}
+ className="h-8 max-w-full rounded-md border border-border bg-background px-2 text-xs text-foreground"
+ >
+ {groups.map((g) => {g} )}
+
+ >
+ )}
+ {state === true ? (
+
+
+ {t("already", { group })}
+
+ ) : (
+ void share()} disabled={saving || state === undefined} data-testid="free-busy-share-button">
+ {saving ? : }
+ {t("button", { group })}
+
+ )}
+
+ );
+}
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 (
-
-
- {a.name || a.email}
-
- {status === "checking" && }
- {t(status)}
-
-
- );
- })}
-
+ <>
+
+ {attendees.map((a) => {
+ const status = statuses[a.email.trim().toLowerCase()] ?? "checking";
+ return (
+
+
+ {a.name || a.email}
+
+ {status === "checking" && }
+ {status === "not_looked_up" ? tFind("not_looked_up") : t(status)}
+
+
+ );
+ })}
+
+ {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 (
+
+
+ {t('filter_warning')}
+
+ );
+}
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;
},