diff --git a/.github/workflows/static-lite.yml b/.github/workflows/static-lite.yml
index 149e12036..9def1243f 100644
--- a/.github/workflows/static-lite.yml
+++ b/.github/workflows/static-lite.yml
@@ -81,6 +81,12 @@ jobs:
LITE_DEMO_MODE: ${{ inputs.demo }}
run: npm run build:lite
+ # Every host literal in the bundle must be a placeholder, the configured
+ # JMAP server, a built-in host the no-third-party switch turns off, or a
+ # string listed as never requested (#903).
+ - name: Scan the bundle for outside hosts
+ run: node scripts/lite/scan-hosts.mjs out
+
- name: Package zip
env:
REF_NAME: ${{ github.ref_name }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e9bab4990..42bb670be 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,22 @@
# Changelog
+## Unreleased
+
+### Features
+
+- **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
+- **Tags**: On Stalwart 1.0, members of a group mailbox see the same tag names and colors on its mail, in the list, the tag picker, the sidebar and the unified inbox. They edit them under Settings > Folders & tags, where tags already on the group's mail can be given a name. Admins can lock the list per domain or group, for example so only team leads change it (#470, #1310)
+- **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
+- **Recover deleted items**: On Stalwart Enterprise with archiving turned on, users bring back mail they deleted for good under Settings > Recover deleted items, or from a link in Trash when there is something to recover. Restored mail goes back to the Inbox unread. Admins can limit how many days back it lists or hide it per domain or group (#1319)
+- **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)
1.18.0 works with Stalwart 1.0, released today, as well as with 0.16. Sign-in, calendars, contacts, files and sending were adapted to what changed in 1.0, and a Stalwart that was moved from 0.16 to 1.0 with Stalwart's migration tool keeps working with the same open tabs. It also fixes the findings of a security review of the whole webmail, more places where an action in a view that mixes accounts could hit another account's message with the same id, and a series of calendar bugs around repeating events. Plugins can show their own search results and a count on their buttons, quick reply can be turned off, and a new device offers to restore your saved account list.
diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx
index a44a30286..d34288e8a 100644
--- a/app/(main)/[locale]/layout.tsx
+++ b/app/(main)/[locale]/layout.tsx
@@ -10,6 +10,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
+import { ExternalRecipientConfirmHost } from "@/components/email/external-recipient-confirm";
import { CommandPaletteHost } from "@/components/command-palette/command-palette";
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog";
@@ -20,6 +21,8 @@ import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth
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";
@@ -62,8 +65,11 @@ export default async function LocaleLayout({
+
+
{children}
+
diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx
index d5b972efa..43b3080e6 100644
--- a/app/(main)/[locale]/login/page.tsx
+++ b/app/(main)/[locale]/login/page.tsx
@@ -35,6 +35,7 @@ import { toAsciiDomain } from "@/lib/idn";
import { usePrefetchMailView } from "@/hooks/use-prefetch-mail-view";
import { buildMobileHandoffUrl, mobileHandoffTarget, signedInLoginAction } from "@/lib/auth/mobile-handoff";
import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent";
+import { consumeSessionEnd, type SessionEndReason } from "@/lib/session-policy/sign-out-reason";
/** The domain of a complete address (`user@example.com`), or '' while it is still being typed. */
function completeAddressDomain(username: string): string {
@@ -178,6 +179,7 @@ function VersionBadge() {
function LoginPageContent() {
const router = useRouter();
const t = useTranslations("login");
+ const tSessionLimits = useTranslations("session_limits");
const tTheme = useTranslations("settings.appearance.theme");
const params = useParams();
const searchParams = useSearchParams();
@@ -247,6 +249,8 @@ function LoginPageContent() {
// those sessions then only last as long as the tab (lib/auth/lite-tokens.ts).
const [liteTokenLoginSupported, setLiteTokenLoginSupported] = useState(null);
const [sessionExpired, setSessionExpired] = useState(false);
+ // Signed out by the organisation's idle timeout or maximum session length.
+ const [sessionEnd, setSessionEnd] = useState(null);
const [showPassword, setShowPassword] = useState(false);
const [shakeError, setShakeError] = useState(false);
const [showThemeMenu, setShowThemeMenu] = useState(false);
@@ -372,6 +376,11 @@ function LoginPageContent() {
sessionStorage.removeItem('session_expired');
}
} catch { /* sessionStorage unavailable */ }
+ const ended = consumeSessionEnd();
+ if (ended) {
+ setSessionEnd(ended);
+ setSessionExpired(true);
+ }
}, []);
useEffect(() => {
@@ -1269,8 +1278,15 @@ function LoginPageContent() {
-
- {t("session_expired")}
+
+ {sessionEnd?.kind === "idle"
+ ? tSessionLimits("reason_idle", { minutes: sessionEnd.minutes })
+ : sessionEnd?.kind === "max"
+ ? tSessionLimits("reason_max", { hours: sessionEnd.hours })
+ : t("session_expired")}
+ {sessionEnd?.draftsUnsaved && (
+ {tSessionLimits("drafts_not_saved")}
+ )}
;
case 'auth': return
;
case 'policy': return
;
+ case 'organisation': return
;
+ case 'org-signatures': return
;
+ case 'org-templates': return
;
+ case 'org-security': return
;
+ case 'org-sharing': return
;
case 'plugins': return detail ?
:
;
case 'themes': return detail ?
:
;
case 'marketplace': return detail ?
:
;
diff --git a/app/(main)/admin/__tests__/org-signatures-page.test.tsx b/app/(main)/admin/__tests__/org-signatures-page.test.tsx
new file mode 100644
index 000000000..d88c7e847
--- /dev/null
+++ b/app/(main)/admin/__tests__/org-signatures-page.test.tsx
@@ -0,0 +1,96 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+
+// Organisation > Signatures: the templates, the user details they ask for
+// and the `signature` restriction, edited per scope and 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: () => '',
+}));
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ loginCompanyName: 'ACME Ltd' }) }));
+
+import { OrgSignaturesPage } from '../_pages/org-signatures';
+import { EVERYONE, useOrgScopeStore } from '@/components/admin/org/use-org-policy-draft';
+
+const POLICY = {
+ restrictions: {},
+ features: {}, defaults: {}, themePolicy: {},
+ forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
+ organisation: { signature: { html: '{name}
', text: '', fields: [] } },
+ scopes: [{ id: 'example', label: 'Example', match: { domains: ['example.org'] }, policy: {} }],
+};
+
+beforeEach(() => {
+ puts.length = 0;
+ stored = JSON.parse(JSON.stringify(POLICY));
+ useOrgScopeStore.setState({ scopeId: EVERYONE });
+});
+
+describe('Organisation > Signatures', () => {
+ it('sets a signature for a domain scope, with the fields it asks for and a lock', async () => {
+ render( );
+ const html = await screen.findByTestId('org-signature-html') as HTMLTextAreaElement;
+ expect(html.value).toBe('{name}
');
+
+ fireEvent.click(screen.getByRole('button', { name: 'Example' }));
+ expect(screen.getByText(/From Everyone: \{name\}<\/p>/)).toBeInTheDocument();
+ fireEvent.change(screen.getByTestId('org-signature-html'), {
+ target: { value: '
{name} Phone: {phone} Mobile: {mobile}
{company}
' },
+ });
+
+ // The preview fills in sample values and leaves out empty lines.
+ const preview = screen.getByTestId('org-signature-preview');
+ expect(preview.innerHTML).toContain('Alex Example Phone: +49 30 1234567 Mobile: +49 170 1234567');
+ expect(preview.innerHTML).toContain('ACME Ltd');
+ fireEvent.click(screen.getByLabelText('For a user who filled in no details'));
+ expect(screen.getByTestId('org-signature-preview').innerHTML).toContain('Alex Example
ACME Ltd
');
+
+ const fields = screen.getByTestId('org-signature-fields');
+ expect([...fields.querySelectorAll('[data-used]')].map((li) => li.textContent)).toEqual(['Phoneasked for', 'Mobileasked for']);
+
+ fireEvent.click(screen.getByLabelText('Lock'));
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }));
+ await waitFor(() => expect(puts).toHaveLength(1));
+ const scope = (puts[0].scopes as Array<{ policy: Record }>)[0];
+ expect(scope.policy).toEqual({
+ organisation: { signature: { html: '{name} Phone: {phone} Mobile: {mobile}
{company}
', fields: ['phone', 'mobile'] } },
+ restrictions: { signature: { locked: true } },
+ });
+ // Everyone keeps its own.
+ expect((puts[0].organisation as { signature: { html: string } }).signature.html).toBe('{name}
');
+ });
+
+ it('inserts a placeholder at the cursor of the template last edited', async () => {
+ render( );
+ const text = await screen.findByTestId('org-signature-text') as HTMLTextAreaElement;
+ fireEvent.focus(text);
+ fireEvent.click(screen.getByRole('button', { name: '{department}' }));
+ await waitFor(() => expect((screen.getByTestId('org-signature-text') as HTMLTextAreaElement).value).toBe('{department}'));
+ // Branding per domain goes by the web address Bulwark is opened on, not the sender's domain.
+ expect(screen.getByText('company name from Branding, for the web address Bulwark is opened on')).toBeInTheDocument();
+ });
+
+ it('sets a disclaimer for a scope, with a preview', async () => {
+ render( );
+ await screen.findByTestId('org-disclaimer-text');
+ fireEvent.click(screen.getByRole('button', { name: 'Example' }));
+ fireEvent.change(screen.getByTestId('org-disclaimer-text'), { target: { value: 'Confidential, {company}' } });
+ expect(screen.getByTestId('org-disclaimer-sample')).toHaveTextContent('Confidential, ACME Ltd');
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }));
+ await waitFor(() => expect(puts).toHaveLength(1));
+ const scope = (puts[0].scopes as Array<{ policy: Record }>)[0];
+ expect(scope.policy).toEqual({ organisation: { disclaimer: { text: 'Confidential, {company}' } } });
+ });
+});
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/app/(main)/admin/__tests__/organisation-page.test.tsx b/app/(main)/admin/__tests__/organisation-page.test.tsx
new file mode 100644
index 000000000..542c15140
--- /dev/null
+++ b/app/(main)/admin/__tests__/organisation-page.test.tsx
@@ -0,0 +1,128 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
+
+// The Organisation pages edit one scope at a time: Everyone (the global
+// policy) or a domain or group scope. A value a scope doesn't set comes from
+// Everyone and can be reset to it; saving sends the whole policy.
+
+const puts: Array> = [];
+let stored: Record = {};
+const filePosts: string[] = [];
+vi.mock('@/lib/browser-navigation', () => ({
+ apiFetch: vi.fn(async (url: string, init?: RequestInit) => {
+ if (url.startsWith('/api/admin/policy/file') && init?.method === 'POST') {
+ filePosts.push(url);
+ return new Response(JSON.stringify({
+ ok: true,
+ summary: {
+ scopes: { added: ['Two (two)'], changed: [], removed: [] },
+ organisation: { added: [], changed: ['session'], removed: [] },
+ restrictions: { added: [], changed: [], removed: [] },
+ features: { added: [], changed: [], removed: [] },
+ defaults: { added: [], changed: [], removed: [] },
+ other: [], unchanged: false,
+ },
+ }));
+ }
+ 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 { OrganisationPage } from '../_pages/organisation';
+import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '@/components/admin/org/use-org-policy-draft';
+
+const POLICY = {
+ restrictions: { density: { locked: true, value: 'compact' } },
+ features: {}, defaults: {}, themePolicy: {},
+ forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
+ organisation: { session: { idleMinutes: 30 } },
+ scopes: [{ id: 'team', label: 'Team', match: { groups: ['org-team@example.org'] }, policy: {} }],
+};
+
+beforeEach(() => {
+ puts.length = 0;
+ filePosts.length = 0;
+ stored = JSON.parse(JSON.stringify(POLICY));
+ useOrgScopeStore.setState({ scopeId: EVERYONE });
+});
+
+describe('useOrgPolicyDraft', () => {
+ it('writes into the selected scope and shows Everyone\'s value until then', async () => {
+ const { result } = renderHook(() => useOrgPolicyDraft('test'));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ act(() => result.current.setScopeId('team'));
+ expect(result.current.orgValue('session', 'idleMinutes')).toEqual({ value: 30, own: false, everyone: 30 });
+
+ act(() => result.current.setOrgValue('session', 'idleMinutes', 5));
+ expect(result.current.orgValue('session', 'idleMinutes')).toEqual({ value: 5, own: true, everyone: 30 });
+ expect(result.current.policy?.organisation?.session.idleMinutes).toBe(30);
+ expect(result.current.dirty).toBe(true);
+
+ act(() => result.current.clearOrgValue('session', 'idleMinutes'));
+ expect(result.current.orgValue('session', 'idleMinutes').own).toBe(false);
+ expect(result.current.policy?.scopes?.[0].policy.organisation).toEqual({});
+ });
+
+ it('lets a scope unlock what Everyone locks, and saves the whole policy', async () => {
+ const { result } = renderHook(() => useOrgPolicyDraft('test'));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ act(() => result.current.setScopeId('team'));
+ act(() => result.current.setRestriction('density', { locked: false }));
+ expect(result.current.policy?.scopes?.[0].policy.restrictions).toEqual({ density: {} });
+ expect(result.current.policy?.restrictions.density).toEqual({ locked: true, value: 'compact' });
+
+ await act(async () => { await result.current.save(); });
+ expect(puts).toHaveLength(1);
+ expect((puts[0].scopes as Array<{ policy: unknown }>)[0].policy).toEqual({ restrictions: { density: {} } });
+ expect(puts[0].organisation).toMatchObject({ session: { idleMinutes: 30 } });
+ });
+
+ it('writes to the global policy for Everyone', async () => {
+ const { result } = renderHook(() => useOrgPolicyDraft('test'));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ act(() => result.current.setOrgValue('externalMail', 'senderLabel', true));
+ expect(result.current.policy?.organisation?.externalMail.senderLabel).toBe(true);
+ expect(result.current.policy?.scopes?.[0].policy).toEqual({});
+ });
+});
+
+describe('OrganisationPage', () => {
+ it('adds a domain scope and shows which entry sets what for a user', async () => {
+ render( );
+ await screen.findByTestId('org-scope-team');
+
+ fireEvent.click(screen.getByRole('button', { name: /Add domains/ }));
+ const names = screen.getAllByLabelText('Name') as HTMLInputElement[];
+ expect(names.map((n) => n.value)).toEqual(['Team', 'New domain']);
+
+ fireEvent.change(screen.getByLabelText('User address'), { target: { value: 'org-alice@example.org' } });
+ fireEvent.change(screen.getByLabelText('Groups the user is a member of'), { target: { value: 'org-team@example.org' } });
+ const table = await screen.findByTestId('org-effective-policy');
+ expect(table.textContent).toContain('Applies, in this order: Everyone, Team.');
+ expect(table.textContent).toContain('restrictions.density');
+ expect(table.textContent).toContain('organisation.session.idleMinutes');
+ });
+
+ it('shows what an imported file changes and replaces the policy only when asked', async () => {
+ render( );
+ await screen.findByTestId('org-scope-team');
+ const file = new File(['{"scopes":[]}'], 'policy.json', { type: 'application/json' });
+ fireEvent.change(screen.getByLabelText('Policy file to import'), { target: { files: [file] } });
+ const preview = await screen.findByTestId('policy-import-preview');
+ expect(preview.textContent).toContain('Domains and groups: 1 added');
+ expect(preview.textContent).toContain('Organisation settings: 1 changed');
+ expect(filePosts).toEqual(['/api/admin/policy/file?preview=1']);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Replace the current policy' }));
+ await waitFor(() => expect(filePosts).toEqual(['/api/admin/policy/file?preview=1', '/api/admin/policy/file']));
+ await waitFor(() => expect(screen.queryByTestId('policy-import-preview')).toBeNull());
+ });
+});
diff --git a/app/(main)/admin/_pages/org-security.tsx b/app/(main)/admin/_pages/org-security.tsx
new file mode 100644
index 000000000..af778d1ee
--- /dev/null
+++ b/app/(main)/admin/_pages/org-security.tsx
@@ -0,0 +1,18 @@
+'use client';
+
+import { OrgPage } from '@/components/admin/org/org-page';
+import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
+import { ExternalMailSection } from '@/components/admin/org/sections/external-mail-section';
+import { SessionsSection } from '@/components/admin/org/sections/sessions-section';
+import { ThirdPartySection } from '@/components/admin/org/sections/third-party-section';
+
+export function OrgSecurityPage() {
+ const draft = useOrgPolicyDraft('org-security');
+ return (
+
+
+
+
+
+ );
+}
diff --git a/app/(main)/admin/_pages/org-sharing.tsx b/app/(main)/admin/_pages/org-sharing.tsx
new file mode 100644
index 000000000..2617ccc2e
--- /dev/null
+++ b/app/(main)/admin/_pages/org-sharing.tsx
@@ -0,0 +1,18 @@
+'use client';
+
+import { OrgPage } from '@/components/admin/org/org-page';
+import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
+import { SharedTagsSection } from '@/components/admin/org/sections/shared-tags-section';
+import { DelegatesSection } from '@/components/admin/org/sections/delegates-section';
+import { RecoverySection } from '@/components/admin/org/sections/recovery-section';
+
+export function OrgSharingPage() {
+ const draft = useOrgPolicyDraft('org-sharing');
+ return (
+
+
+
+
+
+ );
+}
diff --git a/app/(main)/admin/_pages/org-signatures.tsx b/app/(main)/admin/_pages/org-signatures.tsx
new file mode 100644
index 000000000..7aa0b27c5
--- /dev/null
+++ b/app/(main)/admin/_pages/org-signatures.tsx
@@ -0,0 +1,14 @@
+'use client';
+
+import { OrgPage } from '@/components/admin/org/org-page';
+import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
+import { SignatureSection } from '@/components/admin/org/sections/signature-section';
+
+export function OrgSignaturesPage() {
+ const draft = useOrgPolicyDraft('org-signatures');
+ return (
+
+
+
+ );
+}
diff --git a/app/(main)/admin/_pages/org-templates.tsx b/app/(main)/admin/_pages/org-templates.tsx
new file mode 100644
index 000000000..bc63b0578
--- /dev/null
+++ b/app/(main)/admin/_pages/org-templates.tsx
@@ -0,0 +1,14 @@
+'use client';
+
+import { OrgPage } from '@/components/admin/org/org-page';
+import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
+import { TemplatesSection } from '@/components/admin/org/sections/templates-section';
+
+export function OrgTemplatesPage() {
+ const draft = useOrgPolicyDraft('org-templates');
+ return (
+
+
+
+ );
+}
diff --git a/app/(main)/admin/_pages/organisation.tsx b/app/(main)/admin/_pages/organisation.tsx
new file mode 100644
index 000000000..7191fd911
--- /dev/null
+++ b/app/(main)/admin/_pages/organisation.tsx
@@ -0,0 +1,268 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+import { ArrowDown, ArrowUp, Plus, Trash2 } from '@/components/icons';
+import { SettingsSection } from '@/components/settings/settings-section';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/admin/badge';
+import { inputClass } from '@/components/admin/config-items';
+import { OrgPage, scopeMatchSummary } from '@/components/admin/org/org-page';
+import { PolicyFileSection } from '@/components/admin/org/policy-file-section';
+import { EVERYONE, useOrgPolicyDraft, type OrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
+import { resolvePolicy, type PolicyScope } from '@/lib/admin/policy-scopes';
+import { cn } from '@/lib/utils';
+
+const fieldLabelClass = 'mb-1 block text-[11px] font-medium text-muted-foreground';
+const DOMAIN_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
+const GROUP_RE = /^[^\s@]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
+
+function splitList(text: string): string[] {
+ return [...new Set(text.split(/[\s,;]+/).map((v) => v.trim().toLowerCase()).filter(Boolean))];
+}
+
+function newScopeId(existing: PolicyScope[]): string {
+ const taken = new Set(existing.map((s) => s.id));
+ for (;;) {
+ const id = `scope-${Math.random().toString(36).slice(2, 8)}`;
+ if (!taken.has(id)) return id;
+ }
+}
+
+/** A comma-separated list edited as text and stored as an array when the field loses focus. */
+function ListField({ label, value, placeholder, valid, onCommit }: {
+ label: string;
+ value: string[];
+ placeholder: string;
+ valid: (item: string) => boolean;
+ onCommit: (items: string[]) => void;
+}) {
+ const [text, setText] = useState(null);
+ const shown = text ?? value.join(', ');
+ const invalid = splitList(shown).filter((item) => !valid(item));
+ return (
+
+ {label}
+ 0 && 'border-destructive')}
+ onChange={(e) => setText(e.target.value)}
+ onBlur={() => {
+ if (text === null) return;
+ onCommit(splitList(text));
+ setText(null);
+ }}
+ />
+ {invalid.length > 0 && (
+ Not valid, ignored when saved: {invalid.join(', ')}
+ )}
+
+ );
+}
+
+function ScopeCard({ draft, scope, index, count }: { draft: OrgPolicyDraft; scope: PolicyScope; index: number; count: number }) {
+ const move = (delta: number) => draft.update((p) => {
+ const list = p.scopes ?? [];
+ const [item] = list.splice(index, 1);
+ list.splice(index + delta, 0, item);
+ });
+ const overrides = Object.keys(scope.policy.features ?? {}).length
+ + Object.keys(scope.policy.restrictions ?? {}).length
+ + Object.keys(scope.policy.defaults ?? {}).length
+ + Object.values(scope.policy.organisation ?? {}).reduce((n, section) => n + Object.keys(section ?? {}).length, 0);
+ return (
+
+
+
+ Name
+ draft.updateScope(scope.id, (s) => { s.label = e.target.value; })}
+ />
+
+
+
move(-1)} disabled={index === 0} aria-label="Move up" title="Move up"
+ className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-30">
+
+
+
move(1)} disabled={index === count - 1} aria-label="Move down" title="Move down"
+ className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-30">
+
+
+
draft.update((p) => { p.scopes = (p.scopes ?? []).filter((s) => s.id !== scope.id); })}
+ className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-destructive">
+
+
+
+
+
+ DOMAIN_RE.test(d)}
+ onCommit={(domains) => draft.updateScope(scope.id, (s) => { s.match = { ...s.match, domains }; })}
+ />
+ GROUP_RE.test(g)}
+ onCommit={(groups) => draft.updateScope(scope.id, (s) => { s.match = { ...s.match, groups }; })}
+ />
+
+
+ {overrides === 0 ? 'No settings of its own yet' : `${overrides} setting${overrides === 1 ? '' : 's'} of its own`}
+ draft.setScopeId(scope.id)}>
+ {draft.scopeId === scope.id ? 'Selected on the Organisation pages' : 'Edit its settings on the Organisation pages'}
+
+
+
+ );
+}
+
+function sourceLabel(source: string, scopes: PolicyScope[]): string {
+ if (source === 'global') return 'Everyone';
+ return scopes.find((s) => s.id === source)?.label ?? source;
+}
+
+function valueAt(policy: Record, path: string): unknown {
+ const [root, ...rest] = path.split('.');
+ // Keys of features, restrictions and defaults never contain dots; organisation paths are section.key.
+ let value: unknown = policy[root];
+ for (const part of rest) value = (value as Record | undefined)?.[part];
+ return value;
+}
+
+function formatValue(value: unknown): string {
+ if (typeof value === 'string') return value.length > 60 ? `${value.slice(0, 60)}…` : value || '(empty)';
+ const json = JSON.stringify(value);
+ return json && json.length > 80 ? `${json.slice(0, 80)}…` : String(json);
+}
+
+function EffectivePolicy({ draft }: { draft: OrgPolicyDraft }) {
+ const [email, setEmail] = useState('');
+ const [groupsText, setGroupsText] = useState('');
+ const subject = email.includes('@') ? { email: email.trim().toLowerCase(), groups: splitList(groupsText) } : null;
+ const resolved = useMemo(
+ () => (draft.policy && subject ? resolvePolicy(draft.policy, subject) : null),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [draft.policy, subject?.email, subject?.groups.join(',')],
+ );
+ const rows = resolved
+ ? Object.entries(resolved.sources).sort(([a], [b]) => a.localeCompare(b))
+ : [];
+ return (
+
+
+
+ User address
+ setEmail(e.target.value)} placeholder="anna@example.com"
+ className={cn(inputClass, 'w-full')} spellCheck={false} autoComplete="off" />
+
+
+ Groups the user is a member of
+ setGroupsText(e.target.value)} placeholder="sales@example.com"
+ className={cn(inputClass, 'w-full')} spellCheck={false} autoComplete="off" />
+
+
+ {resolved && (
+
+
+ {resolved.matched.length === 0
+ ? 'No domain or group applies; this user gets Everyone\'s settings.'
+ : `Applies, in this order: Everyone, ${resolved.matched.map((id) => sourceLabel(id, draft.scopes)).join(', ')}.`}
+
+ {rows.length === 0 ? (
+
Everything is at Bulwark's defaults.
+ ) : (
+
+
+
+
+ Setting
+ Value
+ Set by
+
+
+
+ {rows.map(([path, source]) => (
+
+ {path}
+ {formatValue(valueAt(resolved.policy as unknown as Record, path))}
+ {sourceLabel(source, draft.scopes)}
+
+ ))}
+
+
+
+ )}
+
+ Bulwark can only see groups that have a mailbox: a user counts as a member when the group's account is in their session.
+
+
+ )}
+
+ );
+}
+
+export function OrganisationPage() {
+ const draft = useOrgPolicyDraft('organisation');
+ const addScope = (kind: 'domain' | 'group') => draft.update((p) => {
+ const id = newScopeId(p.scopes ?? []);
+ p.scopes = [...(p.scopes ?? []), {
+ id,
+ label: kind === 'domain' ? 'New domain' : 'New group',
+ match: {},
+ policy: {},
+ }];
+ });
+ return (
+
+
+
+
addScope('domain')}>
+ Add domains
+
+
addScope('group')}>
+ Add groups
+
+
+ {draft.scopes.length === 0 ? (
+ None yet: every user gets Everyone's settings.
+ ) : (
+
+ {draft.scopes.map((scope, index) => (
+
+ ))}
+
+ )}
+ {draft.scopeId !== EVERYONE && draft.scope && (
+
+ Selected for editing: {draft.scope.label} ({scopeMatchSummary(draft.scope)}).
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/(main)/admin/_pages/policy.tsx b/app/(main)/admin/_pages/policy.tsx
index c8e88a1c0..2ce7fdaee 100644
--- a/app/(main)/admin/_pages/policy.tsx
+++ b/app/(main)/admin/_pages/policy.tsx
@@ -7,6 +7,7 @@ import type { SettingsPolicy, FeatureGates, PushRelayOption, AdminSidebarApp } f
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
import { adminPageDef } from '@/lib/admin/admin-nav';
+import { restrictionKeysForPage } from '@/lib/admin/restriction-keys';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { DEFAULT_SIDEBAR_APP_ID_PREFIX, MAX_DEFAULT_SIDEBAR_APPS } from '@/lib/sidebar-apps';
import { generateUUID, cn } from '@/lib/utils';
@@ -54,28 +55,7 @@ const FEATURE_GATE_LABELS: Partial Security).',
+ envOverridden && !noThirdParty && 'Locked by the BULWARK_TELEMETRY env var.',
].filter(Boolean).join(' ');
return (
diff --git a/app/(main)/admin/_pages/version.tsx b/app/(main)/admin/_pages/version.tsx
index 102962781..6093aa948 100644
--- a/app/(main)/admin/_pages/version.tsx
+++ b/app/(main)/admin/_pages/version.tsx
@@ -24,6 +24,7 @@ interface VersionAdminStatus {
endpoint: string;
defaultEndpoint: string;
disabledByEnv: boolean;
+ disabledByNoThirdPartyTraffic?: boolean;
lastCheckedAt: string | null;
lastSuccessAt: string | null;
nextScheduledAt: string | null;
@@ -205,6 +206,11 @@ export function VersionPage() {
{data.disabledByEnv ? 'Yes' : 'No'}
+ {data.disabledByNoThirdPartyTraffic && (
+
+ Yes
+
+ )}
diff --git a/app/api/admin/__tests__/policy-file-route.test.ts b/app/api/admin/__tests__/policy-file-route.test.ts
new file mode 100644
index 000000000..9e7afce38
--- /dev/null
+++ b/app/api/admin/__tests__/policy-file-route.test.ts
@@ -0,0 +1,89 @@
+// @vitest-environment node
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { NextRequest } from 'next/server';
+
+// The whole user policy goes out and comes back as one policy.json. An
+// import is previewed first and goes through the same sanitising as the
+// file on disk.
+
+const audit = vi.hoisted(() => [] as Array<{ action: string; detail: Record }>);
+vi.mock('@/lib/admin/session', () => ({
+ requireAdminAuth: async () => ({ payload: { role: 'admin' } }),
+ getClientIP: () => '127.0.0.1',
+}));
+vi.mock('@/lib/admin/audit', () => ({
+ auditLog: async (action: string, detail: Record) => { audit.push({ action, detail }); },
+}));
+
+import { GET, POST } from '@/app/api/admin/policy/file/route';
+import { configManager } from '@/lib/admin/config-manager';
+
+let dir: string;
+const url = (query = '') => `https://mail.example/api/admin/policy/file${query}`;
+const post = (body: string, query = '') => POST(new NextRequest(url(query), { method: 'POST', body }));
+const stored = async () => JSON.parse(await readFile(path.join(dir, 'policy.json'), 'utf-8'));
+
+beforeEach(async () => {
+ dir = await mkdtemp(path.join(tmpdir(), 'bw-policy-file-'));
+ process.env.ADMIN_CONFIG_DIR = dir;
+ audit.length = 0;
+ await writeFile(path.join(dir, 'policy.json'), JSON.stringify({
+ restrictions: { density: { locked: true } },
+ organisation: { session: { idleMinutes: 30 } },
+ scopes: [{ id: 'team', label: 'Team', match: { groups: ['team@example.org'] }, policy: {} }],
+ }));
+ await configManager.reload();
+});
+
+afterEach(async () => {
+ delete process.env.ADMIN_CONFIG_DIR;
+ await rm(dir, { recursive: true, force: true });
+});
+
+describe('policy file', () => {
+ it('exports the stored policy as a dated download and logs it', async () => {
+ const res = await GET(new NextRequest(url()));
+ expect(res.headers.get('content-disposition')).toMatch(/^attachment; filename="bulwark-policy-\d{4}-\d{2}-\d{2}\.json"$/);
+ const body = JSON.parse(await res.text());
+ expect(body.scopes.map((s: { id: string }) => s.id)).toEqual(['team']);
+ expect(body.organisation.session.idleMinutes).toBe(30);
+ expect(audit.map((a) => a.action)).toEqual(['policy.export']);
+ });
+
+ it('previews an import without writing anything', async () => {
+ const file = JSON.stringify({
+ restrictions: {},
+ organisation: { session: { idleMinutes: 10 }, signature: { html: '{name}
' } },
+ scopes: [{ id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: {} }],
+ });
+ const res = await post(file, '?preview=1');
+ const { summary } = await res.json();
+ expect(summary.scopes).toEqual({ added: ['Two (two)'], changed: [], removed: ['Team (team)'] });
+ expect(summary.restrictions.removed).toEqual(['density']);
+ expect(summary.organisation.changed.sort()).toEqual(['session', 'signature']);
+ expect((await stored()).organisation.session.idleMinutes).toBe(30);
+ expect(audit).toEqual([]);
+ });
+
+ it('imports the sanitised policy and logs it', async () => {
+ const res = await post(JSON.stringify({
+ organisation: { session: { idleMinutes: 'soon' } },
+ scopes: [{ id: 'Bad id!' }, { id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: {} }],
+ }));
+ expect(res.status).toBe(200);
+ const policy = await stored();
+ expect(policy.scopes.map((s: { id: string }) => s.id)).toEqual(['two']);
+ expect(policy.organisation.session.idleMinutes).toBe(0);
+ expect(configManager.getPolicy().scopes?.map((s) => s.id)).toEqual(['two']);
+ expect(audit.map((a) => a.action)).toEqual(['policy.import']);
+ });
+
+ it('refuses what isn\'t a policy object', async () => {
+ expect((await post('{nope')).status).toBe(400);
+ expect((await post('[1,2]')).status).toBe(400);
+ expect((await stored()).scopes).toHaveLength(1);
+ });
+});
diff --git a/app/api/admin/__tests__/policy-scopes-route.test.ts b/app/api/admin/__tests__/policy-scopes-route.test.ts
new file mode 100644
index 000000000..3c27c1320
--- /dev/null
+++ b/app/api/admin/__tests__/policy-scopes-route.test.ts
@@ -0,0 +1,80 @@
+// @vitest-environment node
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { NextRequest } from 'next/server';
+
+// The policy route hands each browser only the scopes of the domains it is
+// signed in with, so one organisation on a shared instance never sees
+// another's settings.
+
+const who = vi.hoisted(() => ({
+ signedIn: true,
+ identity: null as null | { admin: true } | { admin: false; addresses: string[] },
+}));
+vi.mock('@/lib/auth/instance-details', () => ({
+ canSeeInstanceDetails: async () => who.signedIn,
+ signedInIdentity: async () => who.identity,
+}));
+
+const SCOPES = [
+ { id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: { features: { calendarEnabled: false } } },
+ { id: 'team', label: 'Team', match: { groups: ['org-team@example.org'] }, policy: {} },
+];
+vi.mock('@/lib/admin/config-manager', () => ({
+ configManager: {
+ ensureLoaded: async () => {},
+ getPolicy: () => ({
+ restrictions: {}, features: {}, defaults: {}, themePolicy: {},
+ forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
+ organisation: { disclaimer: { html: 'Acme GmbH
', text: 'Acme GmbH' } },
+ scopes: SCOPES,
+ }),
+ },
+}));
+vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } }));
+vi.mock('@/lib/admin/session', () => ({ requireAdminAuth: vi.fn(), getClientIP: () => '0.0.0.0' }));
+vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn() }));
+
+import { GET } from '@/app/api/admin/policy/route';
+
+const get = async () => (await GET(new NextRequest('https://mail.example/api/admin/policy'))).json();
+
+beforeEach(() => {
+ who.signedIn = true;
+ who.identity = null;
+});
+
+describe('GET /api/admin/policy scopes', () => {
+ it('gives visitors who are not signed in no scopes and no organisation settings', async () => {
+ who.signedIn = false;
+ const body = await get();
+ expect(body.scopes).toEqual([]);
+ expect(body.organisation).toBeUndefined();
+ });
+
+ it('gives a signed-in user only the scopes of their domains', async () => {
+ who.identity = { admin: false, addresses: ['org-dave@org-two.test'] };
+ expect((await get()).scopes.map((s: { id: string }) => s.id)).toEqual(['two']);
+ who.identity = { admin: false, addresses: ['org-alice@example.org'] };
+ const body = await get();
+ expect(body.scopes.map((s: { id: string }) => s.id)).toEqual(['team']);
+ expect(body.organisation.disclaimer.text).toBe('Acme GmbH');
+ });
+
+ it('says which domains the scopes were picked for, and nothing when all are sent', async () => {
+ who.identity = { admin: false, addresses: ['org-alice@example.org', 'org-dave@org-two.test'] };
+ const res = await GET(new NextRequest('https://mail.example/api/admin/policy'));
+ expect(res.headers.get('x-bulwark-policy-domains')).toBe('example.org,org-two.test');
+ who.identity = { admin: true };
+ expect((await GET(new NextRequest('https://mail.example/api/admin/policy'))).headers.get('x-bulwark-policy-domains')).toBeNull();
+ });
+
+ it('gives the admin every scope', async () => {
+ who.identity = { admin: true };
+ expect((await get()).scopes).toHaveLength(2);
+ });
+
+ it('gives every scope when the server can\'t tell who is asking', async () => {
+ who.identity = null;
+ expect((await get()).scopes).toHaveLength(2);
+ });
+});
diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts
index 2981b5b8a..33e3cd203 100644
--- a/app/api/admin/config/route.ts
+++ b/app/api/admin/config/route.ts
@@ -10,6 +10,7 @@ import { logger } from '@/lib/logger';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
import { checkExtensionDirectoryUrl } from '@/lib/admin/directory-url';
+import { parseNoThirdPartyEnv } from '@/lib/third-party-mode';
// Strings that count as "no real secret configured" - used so the dashboard
// can warn about a placeholder session secret without us ever returning the
@@ -99,6 +100,17 @@ export async function PATCH(request: NextRequest) {
if (problem) return NextResponse.json({ error: problem }, { status: 400 });
}
+ // The no-third-party switch is fail-closed: an "on" from the environment
+ // can't be undone from the panel (lib/admin/third-party-mode.ts).
+ if ('noThirdPartyTraffic' in updates) {
+ if (typeof updates.noThirdPartyTraffic !== 'boolean') {
+ return NextResponse.json({ error: 'noThirdPartyTraffic must be true or false' }, { status: 400 });
+ }
+ if (!updates.noThirdPartyTraffic && parseNoThirdPartyEnv(process.env.NO_THIRD_PARTY_TRAFFIC)) {
+ return NextResponse.json({ error: 'NO_THIRD_PARTY_TRAFFIC is set in the environment; change it there.' }, { status: 409 });
+ }
+ }
+
// Enum keys only accept their declared values; a stray string would
// otherwise be persisted and fail closed (or open) at the read site.
for (const [key, value] of Object.entries(updates)) {
diff --git a/app/api/admin/marketplace/[slug]/route.ts b/app/api/admin/marketplace/[slug]/route.ts
index 4df6da909..f35fd899b 100644
--- a/app/api/admin/marketplace/[slug]/route.ts
+++ b/app/api/admin/marketplace/[slug]/route.ts
@@ -12,6 +12,8 @@ import {
directoryJson,
getDirectoryChannel,
getDirectoryUrl,
+ directoryOff,
+ DIRECTORY_OFF_MESSAGE,
historyFor,
installedExtensions,
installedStateOf,
@@ -65,6 +67,9 @@ export async function GET(
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
+ if (await directoryOff()) {
+ return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 });
+ }
const { slug } = await params;
const requested = request.nextUrl.searchParams.get('version');
diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts
index 50404e882..19cfb739e 100644
--- a/app/api/admin/marketplace/route.ts
+++ b/app/api/admin/marketplace/route.ts
@@ -32,6 +32,8 @@ import {
directoryJson,
getDirectoryChannel,
getDirectoryUrl,
+ directoryOff,
+ DIRECTORY_OFF_MESSAGE,
installedExtensions,
installedStateOf,
readBundleBytes,
@@ -57,6 +59,9 @@ export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
+ if (await directoryOff()) {
+ return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 });
+ }
const directoryUrl = await getDirectoryUrl();
const query: Record = {};
@@ -105,6 +110,9 @@ export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
+ if (await directoryOff()) {
+ return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 });
+ }
const ip = getClientIP(request);
const { slug, version, type, force } = await request.json();
diff --git a/app/api/admin/marketplace/updates/route.ts b/app/api/admin/marketplace/updates/route.ts
index 52b14e8cd..7d2643452 100644
--- a/app/api/admin/marketplace/updates/route.ts
+++ b/app/api/admin/marketplace/updates/route.ts
@@ -8,6 +8,8 @@ import {
directoryJson,
getDirectoryChannel,
getDirectoryUrl,
+ directoryOff,
+ DIRECTORY_OFF_MESSAGE,
installedExtensions,
v2Url,
type DirectoryAdvisory,
@@ -43,6 +45,13 @@ export async function GET(request: NextRequest) {
const list = [...installed].map(([id, e]) => ({ id, ...e }));
const ask = list.filter((e) => askDirectoryAbout(e.source)).map((e) => ({ slug: e.id, version: e.version }));
+ // The installed list is still useful with the official directory off (#903).
+ if (await directoryOff()) {
+ return NextResponse.json(
+ { installed: list, ...EMPTY, directoryUrl: null, channel: getDirectoryChannel(), appVersion: APP_VERSION || null, error: DIRECTORY_OFF_MESSAGE },
+ { headers: { 'Cache-Control': 'no-store' } },
+ );
+ }
const directoryUrl = await getDirectoryUrl();
const channel = getDirectoryChannel();
let answer: UpdatesAnswer = EMPTY;
diff --git a/app/api/admin/outgoing-hosts/route.ts b/app/api/admin/outgoing-hosts/route.ts
new file mode 100644
index 000000000..d7eaeccce
--- /dev/null
+++ b/app/api/admin/outgoing-hosts/route.ts
@@ -0,0 +1,49 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAdminAuth } from '@/lib/admin/session';
+import { configManager } from '@/lib/admin/config-manager';
+import { isConfigReadOnly } from '@/lib/admin/paths';
+import { getPluginRegistry } from '@/lib/admin/plugin-registry';
+import { noThirdPartyTrafficState } from '@/lib/admin/third-party-mode';
+import { listReachableHosts } from '@/lib/admin/reachable-hosts';
+import { pluginDeclaresNetwork } from '@/lib/outgoing-hosts';
+import { OUTGOING_HOSTS } from '@/lib/admin/outgoing-hosts-registry';
+import { logger } from '@/lib/logger';
+
+/**
+ * GET /api/admin/outgoing-hosts - the "no third-party traffic" switch with
+ * where its value comes from, the built-in outside hosts it turns off, the
+ * admin-configured hosts that stay reachable, and the installed plugins it
+ * refuses (#903). Organisation > Security renders this; the switch itself is
+ * saved through PATCH /api/admin/config like every other config key.
+ */
+export async function GET(request: NextRequest) {
+ try {
+ const auth = await requireAdminAuth(request);
+ if ('error' in auth) return auth.error;
+
+ await configManager.ensureLoaded();
+ const state = noThirdPartyTrafficState();
+ const policy = configManager.getPolicy();
+ const registry = await getPluginRegistry().catch(() => ({ plugins: [] }));
+
+ return NextResponse.json(
+ {
+ noThirdPartyTraffic: state.on,
+ source: state.source,
+ // The panel can't turn it off when the environment pins it on, and
+ // can't change it at all on a read-only config.
+ readOnly: isConfigReadOnly(),
+ envLocked: state.source === 'env',
+ builtIn: OUTGOING_HOSTS,
+ reachable: listReachableHosts({ get: (key) => configManager.get(key), policy, env: process.env }),
+ networkPlugins: registry.plugins
+ .filter((plugin) => plugin.enabled && pluginDeclaresNetwork(plugin))
+ .map((plugin) => ({ id: plugin.id, name: plugin.name })),
+ },
+ { headers: { 'Cache-Control': 'no-store' } },
+ );
+ } catch (error) {
+ logger.error('Outgoing hosts read error', { error: error instanceof Error ? error.message : 'Unknown error' });
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+ }
+}
diff --git a/app/api/admin/policy/file/route.ts b/app/api/admin/policy/file/route.ts
new file mode 100644
index 000000000..b1448ad0b
--- /dev/null
+++ b/app/api/admin/policy/file/route.ts
@@ -0,0 +1,79 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { completePolicy, configManager } from '@/lib/admin/config-manager';
+import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
+import { auditLog } from '@/lib/admin/audit';
+import { logger } from '@/lib/logger';
+import { summarizePolicyChange } from '@/lib/admin/policy-diff';
+import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
+import type { SettingsPolicy } from '@/lib/admin/types';
+
+/** Largest policy file an import accepts. */
+const MAX_IMPORT_BYTES = 2 * 1024 * 1024;
+
+/**
+ * GET /api/admin/policy/file - the whole user policy as a policy.json
+ * download: the same file the server reads, so it can go into git, into
+ * another instance's ADMIN_CONFIG_DIR or next to a Lite build.
+ */
+export async function GET(request: NextRequest) {
+ const auth = await requireAdminAuth(request);
+ if ('error' in auth) return auth.error;
+ await configManager.ensureLoaded();
+ const policy = configManager.getPolicy();
+ await auditLog('policy.export', { scopeCount: policy.scopes?.length ?? 0 }, getClientIP(request));
+ const date = new Date().toISOString().slice(0, 10);
+ return new NextResponse(`${JSON.stringify(policy, null, 2)}\n`, {
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Content-Disposition': `attachment; filename="bulwark-policy-${date}.json"`,
+ 'Cache-Control': 'no-store',
+ },
+ });
+}
+
+/**
+ * POST /api/admin/policy/file - import a policy file. With `?preview=1` it
+ * only says what would change; otherwise it replaces the policy. The file
+ * goes through the same defaults and sanitising as policy.json on load, and
+ * the preview shows the result of that, not the raw file.
+ */
+export async function POST(request: NextRequest) {
+ const auth = await requireAdminAuth(request);
+ if ('error' in auth) return auth.error;
+ try {
+ const text = await request.text();
+ if (text.length > MAX_IMPORT_BYTES) {
+ return NextResponse.json({ error: 'The file is larger than 2 MB' }, { status: 413 });
+ }
+ let raw: unknown;
+ try {
+ raw = JSON.parse(text);
+ } catch {
+ return NextResponse.json({ error: 'The file is not valid JSON' }, { status: 400 });
+ }
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
+ return NextResponse.json({ error: 'The file does not hold a policy object' }, { status: 400 });
+ }
+
+ await configManager.ensureLoaded();
+ const next = completePolicy(raw as Partial);
+ const summary = summarizePolicyChange(configManager.getPolicy(), next);
+ if (request.nextUrl.searchParams.get('preview') === '1') {
+ return NextResponse.json({ summary });
+ }
+
+ await configManager.setPolicy(next);
+ await auditLog('policy.import', {
+ scopeCount: next.scopes?.length ?? 0,
+ scopesAdded: summary.scopes.added.length,
+ scopesRemoved: summary.scopes.removed.length,
+ restrictionsChanged: summary.restrictions.added.length + summary.restrictions.changed.length + summary.restrictions.removed.length,
+ }, getClientIP(request));
+ return NextResponse.json({ ok: true, summary });
+ } catch (error) {
+ const readOnly = configReadOnlyResponse(error);
+ if (readOnly) return readOnly;
+ logger.error('Policy import error', { error: error instanceof Error ? error.message : 'Unknown error' });
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+ }
+}
diff --git a/app/api/admin/policy/route.ts b/app/api/admin/policy/route.ts
index 6e707577e..145260bd2 100644
--- a/app/api/admin/policy/route.ts
+++ b/app/api/admin/policy/route.ts
@@ -3,8 +3,9 @@ import { configManager } from '@/lib/admin/config-manager';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
-import { POLICY_SCOPE_HEADER, type SettingsPolicy } from '@/lib/admin/types';
-import { canSeeInstanceDetails } from '@/lib/auth/instance-details';
+import { POLICY_DOMAINS_HEADER, POLICY_SCOPE_HEADER, type SettingsPolicy } from '@/lib/admin/types';
+import { canSeeInstanceDetails, signedInIdentity } from '@/lib/auth/instance-details';
+import { scopesVisibleTo } from '@/lib/admin/policy-scopes';
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
/**
@@ -28,12 +29,30 @@ export async function GET(request: NextRequest) {
pushRelayUrl: '',
pushRelayUrlLocked: false,
defaultSidebarApps: [],
+ // Signatures, domain lists and scopes describe the organisation
+ // and only apply once someone is signed in.
+ organisation: undefined,
+ scopes: [],
},
{ headers: { 'Cache-Control': 'no-store', [POLICY_SCOPE_HEADER]: 'public' } },
);
}
- return NextResponse.json(policy, {
- headers: { 'Cache-Control': 'no-store' },
+ // Users get only the scopes for their own domains, so on an instance
+ // shared by several organisations one doesn't see another's settings.
+ // Without a session secret the server can't tell who is asking and
+ // answers with all of them (documented).
+ const identity = await signedInIdentity();
+ const filtered = identity !== null && !identity.admin;
+ const scopes = filtered
+ ? scopesVisibleTo(policy.scopes ?? [], identity.addresses)
+ : policy.scopes ?? [];
+ // Which domains the scopes were picked for, so a browser that signs in
+ // to an account of another domain knows to fetch again.
+ const domains = filtered
+ ? [...new Set(identity.addresses.map((a) => a.slice(a.lastIndexOf('@') + 1)).filter(Boolean))].join(',')
+ : null;
+ return NextResponse.json({ ...policy, scopes }, {
+ headers: { 'Cache-Control': 'no-store', ...(domains !== null ? { [POLICY_DOMAINS_HEADER]: domains } : {}) },
});
} catch (error) {
logger.error('Policy read error', { error: error instanceof Error ? error.message : 'Unknown error' });
@@ -72,6 +91,12 @@ export async function PUT(request: NextRequest) {
if (policy.defaultSidebarApps !== undefined && !Array.isArray(policy.defaultSidebarApps)) {
return NextResponse.json({ error: 'defaultSidebarApps must be an array' }, { status: 400 });
}
+ if (policy.scopes !== undefined && !Array.isArray(policy.scopes)) {
+ return NextResponse.json({ error: 'scopes must be an array' }, { status: 400 });
+ }
+ if (policy.organisation !== undefined && (typeof policy.organisation !== 'object' || policy.organisation === null || Array.isArray(policy.organisation))) {
+ return NextResponse.json({ error: 'organisation must be an object' }, { status: 400 });
+ }
await configManager.setPolicy(policy);
await auditLog('policy.update', {
diff --git a/app/api/admin/telemetry/route.ts b/app/api/admin/telemetry/route.ts
index 192e64b31..daa5c19f8 100644
--- a/app/api/admin/telemetry/route.ts
+++ b/app/api/admin/telemetry/route.ts
@@ -81,6 +81,12 @@ export async function POST(request: NextRequest) {
{ status: 409 },
);
}
+ if (source === 'no-third-party') {
+ return NextResponse.json(
+ { error: 'telemetry is off while NO_THIRD_PARTY_TRAFFIC is on' },
+ { status: 409 },
+ );
+ }
if (body.consent !== 'on' && body.consent !== 'off') {
return NextResponse.json({ error: 'consent must be "on" or "off"' }, { status: 400 });
}
diff --git a/app/api/admin/version/route.ts b/app/api/admin/version/route.ts
index 2c63a7b33..004db83d2 100644
--- a/app/api/admin/version/route.ts
+++ b/app/api/admin/version/route.ts
@@ -9,6 +9,7 @@ import {
disabledByEnv,
DEFAULT_VERSION_ENDPOINT,
} from '@/lib/version-check';
+import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
/**
* GET /api/admin/version
@@ -27,6 +28,7 @@ export async function GET(request: NextRequest) {
endpoint: effectiveEndpoint(state),
defaultEndpoint: DEFAULT_VERSION_ENDPOINT,
disabledByEnv: disabledByEnv(),
+ disabledByNoThirdPartyTraffic: await isNoThirdPartyTrafficOn(),
lastCheckedAt: state.lastCheckedAt,
lastSuccessAt: state.lastSuccessAt,
nextScheduledAt: state.nextScheduledAt,
diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts
index 3b0bbb5dd..e68645a30 100644
--- a/app/api/auth/session/route.ts
+++ b/app/api/auth/session/route.ts
@@ -23,6 +23,7 @@ import { revokeWopiTokens } from '@/lib/wopi/revocation';
import { clearPairReauthInStore } from '@/lib/auth/pair-reauth';
import { loginClientAddress } from '@/lib/auth/client-address';
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
+import { isPastMaxAge, limitsEnforcedSince, maxSessionHoursFor, SESSION_TOO_OLD } from '@/lib/auth/session-age';
function sessionCookieOptions() {
return {
@@ -73,6 +74,37 @@ function getSlot(request: NextRequest): number {
return slot;
}
+/**
+ * The organisation's maximum session length for a remembered session
+ * (lib/auth/session-age.ts). A cookie from before sign-in times were
+ * recorded counts as signed in when the server first enforced the limits,
+ * a fixed time, so a kept copy of it runs out like any other. Returns the
+ * sign-in time, or the limit when the session is too old: its cookies are
+ * cleared then.
+ */
+async function checkSessionAge(
+ cookieStore: Awaited>,
+ slot: number,
+ credentials: SessionCredentials,
+): Promise<{ signedInAt: number } | { maxHours: number }> {
+ const signedInAt = credentials.signedInAt ?? await limitsEnforcedSince();
+ // An impersonation login is `target%master`; the policy is the target's.
+ const maxHours = await maxSessionHoursFor(credentials.username.split('%', 1)[0]);
+ if (isPastMaxAge(signedInAt, maxHours)) {
+ cookieStore.delete(sessionCookieName(slot));
+ clearStalwartAuthContextInStore(cookieStore, slot);
+ return { maxHours };
+ }
+ return { signedInAt };
+}
+
+function sessionTooOld(maxHours: number) {
+ return NextResponse.json(
+ { error: SESSION_TOO_OLD, maxHours },
+ { status: 401, headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
+ );
+}
+
export async function POST(request: NextRequest) {
// CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax,
// so a cross-site top-level POST would otherwise reach this handler.
@@ -162,7 +194,9 @@ export async function POST(request: NextRequest) {
throw error;
}
refund();
- const token = encryptSession(normalizedServerUrl, username, accessToken ? { token: accessToken } : password);
+ // A remembered sign-in starts the maximum session length.
+ const signedInAt = Date.now();
+ const token = encryptSession(normalizedServerUrl, username, accessToken ? { token: accessToken } : password, signedInAt);
const cookieStore = await cookies();
cookieStore.set(cookieName, token, sessionCookieOptions());
setStalwartAuthContextInStore(cookieStore, slot, {
@@ -176,7 +210,8 @@ export async function POST(request: NextRequest) {
const warning = insecureCookieHint(request);
if (warning) logger.warn(`session: ${warning}`);
- return NextResponse.json(warning ? { ok: true, warning } : { ok: true });
+ // The browser counts the maximum session length from the server's time.
+ return NextResponse.json(warning ? { ok: true, warning, signedInAt } : { ok: true, signedInAt });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
return NextResponse.json(
@@ -202,12 +237,15 @@ export async function GET(request: NextRequest) {
}
const credentials = decryptSession(token);
- if (!credentials) {
+ if (!credentials || holdsMasterPassword(credentials)) {
cookieStore.delete(cookieName);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
+ const age = await checkSessionAge(cookieStore, slot, credentials);
+ if ('maxHours' in age) return sessionTooOld(age.maxHours);
+
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
@@ -217,7 +255,7 @@ export async function GET(request: NextRequest) {
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
const { serverUrl, username } = credentials;
return NextResponse.json(
- { serverUrl, username },
+ { serverUrl, username, signedInAt: age.signedInAt },
{ headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
);
} catch (error) {
@@ -258,13 +296,17 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
+ const age = await checkSessionAge(cookieStore, slot, credentials);
+ if ('maxHours' in age) return sessionTooOld(age.maxHours);
+
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: sessionAuthHeader(credentials),
});
- return NextResponse.json(credentials, {
+ // The browser counts the maximum session length from the server's time.
+ return NextResponse.json({ ...credentials, signedInAt: age.signedInAt }, {
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
});
} catch (error) {
diff --git a/app/api/auth/sso/complete/route.ts b/app/api/auth/sso/complete/route.ts
index de4a8f8de..f79fdb6b9 100644
--- a/app/api/auth/sso/complete/route.ts
+++ b/app/api/auth/sso/complete/route.ts
@@ -14,6 +14,7 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { storeIdToken } from '@/lib/oauth/end-session';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
+import { writeSignInStamp } from '@/lib/auth/session-age';
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
@@ -105,6 +106,7 @@ export async function POST(request: NextRequest) {
// Exchange code for tokens
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
+ const signedInAt = Date.now();
// For the mobile handoff flow the tokens are handed back to the app
// verbatim - we deliberately don't write any cookies on the webmail
@@ -115,6 +117,8 @@ export async function POST(request: NextRequest) {
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot);
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
+ // A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
+ writeSignInStamp(cookieStore, slot, { signedInAt, refreshToken: tokens.refresh_token });
}
const serverCookieName = refreshTokenServerCookieName(slot);
if (pendingServerId) {
@@ -166,6 +170,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in,
+ signed_in_at: signedInAt,
});
} catch (error) {
// Clean up pending cookie on any error
diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts
index 0d07ec38b..fe25532d3 100644
--- a/app/api/auth/stalwart-context/route.ts
+++ b/app/api/auth/stalwart-context/route.ts
@@ -1,7 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, resolveJmapIdentity } from '@/lib/auth/verify-jmap-auth';
+import { cookies } from 'next/headers';
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
+import { readSignInStamp, writeSignInStamp } from '@/lib/auth/session-age';
+import { refreshTokenCookieName } from '@/lib/oauth/tokens';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
@@ -101,6 +104,16 @@ export async function POST(request: NextRequest) {
// Just signed in: an older refusal of this credential no longer applies.
recordStoredCredentialStatus(authHeader, 200);
+ // A token sign-in's stamp (lib/auth/session-age.ts) knows no account:
+ // the token routes never see one. The first verified account names it,
+ // so the maximum session length of its domain applies on renewal.
+ const cookieStore = await cookies();
+ const refreshToken = cookieStore.get(refreshTokenCookieName(slot))?.value;
+ const stamp = refreshToken ? readSignInStamp(cookieStore, slot, refreshToken) : null;
+ if (refreshToken && stamp && !stamp.account) {
+ writeSignInStamp(cookieStore, slot, { signedInAt: stamp.signedInAt, refreshToken, account: accountName });
+ }
+
await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl,
username,
diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts
index 05e7ec29c..b2ea53c3e 100644
--- a/app/api/auth/token/route.ts
+++ b/app/api/auth/token/route.ts
@@ -30,6 +30,15 @@ import {
} from '@/lib/oauth/end-session';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
+import {
+ clearSignInStamp,
+ isPastMaxAge,
+ maxSessionHoursFor,
+ SESSION_TOO_OLD,
+ tokenSignedInAt,
+ writeSignInStamp,
+} from '@/lib/auth/session-age';
+import { clearStalwartAuthContextInStore, readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
function parseSlot(raw: string | null): number | null {
if (raw === null) return null;
@@ -142,16 +151,21 @@ export async function POST(request: NextRequest) {
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId);
+ // A sign-in: the maximum session length counts from here, on the server
+ // and, from this answer, in the browser.
+ const signedInAt = Date.now();
const response = NextResponse.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in,
+ signed_in_at: signedInAt,
});
const cookieStore = await cookies();
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot);
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
+ writeSignInStamp(cookieStore, slot, { signedInAt, refreshToken: tokens.refresh_token });
}
const cachedAccessToken = cacheAccessToken(cookieStore, slot, tokens.access_token, tokens.expires_in || 3600, [tokens.refresh_token, serverId]);
storeIdToken(cookieStore, slot, tokens.id_token, request.nextUrl.basePath, [tokens.refresh_token, cachedAccessToken, serverId]);
@@ -189,6 +203,25 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
}
+ // The organisation's maximum session length (lib/auth/session-age.ts).
+ // A refresh token without a matching stamp (from before stamps, or one
+ // whose stamp was deleted) counts from the fixed time the server first
+ // enforced the limits, never from now.
+ const age = await tokenSignedInAt(cookieStore, slot, refreshToken);
+ if (age) {
+ const context = readStalwartAuthContextFromStore(cookieStore, slot);
+ const account = age.stamp?.account ?? context?.accountName ?? context?.username ?? null;
+ const maxHours = await maxSessionHoursFor(account);
+ if (isPastMaxAge(age.signedInAt, maxHours)) {
+ await signOutSlot(cookieStore, slot, request.nextUrl.basePath, false);
+ clearStalwartAuthContextInStore(cookieStore, slot);
+ return NextResponse.json({ error: SESSION_TOO_OLD, maxHours }, { status: 401 });
+ }
+ // Bind the time to this token from now on.
+ if (!age.stamp) writeSignInStamp(cookieStore, slot, { signedInAt: age.signedInAt, refreshToken, ...(account ? { account } : {}) });
+ }
+ const signedIn = age ? { signed_in_at: age.signedInAt } : {};
+
// A session restore calls this to get its token back, not because the
// current one expired. Serving the cached token avoids spending a refresh
// the IdP may legitimately reject: Rauthy stamps refresh tokens with
@@ -204,6 +237,7 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({
access_token: cached.accessToken,
expires_in: cached.expiresIn,
+ ...signedIn,
});
}
}
@@ -252,6 +286,14 @@ export async function PUT(request: NextRequest) {
if (tokens.refresh_token) {
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
+ // The stamp follows the rotated token, with the same sign-in time and lifetime.
+ if (age) {
+ writeSignInStamp(cookieStore, slot, {
+ signedInAt: age.signedInAt,
+ refreshToken: tokens.refresh_token,
+ ...(age.stamp?.account ? { account: age.stamp.account } : {}),
+ });
+ }
}
const expiresIn = tokens.expires_in || 3600;
@@ -265,6 +307,7 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({
access_token: tokens.access_token,
expires_in: expiresIn,
+ ...signedIn,
});
} catch (error) {
logger.error('Token refresh error', { error: error instanceof Error ? error.message : 'Unknown error' });
@@ -307,6 +350,7 @@ async function signOutSlot(
for (const name of [cookieName, refreshTokenServerCookieName(slot), accessTokenCookieName(slot)]) {
if (cookieStore.get(name)) cookieStore.delete(name);
}
+ clearSignInStamp(cookieStore, slot);
if (idToken !== undefined) clearIdToken(cookieStore, slot, basePath);
return endSessionUrl;
}
diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts
index 6172b7283..9b61b428f 100644
--- a/app/api/auth/totp-token-exchange/route.ts
+++ b/app/api/auth/totp-token-exchange/route.ts
@@ -14,6 +14,7 @@ import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { stalwartPasswordLogin } from '@/lib/auth/stalwart-password-login';
import { loginClientAddress } from '@/lib/auth/client-address';
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
+import { writeSignInStamp } from '@/lib/auth/session-age';
/**
* Exchange a password + (optional) TOTP code for OAuth tokens.
@@ -208,11 +209,15 @@ async function storeAndRespond(
rememberMe: boolean,
): Promise {
const cookieStore = await cookies();
+ // A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
+ const signedInAt = Date.now();
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot);
// Without "Remember me" the sign-in ends with the browser session.
const { maxAge: _maxAge, ...sessionOptions } = getCookieOptions();
- cookieStore.set(cookieName, tokens.refresh_token, rememberMe ? getCookieOptions() : sessionOptions);
+ const options = rememberMe ? getCookieOptions() : sessionOptions;
+ cookieStore.set(cookieName, tokens.refresh_token, options);
+ writeSignInStamp(cookieStore, slot, { signedInAt, refreshToken: tokens.refresh_token }, options);
}
const serverCookieName = refreshTokenServerCookieName(slot);
if (serverId) {
@@ -225,5 +230,6 @@ async function storeAndRespond(
access_token: tokens.access_token,
expires_in: tokens.expires_in || 3600,
has_refresh_token: !!tokens.refresh_token,
+ signed_in_at: signedInAt,
});
}
diff --git a/app/api/config/route.ts b/app/api/config/route.ts
index 1faf393c3..d2293318d 100644
--- a/app/api/config/route.ts
+++ b/app/api/config/route.ts
@@ -11,6 +11,7 @@ import {
pickRequestHost,
type BrandingOverrideKey,
} from '@/lib/admin/domain-branding';
+import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
/**
* Runtime configuration endpoint
@@ -98,6 +99,8 @@ export async function GET(request: NextRequest) {
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get('parentOrigin', ''),
sourceCodeUrl: httpUrlOrEmpty(configManager.get('sourceCodeUrl', '')),
+ // Browser-side switches of lib/outgoing-hosts.ts read this (#903).
+ noThirdPartyTraffic: await isNoThirdPartyTrafficOn(),
},
{
// Runtime settings can change without new asset URLs. Prevent stale
diff --git a/app/api/connector/capabilities/route.ts b/app/api/connector/capabilities/route.ts
index 4ff6fd1a1..2235298b9 100644
--- a/app/api/connector/capabilities/route.ts
+++ b/app/api/connector/capabilities/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { configManager } from '@/lib/admin/config-manager';
+import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
import { TARGET_NAMES } from '@/lib/connector/registry';
/**
@@ -28,7 +29,7 @@ import { TARGET_NAMES } from '@/lib/connector/registry';
export async function GET() {
await configManager.ensureLoaded();
- if (!configManager.get('connectorEnabled', true)) {
+ if (!configManager.get('connectorEnabled', true) || await isNoThirdPartyTrafficOn()) {
return new NextResponse(null, { status: 404 });
}
diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts
index 7b8128fdb..d437585ce 100644
--- a/app/api/dev-jmap/[...path]/route.ts
+++ b/app/api/dev-jmap/[...path]/route.ts
@@ -4,6 +4,7 @@ import { createDemoFileNodes } from '@/lib/demo/fixtures/files';
import { generateDemoId } from '@/lib/demo/demo-utils';
import type { FileNode } from '@/lib/jmap/types';
import JSZip from 'jszip';
+import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
/**
* Mock JMAP server for local development.
@@ -2035,9 +2036,14 @@ function handleVacationResponseGet(_args: MethodArgs, callId: string): MethodRes
return ['VacationResponse/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ id: 'vacation-1', isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null }], notFound: [] }, callId];
}
+// The sample photos come from randomuser.me: none while third-party traffic
+// is off (#903). Set per request in POST, which can await the config.
+let samplePhotos = true;
+
function handleContactCardGet(args: MethodArgs, callId: string): MethodResult {
const ids = args.ids as string[] | undefined;
- const list = ids ? contacts.filter((c) => ids.includes(c.id)) : contacts;
+ const found = ids ? contacts.filter((c) => ids.includes(c.id)) : contacts;
+ const list = samplePhotos ? found : found.map(({ media: _media, ...rest }) => rest);
return ['ContactCard/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
}
@@ -2826,6 +2832,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// JMAP API endpoint
if (joined === 'api') {
+ samplePhotos = !(await isNoThirdPartyTrafficOn());
try {
const body = await request.json();
const methodCalls = body.methodCalls as Array<[string, MethodArgs, string]>;
diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts
index 7dc48fab8..311757fbe 100644
--- a/app/api/favicon/route.ts
+++ b/app/api/favicon/route.ts
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
+import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode';
// In-memory LRU cache: domain -> { data, contentType, fetchedAt }
const CACHE_MAX_SIZE = 1000;
@@ -439,6 +440,10 @@ function evictOldest() {
}
export async function GET(request: NextRequest) {
+ // Sender icons come from DuckDuckGo: off, before anything else, while the
+ // server must not contact outside services (#903).
+ if (await isNoThirdPartyTrafficOn()) return thirdPartyRefusal('Sender icons');
+
const domain = request.nextUrl.searchParams.get('domain');
if (!domain || !isValidDomain(domain)) {
diff --git a/app/api/plugin-proxy/route.ts b/app/api/plugin-proxy/route.ts
index 5da089c90..2715160e7 100644
--- a/app/api/plugin-proxy/route.ts
+++ b/app/api/plugin-proxy/route.ts
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { configManager } from '@/lib/admin/config-manager';
+import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { CREDENTIAL_ID_RE } from '@/lib/plugin-secrets/credentials';
@@ -41,6 +42,8 @@ export async function POST(request: NextRequest) {
if (!configManager.getPolicy().features.pluginsEnabled) {
return NextResponse.json({ error: 'Plugins are turned off', code: 'disabled' }, { status: 403 });
}
+ // Every proxied request goes to a host outside this deployment (#903).
+ if (await isNoThirdPartyTrafficOn()) return thirdPartyRefusal('The plugin proxy');
const creds = await getStalwartCredentials(request);
if (!creds) return NextResponse.json({ error: 'Not authenticated', code: 'unauthenticated' }, { status: 401 });
diff --git a/app/api/plugin-secrets/__tests__/route.test.ts b/app/api/plugin-secrets/__tests__/route.test.ts
index 60b85a064..7f33684cb 100644
--- a/app/api/plugin-secrets/__tests__/route.test.ts
+++ b/app/api/plugin-secrets/__tests__/route.test.ts
@@ -13,7 +13,7 @@ vi.mock('@/lib/stalwart/credentials', () => ({
getStalwartCredentials: async () => (signedIn ? { ...claimed, serverUrl: 'https://mail.example.org', authHeader: 'x' } : null),
}));
vi.mock('@/lib/admin/config-manager', () => ({
- configManager: { ensureLoaded: async () => {}, getPolicy: () => ({ features: { pluginsEnabled } }) },
+ configManager: { ensureLoaded: async () => {}, get: () => undefined, getPolicy: () => ({ features: { pluginsEnabled } }) },
}));
vi.mock('@/lib/security/same-origin', async () => {
const { NextResponse } = await import('next/server');
diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts
index 238ecc0e7..b5652688a 100644
--- a/app/api/plugins/route.ts
+++ b/app/api/plugins/route.ts
@@ -4,6 +4,8 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry
import { listDevPlugins } from '@/lib/admin/plugin-dev';
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger';
+import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
+import { pluginDeclaresNetwork } from '@/lib/outgoing-hosts';
/**
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
@@ -32,11 +34,14 @@ export async function GET(request: NextRequest) {
// plugin without uninstalling it first.
const devIds = new Set(devEntries.map(e => e.plugin.id));
const installedEnabled = pluginRegistry.plugins.filter(p => p.enabled && !devIds.has(p.id));
+ // Plugins that declare network access never reach a browser while the
+ // server must stay off outside hosts (#903); the rest run as usual.
+ const noThirdParty = await isNoThirdPartyTrafficOn();
const plugins = [
...devEntries.map(e => ({ ...e.plugin, dev: true })),
...installedEnabled.map(p => ({ ...p, dev: false })),
- ].map(p => ({
+ ].filter(p => !(noThirdParty && pluginDeclaresNetwork(p))).map(p => ({
id: p.id,
name: p.name,
version: p.version,
diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts
index 0ad259273..18080c4b5 100644
--- a/app/api/settings/route.ts
+++ b/app/api/settings/route.ts
@@ -7,6 +7,7 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
import { configManager } from '@/lib/admin/config-manager';
+import { resolvePolicy } from '@/lib/admin/policy-scopes';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { mergeStoredTemplates } from '@/lib/template-merge';
@@ -180,7 +181,9 @@ export async function POST(request: NextRequest) {
// Enforce admin policy - strip locked settings so users can't override them
await configManager.ensureLoaded();
- const policy = configManager.getPolicy();
+ // With the account's domain scopes applied; group scopes need the
+ // account's session, which only the client has, so the client enforces those.
+ const policy = resolvePolicy(configManager.getPolicy(), { email: accountName, groups: [] }).policy;
const filteredSettings = { ...settings };
for (const key of Object.keys(filteredSettings)) {
const restriction = policy.restrictions[key];
diff --git a/app/api/translate/route.ts b/app/api/translate/route.ts
index cdf729636..7507303f6 100644
--- a/app/api/translate/route.ts
+++ b/app/api/translate/route.ts
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { readFileEnv } from "@/lib/read-file-env";
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
+import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode';
// Host-side proxy backing the "Translate" plugin (manifest apiPostPaths:
// ["/api/translate"]). The plugin slot iframe POSTs { text, target, source,
@@ -284,6 +285,10 @@ export async function POST(request: NextRequest) {
const target = typeof body.target === 'string' && body.target.trim() ? body.target.trim() : 'en';
const source = typeof body.source === 'string' && body.source.trim() ? body.source.trim() : 'auto';
const provider: Provider = body.provider === 'libretranslate' ? 'libretranslate' : 'mymemory';
+ // MyMemory is a public service; the admin's own LibreTranslate is not (#903).
+ if (provider === 'mymemory' && await isNoThirdPartyTrafficOn()) {
+ return thirdPartyRefusal('Translation through MyMemory');
+ }
if (!text) {
return NextResponse.json({ error: 'No text to translate' }, { status: 400 });
diff --git a/components/admin/__tests__/third-party-section.test.tsx b/components/admin/__tests__/third-party-section.test.tsx
new file mode 100644
index 000000000..8ecadf4ac
--- /dev/null
+++ b/components/admin/__tests__/third-party-section.test.tsx
@@ -0,0 +1,129 @@
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { listReachableHosts } from '@/lib/admin/reachable-hosts';
+import { OUTGOING_HOSTS } from '@/lib/admin/outgoing-hosts-registry';
+import type { OrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
+
+const apiFetch = vi.hoisted(() => vi.fn());
+vi.mock('@/lib/browser-navigation', async (orig) => ({
+ ...(await orig()),
+ apiFetch: (...args: unknown[]) => apiFetch(...args),
+}));
+
+const { ThirdPartySection } = await import('@/components/admin/org/sections/third-party-section');
+
+const setMessage = vi.fn();
+const draft = { setMessage } as unknown as OrgPolicyDraft;
+
+function state(overrides: Record = {}) {
+ return {
+ noThirdPartyTraffic: false,
+ source: 'default',
+ readOnly: false,
+ envLocked: false,
+ builtIn: OUTGOING_HOSTS,
+ reachable: [{ kind: 'JMAP server', host: 'mail.corp.example', setIn: 'JMAP_SERVER_URL', side: 'both' }],
+ networkPlugins: [],
+ ...overrides,
+ };
+}
+
+function switchOf() {
+ return within(screen.getByTestId('third-party-switch')).getByRole('switch');
+}
+
+beforeEach(() => {
+ apiFetch.mockReset();
+ setMessage.mockReset();
+});
+
+describe('ThirdPartySection', () => {
+ it('saves the switch as config through /api/admin/config', async () => {
+ let current = state();
+ apiFetch.mockImplementation(async (url: string, init?: RequestInit) => {
+ if (url === '/api/admin/config' && init?.method === 'PATCH') {
+ current = state({ noThirdPartyTraffic: true, source: 'admin' });
+ return Response.json({ ok: true });
+ }
+ return Response.json(current);
+ });
+ render( );
+ await waitFor(() => expect(switchOf()).toHaveAttribute('aria-checked', 'false'));
+ expect(screen.getByTestId('third-party-scope-note').textContent).toMatch(/whole server, not per domain or group/);
+ expect(screen.getByTestId('third-party-reachable').textContent).toContain('mail.corp.example');
+
+ await act(async () => { fireEvent.click(switchOf()); });
+ expect(apiFetch).toHaveBeenCalledWith('/api/admin/config', expect.objectContaining({
+ method: 'PATCH', body: JSON.stringify({ noThirdPartyTraffic: true }),
+ }));
+ await waitFor(() => expect(switchOf()).toHaveAttribute('aria-checked', 'true'));
+ expect(screen.getByText('config.json')).toBeInTheDocument();
+ // No relay of their own: push needs one now.
+ expect(screen.getByText(/Push notifications now need a relay you run yourself/)).toBeInTheDocument();
+ });
+
+ it('is read-only when the environment sets it', async () => {
+ apiFetch.mockImplementation(async () => Response.json(state({ noThirdPartyTraffic: true, source: 'env', envLocked: true })));
+ render( );
+ await waitFor(() => expect(switchOf()).toBeDisabled());
+ expect(screen.getByText('NO_THIRD_PARTY_TRAFFIC')).toBeInTheDocument();
+ expect(screen.getByText(/Set by NO_THIRD_PARTY_TRAFFIC in the environment/)).toBeInTheDocument();
+ });
+
+ it('is read-only on a read-only config', async () => {
+ apiFetch.mockImplementation(async () => Response.json(state({ readOnly: true })));
+ render( );
+ await waitFor(() => expect(switchOf()).toBeDisabled());
+ expect(screen.getByText(/The configuration is read-only/)).toBeInTheDocument();
+ });
+});
+
+describe('listReachableHosts', () => {
+ it('lists the hosts the deployment configured and leaves the built-in ones out', () => {
+ const config: Record = {
+ jmapServerUrl: 'https://mail.corp.example',
+ jmapServers: [{ id: 'b', label: 'Branch', url: 'https://jmap.branch.example', oauth: { issuerUrl: 'https://sso.corp.example' } }],
+ oauthEnabled: false,
+ oauthIssuerUrl: 'https://ignored.example',
+ extensionDirectoryUrl: 'https://extensions.bulwarkmail.org',
+ wopiClientUrl: 'https://office.corp.example',
+ loginLogoLightUrl: '/branding/Bulwark_Logo_Color.svg',
+ appLogoLightUrl: 'https://cdn.corp.example/logo.svg',
+ };
+ const hosts = listReachableHosts({
+ get: (key) => config[key],
+ policy: {
+ pushRelays: [{ label: 'Own', url: 'https://push.corp.example' }, { label: 'Built-in', url: 'https://notifications.relay.bulwarkmail.org' }],
+ pushRelayUrl: '',
+ defaultSidebarApps: [{ id: 'w', name: 'Wiki', url: 'https://wiki.corp.example', icon: 'Globe', openMode: 'inline', showOnMobile: true }],
+ },
+ env: { LIBRETRANSLATE_URL: 'https://translate.corp.example' },
+ });
+ expect(hosts.map((h) => `${h.kind}: ${h.host}`)).toEqual([
+ 'JMAP server: mail.corp.example',
+ 'JMAP server: jmap.branch.example',
+ 'OAuth issuer: sso.corp.example',
+ 'Push relay: push.corp.example',
+ 'Sidebar app: wiki.corp.example',
+ 'Document editor (WOPI): office.corp.example',
+ 'Translation (LibreTranslate): translate.corp.example',
+ 'Branding image: cdn.corp.example',
+ 'Sidebar apps users add: any address your users enter',
+ 'Calendar subscriptions: any address your users enter',
+ ]);
+ });
+
+ it('names the relay browsers were built with, flags a different one in the environment, and lists what users enter', () => {
+ const hosts = listReachableHosts({
+ get: (key) => ({ allowCustomJmapEndpoint: true } as Record)[key],
+ policy: { pushRelays: [], pushRelayUrl: '', defaultSidebarApps: [], features: { calendarEnabled: false, sidebarAppsEnabled: false } },
+ env: { NEXT_PUBLIC_PUSH_RELAY_URL: 'https://push.runtime.example' },
+ buildRelayUrl: 'https://push.build.example',
+ });
+ expect(hosts.map((h) => [h.host, h.setIn, !!h.note])).toEqual([
+ ['push.build.example', 'NEXT_PUBLIC_PUSH_RELAY_URL (build)', false],
+ ['push.runtime.example', 'NEXT_PUBLIC_PUSH_RELAY_URL (environment)', true],
+ ['any address your users enter', 'ALLOW_CUSTOM_JMAP_ENDPOINT', true],
+ ]);
+ });
+});
diff --git a/components/admin/admin-shell.tsx b/components/admin/admin-shell.tsx
index e0f6f0286..eac06295f 100644
--- a/components/admin/admin-shell.tsx
+++ b/components/admin/admin-shell.tsx
@@ -7,17 +7,22 @@ import {
ArrowLeft,
ArrowUpCircle,
Activity,
+ Building2,
ChevronRight,
KeyRound,
LayoutDashboard,
+ LayoutTemplate,
LogOut,
Palette,
+ PenLine,
Puzzle,
Scale,
ScrollText,
Search,
Server,
+ Share2,
Shield,
+ ShieldCheck,
Store,
SwatchBook,
X,
@@ -43,6 +48,11 @@ const PAGE_ICONS: Record = {
auth: Shield,
branding: Palette,
policy: Scale,
+ organisation: Building2,
+ 'org-signatures': PenLine,
+ 'org-templates': LayoutTemplate,
+ 'org-security': ShieldCheck,
+ 'org-sharing': Share2,
plugins: Puzzle,
themes: SwatchBook,
marketplace: Store,
@@ -53,6 +63,7 @@ const PAGE_ICONS: Record = {
const GROUP_LABELS: Record = {
top: null,
+ organisation: 'Organisation',
extensions: 'Extensions',
system: 'System',
};
@@ -159,7 +170,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
}, [trimmed]);
const groups = useMemo(() => (
- (['top', 'extensions', 'system'] as const)
+ (['top', 'organisation', 'extensions', 'system'] as const)
.map((group) => ({ group, label: GROUP_LABELS[group], items: matches.filter((m) => m.page.group === group) }))
.filter((g) => g.items.length > 0)
), [matches]);
diff --git a/components/admin/org/__tests__/external-mail-section.test.tsx b/components/admin/org/__tests__/external-mail-section.test.tsx
new file mode 100644
index 000000000..29a1ff1b3
--- /dev/null
+++ b/components/admin/org/__tests__/external-mail-section.test.tsx
@@ -0,0 +1,128 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+
+// Organisation > Security > External mail: the switches and the domain list
+// go into the draft at the chosen scope, and the External Mail Warning
+// plugin's safe domains can be copied in as an unsaved change.
+
+const puts: Array> = [];
+let stored: Record = {};
+let plugins: Array> = [];
+let pluginConfig: Record = {};
+vi.mock('@/lib/browser-navigation', () => ({
+ apiFetch: vi.fn(async (url: string, init?: RequestInit) => {
+ if (url === '/api/admin/plugins') return new Response(JSON.stringify(plugins));
+ if (url === '/api/admin/plugins/external-mail-warning/config') return new Response(JSON.stringify(pluginConfig));
+ 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 { ExternalMailSection } from '../sections/external-mail-section';
+import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft';
+
+const POLICY = {
+ restrictions: {}, features: {}, defaults: {}, themePolicy: {},
+ forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
+ organisation: { externalMail: { domains: ['partner.com'] } },
+ scopes: [{ id: 'two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: {} }],
+};
+
+let latest: ReturnType | null = null;
+function Harness() {
+ const draft = useOrgPolicyDraft('test');
+ latest = draft;
+ if (draft.loading) return null;
+ return ;
+}
+
+async function renderSection() {
+ render( );
+ await waitFor(() => expect(screen.getByTestId('org-external-sender-label')).toBeInTheDocument());
+}
+
+beforeEach(() => {
+ puts.length = 0;
+ stored = JSON.parse(JSON.stringify(POLICY));
+ plugins = [];
+ pluginConfig = {};
+ latest = null;
+ useOrgScopeStore.setState({ scopeId: EVERYONE });
+});
+
+describe('External mail section', () => {
+ it('starts with both switches off and saves what the admin turns on', async () => {
+ await renderSection();
+ expect(screen.getByTestId('org-external-sender-label').getAttribute('aria-checked')).toBe('false');
+ expect(screen.getByTestId('org-external-recipient-warning').getAttribute('aria-checked')).toBe('false');
+ fireEvent.click(screen.getByTestId('org-external-sender-label'));
+ fireEvent.click(screen.getByTestId('org-external-recipient-warning'));
+ await act(async () => { await latest!.save(); });
+ expect((puts[0].organisation as { externalMail: unknown }).externalMail).toMatchObject({ senderLabel: true, recipientWarning: true, domains: ['partner.com'] });
+ });
+
+ it('writes a scope override only into that scope', async () => {
+ await renderSection();
+ act(() => latest!.setScopeId('two'));
+ fireEvent.click(screen.getByTestId('org-external-sender-label'));
+ expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ externalMail: { senderLabel: true } });
+ expect(latest!.policy?.organisation?.externalMail.senderLabel).toBe(false);
+ });
+
+ it('takes the typed domain list apart when the field is left', async () => {
+ await renderSection();
+ const field = screen.getByTestId('org-external-domains');
+ fireEvent.change(field, { target: { value: 'partner.com, Sister.example\n@third.test' } });
+ fireEvent.blur(field);
+ expect(latest!.orgValue('externalMail', 'domains').value).toEqual(['partner.com', 'sister.example', 'third.test']);
+ expect(screen.queryByTestId('org-external-domains-rejected')).toBeNull();
+ });
+
+ it('stores an IDN in ASCII, shows it in Unicode and names entries that are not domains', async () => {
+ await renderSection();
+ const field = screen.getByTestId('org-external-domains') as HTMLTextAreaElement;
+ fireEvent.change(field, { target: { value: 'bücher.de\nnot_a_domain, x' } });
+ fireEvent.blur(field);
+ expect(latest!.orgValue('externalMail', 'domains').value).toEqual(['xn--bcher-kva.de']);
+ expect(screen.getByTestId('org-external-domains-rejected').textContent).toContain('not_a_domain, x');
+ await waitFor(() => expect(field.value).toBe('bücher.de'), { timeout: 5000 });
+ });
+
+ it('offers no import and no note without the plugin', async () => {
+ await renderSection();
+ fireEvent.click(screen.getByTestId('org-external-sender-label'));
+ expect(screen.queryByTestId('org-external-import-plugin')).toBeNull();
+ expect(screen.queryByTestId('org-external-plugin-note')).toBeNull();
+ });
+
+ it('asks to update an old plugin while the warnings are on and imports its safe domains as a draft change', async () => {
+ plugins = [{ id: 'external-mail-warning', version: '1.5.1' }];
+ pluginConfig = { safeDomains: 'partner.com, acme.example\nsub.acme.example' };
+ await renderSection();
+ await waitFor(() => expect(screen.getByTestId('org-external-import-plugin')).toBeInTheDocument());
+ expect(screen.queryByTestId('org-external-plugin-note')).toBeNull();
+
+ fireEvent.click(screen.getByTestId('org-external-recipient-warning'));
+ expect(screen.getByTestId('org-external-plugin-note').textContent).toContain('Update or remove the External Mail Warning plugin');
+
+ await act(async () => { fireEvent.click(screen.getByTestId('org-external-import-plugin')); });
+ await waitFor(() => expect(latest!.orgValue('externalMail', 'domains').value).toEqual(['partner.com', 'acme.example', 'sub.acme.example']));
+ expect(latest!.dirty).toBe(true);
+ expect(puts).toHaveLength(0);
+ });
+
+ it('only suggests removing a plugin that steps back by itself', async () => {
+ plugins = [{ id: 'external-mail-warning', version: '1.6.0' }];
+ await renderSection();
+ await waitFor(() => expect(screen.getByTestId('org-external-import-plugin')).toBeInTheDocument());
+ fireEvent.click(screen.getByTestId('org-external-sender-label'));
+ expect(screen.getByTestId('org-external-plugin-note').textContent).toContain('You can remove the External Mail Warning plugin');
+ });
+});
diff --git a/components/admin/org/__tests__/recovery-section.test.tsx b/components/admin/org/__tests__/recovery-section.test.tsx
new file mode 100644
index 000000000..afc867313
--- /dev/null
+++ b/components/admin/org/__tests__/recovery-section.test.tsx
@@ -0,0 +1,86 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+
+// Organisation > Sharing > Deleted items: the days limit per scope, the
+// deletedItemsRecovery hide switch, and what Stalwart needs for it to work.
+
+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 { RecoverySection } from '../sections/recovery-section';
+import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft';
+
+const POLICY = {
+ restrictions: {}, features: {}, defaults: {}, themePolicy: {},
+ forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
+ organisation: {},
+ scopes: [{ id: 'two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: {} }],
+};
+
+let latest: ReturnType | null = null;
+function Harness() {
+ const draft = useOrgPolicyDraft('test');
+ latest = draft;
+ if (draft.loading) return null;
+ return ;
+}
+
+async function renderSection() {
+ render( );
+ await waitFor(() => expect(screen.getByTestId('org-recovery-requirements')).toBeInTheDocument());
+}
+
+beforeEach(() => {
+ puts.length = 0;
+ stored = JSON.parse(JSON.stringify(POLICY));
+ latest = null;
+ useOrgScopeStore.setState({ scopeId: EVERYONE });
+});
+
+describe('Recover deleted items section', () => {
+ it('says it needs the Enterprise license and archiving in Stalwart, with the docs', async () => {
+ await renderSection();
+ const note = screen.getByTestId('org-recovery-requirements');
+ expect(note.textContent).toContain('Stalwart Enterprise license');
+ expect(note.textContent).toContain('archiveDeletedItemsFor');
+ expect(screen.getByRole('link', { name: 'How to set it up' }).getAttribute('href')).toBe('https://bulwarkmail.org/docs/guides/organisation/recover-deleted-items');
+ });
+
+ it('saves the days limit, with empty meaning everything the server keeps', async () => {
+ await renderSection();
+ const field = screen.getByLabelText('Show deleted mail of the last (days)') as HTMLInputElement;
+ expect(field.value).toBe('');
+ fireEvent.change(field, { target: { value: '14' } });
+ await act(async () => { await latest!.save(); });
+ expect((puts[0].organisation as { recovery: unknown }).recovery).toEqual({ days: 14 });
+ fireEvent.change(field, { target: { value: '' } });
+ expect(latest!.orgValue('recovery', 'days').value).toBe(0);
+ });
+
+ it('writes a scope override only into that scope', async () => {
+ await renderSection();
+ act(() => latest!.setScopeId('two'));
+ fireEvent.change(screen.getByLabelText('Show deleted mail of the last (days)'), { target: { value: '7' } });
+ expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ recovery: { days: 7 } });
+ expect(latest!.policy?.organisation?.recovery.days).toBe(0);
+ });
+
+ it('hides the feature for users through deletedItemsRecovery', async () => {
+ await renderSection();
+ fireEvent.click(screen.getByRole('checkbox', { name: 'Hide' }));
+ expect(latest!.restriction('deletedItemsRecovery').value).toEqual({ hidden: true });
+ });
+});
diff --git a/components/admin/org/__tests__/shared-tags-section.test.tsx b/components/admin/org/__tests__/shared-tags-section.test.tsx
new file mode 100644
index 000000000..621994ded
--- /dev/null
+++ b/components/admin/org/__tests__/shared-tags-section.test.tsx
@@ -0,0 +1,78 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+
+// Organisation > Sharing > Shared tags: one sentence and the lock for the
+// `sharedTags` restriction, written at the scope chosen in the scope bar.
+
+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 { SharedTagsSection } from '../sections/shared-tags-section';
+import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft';
+import { RESTRICTION_KEYS } from '@/lib/admin/restriction-keys';
+import { resolvePolicy } from '@/lib/admin/policy-scopes';
+
+const POLICY = {
+ restrictions: {}, features: {}, defaults: {}, themePolicy: {},
+ forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
+ scopes: [{ id: 'leads', label: 'Leads', match: { groups: ['leads@example.org'] }, policy: {} }],
+};
+
+let latest: ReturnType | null = null;
+function Harness() {
+ const draft = useOrgPolicyDraft('test');
+ latest = draft;
+ if (draft.loading) return null;
+ return ;
+}
+
+beforeEach(() => {
+ puts.length = 0;
+ stored = JSON.parse(JSON.stringify(POLICY));
+ latest = null;
+ useOrgScopeStore.setState({ scopeId: EVERYONE });
+});
+
+describe('Shared tags section', () => {
+ it('registers sharedTags on the Sharing page, lock only', () => {
+ expect(RESTRICTION_KEYS.find((def) => def.key === 'sharedTags')).toMatchObject({ page: 'org-sharing', modes: ['locked'] });
+ });
+
+ it('locks shared tags for everyone and unlocks them in a group scope', async () => {
+ render( );
+ const section = await screen.findByTestId('org-shared-tags-section');
+ expect(within(section).queryByText('Hide')).not.toBeInTheDocument();
+
+ fireEvent.click(within(section).getByRole('checkbox'));
+ expect(latest!.policy?.restrictions.sharedTags).toEqual({ locked: true });
+
+ act(() => latest!.setScopeId('leads'));
+ await waitFor(() => expect(within(screen.getByTestId('org-shared-tags-section')).getByRole('checkbox')).toBeChecked());
+ fireEvent.click(within(screen.getByTestId('org-shared-tags-section')).getByRole('checkbox'));
+ // In a scope an empty restriction is a real override: it unlocks.
+ expect(latest!.policy?.scopes?.[0].policy.restrictions?.sharedTags).toEqual({});
+ const lead = resolvePolicy(latest!.policy!, { email: 'anna@example.org', groups: ['leads@example.org'] }).policy;
+ const member = resolvePolicy(latest!.policy!, { email: 'bob@example.org', groups: ['team@example.org'] }).policy;
+ expect(lead.restrictions.sharedTags?.locked).not.toBe(true);
+ expect(member.restrictions.sharedTags?.locked).toBe(true);
+
+ await act(async () => { await latest!.save(); });
+ expect(puts[0]).toMatchObject({
+ restrictions: { sharedTags: { locked: true } },
+ scopes: [{ id: 'leads', policy: { restrictions: { sharedTags: {} } } }],
+ });
+ });
+});
diff --git a/components/admin/org/org-page.tsx b/components/admin/org/org-page.tsx
new file mode 100644
index 000000000..fbb0cf8ac
--- /dev/null
+++ b/components/admin/org/org-page.tsx
@@ -0,0 +1,178 @@
+'use client';
+
+import type { ReactNode } from 'react';
+import { Loader2, Lock, RotateCcw } from '@/components/icons';
+import { AdminPageHeader } from '@/components/admin/page-header';
+import { AdminSaveBar } from '@/components/admin/save-bar';
+import { AdminLink } from '@/components/admin/admin-link';
+import { SectionNav, SettingItem } from '@/components/settings/settings-section';
+import { adminPageDef, type AdminPageId } from '@/lib/admin/admin-nav';
+import type { RestrictionKeyDef } from '@/lib/admin/restriction-keys';
+import type { PolicyScope } from '@/lib/admin/policy-scopes';
+import { cn } from '@/lib/utils';
+import { EVERYONE, type OrgPolicyDraft } from './use-org-policy-draft';
+
+export const chipClass = (selected: boolean) => cn(
+ 'h-8 px-3 rounded-md text-sm font-medium transition-colors max-w-full truncate',
+ selected ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground hover:bg-accent hover:text-accent-foreground',
+);
+
+/** "example.com, sales@example.com": what a scope matches, for labels. */
+export function scopeMatchSummary(scope: PolicyScope): string {
+ const parts = [...(scope.match.domains ?? []), ...(scope.match.groups ?? [])];
+ return parts.length > 0 ? parts.join(', ') : 'matches nobody yet';
+}
+
+/**
+ * Which part of the organisation an Organisation page edits: Everyone, or
+ * one domain or group scope. Chips on wide screens; they wrap at phone width.
+ */
+export function ScopeBar({ draft }: { draft: OrgPolicyDraft }) {
+ return (
+
+
+
Applies to
+
draft.setScopeId(EVERYONE)}
+ >
+ Everyone
+
+ {draft.scopes.map((scope) => (
+
draft.setScopeId(scope.id)}
+ >
+ {scope.label}
+
+ ))}
+
+ Manage domains and groups
+
+
+ {draft.scope && (
+
+ Changes here apply to {scopeMatchSummary(draft.scope)} only. Settings this scope doesn't change come from Everyone.
+
+ )}
+
+ );
+}
+
+/** "From Everyone: 15 minutes" with a reset, under a field a scope inherits or overrides. */
+export function InheritNote({ draft, own, everyone, onReset }: {
+ draft: OrgPolicyDraft;
+ own: boolean;
+ everyone: ReactNode;
+ onReset: () => void;
+}) {
+ if (!draft.scope) return null;
+ return (
+
+ {own ? Everyone: {everyone} : From Everyone: {everyone} }
+ {own && (
+
+ Use Everyone's value
+
+ )}
+
+ );
+}
+
+/**
+ * Lock, hide and allowed values of one restriction key, at the selected
+ * scope. Every user-facing option of the Organisation features is
+ * restricted through this, never through a flag of its own.
+ */
+export function RestrictionRow({ draft, def }: { draft: OrgPolicyDraft; def: RestrictionKeyDef }) {
+ const { value, own, everyone } = draft.restriction(def.key);
+ const current = value ?? {};
+ const modes = def.modes ?? ['locked', 'hidden'];
+ const set = (patch: Partial) => draft.setRestriction(def.key, { ...current, ...patch });
+ const describe = (r: typeof current | undefined) =>
+ r?.hidden ? 'hidden' : r?.locked ? 'locked' : r?.allowedValues?.length ? `limited to ${r.allowedValues.join(', ')}` : 'users choose';
+ return (
+
+
+
+ );
+}
+
+/**
+ * The frame of an Organisation page: title and the one sentence on what it
+ * does for users, the scope bar, messages, and the save bar.
+ */
+export function OrgPage({ pageId, draft, children, scoped = true }: {
+ pageId: AdminPageId;
+ draft: OrgPolicyDraft;
+ children: ReactNode;
+ /** Pages whose settings can be set per scope show the scope bar. */
+ scoped?: boolean;
+}) {
+ const page = adminPageDef(pageId);
+ return (
+
+
+ {draft.loading ? (
+
+ Loading
+
+ ) : (
+
+ {scoped && }
+
+ {children}
+
+ )}
+
void draft.save()}
+ onDiscard={draft.discard}
+ saveLabel="Save"
+ note="Users get the change the next time Bulwark loads"
+ />
+
+ );
+}
diff --git a/components/admin/org/policy-file-section.tsx b/components/admin/org/policy-file-section.tsx
new file mode 100644
index 000000000..bbb7e737a
--- /dev/null
+++ b/components/admin/org/policy-file-section.tsx
@@ -0,0 +1,144 @@
+'use client';
+
+import { useRef, useState } from 'react';
+import { Download, Loader2, Upload } from '@/components/icons';
+import { SettingsSection } from '@/components/settings/settings-section';
+import { Button } from '@/components/ui/button';
+import { apiFetch } from '@/lib/browser-navigation';
+import type { KeyChanges, PolicyChangeSummary } from '@/lib/admin/policy-diff';
+import type { OrgPolicyDraft } from './use-org-policy-draft';
+
+function describe(label: string, changes: KeyChanges): string | null {
+ const parts = [
+ changes.added.length ? `${changes.added.length} added` : null,
+ changes.changed.length ? `${changes.changed.length} changed` : null,
+ changes.removed.length ? `${changes.removed.length} removed` : null,
+ ].filter(Boolean);
+ return parts.length ? `${label}: ${parts.join(', ')}` : null;
+}
+
+function summaryLines(summary: PolicyChangeSummary): string[] {
+ return [
+ describe('Domains and groups', summary.scopes),
+ describe('Organisation settings', summary.organisation),
+ describe('Locked and hidden settings', summary.restrictions),
+ describe('Features', summary.features),
+ describe('Defaults for new users', summary.defaults),
+ summary.other.length ? `Also: ${summary.other.join(', ')}` : null,
+ ].filter((line): line is string => !!line);
+}
+
+/**
+ * Export and import of the whole user policy as policy.json, so the policy
+ * can live in git, move to another instance or go next to a Lite build.
+ * An import shows what it changes before it replaces anything.
+ */
+export function PolicyFileSection({ draft }: { draft: OrgPolicyDraft }) {
+ const input = useRef(null);
+ const [pending, setPending] = useState<{ name: string; text: string; summary: PolicyChangeSummary } | null>(null);
+ const [busy, setBusy] = useState(false);
+
+ async function exportFile() {
+ const res = await apiFetch('/api/admin/policy/file');
+ if (!res.ok) {
+ draft.setMessage({ type: 'error', text: 'Could not export the policy' });
+ return;
+ }
+ const blob = await res.blob();
+ const name = /filename="([^"]+)"/.exec(res.headers.get('Content-Disposition') ?? '')?.[1] ?? 'bulwark-policy.json';
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = name;
+ link.click();
+ URL.revokeObjectURL(url);
+ }
+
+ async function pick(file: File) {
+ setBusy(true);
+ try {
+ const text = await file.text();
+ const res = await apiFetch('/api/admin/policy/file?preview=1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: text });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ draft.setMessage({ type: 'error', text: data.error || 'Could not read the file' });
+ return;
+ }
+ setPending({ name: file.name, text, summary: data.summary });
+ } finally {
+ setBusy(false);
+ if (input.current) input.current.value = '';
+ }
+ }
+
+ async function apply() {
+ if (!pending) return;
+ setBusy(true);
+ try {
+ const res = await apiFetch('/api/admin/policy/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: pending.text });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ draft.setMessage({ type: 'error', text: data.error || 'Could not import the file' });
+ return;
+ }
+ setPending(null);
+ await draft.reload();
+ draft.setMessage({ type: 'success', text: `Imported ${pending.name}.` });
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+ void exportFile()}>
+ Export policy.json
+
+ input.current?.click()}
+ >
+ {busy ? : } Import a file
+
+ { const file = e.target.files?.[0]; if (file) void pick(file); }}
+ />
+
+ {draft.dirty && Save or discard your changes first.
}
+ {pending && (
+
+
Importing {pending.name}
+ {pending.summary.unchanged ? (
+
It matches the current policy; nothing would change.
+ ) : (
+
+ {summaryLines(pending.summary).map((line) => {line} )}
+
+ )}
+
+ void apply()}>
+ Replace the current policy
+
+ setPending(null)}>
+ Cancel
+
+
+
+ )}
+
+ );
+}
diff --git a/components/admin/org/sections/delegates-section.tsx b/components/admin/org/sections/delegates-section.tsx
new file mode 100644
index 000000000..1fecb99c5
--- /dev/null
+++ b/components/admin/org/sections/delegates-section.tsx
@@ -0,0 +1,34 @@
+'use client';
+
+import { SettingsSection } from '@/components/settings/settings-section';
+import { RESTRICTION_KEYS } from '@/lib/admin/restriction-keys';
+import { RestrictionRow } from '../org-page';
+import type { OrgPolicyDraft } from '../use-org-policy-draft';
+
+const DELEGATION = RESTRICTION_KEYS.find((def) => def.key === 'delegation')!;
+const SHARING_RIGHTS = RESTRICTION_KEYS.find((def) => def.key === 'sharingRights')!;
+
+/** Organisation > Sharing > Delegates: whether users may delegate, and which rights any share dialog offers. */
+export function DelegatesSection({ draft }: { draft: OrgPolicyDraft }) {
+ const rights = draft.restriction(SHARING_RIGHTS.key).value;
+ const noneTicked = Array.isArray(rights?.allowedValues) && rights.allowedValues.length === 0;
+ return (
+
+
+
+ {noneTicked && (
+
+ With neither ticked nothing is limited, so users can give both. Tick "read" to allow read access only.
+
+ )}
+
+ Both limit what Bulwark offers. Stalwart doesn't enforce them, so users can still share through other clients. Shared
+ mailboxes of groups are yours to manage in Stalwart.
+
+
+ );
+}
diff --git a/components/admin/org/sections/external-mail-section.tsx b/components/admin/org/sections/external-mail-section.tsx
new file mode 100644
index 000000000..e77c38efc
--- /dev/null
+++ b/components/admin/org/sections/external-mail-section.tsx
@@ -0,0 +1,184 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { Download, Info } from '@/components/icons';
+import { Button } from '@/components/ui/button';
+import { SettingItem, SettingsSection, ToggleSwitch } from '@/components/settings/settings-section';
+import { AdminLink } from '@/components/admin/admin-link';
+import { parseDomainList } from '@/lib/admin/organisation-policy';
+import { toUnicodeDomain } from '@/lib/idn';
+import { apiFetch } from '@/lib/browser-navigation';
+import { EXTERNAL_MAIL_WARNING_PLUGIN_ID } from '@/lib/plugin-sandbox/host-features';
+import { InheritNote } from '../org-page';
+import type { OrgPolicyDraft } from '../use-org-policy-draft';
+
+/** The first release of the plugin core took over from that steps back on its own. */
+export const PLUGIN_STEPS_BACK_FROM = '1.6.0';
+
+function older(version: string, than: string): boolean {
+ const a = version.split(/[-+]/)[0].split('.').map(Number);
+ const b = than.split(/[-+]/)[0].split('.').map(Number);
+ for (let i = 0; i < 3; i++) if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) < (b[i] || 0);
+ return false;
+}
+
+type InstalledPlugin = { version: string };
+
+/** The External Mail Warning plugin when the admin installed it, else null. */
+function useInstalledPlugin(): InstalledPlugin | null {
+ const [plugin, setPlugin] = useState(null);
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ try {
+ const res = await apiFetch('/api/admin/plugins');
+ const list = res.ok ? ((await res.json()) as Array<{ id: string; version?: string }>) : [];
+ const found = Array.isArray(list) ? list.find((p) => p.id === EXTERNAL_MAIL_WARNING_PLUGIN_ID) : undefined;
+ if (!cancelled) setPlugin(found ? { version: found.version ?? '' } : null);
+ } catch {
+ if (!cancelled) setPlugin(null);
+ }
+ })();
+ return () => { cancelled = true; };
+ }, []);
+ return plugin;
+}
+
+const onOff = (v: boolean) => (v ? 'on' : 'off');
+
+/**
+ * Organisation > Security > External mail: the "External" sender label,
+ * the confirm before sending outside, which domains are internal, and the
+ * hand-over from the External Mail Warning plugin.
+ */
+export function ExternalMailSection({ draft }: { draft: OrgPolicyDraft }) {
+ const senderLabel = draft.orgValue('externalMail', 'senderLabel');
+ const recipientWarning = draft.orgValue('externalMail', 'recipientWarning');
+ const domains = draft.orgValue('externalMail', 'domains');
+ const trustContacts = draft.orgValue('externalMail', 'trustContacts');
+ const plugin = useInstalledPlugin();
+ const anyOn = senderLabel.value || recipientWarning.value;
+
+ // The list is typed as text and taken apart when the field is left, so a
+ // half-typed domain isn't thrown away on every key. It is stored in ASCII
+ // (xn--) form and shown in Unicode; entries that aren't domains are named.
+ const stored = domains.value.join('\n');
+ const joined = domains.value.map(toUnicodeDomain).join('\n');
+ const [text, setText] = useState(joined);
+ const [rejected, setRejected] = useState([]);
+ useEffect(() => { setText(joined); }, [joined]);
+ const commitDomains = (raw: string) => {
+ const parsed = parseDomainList(raw) ?? { domains: [], rejected: [] };
+ setRejected(parsed.rejected);
+ if (parsed.domains.join('\n') !== stored) draft.setOrgValue('externalMail', 'domains', parsed.domains);
+ else setText(joined);
+ };
+
+ const [importing, setImporting] = useState(false);
+ const importFromPlugin = async () => {
+ setImporting(true);
+ try {
+ const res = await apiFetch(`/api/admin/plugins/${EXTERNAL_MAIL_WARNING_PLUGIN_ID}/config`);
+ if (!res.ok) throw new Error(String(res.status));
+ const config = (await res.json()) as Record;
+ const parsed = parseDomainList(config.safeDomains) ?? { domains: [], rejected: [] };
+ const fromPlugin = parsed.domains;
+ setRejected(parsed.rejected);
+ const added = fromPlugin.filter((d) => !domains.value.includes(d));
+ if (added.length > 0) draft.setOrgValue('externalMail', 'domains', [...domains.value, ...added]);
+ draft.setMessage({
+ type: 'success',
+ text: added.length > 0
+ ? `Added ${added.length} ${added.length === 1 ? 'domain' : 'domains'} from the plugin. Save to apply them.`
+ : fromPlugin.length > 0 ? 'The list already has every domain the plugin had.' : 'The plugin has no safe domains set.',
+ });
+ } catch {
+ draft.setMessage({ type: 'error', text: 'Could not read the plugin settings' });
+ } finally {
+ setImporting(false);
+ }
+ };
+
+ const outdated = !!plugin && older(plugin.version, PLUGIN_STEPS_BACK_FROM);
+
+ return (
+
+
+
+ draft.setOrgValue('externalMail', 'senderLabel', v)} testId="org-external-sender-label" />
+ draft.clearOrgValue('externalMail', 'senderLabel')} />
+
+
+
+
+
+ draft.setOrgValue('externalMail', 'recipientWarning', v)} testId="org-external-recipient-warning" />
+ draft.clearOrgValue('externalMail', 'recipientWarning')} />
+
+
+
+
+
+ draft.setOrgValue('externalMail', 'trustContacts', v)} testId="org-external-trust-contacts" />
+ draft.clearOrgValue('externalMail', 'trustContacts')} />
+
+
+
+
+
More organisation domains
+
+ Your users' own domains always count as internal. Add partner or sister-company domains here, one per line or separated by commas. A domain covers its subdomains: example.com also covers eu.example.com.
+
+
+
+ {plugin && anyOn && (
+
+
+
+
+ {outdated ? 'Update or remove the External Mail Warning plugin' : 'You can remove the External Mail Warning plugin'}
+
+
+ {outdated
+ ? `Version ${plugin.version || 'unknown'} is installed. For each switch that is on, Bulwark skips the matching part of the plugin (its send confirm, or its banner and new-mail toast) so nobody is warned twice; version ${PLUGIN_STEPS_BACK_FROM} and later step back by themselves.`
+ : 'It turns off its own send confirm, banner and toast for each switch that is on here.'}{' '}
+ “Import from the plugin” copies its safe domains into the list above.{' '}
+ Open the plugin
+
+
+
+ )}
+
+ );
+}
diff --git a/components/admin/org/sections/recovery-section.tsx b/components/admin/org/sections/recovery-section.tsx
new file mode 100644
index 000000000..d20322189
--- /dev/null
+++ b/components/admin/org/sections/recovery-section.tsx
@@ -0,0 +1,67 @@
+'use client';
+
+import { SettingItem, SettingsSection } from '@/components/settings/settings-section';
+import { RESTRICTION_KEYS } from '@/lib/admin/restriction-keys';
+import { InheritNote, RestrictionRow } from '../org-page';
+import type { OrgPolicyDraft } from '../use-org-policy-draft';
+
+const RECOVERY = RESTRICTION_KEYS.find((def) => def.key === 'deletedItemsRecovery')!;
+const MAX_DAYS = 3650;
+const DOCS_URL = 'https://bulwarkmail.org/docs/guides/organisation/recover-deleted-items';
+
+function describeDays(days: number): string {
+ return days > 0 ? `${days} days` : 'everything the server keeps';
+}
+
+/** Organisation > Sharing > Deleted items: how far back users can recover deleted mail, and whether they can. */
+export function RecoverySection({ draft }: { draft: OrgPolicyDraft }) {
+ const { value, own, everyone } = draft.orgValue('recovery', 'days');
+ return (
+
+
+ This needs a Stalwart Enterprise license and archiving turned on in Stalwart (Data Retention > Archiving, the
+ archiveDeletedItemsFor setting). Without both, Bulwark asks the server, gets a refusal, and shows users nothing.{' '}
+
+ How to set it up
+
+
+
+
+
+
+ 0 ? value : ''}
+ onChange={(e) => {
+ const raw = e.target.value.trim();
+ const parsed = raw === '' ? 0 : Math.round(Number(raw));
+ if (!Number.isFinite(parsed)) return;
+ draft.setOrgValue('recovery', 'days', Math.min(MAX_DAYS, Math.max(0, parsed)));
+ }}
+ className="h-9 w-28 rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
+ />
+ days
+
+
Empty shows everything the server keeps.
+
draft.clearOrgValue('recovery', 'days')} />
+
+
+
+
+
+ );
+}
diff --git a/components/admin/org/sections/sessions-section.tsx b/components/admin/org/sections/sessions-section.tsx
new file mode 100644
index 000000000..cfcc0987b
--- /dev/null
+++ b/components/admin/org/sections/sessions-section.tsx
@@ -0,0 +1,91 @@
+'use client';
+
+import { SettingItem, SettingsSection } from '@/components/settings/settings-section';
+import { InheritNote } from '../org-page';
+import type { OrgPolicyDraft } from '../use-org-policy-draft';
+
+interface SessionField {
+ key: 'idleMinutes' | 'maxHours' | 'warnSeconds';
+ label: string;
+ description: string;
+ unit: string;
+ max: number;
+ /** What 0 means. */
+ zero: string;
+}
+
+const FIELDS: SessionField[] = [
+ {
+ key: 'idleMinutes',
+ label: 'Sign out after inactivity',
+ description: 'Signs users out of every account in the browser after this many minutes without activity in any Bulwark tab. Open messages are saved as drafts first.',
+ unit: 'minutes',
+ max: 7 * 24 * 60,
+ zero: 'Off',
+ },
+ {
+ key: 'maxHours',
+ label: 'Maximum session length',
+ description: 'Signs users out this many hours after they signed in, active or not, also with "Remember me". The server enforces domain values too; group values only apply in the browser.',
+ unit: 'hours',
+ max: 24 * 365,
+ zero: 'Off',
+ },
+ {
+ key: 'warnSeconds',
+ label: 'Warning before signing out',
+ description: 'How long a countdown shows before either limit signs users out. During the inactivity warning users can choose to stay signed in.',
+ unit: 'seconds',
+ max: 600,
+ zero: 'No warning',
+ },
+];
+
+function describe(value: number, field: SessionField): string {
+ return value > 0 ? `${value} ${field.unit}` : field.zero;
+}
+
+/** Organisation > Security > Sessions: the idle timeout and the maximum session length. */
+export function SessionsSection({ draft }: { draft: OrgPolicyDraft }) {
+ return (
+
+ {FIELDS.map((field) => {
+ const { value, own, everyone } = draft.orgValue('session', field.key);
+ const inputId = `org-session-${field.key}`;
+ return (
+
+
+
+ 0 ? value : ''}
+ onChange={(e) => {
+ const raw = e.target.value.trim();
+ const parsed = raw === '' ? 0 : Math.round(Number(raw));
+ if (!Number.isFinite(parsed)) return;
+ draft.setOrgValue('session', field.key, Math.min(field.max, Math.max(0, parsed)));
+ }}
+ className="h-9 w-28 rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
+ />
+ {field.unit}
+
+
draft.clearOrgValue('session', field.key)}
+ />
+
+
+ );
+ })}
+
+ );
+}
diff --git a/components/admin/org/sections/shared-tags-section.tsx b/components/admin/org/sections/shared-tags-section.tsx
new file mode 100644
index 000000000..8b19486e1
--- /dev/null
+++ b/components/admin/org/sections/shared-tags-section.tsx
@@ -0,0 +1,27 @@
+'use client';
+
+import { SettingsSection } from '@/components/settings/settings-section';
+import { restrictionKeysForPage } from '@/lib/admin/restriction-keys';
+import { RestrictionRow } from '../org-page';
+import type { OrgPolicyDraft } from '../use-org-policy-draft';
+
+/**
+ * Organisation feature 6: shared tags in group mailboxes. The tags live in
+ * the group's Inbox on the mail server (Stalwart 1.0 metadata), so the only
+ * thing to set here is who may change them.
+ */
+export function SharedTagsSection({ draft }: { draft: OrgPolicyDraft }) {
+ return (
+
+
+ {restrictionKeysForPage('org-sharing')
+ .filter((def) => def.key === 'sharedTags')
+ .map((def) => )}
+
+
+ );
+}
diff --git a/components/admin/org/sections/signature-section.tsx b/components/admin/org/sections/signature-section.tsx
new file mode 100644
index 000000000..6d58008fe
--- /dev/null
+++ b/components/admin/org/sections/signature-section.tsx
@@ -0,0 +1,311 @@
+'use client';
+
+import { useId, useRef, useState } from 'react';
+import { Check } from '@/components/icons';
+import { SettingsSection } from '@/components/settings/settings-section';
+import { SIGNATURE_FIELDS, type SignatureField } from '@/lib/admin/organisation-policy';
+import { restrictionKeysForPage } from '@/lib/admin/restriction-keys';
+import { sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization';
+import {
+ SIGNATURE_PLACEHOLDERS,
+ buildOrgSignature,
+ signatureFieldsUsed,
+ signatureValuesFor,
+ type SignaturePlaceholder,
+} from '@/lib/org-signature';
+import { getPlainTextSignature } from '@/lib/signature-utils';
+import { useConfig } from '@/hooks/use-config';
+import { cn } from '@/lib/utils';
+import { InheritNote, RestrictionRow } from '../org-page';
+import type { OrgPolicyDraft } from '../use-org-policy-draft';
+
+const textareaClass =
+ 'block w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs 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-sm font-medium text-foreground';
+const hintClass = 'mt-1 text-xs text-muted-foreground';
+
+const PLACEHOLDER_HELP: Record = {
+ name: 'name of the address the mail is sent from',
+ email: 'that address',
+ identity: 'name and address, as in From',
+ company: 'company name from Branding, for the web address Bulwark is opened on',
+ domain: 'domain of the address',
+ title: 'job title the user fills in',
+ department: 'department the user fills in',
+ phone: 'phone number the user fills in',
+ mobile: 'mobile number the user fills in',
+};
+
+const FIELD_LABELS: Record = {
+ title: 'Job title',
+ department: 'Department',
+ phone: 'Phone',
+ mobile: 'Mobile',
+};
+
+const SAMPLE_DETAILS = { title: 'Head of Sales', department: 'Sales', phone: '+49 30 1234567', mobile: '+49 170 1234567' };
+const EMPTY_DETAILS = { title: '', department: '', phone: '', mobile: '' };
+
+const describeText = (value: string) => {
+ const first = value.trim().split('\n')[0] ?? '';
+ return first ? (first.length > 60 ? `${first.slice(0, 60)}…` : first) : 'none';
+};
+
+export interface FocusedTemplate {
+ el: HTMLTextAreaElement;
+ apply: (value: string) => void;
+}
+
+/** A template field (HTML or plain text) at the selected scope. */
+export function TemplateField({ label, hint, value, own, everyone, rows, testId, onChange, onReset, draft, onFocusField }: {
+ label: string;
+ hint: string;
+ value: string;
+ own: boolean;
+ everyone: string;
+ rows: number;
+ testId: string;
+ onChange: (value: string) => void;
+ onReset: () => void;
+ draft: OrgPolicyDraft;
+ onFocusField: (el: HTMLTextAreaElement) => void;
+}) {
+ const id = useId();
+ return (
+
+ );
+}
+
+/** What a template looks like filled in with sample values: the HTML and the plain text. */
+export function TemplatePreview({ html, text, testId }: { html: string; text: string; testId: string }) {
+ const plain = getPlainTextSignature({ textSignature: text, htmlSignature: html });
+ return (
+
+
+
HTML
+
+ {html
+ ?
+ :
{plain ? 'Plain text only' : 'Nothing'} }
+
+
+
+
Plain text{!text && html ? ' (from the HTML)' : ''}
+
{plain || ' '}
+
+
+ );
+}
+
+/** Inserts `{placeholder}` at the caret of the template field last focused. */
+export function insertPlaceholder(target: FocusedTemplate | null, placeholder: SignaturePlaceholder): void {
+ if (!target) return;
+ const { el, apply } = target;
+ const start = el.selectionStart ?? el.value.length;
+ const end = el.selectionEnd ?? start;
+ const token = `{${placeholder}}`;
+ apply(`${el.value.slice(0, start)}${token}${el.value.slice(end)}`);
+ requestAnimationFrame(() => {
+ el.focus();
+ el.setSelectionRange(start + token.length, start + token.length);
+ });
+}
+
+/**
+ * Organisation > Signatures (feature 1): the organisation signature as an
+ * HTML and a plain-text template with placeholders, the user details it
+ * asks for, the `signature` restriction, and the disclaimer. Everything per
+ * scope through the draft hook; stored in policy.json
+ * `organisation.signature` and `organisation.disclaimer`.
+ */
+export function SignatureSection({ draft }: { draft: OrgPolicyDraft }) {
+ const config = useConfig();
+ const focused = useRef(null);
+ const [emptyDetails, setEmptyDetails] = useState(false);
+
+ const html = draft.orgValue('signature', 'html');
+ const text = draft.orgValue('signature', 'text');
+ const fields = draft.orgValue('signature', 'fields');
+ const disclaimerHtml = draft.orgValue('disclaimer', 'html');
+ const disclaimerText = draft.orgValue('disclaimer', 'text');
+
+ // Which details users are asked for follows the templates. It is stored
+ // with them (`fields`), so a hand-written policy.json can say it too.
+ const setTemplate = (key: 'html' | 'text', value: string) => {
+ draft.setOrgValue('signature', key, value);
+ const used = signatureFieldsUsed({ html: key === 'html' ? value : html.value, text: key === 'text' ? value : text.value });
+ if (!fields.own || used.join() !== fields.value.join()) draft.setOrgValue('signature', 'fields', used);
+ };
+ const resetTemplate = (key: 'html' | 'text') => {
+ draft.clearOrgValue('signature', key);
+ const otherOwn = key === 'html' ? text.own : html.own;
+ if (!otherOwn) {
+ draft.clearOrgValue('signature', 'fields');
+ return;
+ }
+ draft.setOrgValue('signature', 'fields', signatureFieldsUsed({
+ html: key === 'html' ? html.everyone : html.value,
+ text: key === 'text' ? text.everyone : text.value,
+ }));
+ };
+
+ const domain = draft.scope?.match.domains?.[0]?.replace(/^\*\./, '') ?? 'example.com';
+ const values = signatureValuesFor(
+ { name: 'Alex Example', email: `alex@${domain}` },
+ emptyDetails ? EMPTY_DETAILS : SAMPLE_DETAILS,
+ config.loginCompanyName || 'Example Ltd',
+ );
+ const sample = buildOrgSignature({ html: html.value, text: text.value }, values);
+ const sampleDisclaimer = buildOrgSignature({ html: disclaimerHtml.value, text: disclaimerText.value }, values);
+ const usedFields = signatureFieldsUsed({ html: html.value, text: text.value });
+
+ return (
+ <>
+
+
+
setTemplate('html', value)}
+ onReset={() => resetTemplate('html')}
+ onFocusField={(el) => { focused.current = { el, apply: (value) => setTemplate('html', value) }; }}
+ />
+ setTemplate('text', value)}
+ onReset={() => resetTemplate('text')}
+ onFocusField={(el) => { focused.current = { el, apply: (value) => setTemplate('text', value) }; }}
+ />
+
+
+
Placeholders
+
Click one to insert it at the cursor of the template you last edited.
+
+ {SIGNATURE_PLACEHOLDERS.map((placeholder) => (
+
+ e.preventDefault()}
+ onClick={() => insertPlaceholder(focused.current, placeholder)}
+ >
+ {`{${placeholder}}`}
+
+ {PLACEHOLDER_HELP[placeholder]}
+
+ ))}
+
+
+
+
+
+
Preview
+
+ setEmptyDetails(e.target.checked)} />
+ For a user who filled in no details
+
+
+ {sample
+ ?
+ :
No organisation signature: users send their own.
}
+
+
+ {restrictionKeysForPage('org-signatures').map((def) => )}
+
+
+
+
+
+ {SIGNATURE_FIELDS.map((field) => {
+ const used = usedFields.includes(field);
+ return (
+
+
+ {FIELD_LABELS[field]}
+
+ {used ? 'asked for' : <>add {`{${field}}`} to ask for it>}
+
+
+ );
+ })}
+
+
+
+
+
+
draft.setOrgValue('disclaimer', 'html', value)}
+ onReset={() => draft.clearOrgValue('disclaimer', 'html')}
+ onFocusField={(el) => { focused.current = { el, apply: (value) => draft.setOrgValue('disclaimer', 'html', value) }; }}
+ />
+ draft.setOrgValue('disclaimer', 'text', value)}
+ onReset={() => draft.clearOrgValue('disclaimer', 'text')}
+ onFocusField={(el) => { focused.current = { el, apply: (value) => draft.setOrgValue('disclaimer', 'text', value) }; }}
+ />
+ {sampleDisclaimer && }
+
+ Bulwark adds the signature and the disclaimer in the browser. Mail sent from other apps over IMAP and SMTP gets neither.
+
+
+
+ >
+ );
+}
diff --git a/components/admin/org/sections/templates-section.tsx b/components/admin/org/sections/templates-section.tsx
new file mode 100644
index 000000000..7356b93bd
--- /dev/null
+++ b/components/admin/org/sections/templates-section.tsx
@@ -0,0 +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';
+
+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 (
+
+
+ setOpen(!open)}
+ aria-expanded={open}
+ className="flex min-w-0 flex-1 items-center gap-2 rounded px-1 py-1 text-start text-sm hover:bg-muted"
+ >
+ {open ? : }
+ {template.name || 'New template'}
+ {template.category && {template.category} }
+
+
+
+
+
+ {!template.name.trim() && (
+ A template without a name is not saved. Give it one.
+ )}
+ {open && (
+
+
+
+ Subject
+ set({ subject: e.target.value })} />
+
+
+
+ set({ isHTML: e.target.checked })} />
+ The text is HTML
+
+
+
Preview
+
+ {template.subject &&
{template.subject}
}
+ {template.body ?
:
No text
}
+
+
+
+ )}
+
+ );
+}
+
+/**
+ * Organisation > Templates (feature 3): the admin's template library
+ * (policy.json `organisation.templates.library`) at the selected scope, and
+ * the restrictions on users' own and team templates. A scope's library adds
+ * to Everyone's by id; the same id replaces Everyone's template for that
+ * scope (mergeOrganisation).
+ */
+export function TemplatesSection({ draft }: { draft: OrgPolicyDraft }) {
+ const library = draft.orgValue('templates', 'library');
+ const inScope = !!draft.scope;
+ const own: OrgTemplate[] = inScope ? (library.own ? library.value : []) : library.value;
+ const everyone = library.everyone;
+ const ownIds = new Set(own.map((t) => t.id));
+
+ const setOwn = (next: OrgTemplate[]) => {
+ if (inScope && next.length === 0) draft.clearOrgValue('templates', 'library');
+ else draft.setOrgValue('templates', 'library', next);
+ };
+ const add = () => setOwn([...own, { id: generateUUID(), name: '', subject: '', body: '', isHTML: false, category: '' }]);
+ const change = (index: number, template: OrgTemplate) => setOwn(own.map((t, i) => (i === index ? template : t)));
+ const remove = (index: number) => setOwn(own.filter((_, i) => i !== index));
+ const replaceInScope = (template: OrgTemplate) => setOwn([...own, { ...template }]);
+
+ const total = new Set([...everyone.map((t) => t.id), ...own.map((t) => t.id)]).size;
+
+ return (
+ <>
+
+
+ {inScope && (
+
+
From Everyone
+ {everyone.length === 0 ? (
+
Everyone's library is empty.
+ ) : (
+
+ {everyone.map((template) => (
+
+ {template.name}
+ {ownIds.has(template.id) ? (
+ Changed for this scope
+ ) : (
+ replaceInScope(template)} className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-2 hover:underline">
+ Change for this scope
+
+ )}
+
+ ))}
+
+ )}
+
For this scope
+
+ Users in this scope get Everyone's templates and these. A template changed here replaces Everyone's for this scope only.
+
+
+ )}
+
+ {own.length === 0 ? (
+
+ {inScope ? 'Nothing added for this scope.' : 'No templates yet. Users only have their own.'}
+
+ ) : (
+
+ {own.map((template, index) => (
+ change(index, t)} onRemove={() => remove(index)} />
+ ))}
+
+ )}
+
+
+
= MAX_LIBRARY} data-testid="org-template-add">
+ Add template
+
+ {total >= MAX_LIBRARY &&
A library holds at most {MAX_LIBRARY} templates. }
+
+
+
+
+
+
+ {restrictionKeysForPage('org-templates').map((def) => )}
+
+
+ >
+ );
+}
diff --git a/components/admin/org/sections/third-party-section.tsx b/components/admin/org/sections/third-party-section.tsx
new file mode 100644
index 000000000..9280d8b48
--- /dev/null
+++ b/components/admin/org/sections/third-party-section.tsx
@@ -0,0 +1,190 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import { Loader2 } from '@/components/icons';
+import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
+import { ConfigNote, RevertButton, SourceBadge } from '@/components/admin/config-items';
+import { AdminLink } from '@/components/admin/admin-link';
+import { apiFetch } from '@/lib/browser-navigation';
+import type { OutgoingHost } from '@/lib/admin/outgoing-hosts-registry';
+import type { ReachableHost } from '@/lib/admin/reachable-hosts';
+import type { OrgPolicyDraft } from '../use-org-policy-draft';
+
+interface OutgoingHostsState {
+ noThirdPartyTraffic: boolean;
+ source: 'env' | 'admin' | 'default';
+ readOnly: boolean;
+ envLocked: boolean;
+ builtIn: OutgoingHost[];
+ reachable: ReachableHost[];
+ networkPlugins: { id: string; name: string }[];
+}
+
+const SIDE_LABEL: Record = {
+ server: 'this server',
+ browser: 'the browser',
+ both: 'server and browser',
+};
+
+/**
+ * Organisation > Security > Third-party traffic (#903). The switch is config,
+ * not policy: it is saved through /api/admin/config like the Server page's
+ * keys, applies to the whole server whatever scope the scope bar shows, and
+ * is read-only when the environment or a read-only config sets it.
+ */
+export function ThirdPartySection({ draft }: { draft: OrgPolicyDraft }) {
+ const [state, setState] = useState(null);
+ const [failed, setFailed] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const { setMessage } = draft;
+
+ const load = useCallback(async () => {
+ try {
+ const res = await apiFetch('/api/admin/outgoing-hosts');
+ if (!res.ok) throw new Error();
+ setState(await res.json());
+ setFailed(false);
+ } catch {
+ setFailed(true);
+ }
+ }, []);
+
+ useEffect(() => { void load(); }, [load]);
+
+ async function save(on: boolean) {
+ setSaving(true);
+ try {
+ const res = await apiFetch('/api/admin/config', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ noThirdPartyTraffic: on }),
+ });
+ if (res.ok) {
+ setMessage({
+ type: 'success',
+ text: on
+ ? 'Third-party traffic is off. The server stops at once; browsers follow the next time Bulwark loads.'
+ : 'Third-party traffic is allowed again.',
+ });
+ } else {
+ const data = await res.json().catch(() => ({}));
+ setMessage({ type: 'error', text: data.error || 'Could not save the setting' });
+ }
+ } catch {
+ setMessage({ type: 'error', text: 'Could not save the setting' });
+ } finally {
+ await load();
+ setSaving(false);
+ }
+ }
+
+ async function revert() {
+ setSaving(true);
+ try {
+ const res = await apiFetch('/api/admin/config', {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ key: 'noThirdPartyTraffic' }),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ setMessage({ type: 'error', text: data.error || 'Could not revert the setting' });
+ }
+ } catch {
+ setMessage({ type: 'error', text: 'Could not revert the setting' });
+ } finally {
+ await load();
+ setSaving(false);
+ }
+ }
+
+ const on = !!state?.noThirdPartyTraffic;
+ const locked = !!state && (state.readOnly || state.envLocked);
+ const relays = state?.reachable.filter((h) => h.kind === 'Push relay') ?? [];
+
+ return (
+
+
+ This switch is for the whole server, not per domain or group: most of what it stops, such as telemetry and the version check, runs for no particular user.
+
+
+ {!state && !failed && (
+
+ Loading
+
+ )}
+ {failed && Could not load the outgoing hosts.
}
+
+ {state && (
+ <>
+
+
+
+ void save(checked)} />
+ {state.source === 'admin' && !locked && void revert()} />}
+
+
+
+ {state.envLocked && (
+ Set by NO_THIRD_PARTY_TRAFFIC in the environment. Change it there and restart Bulwark.
+ )}
+ {state.readOnly && !state.envLocked && (
+ The configuration is read-only. Set noThirdPartyTraffic in config.json, or NO_THIRD_PARTY_TRAFFIC in the environment.
+ )}
+ {on && relays.length === 0 && (
+
+ Push notifications now need a relay you run yourself. Add one under{' '}
+ User policy > Push relays ;
+ until then users get new mail only while Bulwark is open.
+
+ )}
+ {on && state.networkPlugins.length > 0 && (
+
+ Not running because they declare network access: {state.networkPlugins.map((p) => p.name).join(', ')}.
+
+ )}
+
+
+
{on ? 'Still reachable' : 'Stays reachable with the switch on'}
+
Hosts you configured yourself, and what users enter themselves.
+ {state.reachable.length === 0 ? (
+
None configured.
+ ) : (
+
+ {state.reachable.map((h) => (
+
+ {h.kind}
+ {h.host}
+ {h.setIn} · {SIDE_LABEL[h.side]}
+ {h.note && {h.note} }
+
+ ))}
+
+ )}
+
+
+
+
+ Built-in outside hosts ({state.builtIn.length}){on ? ', all off' : ''}
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/components/admin/org/use-org-policy-draft.ts b/components/admin/org/use-org-policy-draft.ts
new file mode 100644
index 000000000..411be8639
--- /dev/null
+++ b/components/admin/org/use-org-policy-draft.ts
@@ -0,0 +1,237 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { create } from 'zustand';
+import type { FeatureGates, SettingRestriction, SettingsPolicy } from '@/lib/admin/types';
+import { DEFAULT_POLICY } from '@/lib/admin/types';
+import {
+ DEFAULT_ORGANISATION_POLICY,
+ sanitizeOrganisationPolicy,
+ type OrganisationPolicy,
+ type OrganisationSection,
+} from '@/lib/admin/organisation-policy';
+import type { PolicyScope } from '@/lib/admin/policy-scopes';
+import { apiFetch } from '@/lib/browser-navigation';
+import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
+import { useAdminFeedback } from '@/components/admin/feedback';
+
+/** The scope id the Organisation pages edit; '' is Everyone (the global policy). */
+export const EVERYONE = '';
+
+/** The scope picked on one Organisation page stays picked on the others. */
+export const useOrgScopeStore = create<{ scopeId: string; setScopeId: (id: string) => void }>()((set) => ({
+ scopeId: EVERYONE,
+ setScopeId: (scopeId) => set({ scopeId }),
+}));
+
+type SectionValues = OrganisationPolicy[S];
+
+export interface OrgValue {
+ /** What applies at the selected scope: its own value, or the inherited one. */
+ value: T;
+ /** The selected scope sets it itself (always true for Everyone). */
+ own: boolean;
+ /** Everyone's value, shown as "From Everyone" in a scope. */
+ everyone: T;
+}
+
+function clone(value: T): T {
+ return JSON.parse(JSON.stringify(value)) as T;
+}
+
+/**
+ * Loading, editing and saving the user policy for one Organisation page.
+ * Edits go to the scope chosen in the scope bar (Everyone or one domain or
+ * group scope); in a scope, a value the scope doesn't set shows Everyone's
+ * and can be reset to it. Saving writes the whole policy, so the User
+ * policy page and every Organisation page work on the same policy.json.
+ */
+export function useOrgPolicyDraft(dirtyId: string) {
+ const [policy, setPolicy] = useState(null);
+ const [saved, setSaved] = useState(null);
+ const [saving, setSaving] = useState(false);
+ // Outcomes show in a toast, like every admin page's.
+ const setMessage = useAdminFeedback();
+ const scopeId = useOrgScopeStore((s) => s.scopeId);
+ const setScopeId = useOrgScopeStore((s) => s.setScopeId);
+
+ const load = useCallback(async () => {
+ try {
+ const res = await apiFetch('/api/admin/policy');
+ if (!res.ok) throw new Error();
+ const data = (await res.json()) as SettingsPolicy;
+ const normalised = { ...DEFAULT_POLICY, ...data, organisation: sanitizeOrganisationPolicy(data.organisation), scopes: data.scopes ?? [] };
+ setPolicy(normalised);
+ setSaved(clone(normalised));
+ } catch {
+ setMessage({ type: 'error', text: 'Could not load the policy' });
+ setPolicy((p) => p ?? { ...DEFAULT_POLICY, organisation: clone(DEFAULT_ORGANISATION_POLICY), scopes: [] });
+ }
+ }, [setMessage]);
+
+ useEffect(() => { void load(); }, [load]);
+
+ const scopes = useMemo(() => policy?.scopes ?? [], [policy]);
+ const scope = scopes.find((s) => s.id === scopeId) ?? null;
+ // A scope deleted elsewhere (or on another page) falls back to Everyone.
+ useEffect(() => {
+ if (policy && scopeId !== EVERYONE && !scope) setScopeId(EVERYONE);
+ }, [policy, scopeId, scope, setScopeId]);
+
+ const dirty = !!policy && !!saved && JSON.stringify(policy) !== JSON.stringify(saved);
+ useAdminUnsavedChanges(dirtyId, dirty);
+
+ const update = useCallback((change: (draft: SettingsPolicy) => void) => {
+ setPolicy((current) => {
+ if (!current) return current;
+ const draft = clone(current);
+ change(draft);
+ return draft;
+ });
+ setMessage(null);
+ }, [setMessage]);
+
+ const updateScope = useCallback((id: string, change: (scope: PolicyScope) => void) => {
+ update((draft) => {
+ const target = draft.scopes?.find((s) => s.id === id);
+ if (target) change(target);
+ });
+ }, [update]);
+
+ const org = policy?.organisation ?? DEFAULT_ORGANISATION_POLICY;
+
+ function orgValue>(section: S, key: K): OrgValue[K]> {
+ const everyone = org[section][key];
+ if (!scope) return { value: everyone, own: true, everyone };
+ const override = (scope.policy.organisation?.[section] as Partial> | undefined)?.[key];
+ return override === undefined
+ ? { value: everyone, own: false, everyone }
+ : { value: override as SectionValues[K], own: true, everyone };
+ }
+
+ function setOrgValue>(section: S, key: K, value: SectionValues[K]) {
+ if (!scope) {
+ update((draft) => {
+ const target = (draft.organisation ?? clone(DEFAULT_ORGANISATION_POLICY)) as OrganisationPolicy;
+ (target[section] as SectionValues)[key] = value;
+ draft.organisation = target;
+ });
+ return;
+ }
+ updateScope(scope.id, (s) => {
+ const organisation = (s.policy.organisation ??= {}) as Record>;
+ (organisation[section] ??= {})[key as string] = value;
+ });
+ }
+
+ /** In a scope: use Everyone's value again. */
+ function clearOrgValue(section: S, key: keyof SectionValues) {
+ if (!scope) return;
+ updateScope(scope.id, (s) => {
+ const values = s.policy.organisation?.[section] as Record | undefined;
+ if (!values) return;
+ delete values[key as string];
+ if (Object.keys(values).length === 0) delete (s.policy.organisation as Record)[section];
+ });
+ }
+
+ function restriction(key: string): OrgValue {
+ const everyone = policy?.restrictions[key];
+ if (!scope) return { value: everyone, own: true, everyone };
+ const override = scope.policy.restrictions?.[key];
+ return override === undefined ? { value: everyone, own: false, everyone } : { value: override, own: true, everyone };
+ }
+
+ /** Sets the restriction at the selected scope; undefined removes it (in a scope: inherit). */
+ function setRestriction(key: string, value: SettingRestriction | undefined) {
+ const empty = !value || (!value.locked && !value.hidden && !value.allowedValues?.length && value.min === undefined && value.max === undefined);
+ if (!scope) {
+ update((draft) => {
+ if (empty) delete draft.restrictions[key];
+ else draft.restrictions[key] = value!;
+ });
+ return;
+ }
+ updateScope(scope.id, (s) => {
+ const restrictions = (s.policy.restrictions ??= {});
+ // In a scope, "nothing" is a real override: it unlocks what Everyone locks.
+ if (value === undefined) delete restrictions[key];
+ else restrictions[key] = empty ? {} : value;
+ });
+ }
+
+ function feature(key: keyof FeatureGates): OrgValue {
+ const everyone = policy?.features[key] ?? DEFAULT_POLICY.features[key];
+ if (!scope) return { value: everyone, own: true, everyone };
+ const override = scope.policy.features?.[key];
+ return override === undefined ? { value: everyone, own: false, everyone } : { value: override, own: true, everyone };
+ }
+
+ function setFeature(key: keyof FeatureGates, value: boolean | undefined) {
+ if (!scope) {
+ update((draft) => { draft.features[key] = value ?? DEFAULT_POLICY.features[key]; });
+ return;
+ }
+ updateScope(scope.id, (s) => {
+ const features = (s.policy.features ??= {});
+ if (value === undefined) delete features[key];
+ else features[key] = value;
+ });
+ }
+
+ async function save() {
+ if (!policy) return;
+ setSaving(true);
+ setMessage(null);
+ try {
+ const res = await apiFetch('/api/admin/policy', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(policy),
+ });
+ if (res.ok) {
+ // The server sanitises; show what it kept.
+ await load();
+ setMessage({ type: 'success', text: 'Saved. Users get the change the next time Bulwark loads.' });
+ } else {
+ const data = await res.json().catch(() => ({}));
+ setMessage({ type: 'error', text: data.error || 'Could not save the policy' });
+ }
+ } catch {
+ setMessage({ type: 'error', text: 'Could not save the policy' });
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ function discard() {
+ if (saved) setPolicy(clone(saved));
+ setMessage(null);
+ }
+
+ return {
+ policy,
+ loading: policy === null,
+ scopes,
+ scope,
+ scopeId: scope ? scope.id : EVERYONE,
+ setScopeId,
+ update,
+ updateScope,
+ orgValue,
+ setOrgValue,
+ clearOrgValue,
+ restriction,
+ setRestriction,
+ feature,
+ setFeature,
+ dirty,
+ saving,
+ save,
+ discard,
+ setMessage,
+ reload: load,
+ };
+}
+
+export type OrgPolicyDraft = ReturnType;
diff --git a/components/calendar/__tests__/event-detail-popover-maps.test.tsx b/components/calendar/__tests__/event-detail-popover-maps.test.tsx
new file mode 100644
index 000000000..8ef172aa0
--- /dev/null
+++ b/components/calendar/__tests__/event-detail-popover-maps.test.tsx
@@ -0,0 +1,46 @@
+import { render, screen } from '@testing-library/react';
+import { afterEach, describe, it, expect, vi } from 'vitest';
+import { EventDetailPopover } from '../event-detail-popover';
+import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
+import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
+
+// #903: an address opens Google Maps only while third-party traffic is allowed.
+
+vi.mock('next-intl', () => ({
+ useTranslations: () => (key: string) => key,
+ useLocale: () => 'en',
+ useFormatter: () => ({ dateTime: (d: Date | string) => String(d), relativeTime: (d: Date | string) => String(d), number: (n: number) => String(n) }),
+}));
+
+const calendar = { id: 'cal-1', name: 'Work', myRights: { mayWriteAll: true, mayWriteOwn: true } } as unknown as Calendar;
+
+function renderPopover() {
+ const event = {
+ id: 'evt-1', calendarIds: { 'cal-1': true }, title: 'Visit', start: '2026-11-20T10:00:00', duration: 'PT1H',
+ timeZone: 'Europe/Berlin', showWithoutTime: false, participants: null, alerts: null, useDefaultAlerts: false,
+ locations: { l1: { name: 'Hauptstrasse 1, Berlin' } },
+ } as unknown as CalendarEvent;
+ render(
+ ,
+ );
+ return screen.getByTitle('Hauptstrasse 1, Berlin');
+}
+
+afterEach(() => setNoThirdPartyTraffic(false));
+
+describe('event address link', () => {
+ it('opens Google Maps with third-party traffic allowed', () => {
+ expect(renderPopover().getAttribute('href')).toMatch(/^https:\/\/www\.google\.com\/maps\//);
+ });
+
+ it('is plain text with no third-party traffic', () => {
+ setNoThirdPartyTraffic(true);
+ expect(renderPopover().getAttribute('href')).toBeNull();
+ });
+
+ it('is plain text while the config is still unknown', () => {
+ setNoThirdPartyTraffic('unknown');
+ expect(renderPopover().getAttribute('href')).toBeNull();
+ });
+});
diff --git a/components/calendar/__tests__/event-modal-external-invitees.test.tsx b/components/calendar/__tests__/event-modal-external-invitees.test.tsx
new file mode 100644
index 000000000..0c3108d49
--- /dev/null
+++ b/components/calendar/__tests__/event-modal-external-invitees.test.tsx
@@ -0,0 +1,105 @@
+import { render, screen, fireEvent, waitFor, cleanup, act } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { EventModal } from '../event-modal';
+import { ExternalRecipientConfirmHost } from '@/components/email/external-recipient-confirm';
+import type { Calendar, CalendarEvent, Identity } from '@/lib/jmap/types';
+import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
+import { DEFAULT_POLICY } from '@/lib/admin/types';
+import { usePolicyStore } from '@/stores/policy-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { useContactStore } from '@/stores/contact-store';
+import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store';
+import { resetSendPipeline } from '@/lib/outgoing/send-pipeline';
+
+// Saving a meeting sends its invitations (the server does, on save), so a
+// newly invited person outside the organisation gets the same confirm as a
+// message. People already invited aren't asked about again.
+
+vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
+
+const calendars = [{
+ id: 'cal', name: 'Personal', color: '#123456', isDefault: true,
+ myRights: {
+ mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
+ mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
+ },
+}] as unknown as Calendar[];
+
+const party = (address: string, owner: boolean) => ({
+ '@type': 'Participant', calendarAddress: `mailto:${address}`, email: address,
+ roles: owner ? { owner: true } : { attendee: true },
+ participationStatus: owner ? 'accepted' : 'needs-action', scheduleAgent: 'server', expectReply: !owner, kind: 'individual',
+});
+
+// The user's meeting, already sent to a partner outside.
+const meeting = {
+ id: 'e1', uid: 'u1', '@type': 'Event', title: 'Planning', description: '', start: '2026-10-12T10:00:00',
+ duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, calendarIds: { cal: true },
+ organizerCalendarAddress: 'mailto:me@example.com', alerts: null, recurrenceRules: null,
+ participants: { org: party('me@example.com', true), partner: party('partner@partner.example', false) },
+} as unknown as CalendarEvent;
+
+function setWarning(on: boolean) {
+ usePolicyStore.setState({
+ policy: {
+ ...DEFAULT_POLICY,
+ organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, recipientWarning: on } },
+ },
+ });
+}
+
+beforeEach(() => {
+ setWarning(true);
+ useAuthStore.setState({ identities: [{ id: 'i1', name: 'Me', email: 'me@example.com' } as unknown as Identity] });
+ useContactStore.setState({ contacts: [], contactsLoaded: true });
+ useExternalRecipientConfirmStore.setState({ pending: null, queue: [] });
+});
+afterEach(() => {
+ cleanup();
+ resetSendPipeline();
+});
+
+function editWithNewInvitee(onSave: ReturnType, invitee: string) {
+ render(
+ <>
+
+
+ >,
+ );
+ fireEvent.click(screen.getByText('events.edit'));
+ fireEvent.change(screen.getByPlaceholderText('email_placeholder'), { target: { value: invitee } });
+ fireEvent.click(screen.getByText('form.save'));
+}
+
+describe('invitations to people outside the organisation', () => {
+ it('asks about the new external invitee only, and Back saves nothing', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ editWithNewInvitee(onSave, 'org-dave@org-two.test');
+ await waitFor(() => expect(useExternalRecipientConfirmStore.getState().pending).not.toBeNull());
+ expect(useExternalRecipientConfirmStore.getState().pending?.recipients.map((r) => r.address)).toEqual(['org-dave@org-two.test']);
+ act(() => useExternalRecipientConfirmStore.getState().answer(false));
+ await new Promise((r) => setTimeout(r, 20));
+ expect(onSave).not.toHaveBeenCalled();
+ });
+
+ it('saves and sends after Send anyway', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ editWithNewInvitee(onSave, 'org-dave@org-two.test');
+ await waitFor(() => expect(useExternalRecipientConfirmStore.getState().pending).not.toBeNull());
+ act(() => useExternalRecipientConfirmStore.getState().answer(true));
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
+ expect(onSave.mock.calls[0][1]).toBe(true);
+ });
+
+ it('saves without asking for an internal invitee or with the warning off', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ editWithNewInvitee(onSave, 'colleague@example.com');
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
+ cleanup();
+ setWarning(false);
+ const second = vi.fn().mockResolvedValue(undefined);
+ editWithNewInvitee(second, 'org-dave@org-two.test');
+ await waitFor(() => expect(second).toHaveBeenCalledTimes(1));
+ expect(useExternalRecipientConfirmStore.getState().pending).toBeNull();
+ });
+});
diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx
index ee0e7128b..4e7214664 100644
--- a/components/calendar/event-detail-popover.tsx
+++ b/components/calendar/event-detail-popover.tsx
@@ -26,6 +26,7 @@ import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib
import { useFormatEventDate } from "@/hooks/use-format-event-date";
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links";
+import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { eventToView } from "@/lib/plugin-projection";
import { ParticipantStatusBadge, RsvpButtons } from "./rsvp-buttons";
@@ -157,6 +158,8 @@ export function EventDetailPopover({
const isMultiDay = !isSameDay(startDate, displayEndDate);
const locationName = useMemo(() => primaryLocationName(event) ?? null, [event]);
+ // Re-computes the location link once the config is known (#903).
+ const noThirdParty = useNoThirdPartyTraffic();
// Invitations (Teams above all) leave virtualLocations empty and bury the
// join URL in the description; surface it as the meeting link anyway.
@@ -169,8 +172,10 @@ export function EventDetailPopover({
const locationHref = useMemo(() => {
if (!locationName) return null;
const action = locationAction(locationName, meeting);
- return action.kind === "url" ? action.uri : mapsUrl(action.query);
- }, [locationName, meeting]);
+ if (action.kind === "url") return action.uri;
+ // No Google Maps link while third-party traffic is off (#903).
+ return noThirdParty ? null : mapsUrl(action.query);
+ }, [locationName, meeting, noThirdParty]);
const [locationCopied, setLocationCopied] = useState(false);
const copiedTimer = useRef | null>(null);
useEffect(() => () => {
@@ -504,7 +509,7 @@ export function EventDetailPopover({
href={locationHref ?? undefined}
target="_blank"
rel="noreferrer"
- className="text-sm text-primary hover:underline min-w-0 break-words"
+ className={cn("text-sm min-w-0 break-words", locationHref ? "text-primary hover:underline" : "text-foreground")}
dir="auto"
title={locationName}
>
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx
index 1d08cf377..c68bf2d5a 100644
--- a/components/calendar/event-modal.tsx
+++ b/components/calendar/event-modal.tsx
@@ -39,6 +39,7 @@ import { buildDuplicateEventData } from "@/lib/calendar-duplicate";
import { useFormatEventDate } from "@/hooks/use-format-event-date";
import { useIsPaneScoped } from "@/hooks/use-pane-context";
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
+import { confirmInvitationRecipients } from "@/lib/outgoing/external-recipients";
import {
calendarFormHooks,
calendarHooks,
@@ -891,6 +892,14 @@ export function EventModal({
}
const shouldSendScheduling = userIsOrganizer && effectiveAttendees.length > 0 && sendInvitations;
+ // Invitations are mail too: newly invited people outside the
+ // organisation get the same confirm as a message (Back keeps the
+ // editor open, nothing saved).
+ if (shouldSendScheduling) {
+ const known = new Set(initialAttendees.map((a) => a.email.trim().toLowerCase()));
+ const invited = effectiveAttendees.map((a) => a.email).filter((email) => email && !known.has(email.trim().toLowerCase()));
+ if (!(await confirmInvitationRecipients(invited))) return;
+ }
await onSave(data, shouldSendScheduling, {
initialAttendees,
attendees: effectiveAttendees,
diff --git a/components/email/__tests__/composer-external-recipients.test.tsx b/components/email/__tests__/composer-external-recipients.test.tsx
new file mode 100644
index 000000000..b2a9ddd76
--- /dev/null
+++ b/components/email/__tests__/composer-external-recipients.test.tsx
@@ -0,0 +1,308 @@
+import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import React from 'react';
+import { EmailComposer } from '../email-composer';
+import { resetSendPipeline } from '@/lib/outgoing/send-pipeline';
+import { ExternalRecipientConfirmHost } from '../external-recipient-confirm';
+import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store';
+import { usePolicyStore } from '@/stores/policy-store';
+import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
+import { DEFAULT_POLICY } from '@/lib/admin/types';
+import { useAuthStore } from '@/stores/auth-store';
+import { useIdentityStore } from '@/stores/identity-store';
+
+// ─── Heavy component mocks (mirrors composer-reopened-draft-signature.test.tsx) ─
+
+vi.mock('@/components/email/rich-text-editor', () => ({
+ RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
+}));
+
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
+vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
+vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
+vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
+vi.mock('@/hooks/use-focus-trap', () => ({
+ useFocusTrap: () => ({ current: null }),
+}));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
+ useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
+ stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
+}));
+
+// ─── Store mocks ──────────────────────────────────────────────────────────────
+
+vi.mock('@/stores/auth-store', () => {
+ const state = {
+ client: null,
+ identities: [],
+ primaryIdentity: null,
+ isAuthenticated: false,
+ isDemoMode: false,
+ activeAccountId: null,
+ connectionLost: false,
+ getClientForAccount: () => undefined,
+ getAllConnectedClients: () => new Map(),
+ syncIdentities: () => {},
+ refreshIdentities: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAuthStore: hook };
+});
+
+vi.mock('@/stores/identity-store', () => {
+ const state = {
+ identities: [
+ { id: 'id-me', email: 'me@example.com', name: 'Me' },
+ { id: 'id-sales', email: 'sales@example.com', name: 'Sales' },
+ ] as Array>,
+ defaultIdentityId: 'id-me',
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useIdentityStore: hook };
+});
+
+vi.mock('@/stores/account-store', () => {
+ const state = { accounts: [], getAccountById: () => undefined };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAccountStore: hook };
+});
+
+vi.mock('@/stores/email-store', () => {
+ const state = {
+ draftSaveEnabled: false,
+ sendRawEmail: async () => ({ sent: true }),
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useEmailStore: hook };
+});
+
+vi.mock('@/stores/settings-store', () => {
+ const state = {
+ timeFormat: '24h',
+ plainTextMode: false,
+ subAddressDelimiter: '+',
+ autoSelectReplyIdentity: true,
+ attachmentReminderEnabled: false,
+ attachmentReminderKeywords: [],
+ emptySubjectWarningEnabled: true,
+ sendDelaySeconds: 0,
+ // The default position: replies/forwards get the signature appended at
+ // send time rather than embedded above the quote.
+ signaturePosition: 'below_quote',
+ signatureSeparatorEnabled: true,
+ requestReadReceiptDefault: false,
+ addTrustedSender: () => {},
+ trustedSendersAddressBook: null,
+ updateSetting: () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useSettingsStore: hook };
+});
+
+vi.mock('@/stores/contact-store', () => {
+ const state = {
+ contacts: [],
+ addressBooks: [],
+ trustedSendersBookId: null,
+ getAutocomplete: async () => [],
+ addToTrustedSendersBook: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useContactStore: hook };
+});
+
+vi.mock('@/stores/template-store', () => {
+ const state = { templates: [], addTemplate: async () => {} };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useTemplateStore: hook };
+});
+
+// ─── Misc dependency mocks ────────────────────────────────────────────────────
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { info: () => {}, error: () => {}, success: () => {} },
+}));
+
+vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
+ emailHooks: {
+ onComposerOpen: { call: async () => [] },
+ onRecipientChange: { call: async () => [] },
+ getRecipientSuggestions: { call: async () => [] },
+ onRecipientChipsChange: { transform: async (chips: unknown) => chips },
+ onDraftChange: { emit: () => {} },
+ onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
+ onComposeSend: { takeOver: async () => 'default' },
+ onTransformOutgoingEmail: { transform: async (email: unknown) => email },
+ },
+ contactHooks: {
+ search: { call: async () => [] },
+ onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
+ },
+}));
+
+vi.mock('@/lib/email-sanitization', () => ({
+ sanitizeSignatureHtml: (v: string) => v,
+ sanitizeSignatureHtmlForDisplay: (v: string) => v,
+ sanitizeEmailHtml: (v: string) => v,
+ sanitizePluginBodyHtml: (v: string) => v,
+ escapeHtml: (v: string) => v,
+ parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
+}));
+
+vi.mock('@/lib/email-threading', () => ({
+ computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
+}));
+vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
+vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
+vi.mock('@/components/email/quoted-html', () => ({
+ buildQuotedHtmlBlock: () => '',
+ serializeEditorContent: () => '',
+}));
+vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+// The external-recipient confirm in the real composer: the check is
+// registered by the dialog host, asks only about the recipients outside the
+// organisation, and the composer sends or not by the user's answer.
+
+const DRAFT = {
+ to: 'bob@example.com, dave@org-two.test',
+ cc: 'carol@example.com',
+ bcc: '',
+ subject: 'Hello',
+ body: 'Hi
',
+ showCc: true,
+ showBcc: false,
+ selectedIdentityId: 'id-me',
+ subAddressTag: '',
+ mode: 'compose' as const,
+ draftId: null,
+};
+
+function setExternalMail(patch: Partial) {
+ usePolicyStore.setState({
+ policy: {
+ ...DEFAULT_POLICY,
+ organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } },
+ },
+ });
+}
+
+function clickSend() {
+ fireEvent.click(screen.getAllByTestId('composer-send')[0]);
+}
+
+function renderComposer(onSend: () => void) {
+ return render(
+ <>
+
+
+ >,
+ );
+}
+
+describe('composer: confirm before sending outside the organisation', () => {
+ beforeEach(() => {
+ resetSendPipeline();
+ useExternalRecipientConfirmStore.setState({ pending: null, queue: [] });
+ setExternalMail({ recipientWarning: true });
+ // The user's identities, as the auth store holds them after sign-in:
+ // example.com is internal because of them.
+ const identities = [
+ { id: 'id-me', email: 'me@example.com', name: 'Me' },
+ { id: 'id-sales', email: 'sales@example.com', name: 'Sales' },
+ ];
+ useAuthStore.setState({ identities } as never);
+ useIdentityStore.setState({ identities } as never);
+ });
+ afterEach(() => {
+ cleanup();
+ resetSendPipeline();
+ vi.clearAllMocks();
+ });
+
+ it('lists only the external recipient and sends nothing on Back', async () => {
+ const onSend = vi.fn();
+ const { container } = renderComposer(onSend);
+ clickSend();
+ const list = await waitFor(() => {
+ const el = container.querySelector('[data-external-recipients]');
+ expect(el).not.toBeNull();
+ return el!;
+ });
+ expect(list.textContent).toContain('dave@org-two.test');
+ expect(list.textContent).not.toContain('bob@example.com');
+ expect(list.textContent).not.toContain('carol@example.com');
+ fireEvent.click(container.querySelector('[data-external-recipient-back]')!);
+ await waitFor(() => expect(container.querySelector('[data-external-recipient-confirm]')).toBeNull());
+ expect(onSend).not.toHaveBeenCalled();
+ });
+
+ it('sends after Send anyway', async () => {
+ const onSend = vi.fn();
+ const { container } = renderComposer(onSend);
+ clickSend();
+ await waitFor(() => expect(container.querySelector('[data-external-recipient-send]')).not.toBeNull());
+ fireEvent.click(container.querySelector('[data-external-recipient-send]')!);
+ await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
+ });
+
+ it('leaves the identity\'s automatic Bcc out of the question', async () => {
+ useIdentityStore.setState({
+ identities: [
+ { id: 'id-me', email: 'me@example.com', name: 'Me', bcc: [{ name: '', email: 'archive@archive-service.example' }] },
+ { id: 'id-sales', email: 'sales@example.com', name: 'Sales' },
+ ],
+ } as never);
+ const { container } = renderComposer(vi.fn());
+ clickSend();
+ const list = await waitFor(() => {
+ const el = container.querySelector('[data-external-recipients]');
+ expect(el).not.toBeNull();
+ return el!;
+ });
+ expect(list.textContent).toContain('dave@org-two.test');
+ expect(list.textContent).not.toContain('archive@archive-service.example');
+ });
+
+ it('sends without asking while the warning is off', async () => {
+ setExternalMail({ recipientWarning: false, senderLabel: true });
+ const onSend = vi.fn();
+ const { container } = renderComposer(onSend);
+ clickSend();
+ await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
+ expect(container.querySelector('[data-external-recipient-confirm]')).toBeNull();
+ });
+});
diff --git a/components/email/__tests__/composer-org-signature.test.tsx b/components/email/__tests__/composer-org-signature.test.tsx
new file mode 100644
index 000000000..e6e76afa0
--- /dev/null
+++ b/components/email/__tests__/composer-org-signature.test.tsx
@@ -0,0 +1,607 @@
+import { act, render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
+import React from 'react';
+import { EmailComposer } from '../email-composer';
+import { useSettingsStore } from '@/stores/settings-store';
+import { usePolicyStore } from '@/stores/policy-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { registerOrgOutgoing } from '@/lib/org/outgoing';
+import { resetSendPipeline } from '@/lib/outgoing/send-pipeline';
+import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types';
+import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
+
+// ─── Heavy component mocks (mirrors composer-reply-signature.test.tsx) ─
+
+// A stand-in for the TipTap editor: it hands the composer an editor object
+// whose content is whatever the composer last rendered into it, the way the
+// real editor reports itself ready from an effect after mounting.
+const editorState = vi.hoisted(() => ({ html: '' }));
+// A plugin that rewrites signatures (onSignatureRender), off unless a test turns it on.
+const signaturePlugin = vi.hoisted(() => ({ on: false }));
+// Send plugins: a footer added by onTransformOutgoingEmail, and a crypto
+// plugin taking the send over (onComposeSend).
+const sendPlugins = vi.hoisted(() => ({
+ footer: false,
+ takeOver: null as null | ((request: { htmlBody: string; textBody: string }) => void),
+}));
+vi.mock('@/components/email/rich-text-editor', () => ({
+ RichTextEditor: ({ content, onChange, onEditorReady }: {
+ content: string;
+ onChange: (html: string) => void;
+ onEditorReady?: (editor: unknown) => void;
+ }) => {
+ editorState.html = content;
+ const onChangeRef = React.useRef(onChange);
+ onChangeRef.current = onChange;
+ React.useEffect(() => {
+ onEditorReady?.({
+ isDestroyed: false,
+ commands: {
+ focus: () => {},
+ setContent: (html: string) => {
+ editorState.html = html;
+ onChangeRef.current(html);
+ },
+ },
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+ return React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-html': content });
+ },
+}));
+
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
+vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
+vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
+vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
+vi.mock('@/hooks/use-focus-trap', () => ({
+ useFocusTrap: () => ({ current: null }),
+}));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
+ useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
+ stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
+}));
+
+// ─── Store mocks ──────────────────────────────────────────────────────────────
+
+vi.mock('@/stores/auth-store', () => {
+ const state = {
+ client: null,
+ identities: [],
+ primaryIdentity: null,
+ isAuthenticated: false,
+ isDemoMode: false,
+ activeAccountId: null,
+ connectionLost: false,
+ getClientForAccount: () => undefined,
+ getAllConnectedClients: () => new Map(),
+ syncIdentities: () => {},
+ refreshIdentities: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAuthStore: hook };
+});
+
+vi.mock('@/stores/identity-store', () => {
+ const state = {
+ identities: [
+ { id: 'id-me', email: 'me@example.com', name: 'Me', htmlSignature: 'Alice ', textSignature: 'Alice' },
+ { id: 'id-team', email: 'team@example.com', name: 'Team', htmlSignature: '', textSignature: '' },
+ ] as Array>,
+ defaultIdentityId: 'id-me',
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useIdentityStore: hook, notifyIdentitySelected: () => {} };
+});
+
+vi.mock('@/stores/account-store', () => {
+ const state = { accounts: [], getAccountById: () => undefined };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAccountStore: hook };
+});
+
+vi.mock('@/stores/email-store', () => {
+ const state = {
+ draftSaveEnabled: false,
+ sendRawEmail: async () => ({ sent: true }),
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useEmailStore: hook };
+});
+
+vi.mock('@/stores/settings-store', () => {
+ const state = {
+ timeFormat: '24h',
+ plainTextMode: false,
+ subAddressDelimiter: '+',
+ autoSelectReplyIdentity: true,
+ attachmentReminderEnabled: false,
+ attachmentReminderKeywords: [],
+ emptySubjectWarningEnabled: false,
+ signatureDetails: { title: '', department: '', phone: '', mobile: '' },
+ sendDelaySeconds: 0,
+ // The default position: replies/forwards get the signature appended at
+ // send time rather than embedded above the quote.
+ signaturePosition: 'below_quote',
+ signatureSeparatorEnabled: true,
+ requestReadReceiptDefault: false,
+ addTrustedSender: () => {},
+ externalContentPolicy: 'block',
+ isSenderTrusted: () => false,
+ trustedSendersAddressBook: null,
+ updateSetting: () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useSettingsStore: hook };
+});
+
+vi.mock('@/stores/contact-store', () => {
+ const state = {
+ contacts: [],
+ getAutocomplete: async () => [],
+ addToTrustedSendersBook: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useContactStore: hook };
+});
+
+vi.mock('@/stores/template-store', () => {
+ const state = { templates: [], addTemplate: async () => {} };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useTemplateStore: hook };
+});
+
+// ─── Misc dependency mocks ────────────────────────────────────────────────────
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { info: () => {}, error: () => {}, success: () => {} },
+}));
+
+vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: {
+ get size() { return signaturePlugin.on ? 1 : 0; },
+ transform: async (value: { html: string; text: string }) => ({ html: `${value.html}PLUGIN
`, text: `${value.text} PLUGIN` }),
+ },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
+ emailHooks: {
+ onComposerOpen: { call: async () => [] },
+ onRecipientChange: { call: async () => [] },
+ getRecipientSuggestions: { call: async () => [] },
+ onRecipientChipsChange: { transform: async (chips: unknown) => chips },
+ onDraftChange: { emit: () => {} },
+ onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
+ onComposeSend: {
+ takeOver: async (request: { htmlBody: string; textBody: string }) => {
+ if (!sendPlugins.takeOver) return 'default';
+ sendPlugins.takeOver(request);
+ return 'handled';
+ },
+ },
+ onTransformOutgoingEmail: {
+ transform: async (email: { htmlBody: string; textBody: string }) => (sendPlugins.footer
+ ? { ...email, htmlBody: `${email.htmlBody}Plugin footer
`, textBody: `${email.textBody}\n\nPlugin footer` }
+ : email),
+ },
+ },
+ contactHooks: {
+ search: { call: async () => [] },
+ onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
+ },
+}));
+
+vi.mock('@/lib/email-sanitization', () => ({
+ sanitizeSignatureHtml: (v: string) => v,
+ sanitizeSignatureHtmlForDisplay: (v: string) => v,
+ sanitizeEmailHtml: (v: string) => v,
+ sanitizePluginBodyHtml: (v: string) => v,
+ escapeHtml: (v: string) => v,
+ parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
+}));
+
+vi.mock('@/lib/email-threading', () => ({
+ computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
+}));
+vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
+vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
+vi.mock('@/components/email/quoted-html', () => ({
+ buildQuotedHtmlBlock: (html: string) => `${html}
`,
+ serializeEditorContent: () => editorState.html,
+}));
+vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+/**
+ * The organisation signature (lib/org-signature.ts) goes in where the
+ * composer's own signature goes, before plugins, so every path that builds
+ * a signature carries it: a new mail, a reply, a forward, a re-opened draft,
+ * plain text, an identity switch. Each body must carry it exactly once.
+ */
+
+const ORG_SIGNATURE = {
+ html: '{name} {title} Phone: {phone} Mobile: {mobile}
ACME Ltd
',
+ text: '{name}\n{title}\nPhone: {phone}\nMobile: {mobile}\nACME Ltd',
+ fields: ['title', 'phone', 'mobile'] as Array<'title' | 'phone' | 'mobile'>,
+};
+
+function setOrgPolicy(options: { locked?: boolean; signature?: typeof ORG_SIGNATURE | null } = {}) {
+ const signature = options.signature === undefined ? ORG_SIGNATURE : options.signature;
+ const policy: SettingsPolicy = {
+ ...DEFAULT_POLICY,
+ restrictions: options.locked ? { signature: { locked: true } } : {},
+ organisation: {
+ ...DEFAULT_ORGANISATION_POLICY,
+ signature: signature ?? DEFAULT_ORGANISATION_POLICY.signature,
+ },
+ };
+ usePolicyStore.setState({ policy, rawPolicy: policy, subject: null });
+}
+
+const REPLY_TO = {
+ from: [{ name: 'Bob', email: 'bob@example.com' }],
+ to: [{ email: 'me@example.com' }],
+ subject: 'Question',
+ body: 'Original text',
+ htmlBody: 'Original text
',
+ receivedAt: '2026-08-01T10:00:00Z',
+};
+
+const DRAFT = {
+ to: 'bob@example.com',
+ cc: '',
+ bcc: '',
+ subject: 'Re: Question',
+ body: 'Thanks, looks good.
original mail ',
+ showCc: false,
+ showBcc: false,
+ selectedIdentityId: 'id-me',
+ subAddressTag: '',
+ mode: 'compose' as const,
+ draftId: 'draft-v1',
+};
+
+const sendButton = () => screen.getAllByTestId('composer-send')[0] as HTMLButtonElement;
+const fromSelect = () => screen.getByTestId('composer-from') as HTMLSelectElement;
+const countOf = (haystack: string, needle: string) => haystack.split(needle).length - 1;
+
+async function send(onSend: ReturnType) {
+ fireEvent.click(sendButton());
+ await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
+ return onSend.mock.calls[0][0] as { body: string; htmlBody?: string };
+}
+
+/** The body carries the organisation signature for `name` once, and no personal one. */
+function expectOrgSignatureOnce(sent: { body: string; htmlBody?: string }, name: string) {
+ if (sent.htmlBody !== undefined) {
+ expect(countOf(sent.htmlBody, 'ACME Ltd')).toBe(1);
+ expect(sent.htmlBody).toContain(`${name} CEO Phone: +49 30 1
`);
+ expect(sent.htmlBody).not.toContain('Mobile:');
+ expect(sent.htmlBody).not.toContain('Alice');
+ }
+ expect(countOf(sent.body, 'ACME Ltd')).toBe(1);
+ // The text part of an HTML mail is made from the HTML, so its spacing
+ // follows the paragraphs; a plain-text mail has the text template as is.
+ const squash = (value: string) => value.replace(/\s+/g, ' ');
+ if (sent.htmlBody !== undefined) expect(squash(sent.body)).toContain(`${name} CEO Phone: +49 30 1 ACME Ltd`);
+ else expect(sent.body).toContain(`-- \n${name}\nCEO\nPhone: +49 30 1\nACME Ltd`);
+ expect(sent.body).not.toContain('Mobile:');
+ expect(sent.body).not.toContain('Alice');
+}
+
+describe('organisation signature in the composer', () => {
+ beforeEach(() => {
+ resetSendPipeline();
+ registerOrgOutgoing();
+ useSettingsStore.setState({
+ signatureDetails: { title: 'CEO', department: '', phone: '+49 30 1', mobile: '' },
+ } as never);
+ });
+ afterEach(() => {
+ resetSendPipeline();
+ useSettingsStore.setState({ plainTextMode: false, signaturePosition: 'below_quote' });
+ setOrgPolicy({ signature: null });
+ vi.clearAllMocks();
+ });
+
+ describe('locked: it replaces every signature', () => {
+ beforeEach(() => setOrgPolicy({ locked: true }));
+
+ it('signs a new mail once', async () => {
+ const onSend = vi.fn();
+ render( );
+ expectOrgSignatureOnce(await send(onSend), 'Me');
+ });
+
+ it('signs a reply once, below the quote', async () => {
+ const onSend = vi.fn();
+ render( );
+ const sent = await send(onSend);
+ expectOrgSignatureOnce(sent, 'Me');
+ expect(sent.htmlBody!.indexOf('Original text')).toBeLessThan(sent.htmlBody!.indexOf('ACME Ltd'));
+ });
+
+ it('signs a reply above the quote once', async () => {
+ useSettingsStore.setState({ signaturePosition: 'above_quote' });
+ const onSend = vi.fn();
+ render( );
+ const sent = await send(onSend);
+ expectOrgSignatureOnce(sent, 'Me');
+ expect(sent.htmlBody!.indexOf('ACME Ltd')).toBeLessThan(sent.htmlBody!.indexOf('reply_line'));
+ });
+
+ it('signs a forward once', async () => {
+ const onSend = vi.fn();
+ render( );
+ expectOrgSignatureOnce(await send(onSend), 'Me');
+ });
+
+ it('signs a re-opened draft once, also one that already carries it', async () => {
+ const first = vi.fn();
+ const { unmount } = render( );
+ expectOrgSignatureOnce(await send(first), 'Me');
+ const resent = first.mock.calls[0][0] as { htmlBody: string };
+ unmount();
+
+ // The body as the composer saved it, signature range included.
+ const withSignature = resent.htmlBody.replace(/^|<\/div>$/g, '');
+ const second = vi.fn();
+ render(
);
+ expectOrgSignatureOnce(await send(second), 'Me');
+ });
+
+ it('signs a plain-text mail and a plain-text reply once', async () => {
+ useSettingsStore.setState({ plainTextMode: true });
+ const compose = vi.fn();
+ const { unmount } = render(
);
+ const sent = await send(compose);
+ expect(sent.htmlBody).toBeUndefined();
+ expectOrgSignatureOnce(sent, 'Me');
+ unmount();
+
+ const reply = vi.fn();
+ render(
);
+ expectOrgSignatureOnce(await send(reply), 'Me');
+ });
+
+ it('fills it in again for the identity picked in From', async () => {
+ const onSend = vi.fn();
+ render(
);
+ fireEvent.change(fromSelect(), { target: { value: 'id-team' } });
+ await waitFor(() => expect(editorState.html).toContain('
Team '));
+ const sent = await send(onSend);
+ expectOrgSignatureOnce(sent, 'Team');
+ expect(sent.htmlBody).not.toContain('
Me ');
+ });
+
+ it('swaps it in a plain-text mail too', async () => {
+ useSettingsStore.setState({ plainTextMode: true });
+ const onSend = vi.fn();
+ render(
);
+ fireEvent.change(fromSelect(), { target: { value: 'id-team' } });
+ const textarea = () => document.querySelector('textarea') as HTMLTextAreaElement;
+ await waitFor(() => expect(textarea().value).toContain('Team\nCEO'));
+ const sent = await send(onSend);
+ expectOrgSignatureOnce(sent, 'Team');
+ expect(sent.body).not.toContain('Me\nCEO');
+ });
+ });
+
+ describe('not locked: the default for identities without their own', () => {
+ beforeEach(() => setOrgPolicy());
+
+ it('keeps the signature of an identity that has one', async () => {
+ const onSend = vi.fn();
+ render(
);
+ const sent = await send(onSend);
+ expect(sent.htmlBody).toContain('
Alice ');
+ expect(sent.htmlBody).not.toContain('ACME Ltd');
+ });
+
+ it('signs from an identity without one with the organisation signature', async () => {
+ const onSend = vi.fn();
+ render(
);
+ fireEvent.change(fromSelect(), { target: { value: 'id-team' } });
+ await waitFor(() => expect(editorState.html).toContain('ACME Ltd'));
+ expectOrgSignatureOnce(await send(onSend), 'Team');
+ });
+ });
+
+ describe('a signature that changes while the composer is open', () => {
+ const PROPS = { prefill: { to: ['bob@example.com'] } };
+
+ it('swaps in a locked signature whose policy arrives after the composer opened', async () => {
+ setOrgPolicy({ signature: null });
+ const onSend = vi.fn();
+ render(
);
+ await waitFor(() => expect(editorState.html).toContain('
Alice '));
+ act(() => setOrgPolicy({ locked: true }));
+ await waitFor(() => expect(editorState.html).toContain('ACME Ltd'));
+ expectOrgSignatureOnce(await send(onSend), 'Me');
+ });
+
+ it('brings in a default that arrives late for an identity without a signature', async () => {
+ setOrgPolicy({ signature: null });
+ const onSend = vi.fn();
+ render(
);
+ fireEvent.change(fromSelect(), { target: { value: 'id-team' } });
+ await waitFor(() => expect(editorState.html).not.toContain('Alice'));
+ act(() => setOrgPolicy());
+ await waitFor(() => expect(editorState.html).toContain('ACME Ltd'));
+ expectOrgSignatureOnce(await send(onSend), 'Team');
+ });
+
+ it('fills in the details again when they change', async () => {
+ setOrgPolicy({ locked: true });
+ const onSend = vi.fn();
+ const { rerender } = render(
);
+ await waitFor(() => expect(editorState.html).toContain('CEO'));
+ useSettingsStore.setState({ signatureDetails: { title: 'CTO', department: '', phone: '+49 30 1', mobile: '' } } as never);
+ rerender(
);
+ await waitFor(() => expect(editorState.html).toContain('CTO'));
+ const sent = await send(onSend);
+ expect(countOf(sent.htmlBody!, 'ACME Ltd')).toBe(1);
+ expect(sent.htmlBody).not.toContain('CEO');
+ });
+
+ it('keeps plugins away from a locked signature, and lets them change an unlocked one', async () => {
+ signaturePlugin.on = true;
+ try {
+ setOrgPolicy({ locked: true });
+ const locked = vi.fn();
+ const { unmount } = render(
);
+ await waitFor(() => expect(editorState.html).toContain('ACME Ltd'));
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ const sent = await send(locked);
+ expect(sent.htmlBody).not.toContain('PLUGIN');
+ expectOrgSignatureOnce(sent, 'Me');
+ unmount();
+
+ setOrgPolicy();
+ render(
);
+ await waitFor(() => expect(editorState.html).toContain('
Alice PLUGIN
'));
+ } finally {
+ signaturePlugin.on = false;
+ }
+ });
+
+ it('swaps a plain-text signature whose policy arrives late', async () => {
+ useSettingsStore.setState({ plainTextMode: true });
+ setOrgPolicy({ signature: null });
+ const onSend = vi.fn();
+ render(
);
+ const textarea = () => document.querySelector('textarea') as HTMLTextAreaElement;
+ await waitFor(() => expect(textarea().value).toContain('Alice'));
+ act(() => setOrgPolicy({ locked: true }));
+ await waitFor(() => expect(textarea().value).toContain('ACME Ltd'));
+ expectOrgSignatureOnce(await send(onSend), 'Me');
+ });
+ });
+});
+
+describe('organisation disclaimer in the composer', () => {
+ const DISCLAIMER = { html: '
Confidential, from {email}
', text: '' };
+
+ beforeEach(() => {
+ registerOrgOutgoing();
+ const policy: SettingsPolicy = {
+ ...DEFAULT_POLICY,
+ organisation: { ...DEFAULT_ORGANISATION_POLICY, disclaimer: DISCLAIMER },
+ };
+ usePolicyStore.setState({ policy, rawPolicy: policy, subject: null });
+ });
+ afterEach(() => {
+ resetSendPipeline();
+ useAuthStore.setState({ client: null });
+ useSettingsStore.setState({ plainTextMode: false });
+ setOrgPolicy({ signature: null });
+ vi.clearAllMocks();
+ });
+
+ it('shows it read-only under the editor', () => {
+ render(
);
+ const preview = screen.getByTestId('org-disclaimer-preview');
+ expect(preview).toHaveTextContent('disclaimer_added_on_send');
+ expect(preview.innerHTML).toContain('
Confidential, from me@example.com
');
+ expect(editorState.html).not.toContain('Confidential');
+ });
+
+ it('sends it once below the quote, and saves the draft without it', async () => {
+ const createDraft = vi.fn().mockResolvedValue('draft-1');
+ useAuthStore.setState({
+ client: { createDraft, getEmail: vi.fn().mockResolvedValue(null), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
+ });
+ const onSend = vi.fn();
+ render(
);
+ // A change arms the autosave, which the send flushes first.
+ fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Re: Question?' } });
+ const sent = await send(onSend);
+
+ expect(createDraft).toHaveBeenCalled();
+ for (const call of createDraft.mock.calls) {
+ expect(`${call[2]} ${call[10]}`).not.toContain('Confidential');
+ }
+ expect(countOf(sent.htmlBody!, 'Confidential')).toBe(1);
+ expect(sent.htmlBody).toMatch(/Original text[\s\S]*
Confidential, from me@example.com<\/p><\/div>$/);
+ expect(countOf(sent.body, 'Confidential')).toBe(1);
+ expect(sent.body).toMatch(/Original text[\s\S]*\n\nConfidential, from me@example.com$/);
+ });
+
+ it('sends it once in a plain-text mail', async () => {
+ useSettingsStore.setState({ plainTextMode: true });
+ const onSend = vi.fn();
+ render( );
+ const sent = await send(onSend);
+ expect(sent.htmlBody).toBeUndefined();
+ expect(countOf(sent.body, 'Confidential')).toBe(1);
+ expect(sent.body.endsWith('\n\nConfidential, from me@example.com')).toBe(true);
+ });
+
+ it('shows no block and adds no second copy when a draft back from Undo already ends with it', async () => {
+ const onSend = vi.fn();
+ // As the first send went out: the signature embedded, the disclaimer below it.
+ const body = '
Hi Bob
--
Alice
'
+ + '
Confidential, from me@example.com
';
+ render(
);
+ await waitFor(() => expect(screen.queryByTestId('org-disclaimer-preview')).toBeNull());
+ const sent = await send(onSend);
+ expect(countOf(sent.htmlBody!, 'Confidential')).toBe(1);
+ expect(countOf(sent.body, 'Confidential')).toBe(1);
+ });
+
+ it('goes below a footer a plugin adds', async () => {
+ sendPlugins.footer = true;
+ try {
+ const onSend = vi.fn();
+ render(
);
+ const sent = await send(onSend);
+ expect(sent.htmlBody).toMatch(/
Plugin footer<\/p>
Confidential, from me@example.com<\/p><\/div>$/);
+ expect(sent.body).toMatch(/Plugin footer\n\nConfidential, from me@example.com$/);
+ } finally {
+ sendPlugins.footer = false;
+ }
+ });
+
+ it('is in the message a signing or encrypting plugin takes over', async () => {
+ const taken = vi.fn();
+ sendPlugins.takeOver = taken;
+ try {
+ render( );
+ fireEvent.click(sendButton());
+ await waitFor(() => expect(taken).toHaveBeenCalledTimes(1));
+ const request = taken.mock.calls[0][0] as { htmlBody: string; textBody: string };
+ expect(countOf(request.htmlBody, 'Confidential')).toBe(1);
+ expect(request.textBody.endsWith('\n\nConfidential, from me@example.com')).toBe(true);
+ } finally {
+ sendPlugins.takeOver = null;
+ }
+ });
+});
diff --git a/components/email/__tests__/composer-send-pipeline.test.tsx b/components/email/__tests__/composer-send-pipeline.test.tsx
new file mode 100644
index 000000000..075421c0e
--- /dev/null
+++ b/components/email/__tests__/composer-send-pipeline.test.tsx
@@ -0,0 +1,248 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import React from 'react';
+import { EmailComposer } from '../email-composer';
+import { registerOutgoingFinalizer, registerPreSendCheck, resetSendPipeline } from '@/lib/outgoing/send-pipeline';
+
+// ─── Heavy component mocks (mirrors composer-reopened-draft-signature.test.tsx) ─
+
+vi.mock('@/components/email/rich-text-editor', () => ({
+ RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
+}));
+
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
+vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
+vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
+vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
+vi.mock('@/hooks/use-focus-trap', () => ({
+ useFocusTrap: () => ({ current: null }),
+}));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
+ useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
+ stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
+}));
+
+// ─── Store mocks ──────────────────────────────────────────────────────────────
+
+vi.mock('@/stores/auth-store', () => {
+ const state = {
+ client: null,
+ identities: [],
+ primaryIdentity: null,
+ isAuthenticated: false,
+ isDemoMode: false,
+ activeAccountId: null,
+ connectionLost: false,
+ getClientForAccount: () => undefined,
+ getAllConnectedClients: () => new Map(),
+ syncIdentities: () => {},
+ refreshIdentities: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAuthStore: hook };
+});
+
+vi.mock('@/stores/identity-store', () => {
+ const state = {
+ identities: [
+ { id: 'id-me', email: 'me@example.com', name: 'Me' },
+ { id: 'id-sales', email: 'sales@example.com', name: 'Sales' },
+ ] as Array>,
+ defaultIdentityId: 'id-me',
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useIdentityStore: hook };
+});
+
+vi.mock('@/stores/account-store', () => {
+ const state = { accounts: [], getAccountById: () => undefined };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAccountStore: hook };
+});
+
+vi.mock('@/stores/email-store', () => {
+ const state = {
+ draftSaveEnabled: false,
+ sendRawEmail: async () => ({ sent: true }),
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useEmailStore: hook };
+});
+
+vi.mock('@/stores/settings-store', () => {
+ const state = {
+ timeFormat: '24h',
+ plainTextMode: false,
+ subAddressDelimiter: '+',
+ autoSelectReplyIdentity: true,
+ attachmentReminderEnabled: false,
+ attachmentReminderKeywords: [],
+ emptySubjectWarningEnabled: true,
+ sendDelaySeconds: 0,
+ // The default position: replies/forwards get the signature appended at
+ // send time rather than embedded above the quote.
+ signaturePosition: 'below_quote',
+ signatureSeparatorEnabled: true,
+ requestReadReceiptDefault: false,
+ addTrustedSender: () => {},
+ trustedSendersAddressBook: null,
+ updateSetting: () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useSettingsStore: hook };
+});
+
+vi.mock('@/stores/contact-store', () => {
+ const state = {
+ contacts: [],
+ getAutocomplete: async () => [],
+ addToTrustedSendersBook: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useContactStore: hook };
+});
+
+vi.mock('@/stores/template-store', () => {
+ const state = { templates: [], addTemplate: async () => {} };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useTemplateStore: hook };
+});
+
+// ─── Misc dependency mocks ────────────────────────────────────────────────────
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { info: () => {}, error: () => {}, success: () => {} },
+}));
+
+vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
+ emailHooks: {
+ onComposerOpen: { call: async () => [] },
+ onRecipientChange: { call: async () => [] },
+ getRecipientSuggestions: { call: async () => [] },
+ onRecipientChipsChange: { transform: async (chips: unknown) => chips },
+ onDraftChange: { emit: () => {} },
+ onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
+ onComposeSend: { takeOver: async () => 'default' },
+ onTransformOutgoingEmail: { transform: async (email: unknown) => email },
+ },
+ contactHooks: {
+ search: { call: async () => [] },
+ onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
+ },
+}));
+
+vi.mock('@/lib/email-sanitization', () => ({
+ sanitizeSignatureHtml: (v: string) => v,
+ sanitizeSignatureHtmlForDisplay: (v: string) => v,
+ sanitizeEmailHtml: (v: string) => v,
+ sanitizePluginBodyHtml: (v: string) => v,
+ escapeHtml: (v: string) => v,
+ parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
+}));
+
+vi.mock('@/lib/email-threading', () => ({
+ computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
+}));
+vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
+vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
+vi.mock('@/components/email/quoted-html', () => ({
+ buildQuotedHtmlBlock: () => '',
+ serializeEditorContent: () => '',
+}));
+vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+// Features add to every send through lib/outgoing/send-pipeline.ts: a check
+// can stop it, a finaliser changes what goes out. Both run in the composer's
+// send, after its own checks.
+
+const DRAFT = {
+ to: 'bob@example.com',
+ cc: 'carol@example.com',
+ bcc: '',
+ subject: 'Hello',
+ body: 'Hi
',
+ showCc: true,
+ showBcc: false,
+ selectedIdentityId: 'id-me',
+ subAddressTag: '',
+ mode: 'compose' as const,
+ draftId: null,
+};
+
+function clickSend() {
+ fireEvent.click(screen.getAllByTestId('composer-send')[0]);
+}
+
+describe('composer send pipeline', () => {
+ beforeEach(() => resetSendPipeline());
+ afterEach(() => {
+ resetSendPipeline();
+ vi.clearAllMocks();
+ });
+
+ it('sends what the finalisers return, with the recipients in the context', async () => {
+ const seen: unknown[] = [];
+ registerOutgoingFinalizer('footer', 10, (bodies, context) => {
+ seen.push(context);
+ return { html: `${bodies.html}Footer
`, text: `${bodies.text}\n\nFooter` };
+ });
+ const onSend = vi.fn();
+ render( );
+ clickSend();
+ await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
+ const sent = onSend.mock.calls[0][0] as { htmlBody?: string; body: string };
+ expect(sent.htmlBody).toMatch(/Footer<\/p>$/);
+ expect(sent.body).toMatch(/\n\nFooter$/);
+ expect(seen).toEqual([expect.objectContaining({
+ surface: 'composer', to: ['bob@example.com'], cc: ['carol@example.com'], fromEmail: 'me@example.com', mode: 'compose', plainText: false,
+ })]);
+ });
+
+ it('sends nothing when a check says no, and sends after it says yes', async () => {
+ let allow = false;
+ const check = vi.fn(() => allow);
+ registerPreSendCheck('ask', 10, check);
+ const onSend = vi.fn();
+ render( );
+ clickSend();
+ await waitFor(() => expect(check).toHaveBeenCalledTimes(1));
+ expect(onSend).not.toHaveBeenCalled();
+
+ allow = true;
+ clickSend();
+ await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
+ });
+});
diff --git a/components/email/__tests__/composer-sign-out-flush.test.tsx b/components/email/__tests__/composer-sign-out-flush.test.tsx
new file mode 100644
index 000000000..be454dee1
--- /dev/null
+++ b/components/email/__tests__/composer-sign-out-flush.test.tsx
@@ -0,0 +1,308 @@
+import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
+import React from 'react';
+import { EmailComposer, type ComposerDraftData } from '../email-composer';
+import { useAuthStore } from '@/stores/auth-store';
+import { flushComposers, resetComposeFlushRegistry } from '@/lib/compose-flush-registry';
+
+// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ────────────────
+
+vi.mock('@/components/email/rich-text-editor', () => ({
+ RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
+}));
+
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
+vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
+vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
+vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
+vi.mock('@/hooks/use-focus-trap', () => ({
+ useFocusTrap: () => ({ current: null }),
+}));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
+ useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
+ stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
+}));
+
+// ─── Store mocks ──────────────────────────────────────────────────────────────
+
+vi.mock('@/stores/auth-store', () => {
+ const state = {
+ client: null,
+ identities: [],
+ primaryIdentity: null,
+ isAuthenticated: false,
+ isDemoMode: false,
+ activeAccountId: null,
+ connectionLost: false,
+ getClientForAccount: () => undefined,
+ getAllConnectedClients: () => new Map(),
+ syncIdentities: () => {},
+ refreshIdentities: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAuthStore: hook };
+});
+
+vi.mock('@/stores/identity-store', () => {
+ const state = {
+ identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
+ defaultIdentityId: 'id-me',
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useIdentityStore: hook };
+});
+
+vi.mock('@/stores/account-store', () => {
+ const state = { accounts: [], getAccountById: () => undefined };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAccountStore: hook };
+});
+
+vi.mock('@/stores/email-store', () => {
+ const state = {
+ sendRawEmail: async () => ({ sent: true }),
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useEmailStore: hook };
+});
+
+const updateSetting = vi.fn();
+
+vi.mock('@/stores/settings-store', () => {
+ const state = {
+ timeFormat: '24h',
+ plainTextMode: false,
+ subAddressDelimiter: '+',
+ autoSelectReplyIdentity: true,
+ attachmentReminderEnabled: false,
+ attachmentReminderKeywords: [],
+ emptySubjectWarningEnabled: true,
+ sendDelaySeconds: 0,
+ signaturePosition: 'above_quote',
+ signatureSeparatorEnabled: false,
+ requestReadReceiptDefault: false,
+ addTrustedSender: () => {},
+ trustedSendersAddressBook: null,
+ updateSetting: (...args: unknown[]) => updateSetting(...args),
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useSettingsStore: hook };
+});
+
+vi.mock('@/stores/contact-store', () => {
+ const state = {
+ contacts: [],
+ getAutocomplete: async () => [],
+ addToTrustedSendersBook: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useContactStore: hook };
+});
+
+vi.mock('@/stores/template-store', () => {
+ const state = { templates: [], addTemplate: async () => {} };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useTemplateStore: hook };
+});
+
+// ─── Misc dependency mocks ────────────────────────────────────────────────────
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { info: () => {}, error: () => {}, success: () => {} },
+}));
+
+vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
+ emailHooks: {
+ onComposerOpen: { call: async () => [] },
+ onRecipientChange: { call: async () => [] },
+ getRecipientSuggestions: { call: async () => [] },
+ onRecipientChipsChange: { transform: async (chips: unknown) => chips },
+ onDraftChange: { emit: () => {} },
+ onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
+ onComposeSend: { takeOver: async () => 'default' },
+ onTransformOutgoingEmail: { transform: async (email: unknown) => email },
+ },
+ contactHooks: {
+ search: { call: async () => [] },
+ onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
+ },
+}));
+
+vi.mock('@/lib/email-sanitization', () => ({
+ sanitizeSignatureHtml: (v: string) => v,
+ sanitizeEmailHtml: (v: string) => v,
+ parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
+}));
+
+vi.mock('@/lib/email-threading', () => ({
+ computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
+}));
+vi.mock('@/lib/signature-utils', () => ({
+ appendPlainTextSignature: (body: string) => body,
+ getPlainTextSignature: () => '',
+}));
+vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
+vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
+vi.mock('@/components/email/quoted-html', () => ({
+ buildQuotedHtmlBlock: () => '',
+ serializeEditorContent: () => '',
+}));
+vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+/**
+ * Before an automatic sign-out (organisation idle timeout or maximum session
+ * length) every composer saves what the server doesn't have yet, through the
+ * compose flush registry.
+ */
+
+const DRAFT: ComposerDraftData = {
+ to: 'bob@example.com',
+ cc: '',
+ bcc: '',
+ subject: 'Half-written',
+ body: 'Still typing
',
+ showCc: false,
+ showBcc: false,
+ selectedIdentityId: 'id-me',
+ subAddressTag: '',
+ mode: 'compose',
+ draftId: null,
+};
+
+let createDraft: ReturnType;
+
+beforeEach(() => {
+ resetComposeFlushRegistry();
+ createDraft = vi.fn().mockResolvedValue('draft-1');
+ useAuthStore.setState({
+ client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
+ });
+});
+
+afterEach(() => {
+ useAuthStore.setState({ client: null });
+});
+
+describe('composer flush before an automatic sign-out', () => {
+ it('saves edits the autosave has not stored yet', async () => {
+ render( );
+ fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
+
+ let result: Awaited> | undefined;
+ await act(async () => { result = await flushComposers(5000); });
+
+ expect(result).toEqual({ total: 1, settled: 1, failed: 0, timedOut: false });
+ expect(createDraft).toHaveBeenCalledTimes(1);
+ expect(createDraft.mock.calls[0][1]).toBe('Half-written more');
+ });
+
+ it('leaves a composer without changes alone', async () => {
+ render( );
+
+ await act(async () => { await flushComposers(5000); });
+
+ expect(createDraft).not.toHaveBeenCalled();
+ });
+
+ it('saves a minimised composer opened again with its stashed edits', async () => {
+ render( );
+
+ await act(async () => { await flushComposers(5000); });
+
+ expect(createDraft).toHaveBeenCalledTimes(1);
+ expect(createDraft.mock.calls[0][1]).toBe('Half-written');
+ });
+
+ it('waits for a send in flight and saves no draft of the sent message', async () => {
+ let finishSend!: () => void;
+ const onSend = vi.fn(() => new Promise((resolve) => { finishSend = resolve; }));
+ render( );
+ fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
+ fireEvent.click(screen.getAllByTestId('composer-send')[0]);
+ await waitFor(() => expect(onSend).toHaveBeenCalled());
+ const savesBeforeSignOut = createDraft.mock.calls.length;
+
+ let done = false;
+ const flushed = flushComposers(5000).then((r) => { done = true; return r; });
+ await new Promise((r) => setTimeout(r, 300));
+ expect(done).toBe(false);
+
+ await act(async () => { finishSend(); });
+ await expect(flushed).resolves.toMatchObject({ total: 1, failed: 0, timedOut: false });
+ expect(createDraft).toHaveBeenCalledTimes(savesBeforeSignOut);
+ });
+
+ it('reports a save the server refused, so the login page can say so', async () => {
+ createDraft.mockRejectedValue(new Error('refused'));
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ render( );
+ fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
+
+ let result: Awaited> | undefined;
+ await act(async () => { result = await flushComposers(5000); });
+ expect(result).toMatchObject({ total: 1, failed: 1 });
+ } finally {
+ consoleError.mockRestore();
+ }
+ });
+
+ it('hands an already-saved state back as saved', async () => {
+ const onSaveState = vi.fn();
+ const view = render( );
+ fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
+ await act(async () => { await flushComposers(5000); });
+ view.unmount();
+
+ expect(onSaveState).toHaveBeenCalledWith(expect.objectContaining({ subject: 'Half-written more', unsaved: false }));
+ });
+
+ it('marks the state it hands to the host on unmount as unsaved', () => {
+ const onSaveState = vi.fn();
+ const view = render( );
+ fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
+ view.unmount();
+
+ expect(onSaveState).toHaveBeenCalledWith(expect.objectContaining({ subject: 'Half-written more', unsaved: true }));
+ });
+
+ it('unregisters when it unmounts', async () => {
+ const view = render( );
+ view.unmount();
+
+ await expect(flushComposers(1000)).resolves.toMatchObject({ total: 0 });
+ });
+});
diff --git a/components/email/__tests__/email-context-menu-shared-tags.test.tsx b/components/email/__tests__/email-context-menu-shared-tags.test.tsx
new file mode 100644
index 000000000..9f9e340a3
--- /dev/null
+++ b/components/email/__tests__/email-context-menu-shared-tags.test.tsx
@@ -0,0 +1,96 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+
+vi.mock('@/components/plugins/plugin-slot', () => ({
+ PluginSlot: () => null,
+}));
+
+import { EmailContextMenu } from '../email-context-menu';
+import { useAuthStore } from '@/stores/auth-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { loadSharedTags, resetSharedTags, sharedTagsKey, useSharedTagsStore } from '@/stores/shared-tags-store';
+
+// The context menu's tag list for a selection offers the tags of the
+// selection's account (commonTagScope, like the toolbar), not those of the
+// row that was right-clicked.
+
+const TEAM = { loginId: 'login-1', accountId: 'team' };
+
+// A message in the group account, right-clicked.
+const email = {
+ id: 'e1',
+ threadId: 't1',
+ mailboxIds: { 'team:inbox': true },
+ keywords: { $seen: true },
+ from: [{ email: 'alice@example.com' }],
+ subject: 'Hello',
+ receivedAt: '2026-01-01T00:00:00Z',
+ size: 100,
+ sourceClientAccountId: 'login-1',
+ sourceAccountId: 'team',
+} as unknown as Email;
+
+const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[];
+
+function openTags(batchTagScope: typeof TEAM | null) {
+ render(
+ ,
+ );
+ act(() => { fireEvent.mouseEnter(screen.getByTestId('ctx-tag')); });
+}
+
+beforeEach(async () => {
+ resetSharedTags();
+ // The group's tags come through the real loading path.
+ const client = {
+ getAccountId: () => 'me',
+ getSieveAccountId: () => 'me',
+ supportsSieve: () => false,
+ getSharedAccounts: () => [],
+ getAccountInfo: () => ({ isPersonal: false }),
+ getPrincipalTypes: async () => ({ team: 'group' }),
+ advertisesBulwarkMetadata: () => true,
+ supportsBulwarkMetadata: () => true,
+ getBulwarkMetadata: async () => ({ tags: { tags: { urgent: { label: 'Team urgent', color: 'red' } } } }),
+ discoverKeywords: async () => ({ keywords: {}, scanned: 0, total: 0, complete: true }),
+ };
+ useAuthStore.setState({
+ client: client as never,
+ activeAccountId: 'login-1',
+ getClientForAccount: ((id: string) => (id === 'login-1' ? client : undefined)) as never,
+ });
+ useSettingsStore.setState({ emailKeywords: [{ id: 'mine', label: 'Mine', color: 'blue' }], nestedTags: false });
+ await loadSharedTags(sharedTagsKey(TEAM.loginId, TEAM.accountId), TEAM.accountId, client);
+ expect(useSharedTagsStore.getState().entries[sharedTagsKey(TEAM.loginId, TEAM.accountId)]?.status).toBe('ready');
+});
+afterEach(() => resetSharedTags());
+
+describe('EmailContextMenu tags for a selection', () => {
+ it('offers the group\'s shared tags when the whole selection is in the group', async () => {
+ openTags(TEAM);
+ expect(await screen.findByText('Team urgent')).toBeInTheDocument();
+ expect(screen.queryByText('Mine')).not.toBeInTheDocument();
+ });
+
+ it('offers the own tags when the selection mixes accounts, even from a group row', async () => {
+ openTags(null);
+ expect(await screen.findByText('Mine')).toBeInTheDocument();
+ expect(screen.queryByText('Team urgent')).not.toBeInTheDocument();
+ });
+});
diff --git a/components/email/__tests__/email-hover-actions-labels.test.tsx b/components/email/__tests__/email-hover-actions-labels.test.tsx
index 7ce14993d..ed26300f3 100644
--- a/components/email/__tests__/email-hover-actions-labels.test.tsx
+++ b/components/email/__tests__/email-hover-actions-labels.test.tsx
@@ -4,7 +4,11 @@ import type { Email } from '@/lib/jmap/types';
import { useSettingsStore } from '@/stores/settings-store';
import { EmailHoverActions } from '../email-hover-actions';
-vi.mock('@/components/plugins/plugin-actions', () => ({ PluginActions: () => null, usePluginActions: () => [] }));
+vi.mock('@/components/plugins/plugin-actions', () => ({
+ PluginActions: () => null,
+ usePluginActions: () => [],
+ useMessageActionTarget: () => ({ accountKind: 'personal', keywords: [] }),
+}));
// R10: one word per action. The row's quick actions say what the row's menu
// says ("Report spam", "Mark as unread"), not "Mark as Spam" or "Mark Read /
diff --git a/components/email/__tests__/email-list-plugin-filters.test.tsx b/components/email/__tests__/email-list-plugin-filters.test.tsx
new file mode 100644
index 000000000..4e9c534b0
--- /dev/null
+++ b/components/email/__tests__/email-list-plugin-filters.test.tsx
@@ -0,0 +1,84 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import { EmailList } from '../email-list';
+import { useEmailStore } from '@/stores/email-store';
+import { resetListFilters, setPluginListFilters } from '@/lib/plugin-sandbox/list-filters';
+import { confirmGroups, resetConfirmedGroups } from '@/lib/plugin-sandbox/account-kind';
+import type { Mailbox } from '@/lib/jmap/types';
+
+// The plugins' list filter chips in the message list: shown in a team
+// folder's own list, loading the list again when one is turned on, and not
+// in a search, a tag view or the merged views.
+
+const client = vi.hoisted(() => ({
+ getAccountId: () => 'ci',
+ getAccountInfo: (id: string) => (id === 'ch' ? { name: 'org-team@example.org', isPersonal: false, isReadOnly: false, accountCapabilities: {} } : null),
+ getSharedAccounts: () => [
+ { id: 'ci', name: 'org-alice@example.org', isPrimary: true, capabilities: { mail: true } },
+ { id: 'ch', name: 'org-team@example.org', isPrimary: false, capabilities: { mail: true } },
+ ],
+ getPrincipalTypes: async () => ({ ch: 'group' }),
+}));
+
+vi.mock('@/hooks/use-email-drag', () => ({
+ useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
+}));
+vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
+vi.mock('@/stores/auth-store', async () => {
+ const { create } = await import('zustand');
+ return {
+ useAuthStore: create(() => ({
+ client, identities: [], username: null, activeAccountId: 'acct-1',
+ getClientForAccount: () => client, getAllConnectedClients: () => new Map(),
+ })),
+ };
+});
+vi.mock('@/stores/account-store', () => {
+ const state = { getAccountById: () => undefined };
+ return {
+ useAccountStore: Object.assign(
+ (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state),
+ { getState: () => state },
+ ),
+ };
+});
+
+const teamInbox = { id: 'ch:a', originalId: 'a', name: 'Inbox', role: 'inbox', isShared: true, accountId: 'ch', totalEmails: 0, unreadEmails: 0, sortOrder: 0 } as unknown as Mailbox;
+const initialEmailState = useEmailStore.getState();
+const fetchEmails = vi.fn(async () => {});
+
+beforeEach(async () => {
+ resetListFilters();
+ resetConfirmedGroups();
+ fetchEmails.mockClear();
+ useEmailStore.setState(initialEmailState, true);
+ useEmailStore.setState({ selectedMailbox: 'ch:a', mailboxes: [teamInbox], fetchEmails });
+ setPluginListFilters('team-inbox', 'Team inbox', [{ id: 'open', label: 'Open', notKeyword: '$done', accounts: ['group'] }]);
+ await confirmGroups(client as never);
+});
+
+describe('plugin list filters in the message list', () => {
+ it('shows the chip in a team folder and loads the list when it is turned on and off', async () => {
+ render( );
+ const chip = await screen.findByRole('button', { name: 'Open' });
+ expect(fetchEmails).not.toHaveBeenCalled();
+ act(() => { fireEvent.click(chip); });
+ expect(chip).toHaveAttribute('aria-pressed', 'true');
+ expect(fetchEmails).toHaveBeenCalledTimes(1);
+ expect(fetchEmails).toHaveBeenCalledWith(client);
+ act(() => { fireEvent.click(chip); });
+ expect(fetchEmails).toHaveBeenCalledTimes(2);
+ });
+
+ it.each([
+ ['a search', { searchQuery: 'invoice' }],
+ ['a tag view', { selectedKeyword: 'work' }],
+ ['the merged views', { isUnifiedView: true }],
+ ])('leaves %s without chips', async (_name, state) => {
+ useEmailStore.setState(state);
+ render( );
+ await act(async () => {});
+ expect(screen.queryByTestId('plugin-list-filters')).toBeNull();
+ });
+});
diff --git a/components/email/__tests__/email-list-trash-recovery.test.tsx b/components/email/__tests__/email-list-trash-recovery.test.tsx
new file mode 100644
index 000000000..34bb0245f
--- /dev/null
+++ b/components/email/__tests__/email-list-trash-recovery.test.tsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import { JMAPClient } from '@/lib/jmap/client';
+import { useEmailStore } from '@/stores/email-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { useRecoveryStore } from '@/stores/recovery-store';
+import { usePolicyStore } from '@/stores/policy-store';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+import { EmailList } from '../email-list';
+
+// The Trash folder's header links to Recover deleted items when the server
+// offers it; other folders don't show the line.
+
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/hooks/use-email-drag', () => ({ useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }) }));
+vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ stalwartFeaturesEnabled: true, isLoading: false }) }));
+
+const initialEmail = useEmailStore.getState();
+const initialAuth = useAuthStore.getState();
+const initialPolicy = usePolicyStore.getState();
+
+function mailbox(id: string, role: string): Mailbox {
+ return { id, name: id, role, accountId: 'c', totalEmails: 1, unreadEmails: 0, totalThreads: 1, unreadThreads: 0,
+ myRights: { mayReadItems: true, mayAddItems: true, mayRemoveItems: true } } as Mailbox;
+}
+
+function show(selected: 'trash' | 'inbox') {
+ const emails = [{ id: 'm1', threadId: 't1', blobId: 'b1', mailboxIds: { [selected]: true }, keywords: {}, subject: 'Hello',
+ receivedAt: '2026-10-01T00:00:00Z', from: [{ email: 'org-bob@example.org' }], size: 1, hasAttachment: false } as Email];
+ useEmailStore.setState({ emails, mailboxes: [mailbox('trash', 'trash'), mailbox('inbox', 'inbox')], selectedMailbox: selected, totalEmails: 1 });
+ return render( );
+}
+
+beforeEach(() => {
+ const client = JMAPClient.withBearer('https://list.example.invalid', 'synthetic', 'org-alice@example.org');
+ Object.assign(client, { apiUrl: 'https://list.example.invalid/jmap', accountId: 'c' });
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ methodResponses: [] }))));
+ useAuthStore.setState({ activeAccountId: 'acc-alice', client, getClientForAccount: () => client } as never);
+ useEmailStore.setState(initialEmail, true);
+ usePolicyStore.setState({ loaded: true, subject: { email: client.getUsername().toLowerCase(), groups: [] } });
+ // Detection and the look for a recent message already answered for this account.
+ useRecoveryStore.setState({
+ checks: { 'acc-alice|c': { supported: true, retryAt: null } },
+ recent: { 'acc-alice|c': { has: true, days: 0, checkedAt: Date.now() } },
+ });
+});
+
+afterEach(() => {
+ useRecoveryStore.getState().reset();
+ usePolicyStore.setState(initialPolicy, true);
+ useAuthStore.setState(initialAuth, true);
+ vi.unstubAllGlobals();
+});
+
+describe('EmailList and Recover deleted items', () => {
+ it('shows the link in Trash', async () => {
+ show('trash');
+ await waitFor(() => expect(screen.getByTestId('trash-recovery-link')).toBeTruthy());
+ });
+
+ it('leaves it out of other folders', async () => {
+ show('inbox');
+ await screen.findByText('no_more_emails');
+ expect(screen.queryByTestId('trash-recovery-link')).toBeNull();
+ });
+});
diff --git a/components/email/__tests__/external-recipient-confirm-keys.test.tsx b/components/email/__tests__/external-recipient-confirm-keys.test.tsx
new file mode 100644
index 000000000..1fd546120
--- /dev/null
+++ b/components/email/__tests__/external-recipient-confirm-keys.test.tsx
@@ -0,0 +1,64 @@
+import { render, act, fireEvent, cleanup } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { ExternalRecipientConfirmHost } from '../external-recipient-confirm';
+import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store';
+import { resetSendPipeline } from '@/lib/outgoing/send-pipeline';
+
+// The confirm dialog keeps its keys: the mail view's shortcuts listen on the
+// window, and must not see Escape (close the message), j/k (move the list)
+// or Enter (open a message) while the question is open. Real focus trap.
+
+const RECIPIENTS = [{ address: 'org-dave@org-two.test', domain: 'org-two.test', field: 'to' as const }];
+const pageKeys = vi.fn();
+
+beforeEach(() => {
+ useExternalRecipientConfirmStore.setState({ pending: null, queue: [] });
+ window.addEventListener('keydown', pageKeys);
+});
+afterEach(() => {
+ window.removeEventListener('keydown', pageKeys);
+ pageKeys.mockReset();
+ cleanup();
+ resetSendPipeline();
+});
+
+function open() {
+ const view = render( );
+ let answer!: Promise;
+ act(() => { answer = useExternalRecipientConfirmStore.getState().ask(RECIPIENTS); });
+ const back = view.container.querySelector('[data-external-recipient-back]') as HTMLButtonElement;
+ const send = view.container.querySelector('[data-external-recipient-send]') as HTMLButtonElement;
+ return { answer, back, send };
+}
+
+describe('keys in the external recipient confirm', () => {
+ it('puts the focus on Back and keeps shortcut keys from the page', () => {
+ const { back } = open();
+ expect(document.activeElement).toBe(back);
+ for (const key of ['j', 'k', 'Enter', 'Escape']) {
+ act(() => { fireEvent.keyDown(document.activeElement!, { key }); });
+ // Re-open after Escape and Enter answered it.
+ if (!useExternalRecipientConfirmStore.getState().pending) act(() => { void useExternalRecipientConfirmStore.getState().ask(RECIPIENTS); });
+ }
+ expect(pageKeys).not.toHaveBeenCalled();
+ });
+
+ it('answers Back on Escape', async () => {
+ const { answer } = open();
+ act(() => { fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); });
+ expect(await answer).toBe(false);
+ });
+
+ it('presses the focused button on Enter and on Space', async () => {
+ const first = open();
+ first.send.focus();
+ act(() => { fireEvent.keyDown(first.send, { key: 'Enter' }); });
+ expect(await first.answer).toBe(true);
+
+ let second!: Promise;
+ act(() => { second = useExternalRecipientConfirmStore.getState().ask(RECIPIENTS); });
+ act(() => { fireEvent.keyDown(document.activeElement!, { key: ' ' }); });
+ // The focus went back to the first button, Back.
+ expect(await second).toBe(false);
+ });
+});
diff --git a/components/email/__tests__/external-sender-chip-detail.test.tsx b/components/email/__tests__/external-sender-chip-detail.test.tsx
new file mode 100644
index 000000000..fefccd89a
--- /dev/null
+++ b/components/email/__tests__/external-sender-chip-detail.test.tsx
@@ -0,0 +1,106 @@
+import { render, fireEvent, cleanup, screen, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import type { Identity } from '@/lib/jmap/types';
+import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
+import { DEFAULT_POLICY } from '@/lib/admin/types';
+import { usePolicyStore } from '@/stores/policy-store';
+import { useAuthStore } from '@/stores/auth-store';
+
+// Strings with their values, so the domain can be seen in names and text.
+vi.mock('next-intl', () => ({
+ useTranslations: () => (key: string, vars?: Record) =>
+ vars ? `${key}(${Object.values(vars).join(',')})` : key,
+ useLocale: () => 'en',
+}));
+
+const { ExternalSenderChip } = await import('../external-sender-chip');
+
+function setLabel(on: boolean) {
+ usePolicyStore.setState({
+ policy: {
+ ...DEFAULT_POLICY,
+ organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, senderLabel: on } },
+ },
+ });
+}
+
+beforeEach(() => {
+ setLabel(true);
+ useAuthStore.setState({ identities: [{ id: 'i1', name: 'Alice', email: 'alice@example.org' } as unknown as Identity] });
+});
+afterEach(() => cleanup());
+
+describe('External chip on phones and keyboards', () => {
+ it('is a button whose name says the domain, and a tap shows it', () => {
+ render( );
+ const chip = screen.getByRole('button', { name: /org-two\.test/ });
+ expect(chip.getAttribute('aria-expanded')).toBe('false');
+ fireEvent.click(chip);
+ expect(chip.getAttribute('aria-expanded')).toBe('true');
+ expect(document.querySelector('[data-external-sender-detail]')?.textContent).toBe('sender_tooltip(org-two.test)');
+ // Escape and a tap elsewhere close it.
+ fireEvent.keyDown(chip, { key: 'Escape' });
+ expect(document.querySelector('[data-external-sender-detail]')).toBeNull();
+ fireEvent.click(chip);
+ fireEvent.pointerDown(document.body);
+ expect(document.querySelector('[data-external-sender-detail]')).toBeNull();
+ });
+
+ it('shows for any external one of several From addresses', () => {
+ render( );
+ expect(screen.getByRole('button', { name: /evil\.example/ })).toBeTruthy();
+ });
+
+ it('shows for a From without a domain', () => {
+ render( );
+ expect(screen.getByRole('button', { name: /sender_tooltip_no_domain/ })).toBeTruthy();
+ });
+
+ it('stays a plain label inside another button', () => {
+ const { container } = render( );
+ expect(container.querySelectorAll('button')).toHaveLength(1);
+ expect(container.querySelector('[data-external-sender-chip]')?.getAttribute('title')).toBe('sender_tooltip(org-two.test)');
+ });
+});
+
+describe('a message of another signed-in account (unified inbox)', () => {
+ function signInDave(daveScope: Record | null) {
+ usePolicyStore.setState({
+ rawPolicy: {
+ ...DEFAULT_POLICY,
+ organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, senderLabel: true } },
+ scopes: daveScope ? [{ id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: { organisation: { externalMail: daveScope } } }] : [],
+ },
+ });
+ const daveClient = {
+ getUsername: () => 'org-dave@org-two.test',
+ getSharedAccounts: () => [],
+ getAccountInfo: () => null,
+ getPrincipalTypes: async () => ({}),
+ getIdentities: async () => [{ id: 'd1', name: 'Dave', email: 'org-dave@org-two.test' }],
+ };
+ useAuthStore.setState({
+ activeAccountId: 'acc-alice',
+ getClientForAccount: (id: string) => (id === 'acc-dave' ? daveClient : undefined),
+ } as never);
+ }
+
+ it("is judged with that account's identities", async () => {
+ signInDave(null);
+ // For dave, a colleague at org-two.test is internal and alice is not.
+ const colleague = render( );
+ const alice = render( );
+ await waitFor(() => expect(alice.container.querySelector('[data-external-sender-chip]')).not.toBeNull());
+ expect(colleague.container.querySelector('[data-external-sender-chip]')).toBeNull();
+ });
+
+ it("follows that account's scope, not the active one's", async () => {
+ signInDave({ senderLabel: false });
+ const { container } = render( );
+ // The active account has the label on; dave's scope turns it off.
+ await new Promise((r) => setTimeout(r, 20));
+ expect(container.querySelector('[data-external-sender-chip]')).toBeNull();
+ const active = render( );
+ expect(active.container.querySelector('[data-external-sender-chip]')).not.toBeNull();
+ });
+});
diff --git a/components/email/__tests__/external-sender-chip.test.tsx b/components/email/__tests__/external-sender-chip.test.tsx
new file mode 100644
index 000000000..00e271fe2
--- /dev/null
+++ b/components/email/__tests__/external-sender-chip.test.tsx
@@ -0,0 +1,97 @@
+import { render, act } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import type { Email, Identity, Mailbox } from '@/lib/jmap/types';
+import { clearAllHooks } from '@/lib/plugin-hooks';
+import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
+import { DEFAULT_POLICY } from '@/lib/admin/types';
+import { usePolicyStore } from '@/stores/policy-store';
+import { useAuthStore } from '@/stores/auth-store';
+
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
+ useMediaQuery: () => false,
+}));
+vi.mock('@/components/tour/tour-provider', () => ({
+ useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
+}));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
+
+class NoopResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+vi.stubGlobal('ResizeObserver', NoopResizeObserver);
+
+const { EmailViewer } = await import('../email-viewer');
+const { ExternalSenderChip } = await import('../external-sender-chip');
+
+const mailboxes = [
+ { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
+] as unknown as Mailbox[];
+
+const message = (from: string) => ({
+ id: 'm1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
+ from: [{ name: 'Sender', email: from }], to: [{ name: 'Org Alice', email: 'org-alice@example.org' }],
+ subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
+ textBody: [{ partId: '1', type: 'text/html' }], htmlBody: [{ partId: '1', type: 'text/html' }], attachments: [],
+ bodyValues: { '1': { value: 'Body
' } },
+}) as unknown as Email;
+
+function setExternalMail(patch: Partial) {
+ usePolicyStore.setState({
+ policy: {
+ ...DEFAULT_POLICY,
+ organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } },
+ },
+ });
+}
+
+async function openMessage(from: string) {
+ const view = render( );
+ await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
+ return view;
+}
+
+beforeEach(() => {
+ clearAllHooks();
+ setExternalMail({});
+ useAuthStore.setState({ identities: [{ id: 'i1', name: 'Org Alice', email: 'org-alice@example.org' } as unknown as Identity] });
+});
+
+describe('External sender chip', () => {
+ it('names the sender domain in its tooltip when the label is on', () => {
+ setExternalMail({ senderLabel: true });
+ const { container } = render( );
+ const chip = container.querySelector('[data-external-sender-chip]');
+ expect(chip?.textContent).toContain('sender_label');
+ expect(chip?.getAttribute('title')).toBe('sender_tooltip');
+ });
+
+ it('shows in the reader header for a sender outside the organisation', async () => {
+ setExternalMail({ senderLabel: true });
+ const { container } = await openMessage('org-dave@org-two.test');
+ expect(container.querySelectorAll('[data-external-sender-chip]').length).toBeGreaterThan(0);
+ });
+
+ it('stays away for a sender in an identity domain or its subdomain', async () => {
+ setExternalMail({ senderLabel: true });
+ const { container } = await openMessage('org-bob@example.org');
+ expect(container.querySelector('[data-external-sender-chip]')).toBeNull();
+ const sub = render( );
+ expect(sub.container.querySelector('[data-external-sender-chip]')).toBeNull();
+ });
+
+ it('stays away for a domain the admin listed', async () => {
+ setExternalMail({ senderLabel: true, domains: ['org-two.test'] });
+ const { container } = await openMessage('org-dave@org-two.test');
+ expect(container.querySelector('[data-external-sender-chip]')).toBeNull();
+ });
+
+ it('stays away while the label is off, even with the recipient warning on', async () => {
+ setExternalMail({ senderLabel: false, recipientWarning: true });
+ const { container } = await openMessage('org-dave@org-two.test');
+ expect(container.querySelector('[data-external-sender-chip]')).toBeNull();
+ });
+});
diff --git a/components/email/__tests__/plugin-list-filters.test.tsx b/components/email/__tests__/plugin-list-filters.test.tsx
new file mode 100644
index 000000000..99be089c9
--- /dev/null
+++ b/components/email/__tests__/plugin-list-filters.test.tsx
@@ -0,0 +1,64 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { PluginListFilters } from '../plugin-list-filters';
+import {
+ activeListFilterCondition, clearPluginListFilters, resetListFilters, setPluginListFilters,
+} from '@/lib/plugin-sandbox/list-filters';
+
+// The chips plugins' list filters get in the list header (api.ui.setListFilters).
+// The global next-intl mock returns the key, so labels read as keys.
+
+const TEAM = { role: 'inbox', accountKind: 'group' as const };
+
+beforeEach(() => {
+ resetListFilters();
+ setPluginListFilters('team-inbox', 'Team inbox', [
+ { id: 'mine', label: 'Assigned to me', icon: 'user', hasKeyword: '$assigned:ci', accounts: ['group'], order: 1 },
+ { id: 'open', label: 'Open', notKeyword: '$done', accounts: ['group'], order: 2 },
+ ]);
+});
+
+describe('PluginListFilters', () => {
+ it('shows the chips of a team folder, off at first', () => {
+ render( {}} />);
+ const chips = screen.getAllByRole('button');
+ expect(chips.map((c) => c.textContent)).toEqual(['Assigned to me', 'Open']);
+ expect(chips.every((c) => c.getAttribute('aria-pressed') === 'false')).toBe(true);
+ expect(screen.getByRole('group')).toHaveAttribute('aria-label', 'filters');
+ });
+
+ it('shows nothing in the user\'s own folders', () => {
+ const { container } = render( {}} />);
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('turns a filter on with a click, loads the list, and removes it with the next', () => {
+ const onChange = vi.fn();
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Open' }));
+ // The name stays the label; pressed says it's on, the tooltip how to remove it.
+ const active = screen.getByRole('button', { name: 'Open' });
+ expect(active).toHaveAttribute('aria-pressed', 'true');
+ expect(active).toHaveAttribute('title', 'remove_filter');
+ expect(active.querySelector('svg')).not.toBeNull();
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(activeListFilterCondition(TEAM)).toEqual({ notKeyword: '$done' });
+
+ fireEvent.click(active);
+ expect(screen.getByRole('button', { name: 'Open' })).toHaveAttribute('aria-pressed', 'false');
+ expect(onChange).toHaveBeenCalledTimes(2);
+ expect(activeListFilterCondition(TEAM)).toBeNull();
+ });
+
+ it('loads the list again when the plugin that offered an active filter goes away', () => {
+ const onChange = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'Open' }));
+ onChange.mockClear();
+
+ act(() => clearPluginListFilters('team-inbox'));
+ expect(screen.queryByRole('group')).toBeNull();
+ expect(onChange).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/components/email/__tests__/plugin-row-badges.test.tsx b/components/email/__tests__/plugin-row-badges.test.tsx
index 149d9a559..5db64285b 100644
--- a/components/email/__tests__/plugin-row-badges.test.tsx
+++ b/components/email/__tests__/plugin-row-badges.test.tsx
@@ -89,6 +89,22 @@ describe('plugin badges in message-list rows', () => {
expect(ctx.email).not.toHaveProperty('mailboxIds.inbox');
});
+ it('draws a badge\'s icon before its label, and none for a name that isn\'t one', async () => {
+ renderHooks.onEmailListItemRender.register('test', async () => [
+ { key: 'done', label: 'Done', icon: 'check' },
+ { key: 'bad', label: 'Bad', icon: 'x">`, isHTML: true, category: '' });
+
+const RAW: SettingsPolicy = {
+ ...DEFAULT_POLICY,
+ organisation: { ...DEFAULT_ORGANISATION_POLICY, templates: { library: [lib('welcome', 'Welcome')] } },
+ scopes: [
+ { id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: { organisation: { templates: { library: [lib('two', 'Two only')] } } } },
+ ],
+};
+const ALICE = { email: 'alice@example.org', groups: [] };
+const DAVE_CLIENT = {
+ getUsername: () => 'dave@org-two.test',
+ getSharedAccounts: () => [],
+ getAccountInfo: () => null,
+};
+
+const original = useAuthStore.getState();
+
+function setPolicy(raw: SettingsPolicy) {
+ usePolicyStore.setState({ rawPolicy: raw, policy: resolvePolicy(raw, ALICE).policy, subject: ALICE });
+}
+
+beforeEach(() => {
+ resetAccountSubjects();
+ useAuthStore.setState({
+ activeAccountId: 'a1',
+ getClientForAccount: (id: string) => (id === 'a2' ? DAVE_CLIENT : undefined),
+ } as never);
+ setPolicy(RAW);
+ useTemplateStore.setState({ templates: [own('mine', 'Mine')], recentTemplateIds: [], deletedTemplateIds: {} });
+ useAccountTemplatesStore.setState({ personal: {}, teams: {}, active: null });
+});
+afterEach(() => {
+ resetAccountSubjects();
+ useAuthStore.setState({ activeAccountId: original.activeAccountId, getClientForAccount: original.getClientForAccount } as never);
+});
+
+const headings = () => screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent).slice(1);
+
+describe('template picker sources', () => {
+ it('heads the user\'s own templates and the organisation library', () => {
+ render( {}} onSelect={() => {}} />);
+ expect(headings()).toEqual(['section_personal', 'section_organisation']);
+ const organisation = screen.getByTestId('template-source-organisation');
+ expect(within(organisation).getByText('Welcome')).toBeInTheDocument();
+ expect(within(organisation).queryByText('Two only')).toBeNull();
+ expect(within(screen.getByTestId('template-source-personal')).getByText('Mine')).toBeInTheDocument();
+ });
+
+ it('looks as before when there is no organisation library', () => {
+ setPolicy({ ...RAW, organisation: DEFAULT_ORGANISATION_POLICY, scopes: [] });
+ render( {}} onSelect={() => {}} />);
+ expect(headings()).toEqual([]);
+ expect(screen.queryByTestId('template-source-personal')).toBeNull();
+ expect(screen.getByText('Mine')).toBeInTheDocument();
+ });
+
+ it('shows the library of the account the message is sent from', () => {
+ render( {}} onSelect={() => {}} accountId="a2" />);
+ const organisation = screen.getByTestId('template-source-organisation');
+ expect(within(organisation).getByText('Welcome')).toBeInTheDocument();
+ expect(within(organisation).getByText('Two only')).toBeInTheDocument();
+ });
+
+ it('hands an organisation template to the composer sanitised', () => {
+ const onSelect = vi.fn();
+ render( {}} onSelect={onSelect} />);
+ fireEvent.click(screen.getByText('Welcome'));
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ const template = onSelect.mock.calls[0][0] as EmailTemplate;
+ expect(template.subject).toBe('Welcome subject');
+ expect(template.body).toContain('Welcome
');
+ expect(template.body).not.toContain('script');
+ // Recently used is for the user's own templates.
+ expect(useTemplateStore.getState().recentTemplateIds).toEqual([]);
+ });
+
+ it('leaves out personal templates the admin hid', () => {
+ setPolicy({ ...RAW, restrictions: { personalTemplates: { hidden: true } } });
+ render( {}} onSelect={() => {}} />);
+ expect(screen.queryByText('Mine')).toBeNull();
+ expect(screen.getByText('Welcome')).toBeInTheDocument();
+ });
+
+ it('searches every source under its heading', () => {
+ useTemplateStore.setState({ templates: [own('mine', 'Welcome back')] });
+ render( {}} onSelect={() => {}} />);
+ fireEvent.change(screen.getByPlaceholderText('search_placeholder'), { target: { value: 'welc' } });
+ expect(within(screen.getByTestId('template-source-personal')).getByText('Welcome back')).toBeInTheDocument();
+ expect(within(screen.getByTestId('template-source-organisation')).getByText('Welcome')).toBeInTheDocument();
+ });
+
+ it('shows the own templates of the account the message is sent from once it keeps them', () => {
+ useAccountTemplatesStore.setState({ personal: { a2: { ...emptyPartition(), templates: [own('dave-1', "Dave's own")] } } });
+ const { unmount } = render( {}} onSelect={() => {}} accountId="a2" />);
+ expect(screen.getByText("Dave's own")).toBeInTheDocument();
+ expect(screen.queryByText('Mine')).toBeNull();
+ unmount();
+ // The active account has no copy of its own yet: the browser's list.
+ render( {}} onSelect={() => {}} />);
+ expect(screen.getByText('Mine')).toBeInTheDocument();
+ expect(screen.queryByText("Dave's own")).toBeNull();
+ });
+
+ it('shows who a template addresses before it is inserted', () => {
+ useTemplateStore.setState({ templates: [own('mine', 'Mine', { defaultRecipients: { to: ['a@x.test', 'b@x.test'], cc: ['c@x.test'], bcc: ['hidden@x.test'] } })] });
+ render( {}} onSelect={() => {}} />);
+ const line = screen.getByTestId('template-recipients');
+ expect(line).toHaveTextContent('preview_to');
+ expect(line).toHaveTextContent('preview_cc');
+ });
+});
diff --git a/components/templates/__tests__/template-team-sources.test.tsx b/components/templates/__tests__/template-team-sources.test.tsx
new file mode 100644
index 000000000..986cf7f83
--- /dev/null
+++ b/components/templates/__tests__/template-team-sources.test.tsx
@@ -0,0 +1,160 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+
+// Team templates of a group mailbox: under the group's name in the picker,
+// editable in the manager by members who may, read-only with "Set by your
+// organisation" when the admin locked them (`teamTemplates`).
+
+const save = vi.hoisted(() => vi.fn(async (..._args: unknown[]): Promise => 'ok'));
+vi.mock('@/lib/templates/team-templates', () => ({
+ saveTeamTemplate: save,
+ deleteTeamTemplate: vi.fn(async () => 'ok'),
+ refreshTeamTemplates: vi.fn(),
+}));
+
+import { TemplatePicker } from '../template-picker';
+import { TemplateManagerModal } from '../template-manager-modal';
+import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types';
+import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
+import { resolvePolicy } from '@/lib/admin/policy-scopes';
+import { resetAccountSubjects } from '@/lib/org/account-subjects';
+import { usePolicyStore } from '@/stores/policy-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { useTemplateStore } from '@/stores/template-store';
+import { useAccountTemplatesStore } from '@/stores/account-templates-store';
+import type { EmailTemplate } from '@/lib/template-types';
+
+const tpl = (id: string, name: string): EmailTemplate => ({
+ id, name, subject: '', body: `${name} body`, category: '', isFavorite: false,
+ createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
+});
+
+const RAW: SettingsPolicy = {
+ ...DEFAULT_POLICY,
+ organisation: { ...DEFAULT_ORGANISATION_POLICY, templates: { library: [{ id: 'org-1', name: 'Company', subject: '', body: 'x', isHTML: false, category: '' }] } },
+};
+const ALICE = { email: 'alice@example.org', groups: ['team@example.org'] };
+
+function setPolicy(raw: SettingsPolicy) {
+ usePolicyStore.setState({ rawPolicy: raw, policy: resolvePolicy(raw, ALICE).policy, subject: ALICE });
+}
+
+function setTeam(mayWrite = true) {
+ useAccountTemplatesStore.setState({
+ personal: {},
+ teams: { a1: [{ accountId: 'g1', name: 'team@example.org', templates: [tpl('t1', 'Team reply')], mayWrite }] },
+ active: 'a1',
+ });
+}
+
+const original = useAuthStore.getState();
+
+beforeEach(() => {
+ save.mockClear();
+ save.mockResolvedValue('ok');
+ resetAccountSubjects();
+ useAuthStore.setState({ activeAccountId: 'a1' } as never);
+ setPolicy(RAW);
+ useTemplateStore.setState({ templates: [tpl('mine', 'Mine')], recentTemplateIds: [], deletedTemplateIds: {} });
+ setTeam();
+});
+afterEach(() => {
+ resetAccountSubjects();
+ useAuthStore.setState({ activeAccountId: original.activeAccountId } as never);
+});
+
+describe('team templates in the picker', () => {
+ it('heads them with the group\'s name between the user\'s own and the organisation\'s', () => {
+ render( {}} onSelect={() => {}} />);
+ const headings = screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent).slice(1);
+ expect(headings).toEqual(['section_personal', 'team@example.org', 'section_organisation']);
+ expect(within(screen.getByTestId('template-source-team')).getByText('Team reply')).toBeInTheDocument();
+ });
+
+ it('shows a team template the admin locked, which members still use', () => {
+ setPolicy({ ...RAW, restrictions: { teamTemplates: { locked: true } } });
+ const onSelect = vi.fn();
+ render( {}} onSelect={onSelect} />);
+ fireEvent.click(screen.getByText('Team reply'));
+ expect(onSelect.mock.calls[0][0]).toMatchObject({ id: 't1', body: 'Team reply body' });
+ });
+});
+
+describe('team templates in the manager', () => {
+ it('adds a template to the group mailbox', async () => {
+ render( {}} />);
+ fireEvent.click(screen.getByTestId('template-source-tab-team'));
+ expect(screen.getByTestId('template-source-note')).toHaveTextContent('team_note');
+ fireEvent.click(screen.getByRole('button', { name: 'add' }));
+ // A team template has no sending identity and no favourite.
+ expect(screen.queryByText('favorite')).toBeNull();
+ fireEvent.change(screen.getByPlaceholderText('name_placeholder'), { target: { value: 'For the team' } });
+ fireEvent.click(screen.getByRole('button', { name: 'create' }));
+ await waitFor(() => expect(save).toHaveBeenCalledTimes(1));
+ expect(save.mock.calls[0][0]).toBe('a1');
+ expect(save.mock.calls[0][1]).toBe('g1');
+ expect(save.mock.calls[0][2]).toMatchObject({ name: 'For the team' });
+ });
+
+ it('keeps what the user typed when the server refuses it', async () => {
+ save.mockResolvedValue('too-large');
+ render( {}} />);
+ fireEvent.click(screen.getByTestId('template-source-tab-team'));
+ fireEvent.click(screen.getByRole('button', { name: 'add' }));
+ fireEvent.change(screen.getByPlaceholderText('name_placeholder'), { target: { value: 'Too long' } });
+ fireEvent.click(screen.getByRole('button', { name: 'create' }));
+ expect(await screen.findByTestId('template-save-error')).toHaveTextContent('save_failed_too_large');
+ expect((screen.getByPlaceholderText('name_placeholder') as HTMLInputElement).value).toBe('Too long');
+ });
+
+ it('is read-only with "Set by your organisation" when the admin locked team templates', () => {
+ setPolicy({ ...RAW, restrictions: { teamTemplates: { locked: true } } });
+ render( {}} />);
+ fireEvent.click(screen.getByTestId('template-source-tab-team'));
+ expect(screen.getByTestId('template-source-note').querySelector('[data-org-locked]')).not.toBeNull();
+ expect(screen.queryByRole('button', { name: 'add' })).toBeNull();
+ const row = screen.getByTestId('template-row');
+ expect(within(row).queryByRole('button', { name: 'edit' })).toBeNull();
+ expect(within(row).queryByRole('button', { name: 'delete' })).toBeNull();
+ expect(within(row).getByRole('button', { name: 'copy_to_personal' })).toBeInTheDocument();
+ });
+
+ it('is read-only for a member without write rights, and says so', () => {
+ setTeam(false);
+ render( {}} />);
+ fireEvent.click(screen.getByTestId('template-source-tab-team'));
+ expect(screen.getByTestId('template-source-note')).toHaveTextContent('team_read_only');
+ expect(screen.queryByRole('button', { name: 'add' })).toBeNull();
+ });
+
+ it('says that someone else changed it, and keeps what the user typed', async () => {
+ save.mockResolvedValue('conflict');
+ render( {}} />);
+ fireEvent.click(screen.getByTestId('template-source-tab-team'));
+ fireEvent.click(screen.getByRole('button', { name: 'edit' }));
+ fireEvent.change(screen.getByPlaceholderText('name_placeholder'), { target: { value: 'My change' } });
+ fireEvent.click(screen.getByRole('button', { name: 'update' }));
+ expect(await screen.findByTestId('template-save-error')).toHaveTextContent('save_failed_conflict');
+ expect((screen.getByPlaceholderText('name_placeholder') as HTMLInputElement).value).toBe('My change');
+ // The edit started from the version the user saw.
+ expect(save.mock.calls[0][3]).toBe('2026-01-01T00:00:00.000Z');
+ });
+
+ it('moves between the lists with the arrow keys, as a tab list', () => {
+ render( {}} />);
+ const personal = screen.getByTestId('template-source-tab-personal');
+ expect(personal).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', personal.id);
+ personal.focus();
+ fireEvent.keyDown(personal, { key: 'ArrowRight' });
+ const team = screen.getByTestId('template-source-tab-team');
+ expect(team).toHaveAttribute('aria-selected', 'true');
+ expect(document.activeElement).toBe(team);
+ expect(team).toHaveAttribute('tabindex', '0');
+ expect(personal).toHaveAttribute('tabindex', '-1');
+ fireEvent.keyDown(team, { key: 'End' });
+ expect(screen.getByTestId('template-source-tab-organisation')).toHaveAttribute('aria-selected', 'true');
+ fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' });
+ expect(personal).toHaveAttribute('aria-selected', 'true');
+ });
+});
diff --git a/components/templates/account-template-sync.tsx b/components/templates/account-template-sync.tsx
new file mode 100644
index 000000000..e2fb2167b
--- /dev/null
+++ b/components/templates/account-template-sync.tsx
@@ -0,0 +1,56 @@
+'use client';
+
+import { useEffect } from 'react';
+import { useAuthStore } from '@/stores/auth-store';
+import { useAccountTemplatesStore } from '@/stores/account-templates-store';
+import { refreshAccountTemplates, scheduleAccountSync } from '@/lib/templates/personal-templates';
+import { useTemplateStore } from '@/stores/template-store';
+import { onSettingsLoadSettled } from '@/stores/settings-store';
+import { refreshTeamTemplates } from '@/lib/templates/team-templates';
+
+/**
+ * Syncs the active account's templates with the account and reads its
+ * team templates (Stalwart 1.0 metadata) when it signs in or becomes
+ * active, and again when the tab comes back into view. Renders nothing;
+ * does nothing on servers without metadata.
+ */
+export function AccountTemplateSync() {
+ const activeAccountId = useAuthStore((s) => s.activeAccountId);
+ const client = useAuthStore((s) => s.client);
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+
+ useEffect(() => {
+ useAccountTemplatesStore.setState({ active: isAuthenticated ? activeAccountId : null });
+ }, [activeAccountId, isAuthenticated]);
+
+ // The browser's list changing (the account's synced settings just
+ // loaded, or a template changed there) is merged in at once.
+ useEffect(() => {
+ if (!isAuthenticated || !activeAccountId) return;
+ const unsubscribe = useTemplateStore.subscribe(() => scheduleAccountSync(activeAccountId));
+ // Once the account's synced settings have loaded, its browser list is
+ // known, even when the load changed nothing in it.
+ const unlisten = onSettingsLoadSettled((accountId) => {
+ if (accountId === activeAccountId) scheduleAccountSync(activeAccountId);
+ });
+ return () => {
+ unsubscribe();
+ unlisten();
+ };
+ }, [activeAccountId, isAuthenticated]);
+
+ useEffect(() => {
+ if (!isAuthenticated || !activeAccountId || !client) return;
+ refreshAccountTemplates(activeAccountId, 0);
+ refreshTeamTemplates(activeAccountId, 0);
+ const onVisible = () => {
+ if (document.visibilityState !== 'visible') return;
+ refreshAccountTemplates(activeAccountId, 60_000);
+ refreshTeamTemplates(activeAccountId, 60_000);
+ };
+ document.addEventListener('visibilitychange', onVisible);
+ return () => document.removeEventListener('visibilitychange', onVisible);
+ }, [activeAccountId, client, isAuthenticated]);
+
+ return null;
+}
diff --git a/components/templates/template-body-preview.tsx b/components/templates/template-body-preview.tsx
new file mode 100644
index 000000000..46a9af01e
--- /dev/null
+++ b/components/templates/template-body-preview.tsx
@@ -0,0 +1,17 @@
+'use client';
+
+import { sanitizeEmailHtml } from '@/lib/email-sanitization';
+import type { EmailTemplate } from '@/lib/template-types';
+
+/** A template's body as it goes into the composer, for templates the user can't open in the editor. */
+export function TemplateBodyPreview({ template }: { template: Pick }) {
+ if (template.isHTML) {
+ return (
+
+ );
+ }
+ return {template.body} ;
+}
diff --git a/components/templates/template-form.tsx b/components/templates/template-form.tsx
index 794c0cca6..ee0823cb7 100644
--- a/components/templates/template-form.tsx
+++ b/components/templates/template-form.tsx
@@ -26,17 +26,27 @@ interface TemplateFormProps {
};
onSave: (data: Omit) => void;
onCancel: () => void;
+ /**
+ * A template others use too (a team's): no sending identity, since
+ * identity ids are each member's own, and no favourite.
+ */
+ shared?: boolean;
+ /** A save is under way: the save button waits. */
+ busy?: boolean;
+ /** The templates whose categories the category field suggests; the browser's list when left out. */
+ categorySource?: EmailTemplate[];
/** Told whenever the form gets or loses changes, so its host can ask before closing (R5). */
onDirtyChange?: (dirty: boolean) => void;
}
-export function TemplateForm({ template, initialData, onSave, onCancel, onDirtyChange }: TemplateFormProps) {
+export function TemplateForm({ template, initialData, onSave, onCancel, shared = false, busy = false, categorySource, onDirtyChange }: TemplateFormProps) {
const t = useTranslations('templates');
const tSettings = useTranslations('settings.templates');
const tComposer = useTranslations('email_composer');
const { identities } = useAuthStore();
- const templates = useTemplateStore((s) => s.templates);
+ const browserList = useTemplateStore((s) => s.templates);
+ const templates = categorySource ?? browserList;
const [name, setName] = useState(template?.name || '');
const [category, setCategory] = useState(template?.category || '');
@@ -98,8 +108,8 @@ export function TemplateForm({ template, initialData, onSave, onCancel, onDirtyC
defaultRecipients: to.length || cc.length || bcc.length
? { to: to.length ? to : undefined, cc: cc.length ? cc : undefined, bcc: bcc.length ? bcc : undefined }
: undefined,
- identityId: identityId || undefined,
- isFavorite,
+ identityId: shared ? undefined : identityId || undefined,
+ isFavorite: shared ? false : isFavorite,
});
};
@@ -227,7 +237,7 @@ export function TemplateForm({ template, initialData, onSave, onCancel, onDirtyC
- {identities.length > 1 && (
+ {!shared && identities.length > 1 && (
{tSettings('identity')}
- setIsFavorite(!isFavorite)}
- className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
- >
-
- {tSettings('favorite')}
-
+ {shared ? : (
+ setIsFavorite(!isFavorite)}
+ className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
+ >
+
+ {tSettings('favorite')}
+
+ )}
{tSettings('cancel')}
-
+
{template ? tSettings('update') : tSettings('create')}
diff --git a/components/templates/template-manager-modal.tsx b/components/templates/template-manager-modal.tsx
index 67788d447..8be08bc1c 100644
--- a/components/templates/template-manager-modal.tsx
+++ b/components/templates/template-manager-modal.tsx
@@ -1,8 +1,8 @@
'use client';
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
-import { FileText, Pencil, Trash2, Star, CopyPlus, Search } from '@/components/icons';
+import { FileText, Pencil, Trash2, Star, Copy, CopyPlus, Search, Eye } from '@/components/icons';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { IconButton } from '@/components/ui/icon-button';
@@ -13,45 +13,66 @@ import { EmptyState } from '@/components/ui/empty-state';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import { useDiscardChanges } from '@/components/settings/use-discard-changes';
import { TemplateForm } from './template-form';
-import { useTemplateStore } from '@/stores/template-store';
+import { TemplateBodyPreview } from './template-body-preview';
+import { useTemplateSources } from '@/hooks/use-template-sources';
+import { useTemplateSourceActions, type TemplateSaveResult } from '@/hooks/use-template-source-actions';
+import { OrgLockedNote } from '@/components/settings/org-locked-note';
+import { filterTemplates } from '@/lib/template-utils';
+import { PERSONAL_SOURCE, type TemplateSource } from '@/lib/templates/template-sources';
+import { refreshAccountTemplates, resolveAccountId } from '@/lib/templates/personal-templates';
+import { refreshTeamTemplates } from '@/lib/templates/team-templates';
import type { EmailTemplate } from '@/lib/template-types';
interface TemplateManagerModalProps {
isOpen: boolean;
onClose: () => void;
+ /** The signed-in account whose templates are managed; the active one when left out. */
+ accountId?: string | null;
}
-export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalProps) {
+type TemplateData = Omit;
+
+const PANEL_ID = 'template-source-panel';
+const tabId = (key: string) => `template-source-tab-${key.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
+
+export function TemplateManagerModal({ isOpen, onClose, accountId }: TemplateManagerModalProps) {
const t = useTranslations('templates');
const tSettings = useTranslations('settings.templates');
const tCommon = useTranslations('common');
+ const tOrg = useTranslations('org_templates');
- const {
- templates,
- addTemplate,
- updateTemplate,
- deleteTemplate,
- duplicateTemplate,
- toggleFavorite,
- searchTemplates,
- } = useTemplateStore();
+ const { sources, personalHidden } = useTemplateSources(accountId);
+ const actions = useTemplateSourceActions(accountId);
+ const [selectedKey, setSelectedKey] = useState(PERSONAL_SOURCE);
const [editingId, setEditingId] = useState(null);
const [isCreating, setIsCreating] = useState(false);
const [formDirty, setFormDirty] = useState(false);
+ const [previewId, setPreviewId] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
const { confirmDiscard, discardDialog } = useDiscardChanges();
const { dialogProps: deleteDialogProps, confirm: confirmDelete } = useConfirmDialog();
- const filtered = searchQuery ? searchTemplates(searchQuery) : templates;
+ const source: TemplateSource | undefined = sources.find((s) => s.key === selectedKey) ?? sources[0];
+ const personal = sources.find((s) => s.kind === 'personal');
const formOpen = isCreating || !!editingId;
const closeForm = () => {
setIsCreating(false);
setEditingId(null);
setFormDirty(false);
+ setError(null);
};
+ useEffect(() => {
+ if (!isOpen) return;
+ refreshAccountTemplates(accountId);
+ const id = resolveAccountId(accountId);
+ if (id) refreshTeamTemplates(id);
+ }, [isOpen, accountId]);
+
// Escape, the X and Cancel ask first while the form holds changes (R5).
const requestClose = async () => {
if (!(await confirmDiscard(formDirty))) return;
@@ -63,16 +84,45 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
closeForm();
};
- const handleSave = (data: Omit) => {
- if (editingId) {
- updateTemplate(editingId, data);
- } else {
- addTemplate(data);
- }
+ const selectSource = (key: string) => {
+ setSelectedKey(key);
closeForm();
+ setPreviewId(null);
+ };
+
+ const sourceTitle = (s: TemplateSource) =>
+ s.kind === 'organisation' ? tOrg('section_organisation')
+ : s.kind === 'team' ? (s.name ?? '')
+ : tOrg('section_personal');
+
+ const errorText = (result: TemplateSaveResult) =>
+ result === 'too-large' ? tOrg('save_failed_too_large')
+ : result === 'no-room' ? tOrg('save_failed_no_room')
+ : result === 'forbidden' ? tOrg('save_failed_forbidden')
+ : result === 'conflict' ? tOrg('save_failed_conflict')
+ : tOrg('save_failed');
+
+ // A failed save keeps the form open with what the user typed.
+ const run = async (step: () => Promise, after: () => void) => {
+ setBusy(true);
+ setError(null);
+ try {
+ const result = await step();
+ if (result === 'ok') after();
+ else setError(errorText(result));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const handleSave = (data: TemplateData) => {
+ if (!source?.editable) return;
+ if (editingId) void run(() => actions.update(source, editingId, data), closeForm);
+ else void run(() => actions.add(source, data), closeForm);
};
const handleDelete = async (template: EmailTemplate) => {
+ if (!source?.editable) return;
const ok = await confirmDelete({
title: t('delete_title'),
message: t('delete_message', { name: template.name }),
@@ -80,9 +130,24 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
cancelText: tCommon('cancel'),
variant: 'destructive',
});
- if (ok) deleteTemplate(template.id);
+ if (ok) await run(() => actions.remove(source, template.id), () => {});
};
+ const templates = source?.templates ?? [];
+ const filtered = searchQuery ? filterTemplates(templates, searchQuery) : templates;
+ const editable = !!source?.editable;
+ const isPersonal = source?.kind === 'personal';
+ const showTabs = sources.length > 1 || personalHidden;
+
+ let note: React.ReactNode = null;
+ if (source?.kind === 'organisation') {
+ note = {tOrg('organisation_note')}
;
+ } else if (source?.kind === 'team') {
+ note = source.locked
+ ?
+ : {source.editable ? tOrg('team_note', { name: source.name ?? '' }) : tOrg('team_read_only')}
;
+ }
+
return (
<>
- {!formOpen && (
+ {/* The mail shortcuts behind the dialog must not see its keys. */}
+ e.stopPropagation()}>
+ {showTabs && (
+
{
+ // Arrow keys, Home and End move between the lists, as in any tab list.
+ const index = sources.findIndex((s) => s.key === source?.key);
+ const next = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? index + 1
+ : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? index - 1
+ : e.key === 'Home' ? 0
+ : e.key === 'End' ? sources.length - 1
+ : null;
+ if (next === null || sources.length === 0) return;
+ e.preventDefault();
+ const target = sources[(next + sources.length) % sources.length];
+ selectSource(target.key);
+ document.getElementById(tabId(target.key))?.focus();
+ }}
+ >
+ {sources.map((s) => (
+ selectSource(s.key)}
+ className={cn(
+ 'max-w-full truncate rounded-full border px-3 py-1 text-sm transition-colors',
+ s.key === source?.key
+ ? 'border-primary bg-primary/10 text-primary'
+ : 'border-border text-muted-foreground hover:text-foreground hover:bg-muted'
+ )}
+ >
+ {sourceTitle(s)} {s.templates.length}
+
+ ))}
+
+ )}
+
+
+ {note &&
{note}
}
+
+ {!source && (
+
+ )}
+
+ {source && !formOpen && (
@@ -105,16 +228,25 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
className="ps-9"
/>
-
setIsCreating(true)} size="sm">
- {tSettings('add')}
-
+ {editable && (
+
{ setIsCreating(true); setError(null); }} size="sm">
+ {tSettings('add')}
+
+ )}
)}
- {isCreating && (
+ {error && (
+
{error}
+ )}
+
+ {editable && isCreating && (
{tSettings('add')}
void requestCloseForm()}
onDirtyChange={setFormDirty}
@@ -122,11 +254,14 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
)}
- {editingId && (
+ {editable && editingId && (
{tSettings('edit')}
tpl.id === editingId)}
+ categorySource={templates}
+ shared={!isPersonal}
+ busy={busy}
onSave={handleSave}
onCancel={() => void requestCloseForm()}
onDirtyChange={setFormDirty}
@@ -135,79 +270,124 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
)}
- {filtered.map((template) => (
-
-
toggleFavorite(template.id)}
- className="flex-shrink-0 -ms-1.5"
- >
-
-
+ {source && filtered.map((template) => (
+
+
+ {isPersonal && (
+
void actions.toggleFavorite(source, template.id)}
+ className="flex-shrink-0 -ms-1.5"
+ >
+
+
+ )}
-
-
-
- {template.name}
-
- {template.category && (
-
- {template.category}
-
+
+
+
+ {template.name}
+
+ {template.category && (
+
+ {template.category}
+
+ )}
+
+ {template.subject && (
+
+ {template.subject}
+
+ )}
+ {isPersonal && actions.localOnly[template.id] && (
+
+ {actions.localOnly[template.id] === 'too-large' ? tOrg('local_only_too_large') : tOrg('local_only_no_room')}
+
)}
- {template.subject && (
-
- {template.subject}
-
- )}
-
-
-
{ setEditingId(template.id); setIsCreating(false); }}
- disabled={formOpen}
- >
-
-
-
duplicateTemplate(template.id, t('copy_suffix'))}
- disabled={formOpen}
- >
-
-
-
void handleDelete(template)}
- disabled={formOpen}
- >
-
-
+
+ {editable ? (
+ <>
+
{ setEditingId(template.id); setIsCreating(false); setError(null); }}
+ disabled={formOpen}
+ className="pointer-coarse:h-11 pointer-coarse:w-11"
+ >
+
+
+
void run(() => actions.duplicate(source, template.id, t('copy_suffix')), () => {})}
+ disabled={formOpen || busy}
+ className="pointer-coarse:h-11 pointer-coarse:w-11"
+ >
+
+
+
void handleDelete(template)}
+ disabled={formOpen || busy}
+ className="pointer-coarse:h-11 pointer-coarse:w-11"
+ >
+
+
+ >
+ ) : (
+ <>
+
setPreviewId(previewId === template.id ? null : template.id)}
+ aria-expanded={previewId === template.id}
+ className="pointer-coarse:h-11 pointer-coarse:w-11"
+ >
+
+
+ {personal?.editable && (
+
void run(() => actions.copyToPersonal(template), () => {})}
+ disabled={busy}
+ className="pointer-coarse:h-11 pointer-coarse:w-11"
+ >
+
+
+ )}
+ >
+ )}
+
+ {previewId === template.id && (
+
+
+
+ )}
))}
- {filtered.length === 0 && !isCreating && (
+ {source && filtered.length === 0 && !isCreating && (
setIsCreating(true)}>{tSettings('add')}
+ actions={searchQuery || !editable ? undefined : (
+ { setIsCreating(true); setError(null); }}>{tSettings('add')}
)}
/>
)}
+
+ {isPersonal && actions.storedInAccount && (
+
{tOrg('personal_in_account')}
+ )}
+
+
{discardDialog}
diff --git a/components/templates/template-picker.tsx b/components/templates/template-picker.tsx
index 40053a35b..8580612dc 100644
--- a/components/templates/template-picker.tsx
+++ b/components/templates/template-picker.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { Search, Star, FileText, Settings } from '@/components/icons';
import { Input } from '@/components/ui/input';
@@ -8,43 +8,61 @@ import { Button } from '@/components/ui/button';
import { Dialog } from '@/components/ui/dialog';
import { useTemplateStore } from '@/stores/template-store';
import { useAuthStore } from '@/stores/auth-store';
-import { TemplateManagerModal } from './template-manager-modal';
+import { useTemplateSources } from '@/hooks/use-template-sources';
import {
+ filterTemplates,
getPlaceholdersFromTemplate,
getAutoFilledPlaceholders,
} from '@/lib/template-utils';
+import type { TemplateSource } from '@/lib/templates/template-sources';
+import { refreshAccountTemplates, resolveAccountId } from '@/lib/templates/personal-templates';
+import { refreshTeamTemplates } from '@/lib/templates/team-templates';
import { PlaceholderFillModal } from './placeholder-fill-modal';
+import { TemplateManagerModal } from './template-manager-modal';
import type { EmailTemplate } from '@/lib/template-types';
interface TemplatePickerProps {
isOpen: boolean;
onClose: () => void;
onSelect: (template: EmailTemplate, filledValues: Record
) => void;
+ /**
+ * The signed-in account the message is written from: its own templates,
+ * its team templates and its organisation library. The active one when
+ * left out.
+ */
+ accountId?: string | null;
}
-export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProps) {
+export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: TemplatePickerProps) {
const t = useTranslations('templates');
+ const tOrg = useTranslations('org_templates');
const locale = useLocale();
- const { templates, getFavorites, getRecent, getTemplatesByCategory, searchTemplates, recordUsage } =
- useTemplateStore();
+ const recordUsage = useTemplateStore((s) => s.recordUsage);
+ const recentTemplateIds = useTemplateStore((s) => s.recentTemplateIds);
const { primaryIdentity } = useAuthStore();
+ const { sources } = useTemplateSources(accountId);
const [searchQuery, setSearchQuery] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState(null);
const [showFillModal, setShowFillModal] = useState(false);
+
+ // What another device or a team member changed shows up when the picker opens.
+ useEffect(() => {
+ if (!isOpen) return;
+ refreshAccountTemplates(accountId);
+ const id = resolveAccountId(accountId);
+ if (id) refreshTeamTemplates(id);
+ }, [isOpen, accountId]);
+
// "Manage templates" (MAIL-31): the template manager in place of the
// picker, which comes back when it closes.
const [showManager, setShowManager] = useState(false);
- const favorites = getFavorites();
- const recent = getRecent();
- const byCategory = getTemplatesByCategory();
- const filtered = searchQuery ? searchTemplates(searchQuery) : null;
-
- const handleSelectTemplate = (template: EmailTemplate) => {
+ const handleSelectTemplate = (template: EmailTemplate, source: TemplateSource) => {
const placeholders = getPlaceholdersFromTemplate(template);
- recordUsage(template.id);
+ // Recently used lists the user's own templates only.
+ if (source.kind === 'personal') recordUsage(template.id);
if (placeholders.length > 0) {
setSelectedTemplate(template);
@@ -69,22 +87,22 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
locale,
});
- const renderTemplateItem = (template: EmailTemplate) => (
+ const renderTemplateItem = (template: EmailTemplate, source: TemplateSource) => (
handleSelectTemplate(template)}
+ onClick={() => handleSelectTemplate(template, source)}
className="w-full text-start p-3 rounded-md hover:bg-muted transition-colors group"
>
-
- {template.isFavorite && (
+
+ {source.kind === 'personal' && template.isFavorite && (
)}
{template.name}
{template.category && (
-
+
{template.category}
)}
@@ -94,33 +112,89 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
{template.subject}
)}
+ {/* Who the template addresses, before it is put into the message. */}
+ {(template.defaultRecipients?.to?.length || template.defaultRecipients?.cc?.length) ? (
+
+ {[
+ template.defaultRecipients?.to?.length ? tOrg('preview_to', { list: template.defaultRecipients.to.join(', ') }) : null,
+ template.defaultRecipients?.cc?.length ? tOrg('preview_cc', { list: template.defaultRecipients.cc.join(', ') }) : null,
+ ].filter(Boolean).join(' · ')}
+
+ ) : null}
);
- const renderSection = (key: string, title: string, items: EmailTemplate[]) => {
+ const renderSection = (title: string, items: EmailTemplate[], source: TemplateSource) => {
if (items.length === 0) return null;
return (
-
+
{title}
-
{items.map(renderTemplateItem)}
+
{items.map((item) => renderTemplateItem(item, source))}
);
};
- const categorizedEntries = Object.entries(byCategory).filter(
- ([cat]) => cat !== ''
- );
- const favoriteIds = new Set(favorites.map((f) => f.id));
- const recentFiltered = recent.filter((r) => !favoriteIds.has(r.id));
- const shownIds = new Set([
- ...favoriteIds,
- ...recentFiltered.map((r) => r.id),
- ]);
- const uncategorizedFiltered = (byCategory[''] || []).filter(
- (i) => !shownIds.has(i.id)
- );
+ const sourceTitle = (source: TemplateSource) =>
+ source.kind === 'organisation' ? tOrg('section_organisation')
+ : source.kind === 'team' ? (source.name ?? '')
+ : tOrg('section_personal');
+
+ // The user's own templates keep their favourites, recently used and
+ // categories. With team or organisation templates next to them, the
+ // whole block gets a heading of its own.
+ const renderPersonal = (source: TemplateSource, withHeading: boolean) => {
+ const templates = source.templates;
+ const favorites = templates.filter((tpl) => tpl.isFavorite);
+ const favoriteIds = new Set(favorites.map((f) => f.id));
+ const recent = recentTemplateIds
+ .map((id) => templates.find((tpl) => tpl.id === id))
+ .filter((tpl): tpl is EmailTemplate => !!tpl && !favoriteIds.has(tpl.id));
+ const shownIds = new Set([...favoriteIds, ...recent.map((r) => r.id)]);
+ const byCategory: Record
= {};
+ for (const tpl of templates) {
+ if (shownIds.has(tpl.id)) continue;
+ (byCategory[tpl.category || ''] ??= []).push(tpl);
+ }
+ const body = (
+ <>
+ {renderSection(t('section_favorites'), favorites, source)}
+ {renderSection(t('section_recent'), recent, source)}
+ {Object.entries(byCategory)
+ .filter(([cat]) => cat !== '')
+ .map(([cat, items]) => renderSection(cat, items, source))}
+ {renderSection(t('section_uncategorized'), byCategory[''] ?? [], source)}
+ >
+ );
+ if (!withHeading) return {body}
;
+ if (templates.length === 0) return null;
+ return (
+
+ {sourceTitle(source)}
+ {body}
+
+ );
+ };
+
+ const renderShared = (source: TemplateSource, items: EmailTemplate[]) => {
+ if (items.length === 0) return null;
+ return (
+
+ {sourceTitle(source)}
+ {items.map((item) => renderTemplateItem(item, source))}
+
+ );
+ };
+
+ const total = sources.reduce((n, source) => n + source.templates.length, 0);
+ // Only the user's own templates: the picker looks as it always did.
+ const onlyPersonal = sources.every((source) => source.kind === 'personal' || source.templates.length === 0);
+
+ const results = searchQuery
+ ? sources.map((source) => ({ source, items: filterTemplates(source.templates, searchQuery) }))
+ : null;
+ const resultCount = results?.reduce((n, r) => n + r.items.length, 0) ?? 0;
return (
<>
@@ -137,6 +211,8 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
}
>
+ {/* The mail shortcuts behind the dialog must not see its keys. */}
+ e.stopPropagation()}>
@@ -152,41 +228,37 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
- {templates.length === 0 && (
+ {total === 0 && (
)}
- {filtered ? (
- filtered.length > 0 ? (
-
- {filtered.map(renderTemplateItem)}
-
+ {results ? (
+ resultCount > 0 ? (
+ onlyPersonal ? (
+
+ {results.flatMap(({ source, items }) => items.map((item) => renderTemplateItem(item, source)))}
+
+ ) : (
+ results.map(({ source, items }) => renderShared(source, items))
+ )
) : (
{t('no_results')}
)
) : (
- <>
- {renderSection('favorites', t('section_favorites'), favorites)}
- {renderSection('recent', t('section_recent'), recentFiltered)}
- {categorizedEntries.map(([cat, items]) =>
- renderSection(
- `category:${cat}`,
- cat,
- items.filter((i) => !shownIds.has(i.id))
- )
- )}
- {renderSection('uncategorized', t('section_uncategorized'), uncategorizedFiltered)}
- >
+ sources.map((source) => source.kind === 'personal'
+ ? renderPersonal(source, !onlyPersonal)
+ : renderShared(source, source.templates))
)}
+
- {showManager &&
setShowManager(false)} />}
+ {showManager && setShowManager(false)} />}
{showFillModal && selectedTemplate && (
({ useConfig: () => ({ devMode: true }) }));
+
+import { Avatar } from '../avatar';
+import { avatarHooks } from '@/lib/plugin-hooks';
+
+const PEOPLE = ['anna', 'ben', 'carla', 'dirk', 'eva', 'finn', 'greta', 'hans'].map((n) => `${n}@gmail.com`);
+
+function sampleImages(): string[] {
+ const { container, unmount } = render(
+
+ {PEOPLE.map((email) =>
)}
+
+
,
+ );
+ const srcs = [...container.querySelectorAll('img')].map((img) => img.getAttribute('src') ?? '');
+ unmount();
+ return srcs.filter((src) => /randomuser\.me|img\.freepik\.com/.test(src));
+}
+
+afterEach(() => setNoThirdPartyTraffic(false));
+
+describe('mock server sample photos', () => {
+ it('show with third-party traffic allowed', () => {
+ expect(sampleImages().length).toBeGreaterThan(0);
+ });
+
+ it('are left out with no third-party traffic', () => {
+ setNoThirdPartyTraffic(true);
+ expect(sampleImages()).toEqual([]);
+ });
+});
+
+describe('plugin avatars (Gravatar and the like)', () => {
+ afterEach(() => avatarHooks.onAvatarResolve.clear());
+
+ async function renderWithGravatar() {
+ avatarHooks.onAvatarResolve.register('gravatar', (async () => 'https://www.gravatar.com/avatar/abc?d=404') as never);
+ const view = render( );
+ await new Promise((r) => setTimeout(r, 20));
+ return view;
+ }
+
+ it("show the plugin's outside URL with third-party traffic allowed", async () => {
+ const { container } = await renderWithGravatar();
+ await waitFor(() => expect(container.querySelector('img')?.getAttribute('src')).toMatch(/gravatar\.com/));
+ });
+
+ it('fall back to initials with no third-party traffic', async () => {
+ setNoThirdPartyTraffic(true);
+ const { container } = await renderWithGravatar();
+ expect(container.querySelector('img')).toBeNull();
+ expect(container.textContent).toBe('ZO');
+ });
+});
diff --git a/components/ui/__tests__/avatar.test.tsx b/components/ui/__tests__/avatar.test.tsx
index 3c1be9ae7..30bb67127 100644
--- a/components/ui/__tests__/avatar.test.tsx
+++ b/components/ui/__tests__/avatar.test.tsx
@@ -6,6 +6,7 @@ import { useContactStore } from '@/stores/contact-store';
import { useFaviconRefreshStore } from '@/stores/favicon-refresh-store';
import { clearContactPhotoCache } from '@/lib/contact-photo-cache';
import type { ContactCard } from '@/lib/jmap/types';
+import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
describe('Avatar', () => {
it('renders initials from full name', () => {
@@ -230,4 +231,17 @@ describe('Avatar', () => {
expect(stamp).toBeGreaterThan(0);
expect(img()?.getAttribute('src')).toBe(`/api/favicon?domain=example.cz&v=${stamp}`);
});
+
+ // #903: no sender icons, and so no /api/favicon request, while third-party traffic is off.
+ it('shows initials instead of the sender favicon in the no-third-party mode', () => {
+ useFaviconRefreshStore.setState({ refreshedAt: 0 });
+ setNoThirdPartyTraffic(true);
+ try {
+ const { container } = render( );
+ expect(container.querySelector('img')).toBeNull();
+ expect(container.textContent).toBe('JA');
+ } finally {
+ setNoThirdPartyTraffic(false);
+ }
+ });
});
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx
index d17545464..96db820ae 100644
--- a/components/ui/avatar.tsx
+++ b/components/ui/avatar.tsx
@@ -9,6 +9,8 @@ import { useContactPhotoSrc } from "@/hooks/use-contact-photo";
import { avatarHooks } from "@/lib/plugin-hooks";
import { withBasePath } from "@/lib/browser-navigation";
import { IS_LITE } from "@/lib/lite";
+import { avatarUrlAllowed, sampleAvatarsAllowed, senderFaviconsAllowed } from "@/lib/outgoing-hosts";
+import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
import { useFaviconRefreshStore, failedFaviconDomains } from "@/stores/favicon-refresh-store";
const IS_DEV = process.env.NODE_ENV !== "production";
@@ -126,7 +128,7 @@ const CUSTOM_AVATARS: Record = IS_DEV ? {
// Mock-server-only: for personal-domain emails, deterministically pick a randomuser.me portrait.
// Returns null for ~30% of addresses so not everyone has a photo.
function getProfilePictureUrl(email: string, domain: string, devMode: boolean, name?: string): string | null {
- if (!devMode) return null;
+ if (!devMode || !sampleAvatarsAllowed()) return null;
if (!PERSONAL_DOMAINS.has(domain)) return null;
const h = emailHash(email);
if (h % 10 < 3) return null; // ~30% get no photo
@@ -165,6 +167,8 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa
const faviconRefreshedAt = useFaviconRefreshStore((s) => s.refreshedAt);
const contacts = useContactStore((s) => s.contacts);
const { devMode } = useConfig();
+ // Re-render when the config arrives: until then nothing outside is asked (#903).
+ useNoThirdPartyTraffic();
// Ask plugins (e.g. Gravatar) to resolve an avatar URL for this email address.
// Runs whenever email or name changes; resets plugin avatar state on each change.
@@ -187,7 +191,7 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa
return getContactPhotoIndex(contacts).get(email.toLowerCase()) ?? null;
}, [disableImages, contactPhoto, email, contacts]);
const { src: contactPhotoSrc, observe } = useContactPhotoSrc(photoRef);
- const resolvedContactPhoto = contactPhotoSrc && contactPhotoSrc !== failedContactPhoto ? contactPhotoSrc : undefined;
+ const resolvedContactPhoto = contactPhotoSrc && contactPhotoSrc !== failedContactPhoto && avatarUrlAllowed(contactPhotoSrc) ? contactPhotoSrc : undefined;
// A refresh of the sender icons gives a failed favicon another chance.
useEffect(() => {
@@ -237,13 +241,15 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa
};
const profilePic = email && domain ? getProfilePictureUrl(email, domain, devMode, name) : null;
- // Sender favicons come from the /api/favicon proxy, absent in the static build.
+ // Sender favicons come from the /api/favicon proxy, absent in the static
+ // build and off while third-party traffic is (#903).
const showFavicon =
- !IS_LITE && !disableFavicon && senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
+ !IS_LITE && senderFaviconsAllowed() && !disableFavicon && senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
- const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
- const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
+ const customAvatar = devMode && email && sampleAvatarsAllowed() ? CUSTOM_AVATARS[email.toLowerCase()] : null;
+ // Gravatar and the like are outside hosts: initials instead in the no-third-party mode.
+ const pluginAvatar = pluginAvatarFailed || !avatarUrlAllowed(pluginAvatarUrl) ? null : pluginAvatarUrl;
const photoSrc = resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null;
const faviconSrc = !imgError && !domainFailed && showFavicon ? withBasePath(`/api/favicon?domain=${encodeURIComponent(faviconDomain!)}${faviconRefreshedAt ? `&v=${faviconRefreshedAt}` : ""}`) : null;
const imgSrc = disableImages ? null : (photoSrc || faviconSrc);
diff --git a/hooks/__tests__/use-account-tags.test.tsx b/hooks/__tests__/use-account-tags.test.tsx
new file mode 100644
index 000000000..6e6652153
--- /dev/null
+++ b/hooks/__tests__/use-account-tags.test.tsx
@@ -0,0 +1,132 @@
+import { act, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { TagBadge } from '@/components/email/tag-badge';
+import { TagPicker } from '@/components/email/tag-picker';
+import { TagScopeContext, tagScopeOf, type TagScope } from '../use-account-tags';
+import { useAuthStore } from '@/stores/auth-store';
+import { KEYWORD_PALETTE, useSettingsStore } from '@/stores/settings-store';
+import { resetSharedTags, sharedTagsKey, useSharedTagsStore, type SharedTagsEntry } from '@/stores/shared-tags-store';
+
+// A message in a group account shows the group's shared tag names and
+// colours; everywhere else, and wherever the shared ones aren't known, the
+// user's own tags apply as before.
+
+const TEAM: TagScope = { loginId: 'login-1', accountId: 'team' };
+const OTHER_TEAM: TagScope = { loginId: 'login-1', accountId: 'other-team' };
+
+function setEntry(scope: TagScope, entry: Partial) {
+ useSharedTagsStore.setState((state) => ({
+ entries: {
+ ...state.entries,
+ [sharedTagsKey(scope.loginId, scope.accountId)]: { status: 'ready', tags: [], stored: null, pending: [], discovered: {}, loadedAt: 1, discoveredAt: 1, readFailedAt: null, ...entry },
+ },
+ }));
+}
+
+function badge(tagId: string, scope?: TagScope | null) {
+ return render( );
+}
+
+beforeEach(() => {
+ resetSharedTags();
+ // No signed-in client: the entries below stay what the test sets.
+ useAuthStore.setState({ client: null, activeAccountId: null, getClientForAccount: (() => undefined) as never });
+ useSettingsStore.setState({ emailKeywords: [{ id: 'urgent', label: 'My urgent', color: 'blue' }], nestedTags: false });
+});
+afterEach(() => resetSharedTags());
+
+describe('tag names and colours per account', () => {
+ it('uses the shared definition in a group account and the own one elsewhere', () => {
+ setEntry(TEAM, { tags: [{ id: 'urgent', label: 'Urgent', color: 'red' }] });
+ badge('urgent', TEAM);
+ expect(screen.getByTitle('Urgent')).toHaveClass(KEYWORD_PALETTE.red.fill);
+ badge('urgent', null);
+ expect(screen.getByText('My urgent')).toBeInTheDocument();
+ });
+
+ it('keeps the own tags in a group account whose server keeps no metadata', () => {
+ setEntry(TEAM, { status: 'unsupported' });
+ badge('urgent', TEAM);
+ expect(screen.getByText('My urgent')).toBeInTheDocument();
+ });
+
+ it('keeps the own tags while the shared ones couldn\'t be read', () => {
+ setEntry(TEAM, { status: 'unavailable', tags: [{ id: 'urgent', label: 'Stale', color: 'red' }] });
+ badge('urgent', TEAM);
+ expect(screen.getByText('My urgent')).toBeInTheDocument();
+ });
+
+ it('keeps the own tags in a mailbox a person shared', () => {
+ setEntry(TEAM, { status: 'personal' });
+ badge('urgent', TEAM);
+ expect(screen.getByText('My urgent')).toBeInTheDocument();
+ });
+
+ it('applies definitions that arrive after the message was drawn', () => {
+ setEntry(TEAM, { status: 'loading' });
+ badge('urgent', TEAM);
+ expect(screen.getByText('My urgent')).toBeInTheDocument();
+ act(() => setEntry(TEAM, { status: 'ready', tags: [{ id: 'urgent', label: 'Urgent', color: 'red' }] }));
+ expect(screen.getByText('Urgent')).toBeInTheDocument();
+ expect(screen.queryByText('My urgent')).not.toBeInTheDocument();
+ });
+
+ it('names each message by its own account in a mixed list', () => {
+ setEntry(TEAM, { tags: [{ id: 'urgent', label: 'Team urgent', color: 'red' }] });
+ setEntry(OTHER_TEAM, { tags: [{ id: 'urgent', label: 'Sales urgent', color: 'green' }] });
+ render(
+ <>
+
+
+
+ >,
+ );
+ expect(screen.getByText('Team urgent')).toBeInTheDocument();
+ expect(screen.getByText('Sales urgent')).toBeInTheDocument();
+ expect(screen.getByText('My urgent')).toBeInTheDocument();
+ });
+});
+
+describe('the tag picker in a group account', () => {
+ it('offers the shared tags and every tag found on the messages, not the own ones', () => {
+ setEntry(TEAM, {
+ tags: [{ id: 'urgent', label: 'Urgent', color: 'red' }],
+ discovered: { '$label:waiting-for-customer': 2 },
+ });
+ useSettingsStore.setState({ emailKeywords: [{ id: 'private', label: 'Private', color: 'blue' }] });
+ render( {}} scope={TEAM} />);
+ expect(screen.getByText('Urgent')).toBeInTheDocument();
+ expect(screen.getByText('Waiting For Customer')).toBeInTheDocument();
+ // A keyword on the message is never hidden, named or not.
+ expect(screen.getByText('only-on-this-one')).toBeInTheDocument();
+ expect(screen.queryByText('Private')).not.toBeInTheDocument();
+ expect(screen.getByTestId('tag-picker-shared')).toHaveTextContent('picker_heading');
+ });
+
+ it('offers the own tags without a scope', () => {
+ setEntry(TEAM, { tags: [{ id: 'urgent', label: 'Urgent', color: 'red' }] });
+ render( {}} scope={null} />);
+ expect(screen.getByText('My urgent')).toBeInTheDocument();
+ expect(screen.queryByTestId('tag-picker-shared')).not.toBeInTheDocument();
+ });
+});
+
+describe('tagScopeOf', () => {
+ beforeEach(() => {
+ const client = { getAccountId: () => 'me' };
+ useAuthStore.setState({
+ client: client as never,
+ activeAccountId: 'login-1',
+ getClientForAccount: ((id: string) => (id === 'login-1' ? client : undefined)) as never,
+ });
+ });
+
+ it('names the group account of a message reached through a login', () => {
+ expect(tagScopeOf({ sourceClientAccountId: 'login-1', sourceAccountId: 'team' })).toEqual(TEAM);
+ });
+
+ it('is null for a message in the login\'s own account', () => {
+ expect(tagScopeOf({ sourceClientAccountId: 'login-1', sourceAccountId: 'me' })).toBeNull();
+ expect(tagScopeOf(null)).toBeNull();
+ });
+});
diff --git a/hooks/__tests__/use-minimised-composer-flush.test.tsx b/hooks/__tests__/use-minimised-composer-flush.test.tsx
new file mode 100644
index 000000000..61031d1ae
--- /dev/null
+++ b/hooks/__tests__/use-minimised-composer-flush.test.tsx
@@ -0,0 +1,53 @@
+import { renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { MINIMISED_COMPOSER_REOPEN_MS, useMinimisedComposerFlush } from '../use-minimised-composer-flush';
+import { flushComposers, registerComposeFlush, resetComposeFlushRegistry } from '@/lib/compose-flush-registry';
+
+/**
+ * mail-app keeps a minimised composer's edits in memory only. Before an
+ * automatic sign-out the hook opens the composer again and lets it save.
+ */
+describe('minimised composer flush', () => {
+ beforeEach(() => {
+ resetComposeFlushRegistry();
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('opens the minimised composer again and runs its own save', async () => {
+ const save = vi.fn(async () => {});
+ // Opening the composer mounts it, and it registers its flush.
+ const reopen = vi.fn(() => { registerComposeFlush(save); });
+ renderHook(() => useMinimisedComposerFlush(true, reopen));
+
+ const result = await flushComposers(8000);
+
+ expect(reopen).toHaveBeenCalledTimes(1);
+ expect(save).toHaveBeenCalledTimes(1);
+ expect(result).toEqual({ total: 1, settled: 1, failed: 0, timedOut: false });
+ });
+
+ it('registers nothing without unsaved minimised edits', async () => {
+ const reopen = vi.fn();
+ renderHook(() => useMinimisedComposerFlush(false, reopen));
+
+ await expect(flushComposers(8000)).resolves.toMatchObject({ total: 0 });
+ expect(reopen).not.toHaveBeenCalled();
+ });
+
+ it('counts a composer that does not come back as a draft not saved', async () => {
+ renderHook(() => useMinimisedComposerFlush(true, () => {}));
+
+ const done = flushComposers(8000);
+ await vi.advanceTimersByTimeAsync(MINIMISED_COMPOSER_REOPEN_MS);
+ await expect(done).resolves.toEqual({ total: 1, settled: 1, failed: 1, timedOut: false });
+ });
+
+ it('stops registering once the composer is open again', async () => {
+ const { rerender } = renderHook(({ minimised }) => useMinimisedComposerFlush(minimised, () => {}), { initialProps: { minimised: true } });
+ rerender({ minimised: false });
+ await expect(flushComposers(8000)).resolves.toMatchObject({ total: 0 });
+ });
+});
diff --git a/hooks/use-account-policy.ts b/hooks/use-account-policy.ts
new file mode 100644
index 000000000..9eaddcbcf
--- /dev/null
+++ b/hooks/use-account-policy.ts
@@ -0,0 +1,24 @@
+import { useMemo } from 'react';
+import type { SettingsPolicy } from '@/lib/admin/types';
+import { subjectForAccount, useAccountSubjects } from '@/lib/org/account-subjects';
+import { usePolicyStore } from '@/stores/policy-store';
+
+/**
+ * The effective policy of a signed-in account: the active account's from
+ * the policy store, another account's resolved for its own domain and group
+ * scopes (a composer sending from that account). Follows the policy and the
+ * account's subject as they load.
+ */
+export function useAccountPolicy(accountId: string | null | undefined): SettingsPolicy {
+ const policy = usePolicyStore((s) => s.policy);
+ const rawPolicy = usePolicyStore((s) => s.rawPolicy);
+ const subject = usePolicyStore((s) => s.subject);
+ const resolveFor = usePolicyStore((s) => s.resolveFor);
+ // Re-read when another account's subject has been worked out.
+ const accountSubjects = useAccountSubjects((s) => s.subjects);
+ return useMemo(() => {
+ const accountSubject = subjectForAccount(accountId);
+ return accountSubject === subject ? policy : resolveFor(accountSubject);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [policy, rawPolicy, subject, resolveFor, accountId, accountSubjects]);
+}
diff --git a/hooks/use-account-tags.ts b/hooks/use-account-tags.ts
new file mode 100644
index 000000000..b35a774ff
--- /dev/null
+++ b/hooks/use-account-tags.ts
@@ -0,0 +1,141 @@
+"use client";
+
+import { createContext, useContext, useEffect, useMemo } from "react";
+import type { Email } from "@/lib/jmap/types";
+import { groupAccountTags } from "@/lib/shared-tags";
+import { useAuthStore } from "@/stores/auth-store";
+import { resolveEmailBlobContext, useEmailStore } from "@/stores/email-store";
+import { useSettingsStore, type KeywordDefinition } from "@/stores/settings-store";
+import {
+ loadSharedTags,
+ sharedTagsKey,
+ useSharedTagsStore,
+ type SharedTagsClient,
+ type SharedTagsEntry,
+ type SharedTagsStatus,
+} from "@/stores/shared-tags-store";
+
+/**
+ * Which account's tags apply: a JMAP account other than the login's own,
+ * reached through the login `loginId`. Null means the user's own tags.
+ */
+export interface TagScope {
+ loginId: string;
+ accountId: string;
+}
+
+/**
+ * The tag scope of whatever is drawn inside: a message row, the open
+ * message, a group account's sidebar section. Tag badges, pickers and
+ * names read it, so each message shows its own account's tags (the unified
+ * inbox mixes accounts).
+ */
+export const TagScopeContext = createContext(null);
+
+/** The scope of a message: its own account when that isn't the login's. */
+export function tagScopeOf(email: Pick | null | undefined): TagScope | null {
+ if (!email) return null;
+ try {
+ const auth = useAuthStore.getState();
+ const { client, accountId, clientAccountId } = resolveEmailBlobContext(email, auth.client);
+ if (!client || !accountId || accountId === client.getAccountId()) return null;
+ const loginId = clientAccountId ?? auth.activeAccountId;
+ return loginId ? { loginId, accountId } : null;
+ } catch {
+ // No session to tell (a stand-in store, a signed-out moment): own tags.
+ return null;
+ }
+}
+
+/**
+ * tagScopeOf for a message on screen, kept stable between renders and
+ * worked out again when the view changes (an unstamped message belongs to
+ * the account of the folder on screen).
+ */
+export function useEmailTagScope(email: Pick | null | undefined): TagScope | null {
+ useEmailStore((state) => state.selectedMailbox);
+ useEmailStore((state) => state.viewingAccountId);
+ useAuthStore((state) => state.activeAccountId);
+ const scope = tagScopeOf(email);
+ const loginId = scope?.loginId;
+ const accountId = scope?.accountId;
+ return useMemo(() => (loginId && accountId ? { loginId, accountId } : null), [loginId, accountId]);
+}
+
+/** The scope shared by every message given, or null when they differ. */
+export function commonTagScope(emails: Array>): TagScope | null {
+ const scopes = emails.map((email) => tagScopeOf(email));
+ const first = scopes[0] ?? null;
+ const same = scopes.every((scope) => scope?.loginId === first?.loginId && scope?.accountId === first?.accountId);
+ return same ? first : null;
+}
+
+export function clientForLogin(loginId: string): SharedTagsClient | null {
+ const auth = useAuthStore.getState();
+ return (auth.getClientForAccount?.(loginId) as SharedTagsClient | undefined)
+ ?? (loginId === auth.activeAccountId ? (auth.client as SharedTagsClient | null) : null);
+}
+
+/** Starts loading a scope's shared tags (once per sign-in). */
+export function ensureSharedTags(scope: TagScope): void {
+ const client = clientForLogin(scope.loginId);
+ if (client) void loadSharedTags(sharedTagsKey(scope.loginId, scope.accountId), scope.accountId, client);
+}
+
+const derived = new WeakMap>();
+
+/** The shared definitions plus the tags found on the messages, worked out once per entry. */
+function groupDefinitions(entry: SharedTagsEntry, nested: boolean): KeywordDefinition[] {
+ let byNesting = derived.get(entry);
+ if (!byNesting) {
+ byNesting = new Map();
+ derived.set(entry, byNesting);
+ }
+ let definitions = byNesting.get(nested);
+ if (!definitions) {
+ definitions = groupAccountTags(entry.tags, entry.discovered, nested).definitions;
+ byNesting.set(nested, definitions);
+ }
+ return definitions;
+}
+
+export interface AccountTags {
+ /** The tags to offer and name things by in this scope. */
+ definitions: KeywordDefinition[];
+ /** True when they are the group account's shared tags. */
+ shared: boolean;
+ /** `personal` for the user's own account. */
+ status: SharedTagsStatus;
+}
+
+/**
+ * The tags of a scope: in a group account whose server keeps metadata, its
+ * shared definitions plus every tag found on its messages; everywhere else
+ * (own account, a mailbox a person shared, a server without metadata, while
+ * loading) the user's own tags. Definitions that arrive after the first
+ * render apply as soon as they are there.
+ *
+ * `scope` undefined reads TagScopeContext; null means the user's own tags.
+ */
+export function useAccountTags(scope?: TagScope | null): AccountTags {
+ const contextScope = useContext(TagScopeContext);
+ const effective = scope === undefined ? contextScope : scope;
+ const key = effective ? sharedTagsKey(effective.loginId, effective.accountId) : null;
+ const entry = useSharedTagsStore((state) => (key ? state.entries[key] : undefined));
+ const personal = useSettingsStore((state) => state.emailKeywords);
+ const nested = useSettingsStore((state) => state.nestedTags);
+ const loginId = effective?.loginId;
+ const accountId = effective?.accountId;
+
+ useEffect(() => {
+ if (loginId && accountId) ensureSharedTags({ loginId, accountId });
+ }, [loginId, accountId]);
+
+ return useMemo(() => {
+ if (!effective) return { definitions: personal, shared: false, status: "personal" };
+ if (entry?.status !== "ready") return { definitions: personal, shared: false, status: entry?.status ?? "loading" };
+ return { definitions: groupDefinitions(entry, nested), shared: true, status: "ready" };
+ // `effective` is fully described by the key.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [key, entry, personal, nested]);
+}
diff --git a/hooks/use-config.ts b/hooks/use-config.ts
index e69c6f650..d34ad572a 100644
--- a/hooks/use-config.ts
+++ b/hooks/use-config.ts
@@ -7,6 +7,7 @@ import type { PublicJmapServerEntry } from '@/lib/admin/jmap-servers';
import { IS_LITE, IS_LITE_STALWART, LITE_CONFIG_PATH, withLiteBuildId } from '@/lib/lite';
import { applyLiteConfig, liteStalwartDefaults } from '@/lib/lite-config';
import { isConfigData } from '@/lib/config-validation';
+import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
export interface ConfigData {
appName: string;
@@ -46,6 +47,8 @@ export interface ConfigData {
embeddedMode: boolean;
parentOrigin: string;
sourceCodeUrl: string;
+ /** Stay off every built-in outside host (#903; lib/outgoing-hosts.ts). */
+ noThirdPartyTraffic: boolean;
}
interface AppConfig extends ConfigData {
@@ -71,8 +74,10 @@ async function fetchLiteConfig(): Promise {
if (!res.ok) throw new Error(`config.json answered ${res.status}`);
return applyLiteConfig(await res.json(), defaults);
} catch (err) {
- console.warn('[lite] config.json missing or invalid, using defaults:', err);
- return applyLiteConfig({}, defaults);
+ // Fail closed (#903): without a readable config.json nobody said outside
+ // hosts may be used, so the no-third-party switches stay on.
+ console.warn('[lite] config.json missing or invalid, using defaults with no third-party traffic:', err);
+ return applyLiteConfig({ noThirdPartyTraffic: true }, defaults);
}
}
@@ -108,6 +113,11 @@ async function fetchServerConfig(): Promise {
throw lastError;
}
+/** The config once fetched (it is fetched at app start), for code outside React. */
+export function getCachedConfig(): ConfigData | null {
+ return configCache;
+}
+
export async function fetchConfig(): Promise {
// Return cached config if available
if (configCache) {
@@ -123,6 +133,7 @@ export async function fetchConfig(): Promise {
configPromise = (IS_LITE ? fetchLiteConfig() : fetchServerConfig())
.then((data) => {
configCache = data;
+ setNoThirdPartyTraffic(data.noThirdPartyTraffic === true);
// Fetch admin policy alongside config (non-blocking)
usePolicyStore.getState().fetchPolicy();
return data;
@@ -181,6 +192,7 @@ export function useConfig(): AppConfig {
embeddedMode: configCache?.embeddedMode || false,
parentOrigin: configCache?.parentOrigin || '',
sourceCodeUrl: configCache?.sourceCodeUrl || '',
+ noThirdPartyTraffic: configCache?.noThirdPartyTraffic ?? false,
isLoading: !configCache,
error: null,
});
@@ -226,6 +238,7 @@ export function useConfig(): AppConfig {
embeddedMode: configCache.embeddedMode,
parentOrigin: configCache.parentOrigin,
sourceCodeUrl: configCache.sourceCodeUrl,
+ noThirdPartyTraffic: configCache.noThirdPartyTraffic,
isLoading: false,
error: null,
});
@@ -272,6 +285,7 @@ export function useConfig(): AppConfig {
embeddedMode: data.embeddedMode,
parentOrigin: data.parentOrigin,
sourceCodeUrl: data.sourceCodeUrl,
+ noThirdPartyTraffic: data.noThirdPartyTraffic,
isLoading: false,
error: null,
});
diff --git a/hooks/use-deleted-items-recovery.ts b/hooks/use-deleted-items-recovery.ts
new file mode 100644
index 000000000..f8b3360ef
--- /dev/null
+++ b/hooks/use-deleted-items-recovery.ts
@@ -0,0 +1,59 @@
+import { useEffect } from 'react';
+import { useAuthStore } from '@/stores/auth-store';
+import { usePolicyStore } from '@/stores/policy-store';
+import { recoveryKey, useRecoveryStore } from '@/stores/recovery-store';
+import { useConfig } from '@/hooks/use-config';
+import { useRestriction } from '@/hooks/use-restriction';
+import { IS_LITE } from '@/lib/lite';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+
+export interface DeletedItemsRecovery {
+ /** Offered to this user for the active account (the server has the feature). */
+ available: boolean;
+ /** The admin hid it, or the instance switched Stalwart features off: nothing is asked. */
+ off: boolean;
+ /** Policy and config aren't in for this account yet: nothing is asked or shown. */
+ pending: boolean;
+ client: IJMAPClient | null;
+ /** The detection cache key of the active account. */
+ key: string | null;
+}
+
+/**
+ * Whether "Recover deleted items" is offered for the active account.
+ *
+ * Nothing is asked until the config and the policy are loaded and the
+ * policy is resolved for this client's account, so an account switch never
+ * runs on the previous account's restrictions. Then: not hidden by the admin
+ * (`deletedItemsRecovery`), Stalwart features on (full Bulwark; Lite asks
+ * the server itself), and the server answering x:ArchivedItem/query
+ * (Enterprise license). Asked once per account and session, and again after
+ * a refusal once its retry time comes, also while the page stays open.
+ */
+export function useDeletedItemsRecovery(): DeletedItemsRecovery {
+ const hiddenByAdmin = useRestriction('deletedItemsRecovery').hidden;
+ const policyLoaded = usePolicyStore((s) => s.loaded);
+ const subjectEmail = usePolicyStore((s) => s.subject?.email ?? null);
+ const { stalwartFeaturesEnabled, isLoading: configLoading } = useConfig();
+ const client = useAuthStore((s) => s.client);
+ const activeAccountId = useAuthStore((s) => s.activeAccountId);
+ const clientEmail = client?.getUsername?.()?.trim().toLowerCase() ?? '';
+ const pending = !client || configLoading || !policyLoaded || !subjectEmail || subjectEmail !== clientEmail;
+ const off = !pending && (hiddenByAdmin || (!IS_LITE && !stalwartFeaturesEnabled));
+ const key = client ? recoveryKey(activeAccountId, client) : null;
+ const check = useRecoveryStore((s) => (key ? s.checks[key] : undefined));
+ const detect = useRecoveryStore((s) => s.detect);
+
+ useEffect(() => {
+ if (pending || off || !client || !key) return;
+ if (!check) {
+ void detect(client, key);
+ return;
+ }
+ if (check.supported || check.retryAt === null || !Number.isFinite(check.retryAt)) return;
+ const timer = setTimeout(() => void detect(client, key, { force: true }), Math.max(0, check.retryAt - Date.now()));
+ return () => clearTimeout(timer);
+ }, [pending, off, client, key, check, detect]);
+
+ return { available: !pending && !off && !!check?.supported, off, pending, client, key };
+}
diff --git a/hooks/use-keyword-format.ts b/hooks/use-keyword-format.ts
index ae82caa65..3d44c934b 100644
--- a/hooks/use-keyword-format.ts
+++ b/hooks/use-keyword-format.ts
@@ -9,6 +9,7 @@ import {
} from "@/stores/settings-store";
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
import { suggestKeywordColor } from "@/lib/keyword-discovery";
+import { useAccountTags, type TagScope } from "@/hooks/use-account-tags";
/**
* Names and colours tags for the screen, bound to the user's tag settings.
@@ -18,9 +19,13 @@ import { suggestKeywordColor } from "@/lib/keyword-discovery";
* someone who never asked for nesting. Subscribing to them also keeps tags in
* step the moment either changes: reading the store inside the formatter would
* leave every list stale until something else happened to re-render it.
+ *
+ * The definitions are those of the account in `scope` (useAccountTags): a
+ * group account's shared tags, otherwise the user's own. Without `scope`
+ * the TagScopeContext around the caller decides.
*/
-export function useKeywordFormat() {
- const keywords = useSettingsStore((state) => state.emailKeywords);
+export function useKeywordFormat(scope?: TagScope | null) {
+ const keywords = useAccountTags(scope).definitions;
const nested = useSettingsStore((state) => state.nestedTags);
return useMemo(
diff --git a/hooks/use-minimised-composer-flush.ts b/hooks/use-minimised-composer-flush.ts
new file mode 100644
index 000000000..28b1ccccd
--- /dev/null
+++ b/hooks/use-minimised-composer-flush.ts
@@ -0,0 +1,26 @@
+import { useEffect, useRef } from 'react';
+import { registerComposeFlush, waitForComposeFlush } from '@/lib/compose-flush-registry';
+
+/** How long an automatic sign-out waits for a minimised composer to open again and register. */
+export const MINIMISED_COMPOSER_REOPEN_MS = 3000;
+
+/**
+ * A minimised composer's edits live only in its host's memory. While there
+ * are unsaved ones, an automatic sign-out opens the composer again (`reopen`)
+ * and lets it save itself (lib/compose-flush-registry.ts). A composer that
+ * doesn't come back in time counts as a draft that wasn't saved.
+ */
+export function useMinimisedComposerFlush(minimisedUnsaved: boolean, reopen: () => void): void {
+ const reopenRef = useRef(reopen);
+ reopenRef.current = reopen;
+ useEffect(() => {
+ if (!minimisedUnsaved) return;
+ return registerComposeFlush(async () => {
+ const reopened = waitForComposeFlush(MINIMISED_COMPOSER_REOPEN_MS);
+ reopenRef.current();
+ const flush = await reopened;
+ if (!flush) throw new Error('The minimised message did not open again');
+ await flush();
+ });
+ }, [minimisedUnsaved]);
+}
diff --git a/hooks/use-org-signature.ts b/hooks/use-org-signature.ts
new file mode 100644
index 000000000..cee4e94cb
--- /dev/null
+++ b/hooks/use-org-signature.ts
@@ -0,0 +1,73 @@
+import { useMemo } from 'react';
+import type { Identity } from '@/lib/jmap/types';
+import { effectiveSignatureIdentity, isSignatureEnforced, type FilledSignature } from '@/lib/org-signature';
+import { companyName, orgSignatureSetup, policyForSender, type OrgSignatureSetup } from '@/lib/org/signature-state';
+import { subjectForAccount, useAccountSubjects } from '@/lib/org/account-subjects';
+import { usePolicyStore } from '@/stores/policy-store';
+import { useSettingsStore } from '@/stores/settings-store';
+
+type Sender = Pick | null | undefined;
+
+function useSenderPolicy(email: string | undefined, accountId: string | null | undefined) {
+ const policy = usePolicyStore((s) => s.policy);
+ const rawPolicy = usePolicyStore((s) => s.rawPolicy);
+ const subject = usePolicyStore((s) => s.subject);
+ const resolveFor = usePolicyStore((s) => s.resolveFor);
+ // Re-read when another account's subject has been worked out.
+ const accountSubjects = useAccountSubjects((s) => s.subjects);
+ return useMemo(
+ () => policyForSender({ policy, rawPolicy, subject, resolveFor }, email, subjectForAccount(accountId)),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [policy, rawPolicy, subject, resolveFor, email, accountId, accountSubjects],
+ );
+}
+
+/**
+ * The organisation signature and disclaimer filled in for mail from
+ * `sending`, an identity of the signed-in account `accountId` (the active
+ * one when left out), and how the signature is restricted.
+ */
+export function useOrgSignatureSetup(sending: Sender, accountId?: string | null): OrgSignatureSetup {
+ const policy = useSenderPolicy(sending?.email, accountId);
+ const details = useSettingsStore((s) => s.signatureDetails);
+ const company = companyName();
+ const name = sending?.name;
+ const email = sending?.email;
+ return useMemo(
+ () => orgSignatureSetup(policy, { name: name ?? '', email: email ?? '' }, details, company),
+ [policy, name, email, details, company],
+ );
+}
+
+/**
+ * The composer's signature identity with the organisation signature
+ * applied (see effectiveSignatureIdentity), and whether that signature is
+ * the admin's locked or hidden one. Plugins come after this
+ * (usePluginSignature) and may change an unlocked organisation signature,
+ * not a locked one.
+ */
+export function useOrgSignature(
+ own: T | null | undefined,
+ sending: T | null | undefined,
+ accountId?: string | null,
+): { identity: T | null | undefined; enforced: boolean } {
+ const setup = useOrgSignatureSetup(sending ?? own, accountId);
+ return useMemo(() => ({
+ identity: effectiveSignatureIdentity(own, sending, setup.signature, setup.restriction),
+ enforced: !!setup.signature && isSignatureEnforced(setup.restriction),
+ }), [own, sending, setup]);
+}
+
+/** useOrgSignature's identity alone. */
+export function useOrgSignatureIdentity(
+ own: T | null | undefined,
+ sending: T | null | undefined,
+ accountId?: string | null,
+): T | null | undefined {
+ return useOrgSignature(own, sending, accountId).identity;
+}
+
+/** The disclaimer added on send, filled in for mail from `sending`; null when there is none. */
+export function useOrgDisclaimer(sending: Sender, accountId?: string | null): FilledSignature | null {
+ return useOrgSignatureSetup(sending, accountId).disclaimer;
+}
diff --git a/hooks/use-plugin-signature.ts b/hooks/use-plugin-signature.ts
index 0208ceb19..cba4086a6 100644
--- a/hooks/use-plugin-signature.ts
+++ b/hooks/use-plugin-signature.ts
@@ -22,8 +22,11 @@ const sourceKey = (identity: Identity) => JSON.stringify([identity.htmlSignature
*
* `revision` changes each time a plugin result is applied, so the composer
* can swap the signature already in the body.
+ *
+ * With `locked` (an organisation signature the admin locked or hid) plugins
+ * aren't asked and the identity comes back as it is.
*/
-export function usePluginSignature(identity: Identity | null | undefined): {
+export function usePluginSignature(identity: Identity | null | undefined, options: { locked?: boolean } = {}): {
identity: Identity | null | undefined;
revision: number;
} {
@@ -31,9 +34,10 @@ export function usePluginSignature(identity: Identity | null | undefined): {
const [revision, setRevision] = useState(0);
const id = identity?.id;
const source = identity ? sourceKey(identity) : '';
+ const locked = options.locked === true;
useEffect(() => {
- if (!identity || !id || identityHooks.onSignatureRender.size === 0) return;
+ if (locked || !identity || !id || identityHooks.onSignatureRender.size === 0) return;
let cancelled = false;
const initial = { html: identity.htmlSignature ?? '', text: identity.textSignature ?? '' };
void identityHooks.onSignatureRender.transform(initial, { identityId: id }).then((result: unknown) => {
@@ -49,14 +53,14 @@ export function usePluginSignature(identity: Identity | null | undefined): {
// Keyed to the identity and its signature text, not the object, which is
// rebuilt on unrelated store updates.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [id, source]);
+ }, [id, source, locked]);
const result = useMemo(() => {
- if (!identity || !rendered || rendered.identityId !== identity.id || rendered.source !== sourceKey(identity)) {
+ if (locked || !identity || !rendered || rendered.identityId !== identity.id || rendered.source !== sourceKey(identity)) {
return identity;
}
return { ...identity, htmlSignature: rendered.html, textSignature: rendered.text };
- }, [identity, rendered]);
+ }, [identity, rendered, locked]);
return { identity: result, revision };
}
diff --git a/hooks/use-plugin-slot-offers.ts b/hooks/use-plugin-slot-offers.ts
index 8a2358124..76f6fc0cd 100644
--- a/hooks/use-plugin-slot-offers.ts
+++ b/hooks/use-plugin-slot-offers.ts
@@ -3,12 +3,24 @@
// Reactive accessor for sandboxed plugin slot offers. Components that gate
// layout on the *presence* of a plugin-supplied slot (e.g. a detail
// sidebar) read from here instead of the legacy `usePluginStore.slots` map.
+//
+// These are the offers the host shows: a retired plugin's offer is left out
+// while Bulwark does its job itself (lib/plugin-sandbox/host-features.ts), so
+// nothing is built for a slot nobody will see.
-import { useSyncExternalStore } from 'react';
+import { useMemo, useSyncExternalStore } from 'react';
import type { SlotName } from '@/lib/plugin-types';
import { offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry';
+import { hostFeaturesFor, isSlotHandedOver } from '@/lib/plugin-sandbox/host-features';
+import { usePolicyStore } from '@/stores/policy-store';
export function usePluginSlotOffers(slotName: SlotName) {
const getSnapshot = () => offersForSlot(slotName);
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+ const offers = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+ const externalMail = usePolicyStore((s) => s.policy.organisation?.externalMail);
+ return useMemo(() => {
+ const features = hostFeaturesFor(externalMail);
+ const shown = offers.filter((offer) => !isSlotHandedOver(offer.pluginId, slotName, features));
+ return shown.length === offers.length ? offers : shown;
+ }, [offers, slotName, externalMail]);
}
diff --git a/hooks/use-pro-multi-account-identities.ts b/hooks/use-pro-multi-account-identities.ts
index a714af72f..364b33cdb 100644
--- a/hooks/use-pro-multi-account-identities.ts
+++ b/hooks/use-pro-multi-account-identities.ts
@@ -21,6 +21,11 @@ const CROSS_ACCOUNT_IDENTITY_DELIMITER = '::';
// still refetches.
const remoteIdentityCache = new Map();
+/** One non-active account's identities, if fetched already (raw ids). */
+export function cachedIdentitiesForAccount(localAccountId: string): Identity[] | undefined {
+ return remoteIdentityCache.get(localAccountId);
+}
+
/** Every non-active account's identities fetched so far (raw ids). */
export function cachedRemoteIdentities(): Identity[] {
return [...remoteIdentityCache.values()].flat();
diff --git a/hooks/use-restriction.ts b/hooks/use-restriction.ts
new file mode 100644
index 000000000..9edc49c4d
--- /dev/null
+++ b/hooks/use-restriction.ts
@@ -0,0 +1,32 @@
+import { usePolicyStore } from '@/stores/policy-store';
+
+export interface RestrictionView {
+ /** Not offered to the user at all. */
+ hidden: boolean;
+ /** Shown with the admin's value and "Set by your organisation", not changeable. */
+ locked: boolean;
+ /** Values the user may pick from, when the admin limited them. */
+ allowedValues: unknown[] | null;
+ min: number | null;
+ max: number | null;
+ /** The value a lock pins, if it names one. */
+ value: unknown;
+}
+
+/**
+ * The admin's restriction on one user-facing option, from the effective
+ * policy of the active account (domain and group scopes applied). Every
+ * option an admin can restrict reads it through here, so locking works the
+ * same everywhere.
+ */
+export function useRestriction(key: string): RestrictionView {
+ const restriction = usePolicyStore((state) => state.policy.restrictions[key]);
+ return {
+ hidden: restriction?.hidden === true,
+ locked: restriction?.locked === true,
+ allowedValues: Array.isArray(restriction?.allowedValues) && restriction.allowedValues.length > 0 ? restriction.allowedValues : null,
+ min: typeof restriction?.min === 'number' ? restriction.min : null,
+ max: typeof restriction?.max === 'number' ? restriction.max : null,
+ value: restriction?.value,
+ };
+}
diff --git a/hooks/use-template-source-actions.ts b/hooks/use-template-source-actions.ts
new file mode 100644
index 000000000..7a859e72f
--- /dev/null
+++ b/hooks/use-template-source-actions.ts
@@ -0,0 +1,116 @@
+import { useMemo } from 'react';
+import type { EmailTemplate } from '@/lib/template-types';
+import type { TemplateSaveResult, TemplateSource } from '@/lib/templates/template-sources';
+import type { TemplateData } from '@/lib/templates/template-reducer';
+import { personalTemplatesFor } from '@/lib/templates/personal-templates';
+import { deleteTeamTemplate, saveTeamTemplate } from '@/lib/templates/team-templates';
+import { generateUUID } from '@/lib/utils';
+import { useAuthStore } from '@/stores/auth-store';
+import { useAccountTemplatesStore, type LocalOnlyReason } from '@/stores/account-templates-store';
+
+export type { LocalOnlyReason, TemplateSaveResult };
+
+export interface TemplateSourceActions {
+ add: (source: TemplateSource, data: TemplateData) => Promise;
+ update: (source: TemplateSource, id: string, data: TemplateData) => Promise;
+ remove: (source: TemplateSource, id: string) => Promise;
+ duplicate: (source: TemplateSource, id: string, nameSuffix: string) => Promise;
+ toggleFavorite: (source: TemplateSource, id: string) => Promise;
+ /** A read-only (organisation or team) template copied into the user's own. */
+ copyToPersonal: (template: EmailTemplate) => Promise;
+ /** Personal templates that stay in this browser, by id. */
+ localOnly: Record;
+ /** Personal templates live in the account (server metadata), not only in this browser. */
+ storedInAccount: boolean;
+}
+
+const NO_LOCAL_ONLY: Record = {};
+
+/**
+ * Changes to the templates of each source the manager shows, for the
+ * signed-in account `accountId` (the active one when left out). Personal
+ * templates change in this browser at once and go to the account in the
+ * background; team templates are written to the group's Inbox at once;
+ * read-only sources refuse every change.
+ */
+export function useTemplateSourceActions(accountId?: string | null): TemplateSourceActions {
+ const resolvedId = useAuthStore((s) => accountId || s.activeAccountId);
+ const partition = useAccountTemplatesStore((s) => (resolvedId ? s.personal[resolvedId] : undefined));
+ const storedInAccount = !!partition;
+ const localOnly = partition?.localOnly ?? NO_LOCAL_ONLY;
+ return useMemo(() => {
+ const ops = () => personalTemplatesFor(resolvedId);
+ const group = (source: TemplateSource) => source.key.slice('team:'.length);
+ const find = (source: TemplateSource, id: string) => source.templates.find((t) => t.id === id);
+ const team = (source: TemplateSource, template: EmailTemplate, base: string | null): Promise =>
+ resolvedId ? saveTeamTemplate(resolvedId, group(source), template, base) : Promise.resolve('failed');
+ const fresh = (data: TemplateData): EmailTemplate => {
+ const at = new Date().toISOString();
+ return { ...data, id: generateUUID(), createdAt: at, updatedAt: at };
+ };
+ return {
+ add: async (source, data) => {
+ if (!source.editable) return 'forbidden';
+ if (source.kind === 'personal') {
+ ops().add(data);
+ return 'ok';
+ }
+ return source.kind === 'team' ? team(source, fresh(data), null) : 'forbidden';
+ },
+ update: async (source, id, data) => {
+ if (!source.editable) return 'forbidden';
+ if (source.kind === 'personal') {
+ ops().update(id, data);
+ return 'ok';
+ }
+ const existing = find(source, id);
+ if (source.kind !== 'team' || !existing) return 'forbidden';
+ return team(source, { ...existing, ...data, id, updatedAt: new Date().toISOString() }, existing.updatedAt);
+ },
+ remove: async (source, id) => {
+ if (!source.editable) return 'forbidden';
+ if (source.kind === 'personal') {
+ ops().remove(id);
+ return 'ok';
+ }
+ const existing = find(source, id);
+ if (source.kind !== 'team' || !existing || !resolvedId) return 'forbidden';
+ return deleteTeamTemplate(resolvedId, group(source), id, existing.updatedAt);
+ },
+ duplicate: async (source, id, suffix) => {
+ if (!source.editable) return 'forbidden';
+ if (source.kind === 'personal') {
+ ops().duplicate(id, suffix);
+ return 'ok';
+ }
+ const existing = find(source, id);
+ if (source.kind !== 'team' || !existing) return 'forbidden';
+ return team(source, fresh({ ...copyOf(existing), name: `${existing.name} ${suffix}` }), null);
+ },
+ toggleFavorite: async (source, id) => {
+ if (source.kind !== 'personal' || !source.editable) return 'forbidden';
+ ops().toggleFavorite(id);
+ return 'ok';
+ },
+ copyToPersonal: async (template) => {
+ ops().add(copyOf(template));
+ return 'ok';
+ },
+ localOnly,
+ storedInAccount,
+ };
+ }, [resolvedId, localOnly, storedInAccount]);
+}
+
+/** The fields a copy of a shared template takes into the user's own list. */
+export function copyOf(template: EmailTemplate): TemplateData {
+ return {
+ name: template.name,
+ subject: template.subject,
+ body: template.body,
+ isHTML: template.isHTML,
+ category: template.category,
+ defaultRecipients: template.defaultRecipients,
+ isFavorite: false,
+ };
+}
diff --git a/hooks/use-template-sources.ts b/hooks/use-template-sources.ts
new file mode 100644
index 000000000..108f2a4b6
--- /dev/null
+++ b/hooks/use-template-sources.ts
@@ -0,0 +1,62 @@
+import { useMemo } from 'react';
+import { useAccountPolicy } from '@/hooks/use-account-policy';
+import { orgLibraryTemplates } from '@/lib/templates/org-library';
+import {
+ ORGANISATION_SOURCE,
+ PERSONAL_SOURCE,
+ byName,
+ teamSourceKey,
+ type TemplateSource,
+} from '@/lib/templates/template-sources';
+import { useTemplateStore } from '@/stores/template-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { useAccountTemplatesStore } from '@/stores/account-templates-store';
+
+export interface TemplateSources {
+ /** Personal first, then teams, then the organisation library; empty sources left out except personal. */
+ sources: TemplateSource[];
+ /** The admin hid personal templates (`personalTemplates` hidden): only organisation and team templates. */
+ personalHidden: boolean;
+}
+
+/**
+ * The templates a user can pick when writing from the signed-in account
+ * `accountId` (the active one when left out): their own, those of the
+ * account's group mailboxes, and the admin library of the account's
+ * policy, so a composer sending from another account shows that
+ * account's templates.
+ */
+export function useTemplateSources(accountId?: string | null): TemplateSources {
+ const policy = useAccountPolicy(accountId);
+ const resolvedId = useAuthStore((s) => accountId || s.activeAccountId);
+ // The account's own copy once it keeps its templates (Stalwart 1.0),
+ // the browser's list before that and on other servers.
+ const accountCopy = useAccountTemplatesStore((s) => (resolvedId ? s.personal[resolvedId]?.templates : undefined));
+ const browserList = useTemplateStore((s) => s.templates);
+ const personal = accountCopy ?? browserList;
+ const teams = useAccountTemplatesStore((s) => (resolvedId ? s.teams[resolvedId] : undefined));
+ const personalHidden = policy.restrictions?.personalTemplates?.hidden === true;
+ // `teamTemplates` locked: members can use them, not change them.
+ const teamsLocked = policy.restrictions?.teamTemplates?.locked === true;
+ return useMemo(() => {
+ const sources: TemplateSource[] = [];
+ if (!personalHidden) {
+ sources.push({ key: PERSONAL_SOURCE, kind: 'personal', templates: personal, editable: true, locked: false });
+ }
+ for (const team of teams ?? []) {
+ sources.push({
+ key: teamSourceKey(team.accountId),
+ kind: 'team',
+ name: team.name,
+ templates: byName(team.templates),
+ editable: team.mayWrite && !teamsLocked,
+ locked: teamsLocked,
+ });
+ }
+ const organisation = orgLibraryTemplates(policy);
+ if (organisation.length > 0) {
+ sources.push({ key: ORGANISATION_SOURCE, kind: 'organisation', templates: byName(organisation), editable: false, locked: false });
+ }
+ return { sources, personalHidden };
+ }, [policy, personal, personalHidden, teams, teamsLocked]);
+}
diff --git a/hooks/use-third-party-mode.ts b/hooks/use-third-party-mode.ts
new file mode 100644
index 000000000..5f81f5e9b
--- /dev/null
+++ b/hooks/use-third-party-mode.ts
@@ -0,0 +1,13 @@
+"use client";
+
+import { useSyncExternalStore } from 'react';
+import { noThirdPartyTraffic, subscribeThirdPartyMode } from '@/lib/third-party-mode';
+
+/**
+ * Whether the browser must stay off built-in outside hosts (#903), as React
+ * state: components re-render when the config arrives. True while the config
+ * is still unknown, so nothing reaches out before it says it may.
+ */
+export function useNoThirdPartyTraffic(): boolean {
+ return useSyncExternalStore(subscribeThirdPartyMode, noThirdPartyTraffic, () => true);
+}
diff --git a/integration/tests/19-org-delegates.spec.ts b/integration/tests/19-org-delegates.spec.ts
new file mode 100644
index 000000000..3f4f8aaa3
--- /dev/null
+++ b/integration/tests/19-org-delegates.spec.ts
@@ -0,0 +1,284 @@
+import { test, expect, type Page } from '@playwright/test';
+import {
+ ORG,
+ ORG_PASSWORD,
+ adminContext,
+ getPolicy,
+ putPolicy,
+ requireOrgEnv,
+ shot,
+ userPage,
+} from './helpers/org';
+import { JmapClient } from './helpers/jmap';
+import { expandSharedFolders, folderRow } from './helpers/app';
+
+/**
+ * Delegates (organisation feature 7), through the real UI on a VM 106 test
+ * server: org-alice gives org-carol read access in one dialog, carol sees
+ * alice's Inbox and calendar, alice changes it to read and write and then
+ * removes it everywhere, each step checked over JMAP. The admin hides the
+ * section for a group and limits the rights for a domain on Organisation >
+ * Sharing. Every grant made here is taken away again, and the admin's policy
+ * is put back.
+ *
+ * With directory queries off (Stalwart 1.0's default) alice can't look up
+ * carol, so the typed address shows the note; carol then shares her Drafts
+ * with alice, which makes her known, and alice picks her from the list.
+ */
+
+test.describe.configure({ mode: 'serial' });
+
+const CALENDARS = 'urn:ietf:params:jmap:calendars';
+const CONTACTS = 'urn:ietf:params:jmap:contacts';
+const MAIL_SHARE = 'urn:ietf:params:jmap:mail:share';
+const USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', MAIL_SHARE, CALENDARS, CONTACTS, 'urn:ietf:params:jmap:principals'];
+
+let original: Record;
+let alice: JmapClient;
+let carol: JmapClient;
+let directoryOff = false;
+let carolDraftsId = '';
+
+type Grants = Record>>;
+
+/** alice's grants per item ("mailbox:a", "calendar:b", ...) for one principal. */
+async function grantsFor(principalId: string): Promise {
+ const r = await alice.request([
+ ['Mailbox/get', { accountId: alice.accountId, ids: null, properties: ['id', 'role', 'shareWith'] }, 'm'],
+ ['Calendar/get', { accountId: alice.accountId, ids: null, properties: ['id', 'shareWith'] }, 'c'],
+ ['AddressBook/get', { accountId: alice.accountId, ids: null, properties: ['id', 'shareWith'] }, 'a'],
+ ], USING);
+ const out: Grants[string] = {};
+ const kinds: Record = { m: 'mailbox', c: 'calendar', a: 'addressBook' };
+ for (const [, result, id] of r.methodResponses as [string, { list: Array<{ id: string; role?: string; shareWith?: Grants[string] | null }> }, string][]) {
+ for (const item of result.list ?? []) {
+ const rights = item.shareWith?.[principalId];
+ if (rights) out[`${kinds[id]}:${item.role ?? item.id}`] = rights;
+ }
+ }
+ return out;
+}
+
+/** Takes every grant of `principalId` off alice's folders, calendars and address books. */
+async function revokeAllOverJmap(principalId: string): Promise {
+ const r = await alice.request([
+ ['Mailbox/get', { accountId: alice.accountId, ids: null, properties: ['id', 'shareWith'] }, 'm'],
+ ['Calendar/get', { accountId: alice.accountId, ids: null, properties: ['id', 'shareWith'] }, 'c'],
+ ['AddressBook/get', { accountId: alice.accountId, ids: null, properties: ['id', 'shareWith'] }, 'a'],
+ ], USING);
+ const methods: Record = { m: 'Mailbox/set', c: 'Calendar/set', a: 'AddressBook/set' };
+ const calls: [string, Record, string][] = [];
+ for (const [, result, id] of r.methodResponses as [string, { list: Array<{ id: string; shareWith?: Record | null }> }, string][]) {
+ const update = Object.fromEntries((result.list ?? [])
+ .filter((item) => item.shareWith && principalId in item.shareWith)
+ .map((item) => [item.id, { [`shareWith/${principalId}`]: null }]));
+ if (Object.keys(update).length) calls.push([methods[id], { accountId: alice.accountId, update }, id]);
+ }
+ if (calls.length) await alice.request(calls, USING);
+}
+
+async function setCarolDraftsShare(on: boolean): Promise {
+ await carol.request([
+ ['Mailbox/set', { accountId: carol.accountId, update: { [carolDraftsId]: { [`shareWith/${alice.accountId}`]: on ? { mayReadItems: true } : null } } }, '0'],
+ ], USING);
+}
+
+async function openSharing(page: Page, phone = false): Promise {
+ await page.goto('/settings/sharing');
+ if (phone) {
+ await page.getByRole('button', { name: 'Sharing' }).or(page.getByRole('link', { name: 'Sharing' })).first().click();
+ }
+ await expect(page.getByRole('heading', { name: 'Delegates' })).toBeVisible({ timeout: 30000 });
+}
+
+const dialog = (page: Page) => page.getByRole('dialog');
+
+test.beforeAll(async ({ browser }) => {
+ requireOrgEnv();
+ const admin = await adminContext(browser);
+ original = await getPolicy(admin);
+ await putPolicy(admin, { ...original, scopes: [], restrictions: {}, features: {} });
+ await admin.close();
+
+ alice = await JmapClient.connect(ORG.alice, ORG_PASSWORD);
+ carol = await JmapClient.connect(ORG.carol, ORG_PASSWORD);
+ carolDraftsId = (await carol.mailboxByRole('drafts'))!.id;
+ const q = await alice.request([['Principal/query', { accountId: alice.accountId, filter: { email: ORG.carol } }, '0']], USING);
+ directoryOff = q.methodResponses[0][0] === 'error';
+ // Leftovers of an earlier run.
+ await revokeAllOverJmap(carol.accountId);
+ await setCarolDraftsShare(false);
+});
+
+test.afterAll(async ({ browser }) => {
+ if (alice && carol) {
+ await revokeAllOverJmap(carol.accountId);
+ await setCarolDraftsShare(false);
+ expect(await grantsFor(carol.accountId)).toEqual({});
+ }
+ const admin = await adminContext(browser);
+ await putPolicy(admin, original);
+ await admin.close();
+});
+
+test('a typed address works, or says the administrator has to turn on directory lookups', async ({ browser }) => {
+ const page = await userPage(browser, ORG.alice);
+ await openSharing(page);
+ await page.getByRole('button', { name: 'Give access' }).click();
+ await dialog(page).getByRole('textbox', { name: 'Person' }).fill(ORG.carol);
+ await dialog(page).getByRole('button', { name: `Use ${ORG.carol}` }).click();
+ if (directoryOff) {
+ await expect(dialog(page).getByTestId('delegate-directory-off'))
+ .toContainText('Your administrator has to turn on directory lookups in Stalwart before you can add someone new.');
+ await shot(page, 'delegates-directory-off');
+ } else {
+ await expect(dialog(page).getByText(ORG.carol)).toBeVisible();
+ }
+ await page.context().close();
+});
+
+test('alice gives carol read access to her mail, calendar and contacts in one step', async ({ browser }) => {
+ if (directoryOff) await setCarolDraftsShare(true);
+ const page = await userPage(browser, ORG.alice);
+ await openSharing(page);
+ await expect(page.getByText('Nobody has access to your mail, calendars, contacts or files.')).toBeVisible();
+ await page.getByRole('button', { name: 'Give access' }).click();
+ const d = dialog(page);
+ await expect(d.getByText("Stalwart doesn't let them send as you or on your behalf.", { exact: false })).toBeVisible();
+ await d.getByRole('textbox', { name: 'Person' }).fill('org-carol');
+ await d.getByTestId('delegate-candidates').getByRole('button', { name: /org-carol@example\.org/ }).click();
+ await expect(d.getByRole('radio', { name: /^Read See/ })).toBeChecked();
+ await shot(page, 'delegates-dialog');
+ await d.getByRole('button', { name: 'Give access' }).click();
+ await expect(d).toBeHidden();
+
+ const row = page.getByTestId('delegate-row');
+ await expect(row).toHaveCount(1);
+ await expect(row).toContainText(ORG.carol);
+ await expect(row).toContainText('All mail folders');
+ await expect(row).toContainText('Read');
+ await shot(page, 'delegates-overview');
+
+ const grants = await grantsFor(carol.accountId);
+ expect(grants['mailbox:inbox']).toMatchObject({ mayReadItems: true, mayAddItems: false, mayRemoveItems: false, maySubmit: false });
+ expect(grants['mailbox:sent']).toMatchObject({ mayReadItems: true, mayAddItems: false });
+ const calendars = Object.entries(grants).filter(([k]) => k.startsWith('calendar:'));
+ expect(calendars).toHaveLength(1);
+ expect(calendars[0][1]).toMatchObject({ mayReadItems: true, mayWriteAll: false });
+ const books = Object.entries(grants).filter(([k]) => k.startsWith('addressBook:'));
+ expect(books).toHaveLength(1);
+ expect(books[0][1]).toMatchObject({ mayRead: true, mayWrite: false });
+ await page.context().close();
+});
+
+test("carol sees alice's Inbox and calendar", async ({ browser }) => {
+ test.setTimeout(180_000);
+ const page = await userPage(browser, ORG.carol);
+ await expandSharedFolders(page, ORG.alice);
+ await expect(folderRow(page, { role: 'inbox', shared: true }).first()).toBeVisible({ timeout: 30000 });
+ await shot(page, 'delegates-carol-sees-inbox');
+ await page.goto('/calendar');
+ await expect(page.getByText(`Stalwart Calendar (${ORG.alice})`).first()).toBeVisible({ timeout: 30000 });
+ await page.context().close();
+});
+
+test('Change gives carol read and write access, and only to the Inbox', async ({ browser }) => {
+ const page = await userPage(browser, ORG.alice);
+ await openSharing(page);
+ await page.getByTestId('delegate-row').getByRole('button', { name: 'Change' }).click();
+ const d = dialog(page);
+ await expect(d.getByRole('heading', { name: `Change access for ${ORG.carol}` })).toBeVisible();
+ // It starts from what carol has: exactly every folder, the default calendar and address book, read.
+ await expect(d.getByRole('radiogroup', { name: 'Mail' }).getByRole('radio', { name: 'All folders' })).toBeChecked();
+ await expect(d.getByRole('radiogroup', { name: 'Calendars' }).getByRole('radio', { name: 'Default calendar' })).toBeChecked();
+ await expect(d.getByRole('radiogroup', { name: 'Contacts' }).getByRole('radio', { name: 'Default address book' })).toBeChecked();
+ await expect(d.getByRole('radio', { name: /^Read See/ })).toBeChecked();
+ await d.getByRole('radiogroup', { name: 'Mail' }).getByText('Inbox only').click();
+ await d.getByRole('radio', { name: /^Read and write/ }).check();
+ await d.getByRole('button', { name: 'Save' }).click();
+ await expect(d).toBeHidden();
+ await expect(page.getByTestId('delegate-row')).toContainText('Read and write');
+
+ const grants = await grantsFor(carol.accountId);
+ expect(grants['mailbox:inbox']).toMatchObject({ mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySubmit: false, mayShare: false });
+ // Change replaces: the other folders lost their access.
+ expect(Object.keys(grants).filter((k) => k.startsWith('mailbox:'))).toEqual(['mailbox:inbox']);
+ const calendars = Object.entries(grants).filter(([k]) => k.startsWith('calendar:'));
+ expect(calendars[0][1]).toMatchObject({ mayReadItems: true, mayWriteAll: true, mayShare: false });
+ await page.context().close();
+});
+
+test('Remove access takes it away everywhere', async ({ browser }) => {
+ const page = await userPage(browser, ORG.alice);
+ await openSharing(page);
+ await page.getByTestId('delegate-row').getByRole('button', { name: 'Remove access' }).click();
+ await page.getByRole('alertdialog').getByRole('button', { name: 'Remove access' }).click();
+ await expect(page.getByText('Nobody has access to your mail, calendars, contacts or files.')).toBeVisible();
+ expect(await grantsFor(carol.accountId)).toEqual({});
+ await page.context().close();
+});
+
+test('the admin limits delegates to read for one domain and hides them for a group', async ({ browser }) => {
+ const admin = await adminContext(browser);
+ const policy = await getPolicy(admin);
+ await putPolicy(admin, {
+ ...policy,
+ scopes: [
+ { id: 'delegates-team', label: 'Org team', match: { groups: [ORG.team] }, policy: {} },
+ { id: 'delegates-example', label: 'Example', match: { domains: ['example.org'] }, policy: {} },
+ ],
+ });
+
+ const page = await admin.newPage();
+ await page.goto('/admin/org-sharing');
+ await expect(page.getByRole('heading', { name: 'Delegates' })).toBeVisible();
+ // Example (a domain scope): untick write.
+ const scopeBar = page.getByTestId('org-scope-bar');
+ await scopeBar.getByRole('button', { name: 'Example', exact: true }).click();
+ const section = page.locator('[data-settings-section="delegates"]');
+ await section.getByLabel('write', { exact: true }).uncheck();
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ // Org team (a group scope): hide.
+ await scopeBar.getByRole('button', { name: 'Org team', exact: true }).click();
+ await section.getByLabel('Hide', { exact: true }).check();
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ await section.scrollIntoViewIfNeeded();
+ await shot(page, 'delegates-admin');
+
+ const saved = await getPolicy(admin);
+ const scopes = saved.scopes as Array<{ id: string; policy: { restrictions?: Record } }>;
+ expect(scopes.find((s) => s.id === 'delegates-example')!.policy.restrictions).toEqual({ sharingRights: { allowedValues: ['read'] } });
+ expect(scopes.find((s) => s.id === 'delegates-team')!.policy.restrictions).toEqual({ delegation: { hidden: true } });
+ await page.close();
+ await admin.close();
+});
+
+test('a member of the hidden group gets no Delegates section', async ({ browser }) => {
+ const page = await userPage(browser, ORG.alice);
+ await page.goto('/settings/account');
+ await expect(page.getByRole('heading', { name: 'Account' }).first()).toBeVisible({ timeout: 30000 });
+ await expect(page.getByRole('button', { name: 'Sharing' }).or(page.getByRole('link', { name: 'Sharing' }))).toHaveCount(0);
+ await page.goto('/settings/sharing');
+ await expect(page.getByRole('heading', { name: 'Delegates' })).toHaveCount(0);
+ await page.context().close();
+});
+
+test('the domain limit offers only read, says why, and the dialog fills a phone screen', async ({ browser }) => {
+ // carol is in example.org but not in the group.
+ const page = await userPage(browser, ORG.carol, true);
+ await openSharing(page, true);
+ await page.getByRole('button', { name: 'Give access' }).click();
+ const d = dialog(page);
+ const rights = d.getByTestId('delegate-rights');
+ await expect(rights.getByRole('radio')).toHaveCount(1);
+ await expect(rights.getByRole('radio', { name: /^Read See/ })).toBeChecked();
+ await expect(rights.getByText('Set by your organisation')).toBeVisible();
+ const box = await d.boundingBox();
+ expect(box).toMatchObject({ x: 0, y: 0, width: 390 });
+ await shot(page, 'delegates-dialog-phone');
+ await d.getByRole('button', { name: 'Cancel' }).click();
+ await shot(page, 'delegates-overview-phone');
+ await page.context().close();
+});
diff --git a/integration/tests/19-org-external.spec.ts b/integration/tests/19-org-external.spec.ts
new file mode 100644
index 000000000..4ac21961b
--- /dev/null
+++ b/integration/tests/19-org-external.spec.ts
@@ -0,0 +1,360 @@
+import { test, expect, type Page } from '@playwright/test';
+import { readFileSync } from 'node:fs';
+import { JmapClient } from './helpers/jmap';
+import { addRecipient, emailItem, openComposer, setSubject } from './helpers/app';
+import {
+ ORG,
+ ORG_PASSWORD,
+ adminContext,
+ getPolicy,
+ putPolicy,
+ requireOrgEnv,
+ shot,
+ userPage,
+} from './helpers/org';
+
+/**
+ * External sender label and external recipient warning (organisation
+ * feature 2), through the real UI on a VM 106 test server. Test mail is
+ * delivered over JMAP between the org-* accounts (SMTP on the VM doesn't
+ * work). The admin's policy is put back afterwards.
+ */
+
+test.describe.configure({ mode: 'serial' });
+
+const RUN = Date.now().toString(36);
+const FROM_DAVE = `External check from dave ${RUN}`;
+const FROM_BOB = `Internal check from bob ${RUN}`;
+// The quick reply turns FROM_DAVE into a thread whose latest message is
+// alice's own; the later checks read this one.
+const FROM_DAVE_2 = `Second external check from dave ${RUN}`;
+
+let original: Record;
+
+/** Sends a plain message from one org account to another, the way a mail app would. */
+async function deliver(from: string, to: string, subject: string): Promise {
+ const client = await JmapClient.connect(from, ORG_PASSWORD);
+ const [drafts, sent] = await Promise.all([client.mailboxByRole('drafts'), client.mailboxByRole('sent')]);
+ const identity = (await client.identities()).find((i) => i.email === from);
+ if (!drafts || !sent || !identity) throw new Error(`${from}: no drafts, sent or identity`);
+ const r = await client.request([
+ ['Email/set', {
+ accountId: client.accountId,
+ create: {
+ m: {
+ mailboxIds: { [drafts.id]: true },
+ keywords: { $draft: true, $seen: true },
+ from: [{ email: from }],
+ to: [{ email: to }],
+ subject,
+ bodyValues: { b: { value: `Test message for the external mail spec (${RUN}).` } },
+ textBody: [{ partId: 'b', type: 'text/plain' }],
+ },
+ },
+ }, '0'],
+ ['EmailSubmission/set', {
+ accountId: client.accountId,
+ create: { s: { identityId: identity.id, emailId: '#m' } },
+ onSuccessUpdateEmail: { '#s': { [`mailboxIds/${drafts.id}`]: null, [`mailboxIds/${sent.id}`]: true, 'keywords/$draft': null } },
+ }, '1'],
+ ]);
+ const submission = r.methodResponses[1][1];
+ if (!submission.created?.s) throw new Error(`${from}: submission failed ${JSON.stringify(r.methodResponses)}`);
+ const receiver = await JmapClient.connect(to, ORG_PASSWORD);
+ await receiver.waitForEmail(subject, { timeoutMs: 30000 });
+}
+
+/** Polls until the account has a message with exactly this subject (the subject filter doesn't find "Re:" subjects). */
+async function waitForSubject(account: string, subject: string, timeoutMs = 60000): Promise {
+ const client = await JmapClient.connect(account, ORG_PASSWORD);
+ const deadline = Date.now() + timeoutMs;
+ for (;;) {
+ const r = await client.request([
+ ['Email/query', { accountId: client.accountId, filter: { text: RUN }, limit: 50 }, '0'],
+ ['Email/get', { accountId: client.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['subject'] }, '1'],
+ ]);
+ const subjects = (r.methodResponses[1][1].list as Array<{ subject: string }>).map((e) => e.subject);
+ if (subjects.includes(subject)) return;
+ if (Date.now() > deadline) throw new Error(`${account} has no "${subject}" (has: ${subjects.join(' | ')})`);
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ }
+}
+
+async function openMessage(page: Page, subject: string): Promise {
+ const row = emailItem(page, subject).first();
+ await expect(row).toBeVisible({ timeout: 30000 });
+ await row.click();
+ await expect(page.getByRole('heading', { name: subject }).first()).toBeVisible({ timeout: 15000 });
+}
+
+const chip = (page: Page) => page.locator('[data-external-sender-chip]:visible');
+const confirm = (page: Page) => page.locator('[data-external-recipient-confirm]');
+
+async function composeTo(page: Page, to: string, subject: string): Promise {
+ await openComposer(page);
+ await addRecipient(page, to);
+ await setSubject(page, subject);
+ // An empty body keeps Send disabled.
+ await page.locator('[data-testid="email-composer"] [contenteditable="true"]').first().click();
+ await page.keyboard.type('Hello');
+ // Send with the shortcut: the recipient autocomplete can cover the button.
+ await page.locator('[data-testid="composer-subject"]').first().focus();
+ await page.keyboard.press('Control+Enter');
+}
+
+test.beforeAll(async ({ browser }) => {
+ requireOrgEnv();
+ const admin = await adminContext(browser);
+ original = await getPolicy(admin);
+ // Start from defaults: no scopes, both switches off.
+ await putPolicy(admin, { ...original, scopes: [], organisation: {} });
+ await admin.close();
+ await deliver(ORG.dave, ORG.alice, FROM_DAVE);
+ await deliver(ORG.bob, ORG.alice, FROM_BOB);
+ await deliver(ORG.dave, ORG.alice, FROM_DAVE_2);
+});
+
+test.afterAll(async ({ browser }) => {
+ const admin = await adminContext(browser);
+ await putPolicy(admin, original);
+ await admin.close();
+});
+
+test('with both switches off nothing is labelled or asked', async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice);
+ await openMessage(alice, FROM_DAVE);
+ await expect(chip(alice)).toHaveCount(0);
+ await composeTo(alice, ORG.dave, `Off ${RUN}`);
+ await expect(alice.locator('[data-testid="email-composer"]')).toBeHidden({ timeout: 30000 });
+ await expect(confirm(alice)).toHaveCount(0);
+ await alice.context().close();
+});
+
+test('the admin turns both on in Organisation > Security', async ({ browser }) => {
+ const admin = await adminContext(browser);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-security');
+ await expect(page.getByRole('heading', { name: 'External mail' })).toBeVisible();
+ await page.getByTestId('org-external-sender-label').click();
+ await page.getByTestId('org-external-recipient-warning').click();
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ await page.locator('[data-settings-section="external"]').scrollIntoViewIfNeeded();
+ await shot(page, 'external-admin-section');
+ const policy = await getPolicy(admin);
+ expect((policy.organisation as { externalMail: unknown }).externalMail).toMatchObject({ senderLabel: true, recipientWarning: true, trustContacts: true });
+ await admin.close();
+});
+
+test('mail from org-dave is labelled External, mail from org-bob is not', async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice);
+ await openMessage(alice, FROM_DAVE);
+ await expect(chip(alice).first()).toBeVisible();
+ await expect(chip(alice).first()).toHaveAttribute('title', /org-two\.test/);
+ await shot(alice, 'external-sender-chip');
+ // A click (a tap on phones) names the domain.
+ await chip(alice).first().click();
+ await expect(alice.locator('[data-external-sender-detail]')).toContainText('org-two.test');
+
+ await openMessage(alice, FROM_BOB);
+ await expect(chip(alice)).toHaveCount(0);
+ await alice.context().close();
+});
+
+test('sending to org-dave asks first, sending to org-bob does not', async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice);
+ await composeTo(alice, ORG.dave, `Asks ${RUN}`);
+ await expect(confirm(alice)).toBeVisible();
+ await expect(confirm(alice).locator('[data-external-recipients]')).toHaveText(/org-dave@org-two\.test/);
+ await shot(alice, 'external-recipient-confirm');
+ // Back keeps the message open and unsent.
+ await confirm(alice).locator('[data-external-recipient-back]').click();
+ await expect(confirm(alice)).toHaveCount(0);
+ await expect(alice.locator('[data-testid="email-composer"]')).toBeVisible();
+ await alice.keyboard.press('Escape');
+ await alice.getByRole('button', { name: 'Discard', exact: true }).last().click();
+ await expect(alice.locator('[data-testid="email-composer"]')).toBeHidden();
+
+ await composeTo(alice, ORG.bob, `Internal ${RUN}`);
+ await expect(alice.locator('[data-testid="email-composer"]')).toBeHidden({ timeout: 30000 });
+ await expect(confirm(alice)).toHaveCount(0);
+ await alice.context().close();
+});
+
+test('a quick reply to org-dave asks too: Back keeps the text, Send anyway sends it', async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice);
+ await openMessage(alice, FROM_DAVE);
+ const box = alice.getByPlaceholder('Write a quick reply...');
+ await box.fill(`Quick reply ${RUN}`);
+ await box.press('Control+Enter');
+ await expect(confirm(alice)).toBeVisible();
+ await confirm(alice).locator('[data-external-recipient-back]').click();
+ await expect(confirm(alice)).toHaveCount(0);
+ await expect(box).toHaveValue(`Quick reply ${RUN}`);
+ await box.press('Control+Enter');
+ await expect(confirm(alice)).toBeVisible();
+ // Keys stay in the dialog: Escape answers Back and leaves the message open.
+ await alice.keyboard.press('Escape');
+ await expect(confirm(alice)).toHaveCount(0);
+ await expect(box).toHaveValue(`Quick reply ${RUN}`);
+ await box.press('Control+Enter');
+ await expect(confirm(alice)).toBeVisible();
+ await confirm(alice).locator('[data-external-recipient-send]').click();
+ await expect(confirm(alice)).toHaveCount(0);
+ await waitForSubject(ORG.dave, `Re: ${FROM_DAVE}`);
+ await alice.context().close();
+});
+
+test('a scope for org-two.test with the label off applies to org-dave only', async ({ browser }) => {
+ const admin = await adminContext(browser);
+ const policy = await getPolicy(admin);
+ await putPolicy(admin, {
+ ...policy,
+ scopes: [{ id: 'org-two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: { organisation: { externalMail: { senderLabel: false } } } }],
+ });
+
+ // The scope shows on the admin page as an override.
+ const page = await admin.newPage();
+ await page.goto('/admin/org-security');
+ await page.getByTestId('org-scope-bar').getByRole('button', { name: 'Org two' }).click();
+ await expect(page.getByTestId('org-external-sender-label')).toHaveAttribute('aria-checked', 'false');
+ await expect(page.getByText('Everyone: on').first()).toBeVisible();
+ await shot(page, 'external-admin-scope');
+ await admin.close();
+
+ // org-dave reads mail from outside org-two.test: no label for that domain.
+ await deliver(ORG.alice, ORG.dave, `From alice ${RUN}`);
+ const dave = await userPage(browser, ORG.dave);
+ await openMessage(dave, `From alice ${RUN}`);
+ await expect(chip(dave)).toHaveCount(0);
+ await dave.context().close();
+
+ // org-alice keeps it.
+ const alice = await userPage(browser, ORG.alice);
+ await openMessage(alice, FROM_DAVE_2);
+ await expect(chip(alice).first()).toBeVisible();
+ await alice.context().close();
+});
+
+test('the label and the confirm fit a phone', async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice, true);
+ await openMessage(alice, FROM_DAVE_2);
+ await expect(chip(alice).first()).toBeVisible();
+ const overflow = await alice.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
+ expect(overflow).toBeLessThanOrEqual(0);
+ const box = await chip(alice).first().boundingBox();
+ expect(box && box.x + box.width).toBeLessThanOrEqual(390);
+ await shot(alice, 'external-sender-chip-phone');
+
+ await alice.goBack().catch(() => {});
+ await composeTo(alice, ORG.dave, `Phone ${RUN}`);
+ await expect(confirm(alice)).toBeVisible();
+ const dialog = await confirm(alice).boundingBox();
+ expect(dialog && dialog.x).toBeGreaterThanOrEqual(0);
+ expect(dialog && dialog.x + dialog.width).toBeLessThanOrEqual(390);
+ await shot(alice, 'external-recipient-confirm-phone');
+ await confirm(alice).locator('[data-external-recipient-back]').click();
+ await alice.context().close();
+});
+
+test('Organisation > Security fits a phone', async ({ browser }) => {
+ const admin = await adminContext(browser, true);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-security');
+ await expect(page.getByTestId('org-external-sender-label')).toBeVisible();
+ const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
+ expect(overflow).toBeLessThanOrEqual(0);
+ await page.getByTestId('org-external-domains').scrollIntoViewIfNeeded();
+ await shot(page, 'external-admin-section-phone');
+ await admin.close();
+});
+
+// ─── The retired External Mail Warning plugin ─────────────────────────────
+// Needs the 1.5.1 package (from before the plugin knew api.host): set
+// IT_EMW_ZIP to its path. It is installed for this test and removed again.
+
+test.describe('an installed External Mail Warning 1.5.1', () => {
+ const zip = process.env.IT_EMW_ZIP;
+ test.skip(!zip, 'set IT_EMW_ZIP to the external-mail-warning 1.5.1 zip');
+
+ test.afterAll(async ({ browser }) => {
+ const admin = await adminContext(browser);
+ await admin.request.delete('/api/admin/plugins', { data: { id: 'external-mail-warning' } });
+ await admin.close();
+ });
+
+ test('steps back one switch at a time, and its safe domains import', async ({ browser }) => {
+ const admin = await adminContext(browser);
+ // next dev compiles the sandbox page on first use, which can outlast the plugin's init timeout.
+ await admin.request.get('/plugin-sandbox', { timeout: 120000 });
+ const installed = await admin.request.post('/api/admin/plugins', {
+ multipart: { file: { name: 'external-mail-warning.zip', mimeType: 'application/zip', buffer: readFileSync(zip!) } },
+ });
+ expect(installed.ok(), await installed.text()).toBe(true);
+ const config = await admin.request.put('/api/admin/plugins/external-mail-warning/config', { data: { key: 'safeDomains', value: 'partner.example, other-partner.example' } });
+ expect(config.ok(), await config.text()).toBe(true);
+
+ // Core off, plugin on for everyone: the plugin asks, which shows it runs.
+ const policy = await getPolicy(admin);
+ const features = { ...(policy.features as Record), pluginsEnabled: true };
+ await putPolicy(admin, {
+ ...policy, features, forceEnabledPlugins: ['external-mail-warning'], scopes: [],
+ organisation: { externalMail: { senderLabel: false, recipientWarning: false } },
+ });
+ let alice = await userPage(browser, ORG.alice);
+ // The plugin starts after the sign-in; its frame is the sign that it runs.
+ await expect(alice.locator('iframe[src*="/plugin-sandbox"]').first()).toBeAttached({ timeout: 30000 });
+ await composeTo(alice, ORG.dave, `Plugin asks ${RUN}`);
+ const pluginDialog = alice.locator('[data-plugin-dialog="external-mail-warning"]');
+ await expect(pluginDialog).toBeVisible({ timeout: 30000 });
+ await expect(confirm(alice)).toHaveCount(0);
+ await pluginDialog.getByRole('button', { name: 'Cancel' }).click();
+ await alice.context().close();
+
+ // The admin page asks to update the plugin and imports its list.
+ await putPolicy(admin, { ...(await getPolicy(admin)), organisation: { externalMail: { senderLabel: true, recipientWarning: true } } });
+ const page = await admin.newPage();
+ await page.goto('/admin/org-security');
+ await expect(page.getByTestId('org-external-plugin-note')).toContainText('Update or remove the External Mail Warning plugin');
+ await page.getByTestId('org-external-import-plugin').click();
+ await expect(page.getByTestId('org-external-domains')).toHaveValue('partner.example\nother-partner.example');
+ await page.getByTestId('org-external-plugin-note').scrollIntoViewIfNeeded();
+ await shot(page, 'external-admin-plugin-note');
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ expect(((await getPolicy(admin)).organisation as { externalMail: { domains: string[] } }).externalMail.domains)
+ .toEqual(['partner.example', 'other-partner.example']);
+ await page.close();
+
+ // Only the recipient warning on: one question on send, Bulwark's own;
+ // the plugin keeps its banner.
+ await putPolicy(admin, { ...(await getPolicy(admin)), organisation: { externalMail: { senderLabel: false, recipientWarning: true } } });
+ alice = await userPage(browser, ORG.alice);
+ await expect(alice.locator('iframe[src*="/plugin-sandbox"]').first()).toBeAttached({ timeout: 30000 });
+ await openMessage(alice, FROM_DAVE_2);
+ await expect(chip(alice)).toHaveCount(0);
+ await expect(alice.locator('[data-plugin-slot="email-banner"]')).toHaveCount(1);
+ await composeTo(alice, ORG.dave, `Core asks ${RUN}`);
+ await expect(confirm(alice)).toBeVisible();
+ await confirm(alice).locator('[data-external-recipient-send]').click();
+ await expect(alice.locator('[data-testid="email-composer"]')).toBeHidden({ timeout: 30000 });
+ await expect(alice.locator('[data-plugin-dialog="external-mail-warning"]')).toHaveCount(0);
+ await waitForSubject(ORG.dave, `Core asks ${RUN}`);
+ await alice.context().close();
+
+ // Only the label on: Bulwark's chip instead of the plugin's banner; the
+ // plugin still asks on send, Bulwark doesn't.
+ await putPolicy(admin, { ...(await getPolicy(admin)), organisation: { externalMail: { senderLabel: true, recipientWarning: false } } });
+ alice = await userPage(browser, ORG.alice);
+ await expect(alice.locator('iframe[src*="/plugin-sandbox"]').first()).toBeAttached({ timeout: 30000 });
+ await openMessage(alice, FROM_DAVE_2);
+ await expect(chip(alice).first()).toBeVisible();
+ await expect(alice.locator('[data-plugin-slot="email-banner"]')).toHaveCount(0);
+ await composeTo(alice, ORG.dave, `Plugin still asks ${RUN}`);
+ await expect(alice.locator('[data-plugin-dialog="external-mail-warning"]')).toBeVisible({ timeout: 30000 });
+ await expect(confirm(alice)).toHaveCount(0);
+ await alice.locator('[data-plugin-dialog="external-mail-warning"]').getByRole('button', { name: 'Cancel' }).click();
+ await alice.context().close();
+ await admin.close();
+ });
+});
diff --git a/integration/tests/19-org-policy-scopes.spec.ts b/integration/tests/19-org-policy-scopes.spec.ts
new file mode 100644
index 000000000..dedc01ff3
--- /dev/null
+++ b/integration/tests/19-org-policy-scopes.spec.ts
@@ -0,0 +1,169 @@
+import { test, expect } from '@playwright/test';
+import { readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import {
+ ORG,
+ adminContext,
+ getPolicy,
+ putPolicy,
+ requireOrgEnv,
+ shot,
+ userPage,
+} from './helpers/org';
+
+/**
+ * Domain and group scopes (organisation feature 8), through the real UI on a
+ * VM 106 test server: the admin creates a domain scope and a group scope on
+ * Organisation > Domains and groups, and each applies only where it should.
+ * The admin's policy is put back afterwards.
+ */
+
+test.describe.configure({ mode: 'serial' });
+
+let original: Record;
+
+test.beforeAll(async ({ browser }) => {
+ requireOrgEnv();
+ const admin = await adminContext(browser);
+ original = await getPolicy(admin);
+ await putPolicy(admin, { ...original, scopes: [], restrictions: {}, features: {} });
+ await admin.close();
+});
+
+test.afterAll(async ({ browser }) => {
+ const admin = await adminContext(browser);
+ await putPolicy(admin, original);
+ await admin.close();
+});
+
+test('the admin adds a domain and a group on the Domains and groups page', async ({ browser }) => {
+ const admin = await adminContext(browser);
+ const page = await admin.newPage();
+ await page.goto('/admin/organisation');
+ await expect(page.getByRole('heading', { name: 'Domains and groups' }).first()).toBeVisible();
+
+ await page.getByRole('button', { name: 'Add domains' }).click();
+ await page.getByLabel('Name', { exact: true }).last().fill('Org two');
+ await page.getByLabel('Domains', { exact: true }).last().fill('org-two.test');
+ await page.getByLabel('Domains', { exact: true }).last().blur();
+
+ await page.getByRole('button', { name: 'Add groups' }).click();
+ await page.getByLabel('Name', { exact: true }).last().fill('Org team');
+ await page.getByLabel('Groups', { exact: true }).last().fill(ORG.team);
+ await page.getByLabel('Groups', { exact: true }).last().blur();
+
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ await shot(page, 'admin-domains-and-groups');
+
+ const policy = await getPolicy(admin);
+ const scopes = policy.scopes as Array<{ id: string; label: string; match: Record }>;
+ expect(scopes.map((s) => [s.label, s.match])).toEqual([
+ ['Org two', { domains: ['org-two.test'] }],
+ ['Org team', { groups: [ORG.team] }],
+ ]);
+
+ // The settings of each scope come with the Organisation pages of the
+ // features; here they are set through the same policy the pages write.
+ const [two, team] = scopes;
+ await putPolicy(admin, {
+ ...policy,
+ scopes: [
+ { ...two, policy: { features: { calendarEnabled: false } } },
+ { ...team, policy: { restrictions: { density: { locked: true, value: 'compact' } } } },
+ ],
+ });
+
+ // The effective policy for org-alice names the group scope.
+ await page.reload();
+ await page.getByLabel('User address').fill(ORG.alice);
+ await page.getByLabel('Groups the user is a member of').fill(ORG.team);
+ const effective = page.getByTestId('org-effective-policy');
+ await expect(effective).toContainText('Applies, in this order: Everyone, Org team.');
+ await expect(effective).toContainText('restrictions.density');
+ await shot(page, 'admin-effective-policy');
+ await admin.close();
+});
+
+test('a domain scope applies to that domain only', async ({ browser }) => {
+ const dave = await userPage(browser, ORG.dave);
+ await expect(dave.locator('[data-tour="nav-mail"]')).toBeVisible();
+ await expect(dave.locator('[data-tour="nav-calendar"]')).toHaveCount(0);
+ await dave.context().close();
+
+ const carol = await userPage(browser, ORG.carol);
+ await expect(carol.locator('[data-tour="nav-calendar"]')).toBeVisible();
+ await carol.goto('/settings/appearance');
+ await expect(carol.getByText('Set by your organisation')).toHaveCount(0);
+ await carol.context().close();
+});
+
+test('a group scope locks a setting for members only, with the reason in place', async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice);
+ await expect(alice.locator('[data-tour="nav-calendar"]')).toBeVisible();
+ await alice.goto('/settings/appearance');
+ const note = alice.getByText('Set by your organisation');
+ await expect(note).toHaveCount(1);
+ await note.scrollIntoViewIfNeeded();
+ await shot(alice, 'user-locked-setting');
+ // The locked control can't be reached.
+ const row = alice.locator('[data-search-label]').filter({ has: note });
+ await expect(row.locator('[inert]')).toHaveCount(1);
+ await alice.context().close();
+});
+
+test('the lock shows at phone width too', async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice, true);
+ // A phone opens the settings on their list.
+ await alice.goto('/settings/appearance');
+ await alice.getByRole('button', { name: 'Appearance' }).or(alice.getByRole('link', { name: 'Appearance' })).first().click();
+ const note = alice.getByText('Set by your organisation');
+ await expect(note).toBeVisible();
+ await note.scrollIntoViewIfNeeded();
+ await shot(alice, 'user-locked-setting-phone');
+ await alice.context().close();
+});
+
+test('the whole policy exports to one file and imports back', async ({ browser }, testInfo) => {
+ const admin = await adminContext(browser);
+ const page = await admin.newPage();
+ await page.goto('/admin/organisation');
+ const [download] = await Promise.all([
+ page.waitForEvent('download'),
+ page.getByRole('button', { name: 'Export policy.json' }).click(),
+ ]);
+ const exported = testInfo.outputPath('exported-policy.json');
+ await download.saveAs(exported);
+ const file = JSON.parse(await readFile(exported, 'utf-8'));
+ expect(file.scopes.map((s: { label: string }) => s.label)).toEqual(['Org two', 'Org team']);
+
+ // Change it outside Bulwark, as someone keeping it in git would.
+ file.scopes = file.scopes.filter((s: { label: string }) => s.label !== 'Org two');
+ const edited = path.join(path.dirname(exported), 'edited-policy.json');
+ await writeFile(edited, JSON.stringify(file));
+ await page.getByLabel('Policy file to import').setInputFiles(edited);
+ const preview = page.getByTestId('policy-import-preview');
+ await expect(preview).toContainText('Domains and groups: 1 removed');
+ await shot(page, 'admin-policy-import-preview');
+ await page.getByRole('button', { name: 'Replace the current policy' }).click();
+ await expect(preview).toHaveCount(0);
+ const after = await getPolicy(admin);
+ expect((after.scopes as Array<{ label: string }>).map((s) => s.label)).toEqual(['Org team']);
+ await admin.close();
+
+ // org-dave has the calendar again.
+ const dave = await userPage(browser, ORG.dave);
+ await expect(dave.locator('[data-tour="nav-calendar"]')).toBeVisible();
+ await dave.context().close();
+});
+
+test('the Organisation pages work at phone width', async ({ browser }) => {
+ const admin = await adminContext(browser, true);
+ const page = await admin.newPage();
+ await page.goto('/admin/organisation');
+ await expect(page.getByRole('button', { name: 'Add domains' })).toBeVisible();
+ const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
+ expect(overflow).toBeLessThanOrEqual(0);
+ await shot(page, 'admin-domains-and-groups-phone');
+ await admin.close();
+});
diff --git a/integration/tests/19-org-recovery.spec.ts b/integration/tests/19-org-recovery.spec.ts
new file mode 100644
index 000000000..735df4687
--- /dev/null
+++ b/integration/tests/19-org-recovery.spec.ts
@@ -0,0 +1,374 @@
+import { test, expect, type Page } from '@playwright/test';
+import {
+ ORG,
+ ORG_PASSWORD,
+ adminContext,
+ getPolicy,
+ putPolicy,
+ requireOrgEnv,
+ shot,
+ userPage,
+} from './helpers/org';
+import { JmapClient } from './helpers/jmap';
+import { folderRow } from './helpers/app';
+
+/**
+ * Recover deleted items (organisation feature 9, #1319) through the real UI
+ * on a VM 106 test server.
+ *
+ * No test server has a Stalwart Enterprise license yet, so x:ArchivedItem
+ * answers `forbidden` everywhere and the feature has to hide itself: Bulwark
+ * asks once, then shows no Settings entry and no Trash link. An admin scope
+ * that hides `deletedItemsRecovery` keeps Bulwark from asking at all.
+ *
+ * The licensed case (IT_RECOVERY_LICENSED=1, on a server with a license and
+ * `archiveDeletedItemsFor` set): org-alice deletes a message for good, finds
+ * it under Settings > Recover deleted items, restores it, and it is back in
+ * her Inbox over JMAP. The admin's policy is put back at the end.
+ */
+
+// Sign-ins and first compiles of a dev server take a while.
+test.describe.configure({ mode: 'serial', timeout: 180000 });
+
+const LICENSED = process.env.IT_RECOVERY_LICENSED === '1';
+const STALWART_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'];
+
+let original: Record;
+let alice: JmapClient;
+
+const isArchiveQuery = (body: string | null) => !!body && body.includes('x:ArchivedItem/query');
+
+/** Collects the bodies of the JMAP requests the page sends that ask the archive. */
+function watchArchiveQueries(page: Page): string[] {
+ const seen: string[] = [];
+ page.on('request', (request) => {
+ const body = request.postData();
+ if (request.method() === 'POST' && isArchiveQuery(body)) seen.push(body!);
+ });
+ return seen;
+}
+
+async function openTrash(page: Page): Promise {
+ const trash = folderRow(page, { role: 'trash', shared: false }).first();
+ await expect(trash).toBeVisible({ timeout: 30000 });
+ await trash.click();
+}
+
+async function setPolicy(browser: import('@playwright/test').Browser, patch: Record): Promise {
+ const admin = await adminContext(browser);
+ await putPolicy(admin, { ...original, scopes: [], restrictions: {}, features: {}, ...patch });
+ await admin.close();
+}
+
+test.beforeAll(async ({ browser }) => {
+ requireOrgEnv();
+ const admin = await adminContext(browser);
+ original = await getPolicy(admin);
+ await admin.close();
+ await setPolicy(browser, {});
+ alice = await JmapClient.connect(ORG.alice, ORG_PASSWORD);
+});
+
+test.afterAll(async ({ browser }) => {
+ if (!original) return;
+ const admin = await adminContext(browser);
+ await putPolicy(admin, original);
+ await admin.close();
+});
+
+test.describe('without an Enterprise license', () => {
+ test.skip(LICENSED, 'the server has a license (IT_RECOVERY_LICENSED=1)');
+
+ test('the server refuses x:ArchivedItem/query', async () => {
+ const r = await alice.request([['x:ArchivedItem/query', { accountId: alice.accountId }, 'q']], STALWART_USING);
+ expect(r.methodResponses[0][0]).toBe('error');
+ expect(r.methodResponses[0][1]).toMatchObject({ type: 'forbidden' });
+ });
+
+ test('Bulwark asks once, then shows no Trash link and no Settings entry', async ({ browser }) => {
+ const page = await userPage(browser, ORG.alice);
+ const asked = watchArchiveQueries(page);
+ const answer = page.waitForResponse((res) => isArchiveQuery(res.request().postData()), { timeout: 30000 });
+ await openTrash(page);
+ await answer;
+ await expect(page.getByTestId('trash-recovery-link')).toHaveCount(0);
+ await shot(page, 'recovery-trash-hidden');
+
+ await page.goto('/settings/recovery');
+ await expect(page.getByRole('heading', { name: 'Appearance' }).first()).toBeVisible({ timeout: 30000 });
+ await expect(page.getByRole('button', { name: 'Recover deleted items' }).or(page.getByRole('link', { name: 'Recover deleted items' }))).toHaveCount(0);
+ await expect(page.getByRole('heading', { name: 'Recover deleted items' })).toHaveCount(0);
+ await expect(page.getByTestId('recover-deleted-list')).toHaveCount(0);
+ // One question per account and session, not one per page.
+ expect(asked.length).toBe(1);
+ await page.context().close();
+ });
+
+ test('the same at phone width', async ({ browser }) => {
+ const page = await userPage(browser, ORG.carol, true);
+ const answer = page.waitForResponse((res) => isArchiveQuery(res.request().postData()), { timeout: 30000 });
+ await page.goto('/settings/recovery');
+ await answer;
+ await expect(page.getByRole('button', { name: 'Recover deleted items' }).or(page.getByRole('link', { name: 'Recover deleted items' }))).toHaveCount(0);
+ await shot(page, 'recovery-settings-hidden-phone');
+ await page.context().close();
+ });
+});
+
+test('the admin hides it for a group and limits the days for a domain on Organisation > Sharing', async ({ browser }) => {
+ const admin = await adminContext(browser);
+ await putPolicy(admin, {
+ ...original,
+ restrictions: {}, features: {},
+ scopes: [
+ { id: 'recovery-team', label: 'Org team', match: { groups: [ORG.team] }, policy: {} },
+ { id: 'recovery-example', label: 'Example', match: { domains: ['example.org'] }, policy: {} },
+ ],
+ });
+ const page = await admin.newPage();
+ await page.goto('/admin/org-sharing');
+ const section = page.locator('[data-settings-section="recovery"]');
+ await expect(section.getByRole('heading', { name: 'Recover deleted items' })).toBeVisible({ timeout: 30000 });
+ await expect(section.getByTestId('org-recovery-requirements')).toContainText('Stalwart Enterprise license');
+ const scopeBar = page.getByTestId('org-scope-bar');
+ await scopeBar.getByRole('button', { name: 'Example', exact: true }).click();
+ await section.getByLabel('Show deleted mail of the last (days)').fill('14');
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ await scopeBar.getByRole('button', { name: 'Org team', exact: true }).click();
+ await section.getByLabel('Hide', { exact: true }).check();
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ await section.scrollIntoViewIfNeeded();
+ await shot(page, 'recovery-admin');
+
+ const saved = await getPolicy(admin);
+ const scopes = saved.scopes as Array<{ id: string; policy: { restrictions?: Record; organisation?: Record } }>;
+ expect(scopes.find((s) => s.id === 'recovery-team')!.policy.restrictions).toEqual({ deletedItemsRecovery: { hidden: true } });
+ expect(scopes.find((s) => s.id === 'recovery-example')!.policy.organisation).toEqual({ recovery: { days: 14 } });
+ await page.close();
+
+ const phone = await adminContext(browser, true);
+ const phonePage = await phone.newPage();
+ await phonePage.goto('/admin/org-sharing');
+ const phoneSection = phonePage.locator('[data-settings-section="recovery"]');
+ await phoneSection.scrollIntoViewIfNeeded();
+ await expect(phoneSection.getByLabel('Show deleted mail of the last (days)')).toBeVisible({ timeout: 30000 });
+ const width = await phonePage.evaluate(() => document.documentElement.scrollWidth);
+ expect(width).toBeLessThanOrEqual(390);
+ await shot(phonePage, 'recovery-admin-phone');
+ await phone.close();
+ await admin.close();
+});
+
+test('a member of the hidden group gets nothing and Bulwark never asks the server', async ({ browser }) => {
+ const page = await userPage(browser, ORG.alice);
+ const asked = watchArchiveQueries(page);
+ await openTrash(page);
+ await page.goto('/settings/recovery');
+ await expect(page.getByRole('heading', { name: 'Appearance' }).first()).toBeVisible({ timeout: 30000 });
+ await expect(page.getByRole('button', { name: 'Recover deleted items' }).or(page.getByRole('link', { name: 'Recover deleted items' }))).toHaveCount(0);
+ expect(asked).toEqual([]);
+ await page.context().close();
+});
+
+test('someone outside the group is still asked about', async ({ browser }) => {
+ // carol is in example.org but not in the group: Bulwark asks the server for her.
+ const page = await userPage(browser, ORG.carol);
+ const answer = page.waitForResponse((res) => isArchiveQuery(res.request().postData()), { timeout: 30000 });
+ await openTrash(page);
+ await answer;
+ if (!LICENSED) await expect(page.getByTestId('trash-recovery-link')).toHaveCount(0);
+ else await expect(page.getByTestId('trash-recovery-link')).toBeVisible();
+ await page.context().close();
+});
+
+/**
+ * The page with archive answers put in by the browser test, not a server:
+ * DERIVED from the Stalwart source (x:ArchivedItem in
+ * crates/jmap/src/registry/mapping/archived_item.rs, ArchivedEmail in
+ * crates/registry/src/schema/structs_impl.rs), because no server can give
+ * them yet. Every other request goes to the real server. Restoring `b` is
+ * refused, to show the partial failure.
+ */
+type ArchiveMode = 'items' | 'empty' | 'getFails';
+
+async function answerArchiveCalls(page: Page, mode: () => ArchiveMode): Promise {
+ const restored: string[] = [];
+ const day = 24 * 60 * 60_000;
+ const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
+ const now = Date.now();
+ const items: Record> = {
+ a: { from: 'Org Bob ', subject: 'Signed contract', archivedAt: iso(now - 2 * 60 * 60_000) },
+ b: { from: 'billing@supplier.example', subject: 'Invoice 2026-118', archivedAt: iso(now - 3 * day) },
+ c: { from: 'Org Carol', subject: 'Team offsite plan', archivedAt: iso(now - 9 * day) },
+ };
+ await page.route('**/*', async (route) => {
+ const body = route.request().postData();
+ if (route.request().method() !== 'POST' || !body || !body.includes('"x:ArchivedItem/')) return route.fallback();
+ const request = JSON.parse(body) as { methodCalls: Array<[string, Record, string]> };
+ const methodResponses = request.methodCalls.map(([method, args, id]) => {
+ const accountId = args.accountId;
+ if (method === 'x:ArchivedItem/query') {
+ // A user without Impersonate is refused the accountId filter.
+ if (args.filter) return ['error', { type: 'unsupportedFilter', description: 'Filter on property accountId is not supported or invalid' }, id];
+ // Newest first, as Stalwart's growing registry ids give it.
+ const all = mode() === 'empty' ? [] : Object.keys(items).sort((x, y) => Date.parse(items[y].archivedAt as string) - Date.parse(items[x].archivedAt as string));
+ const position = (args.position as number | undefined) ?? 0;
+ const ids = all.slice(position, position + ((args.limit as number | undefined) ?? 5000));
+ return [method, { accountId, ids, position, ...(args.calculateTotal ? { total: all.length } : {}), queryState: 's0', canCalculateChanges: false }, id];
+ }
+ if (method === 'x:ArchivedItem/get') {
+ if (mode() === 'getFails') return ['error', { type: 'serverFail', description: 'Synthetic failure' }, id];
+ const list = (args.ids as string[]).filter((i) => items[i]).map((i) => ({
+ id: i, '@type': 'Email', receivedAt: iso(now - 30 * day), size: 4096, accountId,
+ archivedUntil: iso(now + 20 * day), blobId: `blob-${i}`, ...items[i],
+ }));
+ return [method, { accountId, list, notFound: [] }, id];
+ }
+ if (method === 'x:ArchivedItem/set') {
+ const ids = Object.keys(args.update as object);
+ const ok = ids.filter((i) => i !== 'b');
+ restored.push(...ok);
+ for (const i of ok) delete items[i];
+ return [method, {
+ accountId, updated: Object.fromEntries(ok.map((i) => [i, null])),
+ notUpdated: ids.includes('b') ? { b: { type: 'serverFail', description: 'Synthetic refusal' } } : {},
+ }, id];
+ }
+ return ['error', { type: 'unknownMethod' }, id];
+ });
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ methodResponses, sessionState: 's' }) });
+ });
+ return restored;
+}
+
+test.describe('the page with derived archive answers (no server has a license yet)', () => {
+ test.beforeAll(async ({ browser }) => {
+ await setPolicy(browser, {});
+ });
+
+ test('Trash links to the list; restoring shows what failed and keeps it selected', async ({ browser }) => {
+ const page = await userPage(browser, ORG.alice);
+ let mode: ArchiveMode = 'items';
+ const restored = await answerArchiveCalls(page, () => mode);
+ await openTrash(page);
+ const link = page.getByTestId('trash-recovery-link');
+ await expect(link).toBeVisible({ timeout: 30000 });
+ await shot(page, 'recovery-trash-link');
+ await link.getByRole('button', { name: 'Recover deleted items' }).click();
+ await expect(page.getByRole('heading', { name: 'Recover deleted items' }).first()).toBeVisible({ timeout: 30000 });
+ const rows = page.getByTestId('recover-deleted-row');
+ await expect(rows).toHaveCount(3);
+ await expect(rows.nth(0)).toContainText('Signed contract');
+ await expect(rows.nth(0)).toContainText('Org Bob');
+ await expect(rows.nth(2)).toContainText('Team offsite plan');
+ await shot(page, 'recovery-list');
+
+ await page.getByTestId('recover-deleted-select-all').check();
+ await page.getByTestId('recover-deleted-restore').click();
+ await expect(page.getByTestId('recover-deleted-partial')).toContainText("1 message couldn't be restored");
+ await expect(rows).toHaveCount(1);
+ await expect(rows.nth(0)).toContainText('Invoice 2026-118');
+ await expect(rows.nth(0).getByRole('checkbox')).toBeChecked();
+ expect(restored.sort()).toEqual(['a', 'c']);
+ await shot(page, 'recovery-partial');
+
+ mode = 'getFails';
+ await page.getByRole('button', { name: 'Refresh' }).click();
+ await expect(page.getByTestId('recover-deleted-error')).toBeVisible();
+ await shot(page, 'recovery-error');
+ mode = 'empty';
+ await page.getByTestId('recover-deleted-error').getByRole('button', { name: 'Try again' }).click();
+ await expect(page.getByTestId('recover-deleted-empty')).toContainText('archiving of deleted items');
+ await shot(page, 'recovery-empty');
+ // With nothing to recover, Trash has no link (the page itself stays in Settings).
+ await page.goto('/');
+ const looked = page.waitForRequest((req) => (req.postData() ?? '').includes('"calculateTotal":true'), { timeout: 30000 });
+ await openTrash(page);
+ await looked;
+ await page.waitForTimeout(1000);
+ await expect(page.getByTestId('trash-recovery-link')).toHaveCount(0);
+ await page.context().close();
+ });
+
+ test('the list fits a phone and the days of a domain scope apply', async ({ browser }) => {
+ await setPolicy(browser, { scopes: [{ id: 'recovery-example', label: 'Example', match: { domains: ['example.org'] }, policy: { organisation: { recovery: { days: 7 } } } }] });
+ const page = await userPage(browser, ORG.alice, true);
+ await answerArchiveCalls(page, () => 'items');
+ await page.goto('/settings/recovery');
+ await page.getByRole('button', { name: 'Recover deleted items' }).or(page.getByRole('link', { name: 'Recover deleted items' })).first().click({ timeout: 30000 });
+ await expect(page.getByRole('heading', { name: 'Recover deleted items' }).first()).toBeVisible({ timeout: 30000 });
+ // c was deleted nine days ago: outside the domain's seven days.
+ await expect(page.getByTestId('recover-deleted-row')).toHaveCount(2);
+ await expect(page.getByTestId('recover-deleted-range')).toHaveText('Showing mail deleted in the last 7 days.');
+ const width = await page.evaluate(() => document.documentElement.scrollWidth);
+ expect(width).toBeLessThanOrEqual(390);
+ await shot(page, 'recovery-list-phone');
+ await page.context().close();
+ });
+});
+
+test.describe('with an Enterprise license', () => {
+ test.skip(!LICENSED, 'set IT_RECOVERY_LICENSED=1 on a server with a license and archiveDeletedItemsFor');
+
+ test('alice deletes a message for good, finds it under Recover deleted items and restores it to her Inbox', async ({ browser }) => {
+ await setPolicy(browser, {});
+ const subject = `Recovery ${Date.now()}`;
+ const inbox = (await alice.mailboxByRole('inbox'))!;
+ const created = await alice.request([
+ ['Email/set', {
+ accountId: alice.accountId,
+ create: {
+ m: {
+ mailboxIds: { [inbox.id]: true },
+ keywords: { $seen: true },
+ from: [{ name: 'Org Bob', email: ORG.bob }],
+ to: [{ email: ORG.alice }],
+ subject,
+ bodyValues: { b: { value: 'Please bring this back.' } },
+ textBody: [{ partId: 'b', type: 'text/plain' }],
+ },
+ },
+ }, '0'],
+ ]);
+ const emailId = created.methodResponses[0][1].created.m.id as string;
+ // Deleted for good, as emptying Trash does.
+ await alice.request([['Email/set', { accountId: alice.accountId, destroy: [emailId] }, '0']]);
+ expect(await alice.findEmailBySubject(subject)).toBeUndefined();
+
+ const page = await userPage(browser, ORG.alice);
+ // The Trash link only shows once the archive holds the message; the page is there at once.
+ await page.goto('/settings/recovery');
+ await expect(page.getByRole('heading', { name: 'Recover deleted items' }).first()).toBeVisible({ timeout: 30000 });
+ // The server archives in the background: refresh until the message is there.
+ const row = page.getByTestId('recover-deleted-row').filter({ hasText: subject });
+ await expect(async () => {
+ await page.getByRole('button', { name: 'Refresh' }).click();
+ await expect(row).toBeVisible({ timeout: 3000 });
+ }).toPass({ timeout: 90000 });
+ await expect(row).toContainText('Org Bob');
+ await shot(page, 'recovery-list-licensed');
+
+ await row.getByRole('checkbox').check();
+ await page.getByTestId('recover-deleted-restore').click();
+ await expect(page.getByTestId('recover-deleted-status')).toContainText('on the way back to your Inbox');
+ await expect(row).toHaveCount(0);
+
+ const restored = await alice.waitForEmail(subject, { mailboxId: inbox.id, timeoutMs: 90000 });
+ expect(restored.keywords?.$seen).toBeFalsy();
+ await alice.request([['Email/set', { accountId: alice.accountId, destroy: [restored.id] }, '0']]);
+ await page.context().close();
+ });
+
+ test('the list and restore work at phone width', async ({ browser }) => {
+ const page = await userPage(browser, ORG.alice, true);
+ await page.goto('/settings/recovery');
+ await page.getByRole('button', { name: 'Recover deleted items' }).or(page.getByRole('link', { name: 'Recover deleted items' })).first().click();
+ await expect(page.getByRole('heading', { name: 'Recover deleted items' }).first()).toBeVisible({ timeout: 30000 });
+ const width = await page.evaluate(() => document.documentElement.scrollWidth);
+ expect(width).toBeLessThanOrEqual(390);
+ await shot(page, 'recovery-list-phone-licensed');
+ await page.context().close();
+ });
+});
diff --git a/integration/tests/19-org-sessions.spec.ts b/integration/tests/19-org-sessions.spec.ts
new file mode 100644
index 000000000..58b5e3203
--- /dev/null
+++ b/integration/tests/19-org-sessions.spec.ts
@@ -0,0 +1,209 @@
+import { test, expect, type Page } from '@playwright/test';
+import {
+ ORG,
+ ORG_PASSWORD,
+ adminContext,
+ getPolicy,
+ putPolicy,
+ requireOrgEnv,
+ shot,
+ userPage,
+} from './helpers/org';
+import { JmapClient } from './helpers/jmap';
+import { emailItem, openComposer, setSubject, waitDraftSaved } from './helpers/app';
+
+/**
+ * Session idle timeout (organisation feature 4), through the real UI on a
+ * VM 106 test server. The admin sets one minute with a 30-second warning for
+ * example.org on Organisation > Security, Off for org-two.test, and two
+ * minutes for everyone else. org-alice gets the warning, stays, then lets it
+ * run out: Bulwark saves her minimised draft and signs her out with the
+ * reason on the login page. org-dave's domain has no timeout. The admin's
+ * policy is put back afterwards.
+ */
+
+test.describe.configure({ mode: 'serial' });
+
+const RUN = Date.now().toString(36);
+const INBOX_SUBJECT = `Session limits inbox ${RUN}`;
+const DRAFT_SUBJECT = `Session limits draft ${RUN}`;
+
+let original: Record;
+let alice: JmapClient;
+
+async function draftSubjects(client: JmapClient): Promise {
+ const drafts = await client.mailboxByRole('drafts');
+ if (!drafts) throw new Error('No Drafts mailbox');
+ const r = await client.request([
+ ['Email/query', { accountId: client.accountId, filter: { inMailbox: drafts.id }, sort: [{ property: 'receivedAt', isAscending: false }], limit: 50 }, '0'],
+ ['Email/get', { accountId: client.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['subject'] }, '1'],
+ ]);
+ return (r.methodResponses[1][1].list as Array<{ subject: string }>).map((e) => e.subject);
+}
+
+async function removeTestMail(client: JmapClient): Promise {
+ const r = await client.request([
+ ['Email/query', { accountId: client.accountId, filter: { text: RUN }, limit: 50 }, '0'],
+ ]);
+ const ids = r.methodResponses[0][1].ids as string[];
+ if (ids.length) await client.request([['Email/set', { accountId: client.accountId, destroy: ids }, '0']]);
+}
+
+const warning = (page: Page) => page.getByTestId('session-timeout-dialog');
+
+test.beforeAll(async ({ browser }) => {
+ requireOrgEnv();
+ const admin = await adminContext(browser);
+ original = await getPolicy(admin);
+ await putPolicy(admin, {
+ ...original,
+ restrictions: {},
+ features: {},
+ organisation: { ...(original.organisation as object), session: { idleMinutes: 0, maxHours: 0, warnSeconds: 60 } },
+ scopes: [
+ { id: 'sessions-example', label: 'Example', match: { domains: ['example.org'] }, policy: {} },
+ { id: 'sessions-two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: {} },
+ ],
+ });
+ await admin.close();
+
+ // A message for org-alice to open while composing, which minimises the composer.
+ alice = await JmapClient.connect(ORG.alice, ORG_PASSWORD);
+ const inbox = await alice.mailboxByRole('inbox');
+ const r = await alice.request([
+ ['Email/set', {
+ accountId: alice.accountId,
+ create: {
+ m: {
+ mailboxIds: { [inbox!.id]: true },
+ keywords: { $seen: true },
+ from: [{ name: 'Org Bob', email: ORG.bob }],
+ to: [{ email: ORG.alice }],
+ subject: INBOX_SUBJECT,
+ receivedAt: new Date().toISOString(),
+ bodyValues: { b: { value: 'Open me while writing.' } },
+ textBody: [{ partId: 'b', type: 'text/plain' }],
+ },
+ },
+ }, '0'],
+ ]);
+ expect(r.methodResponses[0][1].created?.m, JSON.stringify(r.methodResponses[0][1])).toBeTruthy();
+});
+
+test.afterAll(async ({ browser }) => {
+ if (original) {
+ const admin = await adminContext(browser);
+ await putPolicy(admin, original);
+ await admin.close();
+ }
+ if (alice) await removeTestMail(alice);
+});
+
+test('the admin sets the idle timeout per domain on Organisation > Security', async ({ browser }) => {
+ const admin = await adminContext(browser);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-security');
+ const idle = page.getByLabel('Sign out after inactivity (minutes)');
+ const warn = page.getByLabel('Warning before signing out (seconds)');
+ await expect(idle).toBeVisible();
+
+ // Everyone: two minutes, so org-dave would be warned within the test if his scope didn't apply.
+ await idle.fill('2');
+ // example.org: one minute, warned 30 seconds before.
+ await page.getByTestId('org-scope-bar').getByRole('button', { name: 'Example', exact: true }).click();
+ await expect(idle).toHaveValue('2');
+ await expect(page.getByText('From Everyone: 2 minutes')).toBeVisible();
+ await idle.fill('1');
+ await warn.fill('30');
+ // org-two.test: off.
+ await page.getByTestId('org-scope-bar').getByRole('button', { name: 'Org two', exact: true }).click();
+ await idle.fill('');
+ await expect(idle).toHaveAttribute('placeholder', 'Off');
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ await page.getByTestId('org-scope-bar').getByRole('button', { name: 'Example', exact: true }).click();
+ await page.locator('[data-settings-section="sessions"]').scrollIntoViewIfNeeded();
+ await shot(page, 'sessions-admin-page');
+
+ const policy = await getPolicy(admin);
+ expect((policy.organisation as { session: unknown }).session).toEqual({ idleMinutes: 2, maxHours: 0, warnSeconds: 60 });
+ const scopes = policy.scopes as Array<{ id: string; policy: { organisation?: { session?: unknown } } }>;
+ expect(scopes.find((s) => s.id === 'sessions-example')?.policy.organisation?.session).toEqual({ idleMinutes: 1, warnSeconds: 30 });
+ expect(scopes.find((s) => s.id === 'sessions-two')?.policy.organisation?.session).toEqual({ idleMinutes: 0 });
+ await admin.close();
+});
+
+test('the admin page works at phone width', async ({ browser }) => {
+ const admin = await adminContext(browser, true);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-security');
+ const idle = page.getByLabel('Sign out after inactivity (minutes)');
+ await idle.scrollIntoViewIfNeeded();
+ await expect(idle).toBeVisible();
+ const box = await idle.boundingBox();
+ expect(box!.x + box!.width).toBeLessThanOrEqual(390);
+ await shot(page, 'sessions-admin-page-phone');
+ await admin.close();
+});
+
+test('org-alice is warned, stays, then is signed out with her minimised draft saved; org-dave is not', async ({ browser }) => {
+ test.setTimeout(5 * 60 * 1000);
+ const [page, dave] = await Promise.all([userPage(browser, ORG.alice), userPage(browser, ORG.dave)]);
+
+ // Write, let the autosave store a first version, write more, then open a
+ // message: the composer is minimised with the newer text only in memory.
+ await expect(emailItem(page, INBOX_SUBJECT).first()).toBeVisible({ timeout: 30000 });
+ await openComposer(page);
+ await setSubject(page, DRAFT_SUBJECT);
+ await waitDraftSaved(page);
+ await setSubject(page, `${DRAFT_SUBJECT} final`);
+ await emailItem(page, INBOX_SUBJECT).first().click();
+ await expect(page.getByText('Continue draft')).toBeVisible();
+ let lastActivity = Date.now();
+ expect(await draftSubjects(alice)).toContain(DRAFT_SUBJECT);
+ expect(await draftSubjects(alice)).not.toContain(`${DRAFT_SUBJECT} final`);
+
+ // The warning comes 30 seconds before the minute is up.
+ await expect(warning(page)).toBeVisible({ timeout: 45000 });
+ const warnedAfter = Date.now() - lastActivity;
+ expect(warnedAfter).toBeGreaterThan(25000);
+ expect(warnedAfter).toBeLessThan(40000);
+ await expect(warning(page)).toContainText('Are you still there?');
+ await shot(page, 'sessions-idle-warning');
+
+ await page.getByRole('button', { name: 'Stay signed in' }).click();
+ lastActivity = Date.now();
+ await expect(warning(page)).toBeHidden();
+
+ // Left alone: warned again, then signed out a minute after the last activity.
+ await expect(warning(page)).toBeVisible({ timeout: 45000 });
+ await expect(page.locator('#username')).toBeVisible({ timeout: 60000 });
+ const signedOutAfter = Date.now() - lastActivity;
+ expect(signedOutAfter).toBeGreaterThan(55000);
+ await expect(page.getByTestId('session-expired-reason')).toHaveText('You were signed out after 1 minute without activity.');
+ await shot(page, 'sessions-login-reason');
+
+ // The minimised composer saved its newer text before the sign-out.
+ await expect.poll(() => draftSubjects(alice), { timeout: 15000 }).toContain(`${DRAFT_SUBJECT} final`);
+
+ // org-two.test turns the timeout off: org-dave, idle just as long, is still in.
+ await expect(warning(dave)).toHaveCount(0);
+ await expect(dave.locator('[data-testid="account-switcher"]').first()).toBeVisible();
+ await page.context().close();
+ await dave.context().close();
+});
+
+test('the warning fits a phone and "Stay signed in" works there', async ({ browser }) => {
+ test.setTimeout(3 * 60 * 1000);
+ const page = await userPage(browser, ORG.alice, true);
+ await expect(warning(page)).toBeVisible({ timeout: 45000 });
+ const dialog = page.getByRole('alertdialog');
+ const box = await dialog.boundingBox();
+ expect(box!.x).toBeGreaterThanOrEqual(0);
+ expect(box!.x + box!.width).toBeLessThanOrEqual(390);
+ await shot(page, 'sessions-idle-warning-phone');
+ await page.getByRole('button', { name: 'Stay signed in' }).tap();
+ await expect(warning(page)).toBeHidden();
+ await expect(page.locator('[data-testid="account-switcher"]').first()).toBeVisible();
+ await page.context().close();
+});
diff --git a/integration/tests/19-org-signatures.spec.ts b/integration/tests/19-org-signatures.spec.ts
new file mode 100644
index 000000000..5ed017f60
--- /dev/null
+++ b/integration/tests/19-org-signatures.spec.ts
@@ -0,0 +1,319 @@
+import { test, expect, type Page } from '@playwright/test';
+import { JmapClient } from './helpers/jmap';
+import { addRecipient, emailItem, setSubject } from './helpers/app';
+import {
+ ORG,
+ ORG_PASSWORD,
+ adminContext,
+ getPolicy,
+ putPolicy,
+ requireOrgEnv,
+ shot,
+ userPage,
+} from './helpers/org';
+
+/**
+ * Organisation signatures and the disclaimer (organisation feature 1),
+ * through the real UI on a VM 106 test server. The admin sets a locked
+ * signature and a disclaimer for the example.org domain on Organisation >
+ * Signatures; org-alice's mail carries both once, org-dave's (org-two.test)
+ * neither; org-carol reads what arrives over JMAP. The admin's policy is
+ * put back afterwards.
+ */
+
+test.describe.configure({ mode: 'serial', timeout: 240000 });
+
+const STAMP = Date.now().toString(36);
+const SIGNATURE_MARK = `Org signature ${STAMP}`;
+const SIGNATURE_HTML = `{name} Phone: {phone} Mobile: {mobile}
${SIGNATURE_MARK}
`;
+const DISCLAIMER = `Confidential ${STAMP}: meant for the addressee only.`;
+// The plain-text templates differ from the HTML ones, so a check that
+// compares one against the other would add them twice.
+const SIGNATURE_TEXT = `{name} | Phone {phone} | ${SIGNATURE_MARK}`;
+const DISCLAIMER_HTML = `${DISCLAIMER} Imprint
`;
+const DISCLAIMER_TEXT = `${DISCLAIMER} Imprint: https://example.org/imprint`;
+const PHONE = '+49 30 5550100';
+
+let original: Record;
+
+const countOf = (haystack: string, needle: string) => haystack.split(needle).length - 1;
+
+interface Received {
+ html: string;
+ text: string;
+}
+
+/** The message as org-carol receives it, read over JMAP. */
+async function receivedByCarol(subject: string): Promise {
+ const carol = await JmapClient.connect(ORG.carol, ORG_PASSWORD);
+ // Matched on the exact subject: the subject filter is a word search, and
+ // "Re: x" also finds "x".
+ const inbox = await carol.mailboxByRole('inbox');
+ let id: string | undefined;
+ const deadline = Date.now() + 90000;
+ while (!id) {
+ const q = await carol.request([
+ ['Email/query', { accountId: carol.accountId, filter: { inMailbox: inbox!.id, subject: subject.replace(/^Re: /, '') } }, '0'],
+ ['Email/get', { accountId: carol.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject'] }, '1'],
+ ]);
+ id = (q.methodResponses[1][1].list as Array<{ id: string; subject: string }>).find((e) => e.subject === subject)?.id;
+ if (!id) {
+ if (Date.now() > deadline) throw new Error(`Timed out waiting for "${subject}" for org-carol`);
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ }
+ }
+ const r = await carol.request([
+ ['Email/get', {
+ accountId: carol.accountId,
+ ids: [id],
+ properties: ['htmlBody', 'textBody', 'bodyValues'],
+ fetchHTMLBodyValues: true,
+ fetchTextBodyValues: true,
+ }, '0'],
+ ]);
+ const email = r.methodResponses[0][1].list[0];
+ const values = email.bodyValues as Record;
+ const part = (parts: Array<{ partId: string; type: string }>, type: string) =>
+ parts.filter((p) => p.type === type).map((p) => values[p.partId]?.value ?? '').join('\n');
+ return { html: part(email.htmlBody, 'text/html'), text: part(email.textBody, 'text/plain') };
+}
+
+/** Sends a plain mail over JMAP, for the reply test (SMTP on the test servers doesn't work). */
+async function sendOverJmap(fromEmail: string, to: string, subject: string, text: string): Promise {
+ const from = await JmapClient.connect(fromEmail, ORG_PASSWORD);
+ const identity = (await from.identities()).find((i) => i.email === fromEmail);
+ const drafts = await from.mailboxByRole('drafts');
+ if (!identity || !drafts) throw new Error(`No identity or Drafts for ${fromEmail}`);
+ const r = await from.request([
+ ['Email/set', {
+ accountId: from.accountId,
+ create: {
+ m: {
+ mailboxIds: { [drafts.id]: true },
+ keywords: { $draft: true, $seen: true },
+ from: [{ name: identity.name, email: identity.email }],
+ to: [{ email: to }],
+ subject,
+ bodyValues: { t: { value: text } },
+ textBody: [{ partId: 't', type: 'text/plain' }],
+ },
+ },
+ }, '0'],
+ ['EmailSubmission/set', {
+ accountId: from.accountId,
+ create: { s: { identityId: identity.id, emailId: '#m' } },
+ onSuccessDestroyEmail: ['#s'],
+ }, '1'],
+ ]);
+ const created = r.methodResponses[1][1].created;
+ if (!created?.s) throw new Error(`Submission failed: ${JSON.stringify(r.methodResponses)}`);
+}
+
+/** Opens a new message with the Compose button (the "c" shortcut can land before the list has focus). */
+async function startCompose(page: Page): Promise {
+ await page.getByRole('button', { name: 'Compose', exact: true }).first().click();
+ await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15000 });
+}
+
+async function writeAndSend(page: Page, to: string, subject: string, text: string): Promise {
+ await addRecipient(page, to);
+ await setSubject(page, subject);
+ await page.locator('.ProseMirror').first().click();
+ await page.keyboard.type(text);
+ await page.locator('[data-testid="composer-send"]:visible').first().click();
+ await page.locator('[data-testid="email-composer"]').waitFor({ state: 'hidden', timeout: 30000 });
+}
+
+/** org-alice fills in the phone the signature asks for, under Settings > Account. */
+async function fillDetails(page: Page): Promise {
+ await page.goto('/settings/account');
+ const phone = page.getByTestId('signature-detail-phone');
+ await expect(phone).toBeVisible();
+ // Let settings sync load the server's copy first: it would replace what is
+ // typed. (The page never goes network-idle: push keeps a request open.)
+ await page.waitForTimeout(3000);
+ if ((await phone.inputValue()) !== PHONE) {
+ // The next page load takes the server's copy again, so wait until the
+ // details went up (the sync waits two seconds after a change).
+ const synced = page.waitForResponse((res) => res.url().endsWith('/api/settings') && res.request().method() === 'POST'
+ && (res.request().postData() ?? '').includes(PHONE), { timeout: 30000 });
+ await phone.fill(PHONE);
+ await synced;
+ }
+ await expect(page.getByTestId('signature-detail-title')).toHaveCount(0);
+ await expect(page.getByTestId('signature-details-preview')).toContainText(PHONE);
+}
+
+test.beforeAll(async ({ browser }) => {
+ requireOrgEnv();
+ const admin = await adminContext(browser);
+ original = await getPolicy(admin);
+ await putPolicy(admin, {
+ ...original,
+ restrictions: {},
+ organisation: {},
+ scopes: [{ id: 'example-org', label: 'Example org', match: { domains: ['example.org'] }, policy: {} }],
+ });
+ await admin.close();
+});
+
+test.afterAll(async ({ browser }) => {
+ const admin = await adminContext(browser);
+ await putPolicy(admin, original);
+ await admin.close();
+});
+
+test('the admin sets a locked signature and a disclaimer for example.org', async ({ browser }) => {
+ const admin = await adminContext(browser);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-signatures');
+ await expect(page.getByRole('heading', { name: 'Signatures' }).first()).toBeVisible();
+ await page.getByRole('button', { name: 'Example org' }).click();
+
+ // The dev server loads the policy twice (strict mode); a fill before the
+ // second load lands would be replaced, so fill until it sticks.
+ await page.waitForLoadState('networkidle');
+ await expect(async () => {
+ await page.getByTestId('org-signature-html').fill(SIGNATURE_HTML);
+ await expect(page.getByTestId('org-signature-preview')).toContainText(SIGNATURE_MARK, { timeout: 2000 });
+ }).toPass({ timeout: 20000 });
+ await expect(page.getByTestId('org-signature-fields').locator('[data-used]')).toHaveCount(2);
+ await page.getByTestId('org-signature-text').fill(SIGNATURE_TEXT);
+ await page.getByTestId('org-disclaimer-html').fill(DISCLAIMER_HTML);
+ await page.getByTestId('org-disclaimer-text').fill(DISCLAIMER_TEXT);
+ await page.getByLabel('Lock').check();
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ await page.getByTestId('org-signature-html').scrollIntoViewIfNeeded();
+ await shot(page, 'signatures-admin-page');
+ await page.getByTestId('org-disclaimer-text').scrollIntoViewIfNeeded();
+ await shot(page, 'signatures-admin-disclaimer');
+
+ const policy = await getPolicy(admin);
+ const scope = (policy.scopes as Array<{ id: string; policy: Record }>).find((s) => s.id === 'example-org')!;
+ expect(scope.policy.organisation.signature).toEqual({ html: SIGNATURE_HTML, text: SIGNATURE_TEXT, fields: ['phone', 'mobile'] });
+ expect(scope.policy.organisation.disclaimer).toEqual({ html: DISCLAIMER_HTML, text: DISCLAIMER_TEXT });
+ expect(scope.policy.restrictions.signature).toEqual({ locked: true });
+ // Everyone has no signature.
+ expect((policy.organisation as Record).signature.html).toBe('');
+ await admin.close();
+});
+
+test("org-alice's new mail carries the signature and the disclaimer once", async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice);
+ await fillDetails(alice);
+ await shot(alice, 'signatures-user-details');
+
+ await alice.goto('/');
+ await startCompose(alice);
+ await expect(alice.getByTestId('org-disclaimer-preview')).toContainText('Added when you send');
+ await expect(alice.getByTestId('org-disclaimer-preview')).toContainText(DISCLAIMER);
+ // The signature is an island in a shadow root; getByText looks inside it.
+ const island = alice.locator('.ProseMirror .signature-block-island').first();
+ await expect(island.getByText(SIGNATURE_MARK)).toBeVisible();
+ await expect(island.getByText(PHONE, { exact: false })).toBeVisible();
+ await shot(alice, 'signatures-composer');
+ const subject = `Org signature new ${STAMP}`;
+ await writeAndSend(alice, ORG.carol, subject, 'Hello Carol');
+
+ const received = await receivedByCarol(subject);
+ expect(countOf(received.html, SIGNATURE_MARK)).toBe(1);
+ expect(countOf(received.text, SIGNATURE_MARK)).toBe(1);
+ expect(received.html).toContain(PHONE);
+ expect(received.html).not.toContain('Mobile:');
+ expect(countOf(received.html, DISCLAIMER)).toBe(1);
+ expect(countOf(received.text, DISCLAIMER)).toBe(1);
+ // Below everything.
+ expect(received.text.trimEnd().endsWith(DISCLAIMER_TEXT)).toBe(true);
+ expect(received.html).toContain(`${DISCLAIMER} `);
+ await alice.context().close();
+});
+
+test("org-alice's reply carries them once too", async ({ browser }) => {
+ const subject = `Org signature question ${STAMP}`;
+ await sendOverJmap(ORG.carol, ORG.alice, subject, 'A question from Carol');
+
+ const alice = await userPage(browser, ORG.alice);
+ await fillDetails(alice);
+ await alice.goto('/');
+ await expect(emailItem(alice, subject).first()).toBeVisible({ timeout: 60000 });
+ await emailItem(alice, subject).first().click();
+ await alice.keyboard.press('r');
+ await alice.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15000 });
+ await alice.locator('.ProseMirror').first().click();
+ await alice.keyboard.type('The answer');
+ await alice.locator('[data-testid="composer-send"]:visible').first().click();
+ await alice.locator('[data-testid="email-composer"]').waitFor({ state: 'hidden', timeout: 30000 });
+
+ const received = await receivedByCarol(`Re: ${subject}`);
+ expect(received.text).toContain('A question from Carol');
+ expect(countOf(received.html, SIGNATURE_MARK)).toBe(1);
+ expect(countOf(received.text, SIGNATURE_MARK)).toBe(1);
+ expect(countOf(received.html, DISCLAIMER)).toBe(1);
+ expect(countOf(received.text, DISCLAIMER)).toBe(1);
+ // The disclaimer comes after the quote.
+ expect(received.text.indexOf('A question from Carol')).toBeLessThan(received.text.indexOf(DISCLAIMER));
+ await alice.context().close();
+});
+
+test("the locked editor says it's set by the organisation", async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice);
+ await fillDetails(alice);
+ await alice.getByRole('button', { name: 'Manage' }).first().click();
+ await alice.getByRole('button', { name: 'Edit Identity' }).first().click();
+ const locked = alice.getByTestId('identity-locked-signature');
+ await expect(locked).toContainText(SIGNATURE_MARK);
+ await expect(locked).toContainText(PHONE);
+ await expect(locked).toContainText('Set by your organisation');
+ await expect(alice.locator('#identity-html-sig')).toHaveCount(0);
+ await shot(alice, 'signatures-identity-locked');
+ await alice.context().close();
+});
+
+test("org-dave's mail from another domain gets neither", async ({ browser }) => {
+ const dave = await userPage(browser, ORG.dave);
+ await startCompose(dave);
+ await expect(dave.getByTestId('org-disclaimer-preview')).toHaveCount(0);
+ const subject = `Org signature dave ${STAMP}`;
+ await writeAndSend(dave, ORG.carol, subject, 'Hello from org-two');
+
+ const received = await receivedByCarol(subject);
+ expect(received.text).toContain('Hello from org-two');
+ expect(received.html + received.text).not.toContain(SIGNATURE_MARK);
+ expect(received.html + received.text).not.toContain(DISCLAIMER);
+ await dave.context().close();
+});
+
+test('the composer, the details and the admin page work at phone width', async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice, true);
+ await alice.goto('/settings/account');
+ await alice.getByRole('button', { name: 'Account' }).or(alice.getByRole('link', { name: 'Account' })).first().click();
+ const phone = alice.getByTestId('signature-detail-phone');
+ await phone.scrollIntoViewIfNeeded();
+ await phone.fill(PHONE);
+ const box = await phone.boundingBox();
+ // One column: the input spans the width.
+ expect(box!.width).toBeGreaterThan(300);
+ await shot(alice, 'signatures-user-details-phone');
+
+ await alice.goto('/');
+ await startCompose(alice);
+ const preview = alice.getByTestId('org-disclaimer-preview');
+ await preview.scrollIntoViewIfNeeded();
+ await expect(preview).toContainText(DISCLAIMER);
+ const overflow = await alice.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
+ expect(overflow).toBeLessThanOrEqual(0);
+ const previewBox = await preview.boundingBox();
+ expect(previewBox!.x + previewBox!.width).toBeLessThanOrEqual(390);
+ await shot(alice, 'signatures-composer-phone');
+ await alice.context().close();
+
+ const admin = await adminContext(browser, true);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-signatures');
+ await expect(page.getByTestId('org-signature-html')).toBeVisible();
+ const adminOverflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
+ expect(adminOverflow).toBeLessThanOrEqual(0);
+ await shot(page, 'signatures-admin-page-phone');
+ await admin.close();
+});
diff --git a/integration/tests/19-org-tags.spec.ts b/integration/tests/19-org-tags.spec.ts
new file mode 100644
index 000000000..59c1193af
--- /dev/null
+++ b/integration/tests/19-org-tags.spec.ts
@@ -0,0 +1,370 @@
+import { test, expect, type Page } from '@playwright/test';
+import { JMAP_URL } from './helpers/config';
+import { expandSharedFolders, emailItem, folderRow } from './helpers/app';
+import {
+ ORG,
+ ORG_PASSWORD,
+ ORG_SERVER,
+ adminContext,
+ getPolicy,
+ putPolicy,
+ requireOrgEnv,
+ shot,
+ userPage,
+} from './helpers/org';
+
+/**
+ * Shared tags in group mailboxes (organisation feature 6), through the real
+ * UI. On the v1.0.0 release (label 1.0.0) the definitions live in the
+ * metadata of org-team's Inbox: org-alice defines "Urgent" in red and tags a
+ * message, org-bob sees the same name and colour, and a group scope that
+ * locks `sharedTags` makes the editor read-only. On servers without working
+ * metadata (0.16, the 1.0 test build) there is no shared editor, the tags on
+ * group messages still show, and the app stays online.
+ *
+ * Cleanup: the test message (and with it its tags) is destroyed, the
+ * `org.bulwarkmail.tags` metadata entry is set back to null, the policy is
+ * put back. Other `org.bulwarkmail.*` entries (templates) are never touched.
+ */
+
+test.describe.configure({ mode: 'serial' });
+
+const SUBJECT = `Shared tags ${Date.now()}`;
+const OWN_SUBJECT = `Own tags ${Date.now()}`;
+const HAS_METADATA = ORG_SERVER === '1.0.0';
+
+// --- JMAP, as org-alice, straight to the server --------------------------
+
+type Call = [string, Record, string];
+let api: { url: string; team: string; inbox: string; own: string; ownInbox: string } | null = null;
+let messageId: string | null = null;
+/** A message in org-alice's own Inbox with the same keyword, for the unified inbox. */
+let ownMessageId: string | null = null;
+
+const auth = () => `Basic ${Buffer.from(`${ORG.alice}:${ORG_PASSWORD}`).toString('base64')}`;
+
+/** The API url, org-team's account id and its Inbox id, looked up once. */
+async function ensureApi(): Promise> {
+ if (!api) {
+ const session = await (await fetch(`${JMAP_URL}/.well-known/jmap`, { headers: { Authorization: auth() } })).json();
+ const team = Object.entries(session.accounts as Record).find(([, a]) => a.name === ORG.team)![0];
+ const own = session.primaryAccounts['urn:ietf:params:jmap:mail'] as string;
+ api = { url: `${JMAP_URL}${new URL(session.apiUrl).pathname}`, team, inbox: '', own, ownInbox: '' };
+ const res = await jmapRaw([
+ ['Mailbox/query', { accountId: team, filter: { role: 'inbox' } }, 'q'],
+ ['Mailbox/query', { accountId: own, filter: { role: 'inbox' } }, 'o'],
+ ]);
+ api.inbox = res[0][1].ids[0];
+ api.ownInbox = res[1][1].ids[0];
+ }
+ return api;
+}
+
+async function jmap(calls: Call[], using?: string[]) {
+ await ensureApi();
+ return jmapRaw(calls, using);
+}
+
+async function jmapRaw(calls: Call[], using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail']) {
+ const res = await fetch(api!.url, {
+ method: 'POST',
+ headers: { Authorization: auth(), 'Content-Type': 'application/json' },
+ body: JSON.stringify({ using, methodCalls: calls }),
+ });
+ expect(res.ok, `JMAP ${res.status}`).toBe(true);
+ return (await res.json()).methodResponses as Array<[string, any, string]>;
+}
+
+const META_USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:metadata'];
+
+async function sharedTagsOnServer(): Promise {
+ const res = await jmap([['Mailbox/get', { accountId: api!.team, ids: [api!.inbox], properties: ['metadata'] }, 'g']], META_USING);
+ return res[0][1].list[0].metadata?.['org.bulwarkmail.tags'] ?? null;
+}
+
+async function clearSharedTags(): Promise {
+ await jmap([['Mailbox/set', { accountId: api!.team, update: { [api!.inbox]: { 'metadata/org.bulwarkmail.tags': null } } }, 's']], META_USING);
+}
+
+async function messageKeywords(): Promise> {
+ const res = await jmap([['Email/get', { accountId: api!.team, ids: [messageId], properties: ['keywords'] }, 'g']]);
+ return res[0][1].list[0].keywords;
+}
+
+// --- UI helpers ----------------------------------------------------------
+
+async function openTeamInbox(page: Page): Promise {
+ await expandSharedFolders(page, ORG.team);
+ await folderRow(page, { role: 'inbox', shared: true }).first().click();
+ await expect(emailItem(page, SUBJECT).first()).toBeVisible({ timeout: 30000 });
+}
+
+/** Settings > Folders & tags, scrolled to the shared tags of org-team. */
+async function openSharedTagSettings(page: Page, phone = false) {
+ if (phone) {
+ await page.goto('/settings');
+ await page.getByRole('button', { name: 'Folders & tags' }).or(page.getByRole('link', { name: 'Folders & tags' })).first().click();
+ } else {
+ await page.goto('/settings/organize/shared-tags');
+ }
+ // The block is drawn again once the tags have loaded.
+ const account = page.locator('[data-testid="shared-tags-account"]:not([data-status="loading"])').filter({ hasText: ORG.team });
+ await expect(account).toBeVisible({ timeout: 30000 });
+ await account.scrollIntoViewIfNeeded();
+ return account;
+}
+
+let original: Record;
+
+test.beforeAll(async ({ browser }) => {
+ requireOrgEnv();
+ const admin = await adminContext(browser);
+ original = await getPolicy(admin);
+ await putPolicy(admin, { ...original, restrictions: {}, scopes: [] });
+ await admin.close();
+
+ // A message in org-team's Inbox; on servers without metadata it carries a
+ // tag set by another client, which must still show.
+ const keywords = HAS_METADATA ? {} : { '$label:from-another-client': true };
+ const { team, inbox, own, ownInbox } = await ensureApi();
+ const message = (mailbox: string, to: string, subject: string, kw: Record) => ({
+ mailboxIds: { [mailbox]: true },
+ from: [{ name: 'Org Alice', email: ORG.alice }],
+ to: [{ email: to }],
+ subject,
+ keywords: kw,
+ bodyValues: { b: { value: 'A message for the shared tags spec.' } },
+ textBody: [{ partId: 'b', type: 'text/plain' }],
+ });
+ const res = await jmap([['Email/set', { accountId: team, create: { m: message(inbox, ORG.team, SUBJECT, keywords) } }, 'c']]);
+ messageId = res[0][1].created.m.id;
+ if (HAS_METADATA) {
+ const mine = await jmap([['Email/set', { accountId: own, create: { m: message(ownInbox, ORG.alice, OWN_SUBJECT, { '$label:urgent': true }) } }, 'c']]);
+ ownMessageId = mine[0][1].created.m.id;
+ }
+ if (HAS_METADATA) await clearSharedTags();
+});
+
+test.afterAll(async ({ browser }) => {
+ if (messageId) await jmap([['Email/set', { accountId: api!.team, destroy: [messageId] }, 'd']]);
+ if (ownMessageId) await jmap([['Email/set', { accountId: api!.own, destroy: [ownMessageId] }, 'd']]);
+ if (HAS_METADATA && api) {
+ await clearSharedTags();
+ expect(await sharedTagsOnServer()).toBeNull();
+ }
+ const admin = await adminContext(browser);
+ await putPolicy(admin, original);
+ await admin.close();
+});
+
+test.describe('on a server with metadata', () => {
+ test.skip(!HAS_METADATA, 'shared definitions need Stalwart v1.0.0 metadata');
+
+ test('org-alice defines "Urgent" in red for org-team and tags a message with it', async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice);
+ const account = await openSharedTagSettings(alice);
+ await account.getByRole('button', { name: 'Add shared tag' }).click();
+ const form = account.getByTestId('shared-tag-form');
+ await form.getByLabel('Name').fill('Urgent');
+ await form.getByRole('button', { name: 'red', exact: true }).click();
+ await form.getByRole('button', { name: 'Save' }).click();
+ await expect(account.locator('[data-testid="shared-tag-row"][data-tag-id="urgent"]')).toBeVisible();
+ await shot(alice, 'tags-settings-editor');
+ await expect.poll(sharedTagsOnServer).toEqual({ tags: { urgent: { label: 'Urgent', color: 'red' } } });
+
+ await alice.goto('/');
+ await openTeamInbox(alice);
+ // The group's tags are listed under its folders.
+ await expect(alice.getByTestId('shared-account-tags').getByText('Urgent')).toBeVisible();
+
+ const row = emailItem(alice, SUBJECT).first();
+ await expect(async () => {
+ await row.click({ button: 'right' });
+ await alice.getByTestId('ctx-tag').waitFor({ state: 'visible', timeout: 2000 });
+ }).toPass({ timeout: 15000 });
+ await alice.getByTestId('ctx-tag').hover();
+ await expect(alice.getByTestId('tag-picker-shared')).toBeVisible();
+ await shot(alice, 'tags-picker');
+ await alice.getByRole('menuitemcheckbox', { name: 'Urgent' }).click();
+ await expect.poll(async () => (await messageKeywords())['$label:urgent']).toBe(true);
+ await alice.keyboard.press('Escape');
+ await expect(row.getByTitle('Urgent')).toBeVisible();
+
+ // The group's tag in the sidebar counts and lists that group's mail
+ // only: her own message with the same keyword stays out.
+ const sharedRow = alice.getByTestId('shared-account-tags').getByTestId('folder-row').filter({ hasText: 'Urgent' });
+ await expect(sharedRow.getByTestId('folder-counts')).toHaveAttribute('data-total', '1', { timeout: 30000 });
+ await sharedRow.click();
+ await expect(emailItem(alice, SUBJECT).first()).toBeVisible({ timeout: 30000 });
+ await expect(emailItem(alice, OWN_SUBJECT)).toHaveCount(0);
+ await expect(sharedRow).toHaveAttribute('data-selected', 'true');
+ await shot(alice, 'tags-sidebar-group-view');
+ await alice.context().close();
+ });
+
+ test('org-bob sees the same name and colour on that message', async ({ browser }) => {
+ const bob = await userPage(browser, ORG.bob);
+ await openTeamInbox(bob);
+ const chip = emailItem(bob, SUBJECT).first().getByTitle('Urgent');
+ await expect(chip).toBeVisible();
+ await expect(chip).toHaveClass(/bg-red-500\/10/);
+ await expect(bob.getByTestId('shared-account-tags').getByText('Urgent')).toBeVisible();
+ await shot(bob, 'tags-member-view');
+
+ // Another member's edit arrives without a reload (push: the Inbox's
+ // Mailbox state changes with its metadata).
+ await jmap([['Mailbox/set', { accountId: api!.team, update: { [api!.inbox]: { 'metadata/org.bulwarkmail.tags/tags/urgent': { label: 'Very urgent', color: 'orange' } } } }, 's']], META_USING);
+ const renamed = emailItem(bob, SUBJECT).first().getByTitle('Very urgent');
+ await expect(renamed).toBeVisible({ timeout: 30000 });
+ await expect(renamed).toHaveClass(/bg-orange-500\/10/);
+ await jmap([['Mailbox/set', { accountId: api!.team, update: { [api!.inbox]: { 'metadata/org.bulwarkmail.tags/tags/urgent': { label: 'Urgent', color: 'red' } } } }, 's']], META_USING);
+ await expect(emailItem(bob, SUBJECT).first().getByTitle('Urgent')).toBeVisible({ timeout: 30000 });
+ await bob.context().close();
+ });
+
+ test('the unified inbox names the tags of each message by its own account', async ({ browser }) => {
+ // Unified inbox with the group inbox in it, pinned by the policy so a
+ // synced setting of the account can't turn it off.
+ const admin = await adminContext(browser);
+ const policy = await getPolicy(admin);
+ await putPolicy(admin, {
+ ...policy,
+ restrictions: {
+ ...(policy.restrictions as Record),
+ enableUnifiedMailbox: { locked: true, value: true },
+ includeGroupInUnified: { locked: true, value: true },
+ },
+ });
+ const alice = await userPage(browser, ORG.alice);
+ await folderRow(alice, { name: 'unified-inbox' }).first().click({ timeout: 60000 });
+ // The same keyword: the group's shared name on the team message, the
+ // bare keyword on her own (she has no tag of that name).
+ await expect(emailItem(alice, SUBJECT).first().getByTitle('Urgent')).toBeVisible({ timeout: 30000 });
+ await expect(emailItem(alice, OWN_SUBJECT).first().getByTitle('urgent', { exact: true })).toBeVisible();
+ await shot(alice, 'tags-unified-inbox');
+ await alice.context().close();
+ await putPolicy(admin, policy);
+ await admin.close();
+ });
+
+ test('deleting a shared tag warns that it is for everyone, at phone width too', async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice, true);
+ const account = await openSharedTagSettings(alice, true);
+ await account.getByRole('button', { name: 'Delete Urgent' }).click();
+ const confirm = account.getByTestId('shared-tag-delete-confirm');
+ await expect(confirm.getByText('Delete for everyone? The tag stays on its messages.')).toBeVisible();
+ for (const name of ['Delete', 'Cancel']) {
+ const box = await confirm.getByRole('button', { name, exact: true }).boundingBox();
+ expect(box!.height).toBeGreaterThanOrEqual(44);
+ }
+ await shot(alice, 'tags-delete-confirm-phone');
+ await confirm.getByRole('button', { name: 'Cancel', exact: true }).click();
+ await expect(account.getByRole('button', { name: 'Edit Urgent' })).toBeFocused();
+ expect(await sharedTagsOnServer()).toEqual({ tags: { urgent: { label: 'Urgent', color: 'red' } } });
+ await alice.context().close();
+ });
+
+ test('a lock applies only to the scope that sets it, read-only with the reason in place', async ({ browser }) => {
+ const admin = await adminContext(browser);
+ let policy = await getPolicy(admin);
+ await putPolicy(admin, {
+ ...policy,
+ scopes: [
+ { id: 'org-two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: { restrictions: { sharedTags: { locked: true } } } },
+ { id: 'org-team', label: 'Org team', match: { groups: [ORG.team] }, policy: {} },
+ ],
+ });
+
+ // A domain scope for another domain leaves org-team's members alone.
+ const alice = await userPage(browser, ORG.alice);
+ const aliceTags = await openSharedTagSettings(alice);
+ await expect(aliceTags.getByRole('button', { name: 'Add shared tag' })).toBeVisible();
+ await alice.context().close();
+
+ // The admin locks shared tags for the org-team group on Organisation > Sharing.
+ const page = await admin.newPage();
+ await page.goto('/admin/org-sharing');
+ await expect(page.getByRole('heading', { name: 'Sharing' }).first()).toBeVisible();
+ await page.getByRole('button', { name: 'Org team' }).click();
+ await page.waitForLoadState('networkidle');
+ const section = page.getByTestId('org-shared-tags-section');
+ await section.getByLabel('Lock').check();
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ await section.scrollIntoViewIfNeeded();
+ await shot(page, 'tags-admin-lock');
+ policy = await getPolicy(admin);
+ const team = (policy.scopes as Array<{ id: string; policy: Record }>).find((s) => s.id === 'org-team')!;
+ expect(team.policy.restrictions.sharedTags).toEqual({ locked: true });
+ expect((policy.restrictions as Record).sharedTags).toBeUndefined();
+ await admin.close();
+
+ const bob = await userPage(browser, ORG.bob);
+ const bobTags = await openSharedTagSettings(bob);
+ await expect(bobTags.getByText('Set by your organisation')).toBeVisible();
+ await expect(bobTags.getByRole('button', { name: 'Add shared tag' })).toHaveCount(0);
+ await expect(bobTags.getByRole('button', { name: /^(Edit|Delete|Name) / })).toHaveCount(0);
+ await expect(bobTags.locator('[data-tag-id="urgent"]')).toBeVisible();
+ await shot(bob, 'tags-settings-locked');
+ // Tagging with the shared tags still works.
+ await bob.goto('/');
+ await openTeamInbox(bob);
+ await expect(emailItem(bob, SUBJECT).first().getByTitle('Urgent')).toBeVisible();
+ await bob.context().close();
+ });
+
+ test('the shared tags work at phone width', async ({ browser }) => {
+ const bob = await userPage(browser, ORG.bob, true);
+ const account = await openSharedTagSettings(bob, true);
+ await expect(account.getByText('Set by your organisation')).toBeVisible();
+ await expect(account.locator('[data-tag-id="urgent"]')).toBeVisible();
+ const overflow = await bob.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
+ expect(overflow).toBeLessThanOrEqual(0);
+ await shot(bob, 'tags-settings-phone');
+ await bob.context().close();
+
+ const admin = await adminContext(browser, true);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-sharing');
+ await expect(page.getByTestId('org-shared-tags-section')).toBeVisible();
+ expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
+ await page.getByTestId('org-shared-tags-section').scrollIntoViewIfNeeded();
+ await shot(page, 'tags-admin-phone');
+ await admin.close();
+ });
+});
+
+test.describe('on a server without metadata', () => {
+ test.skip(HAS_METADATA, 'covered above');
+
+ test('no shared editor, the tags on group messages still show, and the app stays online', async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice);
+ const requests: string[] = [];
+ alice.on('request', (request) => {
+ const body = request.postData() ?? '';
+ if (body.includes('urn:ietf:params:jmap:metadata')) requests.push(body);
+ });
+ await openTeamInbox(alice);
+ await expect(emailItem(alice, SUBJECT).first().getByTitle('from-another-client')).toBeVisible();
+ await expect(alice.getByTestId('shared-account-tags')).toHaveCount(0);
+
+ const account = await openSharedTagSettings(alice);
+ // 0.16 has no metadata; the 1.0 test build has it but drops the request.
+ await expect(account.getByTestId(ORG_SERVER === '1.0' ? 'shared-tags-unavailable' : 'shared-tags-unsupported')).toBeVisible();
+ await expect(account.getByRole('button', { name: 'Add shared tag' })).toHaveCount(0);
+ await shot(alice, 'tags-settings-unsupported');
+
+ // The 1.0 test build drops the connection on Mailbox/get with metadata;
+ // that must not leave the app reconnecting. Had the failed read reported
+ // the login offline (onConnectionChange), the banner would stay until the
+ // next good keep-alive ping, 30 s later. 0.16 gets no such request.
+ await alice.goto('/');
+ await openTeamInbox(alice);
+ await alice.waitForTimeout(3000);
+ await expect(alice.getByText('Connection lost. Attempting to reconnect')).toHaveCount(0);
+ if (ORG_SERVER === '0.16') expect(requests).toEqual([]);
+ // The 1.0 test build advertises metadata, so it was asked (and dropped it).
+ if (ORG_SERVER === '1.0') expect(requests.length).toBeGreaterThan(0);
+ await alice.context().close();
+ });
+});
diff --git a/integration/tests/19-org-teaminbox.spec.ts b/integration/tests/19-org-teaminbox.spec.ts
new file mode 100644
index 000000000..7395c774a
--- /dev/null
+++ b/integration/tests/19-org-teaminbox.spec.ts
@@ -0,0 +1,313 @@
+import { test, expect, type Locator, type Page } from '@playwright/test';
+import { existsSync, readFileSync } from 'node:fs';
+import { JmapClient } from './helpers/jmap';
+import { emailItem, expandSharedFolders, folderRow } from './helpers/app';
+import {
+ ORG,
+ ORG_PASSWORD,
+ ORG_SERVER,
+ adminContext,
+ getPolicy,
+ putPolicy,
+ requireOrgEnv,
+ shot,
+ userPage,
+} from './helpers/org';
+
+/**
+ * The team inbox plugin (organisation feature 10) through the real UI on a
+ * VM 106 test server: installed from a local build through the admin, then
+ * org-alice assigns a message of the org-team group to org-bob, bob finds it
+ * under "Assigned to me" and marks it done, which takes it out of "Open".
+ * carol, who is in no group, and alice's own mailbox get no team actions.
+ *
+ * IT_TEAM_INBOX_ZIP names the zip (default: the plugins worktree's build).
+ * The test messages are created in the group's Inbox over JMAP and
+ * destroyed at the end, keywords and all; the plugin is removed and the
+ * policy put back.
+ */
+
+test.describe.configure({ mode: 'serial', timeout: 240000 });
+
+const ZIP = process.env.IT_TEAM_INBOX_ZIP ?? 'C:/Users/info/worktrees/plugins-org-teaminbox/team-inbox/team-inbox.zip';
+const RUN = Date.now().toString(36);
+const SELF = `Team inbox bob takes it ${RUN}`;
+const FOR_BOB = `Team inbox for bob ${RUN}`;
+const OTHER = `Team inbox nobody ${RUN}`;
+const OWN = `Team inbox alice own ${RUN}`;
+
+let original: Record;
+const created: Array<{ account: string; accountId: string; id: string; subject: string }> = [];
+
+/** A received-looking message, created by `account` in the Inbox of the account named `accountName`. */
+async function createMessage(account: string, accountName: string, subject: string): Promise {
+ const client = await JmapClient.connect(account, ORG_PASSWORD);
+ const accountId = accountName === account ? client.accountId : client.accountIdByName(accountName);
+ const inbox = await client.mailboxByRole('inbox', accountId);
+ if (!inbox) throw new Error(`${accountName}: no Inbox`);
+ const r = await client.request([
+ ['Email/set', {
+ accountId,
+ create: {
+ m: {
+ mailboxIds: { [inbox.id]: true },
+ keywords: {},
+ from: [{ name: 'Customer', email: 'customer@example.net' }],
+ to: [{ email: accountName }],
+ subject,
+ receivedAt: new Date().toISOString(),
+ bodyValues: { b: { value: `A message for the team inbox spec (${RUN}).` } },
+ textBody: [{ partId: 'b', type: 'text/plain' }],
+ },
+ },
+ }, '0'],
+ ]);
+ const id = r.methodResponses?.[0]?.[1]?.created?.m?.id;
+ if (!id) throw new Error(`could not create "${subject}": ${JSON.stringify(r.methodResponses?.[0])}`);
+ created.push({ account, accountId, id, subject });
+}
+
+/** The keywords of a test message, as the server has them. */
+async function keywordsOf(subject: string): Promise {
+ const entry = created.find((c) => c.subject === subject);
+ if (!entry) return [];
+ const client = await JmapClient.connect(entry.account, ORG_PASSWORD);
+ const r = await client.request([['Email/get', { accountId: entry.accountId, ids: [entry.id], properties: ['keywords'] }, '0']]);
+ return Object.keys(r.methodResponses?.[0]?.[1]?.list?.[0]?.keywords ?? {});
+}
+
+async function openTeamInbox(page: Page): Promise {
+ await expandSharedFolders(page, ORG.team);
+ await folderRow(page, { role: 'inbox', shared: true }).first().click();
+}
+
+function filterChip(page: Page, name: string): Locator {
+ return page.locator('[data-testid="plugin-list-filters"] button', { hasText: name });
+}
+
+/** Right-click a row and wait for the menu to carry the plugin's entries. */
+async function rowMenu(page: Page, subject: string): Promise {
+ const row = emailItem(page, subject).first();
+ await row.waitFor({ state: 'visible', timeout: 30000 });
+ await expect(async () => {
+ await page.keyboard.press('Escape');
+ await row.click({ button: 'right' });
+ await page.getByRole('menuitem', { name: 'Reply', exact: true }).first().waitFor({ state: 'visible', timeout: 2000 });
+ }).toPass({ timeout: 20000 });
+}
+
+/** The plugin runs once its background frame is there and it has offered its filters. */
+async function pluginReady(page: Page): Promise {
+ await expect(page.locator('iframe[src*="/plugin-sandbox"]').first()).toBeAttached({ timeout: 60000 });
+}
+
+test.beforeAll(async ({ browser }) => {
+ test.setTimeout(300000);
+ test.setTimeout(300000);
+ requireOrgEnv();
+ test.skip(!existsSync(ZIP), `build the plugin first: ${ZIP}`);
+ const admin = await adminContext(browser);
+ original = await getPolicy(admin);
+ // next dev compiles the sandbox page on first use, which can outlast the plugin's init timeout.
+ await admin.request.get('/plugin-sandbox', { timeout: 120000 });
+ await admin.request.delete('/api/admin/plugins', { data: { id: 'team-inbox' } });
+ const installed = await admin.request.post('/api/admin/plugins', {
+ multipart: { file: { name: 'team-inbox.zip', mimeType: 'application/zip', buffer: readFileSync(ZIP) } },
+ });
+ expect(installed.ok(), await installed.text()).toBe(true);
+ const features = { ...(original.features as Record), pluginsEnabled: true };
+ const forced = [...new Set([...((original.forceEnabledPlugins as string[] | undefined) ?? []), 'team-inbox'])];
+ await putPolicy(admin, { ...original, features, forceEnabledPlugins: forced });
+ await admin.close();
+
+ await createMessage(ORG.alice, ORG.team, OTHER);
+ await createMessage(ORG.alice, ORG.team, SELF);
+ await createMessage(ORG.alice, ORG.team, FOR_BOB);
+ await createMessage(ORG.alice, ORG.alice, OWN);
+});
+
+const METADATA = 'urn:ietf:params:jmap:metadata';
+const NAMES_ENTRY = 'org.bulwarkmail.plugin.team-inbox';
+
+/** The plugin's names in the team Inbox's shared metadata (only v1.0.0 keeps them). */
+async function sharedNames(): Promise | null> {
+ const client = await JmapClient.connect(ORG.alice, ORG_PASSWORD);
+ const accountId = client.accountIdByName(ORG.team);
+ const inbox = await client.mailboxByRole('inbox', accountId);
+ const r = await client.request([['Mailbox/get', { accountId, ids: [inbox!.id], properties: ['id', 'metadata'] }, '0']],
+ ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', METADATA]);
+ return r.methodResponses?.[0]?.[1]?.list?.[0]?.metadata?.[NAMES_ENTRY] ?? null;
+}
+
+test.afterAll(async ({ browser }) => {
+ test.setTimeout(180000);
+ for (const entry of created) {
+ const client = await JmapClient.connect(entry.account, ORG_PASSWORD);
+ await client.request([['Email/set', { accountId: entry.accountId, destroy: [entry.id] }, '0']]).catch(() => {});
+ }
+ // The names the plugin kept in the team Inbox go too (other features keep
+ // their own entries there).
+ if (ORG_SERVER === '1.0.0') {
+ const client = await JmapClient.connect(ORG.alice, ORG_PASSWORD);
+ const accountId = client.accountIdByName(ORG.team);
+ const inbox = await client.mailboxByRole('inbox', accountId);
+ if (inbox) {
+ await client.request([['Mailbox/set', { accountId, update: { [inbox.id]: { [`metadata/${NAMES_ENTRY}`]: null } } }, '0']],
+ ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', METADATA]).catch(() => {});
+ }
+ }
+ if (!original) return;
+ const admin = await adminContext(browser);
+ await putPolicy(admin, original);
+ await admin.request.delete('/api/admin/plugins', { data: { id: 'team-inbox' } });
+ await admin.close();
+});
+
+test('bob takes a message himself from the row menu', async ({ browser }) => {
+ const bob = await userPage(browser, ORG.bob);
+ await pluginReady(bob);
+ await openTeamInbox(bob);
+ await expect(filterChip(bob, 'Assigned to me')).toBeVisible({ timeout: 30000 });
+ await expect(filterChip(bob, 'Open')).toBeVisible();
+
+ await rowMenu(bob, SELF);
+ await expect(bob.getByRole('menuitem', { name: 'Mark done' })).toBeVisible();
+ await expect(bob.getByRole('menuitem', { name: 'Reopen' })).toHaveCount(0);
+ await expect(bob.getByRole('menuitem', { name: 'Unassign' })).toHaveCount(0);
+ await bob.getByRole('menuitem', { name: 'Assign to me' }).click();
+ await expect(emailItem(bob, SELF).first().locator('[data-plugin-row-badge="team-inbox-assignee"]')).toHaveText('Me', { timeout: 20000 });
+ await expect.poll(() => keywordsOf(SELF)).toContain(`$assigned:${await principalId(ORG.bob)}`);
+ await bob.context().close();
+});
+
+async function principalId(email: string): Promise {
+ return (await JmapClient.connect(email, ORG_PASSWORD)).accountId;
+}
+
+test('alice assigns a team message to bob', async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice);
+ await pluginReady(alice);
+ await openTeamInbox(alice);
+ await expect(emailItem(alice, SELF).first().locator('[data-plugin-row-badge="team-inbox-assignee"]')).toBeVisible({ timeout: 30000 });
+ await shot(alice, 'teaminbox-list-badges');
+
+ await rowMenu(alice, FOR_BOB);
+ await alice.getByRole('menuitem', { name: 'Assign to…' }).click();
+ const dialog = alice.locator('[data-plugin-dialog="team-inbox"]');
+ await expect(dialog).toBeVisible({ timeout: 30000 });
+ const frame = dialog.frameLocator('iframe').first();
+ // Bob is known by name where the server has metadata or a directory, else
+ // by his id, as the team member messages were assigned to lately.
+ const bobId = await principalId(ORG.bob);
+ const bobRow = frame.getByRole('listitem').filter({ hasText: new RegExp(`org-bob@example\\.org|Id ${bobId}$`) }).first();
+ await expect(bobRow).toBeVisible({ timeout: 30000 });
+ await expect(frame.getByRole('listitem').filter({ hasText: 'You' })).toHaveCount(1);
+ await shot(alice, 'teaminbox-assign-dialog');
+ await bobRow.click();
+ await expect(dialog).toHaveCount(0, { timeout: 20000 });
+
+ await expect.poll(() => keywordsOf(FOR_BOB), { timeout: 20000 }).toContain(`$assigned:${bobId}`);
+ const forBobBadge = emailItem(alice, FOR_BOB).first().locator('[data-plugin-row-badge="team-inbox-assignee"]');
+ await expect(forBobBadge).toBeVisible({ timeout: 20000 });
+ // The badge names bob where the server shares names (metadata on v1.0.0,
+ // or a directory); without either it says the assignee isn't known.
+ if (ORG_SERVER === '1.0.0') {
+ await expect(forBobBadge).toHaveAttribute('title', `Assigned to Org Bob <${ORG.bob}>`);
+ await expect(forBobBadge).toHaveText('Org');
+ } else {
+ await expect(forBobBadge).toHaveAttribute('title', /Assigned to (Org Bob|a team member whose name Bulwark doesn't know)/);
+ }
+
+ // Alice's own mailbox gets no team actions and no filters.
+ await folderRow(alice, { role: 'inbox', shared: false }).first().click();
+ await expect(emailItem(alice, OWN).first()).toBeVisible({ timeout: 30000 });
+ await expect(alice.locator('[data-testid="plugin-list-filters"]')).toHaveCount(0);
+ await rowMenu(alice, OWN);
+ await expect(alice.getByRole('menuitem', { name: 'Assign to me' })).toHaveCount(0);
+ await expect(alice.getByRole('menuitem', { name: 'Mark done' })).toHaveCount(0);
+ await alice.context().close();
+});
+
+test('on Stalwart v1.0.0 the names are shared in the team Inbox\'s metadata', async () => {
+ test.skip(ORG_SERVER !== '1.0.0', 'only the v1.0.0 release keeps metadata (facts §2)');
+ const names = await sharedNames() as { people?: Record } | null;
+ const [aliceId, bobId] = [await principalId(ORG.alice), await principalId(ORG.bob)];
+ expect(names?.people?.[aliceId]?.e).toBe(ORG.alice);
+ expect(names?.people?.[bobId]?.e).toBe(ORG.bob);
+});
+
+test('bob finds it under "Assigned to me" and marking it done takes it out of "Open"', async ({ browser }) => {
+ const bob = await userPage(browser, ORG.bob);
+ await pluginReady(bob);
+ await openTeamInbox(bob);
+ await expect(emailItem(bob, OTHER).first()).toBeVisible({ timeout: 30000 });
+
+ await filterChip(bob, 'Assigned to me').click();
+ await expect(filterChip(bob, 'Assigned to me')).toHaveAttribute('aria-pressed', 'true');
+ await expect(emailItem(bob, FOR_BOB).first()).toBeVisible({ timeout: 30000 });
+ await expect(emailItem(bob, SELF).first()).toBeVisible();
+ await expect(emailItem(bob, OTHER)).toHaveCount(0);
+ await shot(bob, 'teaminbox-assigned-to-me');
+
+ // Done from the reader toolbar.
+ await emailItem(bob, FOR_BOB).first().click();
+ const markDone = bob.locator('[data-plugin-actions="reader-actions"]').getByRole('button', { name: 'Mark done' });
+ await expect(markDone).toBeVisible({ timeout: 30000 });
+ await markDone.click();
+ await expect(bob.locator('[data-plugin-actions="reader-actions"]').getByRole('button', { name: 'Reopen' })).toBeVisible({ timeout: 20000 });
+ await expect.poll(() => keywordsOf(FOR_BOB), { timeout: 20000 }).toContain('$done');
+ await expect(emailItem(bob, FOR_BOB).first().locator('[data-plugin-row-badge="team-inbox-done"]')).toBeVisible({ timeout: 20000 });
+
+ await filterChip(bob, 'Open').click();
+ await expect(emailItem(bob, FOR_BOB)).toHaveCount(0, { timeout: 30000 });
+ await expect(emailItem(bob, SELF).first()).toBeVisible();
+ await shot(bob, 'teaminbox-open-filter');
+
+ // Off again: the chip's second click removes the filter.
+ await filterChip(bob, 'Open').click();
+ await filterChip(bob, 'Assigned to me').click();
+ await expect(emailItem(bob, OTHER).first()).toBeVisible({ timeout: 30000 });
+ await expect(emailItem(bob, FOR_BOB).first()).toBeVisible();
+ await bob.context().close();
+});
+
+test('carol, in no group, gets no team actions or filters', async ({ browser }) => {
+ await createMessage(ORG.carol, ORG.carol, `Team inbox carol ${RUN}`);
+ const carol = await userPage(browser, ORG.carol);
+ await pluginReady(carol);
+ await expect(emailItem(carol, `Team inbox carol ${RUN}`).first()).toBeVisible({ timeout: 30000 });
+ await expect(carol.locator('[data-testid="plugin-list-filters"]')).toHaveCount(0);
+ await rowMenu(carol, `Team inbox carol ${RUN}`);
+ await expect(carol.getByRole('menuitem', { name: 'Assign to me' })).toHaveCount(0);
+ await expect(carol.getByRole('menuitem', { name: 'Assign to…' })).toHaveCount(0);
+ await carol.context().close();
+});
+
+test('on a phone the filters fit and the actions are in the message\'s More menu', async ({ browser }) => {
+ const bob = await userPage(browser, ORG.bob, true);
+ await pluginReady(bob);
+ // The folder list is behind the header's menu button on phones.
+ await bob.locator('[data-mobile-mail-header] button').first().click();
+ await openTeamInbox(bob);
+ const filters = bob.locator('[data-testid="plugin-list-filters"]');
+ await expect(filters).toBeVisible({ timeout: 30000 });
+ for (const chip of await filters.getByRole('button').all()) {
+ const box = await chip.boundingBox();
+ expect(box).not.toBeNull();
+ expect(box!.x).toBeGreaterThanOrEqual(0);
+ expect(box!.x + box!.width).toBeLessThanOrEqual(390.5);
+ }
+ expect(await bob.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
+ await shot(bob, 'teaminbox-phone-filters');
+
+ await emailItem(bob, OTHER).first().click();
+ await bob.getByRole('button', { name: 'More actions' }).last().click();
+ await expect(bob.getByRole('menuitem', { name: 'Assign to me' })).toBeVisible({ timeout: 20000 });
+ await expect(bob.getByRole('menuitem', { name: 'Mark done' })).toBeVisible();
+ await shot(bob, 'teaminbox-phone-more');
+ await bob.context().close();
+});
+
+test.afterAll(() => {
+ console.log(`team inbox spec ran on ${ORG_SERVER}`);
+});
diff --git a/integration/tests/19-org-templates.spec.ts b/integration/tests/19-org-templates.spec.ts
new file mode 100644
index 000000000..aecdfb075
--- /dev/null
+++ b/integration/tests/19-org-templates.spec.ts
@@ -0,0 +1,469 @@
+import { test, expect, type Page } from '@playwright/test';
+import { JmapClient } from './helpers/jmap';
+import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
+import {
+ ORG,
+ ORG_PASSWORD,
+ ORG_SERVER,
+ adminContext,
+ contextOptions,
+ getPolicy,
+ putPolicy,
+ requireOrgEnv,
+ shot,
+ userPage,
+} from './helpers/org';
+
+/**
+ * Shared templates (organisation feature 3), through the real UI on a VM 106
+ * test server.
+ *
+ * On the v1.0.0 release (label 1.0.0, the only one with working metadata):
+ * the admin builds a library on Organisation > Templates, for Everyone and
+ * for org-two.test; org-alice adds a team template in org-team, org-bob
+ * picks it; org-alice's own template reaches a second browser; org-dave
+ * sees the library of his domain; the admin locks team templates for the
+ * group; the picker, the manager and the admin page work at phone width.
+ *
+ * On 0.16 and the 1.0 test build: no team section, personal templates stay
+ * in the browser as before, and the app stays online.
+ *
+ * The admin's policy is put back, and every template entry these tests put
+ * in the accounts' Inbox metadata is removed again.
+ */
+
+test.describe.configure({ mode: 'serial', timeout: 240000 });
+
+const METADATA = ORG_SERVER === '1.0.0';
+const STAMP = Date.now().toString(36);
+const LIBRARY = `Org welcome ${STAMP}`;
+const SCOPE_TEMPLATE = `Two offer ${STAMP}`;
+const TEAM_TEMPLATE = `Team reply ${STAMP}`;
+const TEAM_BODY = `The team answers ${STAMP}`;
+const PERSONAL = `Alice own ${STAMP}`;
+const LOCAL = `Local only ${STAMP}`;
+const SEEDED_A = `Browser A ${STAMP}`;
+const SEEDED_B = `Browser B ${STAMP}`;
+
+const CORE = 'urn:ietf:params:jmap:core';
+const MAIL = 'urn:ietf:params:jmap:mail';
+const META = 'urn:ietf:params:jmap:metadata';
+
+let original: Record;
+
+/** Bulwark's template entries in the Inbox metadata of an account the user can open. */
+async function templateEntries(email: string, accountName = email): Promise>> {
+ const client = await JmapClient.connect(email, ORG_PASSWORD);
+ const accountId = accountName === email ? client.accountId : client.accountIdByName(accountName);
+ const r = await client.request([
+ ['Mailbox/query', { accountId, filter: { role: 'inbox' } }, 'q'],
+ ['Mailbox/get', { accountId, '#ids': { resultOf: 'q', name: 'Mailbox/query', path: '/ids' }, properties: ['id', 'metadata'] }, 'g'],
+ ], [CORE, MAIL, META]);
+ const metadata = (r.methodResponses[1][1].list[0]?.metadata ?? {}) as Record>;
+ return Object.fromEntries(Object.entries(metadata).filter(([key]) => key === 'org.bulwarkmail.templates' || key.startsWith('org.bulwarkmail.template.')));
+}
+
+/** Removes Bulwark's template entries (never the shared tags or anything else) from an Inbox. */
+async function clearTemplateEntries(email: string, accountName = email): Promise {
+ const client = await JmapClient.connect(email, ORG_PASSWORD);
+ const accountId = accountName === email ? client.accountId : client.accountIdByName(accountName);
+ const inbox = await client.request([['Mailbox/query', { accountId, filter: { role: 'inbox' } }, 'q']], [CORE, MAIL]);
+ const inboxId = inbox.methodResponses[0][1].ids[0];
+ for (const key of Object.keys(await templateEntries(email, accountName))) {
+ await client.request([['Mailbox/set', { accountId, update: { [inboxId]: { [`metadata/${key}`]: null } } }, '0']], [CORE, MAIL, META]);
+ }
+}
+
+async function clearAll(): Promise {
+ if (!METADATA) return;
+ for (const email of [ORG.alice, ORG.bob, ORG.carol, ORG.dave]) await clearTemplateEntries(email);
+ await clearTemplateEntries(ORG.alice, ORG.team);
+}
+
+async function startCompose(page: Page): Promise {
+ await page.getByRole('button', { name: 'Compose', exact: true }).first().click();
+ await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15000 });
+}
+
+/** Opens the template picker of the open composer. */
+async function openPicker(page: Page) {
+ const menu = page.getByTestId('composer-templates-menu');
+ if (await menu.isVisible().catch(() => false)) {
+ await menu.click();
+ await page.getByRole('menuitem', { name: 'Insert template' }).or(page.getByRole('button', { name: 'Insert template' })).first().click();
+ } else {
+ await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
+ await page.keyboard.press('t');
+ }
+ const dialog = page.getByRole('dialog', { name: 'Choose a Template' });
+ await expect(dialog).toBeVisible();
+ return dialog;
+}
+
+/** Settings > Composing > Templates > Manage Templates. */
+async function openManager(page: Page) {
+ await page.goto('/settings/templates');
+ const manage = page.getByRole('button', { name: 'Manage Templates' });
+ if (!(await manage.isVisible().catch(() => false))) {
+ // Phone: the settings list first.
+ await page.getByRole('button', { name: 'Composing' }).or(page.getByRole('link', { name: 'Composing' })).first().click();
+ }
+ await page.getByRole('button', { name: 'Manage Templates' }).click();
+ const dialog = page.getByRole('dialog', { name: 'Email Templates' });
+ await expect(dialog).toBeVisible();
+ return dialog;
+}
+
+async function createTemplate(dialog: ReturnType, name: string, body: string): Promise {
+ await dialog.getByRole('button', { name: 'New Template' }).click();
+ await dialog.getByPlaceholder('e.g., Follow-up email').fill(name);
+ await dialog.getByPlaceholder('Email body content...').fill(body);
+ await dialog.getByRole('button', { name: 'Create' }).click();
+ await expect(dialog.getByTestId('template-row').filter({ hasText: name })).toBeVisible({ timeout: 20000 });
+}
+
+/**
+ * A browser that already holds templates of its own from before (in
+ * template-storage), signed in as `email`.
+ */
+async function userWithLocalTemplates(browser: import('@playwright/test').Browser, email: string, templates: Array<{ id: string; name: string }>): Promise {
+ const context = await browser.newContext(contextOptions());
+ const now = new Date().toISOString();
+ const stored = JSON.stringify({
+ state: {
+ templates: templates.map((t) => ({ ...t, subject: '', body: `${t.name} body`, isHTML: false, category: '', isFavorite: false, createdAt: now, updatedAt: now })),
+ recentTemplateIds: [],
+ deletedTemplateIds: {},
+ },
+ version: 1,
+ });
+ await context.addInitScript((value) => {
+ if (!localStorage.getItem('template-storage')) localStorage.setItem('template-storage', value);
+ }, stored);
+ const page = await context.newPage();
+ await neutralizeDevOverlay(page);
+ await page.goto('/');
+ await page.fill('#username', email);
+ await page.fill('#password', ORG_PASSWORD);
+ await page.click('button[type="submit"]');
+ await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 });
+ return page;
+}
+
+async function expectOnline(page: Page): Promise {
+ await expect(page.getByText('Connection lost. Attempting to reconnect…')).toHaveCount(0);
+}
+
+test.beforeAll(async ({ browser }) => {
+ requireOrgEnv();
+ const admin = await adminContext(browser);
+ original = await getPolicy(admin);
+ await putPolicy(admin, {
+ ...original,
+ restrictions: {},
+ organisation: {},
+ scopes: [
+ { id: 'org-two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: {} },
+ { id: 'org-team', label: 'Org team', match: { groups: [ORG.team] }, policy: {} },
+ ],
+ });
+ await admin.close();
+ await clearAll();
+});
+
+test.afterAll(async ({ browser }) => {
+ const admin = await adminContext(browser);
+ await putPolicy(admin, original);
+ await admin.close();
+ await clearAll();
+});
+
+test('the admin builds the library for Everyone and for org-two.test', async ({ browser }) => {
+ const admin = await adminContext(browser);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-templates');
+ await expect(page.getByRole('heading', { name: 'Organisation library' })).toBeVisible();
+ // The dev server loads the policy twice (strict mode): wait for it.
+ await page.waitForLoadState('networkidle');
+
+ await page.getByTestId('org-template-add').click();
+ await page.getByTestId('org-template-name').last().fill(LIBRARY);
+ await page.getByTestId('org-template-subject').last().fill('Welcome');
+ await page.getByTestId('org-template-body').last().fill(`Welcome to Example ${STAMP}
`);
+ await page.getByLabel('The text is HTML').last().check();
+ await expect(page.getByTestId('org-template-preview').last().locator('b')).toHaveText('Example');
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ await shot(page, 'templates-admin-page');
+
+ await page.getByRole('button', { name: 'Org two' }).click();
+ await expect(page.getByTestId('org-template-inherited')).toContainText(LIBRARY);
+ await page.getByTestId('org-template-add').click();
+ await page.getByTestId('org-template-name').last().fill(SCOPE_TEMPLATE);
+ await page.getByTestId('org-template-body').last().fill(`Our offer ${STAMP}`);
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ await shot(page, 'templates-admin-scope');
+
+ const policy = await getPolicy(admin);
+ const everyone = (policy.organisation as { templates: { library: Array<{ name: string; isHTML: boolean }> } }).templates.library;
+ expect(everyone.map((t) => t.name)).toEqual([LIBRARY]);
+ expect(everyone[0].isHTML).toBe(true);
+ const two = (policy.scopes as Array<{ id: string; policy: { organisation?: { templates?: { library: Array<{ name: string }> } } } }>)
+ .find((s) => s.id === 'org-two')!;
+ expect(two.policy.organisation?.templates?.library.map((t) => t.name)).toEqual([SCOPE_TEMPLATE]);
+ await admin.close();
+});
+
+test('the library is read-only for users and follows their domain', async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice);
+ await startCompose(alice);
+ let picker = await openPicker(alice);
+ const organisation = picker.getByTestId('template-source-organisation');
+ await expect(organisation).toContainText(LIBRARY);
+ await expect(organisation).not.toContainText(SCOPE_TEMPLATE);
+ await alice.keyboard.press('Escape');
+
+ const manager = await openManager(alice);
+ await manager.getByTestId('template-source-tab-organisation').click();
+ const row = manager.getByTestId('template-row').filter({ hasText: LIBRARY });
+ await expect(row.getByRole('button', { name: 'Edit Template' })).toHaveCount(0);
+ await expect(manager.getByRole('button', { name: 'New Template' })).toHaveCount(0);
+ await row.getByRole('button', { name: 'Preview' }).click();
+ await expect(manager.getByTestId('template-preview').locator('b')).toHaveText('Example');
+ await shot(alice, 'templates-manager-organisation');
+ await expectOnline(alice);
+ await alice.context().close();
+
+ const dave = await userPage(browser, ORG.dave);
+ await startCompose(dave);
+ picker = await openPicker(dave);
+ await expect(picker.getByTestId('template-source-organisation')).toContainText(LIBRARY);
+ await expect(picker.getByTestId('template-source-organisation')).toContainText(SCOPE_TEMPLATE);
+ await shot(dave, 'templates-picker-org-two');
+ await dave.context().close();
+});
+
+test('personal templates hidden by the admin: only organisation templates, no saving', async ({ browser }) => {
+ const admin = await adminContext(browser);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-templates');
+ await page.waitForLoadState('networkidle');
+ await page.getByRole('button', { name: 'Everyone' }).click();
+ await page.getByLabel('Hide').check();
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ expect(((await getPolicy(admin)).restrictions as Record).personalTemplates).toEqual({ hidden: true });
+
+ const alice = await userPage(browser, ORG.alice);
+ await startCompose(alice);
+ const menu = alice.getByTestId('composer-templates-menu');
+ await menu.click();
+ await expect(alice.getByText('Save as template')).toHaveCount(0);
+ await alice.getByText('Insert template').first().click();
+ const picker = alice.getByRole('dialog', { name: 'Choose a Template' });
+ await expect(picker.getByTestId('template-source-organisation')).toContainText(LIBRARY);
+ await expect(picker.getByTestId('template-source-personal')).toHaveCount(0);
+ await shot(alice, 'templates-picker-personal-hidden');
+ await alice.keyboard.press('Escape');
+ const manager = await openManager(alice);
+ await expect(manager.getByTestId('template-source-tab-personal')).toHaveCount(0);
+ await alice.context().close();
+
+ const policy = await getPolicy(admin);
+ const restrictions = { ...(policy.restrictions as Record) };
+ delete restrictions.personalTemplates;
+ await putPolicy(admin, { ...policy, restrictions });
+ await admin.close();
+});
+
+test.describe('with metadata (v1.0.0)', () => {
+ test.skip(!METADATA, 'team templates and templates in the account need Stalwart v1.0.0');
+
+ test('org-alice adds a team template in org-team and org-bob uses it', async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice);
+ const manager = await openManager(alice);
+ await manager.getByTestId('template-source-tab-team').click();
+ await expect(manager.getByTestId('template-source-tab-team')).toContainText(ORG.team);
+ await expect(manager.getByTestId('template-source-note')).toContainText(`Everyone in ${ORG.team}`);
+ await createTemplate(manager, TEAM_TEMPLATE, TEAM_BODY);
+ await shot(alice, 'templates-manager-team');
+ await alice.context().close();
+
+ const stored = await templateEntries(ORG.alice, ORG.team);
+ const entry = Object.entries(stored).find(([, value]) => value.name === TEAM_TEMPLATE);
+ expect(entry, JSON.stringify(Object.keys(stored))).toBeTruthy();
+ expect(entry![1].body).toBe(TEAM_BODY);
+
+ const bob = await userPage(browser, ORG.bob);
+ await startCompose(bob);
+ const picker = await openPicker(bob);
+ const team = picker.getByTestId('template-source-team');
+ await expect(team).toContainText(TEAM_TEMPLATE, { timeout: 20000 });
+ const headings = await picker.getByRole('heading', { level: 2 }).allTextContents();
+ expect(headings).toContain(ORG.team);
+ expect(headings).toContain('Organisation');
+ await shot(bob, 'templates-picker-team');
+ await team.getByText(TEAM_TEMPLATE).click();
+ await expect(picker).toBeHidden();
+ await expect(bob.locator('.ProseMirror').first()).toContainText(TEAM_BODY);
+ await expectOnline(bob);
+ await bob.context().close();
+ });
+
+ test("org-alice's own template is in the account and reaches a second browser", async ({ browser }) => {
+ const first = await userPage(browser, ORG.alice);
+ const manager = await openManager(first);
+ await expect(manager.getByTestId('template-storage-note')).toContainText('kept in your account', { timeout: 20000 });
+ await createTemplate(manager, PERSONAL, `Hello from Alice ${STAMP}`);
+ await shot(first, 'templates-manager-personal');
+ await expect(async () => {
+ const entries = await templateEntries(ORG.alice);
+ expect(Object.values(entries).map((v) => v.name)).toContain(PERSONAL);
+ }).toPass({ timeout: 30000 });
+ await first.context().close();
+
+ const second = await userPage(browser, ORG.alice);
+ await startCompose(second);
+ const picker = await openPicker(second);
+ await expect(picker.getByTestId('template-source-personal')).toContainText(PERSONAL, { timeout: 20000 });
+ await second.context().close();
+ });
+
+ test('templates a browser held from before go into the account, and a second browser keeps both lists', async ({ browser }) => {
+ const idA = `aaaa${STAMP}-0001`;
+ const idB = `bbbb${STAMP}-0002`;
+ const first = await userWithLocalTemplates(browser, ORG.alice, [{ id: idA, name: SEEDED_A }]);
+ await expect(async () => {
+ const names = Object.values(await templateEntries(ORG.alice)).map((v) => v.name);
+ expect(names).toContain(SEEDED_A);
+ }).toPass({ timeout: 45000 });
+ await first.context().close();
+
+ const second = await userWithLocalTemplates(browser, ORG.alice, [{ id: idB, name: SEEDED_B }]);
+ await expect(async () => {
+ const names = Object.values(await templateEntries(ORG.alice)).map((v) => v.name);
+ expect(names).toEqual(expect.arrayContaining([SEEDED_A, SEEDED_B]));
+ }).toPass({ timeout: 45000 });
+ await startCompose(second);
+ const picker = await openPicker(second);
+ await expect(picker.getByTestId('template-source-personal')).toContainText(SEEDED_A, { timeout: 20000 });
+ await expect(picker.getByTestId('template-source-personal')).toContainText(SEEDED_B);
+ await shot(second, 'templates-picker-migrated');
+ await second.context().close();
+ // The marker with the tombstones is there once the lists are in.
+ expect(Object.keys(await templateEntries(ORG.alice))).toContain('org.bulwarkmail.templates');
+ });
+
+ test('a locked team template is read-only for the group', async ({ browser }) => {
+ const admin = await adminContext(browser);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-templates');
+ await page.waitForLoadState('networkidle');
+ await page.getByRole('button', { name: 'Org team' }).click();
+ await page.getByLabel('Lock').check();
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ await page.getByLabel('Lock').scrollIntoViewIfNeeded();
+ await shot(page, 'templates-admin-lock');
+ const policy = await getPolicy(admin);
+ const team = (policy.scopes as Array<{ id: string; policy: { restrictions?: Record } }>).find((s) => s.id === 'org-team')!;
+ expect(team.policy.restrictions).toEqual({ teamTemplates: { locked: true } });
+ await admin.close();
+
+ const bob = await userPage(browser, ORG.bob);
+ const manager = await openManager(bob);
+ await manager.getByTestId('template-source-tab-team').click();
+ await expect(manager.getByTestId('template-source-note')).toContainText('Set by your organisation');
+ await expect(manager.getByRole('button', { name: 'New Template' })).toHaveCount(0);
+ const row = manager.getByTestId('template-row').filter({ hasText: TEAM_TEMPLATE });
+ await expect(row).toBeVisible();
+ await expect(row.getByRole('button', { name: 'Edit Template' })).toHaveCount(0);
+ await expect(row.getByRole('button', { name: 'Delete' })).toHaveCount(0);
+ await shot(bob, 'templates-manager-team-locked');
+ await bob.context().close();
+
+ // The lock leaves the template in place; members still use it.
+ expect(Object.values(await templateEntries(ORG.alice, ORG.team)).map((v) => v.name)).toContain(TEAM_TEMPLATE);
+ });
+
+ test('the picker, the manager and the admin page work at phone width', async ({ browser }) => {
+ const bob = await userPage(browser, ORG.bob, true);
+ await startCompose(bob);
+ const picker = await openPicker(bob);
+ await expect(picker.getByTestId('template-source-team')).toContainText(TEAM_TEMPLATE, { timeout: 20000 });
+ const box = await picker.boundingBox();
+ expect(box!.x).toBeGreaterThanOrEqual(0);
+ expect(box!.x + box!.width).toBeLessThanOrEqual(390);
+ await shot(bob, 'templates-picker-phone');
+ await picker.getByText(TEAM_TEMPLATE).click();
+ await expect(bob.locator('.ProseMirror').first()).toContainText(TEAM_BODY);
+ await bob.context().close();
+
+ const alice = await userPage(browser, ORG.alice, true);
+ const manager = await openManager(alice);
+ const managerBox = await manager.boundingBox();
+ expect(managerBox!.x + managerBox!.width).toBeLessThanOrEqual(390);
+ await expect(manager.getByTestId('template-sources')).toBeVisible();
+ await manager.getByTestId('template-source-tab-team').click();
+ await expect(manager.getByTestId('template-row').filter({ hasText: TEAM_TEMPLATE })).toBeVisible();
+ const overflow = await alice.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
+ expect(overflow).toBeLessThanOrEqual(0);
+ await shot(alice, 'templates-manager-phone');
+ await alice.context().close();
+
+ const admin = await adminContext(browser, true);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-templates');
+ await expect(page.getByTestId('org-template-add')).toBeVisible();
+ const adminOverflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
+ expect(adminOverflow).toBeLessThanOrEqual(0);
+ await shot(page, 'templates-admin-page-phone');
+ await admin.close();
+ });
+});
+
+test.describe('without metadata (0.16, 1.0 test build)', () => {
+ test.skip(METADATA, 'this server keeps templates in the account');
+
+ test('no team section, personal templates stay in the browser, and the app stays online', async ({ browser }) => {
+ // The 1.0 test build advertises metadata and drops the connection on
+ // it: count the metadata requests that went out and failed.
+ const metadataRequests: string[] = [];
+ const context = await browser.newContext(contextOptions());
+ context.on('requestfailed', (request) => {
+ if ((request.postData() ?? '').includes('"properties":["id","metadata"]')) metadataRequests.push(request.failure()?.errorText ?? 'failed');
+ });
+ const alice = await context.newPage();
+ await neutralizeDevOverlay(alice);
+ await alice.goto('/');
+ await alice.fill('#username', ORG.alice);
+ await alice.fill('#password', ORG_PASSWORD);
+ await alice.click('button[type="submit"]');
+ await accountSwitcher(alice).waitFor({ state: 'visible', timeout: 60000 });
+ const manager = await openManager(alice);
+ await expect(manager.getByTestId('template-source-tab-team')).toHaveCount(0);
+ await expect(manager.getByTestId('template-storage-note')).toHaveCount(0);
+ await createTemplate(manager, LOCAL, `Kept here ${STAMP}`);
+ const stored = await alice.evaluate(() => ({
+ browser: localStorage.getItem('template-storage') ?? '',
+ account: localStorage.getItem('account-templates') ?? '',
+ }));
+ expect(stored.browser).toContain(LOCAL);
+ expect(stored.account).not.toContain(LOCAL);
+ await shot(alice, 'templates-manager-no-metadata');
+ await alice.keyboard.press('Escape');
+
+ await alice.goto('/');
+ await startCompose(alice);
+ const picker = await openPicker(alice);
+ await expect(picker.getByTestId('template-source-team')).toHaveCount(0);
+ await expect(picker).toContainText(LOCAL);
+ // Long enough for the keep-alive ping after the metadata request failed.
+ await alice.waitForTimeout(35000);
+ await expectOnline(alice);
+ if (ORG_SERVER === '1.0') expect(metadataRequests.length).toBeGreaterThan(0);
+ else expect(metadataRequests).toEqual([]);
+ await alice.context().close();
+ });
+});
diff --git a/integration/tests/19-org-thirdparty.spec.ts b/integration/tests/19-org-thirdparty.spec.ts
new file mode 100644
index 000000000..cbd122c1d
--- /dev/null
+++ b/integration/tests/19-org-thirdparty.spec.ts
@@ -0,0 +1,256 @@
+import { test, expect, type BrowserContext, type Page } from '@playwright/test';
+import { JMAP_URL } from './helpers/config';
+import { neutralizeDevOverlay, accountSwitcher, emailItem, openComposer, closeComposer } from './helpers/app';
+import { JmapClient } from './helpers/jmap';
+import { readFileSync, existsSync } from 'node:fs';
+import { ORG, ORG_PASSWORD, adminContext, contextOptions, getPolicy, putPolicy, requireOrgEnv, shot } from './helpers/org';
+
+/**
+ * "No third-party traffic" (organisation feature 5, #903) through the real
+ * UI. The dev server runs with NO_THIRD_PARTY_TRAFFIC=true. Every request the
+ * browser makes while org-alice signs in, reads mail (from a sender whose
+ * domain has a favicon), composes and opens settings, and while the admin
+ * opens the Organisation pages, must go to the webmail itself or the JMAP
+ * server. The server's own requests (telemetry, version check, favicons,
+ * directory) are covered by lib/__tests__/no-third-party-server.test.ts; the
+ * direct calls here check that the routes refuse before reaching out.
+ */
+
+test.describe.configure({ mode: 'serial' });
+
+const SUBJECT = `Third-party check ${Date.now()}`;
+const REMOTE_SUBJECT = `Remote picture check ${Date.now()}`;
+const seededIds: string[] = [];
+
+const allowedHosts = () => new Set([new URL(contextOptions().baseURL).host, new URL(JMAP_URL).host]);
+
+/** Records every http(s) request of a context, service worker included. */
+function recordRequests(context: BrowserContext): string[] {
+ const urls: string[] = [];
+ context.on('request', (request) => {
+ const url = request.url();
+ if (/^https?:/i.test(url)) urls.push(url);
+ });
+ return urls;
+}
+
+function outside(urls: string[]): string[] {
+ const allowed = allowedHosts();
+ return [...new Set(urls.filter((url) => !allowed.has(new URL(url).host)))];
+}
+
+async function signIn(page: Page, email: string): Promise {
+ await neutralizeDevOverlay(page);
+ await page.goto('/');
+ await page.fill('#username', email);
+ await page.fill('#password', ORG_PASSWORD);
+ await page.click('button[type="submit"]');
+ await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 });
+}
+
+test.beforeAll(async ({ browser }) => {
+ requireOrgEnv();
+ // The mode must come from the server's config, not from this spec.
+ const admin = await adminContext(browser);
+ const res = await admin.request.get('/api/admin/outgoing-hosts');
+ expect(res.ok()).toBe(true);
+ const body = await res.json();
+ expect(body.noThirdPartyTraffic, 'start the dev server with NO_THIRD_PARTY_TRAFFIC=true').toBe(true);
+ await admin.close();
+
+ // A message from a sender whose domain has a favicon, straight into the
+ // Inbox (SMTP on the test servers doesn't work).
+ const alice = await JmapClient.connect(ORG.alice, ORG_PASSWORD);
+ const inbox = await alice.mailboxByRole('inbox');
+ const r = await alice.request([['Email/set', {
+ accountId: alice.accountId,
+ create: {
+ m: {
+ mailboxIds: { [inbox!.id]: true },
+ from: [{ name: 'GitHub', email: 'noreply@github.com' }],
+ to: [{ name: 'Org Alice', email: ORG.alice }],
+ subject: SUBJECT,
+ receivedAt: new Date().toISOString(),
+ bodyStructure: { partId: 't', type: 'text/plain' },
+ bodyValues: { t: { value: 'Checking that nothing leaves for an outside host.' } },
+ },
+ // A newsletter with a tracking picture on another host.
+ p: {
+ mailboxIds: { [inbox!.id]: true },
+ from: [{ name: 'Shop News', email: 'news@shop.example' }],
+ to: [{ name: 'Org Alice', email: ORG.alice }],
+ subject: REMOTE_SUBJECT,
+ receivedAt: new Date(Date.now() - 60_000).toISOString(),
+ bodyStructure: { partId: 'h', type: 'text/html' },
+ bodyValues: { h: { value: 'Our offers this week.
' } },
+ },
+ },
+ }, '0']]);
+ const created = r.methodResponses[0][1].created ?? {};
+ for (const key of ['m', 'p']) if (created[key]?.id) seededIds.push(created[key].id);
+ expect(seededIds.length, JSON.stringify(r.methodResponses[0][1])).toBe(2);
+});
+
+test.afterAll(async () => {
+ if (seededIds.length === 0) return;
+ const alice = await JmapClient.connect(ORG.alice, ORG_PASSWORD);
+ await alice.request([['Email/set', { accountId: alice.accountId, destroy: seededIds }, '0']]);
+});
+
+test('signing in, reading, composing and settings reach no outside host', async ({ browser }) => {
+ const context = await browser.newContext(contextOptions());
+ const urls = recordRequests(context);
+ const page = await context.newPage();
+ await signIn(page, ORG.alice);
+
+ const row = emailItem(page, SUBJECT).first();
+ await expect(row).toBeVisible({ timeout: 30000 });
+ await row.click();
+ await expect(page.locator('[data-plugin-source="email-body"]').first()).toContainText('Checking that nothing leaves', { timeout: 20000 });
+ // Initials, not GitHub's favicon: the avatars never ask /api/favicon.
+ await expect(page.locator('img[src*="/api/favicon"]')).toHaveCount(0);
+ await shot(page, 'thirdparty-reading');
+
+ // Remote pictures: the notice says the organisation blocks them, and
+ // nothing offers to load them or to trust the sender.
+ await emailItem(page, REMOTE_SUBJECT).first().click();
+ const notice = page.getByTestId('external-content-notice').first();
+ await expect(notice).toContainText('Your organisation blocks content from other servers', { timeout: 20000 });
+ await expect(page.getByRole('button', { name: 'Load images' })).toHaveCount(0);
+ await expect(page.getByRole('button', { name: 'Always trust this sender' })).toHaveCount(0);
+ await shot(page, 'thirdparty-remote-blocked');
+
+ await openComposer(page);
+ await page.locator('[data-testid="composer-subject"]').fill('Not sent');
+ await page.waitForTimeout(1500);
+ await closeComposer(page);
+
+ await page.goto('/settings/notifications');
+ const pushNote = page.getByText(/Push notifications need a push relay run by your organisation/);
+ await expect(pushNote).toBeVisible({ timeout: 20000 });
+ await pushNote.scrollIntoViewIfNeeded();
+ await shot(page, 'thirdparty-push-settings');
+ await page.goto('/settings/reading');
+ const externalContent = page.locator('[data-search-label="External Content"], [data-search-label="External content"]').first();
+ await expect(externalContent).toContainText('Set by your organisation', { timeout: 20000 });
+ await page.goto('/settings/message_list');
+ await expect(page.getByText(/Sender/).first()).toBeVisible({ timeout: 20000 });
+ await expect(page.getByText('Sender Favicons', { exact: true })).toHaveCount(0);
+
+ await page.waitForTimeout(2000);
+ expect(urls.length).toBeGreaterThan(10);
+ expect(urls.some((url) => url.includes('/api/favicon'))).toBe(false);
+ expect(outside(urls)).toEqual([]);
+
+ // Direct calls are refused before the server reaches out.
+ expect((await context.request.get('/api/favicon?domain=github.com')).status()).toBe(403);
+ const translate = await context.request.post('/api/translate', {
+ data: { text: 'Hallo Welt', target: 'en', source: 'de' },
+ headers: { Origin: new URL(contextOptions().baseURL).origin },
+ });
+ expect(translate.status()).toBe(403);
+ expect((await context.request.get('/api/connector/capabilities')).status()).toBe(404);
+ await context.close();
+});
+
+test('the admin sees the switch from the environment and what stays reachable', async ({ browser }) => {
+ const admin = await adminContext(browser);
+ const urls = recordRequests(admin);
+ const page = await admin.newPage();
+ await page.goto('/admin/organisation');
+ await expect(page.getByRole('heading', { name: 'Domains and groups' }).first()).toBeVisible();
+ await page.goto('/admin/org-security');
+ const section = page.locator('[data-settings-section="third-party"]');
+ await expect(section.getByText('No third-party traffic', { exact: true })).toBeVisible({ timeout: 20000 });
+ const toggle = page.getByTestId('third-party-switch').getByRole('switch');
+ await expect(toggle).toHaveAttribute('aria-checked', 'true');
+ await expect(toggle).toBeDisabled();
+ await expect(section.getByText('NO_THIRD_PARTY_TRAFFIC', { exact: true })).toBeVisible();
+ await expect(section.getByText(/whole server, not per domain or group/)).toBeVisible();
+ await expect(page.getByTestId('third-party-reachable')).toContainText(new URL(JMAP_URL).host);
+ await section.getByText(/Built-in outside hosts/).click();
+ await expect(page.getByTestId('third-party-builtin')).toContainText('icons.duckduckgo.com');
+ await section.scrollIntoViewIfNeeded();
+ await shot(page, 'thirdparty-admin-security');
+
+ await page.goto('/admin/marketplace');
+ await expect(page.getByText(/official extension directory is off/).first()).toBeVisible({ timeout: 20000 });
+ await page.waitForTimeout(1500);
+ expect(outside(urls)).toEqual([]);
+ await admin.close();
+});
+
+test('the admin section and the user settings work at phone width', async ({ browser }) => {
+ const admin = await adminContext(browser, true);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-security');
+ const section = page.locator('[data-settings-section="third-party"]');
+ await expect(section.getByText('No third-party traffic', { exact: true })).toBeVisible({ timeout: 20000 });
+ await section.scrollIntoViewIfNeeded();
+ const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
+ expect(overflow).toBeLessThanOrEqual(0);
+ await shot(page, 'thirdparty-admin-security-phone');
+ await admin.close();
+
+ const context = await browser.newContext(contextOptions(true));
+ const urls = recordRequests(context);
+ const user = await context.newPage();
+ await signIn(user, ORG.alice);
+ await user.goto('/settings/notifications');
+ await user.getByRole('button', { name: 'Notifications' }).or(user.getByRole('link', { name: 'Notifications' })).first().click();
+ const pushNote = user.getByText(/Push notifications need a push relay run by your organisation/);
+ await expect(pushNote).toBeVisible({ timeout: 20000 });
+ await pushNote.scrollIntoViewIfNeeded();
+ await shot(user, 'thirdparty-push-settings-phone');
+ expect(outside(urls)).toEqual([]);
+ await context.close();
+});
+
+/**
+ * A plugin that only returns image URLs (Gravatar declares no network
+ * access, so it runs): its avatars must fall back to initials, and the page's
+ * CSP stops anything that slips through. The zip is built from
+ * repos/plugins/gravatar into the rig's scratch folder with the fallback
+ * style set to "identicon"; IT_GRAVATAR_ZIP points at it.
+ */
+test('the Gravatar plugin runs but nothing reaches gravatar.com', async ({ browser }) => {
+ const zip = process.env.IT_GRAVATAR_ZIP ?? '';
+ test.skip(!zip || !existsSync(zip), 'IT_GRAVATAR_ZIP not set');
+ const admin = await adminContext(browser);
+ const before = await getPolicy(admin);
+ const features = (before.features ?? {}) as Record;
+ try {
+ const upload = await admin.request.post('/api/admin/plugins', {
+ multipart: { file: { name: 'gravatar.zip', mimeType: 'application/zip', buffer: readFileSync(zip) } },
+ });
+ expect(upload.ok(), await upload.text()).toBe(true);
+ await putPolicy(admin, { ...before, features: { ...features, pluginsEnabled: true }, forceEnabledPlugins: ['gravatar'] });
+
+ const context = await browser.newContext(contextOptions());
+ const urls = recordRequests(context);
+ const page = await context.newPage();
+ await signIn(page, ORG.alice);
+ await expect(emailItem(page, SUBJECT).first()).toBeVisible({ timeout: 30000 });
+ // The plugin is running in this browser. The zip's fallback style is
+ // "identicon", so it returns a gravatar.com URL for every sender without
+ // first asking gravatar.com whether a picture exists (a fetch the
+ // sandbox's own policy stops); that URL is what the avatar must refuse.
+ await page.goto('/settings/addons');
+ await expect(page.getByText('Gravatar Avatars').first()).toBeVisible({ timeout: 30000 });
+ await expect(page.getByText('On', { exact: true }).first()).toBeVisible({ timeout: 60000 });
+ // Back inside the app, so the running plugin stays loaded.
+ await page.getByRole('button', { name: 'Back to Mail' }).or(page.getByRole('link', { name: 'Back to Mail' })).first().click();
+ await emailItem(page, SUBJECT).first().click();
+ await expect(page.locator('[data-plugin-source="email-body"]').first()).toContainText('Checking that nothing leaves', { timeout: 20000 });
+ await page.waitForTimeout(3000);
+ await expect(page.locator('img[src*="gravatar.com"]')).toHaveCount(0);
+ await shot(page, 'thirdparty-gravatar-initials');
+ expect(urls.filter((url) => /gravatar\.com/i.test(url))).toEqual([]);
+ expect(outside(urls)).toEqual([]);
+ await context.close();
+ } finally {
+ await putPolicy(admin, before);
+ await admin.request.delete('/api/admin/plugins', { data: { id: 'gravatar' } });
+ await admin.close();
+ }
+});
diff --git a/integration/tests/helpers/org.ts b/integration/tests/helpers/org.ts
new file mode 100644
index 000000000..050763300
--- /dev/null
+++ b/integration/tests/helpers/org.ts
@@ -0,0 +1,108 @@
+/**
+ * Helpers for the organisation feature specs (19-org-*.spec.ts). They run
+ * against a local dev server on a VM 106 test Stalwart with the throwaway
+ * org-* accounts, never the suite's alice/bob/carol, and never reset an
+ * account.
+ *
+ * Env:
+ * IT_WEBMAIL_URL the dev server (scratch ADMIN_CONFIG_DIR)
+ * IT_ORG_PASSWORD password of the org-* accounts (vault "Stalwart test – org-* throwaway accounts")
+ * IT_ADMIN_PASSWORD the dev server's admin password (vault "Bulwark dev rig – org features admin")
+ * IT_ORG_SERVER label for screenshots and logs: 0.16, 1.0, 1.0.0
+ */
+import { type Browser, type BrowserContext, type Page, expect } from '@playwright/test';
+import { existsSync, mkdirSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { WEBMAIL_URL } from './config';
+import { accountSwitcher, neutralizeDevOverlay } from './app';
+
+export const ORG_PASSWORD = process.env.IT_ORG_PASSWORD ?? '';
+export const ADMIN_PASSWORD = process.env.IT_ADMIN_PASSWORD ?? '';
+export const ORG_SERVER = process.env.IT_ORG_SERVER ?? 'unknown';
+
+export const ORG = {
+ alice: 'org-alice@example.org',
+ bob: 'org-bob@example.org',
+ carol: 'org-carol@example.org',
+ dave: 'org-dave@org-two.test',
+ team: 'org-team@example.org',
+} as const;
+
+export function requireOrgEnv(): void {
+ if (!ORG_PASSWORD || !ADMIN_PASSWORD) {
+ throw new Error('Set IT_ORG_PASSWORD and IT_ADMIN_PASSWORD (vw exec --env ...) to run the org specs');
+ }
+}
+
+/** English UI (Playwright's default locale gives German) at desktop or phone width. */
+export function contextOptions(phone = false) {
+ return {
+ baseURL: WEBMAIL_URL,
+ locale: 'en-US',
+ viewport: phone ? { width: 390, height: 844 } : { width: 1440, height: 900 },
+ ...(phone ? { isMobile: true, hasTouch: true } : {}),
+ };
+}
+
+// Not under integration/test-results: Playwright empties that folder on
+// every run, which would cost an admin login per run.
+const stateDir = path.join(tmpdir(), 'bulwark-org-it');
+const adminStatePath = () => path.join(stateDir, `org-admin-${new URL(WEBMAIL_URL).port || '80'}.json`);
+
+/**
+ * A browser context signed in to /admin. Admin login is rate limited (5 per
+ * 15 minutes), so the session is stored once and reused across specs. It
+ * signs in through the API, not the form: while next dev recompiles, the
+ * form can lose the typed password, and each lost attempt counts.
+ */
+export async function adminContext(browser: Browser, phone = false): Promise {
+ if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
+ if (existsSync(adminStatePath())) {
+ const context = await browser.newContext({ ...contextOptions(phone), storageState: adminStatePath() });
+ const res = await context.request.get('/api/admin/auth');
+ if (res.ok() && (await res.json().catch(() => ({})))?.authenticated !== false) return context;
+ await context.close();
+ }
+ const context = await browser.newContext(contextOptions(phone));
+ const res = await context.request.post('/api/admin/auth', {
+ data: { password: ADMIN_PASSWORD },
+ headers: { Origin: new URL(WEBMAIL_URL).origin },
+ });
+ expect(res.ok(), `admin sign-in: ${res.status()} ${await res.text()}`).toBe(true);
+ await context.storageState({ path: adminStatePath() });
+ return context;
+}
+
+/** The policy as the admin sees it. */
+export async function getPolicy(admin: BrowserContext): Promise> {
+ const res = await admin.request.get('/api/admin/policy');
+ expect(res.ok()).toBe(true);
+ return res.json();
+}
+
+export async function putPolicy(admin: BrowserContext, policy: Record): Promise {
+ const res = await admin.request.put('/api/admin/policy', { data: policy });
+ expect(res.ok(), await res.text()).toBe(true);
+}
+
+/** Sign in a fresh context as one of the org-* accounts. */
+export async function userPage(browser: Browser, email: string, phone = false): Promise {
+ const context = await browser.newContext(contextOptions(phone));
+ const page = await context.newPage();
+ await neutralizeDevOverlay(page);
+ await page.goto('/');
+ await page.fill('#username', email);
+ await page.fill('#password', ORG_PASSWORD);
+ await page.click('button[type="submit"]');
+ await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 });
+ return page;
+}
+
+/** Screenshots for local-data/org-features-screenshots/, named by feature and server. */
+export async function shot(page: Page, name: string): Promise {
+ const dir = process.env.IT_ORG_SCREENSHOTS;
+ if (!dir) return;
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
+ await page.screenshot({ path: path.join(dir, `${name}-${ORG_SERVER}.png`), fullPage: false });
+}
diff --git a/lib/__tests__/account-template-sync.test.ts b/lib/__tests__/account-template-sync.test.ts
new file mode 100644
index 000000000..df8bd4c83
--- /dev/null
+++ b/lib/__tests__/account-template-sync.test.ts
@@ -0,0 +1,424 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// Personal templates kept in the user's own Inbox metadata on Stalwart
+// v1.0.0, one namespace per template plus a marker entry holding the
+// tombstones of deleted ones. The server below answers with the envelopes
+// recorded from stw-rel (fixtures/…template-metadata.json) and keeps the
+// metadata in memory, refusing like the real one once 64 KB are used.
+
+const config = vi.hoisted(() => ({ value: { settingsSyncEnabled: true } as Record | null }));
+vi.mock('@/hooks/use-config', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ getCachedConfig: () => config.value,
+}));
+
+import { JMAPClient } from '../jmap/client';
+import { syncPersonalTemplates, type BrowserList } from '../templates/account-template-sync';
+import { planPersonalSync, pruneTombstones, storableId } from '../templates/metadata-templates';
+import { browserListFor, flushAccountTemplates, personalTemplatesFor, syncAccountTemplates } from '../templates/personal-templates';
+import { useAccountTemplatesStore } from '@/stores/account-templates-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { useTemplateStore } from '@/stores/template-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { purgeSignedOutData, runBeforeSignOut } from '@/lib/sign-out-cleanup';
+import type { EmailTemplate } from '../template-types';
+import recorded from './fixtures/stalwart-1.0.0-template-metadata.json';
+
+const METADATA_CAP = {
+ 'urn:ietf:params:jmap:metadata': {
+ dataTypes: { Mailbox: { namespaces: [], supportsVendorNamespaces: true, supportsPrivate: true, maxDepth: 8 } },
+ },
+};
+
+function session(withMetadata = true) {
+ return {
+ capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {}, ...(withMetadata ? { 'urn:ietf:params:jmap:metadata': {} } : {}) },
+ accounts: {
+ bl: { name: 'carol@example.org', isPersonal: true, isReadOnly: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...(withMetadata ? METADATA_CAP : {}) } },
+ },
+ primaryAccounts: { 'urn:ietf:params:jmap:mail': 'bl' },
+ apiUrl: 'https://mail.example.com/jmap/api',
+ downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
+ uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
+ eventSourceUrl: '',
+ };
+}
+
+const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
+const clone = (v: T): T => JSON.parse(JSON.stringify(v));
+
+type Call = [string, Record, string];
+let server: Record;
+let sets: Array<{ key: string; value: unknown }>;
+let drop: boolean;
+let omitMetadata: boolean;
+let onSet: (() => void) | null;
+const SHARED_MAX = 65536;
+
+function answer(body: { methodCalls: Call[] }): Response {
+ if (drop) throw new TypeError('Failed to fetch');
+ const [method, args] = body.methodCalls[body.methodCalls.length - 1];
+ if (method === 'Mailbox/get') {
+ const out = clone(recorded.getEmpty);
+ const inbox = (out.methodResponses[1][1] as { list: Array> }).list[0];
+ if (omitMetadata) delete inbox.metadata;
+ else inbox.metadata = clone(server);
+ return json(out);
+ }
+ if (method === 'Mailbox/set') {
+ onSet?.();
+ const patch = (args.update as Record>).a;
+ const [path, value] = Object.entries(patch)[0];
+ const namespace = path.replace(/^metadata\//, '');
+ const next = { ...server, [namespace]: value };
+ if (value === null) delete next[namespace];
+ if (new TextEncoder().encode(JSON.stringify(next)).length > SHARED_MAX) return json(clone(recorded.setNoRoom));
+ server = next;
+ sets.push({ key: namespace, value });
+ return json(clone(value === null ? recorded.deleteOne : recorded.setOne));
+ }
+ throw new Error(`unexpected ${method}`);
+}
+
+const tpl = (id: string, name: string, updatedAt = '2026-10-01T00:00:00.000Z', extra: Partial = {}): EmailTemplate => ({
+ id, name, subject: '', body: `${name} body`, isHTML: false, category: '', isFavorite: false,
+ createdAt: '2026-10-01T00:00:00.000Z', updatedAt, ...extra,
+});
+const ns = (id: string) => `org.bulwarkmail.template.${id}`;
+const MARKER = 'org.bulwarkmail.templates';
+const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
+
+let client: JMAPClient;
+let connection: ReturnType void>>;
+let browser: BrowserList;
+const sync = () => syncPersonalTemplates('carol@mail', client, () => browser);
+const list = (templates: EmailTemplate[]): BrowserList => ({ status: 'list', templates });
+const partition = () => useAccountTemplatesStore.getState().personal['carol@mail'];
+const names = () => partition().templates.map((t) => t.name).sort();
+const edit = (fn: (templates: EmailTemplate[]) => EmailTemplate[]) =>
+ useAccountTemplatesStore.getState().updatePersonal('carol@mail', (p) => ({ ...p, templates: fn(p.templates) }));
+
+async function connect(withMetadata = true) {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockResolvedValueOnce(json(session(withMetadata)));
+ client = new JMAPClient('https://mail.example.com', 'carol@example.org', 'pw');
+ await client.connect();
+ fetchSpy.mockReset();
+ fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => answer(JSON.parse(String(init?.body ?? '{}'))));
+ connection = vi.fn<(connected: boolean) => void>();
+ client.onConnectionChange(connection);
+}
+
+const original = useAuthStore.getState();
+const asCarol = () => useAuthStore.setState({
+ activeAccountId: 'carol@mail',
+ getClientForAccount: (id: string) => (id === 'carol@mail' ? client : undefined),
+} as never);
+
+beforeEach(async () => {
+ vi.useRealTimers();
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ server = {};
+ sets = [];
+ drop = false;
+ omitMetadata = false;
+ onSet = null;
+ browser = list([]);
+ config.value = { settingsSyncEnabled: true };
+ localStorage.removeItem('settings-sync-owner');
+ useAccountTemplatesStore.setState({ personal: {}, teams: {}, active: null, browserListOwner: null });
+ useTemplateStore.setState({ templates: [], deletedTemplateIds: {}, recentTemplateIds: [] });
+ useSettingsStore.setState({ settingsSyncDisabled: false });
+ await connect();
+});
+afterEach(() => {
+ vi.restoreAllMocks();
+ useAuthStore.setState({ activeAccountId: original.activeAccountId, getClientForAccount: original.getClientForAccount } as never);
+});
+
+describe('the browser\'s own list', () => {
+ it('goes into an empty account, with the marker after it', async () => {
+ browser = list([tpl('aaaa-1', 'Hello'), tpl('aaaa-2', 'Thanks')]);
+ expect(await sync()).toBe('synced');
+ expect(sets.map((s) => s.key)).toEqual([ns('aaaa-1'), ns('aaaa-2'), MARKER]);
+ expect(server[MARKER]).toEqual({ version: 1, deleted: {} });
+ expect(names()).toEqual(['Hello', 'Thanks']);
+ expect(partition().markerOnServer).toBe(true);
+
+ // Each version is taken once: the next round writes nothing.
+ sets = [];
+ await sync();
+ expect(sets).toEqual([]);
+ });
+
+ it('doesn\'t bring back a template deleted in the account once its tombstone has expired', async () => {
+ browser = list([tpl('aaaa-1', 'Hello'), tpl('aaaa-2', 'Thanks')]);
+ await sync();
+ personalTemplatesFor('carol@mail').remove('aaaa-1');
+ await sync();
+ expect(server[ns('aaaa-1')]).toBeUndefined();
+ // 90 days later the tombstone is gone; the browser's list still has the old copy.
+ useAccountTemplatesStore.getState().updatePersonal('carol@mail', (p) => ({ ...p, deletedTemplateIds: {}, serverDeleted: {} }));
+ server[MARKER] = { version: 1, deleted: {} };
+ await sync();
+ expect(names()).toEqual(['Thanks']);
+ expect(server[ns('aaaa-1')]).toBeUndefined();
+ });
+
+ it('is merged into an account that holds templates already, so a second browser keeps both lists', async () => {
+ server = { [ns('bbbb-1')]: tpl('bbbb-1', 'From the other browser'), [MARKER]: { version: 1, deleted: {} } };
+ browser = list([tpl('aaaa-1', 'Only here')]);
+ await sync();
+ expect(names()).toEqual(['From the other browser', 'Only here']);
+ expect(sets.map((s) => s.key)).toEqual([ns('aaaa-1')]);
+ });
+
+ it('leaves out a template that a newer tombstone deleted', async () => {
+ server = { [MARKER]: { version: 1, deleted: { 'aaaa-1': ago(1000) } } };
+ browser = list([tpl('aaaa-1', 'Deleted elsewhere', ago(60_000)), tpl('aaaa-2', 'Kept', ago(60_000))]);
+ await sync();
+ expect(names()).toEqual(['Kept']);
+ expect(server[ns('aaaa-1')]).toBeUndefined();
+ });
+
+ it('is not used, and no marker written, while it isn\'t known whose it is', async () => {
+ browser = { status: 'unknown' };
+ useTemplateStore.setState({ templates: [tpl('aaaa-9', 'Someone else’s')] });
+ await sync();
+ expect(sets).toEqual([]);
+ expect(partition().templates).toEqual([]);
+ expect(partition().markerOnServer).toBe(false);
+ });
+
+ it('gives an id that doesn\'t fit a metadata key the same new id on every device', async () => {
+ const id = storableId('Demo_Template 1');
+ expect(id).toMatch(/^[0-9a-f]{28}$/);
+ expect(storableId('Demo_Template 1')).toBe(id);
+ expect(storableId('Demo_Template 2')).not.toBe(id);
+ browser = list([tpl('Demo_Template 1', 'Old id')]);
+ await sync();
+ expect(partition().templates[0].id).toBe(id);
+ expect(server[ns(id)]).toBeTruthy();
+ });
+
+ it('keeps a template over 8 KB and one the server has no room for in this browser, with why', async () => {
+ server = { 'org.bulwarkmail.other': { fill: 'y'.repeat(SHARED_MAX - 4000) } };
+ browser = list([tpl('aaaa-big', 'Big', undefined, { body: 'x'.repeat(9000) }), tpl('aaaa-mid', 'Mid', undefined, { body: 'z'.repeat(5000) }), tpl('aaaa-ok', 'Small')]);
+ await sync();
+ expect(partition().localOnly).toEqual({ 'aaaa-big': 'too-large', 'aaaa-mid': 'no-room' });
+ expect(names()).toEqual(['Big', 'Mid', 'Small']);
+ expect(server[ns('aaaa-ok')]).toBeTruthy();
+ expect(sets.some((s) => s.key === ns('aaaa-big'))).toBe(false);
+ });
+});
+
+describe('later rounds', () => {
+ beforeEach(async () => {
+ browser = list([tpl('aaaa-1', 'One'), tpl('aaaa-2', 'Two')]);
+ await sync();
+ sets = [];
+ });
+
+ it('take a newer server version and upload a newer local one', async () => {
+ server[ns('aaaa-1')] = tpl('aaaa-1', 'One, edited elsewhere', '2026-10-05T00:00:00.000Z');
+ edit((templates) => templates.map((t) => (t.id === 'aaaa-2' ? { ...t, name: 'Two, edited here', updatedAt: '2026-10-06T00:00:00.000Z' } : t)));
+ await sync();
+ expect(names()).toEqual(['One, edited elsewhere', 'Two, edited here']);
+ expect(sets.map((s) => s.key)).toEqual([ns('aaaa-2')]);
+ });
+
+ it('upload an entry that is missing without a tombstone, never take it as deleted', async () => {
+ delete server[ns('aaaa-1')];
+ await sync();
+ expect(names()).toEqual(['One', 'Two']);
+ expect(server[ns('aaaa-1')]).toBeTruthy();
+ });
+
+ it('delete through the tombstone: the marker first, then the entry', async () => {
+ personalTemplatesFor('carol@mail').remove('aaaa-2');
+ await sync();
+ expect(sets.map((s) => s.key)).toEqual([MARKER, ns('aaaa-2')]);
+ expect((server[MARKER] as { deleted: Record }).deleted['aaaa-2']).toBeTruthy();
+ expect(partition().serverDeleted['aaaa-2']).toBeTruthy();
+ });
+
+ it('drop a template another device deleted, also when the store changes during the round (review A)', async () => {
+ server[MARKER] = { version: 1, deleted: { 'aaaa-1': ago(0) } };
+ delete server[ns('aaaa-1')];
+ edit((templates) => templates.map((t) => (t.id === 'aaaa-2' ? { ...t, name: 'Two!', updatedAt: ago(500) } : t)));
+ onSet = () => {
+ // While the upload runs, a new template arrives (this tab or another one).
+ edit((templates) => [...templates, tpl('aaaa-3', 'Three', ago(100))]);
+ onSet = null;
+ };
+ await sync();
+ await sync();
+ expect(partition().templates.map((t) => t.id).sort()).toEqual(['aaaa-2', 'aaaa-3']);
+ expect(server[ns('aaaa-1')]).toBeUndefined();
+ expect(server[ns('aaaa-3')]).toBeTruthy();
+ });
+
+ it('keep what another tab wrote while the round ran', async () => {
+ onSet = () => {
+ // Another tab's write replaces the whole partition (storage rehydrate).
+ const p = partition();
+ useAccountTemplatesStore.setState({
+ personal: {
+ 'carol@mail': {
+ ...p,
+ templates: [...p.templates.filter((t) => t.id !== 'aaaa-1'), tpl('aaaa-4', 'From the other tab', ago(50))],
+ deletedTemplateIds: { 'aaaa-1': ago(10) },
+ },
+ },
+ });
+ onSet = null;
+ };
+ edit((templates) => templates.map((t) => (t.id === 'aaaa-2' ? { ...t, updatedAt: ago(1000) } : t)));
+ await sync();
+ expect(partition().templates.map((t) => t.id).sort()).toEqual(['aaaa-2', 'aaaa-4']);
+ expect(server[ns('aaaa-1')]).toBeUndefined();
+ expect(server[ns('aaaa-4')]).toBeTruthy();
+ });
+
+ it('keep the copy when an answer comes without the metadata property (review B)', async () => {
+ omitMetadata = true;
+ expect(await sync()).toBe('unsupported');
+ expect(names()).toEqual(['One', 'Two']);
+ });
+
+ it('keep an edit made on a device whose clock is behind (review C)', async () => {
+ server[ns('aaaa-1')] = tpl('aaaa-1', 'Edited on a fast clock', new Date(Date.now() + 3_600_000).toISOString());
+ await sync();
+ personalTemplatesFor('carol@mail').update('aaaa-1', { name: 'Edited here later' });
+ await sync();
+ expect(partition().templates.find((t) => t.id === 'aaaa-1')?.name).toBe('Edited here later');
+ expect((server[ns('aaaa-1')] as EmailTemplate).name).toBe('Edited here later');
+ });
+});
+
+describe('whose the browser\'s list is', () => {
+ beforeEach(asCarol);
+
+ it('is unknown until settings sync loaded the account\'s settings, whose templates then reach the account (review D)', async () => {
+ expect(browserListFor('carol@mail')).toEqual({ status: 'unknown' });
+ await syncAccountTemplates('carol@mail');
+ expect(server[MARKER]).toBeUndefined();
+ expect(partition().markerOnServer).toBe(false);
+
+ // The settings blob loads: its templates go into the account's copy too.
+ localStorage.setItem('settings-sync-owner', 'carol@mail');
+ useSettingsStore.getState().importSettings(JSON.stringify({ templates: [tpl('blob-1', 'Only in the blob')], deletedTemplateIds: {} }), { serverAccountId: 'carol@mail' });
+ expect(partition().templates.map((t) => t.id)).toEqual(['blob-1']);
+ await syncAccountTemplates('carol@mail');
+ expect(server[ns('blob-1')]).toBeTruthy();
+ expect(server[MARKER]).toEqual({ version: 1, deleted: {} });
+ });
+
+ it('is another account\'s while that account\'s settings are loaded', () => {
+ localStorage.setItem('settings-sync-owner', 'alice@mail');
+ expect(browserListFor('carol@mail')).toEqual({ status: 'unknown' });
+ expect(browserListFor('dave@mail')).toEqual({ status: 'none' });
+ });
+
+ it('goes into the first account only without settings sync, and others start empty', async () => {
+ config.value = { settingsSyncEnabled: false };
+ useTemplateStore.setState({ templates: [tpl('aaaa-1', 'Browser')] });
+ expect(browserListFor('carol@mail').status).toBe('list');
+ await syncAccountTemplates('carol@mail');
+ expect(useAccountTemplatesStore.getState().browserListOwner).toBe('carol@mail');
+ expect(names()).toEqual(['Browser']);
+ expect(browserListFor('dave@mail')).toEqual({ status: 'none' });
+ expect(browserListFor('carol@mail').status).toBe('list');
+ });
+});
+
+describe('servers without metadata', () => {
+ it('leave an account without the capability alone', async () => {
+ vi.restoreAllMocks();
+ await connect(false);
+ browser = list([tpl('aaaa-1', 'Local')]);
+ const fetchSpy = vi.mocked(globalThis.fetch);
+ fetchSpy.mockClear();
+ expect(await sync()).toBe('unsupported');
+ expect(fetchSpy).not.toHaveBeenCalled();
+ expect(partition()).toBeUndefined();
+ });
+
+ it('keep the app online when the server drops the connection on metadata (the 1.0 test build)', async () => {
+ drop = true;
+ browser = list([tpl('aaaa-1', 'Local')]);
+ expect(await sync()).toBe('unsupported');
+ expect(partition()).toBeUndefined();
+ expect(connection).not.toHaveBeenCalledWith(false);
+ const fetchSpy = vi.mocked(globalThis.fetch);
+ fetchSpy.mockClear();
+ expect(await sync()).toBe('unsupported');
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+});
+
+describe('signing out', () => {
+ beforeEach(async () => {
+ asCarol();
+ localStorage.setItem('settings-sync-owner', 'carol@mail');
+ useTemplateStore.setState({ templates: [tpl('aaaa-1', 'One'), tpl('aaaa-2', 'Two')] });
+ await syncAccountTemplates('carol@mail');
+ sets = [];
+ });
+
+ it('sends a deletion that is still queued before the account signs out', async () => {
+ personalTemplatesFor('carol@mail').remove('aaaa-1');
+ await runBeforeSignOut('carol@mail');
+ expect(sets.map((s) => s.key)).toEqual([MARKER, ns('aaaa-1')]);
+ expect(server[ns('aaaa-1')]).toBeUndefined();
+ });
+
+ it('keeps only unsent templates and deletions after a full sign-out', async () => {
+ drop = true;
+ personalTemplatesFor('carol@mail').remove('aaaa-1');
+ personalTemplatesFor('carol@mail').add({ name: 'Unsent', subject: '', body: '', category: '', isFavorite: false });
+ await flushAccountTemplates('carol@mail');
+ purgeSignedOutData();
+ const kept = useAccountTemplatesStore.getState().personal['carol@mail'];
+ expect(kept.templates.map((t) => t.name)).toEqual(['Unsent']);
+ expect(Object.keys(kept.deletedTemplateIds)).toEqual(['aaaa-1']);
+ });
+
+ it('keeps a deletion the server hasn\'t heard of, also with nothing else left to send', async () => {
+ drop = true;
+ personalTemplatesFor('carol@mail').remove('aaaa-1');
+ await flushAccountTemplates('carol@mail');
+ purgeSignedOutData();
+ const kept = useAccountTemplatesStore.getState().personal['carol@mail'];
+ expect(kept.templates).toEqual([]);
+ expect(Object.keys(kept.deletedTemplateIds)).toEqual(['aaaa-1']);
+ });
+
+ it('drops an account copy that the server holds in full', () => {
+ purgeSignedOutData();
+ expect(useAccountTemplatesStore.getState().personal['carol@mail']).toBeUndefined();
+ });
+});
+
+describe('tombstones', () => {
+ it('keep 90 days and at most 8 KB, the oldest go first', () => {
+ const deleted: Record = { old: ago(91 * 86_400_000) };
+ for (let i = 0; i < 400; i++) deleted[`id-${String(i).padStart(4, '0')}-${'x'.repeat(20)}`] = ago(i * 1000);
+ const kept = pruneTombstones(deleted);
+ expect(kept.old).toBeUndefined();
+ expect(new TextEncoder().encode(JSON.stringify({ version: 1, deleted: kept })).length).toBeLessThanOrEqual(8192);
+ expect(kept[`id-0000-${'x'.repeat(20)}`]).toBeDefined();
+ expect(kept[`id-0399-${'x'.repeat(20)}`]).toBeUndefined();
+ });
+
+ it('a deletion here wins over an older server copy, an edit there over an older deletion', () => {
+ const plan = planPersonalSync(
+ { templates: [], deletedTemplateIds: { a: ago(5000), b: ago(5000) } },
+ { a: tpl('a', 'A', ago(10_000)), b: tpl('b', 'B', ago(1000)) },
+ {},
+ );
+ expect(plan.deletes).toEqual(['a']);
+ expect(plan.templates.map((t) => t.id)).toEqual(['b']);
+ });
+});
diff --git a/lib/__tests__/auth-crypto.test.ts b/lib/__tests__/auth-crypto.test.ts
index 2fa3c854d..5d296c0e1 100644
--- a/lib/__tests__/auth-crypto.test.ts
+++ b/lib/__tests__/auth-crypto.test.ts
@@ -31,12 +31,13 @@ describe('encryptSession / decryptSession', () => {
serverUrl: 'https://mail.example.com',
username: 'alice',
password: 's3cret',
+ signedInAt: expect.any(Number),
});
});
it('produces a base64 token with a random IV (two encrypts differ, both decrypt equal)', () => {
- const a = encryptSession('https://x', 'u', 'p');
- const b = encryptSession('https://x', 'u', 'p');
+ const a = encryptSession('https://x', 'u', 'p', 1000);
+ const b = encryptSession('https://x', 'u', 'p', 1000);
expect(a).not.toBe(b);
expect(Buffer.from(a, 'base64').toString('base64')).toBe(a); // valid base64
expect(decryptSession(a)).toEqual(decryptSession(b));
diff --git a/lib/__tests__/auth-login-routes-budget.test.ts b/lib/__tests__/auth-login-routes-budget.test.ts
index 793cc52f5..1a228c07a 100644
--- a/lib/__tests__/auth-login-routes-budget.test.ts
+++ b/lib/__tests__/auth-login-routes-budget.test.ts
@@ -48,6 +48,7 @@ vi.mock('@/lib/stalwart/auth-context', () => ({
setStalwartAuthContext: vi.fn(),
setStalwartAuthContextInStore: vi.fn(),
clearStalwartAuthContextInStore: vi.fn(),
+ readStalwartAuthContextFromStore: () => null,
}));
vi.mock('@/lib/telemetry/login-tracker', () => ({ recordLogin: vi.fn() }));
diff --git a/lib/__tests__/auth-routes-credential-check.test.ts b/lib/__tests__/auth-routes-credential-check.test.ts
index 3c49cbe32..f286fc472 100644
--- a/lib/__tests__/auth-routes-credential-check.test.ts
+++ b/lib/__tests__/auth-routes-credential-check.test.ts
@@ -56,6 +56,7 @@ vi.mock('@/lib/stalwart/auth-context', () => ({
setStalwartAuthContext: (...args: unknown[]) => setStalwartAuthContext(...args),
setStalwartAuthContextInStore: (...args: unknown[]) => setStalwartAuthContextInStore(...args),
clearStalwartAuthContextInStore: vi.fn(),
+ readStalwartAuthContextFromStore: () => null,
}));
const recordLogin = vi.fn();
diff --git a/lib/__tests__/compose-flush-registry.test.ts b/lib/__tests__/compose-flush-registry.test.ts
new file mode 100644
index 000000000..c87cbad79
--- /dev/null
+++ b/lib/__tests__/compose-flush-registry.test.ts
@@ -0,0 +1,69 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ flushComposers,
+ registerComposeFlush,
+ resetComposeFlushRegistry,
+ waitForComposeFlush,
+} from '../compose-flush-registry';
+
+describe('compose flush registry', () => {
+ beforeEach(() => {
+ resetComposeFlushRegistry();
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('runs every registered flush and waits for them', async () => {
+ const order: string[] = [];
+ registerComposeFlush(async () => {
+ await new Promise((r) => setTimeout(r, 100));
+ order.push('a');
+ });
+ registerComposeFlush(async () => { order.push('b'); });
+
+ const done = flushComposers(5000);
+ await vi.advanceTimersByTimeAsync(100);
+ await expect(done).resolves.toEqual({ total: 2, settled: 2, failed: 0, timedOut: false });
+ expect(order.sort()).toEqual(['a', 'b']);
+ });
+
+ it('stops waiting for a hanging flush at the timeout', async () => {
+ const quick = vi.fn(async () => {});
+ registerComposeFlush(() => new Promise(() => {}));
+ registerComposeFlush(quick);
+
+ let result: Awaited> | null = null;
+ void flushComposers(3000).then((r) => { result = r; });
+ await vi.advanceTimersByTimeAsync(2999);
+ expect(result).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ expect(result).toEqual({ total: 2, settled: 1, failed: 0, timedOut: true });
+ expect(quick).toHaveBeenCalledTimes(1);
+ });
+
+ it('goes on when a flush fails', async () => {
+ registerComposeFlush(async () => { throw new Error('save refused'); });
+ await expect(flushComposers(1000)).resolves.toEqual({ total: 1, settled: 1, failed: 1, timedOut: false });
+ });
+
+ it('forgets a composer that unregistered', async () => {
+ const flush = vi.fn(async () => {});
+ const unregister = registerComposeFlush(flush);
+ unregister();
+ await expect(flushComposers(1000)).resolves.toEqual({ total: 0, settled: 0, failed: 0, timedOut: false });
+ expect(flush).not.toHaveBeenCalled();
+ });
+
+ it('hands the next registration to a waiting host, or null after the timeout', async () => {
+ const waiting = waitForComposeFlush(500);
+ const flush = vi.fn(async () => {});
+ registerComposeFlush(flush);
+ await expect(waiting).resolves.toBe(flush);
+
+ const nobody = waitForComposeFlush(500);
+ await vi.advanceTimersByTimeAsync(500);
+ await expect(nobody).resolves.toBeNull();
+ });
+});
diff --git a/lib/__tests__/external-mail.test.ts b/lib/__tests__/external-mail.test.ts
new file mode 100644
index 000000000..4f2328fca
--- /dev/null
+++ b/lib/__tests__/external-mail.test.ts
@@ -0,0 +1,247 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import {
+ addressDomain,
+ addressKey,
+ contactAddresses,
+ activeExternalMailContext,
+ contextInternalDomains,
+ externalMailContextFor,
+ domainMatches,
+ externalRecipients,
+ externalSenders,
+ internalDomains,
+ isExternalAddress,
+} from '../external-mail';
+import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
+import { DEFAULT_POLICY } from '@/lib/admin/types';
+import { usePolicyStore } from '@/stores/policy-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { useContactStore } from '@/stores/contact-store';
+import type { ContactCard, Identity } from '@/lib/jmap/types';
+
+const identity = (email: string) => ({ id: email, name: '', email }) as unknown as Identity;
+
+function setExternalMail(patch: Partial) {
+ usePolicyStore.setState({
+ policy: {
+ ...DEFAULT_POLICY,
+ organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } },
+ },
+ });
+}
+
+describe('addressDomain', () => {
+ it('takes the domain of bare and named addresses, lowercased', () => {
+ expect(addressDomain('Bob@Example.COM')).toBe('example.com');
+ expect(addressDomain('Bob ')).toBe('eu.example.com');
+ expect(addressDomain('no-at-sign')).toBe('');
+ expect(addressDomain(undefined)).toBe('');
+ });
+});
+
+describe('domainMatches', () => {
+ it('matches the domain itself and its subdomains, nothing else', () => {
+ expect(domainMatches('example.com', ['example.com'])).toBe(true);
+ expect(domainMatches('eu.example.com', ['example.com'])).toBe(true);
+ expect(domainMatches('a.b.example.com', ['example.com'])).toBe(true);
+ // A suffix that isn't a subdomain doesn't count.
+ expect(domainMatches('badexample.com', ['example.com'])).toBe(false);
+ expect(domainMatches('example.com.evil.net', ['example.com'])).toBe(false);
+ // A subdomain in the list doesn't cover its parent.
+ expect(domainMatches('example.com', ['eu.example.com'])).toBe(false);
+ });
+});
+
+describe('internalDomains', () => {
+ it('always counts the identity domains and adds the admin list', () => {
+ expect(internalDomains(['me@example.org'], [])).toEqual(['example.org']);
+ expect(internalDomains(['me@example.org', 'alias@Example.org'], ['partner.com'])).toEqual(['example.org', 'partner.com']);
+ });
+
+ it('keeps the identity domains when the admin list fills the cap', () => {
+ const full = Array.from({ length: 500 }, (_, i) => `partner${i}.example`);
+ const internal = internalDomains(['me@own.example'], full);
+ expect(internal).toHaveLength(500);
+ expect(internal[0]).toBe('own.example');
+ });
+});
+
+describe('IDN domains', () => {
+ it('compares Unicode and xn-- forms as the same domain, on both sides', () => {
+ // The admin typed it in Unicode, the server delivers xn--.
+ const internal = internalDomains([], ['bücher.de']);
+ expect(internal).toEqual(['xn--bcher-kva.de']);
+ expect(isExternalAddress('anna@xn--bcher-kva.de', internal)).toBe(false);
+ expect(isExternalAddress('anna@bücher.de', internal)).toBe(false);
+ expect(isExternalAddress('anna@eu.bücher.de', internal)).toBe(false);
+ expect(isExternalAddress('anna@buecher.de', internal)).toBe(true);
+ // An identity on an IDN domain counts in whichever form the address has.
+ expect(isExternalAddress('x@münchen.example', internalDomains(['me@xn--mnchen-3ya.example'], []))).toBe(false);
+ });
+
+ it('matches a contact written in Unicode against an xn-- recipient', () => {
+ expect(addressKey('Anna@Bücher.de')).toBe(addressKey('anna@xn--bcher-kva.de'));
+ });
+});
+
+describe('isExternalAddress', () => {
+ const internal = internalDomains(['me@example.org'], ['partner.com']);
+ it('is external only outside the identity domains and the list, subdomains included', () => {
+ expect(isExternalAddress('bob@example.org', internal)).toBe(false);
+ expect(isExternalAddress('x@eu.partner.com', internal)).toBe(false);
+ expect(isExternalAddress('dave@org-two.test', internal)).toBe(true);
+ });
+ it('calls nobody external when no internal domain is known', () => {
+ expect(isExternalAddress('dave@org-two.test', [])).toBe(false);
+ });
+});
+
+describe('externalRecipients', () => {
+ const internal = ['example.org'];
+ it('lists only the external recipients, each once, with the field it is in', () => {
+ const result = externalRecipients(
+ { to: ['bob@example.org', 'dave@org-two.test'], cc: ['DAVE@org-two.test', 'eve@other.net'], bcc: ['x@sub.example.org'] },
+ internal,
+ );
+ expect(result).toEqual([
+ { address: 'dave@org-two.test', domain: 'org-two.test', field: 'to' },
+ { address: 'eve@other.net', domain: 'other.net', field: 'cc' },
+ ]);
+ });
+ it('leaves out trusted addresses (contacts)', () => {
+ const trusted = new Set(['dave@org-two.test']);
+ const result = externalRecipients({ to: ['Dave@org-two.test', 'eve@other.net'], cc: [], bcc: [] }, internal, (a) => trusted.has(a));
+ expect(result.map((r) => r.address)).toEqual(['eve@other.net']);
+ });
+});
+
+describe('externalSenders', () => {
+ const internal = ['example.org'];
+ it('names the external senders and nothing for internal ones', () => {
+ expect(externalSenders([{ email: 'dave@org-two.test' }], internal)).toEqual([{ address: 'dave@org-two.test', domain: 'org-two.test' }]);
+ expect(externalSenders([{ email: 'bob@example.org' }], internal)).toEqual([]);
+ expect(externalSenders(undefined, internal)).toEqual([]);
+ });
+
+ it('counts a message as external when any of several From addresses is', () => {
+ expect(externalSenders([{ email: 'bob@example.org' }, { email: 'eve@evil.example' }], internal))
+ .toEqual([{ address: 'eve@evil.example', domain: 'evil.example' }]);
+ });
+
+ it('counts a From without a domain as external', () => {
+ expect(externalSenders([{ email: 'ceo' }], internal)).toEqual([{ address: 'ceo', domain: '' }]);
+ });
+
+ it('calls nobody external when no internal domain is known', () => {
+ expect(externalSenders([{ email: 'ceo' }, { email: 'dave@org-two.test' }], [])).toEqual([]);
+ });
+});
+
+describe('the active account', () => {
+ beforeEach(() => {
+ setExternalMail({});
+ useAuthStore.setState({ identities: [], activeAccountId: 'acc-alice' });
+ useContactStore.setState({ contacts: [], addressBooks: [], trustedSendersBookId: null });
+ });
+
+ it('reads the switches from the effective policy', () => {
+ expect(activeExternalMailContext().policy.senderLabel).toBe(false);
+ setExternalMail({ senderLabel: true });
+ expect(activeExternalMailContext().policy.senderLabel).toBe(true);
+ });
+
+ it('combines the policy domains with the identity domains', () => {
+ setExternalMail({ domains: ['partner.com'] });
+ useAuthStore.setState({ identities: [identity('org-alice@example.org')] });
+ expect(contextInternalDomains(activeExternalMailContext())).toEqual(['example.org', 'partner.com']);
+ });
+
+ it('resolves another signed-in account with its own scopes and identities', async () => {
+ // The raw policy: the label on for everyone, off for org-two.test.
+ usePolicyStore.setState({
+ rawPolicy: {
+ ...DEFAULT_POLICY,
+ organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, senderLabel: true } },
+ scopes: [{ id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: { organisation: { externalMail: { senderLabel: false } } } }],
+ },
+ });
+ setExternalMail({ senderLabel: true });
+ const daveClient = {
+ getUsername: () => 'org-dave@org-two.test',
+ getSharedAccounts: () => [],
+ getAccountInfo: () => null,
+ getPrincipalTypes: async () => ({}),
+ getIdentities: async () => [identity('org-dave@org-two.test')],
+ };
+ useAuthStore.setState({
+ identities: [identity('org-alice@example.org')],
+ getClientForAccount: (id: string) => (id === 'acc-dave' ? daveClient : undefined),
+ } as never);
+ const dave = await externalMailContextFor('acc-dave');
+ expect(dave.policy.senderLabel).toBe(false);
+ expect(dave.identityEmails).toEqual(['org-dave@org-two.test']);
+ // The active account, and an unknown one, read the stores.
+ expect((await externalMailContextFor('acc-alice')).identityEmails).toEqual(['org-alice@example.org']);
+ expect((await externalMailContextFor(undefined)).policy.senderLabel).toBe(true);
+ });
+
+ it("looks another account up only when the switch can be on for it", async () => {
+ const calls = { subject: 0, identities: 0 };
+ const eveClient = {
+ getUsername: () => { calls.subject++; return 'org-eve@org-three.test'; },
+ getSharedAccounts: () => [],
+ getAccountInfo: () => null,
+ getPrincipalTypes: async () => ({}),
+ getIdentities: async () => { calls.identities++; return [identity('org-eve@org-three.test')]; },
+ };
+ useAuthStore.setState({
+ identities: [identity('org-alice@example.org')],
+ getClientForAccount: (id: string) => (id === 'acc-eve' ? eveClient : undefined),
+ } as never);
+ const scope = (externalMail: Record) => ({ id: 's', label: 'S', match: { domains: ['org-three.test'] }, policy: { organisation: { externalMail } } });
+
+ // Off for Everyone and in every scope: no request at all.
+ usePolicyStore.setState({ rawPolicy: { ...DEFAULT_POLICY, scopes: [scope({ recipientWarning: true })] } });
+ expect((await externalMailContextFor('acc-eve', 'senderLabel')).policy.senderLabel).toBe(false);
+ expect(calls).toEqual({ subject: 0, identities: 0 });
+
+ // On in a scope that doesn't match eve: her subject is needed, her identities aren't.
+ usePolicyStore.setState({ rawPolicy: { ...DEFAULT_POLICY, scopes: [{ ...scope({ senderLabel: true }), match: { domains: ['elsewhere.test'] } }] } });
+ expect((await externalMailContextFor('acc-eve', 'senderLabel')).identityEmails).toEqual([]);
+ expect(calls).toEqual({ subject: 1, identities: 0 });
+
+ // On for her domain: her identities count.
+ usePolicyStore.setState({ rawPolicy: { ...DEFAULT_POLICY, scopes: [scope({ senderLabel: true })] } });
+ expect((await externalMailContextFor('acc-eve', 'senderLabel')).identityEmails).toEqual(['org-eve@org-three.test']);
+ expect(calls.identities).toBe(1);
+ });
+
+ it('collects the addresses of every contact', () => {
+ useContactStore.setState({
+ contacts: [
+ { id: 'c1', emails: { e1: { address: 'Dave@org-two.test' }, e2: { address: 'second@x.test' } } },
+ { id: 'c2' },
+ ] as unknown as ContactCard[],
+ });
+ expect([...contactAddresses()].sort()).toEqual(['dave@org-two.test', 'second@x.test']);
+ });
+
+ it('leaves out cards that are only in the Trusted Senders book', () => {
+ useContactStore.setState({
+ addressBooks: [
+ { id: 'book-main', name: 'Contacts' },
+ { id: 'acc1::book-trusted', name: 'Trusted Senders' },
+ ] as never,
+ trustedSendersBookId: 'book-trusted-raw',
+ contacts: [
+ // Only "always load images" for this sender: not a contact.
+ { id: 'c1', addressBookIds: { 'acc1::book-trusted': true }, emails: { e: { address: 'newsletter@shop.example' } } },
+ { id: 'c2', addressBookIds: { 'book-trusted-raw': true }, emails: { e: { address: 'promo@shop.example' } } },
+ // In a real book too: a contact.
+ { id: 'c3', addressBookIds: { 'book-main': true, 'acc1::book-trusted': true }, emails: { e: { address: 'dave@org-two.test' } } },
+ { id: 'c4', addressBookIds: { 'book-main': true }, emails: { e: { address: 'carol@partner.example' } } },
+ ] as unknown as ContactCard[],
+ });
+ expect([...contactAddresses()].sort()).toEqual(['carol@partner.example', 'dave@org-two.test']);
+ });
+});
diff --git a/lib/__tests__/favicon-route-inflight.test.ts b/lib/__tests__/favicon-route-inflight.test.ts
index d559581b3..88b79dc6e 100644
--- a/lib/__tests__/favicon-route-inflight.test.ts
+++ b/lib/__tests__/favicon-route-inflight.test.ts
@@ -30,8 +30,8 @@ describe('favicon route upstream concurrency', () => {
const inFlight = Array.from({ length: 16 }, (_, i) =>
GET(new NextRequest(`https://webmail.example/api/favicon?domain=site${i}.example`)),
);
- await new Promise((r) => setTimeout(r, 0));
- expect(fetchMock).toHaveBeenCalledTimes(16);
+ // The route reads the no-third-party switch from config first (#903).
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(16));
const over = await GET(new NextRequest('https://webmail.example/api/favicon?domain=site99.example'));
expect(fetchMock).toHaveBeenCalledTimes(16);
@@ -42,7 +42,7 @@ describe('favicon route upstream concurrency', () => {
for (const release of pending.splice(0)) release();
await Promise.all(inFlight);
const later = GET(new NextRequest('https://webmail.example/api/favicon?domain=site100.example'));
- expect(fetchMock).toHaveBeenCalledTimes(17);
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(17));
for (const release of pending.splice(0)) release();
expect((await later).status).toBe(200);
});
diff --git a/lib/__tests__/fixtures/config.ts b/lib/__tests__/fixtures/config.ts
index b2824f5d1..4dd018c3c 100644
--- a/lib/__tests__/fixtures/config.ts
+++ b/lib/__tests__/fixtures/config.ts
@@ -40,6 +40,7 @@ export function createConfig(overrides: Partial = {}): ConfigData {
embeddedMode: false,
parentOrigin: '',
sourceCodeUrl: '',
+ noThirdPartyTraffic: false,
...overrides,
};
}
diff --git a/lib/__tests__/fixtures/stalwart-1.0.0-template-metadata.json b/lib/__tests__/fixtures/stalwart-1.0.0-template-metadata.json
new file mode 100644
index 000000000..6df8f5ca0
--- /dev/null
+++ b/lib/__tests__/fixtures/stalwart-1.0.0-template-metadata.json
@@ -0,0 +1,181 @@
+{
+ "_comment": "Recorded from Stalwart v1.0.0 (stw-rel, http://10.10.10.62:8120) on 2026-10-28, org-carol@example.org own Inbox: Bulwark template metadata. Removed again after recording. setForbidden: org-carol writing to the Inbox org-alice shared with her read-only.",
+ "getEmpty": {
+ "methodResponses": [
+ [
+ "Mailbox/query",
+ {
+ "accountId": "bl",
+ "queryState": "saq",
+ "canCalculateChanges": true,
+ "position": 0,
+ "ids": [
+ "a"
+ ]
+ },
+ "q"
+ ],
+ [
+ "Mailbox/get",
+ {
+ "accountId": "bl",
+ "state": "saq",
+ "list": [
+ {
+ "id": "a",
+ "metadata": {}
+ }
+ ],
+ "notFound": []
+ },
+ "g"
+ ]
+ ],
+ "sessionState": "c499329"
+ },
+ "setOne": {
+ "methodResponses": [
+ [
+ "Mailbox/set",
+ {
+ "accountId": "bl",
+ "oldState": "saq",
+ "newState": "sau",
+ "updated": {
+ "a": null
+ }
+ },
+ "0"
+ ]
+ ],
+ "sessionState": "c499329"
+ },
+ "setMarker": {
+ "methodResponses": [
+ [
+ "Mailbox/set",
+ {
+ "accountId": "bl",
+ "oldState": "sau",
+ "newState": "say",
+ "updated": {
+ "a": null
+ }
+ },
+ "0"
+ ]
+ ],
+ "sessionState": "c499329"
+ },
+ "getOne": {
+ "methodResponses": [
+ [
+ "Mailbox/query",
+ {
+ "accountId": "bl",
+ "queryState": "say",
+ "canCalculateChanges": true,
+ "position": 0,
+ "ids": [
+ "a"
+ ]
+ },
+ "q"
+ ],
+ [
+ "Mailbox/get",
+ {
+ "accountId": "bl",
+ "state": "say",
+ "list": [
+ {
+ "id": "a",
+ "metadata": {
+ "org.bulwarkmail.template.rec-1": {
+ "id": "rec-1",
+ "name": "Recorded",
+ "subject": "Hi",
+ "body": "Body",
+ "isHTML": false,
+ "category": "",
+ "isFavorite": false,
+ "createdAt": "2026-10-28T10:00:00.000Z",
+ "updatedAt": "2026-10-28T10:00:00.000Z"
+ },
+ "org.bulwarkmail.templates": {
+ "v": 1
+ }
+ }
+ }
+ ],
+ "notFound": []
+ },
+ "g"
+ ]
+ ],
+ "sessionState": "c499329"
+ },
+ "setNoRoom": {
+ "methodResponses": [
+ [
+ "Mailbox/set",
+ {
+ "accountId": "bl",
+ "oldState": "sb2",
+ "newState": "sb2",
+ "notUpdated": {
+ "a": {
+ "type": "invalidProperties",
+ "description": "Metadata size 72076 exceeds the maximum of 65536 bytes.",
+ "properties": [
+ "metadata"
+ ]
+ }
+ }
+ },
+ "0"
+ ]
+ ],
+ "sessionState": "c499329"
+ },
+ "deleteOne": {
+ "methodResponses": [
+ [
+ "Mailbox/set",
+ {
+ "accountId": "bl",
+ "oldState": "sb2",
+ "newState": "sca",
+ "updated": {
+ "a": null
+ }
+ },
+ "0"
+ ]
+ ],
+ "sessionState": "c499329"
+ },
+ "setForbidden": {
+ "methodResponses": [
+ [
+ "Mailbox/set",
+ {
+ "accountId": "bj",
+ "oldState": "seu",
+ "newState": "seu",
+ "notUpdated": {
+ "a": {
+ "type": "forbidden",
+ "description": "Not allowed to modify metadata.",
+ "properties": [
+ "metadata"
+ ]
+ }
+ }
+ },
+ "0"
+ ]
+ ],
+ "sessionState": "e6894019"
+ }
+}
diff --git a/lib/__tests__/impersonation-session.test.ts b/lib/__tests__/impersonation-session.test.ts
index b70abd176..aa44a2171 100644
--- a/lib/__tests__/impersonation-session.test.ts
+++ b/lib/__tests__/impersonation-session.test.ts
@@ -110,7 +110,7 @@ describe('impersonation handoff', () => {
const put = await readSession();
expect(put.status).toBe(200);
const body = await put.json();
- expect(body).toEqual({ serverUrl: 'https://mail.example.org', username: 'alice@example.org', password: APP_SECRET });
+ expect(body).toEqual({ serverUrl: 'https://mail.example.org', username: 'alice@example.org', password: APP_SECRET, signedInAt: expect.any(Number) });
expect(JSON.stringify(body)).not.toContain(MASTER_PASSWORD);
expect([...jar.values()].join()).not.toContain(MASTER_PASSWORD);
});
diff --git a/lib/__tests__/instance-details-gates.test.ts b/lib/__tests__/instance-details-gates.test.ts
index 85b3b96ac..5e2d91c92 100644
--- a/lib/__tests__/instance-details-gates.test.ts
+++ b/lib/__tests__/instance-details-gates.test.ts
@@ -10,6 +10,7 @@ import { NextRequest } from 'next/server';
const gate = vi.hoisted(() => ({ signedIn: false }));
vi.mock('@/lib/auth/instance-details', () => ({
canSeeInstanceDetails: async () => gate.signedIn,
+ signedInIdentity: async () => null,
}));
const config = vi.hoisted(() => ({ values: {} as Record }));
@@ -121,6 +122,6 @@ describe('endpoints for visitors who are not signed in', () => {
gate.signedIn = true;
const full = await policy(req('/api/admin/policy'));
expect(full.headers.get('x-bulwark-policy-scope')).toBeNull();
- expect(await full.json()).toEqual(POLICY);
+ expect(await full.json()).toEqual({ ...POLICY, scopes: [] });
});
});
diff --git a/lib/__tests__/jmap-archived-items.test.ts b/lib/__tests__/jmap-archived-items.test.ts
new file mode 100644
index 000000000..794124849
--- /dev/null
+++ b/lib/__tests__/jmap-archived-items.test.ts
@@ -0,0 +1,127 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { JMAPClient } from '../jmap/client';
+import { jmapErrorType } from '../jmap/jmap-error';
+import { ACCOUNT, LICENSE_REFUSAL, archivedEmail, bigArchive, fakeArchiveServer } from '../recovery/__tests__/fixtures';
+
+type Call = [string, Record, string];
+type Body = { using: string[]; methodCalls: Call[] };
+
+function client(core: Record = {}, stalwart = true) {
+ const c = new JMAPClient('https://jmap.example.com', 'user', 'pass');
+ const accounts = {
+ [ACCOUNT]: {
+ name: 'org-alice@example.org', isPersonal: true,
+ accountCapabilities: stalwart ? { 'urn:ietf:params:jmap:mail': {}, 'urn:stalwart:jmap': {} } : { 'urn:ietf:params:jmap:mail': {} },
+ },
+ };
+ Object.assign(c, {
+ apiUrl: 'https://jmap.example.com/api',
+ accountId: ACCOUNT,
+ capabilities: { 'urn:ietf:params:jmap:core': { maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500, ...core } },
+ accounts,
+ session: { accounts },
+ });
+ return c;
+}
+
+function respondWith(handler: (body: Body) => unknown) {
+ return vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
+ const body = JSON.parse((init as RequestInit).body as string) as Body;
+ const response = handler(body);
+ return { ok: true, status: 200, text: async () => JSON.stringify(response), json: async () => response } as Response;
+ });
+}
+
+const bodies = (spy: ReturnType) => spy.mock.calls.map(([, init]) => JSON.parse((init as RequestInit).body as string) as Body);
+const queries = (spy: ReturnType) => bodies(spy).flatMap((b) => b.methodCalls).filter(([m]) => m === 'x:ArchivedItem/query');
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('JMAPClient archived items', () => {
+ it('advertises them only on Stalwart', () => {
+ expect(client().advertisesArchivedItems()).toBe(true);
+ expect(client({}, false).advertisesArchivedItems()).toBe(false);
+ });
+
+ it('asks the account filter first and, once refused, never again for that account', async () => {
+ const server = fakeArchiveServer([archivedEmail('a')]);
+ const spy = respondWith(server.answer);
+ const c = client();
+ await expect(c.queryArchivedItemIds(undefined, { limit: 1 })).resolves.toEqual({ ids: ['a'], total: null });
+ await c.queryArchivedItemIds(undefined, { limit: 1 });
+ expect(queries(spy).map(([, args]) => args)).toEqual([
+ { accountId: ACCOUNT, limit: 1, filter: { accountId: ACCOUNT } },
+ { accountId: ACCOUNT, limit: 1 },
+ { accountId: ACCOUNT, limit: 1 },
+ ]);
+ expect(bodies(spy)[0].using).toEqual(['urn:ietf:params:jmap:core', 'urn:stalwart:jmap']);
+ });
+
+ it("keeps an admin's answer to the account through the filter, and drops other accounts' items anyway", async () => {
+ const server = fakeArchiveServer([
+ archivedEmail('i3'),
+ archivedEmail('i2', { accountId: 'cz' }),
+ archivedEmail('i1', { accountId: undefined }),
+ ], { impersonate: true });
+ const spy = respondWith(server.answer);
+ const result = await client().listArchivedEmails(undefined, { days: 0 });
+ expect(result.items.map((i) => i.id)).toEqual(['i3']);
+ expect(queries(spy).every(([, args]) => (args.filter as { accountId?: string })?.accountId === ACCOUNT)).toBe(true);
+ });
+
+ it('drops the other accounts in an unfiltered mixed answer', async () => {
+ // A server that ignores the filter and answers with every account.
+ respondWith((body) => ({
+ methodResponses: body.methodCalls.map(([method, , id]) => method === 'x:ArchivedItem/query'
+ ? [method, { ids: ['a', 'z', 'n'], total: 3 }, id]
+ : [method, { list: [archivedEmail('a'), archivedEmail('z', { accountId: 'cz' }), archivedEmail('n', { accountId: undefined })] }, id]),
+ }));
+ const result = await client().listArchivedEmails(undefined, { days: 0 });
+ expect(result.items.map((i) => i.id)).toEqual(['a']);
+ });
+
+ it('throws the refusal with its type when the server has no license', async () => {
+ respondWith(() => ({ methodResponses: [['error', LICENSE_REFUSAL, 'q']] }));
+ const error = await client().queryArchivedItemIds().catch((e: unknown) => e);
+ expect(jmapErrorType(error)).toBe('forbidden');
+ });
+
+ it('pages with calculateTotal, so the newest of more than 5000 items comes first', async () => {
+ const now = Date.now();
+ const server = fakeArchiveServer(bigArchive(6000, now));
+ const spy = respondWith(server.answer);
+ const first = await client().listArchivedEmails(undefined, { days: 0 });
+ expect(first.items[0].id).toBe('i006000');
+ expect(first.nextPosition).toBe(2000);
+ const pages = queries(spy).filter(([, args]) => args.calculateTotal && !args.filter);
+ expect(pages.map(([, args]) => [args.position ?? 0, args.limit])).toEqual([[0, 500], [500, 500], [1000, 500], [1500, 500]]);
+ const gets = bodies(spy).flatMap((b) => b.methodCalls).filter(([m]) => m === 'x:ArchivedItem/get');
+ expect(gets.every(([, args]) => (args.ids as string[]).length <= 500)).toBe(true);
+ const older = await client().listArchivedEmails(undefined, { days: 0, startPosition: 2000 });
+ expect(older.items[0].id).toBe('i004000');
+ });
+
+ it('checks for the Trash link with one-item pages and stops at the first message', async () => {
+ const now = Date.now();
+ const server = fakeArchiveServer(bigArchive(30, now));
+ const spy = respondWith(server.answer);
+ await expect(client().hasArchivedEmail(undefined, { days: 7 })).resolves.toBe(true);
+ expect(queries(spy).filter(([, args]) => args.calculateTotal && !args.filter).map(([, args]) => args.limit)).toEqual([1]);
+ });
+
+ it('restores with requestRestore and reports notFound and failures per item', async () => {
+ const server = fakeArchiveServer([archivedEmail('a')]);
+ const spy = respondWith(server.answer);
+ const results = await client().restoreArchivedItems(['a', 'b']);
+ expect(results).toEqual([{ id: 'a', ok: true }, { id: 'b', ok: false, error: 'notFound', type: 'notFound' }]);
+ expect(bodies(spy)[0].methodCalls).toEqual([
+ ['x:ArchivedItem/set', { accountId: ACCOUNT, update: { a: { status: 'requestRestore' }, b: { status: 'requestRestore' } } }, 'r0'],
+ ]);
+ });
+
+ it('fails every item of a request that fails instead of throwing', async () => {
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline'));
+ const results = await client().restoreArchivedItems(['a']);
+ expect(results).toEqual([{ id: 'a', ok: false, error: expect.any(String) }]);
+ });
+});
diff --git a/lib/__tests__/jmap-bulwark-metadata.test.ts b/lib/__tests__/jmap-bulwark-metadata.test.ts
new file mode 100644
index 000000000..0a86948bc
--- /dev/null
+++ b/lib/__tests__/jmap-bulwark-metadata.test.ts
@@ -0,0 +1,162 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { JMAPClient } from '../jmap/client';
+import { MetadataRefusedError, MetadataTooLargeError, MetadataUnsupportedError, bulwarkEntries, isValidMetadataKey } from '../jmap/bulwark-metadata';
+import recorded from './fixtures/stalwart-1.0.0-template-metadata.json';
+
+// Bulwark keeps shared tags and templates in the Inbox metadata of an
+// account, one namespace per item (each namespace holds at most 8 KB on
+// Stalwart v1.0.0). Servers without metadata, and the pre-release 1.0 build
+// that drops the connection on these calls, count as "not supported".
+
+const METADATA_CAP = {
+ 'urn:ietf:params:jmap:metadata': {
+ dataTypes: { Mailbox: { namespaces: [], supportsVendorNamespaces: true, supportsPrivate: true, maxDepth: 8 } },
+ },
+};
+
+function session() {
+ return {
+ capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:metadata': {} },
+ accounts: {
+ me: { name: 'me@example.org', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...METADATA_CAP } },
+ team: { name: 'team@example.org', isPersonal: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...METADATA_CAP } },
+ old: { name: 'old@example.org', isPersonal: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } },
+ },
+ primaryAccounts: { 'urn:ietf:params:jmap:mail': 'me' },
+ apiUrl: 'https://mail.example.com/jmap/api',
+ downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
+ uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
+ eventSourceUrl: '',
+ };
+}
+
+const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
+
+let fetchSpy: ReturnType;
+let client: JMAPClient;
+let requests: Array<{ using: string[]; methodCalls: Array<[string, Record, string]> }>;
+let answer: (body: (typeof requests)[number]) => Response | Promise;
+
+beforeEach(async () => {
+ vi.useRealTimers();
+ fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockResolvedValueOnce(json(session()));
+ client = new JMAPClient('https://mail.example.com', 'me@example.org', 'pw');
+ await client.connect();
+ fetchSpy.mockReset();
+ requests = [];
+ fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
+ const body = JSON.parse(String(init?.body ?? '{}'));
+ requests.push(body);
+ return answer(body);
+ });
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+});
+
+afterEach(() => vi.restoreAllMocks());
+
+const inboxAnswer = (metadata: unknown, property = 'metadata') => json({
+ methodResponses: [
+ ['Mailbox/query', { ids: ['inbox-1'] }, 'q'],
+ ['Mailbox/get', { list: [{ id: 'inbox-1', [property]: metadata }] }, 'g'],
+ ],
+});
+
+describe('getBulwarkMetadata', () => {
+ it('reads Bulwark\'s namespaces of the Inbox in a request of its own', async () => {
+ answer = () => inboxAnswer({ 'org.bulwarkmail.tags': [{ id: 'urgent' }], 'org.bulwarkmail.template.a': { name: 'A' }, 'other.example': 1 });
+ expect(await client.getBulwarkMetadata('team')).toEqual({ tags: [{ id: 'urgent' }], 'template.a': { name: 'A' } });
+ expect(requests).toHaveLength(1);
+ expect(requests[0].using).toContain('urn:ietf:params:jmap:metadata');
+ expect(requests[0].methodCalls.map((c) => c[0])).toEqual(['Mailbox/query', 'Mailbox/get']);
+ expect(requests[0].methodCalls[1][1].properties).toEqual(['id', 'metadata']);
+ });
+
+ it('reads private metadata when asked', async () => {
+ answer = () => inboxAnswer({ 'org.bulwarkmail.templates': { x: 1 } }, 'privateMetadata');
+ expect(await client.getBulwarkMetadata('me', { private: true })).toEqual({ templates: { x: 1 } });
+ expect(requests[0].methodCalls[1][1].properties).toEqual(['id', 'privateMetadata']);
+ });
+
+ it('asks nothing of an account without the capability', async () => {
+ answer = () => { throw new Error('should not be asked'); };
+ expect(client.supportsBulwarkMetadata('old')).toBe(false);
+ expect(await client.getBulwarkMetadata('old')).toBeNull();
+ expect(requests).toHaveLength(0);
+ });
+
+ it('treats a dropped connection as unsupported and leaves the account alone afterwards', async () => {
+ answer = () => { throw new TypeError('Failed to fetch'); };
+ expect(await client.getBulwarkMetadata('team')).toBeNull();
+ const asked = requests.length;
+ expect(client.supportsBulwarkMetadata('team')).toBe(false);
+ expect(await client.getBulwarkMetadata('team')).toBeNull();
+ expect(requests).toHaveLength(asked);
+ // Other accounts are not affected.
+ expect(client.supportsBulwarkMetadata('me')).toBe(true);
+ });
+});
+
+describe('setBulwarkMetadata', () => {
+ it('patches one namespace of the Inbox and nothing else', async () => {
+ answer = (body) => body.methodCalls[0][0] === 'Mailbox/set'
+ ? json({ methodResponses: [['Mailbox/set', { updated: { 'inbox-1': null } }, '0']] })
+ : inboxAnswer({});
+ await client.setBulwarkMetadata('team', 'tags', [{ id: 'urgent', color: '#f00' }]);
+ const set = requests.find((r) => r.methodCalls[0][0] === 'Mailbox/set')!;
+ expect(set.methodCalls[0][1]).toEqual({
+ accountId: 'team',
+ update: { 'inbox-1': { 'metadata/org.bulwarkmail.tags': [{ id: 'urgent', color: '#f00' }] } },
+ });
+ });
+
+ it('refuses an entry over 8 KB before asking, and passes on the server\'s size refusal', async () => {
+ answer = (body) => body.methodCalls[0][0] === 'Mailbox/set'
+ ? json({ methodResponses: [['Mailbox/set', { notUpdated: { 'inbox-1': { type: 'invalidProperties', description: 'Metadata size 70413 exceeds the maximum of 65536 bytes.' } } }, '0']] })
+ : inboxAnswer({});
+ await expect(client.setBulwarkMetadata('team', 'template.big', { body: 'x'.repeat(9000) })).rejects.toBeInstanceOf(MetadataTooLargeError);
+ expect(requests.some((r) => r.methodCalls[0][0] === 'Mailbox/set')).toBe(false);
+ await expect(client.setBulwarkMetadata('team', 'template.ok', { body: 'small' })).rejects.toBeInstanceOf(MetadataTooLargeError);
+ });
+
+ it('says unsupported for an account without metadata', async () => {
+ await expect(client.setBulwarkMetadata('old', 'tags', [])).rejects.toBeInstanceOf(MetadataUnsupportedError);
+ });
+
+ it('passes on the type of Stalwart\'s refusal (recorded on v1.0.0)', async () => {
+ answer = (body) => body.methodCalls[0][0] === 'Mailbox/set'
+ ? json({ methodResponses: [['Mailbox/set', { ...(recorded.setForbidden.methodResponses[0][1] as object), notUpdated: { 'inbox-1': (recorded.setForbidden.methodResponses[0][1] as { notUpdated: { a: unknown } }).notUpdated.a } }, '0']] })
+ : inboxAnswer({});
+ const error = await client.setBulwarkMetadata('team', 'template.x', { name: 'x' }).catch((e) => e);
+ expect(error).toBeInstanceOf(MetadataRefusedError);
+ expect(error.type).toBe('forbidden');
+ expect(error.message).toBe('Not allowed to modify metadata.');
+ });
+});
+
+describe('an answer without the metadata property', () => {
+ it('means unsupported, never "every entry was removed"', async () => {
+ answer = () => json({
+ methodResponses: [
+ ['Mailbox/query', { ids: ['inbox-1'] }, 'q'],
+ ['Mailbox/get', { list: [{ id: 'inbox-1' }] }, 'g'],
+ ],
+ });
+ expect(await client.getBulwarkMetadata('team')).toBeNull();
+ expect(client.supportsBulwarkMetadata('team')).toBe(false);
+ });
+});
+
+describe('helpers', () => {
+ it('accepts lowercase dotted keys only', () => {
+ expect(isValidMetadataKey('template.abc-1')).toBe(true);
+ expect(isValidMetadataKey('Tags')).toBe(false);
+ expect(isValidMetadataKey('a/b')).toBe(false);
+ expect(isValidMetadataKey('.x')).toBe(false);
+ });
+
+ it('ignores other vendors and non-objects', () => {
+ expect(bulwarkEntries(null)).toEqual({});
+ expect(bulwarkEntries({ 'org.bulwarkmail': 1, 'org.bulwarkmailx.a': 2, 'org.bulwarkmail.a': 3 })).toEqual({ a: 3 });
+ });
+});
diff --git a/lib/__tests__/jmap-metadata-requests.test.ts b/lib/__tests__/jmap-metadata-requests.test.ts
index 70aefc8a1..19168960a 100644
--- a/lib/__tests__/jmap-metadata-requests.test.ts
+++ b/lib/__tests__/jmap-metadata-requests.test.ts
@@ -5,12 +5,15 @@ import { JMAPClient } from '../jmap/client';
/**
* Stalwart 1.0 advertises JMAP Metadata (`urn:ietf:params:jmap:metadata`) by
- * default, but every handler behind it is still `todo!()`. Selecting
- * `metadata` / `privateMetadata` (or a `metadata/...` pointer) in a get, or
- * sending `ignoreMetadataOnlyChanges` to a /changes method, panics the request
- * on the server. 0.16 knows none of it. So Bulwark must never send any of it,
- * and the gets whose server default could grow to include metadata ask for an
- * explicit property list instead.
+ * default. Pre-release 1.0 builds left every handler behind it `todo!()`:
+ * selecting `metadata` / `privateMetadata` (or a `metadata/...` pointer) in a
+ * get, or sending `ignoreMetadataOnlyChanges` to a /changes method, panics the
+ * request there. The v1.0.0 release implements it; 0.16 knows none of it. So
+ * ordinary requests never send any of it, and the gets whose server default
+ * could grow to include metadata ask for an explicit property list instead.
+ * Only the Bulwark metadata adapter (lib/jmap/bulwark-metadata.ts and the
+ * client's *BulwarkMetadata methods) reads and writes it, in requests of its
+ * own and only after the server has answered one (organisation features).
*/
const METADATA_CAPABILITY = 'urn:ietf:params:jmap:metadata';
@@ -214,12 +217,40 @@ describe('JMAP Metadata in the request-building sources', () => {
/(['"`])metadata(\/[^'"`\n]*)?\1/,
];
- it('never names a JMAP Metadata property, argument or capability in code', () => {
- const files = SOURCE_DIRS.flatMap(sourceFiles);
+ // The metadata adapter is the one place allowed to name Metadata: its own
+ // module, and the bodies of the client's *BulwarkMetadata methods.
+ const ADAPTER_FILES = new Set(['lib/jmap/bulwark-metadata.ts']);
+ const ADAPTER_METHOD = /^\s+(?:async\s+)?\w*BulwarkMetadata\s*\(/;
+
+ function withoutAdapterMethods(source: string): string {
+ const lines = source.split('\n');
+ const kept: string[] = [];
+ for (let i = 0; i < lines.length; i++) {
+ if (!ADAPTER_METHOD.test(lines[i])) {
+ kept.push(lines[i]);
+ continue;
+ }
+ // Skip to the line that closes the method's body.
+ let depth = 0;
+ let opened = false;
+ for (; i < lines.length; i++) {
+ for (const ch of lines[i]) {
+ if (ch === '{') { depth++; opened = true; }
+ if (ch === '}') depth--;
+ }
+ if (opened && depth <= 0) break;
+ }
+ }
+ return kept.join('\n');
+ }
+
+ it('never names a JMAP Metadata property, argument or capability in code outside the adapter', () => {
+ const files = SOURCE_DIRS.flatMap(sourceFiles).filter((file) => !ADAPTER_FILES.has(file));
expect(files).toContain('lib/jmap/client.ts');
const hits: string[] = [];
for (const file of files) {
- const code = withoutComments(readFileSync(path.join(root, file), 'utf8'));
+ let code = withoutComments(readFileSync(path.join(root, file), 'utf8'));
+ if (file === 'lib/jmap/client.ts') code = withoutAdapterMethods(code);
for (const pattern of FORBIDDEN) {
const match = code.match(pattern);
if (match) hits.push(`${file}: ${match[0]}`);
diff --git a/lib/__tests__/jmap-share-batch.test.ts b/lib/__tests__/jmap-share-batch.test.ts
new file mode 100644
index 000000000..fed63239f
--- /dev/null
+++ b/lib/__tests__/jmap-share-batch.test.ts
@@ -0,0 +1,244 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { JMAPClient } from '../jmap/client';
+import { buildShareSetCalls, parseShareTargets, readShareSetResults, type ShareChange } from '../jmap/share-batch';
+import { CALENDAR_PRESETS, FILE_PRESETS, MAILBOX_PRESETS } from '../jmap/share-rights';
+
+const change = (kind: ShareChange['kind'], id: string, rights: ShareChange['rights'], principalId = 'ck', accountId = 'ci'): ShareChange =>
+ ({ kind, accountId, id, name: id, principalId, rights });
+
+describe('buildShareSetCalls', () => {
+ it('puts each type in one call with a shareWith patch per item', () => {
+ const { calls } = buildShareSetCalls([
+ change('mailbox', 'a', MAILBOX_PRESETS.read),
+ change('mailbox', 'b', MAILBOX_PRESETS.read),
+ change('calendar', 'c2', CALENDAR_PRESETS.read),
+ change('file', 'n1', null),
+ ], { maxObjectsInSet: 500 });
+ expect(calls).toEqual([
+ ['Mailbox/set', { accountId: 'ci', update: { a: { 'shareWith/ck': MAILBOX_PRESETS.read }, b: { 'shareWith/ck': MAILBOX_PRESETS.read } } }, 's0'],
+ ['Calendar/set', { accountId: 'ci', update: { c2: { 'shareWith/ck': CALENDAR_PRESETS.read } } }, 's1'],
+ ['FileNode/set', { accountId: 'ci', update: { n1: { 'shareWith/ck': null } } }, 's2'],
+ ]);
+ });
+
+ it('splits a type past maxObjectsInSet', () => {
+ const changes = ['a', 'b', 'c'].map((id) => change('mailbox', id, null));
+ const { calls, changesByCall } = buildShareSetCalls(changes, { maxObjectsInSet: 2 });
+ expect(calls.map(([, args]) => Object.keys(args.update as object))).toEqual([['a', 'b'], ['c']]);
+ expect(changesByCall.get('s1')!.map((c) => c.id)).toEqual(['c']);
+ });
+
+ it('converts rights through wireRights, but never a revoke', () => {
+ const { calls } = buildShareSetCalls([change('file', 'n1', FILE_PRESETS.read), change('file', 'n2', null)], {
+ maxObjectsInSet: 500,
+ wireRights: () => ({ mayRead: true, mayWrite: false, mayShare: false }),
+ });
+ expect(calls[0][1].update).toEqual({ n1: { 'shareWith/ck': { mayRead: true, mayWrite: false, mayShare: false } }, n2: { 'shareWith/ck': null } });
+ });
+});
+
+describe('readShareSetResults', () => {
+ it('reports each item: updated, refused with the reason, or the whole call failing', () => {
+ const changes = [
+ change('mailbox', 'a', MAILBOX_PRESETS.read),
+ change('mailbox', 'b', MAILBOX_PRESETS.read),
+ change('mailbox', 'z', MAILBOX_PRESETS.read),
+ change('calendar', 'c2', CALENDAR_PRESETS.read),
+ ];
+ const { calls, changesByCall } = buildShareSetCalls(changes, { maxObjectsInSet: 500 });
+ const results = readShareSetResults(calls, changesByCall, [
+ ['Mailbox/set', { updated: { a: null }, notUpdated: { b: { type: 'forbidden', description: 'You may not share this folder' } } }, 's0'],
+ ['error', { type: 'forbidden' }, 's1'],
+ ]);
+ expect(results.map((r) => [r.change.id, r.ok, r.error])).toEqual([
+ ['a', true, undefined],
+ ['b', false, 'You may not share this folder'],
+ ['z', false, undefined],
+ ['c2', false, 'forbidden'],
+ ]);
+ });
+});
+
+describe('parseShareTargets', () => {
+ it('reads every type and names the ones whose call failed', () => {
+ const { targets, failed } = parseShareTargets([
+ ['Mailbox/get', { list: [{ id: 'a', name: 'Inbox', role: 'inbox', parentId: null, shareWith: { ck: MAILBOX_PRESETS.read } }] }, 'm'],
+ ['error', { type: 'serverFail' }, 'c'],
+ ['AddressBook/get', { list: [{ id: 'ab', name: 'Contacts', isDefault: true, shareWith: null }] }, 'a'],
+ ], { mailbox: 'ci', calendar: 'ci', addressBook: 'ci' });
+ expect(targets).toEqual([
+ { kind: 'mailbox', accountId: 'ci', id: 'a', name: 'Inbox', role: 'inbox', parentId: null, shareWith: { ck: MAILBOX_PRESETS.read } },
+ { kind: 'addressBook', accountId: 'ci', id: 'ab', name: 'Contacts', isDefault: true, shareWith: {} },
+ ]);
+ expect(failed).toEqual(['calendar']);
+ });
+});
+
+describe('JMAPClient.applyShareChanges', () => {
+ afterEach(() => vi.restoreAllMocks());
+
+ function client(fileCapability: Record = { forbiddenNameChars: '/' }) {
+ const c = new JMAPClient('https://jmap.example.com', 'user', 'pass');
+ Object.assign(c, {
+ apiUrl: 'https://jmap.example.com/api',
+ accountId: 'ci',
+ capabilities: {
+ 'urn:ietf:params:jmap:core': { maxCallsInRequest: 2, maxObjectsInSet: 500 },
+ 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:calendars': {}, 'urn:ietf:params:jmap:filenode': {},
+ },
+ });
+ const accounts = { ci: { name: 'org-alice@example.org', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:filenode': fileCapability } } };
+ Object.assign(c, { accounts, session: { accounts } });
+ return c;
+ }
+
+ function respondWith(handler: (body: { using: string[]; methodCalls: [string, Record, string][] }) => unknown) {
+ return vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
+ const body = JSON.parse((init as RequestInit).body as string);
+ const response = handler(body);
+ return { ok: true, status: 200, text: async () => JSON.stringify(response), json: async () => response } as Response;
+ });
+ }
+
+ it('sends one request per maxCallsInRequest calls and reports every item', async () => {
+ const spy = respondWith((body) => ({
+ methodResponses: body.methodCalls.map(([method, args, id]) => [method, { updated: Object.fromEntries(Object.keys(args.update as object).map((k) => [k, null])) }, id]),
+ }));
+ const results = await client().applyShareChanges([
+ change('mailbox', 'a', MAILBOX_PRESETS.read),
+ change('calendar', 'c2', CALENDAR_PRESETS.read),
+ change('file', 'n1', null),
+ ]);
+ expect(spy).toHaveBeenCalledTimes(2);
+ const first = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string);
+ expect(first.using).toEqual(expect.arrayContaining(['urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:mail:share', 'urn:ietf:params:jmap:calendars']));
+ expect(results.map((r) => [r.change.id, r.ok])).toEqual([['a', true], ['c2', true], ['n1', true]]);
+ });
+
+ it('marks the items of a failed request failed instead of throwing', async () => {
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline'));
+ const results = await client().applyShareChanges([change('mailbox', 'a', MAILBOX_PRESETS.read)]);
+ expect(results).toEqual([{ change: expect.objectContaining({ id: 'a' }), ok: false, error: expect.any(String) }]);
+ });
+
+ it('sends the old file rights to a server before 0.16.6', async () => {
+ const spy = respondWith((body) => ({ methodResponses: [['FileNode/set', { updated: { n1: null } }, body.methodCalls[0][2]]] }));
+ await client({}).applyShareChanges([change('file', 'n1', FILE_PRESETS.readWrite)]);
+ const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string);
+ expect(body.methodCalls[0][1].update).toEqual({ n1: { 'shareWith/ck': { mayRead: true, mayWrite: true, mayShare: false } } });
+ });
+});
+
+describe('JMAPClient.getShareTargets', () => {
+ afterEach(() => vi.restoreAllMocks());
+
+ type Call = [string, Record, string];
+ function client(answer: (calls: Call[]) => unknown[]) {
+ const c = new JMAPClient('https://jmap.example.com', 'user', 'pass');
+ const accounts = {
+ ci: {
+ name: 'org-alice@example.org', isPersonal: true,
+ accountCapabilities: {
+ 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:mail:share': {}, 'urn:ietf:params:jmap:calendars': {},
+ 'urn:ietf:params:jmap:contacts': {}, 'urn:ietf:params:jmap:filenode': { forbiddenNameChars: '/' },
+ },
+ },
+ };
+ Object.assign(c, {
+ apiUrl: 'https://jmap.example.com/api', accountId: 'ci', accounts,
+ capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {} },
+ session: { accounts, primaryAccounts: {} },
+ });
+ const request = vi.spyOn(c as unknown as { request: (calls: Call[]) => Promise }, 'request')
+ .mockImplementation(async (calls: Call[]) => ({ methodResponses: answer(calls) }));
+ return { c, request };
+ }
+ const lists = (calls: Call[]) => calls.map(([method, , id]) => [method, {
+ list: method === 'Mailbox/get' ? [{ id: 'a', name: 'Inbox', role: 'inbox', shareWith: { ck: MAILBOX_PRESETS.read } }]
+ : method === 'Calendar/get' ? [{ id: 'b', name: 'Calendar', isDefault: true, shareWith: {} }]
+ : [{ id: 'c', name: 'Contacts', isDefault: true, shareWith: {} }],
+ }, id]);
+
+ it('reads folders, calendars and address books in one request, and only the shared files', async () => {
+ const { c, request } = client(lists);
+ vi.spyOn(c, 'listAllFileNodes').mockResolvedValue([
+ { id: 'n1', name: 'plan.pdf', parentId: null, shareWith: { ck: FILE_PRESETS.read } },
+ { id: 'n2', name: 'notes.txt', parentId: null, shareWith: {} },
+ ] as never);
+ const { targets, failed } = await c.getShareTargets();
+ expect(request).toHaveBeenCalledTimes(1);
+ expect(request.mock.calls[0][0].map(([m]) => m)).toEqual(['Mailbox/get', 'Calendar/get', 'AddressBook/get']);
+ expect(targets.map((t) => `${t.kind}:${t.id}`)).toEqual(['mailbox:a', 'calendar:b', 'addressBook:c', 'file:n1']);
+ expect(failed).toEqual([]);
+ });
+
+ it('reads the file tree alongside the rest, not after it', async () => {
+ let releaseCollections: () => void = () => {};
+ const collectionsHeld = new Promise((resolve) => { releaseCollections = resolve; });
+ const { c, request } = client(lists);
+ request.mockImplementation(async (calls: Call[]) => { await collectionsHeld; return { methodResponses: lists(calls) }; });
+ const files = vi.spyOn(c, 'listAllFileNodes').mockResolvedValue([]);
+ const pending = c.getShareTargets();
+ // The collections haven't answered yet, and the file read has already started.
+ await Promise.resolve();
+ expect(files).toHaveBeenCalled();
+ releaseCollections();
+ expect((await pending).failed).toEqual([]);
+ });
+
+ it('skips the types whose feature is off', async () => {
+ const { c, request } = client(lists);
+ const files = vi.spyOn(c, 'listAllFileNodes').mockResolvedValue([]);
+ await c.getShareTargets({ calendars: false, contacts: false, files: false });
+ expect(request.mock.calls[0][0].map(([m]) => m)).toEqual(['Mailbox/get']);
+ expect(files).not.toHaveBeenCalled();
+ });
+
+ it('names the types whose read failed', async () => {
+ const { c } = client((calls) => calls.map((call) => (call[0] === 'Calendar/get' ? ['error', { type: 'serverFail' }, call[2]] : lists([call])[0])));
+ vi.spyOn(c, 'listAllFileNodes').mockRejectedValue(new Error('offline'));
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { failed } = await c.getShareTargets();
+ expect(failed.sort()).toEqual(['calendar', 'file']);
+ });
+});
+
+describe('JMAPClient.findPrincipalByEmail', () => {
+ afterEach(() => vi.restoreAllMocks());
+
+ function client(responses: unknown[]) {
+ const c = new JMAPClient('https://jmap.example.com', 'user', 'pass');
+ Object.assign(c, {
+ apiUrl: 'https://jmap.example.com/api', accountId: 'ci',
+ capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:principals': {} },
+ });
+ const request = vi.spyOn(c as unknown as { request: () => Promise }, 'request').mockResolvedValue({ methodResponses: responses });
+ return { c, request };
+ }
+
+ it('finds the principal with that address, whatever its case', async () => {
+ const { c, request } = client([
+ ['Principal/query', { ids: ['ck'] }, '0'],
+ ['Principal/get', { list: [{ id: 'ck', type: 'individual', name: 'Carol', email: 'org-carol@example.org' }] }, '1'],
+ ]);
+ const found = await c.findPrincipalByEmail(' Org-Carol@Example.org ');
+ expect(found).toEqual({ principal: expect.objectContaining({ id: 'ck', accountId: 'ci' }), status: 'ok' });
+ const [calls] = request.mock.calls[0] as unknown as [[string, Record][]];
+ expect(calls[0][1].filter).toEqual({ email: 'org-carol@example.org' });
+ });
+
+ it('says when directory queries are off (Stalwart 1.0)', async () => {
+ const { c } = client([
+ ['error', { type: 'forbidden', description: 'The administrator has disabled directory queries.' }, '0'],
+ ['error', { type: 'invalidResultReference' }, '1'],
+ ]);
+ expect(await c.findPrincipalByEmail('org-carol@example.org')).toEqual({ principal: null, status: 'forbidden', directoryQueriesOff: true });
+ });
+
+ it('tells a refusal for another reason apart, and an unknown address', async () => {
+ expect(await client([['error', { type: 'forbidden', description: 'You are not authorized to perform this action' }, '0']]).c
+ .findPrincipalByEmail('x@example.org')).toEqual({ principal: null, status: 'forbidden' });
+ expect(await client([['Principal/query', { ids: [] }, '0'], ['Principal/get', { list: [] }, '1']]).c
+ .findPrincipalByEmail('x@example.org')).toEqual({ principal: null, status: 'ok' });
+ });
+});
diff --git a/lib/__tests__/lite-bundle-hosts.test.ts b/lib/__tests__/lite-bundle-hosts.test.ts
new file mode 100644
index 000000000..99fe6ddb8
--- /dev/null
+++ b/lib/__tests__/lite-bundle-hosts.test.ts
@@ -0,0 +1,69 @@
+// @vitest-environment node
+import { afterAll, describe, expect, it } from 'vitest';
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { BUILT_IN_HOSTNAMES } from '@/lib/outgoing-hosts';
+import { isPlaceholderHost as sourcePlaceholder } from '@/lib/admin/outgoing-hosts-registry';
+import { LITE_BUILT_IN_HOSTS, isPlaceholderHost, scanBundleHosts } from '../../scripts/lite/scan-hosts.mjs';
+
+// scripts/lite/scan-hosts.mjs runs in the Lite CI job after build:lite (the
+// build prunes the test trees, so the scan can't be a vitest file there).
+// These tests pin it to the app's host list and show what it catches. With
+// LITE_OUT_DIR set it also scans a real build:
+//
+// LITE_OUT_DIR=/path/to/out npx vitest run lib/__tests__/lite-bundle-hosts.test.ts
+
+const dirs: string[] = [];
+function fakeOut(files: Record): string {
+ const dir = mkdtempSync(path.join(tmpdir(), 'lite-out-'));
+ dirs.push(dir);
+ for (const [name, text] of Object.entries(files)) {
+ mkdirSync(path.dirname(path.join(dir, name)), { recursive: true });
+ writeFileSync(path.join(dir, name), text);
+ }
+ return dir;
+}
+afterAll(() => { for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); });
+
+describe('Lite bundle host scan', () => {
+ it('knows the same built-in hosts and placeholders as the app', () => {
+ expect((LITE_BUILT_IN_HOSTS as string[]).filter((h) => !h.endsWith('.')).sort()).toEqual([...BUILT_IN_HOSTNAMES].sort());
+ for (const host of ['mail.example.com', 'x', '10.0.0.5', '93.184.216.34', 'acme.test', 'github.com', '*.vimeo.com']) {
+ expect(isPlaceholderHost(host), host).toBe(sourcePlaceholder(host));
+ }
+ });
+
+ it('passes a bundle with only accounted hosts', () => {
+ const out = fakeOut({
+ 'config.json': JSON.stringify({ jmapServerUrl: 'https://mail.corp.example.net' }),
+ '_next/static/chunks/a1.js': 'fetch("https://mail.corp.example.net/jmap");x="https://react.dev/errors/";y="https://icons.duckduckgo.com"',
+ 'en/mail/index.html': ' ',
+ });
+ expect(scanBundleHosts(out)).toEqual([]);
+ });
+
+ it('catches a new host, a ws host, a protocol-relative host, a public IP and a listed host in the wrong file', () => {
+ const out = fakeOut({
+ 'config.json': '{}',
+ '_next/static/chunks/b2.js': [
+ 'fetch("https://beacon.fakecdn.io/x")',
+ 'new WebSocket("WSS://Live.FakeCDN.io")',
+ 's.src="//cdn.fakecdn.io/lib.js"',
+ 'fetch("http://93.184.216.34/")',
+ ].join(';'),
+ 'en/mail/index.html': ' ',
+ });
+ expect(scanBundleHosts(out).sort()).toEqual([
+ '_next/static/chunks/b2.js: 93.184.216.34',
+ '_next/static/chunks/b2.js: beacon.fakecdn.io',
+ '_next/static/chunks/b2.js: cdn.fakecdn.io',
+ '_next/static/chunks/b2.js: live.fakecdn.io',
+ 'en/mail/index.html: icons.duckduckgo.com',
+ ]);
+ });
+
+ it.skipIf(!process.env.LITE_OUT_DIR)('finds nothing unaccounted in the build at LITE_OUT_DIR', () => {
+ expect(scanBundleHosts(process.env.LITE_OUT_DIR)).toEqual([]);
+ });
+});
diff --git a/lib/__tests__/lite-scripts.test.ts b/lib/__tests__/lite-scripts.test.ts
index 22fd7ef68..0b7a0f593 100644
--- a/lib/__tests__/lite-scripts.test.ts
+++ b/lib/__tests__/lite-scripts.test.ts
@@ -195,6 +195,19 @@ describe('lite build helpers', () => {
expect(withScriptHashCsp('')).toMatch(/^ ]*>