From 47784cc07ad31dc82cb091b0180f0b2c79e86098 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:02:39 +0100 Subject: [PATCH] feat: offer admin out-of-office templates and show the absence dates in the user's time zone --- .../sections/vacation-templates-section.tsx | 234 +++++++++++++++++- .../__tests__/vacation-templates.test.tsx | 113 +++++++++ components/settings/vacation-settings.tsx | 79 +++++- .../settings/vacation-template-picker.tsx | 129 ++++++++++ lib/__tests__/vacation-templates.test.ts | 84 +++++++ lib/org-signature.ts | 76 +++--- lib/vacation-templates.ts | 114 +++++++++ locales/ar/common.json | 14 ++ locales/ca/common.json | 14 ++ locales/cs/common.json | 14 ++ locales/da/common.json | 14 ++ locales/de/common.json | 14 ++ locales/en/common.json | 14 ++ locales/es/common.json | 14 ++ locales/et/common.json | 14 ++ locales/fa/common.json | 14 ++ locales/fr/common.json | 14 ++ locales/he/common.json | 14 ++ locales/hu/common.json | 14 ++ locales/it/common.json | 14 ++ locales/ja/common.json | 14 ++ locales/ko/common.json | 14 ++ locales/lv/common.json | 14 ++ locales/mn/common.json | 14 ++ locales/nb/common.json | 14 ++ locales/nl/common.json | 14 ++ locales/pl/common.json | 14 ++ locales/pt/common.json | 14 ++ locales/ro/common.json | 14 ++ locales/ru/common.json | 14 ++ locales/sk/common.json | 14 ++ locales/th/common.json | 14 ++ locales/tr/common.json | 14 ++ locales/uk/common.json | 14 ++ locales/vi/common.json | 14 ++ locales/zh-TW/common.json | 14 ++ locales/zh/common.json | 14 ++ 37 files changed, 1201 insertions(+), 48 deletions(-) create mode 100644 components/settings/__tests__/vacation-templates.test.tsx create mode 100644 components/settings/vacation-template-picker.tsx create mode 100644 lib/__tests__/vacation-templates.test.ts create mode 100644 lib/vacation-templates.ts diff --git a/components/admin/org/sections/vacation-templates-section.tsx b/components/admin/org/sections/vacation-templates-section.tsx index c974fef78..040408e20 100644 --- a/components/admin/org/sections/vacation-templates-section.tsx +++ b/components/admin/org/sections/vacation-templates-section.tsx @@ -1,13 +1,239 @@ 'use client'; +import { useId, useState } from 'react'; +import { AlertTriangle, ChevronDown, ChevronRight, Plus, RotateCcw, Trash2 } from '@/components/icons'; import { SettingsSection } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import type { VacationTemplate } from '@/lib/admin/organisation-policy'; +import { sanitizeEmailHtml } from '@/lib/email-sanitization'; +import { cn, generateUUID } from '@/lib/utils'; +import { fillVacationTemplate, VACATION_PLACEHOLDERS } from '@/lib/vacation-templates'; import type { OrgPolicyDraft } from '../use-org-policy-draft'; +/** Same cap as the sanitiser (organisation-policy.ts). */ +const MAX_LIBRARY = 200; +/** What Stalwart takes for an out-of-office reply (bytes); a longer one can't be saved by users. */ +export const STALWART_LIMITS = { subject: 511, body: 2047 }; +const DOCS_URL = 'https://bulwarkmail.org/docs/guides/organisation/out-of-office'; + +const inputClass = + 'block w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ' + + 'placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-hidden focus-visible:border-ring'; +const labelClass = 'mb-1 block text-xs font-medium text-foreground'; + +const PLACEHOLDER_HELP: Record<(typeof VACATION_PLACEHOLDERS)[number], string> = { + name: 'the user’s name', + email: 'their address', + company: 'the company name from Branding', + start: 'the first day away', + end: 'the last day away (or the return)', + deputy: 'the address of a colleague the user types in when picking the template', + deputy_name: 'that colleague’s name', +}; + +const EXAMPLE = { + name: 'Alex Example', email: 'alex@example.com', company: 'Example Ltd', + start: 'Monday 21 December 2026', end: 'Monday 4 January 2027', + deputy: 'sam@example.com', deputy_name: 'Sam Example', +}; + +const bytes = (value: string) => new TextEncoder().encode(value).length; + +/** Over Stalwart's limits once filled in with a typical name and dates (the placeholders grow a little). */ +export function vacationTemplateTooLong(template: VacationTemplate): { subject: boolean; body: boolean } { + const filled = fillVacationTemplate(template, EXAMPLE); + return { + subject: bytes(filled.subject) > STALWART_LIMITS.subject, + body: bytes(filled.text) > STALWART_LIMITS.body || bytes(filled.html) > STALWART_LIMITS.body, + }; +} + +function VacationTemplateEditor({ template, onChange, onRemove }: { + template: VacationTemplate; + onChange: (template: VacationTemplate) => void; + onRemove: () => void; +}) { + const id = useId(); + const [open, setOpen] = useState(!template.name); + const [html, setHtml] = useState(!!template.html); + const set = (patch: Partial) => onChange({ ...template, ...patch }); + const tooLong = vacationTemplateTooLong(template); + const preview = fillVacationTemplate(template, EXAMPLE); + return ( +
  • +
    + + +
    + {!template.name.trim() && ( +

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

    + )} + {open && ( +
    +
    +
    + + set({ name: e.target.value })} /> +
    +
    + + set({ category: e.target.value })} /> +
    +
    +
    + + set({ subject: e.target.value })} /> + {tooLong.subject &&

    Longer than Stalwart takes (511 bytes). Users couldn't save it.

    } +
    +
    + +