Merge branch 'feat/org-features' into feat/org-recovery

This commit is contained in:
Linus Rath
2026-10-28 09:08:49 +01:00
80 changed files with 6574 additions and 376 deletions
+4
View File
@@ -6,10 +6,14 @@
- **Organisation**: Admins can set an organisation signature with placeholders for each user's name, address, company, job title, department and phone numbers, per domain or group, and lock it so it replaces everyone's own signature. Users fill in their details once under Settings > Account. Lines whose details are empty are left out (#908)
- **Organisation**: Admins can add a legal disclaimer that goes below every message when it is sent, never into drafts, and is shown read-only in the composer
- **Organisation**: Admins can give every user a library of templates, per domain or group, that users pick under "Organisation" in the template picker and can copy but not change
- **Templates**: On Stalwart 1.0, members of a group mailbox share team templates, and users' own templates are kept in their account so they follow them to every browser. Templates already in a browser move over on the first sign-in. Admins can lock team templates or leave users only the organisation and team templates
- **Organisation**: Admins can label mail from outside the organisation with an "External" chip and have users confirm before sending to or inviting external recipients (composer, quick reply and meeting invitations), per domain or group. Partner domains and contacts count as internal. This replaces the External Mail Warning plugin, whose safe domains can be imported. Plugins can read `api.host.features.externalSenderLabel` and `externalRecipientWarning`
- **Organisation**: Admins can sign users out after a time without activity or a fixed time after sign-in, per domain or group. A countdown with "Stay signed in" comes first, open and minimised messages and quick replies are saved as drafts, and the login page says why
- **Delegates**: Settings > Sharing gives a colleague read, or read and write, access to all mail folders or just the Inbox, calendars and contacts in one step, lists who has access to what, and removes access everywhere at once. Admins can hide it per domain or group and limit every share dialog to read access on Organisation > Sharing
- **Privacy**: One switch, `NO_THIRD_PARTY_TRAFFIC` or Organisation > Security, stops every connection to services outside your own servers: telemetry, the version check, the built-in push relay, the official extension directory, sender icons, MyMemory translation, map links, OpenPGP key lookups and remote pictures in mail. Plugins that need network access don't run, and the page's CSP allows only the hosts you configured. Push registrations move to your own relay. The admin page lists what stays reachable (#903)
- **Team inbox**: The new official Team inbox plugin lets members of a team mailbox assign messages to each other, mark them done, and filter by "Assigned to me" and "Open"
- **Plugins**: Plugins can add keyword filters to the message list, shown as chips above it, put icons on row badges, show actions only in team mailboxes or for some keywords, change several keywords in one step, see the user's own account and which mailboxes are team mailboxes, and keep a small shared value in a team mailbox on Stalwart 1.0. Looking people up in the directory needs the new `directory:read` permission. Plugins can no longer set `$draft`, `$junk`, `$notjunk` or `$phishing`
## 1.18.0 (2026-10-26)
+2
View File
@@ -21,6 +21,7 @@ import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt";
import { SessionTimeoutGuard } from "@/components/session/session-timeout-guard";
import { AccountTemplateSync } from "@/components/templates/account-template-sync";
import { IS_LITE } from "@/lib/lite";
import { setRequestLocale } from "next-intl/server";
import { locales } from "@/i18n/routing";
@@ -64,6 +65,7 @@ export default async function LocaleLayout({
<ImpersonationReconciler />
<PushBindingsHost />
<SessionTimeoutGuard />
<AccountTemplateSync />
{children}
<PluginDialogHost />
<ExternalRecipientConfirmHost />
@@ -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<Record<string, unknown>> = [];
let stored: Record<string, unknown> = {};
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: '<p>Hi <b>there</b></p>', 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(<OrgTemplatesPage />);
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('<b>there</b>');
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(<OrgTemplatesPage />);
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(<OrgTemplatesPage />);
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<string, unknown> } }>)[0].policy;
expect(policy.organisation?.templates).toBeUndefined();
});
it('says that a template without a name is not saved', async () => {
render(<OrgTemplatesPage />);
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(<OrgTemplatesPage />);
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<string, unknown>).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(<OrgTemplatesPage />);
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<string, unknown> } }>;
expect(scopes.find((scope) => scope.id === 'team')?.policy.restrictions).toEqual({ teamTemplates: { locked: true } });
expect((puts[0].restrictions as Record<string, unknown>).teamTemplates).toBeUndefined();
});
});
@@ -1,14 +1,191 @@
'use client';
import { useId, useState } from 'react';
import { ChevronDown, ChevronRight, Plus, RotateCcw, Trash2 } from '@/components/icons';
import { SettingsSection } from '@/components/settings/settings-section';
import { TemplateBodyPreview } from '@/components/templates/template-body-preview';
import { Button } from '@/components/ui/button';
import type { OrgTemplate } from '@/lib/admin/organisation-policy';
import { restrictionKeysForPage } from '@/lib/admin/restriction-keys';
import { cn, generateUUID } from '@/lib/utils';
import { RestrictionRow } from '../org-page';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
/** Organisation feature 3; filled in by its own step (local-data/org-features-plan.md). */
export function TemplatesSection({ draft }: { draft: OrgPolicyDraft }) {
void draft;
const inputClass =
'block w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ' +
'placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 ' +
'focus-visible:ring-ring focus-visible:border-ring';
const labelClass = 'mb-1 block text-xs font-medium text-foreground';
/** Same cap as the sanitiser (organisation-policy.ts): more are not saved. */
const MAX_LIBRARY = 200;
function TemplateEditor({ template, onChange, onRemove }: {
template: OrgTemplate;
onChange: (template: OrgTemplate) => void;
onRemove: () => void;
}) {
const id = useId();
const [open, setOpen] = useState(!template.name);
const set = (patch: Partial<OrgTemplate>) => onChange({ ...template, ...patch });
return (
<SettingsSection id="library" title="Organisation library" description="Templates every user can pick in the composer.">
<p className="text-sm text-muted-foreground">Nothing to set here yet.</p>
</SettingsSection>
<li className="rounded-md border border-border" data-testid="org-template-item">
<div className="flex items-center gap-2 p-2">
<button
type="button"
onClick={() => 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 ? <ChevronDown className="h-4 w-4 shrink-0" /> : <ChevronRight className="h-4 w-4 shrink-0" />}
<span className={cn('truncate font-medium', !template.name && 'text-muted-foreground')}>{template.name || 'New template'}</span>
{template.category && <span className="max-w-[40%] shrink-0 truncate rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">{template.category}</span>}
</button>
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="h-8 shrink-0 px-2 hover:text-red-600" aria-label={`Remove ${template.name || 'template'}`}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{!template.name.trim() && (
<p className="px-3 pb-2 text-xs text-red-600 dark:text-red-400">A template without a name is not saved. Give it one.</p>
)}
{open && (
<div className="space-y-3 border-t border-border p-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label htmlFor={`${id}-name`} className={labelClass}>Name</label>
<input id={`${id}-name`} data-testid="org-template-name" className={inputClass} value={template.name} maxLength={200} onChange={(e) => set({ name: e.target.value })} />
</div>
<div>
<label htmlFor={`${id}-category`} className={labelClass}>Category</label>
<input id={`${id}-category`} className={inputClass} value={template.category} maxLength={100} onChange={(e) => set({ category: e.target.value })} />
</div>
</div>
<div>
<label htmlFor={`${id}-subject`} className={labelClass}>Subject</label>
<input id={`${id}-subject`} data-testid="org-template-subject" className={inputClass} value={template.subject} onChange={(e) => set({ subject: e.target.value })} />
</div>
<div>
<label htmlFor={`${id}-body`} className={labelClass}>Text</label>
<textarea
id={`${id}-body`}
data-testid="org-template-body"
rows={6}
className={cn(inputClass, template.isHTML && 'font-mono text-xs')}
value={template.body}
onChange={(e) => set({ body: e.target.value })}
/>
<p className="mt-1 text-xs text-muted-foreground">
Placeholders such as {'{{recipient_name}}'} work as in users&apos; own templates: users fill them in when they pick the template.
</p>
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm text-foreground">
<input type="checkbox" className="rounded border-input" checked={!!template.isHTML} onChange={(e) => set({ isHTML: e.target.checked })} />
The text is HTML
</label>
<div>
<p className={labelClass}>Preview</p>
<div className="rounded-md border border-border bg-background p-3" data-testid="org-template-preview">
{template.subject && <p className="mb-2 text-sm font-medium">{template.subject}</p>}
{template.body ? <TemplateBodyPreview template={template} /> : <p className="text-sm text-muted-foreground">No text</p>}
</div>
</div>
</div>
)}
</li>
);
}
/**
* 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 (
<>
<SettingsSection
id="library"
title="Organisation library"
description="Users see these templates under “Organisation” in the template picker and in Settings > Templates. They can use and copy them, not change them."
>
<div className="space-y-4">
{inScope && (
<div>
<p className="mb-2 text-sm font-medium text-foreground">From Everyone</p>
{everyone.length === 0 ? (
<p className="text-sm text-muted-foreground">Everyone&apos;s library is empty.</p>
) : (
<ul className="space-y-1.5" data-testid="org-template-inherited">
{everyone.map((template) => (
<li key={template.id} className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-dashed border-border px-3 py-2 text-sm">
<span className="min-w-0 truncate">{template.name}</span>
{ownIds.has(template.id) ? (
<span className="text-xs text-muted-foreground">Changed for this scope</span>
) : (
<button type="button" onClick={() => replaceInScope(template)} className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-2 hover:underline">
<RotateCcw className="h-3 w-3" /> Change for this scope
</button>
)}
</li>
))}
</ul>
)}
<p className="mt-4 mb-1 text-sm font-medium text-foreground">For this scope</p>
<p className="mb-2 text-xs text-muted-foreground">
Users in this scope get Everyone&apos;s templates and these. A template changed here replaces Everyone&apos;s for this scope only.
</p>
</div>
)}
{own.length === 0 ? (
<p className="text-sm text-muted-foreground" data-testid="org-template-empty">
{inScope ? 'Nothing added for this scope.' : 'No templates yet. Users only have their own.'}
</p>
) : (
<ul className="space-y-2" data-testid="org-template-list">
{own.map((template, index) => (
<TemplateEditor key={template.id} template={template} onChange={(t) => change(index, t)} onRemove={() => remove(index)} />
))}
</ul>
)}
<div className="flex flex-wrap items-center gap-3">
<Button type="button" variant="outline" size="sm" onClick={add} disabled={total >= MAX_LIBRARY} data-testid="org-template-add">
<Plus className="me-1 h-4 w-4" /> Add template
</Button>
{total >= MAX_LIBRARY && <span className="text-xs text-muted-foreground">A library holds at most {MAX_LIBRARY} templates.</span>}
</div>
</div>
</SettingsSection>
<SettingsSection
id="user-templates"
title="Personal and team templates"
description="On Stalwart 1.0 and later, users' own templates are kept in their account, and the members of a group mailbox share its team templates. On older servers, users keep their own templates in the browser and there are no team templates."
>
<div>
{restrictionKeysForPage('org-templates').map((def) => <RestrictionRow key={def.key} draft={draft} def={def} />)}
</div>
</SettingsSection>
</>
);
}
@@ -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(<EmailList emails={[]} />);
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(<EmailList emails={[]} />);
await act(async () => {});
expect(screen.queryByTestId('plugin-list-filters')).toBeNull();
});
});
@@ -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(<PluginListFilters mailboxRole="inbox" accountKind="group" onChange={() => {}} />);
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(<PluginListFilters mailboxRole="inbox" accountKind="personal" onChange={() => {}} />);
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(<PluginListFilters mailboxRole="inbox" accountKind="group" onChange={onChange} />);
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(<PluginListFilters mailboxRole="inbox" accountKind="group" onChange={onChange} />);
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
onChange.mockClear();
act(() => clearPluginListFilters('team-inbox'));
expect(screen.queryByRole('group')).toBeNull();
expect(onChange).toHaveBeenCalledTimes(1);
});
});
@@ -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"><script>' },
]);
const { container } = render(row([makeEmail()]));
await screen.findByText('Done');
const done = container.querySelector('[data-plugin-row-badge="done"]')!;
expect(done.querySelector('svg[data-badge-icon="check"]')).not.toBeNull();
expect(done).toHaveTextContent('Done');
expect(container.querySelector('[data-plugin-row-badge="bad"] svg')).toBeNull();
expect(normalizeBadges([{ key: 'bad', label: 'Bad', icon: 'x"><script>' }])[0]).not.toHaveProperty('icon');
});
it('asks the plugin again when it requests a refresh, at most once a second', async () => {
let label = 'Open';
const handler = vi.fn(async () => [{ key: 'task', label }]);
+19 -5
View File
@@ -83,7 +83,9 @@ import type { DirectoryEntry } from "@/stores/contact-store";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { FileNode } from "@/lib/jmap/types";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { useTemplateStore, renderTemplateWithPlugins, notifyTemplateApplied } from "@/stores/template-store";
import { renderTemplateWithPlugins, notifyTemplateApplied } from "@/stores/template-store";
import { useTemplateSources } from "@/hooks/use-template-sources";
import { personalTemplatesFor } from "@/lib/templates/personal-templates";
import { notifyIdentitySelected } from "@/stores/identity-store";
import { usePluginSignature } from "@/hooks/use-plugin-signature";
import { useOrgSignature, useOrgSignatureIdentity } from "@/hooks/use-org-signature";
@@ -1553,7 +1555,15 @@ export function EmailComposer({
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
const addTemplate = useTemplateStore((s) => s.addTemplate);
// "Save as template" keeps it with the account the message is written from.
const addTemplate = useCallback(
(data: Parameters<ReturnType<typeof personalTemplatesFor>['add']>[0]) => personalTemplatesFor(composingAccountId).add(data),
[composingAccountId],
);
// The admin may leave users only organisation and team templates.
const composingTemplates = useTemplateSources(composingAccountId);
const personalTemplatesHidden = composingTemplates.personalHidden;
const composingPersonalTemplates = composingTemplates.sources.find((source) => source.kind === 'personal')?.templates;
// Sign/encrypt is provided by crypto plugins (S/MIME, PGP) via the
// composer-toolbar slot + the onComposeSend hook — the host stays
// crypto-agnostic.
@@ -4676,9 +4686,11 @@ export function EmailComposer({
<ComposerToolbarMenuItem icon={FileText} onSelect={() => { close(); setShowTemplatePicker(true); }}>
{t('insert_template')}
</ComposerToolbarMenuItem>
<ComposerToolbarMenuItem icon={BookmarkPlus} onSelect={() => { close(); setShowSaveAsTemplate(true); }}>
{t('save_as_template')}
</ComposerToolbarMenuItem>
{!personalTemplatesHidden && (
<ComposerToolbarMenuItem icon={BookmarkPlus} onSelect={() => { close(); setShowSaveAsTemplate(true); }}>
{t('save_as_template')}
</ComposerToolbarMenuItem>
)}
</>}
</ComposerToolbarMenu>
)}
@@ -4833,6 +4845,7 @@ export function EmailComposer({
isOpen={showTemplatePicker}
onClose={() => setShowTemplatePicker(false)}
onSelect={handleTemplateSelect}
accountId={composingAccountId}
/>
)}
@@ -4862,6 +4875,7 @@ export function EmailComposer({
>
<h3 className="text-lg font-semibold text-foreground mb-4">{t('save_as_template')}</h3>
<TemplateForm
categorySource={composingPersonalTemplates}
initialData={{
subject,
body,
+8 -2
View File
@@ -13,7 +13,7 @@ import {
ContextMenuHeader,
} from "@/components/ui/context-menu";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
import { PluginActions, useMessageActionTarget, usePluginActions } from "@/components/plugins/plugin-actions";
import {
Reply,
ReplyAll,
@@ -292,7 +292,12 @@ export function EmailContextMenu({
// The row's plugin buttons (message-row-actions). On phones the long press
// opens this menu, so it is where those buttons live there.
const hasRowPluginActions = usePluginActions("message-row-actions", { mailboxRole: currentMailboxRole ?? null }).length > 0;
const rowPluginTarget = useMessageActionTarget(email);
const hasRowPluginActions = usePluginActions("message-row-actions", {
mailboxRole: currentMailboxRole ?? null,
accountKind: rowPluginTarget.accountKind,
keywords: rowPluginTarget.keywords,
}).length > 0;
const rowPluginContext = useMemo(() => ({ emailId: email.id }), [email.id]);
const rowPluginBlock = !showBatchActions && hasRowPluginActions ? (
<>
@@ -301,6 +306,7 @@ export function EmailContextMenu({
surface="message-row-actions"
variant="menu"
context={rowPluginContext}
target={rowPluginTarget}
mailboxRole={currentMailboxRole ?? null}
onRan={onClose}
/>
+6 -2
View File
@@ -12,7 +12,7 @@ import { useTranslations } from "next-intl";
import { useIsMobile } from "@/hooks/use-media-query";
import { TagPicker } from "@/components/email/tag-picker";
import { getEmailTagIds } from "@/lib/thread-utils";
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
import { PluginActions, useMessageActionTarget, usePluginActions } from "@/components/plugins/plugin-actions";
interface EmailHoverActionsProps {
email: Email;
@@ -123,7 +123,10 @@ export function EmailHoverActions({
const t = useTranslations("settings.email_behavior.hover_actions");
const isMobile = useIsMobile();
// Buttons plugins add to the row (message-row-actions), after the app's own.
const pluginActionCount = usePluginActions("message-row-actions", { mailboxRole }).length;
const pluginTarget = useMessageActionTarget(email);
const pluginActionCount = usePluginActions("message-row-actions", {
mailboxRole, accountKind: pluginTarget.accountKind, keywords: pluginTarget.keywords,
}).length;
const pluginContext = useMemo(() => ({ emailId: email.id }), [email.id]);
// The tag quick-action opens the same picker the context menu and the reading
@@ -269,6 +272,7 @@ export function EmailHoverActions({
key="plugin-actions"
surface="message-row-actions"
context={pluginContext}
target={pluginTarget}
mailboxRole={mailboxRole}
compact
className="flex-nowrap gap-0.5"
+13 -1
View File
@@ -38,7 +38,8 @@ import { getOwnAddresses } from "@/lib/filters/quick-rule-target";
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
import { normalizeAddress } from "@/lib/filters/quick-rules";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { PluginListFilters } from "./plugin-list-filters";
import { PluginActions, useFolderAccountKind } from "@/components/plugins/plugin-actions";
import { useUnreadFirstSection } from "@/hooks/use-unread-first-section";
import { unreadSectionRowCount } from "@/lib/unread-first";
import { MvListSectionHeader } from "./mv-list-section-header";
@@ -533,6 +534,7 @@ export function EmailList({
[emails, threadEmailsCache, selectedEmailKeys],
);
const bulkActionContext = useMemo(() => ({ emailIds: selectedEmailIds }), [selectedEmailIds]);
const folderAccountKind = useFolderAccountKind();
// "Remove from this folder" (#615): offered for a selection only when every
// selected message is also in another folder.
const removeFromFolder = useRemoveFromFolder();
@@ -915,6 +917,7 @@ export function EmailList({
<PluginActions
surface="message-bulk-actions"
context={bulkActionContext}
accountKind={folderAccountKind}
mailboxRole={effectiveMailboxRole ?? null}
compact
overflow={batchNarrow}
@@ -980,6 +983,15 @@ export function EmailList({
</div>
)}
{/* Plugins' keyword filters, in a folder's own list (not search, tags or merged views). */}
{!isSearching && !selectedKeyword && !isUnifiedView && !isScheduledView && selectedMailbox && (
<PluginListFilters
mailboxRole={effectiveMailboxRole ?? null}
accountKind={folderAccountKind}
onChange={() => { if (client) void useEmailStore.getState().fetchEmails(client); }}
/>
)}
<PluginSlot name="message-list-header" className="w-full" extraProps={listSlotProps} />
<PluginSearchResults surface="search-results" onOpenEmail={onOpenSearchResult} />
+4 -1
View File
@@ -139,7 +139,7 @@ import { VerificationCodeChip } from "./verification-code-chip";
import { ConversationEmailBody, type ConversationAttachmentHandler, useConversationExternalContent, useMarkReadWhenShown } from "./thread-conversation-view";
import type { TnefAttachment } from "@/lib/tnef";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { PluginActions, useMessageActionTarget } from "@/components/plugins/plugin-actions";
import { AttachmentPluginFrames, AttachmentPluginMenu, PluginPanelEntries, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
import { RulesPanel, useRulesAvailability } from "./rules-menu";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
@@ -1604,6 +1604,7 @@ export function EmailViewer({
const emailIdRef = useRef(email?.id);
emailIdRef.current = email?.id;
const readerActionContext = useMemo(() => ({ emailId: email?.id }), [email?.id]);
const readerActionTarget = useMessageActionTarget(email);
const pluginFooterProps = useMemo(() => ({ emailId: email?.id }), [email?.id]);
// The user's choice; until they make one, a panel whose plugin asked to
// start folded (a chat opened on demand) is folded, the rest open.
@@ -3992,6 +3993,7 @@ export function EmailViewer({
<PluginActions
surface="reader-actions"
context={readerActionContext}
target={readerActionTarget}
mailboxRole={currentMailboxRole ?? null}
compact={!showToolbarLabels}
/>
@@ -4819,6 +4821,7 @@ export function EmailViewer({
<PluginPanelEntries
surface="reader-actions"
context={readerActionContext}
target={readerActionTarget}
mailboxRole={currentMailboxRole ?? null}
onRan={() => setMoreMenuOpen(false)}
/>
+1 -1
View File
@@ -31,7 +31,7 @@ function useAccountContext(accountId: string | null | undefined): ExternalMailCo
useEffect(() => {
if (!other || !accountId) return;
let cancelled = false;
void externalMailContextFor(accountId).then((context) => {
void externalMailContextFor(accountId, 'senderLabel').then((context) => {
if (!cancelled) setOtherContext({ accountId, context });
});
return () => { cancelled = true; };
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useEffect, useRef, useSyncExternalStore } from "react";
import { useTranslations } from "next-intl";
import { iconForName, X } from "@/components/icons";
import { cn } from "@/lib/utils";
import {
activeListFilterKeys,
listFilterApplies,
listFilterRevision,
listFilters,
setListFilterActive,
subscribeListFilters,
} from "@/lib/plugin-sandbox/list-filters";
import type { MailAccountKind } from "@/lib/plugin-sandbox/account-kind";
const NONE = new Set<string>();
/**
* The keyword filters plugins offer for this folder (api.ui.setListFilters),
* as chips in the list header. A chip turns its filter on; an active chip
* is pressed, shows a cross and turns it off again. `onChange` loads the list again
* whenever the applied filters change, also when a plugin drops one that
* was on.
*/
export function PluginListFilters({
mailboxRole,
accountKind,
onChange,
className,
}: {
mailboxRole: string | null;
accountKind: MailAccountKind | null;
onChange: () => void;
className?: string;
}) {
const t = useTranslations("plugin_list");
const filters = useSyncExternalStore(subscribeListFilters, listFilters, listFilters);
const active = useSyncExternalStore(subscribeListFilters, activeListFilterKeys, () => NONE);
const revision = useSyncExternalStore(subscribeListFilters, listFilterRevision, listFilterRevision);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const seenRevision = useRef(revision);
useEffect(() => {
if (seenRevision.current === revision) return;
seenRevision.current = revision;
onChangeRef.current();
}, [revision]);
const shown = filters.filter((f) => listFilterApplies(f, { role: mailboxRole, accountKind }));
if (shown.length === 0) return null;
return (
<div
role="group"
aria-label={t("filters")}
data-testid="plugin-list-filters"
className={cn("px-4 py-1.5 border-b border-border flex items-center gap-2 flex-wrap", className)}
>
{shown.map((filter) => {
const on = active.has(filter.key);
const Icon = iconForName(filter.icon);
return (
<button
key={filter.key}
type="button"
aria-pressed={on}
// The name stays the filter's label; aria-pressed says it is on,
// and the tooltip says a click removes it.
title={on ? t("remove_filter", { label: filter.label }) : filter.pluginName}
data-plugin-list-filter={filter.key}
onClick={() => setListFilterActive(filter.key)}
className={cn(
"inline-flex items-center gap-1 h-7 max-w-[12rem] px-2.5 rounded-full text-xs border transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
on
? "bg-primary/10 text-primary border-primary/30 hover:bg-primary/15"
: "border-border text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{Icon && <Icon className="w-3.5 h-3.5 flex-shrink-0" aria-hidden />}
<span className="truncate font-medium">{filter.label}</span>
{on && <X className="w-3 h-3 flex-shrink-0" aria-hidden />}
</button>
);
})}
</div>
);
}
+28 -18
View File
@@ -6,7 +6,9 @@ import type { EmailListBadge } from "@/lib/plugin-types";
import { renderHooks } from "@/lib/plugin-hooks";
import { emailSummary } from "@/lib/plugin-projection";
import { rowBadgeGeneration, subscribeRowBadges } from "@/lib/plugin-sandbox/row-badges";
import { PLUGIN_ICON_RE } from "@/lib/plugin-sandbox/actions";
import { cn } from "@/lib/utils";
import { iconForName } from "@/components/icons";
import { TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge";
// Badges plugins add to a message-list row through onEmailListItemRender.
@@ -67,6 +69,8 @@ export function normalizeBadges(raw: unknown): EmailListBadge[] {
if (color && SAFE_COLOR.test(color)) badge.color = color;
const title = cleanText(candidate.title, MAX_TITLE_LENGTH);
if (title) badge.title = title;
const icon = cleanText(candidate.icon, 40);
if (icon && PLUGIN_ICON_RE.test(icon)) badge.icon = icon;
out.push(badge);
}
return out;
@@ -141,24 +145,30 @@ export function PluginRowBadges({ email, className }: { email: Email; className?
if (badges.length === 0) return null;
return (
<span className={cn(TAG_GROUP_CLASS, className)} data-testid="plugin-row-badges">
{badges.map((badge) => (
<span
key={badge.key}
title={badge.title}
className={cn(
TAG_LOZENGE_CLASS,
"max-w-[8rem] truncate border",
!badge.color && "border-border bg-muted text-muted-foreground",
)}
style={badge.color ? {
color: badge.color,
borderColor: badge.color,
backgroundColor: `color-mix(in srgb, ${badge.color} 12%, transparent)`,
} : undefined}
>
{badge.label}
</span>
))}
{badges.map((badge) => {
const Icon = iconForName(badge.icon);
return (
<span
key={badge.key}
title={badge.title}
data-plugin-row-badge={badge.key}
className={cn(
TAG_LOZENGE_CLASS,
"max-w-[8rem] truncate border",
Icon && "gap-0.5",
!badge.color && "border-border bg-muted text-muted-foreground",
)}
style={badge.color ? {
color: badge.color,
borderColor: badge.color,
backgroundColor: `color-mix(in srgb, ${badge.color} 12%, transparent)`,
} : undefined}
>
{Icon && <Icon className="w-3 h-3 flex-shrink-0" aria-hidden data-badge-icon={badge.icon} />}
{Icon ? <span className="truncate">{badge.label}</span> : badge.label}
</span>
);
})}
</span>
);
}
+7 -3
View File
@@ -9,7 +9,7 @@ import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
import { iconForName, MoreHorizontal, Puzzle } from "@/components/icons";
import { ContextMenu } from "@/components/ui/context-menu";
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
import { PluginActions, usePluginActions, type MessageActionTarget } from "@/components/plugins/plugin-actions";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { useContextMenu } from "@/hooks/use-context-menu";
@@ -128,14 +128,18 @@ export function PluginPanelEntries({
surface,
context,
mailboxRole,
target,
onRan,
}: {
surface: string;
context: Record<string, unknown>;
mailboxRole?: string | null;
/** The open message (useMessageActionTarget). */
target?: MessageActionTarget;
onRan?: () => void;
}) {
const actions = usePluginActions(surface, { mailboxRole });
const actions = usePluginActions(surface, { mailboxRole, accountKind: target?.accountKind ?? null, keywords: target?.keywords ?? null });
const clickContext = target?.accountId ? { ...context, accountId: target.accountId } : context;
if (actions.length === 0) return null;
return (
<>
@@ -147,7 +151,7 @@ export function PluginPanelEntries({
key={`${action.pluginId}:${action.id}`}
role="menuitem"
title={action.pluginName}
onClick={() => { onRan?.(); void runAction(action, surface, context); }}
onClick={() => { onRan?.(); void runAction(action, surface, clickContext); }}
className={cn(
"w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3",
action.danger ? "text-destructive" : "text-foreground",
@@ -51,7 +51,7 @@ import { useOwnDomainAddress } from "@/hooks/use-own-domain-address";
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection } from "./reader-selection-menu";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { PluginActions, useMessageActionTarget } from "@/components/plugins/plugin-actions";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { usePluginRenderedBody } from "@/hooks/use-plugin-rendered-body";
@@ -339,7 +339,8 @@ function useConversationPluginProps(email: Email) {
const deviceType = isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop';
const emailProps = useMemo(() => ({ email: wantsView ? emailToReadView(email) : null, deviceType }), [email, wantsView, deviceType]);
const idProps = useMemo(() => ({ emailId: email.id }), [email.id]);
return { emailProps, idProps };
const actionTarget = useMessageActionTarget(email);
return { emailProps, idProps, actionTarget };
}
function EmailCard({
@@ -490,6 +491,7 @@ function EmailCard({
<PluginActions
surface="reader-actions"
context={plugin.idProps}
target={plugin.actionTarget}
mailboxRole={mailboxRole ?? null}
overflow
className="flex-shrink-0 rounded-md border border-border"
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { render } from '@testing-library/react';
import { iconForName } from '../named';
describe('icons by name', () => {
it('draws the team inbox plugin\'s icons at once, without loading the whole set', () => {
for (const name of ['user', 'check', 'user-check', 'user-x', 'circle-check', 'circle-dashed', 'arrow-back-up', 'user-plus']) {
const Icon = iconForName(name)!;
const { container, unmount } = render(<Icon data-testid="icon" />);
// A curated icon draws its paths straight away; the lazy blank has none.
expect(container.querySelector('svg path'), name).not.toBeNull();
unmount();
}
});
it('keeps the caller\'s attributes on the blank shown while an icon loads', () => {
const Icon = iconForName('brand-mastodon')!;
const { container } = render(<Icon data-testid="lazy" data-kind="x" className="w-3" size={12} />);
const svg = container.querySelector('svg')!;
expect(svg.getAttribute('data-testid')).toBe('lazy');
expect(svg.getAttribute('data-kind')).toBe('x');
expect(svg.getAttribute('class')).toBe('w-3');
expect(svg.getAttribute('width')).toBe('12');
});
});
+21 -2
View File
@@ -12,6 +12,13 @@
import { forwardRef, useEffect, useState } from 'react';
import type { TablerIcon } from '@tabler/icons-react';
import {
IconArrowBackUp,
IconCheck,
IconCircleCheck,
IconCircleDashed,
IconUser,
IconUserCheck,
IconUserX,
IconAddressBook,
IconAperture,
IconApple,
@@ -177,6 +184,15 @@ export const TABLER_ICON_PREFIX = 'tabler:';
export const POPULAR_ICON_NAMES: readonly string[] = ["world","rss","radio","microphone-2","message-circle","message","messages","phone","video","device-computer-camera","headphones","microphone","file-text","file-spreadsheet","notebook","book","clipboard-list","checklist","square-check","layout-kanban","brand-trello","pencil","edit","code","terminal","braces","bug","database","server","cpu","server-2","device-desktop","device-laptop","device-mobile","device-tablet","wifi","cloud","cloud-download","cloud-upload","users","user-plus","user-circle","address-book","man","heart","thumb-up","star","award","trophy","crown","photo","camera","movie","music","player-play","device-tv","brand-youtube","palette","brush","map","map-pin","navigation","compass","home","building","building-bank","building-store","building-warehouse","currency-dollar","currency-euro","credit-card","wallet","receipt","pig-money","trending-up","chart-bar","chart-column","chart-line","chart-pie","shield","shield-check","lock","lock-open","key","fingerprint","eye","flask-2","atom","dna","microscope","stethoscope","heartbeat","pill","vaccine","thermometer","sun","moon","sun-wind","snowflake","bolt","flame","christmas-tree","flower","leaf","mountain","ripple","tool","hammer","scissors","ruler","magnet","package","gift","box","archive","car","bike","bus","train","plane","ship","rocket","coffee","glass","beer","pizza","apple","cake","soup","device-gamepad-2","dice-5","puzzle","sparkles","wand","robot","brain","bulb","bookmark","flag","bell","clock","stopwatch","link","external-link","qrcode","scan","layout-grid","stack","aperture","circle-dot","target","crosshair"];
const CURATED: Record<string, TablerIcon> = {
// Plugin badges, actions and list filters (team inbox), so a list of rows
// never waits for the whole set.
'arrow-back-up': IconArrowBackUp,
'check': IconCheck,
'circle-check': IconCircleCheck,
'circle-dashed': IconCircleDashed,
'user': IconUser,
'user-check': IconUserCheck,
'user-x': IconUserX,
'address-book': IconAddressBook,
'aperture': IconAperture,
'apple': IconApple,
@@ -403,8 +419,11 @@ function lazyIcon(name: string): TablerIcon {
};
}, [Resolved]);
if (!Resolved) {
const size = props.size ?? 24;
return <svg ref={ref} width={size} height={size} className={props.className} aria-hidden="true" />;
// A blank of the same size, with the caller's attributes (test ids,
// aria, data-*), until the set has loaded.
const { size = 24, stroke: _stroke, title: _title, ...rest } = props;
void _stroke; void _title;
return <svg ref={ref} aria-hidden="true" {...rest} width={size} height={size} />;
}
return <Resolved ref={ref} {...props} />;
});
@@ -38,7 +38,10 @@ vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => [],
cachedIdentitiesForAccount: () => undefined,
}));
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
@@ -1,6 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { PluginActions } from '../plugin-actions';
import { act, fireEvent, render, renderHook, screen } from '@testing-library/react';
import { PluginActions, useMessageActionTarget } from '../plugin-actions';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { usePolicyStore } from '@/stores/policy-store';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { resetConfirmedGroups } from '@/lib/plugin-sandbox/account-kind';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
const dispatch = vi.fn().mockResolvedValue(undefined);
@@ -66,3 +72,72 @@ describe('PluginActions', () => {
expect(screen.getByRole('menuitem', { name: /Labels/ })).toHaveTextContent('99+');
});
});
describe('actions for team mailboxes', () => {
const TEAM_ACTIONS = [
{ id: 'assign', label: 'Assign to me', accounts: ['group' as const] },
{ id: 'done', label: 'Mark done', accounts: ['group' as const], notKeyword: '$done' },
{ id: 'reopen', label: 'Reopen', accounts: ['group' as const], hasKeyword: '$done' },
];
it("shows them only on a group account's message, by its keywords, and sends its account", async () => {
act(() => setPluginActions('team', 'Team inbox', 'message-row-actions', TEAM_ACTIONS));
const view = render(
<PluginActions surface="message-row-actions" context={{ emailId: 'm1' }} variant="menu"
target={{ accountId: 'ch', accountKind: 'group', keywords: ['$seen'] }} />,
);
expect(screen.getAllByRole('menuitem').map((m) => m.textContent)).toEqual(['Assign to me', 'Mark done']);
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Mark done' })); });
expect(dispatch).toHaveBeenCalledWith('team', 'message-row-actions', 'done', { emailId: 'm1', accountId: 'ch' });
view.rerender(
<PluginActions surface="message-row-actions" context={{ emailId: 'm1' }} variant="menu"
target={{ accountId: 'ch', accountKind: 'group', keywords: ['$done'] }} />,
);
expect(screen.getAllByRole('menuitem').map((m) => m.textContent)).toEqual(['Assign to me', 'Reopen']);
view.rerender(
<PluginActions surface="message-row-actions" context={{ emailId: 'm1' }} variant="menu"
target={{ accountKind: 'personal', keywords: [] }} />,
);
expect(screen.queryAllByRole('menuitem')).toHaveLength(0);
});
it("hides them where the surface isn't about a message", () => {
act(() => setPluginActions('team', 'Team inbox', 'message-row-actions', TEAM_ACTIONS));
const { container } = render(<PluginActions surface="message-row-actions" context={{ emailId: 'm1' }} />);
expect(container).toBeEmptyDOMElement();
});
it("works out a message's account from the open folder and follows the server's word on groups", async () => {
let answer!: (types: Record<string, string>) => void;
const client = {
getAccountId: () => 'ci',
getAccountInfo: (id: string) => (id === 'ch' ? { name: 'org-team@example.org', isPersonal: false, isReadOnly: false, accountCapabilities: {} } : null),
getSharedAccounts: () => [{ id: 'ci', isPrimary: true }, { id: 'ch', isPrimary: false }],
getPrincipalTypes: () => new Promise((resolve) => { answer = resolve; }),
} as unknown as IJMAPClient;
resetConfirmedGroups();
const team = { id: 'ch:a', originalId: 'a', role: 'inbox', isShared: true, accountId: 'ch' } as unknown as Mailbox;
useAuthStore.setState({ client });
useEmailStore.setState({ mailboxes: [team], selectedMailbox: 'ch:a', viewingAccountId: null });
// The policy subject counting it a group is not enough.
usePolicyStore.setState({ subject: { email: 'org-alice@example.org', groups: ['org-team@example.org'] } });
const email = { id: 'm1', keywords: { $seen: true, $done: true } } as unknown as Email;
const { result } = renderHook(() => useMessageActionTarget(email));
expect(result.current).toEqual({ accountId: 'ch', accountKind: 'shared', keywords: ['$done', '$seen'] });
await act(async () => { answer({ ch: 'group' }); });
expect(result.current.accountKind).toBe('group');
act(() => useEmailStore.setState({ selectedMailbox: 'inbox', mailboxes: [team, { id: 'inbox', role: 'inbox' } as Mailbox] }));
expect(result.current).toEqual({ accountKind: 'personal', keywords: ['$done', '$seen'] });
useAuthStore.setState({ client: null });
usePolicyStore.setState({ subject: null });
useEmailStore.setState({ mailboxes: [], selectedMailbox: '' });
});
});
+84 -8
View File
@@ -1,6 +1,6 @@
'use client';
import { useSyncExternalStore, useState } from 'react';
import { useMemo, useSyncExternalStore, useState } from 'react';
import { useTranslations } from 'next-intl';
import { iconForName, MoreHorizontal, Puzzle } from '@/components/icons';
import { ContextMenu, ContextMenuItem } from '@/components/ui/context-menu';
@@ -12,17 +12,87 @@ import {
badgeText,
runAction,
subscribeActions,
type ActionFilter,
type RegisteredAction,
} from '@/lib/plugin-sandbox/actions';
import { mailAccountKind, useConfirmedGroups, type MailAccountKind } from '@/lib/plugin-sandbox/account-kind';
import type { Email } from '@/lib/jmap/types';
import { useAuthStore } from '@/stores/auth-store';
import { resolveEmailBlobContext, useEmailStore } from '@/stores/email-store';
const EMPTY: RegisteredAction[] = [];
/** The actions plugins declared for a surface, filtered by folder role or MIME type. */
export function usePluginActions(surface: string, filter: { mailboxRole?: string | null; mimeType?: string | null } = {}) {
/** The actions plugins declared for a surface, filtered by folder role, MIME type, account and keywords. */
export function usePluginActions(surface: string, filter: ActionFilter = {}) {
const all = useSyncExternalStore(subscribeActions, () => actionsFor(surface), () => EMPTY);
return all.filter((a) => actionApplies(a, filter));
}
/** The message an action surface is about: its account and keywords. */
export interface MessageActionTarget {
/** The JMAP account the message is in, when it isn't the signed-in account's own. */
accountId?: string;
accountKind: MailAccountKind | null;
keywords: string[] | null;
}
type ActionEmail = Pick<Email, 'id' | 'keywords' | 'sourceAccountId' | 'sourceClientAccountId'>;
/**
* The account and keywords of `email`, for the actions' `accounts`,
* `hasKeyword` and `notKeyword` and the click's `accountId`. Follows the
* server's answer on which accounts are groups, so a team mailbox's
* actions appear once it is in.
*/
export function useMessageActionTarget(email: ActionEmail | null | undefined): MessageActionTarget {
const groups = useConfirmedGroups((s) => s.revision);
const client = useAuthStore((s) => s.client);
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
const mailboxes = useEmailStore((s) => s.mailboxes);
const keywords = email?.keywords;
const keywordKey = keywords ? Object.keys(keywords).filter((k) => keywords[k]).sort().join(' ') : '';
const sourceAccountId = email?.sourceAccountId;
const sourceClientAccountId = email?.sourceClientAccountId;
const hasEmail = !!email;
return useMemo(() => {
if (!hasEmail) return { accountKind: null, keywords: null };
const owner = resolveEmailBlobContext({ sourceAccountId, sourceClientAccountId }, client);
const own = owner.client?.getAccountId?.();
const accountId = owner.accountId && owner.accountId !== own ? owner.accountId : undefined;
return {
...(accountId ? { accountId } : {}),
accountKind: owner.client ? mailAccountKind(owner.client, owner.accountId) : null,
keywords: keywordKey ? keywordKey.split(' ') : [],
};
// The group lists, the open folder and its mailboxes decide the account
// (resolveEmailBlobContext reads them from the stores).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasEmail, keywordKey, sourceAccountId, sourceClientAccountId, client, groups, selectedMailbox, mailboxes]);
}
/** The account kind of the open folder, for surfaces about the selection (message-bulk-actions). */
export function useFolderAccountKind(): MailAccountKind | null {
const groups = useConfirmedGroups((s) => s.revision);
const client = useAuthStore((s) => s.client);
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
const mailboxes = useEmailStore((s) => s.mailboxes);
return useMemo(() => {
const owner = resolveEmailBlobContext({}, client);
return owner.client ? mailAccountKind(owner.client, owner.accountId) : null;
// See useMessageActionTarget.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client, groups, selectedMailbox, mailboxes]);
}
/** The context a message action's click carries: the surface's, plus the message's account. */
function withTarget(context: Record<string, unknown>, target: MessageActionTarget | undefined): Record<string, unknown> {
return target?.accountId ? { ...context, accountId: target.accountId } : context;
}
function targetFilter(target: MessageActionTarget | undefined, accountKind: MailAccountKind | null | undefined): Pick<ActionFilter, 'accountKind' | 'keywords'> {
return { accountKind: target ? target.accountKind : accountKind ?? null, keywords: target?.keywords ?? null };
}
interface PluginActionsProps {
/** A surface of kind 'action' in lib/plugin-slot-catalog.ts. */
surface: string;
@@ -39,18 +109,22 @@ interface PluginActionsProps {
overflow?: boolean;
/** Called after a click, e.g. to close the menu the entry sits in. */
onRan?: () => void;
/** The message the surface is about (useMessageActionTarget). */
target?: MessageActionTarget;
/** Surfaces about the selection: the open folder's account kind. */
accountKind?: MailAccountKind | null;
}
/**
* Buttons or menu entries plugins declared with api.ui.setActions. The host
* draws them so they match the app; the plugin gets the click.
*/
export function PluginActions({ surface, context, variant = 'buttons', mailboxRole, mimeType, className, compact, overflow, onRan }: PluginActionsProps) {
const actions = usePluginActions(surface, { mailboxRole, mimeType });
export function PluginActions({ surface, context, variant = 'buttons', mailboxRole, mimeType, className, compact, overflow, onRan, target, accountKind }: PluginActionsProps) {
const actions = usePluginActions(surface, { mailboxRole, mimeType, ...targetFilter(target, accountKind) });
const [busy, setBusy] = useState<string | null>(null);
if (actions.length === 0) return null;
if (overflow && variant === 'buttons') {
return <PluginActionsOverflow surface={surface} context={context} mailboxRole={mailboxRole} mimeType={mimeType} className={className} />;
return <PluginActionsOverflow surface={surface} context={context} mailboxRole={mailboxRole} mimeType={mimeType} className={className} target={target} accountKind={accountKind} />;
}
const run = async (action: RegisteredAction) => {
@@ -58,7 +132,7 @@ export function PluginActions({ surface, context, variant = 'buttons', mailboxRo
setBusy(key);
try {
onRan?.();
await runAction(action, surface, context);
await runAction(action, surface, withTarget(context, target));
} finally {
setBusy((current) => (current === key ? null : current));
}
@@ -122,7 +196,7 @@ export function PluginActions({ surface, context, variant = 'buttons', mailboxRo
}
/** The actions of a surface behind one "more" button, for toolbars without room for them. */
function PluginActionsOverflow({ surface, context, mailboxRole, mimeType, className }: Pick<PluginActionsProps, 'surface' | 'context' | 'mailboxRole' | 'mimeType' | 'className'>) {
function PluginActionsOverflow({ surface, context, mailboxRole, mimeType, className, target, accountKind }: Pick<PluginActionsProps, 'surface' | 'context' | 'mailboxRole' | 'mimeType' | 'className' | 'target' | 'accountKind'>) {
const t = useTranslations('email_viewer');
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<true>();
return (
@@ -148,6 +222,8 @@ function PluginActionsOverflow({ surface, context, mailboxRole, mimeType, classN
context={context}
mailboxRole={mailboxRole}
mimeType={mimeType}
target={target}
accountKind={accountKind}
onRan={closeContextMenu}
/>
</ContextMenu>
+11 -8
View File
@@ -5,7 +5,8 @@ import { useTranslations } from 'next-intl';
import { SettingsSection } from './settings-section';
import { Button } from '@/components/ui/button';
import { TemplateManagerModal } from '@/components/templates/template-manager-modal';
import { useTemplateStore } from '@/stores/template-store';
import { personalTemplatesFor } from '@/lib/templates/personal-templates';
import { useTemplateSources } from '@/hooks/use-template-sources';
import { toast } from '@/stores/toast-store';
import { debug } from '@/lib/debug';
import {
@@ -20,13 +21,15 @@ export function TemplateSettings() {
const t = useTranslations('settings.templates');
const tNotif = useTranslations('notifications');
const { templates, exportAllTemplates, importTemplates: storeImport } = useTemplateStore();
const { sources, personalHidden } = useTemplateSources();
const personal = sources.find((source) => source.kind === 'personal')?.templates ?? [];
const total = sources.reduce((n, source) => n + source.templates.length, 0);
const [showManager, setShowManager] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleExport = () => {
const json = exportAllTemplates();
const json = personalTemplatesFor().exportAll();
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -56,7 +59,7 @@ export function TemplateSettings() {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
const result = storeImport(content);
const result = personalTemplatesFor().importJson(content);
if (result.errors.length > 0) {
toast.error(tNotif('templates_import_errors'));
@@ -83,7 +86,7 @@ export function TemplateSettings() {
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FileText className="w-4 h-4" />
<span>
{t('count', { count: templates.length })}
{t('count', { count: total })}
</span>
</div>
<Button
@@ -96,13 +99,13 @@ export function TemplateSettings() {
</div>
</SettingsSection>
<SettingsSection id="templates-export" advanced title={t('export_import')} description={t('export_import_description')}>
{!personalHidden && <SettingsSection id="templates-export" advanced title={t('export_import')} description={t('export_import_description')}>
<div className="flex items-center gap-3 py-3">
<Button
variant="outline"
size="sm"
onClick={handleExport}
disabled={templates.length === 0}
disabled={personal.length === 0}
>
<Download className="w-4 h-4 me-1" />
{t('export')}
@@ -125,7 +128,7 @@ export function TemplateSettings() {
</Button>
</div>
</div>
</SettingsSection>
</SettingsSection>}
{showManager && (
<TemplateManagerModal
@@ -0,0 +1,48 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render } from '@testing-library/react';
// The driver syncs the active account's templates when its synced settings
// have loaded, also when the load changed nothing in the browser's list:
// only then is it known whose that list is.
const sync = vi.hoisted(() => ({ schedule: vi.fn(), refresh: vi.fn() }));
vi.mock('@/lib/templates/personal-templates', () => ({
scheduleAccountSync: sync.schedule,
refreshAccountTemplates: sync.refresh,
}));
vi.mock('@/lib/templates/team-templates', () => ({ refreshTeamTemplates: vi.fn() }));
vi.mock('@/lib/browser-navigation', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
apiFetch: vi.fn(async () => new Response(JSON.stringify({ settings: { theme: 'dark' } }), { status: 200 })),
}));
import { AccountTemplateSync } from '../account-template-sync';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
const ACCOUNT = 'carol@example.org@mail.example.com';
const original = useAuthStore.getState();
beforeEach(() => {
sync.schedule.mockClear();
localStorage.removeItem('settings-sync-owner');
useAuthStore.setState({ isAuthenticated: true, activeAccountId: ACCOUNT, client: {} as never });
});
afterEach(() => {
useAuthStore.setState({ isAuthenticated: original.isAuthenticated, activeAccountId: original.activeAccountId, client: original.client });
});
describe('AccountTemplateSync', () => {
it('syncs the active account once its settings have loaded', async () => {
render(<AccountTemplateSync />);
expect(sync.schedule).not.toHaveBeenCalled();
await useSettingsStore.getState().loadFromServer('carol@example.org', 'https://mail.example.com');
expect(sync.schedule).toHaveBeenCalledWith(ACCOUNT);
});
it('leaves another account\'s settings load alone', async () => {
render(<AccountTemplateSync />);
await useSettingsStore.getState().loadFromServer('dave@example.org', 'https://mail.example.com');
expect(sync.schedule).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
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 { useTemplateStore } from '@/stores/template-store';
import type { EmailTemplate } from '@/lib/template-types';
import { emptyPartition, useAccountTemplatesStore } from '@/stores/account-templates-store';
import { useAuthStore } from '@/stores/auth-store';
// The template manager lists the organisation library read-only next to
// the user's own templates; a template from it can be copied into the
// user's own.
const own = (id: string, name: string): EmailTemplate => ({
id, name, subject: '', 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: 'welcome', name: 'Welcome', subject: 'Hello', body: 'Welcome text', isHTML: false, category: '' }] },
},
};
const ALICE = { email: 'alice@example.org', groups: [] };
function setPolicy(raw: SettingsPolicy) {
usePolicyStore.setState({ rawPolicy: raw, policy: resolvePolicy(raw, ALICE).policy, subject: ALICE });
}
beforeEach(() => {
resetAccountSubjects();
setPolicy(RAW);
useTemplateStore.setState({ templates: [own('mine', 'Mine')], recentTemplateIds: [], deletedTemplateIds: {} });
useAccountTemplatesStore.setState({ personal: {}, teams: {}, active: null });
});
afterEach(() => {
resetAccountSubjects();
useAuthStore.setState({ activeAccountId: null } as never);
});
describe('template manager sources', () => {
it('shows the organisation library read-only, with a preview and a copy into the user\'s own', () => {
render(<TemplateManagerModal isOpen onClose={() => {}} />);
expect(screen.getByText('Mine')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('template-source-tab-organisation'));
expect(screen.getByTestId('template-source-note')).toHaveTextContent('organisation_note');
const row = screen.getByTestId('template-row');
expect(within(row).getByText('Welcome')).toBeInTheDocument();
expect(within(row).queryByRole('button', { name: 'edit' })).toBeNull();
expect(within(row).queryByRole('button', { name: 'confirm_delete' })).toBeNull();
expect(screen.queryByRole('button', { name: 'add' })).toBeNull();
fireEvent.click(within(row).getByRole('button', { name: 'preview' }));
expect(screen.getByTestId('template-preview')).toHaveTextContent('Welcome text');
fireEvent.click(within(row).getByRole('button', { name: 'copy_to_personal' }));
const names = useTemplateStore.getState().templates.map((t) => t.name);
expect(names).toEqual(['Mine', 'Welcome']);
expect(useTemplateStore.getState().templates[1].id).not.toBe('welcome');
});
it('offers no personal templates and no copy when the admin hid them', () => {
setPolicy({ ...RAW, restrictions: { personalTemplates: { hidden: true } } });
render(<TemplateManagerModal isOpen onClose={() => {}} />);
expect(screen.queryByTestId('template-source-tab-personal')).toBeNull();
expect(screen.queryByText('Mine')).toBeNull();
const row = screen.getByTestId('template-row');
expect(within(row).getByText('Welcome')).toBeInTheDocument();
expect(within(row).queryByRole('button', { name: 'copy_to_personal' })).toBeNull();
});
it("keeps the account's templates in the account, says so, and names the ones that stay in this browser", () => {
useAuthStore.setState({ activeAccountId: 'a1' } as never);
useAccountTemplatesStore.setState({
personal: { a1: { ...emptyPartition(), templates: [own('big', 'Big one')], localOnly: { big: 'too-large' } } },
});
render(<TemplateManagerModal isOpen onClose={() => {}} />);
expect(screen.getByText('Big one')).toBeInTheDocument();
expect(screen.queryByText('Mine')).toBeNull();
expect(screen.getByTestId('template-local-only')).toHaveTextContent('local_only_too_large');
expect(screen.getByTestId('template-storage-note')).toHaveTextContent('personal_in_account');
fireEvent.click(screen.getByRole('button', { name: 'add' }));
fireEvent.change(screen.getByPlaceholderText('name_placeholder'), { target: { value: 'New in the account' } });
fireEvent.click(screen.getByRole('button', { name: 'create' }));
return waitFor(() => {
expect(useAccountTemplatesStore.getState().personal.a1.templates.map((t) => t.name)).toEqual(['Big one', 'New in the account']);
expect(useTemplateStore.getState().templates.map((t) => t.name)).toEqual(['Mine']);
});
});
});
@@ -0,0 +1,134 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { TemplatePicker } from '../template-picker';
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 type { EmailTemplate } from '@/lib/template-types';
import { emptyPartition, useAccountTemplatesStore } from '@/stores/account-templates-store';
// The picker shows the user's own templates, then the admin library under
// "Organisation", from the policy of the account the message is written
// from. The global next-intl mock returns keys.
const own = (id: string, name: string, extra: Partial<EmailTemplate> = {}): EmailTemplate => ({
id, name, subject: '', body: `${name} body`, category: '', isFavorite: false,
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', ...extra,
});
const lib = (id: string, name: string) => ({ id, name, subject: `${name} subject`, body: `<p>${name}</p><script>x()</script>`, 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(<TemplatePicker isOpen onClose={() => {}} 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(<TemplatePicker isOpen onClose={() => {}} 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(<TemplatePicker isOpen onClose={() => {}} 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(<TemplatePicker isOpen onClose={() => {}} 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('<p>Welcome</p>');
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(<TemplatePicker isOpen onClose={() => {}} 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(<TemplatePicker isOpen onClose={() => {}} 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(<TemplatePicker isOpen onClose={() => {}} 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(<TemplatePicker isOpen onClose={() => {}} 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(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} />);
const line = screen.getByTestId('template-recipients');
expect(line).toHaveTextContent('preview_to');
expect(line).toHaveTextContent('preview_cc');
});
});
@@ -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<string> => '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(<TemplatePicker isOpen onClose={() => {}} 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(<TemplatePicker isOpen onClose={() => {}} 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(<TemplateManagerModal isOpen onClose={() => {}} />);
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(<TemplateManagerModal isOpen onClose={() => {}} />);
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(<TemplateManagerModal isOpen onClose={() => {}} />);
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: 'confirm_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(<TemplateManagerModal isOpen onClose={() => {}} />);
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(<TemplateManagerModal isOpen onClose={() => {}} />);
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(<TemplateManagerModal isOpen onClose={() => {}} />);
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');
});
});
@@ -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;
}
@@ -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<EmailTemplate, 'body' | 'isHTML'> }) {
if (template.isHTML) {
return (
<div
className="max-h-60 overflow-auto text-sm break-words text-foreground"
dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(template.body || '') }}
/>
);
}
return <pre className="max-h-60 overflow-auto whitespace-pre-wrap break-words font-sans text-sm text-foreground">{template.body}</pre>;
}
+26 -14
View File
@@ -24,15 +24,25 @@ interface TemplateFormProps {
};
onSave: (data: Omit<EmailTemplate, 'id' | 'createdAt' | 'updatedAt'>) => 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[];
}
export function TemplateForm({ template, initialData, onSave, onCancel }: TemplateFormProps) {
export function TemplateForm({ template, initialData, onSave, onCancel, shared = false, busy = false, categorySource }: 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 || '');
@@ -82,8 +92,8 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
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,
});
};
@@ -232,7 +242,7 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
</div>
</div>
{identities.length > 1 && (
{!shared && identities.length > 1 && (
<div>
<label className="text-sm font-medium text-foreground">{tSettings('identity')}</label>
<select
@@ -251,20 +261,22 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
)}
<div className="flex items-center justify-between pt-2">
<button
type="button"
onClick={() => setIsFavorite(!isFavorite)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Star className={cn('w-4 h-4', isFavorite && 'fill-amber-400 text-amber-400')} />
{tSettings('favorite')}
</button>
{shared ? <span /> : (
<button
type="button"
onClick={() => setIsFavorite(!isFavorite)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Star className={cn('w-4 h-4', isFavorite && 'fill-amber-400 text-amber-400')} />
{tSettings('favorite')}
</button>
)}
<div className="flex gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
{tSettings('cancel')}
</Button>
<Button type="submit" size="sm">
<Button type="submit" size="sm" disabled={busy}>
{template ? tSettings('update') : tSettings('create')}
</Button>
</div>
+318 -130
View File
@@ -1,72 +1,137 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { X, FileText, Pencil, Trash2, Star, Copy, Search } from '@/components/icons';
import { X, FileText, Pencil, Trash2, Star, Copy, Search, Eye } from '@/components/icons';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { TemplateForm } from './template-form';
import { useTemplateStore } from '@/stores/template-store';
import { TemplateBodyPreview } from './template-body-preview';
import { useFocusTrap } from '@/hooks/use-focus-trap';
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<EmailTemplate, 'id' | 'createdAt' | 'updatedAt'>;
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 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<string>(PERSONAL_SOURCE);
const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [previewId, setPreviewId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const source: TemplateSource | undefined = sources.find((s) => s.key === selectedKey) ?? sources[0];
const personal = sources.find((s) => s.kind === 'personal');
const closeForm = () => {
setIsCreating(false);
setEditingId(null);
setError(null);
};
useEffect(() => {
if (!isOpen) return;
refreshAccountTemplates(accountId);
const id = resolveAccountId(accountId);
if (id) refreshTeamTemplates(id);
}, [isOpen, accountId]);
const modalRef = useFocusTrap({
isActive: isOpen,
onEscape: () => {
if (isCreating || editingId) {
setIsCreating(false);
setEditingId(null);
} else {
onClose();
}
if (isCreating || editingId) closeForm();
else onClose();
},
restoreFocus: true,
});
const filtered = searchQuery ? searchTemplates(searchQuery) : templates;
const selectSource = (key: string) => {
setSelectedKey(key);
closeForm();
setDeleteConfirmId(null);
setPreviewId(null);
};
const handleSave = (data: Omit<EmailTemplate, 'id' | 'createdAt' | 'updatedAt'>) => {
if (editingId) {
updateTemplate(editingId, data);
setEditingId(null);
} else {
addTemplate(data);
setIsCreating(false);
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<TemplateSaveResult>, 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 = (id: string) => {
deleteTemplate(id);
setDeleteConfirmId(null);
if (!source?.editable) return;
void run(() => actions.remove(source, id), () => setDeleteConfirmId(null));
};
if (!isOpen) return null;
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 = <p className="text-xs text-muted-foreground">{tOrg('organisation_note')}</p>;
} else if (source?.kind === 'team') {
note = source.locked
? <OrgLockedNote />
: <p className="text-xs text-muted-foreground">{source.editable ? tOrg('team_note', { name: source.name ?? '' }) : tOrg('team_read_only')}</p>;
}
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
<div
@@ -74,20 +139,23 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
role="dialog"
aria-modal="true"
aria-labelledby="template-manager-title"
// The mail shortcuts behind the dialog must not see its keys.
onKeyDown={(e) => e.stopPropagation()}
className={cn(
'bg-background border border-border rounded-lg shadow-xl',
'w-full max-w-3xl max-h-[90vh] overflow-hidden',
'animate-in zoom-in-95 duration-200'
)}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-muted-foreground" />
<h2 id="template-manager-title" className="text-lg font-semibold text-foreground">
<div className="flex items-center justify-between px-4 sm:px-6 py-4 border-b border-border">
<div className="flex items-center gap-3 min-w-0">
<FileText className="w-5 h-5 text-muted-foreground flex-shrink-0" />
<h2 id="template-manager-title" className="text-lg font-semibold text-foreground truncate">
{tSettings('title')}
</h2>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
>
@@ -95,8 +163,67 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
</button>
</div>
<div className="p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
{!isCreating && !editingId && (
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
{showTabs && (
<div
role="tablist"
aria-label={tOrg('sources_label')}
className="flex flex-wrap gap-2 mb-3"
data-testid="template-sources"
onKeyDown={(e) => {
// 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) => (
<button
key={s.key}
id={tabId(s.key)}
type="button"
role="tab"
aria-selected={s.key === source?.key}
aria-controls={PANEL_ID}
tabIndex={s.key === source?.key ? 0 : -1}
data-testid={`template-source-tab-${s.kind}`}
onClick={() => 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)} <span className="text-xs opacity-70">{s.templates.length}</span>
</button>
))}
</div>
)}
<div
id={PANEL_ID}
role={showTabs ? 'tabpanel' : undefined}
aria-labelledby={showTabs && source ? tabId(source.key) : undefined}
>
{note && <div className="mb-4" data-testid="template-source-note">{note}</div>}
{!source && (
<div className="flex flex-col items-center py-8 text-muted-foreground">
<FileText className="w-10 h-10 mb-3 opacity-40" />
<p className="text-sm">{tSettings('no_templates')}</p>
</div>
)}
{source && !isCreating && !editingId && (
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
@@ -107,129 +234,185 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
className="ps-9"
/>
</div>
<Button
onClick={() => setIsCreating(true)}
size="sm"
>
{tSettings('add')}
</Button>
{editable && (
<Button onClick={() => { setIsCreating(true); setError(null); }} size="sm">
{tSettings('add')}
</Button>
)}
</div>
)}
{isCreating && (
{error && (
<p role="alert" className="mb-3 text-sm text-red-600 dark:text-red-400" data-testid="template-save-error">{error}</p>
)}
{editable && isCreating && (
<div className="mb-6 p-4 border border-border rounded-lg bg-muted/30">
<h3 className="text-sm font-semibold mb-4">{tSettings('add')}</h3>
<TemplateForm
categorySource={templates}
shared={!isPersonal}
busy={busy}
onSave={handleSave}
onCancel={() => setIsCreating(false)}
onCancel={closeForm}
/>
</div>
)}
{editingId && (
{editable && editingId && (
<div className="mb-6 p-4 border border-border rounded-lg bg-muted/30">
<h3 className="text-sm font-semibold mb-4">{tSettings('edit')}</h3>
<TemplateForm
template={templates.find((t) => t.id === editingId)}
template={templates.find((tpl) => tpl.id === editingId)}
categorySource={templates}
shared={!isPersonal}
busy={busy}
onSave={handleSave}
onCancel={() => setEditingId(null)}
onCancel={closeForm}
/>
</div>
)}
<div className="space-y-2">
{filtered.map((template) => (
<div
key={template.id}
className="flex items-center gap-3 p-3 rounded-md border border-border hover:bg-muted/50 transition-colors"
>
<button
type="button"
onClick={() => toggleFavorite(template.id)}
className="flex-shrink-0"
>
<Star
className={cn(
'w-4 h-4 transition-colors',
template.isFavorite
? 'fill-amber-400 text-amber-400'
: 'text-muted-foreground hover:text-amber-400'
)}
/>
</button>
{source && filtered.map((template) => (
<div key={template.id} className="rounded-md border border-border hover:bg-muted/50 transition-colors" data-testid="template-row">
<div className="flex items-center gap-2 sm:gap-3 p-3">
{isPersonal && (
<button
type="button"
onClick={() => void actions.toggleFavorite(source, template.id)}
aria-label={tSettings('favorite')}
aria-pressed={template.isFavorite}
className="flex-shrink-0"
>
<Star
className={cn(
'w-4 h-4 transition-colors',
template.isFavorite
? 'fill-amber-400 text-amber-400'
: 'text-muted-foreground hover:text-amber-400'
)}
/>
</button>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-foreground truncate">
{template.name}
</p>
{template.category && (
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium flex-shrink-0">
{template.category}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{template.name}
</p>
{template.category && (
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium flex-shrink-0 max-w-[40%] truncate">
{template.category}
</span>
)}
</div>
{template.subject && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{template.subject}
</p>
)}
{isPersonal && actions.localOnly[template.id] && (
<p className="text-xs text-amber-700 dark:text-amber-400 mt-1" data-testid="template-local-only">
{actions.localOnly[template.id] === 'too-large' ? tOrg('local_only_too_large') : tOrg('local_only_no_room')}
</p>
)}
</div>
{template.subject && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{template.subject}
</p>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => { setEditingId(template.id); setIsCreating(false); }}
disabled={!!editingId || isCreating}
className="h-8 w-8 p-0"
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => duplicateTemplate(template.id, t('copy_suffix'))}
disabled={!!editingId || isCreating}
className="h-8 w-8 p-0"
>
<Copy className="w-3.5 h-3.5" />
</Button>
{deleteConfirmId === template.id ? (
<div className="flex items-center gap-1">
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(template.id)}
className="h-7 text-xs"
>
{tSettings('confirm_delete')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirmId(null)}
className="h-7 text-xs"
>
{tSettings('cancel')}
</Button>
</div>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirmId(template.id)}
disabled={!!editingId || isCreating}
className="h-8 w-8 p-0 hover:text-red-600 dark:hover:text-red-400"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
<div className="flex items-center gap-1 flex-shrink-0">
{editable ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => { setEditingId(template.id); setIsCreating(false); setError(null); }}
disabled={!!editingId || isCreating}
aria-label={tSettings('edit')}
className="h-11 w-11 sm:h-8 sm:w-8 p-0"
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void run(() => actions.duplicate(source, template.id, t('copy_suffix')), () => {})}
disabled={!!editingId || isCreating || busy}
aria-label={tOrg('duplicate')}
className="h-11 w-11 sm:h-8 sm:w-8 p-0"
>
<Copy className="w-3.5 h-3.5" />
</Button>
{deleteConfirmId === template.id ? (
<div className="flex items-center gap-1">
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(template.id)}
disabled={busy}
className="h-7 text-xs"
>
{tSettings('confirm_delete')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirmId(null)}
className="h-7 text-xs"
>
{tSettings('cancel')}
</Button>
</div>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirmId(template.id)}
disabled={!!editingId || isCreating}
aria-label={tSettings('confirm_delete')}
className="h-11 w-11 sm:h-8 sm:w-8 p-0 hover:text-red-600 dark:hover:text-red-400"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</>
) : (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setPreviewId(previewId === template.id ? null : template.id)}
aria-label={t('preview')}
aria-expanded={previewId === template.id}
className="h-11 w-11 sm:h-8 sm:w-8 p-0"
>
<Eye className="w-3.5 h-3.5" />
</Button>
{personal?.editable && (
<Button
variant="ghost"
size="sm"
onClick={() => void run(() => actions.copyToPersonal(template), () => {})}
disabled={busy}
aria-label={tOrg('copy_to_personal')}
title={tOrg('copy_to_personal')}
className="h-11 w-11 sm:h-8 sm:w-8 p-0"
>
<Copy className="w-3.5 h-3.5" />
</Button>
)}
</>
)}
</div>
</div>
{previewId === template.id && (
<div className="border-t border-border px-3 py-2" data-testid="template-preview">
<TemplateBodyPreview template={template} />
</div>
)}
</div>
))}
{filtered.length === 0 && !isCreating && (
{source && filtered.length === 0 && !isCreating && (
<div className="flex flex-col items-center py-8 text-muted-foreground">
<FileText className="w-10 h-10 mb-3 opacity-40" />
<p className="text-sm">
@@ -238,6 +421,11 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
</div>
)}
</div>
{isPersonal && actions.storedInAccount && (
<p className="mt-4 text-xs text-muted-foreground" data-testid="template-storage-note">{tOrg('personal_in_account')}</p>
)}
</div>
</div>
</div>
</div>
+121 -49
View File
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { X, Search, Star, FileText } from '@/components/icons';
import { cn } from '@/lib/utils';
@@ -8,10 +8,15 @@ import { Input } from '@/components/ui/input';
import { useTemplateStore } from '@/stores/template-store';
import { useAuthStore } from '@/stores/auth-store';
import { useFocusTrap } from '@/hooks/use-focus-trap';
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 type { EmailTemplate } from '@/lib/template-types';
@@ -19,34 +24,46 @@ interface TemplatePickerProps {
isOpen: boolean;
onClose: () => void;
onSelect: (template: EmailTemplate, filledValues: Record<string, string>) => 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<EmailTemplate | null>(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]);
const modalRef = useFocusTrap({
isActive: isOpen && !showFillModal,
onEscape: onClose,
restoreFocus: true,
});
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);
@@ -71,22 +88,22 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
locale,
});
const renderTemplateItem = (template: EmailTemplate) => (
const renderTemplateItem = (template: EmailTemplate, source: TemplateSource) => (
<button
key={template.id}
key={`${source.key}:${template.id}`}
type="button"
onClick={() => handleSelectTemplate(template)}
onClick={() => handleSelectTemplate(template, source)}
className="w-full text-start p-3 rounded-md hover:bg-muted transition-colors group"
>
<div className="flex items-center gap-2">
{template.isFavorite && (
<div className="flex items-center gap-2 min-w-0">
{source.kind === 'personal' && template.isFavorite && (
<Star className="w-3 h-3 fill-amber-400 text-amber-400 flex-shrink-0" />
)}
<span className="text-sm font-medium text-foreground truncate">
{template.name}
</span>
{template.category && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium flex-shrink-0">
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium flex-shrink-0 max-w-[40%] truncate">
{template.category}
</span>
)}
@@ -96,33 +113,89 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
{template.subject}
</p>
)}
{/* Who the template addresses, before it is put into the message. */}
{(template.defaultRecipients?.to?.length || template.defaultRecipients?.cc?.length) ? (
<p className="text-xs text-muted-foreground truncate mt-1" data-testid="template-recipients">
{[
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(' · ')}
</p>
) : null}
</button>
);
const renderSection = (title: string, items: EmailTemplate[]) => {
const renderSection = (title: string, items: EmailTemplate[], source: TemplateSource) => {
if (items.length === 0) return null;
return (
<div className="mb-4">
<div className="mb-4" key={`${source.key}:${title}`}>
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1 px-3">
{title}
</h3>
<div className="space-y-0.5">{items.map(renderTemplateItem)}</div>
<div className="space-y-0.5">{items.map((item) => renderTemplateItem(item, source))}</div>
</div>
);
};
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<string, EmailTemplate[]> = {};
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 <div key={source.key}>{body}</div>;
if (templates.length === 0) return null;
return (
<section key={source.key} data-testid="template-source-personal" aria-label={sourceTitle(source)} className="mb-2">
<h2 className="text-sm font-semibold text-foreground px-3 pt-1 pb-2">{sourceTitle(source)}</h2>
{body}
</section>
);
};
const renderShared = (source: TemplateSource, items: EmailTemplate[]) => {
if (items.length === 0) return null;
return (
<section key={source.key} data-testid={`template-source-${source.kind}`} aria-label={sourceTitle(source)} className="mb-2">
<h2 className="text-sm font-semibold text-foreground px-3 pt-1 pb-2 truncate">{sourceTitle(source)}</h2>
<div className="space-y-0.5 mb-4">{items.map((item) => renderTemplateItem(item, source))}</div>
</section>
);
};
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 (
<>
@@ -132,6 +205,8 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
role="dialog"
aria-modal="true"
aria-labelledby="template-picker-title"
// The mail shortcuts behind the dialog must not see its keys.
onKeyDown={(e) => e.stopPropagation()}
className={cn(
'bg-background border border-border rounded-lg shadow-xl',
'w-full max-w-md max-h-[70vh] overflow-hidden',
@@ -141,6 +216,7 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h2 id="template-picker-title" className="text-sm font-semibold text-foreground">{t('picker_title')}</h2>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
@@ -162,35 +238,31 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
</div>
<div className="overflow-y-auto max-h-[calc(70vh-120px)] p-2">
{templates.length === 0 && (
{total === 0 && (
<div className="flex flex-col items-center py-8 text-muted-foreground">
<FileText className="w-8 h-8 mb-2 opacity-40" />
<p className="text-sm">{t('no_templates')}</p>
</div>
)}
{filtered ? (
filtered.length > 0 ? (
<div className="space-y-0.5">
{filtered.map(renderTemplateItem)}
</div>
{results ? (
resultCount > 0 ? (
onlyPersonal ? (
<div className="space-y-0.5">
{results.flatMap(({ source, items }) => items.map((item) => renderTemplateItem(item, source)))}
</div>
) : (
results.map(({ source, items }) => renderShared(source, items))
)
) : (
<div className="py-6 text-center text-sm text-muted-foreground">
{t('no_results')}
</div>
)
) : (
<>
{renderSection(t('section_favorites'), favorites)}
{renderSection(t('section_recent'), recentFiltered)}
{categorizedEntries.map(([cat, items]) =>
renderSection(
cat,
items.filter((i) => !shownIds.has(i.id))
)
)}
{renderSection(t('section_uncategorized'), uncategorizedFiltered)}
</>
sources.map((source) => source.kind === 'personal'
? renderPersonal(source, !onlyPersonal)
: renderShared(source, source.templates))
)}
</div>
</div>
+24
View File
@@ -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]);
}
+116
View File
@@ -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<TemplateSaveResult>;
update: (source: TemplateSource, id: string, data: TemplateData) => Promise<TemplateSaveResult>;
remove: (source: TemplateSource, id: string) => Promise<TemplateSaveResult>;
duplicate: (source: TemplateSource, id: string, nameSuffix: string) => Promise<TemplateSaveResult>;
toggleFavorite: (source: TemplateSource, id: string) => Promise<TemplateSaveResult>;
/** A read-only (organisation or team) template copied into the user's own. */
copyToPersonal: (template: EmailTemplate) => Promise<TemplateSaveResult>;
/** Personal templates that stay in this browser, by id. */
localOnly: Record<string, LocalOnlyReason>;
/** Personal templates live in the account (server metadata), not only in this browser. */
storedInAccount: boolean;
}
const NO_LOCAL_ONLY: Record<string, LocalOnlyReason> = {};
/**
* 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<TemplateSaveResult> =>
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,
};
}
+62
View File
@@ -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]);
}
+313
View File
@@ -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<string, unknown>;
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<void> {
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<string[]> {
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<void> {
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<void> {
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<void> {
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<string, unknown>), 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<Record<string, unknown> | 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<string> {
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<string, { n: string; e: string }> } | 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}`);
});
+469
View File
@@ -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<string, unknown>;
/** Bulwark's template entries in the Inbox metadata of an account the user can open. */
async function templateEntries(email: string, accountName = email): Promise<Record<string, Record<string, unknown>>> {
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<string, Record<string, unknown>>;
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<void> {
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<void> {
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<void> {
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<Page['getByRole']>, name: string, body: string): Promise<void> {
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<Page> {
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<void> {
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(`<p>Welcome to <b>Example</b> ${STAMP}</p>`);
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<string, unknown>).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<string, unknown>) };
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<string, unknown> } }>).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();
});
});
+424
View File
@@ -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<string, unknown> | null }));
vi.mock('@/hooks/use-config', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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 = <T,>(v: T): T => JSON.parse(JSON.stringify(v));
type Call = [string, Record<string, unknown>, string];
let server: Record<string, unknown>;
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<Record<string, unknown>> }).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<string, Record<string, unknown>>).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> = {}): 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<typeof vi.fn<(connected: boolean) => 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<string, string> }).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<string, string> = { 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']);
});
});
+31
View File
@@ -185,6 +185,37 @@ describe('the active account', () => {
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<string, boolean>) => ({ 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: [
@@ -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"
}
}
+25 -1
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { JMAPClient } from '../jmap/client';
import { MetadataTooLargeError, MetadataUnsupportedError, bulwarkEntries, isValidMetadataKey } from '../jmap/bulwark-metadata';
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
@@ -121,6 +122,29 @@ describe('setBulwarkMetadata', () => {
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', () => {
@@ -6,6 +6,9 @@ import { useAuthStore } from '@/stores/auth-store';
import { useTemplateStore } from '@/stores/template-store';
import { useIdentityStore } from '@/stores/identity-store';
import { useVacationStore } from '@/stores/vacation-store';
import { usePolicyStore } from '@/stores/policy-store';
import { emptyPartition, useAccountTemplatesStore } from '@/stores/account-templates-store';
import { DEFAULT_POLICY } from '../admin/types';
import { HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text';
import { clearAllHooks, identityHooks } from '../plugin-hooks';
@@ -53,7 +56,9 @@ beforeEach(() => {
});
afterEach(() => {
useAuthStore.setState({ client: null });
useAuthStore.setState({ client: null, activeAccountId: null });
useAccountTemplatesStore.setState({ personal: {} });
usePolicyStore.setState({ policy: DEFAULT_POLICY });
useTemplateStore.setState({ templates: [], deletedTemplateIds: {} });
useIdentityStore.setState({ identities: [] });
useVacationStore.getState().clearState();
@@ -101,6 +106,33 @@ describe('api.templates', () => {
});
});
describe('api.templates and organisation settings', () => {
it('works on the account\'s own copy when the account keeps its templates (Stalwart 1.0)', async () => {
const inAccount = { ...greeting, id: 'acc-1', name: 'In the account' };
useAuthStore.setState({ activeAccountId: 'ann@mail' });
useAccountTemplatesStore.setState({ personal: { 'ann@mail': { ...emptyPartition(), templates: [inAccount] } } });
expect((await call(ALL, 'templates.list') as Array<{ name: string }>).map((t) => t.name)).toEqual(['In the account']);
const created = await call(ALL, 'templates.create', { name: 'From a plugin' }) as { id: string };
await call(ALL, 'templates.update', 'acc-1', { subject: 'Changed' });
await call(ALL, 'templates.delete', created.id);
const copy = useAccountTemplatesStore.getState().personal['ann@mail'];
expect(copy.templates.map((t) => t.subject)).toEqual(['Changed']);
expect(copy.deletedTemplateIds).toHaveProperty(created.id);
// The browser's list is left alone.
expect(useTemplateStore.getState().templates.map((t) => t.id)).toEqual(['tpl-1']);
});
it('shows and accepts nothing when the admin hid personal templates', async () => {
usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, restrictions: { personalTemplates: { hidden: true } } } });
expect(await call(ALL, 'templates.list')).toEqual([]);
expect(await call(ALL, 'templates.get', 'tpl-1')).toBeNull();
await expect(call(ALL, 'templates.create', { name: 'x' })).rejects.toThrow(/turned personal templates off/);
await expect(call(ALL, 'templates.update', 'tpl-1', { name: 'x' })).rejects.toThrow(/turned personal templates off/);
await expect(call(ALL, 'templates.delete', 'tpl-1')).rejects.toThrow(/turned personal templates off/);
expect(useTemplateStore.getState().templates.map((t) => t.name)).toEqual(['Greeting']);
});
});
describe('api.identities.update', () => {
it('changes name, reply-to and signatures and never the address', async () => {
const view = await call(ALL, 'identities.update', 'id1', {
+346
View File
@@ -0,0 +1,346 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InstalledPlugin } from '../plugin-types';
import type { IJMAPClient } from '../jmap/client-interface';
import type { Email, Mailbox } from '../jmap/types';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { usePolicyStore } from '@/stores/policy-store';
vi.mock('@/stores/toast-store', () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
}));
import { dispatchApiCall } from '../plugin-sandbox/host-api';
import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input';
import { resetConfirmedGroups } from '../plugin-sandbox/account-kind';
import { pluginDeclaresNetwork } from '../outgoing-hosts';
import { actionApplies, checkActions } from '../plugin-sandbox/actions';
import {
activeListFilterCondition, clearPluginListFilters, listFilterRevision, listFilters, resetListFilters,
setListFilterActive,
} from '../plugin-sandbox/list-filters';
function plugin(perms: string[], id = 'team-inbox'): InstalledPlugin {
return {
id, name: 'Team inbox', version: '1.0.0', author: 'test', description: '',
type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
enabled: true, status: 'running', settings: {},
};
}
const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args);
const ACCOUNTS: Record<string, { name: string; isPersonal: boolean; isReadOnly: boolean }> = {
ci: { name: 'org-alice@example.org', isPersonal: true, isReadOnly: false },
ch: { name: 'org-team@example.org', isPersonal: false, isReadOnly: false },
cx: { name: 'boss@example.org', isPersonal: false, isReadOnly: true },
};
const teamInbox = { id: 'ch:a', originalId: 'a', name: 'Inbox', role: 'inbox', isShared: true, accountId: 'ch', totalEmails: 1, unreadEmails: 0, sortOrder: 0 } as unknown as Mailbox;
const ownInbox = { id: 'a', name: 'Inbox', role: 'inbox', totalEmails: 1, unreadEmails: 0, sortOrder: 0 } as unknown as Mailbox;
function mail(id: string, over: Partial<Email> = {}): Email {
return {
id, threadId: `t-${id}`, mailboxIds: { a: true }, keywords: { $seen: true },
from: [{ name: 'Bob', email: 'bob@example.com' }], subject: id, receivedAt: '2026-10-01T10:00:00Z',
preview: '', size: 1, hasAttachment: false, ...over,
} as Email;
}
let client: Record<string, ReturnType<typeof vi.fn>>;
beforeEach(() => {
resetConfirmedGroups();
resetPluginRateLimits();
resetListFilters();
client = {
getAccountId: vi.fn(() => 'ci'),
getUsername: vi.fn(() => 'org-alice@example.org'),
getAccountInfo: vi.fn((id: string) => (ACCOUNTS[id] ? { ...ACCOUNTS[id], accountCapabilities: {} } : null)),
getSharedAccounts: vi.fn(() => Object.keys(ACCOUNTS).map((id) => ({
id, name: ACCOUNTS[id].name, isPrimary: id === 'ci',
capabilities: { mail: true, sieve: false, calendars: false, contacts: false, filenode: false },
}))),
supportsPrincipals: vi.fn(() => true),
listPrincipals: vi.fn(async () => ({
status: 'restricted',
principals: [
{ id: 'ci', type: 'individual', name: 'org-alice@example.org', description: 'Org Alice', email: 'org-alice@example.org' },
{ id: 'ch', type: 'group', name: 'org-team@example.org', description: 'Org Team', email: 'org-team@example.org' },
],
})),
getPrincipals: vi.fn(async () => []),
getPrincipalTypes: vi.fn(async () => ({ ch: 'group', cx: 'individual' })),
batchUpdateKeywords: vi.fn(async () => undefined),
supportsBulwarkMetadata: vi.fn(() => false),
getBulwarkMetadata: vi.fn(async () => null),
setBulwarkMetadata: vi.fn(async () => undefined),
};
useAuthStore.setState({ client: client as unknown as IJMAPClient, activeAccountId: 'login1' });
usePolicyStore.setState({ subject: { email: 'org-alice@example.org', groups: ['org-team@example.org'] } });
useEmailStore.setState({ mailboxes: [ownInbox, teamInbox], selectedMailbox: 'ch:a', emails: [], selectedEmail: null, viewingAccountId: null });
});
afterEach(() => {
useAuthStore.setState({ client: null, activeAccountId: null });
usePolicyStore.setState({ subject: null });
useEmailStore.setState({ mailboxes: [], selectedMailbox: '', emails: [], selectedEmail: null, threadEmailsCache: new Map() });
});
describe('account info for plugins (account:read)', () => {
it('is refused without account:read', async () => {
await expect(call(plugin([]), 'user.getSelf')).rejects.toThrow(/lacks permission "account:read"/);
await expect(call(plugin(['email:read']), 'user.getMailAccounts')).rejects.toThrow(/account:read/);
});
it('names the user by their own account id, which is their principal id', async () => {
await expect(call(plugin(['account:read']), 'user.getSelf')).resolves.toEqual(expect.objectContaining({
accountId: 'ci', email: 'org-alice@example.org',
}));
});
it('tells group accounts from mailboxes a person shared, and marks the open folder\'s', async () => {
const accounts = await call(plugin(['account:read']), 'user.getMailAccounts');
expect(accounts).toEqual([
{ accountId: 'ci', name: 'org-alice@example.org', kind: 'personal', isReadOnly: false, isOpen: false },
{ accountId: 'ch', name: 'org-team@example.org', kind: 'group', isReadOnly: false, isOpen: true },
{ accountId: 'cx', name: 'boss@example.org', kind: 'shared', isReadOnly: true, isOpen: false },
]);
});
it('says group only for accounts the server confirmed, whatever the policy subject says', async () => {
// The policy subject (above) lists the team; the server can't say.
client.getPrincipalTypes.mockRejectedValue(new Error('Principal/get failed'));
const accounts = await call(plugin(['account:read']), 'user.getMailAccounts') as Array<{ accountId: string; kind: string }>;
expect(accounts.find((a) => a.accountId === 'ch')?.kind).toBe('shared');
expect(accounts.find((a) => a.accountId === 'cx')?.kind).toBe('shared');
});
it('counts a person\'s shared mailbox as shared even when the subject calls it a group', async () => {
usePolicyStore.setState({ subject: { email: 'org-alice@example.org', groups: ['org-team@example.org', 'boss@example.org'] } });
const accounts = await call(plugin(['account:read']), 'user.getMailAccounts') as Array<{ accountId: string; kind: string }>;
expect(accounts.find((a) => a.accountId === 'cx')?.kind).toBe('shared');
});
});
describe('the directory for plugins (directory:read)', () => {
const finder = () => plugin(['directory:read']);
it('needs directory:read, which account:read doesn\'t give and isn\'t network access', async () => {
await expect(call(plugin(['account:read']), 'user.findPeople', { text: 'team' })).rejects.toThrow(/lacks permission "directory:read"/);
// Installed by a user who didn't agree to it yet: refused.
const unconsented = { ...finder(), managed: false, grantedPermissions: ['account:read'] };
await expect(call(unconsented, 'user.findPeople', { text: 'team' })).rejects.toThrow(/directory:read/);
expect(pluginDeclaresNetwork({ permissions: ['email:read', 'email:write', 'account:read', 'directory:read', 'ui:message-list', 'ui:toolbar'] })).toBe(false);
});
it('looks people up by text or id and says when the directory is off', async () => {
await expect(call(finder(), 'user.findPeople', { text: 'team' })).resolves.toEqual({
people: [{ id: 'ch', type: 'group', name: 'Org Team', email: 'org-team@example.org' }],
complete: false,
});
await expect(call(finder(), 'user.findPeople', { ids: ['ci', 'nobody'] })).resolves.toEqual({
people: [{ id: 'ci', type: 'individual', name: 'Org Alice', email: 'org-alice@example.org' }],
complete: false,
});
// One directory listing serves both lookups.
expect(client.listPrincipals).toHaveBeenCalledTimes(1);
await expect(call(finder(), 'user.findPeople', {})).rejects.toThrow(/text or ids/);
await expect(call(finder(), 'user.findPeople', { name: 'x' })).rejects.toThrow(/unknown property/);
});
it('wants at least 3 characters of text and at most 20 ids', async () => {
await expect(call(finder(), 'user.findPeople', { text: 'or' })).rejects.toThrow(/at least 3 characters/);
const ids = Array.from({ length: 21 }, (_, i) => `p${i}`);
await expect(call(finder(), 'user.findPeople', { ids })).rejects.toThrow(/1 to 20 ids/);
await expect(call(finder(), 'user.findPeople', { ids: ids.slice(0, 20) })).resolves.toMatchObject({ people: [] });
});
it('answers at most 20 people and 10 lookups a minute', async () => {
client.listPrincipals.mockResolvedValue({
status: 'ok',
principals: Array.from({ length: 30 }, (_, i) => ({ id: `p${i}`, type: 'individual', name: `org-p${i}@example.org`, email: `org-p${i}@example.org` })),
});
const first = await call(finder(), 'user.findPeople', { text: 'org-p' }) as { people: unknown[]; complete: boolean };
expect(first.people).toHaveLength(20);
expect(first.complete).toBe(true);
for (let i = 1; i < 10; i++) await call(finder(), 'user.findPeople', { text: 'org-p' });
await expect(call(finder(), 'user.findPeople', { text: 'org-p' })).rejects.toThrow(/too many lookups/);
});
it('asks the directory of the account the call names, and only the login\'s own accounts', async () => {
await call(finder(), 'user.findPeople', { text: 'team', accountId: 'ch' });
expect(client.listPrincipals).toHaveBeenCalledWith('ch');
await expect(call(finder(), 'user.findPeople', { text: 'team', accountId: 'elsewhere' })).rejects.toThrow(/no mail account/);
});
it('keeps one load in flight per client', async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
const listing = (id: string) => async () => {
await gate;
return { status: 'ok', principals: [{ id, type: 'individual', name: `${id}@example.org`, email: `${id}@example.org` }] };
};
client.listPrincipals.mockImplementation(listing('from-first'));
const firstA = call(finder(), 'user.findPeople', { text: 'example' });
const firstB = call(finder(), 'user.findPeople', { text: 'example' });
const other = { ...client, listPrincipals: vi.fn(listing('from-second')) };
useAuthStore.setState({ client: other as unknown as IJMAPClient });
const second = call(finder(), 'user.findPeople', { text: 'example' });
release();
const [a, b, c] = await Promise.all([firstA, firstB, second]) as Array<{ people: Array<{ id: string }> }>;
expect(client.listPrincipals).toHaveBeenCalledTimes(1);
expect(other.listPrincipals).toHaveBeenCalledTimes(1);
expect([a.people[0].id, b.people[0].id, c.people[0].id]).toEqual(['from-first', 'from-first', 'from-second']);
});
});
describe('email.updateKeywords', () => {
it('needs email:write and real keywords', async () => {
await expect(call(plugin(['email:read']), 'email.updateKeywords', 'm1', { $done: true })).rejects.toThrow(/email:write/);
const writer = plugin(['email:write']);
await expect(call(writer, 'email.updateKeywords', 'm1', { 'bad@kw': true })).rejects.toThrow(/Invalid JMAP keyword/);
await expect(call(writer, 'email.updateKeywords', 'm1', { $done: 'yes' })).rejects.toThrow(/true \(set\) or false/);
});
it('swaps two keywords of a listed team message in one change and shows it in the list', async () => {
useEmailStore.setState({ emails: [mail('m1', { keywords: { $seen: true, '$assigned:cj': true } })] });
const done = await call(plugin(['email:write']), 'email.updateKeywords', 'm1', { '$assigned:cj': false, '$assigned:ck': true }, 'ch');
expect(done).toBe(true);
expect(client.batchUpdateKeywords).toHaveBeenCalledTimes(1);
expect(client.batchUpdateKeywords).toHaveBeenCalledWith(['m1'], { 'keywords/$assigned:cj': null, 'keywords/$assigned:ck': true }, 'ch');
expect(useEmailStore.getState().emails[0].keywords).toEqual({ $seen: true, '$assigned:ck': true });
});
it('refuses the keywords that change how the app and the server treat a message', async () => {
const writer = plugin(['email:write']);
for (const keyword of ['$draft', '$junk', '$NotJunk', '$phishing']) {
await expect(call(writer, 'email.updateKeywords', 'm1', { [keyword]: true })).rejects.toThrow(/can't be changed by a plugin/);
await expect(call(writer, 'email.setKeyword', 'm1', keyword)).rejects.toThrow(/can't be changed by a plugin/);
await expect(call(writer, 'email.removeKeyword', 'm1', keyword)).rejects.toThrow(/can't be changed by a plugin/);
}
await expect(call(writer, 'email.setKeyword', 'm1', 'a b')).rejects.toThrow(/Invalid JMAP keyword/);
expect(client.batchUpdateKeywords).not.toHaveBeenCalled();
});
it('updates the message in an expanded conversation too', async () => {
const inner = mail('m2', { keywords: { $seen: true }, threadId: 't-m1' });
useEmailStore.setState({
emails: [mail('m1', { keywords: { $seen: true } })],
threadEmailsCache: new Map([['t-m1', [mail('m1'), inner]]]),
});
await call(plugin(['email:write']), 'email.updateKeywords', 'm2', { $done: true }, 'ch');
expect(useEmailStore.getState().threadEmailsCache.get('t-m1')?.[1].keywords).toEqual({ $seen: true, $done: true });
expect(useEmailStore.getState().threadEmailsCache.get('t-m1')?.[0].keywords).toEqual({ $seen: true });
});
it('changes a message that isn\'t listed on the server only, in the named account', async () => {
await call(plugin(['email:write']), 'email.updateKeywords', 'other', { $done: true }, 'ch');
expect(client.batchUpdateKeywords).toHaveBeenCalledWith(['other'], { 'keywords/$done': true }, 'ch');
});
});
describe('ui.setListFilters', () => {
it('needs ui:message-list and checks what it gets', async () => {
await expect(call(plugin([]), 'ui.setListFilters', [{ id: 'open', label: 'Open', notKeyword: '$done' }])).rejects.toThrow(/ui:message-list/);
const lister = plugin(['ui:message-list']);
await expect(call(lister, 'ui.setListFilters', [{ id: 'x', label: 'No condition' }])).rejects.toThrow(/hasKeyword or notKeyword/);
await expect(call(lister, 'ui.setListFilters', [{ id: 'x', label: 'X', hasKeyword: 'a@b' }])).rejects.toThrow(/must be a keyword/);
await expect(call(lister, 'ui.setListFilters', [{ id: 'x', label: 'X', hasKeyword: '$a', accounts: ['team'] }])).rejects.toThrow(/accounts/);
await call(lister, 'ui.setListFilters', [{ id: 'open', label: 'Open', notKeyword: '$done', accounts: ['group'] }]);
expect(listFilters().map((f) => f.key)).toEqual(['team-inbox:open']);
});
it('adds the conditions of the filters that are on, only where they apply', async () => {
await call(plugin(['ui:message-list']), 'ui.setListFilters', [
{ id: 'mine', label: 'Assigned to me', hasKeyword: '$assigned:ci', accounts: ['group'] },
{ id: 'open', label: 'Open', notKeyword: '$done', accounts: ['group'] },
]);
const team = { role: 'inbox', accountKind: 'group' as const };
expect(activeListFilterCondition(team)).toBeNull();
setListFilterActive('team-inbox:open');
expect(activeListFilterCondition(team)).toEqual({ notKeyword: '$done' });
setListFilterActive('team-inbox:mine');
expect(activeListFilterCondition(team)).toEqual({ operator: 'AND', conditions: [{ hasKeyword: '$assigned:ci' }, { notKeyword: '$done' }] });
expect(activeListFilterCondition({ role: 'inbox', accountKind: 'personal' })).toBeNull();
setListFilterActive('team-inbox:mine');
expect(activeListFilterCondition(team)).toEqual({ notKeyword: '$done' });
});
it('drops a filter that was on when the plugin goes away, and says the list must load again', async () => {
await call(plugin(['ui:message-list']), 'ui.setListFilters', [{ id: 'open', label: 'Open', notKeyword: '$done' }]);
setListFilterActive('team-inbox:open');
const before = listFilterRevision();
clearPluginListFilters('team-inbox');
expect(listFilterRevision()).toBe(before + 1);
expect(activeListFilterCondition({ role: 'inbox', accountKind: 'group' })).toBeNull();
});
});
describe('action conditions', () => {
const [assign, unassign, done, reopen] = checkActions([
{ id: 'assign', label: 'Assign to me', accounts: ['group'] },
{ id: 'unassign', label: 'Unassign', accounts: ['group'], hasKeyword: '$assigned:*' },
{ id: 'done', label: 'Mark done', accounts: ['group'], notKeyword: '$done' },
{ id: 'reopen', label: 'Reopen', accounts: ['group'], hasKeyword: '$DONE' },
]);
it('shows team actions only on messages of group accounts', () => {
expect(actionApplies(assign, { accountKind: 'group', keywords: [] })).toBe(true);
expect(actionApplies(assign, { accountKind: 'personal', keywords: [] })).toBe(false);
expect(actionApplies(assign, { accountKind: 'shared', keywords: [] })).toBe(false);
// A surface that doesn't know the account hides them.
expect(actionApplies(assign, {})).toBe(false);
});
it('follows the message\'s keywords, with a prefix and in any case', () => {
expect(actionApplies(unassign, { accountKind: 'group', keywords: ['$seen'] })).toBe(false);
expect(actionApplies(unassign, { accountKind: 'group', keywords: ['$assigned:cj'] })).toBe(true);
expect(actionApplies(done, { accountKind: 'group', keywords: ['$done'] })).toBe(false);
expect(actionApplies(reopen, { accountKind: 'group', keywords: ['$done'] })).toBe(true);
expect(actionApplies(done, { accountKind: 'group', keywords: null })).toBe(false);
});
it('refuses conditions that aren\'t keywords or account kinds', () => {
expect(() => checkActions([{ id: 'a', label: 'A', hasKeyword: 'a b' }])).toThrow(/hasKeyword/);
expect(() => checkActions([{ id: 'a', label: 'A', accounts: [] }])).toThrow(/accounts/);
expect(() => checkActions([{ id: 'a', label: 'A', accounts: ['team'] }])).toThrow(/accounts/);
});
});
describe('accountData (shared plugin data in a mail account)', () => {
it('needs email:read to read and email:write to change', async () => {
await expect(call(plugin([]), 'accountData.get', 'ch')).rejects.toThrow(/email:read/);
await expect(call(plugin(['email:read']), 'accountData.set', 'ch', {})).rejects.toThrow(/email:write/);
});
it('says unsupported where the server keeps no metadata', async () => {
await expect(call(plugin(['email:read']), 'accountData.get', 'ch')).resolves.toEqual({ supported: false, value: null });
await expect(call(plugin(['email:write']), 'accountData.set', 'ch', { a: 1 })).rejects.toThrow(/keeps no shared data/);
});
it('keeps the plugin\'s value under its own entry of the account\'s Inbox', async () => {
client.supportsBulwarkMetadata.mockReturnValue(true);
client.getBulwarkMetadata.mockResolvedValue({ tags: [1], 'plugin.team-inbox': { names: { cj: 'Bob' } } });
await expect(call(plugin(['email:read']), 'accountData.get', 'ch')).resolves.toEqual({ supported: true, value: { names: { cj: 'Bob' } } });
expect(client.getBulwarkMetadata).toHaveBeenCalledWith('ch');
await call(plugin(['email:write']), 'accountData.set', 'ch', { names: {} });
expect(client.setBulwarkMetadata).toHaveBeenCalledWith('ch', 'plugin.team-inbox', { names: {} });
});
it('refuses an account the user doesn\'t reach and a value over 4 KB, leaving room for Bulwark\'s own entries', async () => {
client.supportsBulwarkMetadata.mockReturnValue(true);
await expect(call(plugin(['email:read']), 'accountData.get', 'zz')).rejects.toThrow(/no mail account/);
await expect(call(plugin(['email:write']), 'accountData.set', 'ch', 'x'.repeat(5000))).rejects.toThrow(/at most 4096/);
expect(client.setBulwarkMetadata).not.toHaveBeenCalled();
await call(plugin(['email:write']), 'accountData.set', 'ch', 'x'.repeat(4000));
expect(client.setBulwarkMetadata).toHaveBeenCalledTimes(1);
});
it('reads at most 30 times a minute', async () => {
client.supportsBulwarkMetadata.mockReturnValue(true);
client.getBulwarkMetadata.mockResolvedValue({});
for (let i = 0; i < 30; i++) await call(plugin(['email:read']), 'accountData.get', 'ch');
await expect(call(plugin(['email:read']), 'accountData.get', 'ch')).rejects.toThrow(/too many reads/);
expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(30);
});
});
+158
View File
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { JMAPClient } from '../jmap/client';
import { deleteTeamTemplate, groupAccounts, loadTeamTemplates, saveTeamTemplate } from '../templates/team-templates';
import { useAccountTemplatesStore } from '@/stores/account-templates-store';
import { useAuthStore } from '@/stores/auth-store';
import type { EmailTemplate } from '../template-types';
import recorded from './fixtures/stalwart-1.0.0-template-metadata.json';
// Team templates live in the Inbox metadata of a group account. Only group
// principals count: a mailbox another person shared is non-personal too,
// and stays out. The answers use the envelopes recorded on stw-rel.
const META = { 'urn:ietf:params:jmap:metadata': { dataTypes: { Mailbox: { namespaces: [], supportsVendorNamespaces: true, supportsPrivate: true, maxDepth: 8 } } } };
const MAIL = { 'urn:ietf:params:jmap:mail': {} };
function session() {
return {
capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:metadata': {}, 'urn:ietf:params:jmap:principals': {} },
accounts: {
bj: { name: 'org-alice@example.org', isPersonal: true, isReadOnly: false, accountCapabilities: { ...MAIL, ...META } },
bi: { name: 'org-team@example.org', isPersonal: false, isReadOnly: false, accountCapabilities: { ...MAIL, ...META } },
bk: { name: 'org-carol@example.org', isPersonal: false, isReadOnly: false, accountCapabilities: { ...MAIL, ...META } },
},
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'bj' },
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 = <T,>(v: T): T => JSON.parse(JSON.stringify(v));
type Call = [string, Record<string, unknown>, string];
let metadata: Record<string, Record<string, unknown>>;
let maySetMetadata: boolean;
let requests: Call[][];
let refuse: boolean;
function answer(body: { methodCalls: Call[] }): Response {
requests.push(body.methodCalls);
const [method, args] = body.methodCalls[body.methodCalls.length - 1];
const accountId = args.accountId as string;
if (method === 'Principal/get') {
return json({ methodResponses: [['Principal/get', { list: [{ id: 'bi', type: 'group' }, { id: 'bk', type: 'individual' }], notFound: [] }, '0']] });
}
if (method === 'Mailbox/get' && !args['#ids']) {
const rights = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, maySubmit: true, mayDelete: true, mayShare: true, maySetMetadata };
return json({ methodResponses: [['Mailbox/get', { accountId, list: [{ id: 'a', name: 'Inbox', role: 'inbox', myRights: rights }] }, '0']] });
}
if (method === 'Mailbox/get') {
const out = clone(recorded.getEmpty);
(out.methodResponses[1][1] as { list: Array<{ metadata: unknown }> }).list[0].metadata = clone(metadata[accountId] ?? {});
return json(out);
}
if (method === 'Mailbox/set') {
// Stalwart v1.0.0's refusal for a user who may read the Inbox but not set its metadata.
if (refuse) return json(clone(recorded.setForbidden));
const [path, value] = Object.entries((args.update as Record<string, Record<string, unknown>>).a)[0];
const namespace = path.replace(/^metadata\//, '');
metadata[accountId] = { ...metadata[accountId] };
if (value === null) delete metadata[accountId][namespace];
else metadata[accountId][namespace] = value;
return json(clone(recorded.setOne));
}
throw new Error(`unexpected ${method}`);
}
const tpl = (id: string, name: string, extra: Partial<EmailTemplate> = {}): EmailTemplate => ({
id, name, subject: '', body: `${name} body`, isHTML: false, category: '', isFavorite: false,
createdAt: '2026-10-01T00:00:00.000Z', updatedAt: '2026-10-01T00:00:00.000Z', ...extra,
});
let client: JMAPClient;
const original = useAuthStore.getState();
beforeEach(async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
metadata = {
bi: { 'org.bulwarkmail.template.t1': tpl('t1', 'Team reply') },
bk: { 'org.bulwarkmail.template.c1': tpl('c1', 'Carol’s own') },
};
maySetMetadata = true;
refuse = false;
requests = [];
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockResolvedValueOnce(json(session()));
client = new JMAPClient('https://mail.example.com', 'org-alice@example.org', 'pw');
await client.connect();
fetchSpy.mockReset();
fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => answer(JSON.parse(String(init?.body ?? '{}'))));
useAccountTemplatesStore.setState({ personal: {}, teams: {}, active: null });
useAuthStore.setState({ activeAccountId: 'alice@mail', getClientForAccount: (id: string) => (id === 'alice@mail' ? client : undefined) } as never);
});
afterEach(() => {
vi.restoreAllMocks();
useAuthStore.setState({ activeAccountId: original.activeAccountId, getClientForAccount: original.getClientForAccount } as never);
});
describe('team templates', () => {
it('reads the group mailbox\'s templates, not those of a mailbox a person shared', async () => {
expect(await groupAccounts(client)).toEqual([{ id: 'bi', name: 'org-team@example.org' }]);
const teams = await loadTeamTemplates('alice@mail', client);
expect(teams).toEqual([{ accountId: 'bi', name: 'org-team@example.org', templates: [tpl('t1', 'Team reply')], mayWrite: true }]);
expect(useAccountTemplatesStore.getState().teams['alice@mail']).toEqual(teams);
expect(requests.flat().some(([, args]) => args.accountId === 'bk' && args['#ids'])).toBe(false);
});
it('is read-only for a member without the right to set the Inbox\'s metadata', async () => {
maySetMetadata = false;
const [team] = await loadTeamTemplates('alice@mail', client);
expect(team.mayWrite).toBe(false);
});
it('saves into the group\'s Inbox without the member\'s identity, favourite or blind copies, and deletes', async () => {
await loadTeamTemplates('alice@mail', client);
const result = await saveTeamTemplate('alice@mail', 'bi', tpl('t2', 'Offer', {
identityId: 'alice-identity', isFavorite: true, defaultRecipients: { to: ['a@x.test'], cc: ['c@x.test'], bcc: ['boss@x.test'] },
}), null);
expect(result).toBe('ok');
const saved = metadata.bi['org.bulwarkmail.template.t2'] as EmailTemplate;
expect(saved.name).toBe('Offer');
expect(saved.identityId).toBeUndefined();
expect(saved.isFavorite).toBe(false);
expect(saved.defaultRecipients).toEqual({ to: ['a@x.test'], cc: ['c@x.test'] });
expect(useAccountTemplatesStore.getState().teams['alice@mail'][0].templates.map((t) => t.id).sort()).toEqual(['t1', 't2']);
expect(await deleteTeamTemplate('alice@mail', 'bi', 't1', tpl('t1', 'Team reply').updatedAt)).toBe('ok');
expect(metadata.bi['org.bulwarkmail.template.t1']).toBeUndefined();
expect(useAccountTemplatesStore.getState().teams['alice@mail'][0].templates.map((t) => t.id)).toEqual(['t2']);
});
it('says why the server refused a change', async () => {
await loadTeamTemplates('alice@mail', client);
expect(await saveTeamTemplate('alice@mail', 'bi', tpl('t3', 'Huge', { body: 'x'.repeat(9000) }), null)).toBe('too-large');
refuse = true;
expect(await saveTeamTemplate('alice@mail', 'bi', tpl('t4', 'Small'), null)).toBe('forbidden');
expect(metadata.bi['org.bulwarkmail.template.t4']).toBeUndefined();
});
it('refuses a change to a template someone else changed since, and shows their version', async () => {
await loadTeamTemplates('alice@mail', client);
const base = tpl('t1', 'Team reply').updatedAt;
// Bob saves first.
metadata.bi['org.bulwarkmail.template.t1'] = tpl('t1', 'Version of Bob', { updatedAt: '2026-10-20T00:00:00.000Z' });
const result = await saveTeamTemplate('alice@mail', 'bi', tpl('t1', 'Version of Alice', { updatedAt: '2026-10-21T00:00:00.000Z' }), base);
expect(result).toBe('conflict');
expect((metadata.bi['org.bulwarkmail.template.t1'] as EmailTemplate).name).toBe('Version of Bob');
expect(useAccountTemplatesStore.getState().teams['alice@mail'][0].templates[0].name).toBe('Version of Bob');
expect(await deleteTeamTemplate('alice@mail', 'bi', 't1', base)).toBe('conflict');
// A new template whose id someone took meanwhile is refused too.
expect(await saveTeamTemplate('alice@mail', 'bi', tpl('t1', 'New'), null)).toBe('conflict');
// Starting from Bob's version, the change goes through.
expect(await saveTeamTemplate('alice@mail', 'bi', tpl('t1', 'Alice again', { updatedAt: '2026-10-22T00:00:00.000Z' }), '2026-10-20T00:00:00.000Z')).toBe('ok');
});
});
+1 -1
View File
@@ -179,7 +179,7 @@ export const ADMIN_PAGES: readonly AdminPageDef[] = [
sections: [
{ id: 'shared-tags', title: 'Shared tags', keywords: 'tags labels colours group mailbox edit lock' },
{ id: 'delegates', title: 'Delegates', keywords: 'delegate access mail calendar contacts rights read write' },
{ id: 'recovery', title: 'Deleted items', keywords: 'recover restore deleted archive enterprise' },
{ id: 'recovery', title: 'Recover deleted items', keywords: 'recover restore deleted archive enterprise' },
],
},
{
+18
View File
@@ -51,6 +51,24 @@ export const RESTRICTION_KEYS: readonly RestrictionKeyDef[] = [
page: 'org-signatures',
modes: ['locked', 'hidden'],
},
{
key: 'personalTemplates',
label: 'Personal templates',
description: "Hide: users get only the organisation and team templates and can't save templates of their own. The ones they have are kept, not deleted.",
category: 'Composer',
type: 'boolean',
page: 'org-templates',
modes: ['hidden'],
},
{
key: 'teamTemplates',
label: 'Team templates',
description: 'Lock: members of a group mailbox can use its templates but not add, change or delete them.',
category: 'Composer',
type: 'boolean',
page: 'org-templates',
modes: ['locked'],
},
{ key: 'delegation', label: 'Delegates', description: 'Hide removes Settings > Sharing > Delegates, so users can no longer give someone access to mail, calendars and contacts in one step.', category: 'Sharing', type: 'boolean', page: 'org-sharing', modes: ['hidden'] },
{ key: 'sharingRights', label: 'Rights users can give', description: 'Untick "write" and every share dialog in Bulwark (folders, calendars, address books, files, Delegates) offers read access only.', category: 'Sharing', type: 'enum', allowedValues: ['read', 'write'], page: 'org-sharing', modes: ['allowedValues'] },
{ key: 'deletedItemsRecovery', label: 'Recover deleted items', description: 'Hide removes Settings > Recover deleted items and its link in Trash, so users can no longer restore mail they deleted for good.', category: 'Sharing', type: 'boolean', page: 'org-sharing', modes: ['hidden'] },
+20 -5
View File
@@ -158,6 +158,13 @@ type AccountClient = NonNullable<ReturnType<ReturnType<typeof useAuthStore.getSt
const subjects = new WeakMap<AccountClient, Promise<PolicySubject | null>>();
const identityLists = new WeakMap<AccountClient, Promise<string[]>>();
/** Whether Everyone or any domain or group scope turns this switch on. */
function switchOnAnywhere(key: 'senderLabel' | 'recipientWarning'): boolean {
const raw = usePolicyStore.getState().rawPolicy;
if (raw.organisation?.externalMail?.[key]) return true;
return (raw.scopes ?? []).some((scope) => scope.policy.organisation?.externalMail?.[key] === true);
}
/** Whether `localAccountId` names another signed-in account than the active one. */
export function isOtherAccount(localAccountId: string | null | undefined): localAccountId is string {
const auth = useAuthStore.getState();
@@ -170,15 +177,26 @@ export function isOtherAccount(localAccountId: string | null | undefined): local
* sent from. Its policy is resolved for its own subject, so a domain or
* group scope of that account applies, and its own identities count. The
* active account (or none given) reads the stores directly.
*
* With `need`, another account is only looked up when that switch can be on
* for it: off everywhere in the policy costs no request, and off for that
* account skips its identities.
*/
export async function externalMailContextFor(localAccountId?: string | null): Promise<ExternalMailContext> {
export async function externalMailContextFor(
localAccountId?: string | null,
need?: 'senderLabel' | 'recipientWarning',
): Promise<ExternalMailContext> {
if (!isOtherAccount(localAccountId)) return activeExternalMailContext();
if (need && !switchOnAnywhere(need)) return { policy: DEFAULT_ORGANISATION_POLICY.externalMail, identityEmails: [] };
const client = useAuthStore.getState().getClientForAccount(localAccountId)!;
let subject = subjects.get(client);
if (!subject) {
subject = policySubjectForClient(client).catch(() => null);
subjects.set(client, subject);
}
const policy = usePolicyStore.getState().resolveFor(await subject).organisation?.externalMail
?? DEFAULT_ORGANISATION_POLICY.externalMail;
if (need && !policy[need]) return { policy, identityEmails: [] };
let identities = identityLists.get(client);
if (!identities) {
const cached = cachedIdentitiesForAccount(localAccountId);
@@ -187,10 +205,7 @@ export async function externalMailContextFor(localAccountId?: string | null): Pr
: client.getIdentities().then((list) => list.map((identity) => identity.email)).catch(() => []);
identityLists.set(client, identities);
}
const [resolvedSubject, identityEmails] = await Promise.all([subject, identities]);
const policy = usePolicyStore.getState().resolveFor(resolvedSubject).organisation?.externalMail
?? DEFAULT_ORGANISATION_POLICY.externalMail;
return { policy, identityEmails };
return { policy, identityEmails: await identities };
}
/**
+8
View File
@@ -31,6 +31,14 @@ export class MetadataTooLargeError extends Error {
}
}
/** The server refused a metadata change; `type` is its SetError type (`forbidden`, `invalidProperties`, ...). */
export class MetadataRefusedError extends Error {
constructor(readonly type: string, message: string) {
super(message);
this.name = 'MetadataRefusedError';
}
}
export class MetadataUnsupportedError extends Error {
constructor(message = 'This server keeps no metadata for Bulwark') {
super(message);
+5 -3
View File
@@ -1,4 +1,4 @@
import { advertisesMailboxMetadata, bulwarkEntries, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata";
import { advertisesMailboxMetadata, bulwarkEntries, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataRefusedError, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata";
import { IncompleteDeleteError } from "./delete-error";
import { ArchiveEmailsError } from "./archive-error";
import { generateUUID } from '@/lib/utils';
@@ -7133,7 +7133,9 @@ export class JMAPClient implements IJMAPClient {
], ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", METADATA_CAPABILITY]);
const get = response.methodResponses?.find((r) => r[0] === "Mailbox/get");
const inbox = (get?.[1] as { list?: Array<Record<string, unknown>> } | undefined)?.list?.[0];
if (!inbox) throw new MetadataUnsupportedError("No Inbox metadata in the answer");
// An answer without the property is a server that doesn't keep it,
// never "every entry was removed".
if (!inbox || !(property in inbox)) throw new MetadataUnsupportedError("No Inbox metadata in the answer");
this.metadataInboxIds.set(id, inbox.id as string);
this.metadataUnsupported.delete(id);
return bulwarkEntries(inbox[property]);
@@ -7175,7 +7177,7 @@ export class JMAPClient implements IJMAPClient {
const notUpdated = (result as { notUpdated?: Record<string, { type?: string; description?: string }> }).notUpdated?.[inboxId];
if (notUpdated) {
if (/exceeds the maximum/i.test(notUpdated.description ?? "")) throw new MetadataTooLargeError(notUpdated.description!);
throw new Error(notUpdated.description || `Failed to save metadata (${notUpdated.type ?? "unknown"})`);
throw new MetadataRefusedError(notUpdated.type ?? "unknown", notUpdated.description || `Failed to save metadata (${notUpdated.type ?? "unknown"})`);
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ export async function ensureContactsLoaded(waitMs = CONTACTS_WAIT_MS): Promise<v
export async function recipientsToConfirm(context: OutgoingContext): Promise<ExternalRecipient[]> {
// The sending account's policy and identities: an identity of another
// signed-in account sends under that account's domain or group scopes.
const account = await externalMailContextFor(context.sender?.accountId);
const account = await externalMailContextFor(context.sender?.accountId, 'recipientWarning');
const { policy } = account;
if (!policy.recipientWarning) return [];
// The chosen identity's own address is internal. A From typed over it in
+103
View File
@@ -0,0 +1,103 @@
// What kind of mail account a message or folder belongs to, for plugins: the
// user's own, a group account (a team mailbox the user is a member of) or a
// mailbox another user shared.
//
// Plugins only hear "group" for accounts the server confirmed are group
// principals (Principal/get type "group"; Stalwart uses the account id as
// the principal id and answers for the user's own groups even with
// directory queries off). The policy subject also counts every non-personal
// account as a group when the server won't say, which is right for the
// admin's group scopes but would let a plugin treat a mailbox one person
// shared like a team mailbox. So the confirmed list is kept here, apart,
// and an account whose type isn't known is "shared".
import { create } from 'zustand';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
export type MailAccountKind = 'personal' | 'group' | 'shared';
export const MAIL_ACCOUNT_KINDS: readonly MailAccountKind[] = ['personal', 'group', 'shared'];
type KindClient = Pick<IJMAPClient, 'getAccountId'>
& Partial<Pick<IJMAPClient, 'getAccountInfo' | 'getSharedAccounts' | 'getPrincipalTypes'>>;
interface ConfirmedGroupsState {
/** Per client: the ids of its accounts the server confirmed are groups. */
groups: WeakMap<object, ReadonlySet<string>>;
/** Moves on whenever a client's list arrives, for subscribers. */
revision: number;
}
export const useConfirmedGroups = create<ConfirmedGroupsState>()(() => ({ groups: new WeakMap(), revision: 0 }));
const pending = new WeakMap<object, Promise<ReadonlySet<string>>>();
const failedAt = new WeakMap<object, number>();
/** A failed lookup is tried again after this long, not on every render. */
export const CONFIRM_RETRY_MS = 60_000;
/** The non-personal accounts of a client's session. */
function sharedAccountIds(client: KindClient): string[] {
const own = client.getAccountId();
const accounts = typeof client.getSharedAccounts === 'function' ? client.getSharedAccounts() : [];
return accounts
.filter((a) => a.id !== own && client.getAccountInfo?.(a.id)?.isPersonal !== true)
.map((a) => a.id);
}
/**
* Asks the server once per client which of its accounts are groups. A
* failed or missing lookup confirms none, and is asked again a minute later.
*/
export function confirmGroups(client: KindClient): Promise<ReadonlySet<string>> {
const known = useConfirmedGroups.getState().groups.get(client);
if (known) return Promise.resolve(known);
const running = pending.get(client);
if (running) return running;
const failed = failedAt.get(client);
if (failed !== undefined && Date.now() - failed < CONFIRM_RETRY_MS) return Promise.resolve(new Set());
const ids = sharedAccountIds(client);
const lookup = (async () => {
let confirmed: ReadonlySet<string> = new Set();
let answered = ids.length === 0;
if (ids.length > 0 && typeof client.getPrincipalTypes === 'function') {
const types = await client.getPrincipalTypes(ids).catch(() => null);
if (types) {
answered = true;
confirmed = new Set(ids.filter((id) => types[id] === 'group'));
}
}
if (!answered) failedAt.set(client, Date.now());
if (answered) {
useConfirmedGroups.setState((state) => {
state.groups.set(client, confirmed);
return { groups: state.groups, revision: state.revision + 1 };
});
}
return confirmed;
})().finally(() => pending.delete(client));
pending.set(client, lookup);
return lookup;
}
/**
* The kind of the JMAP account `accountId` reached through `client`. No
* account, or the client's own, is personal. A group needs the server's
* word; until it is in (this starts the lookup), or when the lookup
* failed, the account is shared.
*/
export function mailAccountKind(client: KindClient | null | undefined, accountId: string | null | undefined): MailAccountKind {
if (!client || !accountId || accountId === client.getAccountId()) return 'personal';
const info = typeof client.getAccountInfo === 'function' ? client.getAccountInfo(accountId) : null;
if (info?.isPersonal) return 'personal';
const confirmed = useConfirmedGroups.getState().groups.get(client);
if (!confirmed) {
void confirmGroups(client);
return 'shared';
}
return confirmed.has(accountId) ? 'group' : 'shared';
}
/** For tests: forget every confirmed list. */
export function resetConfirmedGroups(): void {
useConfirmedGroups.setState({ groups: new WeakMap(), revision: 0 });
}
+65 -4
View File
@@ -5,6 +5,7 @@
import { slotSpec } from '../plugin-slot-catalog';
import { isAllowedPluginCombo } from './shortcuts';
import { MAIL_ACCOUNT_KINDS, type MailAccountKind } from './account-kind';
export interface PluginAction {
id: string;
@@ -17,6 +18,20 @@ export interface PluginAction {
mailboxRoles?: string[];
/** Show only for attachments or files of these MIME types (prefix match, "image/"). */
mimeTypes?: string[];
/**
* Show only for messages in these kinds of mail account (message surfaces):
* the user's own, a group account (a team mailbox) or a mailbox someone
* shared. Where the host can't tell the account, the action is hidden.
*/
accounts?: MailAccountKind[];
/**
* Show only when the message has this keyword (message surfaces). A
* trailing `*` matches any keyword that starts with the rest
* (`"$assigned:*"`). Hidden where there is no single message.
*/
hasKeyword?: string;
/** Show only when the message lacks this keyword; `*` as in hasKeyword. */
notKeyword?: string;
/** A danger action is drawn in red. */
danger?: boolean;
/**
@@ -47,9 +62,12 @@ export interface RegisteredAction extends PluginAction {
export const MAX_ACTIONS_PER_SURFACE = 8;
const ID_RE = /^[a-z0-9][a-z0-9-_.]{0,39}$/i;
const ICON_RE = /^[A-Za-z0-9-]{1,40}$/;
/** A host icon name, the one rule for plugin actions, list filters and row badges. */
export const PLUGIN_ICON_RE = /^[A-Za-z0-9-]{1,40}$/;
const ROLE_RE = /^[a-z]{1,20}$/;
const MIME_RE = /^[a-z0-9.+-]+\/[a-z0-9.+*-]*$/i;
// A JMAP keyword as plugins may set one (host-api.ts), or its start and `*`.
const KEYWORD_PATTERN_RE = /^[a-z0-9$][a-z0-9$_.:-]{0,127}\*?$/i;
/** Surfaces that take actions, from the slot catalogue. */
export function isActionSurface(surface: string): boolean {
@@ -70,7 +88,7 @@ export function checkActions(value: unknown): PluginAction[] {
if (typeof a.label !== 'string' || !a.label.trim() || a.label.length > 60) throw new Error(`actions[${i}].label is required (max 60 characters)`);
const out: PluginAction = { id: a.id, label: a.label.trim() };
if (a.icon !== undefined) {
if (typeof a.icon !== 'string' || !ICON_RE.test(a.icon)) throw new Error(`actions[${i}].icon is invalid`);
if (typeof a.icon !== 'string' || !PLUGIN_ICON_RE.test(a.icon)) throw new Error(`actions[${i}].icon is invalid`);
out.icon = a.icon;
}
if (a.order !== undefined) {
@@ -89,6 +107,19 @@ export function checkActions(value: unknown): PluginAction[] {
}
out.mimeTypes = (a.mimeTypes as string[]).map((m) => m.toLowerCase());
}
if (a.accounts !== undefined) {
if (!Array.isArray(a.accounts) || a.accounts.length === 0 || !a.accounts.every((k) => (MAIL_ACCOUNT_KINDS as readonly unknown[]).includes(k))) {
throw new Error(`actions[${i}].accounts must be a list of "personal", "group" and "shared"`);
}
out.accounts = [...new Set(a.accounts as MailAccountKind[])];
}
for (const field of ['hasKeyword', 'notKeyword'] as const) {
if (a[field] === undefined) continue;
if (typeof a[field] !== 'string' || !KEYWORD_PATTERN_RE.test(a[field] as string)) {
throw new Error(`actions[${i}].${field} must be a keyword, optionally ending in *`);
}
out[field] = (a[field] as string).toLowerCase();
}
if (a.danger !== undefined) out.danger = a.danger === true;
if (a.checked !== undefined) {
if (typeof a.checked !== 'boolean') throw new Error(`actions[${i}].checked must be true or false`);
@@ -164,9 +195,39 @@ export function badgeText(count: number): string {
return count > 99 ? '99+' : String(count);
}
/** Whether an action applies to a folder role and/or a MIME type. */
export function actionApplies(action: PluginAction, ctx: { mailboxRole?: string | null; mimeType?: string | null }): boolean {
/**
* What an action's conditions are checked against. A missing folder role or
* MIME type isn't checked; a missing account kind or keyword list hides the
* actions that name one, since the surface is not about one message.
*/
export interface ActionFilter {
mailboxRole?: string | null;
mimeType?: string | null;
/** The account kind of the message (or of the open folder, for the selection toolbar). */
accountKind?: MailAccountKind | null;
/** The message's keywords. */
keywords?: readonly string[] | null;
}
/** Whether `keywords` holds `pattern` (exact, or a prefix ending in `*`), case-insensitively. */
export function keywordMatches(keywords: readonly string[], pattern: string): boolean {
const p = pattern.toLowerCase();
if (p.endsWith('*')) {
const prefix = p.slice(0, -1);
return keywords.some((k) => k.toLowerCase().startsWith(prefix));
}
return keywords.some((k) => k.toLowerCase() === p);
}
/** Whether an action applies to a folder role, a MIME type, a message's account and keywords. */
export function actionApplies(action: PluginAction, ctx: ActionFilter): boolean {
if (action.mailboxRoles && ctx.mailboxRole !== undefined && !action.mailboxRoles.includes(ctx.mailboxRole ?? '')) return false;
if (action.accounts && (!ctx.accountKind || !action.accounts.includes(ctx.accountKind))) return false;
if (action.hasKeyword || action.notKeyword) {
if (!ctx.keywords) return false;
if (action.hasKeyword && !keywordMatches(ctx.keywords, action.hasKeyword)) return false;
if (action.notKeyword && keywordMatches(ctx.keywords, action.notKeyword)) return false;
}
if (action.mimeTypes && ctx.mimeType !== undefined) {
const type = (ctx.mimeType ?? '').toLowerCase();
if (!action.mimeTypes.some((m) => (m.endsWith('/') || m.endsWith('/*') ? type.startsWith(m.replace(/\*$/, '')) : type === m))) return false;
+257
View File
@@ -0,0 +1,257 @@
// Host side of what a plugin may know about the signed-in user and the mail
// accounts they reach (permission account:read): their own account and
// address, which accounts are team (group) mailboxes, and people from the
// server's directory. And a small piece of shared data a plugin may keep in a
// mail account (api.accountData, Stalwart 1.0 metadata).
//
// Stalwart uses the account id as the principal id, so the user's own
// account id names them in the directory and in keywords other members see.
import type { IJMAPClient } from '../jmap/client-interface';
import type { InstalledPlugin } from '../plugin-types';
import { useAccountStore } from '@/stores/account-store';
import { useIdentityStore } from '@/stores/identity-store';
import { resolveEmailBlobContext } from '@/stores/email-store';
import { isValidMetadataKey, MetadataTooLargeError, metadataValueBytes } from '../jmap/bulwark-metadata';
import { confirmGroups, mailAccountKind, type MailAccountKind } from './account-kind';
import { readObject, readString, sessionClient, withinRate } from './host-api-input';
export interface PluginSelfView {
/** The user's own JMAP account id, which Stalwart also uses as their principal id. */
accountId: string;
/** The address the user signed in with. */
email: string;
/** Their name: the account's display name, else their first identity's, else the address. */
name: string;
}
export interface PluginMailAccountView {
accountId: string;
/** The account's name; for a group account the group's address. */
name: string;
kind: MailAccountKind;
isReadOnly: boolean;
/** The open folder belongs to this account. */
isOpen: boolean;
}
export interface PluginPersonView {
id: string;
/** "individual", "group", or what else the server says. */
type: string;
name: string;
email: string;
}
export function doUserGetSelf(): PluginSelfView {
const client = sessionClient('user.getSelf');
const email = client.getUsername?.() ?? '';
const accountId = client.getAccountId();
const { accounts, activeAccountId } = useAccountStore.getState();
const active = accounts.find((a) => a.id === activeAccountId);
const identity = useIdentityStore.getState().identities.find((i) => i.name);
return {
accountId,
email,
name: active?.displayName?.trim() || identity?.name?.trim() || email,
};
}
function mailAccountIds(client: IJMAPClient): string[] {
const own = client.getAccountId();
const shared = typeof client.getSharedAccounts === 'function' ? client.getSharedAccounts() : [];
const ids = shared.length > 0 ? shared.filter((a) => a.isPrimary || a.capabilities.mail).map((a) => a.id) : [own];
if (!ids.includes(own)) ids.unshift(own);
return ids;
}
/**
* The mail accounts of the signed-in login, each with its kind. Waits for
* the server's word on which are groups (account-kind.ts), so a plugin
* asking right after sign-in already hears "group" for a team mailbox.
*/
export async function doUserGetMailAccounts(): Promise<PluginMailAccountView[]> {
const client = sessionClient('user.getMailAccounts');
await confirmGroups(client);
const own = client.getAccountId();
const open = resolveEmailBlobContext({}, client);
const openId = open.client === client ? open.accountId || own : null;
return mailAccountIds(client).map((accountId) => {
const info = client.getAccountInfo?.(accountId);
return {
accountId,
name: info?.name || (accountId === own ? client.getUsername?.() ?? '' : accountId),
kind: mailAccountKind(client, accountId),
isReadOnly: info?.isReadOnly === true,
isOpen: accountId === openId,
};
});
}
/** `accountArg` as one of the login's mail accounts, or the user's own when not given. */
function namedAccount(client: IJMAPClient, method: string, accountArg: unknown): string {
if (accountArg === undefined) return client.getAccountId();
const accountId = readString(accountArg, method, 'accountId', 256, { min: 1 });
if (!mailAccountIds(client).includes(accountId)) throw new Error(`${method}: no mail account "${accountId}"`);
return accountId;
}
// ─── Directory (directory:read) ───────────────────────────────
/** Most people one user.findPeople call returns. */
export const MAX_PLUGIN_PEOPLE = 20;
/** Most ids one call may ask for. */
export const MAX_PLUGIN_PEOPLE_IDS = 20;
/** The shortest search text: one or two letters would page through the directory. */
export const MIN_PLUGIN_PEOPLE_TEXT = 3;
/** Lookups one plugin may make a minute. */
export const PLUGIN_PEOPLE_PER_MINUTE = 10;
const DIRECTORY_TTL_MS = 5 * 60_000;
interface DirectoryCache {
at: number;
complete: boolean;
people: PluginPersonView[];
}
/** Per client, per account: the listing and the load in flight. */
const directories = new WeakMap<object, Map<string, DirectoryCache>>();
const directoryLoads = new WeakMap<object, Map<string, Promise<DirectoryCache>>>();
async function loadDirectory(client: IJMAPClient, accountId: string): Promise<DirectoryCache> {
const cached = directories.get(client)?.get(accountId);
if (cached && Date.now() - cached.at < DIRECTORY_TTL_MS) return cached;
let loads = directoryLoads.get(client);
if (!loads) {
loads = new Map();
directoryLoads.set(client, loads);
}
const running = loads.get(accountId);
if (running) return running;
const inFlight = loads;
const load = (async () => {
let complete = false;
let principals: Array<{ id: string; type?: string; name?: string; description?: string | null; email?: string | null }> = [];
if (client.supportsPrincipals()) {
if (typeof client.listPrincipals === 'function') {
const listing = await client.listPrincipals(accountId);
complete = listing.status === 'ok';
principals = listing.principals;
} else {
principals = await client.getPrincipals(accountId);
complete = true;
}
}
const entry: DirectoryCache = {
at: Date.now(),
complete,
people: principals.map((p) => ({
id: p.id,
type: typeof p.type === 'string' ? p.type : 'individual',
name: (p.description || p.name || p.email || p.id).trim(),
email: (p.email || (p.name?.includes('@') ? p.name : '') || '').trim().toLowerCase(),
})),
};
let byAccount = directories.get(client);
if (!byAccount) {
byAccount = new Map();
directories.set(client, byAccount);
}
byAccount.set(accountId, entry);
return entry;
})().finally(() => { inFlight.delete(accountId); });
inFlight.set(accountId, load);
return load;
}
/**
* People from the server's directory, as the account named (`accountId`,
* else the user's own) sees it: by principal id (`ids`, at most 20), or
* whose name or address contains `text` (3 characters or more). At most 20
* results and 10 lookups a minute. `complete` is false when the
* administrator turned directory lookups off: then only the people the user
* already shares an account with (their own and their groups) are known.
*/
export async function doUserFindPeople(plugin: InstalledPlugin, query: unknown): Promise<{ people: PluginPersonView[]; complete: boolean }> {
const method = 'user.findPeople';
const opts = readObject(query, method, 'query', ['text', 'ids', 'accountId']);
const client = sessionClient(method);
let ids: Set<string> | null = null;
if (opts.ids !== undefined) {
if (!Array.isArray(opts.ids) || opts.ids.length === 0 || opts.ids.length > MAX_PLUGIN_PEOPLE_IDS
|| !opts.ids.every((id) => typeof id === 'string' && id.length > 0 && id.length <= 256)) {
throw new Error(`${method}: ids must be a list of 1 to ${MAX_PLUGIN_PEOPLE_IDS} ids`);
}
ids = new Set(opts.ids as string[]);
}
const text = opts.text === undefined ? '' : readString(opts.text, method, 'text', 200, { trim: true }).toLowerCase();
if (!ids && !text) throw new Error(`${method}: give text or ids`);
if (text && text.length < MIN_PLUGIN_PEOPLE_TEXT) throw new Error(`${method}: text needs at least ${MIN_PLUGIN_PEOPLE_TEXT} characters`);
const accountId = namedAccount(client, method, opts.accountId);
if (!withinRate('directory', plugin.id, PLUGIN_PEOPLE_PER_MINUTE)) throw new Error(`${method}: too many lookups, try again in a minute`);
const { people, complete } = await loadDirectory(client, accountId);
const found = people.filter((p) => (!ids || ids.has(p.id))
&& (!text || p.name.toLowerCase().includes(text) || p.email.includes(text)));
return { people: found.slice(0, MAX_PLUGIN_PEOPLE), complete };
}
// ─── Shared data in a mail account ────────────────────────────
/**
* Largest value a plugin keeps in one account: half of the 8 KB Stalwart
* takes per entry. Every entry counts against the mailbox's 64 KB, which
* Bulwark's own shared tags and team templates need too.
*/
export const MAX_PLUGIN_DATA_BYTES = 4096;
/** Reads and changes one plugin may make a minute. */
export const PLUGIN_DATA_READS_PER_MINUTE = 30;
export const PLUGIN_DATA_WRITES_PER_MINUTE = 20;
/** The metadata key a plugin's data lives under: org.bulwarkmail.plugin.<id>. */
export function pluginDataKey(pluginId: string): string | null {
const key = `plugin.${pluginId.toLowerCase()}`;
return isValidMetadataKey(key) ? key : null;
}
/**
* The plugin's value in the account's shared Inbox metadata. `supported` is
* false on servers without metadata (Stalwart before 1.0); the value is then
* null.
*/
export async function doAccountDataGet(plugin: InstalledPlugin, accountArg: unknown): Promise<{ supported: boolean; value: unknown }> {
const method = 'accountData.get';
const client = sessionClient(method);
const accountId = namedAccount(client, method, accountArg);
const key = pluginDataKey(plugin.id);
if (!key || typeof client.getBulwarkMetadata !== 'function' || !client.supportsBulwarkMetadata?.(accountId)) {
return { supported: false, value: null };
}
if (!withinRate('account-data-read', plugin.id, PLUGIN_DATA_READS_PER_MINUTE)) throw new Error(`${method}: too many reads, try again in a minute`);
const entries = await client.getBulwarkMetadata(accountId);
if (!entries) return { supported: false, value: null };
return { supported: true, value: entries[key] ?? null };
}
/** Replace (or with null remove) the plugin's value in the account's shared Inbox metadata. */
export async function doAccountDataSet(plugin: InstalledPlugin, accountArg: unknown, value: unknown): Promise<void> {
const method = 'accountData.set';
const client = sessionClient(method);
const accountId = namedAccount(client, method, accountArg);
const key = pluginDataKey(plugin.id);
if (!key || typeof client.setBulwarkMetadata !== 'function' || !client.supportsBulwarkMetadata?.(accountId)) {
throw new Error(`${method}: this server keeps no shared data for plugins`);
}
if (value === undefined) throw new Error(`${method}: value is required (null removes it)`);
let size: number;
try { size = metadataValueBytes(value); } catch { throw new Error(`${method}: value must be JSON`); }
if (value !== null && size > MAX_PLUGIN_DATA_BYTES) {
throw new Error(`${method}: the value is ${size} bytes; at most ${MAX_PLUGIN_DATA_BYTES} fit`);
}
if (!withinRate('account-data', plugin.id, PLUGIN_DATA_WRITES_PER_MINUTE)) throw new Error(`${method}: too many changes, try again in a minute`);
try {
await client.setBulwarkMetadata(accountId, key, value);
} catch (error) {
if (error instanceof MetadataTooLargeError) throw new Error(`${method}: the server has no room for the value (${error.message})`);
throw new Error(`${method}: ${error instanceof Error ? error.message : 'could not save'}`);
}
}
+33 -12
View File
@@ -1,5 +1,6 @@
// Host side of api.templates, api.identities and api.vacation: the message
// templates (kept in the browser and synced with the settings), the sender
// templates (the active account's own: in the account on Stalwart 1.0, in the
// browser and synced with the settings elsewhere), the sender
// identities and the out-of-office reply. Changes go through the same stores
// and the same HTML sanitising as Settings, so what a plugin saves is held to
// the rules a user's own edit is.
@@ -8,7 +9,8 @@ import type {
IdentityView, TemplateInput, VacationInput, VacationView, TemplateView,
} from '../plugin-types';
import type { EmailAddress, Identity } from '../jmap/types';
import { useTemplateStore } from '@/stores/template-store';
import { personalTemplatesFor } from '@/lib/templates/personal-templates';
import { usePolicyStore } from '@/stores/policy-store';
import { updateIdentityWithHooks, useIdentityStore } from '@/stores/identity-store';
import { useVacationStore } from '@/stores/vacation-store';
import { identityToView, templateToView, vacationToView } from '../plugin-projection';
@@ -29,13 +31,27 @@ const MAX_TEMPLATE_SUBJECT = 998;
const MAX_TEMPLATE_BODY = 100_000;
const TEMPLATE_FIELDS = ['name', 'subject', 'body', 'isHTML', 'category'] as const;
// The active account's own templates: kept in the account on Stalwart 1.0,
// in the browser's list otherwise (lib/templates/personal-templates.ts).
const personal = () => personalTemplatesFor();
// The admin hid personal templates for the active account
// (`personalTemplates`): plugins see none and can't make any either.
const personalHidden = () => usePolicyStore.getState().policy.restrictions?.personalTemplates?.hidden === true;
function refuseIfHidden(method: string): void {
if (personalHidden()) throw new Error(`${method}: the administrator turned personal templates off`);
}
export function doTemplatesList(): TemplateView[] {
return useTemplateStore.getState().templates.map(templateToView);
if (personalHidden()) return [];
return personal().templates().map(templateToView);
}
export function doTemplatesGet(id: unknown): TemplateView | null {
const templateId = readId(id, 'templates.get');
const template = useTemplateStore.getState().templates.find((t) => t.id === templateId);
if (personalHidden()) return null;
const template = personal().templates().find((t) => t.id === templateId);
return template ? templateToView(template) : null;
}
@@ -59,13 +75,14 @@ export function doTemplatesCreate(input: unknown): TemplateView {
const method = 'templates.create';
const data = readObject(input, method, 'template', TEMPLATE_FIELDS) as TemplateInput & Record<string, unknown>;
if (data.name === undefined) throw new Error(`${method}: name is required`);
const store = useTemplateStore.getState();
if (store.templates.length >= MAX_PLUGIN_TEMPLATES) {
refuseIfHidden(method);
const store = personal();
if (store.templates().length >= MAX_PLUGIN_TEMPLATES) {
throw new Error(`${method}: there are already ${MAX_PLUGIN_TEMPLATES} templates`);
}
const isHTML = data.isHTML === undefined ? false : readBoolean(data.isHTML, method, 'isHTML');
const fields = templateFields(method, data, isHTML);
const created = store.addTemplate({
const created = store.add({
name: fields.name!,
subject: fields.subject ?? '',
body: fields.body ?? '',
@@ -81,23 +98,27 @@ export function doTemplatesUpdate(id: unknown, patch: unknown): TemplateView {
const templateId = readId(id, method);
const data = readObject(patch, method, 'patch', TEMPLATE_FIELDS);
if (Object.keys(data).length === 0) throw new Error(`${method}: patch is empty`);
const existing = useTemplateStore.getState().templates.find((t) => t.id === templateId);
refuseIfHidden(method);
const store = personal();
const existing = store.templates().find((t) => t.id === templateId);
if (!existing) throw new Error(`${method}: no template with id "${templateId}"`);
const isHTML = data.isHTML === undefined ? !!existing.isHTML : readBoolean(data.isHTML, method, 'isHTML');
const fields = templateFields(method, data, isHTML);
// Turning an existing body into HTML must not let unsanitised text through.
if (isHTML && !existing.isHTML && fields.body === undefined) fields.body = sanitizePluginBodyHtml(existing.body || '');
useTemplateStore.getState().updateTemplate(templateId, fields);
return templateToView(useTemplateStore.getState().templates.find((t) => t.id === templateId)!);
store.update(templateId, fields);
return templateToView(store.templates().find((t) => t.id === templateId)!);
}
export function doTemplatesDelete(id: unknown): void {
const method = 'templates.delete';
const templateId = readId(id, method);
if (!useTemplateStore.getState().templates.some((t) => t.id === templateId)) {
refuseIfHidden(method);
const store = personal();
if (!store.templates().some((t) => t.id === templateId)) {
throw new Error(`${method}: no template with id "${templateId}"`);
}
useTemplateStore.getState().deleteTemplate(templateId);
store.remove(templateId);
}
// ─── Identities ───────────────────────────────────────────────
+115 -5
View File
@@ -11,13 +11,15 @@ import { proxyAccounts, proxyCancel, proxyConnect, proxyDisconnect, proxyFetch,
import { checkActions, isActionSurface, setPluginActions } from './actions';
import { checkDayLabels, setDayLabels } from './day-labels';
import { refreshRowBadges } from './row-badges';
import { checkListFilters, setPluginListFilters } from './list-filters';
import { checkPages, setPluginPages } from './pages';
import { toast as appToast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
import { claimLegacyPluginStorage, pluginStoragePrefix } from './storage-scope';
import { useIdentityStore } from '@/stores/identity-store';
import { useEmailStore } from '@/stores/email-store';
import { resolveEmailBlobContext, useEmailStore } from '@/stores/email-store';
import { keywordPointer } from '../jmap/patch-pointer';
import { useFilterStore } from '@/stores/filter-store';
import { useMessageListTabsStore } from '@/stores/message-list-tabs-store';
import {
@@ -34,12 +36,12 @@ import { DEFAULT_KEYWORD_SCAN_LIMIT } from '../jmap/client';
import { isServerSetKeyword } from '../jmap/server-keywords';
import { suggestKeywordColor } from '../keyword-discovery';
import { MAX_KEYWORD_LENGTH } from '../keyword-nesting';
import { KEYWORD_PREFIX } from '../thread-utils';
import { emailKeyFor, KEYWORD_PREFIX } from '../thread-utils';
import { DEFAULT_SEARCH_FILTERS, normalizePluginFilter } from '../jmap/search-utils';
import { awaitDialog, awaitPrompt, awaitCustomDialog, type PromptField } from './host-dialog';
import { fileStorage } from '../plugin-storage';
import { generateUUID } from '../utils';
import { AddressBook, ContactCard, Identity } from '../jmap/types';
import { AddressBook, ContactCard, Email, Identity } from '../jmap/types';
import { EncryptionAtRestConfig, PublicKeyInfo, PublicKeyInput, useAccountSecurityStore } from '@/stores/account-security-store';
import {
doCalendarCreateEvent, doCalendarDeleteEvent, doCalendarGetEvent, doCalendarGetEvents,
@@ -56,6 +58,7 @@ import {
import { MAX_PLUGIN_FETCH_BYTES, readFileName, readMediaType, sessionClient } from './host-api-input';
import { doComposeOpen, doComposeReply, doDraftsCreate } from './host-api-compose';
import { doNotificationsShow } from './host-api-notify';
import { doAccountDataGet, doAccountDataSet, doUserFindPeople, doUserGetMailAccounts, doUserGetSelf } from './host-api-account';
import { setSectionTitle } from './section-titles';
import { requestPanel } from './panel-requests';
import { doComposerAttach, doComposerGetBody, doComposerGetSelection, doComposerReplaceBody, doComposerReplaceSelection } from './host-api-composer';
@@ -152,6 +155,15 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
'addressbook.create': 'contacts:write',
// user
'user.getAccounts': 'account:read',
// The user's own account (principal id) and address, and which mail
// accounts are team (group) mailboxes.
'user.getSelf': 'account:read',
'user.getMailAccounts': 'account:read',
// People from the server's directory: names and addresses of others.
'user.findPeople': 'directory:read',
// A plugin's own value in a mail account's shared Inbox metadata.
'accountData.get': 'email:read',
'accountData.set': 'email:write',
'user.getIdentities': 'identity:read',
// The IANA zone the app shows times in (Settings or the browser's).
'user.getTimeZone': null,
@@ -182,6 +194,7 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
// email keyword mutations
'email.setKeyword': 'email:write',
'email.removeKeyword': 'email:write',
'email.updateKeywords': 'email:write',
// Native keyword definitions are a deliberately narrow settings API: a
// plugin can read definitions, append missing ones, or reorder the complete
// existing set, but cannot overwrite or remove user-managed tags.
@@ -221,6 +234,7 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
'ui.setActions': null,
// Ask the rows again for onEmailListItemRender badges (rate limited).
'ui.refreshRowBadges': null,
'ui.setListFilters': 'ui:message-list',
'ui.setDayLabels': 'ui:calendar-action',
'ui.setPages': 'ui:app-page',
'ui.setSectionTitle': 'ui:sidebar-widget',
@@ -1310,6 +1324,22 @@ function assertPluginKeyword(keyword: unknown): string {
return keyword;
}
/**
* Keywords that change what the app and the server do with a message, not
* just how it is labelled: a draft opens in the composer, $junk and
* $notjunk train the spam filter, $phishing hides the content. The
* email.* keyword methods leave them to the app's own actions.
*/
const PROTECTED_KEYWORDS = new Set(['$draft', '$junk', '$notjunk', '$phishing']);
function assertMailKeyword(keyword: unknown): string {
const checked = assertPluginKeyword(keyword);
if (PROTECTED_KEYWORDS.has(checked.toLowerCase())) {
throw new Error(`JMAP keyword "${checked}" can't be changed by a plugin`);
}
return checked;
}
function assertPluginKeywords(value: unknown): Record<string, true> {
if (!isPlainObject(value)) {
throw new Error('jmap.setKeywords: keywords must be an object');
@@ -1350,6 +1380,79 @@ function requireClient() {
return client;
}
/** Most keywords one email.updateKeywords call changes. */
export const MAX_PLUGIN_KEYWORD_CHANGES = 20;
/**
* Set (true) and clear (false) several keywords of one message in one
* Email/set, so a swap (one assignee for another) never leaves both or
* neither. A message listed in the open view, or the open one, changes
* through the list as the app's own actions do: the row shows the new
* keywords at once and the keyword hooks run. Another message is changed on
* the server only. `accountId` names the message's account (a click's
* context carries it for team and shared mailboxes); without it the
* signed-in account's.
*/
async function doEmailUpdateKeywords(emailIdArg: unknown, changesArg: unknown, accountArg: unknown): Promise<boolean> {
const method = 'email.updateKeywords';
const emailId = assertEmailId(emailIdArg, method);
if (!isPlainObject(changesArg)) throw new Error(`${method}: changes must be an object of keyword: true | false`);
const entries = Object.entries(changesArg);
if (entries.length > MAX_PLUGIN_KEYWORD_CHANGES) throw new Error(`${method}: at most ${MAX_PLUGIN_KEYWORD_CHANGES} keywords at once`);
const changes: Record<string, boolean> = {};
for (const [keyword, value] of entries) {
assertMailKeyword(keyword);
if (typeof value !== 'boolean') throw new Error(`${method}: keyword "${keyword}" must be true (set) or false (clear)`);
changes[keyword] = value;
}
const accountId = assertOptionalAccountId(accountArg, method);
if (entries.length === 0) return true;
const client = requireClient();
const own = client.getAccountId();
const wanted = accountId && accountId !== own ? accountId : undefined;
const state = useEmailStore.getState();
const isTarget = (email: Email) => {
if (email.id !== emailId) return false;
const owner = resolveEmailBlobContext(email, client);
if (owner.client !== client) return false;
return (owner.accountId && owner.accountId !== own ? owner.accountId : undefined) === wanted;
};
const row = [...state.emails, ...(state.selectedEmail ? [state.selectedEmail] : [])].find(isTarget);
let done = true;
if (row) {
done = await state.patchEmailKeywords(client, emailId, changes, emailKeyFor(row));
} else {
const patch = Object.fromEntries(Object.entries(changes).map(([keyword, value]) => [keywordPointer(keyword), value ? true : null]));
await client.batchUpdateKeywords([emailId], patch, wanted);
}
if (done) patchThreadCache(isTarget, changes);
return done;
}
/**
* The same change on the messages of expanded conversations
* (threadEmailsCache), whose inner rows patchEmailKeywords doesn't touch.
*/
function patchThreadCache(isTarget: (email: Email) => boolean, changes: Record<string, boolean>): void {
useEmailStore.setState((state) => {
let cache: Map<string, Email[]> | null = null;
for (const [key, members] of state.threadEmailsCache) {
if (!members.some(isTarget)) continue;
cache ??= new Map(state.threadEmailsCache);
cache.set(key, members.map((email) => {
if (!isTarget(email)) return email;
const keywords = { ...email.keywords };
for (const [keyword, on] of Object.entries(changes)) {
if (on) keywords[keyword] = true;
else delete keywords[keyword];
}
return { ...email, keywords };
}));
}
return cache ? { threadEmailsCache: cache } : {};
});
}
// ─── Native keyword definitions ──────────────────────────────
const MAX_PLUGIN_KEYWORD_DEFINITIONS = 500;
@@ -1894,6 +1997,7 @@ export async function dispatchApiCall(
return undefined;
}
case 'ui.refreshRowBadges': refreshRowBadges(); return undefined;
case 'ui.setListFilters': setPluginListFilters(plugin.id, plugin.name, checkListFilters(args[0])); return undefined;
case 'ui.setDayLabels': setDayLabels(plugin.id, plugin.name, checkDayLabels(args[0])); return undefined;
case 'ui.setPages': setPluginPages(plugin.id, plugin.name, checkPages(args[0])); return undefined;
case 'ui.setSectionTitle': setSectionTitle(plugin.id, args[0]); return undefined;
@@ -1981,6 +2085,11 @@ export async function dispatchApiCall(
case 'addressbook.create': return doAddressBookCreate(args[0] as string);
case 'user.getAccounts': return doUserGetAccounts();
case 'user.getSelf': return doUserGetSelf();
case 'user.getMailAccounts': return doUserGetMailAccounts();
case 'user.findPeople': return doUserFindPeople(plugin, args[0]);
case 'accountData.get': return doAccountDataGet(plugin, args[0]);
case 'accountData.set': return doAccountDataSet(plugin, args[0], args[1]);
case 'user.getIdentities': return doUserGetIdentities();
case 'user.getTimeZone': return userTimeZone();
case 'app.getInfo': return doAppGetInfo();
@@ -2092,14 +2201,15 @@ export async function dispatchApiCall(
case 'email.setKeyword': {
const emailId = assertEmailId(args[0], 'email.setKeyword');
const keyword = assertPluginKeyword(args[1]);
const keyword = assertMailKeyword(args[1]);
const accountId = assertOptionalAccountId(args[2], 'email.setKeyword');
await requireClient().setKeyword(emailId, keyword, accountId);
return undefined;
}
case 'email.updateKeywords': return doEmailUpdateKeywords(args[0], args[1], args[2]);
case 'email.removeKeyword': {
const emailId = assertEmailId(args[0], 'email.removeKeyword');
const keyword = assertPluginKeyword(args[1]);
const keyword = assertMailKeyword(args[1]);
const accountId = assertOptionalAccountId(args[2], 'email.removeKeyword');
await requireClient().removeKeyword(emailId, keyword, accountId);
return undefined;
+219
View File
@@ -0,0 +1,219 @@
// Keyword filters plugins offer for the message list (`api.ui.setListFilters`).
// The host draws each as a chip in the list header of the folders it applies
// to; a chip the user turns on adds its keyword condition to the folder's
// query (ANDed with the folder, a category tab and the other chips) and can
// be removed again with one click. Which chips are on lasts until the page is
// reloaded, across folders: a chip that doesn't apply to a folder is neither
// shown nor applied there.
import type { MailAccountKind } from './account-kind';
import { MAIL_ACCOUNT_KINDS } from './account-kind';
import { PLUGIN_ICON_RE } from './actions';
export interface PluginListFilter {
id: string;
/** The chip's text, at most 40 characters. */
label: string;
/** Name of a host icon (see components/icons). */
icon?: string;
/** Only messages with this keyword. */
hasKeyword?: string;
/** Only messages without this keyword. */
notKeyword?: string;
/** Only in folders of these kinds of mail account (personal, group, shared). */
accounts?: MailAccountKind[];
/** Only in folders with these roles, e.g. ["inbox"]. */
mailboxRoles?: string[];
/** Lower comes first; default 100. */
order?: number;
}
export interface RegisteredListFilter extends PluginListFilter {
pluginId: string;
pluginName: string;
/** `<pluginId>:<id>`, unique across plugins. */
key: string;
}
export const MAX_LIST_FILTERS_PER_PLUGIN = 4;
const ID_RE = /^[a-z0-9][a-z0-9-_.]{0,39}$/i;
const ROLE_RE = /^[a-z]{1,20}$/;
// The keyword rule of the plugin API (host-api.ts PLUGIN_KEYWORD_RE).
const KEYWORD_RE = /^[a-z0-9$][a-z0-9$_.:-]{0,127}$/i;
/** Check and copy what a plugin sent; throws with a message the plugin sees. */
export function checkListFilters(value: unknown): PluginListFilter[] {
if (!Array.isArray(value)) throw new Error('filters must be a list');
if (value.length > MAX_LIST_FILTERS_PER_PLUGIN) throw new Error(`At most ${MAX_LIST_FILTERS_PER_PLUGIN} filters per plugin`);
const seen = new Set<string>();
return value.map((raw, i) => {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`filters[${i}] must be an object`);
const f = raw as Record<string, unknown>;
if (typeof f.id !== 'string' || !ID_RE.test(f.id)) throw new Error(`filters[${i}].id is invalid`);
if (seen.has(f.id)) throw new Error(`filters[${i}].id is used twice`);
seen.add(f.id);
if (typeof f.label !== 'string' || !f.label.trim() || f.label.trim().length > 40) throw new Error(`filters[${i}].label is required (max 40 characters)`);
const out: PluginListFilter = { id: f.id, label: f.label.trim() };
for (const field of ['hasKeyword', 'notKeyword'] as const) {
if (f[field] === undefined) continue;
if (typeof f[field] !== 'string' || !KEYWORD_RE.test(f[field] as string)) throw new Error(`filters[${i}].${field} must be a keyword`);
out[field] = (f[field] as string).toLowerCase();
}
if (!out.hasKeyword && !out.notKeyword) throw new Error(`filters[${i}] needs hasKeyword or notKeyword`);
if (f.icon !== undefined) {
if (typeof f.icon !== 'string' || !PLUGIN_ICON_RE.test(f.icon)) throw new Error(`filters[${i}].icon is invalid`);
out.icon = f.icon;
}
if (f.accounts !== undefined) {
if (!Array.isArray(f.accounts) || f.accounts.length === 0 || !f.accounts.every((k) => (MAIL_ACCOUNT_KINDS as readonly unknown[]).includes(k))) {
throw new Error(`filters[${i}].accounts must be a list of "personal", "group" and "shared"`);
}
out.accounts = [...new Set(f.accounts as MailAccountKind[])];
}
if (f.mailboxRoles !== undefined) {
if (!Array.isArray(f.mailboxRoles) || f.mailboxRoles.length > 10 || !f.mailboxRoles.every((r) => typeof r === 'string' && ROLE_RE.test(r))) {
throw new Error(`filters[${i}].mailboxRoles must be a list of folder roles`);
}
out.mailboxRoles = [...f.mailboxRoles] as string[];
}
if (f.order !== undefined) {
if (typeof f.order !== 'number' || !Number.isFinite(f.order)) throw new Error(`filters[${i}].order must be a number`);
out.order = f.order;
}
return out;
});
}
// ─── Registry ─────────────────────────────────────────────────
const registry = new Map<string, RegisteredListFilter[]>();
const active = new Set<string>();
const listeners = new Set<() => void>();
let snapshot: RegisteredListFilter[] | null = null;
let activeSnapshot: ReadonlySet<string> = new Set();
/** Moves on whenever the set of applied filters changes, so the list loads again. */
let revision = 0;
function changed(activeChanged: boolean): void {
snapshot = null;
if (activeChanged) {
activeSnapshot = new Set(active);
revision += 1;
}
for (const l of listeners) {
try { l(); } catch { /* ignore */ }
}
}
export function subscribeListFilters(listener: () => void): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
export function setPluginListFilters(pluginId: string, pluginName: string, filters: PluginListFilter[]): void {
const previous = registry.get(pluginId) ?? [];
if (filters.length === 0) registry.delete(pluginId);
else registry.set(pluginId, filters.map((f) => ({ ...f, pluginId, pluginName, key: `${pluginId}:${f.id}` })));
// A filter that is on and changed its condition or went away loads the list again.
let activeChanged = false;
for (const key of [...active]) {
if (!key.startsWith(`${pluginId}:`)) continue;
const before = previous.find((f) => f.key === key);
const after = registry.get(pluginId)?.find((f) => f.key === key);
if (!after) {
active.delete(key);
activeChanged = true;
} else if (!before || before.hasKeyword !== after.hasKeyword || before.notKeyword !== after.notKeyword) {
activeChanged = true;
}
}
changed(activeChanged);
}
/** Drop everything a plugin offered (it was turned off or removed). */
export function clearPluginListFilters(pluginId: string): void {
if (!registry.delete(pluginId)) return;
let activeChanged = false;
for (const key of [...active]) {
if (key.startsWith(`${pluginId}:`)) {
active.delete(key);
activeChanged = true;
}
}
changed(activeChanged);
}
function snapshotFilters(): RegisteredListFilter[] {
if (snapshot) return snapshot;
const out: RegisteredListFilter[] = [];
for (const filters of registry.values()) out.push(...filters);
out.sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.label.localeCompare(b.label));
snapshot = out;
return out;
}
/** Every offered filter, in order; the same array until something changes. */
export function listFilters(): RegisteredListFilter[] {
return snapshotFilters();
}
/** The keys of the filters that are on; the same set until one is turned on or off. */
export function activeListFilterKeys(): ReadonlySet<string> {
return activeSnapshot;
}
export function listFilterRevision(): number {
return revision;
}
/** Turn a filter on or off (toggle without `on`). */
export function setListFilterActive(key: string, on?: boolean): void {
if (!snapshotFilters().some((f) => f.key === key)) return;
const next = on ?? !active.has(key);
if (next === active.has(key)) return;
if (next) active.add(key);
else active.delete(key);
changed(true);
}
/** The folder a filter is checked against. */
export interface ListFilterFolder {
role?: string | null;
accountKind: MailAccountKind | null;
}
export function listFilterApplies(filter: PluginListFilter, folder: ListFilterFolder): boolean {
if (filter.mailboxRoles && !filter.mailboxRoles.includes(folder.role ?? '')) return false;
if (filter.accounts && (!folder.accountKind || !filter.accounts.includes(folder.accountKind))) return false;
return true;
}
/** The JMAP condition of the filters that are on and apply to `folder`, or null. */
export function activeListFilterCondition(folder: ListFilterFolder): Record<string, unknown> | null {
const conditions: Record<string, unknown>[] = [];
for (const filter of snapshotFilters()) {
if (!active.has(filter.key) || !listFilterApplies(filter, folder)) continue;
if (filter.hasKeyword) conditions.push({ hasKeyword: filter.hasKeyword });
if (filter.notKeyword) conditions.push({ notKeyword: filter.notKeyword });
}
if (conditions.length === 0) return null;
return conditions.length === 1 ? conditions[0] : { operator: 'AND', conditions };
}
/** The keywords the filters that are on and apply to `folder` test, lowercase. */
export function activeListFilterKeywords(folder: ListFilterFolder): Set<string> {
const out = new Set<string>();
for (const filter of snapshotFilters()) {
if (!active.has(filter.key) || !listFilterApplies(filter, folder)) continue;
if (filter.hasKeyword) out.add(filter.hasKeyword);
if (filter.notKeyword) out.add(filter.notKeyword);
}
return out;
}
/** Test helper. */
export function resetListFilters(): void {
registry.clear();
active.clear();
changed(true);
}
+4 -2
View File
@@ -27,6 +27,7 @@ import { registerShortcuts } from './shortcuts';
import { mayOfferSlot, mayRegisterHook, pluginHasPermission, slotPropsFor } from './permissions';
import { clearPluginActions, setActionDispatcher } from './actions';
import { clearDayLabels } from './day-labels';
import { clearPluginListFilters } from './list-filters';
import { clearPluginPages } from './pages';
import { clearSectionTitle } from './section-titles';
import { cancelPluginProxyRequests } from './proxy-client';
@@ -376,9 +377,10 @@ export function unloadSandboxedPlugin(pluginId: string): void {
// Drop any message-list category tabs the plugin registered so the strip
// disappears (and the inbox unfilters) the moment the plugin is disabled.
try { useMessageListTabsStore.getState().clearTabs(pluginId); } catch { /* ignore */ }
// Same for the buttons, day labels and pages it declared, and anything
// still running through the proxy for it.
// Same for the buttons, list filters, day labels and pages it declared,
// and anything still running through the proxy for it.
clearPluginActions(pluginId);
clearPluginListFilters(pluginId);
clearDayLabels(pluginId);
clearPluginPages(pluginId);
clearSectionTitle(pluginId);
+60 -1
View File
@@ -427,6 +427,32 @@ function buildPluginApi(manifest: PluginManifest) {
},
user: {
getAccounts: () => callApi('user.getAccounts', []),
/**
* The signed-in user: their own JMAP account id (Stalwart's principal
* id), address and name. Needs account:read.
*/
getSelf: () => callApi('user.getSelf', []) as Promise<{ accountId: string; email: string; name: string }>,
/**
* Every mail account of the signed-in login: their own ('personal'),
* team mailboxes they are a member of ('group') and mailboxes others
* shared with them ('shared'); `isOpen` marks the open folder's.
* Needs account:read.
*/
getMailAccounts: () => callApi('user.getMailAccounts', []) as Promise<Array<{
accountId: string; name: string; kind: 'personal' | 'group' | 'shared'; isReadOnly: boolean; isOpen: boolean;
}>>,
/**
* People from the server's directory, as the named mail account (else
* the user's own) sees it: by principal id (at most 20) or by a part
* of their name or address (3 characters or more). At most 20 results
* and 10 lookups a minute. `complete` is false when the administrator
* turned directory lookups off; only the people the user shares an
* account with are known then. Needs directory:read.
*/
findPeople: (query: { text?: string; ids?: string[]; accountId?: string }) =>
callApi('user.findPeople', [query], NETWORK_API_TIMEOUT_MS) as Promise<{
people: Array<{ id: string; type: string; name: string; email: string }>; complete: boolean;
}>,
getIdentities: () => callApi('user.getIdentities', []),
/** The IANA time zone the app shows times in (the user's setting, else the browser's). */
getTimeZone: () => callApi('user.getTimeZone', []) as Promise<string>,
@@ -614,8 +640,21 @@ function buildPluginApi(manifest: PluginManifest) {
* palette, ...). Replaces what this plugin set there before; [] removes
* them. Clicks arrive in the `onAction({ surface, actionId, context })` hook.
*/
setActions: (surface: string, actions: Array<{ id: string; label: string; icon?: string; order?: number; mailboxRoles?: string[]; mimeTypes?: string[]; danger?: boolean }>) =>
setActions: (surface: string, actions: Array<{
id: string; label: string; icon?: string; order?: number; mailboxRoles?: string[]; mimeTypes?: string[]; danger?: boolean;
accounts?: Array<'personal' | 'group' | 'shared'>; hasKeyword?: string; notKeyword?: string;
}>) =>
callApi('ui.setActions', [surface, actions]) as Promise<void>,
/**
* Keyword filters for the message list, drawn as chips in the list
* header of the folders they apply to. A chip the user turns on adds
* its condition to the folder's query; replaces this plugin's earlier
* filters, [] removes them. Needs ui:message-list.
*/
setListFilters: (filters: Array<{
id: string; label: string; icon?: string; hasKeyword?: string; notKeyword?: string;
accounts?: Array<'personal' | 'group' | 'shared'>; mailboxRoles?: string[]; order?: number;
}>) => callApi('ui.setListFilters', [filters]) as Promise<void>,
/**
* Ask every visible message row for its onEmailListItemRender badges
* again, e.g. after a task was created elsewhere. At most once a second.
@@ -711,6 +750,26 @@ function buildPluginApi(manifest: PluginManifest) {
callApi('email.setKeyword', [emailId, keyword, accountId]) as Promise<void>,
removeKeyword: (emailId: string, keyword: string, accountId?: string) =>
callApi('email.removeKeyword', [emailId, keyword, accountId]) as Promise<void>,
/**
* Set (true) and clear (false) up to 20 keywords of a message in one
* change, e.g. `{ '$assigned:b': true, '$assigned:c': false }`. A
* message in the open list shows the change at once. Resolves to false
* when another plugin's onBeforeEmailKeywordChange stopped it.
*/
updateKeywords: (emailId: string, changes: Record<string, boolean>, accountId?: string) =>
callApi('email.updateKeywords', [emailId, changes, accountId]) as Promise<boolean>,
},
// A small JSON value (at most 4 KB) the plugin keeps in a mail account's
// Inbox, shared with everyone who can open that account (Stalwart 1.0
// metadata; it counts against the mailbox's metadata room). get needs
// email:read, set email:write; 30 reads and 20 changes a minute.
accountData: {
/** `supported` is false on servers without metadata; `value` is then null. */
get: (accountId?: string) =>
callApi('accountData.get', [accountId], NETWORK_API_TIMEOUT_MS) as Promise<{ supported: boolean; value: unknown }>,
/** Replace the value; null removes it. Throws where `get` says unsupported. */
set: (accountId: string | undefined, value: unknown) =>
callApi('accountData.set', [accountId, value], NETWORK_API_TIMEOUT_MS) as Promise<void>,
},
// The composer (permission: compose:open). Opens it filled in; the user
// reviews the message and decides whether to send it.
+2 -2
View File
@@ -58,7 +58,7 @@ export const SLOT_CATALOG = {
// ─── Message list ──────────────────────────────────────────
'message-list-header': S('mail-list', 'frame', ['ui:message-list'], { mailboxId: null, mailboxRole: null }, 'above the message list'),
'message-list-empty': S('mail-list', 'frame', ['ui:message-list'], { mailboxId: null, mailboxRole: null, searching: null }, 'in an empty message list, below the "no messages" text'),
'message-row-actions': S('mail-list', 'action', ['ui:message-list'], { emailId: 'email:read' }, 'hover buttons on a message row, and the row menu on phones', true),
'message-row-actions': S('mail-list', 'action', ['ui:message-list'], { emailId: 'email:read', accountId: 'email:read' }, 'hover buttons on a message row, and its right-click menu (the row menu on phones); the click\'s `accountId` names the message\'s account when it is not the user\'s own (a team or shared mailbox)', true),
'message-bulk-actions': S('mail-list', 'action', ['ui:message-list'], { emailIds: 'email:read' }, 'buttons in the toolbar shown while messages are selected', true),
'context-menu-email': S('mail-list', 'frame', ['ui:context-menu'], {}, 'the right-click menu of a message'),
'toolbar-actions': S('mail-list', 'frame', ['ui:toolbar'], {}, 'the message toolbar', true),
@@ -71,7 +71,7 @@ export const SLOT_CATALOG = {
'reader-above-quote': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'between the new text of a message and the quoted text below it'),
'email-footer': S('reader', 'frame', ['ui:email-footer'], { emailId: 'email:read' }, 'below the message body'),
'thread-summary': S('reader', 'frame', ['ui:email-banner'], { threadId: 'email:read', emailIds: 'email:read', deviceType: null }, 'above the messages of an open conversation (the phone conversation view and Mountain View; the default desktop layout opens one message at a time and has no conversation view)'),
'reader-actions': S('reader', 'action', ['ui:toolbar'], { emailId: 'email:read' }, 'buttons in the open message\'s toolbar', true),
'reader-actions': S('reader', 'action', ['ui:toolbar'], { emailId: 'email:read', accountId: 'email:read' }, 'buttons in the open message\'s toolbar (its More menu on phones); `accountId` as on message-row-actions', true),
'email-detail-sidebar': S('reader', 'frame', ['ui:email-details'], { email: 'email:read', selection: 'email:read', focusRequest: null }, 'a side panel next to the open message; `selection` is the text selected in the message as the user changes it ({ text }); `focusRequest` counts up when the plugin opened the panel with api.ui.openPanel'),
'email-details-section': S('reader', 'frame', ['ui:email-details'], { email: 'email:read', category: null }, 'a section in the message details'),
'attachment-actions': S('reader', 'frame', ['email:read'], { attachment: null }, 'buttons on each attachment', true),
+4
View File
@@ -908,6 +908,8 @@ export interface EmailListBadge {
color?: string;
/** Tooltip / aria-label */
title?: string;
/** Name of a host icon (see components/icons) shown before the label, e.g. "check" or "user". */
icon?: string;
}
// ─── Message-list category tabs (Gmail-style inbox tabs) ─────
@@ -1350,6 +1352,8 @@ export const ALL_PERMISSIONS = [
'auth:observe',
'auth:emit',
'account:read',
// Look people up in the server's directory (api.user.findPeople).
'directory:read',
'http:post', 'http:fetch',
'ui:observe', 'ui:toolbar', 'ui:app-top-banner', 'ui:email-banner', 'ui:email-footer', 'ui:contact-cryptokeys',
'ui:email-details',
+15
View File
@@ -38,6 +38,21 @@ export function onFullSignOut(cleanup: () => void): void {
signedOutCleanups.add(cleanup);
}
// Work that has to reach the server before an account signs out (changes
// still queued for it). The auth store waits for it within its sign-out
// budget, next to the plugins' onBeforeLogout.
const beforeSignOutTasks = new Set<(accountId: string) => Promise<void>>();
/** Run `task` before an account signs out; `accountId` is 'all' when every account does. */
export function onBeforeSignOut(task: (accountId: string) => Promise<void>): () => void {
beforeSignOutTasks.add(task);
return () => beforeSignOutTasks.delete(task);
}
export async function runBeforeSignOut(accountId: string): Promise<void> {
await Promise.all([...beforeSignOutTasks].map((task) => task(accountId).catch(() => {})));
}
/** Remove the accounts' leftovers from browser storage. */
export function purgeSignedOutData(): void {
if (typeof window === 'undefined') return;
+10
View File
@@ -98,6 +98,16 @@ export function mergeStoredTemplates(
stored: Record<string, unknown> | null,
incoming: Record<string, unknown>
): Record<string, unknown> {
// The account keeps its templates in its own server storage (JMAP
// metadata, Stalwart 1.0): the blob drops its copy. A push from a build
// that doesn't know this brings them back, and the next push from one
// that does drops them again.
if (incoming.templatesInMetadata === true) {
const rest = { ...incoming };
delete rest.templates;
delete rest.deletedTemplateIds;
return rest;
}
const storedTemplates = stored ? rawTemplates(stored.templates) : null;
if (!storedTemplates) return incoming;
+6 -1
View File
@@ -191,6 +191,9 @@ export {
type SyncedTemplateState,
} from './template-merge';
/** `createdAt` / `updatedAt` of a synced template that has none. */
const UNKNOWN_TIME = new Date(0).toISOString();
/**
* Structurally validates a `templates` value from a synced settings blob or
* settings-file import. Returns null when the value is not an array (a blob
@@ -210,7 +213,9 @@ export function parseSyncedTemplates(value: unknown): EmailTemplate[] | null {
seen.add(t.id);
const recipients = t.defaultRecipients as Record<string, unknown> | undefined;
const now = new Date().toISOString();
// A fixed time, not now: a copy without a date must never win a merge
// by `updatedAt` against one that has a date.
const now = UNKNOWN_TIME;
templates.push({
id: t.id,
+26
View File
@@ -0,0 +1,26 @@
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { useAuthStore } from '@/stores/auth-store';
/** What the template code asks of a signed-in account's client. */
export type TemplateClient = Pick<IJMAPClient, 'getAccountId'> & Partial<Pick<IJMAPClient,
'supportsBulwarkMetadata' | 'getBulwarkMetadata' | 'setBulwarkMetadata'
| 'getSharedAccounts' | 'getAccountInfo' | 'getPrincipalTypes' | 'getMailboxes'>>;
/** The client of the signed-in account `localAccountId`, or null when it isn't connected. */
export function clientFor(localAccountId: string): TemplateClient | null {
const auth = useAuthStore.getState();
const client = auth.getClientForAccount?.(localAccountId) ?? (localAccountId === auth.activeAccountId ? auth.client : null);
if (!client || typeof (client as { getAccountId?: unknown }).getAccountId !== 'function') return null;
return client as unknown as TemplateClient;
}
/** Whether Bulwark may use the metadata of this account of the client. */
export function hasMetadata(
client: TemplateClient | null | undefined,
accountId: string,
): client is TemplateClient & Required<Pick<TemplateClient, 'getBulwarkMetadata' | 'setBulwarkMetadata'>> {
return !!client
&& typeof client.getBulwarkMetadata === 'function'
&& typeof client.setBulwarkMetadata === 'function'
&& client.supportsBulwarkMetadata?.(accountId) === true;
}
+205
View File
@@ -0,0 +1,205 @@
/**
* Keeps each signed-in account's personal templates in its own Inbox
* metadata (Stalwart v1.0.0 and later; lib/templates/metadata-templates.ts).
*
* Every round:
* - reads the account's template entries and the marker entry with the
* tombstones of deleted templates;
* - merges in the browser's own list (template-store, which settings sync
* fills) when it is this account's, each version once, minus templates
* a newer tombstone deleted;
* - merges this browser's copy and the server copy by id and `updatedAt`;
* - uploads what the server lacks, writes the marker (tombstones first),
* then removes the entries the tombstones cover.
* A missing entry is uploaded again, never taken as a deletion. A template
* the server refuses for its size stays in this browser.
*
* While it isn't known whose the browser's list is (settings sync is on
* and hasn't loaded this account's settings yet), nothing is taken from
* it and no marker is written, since the marker lets the settings blob
* drop its copy.
*
* Servers without metadata, and the pre-release 1.0 build that drops the
* connection on metadata requests, never answer: their accounts keep using
* the browser's list, and none of this touches the connection state
* (client.getBulwarkMetadata catches every failure, and pauses the account
* for ten minutes).
*/
import { MetadataTooLargeError } from '@/lib/jmap/bulwark-metadata';
import type { EmailTemplate } from '@/lib/template-types';
import { mergeSyncedTemplates } from '@/lib/template-utils';
import { debug } from '@/lib/debug';
import { hasMetadata, type TemplateClient } from './account-clients';
import {
MARKER_KEY,
MARKER_VERSION,
isTooLargeForMetadata,
markerFromMetadata,
planPersonalSync,
sameTombstones,
templateKey,
templateValue,
templatesFromMetadata,
unionTombstones,
withStorableId,
} from './metadata-templates';
import {
emptyPartition,
useAccountTemplatesStore,
type LocalOnlyReason,
type PersonalPartition,
} from '@/stores/account-templates-store';
/** The browser's own template list, as far as this account is concerned. */
export type BrowserList =
/** Not known yet whose it is: take nothing, write no marker. */
| { status: 'unknown' }
/** It belongs to another account (or to none in particular and was given to another). */
| { status: 'none' }
/** It is this account's. `claim` records that it went into this account. */
| { status: 'list'; templates: EmailTemplate[]; claim?: () => void };
export type SyncOutcome = 'synced' | 'unsupported';
const running = new Map<string, Promise<SyncOutcome>>();
const again = new Set<string>();
/**
* One round for the signed-in account `localAccountId`. Rounds for the
* same account don't overlap; asking during one runs another afterwards.
*/
export function syncPersonalTemplates(localAccountId: string, client: TemplateClient, browserList: () => BrowserList): Promise<SyncOutcome> {
const current = running.get(localAccountId);
if (current) {
again.add(localAccountId);
return current;
}
const round = (async (): Promise<SyncOutcome> => {
try {
let outcome = await syncRound(localAccountId, client, browserList);
while (again.delete(localAccountId) && outcome === 'synced') {
outcome = await syncRound(localAccountId, client, browserList);
}
return outcome;
} finally {
running.delete(localAccountId);
again.delete(localAccountId);
}
})();
running.set(localAccountId, round);
return round;
}
/** Waits for a round of the account that is under way. */
export function pendingRound(localAccountId: string): Promise<SyncOutcome> | undefined {
return running.get(localAccountId);
}
async function syncRound(localAccountId: string, client: TemplateClient, browserList: () => BrowserList): Promise<SyncOutcome> {
const accountId = client.getAccountId();
if (!accountId || !hasMetadata(client, accountId)) return 'unsupported';
const entries = await client.getBulwarkMetadata(accountId);
if (!entries) return 'unsupported';
const server = templatesFromMetadata(entries);
const marker = markerFromMetadata(entries);
const serverTombstones = marker?.deleted ?? {};
const browser = browserList();
const store = useAccountTemplatesStore.getState();
let start: PersonalPartition = store.personal[localAccountId] ?? emptyPartition();
// Every id has to fit a metadata key.
if (start.templates.some((t) => withStorableId(t) !== t)) {
start = { ...start, templates: start.templates.map(withStorableId) };
}
if (browser.status === 'list') {
const fresh = browser.templates.map(withStorableId).filter((t) => start.fromBrowser[t.id] !== t.updatedAt);
if (fresh.length > 0) {
const merged = mergeSyncedTemplates(
{ templates: start.templates, deletedTemplateIds: unionTombstones(start.deletedTemplateIds, serverTombstones) },
{ templates: fresh, deletedTemplateIds: {} },
);
const fromBrowser = { ...start.fromBrowser };
for (const t of fresh) fromBrowser[t.id] = t.updatedAt;
start = { ...start, templates: merged.templates, deletedTemplateIds: merged.deletedTemplateIds, fromBrowser };
}
browser.claim?.();
}
if (start !== store.personal[localAccountId]) store.setPersonal(localAccountId, start);
const plan = planPersonalSync(start, server, serverTombstones);
const serverAfter: Record<string, EmailTemplate> = { ...server };
const localOnly: Record<string, LocalOnlyReason> = {};
let reachable = true;
for (const template of plan.uploads) {
if (isTooLargeForMetadata(template)) {
localOnly[template.id] = 'too-large';
continue;
}
if (!reachable) continue;
try {
await client.setBulwarkMetadata(accountId, templateKey(template.id), templateValue(template));
serverAfter[template.id] = template;
} catch (error) {
if (error instanceof MetadataTooLargeError) {
localOnly[template.id] = 'no-room';
} else {
// Offline or refused: what is left goes up next time.
debug.warn('templates', 'Could not save a template in the account:', error);
reachable = false;
}
}
}
// The tombstones go up before the entries they delete come off.
let serverDeleted = serverTombstones;
let markerOnServer = marker !== null;
const markerAllowed = browser.status !== 'unknown';
if (reachable && markerAllowed && (!marker || !sameTombstones(marker.deleted, plan.deletedTemplateIds))) {
try {
await client.setBulwarkMetadata(accountId, MARKER_KEY, { version: MARKER_VERSION, deleted: plan.deletedTemplateIds });
serverDeleted = plan.deletedTemplateIds;
markerOnServer = true;
} catch (error) {
debug.warn('templates', 'Could not save the deleted templates in the account:', error);
reachable = false;
}
}
for (const id of plan.deletes) {
if (!reachable || serverDeleted[id] === undefined) continue;
try {
await client.setBulwarkMetadata(accountId, templateKey(id), null);
delete serverAfter[id];
} catch (error) {
debug.warn('templates', 'Could not remove a template from the account:', error);
reachable = false;
}
}
// Changes made here while the round ran come after it: plan them again
// against the server as it is now (nothing is written), and go round
// again for what that leaves to do.
const latest = useAccountTemplatesStore.getState().personal[localAccountId] ?? start;
const final = planPersonalSync(latest, serverAfter, serverDeleted);
const serverVersions: Record<string, string> = {};
for (const [id, template] of Object.entries(serverAfter)) serverVersions[id] = template.updatedAt;
const keptLocalOnly: Record<string, LocalOnlyReason> = {};
for (const template of final.templates) {
if (serverVersions[template.id] === template.updatedAt) continue;
const reason = localOnly[template.id] ?? latest.localOnly[template.id];
if (reason) keptLocalOnly[template.id] = reason;
}
useAccountTemplatesStore.getState().setPersonal(localAccountId, {
...latest,
templates: final.templates,
deletedTemplateIds: final.deletedTemplateIds,
serverVersions,
serverDeleted,
markerOnServer,
localOnly: keptLocalOnly,
});
if (latest !== start) again.add(localAccountId);
return 'synced';
}
+187
View File
@@ -0,0 +1,187 @@
/**
* Templates kept in an account's Inbox metadata (Stalwart v1.0.0 and
* later): one namespace per template, `org.bulwarkmail.template.<id>`,
* because Stalwart takes at most 8 KB per namespace and 64 KB of shared
* metadata per mailbox (local-data/org-features-facts.md §2). Personal
* templates go in the shared metadata of the user's own Inbox (the private
* part holds only 16 KB), team templates in the group account's Inbox.
*
* Deletions of personal templates are recorded as tombstones in the marker
* entry `org.bulwarkmail.templates` ({ version, deleted: { id: deletedAt } }),
* and only tombstones delete: an entry that is simply missing (a request
* that failed half way, another client) is uploaded again, never taken as
* a deletion.
*
* Pure: reading the entries, and planning how the copy in this browser and
* the server copy come together. lib/templates/account-template-sync.ts
* does the requests.
*/
import type { EmailTemplate } from '@/lib/template-types';
import { mergeSyncedTemplates, parseSyncedTemplates, parseTemplateTombstones, TEMPLATE_TOMBSTONE_TTL_MS } from '@/lib/template-utils';
import { MAX_METADATA_ENTRY_BYTES, isValidMetadataKey, metadataValueBytes } from '@/lib/jmap/bulwark-metadata';
const KEY_PREFIX = 'template.';
/** The marker entry: tombstones of deleted personal templates. */
export const MARKER_KEY = 'templates';
export const MARKER_VERSION = 1;
/** The metadata key of a template (`template.<id>`). */
export function templateKey(id: string): string {
return `${KEY_PREFIX}${id}`;
}
/** Ids Bulwark generates (UUIDs) fit a metadata key; older or imported ones may not. */
export function isStorableTemplateId(id: string): boolean {
return isValidMetadataKey(templateKey(id));
}
/** 53-bit string hash (cyrb53), as 14 hex digits. */
function hash53(text: string, seed: number): string {
let h1 = 0xdeadbeef ^ seed;
let h2 = 0x41c6ce57 ^ seed;
for (let i = 0; i < text.length; i++) {
const ch = text.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16).padStart(14, '0');
}
/**
* An id that fits a metadata key, derived from the old one: the same old
* id gives the same new id on every device and on every import, so the
* copies still meet by id.
*/
export function storableId(id: string): string {
return isStorableTemplateId(id) ? id : `${hash53(id, 1)}${hash53(id, 2)}`;
}
/** The same template with an id that fits a metadata key. */
export function withStorableId(template: EmailTemplate): EmailTemplate {
const id = storableId(template.id);
return id === template.id ? template : { ...template, id };
}
/** The value stored for a template: the template itself, which carries its id and `updatedAt`. */
export function templateValue(template: EmailTemplate): EmailTemplate {
return { ...template };
}
/** Whether a template is larger than one metadata entry may be. */
export function isTooLargeForMetadata(template: EmailTemplate): boolean {
return metadataValueBytes(templateValue(template)) > MAX_METADATA_ENTRY_BYTES;
}
/**
* The templates among an Inbox's Bulwark metadata entries (`template.<id>`),
* by id, validated and sanitised like a synced template. The key decides
* the id.
*/
export function templatesFromMetadata(entries: Record<string, unknown> | null | undefined): Record<string, EmailTemplate> {
const out: Record<string, EmailTemplate> = {};
if (!entries) return out;
for (const [key, value] of Object.entries(entries)) {
if (!key.startsWith(KEY_PREFIX) || !value || typeof value !== 'object' || Array.isArray(value)) continue;
const id = key.slice(KEY_PREFIX.length);
const [template] = parseSyncedTemplates([{ ...(value as Record<string, unknown>), id }]) ?? [];
if (template) out[id] = template;
}
return out;
}
export interface Marker {
version: number;
deleted: Record<string, string>;
}
/** The marker entry, or null when there is none. */
export function markerFromMetadata(entries: Record<string, unknown> | null | undefined): Marker | null {
const value = entries?.[MARKER_KEY];
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const raw = value as Record<string, unknown>;
return { version: typeof raw.version === 'number' ? raw.version : MARKER_VERSION, deleted: parseTemplateTombstones(raw.deleted) };
}
/**
* Tombstones of the last 90 days, newest first as long as the marker entry
* stays within 8 KB: the oldest go first.
*/
export function pruneTombstones(deleted: Record<string, string>, now: Date = new Date()): Record<string, string> {
const cutoff = now.getTime() - TEMPLATE_TOMBSTONE_TTL_MS;
const fresh = Object.entries(deleted)
.filter(([, at]) => Date.parse(at) >= cutoff)
.sort((a, b) => Date.parse(b[1]) - Date.parse(a[1]));
const kept: Record<string, string> = {};
for (const [id, at] of fresh) {
kept[id] = at;
if (metadataValueBytes({ version: MARKER_VERSION, deleted: kept }) > MAX_METADATA_ENTRY_BYTES) {
delete kept[id];
break;
}
}
return kept;
}
/** The newest tombstone per id of both sets. */
export function unionTombstones(a: Record<string, string>, b: Record<string, string>): Record<string, string> {
const out = { ...a };
for (const [id, at] of Object.entries(b)) {
if (!out[id] || Date.parse(at) > Date.parse(out[id])) out[id] = at;
}
return out;
}
export function sameTombstones(a: Record<string, string>, b: Record<string, string>): boolean {
const ka = Object.keys(a);
return ka.length === Object.keys(b).length && ka.every((id) => a[id] === b[id]);
}
/** This browser's copy of an account's personal templates. */
export interface PersonalCopy {
templates: EmailTemplate[];
/** Tombstones known here: id -> ISO time it was deleted. */
deletedTemplateIds: Record<string, string>;
/** `updatedAt` of each template as the server held it at the last sync. */
serverVersions: Record<string, string>;
}
export interface PersonalSyncPlan {
/** What this browser keeps after the merge. */
templates: EmailTemplate[];
/** All tombstones, pruned: what the marker should hold. */
deletedTemplateIds: Record<string, string>;
/** Templates the server lacks or holds an older version of. */
uploads: EmailTemplate[];
/** Ids to remove from the server, each covered by a tombstone. */
deletes: string[];
}
/**
* Brings this browser's copy and the server copy together, per id with
* the newer `updatedAt` winning (mergeSyncedTemplates), with the
* tombstones of both. A tombstone deletes a template it is newer than; an
* edit newer than the tombstone brings the template back.
*/
export function planPersonalSync(
local: Pick<PersonalCopy, 'templates' | 'deletedTemplateIds'>,
server: Record<string, EmailTemplate>,
serverTombstones: Record<string, string>,
now: Date = new Date(),
): PersonalSyncPlan {
const tombstones = unionTombstones(local.deletedTemplateIds, serverTombstones);
const merged = mergeSyncedTemplates(
{ templates: local.templates, deletedTemplateIds: tombstones },
{ templates: Object.values(server), deletedTemplateIds: {} },
now,
);
const keptIds = new Set(merged.templates.map((t) => t.id));
const uploads = merged.templates.filter((template) => {
const there = server[template.id];
return !there || Date.parse(template.updatedAt) > Date.parse(there.updatedAt);
});
const deletes = Object.keys(server).filter((id) => !keptIds.has(id) && merged.deletedTemplateIds[id] !== undefined);
return { templates: merged.templates, deletedTemplateIds: pruneTombstones(merged.deletedTemplateIds, now), uploads, deletes };
}
+26
View File
@@ -0,0 +1,26 @@
/**
* The admin's template library (policy.json `organisation.templates.library`)
* as templates the picker and the template manager show under
* "Organisation". Users can't change them; scopes add to Everyone's library
* by id (mergeOrganisation), so the effective policy already holds the
* list for the user's domain and groups.
*/
import type { SettingsPolicy } from '@/lib/admin/types';
import type { EmailTemplate } from '@/lib/template-types';
import { parseSyncedTemplates } from '@/lib/template-utils';
export function orgLibraryTemplates(policy: Pick<SettingsPolicy, 'organisation'> | null | undefined): EmailTemplate[] {
const library = policy?.organisation?.templates?.library ?? [];
if (library.length === 0) return [];
// The same validation and HTML sanitising as a synced template of the
// user's own, so an admin's HTML body reaches the editor cleaned too.
return (parseSyncedTemplates(library) ?? []).map((template) => ({
...template,
defaultRecipients: undefined,
identityId: undefined,
isFavorite: false,
createdAt: '',
updatedAt: '',
}));
}
+249
View File
@@ -0,0 +1,249 @@
/**
* A signed-in account's own templates, wherever they live: in the account
* (account-templates-store, kept in the Inbox metadata on Stalwart v1.0.0
* and later), or, for accounts without, the browser's list in
* template-store as before. Everything that changes personal templates
* (the template manager, the composer's "Save as template", plugins) goes
* through here, so an account's templates stay that account's.
*/
import type { EmailTemplate } from '@/lib/template-types';
import { exportTemplates, filterTemplates, importTemplates as parseImport } from '@/lib/template-utils';
import { templateHooks } from '@/lib/plugin-hooks';
import { templateToView } from '@/lib/plugin-projection';
import { onBeforeSignOut, onFullSignOut } from '@/lib/sign-out-cleanup';
import { IS_LITE } from '@/lib/lite';
import { getCachedConfig } from '@/hooks/use-config';
import { useAuthStore } from '@/stores/auth-store';
import { useTemplateStore } from '@/stores/template-store';
import { isSettingsLoadSettled, readSettingsOwner, useSettingsStore } from '@/stores/settings-store';
import {
notifyPersonalChange,
onPersonalChange,
useAccountTemplatesStore,
type LocalOnlyReason,
type PersonalPartition,
} from '@/stores/account-templates-store';
import * as reducer from './template-reducer';
import { clientFor } from './account-clients';
import { pendingRound, syncPersonalTemplates, type BrowserList, type SyncOutcome } from './account-template-sync';
export interface PersonalTemplateOps {
/** The account keeps its templates (in the server's metadata), not just this browser. */
storedInAccount: boolean;
templates: () => EmailTemplate[];
localOnly: () => Record<string, LocalOnlyReason>;
add: (data: reducer.TemplateData) => EmailTemplate;
update: (id: string, updates: reducer.TemplateUpdate) => void;
remove: (id: string) => void;
duplicate: (id: string, nameSuffix?: string) => EmailTemplate | null;
toggleFavorite: (id: string) => void;
search: (query: string) => EmailTemplate[];
exportAll: () => string;
importJson: (json: string) => { count: number; errors: string[] };
}
/** The account a call is about: the given one, or the active one. */
export function resolveAccountId(accountId?: string | null): string | null {
return accountId || useAuthStore.getState().activeAccountId || null;
}
/** Whether the account's templates live in the account. */
export function isStoredInAccount(accountId?: string | null): boolean {
const id = resolveAccountId(accountId);
return !!id && !!useAccountTemplatesStore.getState().personal[id];
}
function legacyOps(): PersonalTemplateOps {
const store = () => useTemplateStore.getState();
return {
storedInAccount: false,
templates: () => store().templates,
localOnly: () => ({}),
add: (data) => store().addTemplate(data),
update: (id, updates) => store().updateTemplate(id, updates),
remove: (id) => store().deleteTemplate(id),
duplicate: (id, suffix) => store().duplicateTemplate(id, suffix),
toggleFavorite: (id) => store().toggleFavorite(id),
search: (query) => store().searchTemplates(query),
exportAll: () => store().exportAllTemplates(),
importJson: (json) => store().importTemplates(json),
};
}
function accountOps(accountId: string): PersonalTemplateOps {
const partition = (): PersonalPartition | undefined => useAccountTemplatesStore.getState().personal[accountId];
const templates = () => partition()?.templates ?? [];
const change = (fn: (list: reducer.TemplateListState) => reducer.TemplateListState) => {
useAccountTemplatesStore.getState().updatePersonal(accountId, (p) => ({ ...p, ...fn({ templates: p.templates, deletedTemplateIds: p.deletedTemplateIds }) }));
notifyPersonalChange(accountId);
};
const find = (id: string) => templates().find((t) => t.id === id);
const emitUpdate = (id: string) => {
const template = find(id);
if (template) void templateHooks.onTemplateUpdate.emit(templateToView(template));
};
const add = (data: reducer.TemplateData): EmailTemplate => {
let created: EmailTemplate | null = null;
change((list) => {
const result = reducer.addTemplate(list, data);
created = result.template;
return result.state;
});
void templateHooks.onTemplateCreate.emit(templateToView(created!));
return created!;
};
return {
storedInAccount: true,
templates,
localOnly: () => partition()?.localOnly ?? {},
add,
update: (id, updates) => {
if (!find(id)) return;
change((list) => reducer.updateTemplate(list, id, updates));
emitUpdate(id);
},
remove: (id) => {
if (!find(id)) return;
change((list) => reducer.deleteTemplate(list, id));
void templateHooks.onTemplateDelete.emit({ templateId: id });
},
duplicate: (id, suffix) => {
let created: EmailTemplate | null = null;
change((list) => {
const result = reducer.duplicateTemplate(list, id, suffix);
if (!result) return list;
created = result.template;
return result.state;
});
if (created) void templateHooks.onTemplateCreate.emit(templateToView(created));
return created;
},
toggleFavorite: (id) => {
if (!find(id)) return;
change((list) => reducer.toggleFavorite(list, id));
emitUpdate(id);
},
search: (query) => filterTemplates(templates(), query),
exportAll: () => exportTemplates(templates()),
importJson: (json) => {
const result = parseImport(json);
if (result.templates.length > 0) {
change((list) => reducer.appendTemplates(list, result.templates));
void templateHooks.onTemplatesImport.emit({ count: result.templates.length });
}
return { count: result.templates.length, errors: result.errors };
},
};
}
/** The personal templates of the signed-in account `accountId` (the active one when left out). */
export function personalTemplatesFor(accountId?: string | null): PersonalTemplateOps {
const id = resolveAccountId(accountId);
return id && useAccountTemplatesStore.getState().personal[id] ? accountOps(id) : legacyOps();
}
// ─── Whose is the browser's list ──────────────────────────────
/**
* The browser's template list as far as `accountId` is concerned.
*
* With settings sync, the list mirrors the settings of the account that
* loaded last (settings-sync-owner): it is this account's once its settings
* have loaded, and unknown before that; a load that ended without making
* any account the owner (the server refused sync) leaves the list to no
* one, as without sync.
*
* Without settings sync (Lite, sync off), the list belongs to no account:
* it goes into the first account whose metadata answers, and only that
* one, so one person's templates don't turn up in every mailbox signed in
* in this browser.
*/
export function browserListFor(accountId: string): BrowserList {
const templates = useTemplateStore.getState().templates;
const config = getCachedConfig();
if (!IS_LITE && !config) return { status: 'unknown' };
const syncOn = !IS_LITE && config?.settingsSyncEnabled === true && !useSettingsStore.getState().settingsSyncDisabled;
if (syncOn) {
const owner = readSettingsOwner();
const active = useAuthStore.getState().activeAccountId;
if (owner !== null) {
if (owner === accountId && accountId === active) return { status: 'list', templates };
return accountId === active ? { status: 'unknown' } : { status: 'none' };
}
if (!isSettingsLoadSettled(accountId)) return accountId === active ? { status: 'unknown' } : { status: 'none' };
}
const given = useAccountTemplatesStore.getState().browserListOwner;
if (given !== null && given !== accountId) return { status: 'none' };
return {
status: 'list',
templates,
claim: () => {
if (useAccountTemplatesStore.getState().browserListOwner === null) useAccountTemplatesStore.setState({ browserListOwner: accountId });
},
};
}
// ─── Syncing ──────────────────────────────────────────────────
/** Sync the account's personal templates now. */
export function syncAccountTemplates(accountId: string): Promise<SyncOutcome> {
const client = clientFor(accountId);
if (!client) return Promise.resolve('unsupported');
return syncPersonalTemplates(accountId, client, () => browserListFor(accountId));
}
const SYNC_DELAY_MS = 800;
const timers = new Map<string, ReturnType<typeof setTimeout>>();
/** Sync shortly after a change, once for a burst of changes. */
export function scheduleAccountSync(accountId: string): void {
const pending = timers.get(accountId);
if (pending) clearTimeout(pending);
timers.set(accountId, setTimeout(() => {
timers.delete(accountId);
void syncAccountTemplates(accountId);
}, SYNC_DELAY_MS));
}
onPersonalChange(scheduleAccountSync);
const lastSync = new Map<string, number>();
/** Sync when the last round is older than `maxAgeMs` (opening the picker or the manager, coming back to the tab). */
export function refreshAccountTemplates(accountId: string | null | undefined, maxAgeMs = 15_000): void {
const id = resolveAccountId(accountId);
if (!id) return;
const last = lastSync.get(id) ?? 0;
if (Date.now() - last < maxAgeMs) return;
lastSync.set(id, Date.now());
void syncAccountTemplates(id);
}
/**
* Before an account signs out: send what is queued for it (above all
* deletions, which otherwise come back from the server next time). The
* auth store bounds the wait.
*/
export async function flushAccountTemplates(accountId: string): Promise<void> {
const ids = accountId === 'all' ? Object.keys(useAccountTemplatesStore.getState().personal) : [accountId];
await Promise.all(ids.map(async (id) => {
if (!useAccountTemplatesStore.getState().personal[id]) return;
const timer = timers.get(id);
if (timer) {
clearTimeout(timer);
timers.delete(id);
}
await pendingRound(id);
await syncAccountTemplates(id);
}));
}
onBeforeSignOut(flushAccountTemplates);
// At a full sign-out only what exists nowhere else stays in this browser:
// templates and deletions the server hasn't taken yet.
onFullSignOut(() => {
lastSync.clear();
useAccountTemplatesStore.getState().forgetServerCopies();
});
+170
View File
@@ -0,0 +1,170 @@
/**
* Team templates: kept in the Inbox metadata of a group account
* (Stalwart v1.0.0 and later), one namespace per template like personal
* ones (lib/templates/metadata-templates.ts). Every member sees them; a
* member whose Inbox rights include setting metadata may change them,
* unless the admin locked them (`teamTemplates`). There is no copy in the
* browser: the server copy is read when an account becomes active and
* when the picker or the template manager opens, and written at once.
*
* Two members may edit the same template. A change is written only if the
* entry is still the version the edit started from; otherwise it is
* refused ('conflict') and the list is read again.
*
* Only group principals count, not a mailbox another person shared
* (facts §5); on servers without metadata there are no team templates.
*/
import { MetadataRefusedError, MetadataTooLargeError } from '@/lib/jmap/bulwark-metadata';
import type { EmailTemplate } from '@/lib/template-types';
import { debug } from '@/lib/debug';
import { useAccountTemplatesStore, type TeamTemplates } from '@/stores/account-templates-store';
import { clientFor, hasMetadata, type TemplateClient } from './account-clients';
import { isTooLargeForMetadata, templateKey, templateValue, templatesFromMetadata } from './metadata-templates';
import type { TemplateSaveResult } from './template-sources';
/**
* The group accounts in a client's session: non-personal accounts whose
* principal is a group. When the server won't say, none.
*/
export async function groupAccounts(client: TemplateClient): Promise<Array<{ id: string; name: string }>> {
if (typeof client.getSharedAccounts !== 'function' || typeof client.getPrincipalTypes !== 'function') return [];
const shared = client.getSharedAccounts()
.filter((account) => !account.isPrimary && client.getAccountInfo?.(account.id)?.isPersonal === false);
if (shared.length === 0) return [];
const types = await client.getPrincipalTypes(shared.map((account) => account.id)).catch(() => null);
if (!types) return [];
return shared
.filter((account) => types[account.id] === 'group')
.map((account) => ({ id: account.id, name: account.name }));
}
/** Whether the member may change the group Inbox's metadata (myRights.maySetMetadata on v1.0.0). */
async function inboxMayWrite(client: TemplateClient, accountId: string): Promise<boolean> {
if (client.getAccountInfo?.(accountId)?.isReadOnly) return false;
if (typeof client.getMailboxes !== 'function') return true;
try {
const inbox = (await client.getMailboxes(accountId)).find((mailbox) => mailbox.role === 'inbox');
const rights = inbox?.myRights as unknown as Record<string, boolean> | undefined;
// A server that doesn't name the right refuses the write itself.
return rights?.maySetMetadata !== false;
} catch {
return false;
}
}
/** Reads the team templates of every group mailbox of the signed-in account `localAccountId`. */
export async function loadTeamTemplates(localAccountId: string, client: TemplateClient): Promise<TeamTemplates[]> {
const teams: TeamTemplates[] = [];
for (const group of await groupAccounts(client)) {
if (!hasMetadata(client, group.id)) continue;
const entries = await client.getBulwarkMetadata(group.id);
if (!entries) continue;
teams.push({
accountId: group.id,
name: group.name,
templates: Object.values(templatesFromMetadata(entries)),
mayWrite: await inboxMayWrite(client, group.id),
});
}
useAccountTemplatesStore.getState().setTeams(localAccountId, teams);
return teams;
}
const lastLoad = new Map<string, number>();
/** Read the team templates again when the last read is older than `maxAgeMs`. */
export function refreshTeamTemplates(localAccountId: string, maxAgeMs = 15_000): void {
const last = lastLoad.get(localAccountId) ?? 0;
if (Date.now() - last < maxAgeMs) return;
const client = clientFor(localAccountId);
if (!client) return;
lastLoad.set(localAccountId, Date.now());
void loadTeamTemplates(localAccountId, client).catch((error) => {
debug.warn('templates', 'Could not read team templates:', error);
});
}
function setTeamTemplates(localAccountId: string, groupAccountId: string, templates: EmailTemplate[]) {
const store = useAccountTemplatesStore.getState();
const teams = store.teams[localAccountId] ?? [];
store.setTeams(localAccountId, teams.map((team) => (team.accountId === groupAccountId ? { ...team, templates } : team)));
}
function refusal(error: unknown, template: EmailTemplate | null): TemplateSaveResult {
if (error instanceof MetadataTooLargeError) return template && isTooLargeForMetadata(template) ? 'too-large' : 'no-room';
if (error instanceof MetadataRefusedError && error.type === 'forbidden') return 'forbidden';
debug.warn('templates', 'Could not change a team template:', error);
return 'failed';
}
/**
* Reads the group's templates and checks that the entry `id` is still at
* `base` (the `updatedAt` the edit started from; null for a new one).
*/
async function checkUnchanged(
localAccountId: string,
client: TemplateClient & Required<Pick<TemplateClient, 'getBulwarkMetadata'>>,
groupAccountId: string,
id: string,
base: string | null,
): Promise<Record<string, EmailTemplate> | TemplateSaveResult> {
const entries = await client.getBulwarkMetadata(groupAccountId);
if (!entries) return 'failed';
const current = templatesFromMetadata(entries);
if ((current[id]?.updatedAt ?? null) !== base) {
setTeamTemplates(localAccountId, groupAccountId, Object.values(current));
return 'conflict';
}
return current;
}
/**
* Saves a team template in the group's Inbox. `base` is the `updatedAt`
* of the version the edit started from, null for a new template.
*/
export async function saveTeamTemplate(
localAccountId: string,
groupAccountId: string,
template: EmailTemplate,
base: string | null,
): Promise<TemplateSaveResult> {
const client = clientFor(localAccountId);
if (!client || !hasMetadata(client, groupAccountId)) return 'failed';
// Identities are each member's own, a favourite is a personal choice,
// and blind copies don't belong in something the whole team uses.
const recipients = template.defaultRecipients
? { to: template.defaultRecipients.to, cc: template.defaultRecipients.cc }
: undefined;
const shared: EmailTemplate = {
...template,
identityId: undefined,
isFavorite: false,
defaultRecipients: recipients && (recipients.to?.length || recipients.cc?.length) ? recipients : undefined,
};
if (isTooLargeForMetadata(shared)) return 'too-large';
const current = await checkUnchanged(localAccountId, client, groupAccountId, shared.id, base);
if (typeof current === 'string') return current;
try {
await client.setBulwarkMetadata(groupAccountId, templateKey(shared.id), templateValue(shared));
} catch (error) {
return refusal(error, shared);
}
setTeamTemplates(localAccountId, groupAccountId, [...Object.values(current).filter((t) => t.id !== shared.id), shared]);
return 'ok';
}
/** Removes a team template from the group's Inbox, if it is still the version `base`. */
export async function deleteTeamTemplate(localAccountId: string, groupAccountId: string, id: string, base: string): Promise<TemplateSaveResult> {
const client = clientFor(localAccountId);
if (!client || !hasMetadata(client, groupAccountId)) return 'failed';
const current = await checkUnchanged(localAccountId, client, groupAccountId, id, base);
if (typeof current === 'string') return current;
try {
await client.setBulwarkMetadata(groupAccountId, templateKey(id), null);
} catch (error) {
return refusal(error, null);
}
setTeamTemplates(localAccountId, groupAccountId, Object.values(current).filter((t) => t.id !== id));
return 'ok';
}
+74
View File
@@ -0,0 +1,74 @@
/**
* The changes a user makes to a list of their own templates, as pure
* functions over `{ templates, deletedTemplateIds }`. The browser's list
* (template-store) and an account's copy (account-templates-store) both use
* them, so both behave the same.
*
* Every change moves `updatedAt` forward from the template's previous value
* even when this device's clock is behind the one that wrote it last;
* otherwise the merge by `updatedAt` would throw the change away.
*/
import type { EmailTemplate } from '@/lib/template-types';
import { generateUUID } from '@/lib/utils';
export interface TemplateListState {
templates: EmailTemplate[];
deletedTemplateIds: Record<string, string>;
}
export type TemplateData = Omit<EmailTemplate, 'id' | 'createdAt' | 'updatedAt'>;
export type TemplateUpdate = Partial<Omit<EmailTemplate, 'id' | 'createdAt'>>;
/** `updatedAt` for a change: now, or just after the previous value if that is later. */
export function nextUpdatedAt(previous?: string, now: number = Date.now()): string {
const before = previous ? Date.parse(previous) : NaN;
return new Date(Number.isNaN(before) ? now : Math.max(now, before + 1)).toISOString();
}
export function addTemplate(state: TemplateListState, data: TemplateData, now = Date.now()): { state: TemplateListState; template: EmailTemplate } {
const at = new Date(now).toISOString();
const template: EmailTemplate = { ...data, id: generateUUID(), createdAt: at, updatedAt: at };
return { state: { ...state, templates: [...state.templates, template] }, template };
}
export function updateTemplate(state: TemplateListState, id: string, updates: TemplateUpdate, now = Date.now()): TemplateListState {
return {
...state,
templates: state.templates.map((t) => (t.id === id ? { ...t, ...updates, updatedAt: nextUpdatedAt(t.updatedAt, now) } : t)),
};
}
export function toggleFavorite(state: TemplateListState, id: string, now = Date.now()): TemplateListState {
return {
...state,
templates: state.templates.map((t) => (t.id === id ? { ...t, isFavorite: !t.isFavorite, updatedAt: nextUpdatedAt(t.updatedAt, now) } : t)),
};
}
/** Removes a template and leaves a tombstone newer than the template, so the deletion wins the merge. */
export function deleteTemplate(state: TemplateListState, id: string, now = Date.now()): TemplateListState {
const template = state.templates.find((t) => t.id === id);
if (!template) return state;
return {
templates: state.templates.filter((t) => t.id !== id),
deletedTemplateIds: { ...state.deletedTemplateIds, [id]: nextUpdatedAt(template.updatedAt, now) },
};
}
export function duplicateTemplate(
state: TemplateListState,
id: string,
nameSuffix?: string,
now = Date.now(),
): { state: TemplateListState; template: EmailTemplate } | null {
const original = state.templates.find((t) => t.id === id);
if (!original) return null;
const { id: _id, createdAt: _c, updatedAt: _u, ...data } = original;
void _id; void _c; void _u;
return addTemplate(state, { ...data, name: `${original.name} ${nameSuffix || '(copy)'}`, isFavorite: false }, now);
}
export function appendTemplates(state: TemplateListState, templates: EmailTemplate[]): TemplateListState {
return { ...state, templates: [...state.templates, ...templates] };
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Where the templates a user can pick come from: their own (personal), a
* group mailbox they are a member of (team), and the admin's library
* (organisation). The picker shows each under its own heading; the template
* manager edits the ones the user may change.
*/
import type { EmailTemplate } from '@/lib/template-types';
export type TemplateSourceKind = 'personal' | 'team' | 'organisation';
export interface TemplateSource {
/** 'personal', 'organisation' or `team:<JMAP account id>`. */
key: string;
kind: TemplateSourceKind;
/** A team's name (the group's address); personal and organisation are named by the UI. */
name?: string;
templates: EmailTemplate[];
/** The user may add, change and delete templates here. */
editable: boolean;
/** Read-only because the admin locked it: "Set by your organisation". */
locked: boolean;
}
export const PERSONAL_SOURCE = 'personal';
export const ORGANISATION_SOURCE = 'organisation';
export const teamSourceKey = (accountId: string) => `team:${accountId}`;
/** Sorted by name, the way the picker and the manager list a source. */
export function byName(templates: EmailTemplate[]): EmailTemplate[] {
return [...templates].sort((a, b) => a.name.localeCompare(b.name));
}
/** What a change to a template came to; anything but 'ok' keeps the form open. */
export type TemplateSaveResult = 'ok' | 'too-large' | 'no-room' | 'forbidden' | 'conflict' | 'failed';
+30 -2
View File
@@ -2910,6 +2910,26 @@
"sender_name": "Ihr Name"
}
},
"org_templates": {
"section_personal": "Meine Vorlagen",
"section_organisation": "Organisation",
"sources_label": "Vorlagenlisten",
"organisation_note": "Von Ihrer Organisation. Sie können diese Vorlagen nicht ändern, aber in Ihre eigenen kopieren.",
"copy_to_personal": "In meine Vorlagen kopieren",
"duplicate": "Duplizieren",
"save_failed": "Die Vorlage konnte nicht gespeichert werden. Versuchen Sie es erneut.",
"save_failed_forbidden": "Sie können diese Vorlagen nicht ändern.",
"save_failed_too_large": "Diese Vorlage ist größer als die 8 KB, die der Server pro Vorlage speichert. Kürzen Sie sie und speichern Sie erneut.",
"save_failed_no_room": "Im Postfach ist kein Platz für weitere Vorlagen. Löschen Sie eine und versuchen Sie es erneut.",
"local_only_too_large": "Nur in diesem Browser: größer als die 8 KB, die der Server pro Vorlage speichert.",
"local_only_no_room": "Nur in diesem Browser: In Ihrem Konto ist kein Platz für weitere Vorlagen.",
"personal_in_account": "Ihre Vorlagen werden in Ihrem Konto gespeichert, Sie haben sie also auf jedem Gerät, auf dem Sie sich anmelden. Alle, für die Sie Ihren Posteingang freigeben, können sie lesen.",
"team_note": "Alle Mitglieder von {name} sehen diese Vorlagen und können sie ändern.",
"team_read_only": "Sie können diese Vorlagen verwenden, aber nicht ändern.",
"save_failed_conflict": "Von jemand anderem geändert. Laden Sie neu und versuchen Sie es erneut.",
"preview_to": "An: {list}",
"preview_cc": "Cc: {list}"
},
"contacts": {
"directory": {
"title": "Verzeichnis",
@@ -4405,7 +4425,7 @@
},
"email_write": {
"title": "Ihre E-Mails ändern und ordnen",
"body": "Labels, Markierungen und andere Schlüsselwörter an Ihren Nachrichten hinzufügen oder entfernen, Nachrichten zwischen Ihren Ordnern und ins Archiv verschieben und Entwürfe speichern."
"body": "Labels, Markierungen und andere Schlüsselwörter an Ihren Nachrichten hinzufügen oder entfernen, Nachrichten zwischen Ihren Ordnern und ins Archiv verschieben und Entwürfe speichern. Außerdem kann es einen kleinen Wert speichern, den jedes Mitglied eines Team-Postfachs lesen kann."
},
"email_send": {
"title": "E-Mails vor dem Senden ändern",
@@ -4521,7 +4541,11 @@
},
"account_read": {
"title": "Ihre Konten sehen",
"body": "Die von Ihnen hinzugefügten Konten mit ihren Adressen und Servern sehen."
"body": "Die von Ihnen hinzugefügten Konten mit ihren Adressen und Servern sehen und erkennen, welche Postfächer Team-Postfächer sind."
},
"directory_read": {
"title": "Personen nachschlagen",
"body": "Personen im Verzeichnis Ihrer Organisation nachschlagen."
},
"http_post": {
"title": "Den Webmail-Server für Sie nutzen",
@@ -4685,6 +4709,10 @@
"error_save": "Speichern fehlgeschlagen: {error}"
}
},
"plugin_list": {
"filters": "Filter",
"remove_filter": "Filter {label} entfernen"
},
"global_search": {
"title": "Alles durchsuchen",
"placeholder": "Mail, Kontakte, Kalender, Dateien durchsuchen...",
+30 -2
View File
@@ -2910,6 +2910,26 @@
"sender_name": "Your name"
}
},
"org_templates": {
"section_personal": "My templates",
"section_organisation": "Organisation",
"sources_label": "Template lists",
"organisation_note": "From your organisation. You can't change these, but you can copy one into your own templates.",
"copy_to_personal": "Copy to my templates",
"duplicate": "Duplicate",
"save_failed": "The template could not be saved. Try again.",
"save_failed_forbidden": "You can't change these templates.",
"save_failed_too_large": "This template is larger than the 8 KB the server keeps per template. Shorten it and save again.",
"save_failed_no_room": "The mailbox has no room for more templates. Delete one and try again.",
"local_only_too_large": "Only in this browser: larger than the 8 KB the server keeps per template.",
"local_only_no_room": "Only in this browser: your account has no room for more templates.",
"personal_in_account": "Your templates are kept in your account, so you have them on every device you sign in on. Anyone you share your Inbox with can read them.",
"team_note": "Everyone in {name} sees these templates and can change them.",
"team_read_only": "You can use these templates but not change them.",
"save_failed_conflict": "Changed by someone else. Reload and try again.",
"preview_to": "To: {list}",
"preview_cc": "Cc: {list}"
},
"contacts": {
"directory": {
"title": "Directory",
@@ -4405,7 +4425,7 @@
},
"email_write": {
"title": "Change and organise your email",
"body": "Add or remove tags, flags and other keywords on your messages, move them between your folders and to the archive, and save drafts."
"body": "Add or remove tags, flags and other keywords on your messages, move them between your folders and to the archive, and save drafts. It can also store a small value that every member of a team mailbox can read."
},
"email_send": {
"title": "Change mail before it is sent",
@@ -4521,7 +4541,11 @@
},
"account_read": {
"title": "See your accounts",
"body": "See the accounts you added, with their addresses and servers."
"body": "See the accounts you added with their addresses and servers, and which mail accounts are team mailboxes."
},
"directory_read": {
"title": "Look up people",
"body": "Look up people in your organisation's directory."
},
"http_post": {
"title": "Use the webmail server for you",
@@ -4685,6 +4709,10 @@
"error_save": "Could not save: {error}"
}
},
"plugin_list": {
"filters": "Filters",
"remove_filter": "Remove filter {label}"
},
"global_search": {
"title": "Search everything",
"placeholder": "Search mail, contacts, calendar, files...",
@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useEmailStore } from '../email-store';
import { useSettingsStore } from '../settings-store';
import { useAuthStore } from '../auth-store';
import { usePolicyStore } from '../policy-store';
import { useMessageListTabsStore } from '../message-list-tabs-store';
import { resetListFilters, setListFilterActive, setPluginListFilters } from '@/lib/plugin-sandbox/list-filters';
import { confirmGroups, resetConfirmedGroups } from '@/lib/plugin-sandbox/account-kind';
import type { Email, Mailbox } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
// Plugins' list filters (api.ui.setListFilters): a filter the user turned on
// is part of the folder's query, only in the folders it applies to, and
// leaves it again when turned off.
const teamInbox = { id: 'ch:a', originalId: 'a', role: 'inbox', isShared: true, accountId: 'ch' } as unknown as Mailbox;
const ownInbox = { id: 'a', role: 'inbox', isShared: false } as Mailbox;
function email(id: string, keywords: Record<string, boolean> = {}): Email {
return {
id, threadId: `thread-${id}`, mailboxIds: { a: true }, keywords,
subject: id, receivedAt: '2026-10-01T00:00:00Z', from: [{ email: 'sender@example.com' }], to: [],
preview: '', size: 1, hasAttachment: false,
} as Email;
}
// Plugins hear "group" once the server confirmed the account is one.
async function signIn(c: IJMAPClient) {
useAuthStore.setState({ client: c });
await confirmGroups(c);
}
function client(rows: Email[] = []) {
return {
getEmails: vi.fn(async () => ({ emails: rows, total: rows.length, hasMore: false, state: 's' })),
getThreads: vi.fn(async () => []),
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' }),
batchUpdateKeywords: vi.fn(async () => undefined),
} as unknown as IJMAPClient;
}
beforeEach(() => {
resetListFilters();
resetConfirmedGroups();
useAuthStore.setState(useAuthStore.getInitialState());
useMessageListTabsStore.setState(useMessageListTabsStore.getInitialState());
useSettingsStore.setState({
emailsPerPage: 25, messageListOrder: [], messageListOrderScope: 'inbox',
notificationMutes: { accounts: [], mailboxes: {} },
});
useEmailStore.setState({ ...useEmailStore.getInitialState(), selectedMailbox: 'ch:a', mailboxes: [ownInbox, teamInbox] });
setPluginListFilters('team-inbox', 'Team inbox', [
{ id: 'open', label: 'Open', notKeyword: '$done', accounts: ['group'] },
{ id: 'mine', label: 'Assigned to me', hasKeyword: '$assigned:ci', accounts: ['group'] },
]);
});
afterEach(() => {
resetListFilters();
usePolicyStore.setState({ subject: null });
});
describe('plugin list filters in the folder query', () => {
it('queries a team folder with the filters that are on, and without them once removed', async () => {
const c = client();
await signIn(c);
await useEmailStore.getState().fetchEmails(c, 'ch:a');
expect(vi.mocked(c.getEmails).mock.calls[0][6]).toBeUndefined();
setListFilterActive('team-inbox:open');
setListFilterActive('team-inbox:mine');
await useEmailStore.getState().fetchEmails(c, 'ch:a');
expect(vi.mocked(c.getEmails).mock.calls[1][0]).toBe('a');
expect(vi.mocked(c.getEmails).mock.calls[1][1]).toBe('ch');
expect(vi.mocked(c.getEmails).mock.calls[1][6]).toEqual({
operator: 'AND', conditions: [{ hasKeyword: '$assigned:ci' }, { notKeyword: '$done' }],
});
setListFilterActive('team-inbox:mine', false);
await useEmailStore.getState().fetchEmails(c, 'ch:a');
expect(vi.mocked(c.getEmails).mock.calls[2][6]).toEqual({ notKeyword: '$done' });
});
it('leaves the user\'s own folders alone', async () => {
const c = client();
await signIn(c);
setListFilterActive('team-inbox:open');
useEmailStore.setState({ selectedMailbox: 'a' });
await useEmailStore.getState().fetchEmails(c, 'a');
expect(vi.mocked(c.getEmails).mock.calls[0][6]).toBeUndefined();
});
it('loads the list again when a message changes a keyword an active filter tests', async () => {
const c = client([email('m1')]);
await signIn(c);
setListFilterActive('team-inbox:open');
await useEmailStore.getState().fetchEmails(c, 'ch:a');
const calls = vi.mocked(c.getEmails).mock.calls.length;
await useEmailStore.getState().patchEmailKeywords(c, 'm1', { $done: true });
await vi.waitFor(() => expect(vi.mocked(c.getEmails).mock.calls.length).toBeGreaterThan(calls));
expect(vi.mocked(c.getEmails).mock.calls.at(-1)?.[6]).toEqual({ notKeyword: '$done' });
});
it('filters a refresh and the next page the same way', async () => {
const c = client([email('m1')]);
await signIn(c);
setListFilterActive('team-inbox:open');
await useEmailStore.getState().fetchEmails(c, 'ch:a');
const before = vi.mocked(c.getEmails).mock.calls.length;
await useEmailStore.getState().refreshCurrentMailbox(c);
expect(vi.mocked(c.getEmails).mock.calls.length).toBeGreaterThan(before);
expect(vi.mocked(c.getEmails).mock.calls.at(-1)?.[6]).toEqual({ notKeyword: '$done' });
useEmailStore.setState({ hasMoreEmails: true, isLoadingMore: false });
const beforeMore = vi.mocked(c.getEmails).mock.calls.length;
await useEmailStore.getState().loadMoreEmails(c);
expect(vi.mocked(c.getEmails).mock.calls.length).toBeGreaterThan(beforeMore);
expect(vi.mocked(c.getEmails).mock.calls.at(-1)?.[6]).toEqual({ notKeyword: '$done' });
});
it('leaves a tag view alone', async () => {
const c = client();
await signIn(c);
setListFilterActive('team-inbox:open');
useEmailStore.setState({ selectedKeyword: 'work' });
await useEmailStore.getState().fetchEmails(c, 'ch:a');
expect(vi.mocked(c.getEmails).mock.calls.length).toBeGreaterThan(0);
for (const args of vi.mocked(c.getEmails).mock.calls) expect(JSON.stringify(args[6] ?? null)).not.toContain('$done');
});
it('applies nothing to a team folder before the server confirmed it is a group', async () => {
const c = { ...client(), getPrincipalTypes: async () => null } as unknown as IJMAPClient;
await signIn(c);
setListFilterActive('team-inbox:open');
await useEmailStore.getState().fetchEmails(c, 'ch:a');
expect(vi.mocked(c.getEmails).mock.calls[0][6]).toBeUndefined();
});
});
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useSettingsStore } from '../settings-store';
import { useTemplateStore } from '../template-store';
import { emptyPartition, useAccountTemplatesStore, type PersonalPartition } from '../account-templates-store';
import { mergeStoredTemplates } from '@/lib/template-merge';
import { parseSyncedTemplates } from '@/lib/template-utils';
import type { EmailTemplate } from '@/lib/template-types';
// An account that keeps its templates in its own Inbox metadata (Stalwart
// 1.0) takes them out of the settings-sync blob once its marker entry is on
// the server; until then, and for accounts without, the blob syncs the
// browser's list as before.
const tpl = (id: string, name = id, updatedAt = '2026-10-01T00:00:00.000Z'): EmailTemplate => ({
id, name, subject: '', body: '', isHTML: false, category: '', isFavorite: false,
createdAt: '2026-10-01T00:00:00.000Z', updatedAt,
});
const copy = (templates: EmailTemplate[], extra: Partial<PersonalPartition> = {}): PersonalPartition => ({ ...emptyPartition(), templates, ...extra });
beforeEach(() => {
useTemplateStore.setState({ templates: [tpl('browser-1', 'Browser')], recentTemplateIds: [], deletedTemplateIds: {} });
useAccountTemplatesStore.setState({ personal: {}, teams: {}, active: null, browserListOwner: null });
});
const push = () => JSON.parse(useSettingsStore.getState().exportSettings({ forSync: true }));
describe('the settings blob', () => {
it('carries the browser\'s templates for an account without its own copy, as before', () => {
useAccountTemplatesStore.setState({ active: 'old@server' });
expect(push().templates.map((t: EmailTemplate) => t.name)).toEqual(['Browser']);
expect(push().templatesInMetadata).toBeUndefined();
});
it('keeps carrying them until the account\'s marker is on the server', () => {
useAccountTemplatesStore.setState({ active: 'new@server', personal: { 'new@server': copy([tpl('acc-1', 'In the account')]) } });
expect(push().templates.map((t: EmailTemplate) => t.name)).toEqual(['Browser']);
expect(push().templatesInMetadata).toBeUndefined();
});
it('leaves them out once the marker is on the server, and the stored blob drops its copy', () => {
useAccountTemplatesStore.setState({ active: 'new@server', personal: { 'new@server': copy([tpl('acc-1', 'In the account')], { markerOnServer: true }) } });
const blob = push();
expect(blob.templates).toBeUndefined();
expect(blob.templatesInMetadata).toBe(true);
const stored = { theme: 'dark', templates: [tpl('old', 'Old copy')], deletedTemplateIds: {} };
const saved = mergeStoredTemplates(stored, blob);
expect(saved.templates).toBeUndefined();
expect(saved.deletedTemplateIds).toBeUndefined();
expect(saved.templatesInMetadata).toBe(true);
});
it('exports the account\'s own templates to a settings file', () => {
useAccountTemplatesStore.setState({ active: 'new@server', personal: { 'new@server': copy([tpl('acc-1', 'In the account')], { markerOnServer: true }) } });
const file = JSON.parse(useSettingsStore.getState().exportSettings());
expect(file.templates.map((t: EmailTemplate) => t.name)).toEqual(['In the account']);
});
it('merges a blob loaded for an account with its own copy into that copy too, tombstones applied', () => {
useAccountTemplatesStore.setState({
personal: { 'new@server': copy([tpl('acc-1', 'In the account'), tpl('gone', 'Deleted elsewhere')]) },
});
useSettingsStore.getState().importSettings(JSON.stringify({
templates: [tpl('blob-1', 'From the blob'), tpl('blob-dead', 'Deleted later')],
deletedTemplateIds: { gone: '2026-10-09T00:00:00.000Z', 'blob-dead': '2026-10-09T00:00:00.000Z' },
}), { serverAccountId: 'new@server' });
expect(useAccountTemplatesStore.getState().personal['new@server'].templates.map((t) => t.id).sort()).toEqual(['acc-1', 'blob-1']);
// The browser's list merges it as before.
expect(useTemplateStore.getState().templates.map((t) => t.name).sort()).toEqual(['Browser', 'From the blob']);
});
it('merges a blob into the browser\'s list for an account without, as before', () => {
useSettingsStore.getState().importSettings(JSON.stringify({ templates: [tpl('blob-1', 'From the blob')], deletedTemplateIds: {} }), { serverAccountId: 'old@server' });
expect(useTemplateStore.getState().templates.map((t) => t.name).sort()).toEqual(['Browser', 'From the blob']);
});
it('adds the templates of an imported settings file to the active account\'s copy, never replacing it', () => {
useAccountTemplatesStore.setState({ active: 'new@server', personal: { 'new@server': copy([tpl('acc-1', 'Kept')]) } });
useSettingsStore.getState().importSettings(JSON.stringify({ templates: [tpl('file-1', 'From the file')], deletedTemplateIds: {} }));
const names = useAccountTemplatesStore.getState().personal['new@server'].templates.map((t) => t.name).sort();
expect(names).toEqual(['From the file', 'Kept']);
expect(useTemplateStore.getState().templates.map((t) => t.name)).toEqual(['Browser']);
});
});
describe('changes', () => {
it('move updatedAt past the previous value when this clock is behind', () => {
const ahead = new Date(Date.now() + 3_600_000).toISOString();
useTemplateStore.setState({ templates: [tpl('a', 'A', ahead)] });
useTemplateStore.getState().updateTemplate('a', { name: 'Edited' });
expect(Date.parse(useTemplateStore.getState().templates[0].updatedAt)).toBe(Date.parse(ahead) + 1);
useTemplateStore.getState().deleteTemplate('a');
expect(Date.parse(useTemplateStore.getState().deletedTemplateIds.a)).toBe(Date.parse(ahead) + 2);
});
it('date a synced template without updatedAt at a fixed early time, so it never beats a dated copy', () => {
const [parsed] = parseSyncedTemplates([{ id: 'x', name: 'No date' }])!;
expect(parsed.updatedAt).toBe('1970-01-01T00:00:00.000Z');
});
});
+146
View File
@@ -0,0 +1,146 @@
/**
* Templates kept in a mail account rather than only in this browser
* (Stalwart v1.0.0 and later, JMAP metadata; lib/templates/metadata-templates.ts):
*
* - `personal`: this browser's copy of each signed-in account's own
* templates, by local account id. An account gets one the first time
* its Inbox metadata answers (lib/templates/account-template-sync.ts);
* until then, and on servers without metadata, its templates are the
* browser's own list in template-store, as they always were.
* - `teams`: the templates of the group mailboxes each signed-in account
* is a member of, as last read. Not kept across reloads: the server
* copy is the only one.
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { EmailTemplate } from '@/lib/template-types';
import type { PersonalCopy } from '@/lib/templates/metadata-templates';
/** Why a personal template could not go to the server and stays in this browser. */
export type LocalOnlyReason = 'too-large' | 'no-room';
export interface PersonalPartition extends PersonalCopy {
/** Templates the server refused, with why. */
localOnly: Record<string, LocalOnlyReason>;
/** Tombstones the server's marker entry held after the last round. Others are still to be sent. */
serverDeleted: Record<string, string>;
/** The marker entry (`org.bulwarkmail.templates`) is on the server: the settings blob may drop its copy. */
markerOnServer: boolean;
/** Versions of the browser's own templates already merged in, so each is taken once. */
fromBrowser: Record<string, string>;
}
export interface TeamTemplates {
/** The group's JMAP account id in the session of the signed-in account. */
accountId: string;
/** The group's address, as Stalwart names the account. */
name: string;
templates: EmailTemplate[];
/** The member may change the group Inbox's metadata (myRights.maySetMetadata). */
mayWrite: boolean;
}
interface AccountTemplatesState {
personal: Record<string, PersonalPartition>;
/**
* Without settings sync (Lite, sync off) the browser's list belongs to no
* account: it goes into the first account whose metadata answers, this one.
*/
browserListOwner: string | null;
/** The active signed-in account, kept here by the sync driver for the settings bridge. */
active: string | null;
teams: Record<string, TeamTemplates[]>;
setPersonal: (accountId: string, partition: PersonalPartition) => void;
updatePersonal: (accountId: string, change: (partition: PersonalPartition) => PersonalPartition) => void;
setTeams: (accountId: string, teams: TeamTemplates[]) => void;
/**
* At a full sign-out: keep only what exists nowhere else, the templates
* the server never took and deletions it hasn't heard of.
*/
forgetServerCopies: () => void;
}
export const emptyPartition = (): PersonalPartition => ({
templates: [],
deletedTemplateIds: {},
serverVersions: {},
localOnly: {},
serverDeleted: {},
markerOnServer: false,
fromBrowser: {},
});
/** What a full sign-out keeps of a partition; null when everything is on the server. */
export function unsentPart(partition: PersonalPartition): PersonalPartition | null {
const templates = partition.templates.filter((t) => partition.serverVersions[t.id] !== t.updatedAt);
const deletedTemplateIds: Record<string, string> = {};
for (const [id, at] of Object.entries(partition.deletedTemplateIds)) {
if (partition.serverDeleted[id] !== at) deletedTemplateIds[id] = at;
}
if (templates.length === 0 && Object.keys(deletedTemplateIds).length === 0) return null;
const localOnly: Record<string, LocalOnlyReason> = {};
for (const t of templates) if (partition.localOnly[t.id]) localOnly[t.id] = partition.localOnly[t.id];
return { ...emptyPartition(), templates, deletedTemplateIds, localOnly, fromBrowser: partition.fromBrowser };
}
export const useAccountTemplatesStore = create<AccountTemplatesState>()(
persist(
(set) => ({
personal: {},
browserListOwner: null,
active: null,
teams: {},
setPersonal: (accountId, partition) => set((state) => ({ personal: { ...state.personal, [accountId]: partition } })),
updatePersonal: (accountId, change) => set((state) => {
const current = state.personal[accountId];
if (!current) return state;
return { personal: { ...state.personal, [accountId]: change(current) } };
}),
setTeams: (accountId, teams) => set((state) => ({ teams: { ...state.teams, [accountId]: teams } })),
forgetServerCopies: () => set((state) => {
const personal: Record<string, PersonalPartition> = {};
for (const [accountId, partition] of Object.entries(state.personal)) {
const kept = unsentPart(partition);
if (kept) personal[accountId] = kept;
}
return { personal, teams: {} };
}),
}),
{
name: 'account-templates',
version: 2,
partialize: (state) => ({ personal: state.personal, browserListOwner: state.browserListOwner }),
migrate: (persisted, version) => {
const state = (persisted ?? {}) as { personal?: Record<string, Partial<PersonalPartition>>; browserListOwner?: string | null };
if (version < 2) {
const personal: Record<string, PersonalPartition> = {};
for (const [id, partition] of Object.entries(state.personal ?? {})) personal[id] = { ...emptyPartition(), ...partition };
return { personal, browserListOwner: state.browserListOwner ?? null } as unknown as AccountTemplatesState;
}
return state as unknown as AccountTemplatesState;
},
},
),
);
// A change made here (not one a sync wrote) asks for a sync. The sync
// module registers the listener, so this store doesn't import it.
const changeListeners = new Set<(accountId: string) => void>();
export function onPersonalChange(listener: (accountId: string) => void): () => void {
changeListeners.add(listener);
return () => changeListeners.delete(listener);
}
export function notifyPersonalChange(accountId: string): void {
for (const listener of changeListeners) listener(accountId);
}
// Every tab writes the whole state back on a change; pick up another tab's.
if (typeof window !== 'undefined') {
window.addEventListener('storage', (event) => {
if (event.key !== 'account-templates' || event.storageArea !== window.localStorage) return;
void useAccountTemplatesStore.persist.rehydrate();
});
}
+2 -2
View File
@@ -19,7 +19,7 @@ import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } fro
import { isEmbedded, notifyParent } from '@/lib/iframe-bridge';
import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager';
import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope';
import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData } from '@/lib/sign-out-cleanup';
import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData, runBeforeSignOut } from '@/lib/sign-out-cleanup';
import { forgetPrincipals } from '@/lib/delegation/known-principals';
import { useSearchHistoryStore } from './search-history-store';
import { useCalendarNotificationStore } from './calendar-notification-store';
@@ -1329,7 +1329,7 @@ function notifySignedIn(state: { activeAccountId: string | null; username: strin
function beforeLogoutHooks(accountId: string): Promise<void> {
return Promise.race([
authHooks.onBeforeLogout.emit({ accountId }),
Promise.all([authHooks.onBeforeLogout.emit({ accountId }), runBeforeSignOut(accountId)]).then(() => {}),
new Promise<void>((resolve) => setTimeout(resolve, LOGOUT_HOOK_BUDGET_MS)),
]);
}
+32 -6
View File
@@ -30,6 +30,8 @@ import { folderListKey, forgetFolderLists, rememberFolderList, rememberedFolderL
import { keywordPointer } from "@/lib/jmap/patch-pointer";
import { useAccountStore } from "@/stores/account-store";
import { useMessageListTabsStore } from "@/stores/message-list-tabs-store";
import { activeListFilterCondition, activeListFilterKeywords, listFilters, type ListFilterFolder } from "@/lib/plugin-sandbox/list-filters";
import { mailAccountKind } from "@/lib/plugin-sandbox/account-kind";
import { SendCancelledCleanupError } from "@/lib/send-failure-message";
import type { StorageQuota } from "@/lib/quota";
import { isArchiveActionEnabled } from "@/lib/archive-gate";
@@ -797,22 +799,43 @@ function unreadFirstFor(
}
/**
* What a folder's query ANDs in: the open category tab's filter, and read
* mail only in the "Unread first" inbox, whose unread mail is the section's.
* The keyword filters plugins offer for the list (api.ui.setListFilters)
* that the user turned on and that apply to this folder.
*/
function pluginListFilter(mailbox: Mailbox | undefined): Record<string, unknown> | null {
if (!mailbox || listFilters().length === 0) return null;
return activeListFilterCondition(pluginFilterFolder(mailbox));
}
/** A folder as the plugins' list filters see it: its role and its account's kind. */
function pluginFilterFolder(mailbox: Mailbox): ListFilterFolder {
const auth = useAuthStore.getState();
const viewing = useEmailStore.getState().viewingAccountId;
const client = viewing ? auth.getClientForAccount(viewing) : auth.client;
return {
role: mailbox.role ?? null,
accountKind: client ? mailAccountKind(client, mailbox.isShared ? mailbox.accountId : undefined) : null,
};
}
/**
* What a folder's query ANDs in: the open category tab's filter, the
* plugins' list filters the user turned on, and read mail only in the
* "Unread first" inbox, whose unread mail is the section's.
*/
function folderListFilter(
mailbox: Mailbox | undefined,
s: Pick<EmailStore, 'selectedKeyword' | 'searchQuery' | 'searchFilters' | 'isUnifiedView'>,
): Record<string, unknown> | null {
const categoryFilter = useMessageListTabsStore.getState().getCategoryFilter(mailbox?.role);
return andFilters(categoryFilter, unreadFirstFor(mailbox, s) ? READ_FILTER : null);
return andFilters(andFilters(categoryFilter, pluginListFilter(mailbox)), unreadFirstFor(mailbox, s) ? READ_FILTER : null);
}
/** The newest unread mail of the folder, for the "Unread first" inbox. */
async function loadUnreadSection(client: IJMAPClient, mailbox: Mailbox, get: StoreGet): Promise<{ emails: Email[]; total: number }> {
const effectiveClient = resolveActionClient(client);
const accountId = mailbox.isShared ? mailbox.accountId : undefined;
const categoryFilter = useMessageListTabsStore.getState().getCategoryFilter(mailbox.role);
const categoryFilter = andFilters(useMessageListTabsStore.getState().getCategoryFilter(mailbox.role), pluginListFilter(mailbox));
const result = await effectiveClient.getEmails(
mailbox.originalId || mailbox.id,
accountId,
@@ -847,13 +870,16 @@ function applyUnreadSection(get: StoreGet, set: StoreSet, mailboxId: string, loa
* the changed keywords (#718). The change moves the row at once, possibly past
* the end of what is loaded, but nothing moves the rows from beyond it up -
* reading the top unread mail over and over ran out of unread mail after the
* first page while more waited on the server.
* first page while more waited on the server. The same for a keyword a
* plugin's list filter tests: a message marked done leaves an "Open" list.
*/
function refillAfterKeywordChange(get: StoreGet, client: IJMAPClient, changed: Iterable<string>): void {
const s = get();
if (s.isUnifiedView || s.isScheduledView || s.selectedKeyword || s.searchQuery || !isFilterEmpty(s.searchFilters)) return;
const sortKeywords = orderKeywords(s.listOrder);
if (![...changed].some((keyword) => sortKeywords.includes(keyword))) return;
const mailbox = listFilters().length > 0 ? resolveActionMailboxes().find((mb) => mb.id === s.selectedMailbox) : undefined;
const filtered = mailbox ? activeListFilterKeywords(pluginFilterFolder(mailbox)) : null;
if (![...changed].some((keyword) => sortKeywords.includes(keyword) || !!filtered?.has(keyword.toLowerCase()))) return;
void s.refreshCurrentMailbox(client);
}
+52 -16
View File
@@ -56,7 +56,25 @@ let blockedSyncAccountId: string | null = null;
// device settings and the first sign-in adopts them.
const SETTINGS_OWNER_KEY = 'settings-sync-owner';
function readSettingsOwner(): string | null {
// Accounts whose settings load from the server has finished (with or
// without a copy) since this page loaded.
const settledLoads = new Set<string>();
/** Whether this account's settings load has finished in this page. */
export function isSettingsLoadSettled(accountId: string): boolean {
return settledLoads.has(accountId);
}
const settledListeners = new Set<(accountId: string) => void>();
/** Called whenever an account's settings load finishes (with or without a copy). */
export function onSettingsLoadSettled(listener: (accountId: string) => void): () => void {
settledListeners.add(listener);
return () => settledListeners.delete(listener);
}
/** The account whose server copy the local settings (and template list) mirror; null if none. */
export function readSettingsOwner(): string | null {
try {
return typeof window === 'undefined' ? null : window.localStorage.getItem(SETTINGS_OWNER_KEY);
} catch {
@@ -89,14 +107,21 @@ interface SettingsSyncJob {
// bindings uninitialized depending on which module loads first. Instead,
// template-store registers itself here during its own module init.
interface TemplateSyncBridge {
getSyncedState: () => {
/**
* The templates of the account `accountId` (the one being synced, or the
* active one). `storedInAccount`: they live in the account's own server
* storage (JMAP metadata, Stalwart 1.0), so a sync push leaves them out
* and the stored blob drops them (`templatesInMetadata`).
*/
getSyncedState: (opts?: { accountId?: string }) => {
templates: EmailTemplate[];
deletedTemplateIds: Record<string, string>;
storedInAccount?: boolean;
};
applySyncedState: (
templates: unknown,
deletedTemplateIds: unknown,
opts: { merge: boolean }
opts: { merge: boolean; accountId?: string }
) => void;
}
@@ -129,9 +154,11 @@ function templateStateKey(templates: unknown, deletedTemplateIds: unknown): stri
* Whether the local template state differs from what a server blob carries,
* so the blob needs a push. False when there is nothing local to push.
*/
function localTemplatesAhead(serverSettings: Record<string, unknown> | null): boolean {
const local = templateSyncBridge?.getSyncedState();
function localTemplatesAhead(serverSettings: Record<string, unknown> | null, accountId?: string): boolean {
const local = templateSyncBridge?.getSyncedState({ accountId });
if (!local) return false;
// Templates kept in the account leave the blob: push once to drop them.
if (local.storedInAccount) return !!serverSettings && Array.isArray(serverSettings.templates);
if (local.templates.length === 0 && Object.keys(local.deletedTemplateIds).length === 0) return false;
if (!serverSettings) return true;
return templateStateKey(local.templates, local.deletedTemplateIds) !==
@@ -1204,9 +1231,11 @@ export const useSettingsStore = create<SettingsState>()(
}
};
const buildExport = (opts?: { subscriptionOwner?: string }): string => {
const buildExport = (opts?: { forSync?: boolean; subscriptionOwner?: string }): string => {
const state = get();
const templateSync = templateSyncBridge?.getSyncedState();
const templateSync = templateSyncBridge?.getSyncedState({
accountId: opts?.forSync && syncUsername && syncServerUrl ? generateAccountId(syncUsername, syncServerUrl) : undefined,
});
const subscriptionSync = calendarSubscriptionSyncBridge?.getSyncedState();
const settings = {
fontSize: state.fontSize,
@@ -1338,12 +1367,16 @@ export const useSettingsStore = create<SettingsState>()(
locale: useLocaleStore.getState().locale,
// Omitted entirely (rather than emitted empty) if the bridge has
// not registered, so an importing device leaves its templates alone.
...(templateSync
? {
templates: templateSync.templates,
deletedTemplateIds: templateSync.deletedTemplateIds,
}
: {}),
// Templates the account keeps itself are left out of a sync push;
// the flag has the stored blob drop its old copy (mergeStoredTemplates).
...(templateSync && opts?.forSync && templateSync.storedInAccount
? { templatesInMetadata: true }
: templateSync
? {
templates: templateSync.templates,
deletedTemplateIds: templateSync.deletedTemplateIds,
}
: {}),
// Omitted entirely if the bridge has not registered, so an importing
// device leaves its calendar subscriptions alone.
...(subscriptionSync
@@ -1545,7 +1578,7 @@ export const useSettingsStore = create<SettingsState>()(
templateSyncBridge?.applySyncedState(
settings.templates,
settings.deletedTemplateIds,
{ merge: Boolean(opts?.serverAccountId) }
{ merge: Boolean(opts?.serverAccountId), accountId: opts?.serverAccountId }
);
calendarSubscriptionSyncBridge?.applySyncedState(
settings.icalSubscriptions,
@@ -1760,7 +1793,7 @@ export const useSettingsStore = create<SettingsState>()(
// previous account left in this browser (#1185).
if (heldByOther) resetSyncedState({ keepCrossAccountViews });
writeSettingsOwner(accountId);
pushTemplatesAfterLoad = localTemplatesAhead(null);
pushTemplatesAfterLoad = localTemplatesAhead(null, accountId);
return false;
}
if (settings && typeof settings === 'object') {
@@ -1778,7 +1811,7 @@ export const useSettingsStore = create<SettingsState>()(
});
isLoadingFromServer = false;
writeSettingsOwner(accountId);
pushTemplatesAfterLoad = localTemplatesAhead(settings);
pushTemplatesAfterLoad = localTemplatesAhead(settings, accountId);
syncLog('Settings loaded from server successfully');
void settingsHooks.onSettingsSync.emit({ direction: 'pulled' });
// The per-account preferred sender identity (#507) is re-applied by
@@ -1792,6 +1825,9 @@ export const useSettingsStore = create<SettingsState>()(
syncError('Failed to load settings from server:', error);
isLoadingFromServer = false;
return failWithoutForeignState();
} finally {
settledLoads.add(accountId);
for (const listener of settledListeners) listener(accountId);
}
},
+72 -54
View File
@@ -9,13 +9,18 @@ import {
parseSyncedTemplates,
parseTemplateTombstones,
} from '@/lib/template-utils';
import { generateUUID } from '@/lib/utils';
import * as reducer from '@/lib/templates/template-reducer';
import { registerTemplateSyncBridge } from './settings-store';
import { notifyPersonalChange, useAccountTemplatesStore } from './account-templates-store';
import { withStorableId } from '@/lib/templates/metadata-templates';
import { templateHooks } from '@/lib/plugin-hooks';
import { templateToView } from '@/lib/plugin-projection';
const MAX_RECENT = 5;
const listOf = (state: { templates: EmailTemplate[]; deletedTemplateIds: Record<string, string> }): reducer.TemplateListState =>
({ templates: state.templates, deletedTemplateIds: state.deletedTemplateIds });
interface TemplateStore {
templates: EmailTemplate[];
recentTemplateIds: string[];
@@ -63,72 +68,36 @@ export const useTemplateStore = create<TemplateStore>()(
deletedTemplateIds: {},
addTemplate: (data) => {
const now = new Date().toISOString();
const template: EmailTemplate = {
...data,
id: generateUUID(),
createdAt: now,
updatedAt: now,
};
set((state) => ({
templates: [...state.templates, template],
}));
const { state, template } = reducer.addTemplate(listOf(get()), data);
set(state);
void templateHooks.onTemplateCreate.emit(templateToView(template));
return template;
},
updateTemplate: (id, updates) => {
set((state) => ({
templates: state.templates.map((t) =>
t.id === id
? { ...t, ...updates, updatedAt: new Date().toISOString() }
: t
),
}));
set(reducer.updateTemplate(listOf(get()), id, updates));
emitTemplateUpdate(id);
},
deleteTemplate: (id) => {
if (!get().templates.some((t) => t.id === id)) return;
set((state) => ({
templates: state.templates.filter((t) => t.id !== id),
...reducer.deleteTemplate(listOf(state), id),
recentTemplateIds: state.recentTemplateIds.filter((rid) => rid !== id),
deletedTemplateIds: {
...state.deletedTemplateIds,
[id]: new Date().toISOString(),
},
}));
void templateHooks.onTemplateDelete.emit({ templateId: id });
},
duplicateTemplate: (id, nameSuffix) => {
const original = get().templates.find((t) => t.id === id);
if (!original) return null;
const now = new Date().toISOString();
const duplicate: EmailTemplate = {
...original,
id: generateUUID(),
name: `${original.name} ${nameSuffix || '(copy)'}`,
isFavorite: false,
createdAt: now,
updatedAt: now,
};
set((state) => ({
templates: [...state.templates, duplicate],
}));
void templateHooks.onTemplateCreate.emit(templateToView(duplicate));
return duplicate;
const result = reducer.duplicateTemplate(listOf(get()), id, nameSuffix);
if (!result) return null;
set(result.state);
void templateHooks.onTemplateCreate.emit(templateToView(result.template));
return result.template;
},
toggleFavorite: (id) => {
set((state) => ({
templates: state.templates.map((t) =>
t.id === id ? { ...t, isFavorite: !t.isFavorite, updatedAt: new Date().toISOString() } : t
),
}));
set(reducer.toggleFavorite(listOf(get()), id));
emitTemplateUpdate(id);
},
@@ -174,9 +143,7 @@ export const useTemplateStore = create<TemplateStore>()(
importTemplates: (json) => {
const result = importUtil(json);
if (result.templates.length > 0) {
set((state) => ({
templates: [...state.templates, ...result.templates],
}));
set(reducer.appendTemplates(listOf(get()), result.templates));
void templateHooks.onTemplatesImport.emit({ count: result.templates.length });
}
return { count: result.templates.length, errors: result.errors };
@@ -260,14 +227,65 @@ if (typeof window !== 'undefined') {
// and file imports, and template changes schedule a sync push. Registration
// (rather than settings-store importing this module) avoids an import cycle -
// see registerTemplateSyncBridge.
//
// An account that keeps its templates in its own Inbox metadata (Stalwart
// 1.0, account-templates-store) leaves the blob once its marker entry is on
// the server: from then on its pushes carry no templates, and the flag has
// the stored blob drop its copy. Until then the blob syncs the browser's
// list as before. A blob loaded for such an account is merged into the
// browser's list as always and into the account's copy as well, so
// templates that only the blob has still reach the account.
const accountCopy = (accountId: string | undefined) => {
const accounts = useAccountTemplatesStore.getState();
const id = accountId ?? accounts.active ?? undefined;
return id ? accounts.personal[id] : undefined;
};
function mergeIntoAccount(accountId: string, templates: unknown, deletedTemplateIds: unknown): void {
const incoming = parseSyncedTemplates(templates);
if (!incoming || incoming.length === 0) return;
useAccountTemplatesStore.getState().updatePersonal(accountId, (copy) => ({
...copy,
...mergeSyncedTemplates(
{ templates: copy.templates, deletedTemplateIds: copy.deletedTemplateIds },
{ templates: incoming.map(withStorableId), deletedTemplateIds: parseTemplateTombstones(deletedTemplateIds) },
),
}));
notifyPersonalChange(accountId);
}
registerTemplateSyncBridge(
{
getSyncedState: () => {
getSyncedState: (opts) => {
const copy = accountCopy(opts?.accountId);
if (copy?.markerOnServer) return { templates: copy.templates, deletedTemplateIds: copy.deletedTemplateIds, storedInAccount: true };
const { templates, deletedTemplateIds } = useTemplateStore.getState();
return { templates, deletedTemplateIds };
},
applySyncedState: (templates, deletedTemplateIds, opts) =>
useTemplateStore.getState().applySyncedState(templates, deletedTemplateIds, opts),
applySyncedState: (templates, deletedTemplateIds, opts) => {
if (opts.accountId) {
useTemplateStore.getState().applySyncedState(templates, deletedTemplateIds, opts);
if (accountCopy(opts.accountId)) mergeIntoAccount(opts.accountId, templates, deletedTemplateIds);
return;
}
// A settings file the user imports: into the active account's copy
// when it keeps one, added by id (never a wholesale replace, which
// would delete server templates).
const active = useAccountTemplatesStore.getState().active;
if (active && accountCopy(active)) {
mergeIntoAccount(active, templates, {});
return;
}
useTemplateStore.getState().applySyncedState(templates, deletedTemplateIds, opts);
},
},
(listener) => useTemplateStore.subscribe(listener)
(listener) => {
useTemplateStore.subscribe(listener);
// An account whose marker just reached the server pushes once, so the
// blob drops its copy.
useAccountTemplatesStore.subscribe((state, prev) => {
const newlyMarked = Object.entries(state.personal).some(([id, copy]) => copy.markerOnServer && !prev.personal[id]?.markerOnServer);
if (newlyMarked) listener();
});
}
);