+ {showTabs && (
+
{
+ // Arrow keys, Home and End move between the lists, as in any tab list.
+ const index = sources.findIndex((s) => s.key === source?.key);
+ const next = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? index + 1
+ : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? index - 1
+ : e.key === 'Home' ? 0
+ : e.key === 'End' ? sources.length - 1
+ : null;
+ if (next === null || sources.length === 0) return;
+ e.preventDefault();
+ const target = sources[(next + sources.length) % sources.length];
+ selectSource(target.key);
+ document.getElementById(tabId(target.key))?.focus();
+ }}
+ >
+ {sources.map((s) => (
+ selectSource(s.key)}
+ className={cn(
+ 'max-w-full truncate rounded-full border px-3 py-1 text-sm transition-colors',
+ s.key === source?.key
+ ? 'border-primary bg-primary/10 text-primary'
+ : 'border-border text-muted-foreground hover:text-foreground hover:bg-muted'
+ )}
+ >
+ {sourceTitle(s)} {s.templates.length}
+
+ ))}
+
+ )}
+
+
+ {note &&
{note}
}
+
+ {!source && (
+
+
+
{tSettings('no_templates')}
+
+ )}
+
+ {source && !isCreating && !editingId && (
@@ -107,129 +234,185 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
className="ps-9"
/>
-
setIsCreating(true)}
- size="sm"
- >
- {tSettings('add')}
-
+ {editable && (
+
{ setIsCreating(true); setError(null); }} size="sm">
+ {tSettings('add')}
+
+ )}
)}
- {isCreating && (
+ {error && (
+
{error}
+ )}
+
+ {editable && isCreating && (
{tSettings('add')}
setIsCreating(false)}
+ onCancel={closeForm}
/>
)}
- {editingId && (
+ {editable && editingId && (
{tSettings('edit')}
t.id === editingId)}
+ template={templates.find((tpl) => tpl.id === editingId)}
+ categorySource={templates}
+ shared={!isPersonal}
+ busy={busy}
onSave={handleSave}
- onCancel={() => setEditingId(null)}
+ onCancel={closeForm}
/>
)}
- {filtered.map((template) => (
-
-
toggleFavorite(template.id)}
- className="flex-shrink-0"
- >
-
-
+ {source && filtered.map((template) => (
+
+
+ {isPersonal && (
+
void actions.toggleFavorite(source, template.id)}
+ aria-label={tSettings('favorite')}
+ aria-pressed={template.isFavorite}
+ className="flex-shrink-0"
+ >
+
+
+ )}
-
-
-
- {template.name}
-
- {template.category && (
-
- {template.category}
-
+
+
+
+ {template.name}
+
+ {template.category && (
+
+ {template.category}
+
+ )}
+
+ {template.subject && (
+
+ {template.subject}
+
+ )}
+ {isPersonal && actions.localOnly[template.id] && (
+
+ {actions.localOnly[template.id] === 'too-large' ? tOrg('local_only_too_large') : tOrg('local_only_no_room')}
+
)}
- {template.subject && (
-
- {template.subject}
-
- )}
-
-
-
{ setEditingId(template.id); setIsCreating(false); }}
- disabled={!!editingId || isCreating}
- className="h-8 w-8 p-0"
- >
-
-
-
duplicateTemplate(template.id, t('copy_suffix'))}
- disabled={!!editingId || isCreating}
- className="h-8 w-8 p-0"
- >
-
-
- {deleteConfirmId === template.id ? (
-
- handleDelete(template.id)}
- className="h-7 text-xs"
- >
- {tSettings('confirm_delete')}
-
- setDeleteConfirmId(null)}
- className="h-7 text-xs"
- >
- {tSettings('cancel')}
-
-
- ) : (
-
setDeleteConfirmId(template.id)}
- disabled={!!editingId || isCreating}
- className="h-8 w-8 p-0 hover:text-red-600 dark:hover:text-red-400"
- >
-
-
- )}
+
+ {editable ? (
+ <>
+
{ 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"
+ >
+
+
+
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"
+ >
+
+
+ {deleteConfirmId === template.id ? (
+
+ handleDelete(template.id)}
+ disabled={busy}
+ className="h-7 text-xs"
+ >
+ {tSettings('confirm_delete')}
+
+ setDeleteConfirmId(null)}
+ className="h-7 text-xs"
+ >
+ {tSettings('cancel')}
+
+
+ ) : (
+
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"
+ >
+
+
+ )}
+ >
+ ) : (
+ <>
+
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"
+ >
+
+
+ {personal?.editable && (
+
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"
+ >
+
+
+ )}
+ >
+ )}
+
+ {previewId === template.id && (
+
+
+
+ )}
))}
- {filtered.length === 0 && !isCreating && (
+ {source && filtered.length === 0 && !isCreating && (
@@ -238,6 +421,11 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
)}
+
+ {isPersonal && actions.storedInAccount && (
+
{tOrg('personal_in_account')}
+ )}
+
diff --git a/components/templates/template-picker.tsx b/components/templates/template-picker.tsx
index 533d858b2..8c2fdff41 100644
--- a/components/templates/template-picker.tsx
+++ b/components/templates/template-picker.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { 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
) => void;
+ /**
+ * The signed-in account the message is written from: its own templates,
+ * its team templates and its organisation library. The active one when
+ * left out.
+ */
+ accountId?: string | null;
}
-export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProps) {
+export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: TemplatePickerProps) {
const t = useTranslations('templates');
+ const tOrg = useTranslations('org_templates');
const locale = useLocale();
- const { templates, getFavorites, getRecent, getTemplatesByCategory, searchTemplates, recordUsage } =
- useTemplateStore();
+ const recordUsage = useTemplateStore((s) => s.recordUsage);
+ const recentTemplateIds = useTemplateStore((s) => s.recentTemplateIds);
const { primaryIdentity } = useAuthStore();
+ const { sources } = useTemplateSources(accountId);
const [searchQuery, setSearchQuery] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState(null);
const [showFillModal, setShowFillModal] = useState(false);
+ // What another device or a team member changed shows up when the picker opens.
+ useEffect(() => {
+ if (!isOpen) return;
+ refreshAccountTemplates(accountId);
+ const id = resolveAccountId(accountId);
+ if (id) refreshTeamTemplates(id);
+ }, [isOpen, accountId]);
+
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) => (
handleSelectTemplate(template)}
+ onClick={() => handleSelectTemplate(template, source)}
className="w-full text-start p-3 rounded-md hover:bg-muted transition-colors group"
>
-
- {template.isFavorite && (
+
+ {source.kind === 'personal' && template.isFavorite && (
)}
{template.name}
{template.category && (
-
+
{template.category}
)}
@@ -96,33 +113,89 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
{template.subject}
)}
+ {/* Who the template addresses, before it is put into the message. */}
+ {(template.defaultRecipients?.to?.length || template.defaultRecipients?.cc?.length) ? (
+
+ {[
+ template.defaultRecipients?.to?.length ? tOrg('preview_to', { list: template.defaultRecipients.to.join(', ') }) : null,
+ template.defaultRecipients?.cc?.length ? tOrg('preview_cc', { list: template.defaultRecipients.cc.join(', ') }) : null,
+ ].filter(Boolean).join(' · ')}
+
+ ) : null}
);
- const renderSection = (title: string, items: EmailTemplate[]) => {
+ const renderSection = (title: string, items: EmailTemplate[], source: TemplateSource) => {
if (items.length === 0) return null;
return (
-
+
{title}
-
{items.map(renderTemplateItem)}
+
{items.map((item) => renderTemplateItem(item, source))}
);
};
- const categorizedEntries = Object.entries(byCategory).filter(
- ([cat]) => cat !== ''
- );
- const favoriteIds = new Set(favorites.map((f) => f.id));
- const recentFiltered = recent.filter((r) => !favoriteIds.has(r.id));
- const shownIds = new Set([
- ...favoriteIds,
- ...recentFiltered.map((r) => r.id),
- ]);
- const uncategorizedFiltered = (byCategory[''] || []).filter(
- (i) => !shownIds.has(i.id)
- );
+ const sourceTitle = (source: TemplateSource) =>
+ source.kind === 'organisation' ? tOrg('section_organisation')
+ : source.kind === 'team' ? (source.name ?? '')
+ : tOrg('section_personal');
+
+ // The user's own templates keep their favourites, recently used and
+ // categories. With team or organisation templates next to them, the
+ // whole block gets a heading of its own.
+ const renderPersonal = (source: TemplateSource, withHeading: boolean) => {
+ const templates = source.templates;
+ const favorites = templates.filter((tpl) => tpl.isFavorite);
+ const favoriteIds = new Set(favorites.map((f) => f.id));
+ const recent = recentTemplateIds
+ .map((id) => templates.find((tpl) => tpl.id === id))
+ .filter((tpl): tpl is EmailTemplate => !!tpl && !favoriteIds.has(tpl.id));
+ const shownIds = new Set([...favoriteIds, ...recent.map((r) => r.id)]);
+ const byCategory: Record
= {};
+ for (const tpl of templates) {
+ if (shownIds.has(tpl.id)) continue;
+ (byCategory[tpl.category || ''] ??= []).push(tpl);
+ }
+ const body = (
+ <>
+ {renderSection(t('section_favorites'), favorites, source)}
+ {renderSection(t('section_recent'), recent, source)}
+ {Object.entries(byCategory)
+ .filter(([cat]) => cat !== '')
+ .map(([cat, items]) => renderSection(cat, items, source))}
+ {renderSection(t('section_uncategorized'), byCategory[''] ?? [], source)}
+ >
+ );
+ if (!withHeading) return {body}
;
+ if (templates.length === 0) return null;
+ return (
+
+ {sourceTitle(source)}
+ {body}
+
+ );
+ };
+
+ const renderShared = (source: TemplateSource, items: EmailTemplate[]) => {
+ if (items.length === 0) return null;
+ return (
+
+ {sourceTitle(source)}
+ {items.map((item) => renderTemplateItem(item, source))}
+
+ );
+ };
+
+ const total = sources.reduce((n, source) => n + source.templates.length, 0);
+ // Only the user's own templates: the picker looks as it always did.
+ const onlyPersonal = sources.every((source) => source.kind === 'personal' || source.templates.length === 0);
+
+ const results = searchQuery
+ ? sources.map((source) => ({ source, items: filterTemplates(source.templates, searchQuery) }))
+ : null;
+ const resultCount = results?.reduce((n, r) => n + r.items.length, 0) ?? 0;
return (
<>
@@ -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
{t('picker_title')}
@@ -162,35 +238,31 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
- {templates.length === 0 && (
+ {total === 0 && (
)}
- {filtered ? (
- filtered.length > 0 ? (
-
- {filtered.map(renderTemplateItem)}
-
+ {results ? (
+ resultCount > 0 ? (
+ onlyPersonal ? (
+
+ {results.flatMap(({ source, items }) => items.map((item) => renderTemplateItem(item, source)))}
+
+ ) : (
+ results.map(({ source, items }) => renderShared(source, items))
+ )
) : (
{t('no_results')}
)
) : (
- <>
- {renderSection(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))
)}
diff --git a/hooks/use-account-policy.ts b/hooks/use-account-policy.ts
new file mode 100644
index 000000000..9eaddcbcf
--- /dev/null
+++ b/hooks/use-account-policy.ts
@@ -0,0 +1,24 @@
+import { useMemo } from 'react';
+import type { SettingsPolicy } from '@/lib/admin/types';
+import { subjectForAccount, useAccountSubjects } from '@/lib/org/account-subjects';
+import { usePolicyStore } from '@/stores/policy-store';
+
+/**
+ * The effective policy of a signed-in account: the active account's from
+ * the policy store, another account's resolved for its own domain and group
+ * scopes (a composer sending from that account). Follows the policy and the
+ * account's subject as they load.
+ */
+export function useAccountPolicy(accountId: string | null | undefined): SettingsPolicy {
+ const policy = usePolicyStore((s) => s.policy);
+ const rawPolicy = usePolicyStore((s) => s.rawPolicy);
+ const subject = usePolicyStore((s) => s.subject);
+ const resolveFor = usePolicyStore((s) => s.resolveFor);
+ // Re-read when another account's subject has been worked out.
+ const accountSubjects = useAccountSubjects((s) => s.subjects);
+ return useMemo(() => {
+ const accountSubject = subjectForAccount(accountId);
+ return accountSubject === subject ? policy : resolveFor(accountSubject);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [policy, rawPolicy, subject, resolveFor, accountId, accountSubjects]);
+}
diff --git a/hooks/use-template-source-actions.ts b/hooks/use-template-source-actions.ts
new file mode 100644
index 000000000..7a859e72f
--- /dev/null
+++ b/hooks/use-template-source-actions.ts
@@ -0,0 +1,116 @@
+import { useMemo } from 'react';
+import type { EmailTemplate } from '@/lib/template-types';
+import type { TemplateSaveResult, TemplateSource } from '@/lib/templates/template-sources';
+import type { TemplateData } from '@/lib/templates/template-reducer';
+import { personalTemplatesFor } from '@/lib/templates/personal-templates';
+import { deleteTeamTemplate, saveTeamTemplate } from '@/lib/templates/team-templates';
+import { generateUUID } from '@/lib/utils';
+import { useAuthStore } from '@/stores/auth-store';
+import { useAccountTemplatesStore, type LocalOnlyReason } from '@/stores/account-templates-store';
+
+export type { LocalOnlyReason, TemplateSaveResult };
+
+export interface TemplateSourceActions {
+ add: (source: TemplateSource, data: TemplateData) => Promise;
+ update: (source: TemplateSource, id: string, data: TemplateData) => Promise;
+ remove: (source: TemplateSource, id: string) => Promise;
+ duplicate: (source: TemplateSource, id: string, nameSuffix: string) => Promise;
+ toggleFavorite: (source: TemplateSource, id: string) => Promise;
+ /** A read-only (organisation or team) template copied into the user's own. */
+ copyToPersonal: (template: EmailTemplate) => Promise;
+ /** Personal templates that stay in this browser, by id. */
+ localOnly: Record;
+ /** Personal templates live in the account (server metadata), not only in this browser. */
+ storedInAccount: boolean;
+}
+
+const NO_LOCAL_ONLY: Record = {};
+
+/**
+ * Changes to the templates of each source the manager shows, for the
+ * signed-in account `accountId` (the active one when left out). Personal
+ * templates change in this browser at once and go to the account in the
+ * background; team templates are written to the group's Inbox at once;
+ * read-only sources refuse every change.
+ */
+export function useTemplateSourceActions(accountId?: string | null): TemplateSourceActions {
+ const resolvedId = useAuthStore((s) => accountId || s.activeAccountId);
+ const partition = useAccountTemplatesStore((s) => (resolvedId ? s.personal[resolvedId] : undefined));
+ const storedInAccount = !!partition;
+ const localOnly = partition?.localOnly ?? NO_LOCAL_ONLY;
+ return useMemo(() => {
+ const ops = () => personalTemplatesFor(resolvedId);
+ const group = (source: TemplateSource) => source.key.slice('team:'.length);
+ const find = (source: TemplateSource, id: string) => source.templates.find((t) => t.id === id);
+ const team = (source: TemplateSource, template: EmailTemplate, base: string | null): Promise =>
+ resolvedId ? saveTeamTemplate(resolvedId, group(source), template, base) : Promise.resolve('failed');
+ const fresh = (data: TemplateData): EmailTemplate => {
+ const at = new Date().toISOString();
+ return { ...data, id: generateUUID(), createdAt: at, updatedAt: at };
+ };
+ return {
+ add: async (source, data) => {
+ if (!source.editable) return 'forbidden';
+ if (source.kind === 'personal') {
+ ops().add(data);
+ return 'ok';
+ }
+ return source.kind === 'team' ? team(source, fresh(data), null) : 'forbidden';
+ },
+ update: async (source, id, data) => {
+ if (!source.editable) return 'forbidden';
+ if (source.kind === 'personal') {
+ ops().update(id, data);
+ return 'ok';
+ }
+ const existing = find(source, id);
+ if (source.kind !== 'team' || !existing) return 'forbidden';
+ return team(source, { ...existing, ...data, id, updatedAt: new Date().toISOString() }, existing.updatedAt);
+ },
+ remove: async (source, id) => {
+ if (!source.editable) return 'forbidden';
+ if (source.kind === 'personal') {
+ ops().remove(id);
+ return 'ok';
+ }
+ const existing = find(source, id);
+ if (source.kind !== 'team' || !existing || !resolvedId) return 'forbidden';
+ return deleteTeamTemplate(resolvedId, group(source), id, existing.updatedAt);
+ },
+ duplicate: async (source, id, suffix) => {
+ if (!source.editable) return 'forbidden';
+ if (source.kind === 'personal') {
+ ops().duplicate(id, suffix);
+ return 'ok';
+ }
+ const existing = find(source, id);
+ if (source.kind !== 'team' || !existing) return 'forbidden';
+ return team(source, fresh({ ...copyOf(existing), name: `${existing.name} ${suffix}` }), null);
+ },
+ toggleFavorite: async (source, id) => {
+ if (source.kind !== 'personal' || !source.editable) return 'forbidden';
+ ops().toggleFavorite(id);
+ return 'ok';
+ },
+ copyToPersonal: async (template) => {
+ ops().add(copyOf(template));
+ return 'ok';
+ },
+ localOnly,
+ storedInAccount,
+ };
+ }, [resolvedId, localOnly, storedInAccount]);
+}
+
+/** The fields a copy of a shared template takes into the user's own list. */
+export function copyOf(template: EmailTemplate): TemplateData {
+ return {
+ name: template.name,
+ subject: template.subject,
+ body: template.body,
+ isHTML: template.isHTML,
+ category: template.category,
+ defaultRecipients: template.defaultRecipients,
+ isFavorite: false,
+ };
+}
diff --git a/hooks/use-template-sources.ts b/hooks/use-template-sources.ts
new file mode 100644
index 000000000..108f2a4b6
--- /dev/null
+++ b/hooks/use-template-sources.ts
@@ -0,0 +1,62 @@
+import { useMemo } from 'react';
+import { useAccountPolicy } from '@/hooks/use-account-policy';
+import { orgLibraryTemplates } from '@/lib/templates/org-library';
+import {
+ ORGANISATION_SOURCE,
+ PERSONAL_SOURCE,
+ byName,
+ teamSourceKey,
+ type TemplateSource,
+} from '@/lib/templates/template-sources';
+import { useTemplateStore } from '@/stores/template-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { useAccountTemplatesStore } from '@/stores/account-templates-store';
+
+export interface TemplateSources {
+ /** Personal first, then teams, then the organisation library; empty sources left out except personal. */
+ sources: TemplateSource[];
+ /** The admin hid personal templates (`personalTemplates` hidden): only organisation and team templates. */
+ personalHidden: boolean;
+}
+
+/**
+ * The templates a user can pick when writing from the signed-in account
+ * `accountId` (the active one when left out): their own, those of the
+ * account's group mailboxes, and the admin library of the account's
+ * policy, so a composer sending from another account shows that
+ * account's templates.
+ */
+export function useTemplateSources(accountId?: string | null): TemplateSources {
+ const policy = useAccountPolicy(accountId);
+ const resolvedId = useAuthStore((s) => accountId || s.activeAccountId);
+ // The account's own copy once it keeps its templates (Stalwart 1.0),
+ // the browser's list before that and on other servers.
+ const accountCopy = useAccountTemplatesStore((s) => (resolvedId ? s.personal[resolvedId]?.templates : undefined));
+ const browserList = useTemplateStore((s) => s.templates);
+ const personal = accountCopy ?? browserList;
+ const teams = useAccountTemplatesStore((s) => (resolvedId ? s.teams[resolvedId] : undefined));
+ const personalHidden = policy.restrictions?.personalTemplates?.hidden === true;
+ // `teamTemplates` locked: members can use them, not change them.
+ const teamsLocked = policy.restrictions?.teamTemplates?.locked === true;
+ return useMemo(() => {
+ const sources: TemplateSource[] = [];
+ if (!personalHidden) {
+ sources.push({ key: PERSONAL_SOURCE, kind: 'personal', templates: personal, editable: true, locked: false });
+ }
+ for (const team of teams ?? []) {
+ sources.push({
+ key: teamSourceKey(team.accountId),
+ kind: 'team',
+ name: team.name,
+ templates: byName(team.templates),
+ editable: team.mayWrite && !teamsLocked,
+ locked: teamsLocked,
+ });
+ }
+ const organisation = orgLibraryTemplates(policy);
+ if (organisation.length > 0) {
+ sources.push({ key: ORGANISATION_SOURCE, kind: 'organisation', templates: byName(organisation), editable: false, locked: false });
+ }
+ return { sources, personalHidden };
+ }, [policy, personal, personalHidden, teams, teamsLocked]);
+}
diff --git a/integration/tests/19-org-teaminbox.spec.ts b/integration/tests/19-org-teaminbox.spec.ts
new file mode 100644
index 000000000..7395c774a
--- /dev/null
+++ b/integration/tests/19-org-teaminbox.spec.ts
@@ -0,0 +1,313 @@
+import { test, expect, type Locator, type Page } from '@playwright/test';
+import { existsSync, readFileSync } from 'node:fs';
+import { JmapClient } from './helpers/jmap';
+import { emailItem, expandSharedFolders, folderRow } from './helpers/app';
+import {
+ ORG,
+ ORG_PASSWORD,
+ ORG_SERVER,
+ adminContext,
+ getPolicy,
+ putPolicy,
+ requireOrgEnv,
+ shot,
+ userPage,
+} from './helpers/org';
+
+/**
+ * The team inbox plugin (organisation feature 10) through the real UI on a
+ * VM 106 test server: installed from a local build through the admin, then
+ * org-alice assigns a message of the org-team group to org-bob, bob finds it
+ * under "Assigned to me" and marks it done, which takes it out of "Open".
+ * carol, who is in no group, and alice's own mailbox get no team actions.
+ *
+ * IT_TEAM_INBOX_ZIP names the zip (default: the plugins worktree's build).
+ * The test messages are created in the group's Inbox over JMAP and
+ * destroyed at the end, keywords and all; the plugin is removed and the
+ * policy put back.
+ */
+
+test.describe.configure({ mode: 'serial', timeout: 240000 });
+
+const ZIP = process.env.IT_TEAM_INBOX_ZIP ?? 'C:/Users/info/worktrees/plugins-org-teaminbox/team-inbox/team-inbox.zip';
+const RUN = Date.now().toString(36);
+const SELF = `Team inbox bob takes it ${RUN}`;
+const FOR_BOB = `Team inbox for bob ${RUN}`;
+const OTHER = `Team inbox nobody ${RUN}`;
+const OWN = `Team inbox alice own ${RUN}`;
+
+let original: Record;
+const created: Array<{ account: string; accountId: string; id: string; subject: string }> = [];
+
+/** A received-looking message, created by `account` in the Inbox of the account named `accountName`. */
+async function createMessage(account: string, accountName: string, subject: string): Promise {
+ const client = await JmapClient.connect(account, ORG_PASSWORD);
+ const accountId = accountName === account ? client.accountId : client.accountIdByName(accountName);
+ const inbox = await client.mailboxByRole('inbox', accountId);
+ if (!inbox) throw new Error(`${accountName}: no Inbox`);
+ const r = await client.request([
+ ['Email/set', {
+ accountId,
+ create: {
+ m: {
+ mailboxIds: { [inbox.id]: true },
+ keywords: {},
+ from: [{ name: 'Customer', email: 'customer@example.net' }],
+ to: [{ email: accountName }],
+ subject,
+ receivedAt: new Date().toISOString(),
+ bodyValues: { b: { value: `A message for the team inbox spec (${RUN}).` } },
+ textBody: [{ partId: 'b', type: 'text/plain' }],
+ },
+ },
+ }, '0'],
+ ]);
+ const id = r.methodResponses?.[0]?.[1]?.created?.m?.id;
+ if (!id) throw new Error(`could not create "${subject}": ${JSON.stringify(r.methodResponses?.[0])}`);
+ created.push({ account, accountId, id, subject });
+}
+
+/** The keywords of a test message, as the server has them. */
+async function keywordsOf(subject: string): Promise {
+ const entry = created.find((c) => c.subject === subject);
+ if (!entry) return [];
+ const client = await JmapClient.connect(entry.account, ORG_PASSWORD);
+ const r = await client.request([['Email/get', { accountId: entry.accountId, ids: [entry.id], properties: ['keywords'] }, '0']]);
+ return Object.keys(r.methodResponses?.[0]?.[1]?.list?.[0]?.keywords ?? {});
+}
+
+async function openTeamInbox(page: Page): Promise {
+ await expandSharedFolders(page, ORG.team);
+ await folderRow(page, { role: 'inbox', shared: true }).first().click();
+}
+
+function filterChip(page: Page, name: string): Locator {
+ return page.locator('[data-testid="plugin-list-filters"] button', { hasText: name });
+}
+
+/** Right-click a row and wait for the menu to carry the plugin's entries. */
+async function rowMenu(page: Page, subject: string): Promise {
+ const row = emailItem(page, subject).first();
+ await row.waitFor({ state: 'visible', timeout: 30000 });
+ await expect(async () => {
+ await page.keyboard.press('Escape');
+ await row.click({ button: 'right' });
+ await page.getByRole('menuitem', { name: 'Reply', exact: true }).first().waitFor({ state: 'visible', timeout: 2000 });
+ }).toPass({ timeout: 20000 });
+}
+
+/** The plugin runs once its background frame is there and it has offered its filters. */
+async function pluginReady(page: Page): Promise {
+ await expect(page.locator('iframe[src*="/plugin-sandbox"]').first()).toBeAttached({ timeout: 60000 });
+}
+
+test.beforeAll(async ({ browser }) => {
+ test.setTimeout(300000);
+ test.setTimeout(300000);
+ requireOrgEnv();
+ test.skip(!existsSync(ZIP), `build the plugin first: ${ZIP}`);
+ const admin = await adminContext(browser);
+ original = await getPolicy(admin);
+ // next dev compiles the sandbox page on first use, which can outlast the plugin's init timeout.
+ await admin.request.get('/plugin-sandbox', { timeout: 120000 });
+ await admin.request.delete('/api/admin/plugins', { data: { id: 'team-inbox' } });
+ const installed = await admin.request.post('/api/admin/plugins', {
+ multipart: { file: { name: 'team-inbox.zip', mimeType: 'application/zip', buffer: readFileSync(ZIP) } },
+ });
+ expect(installed.ok(), await installed.text()).toBe(true);
+ const features = { ...(original.features as Record), pluginsEnabled: true };
+ const forced = [...new Set([...((original.forceEnabledPlugins as string[] | undefined) ?? []), 'team-inbox'])];
+ await putPolicy(admin, { ...original, features, forceEnabledPlugins: forced });
+ await admin.close();
+
+ await createMessage(ORG.alice, ORG.team, OTHER);
+ await createMessage(ORG.alice, ORG.team, SELF);
+ await createMessage(ORG.alice, ORG.team, FOR_BOB);
+ await createMessage(ORG.alice, ORG.alice, OWN);
+});
+
+const METADATA = 'urn:ietf:params:jmap:metadata';
+const NAMES_ENTRY = 'org.bulwarkmail.plugin.team-inbox';
+
+/** The plugin's names in the team Inbox's shared metadata (only v1.0.0 keeps them). */
+async function sharedNames(): Promise | null> {
+ const client = await JmapClient.connect(ORG.alice, ORG_PASSWORD);
+ const accountId = client.accountIdByName(ORG.team);
+ const inbox = await client.mailboxByRole('inbox', accountId);
+ const r = await client.request([['Mailbox/get', { accountId, ids: [inbox!.id], properties: ['id', 'metadata'] }, '0']],
+ ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', METADATA]);
+ return r.methodResponses?.[0]?.[1]?.list?.[0]?.metadata?.[NAMES_ENTRY] ?? null;
+}
+
+test.afterAll(async ({ browser }) => {
+ test.setTimeout(180000);
+ for (const entry of created) {
+ const client = await JmapClient.connect(entry.account, ORG_PASSWORD);
+ await client.request([['Email/set', { accountId: entry.accountId, destroy: [entry.id] }, '0']]).catch(() => {});
+ }
+ // The names the plugin kept in the team Inbox go too (other features keep
+ // their own entries there).
+ if (ORG_SERVER === '1.0.0') {
+ const client = await JmapClient.connect(ORG.alice, ORG_PASSWORD);
+ const accountId = client.accountIdByName(ORG.team);
+ const inbox = await client.mailboxByRole('inbox', accountId);
+ if (inbox) {
+ await client.request([['Mailbox/set', { accountId, update: { [inbox.id]: { [`metadata/${NAMES_ENTRY}`]: null } } }, '0']],
+ ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', METADATA]).catch(() => {});
+ }
+ }
+ if (!original) return;
+ const admin = await adminContext(browser);
+ await putPolicy(admin, original);
+ await admin.request.delete('/api/admin/plugins', { data: { id: 'team-inbox' } });
+ await admin.close();
+});
+
+test('bob takes a message himself from the row menu', async ({ browser }) => {
+ const bob = await userPage(browser, ORG.bob);
+ await pluginReady(bob);
+ await openTeamInbox(bob);
+ await expect(filterChip(bob, 'Assigned to me')).toBeVisible({ timeout: 30000 });
+ await expect(filterChip(bob, 'Open')).toBeVisible();
+
+ await rowMenu(bob, SELF);
+ await expect(bob.getByRole('menuitem', { name: 'Mark done' })).toBeVisible();
+ await expect(bob.getByRole('menuitem', { name: 'Reopen' })).toHaveCount(0);
+ await expect(bob.getByRole('menuitem', { name: 'Unassign' })).toHaveCount(0);
+ await bob.getByRole('menuitem', { name: 'Assign to me' }).click();
+ await expect(emailItem(bob, SELF).first().locator('[data-plugin-row-badge="team-inbox-assignee"]')).toHaveText('Me', { timeout: 20000 });
+ await expect.poll(() => keywordsOf(SELF)).toContain(`$assigned:${await principalId(ORG.bob)}`);
+ await bob.context().close();
+});
+
+async function principalId(email: string): Promise {
+ return (await JmapClient.connect(email, ORG_PASSWORD)).accountId;
+}
+
+test('alice assigns a team message to bob', async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice);
+ await pluginReady(alice);
+ await openTeamInbox(alice);
+ await expect(emailItem(alice, SELF).first().locator('[data-plugin-row-badge="team-inbox-assignee"]')).toBeVisible({ timeout: 30000 });
+ await shot(alice, 'teaminbox-list-badges');
+
+ await rowMenu(alice, FOR_BOB);
+ await alice.getByRole('menuitem', { name: 'Assign to…' }).click();
+ const dialog = alice.locator('[data-plugin-dialog="team-inbox"]');
+ await expect(dialog).toBeVisible({ timeout: 30000 });
+ const frame = dialog.frameLocator('iframe').first();
+ // Bob is known by name where the server has metadata or a directory, else
+ // by his id, as the team member messages were assigned to lately.
+ const bobId = await principalId(ORG.bob);
+ const bobRow = frame.getByRole('listitem').filter({ hasText: new RegExp(`org-bob@example\\.org|Id ${bobId}$`) }).first();
+ await expect(bobRow).toBeVisible({ timeout: 30000 });
+ await expect(frame.getByRole('listitem').filter({ hasText: 'You' })).toHaveCount(1);
+ await shot(alice, 'teaminbox-assign-dialog');
+ await bobRow.click();
+ await expect(dialog).toHaveCount(0, { timeout: 20000 });
+
+ await expect.poll(() => keywordsOf(FOR_BOB), { timeout: 20000 }).toContain(`$assigned:${bobId}`);
+ const forBobBadge = emailItem(alice, FOR_BOB).first().locator('[data-plugin-row-badge="team-inbox-assignee"]');
+ await expect(forBobBadge).toBeVisible({ timeout: 20000 });
+ // The badge names bob where the server shares names (metadata on v1.0.0,
+ // or a directory); without either it says the assignee isn't known.
+ if (ORG_SERVER === '1.0.0') {
+ await expect(forBobBadge).toHaveAttribute('title', `Assigned to Org Bob <${ORG.bob}>`);
+ await expect(forBobBadge).toHaveText('Org');
+ } else {
+ await expect(forBobBadge).toHaveAttribute('title', /Assigned to (Org Bob|a team member whose name Bulwark doesn't know)/);
+ }
+
+ // Alice's own mailbox gets no team actions and no filters.
+ await folderRow(alice, { role: 'inbox', shared: false }).first().click();
+ await expect(emailItem(alice, OWN).first()).toBeVisible({ timeout: 30000 });
+ await expect(alice.locator('[data-testid="plugin-list-filters"]')).toHaveCount(0);
+ await rowMenu(alice, OWN);
+ await expect(alice.getByRole('menuitem', { name: 'Assign to me' })).toHaveCount(0);
+ await expect(alice.getByRole('menuitem', { name: 'Mark done' })).toHaveCount(0);
+ await alice.context().close();
+});
+
+test('on Stalwart v1.0.0 the names are shared in the team Inbox\'s metadata', async () => {
+ test.skip(ORG_SERVER !== '1.0.0', 'only the v1.0.0 release keeps metadata (facts §2)');
+ const names = await sharedNames() as { people?: Record } | null;
+ const [aliceId, bobId] = [await principalId(ORG.alice), await principalId(ORG.bob)];
+ expect(names?.people?.[aliceId]?.e).toBe(ORG.alice);
+ expect(names?.people?.[bobId]?.e).toBe(ORG.bob);
+});
+
+test('bob finds it under "Assigned to me" and marking it done takes it out of "Open"', async ({ browser }) => {
+ const bob = await userPage(browser, ORG.bob);
+ await pluginReady(bob);
+ await openTeamInbox(bob);
+ await expect(emailItem(bob, OTHER).first()).toBeVisible({ timeout: 30000 });
+
+ await filterChip(bob, 'Assigned to me').click();
+ await expect(filterChip(bob, 'Assigned to me')).toHaveAttribute('aria-pressed', 'true');
+ await expect(emailItem(bob, FOR_BOB).first()).toBeVisible({ timeout: 30000 });
+ await expect(emailItem(bob, SELF).first()).toBeVisible();
+ await expect(emailItem(bob, OTHER)).toHaveCount(0);
+ await shot(bob, 'teaminbox-assigned-to-me');
+
+ // Done from the reader toolbar.
+ await emailItem(bob, FOR_BOB).first().click();
+ const markDone = bob.locator('[data-plugin-actions="reader-actions"]').getByRole('button', { name: 'Mark done' });
+ await expect(markDone).toBeVisible({ timeout: 30000 });
+ await markDone.click();
+ await expect(bob.locator('[data-plugin-actions="reader-actions"]').getByRole('button', { name: 'Reopen' })).toBeVisible({ timeout: 20000 });
+ await expect.poll(() => keywordsOf(FOR_BOB), { timeout: 20000 }).toContain('$done');
+ await expect(emailItem(bob, FOR_BOB).first().locator('[data-plugin-row-badge="team-inbox-done"]')).toBeVisible({ timeout: 20000 });
+
+ await filterChip(bob, 'Open').click();
+ await expect(emailItem(bob, FOR_BOB)).toHaveCount(0, { timeout: 30000 });
+ await expect(emailItem(bob, SELF).first()).toBeVisible();
+ await shot(bob, 'teaminbox-open-filter');
+
+ // Off again: the chip's second click removes the filter.
+ await filterChip(bob, 'Open').click();
+ await filterChip(bob, 'Assigned to me').click();
+ await expect(emailItem(bob, OTHER).first()).toBeVisible({ timeout: 30000 });
+ await expect(emailItem(bob, FOR_BOB).first()).toBeVisible();
+ await bob.context().close();
+});
+
+test('carol, in no group, gets no team actions or filters', async ({ browser }) => {
+ await createMessage(ORG.carol, ORG.carol, `Team inbox carol ${RUN}`);
+ const carol = await userPage(browser, ORG.carol);
+ await pluginReady(carol);
+ await expect(emailItem(carol, `Team inbox carol ${RUN}`).first()).toBeVisible({ timeout: 30000 });
+ await expect(carol.locator('[data-testid="plugin-list-filters"]')).toHaveCount(0);
+ await rowMenu(carol, `Team inbox carol ${RUN}`);
+ await expect(carol.getByRole('menuitem', { name: 'Assign to me' })).toHaveCount(0);
+ await expect(carol.getByRole('menuitem', { name: 'Assign to…' })).toHaveCount(0);
+ await carol.context().close();
+});
+
+test('on a phone the filters fit and the actions are in the message\'s More menu', async ({ browser }) => {
+ const bob = await userPage(browser, ORG.bob, true);
+ await pluginReady(bob);
+ // The folder list is behind the header's menu button on phones.
+ await bob.locator('[data-mobile-mail-header] button').first().click();
+ await openTeamInbox(bob);
+ const filters = bob.locator('[data-testid="plugin-list-filters"]');
+ await expect(filters).toBeVisible({ timeout: 30000 });
+ for (const chip of await filters.getByRole('button').all()) {
+ const box = await chip.boundingBox();
+ expect(box).not.toBeNull();
+ expect(box!.x).toBeGreaterThanOrEqual(0);
+ expect(box!.x + box!.width).toBeLessThanOrEqual(390.5);
+ }
+ expect(await bob.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
+ await shot(bob, 'teaminbox-phone-filters');
+
+ await emailItem(bob, OTHER).first().click();
+ await bob.getByRole('button', { name: 'More actions' }).last().click();
+ await expect(bob.getByRole('menuitem', { name: 'Assign to me' })).toBeVisible({ timeout: 20000 });
+ await expect(bob.getByRole('menuitem', { name: 'Mark done' })).toBeVisible();
+ await shot(bob, 'teaminbox-phone-more');
+ await bob.context().close();
+});
+
+test.afterAll(() => {
+ console.log(`team inbox spec ran on ${ORG_SERVER}`);
+});
diff --git a/integration/tests/19-org-templates.spec.ts b/integration/tests/19-org-templates.spec.ts
new file mode 100644
index 000000000..aecdfb075
--- /dev/null
+++ b/integration/tests/19-org-templates.spec.ts
@@ -0,0 +1,469 @@
+import { test, expect, type Page } from '@playwright/test';
+import { JmapClient } from './helpers/jmap';
+import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
+import {
+ ORG,
+ ORG_PASSWORD,
+ ORG_SERVER,
+ adminContext,
+ contextOptions,
+ getPolicy,
+ putPolicy,
+ requireOrgEnv,
+ shot,
+ userPage,
+} from './helpers/org';
+
+/**
+ * Shared templates (organisation feature 3), through the real UI on a VM 106
+ * test server.
+ *
+ * On the v1.0.0 release (label 1.0.0, the only one with working metadata):
+ * the admin builds a library on Organisation > Templates, for Everyone and
+ * for org-two.test; org-alice adds a team template in org-team, org-bob
+ * picks it; org-alice's own template reaches a second browser; org-dave
+ * sees the library of his domain; the admin locks team templates for the
+ * group; the picker, the manager and the admin page work at phone width.
+ *
+ * On 0.16 and the 1.0 test build: no team section, personal templates stay
+ * in the browser as before, and the app stays online.
+ *
+ * The admin's policy is put back, and every template entry these tests put
+ * in the accounts' Inbox metadata is removed again.
+ */
+
+test.describe.configure({ mode: 'serial', timeout: 240000 });
+
+const METADATA = ORG_SERVER === '1.0.0';
+const STAMP = Date.now().toString(36);
+const LIBRARY = `Org welcome ${STAMP}`;
+const SCOPE_TEMPLATE = `Two offer ${STAMP}`;
+const TEAM_TEMPLATE = `Team reply ${STAMP}`;
+const TEAM_BODY = `The team answers ${STAMP}`;
+const PERSONAL = `Alice own ${STAMP}`;
+const LOCAL = `Local only ${STAMP}`;
+const SEEDED_A = `Browser A ${STAMP}`;
+const SEEDED_B = `Browser B ${STAMP}`;
+
+const CORE = 'urn:ietf:params:jmap:core';
+const MAIL = 'urn:ietf:params:jmap:mail';
+const META = 'urn:ietf:params:jmap:metadata';
+
+let original: Record;
+
+/** Bulwark's template entries in the Inbox metadata of an account the user can open. */
+async function templateEntries(email: string, accountName = email): Promise>> {
+ const client = await JmapClient.connect(email, ORG_PASSWORD);
+ const accountId = accountName === email ? client.accountId : client.accountIdByName(accountName);
+ const r = await client.request([
+ ['Mailbox/query', { accountId, filter: { role: 'inbox' } }, 'q'],
+ ['Mailbox/get', { accountId, '#ids': { resultOf: 'q', name: 'Mailbox/query', path: '/ids' }, properties: ['id', 'metadata'] }, 'g'],
+ ], [CORE, MAIL, META]);
+ const metadata = (r.methodResponses[1][1].list[0]?.metadata ?? {}) as Record>;
+ return Object.fromEntries(Object.entries(metadata).filter(([key]) => key === 'org.bulwarkmail.templates' || key.startsWith('org.bulwarkmail.template.')));
+}
+
+/** Removes Bulwark's template entries (never the shared tags or anything else) from an Inbox. */
+async function clearTemplateEntries(email: string, accountName = email): Promise {
+ const client = await JmapClient.connect(email, ORG_PASSWORD);
+ const accountId = accountName === email ? client.accountId : client.accountIdByName(accountName);
+ const inbox = await client.request([['Mailbox/query', { accountId, filter: { role: 'inbox' } }, 'q']], [CORE, MAIL]);
+ const inboxId = inbox.methodResponses[0][1].ids[0];
+ for (const key of Object.keys(await templateEntries(email, accountName))) {
+ await client.request([['Mailbox/set', { accountId, update: { [inboxId]: { [`metadata/${key}`]: null } } }, '0']], [CORE, MAIL, META]);
+ }
+}
+
+async function clearAll(): Promise {
+ if (!METADATA) return;
+ for (const email of [ORG.alice, ORG.bob, ORG.carol, ORG.dave]) await clearTemplateEntries(email);
+ await clearTemplateEntries(ORG.alice, ORG.team);
+}
+
+async function startCompose(page: Page): Promise {
+ await page.getByRole('button', { name: 'Compose', exact: true }).first().click();
+ await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15000 });
+}
+
+/** Opens the template picker of the open composer. */
+async function openPicker(page: Page) {
+ const menu = page.getByTestId('composer-templates-menu');
+ if (await menu.isVisible().catch(() => false)) {
+ await menu.click();
+ await page.getByRole('menuitem', { name: 'Insert template' }).or(page.getByRole('button', { name: 'Insert template' })).first().click();
+ } else {
+ await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
+ await page.keyboard.press('t');
+ }
+ const dialog = page.getByRole('dialog', { name: 'Choose a Template' });
+ await expect(dialog).toBeVisible();
+ return dialog;
+}
+
+/** Settings > Composing > Templates > Manage Templates. */
+async function openManager(page: Page) {
+ await page.goto('/settings/templates');
+ const manage = page.getByRole('button', { name: 'Manage Templates' });
+ if (!(await manage.isVisible().catch(() => false))) {
+ // Phone: the settings list first.
+ await page.getByRole('button', { name: 'Composing' }).or(page.getByRole('link', { name: 'Composing' })).first().click();
+ }
+ await page.getByRole('button', { name: 'Manage Templates' }).click();
+ const dialog = page.getByRole('dialog', { name: 'Email Templates' });
+ await expect(dialog).toBeVisible();
+ return dialog;
+}
+
+async function createTemplate(dialog: ReturnType, name: string, body: string): Promise {
+ await dialog.getByRole('button', { name: 'New Template' }).click();
+ await dialog.getByPlaceholder('e.g., Follow-up email').fill(name);
+ await dialog.getByPlaceholder('Email body content...').fill(body);
+ await dialog.getByRole('button', { name: 'Create' }).click();
+ await expect(dialog.getByTestId('template-row').filter({ hasText: name })).toBeVisible({ timeout: 20000 });
+}
+
+/**
+ * A browser that already holds templates of its own from before (in
+ * template-storage), signed in as `email`.
+ */
+async function userWithLocalTemplates(browser: import('@playwright/test').Browser, email: string, templates: Array<{ id: string; name: string }>): Promise {
+ const context = await browser.newContext(contextOptions());
+ const now = new Date().toISOString();
+ const stored = JSON.stringify({
+ state: {
+ templates: templates.map((t) => ({ ...t, subject: '', body: `${t.name} body`, isHTML: false, category: '', isFavorite: false, createdAt: now, updatedAt: now })),
+ recentTemplateIds: [],
+ deletedTemplateIds: {},
+ },
+ version: 1,
+ });
+ await context.addInitScript((value) => {
+ if (!localStorage.getItem('template-storage')) localStorage.setItem('template-storage', value);
+ }, stored);
+ const page = await context.newPage();
+ await neutralizeDevOverlay(page);
+ await page.goto('/');
+ await page.fill('#username', email);
+ await page.fill('#password', ORG_PASSWORD);
+ await page.click('button[type="submit"]');
+ await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 });
+ return page;
+}
+
+async function expectOnline(page: Page): Promise {
+ await expect(page.getByText('Connection lost. Attempting to reconnect…')).toHaveCount(0);
+}
+
+test.beforeAll(async ({ browser }) => {
+ requireOrgEnv();
+ const admin = await adminContext(browser);
+ original = await getPolicy(admin);
+ await putPolicy(admin, {
+ ...original,
+ restrictions: {},
+ organisation: {},
+ scopes: [
+ { id: 'org-two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: {} },
+ { id: 'org-team', label: 'Org team', match: { groups: [ORG.team] }, policy: {} },
+ ],
+ });
+ await admin.close();
+ await clearAll();
+});
+
+test.afterAll(async ({ browser }) => {
+ const admin = await adminContext(browser);
+ await putPolicy(admin, original);
+ await admin.close();
+ await clearAll();
+});
+
+test('the admin builds the library for Everyone and for org-two.test', async ({ browser }) => {
+ const admin = await adminContext(browser);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-templates');
+ await expect(page.getByRole('heading', { name: 'Organisation library' })).toBeVisible();
+ // The dev server loads the policy twice (strict mode): wait for it.
+ await page.waitForLoadState('networkidle');
+
+ await page.getByTestId('org-template-add').click();
+ await page.getByTestId('org-template-name').last().fill(LIBRARY);
+ await page.getByTestId('org-template-subject').last().fill('Welcome');
+ await page.getByTestId('org-template-body').last().fill(`Welcome to Example ${STAMP}
`);
+ await page.getByLabel('The text is HTML').last().check();
+ await expect(page.getByTestId('org-template-preview').last().locator('b')).toHaveText('Example');
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ await shot(page, 'templates-admin-page');
+
+ await page.getByRole('button', { name: 'Org two' }).click();
+ await expect(page.getByTestId('org-template-inherited')).toContainText(LIBRARY);
+ await page.getByTestId('org-template-add').click();
+ await page.getByTestId('org-template-name').last().fill(SCOPE_TEMPLATE);
+ await page.getByTestId('org-template-body').last().fill(`Our offer ${STAMP}`);
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ await shot(page, 'templates-admin-scope');
+
+ const policy = await getPolicy(admin);
+ const everyone = (policy.organisation as { templates: { library: Array<{ name: string; isHTML: boolean }> } }).templates.library;
+ expect(everyone.map((t) => t.name)).toEqual([LIBRARY]);
+ expect(everyone[0].isHTML).toBe(true);
+ const two = (policy.scopes as Array<{ id: string; policy: { organisation?: { templates?: { library: Array<{ name: string }> } } } }>)
+ .find((s) => s.id === 'org-two')!;
+ expect(two.policy.organisation?.templates?.library.map((t) => t.name)).toEqual([SCOPE_TEMPLATE]);
+ await admin.close();
+});
+
+test('the library is read-only for users and follows their domain', async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice);
+ await startCompose(alice);
+ let picker = await openPicker(alice);
+ const organisation = picker.getByTestId('template-source-organisation');
+ await expect(organisation).toContainText(LIBRARY);
+ await expect(organisation).not.toContainText(SCOPE_TEMPLATE);
+ await alice.keyboard.press('Escape');
+
+ const manager = await openManager(alice);
+ await manager.getByTestId('template-source-tab-organisation').click();
+ const row = manager.getByTestId('template-row').filter({ hasText: LIBRARY });
+ await expect(row.getByRole('button', { name: 'Edit Template' })).toHaveCount(0);
+ await expect(manager.getByRole('button', { name: 'New Template' })).toHaveCount(0);
+ await row.getByRole('button', { name: 'Preview' }).click();
+ await expect(manager.getByTestId('template-preview').locator('b')).toHaveText('Example');
+ await shot(alice, 'templates-manager-organisation');
+ await expectOnline(alice);
+ await alice.context().close();
+
+ const dave = await userPage(browser, ORG.dave);
+ await startCompose(dave);
+ picker = await openPicker(dave);
+ await expect(picker.getByTestId('template-source-organisation')).toContainText(LIBRARY);
+ await expect(picker.getByTestId('template-source-organisation')).toContainText(SCOPE_TEMPLATE);
+ await shot(dave, 'templates-picker-org-two');
+ await dave.context().close();
+});
+
+test('personal templates hidden by the admin: only organisation templates, no saving', async ({ browser }) => {
+ const admin = await adminContext(browser);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-templates');
+ await page.waitForLoadState('networkidle');
+ await page.getByRole('button', { name: 'Everyone' }).click();
+ await page.getByLabel('Hide').check();
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ expect(((await getPolicy(admin)).restrictions as Record).personalTemplates).toEqual({ hidden: true });
+
+ const alice = await userPage(browser, ORG.alice);
+ await startCompose(alice);
+ const menu = alice.getByTestId('composer-templates-menu');
+ await menu.click();
+ await expect(alice.getByText('Save as template')).toHaveCount(0);
+ await alice.getByText('Insert template').first().click();
+ const picker = alice.getByRole('dialog', { name: 'Choose a Template' });
+ await expect(picker.getByTestId('template-source-organisation')).toContainText(LIBRARY);
+ await expect(picker.getByTestId('template-source-personal')).toHaveCount(0);
+ await shot(alice, 'templates-picker-personal-hidden');
+ await alice.keyboard.press('Escape');
+ const manager = await openManager(alice);
+ await expect(manager.getByTestId('template-source-tab-personal')).toHaveCount(0);
+ await alice.context().close();
+
+ const policy = await getPolicy(admin);
+ const restrictions = { ...(policy.restrictions as Record) };
+ delete restrictions.personalTemplates;
+ await putPolicy(admin, { ...policy, restrictions });
+ await admin.close();
+});
+
+test.describe('with metadata (v1.0.0)', () => {
+ test.skip(!METADATA, 'team templates and templates in the account need Stalwart v1.0.0');
+
+ test('org-alice adds a team template in org-team and org-bob uses it', async ({ browser }) => {
+ const alice = await userPage(browser, ORG.alice);
+ const manager = await openManager(alice);
+ await manager.getByTestId('template-source-tab-team').click();
+ await expect(manager.getByTestId('template-source-tab-team')).toContainText(ORG.team);
+ await expect(manager.getByTestId('template-source-note')).toContainText(`Everyone in ${ORG.team}`);
+ await createTemplate(manager, TEAM_TEMPLATE, TEAM_BODY);
+ await shot(alice, 'templates-manager-team');
+ await alice.context().close();
+
+ const stored = await templateEntries(ORG.alice, ORG.team);
+ const entry = Object.entries(stored).find(([, value]) => value.name === TEAM_TEMPLATE);
+ expect(entry, JSON.stringify(Object.keys(stored))).toBeTruthy();
+ expect(entry![1].body).toBe(TEAM_BODY);
+
+ const bob = await userPage(browser, ORG.bob);
+ await startCompose(bob);
+ const picker = await openPicker(bob);
+ const team = picker.getByTestId('template-source-team');
+ await expect(team).toContainText(TEAM_TEMPLATE, { timeout: 20000 });
+ const headings = await picker.getByRole('heading', { level: 2 }).allTextContents();
+ expect(headings).toContain(ORG.team);
+ expect(headings).toContain('Organisation');
+ await shot(bob, 'templates-picker-team');
+ await team.getByText(TEAM_TEMPLATE).click();
+ await expect(picker).toBeHidden();
+ await expect(bob.locator('.ProseMirror').first()).toContainText(TEAM_BODY);
+ await expectOnline(bob);
+ await bob.context().close();
+ });
+
+ test("org-alice's own template is in the account and reaches a second browser", async ({ browser }) => {
+ const first = await userPage(browser, ORG.alice);
+ const manager = await openManager(first);
+ await expect(manager.getByTestId('template-storage-note')).toContainText('kept in your account', { timeout: 20000 });
+ await createTemplate(manager, PERSONAL, `Hello from Alice ${STAMP}`);
+ await shot(first, 'templates-manager-personal');
+ await expect(async () => {
+ const entries = await templateEntries(ORG.alice);
+ expect(Object.values(entries).map((v) => v.name)).toContain(PERSONAL);
+ }).toPass({ timeout: 30000 });
+ await first.context().close();
+
+ const second = await userPage(browser, ORG.alice);
+ await startCompose(second);
+ const picker = await openPicker(second);
+ await expect(picker.getByTestId('template-source-personal')).toContainText(PERSONAL, { timeout: 20000 });
+ await second.context().close();
+ });
+
+ test('templates a browser held from before go into the account, and a second browser keeps both lists', async ({ browser }) => {
+ const idA = `aaaa${STAMP}-0001`;
+ const idB = `bbbb${STAMP}-0002`;
+ const first = await userWithLocalTemplates(browser, ORG.alice, [{ id: idA, name: SEEDED_A }]);
+ await expect(async () => {
+ const names = Object.values(await templateEntries(ORG.alice)).map((v) => v.name);
+ expect(names).toContain(SEEDED_A);
+ }).toPass({ timeout: 45000 });
+ await first.context().close();
+
+ const second = await userWithLocalTemplates(browser, ORG.alice, [{ id: idB, name: SEEDED_B }]);
+ await expect(async () => {
+ const names = Object.values(await templateEntries(ORG.alice)).map((v) => v.name);
+ expect(names).toEqual(expect.arrayContaining([SEEDED_A, SEEDED_B]));
+ }).toPass({ timeout: 45000 });
+ await startCompose(second);
+ const picker = await openPicker(second);
+ await expect(picker.getByTestId('template-source-personal')).toContainText(SEEDED_A, { timeout: 20000 });
+ await expect(picker.getByTestId('template-source-personal')).toContainText(SEEDED_B);
+ await shot(second, 'templates-picker-migrated');
+ await second.context().close();
+ // The marker with the tombstones is there once the lists are in.
+ expect(Object.keys(await templateEntries(ORG.alice))).toContain('org.bulwarkmail.templates');
+ });
+
+ test('a locked team template is read-only for the group', async ({ browser }) => {
+ const admin = await adminContext(browser);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-templates');
+ await page.waitForLoadState('networkidle');
+ await page.getByRole('button', { name: 'Org team' }).click();
+ await page.getByLabel('Lock').check();
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ await page.getByLabel('Lock').scrollIntoViewIfNeeded();
+ await shot(page, 'templates-admin-lock');
+ const policy = await getPolicy(admin);
+ const team = (policy.scopes as Array<{ id: string; policy: { restrictions?: Record } }>).find((s) => s.id === 'org-team')!;
+ expect(team.policy.restrictions).toEqual({ teamTemplates: { locked: true } });
+ await admin.close();
+
+ const bob = await userPage(browser, ORG.bob);
+ const manager = await openManager(bob);
+ await manager.getByTestId('template-source-tab-team').click();
+ await expect(manager.getByTestId('template-source-note')).toContainText('Set by your organisation');
+ await expect(manager.getByRole('button', { name: 'New Template' })).toHaveCount(0);
+ const row = manager.getByTestId('template-row').filter({ hasText: TEAM_TEMPLATE });
+ await expect(row).toBeVisible();
+ await expect(row.getByRole('button', { name: 'Edit Template' })).toHaveCount(0);
+ await expect(row.getByRole('button', { name: 'Delete' })).toHaveCount(0);
+ await shot(bob, 'templates-manager-team-locked');
+ await bob.context().close();
+
+ // The lock leaves the template in place; members still use it.
+ expect(Object.values(await templateEntries(ORG.alice, ORG.team)).map((v) => v.name)).toContain(TEAM_TEMPLATE);
+ });
+
+ test('the picker, the manager and the admin page work at phone width', async ({ browser }) => {
+ const bob = await userPage(browser, ORG.bob, true);
+ await startCompose(bob);
+ const picker = await openPicker(bob);
+ await expect(picker.getByTestId('template-source-team')).toContainText(TEAM_TEMPLATE, { timeout: 20000 });
+ const box = await picker.boundingBox();
+ expect(box!.x).toBeGreaterThanOrEqual(0);
+ expect(box!.x + box!.width).toBeLessThanOrEqual(390);
+ await shot(bob, 'templates-picker-phone');
+ await picker.getByText(TEAM_TEMPLATE).click();
+ await expect(bob.locator('.ProseMirror').first()).toContainText(TEAM_BODY);
+ await bob.context().close();
+
+ const alice = await userPage(browser, ORG.alice, true);
+ const manager = await openManager(alice);
+ const managerBox = await manager.boundingBox();
+ expect(managerBox!.x + managerBox!.width).toBeLessThanOrEqual(390);
+ await expect(manager.getByTestId('template-sources')).toBeVisible();
+ await manager.getByTestId('template-source-tab-team').click();
+ await expect(manager.getByTestId('template-row').filter({ hasText: TEAM_TEMPLATE })).toBeVisible();
+ const overflow = await alice.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
+ expect(overflow).toBeLessThanOrEqual(0);
+ await shot(alice, 'templates-manager-phone');
+ await alice.context().close();
+
+ const admin = await adminContext(browser, true);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-templates');
+ await expect(page.getByTestId('org-template-add')).toBeVisible();
+ const adminOverflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
+ expect(adminOverflow).toBeLessThanOrEqual(0);
+ await shot(page, 'templates-admin-page-phone');
+ await admin.close();
+ });
+});
+
+test.describe('without metadata (0.16, 1.0 test build)', () => {
+ test.skip(METADATA, 'this server keeps templates in the account');
+
+ test('no team section, personal templates stay in the browser, and the app stays online', async ({ browser }) => {
+ // The 1.0 test build advertises metadata and drops the connection on
+ // it: count the metadata requests that went out and failed.
+ const metadataRequests: string[] = [];
+ const context = await browser.newContext(contextOptions());
+ context.on('requestfailed', (request) => {
+ if ((request.postData() ?? '').includes('"properties":["id","metadata"]')) metadataRequests.push(request.failure()?.errorText ?? 'failed');
+ });
+ const alice = await context.newPage();
+ await neutralizeDevOverlay(alice);
+ await alice.goto('/');
+ await alice.fill('#username', ORG.alice);
+ await alice.fill('#password', ORG_PASSWORD);
+ await alice.click('button[type="submit"]');
+ await accountSwitcher(alice).waitFor({ state: 'visible', timeout: 60000 });
+ const manager = await openManager(alice);
+ await expect(manager.getByTestId('template-source-tab-team')).toHaveCount(0);
+ await expect(manager.getByTestId('template-storage-note')).toHaveCount(0);
+ await createTemplate(manager, LOCAL, `Kept here ${STAMP}`);
+ const stored = await alice.evaluate(() => ({
+ browser: localStorage.getItem('template-storage') ?? '',
+ account: localStorage.getItem('account-templates') ?? '',
+ }));
+ expect(stored.browser).toContain(LOCAL);
+ expect(stored.account).not.toContain(LOCAL);
+ await shot(alice, 'templates-manager-no-metadata');
+ await alice.keyboard.press('Escape');
+
+ await alice.goto('/');
+ await startCompose(alice);
+ const picker = await openPicker(alice);
+ await expect(picker.getByTestId('template-source-team')).toHaveCount(0);
+ await expect(picker).toContainText(LOCAL);
+ // Long enough for the keep-alive ping after the metadata request failed.
+ await alice.waitForTimeout(35000);
+ await expectOnline(alice);
+ if (ORG_SERVER === '1.0') expect(metadataRequests.length).toBeGreaterThan(0);
+ else expect(metadataRequests).toEqual([]);
+ await alice.context().close();
+ });
+});
diff --git a/lib/__tests__/account-template-sync.test.ts b/lib/__tests__/account-template-sync.test.ts
new file mode 100644
index 000000000..df8bd4c83
--- /dev/null
+++ b/lib/__tests__/account-template-sync.test.ts
@@ -0,0 +1,424 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// Personal templates kept in the user's own Inbox metadata on Stalwart
+// v1.0.0, one namespace per template plus a marker entry holding the
+// tombstones of deleted ones. The server below answers with the envelopes
+// recorded from stw-rel (fixtures/…template-metadata.json) and keeps the
+// metadata in memory, refusing like the real one once 64 KB are used.
+
+const config = vi.hoisted(() => ({ value: { settingsSyncEnabled: true } as Record | null }));
+vi.mock('@/hooks/use-config', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ getCachedConfig: () => config.value,
+}));
+
+import { JMAPClient } from '../jmap/client';
+import { syncPersonalTemplates, type BrowserList } from '../templates/account-template-sync';
+import { planPersonalSync, pruneTombstones, storableId } from '../templates/metadata-templates';
+import { browserListFor, flushAccountTemplates, personalTemplatesFor, syncAccountTemplates } from '../templates/personal-templates';
+import { useAccountTemplatesStore } from '@/stores/account-templates-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { useTemplateStore } from '@/stores/template-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { purgeSignedOutData, runBeforeSignOut } from '@/lib/sign-out-cleanup';
+import type { EmailTemplate } from '../template-types';
+import recorded from './fixtures/stalwart-1.0.0-template-metadata.json';
+
+const METADATA_CAP = {
+ 'urn:ietf:params:jmap:metadata': {
+ dataTypes: { Mailbox: { namespaces: [], supportsVendorNamespaces: true, supportsPrivate: true, maxDepth: 8 } },
+ },
+};
+
+function session(withMetadata = true) {
+ return {
+ capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {}, ...(withMetadata ? { 'urn:ietf:params:jmap:metadata': {} } : {}) },
+ accounts: {
+ bl: { name: 'carol@example.org', isPersonal: true, isReadOnly: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...(withMetadata ? METADATA_CAP : {}) } },
+ },
+ primaryAccounts: { 'urn:ietf:params:jmap:mail': 'bl' },
+ apiUrl: 'https://mail.example.com/jmap/api',
+ downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
+ uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
+ eventSourceUrl: '',
+ };
+}
+
+const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
+const clone = (v: T): T => JSON.parse(JSON.stringify(v));
+
+type Call = [string, Record, string];
+let server: Record;
+let sets: Array<{ key: string; value: unknown }>;
+let drop: boolean;
+let omitMetadata: boolean;
+let onSet: (() => void) | null;
+const SHARED_MAX = 65536;
+
+function answer(body: { methodCalls: Call[] }): Response {
+ if (drop) throw new TypeError('Failed to fetch');
+ const [method, args] = body.methodCalls[body.methodCalls.length - 1];
+ if (method === 'Mailbox/get') {
+ const out = clone(recorded.getEmpty);
+ const inbox = (out.methodResponses[1][1] as { list: Array> }).list[0];
+ if (omitMetadata) delete inbox.metadata;
+ else inbox.metadata = clone(server);
+ return json(out);
+ }
+ if (method === 'Mailbox/set') {
+ onSet?.();
+ const patch = (args.update as Record>).a;
+ const [path, value] = Object.entries(patch)[0];
+ const namespace = path.replace(/^metadata\//, '');
+ const next = { ...server, [namespace]: value };
+ if (value === null) delete next[namespace];
+ if (new TextEncoder().encode(JSON.stringify(next)).length > SHARED_MAX) return json(clone(recorded.setNoRoom));
+ server = next;
+ sets.push({ key: namespace, value });
+ return json(clone(value === null ? recorded.deleteOne : recorded.setOne));
+ }
+ throw new Error(`unexpected ${method}`);
+}
+
+const tpl = (id: string, name: string, updatedAt = '2026-10-01T00:00:00.000Z', extra: Partial = {}): EmailTemplate => ({
+ id, name, subject: '', body: `${name} body`, isHTML: false, category: '', isFavorite: false,
+ createdAt: '2026-10-01T00:00:00.000Z', updatedAt, ...extra,
+});
+const ns = (id: string) => `org.bulwarkmail.template.${id}`;
+const MARKER = 'org.bulwarkmail.templates';
+const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
+
+let client: JMAPClient;
+let connection: ReturnType void>>;
+let browser: BrowserList;
+const sync = () => syncPersonalTemplates('carol@mail', client, () => browser);
+const list = (templates: EmailTemplate[]): BrowserList => ({ status: 'list', templates });
+const partition = () => useAccountTemplatesStore.getState().personal['carol@mail'];
+const names = () => partition().templates.map((t) => t.name).sort();
+const edit = (fn: (templates: EmailTemplate[]) => EmailTemplate[]) =>
+ useAccountTemplatesStore.getState().updatePersonal('carol@mail', (p) => ({ ...p, templates: fn(p.templates) }));
+
+async function connect(withMetadata = true) {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockResolvedValueOnce(json(session(withMetadata)));
+ client = new JMAPClient('https://mail.example.com', 'carol@example.org', 'pw');
+ await client.connect();
+ fetchSpy.mockReset();
+ fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => answer(JSON.parse(String(init?.body ?? '{}'))));
+ connection = vi.fn<(connected: boolean) => void>();
+ client.onConnectionChange(connection);
+}
+
+const original = useAuthStore.getState();
+const asCarol = () => useAuthStore.setState({
+ activeAccountId: 'carol@mail',
+ getClientForAccount: (id: string) => (id === 'carol@mail' ? client : undefined),
+} as never);
+
+beforeEach(async () => {
+ vi.useRealTimers();
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ server = {};
+ sets = [];
+ drop = false;
+ omitMetadata = false;
+ onSet = null;
+ browser = list([]);
+ config.value = { settingsSyncEnabled: true };
+ localStorage.removeItem('settings-sync-owner');
+ useAccountTemplatesStore.setState({ personal: {}, teams: {}, active: null, browserListOwner: null });
+ useTemplateStore.setState({ templates: [], deletedTemplateIds: {}, recentTemplateIds: [] });
+ useSettingsStore.setState({ settingsSyncDisabled: false });
+ await connect();
+});
+afterEach(() => {
+ vi.restoreAllMocks();
+ useAuthStore.setState({ activeAccountId: original.activeAccountId, getClientForAccount: original.getClientForAccount } as never);
+});
+
+describe('the browser\'s own list', () => {
+ it('goes into an empty account, with the marker after it', async () => {
+ browser = list([tpl('aaaa-1', 'Hello'), tpl('aaaa-2', 'Thanks')]);
+ expect(await sync()).toBe('synced');
+ expect(sets.map((s) => s.key)).toEqual([ns('aaaa-1'), ns('aaaa-2'), MARKER]);
+ expect(server[MARKER]).toEqual({ version: 1, deleted: {} });
+ expect(names()).toEqual(['Hello', 'Thanks']);
+ expect(partition().markerOnServer).toBe(true);
+
+ // Each version is taken once: the next round writes nothing.
+ sets = [];
+ await sync();
+ expect(sets).toEqual([]);
+ });
+
+ it('doesn\'t bring back a template deleted in the account once its tombstone has expired', async () => {
+ browser = list([tpl('aaaa-1', 'Hello'), tpl('aaaa-2', 'Thanks')]);
+ await sync();
+ personalTemplatesFor('carol@mail').remove('aaaa-1');
+ await sync();
+ expect(server[ns('aaaa-1')]).toBeUndefined();
+ // 90 days later the tombstone is gone; the browser's list still has the old copy.
+ useAccountTemplatesStore.getState().updatePersonal('carol@mail', (p) => ({ ...p, deletedTemplateIds: {}, serverDeleted: {} }));
+ server[MARKER] = { version: 1, deleted: {} };
+ await sync();
+ expect(names()).toEqual(['Thanks']);
+ expect(server[ns('aaaa-1')]).toBeUndefined();
+ });
+
+ it('is merged into an account that holds templates already, so a second browser keeps both lists', async () => {
+ server = { [ns('bbbb-1')]: tpl('bbbb-1', 'From the other browser'), [MARKER]: { version: 1, deleted: {} } };
+ browser = list([tpl('aaaa-1', 'Only here')]);
+ await sync();
+ expect(names()).toEqual(['From the other browser', 'Only here']);
+ expect(sets.map((s) => s.key)).toEqual([ns('aaaa-1')]);
+ });
+
+ it('leaves out a template that a newer tombstone deleted', async () => {
+ server = { [MARKER]: { version: 1, deleted: { 'aaaa-1': ago(1000) } } };
+ browser = list([tpl('aaaa-1', 'Deleted elsewhere', ago(60_000)), tpl('aaaa-2', 'Kept', ago(60_000))]);
+ await sync();
+ expect(names()).toEqual(['Kept']);
+ expect(server[ns('aaaa-1')]).toBeUndefined();
+ });
+
+ it('is not used, and no marker written, while it isn\'t known whose it is', async () => {
+ browser = { status: 'unknown' };
+ useTemplateStore.setState({ templates: [tpl('aaaa-9', 'Someone else’s')] });
+ await sync();
+ expect(sets).toEqual([]);
+ expect(partition().templates).toEqual([]);
+ expect(partition().markerOnServer).toBe(false);
+ });
+
+ it('gives an id that doesn\'t fit a metadata key the same new id on every device', async () => {
+ const id = storableId('Demo_Template 1');
+ expect(id).toMatch(/^[0-9a-f]{28}$/);
+ expect(storableId('Demo_Template 1')).toBe(id);
+ expect(storableId('Demo_Template 2')).not.toBe(id);
+ browser = list([tpl('Demo_Template 1', 'Old id')]);
+ await sync();
+ expect(partition().templates[0].id).toBe(id);
+ expect(server[ns(id)]).toBeTruthy();
+ });
+
+ it('keeps a template over 8 KB and one the server has no room for in this browser, with why', async () => {
+ server = { 'org.bulwarkmail.other': { fill: 'y'.repeat(SHARED_MAX - 4000) } };
+ browser = list([tpl('aaaa-big', 'Big', undefined, { body: 'x'.repeat(9000) }), tpl('aaaa-mid', 'Mid', undefined, { body: 'z'.repeat(5000) }), tpl('aaaa-ok', 'Small')]);
+ await sync();
+ expect(partition().localOnly).toEqual({ 'aaaa-big': 'too-large', 'aaaa-mid': 'no-room' });
+ expect(names()).toEqual(['Big', 'Mid', 'Small']);
+ expect(server[ns('aaaa-ok')]).toBeTruthy();
+ expect(sets.some((s) => s.key === ns('aaaa-big'))).toBe(false);
+ });
+});
+
+describe('later rounds', () => {
+ beforeEach(async () => {
+ browser = list([tpl('aaaa-1', 'One'), tpl('aaaa-2', 'Two')]);
+ await sync();
+ sets = [];
+ });
+
+ it('take a newer server version and upload a newer local one', async () => {
+ server[ns('aaaa-1')] = tpl('aaaa-1', 'One, edited elsewhere', '2026-10-05T00:00:00.000Z');
+ edit((templates) => templates.map((t) => (t.id === 'aaaa-2' ? { ...t, name: 'Two, edited here', updatedAt: '2026-10-06T00:00:00.000Z' } : t)));
+ await sync();
+ expect(names()).toEqual(['One, edited elsewhere', 'Two, edited here']);
+ expect(sets.map((s) => s.key)).toEqual([ns('aaaa-2')]);
+ });
+
+ it('upload an entry that is missing without a tombstone, never take it as deleted', async () => {
+ delete server[ns('aaaa-1')];
+ await sync();
+ expect(names()).toEqual(['One', 'Two']);
+ expect(server[ns('aaaa-1')]).toBeTruthy();
+ });
+
+ it('delete through the tombstone: the marker first, then the entry', async () => {
+ personalTemplatesFor('carol@mail').remove('aaaa-2');
+ await sync();
+ expect(sets.map((s) => s.key)).toEqual([MARKER, ns('aaaa-2')]);
+ expect((server[MARKER] as { deleted: Record }).deleted['aaaa-2']).toBeTruthy();
+ expect(partition().serverDeleted['aaaa-2']).toBeTruthy();
+ });
+
+ it('drop a template another device deleted, also when the store changes during the round (review A)', async () => {
+ server[MARKER] = { version: 1, deleted: { 'aaaa-1': ago(0) } };
+ delete server[ns('aaaa-1')];
+ edit((templates) => templates.map((t) => (t.id === 'aaaa-2' ? { ...t, name: 'Two!', updatedAt: ago(500) } : t)));
+ onSet = () => {
+ // While the upload runs, a new template arrives (this tab or another one).
+ edit((templates) => [...templates, tpl('aaaa-3', 'Three', ago(100))]);
+ onSet = null;
+ };
+ await sync();
+ await sync();
+ expect(partition().templates.map((t) => t.id).sort()).toEqual(['aaaa-2', 'aaaa-3']);
+ expect(server[ns('aaaa-1')]).toBeUndefined();
+ expect(server[ns('aaaa-3')]).toBeTruthy();
+ });
+
+ it('keep what another tab wrote while the round ran', async () => {
+ onSet = () => {
+ // Another tab's write replaces the whole partition (storage rehydrate).
+ const p = partition();
+ useAccountTemplatesStore.setState({
+ personal: {
+ 'carol@mail': {
+ ...p,
+ templates: [...p.templates.filter((t) => t.id !== 'aaaa-1'), tpl('aaaa-4', 'From the other tab', ago(50))],
+ deletedTemplateIds: { 'aaaa-1': ago(10) },
+ },
+ },
+ });
+ onSet = null;
+ };
+ edit((templates) => templates.map((t) => (t.id === 'aaaa-2' ? { ...t, updatedAt: ago(1000) } : t)));
+ await sync();
+ expect(partition().templates.map((t) => t.id).sort()).toEqual(['aaaa-2', 'aaaa-4']);
+ expect(server[ns('aaaa-1')]).toBeUndefined();
+ expect(server[ns('aaaa-4')]).toBeTruthy();
+ });
+
+ it('keep the copy when an answer comes without the metadata property (review B)', async () => {
+ omitMetadata = true;
+ expect(await sync()).toBe('unsupported');
+ expect(names()).toEqual(['One', 'Two']);
+ });
+
+ it('keep an edit made on a device whose clock is behind (review C)', async () => {
+ server[ns('aaaa-1')] = tpl('aaaa-1', 'Edited on a fast clock', new Date(Date.now() + 3_600_000).toISOString());
+ await sync();
+ personalTemplatesFor('carol@mail').update('aaaa-1', { name: 'Edited here later' });
+ await sync();
+ expect(partition().templates.find((t) => t.id === 'aaaa-1')?.name).toBe('Edited here later');
+ expect((server[ns('aaaa-1')] as EmailTemplate).name).toBe('Edited here later');
+ });
+});
+
+describe('whose the browser\'s list is', () => {
+ beforeEach(asCarol);
+
+ it('is unknown until settings sync loaded the account\'s settings, whose templates then reach the account (review D)', async () => {
+ expect(browserListFor('carol@mail')).toEqual({ status: 'unknown' });
+ await syncAccountTemplates('carol@mail');
+ expect(server[MARKER]).toBeUndefined();
+ expect(partition().markerOnServer).toBe(false);
+
+ // The settings blob loads: its templates go into the account's copy too.
+ localStorage.setItem('settings-sync-owner', 'carol@mail');
+ useSettingsStore.getState().importSettings(JSON.stringify({ templates: [tpl('blob-1', 'Only in the blob')], deletedTemplateIds: {} }), { serverAccountId: 'carol@mail' });
+ expect(partition().templates.map((t) => t.id)).toEqual(['blob-1']);
+ await syncAccountTemplates('carol@mail');
+ expect(server[ns('blob-1')]).toBeTruthy();
+ expect(server[MARKER]).toEqual({ version: 1, deleted: {} });
+ });
+
+ it('is another account\'s while that account\'s settings are loaded', () => {
+ localStorage.setItem('settings-sync-owner', 'alice@mail');
+ expect(browserListFor('carol@mail')).toEqual({ status: 'unknown' });
+ expect(browserListFor('dave@mail')).toEqual({ status: 'none' });
+ });
+
+ it('goes into the first account only without settings sync, and others start empty', async () => {
+ config.value = { settingsSyncEnabled: false };
+ useTemplateStore.setState({ templates: [tpl('aaaa-1', 'Browser')] });
+ expect(browserListFor('carol@mail').status).toBe('list');
+ await syncAccountTemplates('carol@mail');
+ expect(useAccountTemplatesStore.getState().browserListOwner).toBe('carol@mail');
+ expect(names()).toEqual(['Browser']);
+ expect(browserListFor('dave@mail')).toEqual({ status: 'none' });
+ expect(browserListFor('carol@mail').status).toBe('list');
+ });
+});
+
+describe('servers without metadata', () => {
+ it('leave an account without the capability alone', async () => {
+ vi.restoreAllMocks();
+ await connect(false);
+ browser = list([tpl('aaaa-1', 'Local')]);
+ const fetchSpy = vi.mocked(globalThis.fetch);
+ fetchSpy.mockClear();
+ expect(await sync()).toBe('unsupported');
+ expect(fetchSpy).not.toHaveBeenCalled();
+ expect(partition()).toBeUndefined();
+ });
+
+ it('keep the app online when the server drops the connection on metadata (the 1.0 test build)', async () => {
+ drop = true;
+ browser = list([tpl('aaaa-1', 'Local')]);
+ expect(await sync()).toBe('unsupported');
+ expect(partition()).toBeUndefined();
+ expect(connection).not.toHaveBeenCalledWith(false);
+ const fetchSpy = vi.mocked(globalThis.fetch);
+ fetchSpy.mockClear();
+ expect(await sync()).toBe('unsupported');
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+});
+
+describe('signing out', () => {
+ beforeEach(async () => {
+ asCarol();
+ localStorage.setItem('settings-sync-owner', 'carol@mail');
+ useTemplateStore.setState({ templates: [tpl('aaaa-1', 'One'), tpl('aaaa-2', 'Two')] });
+ await syncAccountTemplates('carol@mail');
+ sets = [];
+ });
+
+ it('sends a deletion that is still queued before the account signs out', async () => {
+ personalTemplatesFor('carol@mail').remove('aaaa-1');
+ await runBeforeSignOut('carol@mail');
+ expect(sets.map((s) => s.key)).toEqual([MARKER, ns('aaaa-1')]);
+ expect(server[ns('aaaa-1')]).toBeUndefined();
+ });
+
+ it('keeps only unsent templates and deletions after a full sign-out', async () => {
+ drop = true;
+ personalTemplatesFor('carol@mail').remove('aaaa-1');
+ personalTemplatesFor('carol@mail').add({ name: 'Unsent', subject: '', body: '', category: '', isFavorite: false });
+ await flushAccountTemplates('carol@mail');
+ purgeSignedOutData();
+ const kept = useAccountTemplatesStore.getState().personal['carol@mail'];
+ expect(kept.templates.map((t) => t.name)).toEqual(['Unsent']);
+ expect(Object.keys(kept.deletedTemplateIds)).toEqual(['aaaa-1']);
+ });
+
+ it('keeps a deletion the server hasn\'t heard of, also with nothing else left to send', async () => {
+ drop = true;
+ personalTemplatesFor('carol@mail').remove('aaaa-1');
+ await flushAccountTemplates('carol@mail');
+ purgeSignedOutData();
+ const kept = useAccountTemplatesStore.getState().personal['carol@mail'];
+ expect(kept.templates).toEqual([]);
+ expect(Object.keys(kept.deletedTemplateIds)).toEqual(['aaaa-1']);
+ });
+
+ it('drops an account copy that the server holds in full', () => {
+ purgeSignedOutData();
+ expect(useAccountTemplatesStore.getState().personal['carol@mail']).toBeUndefined();
+ });
+});
+
+describe('tombstones', () => {
+ it('keep 90 days and at most 8 KB, the oldest go first', () => {
+ const deleted: Record = { old: ago(91 * 86_400_000) };
+ for (let i = 0; i < 400; i++) deleted[`id-${String(i).padStart(4, '0')}-${'x'.repeat(20)}`] = ago(i * 1000);
+ const kept = pruneTombstones(deleted);
+ expect(kept.old).toBeUndefined();
+ expect(new TextEncoder().encode(JSON.stringify({ version: 1, deleted: kept })).length).toBeLessThanOrEqual(8192);
+ expect(kept[`id-0000-${'x'.repeat(20)}`]).toBeDefined();
+ expect(kept[`id-0399-${'x'.repeat(20)}`]).toBeUndefined();
+ });
+
+ it('a deletion here wins over an older server copy, an edit there over an older deletion', () => {
+ const plan = planPersonalSync(
+ { templates: [], deletedTemplateIds: { a: ago(5000), b: ago(5000) } },
+ { a: tpl('a', 'A', ago(10_000)), b: tpl('b', 'B', ago(1000)) },
+ {},
+ );
+ expect(plan.deletes).toEqual(['a']);
+ expect(plan.templates.map((t) => t.id)).toEqual(['b']);
+ });
+});
diff --git a/lib/__tests__/external-mail.test.ts b/lib/__tests__/external-mail.test.ts
index 6062c9a0a..4f2328fca 100644
--- a/lib/__tests__/external-mail.test.ts
+++ b/lib/__tests__/external-mail.test.ts
@@ -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) => ({ 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: [
diff --git a/lib/__tests__/fixtures/stalwart-1.0.0-template-metadata.json b/lib/__tests__/fixtures/stalwart-1.0.0-template-metadata.json
new file mode 100644
index 000000000..6df8f5ca0
--- /dev/null
+++ b/lib/__tests__/fixtures/stalwart-1.0.0-template-metadata.json
@@ -0,0 +1,181 @@
+{
+ "_comment": "Recorded from Stalwart v1.0.0 (stw-rel, http://10.10.10.62:8120) on 2026-10-28, org-carol@example.org own Inbox: Bulwark template metadata. Removed again after recording. setForbidden: org-carol writing to the Inbox org-alice shared with her read-only.",
+ "getEmpty": {
+ "methodResponses": [
+ [
+ "Mailbox/query",
+ {
+ "accountId": "bl",
+ "queryState": "saq",
+ "canCalculateChanges": true,
+ "position": 0,
+ "ids": [
+ "a"
+ ]
+ },
+ "q"
+ ],
+ [
+ "Mailbox/get",
+ {
+ "accountId": "bl",
+ "state": "saq",
+ "list": [
+ {
+ "id": "a",
+ "metadata": {}
+ }
+ ],
+ "notFound": []
+ },
+ "g"
+ ]
+ ],
+ "sessionState": "c499329"
+ },
+ "setOne": {
+ "methodResponses": [
+ [
+ "Mailbox/set",
+ {
+ "accountId": "bl",
+ "oldState": "saq",
+ "newState": "sau",
+ "updated": {
+ "a": null
+ }
+ },
+ "0"
+ ]
+ ],
+ "sessionState": "c499329"
+ },
+ "setMarker": {
+ "methodResponses": [
+ [
+ "Mailbox/set",
+ {
+ "accountId": "bl",
+ "oldState": "sau",
+ "newState": "say",
+ "updated": {
+ "a": null
+ }
+ },
+ "0"
+ ]
+ ],
+ "sessionState": "c499329"
+ },
+ "getOne": {
+ "methodResponses": [
+ [
+ "Mailbox/query",
+ {
+ "accountId": "bl",
+ "queryState": "say",
+ "canCalculateChanges": true,
+ "position": 0,
+ "ids": [
+ "a"
+ ]
+ },
+ "q"
+ ],
+ [
+ "Mailbox/get",
+ {
+ "accountId": "bl",
+ "state": "say",
+ "list": [
+ {
+ "id": "a",
+ "metadata": {
+ "org.bulwarkmail.template.rec-1": {
+ "id": "rec-1",
+ "name": "Recorded",
+ "subject": "Hi",
+ "body": "Body",
+ "isHTML": false,
+ "category": "",
+ "isFavorite": false,
+ "createdAt": "2026-10-28T10:00:00.000Z",
+ "updatedAt": "2026-10-28T10:00:00.000Z"
+ },
+ "org.bulwarkmail.templates": {
+ "v": 1
+ }
+ }
+ }
+ ],
+ "notFound": []
+ },
+ "g"
+ ]
+ ],
+ "sessionState": "c499329"
+ },
+ "setNoRoom": {
+ "methodResponses": [
+ [
+ "Mailbox/set",
+ {
+ "accountId": "bl",
+ "oldState": "sb2",
+ "newState": "sb2",
+ "notUpdated": {
+ "a": {
+ "type": "invalidProperties",
+ "description": "Metadata size 72076 exceeds the maximum of 65536 bytes.",
+ "properties": [
+ "metadata"
+ ]
+ }
+ }
+ },
+ "0"
+ ]
+ ],
+ "sessionState": "c499329"
+ },
+ "deleteOne": {
+ "methodResponses": [
+ [
+ "Mailbox/set",
+ {
+ "accountId": "bl",
+ "oldState": "sb2",
+ "newState": "sca",
+ "updated": {
+ "a": null
+ }
+ },
+ "0"
+ ]
+ ],
+ "sessionState": "c499329"
+ },
+ "setForbidden": {
+ "methodResponses": [
+ [
+ "Mailbox/set",
+ {
+ "accountId": "bj",
+ "oldState": "seu",
+ "newState": "seu",
+ "notUpdated": {
+ "a": {
+ "type": "forbidden",
+ "description": "Not allowed to modify metadata.",
+ "properties": [
+ "metadata"
+ ]
+ }
+ }
+ },
+ "0"
+ ]
+ ],
+ "sessionState": "e6894019"
+ }
+}
diff --git a/lib/__tests__/jmap-bulwark-metadata.test.ts b/lib/__tests__/jmap-bulwark-metadata.test.ts
index 2e1db844a..0a86948bc 100644
--- a/lib/__tests__/jmap-bulwark-metadata.test.ts
+++ b/lib/__tests__/jmap-bulwark-metadata.test.ts
@@ -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', () => {
diff --git a/lib/__tests__/plugin-host-settings-apis.test.ts b/lib/__tests__/plugin-host-settings-apis.test.ts
index e244cf96c..c22f93e6c 100644
--- a/lib/__tests__/plugin-host-settings-apis.test.ts
+++ b/lib/__tests__/plugin-host-settings-apis.test.ts
@@ -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', {
diff --git a/lib/__tests__/plugin-team-apis.test.ts b/lib/__tests__/plugin-team-apis.test.ts
new file mode 100644
index 000000000..c32624ce7
--- /dev/null
+++ b/lib/__tests__/plugin-team-apis.test.ts
@@ -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 = {
+ 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 {
+ 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>;
+
+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((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);
+ });
+});
diff --git a/lib/__tests__/team-templates.test.ts b/lib/__tests__/team-templates.test.ts
new file mode 100644
index 000000000..05902b10d
--- /dev/null
+++ b/lib/__tests__/team-templates.test.ts
@@ -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 = (v: T): T => JSON.parse(JSON.stringify(v));
+type Call = [string, Record, string];
+
+let metadata: Record>;
+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>).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 => ({
+ 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');
+ });
+});
diff --git a/lib/admin/admin-nav.ts b/lib/admin/admin-nav.ts
index f10b80ea7..98ae3791d 100644
--- a/lib/admin/admin-nav.ts
+++ b/lib/admin/admin-nav.ts
@@ -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' },
],
},
{
diff --git a/lib/admin/restriction-keys.ts b/lib/admin/restriction-keys.ts
index fa8a0140c..f0079a630 100644
--- a/lib/admin/restriction-keys.ts
+++ b/lib/admin/restriction-keys.ts
@@ -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'] },
diff --git a/lib/external-mail.ts b/lib/external-mail.ts
index 341fa59d7..364a3003e 100644
--- a/lib/external-mail.ts
+++ b/lib/external-mail.ts
@@ -158,6 +158,13 @@ type AccountClient = NonNullable>();
const identityLists = new WeakMap>();
+/** 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 {
+export async function externalMailContextFor(
+ localAccountId?: string | null,
+ need?: 'senderLabel' | 'recipientWarning',
+): Promise {
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 };
}
/**
diff --git a/lib/jmap/bulwark-metadata.ts b/lib/jmap/bulwark-metadata.ts
index 57f39f7ee..74fa585a5 100644
--- a/lib/jmap/bulwark-metadata.ts
+++ b/lib/jmap/bulwark-metadata.ts
@@ -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);
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index cc615e44f..a53538359 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -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> } | 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 }).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"})`);
}
}
diff --git a/lib/outgoing/external-recipients.ts b/lib/outgoing/external-recipients.ts
index 6fcc2f817..7188df613 100644
--- a/lib/outgoing/external-recipients.ts
+++ b/lib/outgoing/external-recipients.ts
@@ -53,7 +53,7 @@ export async function ensureContactsLoaded(waitMs = CONTACTS_WAIT_MS): Promise {
// 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
diff --git a/lib/plugin-sandbox/account-kind.ts b/lib/plugin-sandbox/account-kind.ts
new file mode 100644
index 000000000..04bd112ce
--- /dev/null
+++ b/lib/plugin-sandbox/account-kind.ts
@@ -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
+ & Partial>;
+
+interface ConfirmedGroupsState {
+ /** Per client: the ids of its accounts the server confirmed are groups. */
+ groups: WeakMap>;
+ /** Moves on whenever a client's list arrives, for subscribers. */
+ revision: number;
+}
+
+export const useConfirmedGroups = create()(() => ({ groups: new WeakMap(), revision: 0 }));
+
+const pending = new WeakMap>>();
+const failedAt = new WeakMap();
+/** 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> {
+ 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 = 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 });
+}
diff --git a/lib/plugin-sandbox/actions.ts b/lib/plugin-sandbox/actions.ts
index 91fab3dd1..f97aee1ea 100644
--- a/lib/plugin-sandbox/actions.ts
+++ b/lib/plugin-sandbox/actions.ts
@@ -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;
diff --git a/lib/plugin-sandbox/host-api-account.ts b/lib/plugin-sandbox/host-api-account.ts
new file mode 100644
index 000000000..1d6f9db20
--- /dev/null
+++ b/lib/plugin-sandbox/host-api-account.ts
@@ -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 {
+ 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>();
+const directoryLoads = new WeakMap>>();
+
+async function loadDirectory(client: IJMAPClient, accountId: string): Promise {
+ 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 | 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.. */
+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 {
+ 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'}`);
+ }
+}
diff --git a/lib/plugin-sandbox/host-api-settings.ts b/lib/plugin-sandbox/host-api-settings.ts
index 8aafffec1..9582b02a4 100644
--- a/lib/plugin-sandbox/host-api-settings.ts
+++ b/lib/plugin-sandbox/host-api-settings.ts
@@ -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;
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 ───────────────────────────────────────────────
diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts
index 87f72dde8..8faa039ed 100644
--- a/lib/plugin-sandbox/host-api.ts
+++ b/lib/plugin-sandbox/host-api.ts
@@ -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> = {
'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> = {
// 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> = {
'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 {
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 {
+ 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 = {};
+ 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): void {
+ useEmailStore.setState((state) => {
+ let cache: Map | 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;
diff --git a/lib/plugin-sandbox/list-filters.ts b/lib/plugin-sandbox/list-filters.ts
new file mode 100644
index 000000000..98b47b03e
--- /dev/null
+++ b/lib/plugin-sandbox/list-filters.ts
@@ -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;
+ /** `:`, 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();
+ 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;
+ 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();
+const active = new Set();
+const listeners = new Set<() => void>();
+let snapshot: RegisteredListFilter[] | null = null;
+let activeSnapshot: ReadonlySet = 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 {
+ 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 | null {
+ const conditions: Record[] = [];
+ 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 {
+ const out = new Set();
+ 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);
+}
diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts
index 311847c54..2fee26ee0 100644
--- a/lib/plugin-sandbox/loader.ts
+++ b/lib/plugin-sandbox/loader.ts
@@ -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);
diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx
index 86d3a57dd..a22d4711f 100644
--- a/lib/plugin-sandbox/runtime.tsx
+++ b/lib/plugin-sandbox/runtime.tsx
@@ -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>,
+ /**
+ * 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,
@@ -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,
+ /**
+ * 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,
/**
* 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,
removeKeyword: (emailId: string, keyword: string, accountId?: string) =>
callApi('email.removeKeyword', [emailId, keyword, accountId]) as Promise,
+ /**
+ * 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, accountId?: string) =>
+ callApi('email.updateKeywords', [emailId, changes, accountId]) as Promise,
+ },
+ // 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,
},
// The composer (permission: compose:open). Opens it filled in; the user
// reviews the message and decides whether to send it.
diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts
index ef0b51b99..3e158f226 100644
--- a/lib/plugin-slot-catalog.ts
+++ b/lib/plugin-slot-catalog.ts
@@ -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),
diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts
index 670d2b231..81bedd346 100644
--- a/lib/plugin-types.ts
+++ b/lib/plugin-types.ts
@@ -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',
diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts
index d355d48db..2162353b8 100644
--- a/lib/sign-out-cleanup.ts
+++ b/lib/sign-out-cleanup.ts
@@ -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>();
+
+/** Run `task` before an account signs out; `accountId` is 'all' when every account does. */
+export function onBeforeSignOut(task: (accountId: string) => Promise): () => void {
+ beforeSignOutTasks.add(task);
+ return () => beforeSignOutTasks.delete(task);
+}
+
+export async function runBeforeSignOut(accountId: string): Promise {
+ 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;
diff --git a/lib/template-merge.ts b/lib/template-merge.ts
index 1377c34aa..cb470b637 100644
--- a/lib/template-merge.ts
+++ b/lib/template-merge.ts
@@ -98,6 +98,16 @@ export function mergeStoredTemplates(
stored: Record | null,
incoming: Record
): Record {
+ // 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;
diff --git a/lib/template-utils.ts b/lib/template-utils.ts
index 0a874e9c8..95a5ad191 100644
--- a/lib/template-utils.ts
+++ b/lib/template-utils.ts
@@ -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 | 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,
diff --git a/lib/templates/account-clients.ts b/lib/templates/account-clients.ts
new file mode 100644
index 000000000..b6cf9236d
--- /dev/null
+++ b/lib/templates/account-clients.ts
@@ -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 & Partial>;
+
+/** 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> {
+ return !!client
+ && typeof client.getBulwarkMetadata === 'function'
+ && typeof client.setBulwarkMetadata === 'function'
+ && client.supportsBulwarkMetadata?.(accountId) === true;
+}
diff --git a/lib/templates/account-template-sync.ts b/lib/templates/account-template-sync.ts
new file mode 100644
index 000000000..c0d019f93
--- /dev/null
+++ b/lib/templates/account-template-sync.ts
@@ -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>();
+const again = new Set();
+
+/**
+ * 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 {
+ const current = running.get(localAccountId);
+ if (current) {
+ again.add(localAccountId);
+ return current;
+ }
+ const round = (async (): Promise => {
+ 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 | undefined {
+ return running.get(localAccountId);
+}
+
+async function syncRound(localAccountId: string, client: TemplateClient, browserList: () => BrowserList): Promise {
+ 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 = { ...server };
+ const localOnly: Record = {};
+ 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 = {};
+ for (const [id, template] of Object.entries(serverAfter)) serverVersions[id] = template.updatedAt;
+ const keptLocalOnly: Record = {};
+ 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';
+}
diff --git a/lib/templates/metadata-templates.ts b/lib/templates/metadata-templates.ts
new file mode 100644
index 000000000..d3f138a03
--- /dev/null
+++ b/lib/templates/metadata-templates.ts
@@ -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.`,
+ * 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.`). */
+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.`),
+ * by id, validated and sanitised like a synced template. The key decides
+ * the id.
+ */
+export function templatesFromMetadata(entries: Record | null | undefined): Record {
+ const out: Record = {};
+ 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), id }]) ?? [];
+ if (template) out[id] = template;
+ }
+ return out;
+}
+
+export interface Marker {
+ version: number;
+ deleted: Record;
+}
+
+/** The marker entry, or null when there is none. */
+export function markerFromMetadata(entries: Record | null | undefined): Marker | null {
+ const value = entries?.[MARKER_KEY];
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
+ const raw = value as Record;
+ 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, now: Date = new Date()): Record {
+ 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 = {};
+ 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, b: Record): Record {
+ 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, b: Record): 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;
+ /** `updatedAt` of each template as the server held it at the last sync. */
+ serverVersions: Record;
+}
+
+export interface PersonalSyncPlan {
+ /** What this browser keeps after the merge. */
+ templates: EmailTemplate[];
+ /** All tombstones, pruned: what the marker should hold. */
+ deletedTemplateIds: Record;
+ /** 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,
+ server: Record,
+ serverTombstones: Record,
+ 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 };
+}
diff --git a/lib/templates/org-library.ts b/lib/templates/org-library.ts
new file mode 100644
index 000000000..5eee248a5
--- /dev/null
+++ b/lib/templates/org-library.ts
@@ -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 | 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: '',
+ }));
+}
diff --git a/lib/templates/personal-templates.ts b/lib/templates/personal-templates.ts
new file mode 100644
index 000000000..68c81b8f0
--- /dev/null
+++ b/lib/templates/personal-templates.ts
@@ -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;
+ 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 {
+ 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>();
+
+/** 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();
+
+/** 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 {
+ 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();
+});
diff --git a/lib/templates/team-templates.ts b/lib/templates/team-templates.ts
new file mode 100644
index 000000000..68c4cb9da
--- /dev/null
+++ b/lib/templates/team-templates.ts
@@ -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> {
+ 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 {
+ 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 | 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 {
+ 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();
+
+/** 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>,
+ groupAccountId: string,
+ id: string,
+ base: string | null,
+): Promise | 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 {
+ 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 {
+ 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';
+}
diff --git a/lib/templates/template-reducer.ts b/lib/templates/template-reducer.ts
new file mode 100644
index 000000000..4c2fb8a64
--- /dev/null
+++ b/lib/templates/template-reducer.ts
@@ -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;
+}
+
+export type TemplateData = Omit;
+export type TemplateUpdate = Partial>;
+
+/** `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] };
+}
diff --git a/lib/templates/template-sources.ts b/lib/templates/template-sources.ts
new file mode 100644
index 000000000..052d4b62c
--- /dev/null
+++ b/lib/templates/template-sources.ts
@@ -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:`. */
+ 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';
diff --git a/locales/de/common.json b/locales/de/common.json
index cee78e078..8cb87b376 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -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...",
diff --git a/locales/en/common.json b/locales/en/common.json
index e734e8c3e..a326c6e3f 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -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...",
diff --git a/stores/__tests__/email-store-plugin-list-filters.test.ts b/stores/__tests__/email-store-plugin-list-filters.test.ts
new file mode 100644
index 000000000..2b9e0248c
--- /dev/null
+++ b/stores/__tests__/email-store-plugin-list-filters.test.ts
@@ -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 = {}): 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();
+ });
+});
diff --git a/stores/__tests__/template-store-account-copy.test.ts b/stores/__tests__/template-store-account-copy.test.ts
new file mode 100644
index 000000000..fb05c038e
--- /dev/null
+++ b/stores/__tests__/template-store-account-copy.test.ts
@@ -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 => ({ ...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');
+ });
+});
diff --git a/stores/account-templates-store.ts b/stores/account-templates-store.ts
new file mode 100644
index 000000000..3f78fb61e
--- /dev/null
+++ b/stores/account-templates-store.ts
@@ -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;
+ /** Tombstones the server's marker entry held after the last round. Others are still to be sent. */
+ serverDeleted: Record;
+ /** 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;
+}
+
+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;
+ /**
+ * 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;
+ 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 = {};
+ 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 = {};
+ 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()(
+ 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 = {};
+ 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>; browserListOwner?: string | null };
+ if (version < 2) {
+ const personal: Record = {};
+ 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();
+ });
+}
diff --git a/stores/auth-store.ts b/stores/auth-store.ts
index 41b83b341..b97bb002b 100644
--- a/stores/auth-store.ts
+++ b/stores/auth-store.ts
@@ -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 {
return Promise.race([
- authHooks.onBeforeLogout.emit({ accountId }),
+ Promise.all([authHooks.onBeforeLogout.emit({ accountId }), runBeforeSignOut(accountId)]).then(() => {}),
new Promise((resolve) => setTimeout(resolve, LOGOUT_HOOK_BUDGET_MS)),
]);
}
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 32d7e2503..dcece4e31 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -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 | 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,
): Record | 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): 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);
}
diff --git a/stores/settings-store.ts b/stores/settings-store.ts
index b6f7a484e..216392860 100644
--- a/stores/settings-store.ts
+++ b/stores/settings-store.ts
@@ -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();
+
+/** 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;
+ 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 | null): boolean {
- const local = templateSyncBridge?.getSyncedState();
+function localTemplatesAhead(serverSettings: Record | 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()(
}
};
- 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()(
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()(
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()(
// 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()(
});
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()(
syncError('Failed to load settings from server:', error);
isLoadingFromServer = false;
return failWithoutForeignState();
+ } finally {
+ settledLoads.add(accountId);
+ for (const listener of settledListeners) listener(accountId);
}
},
diff --git a/stores/template-store.ts b/stores/template-store.ts
index ae3185496..cfccd2728 100644
--- a/stores/template-store.ts
+++ b/stores/template-store.ts
@@ -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 }): reducer.TemplateListState =>
+ ({ templates: state.templates, deletedTemplateIds: state.deletedTemplateIds });
+
interface TemplateStore {
templates: EmailTemplate[];
recentTemplateIds: string[];
@@ -63,72 +68,36 @@ export const useTemplateStore = create()(
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()(
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();
+ });
+ }
);