diff --git a/CHANGELOG.md b/CHANGELOG.md index cef331103..c7ec2928d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,14 @@ - **Organisation**: Admins can set an organisation signature with placeholders for each user's name, address, company, job title, department and phone numbers, per domain or group, and lock it so it replaces everyone's own signature. Users fill in their details once under Settings > Account. Lines whose details are empty are left out (#908) - **Organisation**: Admins can add a legal disclaimer that goes below every message when it is sent, never into drafts, and is shown read-only in the composer +- **Organisation**: Admins can give every user a library of templates, per domain or group, that users pick under "Organisation" in the template picker and can copy but not change +- **Templates**: On Stalwart 1.0, members of a group mailbox share team templates, and users' own templates are kept in their account so they follow them to every browser. Templates already in a browser move over on the first sign-in. Admins can lock team templates or leave users only the organisation and team templates - **Organisation**: Admins can label mail from outside the organisation with an "External" chip and have users confirm before sending to or inviting external recipients (composer, quick reply and meeting invitations), per domain or group. Partner domains and contacts count as internal. This replaces the External Mail Warning plugin, whose safe domains can be imported. Plugins can read `api.host.features.externalSenderLabel` and `externalRecipientWarning` - **Organisation**: Admins can sign users out after a time without activity or a fixed time after sign-in, per domain or group. A countdown with "Stay signed in" comes first, open and minimised messages and quick replies are saved as drafts, and the login page says why - **Delegates**: Settings > Sharing gives a colleague read, or read and write, access to all mail folders or just the Inbox, calendars and contacts in one step, lists who has access to what, and removes access everywhere at once. Admins can hide it per domain or group and limit every share dialog to read access on Organisation > Sharing - **Privacy**: One switch, `NO_THIRD_PARTY_TRAFFIC` or Organisation > Security, stops every connection to services outside your own servers: telemetry, the version check, the built-in push relay, the official extension directory, sender icons, MyMemory translation, map links, OpenPGP key lookups and remote pictures in mail. Plugins that need network access don't run, and the page's CSP allows only the hosts you configured. Push registrations move to your own relay. The admin page lists what stays reachable (#903) +- **Team inbox**: The new official Team inbox plugin lets members of a team mailbox assign messages to each other, mark them done, and filter by "Assigned to me" and "Open" +- **Plugins**: Plugins can add keyword filters to the message list, shown as chips above it, put icons on row badges, show actions only in team mailboxes or for some keywords, change several keywords in one step, see the user's own account and which mailboxes are team mailboxes, and keep a small shared value in a team mailbox on Stalwart 1.0. Looking people up in the directory needs the new `directory:read` permission. Plugins can no longer set `$draft`, `$junk`, `$notjunk` or `$phishing` ## 1.18.0 (2026-10-26) diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 59481960e..95bf81641 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -21,6 +21,7 @@ import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt"; import { SessionTimeoutGuard } from "@/components/session/session-timeout-guard"; +import { AccountTemplateSync } from "@/components/templates/account-template-sync"; import { IS_LITE } from "@/lib/lite"; import { setRequestLocale } from "next-intl/server"; import { locales } from "@/i18n/routing"; @@ -64,6 +65,7 @@ export default async function LocaleLayout({ + {children} diff --git a/app/(main)/admin/__tests__/org-templates-page.test.tsx b/app/(main)/admin/__tests__/org-templates-page.test.tsx new file mode 100644 index 000000000..6abcd5bf4 --- /dev/null +++ b/app/(main)/admin/__tests__/org-templates-page.test.tsx @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; + +// Organisation > Templates: the admin library per scope (a scope adds to +// Everyone's by id) and the restrictions on users' own and team templates, +// saved in one PUT. + +const puts: Array> = []; +let stored: Record = {}; +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.method === 'PUT') { + const body = JSON.parse(String(init.body)); + puts.push(body); + stored = body; + return new Response('{"ok":true}'); + } + return new Response(JSON.stringify(stored)); + }), + withBasePath: (p: string) => p, + getPathPrefix: () => '', +})); + +import { OrgTemplatesPage } from '../_pages/org-templates'; +import { EVERYONE, useOrgScopeStore } from '@/components/admin/org/use-org-policy-draft'; + +const WELCOME = { id: 'welcome', name: 'Welcome', subject: 'Hello', body: '

Hi there

', isHTML: true, category: 'Sales' }; + +const POLICY = { + restrictions: {}, + features: {}, defaults: {}, themePolicy: {}, + forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], + organisation: { templates: { library: [WELCOME] } }, + scopes: [{ id: 'two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: {} }], +}; + +beforeEach(() => { + puts.length = 0; + stored = JSON.parse(JSON.stringify(POLICY)); + useOrgScopeStore.setState({ scopeId: EVERYONE }); +}); + +describe('Organisation > Templates', () => { + it('edits Everyone\'s library with a preview', async () => { + render(); + const items = await screen.findAllByTestId('org-template-item'); + expect(items).toHaveLength(1); + fireEvent.click(within(items[0]).getByRole('button', { name: /^Welcome/ })); + expect(screen.getByTestId('org-template-preview').innerHTML).toContain('there'); + + fireEvent.click(screen.getByTestId('org-template-add')); + const names = screen.getAllByTestId('org-template-name'); + fireEvent.change(names[1], { target: { value: 'Offer' } }); + fireEvent.change(screen.getAllByTestId('org-template-body')[1], { target: { value: 'Our offer' } }); + + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(puts).toHaveLength(1)); + const library = (puts[0].organisation as { templates: { library: Array<{ name: string; body: string }> } }).templates.library; + expect(library.map((t) => t.name)).toEqual(['Welcome', 'Offer']); + expect(library[1].body).toBe('Our offer'); + }); + + it('adds templates for a scope without copying Everyone\'s, and changes one of Everyone\'s for that scope only', async () => { + render(); + await screen.findAllByTestId('org-template-item'); + fireEvent.click(screen.getByRole('button', { name: 'Org two' })); + + // Everyone's template is shown, not editable, and the scope starts empty. + expect(within(screen.getByTestId('org-template-inherited')).getByText('Welcome')).toBeInTheDocument(); + expect(screen.getByTestId('org-template-empty')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('org-template-add')); + fireEvent.change(screen.getByTestId('org-template-name'), { target: { value: 'Two only' } }); + fireEvent.click(screen.getByRole('button', { name: /Change for this scope/ })); + const subjects = screen.getAllByTestId('org-template-subject'); + fireEvent.click(within(screen.getAllByTestId('org-template-item')[1]).getByRole('button', { name: /^Welcome/ })); + fireEvent.change(screen.getAllByTestId('org-template-subject')[1], { target: { value: 'Hello from two' } }); + expect(subjects).toHaveLength(1); + + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(puts).toHaveLength(1)); + const scope = (puts[0].scopes as Array<{ policy: { organisation: { templates: { library: Array<{ id: string; name: string; subject: string }> } } } }>)[0]; + const library = scope.policy.organisation.templates.library; + expect(library.map((t) => t.name)).toEqual(['Two only', 'Welcome']); + expect(library[1]).toMatchObject({ id: 'welcome', subject: 'Hello from two' }); + // Everyone keeps its own. + expect((puts[0].organisation as { templates: { library: Array<{ subject: string }> } }).templates.library[0].subject).toBe('Hello'); + }); + + it('removing the last template of a scope inherits Everyone\'s library again', async () => { + stored = JSON.parse(JSON.stringify({ + ...POLICY, + scopes: [{ ...POLICY.scopes[0], policy: { organisation: { templates: { library: [{ ...WELCOME, id: 'x', name: 'X' }] } } } }], + })); + render(); + await screen.findAllByTestId('org-template-item'); + fireEvent.click(screen.getByRole('button', { name: 'Org two' })); + fireEvent.click(screen.getByRole('button', { name: 'Remove X' })); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(puts).toHaveLength(1)); + const policy = (puts[0].scopes as Array<{ policy: { organisation?: Record } }>)[0].policy; + expect(policy.organisation?.templates).toBeUndefined(); + }); + + it('says that a template without a name is not saved', async () => { + render(); + await screen.findAllByTestId('org-template-item'); + fireEvent.click(screen.getByTestId('org-template-add')); + expect(screen.getByText(/A template without a name is not saved/)).toBeInTheDocument(); + }); + + it('hides personal templates through the restriction row', async () => { + render(); + await screen.findAllByTestId('org-template-item'); + fireEvent.click(screen.getByLabelText('Hide')); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(puts).toHaveLength(1)); + expect((puts[0].restrictions as Record).personalTemplates).toEqual({ hidden: true }); + }); + + it('locks team templates for a group scope only', async () => { + stored = JSON.parse(JSON.stringify({ + ...POLICY, + scopes: [...POLICY.scopes, { id: 'team', label: 'Team', match: { groups: ['org-team@example.org'] }, policy: {} }], + })); + render(); + await screen.findAllByTestId('org-template-item'); + fireEvent.click(screen.getByRole('button', { name: 'Team' })); + fireEvent.click(screen.getByLabelText('Lock')); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(puts).toHaveLength(1)); + const scopes = puts[0].scopes as Array<{ id: string; policy: { restrictions?: Record } }>; + expect(scopes.find((scope) => scope.id === 'team')?.policy.restrictions).toEqual({ teamTemplates: { locked: true } }); + expect((puts[0].restrictions as Record).teamTemplates).toBeUndefined(); + }); +}); diff --git a/components/admin/org/sections/templates-section.tsx b/components/admin/org/sections/templates-section.tsx index 8cf157268..7356b93bd 100644 --- a/components/admin/org/sections/templates-section.tsx +++ b/components/admin/org/sections/templates-section.tsx @@ -1,14 +1,191 @@ 'use client'; +import { useId, useState } from 'react'; +import { ChevronDown, ChevronRight, Plus, RotateCcw, Trash2 } from '@/components/icons'; import { SettingsSection } from '@/components/settings/settings-section'; +import { TemplateBodyPreview } from '@/components/templates/template-body-preview'; +import { Button } from '@/components/ui/button'; +import type { OrgTemplate } from '@/lib/admin/organisation-policy'; +import { restrictionKeysForPage } from '@/lib/admin/restriction-keys'; +import { cn, generateUUID } from '@/lib/utils'; +import { RestrictionRow } from '../org-page'; import type { OrgPolicyDraft } from '../use-org-policy-draft'; -/** Organisation feature 3; filled in by its own step (local-data/org-features-plan.md). */ -export function TemplatesSection({ draft }: { draft: OrgPolicyDraft }) { - void draft; +const inputClass = + 'block w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ' + + 'placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 ' + + 'focus-visible:ring-ring focus-visible:border-ring'; +const labelClass = 'mb-1 block text-xs font-medium text-foreground'; + +/** Same cap as the sanitiser (organisation-policy.ts): more are not saved. */ +const MAX_LIBRARY = 200; + +function TemplateEditor({ template, onChange, onRemove }: { + template: OrgTemplate; + onChange: (template: OrgTemplate) => void; + onRemove: () => void; +}) { + const id = useId(); + const [open, setOpen] = useState(!template.name); + const set = (patch: Partial) => onChange({ ...template, ...patch }); return ( - -

Nothing to set here yet.

-
+
  • +
    + + +
    + {!template.name.trim() && ( +

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