mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Merge branch 'feat/org-features'
This commit is contained in:
@@ -81,6 +81,12 @@ jobs:
|
||||
LITE_DEMO_MODE: ${{ inputs.demo }}
|
||||
run: npm run build:lite
|
||||
|
||||
# Every host literal in the bundle must be a placeholder, the configured
|
||||
# JMAP server, a built-in host the no-third-party switch turns off, or a
|
||||
# string listed as never requested (#903).
|
||||
- name: Scan the bundle for outside hosts
|
||||
run: node scripts/lite/scan-hosts.mjs out
|
||||
|
||||
- name: Package zip
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Features
|
||||
|
||||
- **Organisation**: Admins can set an organisation signature with placeholders for each user's name, address, company, job title, department and phone numbers, per domain or group, and lock it so it replaces everyone's own signature. Users fill in their details once under Settings > Account. Lines whose details are empty are left out (#908)
|
||||
- **Organisation**: Admins can add a legal disclaimer that goes below every message when it is sent, never into drafts, and is shown read-only in the composer
|
||||
- **Organisation**: Admins can give every user a library of templates, per domain or group, that users pick under "Organisation" in the template picker and can copy but not change
|
||||
- **Templates**: On Stalwart 1.0, members of a group mailbox share team templates, and users' own templates are kept in their account so they follow them to every browser. Templates already in a browser move over on the first sign-in. Admins can lock team templates or leave users only the organisation and team templates
|
||||
- **Tags**: On Stalwart 1.0, members of a group mailbox see the same tag names and colors on its mail, in the list, the tag picker, the sidebar and the unified inbox. They edit them under Settings > Folders & tags, where tags already on the group's mail can be given a name. Admins can lock the list per domain or group, for example so only team leads change it (#470, #1310)
|
||||
- **Organisation**: Admins can label mail from outside the organisation with an "External" chip and have users confirm before sending to or inviting external recipients (composer, quick reply and meeting invitations), per domain or group. Partner domains and contacts count as internal. This replaces the External Mail Warning plugin, whose safe domains can be imported. Plugins can read `api.host.features.externalSenderLabel` and `externalRecipientWarning`
|
||||
- **Organisation**: Admins can sign users out after a time without activity or a fixed time after sign-in, per domain or group. A countdown with "Stay signed in" comes first, open and minimised messages and quick replies are saved as drafts, and the login page says why
|
||||
- **Delegates**: Settings > Sharing gives a colleague read, or read and write, access to all mail folders or just the Inbox, calendars and contacts in one step, lists who has access to what, and removes access everywhere at once. Admins can hide it per domain or group and limit every share dialog to read access on Organisation > Sharing
|
||||
- **Recover deleted items**: On Stalwart Enterprise with archiving turned on, users bring back mail they deleted for good under Settings > Recover deleted items, or from a link in Trash when there is something to recover. Restored mail goes back to the Inbox unread. Admins can limit how many days back it lists or hide it per domain or group (#1319)
|
||||
- **Privacy**: One switch, `NO_THIRD_PARTY_TRAFFIC` or Organisation > Security, stops every connection to services outside your own servers: telemetry, the version check, the built-in push relay, the official extension directory, sender icons, MyMemory translation, map links, OpenPGP key lookups and remote pictures in mail. Plugins that need network access don't run, and the page's CSP allows only the hosts you configured. Push registrations move to your own relay. The admin page lists what stays reachable (#903)
|
||||
- **Team inbox**: The new official Team inbox plugin lets members of a team mailbox assign messages to each other, mark them done, and filter by "Assigned to me" and "Open"
|
||||
- **Plugins**: Plugins can add keyword filters to the message list, shown as chips above it, put icons on row badges, show actions only in team mailboxes or for some keywords, change several keywords in one step, see the user's own account and which mailboxes are team mailboxes, and keep a small shared value in a team mailbox on Stalwart 1.0. Looking people up in the directory needs the new `directory:read` permission. Plugins can no longer set `$draft`, `$junk`, `$notjunk` or `$phishing`
|
||||
|
||||
## 1.18.0 (2026-10-26)
|
||||
|
||||
1.18.0 works with Stalwart 1.0, released today, as well as with 0.16. Sign-in, calendars, contacts, files and sending were adapted to what changed in 1.0, and a Stalwart that was moved from 0.16 to 1.0 with Stalwart's migration tool keeps working with the same open tabs. It also fixes the findings of a security review of the whole webmail, more places where an action in a view that mixes accounts could hit another account's message with the same id, and a series of calendar bugs around repeating events. Plugins can show their own search results and a count on their buttons, quick reply can be turned off, and a new device offers to restore your saved account list.
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la
|
||||
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
||||
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
|
||||
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
||||
import { ExternalRecipientConfirmHost } from "@/components/email/external-recipient-confirm";
|
||||
import { CommandPaletteHost } from "@/components/command-palette/command-palette";
|
||||
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
|
||||
import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog";
|
||||
@@ -20,6 +21,8 @@ import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
|
||||
import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt";
|
||||
import { SessionTimeoutGuard } from "@/components/session/session-timeout-guard";
|
||||
import { AccountTemplateSync } from "@/components/templates/account-template-sync";
|
||||
import { IS_LITE } from "@/lib/lite";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { locales } from "@/i18n/routing";
|
||||
@@ -62,8 +65,11 @@ export default async function LocaleLayout({
|
||||
<ProInterfaceRedirect />
|
||||
<ImpersonationReconciler />
|
||||
<PushBindingsHost />
|
||||
<SessionTimeoutGuard />
|
||||
<AccountTemplateSync />
|
||||
{children}
|
||||
<PluginDialogHost />
|
||||
<ExternalRecipientConfirmHost />
|
||||
<CommandPaletteHost />
|
||||
<TotpReauthDialog />
|
||||
<PluginConnectDialog />
|
||||
|
||||
@@ -35,6 +35,7 @@ import { toAsciiDomain } from "@/lib/idn";
|
||||
import { usePrefetchMailView } from "@/hooks/use-prefetch-mail-view";
|
||||
import { buildMobileHandoffUrl, mobileHandoffTarget, signedInLoginAction } from "@/lib/auth/mobile-handoff";
|
||||
import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent";
|
||||
import { consumeSessionEnd, type SessionEndReason } from "@/lib/session-policy/sign-out-reason";
|
||||
|
||||
/** The domain of a complete address (`user@example.com`), or '' while it is still being typed. */
|
||||
function completeAddressDomain(username: string): string {
|
||||
@@ -178,6 +179,7 @@ function VersionBadge() {
|
||||
function LoginPageContent() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("login");
|
||||
const tSessionLimits = useTranslations("session_limits");
|
||||
const tTheme = useTranslations("settings.appearance.theme");
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -247,6 +249,8 @@ function LoginPageContent() {
|
||||
// those sessions then only last as long as the tab (lib/auth/lite-tokens.ts).
|
||||
const [liteTokenLoginSupported, setLiteTokenLoginSupported] = useState<boolean | null>(null);
|
||||
const [sessionExpired, setSessionExpired] = useState(false);
|
||||
// Signed out by the organisation's idle timeout or maximum session length.
|
||||
const [sessionEnd, setSessionEnd] = useState<SessionEndReason | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [shakeError, setShakeError] = useState(false);
|
||||
const [showThemeMenu, setShowThemeMenu] = useState(false);
|
||||
@@ -372,6 +376,11 @@ function LoginPageContent() {
|
||||
sessionStorage.removeItem('session_expired');
|
||||
}
|
||||
} catch { /* sessionStorage unavailable */ }
|
||||
const ended = consumeSessionEnd();
|
||||
if (ended) {
|
||||
setSessionEnd(ended);
|
||||
setSessionExpired(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1269,8 +1278,15 @@ function LoginPageContent() {
|
||||
<Info className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 self-center flex items-center gap-2">
|
||||
<p className="text-sm text-info flex-1 leading-relaxed">
|
||||
{t("session_expired")}
|
||||
<p className="text-sm text-info flex-1 leading-relaxed" data-testid="session-expired-reason">
|
||||
{sessionEnd?.kind === "idle"
|
||||
? tSessionLimits("reason_idle", { minutes: sessionEnd.minutes })
|
||||
: sessionEnd?.kind === "max"
|
||||
? tSessionLimits("reason_max", { hours: sessionEnd.hours })
|
||||
: t("session_expired")}
|
||||
{sessionEnd?.draftsUnsaved && (
|
||||
<span className="block mt-1" data-testid="session-drafts-not-saved">{tSessionLimits("drafts_not_saved")}</span>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -8,6 +8,11 @@ import { SettingsPage } from '../_pages/settings';
|
||||
import { BrandingPage } from '../_pages/branding';
|
||||
import { AuthPage } from '../_pages/auth';
|
||||
import { PolicyPage } from '../_pages/policy';
|
||||
import { OrganisationPage } from '../_pages/organisation';
|
||||
import { OrgSignaturesPage } from '../_pages/org-signatures';
|
||||
import { OrgTemplatesPage } from '../_pages/org-templates';
|
||||
import { OrgSecurityPage } from '../_pages/org-security';
|
||||
import { OrgSharingPage } from '../_pages/org-sharing';
|
||||
import { PluginsPage } from '../_pages/plugins';
|
||||
import { ThemesPage } from '../_pages/themes';
|
||||
import { MarketplacePage } from '../_pages/marketplace';
|
||||
@@ -41,6 +46,11 @@ export default function AdminPage() {
|
||||
case 'branding': return <BrandingPage />;
|
||||
case 'auth': return <AuthPage />;
|
||||
case 'policy': return <PolicyPage />;
|
||||
case 'organisation': return <OrganisationPage />;
|
||||
case 'org-signatures': return <OrgSignaturesPage />;
|
||||
case 'org-templates': return <OrgTemplatesPage />;
|
||||
case 'org-security': return <OrgSecurityPage />;
|
||||
case 'org-sharing': return <OrgSharingPage />;
|
||||
case 'plugins': return detail ? <ExtensionDetailPage key={detail} kind="plugin" id={detail} /> : <PluginsPage />;
|
||||
case 'themes': return detail ? <ExtensionDetailPage key={detail} kind="theme" id={detail} /> : <ThemesPage />;
|
||||
case 'marketplace': return detail ? <MarketplaceDetailPage key={detail} slug={detail} /> : <MarketplacePage />;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
// Organisation > Signatures: the templates, the user details they ask for
|
||||
// and the `signature` restriction, edited per scope and saved in one PUT.
|
||||
|
||||
const puts: Array<Record<string, unknown>> = [];
|
||||
let stored: Record<string, unknown> = {};
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'PUT') {
|
||||
const body = JSON.parse(String(init.body));
|
||||
puts.push(body);
|
||||
stored = body;
|
||||
return new Response('{"ok":true}');
|
||||
}
|
||||
return new Response(JSON.stringify(stored));
|
||||
}),
|
||||
withBasePath: (p: string) => p,
|
||||
getPathPrefix: () => '',
|
||||
}));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ loginCompanyName: 'ACME Ltd' }) }));
|
||||
|
||||
import { OrgSignaturesPage } from '../_pages/org-signatures';
|
||||
import { EVERYONE, useOrgScopeStore } from '@/components/admin/org/use-org-policy-draft';
|
||||
|
||||
const POLICY = {
|
||||
restrictions: {},
|
||||
features: {}, defaults: {}, themePolicy: {},
|
||||
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
|
||||
organisation: { signature: { html: '<p>{name}</p>', text: '', fields: [] } },
|
||||
scopes: [{ id: 'example', label: 'Example', match: { domains: ['example.org'] }, policy: {} }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
puts.length = 0;
|
||||
stored = JSON.parse(JSON.stringify(POLICY));
|
||||
useOrgScopeStore.setState({ scopeId: EVERYONE });
|
||||
});
|
||||
|
||||
describe('Organisation > Signatures', () => {
|
||||
it('sets a signature for a domain scope, with the fields it asks for and a lock', async () => {
|
||||
render(<OrgSignaturesPage />);
|
||||
const html = await screen.findByTestId('org-signature-html') as HTMLTextAreaElement;
|
||||
expect(html.value).toBe('<p>{name}</p>');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Example' }));
|
||||
expect(screen.getByText(/From Everyone: <p>\{name\}<\/p>/)).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByTestId('org-signature-html'), {
|
||||
target: { value: '<p>{name}<br>Phone: {phone}<br>Mobile: {mobile}</p><p>{company}</p>' },
|
||||
});
|
||||
|
||||
// The preview fills in sample values and leaves out empty lines.
|
||||
const preview = screen.getByTestId('org-signature-preview');
|
||||
expect(preview.innerHTML).toContain('Alex Example<br>Phone: +49 30 1234567<br>Mobile: +49 170 1234567');
|
||||
expect(preview.innerHTML).toContain('ACME Ltd');
|
||||
fireEvent.click(screen.getByLabelText('For a user who filled in no details'));
|
||||
expect(screen.getByTestId('org-signature-preview').innerHTML).toContain('<p>Alex Example</p><p>ACME Ltd</p>');
|
||||
|
||||
const fields = screen.getByTestId('org-signature-fields');
|
||||
expect([...fields.querySelectorAll('[data-used]')].map((li) => li.textContent)).toEqual(['Phoneasked for', 'Mobileasked for']);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Lock'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
const scope = (puts[0].scopes as Array<{ policy: Record<string, unknown> }>)[0];
|
||||
expect(scope.policy).toEqual({
|
||||
organisation: { signature: { html: '<p>{name}<br>Phone: {phone}<br>Mobile: {mobile}</p><p>{company}</p>', fields: ['phone', 'mobile'] } },
|
||||
restrictions: { signature: { locked: true } },
|
||||
});
|
||||
// Everyone keeps its own.
|
||||
expect((puts[0].organisation as { signature: { html: string } }).signature.html).toBe('<p>{name}</p>');
|
||||
});
|
||||
|
||||
it('inserts a placeholder at the cursor of the template last edited', async () => {
|
||||
render(<OrgSignaturesPage />);
|
||||
const text = await screen.findByTestId('org-signature-text') as HTMLTextAreaElement;
|
||||
fireEvent.focus(text);
|
||||
fireEvent.click(screen.getByRole('button', { name: '{department}' }));
|
||||
await waitFor(() => expect((screen.getByTestId('org-signature-text') as HTMLTextAreaElement).value).toBe('{department}'));
|
||||
// Branding per domain goes by the web address Bulwark is opened on, not the sender's domain.
|
||||
expect(screen.getByText('company name from Branding, for the web address Bulwark is opened on')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets a disclaimer for a scope, with a preview', async () => {
|
||||
render(<OrgSignaturesPage />);
|
||||
await screen.findByTestId('org-disclaimer-text');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Example' }));
|
||||
fireEvent.change(screen.getByTestId('org-disclaimer-text'), { target: { value: 'Confidential, {company}' } });
|
||||
expect(screen.getByTestId('org-disclaimer-sample')).toHaveTextContent('Confidential, ACME Ltd');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
const scope = (puts[0].scopes as Array<{ policy: Record<string, unknown> }>)[0];
|
||||
expect(scope.policy).toEqual({ organisation: { disclaimer: { text: 'Confidential, {company}' } } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
|
||||
// Organisation > Templates: the admin library per scope (a scope adds to
|
||||
// Everyone's by id) and the restrictions on users' own and team templates,
|
||||
// saved in one PUT.
|
||||
|
||||
const puts: Array<Record<string, unknown>> = [];
|
||||
let stored: Record<string, unknown> = {};
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'PUT') {
|
||||
const body = JSON.parse(String(init.body));
|
||||
puts.push(body);
|
||||
stored = body;
|
||||
return new Response('{"ok":true}');
|
||||
}
|
||||
return new Response(JSON.stringify(stored));
|
||||
}),
|
||||
withBasePath: (p: string) => p,
|
||||
getPathPrefix: () => '',
|
||||
}));
|
||||
|
||||
import { OrgTemplatesPage } from '../_pages/org-templates';
|
||||
import { EVERYONE, useOrgScopeStore } from '@/components/admin/org/use-org-policy-draft';
|
||||
|
||||
const WELCOME = { id: 'welcome', name: 'Welcome', subject: 'Hello', body: '<p>Hi <b>there</b></p>', isHTML: true, category: 'Sales' };
|
||||
|
||||
const POLICY = {
|
||||
restrictions: {},
|
||||
features: {}, defaults: {}, themePolicy: {},
|
||||
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
|
||||
organisation: { templates: { library: [WELCOME] } },
|
||||
scopes: [{ id: 'two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: {} }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
puts.length = 0;
|
||||
stored = JSON.parse(JSON.stringify(POLICY));
|
||||
useOrgScopeStore.setState({ scopeId: EVERYONE });
|
||||
});
|
||||
|
||||
describe('Organisation > Templates', () => {
|
||||
it('edits Everyone\'s library with a preview', async () => {
|
||||
render(<OrgTemplatesPage />);
|
||||
const items = await screen.findAllByTestId('org-template-item');
|
||||
expect(items).toHaveLength(1);
|
||||
fireEvent.click(within(items[0]).getByRole('button', { name: /^Welcome/ }));
|
||||
expect(screen.getByTestId('org-template-preview').innerHTML).toContain('<b>there</b>');
|
||||
|
||||
fireEvent.click(screen.getByTestId('org-template-add'));
|
||||
const names = screen.getAllByTestId('org-template-name');
|
||||
fireEvent.change(names[1], { target: { value: 'Offer' } });
|
||||
fireEvent.change(screen.getAllByTestId('org-template-body')[1], { target: { value: 'Our offer' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
const library = (puts[0].organisation as { templates: { library: Array<{ name: string; body: string }> } }).templates.library;
|
||||
expect(library.map((t) => t.name)).toEqual(['Welcome', 'Offer']);
|
||||
expect(library[1].body).toBe('Our offer');
|
||||
});
|
||||
|
||||
it('adds templates for a scope without copying Everyone\'s, and changes one of Everyone\'s for that scope only', async () => {
|
||||
render(<OrgTemplatesPage />);
|
||||
await screen.findAllByTestId('org-template-item');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Org two' }));
|
||||
|
||||
// Everyone's template is shown, not editable, and the scope starts empty.
|
||||
expect(within(screen.getByTestId('org-template-inherited')).getByText('Welcome')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('org-template-empty')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('org-template-add'));
|
||||
fireEvent.change(screen.getByTestId('org-template-name'), { target: { value: 'Two only' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Change for this scope/ }));
|
||||
const subjects = screen.getAllByTestId('org-template-subject');
|
||||
fireEvent.click(within(screen.getAllByTestId('org-template-item')[1]).getByRole('button', { name: /^Welcome/ }));
|
||||
fireEvent.change(screen.getAllByTestId('org-template-subject')[1], { target: { value: 'Hello from two' } });
|
||||
expect(subjects).toHaveLength(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
const scope = (puts[0].scopes as Array<{ policy: { organisation: { templates: { library: Array<{ id: string; name: string; subject: string }> } } } }>)[0];
|
||||
const library = scope.policy.organisation.templates.library;
|
||||
expect(library.map((t) => t.name)).toEqual(['Two only', 'Welcome']);
|
||||
expect(library[1]).toMatchObject({ id: 'welcome', subject: 'Hello from two' });
|
||||
// Everyone keeps its own.
|
||||
expect((puts[0].organisation as { templates: { library: Array<{ subject: string }> } }).templates.library[0].subject).toBe('Hello');
|
||||
});
|
||||
|
||||
it('removing the last template of a scope inherits Everyone\'s library again', async () => {
|
||||
stored = JSON.parse(JSON.stringify({
|
||||
...POLICY,
|
||||
scopes: [{ ...POLICY.scopes[0], policy: { organisation: { templates: { library: [{ ...WELCOME, id: 'x', name: 'X' }] } } } }],
|
||||
}));
|
||||
render(<OrgTemplatesPage />);
|
||||
await screen.findAllByTestId('org-template-item');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Org two' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove X' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
const policy = (puts[0].scopes as Array<{ policy: { organisation?: Record<string, unknown> } }>)[0].policy;
|
||||
expect(policy.organisation?.templates).toBeUndefined();
|
||||
});
|
||||
|
||||
it('says that a template without a name is not saved', async () => {
|
||||
render(<OrgTemplatesPage />);
|
||||
await screen.findAllByTestId('org-template-item');
|
||||
fireEvent.click(screen.getByTestId('org-template-add'));
|
||||
expect(screen.getByText(/A template without a name is not saved/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides personal templates through the restriction row', async () => {
|
||||
render(<OrgTemplatesPage />);
|
||||
await screen.findAllByTestId('org-template-item');
|
||||
fireEvent.click(screen.getByLabelText('Hide'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
expect((puts[0].restrictions as Record<string, unknown>).personalTemplates).toEqual({ hidden: true });
|
||||
});
|
||||
|
||||
it('locks team templates for a group scope only', async () => {
|
||||
stored = JSON.parse(JSON.stringify({
|
||||
...POLICY,
|
||||
scopes: [...POLICY.scopes, { id: 'team', label: 'Team', match: { groups: ['org-team@example.org'] }, policy: {} }],
|
||||
}));
|
||||
render(<OrgTemplatesPage />);
|
||||
await screen.findAllByTestId('org-template-item');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Team' }));
|
||||
fireEvent.click(screen.getByLabelText('Lock'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
const scopes = puts[0].scopes as Array<{ id: string; policy: { restrictions?: Record<string, unknown> } }>;
|
||||
expect(scopes.find((scope) => scope.id === 'team')?.policy.restrictions).toEqual({ teamTemplates: { locked: true } });
|
||||
expect((puts[0].restrictions as Record<string, unknown>).teamTemplates).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
// The Organisation pages edit one scope at a time: Everyone (the global
|
||||
// policy) or a domain or group scope. A value a scope doesn't set comes from
|
||||
// Everyone and can be reset to it; saving sends the whole policy.
|
||||
|
||||
const puts: Array<Record<string, unknown>> = [];
|
||||
let stored: Record<string, unknown> = {};
|
||||
const filePosts: string[] = [];
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (url.startsWith('/api/admin/policy/file') && init?.method === 'POST') {
|
||||
filePosts.push(url);
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
summary: {
|
||||
scopes: { added: ['Two (two)'], changed: [], removed: [] },
|
||||
organisation: { added: [], changed: ['session'], removed: [] },
|
||||
restrictions: { added: [], changed: [], removed: [] },
|
||||
features: { added: [], changed: [], removed: [] },
|
||||
defaults: { added: [], changed: [], removed: [] },
|
||||
other: [], unchanged: false,
|
||||
},
|
||||
}));
|
||||
}
|
||||
if (init?.method === 'PUT') {
|
||||
const body = JSON.parse(String(init.body));
|
||||
puts.push(body);
|
||||
stored = body;
|
||||
return new Response('{"ok":true}');
|
||||
}
|
||||
return new Response(JSON.stringify(stored));
|
||||
}),
|
||||
withBasePath: (p: string) => p,
|
||||
getPathPrefix: () => '',
|
||||
}));
|
||||
|
||||
import { OrganisationPage } from '../_pages/organisation';
|
||||
import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '@/components/admin/org/use-org-policy-draft';
|
||||
|
||||
const POLICY = {
|
||||
restrictions: { density: { locked: true, value: 'compact' } },
|
||||
features: {}, defaults: {}, themePolicy: {},
|
||||
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
|
||||
organisation: { session: { idleMinutes: 30 } },
|
||||
scopes: [{ id: 'team', label: 'Team', match: { groups: ['org-team@example.org'] }, policy: {} }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
puts.length = 0;
|
||||
filePosts.length = 0;
|
||||
stored = JSON.parse(JSON.stringify(POLICY));
|
||||
useOrgScopeStore.setState({ scopeId: EVERYONE });
|
||||
});
|
||||
|
||||
describe('useOrgPolicyDraft', () => {
|
||||
it('writes into the selected scope and shows Everyone\'s value until then', async () => {
|
||||
const { result } = renderHook(() => useOrgPolicyDraft('test'));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
act(() => result.current.setScopeId('team'));
|
||||
expect(result.current.orgValue('session', 'idleMinutes')).toEqual({ value: 30, own: false, everyone: 30 });
|
||||
|
||||
act(() => result.current.setOrgValue('session', 'idleMinutes', 5));
|
||||
expect(result.current.orgValue('session', 'idleMinutes')).toEqual({ value: 5, own: true, everyone: 30 });
|
||||
expect(result.current.policy?.organisation?.session.idleMinutes).toBe(30);
|
||||
expect(result.current.dirty).toBe(true);
|
||||
|
||||
act(() => result.current.clearOrgValue('session', 'idleMinutes'));
|
||||
expect(result.current.orgValue('session', 'idleMinutes').own).toBe(false);
|
||||
expect(result.current.policy?.scopes?.[0].policy.organisation).toEqual({});
|
||||
});
|
||||
|
||||
it('lets a scope unlock what Everyone locks, and saves the whole policy', async () => {
|
||||
const { result } = renderHook(() => useOrgPolicyDraft('test'));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
act(() => result.current.setScopeId('team'));
|
||||
act(() => result.current.setRestriction('density', { locked: false }));
|
||||
expect(result.current.policy?.scopes?.[0].policy.restrictions).toEqual({ density: {} });
|
||||
expect(result.current.policy?.restrictions.density).toEqual({ locked: true, value: 'compact' });
|
||||
|
||||
await act(async () => { await result.current.save(); });
|
||||
expect(puts).toHaveLength(1);
|
||||
expect((puts[0].scopes as Array<{ policy: unknown }>)[0].policy).toEqual({ restrictions: { density: {} } });
|
||||
expect(puts[0].organisation).toMatchObject({ session: { idleMinutes: 30 } });
|
||||
});
|
||||
|
||||
it('writes to the global policy for Everyone', async () => {
|
||||
const { result } = renderHook(() => useOrgPolicyDraft('test'));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
act(() => result.current.setOrgValue('externalMail', 'senderLabel', true));
|
||||
expect(result.current.policy?.organisation?.externalMail.senderLabel).toBe(true);
|
||||
expect(result.current.policy?.scopes?.[0].policy).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('OrganisationPage', () => {
|
||||
it('adds a domain scope and shows which entry sets what for a user', async () => {
|
||||
render(<OrganisationPage />);
|
||||
await screen.findByTestId('org-scope-team');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add domains/ }));
|
||||
const names = screen.getAllByLabelText('Name') as HTMLInputElement[];
|
||||
expect(names.map((n) => n.value)).toEqual(['Team', 'New domain']);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('User address'), { target: { value: 'org-alice@example.org' } });
|
||||
fireEvent.change(screen.getByLabelText('Groups the user is a member of'), { target: { value: 'org-team@example.org' } });
|
||||
const table = await screen.findByTestId('org-effective-policy');
|
||||
expect(table.textContent).toContain('Applies, in this order: Everyone, Team.');
|
||||
expect(table.textContent).toContain('restrictions.density');
|
||||
expect(table.textContent).toContain('organisation.session.idleMinutes');
|
||||
});
|
||||
|
||||
it('shows what an imported file changes and replaces the policy only when asked', async () => {
|
||||
render(<OrganisationPage />);
|
||||
await screen.findByTestId('org-scope-team');
|
||||
const file = new File(['{"scopes":[]}'], 'policy.json', { type: 'application/json' });
|
||||
fireEvent.change(screen.getByLabelText('Policy file to import'), { target: { files: [file] } });
|
||||
const preview = await screen.findByTestId('policy-import-preview');
|
||||
expect(preview.textContent).toContain('Domains and groups: 1 added');
|
||||
expect(preview.textContent).toContain('Organisation settings: 1 changed');
|
||||
expect(filePosts).toEqual(['/api/admin/policy/file?preview=1']);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Replace the current policy' }));
|
||||
await waitFor(() => expect(filePosts).toEqual(['/api/admin/policy/file?preview=1', '/api/admin/policy/file']));
|
||||
await waitFor(() => expect(screen.queryByTestId('policy-import-preview')).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { OrgPage } from '@/components/admin/org/org-page';
|
||||
import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
|
||||
import { ExternalMailSection } from '@/components/admin/org/sections/external-mail-section';
|
||||
import { SessionsSection } from '@/components/admin/org/sections/sessions-section';
|
||||
import { ThirdPartySection } from '@/components/admin/org/sections/third-party-section';
|
||||
|
||||
export function OrgSecurityPage() {
|
||||
const draft = useOrgPolicyDraft('org-security');
|
||||
return (
|
||||
<OrgPage pageId="org-security" draft={draft}>
|
||||
<ExternalMailSection draft={draft} />
|
||||
<SessionsSection draft={draft} />
|
||||
<ThirdPartySection draft={draft} />
|
||||
</OrgPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { OrgPage } from '@/components/admin/org/org-page';
|
||||
import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
|
||||
import { SharedTagsSection } from '@/components/admin/org/sections/shared-tags-section';
|
||||
import { DelegatesSection } from '@/components/admin/org/sections/delegates-section';
|
||||
import { RecoverySection } from '@/components/admin/org/sections/recovery-section';
|
||||
|
||||
export function OrgSharingPage() {
|
||||
const draft = useOrgPolicyDraft('org-sharing');
|
||||
return (
|
||||
<OrgPage pageId="org-sharing" draft={draft}>
|
||||
<SharedTagsSection draft={draft} />
|
||||
<DelegatesSection draft={draft} />
|
||||
<RecoverySection draft={draft} />
|
||||
</OrgPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { OrgPage } from '@/components/admin/org/org-page';
|
||||
import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
|
||||
import { SignatureSection } from '@/components/admin/org/sections/signature-section';
|
||||
|
||||
export function OrgSignaturesPage() {
|
||||
const draft = useOrgPolicyDraft('org-signatures');
|
||||
return (
|
||||
<OrgPage pageId="org-signatures" draft={draft}>
|
||||
<SignatureSection draft={draft} />
|
||||
</OrgPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { OrgPage } from '@/components/admin/org/org-page';
|
||||
import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
|
||||
import { TemplatesSection } from '@/components/admin/org/sections/templates-section';
|
||||
|
||||
export function OrgTemplatesPage() {
|
||||
const draft = useOrgPolicyDraft('org-templates');
|
||||
return (
|
||||
<OrgPage pageId="org-templates" draft={draft}>
|
||||
<TemplatesSection draft={draft} />
|
||||
</OrgPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ArrowDown, ArrowUp, Plus, Trash2 } from '@/components/icons';
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/admin/badge';
|
||||
import { inputClass } from '@/components/admin/config-items';
|
||||
import { OrgPage, scopeMatchSummary } from '@/components/admin/org/org-page';
|
||||
import { PolicyFileSection } from '@/components/admin/org/policy-file-section';
|
||||
import { EVERYONE, useOrgPolicyDraft, type OrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
|
||||
import { resolvePolicy, type PolicyScope } from '@/lib/admin/policy-scopes';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const fieldLabelClass = 'mb-1 block text-[11px] font-medium text-muted-foreground';
|
||||
const DOMAIN_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
||||
const GROUP_RE = /^[^\s@]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
||||
|
||||
function splitList(text: string): string[] {
|
||||
return [...new Set(text.split(/[\s,;]+/).map((v) => v.trim().toLowerCase()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function newScopeId(existing: PolicyScope[]): string {
|
||||
const taken = new Set(existing.map((s) => s.id));
|
||||
for (;;) {
|
||||
const id = `scope-${Math.random().toString(36).slice(2, 8)}`;
|
||||
if (!taken.has(id)) return id;
|
||||
}
|
||||
}
|
||||
|
||||
/** A comma-separated list edited as text and stored as an array when the field loses focus. */
|
||||
function ListField({ label, value, placeholder, valid, onCommit }: {
|
||||
label: string;
|
||||
value: string[];
|
||||
placeholder: string;
|
||||
valid: (item: string) => boolean;
|
||||
onCommit: (items: string[]) => void;
|
||||
}) {
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const shown = text ?? value.join(', ');
|
||||
const invalid = splitList(shown).filter((item) => !valid(item));
|
||||
return (
|
||||
<label className="block min-w-0 flex-1">
|
||||
<span className={fieldLabelClass}>{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={shown}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className={cn(inputClass, 'w-full', invalid.length > 0 && 'border-destructive')}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (text === null) return;
|
||||
onCommit(splitList(text));
|
||||
setText(null);
|
||||
}}
|
||||
/>
|
||||
{invalid.length > 0 && (
|
||||
<span className="mt-1 block text-xs text-destructive">Not valid, ignored when saved: {invalid.join(', ')}</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeCard({ draft, scope, index, count }: { draft: OrgPolicyDraft; scope: PolicyScope; index: number; count: number }) {
|
||||
const move = (delta: number) => draft.update((p) => {
|
||||
const list = p.scopes ?? [];
|
||||
const [item] = list.splice(index, 1);
|
||||
list.splice(index + delta, 0, item);
|
||||
});
|
||||
const overrides = Object.keys(scope.policy.features ?? {}).length
|
||||
+ Object.keys(scope.policy.restrictions ?? {}).length
|
||||
+ Object.keys(scope.policy.defaults ?? {}).length
|
||||
+ Object.values(scope.policy.organisation ?? {}).reduce((n, section) => n + Object.keys(section ?? {}).length, 0);
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-3" data-testid={`org-scope-${scope.id}`}>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="block min-w-[10rem] flex-1">
|
||||
<span className={fieldLabelClass}>Name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={scope.label}
|
||||
className={cn(inputClass, 'w-full')}
|
||||
onChange={(e) => draft.updateScope(scope.id, (s) => { s.label = e.target.value; })}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" onClick={() => move(-1)} disabled={index === 0} aria-label="Move up" title="Move up"
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-30">
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button type="button" onClick={() => move(1)} disabled={index === count - 1} aria-label="Move down" title="Move down"
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-30">
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button type="button" aria-label="Remove" title="Remove"
|
||||
onClick={() => draft.update((p) => { p.scopes = (p.scopes ?? []).filter((s) => s.id !== scope.id); })}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-destructive">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<ListField
|
||||
label="Domains"
|
||||
value={scope.match.domains ?? []}
|
||||
placeholder="example.com, *.example.com"
|
||||
valid={(d) => DOMAIN_RE.test(d)}
|
||||
onCommit={(domains) => draft.updateScope(scope.id, (s) => { s.match = { ...s.match, domains }; })}
|
||||
/>
|
||||
<ListField
|
||||
label="Groups"
|
||||
value={scope.match.groups ?? []}
|
||||
placeholder="sales@example.com"
|
||||
valid={(g) => GROUP_RE.test(g)}
|
||||
onCommit={(groups) => draft.updateScope(scope.id, (s) => { s.match = { ...s.match, groups }; })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge tone="muted">{overrides === 0 ? 'No settings of its own yet' : `${overrides} setting${overrides === 1 ? '' : 's'} of its own`}</Badge>
|
||||
<button type="button" className="underline-offset-2 hover:underline" onClick={() => draft.setScopeId(scope.id)}>
|
||||
{draft.scopeId === scope.id ? 'Selected on the Organisation pages' : 'Edit its settings on the Organisation pages'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sourceLabel(source: string, scopes: PolicyScope[]): string {
|
||||
if (source === 'global') return 'Everyone';
|
||||
return scopes.find((s) => s.id === source)?.label ?? source;
|
||||
}
|
||||
|
||||
function valueAt(policy: Record<string, unknown>, path: string): unknown {
|
||||
const [root, ...rest] = path.split('.');
|
||||
// Keys of features, restrictions and defaults never contain dots; organisation paths are section.key.
|
||||
let value: unknown = policy[root];
|
||||
for (const part of rest) value = (value as Record<string, unknown> | undefined)?.[part];
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (typeof value === 'string') return value.length > 60 ? `${value.slice(0, 60)}…` : value || '(empty)';
|
||||
const json = JSON.stringify(value);
|
||||
return json && json.length > 80 ? `${json.slice(0, 80)}…` : String(json);
|
||||
}
|
||||
|
||||
function EffectivePolicy({ draft }: { draft: OrgPolicyDraft }) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [groupsText, setGroupsText] = useState('');
|
||||
const subject = email.includes('@') ? { email: email.trim().toLowerCase(), groups: splitList(groupsText) } : null;
|
||||
const resolved = useMemo(
|
||||
() => (draft.policy && subject ? resolvePolicy(draft.policy, subject) : null),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[draft.policy, subject?.email, subject?.groups.join(',')],
|
||||
);
|
||||
const rows = resolved
|
||||
? Object.entries(resolved.sources).sort(([a], [b]) => a.localeCompare(b))
|
||||
: [];
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<label className="block min-w-0 flex-1">
|
||||
<span className={fieldLabelClass}>User address</span>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="anna@example.com"
|
||||
className={cn(inputClass, 'w-full')} spellCheck={false} autoComplete="off" />
|
||||
</label>
|
||||
<label className="block min-w-0 flex-1">
|
||||
<span className={fieldLabelClass}>Groups the user is a member of</span>
|
||||
<input type="text" value={groupsText} onChange={(e) => setGroupsText(e.target.value)} placeholder="sales@example.com"
|
||||
className={cn(inputClass, 'w-full')} spellCheck={false} autoComplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
{resolved && (
|
||||
<div className="space-y-3" data-testid="org-effective-policy">
|
||||
<p className="text-sm text-foreground">
|
||||
{resolved.matched.length === 0
|
||||
? 'No domain or group applies; this user gets Everyone\'s settings.'
|
||||
: `Applies, in this order: Everyone, ${resolved.matched.map((id) => sourceLabel(id, draft.scopes)).join(', ')}.`}
|
||||
</p>
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Everything is at Bulwark's defaults.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-start font-medium">Setting</th>
|
||||
<th className="px-3 py-2 text-start font-medium">Value</th>
|
||||
<th className="px-3 py-2 text-start font-medium">Set by</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(([path, source]) => (
|
||||
<tr key={path} className="border-t border-border">
|
||||
<td className="px-3 py-1.5 font-mono text-xs break-all">{path}</td>
|
||||
<td className="px-3 py-1.5 font-mono text-xs break-all">{formatValue(valueAt(resolved.policy as unknown as Record<string, unknown>, path))}</td>
|
||||
<td className="px-3 py-1.5 text-xs">{sourceLabel(source, draft.scopes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Bulwark can only see groups that have a mailbox: a user counts as a member when the group's account is in their session.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrganisationPage() {
|
||||
const draft = useOrgPolicyDraft('organisation');
|
||||
const addScope = (kind: 'domain' | 'group') => draft.update((p) => {
|
||||
const id = newScopeId(p.scopes ?? []);
|
||||
p.scopes = [...(p.scopes ?? []), {
|
||||
id,
|
||||
label: kind === 'domain' ? 'New domain' : 'New group',
|
||||
match: {},
|
||||
policy: {},
|
||||
}];
|
||||
});
|
||||
return (
|
||||
<OrgPage pageId="organisation" draft={draft} scoped={false}>
|
||||
<SettingsSection
|
||||
id="scopes"
|
||||
title="Domains and groups"
|
||||
description="Each entry gives the users of some domains or the members of some groups their own settings. Pick one at the top of the other Organisation pages to change its settings; everything it doesn't change comes from Everyone. When several apply, domains come first, then groups, each in the order of this list, and the later one wins."
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 text-xs" onClick={() => addScope('domain')}>
|
||||
<Plus className="h-3.5 w-3.5" /> Add domains
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 text-xs" onClick={() => addScope('group')}>
|
||||
<Plus className="h-3.5 w-3.5" /> Add groups
|
||||
</Button>
|
||||
</div>
|
||||
{draft.scopes.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">None yet: every user gets Everyone's settings.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{draft.scopes.map((scope, index) => (
|
||||
<ScopeCard key={scope.id} draft={draft} scope={scope} index={index} count={draft.scopes.length} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{draft.scopeId !== EVERYONE && draft.scope && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Selected for editing: {draft.scope.label} ({scopeMatchSummary(draft.scope)}).
|
||||
</p>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="effective"
|
||||
title="Effective policy"
|
||||
description="What a given user gets, and which entry set each value. Uses the settings on this page, saved or not."
|
||||
>
|
||||
<EffectivePolicy draft={draft} />
|
||||
</SettingsSection>
|
||||
|
||||
<PolicyFileSection draft={draft} />
|
||||
</OrgPage>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { SettingsPolicy, FeatureGates, PushRelayOption, AdminSidebarApp } f
|
||||
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { adminPageDef } from '@/lib/admin/admin-nav';
|
||||
import { restrictionKeysForPage } from '@/lib/admin/restriction-keys';
|
||||
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
|
||||
import { DEFAULT_SIDEBAR_APP_ID_PREFIX, MAX_DEFAULT_SIDEBAR_APPS } from '@/lib/sidebar-apps';
|
||||
import { generateUUID, cn } from '@/lib/utils';
|
||||
@@ -54,28 +55,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
||||
tagsAboveSharedEnabled: { label: 'Tags Above Shared Mailboxes', description: 'List the Tags in the sidebar above the shared mailboxes instead of below them. Turn this on when users have many shared mailboxes and work with tags: expanded, the shared mailboxes push the tags out of sight.' },
|
||||
};
|
||||
|
||||
const RESTRICTABLE_SETTINGS = [
|
||||
{ key: 'fontSize', label: 'Font Size', category: 'Appearance', type: 'enum', allowedValues: ['small', 'medium', 'large'] },
|
||||
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
|
||||
{ key: 'interfaceLayout', label: 'Interface Layout', category: 'Appearance', type: 'enum', allowedValues: ['default', 'mountain-view'] },
|
||||
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
|
||||
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] },
|
||||
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
||||
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
|
||||
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
||||
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
|
||||
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
|
||||
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
|
||||
{ key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
|
||||
{ key: 'replyIdentityMatch', label: 'Reply Identity Matching', category: 'Composer', type: 'enum', allowedValues: ['exact', 'domain'] },
|
||||
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
|
||||
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
|
||||
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
|
||||
{ key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean' },
|
||||
{ key: 'pushNotifyInboxOnly', label: 'Inbox-only Push Notifications', category: 'Notifications', type: 'boolean' },
|
||||
{ key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' },
|
||||
];
|
||||
const RESTRICTABLE_SETTINGS = restrictionKeysForPage('policy');
|
||||
|
||||
const PAGE = adminPageDef('policy');
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
interface TelemetryStatus {
|
||||
consent: 'pending' | 'on' | 'off';
|
||||
consentSource: 'env' | 'file';
|
||||
consentSource: 'no-third-party' | 'env' | 'file';
|
||||
endpoint: string;
|
||||
defaultEndpoint: string;
|
||||
consentedAt: string | null;
|
||||
@@ -141,7 +141,8 @@ export function TelemetryPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const envOverridden = status.consentSource === 'env';
|
||||
const noThirdParty = status.consentSource === 'no-third-party';
|
||||
const envOverridden = status.consentSource === 'env' || noThirdParty;
|
||||
const isOn = status.consent === 'on';
|
||||
const shownOn = (consentDraft ?? status.consent) === 'on';
|
||||
|
||||
@@ -149,7 +150,8 @@ export function TelemetryPage() {
|
||||
status.consent === 'pending' && 'Initialising - no heartbeats sent yet.',
|
||||
status.consent === 'on' && 'Heartbeats are enabled. Thanks for helping us improve!',
|
||||
status.consent === 'off' && 'Heartbeats are off (default).',
|
||||
envOverridden && 'Locked by the BULWARK_TELEMETRY env var.',
|
||||
noThirdParty && 'Locked off: no third-party traffic is on (Organisation > Security).',
|
||||
envOverridden && !noThirdParty && 'Locked by the BULWARK_TELEMETRY env var.',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
|
||||
@@ -24,6 +24,7 @@ interface VersionAdminStatus {
|
||||
endpoint: string;
|
||||
defaultEndpoint: string;
|
||||
disabledByEnv: boolean;
|
||||
disabledByNoThirdPartyTraffic?: boolean;
|
||||
lastCheckedAt: string | null;
|
||||
lastSuccessAt: string | null;
|
||||
nextScheduledAt: string | null;
|
||||
@@ -205,6 +206,11 @@ export function VersionPage() {
|
||||
{data.disabledByEnv ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</SettingItem>
|
||||
{data.disabledByNoThirdPartyTraffic && (
|
||||
<SettingItem label="Off: no third-party traffic" description="NO_THIRD_PARTY_TRAFFIC is on, so this server never asks the version endpoint. See Organisation > Security.">
|
||||
<span className="text-sm font-medium text-amber-600 dark:text-amber-400">Yes</span>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// The whole user policy goes out and comes back as one policy.json. An
|
||||
// import is previewed first and goes through the same sanitising as the
|
||||
// file on disk.
|
||||
|
||||
const audit = vi.hoisted(() => [] as Array<{ action: string; detail: Record<string, unknown> }>);
|
||||
vi.mock('@/lib/admin/session', () => ({
|
||||
requireAdminAuth: async () => ({ payload: { role: 'admin' } }),
|
||||
getClientIP: () => '127.0.0.1',
|
||||
}));
|
||||
vi.mock('@/lib/admin/audit', () => ({
|
||||
auditLog: async (action: string, detail: Record<string, unknown>) => { audit.push({ action, detail }); },
|
||||
}));
|
||||
|
||||
import { GET, POST } from '@/app/api/admin/policy/file/route';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
let dir: string;
|
||||
const url = (query = '') => `https://mail.example/api/admin/policy/file${query}`;
|
||||
const post = (body: string, query = '') => POST(new NextRequest(url(query), { method: 'POST', body }));
|
||||
const stored = async () => JSON.parse(await readFile(path.join(dir, 'policy.json'), 'utf-8'));
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), 'bw-policy-file-'));
|
||||
process.env.ADMIN_CONFIG_DIR = dir;
|
||||
audit.length = 0;
|
||||
await writeFile(path.join(dir, 'policy.json'), JSON.stringify({
|
||||
restrictions: { density: { locked: true } },
|
||||
organisation: { session: { idleMinutes: 30 } },
|
||||
scopes: [{ id: 'team', label: 'Team', match: { groups: ['team@example.org'] }, policy: {} }],
|
||||
}));
|
||||
await configManager.reload();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.ADMIN_CONFIG_DIR;
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('policy file', () => {
|
||||
it('exports the stored policy as a dated download and logs it', async () => {
|
||||
const res = await GET(new NextRequest(url()));
|
||||
expect(res.headers.get('content-disposition')).toMatch(/^attachment; filename="bulwark-policy-\d{4}-\d{2}-\d{2}\.json"$/);
|
||||
const body = JSON.parse(await res.text());
|
||||
expect(body.scopes.map((s: { id: string }) => s.id)).toEqual(['team']);
|
||||
expect(body.organisation.session.idleMinutes).toBe(30);
|
||||
expect(audit.map((a) => a.action)).toEqual(['policy.export']);
|
||||
});
|
||||
|
||||
it('previews an import without writing anything', async () => {
|
||||
const file = JSON.stringify({
|
||||
restrictions: {},
|
||||
organisation: { session: { idleMinutes: 10 }, signature: { html: '<p>{name}</p>' } },
|
||||
scopes: [{ id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: {} }],
|
||||
});
|
||||
const res = await post(file, '?preview=1');
|
||||
const { summary } = await res.json();
|
||||
expect(summary.scopes).toEqual({ added: ['Two (two)'], changed: [], removed: ['Team (team)'] });
|
||||
expect(summary.restrictions.removed).toEqual(['density']);
|
||||
expect(summary.organisation.changed.sort()).toEqual(['session', 'signature']);
|
||||
expect((await stored()).organisation.session.idleMinutes).toBe(30);
|
||||
expect(audit).toEqual([]);
|
||||
});
|
||||
|
||||
it('imports the sanitised policy and logs it', async () => {
|
||||
const res = await post(JSON.stringify({
|
||||
organisation: { session: { idleMinutes: 'soon' } },
|
||||
scopes: [{ id: 'Bad id!' }, { id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: {} }],
|
||||
}));
|
||||
expect(res.status).toBe(200);
|
||||
const policy = await stored();
|
||||
expect(policy.scopes.map((s: { id: string }) => s.id)).toEqual(['two']);
|
||||
expect(policy.organisation.session.idleMinutes).toBe(0);
|
||||
expect(configManager.getPolicy().scopes?.map((s) => s.id)).toEqual(['two']);
|
||||
expect(audit.map((a) => a.action)).toEqual(['policy.import']);
|
||||
});
|
||||
|
||||
it('refuses what isn\'t a policy object', async () => {
|
||||
expect((await post('{nope')).status).toBe(400);
|
||||
expect((await post('[1,2]')).status).toBe(400);
|
||||
expect((await stored()).scopes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// The policy route hands each browser only the scopes of the domains it is
|
||||
// signed in with, so one organisation on a shared instance never sees
|
||||
// another's settings.
|
||||
|
||||
const who = vi.hoisted(() => ({
|
||||
signedIn: true,
|
||||
identity: null as null | { admin: true } | { admin: false; addresses: string[] },
|
||||
}));
|
||||
vi.mock('@/lib/auth/instance-details', () => ({
|
||||
canSeeInstanceDetails: async () => who.signedIn,
|
||||
signedInIdentity: async () => who.identity,
|
||||
}));
|
||||
|
||||
const SCOPES = [
|
||||
{ id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: { features: { calendarEnabled: false } } },
|
||||
{ id: 'team', label: 'Team', match: { groups: ['org-team@example.org'] }, policy: {} },
|
||||
];
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: async () => {},
|
||||
getPolicy: () => ({
|
||||
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
|
||||
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
|
||||
organisation: { disclaimer: { html: '<p>Acme GmbH</p>', text: 'Acme GmbH' } },
|
||||
scopes: SCOPES,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } }));
|
||||
vi.mock('@/lib/admin/session', () => ({ requireAdminAuth: vi.fn(), getClientIP: () => '0.0.0.0' }));
|
||||
vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn() }));
|
||||
|
||||
import { GET } from '@/app/api/admin/policy/route';
|
||||
|
||||
const get = async () => (await GET(new NextRequest('https://mail.example/api/admin/policy'))).json();
|
||||
|
||||
beforeEach(() => {
|
||||
who.signedIn = true;
|
||||
who.identity = null;
|
||||
});
|
||||
|
||||
describe('GET /api/admin/policy scopes', () => {
|
||||
it('gives visitors who are not signed in no scopes and no organisation settings', async () => {
|
||||
who.signedIn = false;
|
||||
const body = await get();
|
||||
expect(body.scopes).toEqual([]);
|
||||
expect(body.organisation).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gives a signed-in user only the scopes of their domains', async () => {
|
||||
who.identity = { admin: false, addresses: ['org-dave@org-two.test'] };
|
||||
expect((await get()).scopes.map((s: { id: string }) => s.id)).toEqual(['two']);
|
||||
who.identity = { admin: false, addresses: ['org-alice@example.org'] };
|
||||
const body = await get();
|
||||
expect(body.scopes.map((s: { id: string }) => s.id)).toEqual(['team']);
|
||||
expect(body.organisation.disclaimer.text).toBe('Acme GmbH');
|
||||
});
|
||||
|
||||
it('says which domains the scopes were picked for, and nothing when all are sent', async () => {
|
||||
who.identity = { admin: false, addresses: ['org-alice@example.org', 'org-dave@org-two.test'] };
|
||||
const res = await GET(new NextRequest('https://mail.example/api/admin/policy'));
|
||||
expect(res.headers.get('x-bulwark-policy-domains')).toBe('example.org,org-two.test');
|
||||
who.identity = { admin: true };
|
||||
expect((await GET(new NextRequest('https://mail.example/api/admin/policy'))).headers.get('x-bulwark-policy-domains')).toBeNull();
|
||||
});
|
||||
|
||||
it('gives the admin every scope', async () => {
|
||||
who.identity = { admin: true };
|
||||
expect((await get()).scopes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('gives every scope when the server can\'t tell who is asking', async () => {
|
||||
who.identity = null;
|
||||
expect((await get()).scopes).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { logger } from '@/lib/logger';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
|
||||
import { checkExtensionDirectoryUrl } from '@/lib/admin/directory-url';
|
||||
import { parseNoThirdPartyEnv } from '@/lib/third-party-mode';
|
||||
|
||||
// Strings that count as "no real secret configured" - used so the dashboard
|
||||
// can warn about a placeholder session secret without us ever returning the
|
||||
@@ -99,6 +100,17 @@ export async function PATCH(request: NextRequest) {
|
||||
if (problem) return NextResponse.json({ error: problem }, { status: 400 });
|
||||
}
|
||||
|
||||
// The no-third-party switch is fail-closed: an "on" from the environment
|
||||
// can't be undone from the panel (lib/admin/third-party-mode.ts).
|
||||
if ('noThirdPartyTraffic' in updates) {
|
||||
if (typeof updates.noThirdPartyTraffic !== 'boolean') {
|
||||
return NextResponse.json({ error: 'noThirdPartyTraffic must be true or false' }, { status: 400 });
|
||||
}
|
||||
if (!updates.noThirdPartyTraffic && parseNoThirdPartyEnv(process.env.NO_THIRD_PARTY_TRAFFIC)) {
|
||||
return NextResponse.json({ error: 'NO_THIRD_PARTY_TRAFFIC is set in the environment; change it there.' }, { status: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
// Enum keys only accept their declared values; a stray string would
|
||||
// otherwise be persisted and fail closed (or open) at the read site.
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
directoryJson,
|
||||
getDirectoryChannel,
|
||||
getDirectoryUrl,
|
||||
directoryOff,
|
||||
DIRECTORY_OFF_MESSAGE,
|
||||
historyFor,
|
||||
installedExtensions,
|
||||
installedStateOf,
|
||||
@@ -65,6 +67,9 @@ export async function GET(
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
if (await directoryOff()) {
|
||||
return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { slug } = await params;
|
||||
const requested = request.nextUrl.searchParams.get('version');
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
directoryJson,
|
||||
getDirectoryChannel,
|
||||
getDirectoryUrl,
|
||||
directoryOff,
|
||||
DIRECTORY_OFF_MESSAGE,
|
||||
installedExtensions,
|
||||
installedStateOf,
|
||||
readBundleBytes,
|
||||
@@ -57,6 +59,9 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
if (await directoryOff()) {
|
||||
return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 });
|
||||
}
|
||||
|
||||
const directoryUrl = await getDirectoryUrl();
|
||||
const query: Record<string, string | undefined> = {};
|
||||
@@ -105,6 +110,9 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
if (await directoryOff()) {
|
||||
return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const { slug, version, type, force } = await request.json();
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
directoryJson,
|
||||
getDirectoryChannel,
|
||||
getDirectoryUrl,
|
||||
directoryOff,
|
||||
DIRECTORY_OFF_MESSAGE,
|
||||
installedExtensions,
|
||||
v2Url,
|
||||
type DirectoryAdvisory,
|
||||
@@ -43,6 +45,13 @@ export async function GET(request: NextRequest) {
|
||||
const list = [...installed].map(([id, e]) => ({ id, ...e }));
|
||||
const ask = list.filter((e) => askDirectoryAbout(e.source)).map((e) => ({ slug: e.id, version: e.version }));
|
||||
|
||||
// The installed list is still useful with the official directory off (#903).
|
||||
if (await directoryOff()) {
|
||||
return NextResponse.json(
|
||||
{ installed: list, ...EMPTY, directoryUrl: null, channel: getDirectoryChannel(), appVersion: APP_VERSION || null, error: DIRECTORY_OFF_MESSAGE },
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
const directoryUrl = await getDirectoryUrl();
|
||||
const channel = getDirectoryChannel();
|
||||
let answer: UpdatesAnswer = EMPTY;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth } from '@/lib/admin/session';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isConfigReadOnly } from '@/lib/admin/paths';
|
||||
import { getPluginRegistry } from '@/lib/admin/plugin-registry';
|
||||
import { noThirdPartyTrafficState } from '@/lib/admin/third-party-mode';
|
||||
import { listReachableHosts } from '@/lib/admin/reachable-hosts';
|
||||
import { pluginDeclaresNetwork } from '@/lib/outgoing-hosts';
|
||||
import { OUTGOING_HOSTS } from '@/lib/admin/outgoing-hosts-registry';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* GET /api/admin/outgoing-hosts - the "no third-party traffic" switch with
|
||||
* where its value comes from, the built-in outside hosts it turns off, the
|
||||
* admin-configured hosts that stay reachable, and the installed plugins it
|
||||
* refuses (#903). Organisation > Security renders this; the switch itself is
|
||||
* saved through PATCH /api/admin/config like every other config key.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
const state = noThirdPartyTrafficState();
|
||||
const policy = configManager.getPolicy();
|
||||
const registry = await getPluginRegistry().catch(() => ({ plugins: [] }));
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
noThirdPartyTraffic: state.on,
|
||||
source: state.source,
|
||||
// The panel can't turn it off when the environment pins it on, and
|
||||
// can't change it at all on a read-only config.
|
||||
readOnly: isConfigReadOnly(),
|
||||
envLocked: state.source === 'env',
|
||||
builtIn: OUTGOING_HOSTS,
|
||||
reachable: listReachableHosts({ get: (key) => configManager.get(key), policy, env: process.env }),
|
||||
networkPlugins: registry.plugins
|
||||
.filter((plugin) => plugin.enabled && pluginDeclaresNetwork(plugin))
|
||||
.map((plugin) => ({ id: plugin.id, name: plugin.name })),
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Outgoing hosts read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { completePolicy, configManager } from '@/lib/admin/config-manager';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { summarizePolicyChange } from '@/lib/admin/policy-diff';
|
||||
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
|
||||
/** Largest policy file an import accepts. */
|
||||
const MAX_IMPORT_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* GET /api/admin/policy/file - the whole user policy as a policy.json
|
||||
* download: the same file the server reads, so it can go into git, into
|
||||
* another instance's ADMIN_CONFIG_DIR or next to a Lite build.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
await configManager.ensureLoaded();
|
||||
const policy = configManager.getPolicy();
|
||||
await auditLog('policy.export', { scopeCount: policy.scopes?.length ?? 0 }, getClientIP(request));
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
return new NextResponse(`${JSON.stringify(policy, null, 2)}\n`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="bulwark-policy-${date}.json"`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/policy/file - import a policy file. With `?preview=1` it
|
||||
* only says what would change; otherwise it replaces the policy. The file
|
||||
* goes through the same defaults and sanitising as policy.json on load, and
|
||||
* the preview shows the result of that, not the raw file.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
try {
|
||||
const text = await request.text();
|
||||
if (text.length > MAX_IMPORT_BYTES) {
|
||||
return NextResponse.json({ error: 'The file is larger than 2 MB' }, { status: 413 });
|
||||
}
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'The file is not valid JSON' }, { status: 400 });
|
||||
}
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return NextResponse.json({ error: 'The file does not hold a policy object' }, { status: 400 });
|
||||
}
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
const next = completePolicy(raw as Partial<SettingsPolicy>);
|
||||
const summary = summarizePolicyChange(configManager.getPolicy(), next);
|
||||
if (request.nextUrl.searchParams.get('preview') === '1') {
|
||||
return NextResponse.json({ summary });
|
||||
}
|
||||
|
||||
await configManager.setPolicy(next);
|
||||
await auditLog('policy.import', {
|
||||
scopeCount: next.scopes?.length ?? 0,
|
||||
scopesAdded: summary.scopes.added.length,
|
||||
scopesRemoved: summary.scopes.removed.length,
|
||||
restrictionsChanged: summary.restrictions.added.length + summary.restrictions.changed.length + summary.restrictions.removed.length,
|
||||
}, getClientIP(request));
|
||||
return NextResponse.json({ ok: true, summary });
|
||||
} catch (error) {
|
||||
const readOnly = configReadOnlyResponse(error);
|
||||
if (readOnly) return readOnly;
|
||||
logger.error('Policy import error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,9 @@ import { configManager } from '@/lib/admin/config-manager';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { POLICY_SCOPE_HEADER, type SettingsPolicy } from '@/lib/admin/types';
|
||||
import { canSeeInstanceDetails } from '@/lib/auth/instance-details';
|
||||
import { POLICY_DOMAINS_HEADER, POLICY_SCOPE_HEADER, type SettingsPolicy } from '@/lib/admin/types';
|
||||
import { canSeeInstanceDetails, signedInIdentity } from '@/lib/auth/instance-details';
|
||||
import { scopesVisibleTo } from '@/lib/admin/policy-scopes';
|
||||
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
|
||||
|
||||
/**
|
||||
@@ -28,12 +29,30 @@ export async function GET(request: NextRequest) {
|
||||
pushRelayUrl: '',
|
||||
pushRelayUrlLocked: false,
|
||||
defaultSidebarApps: [],
|
||||
// Signatures, domain lists and scopes describe the organisation
|
||||
// and only apply once someone is signed in.
|
||||
organisation: undefined,
|
||||
scopes: [],
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store', [POLICY_SCOPE_HEADER]: 'public' } },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(policy, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
// Users get only the scopes for their own domains, so on an instance
|
||||
// shared by several organisations one doesn't see another's settings.
|
||||
// Without a session secret the server can't tell who is asking and
|
||||
// answers with all of them (documented).
|
||||
const identity = await signedInIdentity();
|
||||
const filtered = identity !== null && !identity.admin;
|
||||
const scopes = filtered
|
||||
? scopesVisibleTo(policy.scopes ?? [], identity.addresses)
|
||||
: policy.scopes ?? [];
|
||||
// Which domains the scopes were picked for, so a browser that signs in
|
||||
// to an account of another domain knows to fetch again.
|
||||
const domains = filtered
|
||||
? [...new Set(identity.addresses.map((a) => a.slice(a.lastIndexOf('@') + 1)).filter(Boolean))].join(',')
|
||||
: null;
|
||||
return NextResponse.json({ ...policy, scopes }, {
|
||||
headers: { 'Cache-Control': 'no-store', ...(domains !== null ? { [POLICY_DOMAINS_HEADER]: domains } : {}) },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Policy read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
@@ -72,6 +91,12 @@ export async function PUT(request: NextRequest) {
|
||||
if (policy.defaultSidebarApps !== undefined && !Array.isArray(policy.defaultSidebarApps)) {
|
||||
return NextResponse.json({ error: 'defaultSidebarApps must be an array' }, { status: 400 });
|
||||
}
|
||||
if (policy.scopes !== undefined && !Array.isArray(policy.scopes)) {
|
||||
return NextResponse.json({ error: 'scopes must be an array' }, { status: 400 });
|
||||
}
|
||||
if (policy.organisation !== undefined && (typeof policy.organisation !== 'object' || policy.organisation === null || Array.isArray(policy.organisation))) {
|
||||
return NextResponse.json({ error: 'organisation must be an object' }, { status: 400 });
|
||||
}
|
||||
|
||||
await configManager.setPolicy(policy);
|
||||
await auditLog('policy.update', {
|
||||
|
||||
@@ -81,6 +81,12 @@ export async function POST(request: NextRequest) {
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (source === 'no-third-party') {
|
||||
return NextResponse.json(
|
||||
{ error: 'telemetry is off while NO_THIRD_PARTY_TRAFFIC is on' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (body.consent !== 'on' && body.consent !== 'off') {
|
||||
return NextResponse.json({ error: 'consent must be "on" or "off"' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
disabledByEnv,
|
||||
DEFAULT_VERSION_ENDPOINT,
|
||||
} from '@/lib/version-check';
|
||||
import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
|
||||
|
||||
/**
|
||||
* GET /api/admin/version
|
||||
@@ -27,6 +28,7 @@ export async function GET(request: NextRequest) {
|
||||
endpoint: effectiveEndpoint(state),
|
||||
defaultEndpoint: DEFAULT_VERSION_ENDPOINT,
|
||||
disabledByEnv: disabledByEnv(),
|
||||
disabledByNoThirdPartyTraffic: await isNoThirdPartyTrafficOn(),
|
||||
lastCheckedAt: state.lastCheckedAt,
|
||||
lastSuccessAt: state.lastSuccessAt,
|
||||
nextScheduledAt: state.nextScheduledAt,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { revokeWopiTokens } from '@/lib/wopi/revocation';
|
||||
import { clearPairReauthInStore } from '@/lib/auth/pair-reauth';
|
||||
import { loginClientAddress } from '@/lib/auth/client-address';
|
||||
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
|
||||
import { isPastMaxAge, limitsEnforcedSince, maxSessionHoursFor, SESSION_TOO_OLD } from '@/lib/auth/session-age';
|
||||
|
||||
function sessionCookieOptions() {
|
||||
return {
|
||||
@@ -73,6 +74,37 @@ function getSlot(request: NextRequest): number {
|
||||
return slot;
|
||||
}
|
||||
|
||||
/**
|
||||
* The organisation's maximum session length for a remembered session
|
||||
* (lib/auth/session-age.ts). A cookie from before sign-in times were
|
||||
* recorded counts as signed in when the server first enforced the limits,
|
||||
* a fixed time, so a kept copy of it runs out like any other. Returns the
|
||||
* sign-in time, or the limit when the session is too old: its cookies are
|
||||
* cleared then.
|
||||
*/
|
||||
async function checkSessionAge(
|
||||
cookieStore: Awaited<ReturnType<typeof cookies>>,
|
||||
slot: number,
|
||||
credentials: SessionCredentials,
|
||||
): Promise<{ signedInAt: number } | { maxHours: number }> {
|
||||
const signedInAt = credentials.signedInAt ?? await limitsEnforcedSince();
|
||||
// An impersonation login is `target%master`; the policy is the target's.
|
||||
const maxHours = await maxSessionHoursFor(credentials.username.split('%', 1)[0]);
|
||||
if (isPastMaxAge(signedInAt, maxHours)) {
|
||||
cookieStore.delete(sessionCookieName(slot));
|
||||
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||
return { maxHours };
|
||||
}
|
||||
return { signedInAt };
|
||||
}
|
||||
|
||||
function sessionTooOld(maxHours: number) {
|
||||
return NextResponse.json(
|
||||
{ error: SESSION_TOO_OLD, maxHours },
|
||||
{ status: 401, headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax,
|
||||
// so a cross-site top-level POST would otherwise reach this handler.
|
||||
@@ -162,7 +194,9 @@ export async function POST(request: NextRequest) {
|
||||
throw error;
|
||||
}
|
||||
refund();
|
||||
const token = encryptSession(normalizedServerUrl, username, accessToken ? { token: accessToken } : password);
|
||||
// A remembered sign-in starts the maximum session length.
|
||||
const signedInAt = Date.now();
|
||||
const token = encryptSession(normalizedServerUrl, username, accessToken ? { token: accessToken } : password, signedInAt);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(cookieName, token, sessionCookieOptions());
|
||||
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||
@@ -176,7 +210,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const warning = insecureCookieHint(request);
|
||||
if (warning) logger.warn(`session: ${warning}`);
|
||||
return NextResponse.json(warning ? { ok: true, warning } : { ok: true });
|
||||
// The browser counts the maximum session length from the server's time.
|
||||
return NextResponse.json(warning ? { ok: true, warning, signedInAt } : { ok: true, signedInAt });
|
||||
} catch (error) {
|
||||
if (error instanceof JmapAuthVerificationError) {
|
||||
return NextResponse.json(
|
||||
@@ -202,12 +237,15 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const credentials = decryptSession(token);
|
||||
if (!credentials) {
|
||||
if (!credentials || holdsMasterPassword(credentials)) {
|
||||
cookieStore.delete(cookieName);
|
||||
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||
}
|
||||
|
||||
const age = await checkSessionAge(cookieStore, slot, credentials);
|
||||
if ('maxHours' in age) return sessionTooOld(age.maxHours);
|
||||
|
||||
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||
serverUrl: credentials.serverUrl,
|
||||
username: credentials.username,
|
||||
@@ -217,7 +255,7 @@ export async function GET(request: NextRequest) {
|
||||
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
|
||||
const { serverUrl, username } = credentials;
|
||||
return NextResponse.json(
|
||||
{ serverUrl, username },
|
||||
{ serverUrl, username, signedInAt: age.signedInAt },
|
||||
{ headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -258,13 +296,17 @@ export async function PUT(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||
}
|
||||
|
||||
const age = await checkSessionAge(cookieStore, slot, credentials);
|
||||
if ('maxHours' in age) return sessionTooOld(age.maxHours);
|
||||
|
||||
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||
serverUrl: credentials.serverUrl,
|
||||
username: credentials.username,
|
||||
authHeader: sessionAuthHeader(credentials),
|
||||
});
|
||||
|
||||
return NextResponse.json(credentials, {
|
||||
// The browser counts the maximum session length from the server's time.
|
||||
return NextResponse.json({ ...credentials, signedInAt: age.signedInAt }, {
|
||||
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { storeIdToken } from '@/lib/oauth/end-session';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { writeSignInStamp } from '@/lib/auth/session-age';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
|
||||
@@ -105,6 +106,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
|
||||
const signedInAt = Date.now();
|
||||
|
||||
// For the mobile handoff flow the tokens are handed back to the app
|
||||
// verbatim - we deliberately don't write any cookies on the webmail
|
||||
@@ -115,6 +117,8 @@ export async function POST(request: NextRequest) {
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
// A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
|
||||
writeSignInStamp(cookieStore, slot, { signedInAt, refreshToken: tokens.refresh_token });
|
||||
}
|
||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||
if (pendingServerId) {
|
||||
@@ -166,6 +170,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in,
|
||||
signed_in_at: signedInAt,
|
||||
});
|
||||
} catch (error) {
|
||||
// Clean up pending cookie on any error
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { JmapAuthVerificationError, resolveJmapIdentity } from '@/lib/auth/verify-jmap-auth';
|
||||
import { cookies } from 'next/headers';
|
||||
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||
import { readSignInStamp, writeSignInStamp } from '@/lib/auth/session-age';
|
||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
@@ -101,6 +104,16 @@ export async function POST(request: NextRequest) {
|
||||
// Just signed in: an older refusal of this credential no longer applies.
|
||||
recordStoredCredentialStatus(authHeader, 200);
|
||||
|
||||
// A token sign-in's stamp (lib/auth/session-age.ts) knows no account:
|
||||
// the token routes never see one. The first verified account names it,
|
||||
// so the maximum session length of its domain applies on renewal.
|
||||
const cookieStore = await cookies();
|
||||
const refreshToken = cookieStore.get(refreshTokenCookieName(slot))?.value;
|
||||
const stamp = refreshToken ? readSignInStamp(cookieStore, slot, refreshToken) : null;
|
||||
if (refreshToken && stamp && !stamp.account) {
|
||||
writeSignInStamp(cookieStore, slot, { signedInAt: stamp.signedInAt, refreshToken, account: accountName });
|
||||
}
|
||||
|
||||
await setStalwartAuthContext(slot, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
username,
|
||||
|
||||
@@ -30,6 +30,15 @@ import {
|
||||
} from '@/lib/oauth/end-session';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import {
|
||||
clearSignInStamp,
|
||||
isPastMaxAge,
|
||||
maxSessionHoursFor,
|
||||
SESSION_TOO_OLD,
|
||||
tokenSignedInAt,
|
||||
writeSignInStamp,
|
||||
} from '@/lib/auth/session-age';
|
||||
import { clearStalwartAuthContextInStore, readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
|
||||
function parseSlot(raw: string | null): number | null {
|
||||
if (raw === null) return null;
|
||||
@@ -142,16 +151,21 @@ export async function POST(request: NextRequest) {
|
||||
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||
|
||||
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId);
|
||||
// A sign-in: the maximum session length counts from here, on the server
|
||||
// and, from this answer, in the browser.
|
||||
const signedInAt = Date.now();
|
||||
|
||||
const response = NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in,
|
||||
signed_in_at: signedInAt,
|
||||
});
|
||||
|
||||
const cookieStore = await cookies();
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
writeSignInStamp(cookieStore, slot, { signedInAt, refreshToken: tokens.refresh_token });
|
||||
}
|
||||
const cachedAccessToken = cacheAccessToken(cookieStore, slot, tokens.access_token, tokens.expires_in || 3600, [tokens.refresh_token, serverId]);
|
||||
storeIdToken(cookieStore, slot, tokens.id_token, request.nextUrl.basePath, [tokens.refresh_token, cachedAccessToken, serverId]);
|
||||
@@ -189,6 +203,25 @@ export async function PUT(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
|
||||
}
|
||||
|
||||
// The organisation's maximum session length (lib/auth/session-age.ts).
|
||||
// A refresh token without a matching stamp (from before stamps, or one
|
||||
// whose stamp was deleted) counts from the fixed time the server first
|
||||
// enforced the limits, never from now.
|
||||
const age = await tokenSignedInAt(cookieStore, slot, refreshToken);
|
||||
if (age) {
|
||||
const context = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
const account = age.stamp?.account ?? context?.accountName ?? context?.username ?? null;
|
||||
const maxHours = await maxSessionHoursFor(account);
|
||||
if (isPastMaxAge(age.signedInAt, maxHours)) {
|
||||
await signOutSlot(cookieStore, slot, request.nextUrl.basePath, false);
|
||||
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||
return NextResponse.json({ error: SESSION_TOO_OLD, maxHours }, { status: 401 });
|
||||
}
|
||||
// Bind the time to this token from now on.
|
||||
if (!age.stamp) writeSignInStamp(cookieStore, slot, { signedInAt: age.signedInAt, refreshToken, ...(account ? { account } : {}) });
|
||||
}
|
||||
const signedIn = age ? { signed_in_at: age.signedInAt } : {};
|
||||
|
||||
// A session restore calls this to get its token back, not because the
|
||||
// current one expired. Serving the cached token avoids spending a refresh
|
||||
// the IdP may legitimately reject: Rauthy stamps refresh tokens with
|
||||
@@ -204,6 +237,7 @@ export async function PUT(request: NextRequest) {
|
||||
return NextResponse.json({
|
||||
access_token: cached.accessToken,
|
||||
expires_in: cached.expiresIn,
|
||||
...signedIn,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -252,6 +286,14 @@ export async function PUT(request: NextRequest) {
|
||||
|
||||
if (tokens.refresh_token) {
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
// The stamp follows the rotated token, with the same sign-in time and lifetime.
|
||||
if (age) {
|
||||
writeSignInStamp(cookieStore, slot, {
|
||||
signedInAt: age.signedInAt,
|
||||
refreshToken: tokens.refresh_token,
|
||||
...(age.stamp?.account ? { account: age.stamp.account } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const expiresIn = tokens.expires_in || 3600;
|
||||
@@ -265,6 +307,7 @@ export async function PUT(request: NextRequest) {
|
||||
return NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: expiresIn,
|
||||
...signedIn,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Token refresh error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
@@ -307,6 +350,7 @@ async function signOutSlot(
|
||||
for (const name of [cookieName, refreshTokenServerCookieName(slot), accessTokenCookieName(slot)]) {
|
||||
if (cookieStore.get(name)) cookieStore.delete(name);
|
||||
}
|
||||
clearSignInStamp(cookieStore, slot);
|
||||
if (idToken !== undefined) clearIdToken(cookieStore, slot, basePath);
|
||||
return endSessionUrl;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { stalwartPasswordLogin } from '@/lib/auth/stalwart-password-login';
|
||||
import { loginClientAddress } from '@/lib/auth/client-address';
|
||||
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
|
||||
import { writeSignInStamp } from '@/lib/auth/session-age';
|
||||
|
||||
/**
|
||||
* Exchange a password + (optional) TOTP code for OAuth tokens.
|
||||
@@ -208,11 +209,15 @@ async function storeAndRespond(
|
||||
rememberMe: boolean,
|
||||
): Promise<NextResponse> {
|
||||
const cookieStore = await cookies();
|
||||
// A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
|
||||
const signedInAt = Date.now();
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
// Without "Remember me" the sign-in ends with the browser session.
|
||||
const { maxAge: _maxAge, ...sessionOptions } = getCookieOptions();
|
||||
cookieStore.set(cookieName, tokens.refresh_token, rememberMe ? getCookieOptions() : sessionOptions);
|
||||
const options = rememberMe ? getCookieOptions() : sessionOptions;
|
||||
cookieStore.set(cookieName, tokens.refresh_token, options);
|
||||
writeSignInStamp(cookieStore, slot, { signedInAt, refreshToken: tokens.refresh_token }, options);
|
||||
}
|
||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||
if (serverId) {
|
||||
@@ -225,5 +230,6 @@ async function storeAndRespond(
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in || 3600,
|
||||
has_refresh_token: !!tokens.refresh_token,
|
||||
signed_in_at: signedInAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
pickRequestHost,
|
||||
type BrandingOverrideKey,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
|
||||
|
||||
/**
|
||||
* Runtime configuration endpoint
|
||||
@@ -98,6 +99,8 @@ export async function GET(request: NextRequest) {
|
||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||
sourceCodeUrl: httpUrlOrEmpty(configManager.get<string>('sourceCodeUrl', '')),
|
||||
// Browser-side switches of lib/outgoing-hosts.ts read this (#903).
|
||||
noThirdPartyTraffic: await isNoThirdPartyTrafficOn(),
|
||||
},
|
||||
{
|
||||
// Runtime settings can change without new asset URLs. Prevent stale
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
|
||||
import { TARGET_NAMES } from '@/lib/connector/registry';
|
||||
|
||||
/**
|
||||
@@ -28,7 +29,7 @@ import { TARGET_NAMES } from '@/lib/connector/registry';
|
||||
export async function GET() {
|
||||
await configManager.ensureLoaded();
|
||||
|
||||
if (!configManager.get<boolean>('connectorEnabled', true)) {
|
||||
if (!configManager.get<boolean>('connectorEnabled', true) || await isNoThirdPartyTrafficOn()) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createDemoFileNodes } from '@/lib/demo/fixtures/files';
|
||||
import { generateDemoId } from '@/lib/demo/demo-utils';
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
import JSZip from 'jszip';
|
||||
import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
|
||||
|
||||
/**
|
||||
* Mock JMAP server for local development.
|
||||
@@ -2035,9 +2036,14 @@ function handleVacationResponseGet(_args: MethodArgs, callId: string): MethodRes
|
||||
return ['VacationResponse/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ id: 'vacation-1', isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null }], notFound: [] }, callId];
|
||||
}
|
||||
|
||||
// The sample photos come from randomuser.me: none while third-party traffic
|
||||
// is off (#903). Set per request in POST, which can await the config.
|
||||
let samplePhotos = true;
|
||||
|
||||
function handleContactCardGet(args: MethodArgs, callId: string): MethodResult {
|
||||
const ids = args.ids as string[] | undefined;
|
||||
const list = ids ? contacts.filter((c) => ids.includes(c.id)) : contacts;
|
||||
const found = ids ? contacts.filter((c) => ids.includes(c.id)) : contacts;
|
||||
const list = samplePhotos ? found : found.map(({ media: _media, ...rest }) => rest);
|
||||
return ['ContactCard/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
|
||||
}
|
||||
|
||||
@@ -2826,6 +2832,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
// JMAP API endpoint
|
||||
if (joined === 'api') {
|
||||
samplePhotos = !(await isNoThirdPartyTrafficOn());
|
||||
try {
|
||||
const body = await request.json();
|
||||
const methodCalls = body.methodCalls as Array<[string, MethodArgs, string]>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode';
|
||||
|
||||
// In-memory LRU cache: domain -> { data, contentType, fetchedAt }
|
||||
const CACHE_MAX_SIZE = 1000;
|
||||
@@ -439,6 +440,10 @@ function evictOldest() {
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
// Sender icons come from DuckDuckGo: off, before anything else, while the
|
||||
// server must not contact outside services (#903).
|
||||
if (await isNoThirdPartyTrafficOn()) return thirdPartyRefusal('Sender icons');
|
||||
|
||||
const domain = request.nextUrl.searchParams.get('domain');
|
||||
|
||||
if (!domain || !isValidDomain(domain)) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { CREDENTIAL_ID_RE } from '@/lib/plugin-secrets/credentials';
|
||||
@@ -41,6 +42,8 @@ export async function POST(request: NextRequest) {
|
||||
if (!configManager.getPolicy().features.pluginsEnabled) {
|
||||
return NextResponse.json({ error: 'Plugins are turned off', code: 'disabled' }, { status: 403 });
|
||||
}
|
||||
// Every proxied request goes to a host outside this deployment (#903).
|
||||
if (await isNoThirdPartyTrafficOn()) return thirdPartyRefusal('The plugin proxy');
|
||||
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) return NextResponse.json({ error: 'Not authenticated', code: 'unauthenticated' }, { status: 401 });
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock('@/lib/stalwart/credentials', () => ({
|
||||
getStalwartCredentials: async () => (signedIn ? { ...claimed, serverUrl: 'https://mail.example.org', authHeader: 'x' } : null),
|
||||
}));
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: { ensureLoaded: async () => {}, getPolicy: () => ({ features: { pluginsEnabled } }) },
|
||||
configManager: { ensureLoaded: async () => {}, get: () => undefined, getPolicy: () => ({ features: { pluginsEnabled } }) },
|
||||
}));
|
||||
vi.mock('@/lib/security/same-origin', async () => {
|
||||
const { NextResponse } = await import('next/server');
|
||||
|
||||
@@ -4,6 +4,8 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry
|
||||
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
|
||||
import { pluginDeclaresNetwork } from '@/lib/outgoing-hosts';
|
||||
|
||||
/**
|
||||
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
|
||||
@@ -32,11 +34,14 @@ export async function GET(request: NextRequest) {
|
||||
// plugin without uninstalling it first.
|
||||
const devIds = new Set(devEntries.map(e => e.plugin.id));
|
||||
const installedEnabled = pluginRegistry.plugins.filter(p => p.enabled && !devIds.has(p.id));
|
||||
// Plugins that declare network access never reach a browser while the
|
||||
// server must stay off outside hosts (#903); the rest run as usual.
|
||||
const noThirdParty = await isNoThirdPartyTrafficOn();
|
||||
|
||||
const plugins = [
|
||||
...devEntries.map(e => ({ ...e.plugin, dev: true })),
|
||||
...installedEnabled.map(p => ({ ...p, dev: false })),
|
||||
].map(p => ({
|
||||
].filter(p => !(noThirdParty && pluginDeclaresNetwork(p))).map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
version: p.version,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { resolvePolicy } from '@/lib/admin/policy-scopes';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { mergeStoredTemplates } from '@/lib/template-merge';
|
||||
@@ -180,7 +181,9 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Enforce admin policy - strip locked settings so users can't override them
|
||||
await configManager.ensureLoaded();
|
||||
const policy = configManager.getPolicy();
|
||||
// With the account's domain scopes applied; group scopes need the
|
||||
// account's session, which only the client has, so the client enforces those.
|
||||
const policy = resolvePolicy(configManager.getPolicy(), { email: accountName, groups: [] }).policy;
|
||||
const filteredSettings = { ...settings };
|
||||
for (const key of Object.keys(filteredSettings)) {
|
||||
const restriction = policy.restrictions[key];
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { readFileEnv } from "@/lib/read-file-env";
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode';
|
||||
|
||||
// Host-side proxy backing the "Translate" plugin (manifest apiPostPaths:
|
||||
// ["/api/translate"]). The plugin slot iframe POSTs { text, target, source,
|
||||
@@ -284,6 +285,10 @@ export async function POST(request: NextRequest) {
|
||||
const target = typeof body.target === 'string' && body.target.trim() ? body.target.trim() : 'en';
|
||||
const source = typeof body.source === 'string' && body.source.trim() ? body.source.trim() : 'auto';
|
||||
const provider: Provider = body.provider === 'libretranslate' ? 'libretranslate' : 'mymemory';
|
||||
// MyMemory is a public service; the admin's own LibreTranslate is not (#903).
|
||||
if (provider === 'mymemory' && await isNoThirdPartyTrafficOn()) {
|
||||
return thirdPartyRefusal('Translation through MyMemory');
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return NextResponse.json({ error: 'No text to translate' }, { status: 400 });
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { listReachableHosts } from '@/lib/admin/reachable-hosts';
|
||||
import { OUTGOING_HOSTS } from '@/lib/admin/outgoing-hosts-registry';
|
||||
import type { OrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
|
||||
|
||||
const apiFetch = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/lib/browser-navigation', async (orig) => ({
|
||||
...(await orig<typeof import('@/lib/browser-navigation')>()),
|
||||
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
||||
}));
|
||||
|
||||
const { ThirdPartySection } = await import('@/components/admin/org/sections/third-party-section');
|
||||
|
||||
const setMessage = vi.fn();
|
||||
const draft = { setMessage } as unknown as OrgPolicyDraft;
|
||||
|
||||
function state(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
noThirdPartyTraffic: false,
|
||||
source: 'default',
|
||||
readOnly: false,
|
||||
envLocked: false,
|
||||
builtIn: OUTGOING_HOSTS,
|
||||
reachable: [{ kind: 'JMAP server', host: 'mail.corp.example', setIn: 'JMAP_SERVER_URL', side: 'both' }],
|
||||
networkPlugins: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function switchOf() {
|
||||
return within(screen.getByTestId('third-party-switch')).getByRole('switch');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
apiFetch.mockReset();
|
||||
setMessage.mockReset();
|
||||
});
|
||||
|
||||
describe('ThirdPartySection', () => {
|
||||
it('saves the switch as config through /api/admin/config', async () => {
|
||||
let current = state();
|
||||
apiFetch.mockImplementation(async (url: string, init?: RequestInit) => {
|
||||
if (url === '/api/admin/config' && init?.method === 'PATCH') {
|
||||
current = state({ noThirdPartyTraffic: true, source: 'admin' });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
return Response.json(current);
|
||||
});
|
||||
render(<ThirdPartySection draft={draft} />);
|
||||
await waitFor(() => expect(switchOf()).toHaveAttribute('aria-checked', 'false'));
|
||||
expect(screen.getByTestId('third-party-scope-note').textContent).toMatch(/whole server, not per domain or group/);
|
||||
expect(screen.getByTestId('third-party-reachable').textContent).toContain('mail.corp.example');
|
||||
|
||||
await act(async () => { fireEvent.click(switchOf()); });
|
||||
expect(apiFetch).toHaveBeenCalledWith('/api/admin/config', expect.objectContaining({
|
||||
method: 'PATCH', body: JSON.stringify({ noThirdPartyTraffic: true }),
|
||||
}));
|
||||
await waitFor(() => expect(switchOf()).toHaveAttribute('aria-checked', 'true'));
|
||||
expect(screen.getByText('config.json')).toBeInTheDocument();
|
||||
// No relay of their own: push needs one now.
|
||||
expect(screen.getByText(/Push notifications now need a relay you run yourself/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is read-only when the environment sets it', async () => {
|
||||
apiFetch.mockImplementation(async () => Response.json(state({ noThirdPartyTraffic: true, source: 'env', envLocked: true })));
|
||||
render(<ThirdPartySection draft={draft} />);
|
||||
await waitFor(() => expect(switchOf()).toBeDisabled());
|
||||
expect(screen.getByText('NO_THIRD_PARTY_TRAFFIC')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Set by NO_THIRD_PARTY_TRAFFIC in the environment/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is read-only on a read-only config', async () => {
|
||||
apiFetch.mockImplementation(async () => Response.json(state({ readOnly: true })));
|
||||
render(<ThirdPartySection draft={draft} />);
|
||||
await waitFor(() => expect(switchOf()).toBeDisabled());
|
||||
expect(screen.getByText(/The configuration is read-only/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listReachableHosts', () => {
|
||||
it('lists the hosts the deployment configured and leaves the built-in ones out', () => {
|
||||
const config: Record<string, unknown> = {
|
||||
jmapServerUrl: 'https://mail.corp.example',
|
||||
jmapServers: [{ id: 'b', label: 'Branch', url: 'https://jmap.branch.example', oauth: { issuerUrl: 'https://sso.corp.example' } }],
|
||||
oauthEnabled: false,
|
||||
oauthIssuerUrl: 'https://ignored.example',
|
||||
extensionDirectoryUrl: 'https://extensions.bulwarkmail.org',
|
||||
wopiClientUrl: 'https://office.corp.example',
|
||||
loginLogoLightUrl: '/branding/Bulwark_Logo_Color.svg',
|
||||
appLogoLightUrl: 'https://cdn.corp.example/logo.svg',
|
||||
};
|
||||
const hosts = listReachableHosts({
|
||||
get: (key) => config[key],
|
||||
policy: {
|
||||
pushRelays: [{ label: 'Own', url: 'https://push.corp.example' }, { label: 'Built-in', url: 'https://notifications.relay.bulwarkmail.org' }],
|
||||
pushRelayUrl: '',
|
||||
defaultSidebarApps: [{ id: 'w', name: 'Wiki', url: 'https://wiki.corp.example', icon: 'Globe', openMode: 'inline', showOnMobile: true }],
|
||||
},
|
||||
env: { LIBRETRANSLATE_URL: 'https://translate.corp.example' },
|
||||
});
|
||||
expect(hosts.map((h) => `${h.kind}: ${h.host}`)).toEqual([
|
||||
'JMAP server: mail.corp.example',
|
||||
'JMAP server: jmap.branch.example',
|
||||
'OAuth issuer: sso.corp.example',
|
||||
'Push relay: push.corp.example',
|
||||
'Sidebar app: wiki.corp.example',
|
||||
'Document editor (WOPI): office.corp.example',
|
||||
'Translation (LibreTranslate): translate.corp.example',
|
||||
'Branding image: cdn.corp.example',
|
||||
'Sidebar apps users add: any address your users enter',
|
||||
'Calendar subscriptions: any address your users enter',
|
||||
]);
|
||||
});
|
||||
|
||||
it('names the relay browsers were built with, flags a different one in the environment, and lists what users enter', () => {
|
||||
const hosts = listReachableHosts({
|
||||
get: (key) => ({ allowCustomJmapEndpoint: true } as Record<string, unknown>)[key],
|
||||
policy: { pushRelays: [], pushRelayUrl: '', defaultSidebarApps: [], features: { calendarEnabled: false, sidebarAppsEnabled: false } },
|
||||
env: { NEXT_PUBLIC_PUSH_RELAY_URL: 'https://push.runtime.example' },
|
||||
buildRelayUrl: 'https://push.build.example',
|
||||
});
|
||||
expect(hosts.map((h) => [h.host, h.setIn, !!h.note])).toEqual([
|
||||
['push.build.example', 'NEXT_PUBLIC_PUSH_RELAY_URL (build)', false],
|
||||
['push.runtime.example', 'NEXT_PUBLIC_PUSH_RELAY_URL (environment)', true],
|
||||
['any address your users enter', 'ALLOW_CUSTOM_JMAP_ENDPOINT', true],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -7,17 +7,22 @@ import {
|
||||
ArrowLeft,
|
||||
ArrowUpCircle,
|
||||
Activity,
|
||||
Building2,
|
||||
ChevronRight,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
LayoutTemplate,
|
||||
LogOut,
|
||||
Palette,
|
||||
PenLine,
|
||||
Puzzle,
|
||||
Scale,
|
||||
ScrollText,
|
||||
Search,
|
||||
Server,
|
||||
Share2,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Store,
|
||||
SwatchBook,
|
||||
X,
|
||||
@@ -43,6 +48,11 @@ const PAGE_ICONS: Record<AdminPageId, AppIcon> = {
|
||||
auth: Shield,
|
||||
branding: Palette,
|
||||
policy: Scale,
|
||||
organisation: Building2,
|
||||
'org-signatures': PenLine,
|
||||
'org-templates': LayoutTemplate,
|
||||
'org-security': ShieldCheck,
|
||||
'org-sharing': Share2,
|
||||
plugins: Puzzle,
|
||||
themes: SwatchBook,
|
||||
marketplace: Store,
|
||||
@@ -53,6 +63,7 @@ const PAGE_ICONS: Record<AdminPageId, AppIcon> = {
|
||||
|
||||
const GROUP_LABELS: Record<AdminPageDef['group'], string | null> = {
|
||||
top: null,
|
||||
organisation: 'Organisation',
|
||||
extensions: 'Extensions',
|
||||
system: 'System',
|
||||
};
|
||||
@@ -159,7 +170,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
}, [trimmed]);
|
||||
|
||||
const groups = useMemo(() => (
|
||||
(['top', 'extensions', 'system'] as const)
|
||||
(['top', 'organisation', 'extensions', 'system'] as const)
|
||||
.map((group) => ({ group, label: GROUP_LABELS[group], items: matches.filter((m) => m.page.group === group) }))
|
||||
.filter((g) => g.items.length > 0)
|
||||
), [matches]);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
// Organisation > Security > External mail: the switches and the domain list
|
||||
// go into the draft at the chosen scope, and the External Mail Warning
|
||||
// plugin's safe domains can be copied in as an unsaved change.
|
||||
|
||||
const puts: Array<Record<string, unknown>> = [];
|
||||
let stored: Record<string, unknown> = {};
|
||||
let plugins: Array<Record<string, unknown>> = [];
|
||||
let pluginConfig: Record<string, unknown> = {};
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (url === '/api/admin/plugins') return new Response(JSON.stringify(plugins));
|
||||
if (url === '/api/admin/plugins/external-mail-warning/config') return new Response(JSON.stringify(pluginConfig));
|
||||
if (init?.method === 'PUT') {
|
||||
const body = JSON.parse(String(init.body));
|
||||
puts.push(body);
|
||||
stored = body;
|
||||
return new Response('{"ok":true}');
|
||||
}
|
||||
return new Response(JSON.stringify(stored));
|
||||
}),
|
||||
withBasePath: (p: string) => p,
|
||||
getPathPrefix: () => '',
|
||||
}));
|
||||
|
||||
import { ExternalMailSection } from '../sections/external-mail-section';
|
||||
import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft';
|
||||
|
||||
const POLICY = {
|
||||
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
|
||||
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
|
||||
organisation: { externalMail: { domains: ['partner.com'] } },
|
||||
scopes: [{ id: 'two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: {} }],
|
||||
};
|
||||
|
||||
let latest: ReturnType<typeof useOrgPolicyDraft> | null = null;
|
||||
function Harness() {
|
||||
const draft = useOrgPolicyDraft('test');
|
||||
latest = draft;
|
||||
if (draft.loading) return null;
|
||||
return <ExternalMailSection draft={draft} />;
|
||||
}
|
||||
|
||||
async function renderSection() {
|
||||
render(<Harness />);
|
||||
await waitFor(() => expect(screen.getByTestId('org-external-sender-label')).toBeInTheDocument());
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
puts.length = 0;
|
||||
stored = JSON.parse(JSON.stringify(POLICY));
|
||||
plugins = [];
|
||||
pluginConfig = {};
|
||||
latest = null;
|
||||
useOrgScopeStore.setState({ scopeId: EVERYONE });
|
||||
});
|
||||
|
||||
describe('External mail section', () => {
|
||||
it('starts with both switches off and saves what the admin turns on', async () => {
|
||||
await renderSection();
|
||||
expect(screen.getByTestId('org-external-sender-label').getAttribute('aria-checked')).toBe('false');
|
||||
expect(screen.getByTestId('org-external-recipient-warning').getAttribute('aria-checked')).toBe('false');
|
||||
fireEvent.click(screen.getByTestId('org-external-sender-label'));
|
||||
fireEvent.click(screen.getByTestId('org-external-recipient-warning'));
|
||||
await act(async () => { await latest!.save(); });
|
||||
expect((puts[0].organisation as { externalMail: unknown }).externalMail).toMatchObject({ senderLabel: true, recipientWarning: true, domains: ['partner.com'] });
|
||||
});
|
||||
|
||||
it('writes a scope override only into that scope', async () => {
|
||||
await renderSection();
|
||||
act(() => latest!.setScopeId('two'));
|
||||
fireEvent.click(screen.getByTestId('org-external-sender-label'));
|
||||
expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ externalMail: { senderLabel: true } });
|
||||
expect(latest!.policy?.organisation?.externalMail.senderLabel).toBe(false);
|
||||
});
|
||||
|
||||
it('takes the typed domain list apart when the field is left', async () => {
|
||||
await renderSection();
|
||||
const field = screen.getByTestId('org-external-domains');
|
||||
fireEvent.change(field, { target: { value: 'partner.com, Sister.example\n@third.test' } });
|
||||
fireEvent.blur(field);
|
||||
expect(latest!.orgValue('externalMail', 'domains').value).toEqual(['partner.com', 'sister.example', 'third.test']);
|
||||
expect(screen.queryByTestId('org-external-domains-rejected')).toBeNull();
|
||||
});
|
||||
|
||||
it('stores an IDN in ASCII, shows it in Unicode and names entries that are not domains', async () => {
|
||||
await renderSection();
|
||||
const field = screen.getByTestId('org-external-domains') as HTMLTextAreaElement;
|
||||
fireEvent.change(field, { target: { value: 'bücher.de\nnot_a_domain, x' } });
|
||||
fireEvent.blur(field);
|
||||
expect(latest!.orgValue('externalMail', 'domains').value).toEqual(['xn--bcher-kva.de']);
|
||||
expect(screen.getByTestId('org-external-domains-rejected').textContent).toContain('not_a_domain, x');
|
||||
await waitFor(() => expect(field.value).toBe('bücher.de'), { timeout: 5000 });
|
||||
});
|
||||
|
||||
it('offers no import and no note without the plugin', async () => {
|
||||
await renderSection();
|
||||
fireEvent.click(screen.getByTestId('org-external-sender-label'));
|
||||
expect(screen.queryByTestId('org-external-import-plugin')).toBeNull();
|
||||
expect(screen.queryByTestId('org-external-plugin-note')).toBeNull();
|
||||
});
|
||||
|
||||
it('asks to update an old plugin while the warnings are on and imports its safe domains as a draft change', async () => {
|
||||
plugins = [{ id: 'external-mail-warning', version: '1.5.1' }];
|
||||
pluginConfig = { safeDomains: 'partner.com, acme.example\nsub.acme.example' };
|
||||
await renderSection();
|
||||
await waitFor(() => expect(screen.getByTestId('org-external-import-plugin')).toBeInTheDocument());
|
||||
expect(screen.queryByTestId('org-external-plugin-note')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId('org-external-recipient-warning'));
|
||||
expect(screen.getByTestId('org-external-plugin-note').textContent).toContain('Update or remove the External Mail Warning plugin');
|
||||
|
||||
await act(async () => { fireEvent.click(screen.getByTestId('org-external-import-plugin')); });
|
||||
await waitFor(() => expect(latest!.orgValue('externalMail', 'domains').value).toEqual(['partner.com', 'acme.example', 'sub.acme.example']));
|
||||
expect(latest!.dirty).toBe(true);
|
||||
expect(puts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('only suggests removing a plugin that steps back by itself', async () => {
|
||||
plugins = [{ id: 'external-mail-warning', version: '1.6.0' }];
|
||||
await renderSection();
|
||||
await waitFor(() => expect(screen.getByTestId('org-external-import-plugin')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByTestId('org-external-sender-label'));
|
||||
expect(screen.getByTestId('org-external-plugin-note').textContent).toContain('You can remove the External Mail Warning plugin');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
// Organisation > Sharing > Deleted items: the days limit per scope, the
|
||||
// deletedItemsRecovery hide switch, and what Stalwart needs for it to work.
|
||||
|
||||
const puts: Array<Record<string, unknown>> = [];
|
||||
let stored: Record<string, unknown> = {};
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'PUT') {
|
||||
const body = JSON.parse(String(init.body));
|
||||
puts.push(body);
|
||||
stored = body;
|
||||
return new Response('{"ok":true}');
|
||||
}
|
||||
return new Response(JSON.stringify(stored));
|
||||
}),
|
||||
withBasePath: (p: string) => p,
|
||||
getPathPrefix: () => '',
|
||||
}));
|
||||
|
||||
import { RecoverySection } from '../sections/recovery-section';
|
||||
import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft';
|
||||
|
||||
const POLICY = {
|
||||
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
|
||||
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
|
||||
organisation: {},
|
||||
scopes: [{ id: 'two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: {} }],
|
||||
};
|
||||
|
||||
let latest: ReturnType<typeof useOrgPolicyDraft> | null = null;
|
||||
function Harness() {
|
||||
const draft = useOrgPolicyDraft('test');
|
||||
latest = draft;
|
||||
if (draft.loading) return null;
|
||||
return <RecoverySection draft={draft} />;
|
||||
}
|
||||
|
||||
async function renderSection() {
|
||||
render(<Harness />);
|
||||
await waitFor(() => expect(screen.getByTestId('org-recovery-requirements')).toBeInTheDocument());
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
puts.length = 0;
|
||||
stored = JSON.parse(JSON.stringify(POLICY));
|
||||
latest = null;
|
||||
useOrgScopeStore.setState({ scopeId: EVERYONE });
|
||||
});
|
||||
|
||||
describe('Recover deleted items section', () => {
|
||||
it('says it needs the Enterprise license and archiving in Stalwart, with the docs', async () => {
|
||||
await renderSection();
|
||||
const note = screen.getByTestId('org-recovery-requirements');
|
||||
expect(note.textContent).toContain('Stalwart Enterprise license');
|
||||
expect(note.textContent).toContain('archiveDeletedItemsFor');
|
||||
expect(screen.getByRole('link', { name: 'How to set it up' }).getAttribute('href')).toBe('https://bulwarkmail.org/docs/guides/organisation/recover-deleted-items');
|
||||
});
|
||||
|
||||
it('saves the days limit, with empty meaning everything the server keeps', async () => {
|
||||
await renderSection();
|
||||
const field = screen.getByLabelText('Show deleted mail of the last (days)') as HTMLInputElement;
|
||||
expect(field.value).toBe('');
|
||||
fireEvent.change(field, { target: { value: '14' } });
|
||||
await act(async () => { await latest!.save(); });
|
||||
expect((puts[0].organisation as { recovery: unknown }).recovery).toEqual({ days: 14 });
|
||||
fireEvent.change(field, { target: { value: '' } });
|
||||
expect(latest!.orgValue('recovery', 'days').value).toBe(0);
|
||||
});
|
||||
|
||||
it('writes a scope override only into that scope', async () => {
|
||||
await renderSection();
|
||||
act(() => latest!.setScopeId('two'));
|
||||
fireEvent.change(screen.getByLabelText('Show deleted mail of the last (days)'), { target: { value: '7' } });
|
||||
expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ recovery: { days: 7 } });
|
||||
expect(latest!.policy?.organisation?.recovery.days).toBe(0);
|
||||
});
|
||||
|
||||
it('hides the feature for users through deletedItemsRecovery', async () => {
|
||||
await renderSection();
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Hide' }));
|
||||
expect(latest!.restriction('deletedItemsRecovery').value).toEqual({ hidden: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
|
||||
// Organisation > Sharing > Shared tags: one sentence and the lock for the
|
||||
// `sharedTags` restriction, written at the scope chosen in the scope bar.
|
||||
|
||||
const puts: Array<Record<string, unknown>> = [];
|
||||
let stored: Record<string, unknown> = {};
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'PUT') {
|
||||
const body = JSON.parse(String(init.body));
|
||||
puts.push(body);
|
||||
stored = body;
|
||||
return new Response('{"ok":true}');
|
||||
}
|
||||
return new Response(JSON.stringify(stored));
|
||||
}),
|
||||
withBasePath: (p: string) => p,
|
||||
getPathPrefix: () => '',
|
||||
}));
|
||||
|
||||
import { SharedTagsSection } from '../sections/shared-tags-section';
|
||||
import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft';
|
||||
import { RESTRICTION_KEYS } from '@/lib/admin/restriction-keys';
|
||||
import { resolvePolicy } from '@/lib/admin/policy-scopes';
|
||||
|
||||
const POLICY = {
|
||||
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
|
||||
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
|
||||
scopes: [{ id: 'leads', label: 'Leads', match: { groups: ['leads@example.org'] }, policy: {} }],
|
||||
};
|
||||
|
||||
let latest: ReturnType<typeof useOrgPolicyDraft> | null = null;
|
||||
function Harness() {
|
||||
const draft = useOrgPolicyDraft('test');
|
||||
latest = draft;
|
||||
if (draft.loading) return null;
|
||||
return <SharedTagsSection draft={draft} />;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
puts.length = 0;
|
||||
stored = JSON.parse(JSON.stringify(POLICY));
|
||||
latest = null;
|
||||
useOrgScopeStore.setState({ scopeId: EVERYONE });
|
||||
});
|
||||
|
||||
describe('Shared tags section', () => {
|
||||
it('registers sharedTags on the Sharing page, lock only', () => {
|
||||
expect(RESTRICTION_KEYS.find((def) => def.key === 'sharedTags')).toMatchObject({ page: 'org-sharing', modes: ['locked'] });
|
||||
});
|
||||
|
||||
it('locks shared tags for everyone and unlocks them in a group scope', async () => {
|
||||
render(<Harness />);
|
||||
const section = await screen.findByTestId('org-shared-tags-section');
|
||||
expect(within(section).queryByText('Hide')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(section).getByRole('checkbox'));
|
||||
expect(latest!.policy?.restrictions.sharedTags).toEqual({ locked: true });
|
||||
|
||||
act(() => latest!.setScopeId('leads'));
|
||||
await waitFor(() => expect(within(screen.getByTestId('org-shared-tags-section')).getByRole('checkbox')).toBeChecked());
|
||||
fireEvent.click(within(screen.getByTestId('org-shared-tags-section')).getByRole('checkbox'));
|
||||
// In a scope an empty restriction is a real override: it unlocks.
|
||||
expect(latest!.policy?.scopes?.[0].policy.restrictions?.sharedTags).toEqual({});
|
||||
const lead = resolvePolicy(latest!.policy!, { email: 'anna@example.org', groups: ['leads@example.org'] }).policy;
|
||||
const member = resolvePolicy(latest!.policy!, { email: 'bob@example.org', groups: ['team@example.org'] }).policy;
|
||||
expect(lead.restrictions.sharedTags?.locked).not.toBe(true);
|
||||
expect(member.restrictions.sharedTags?.locked).toBe(true);
|
||||
|
||||
await act(async () => { await latest!.save(); });
|
||||
expect(puts[0]).toMatchObject({
|
||||
restrictions: { sharedTags: { locked: true } },
|
||||
scopes: [{ id: 'leads', policy: { restrictions: { sharedTags: {} } } }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Loader2, Lock, RotateCcw } from '@/components/icons';
|
||||
import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { AdminSaveBar } from '@/components/admin/save-bar';
|
||||
import { AdminLink } from '@/components/admin/admin-link';
|
||||
import { SectionNav, SettingItem } from '@/components/settings/settings-section';
|
||||
import { adminPageDef, type AdminPageId } from '@/lib/admin/admin-nav';
|
||||
import type { RestrictionKeyDef } from '@/lib/admin/restriction-keys';
|
||||
import type { PolicyScope } from '@/lib/admin/policy-scopes';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { EVERYONE, type OrgPolicyDraft } from './use-org-policy-draft';
|
||||
|
||||
export const chipClass = (selected: boolean) => cn(
|
||||
'h-8 px-3 rounded-md text-sm font-medium transition-colors max-w-full truncate',
|
||||
selected ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
);
|
||||
|
||||
/** "example.com, sales@example.com": what a scope matches, for labels. */
|
||||
export function scopeMatchSummary(scope: PolicyScope): string {
|
||||
const parts = [...(scope.match.domains ?? []), ...(scope.match.groups ?? [])];
|
||||
return parts.length > 0 ? parts.join(', ') : 'matches nobody yet';
|
||||
}
|
||||
|
||||
/**
|
||||
* Which part of the organisation an Organisation page edits: Everyone, or
|
||||
* one domain or group scope. Chips on wide screens; they wrap at phone width.
|
||||
*/
|
||||
export function ScopeBar({ draft }: { draft: OrgPolicyDraft }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3" data-testid="org-scope-bar">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground me-1">Applies to</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={draft.scopeId === EVERYONE}
|
||||
className={chipClass(draft.scopeId === EVERYONE)}
|
||||
onClick={() => draft.setScopeId(EVERYONE)}
|
||||
>
|
||||
Everyone
|
||||
</button>
|
||||
{draft.scopes.map((scope) => (
|
||||
<button
|
||||
key={scope.id}
|
||||
type="button"
|
||||
aria-pressed={draft.scopeId === scope.id}
|
||||
className={chipClass(draft.scopeId === scope.id)}
|
||||
title={scopeMatchSummary(scope)}
|
||||
onClick={() => draft.setScopeId(scope.id)}
|
||||
>
|
||||
{scope.label}
|
||||
</button>
|
||||
))}
|
||||
<AdminLink href="/admin/organisation" className="ms-auto text-xs text-muted-foreground underline-offset-2 hover:underline">
|
||||
Manage domains and groups
|
||||
</AdminLink>
|
||||
</div>
|
||||
{draft.scope && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Changes here apply to {scopeMatchSummary(draft.scope)} only. Settings this scope doesn't change come from Everyone.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "From Everyone: 15 minutes" with a reset, under a field a scope inherits or overrides. */
|
||||
export function InheritNote({ draft, own, everyone, onReset }: {
|
||||
draft: OrgPolicyDraft;
|
||||
own: boolean;
|
||||
everyone: ReactNode;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
if (!draft.scope) return null;
|
||||
return (
|
||||
<p className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{own ? <span>Everyone: {everyone}</span> : <span>From Everyone: {everyone}</span>}
|
||||
{own && (
|
||||
<button type="button" onClick={onReset} className="inline-flex items-center gap-1 underline-offset-2 hover:underline">
|
||||
<RotateCcw className="h-3 w-3" /> Use Everyone's value
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock, hide and allowed values of one restriction key, at the selected
|
||||
* scope. Every user-facing option of the Organisation features is
|
||||
* restricted through this, never through a flag of its own.
|
||||
*/
|
||||
export function RestrictionRow({ draft, def }: { draft: OrgPolicyDraft; def: RestrictionKeyDef }) {
|
||||
const { value, own, everyone } = draft.restriction(def.key);
|
||||
const current = value ?? {};
|
||||
const modes = def.modes ?? ['locked', 'hidden'];
|
||||
const set = (patch: Partial<typeof current>) => draft.setRestriction(def.key, { ...current, ...patch });
|
||||
const describe = (r: typeof current | undefined) =>
|
||||
r?.hidden ? 'hidden' : r?.locked ? 'locked' : r?.allowedValues?.length ? `limited to ${r.allowedValues.join(', ')}` : 'users choose';
|
||||
return (
|
||||
<SettingItem label={def.label} description={def.description}>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{modes.includes('locked') && (
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input type="checkbox" className="rounded border-input" checked={!!current.locked} onChange={(e) => set({ locked: e.target.checked })} />
|
||||
<Lock className="h-3 w-3" /> Lock
|
||||
</label>
|
||||
)}
|
||||
{modes.includes('hidden') && (
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input type="checkbox" className="rounded border-input" checked={!!current.hidden} onChange={(e) => set({ hidden: e.target.checked })} />
|
||||
Hide
|
||||
</label>
|
||||
)}
|
||||
{modes.includes('allowedValues') && def.allowedValues?.map((option) => {
|
||||
const allowed = current.allowedValues as string[] | undefined;
|
||||
const checked = !allowed || allowed.length === 0 || allowed.includes(option);
|
||||
return (
|
||||
<label key={option} className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input"
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
const base = allowed && allowed.length > 0 ? allowed : def.allowedValues!;
|
||||
const next = e.target.checked ? [...new Set([...base, option])] : base.filter((v) => v !== option);
|
||||
set({ allowedValues: next.length === def.allowedValues!.length ? undefined : next });
|
||||
}}
|
||||
/>
|
||||
{option}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<InheritNote draft={draft} own={own} everyone={describe(everyone)} onReset={() => draft.setRestriction(def.key, undefined)} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame of an Organisation page: title and the one sentence on what it
|
||||
* does for users, the scope bar, messages, and the save bar.
|
||||
*/
|
||||
export function OrgPage({ pageId, draft, children, scoped = true }: {
|
||||
pageId: AdminPageId;
|
||||
draft: OrgPolicyDraft;
|
||||
children: ReactNode;
|
||||
/** Pages whose settings can be set per scope show the scope bar. */
|
||||
scoped?: boolean;
|
||||
}) {
|
||||
const page = adminPageDef(pageId);
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader title={page.label} description={page.description} />
|
||||
{draft.loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-10">
|
||||
{scoped && <ScopeBar draft={draft} />}
|
||||
<SectionNav />
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
<AdminSaveBar
|
||||
dirty={draft.dirty}
|
||||
saving={draft.saving}
|
||||
onSave={() => void draft.save()}
|
||||
onDiscard={draft.discard}
|
||||
saveLabel="Save"
|
||||
note="Users get the change the next time Bulwark loads"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { Download, Loader2, Upload } from '@/components/icons';
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import type { KeyChanges, PolicyChangeSummary } from '@/lib/admin/policy-diff';
|
||||
import type { OrgPolicyDraft } from './use-org-policy-draft';
|
||||
|
||||
function describe(label: string, changes: KeyChanges): string | null {
|
||||
const parts = [
|
||||
changes.added.length ? `${changes.added.length} added` : null,
|
||||
changes.changed.length ? `${changes.changed.length} changed` : null,
|
||||
changes.removed.length ? `${changes.removed.length} removed` : null,
|
||||
].filter(Boolean);
|
||||
return parts.length ? `${label}: ${parts.join(', ')}` : null;
|
||||
}
|
||||
|
||||
function summaryLines(summary: PolicyChangeSummary): string[] {
|
||||
return [
|
||||
describe('Domains and groups', summary.scopes),
|
||||
describe('Organisation settings', summary.organisation),
|
||||
describe('Locked and hidden settings', summary.restrictions),
|
||||
describe('Features', summary.features),
|
||||
describe('Defaults for new users', summary.defaults),
|
||||
summary.other.length ? `Also: ${summary.other.join(', ')}` : null,
|
||||
].filter((line): line is string => !!line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export and import of the whole user policy as policy.json, so the policy
|
||||
* can live in git, move to another instance or go next to a Lite build.
|
||||
* An import shows what it changes before it replaces anything.
|
||||
*/
|
||||
export function PolicyFileSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [pending, setPending] = useState<{ name: string; text: string; summary: PolicyChangeSummary } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function exportFile() {
|
||||
const res = await apiFetch('/api/admin/policy/file');
|
||||
if (!res.ok) {
|
||||
draft.setMessage({ type: 'error', text: 'Could not export the policy' });
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const name = /filename="([^"]+)"/.exec(res.headers.get('Content-Disposition') ?? '')?.[1] ?? 'bulwark-policy.json';
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = name;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function pick(file: File) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const res = await apiFetch('/api/admin/policy/file?preview=1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: text });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
draft.setMessage({ type: 'error', text: data.error || 'Could not read the file' });
|
||||
return;
|
||||
}
|
||||
setPending({ name: file.name, text, summary: data.summary });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
if (input.current) input.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
if (!pending) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/policy/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: pending.text });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
draft.setMessage({ type: 'error', text: data.error || 'Could not import the file' });
|
||||
return;
|
||||
}
|
||||
setPending(null);
|
||||
await draft.reload();
|
||||
draft.setMessage({ type: 'success', text: `Imported ${pending.name}.` });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="policy-file"
|
||||
title="Policy file"
|
||||
description="Everything on the Organisation and User policy pages is one file, policy.json. Export it to keep it in git or copy it to another Bulwark or a Lite build; importing replaces the current policy."
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 text-xs" onClick={() => void exportFile()}>
|
||||
<Download className="h-3.5 w-3.5" /> Export policy.json
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
disabled={busy || draft.dirty}
|
||||
onClick={() => input.current?.click()}
|
||||
>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />} Import a file
|
||||
</Button>
|
||||
<input
|
||||
ref={input}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
aria-label="Policy file to import"
|
||||
onChange={(e) => { const file = e.target.files?.[0]; if (file) void pick(file); }}
|
||||
/>
|
||||
</div>
|
||||
{draft.dirty && <p className="text-xs text-muted-foreground">Save or discard your changes first.</p>}
|
||||
{pending && (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2" data-testid="policy-import-preview">
|
||||
<p className="text-sm font-medium text-foreground">Importing {pending.name}</p>
|
||||
{pending.summary.unchanged ? (
|
||||
<p className="text-sm text-muted-foreground">It matches the current policy; nothing would change.</p>
|
||||
) : (
|
||||
<ul className="list-disc ps-5 text-sm text-foreground">
|
||||
{summaryLines(pending.summary).map((line) => <li key={line}>{line}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" className="h-8 text-xs" disabled={busy || pending.summary.unchanged} onClick={() => void apply()}>
|
||||
Replace the current policy
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 text-xs" disabled={busy} onClick={() => setPending(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { RESTRICTION_KEYS } from '@/lib/admin/restriction-keys';
|
||||
import { RestrictionRow } from '../org-page';
|
||||
import type { OrgPolicyDraft } from '../use-org-policy-draft';
|
||||
|
||||
const DELEGATION = RESTRICTION_KEYS.find((def) => def.key === 'delegation')!;
|
||||
const SHARING_RIGHTS = RESTRICTION_KEYS.find((def) => def.key === 'sharingRights')!;
|
||||
|
||||
/** Organisation > Sharing > Delegates: whether users may delegate, and which rights any share dialog offers. */
|
||||
export function DelegatesSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
const rights = draft.restriction(SHARING_RIGHTS.key).value;
|
||||
const noneTicked = Array.isArray(rights?.allowedValues) && rights.allowedValues.length === 0;
|
||||
return (
|
||||
<SettingsSection
|
||||
id="delegates"
|
||||
title="Delegates"
|
||||
description="Users can give a colleague read or read and write access to their mail folders, calendars and address books in one step, under Settings > Sharing. Stalwart doesn't let delegates send as the user; for an address several people send from, set up a shared mailbox."
|
||||
>
|
||||
<RestrictionRow draft={draft} def={DELEGATION} />
|
||||
<RestrictionRow draft={draft} def={SHARING_RIGHTS} />
|
||||
{noneTicked && (
|
||||
<p className="text-xs text-destructive" role="status">
|
||||
With neither ticked nothing is limited, so users can give both. Tick "read" to allow read access only.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Both limit what Bulwark offers. Stalwart doesn't enforce them, so users can still share through other clients. Shared
|
||||
mailboxes of groups are yours to manage in Stalwart.
|
||||
</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Download, Info } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SettingItem, SettingsSection, ToggleSwitch } from '@/components/settings/settings-section';
|
||||
import { AdminLink } from '@/components/admin/admin-link';
|
||||
import { parseDomainList } from '@/lib/admin/organisation-policy';
|
||||
import { toUnicodeDomain } from '@/lib/idn';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { EXTERNAL_MAIL_WARNING_PLUGIN_ID } from '@/lib/plugin-sandbox/host-features';
|
||||
import { InheritNote } from '../org-page';
|
||||
import type { OrgPolicyDraft } from '../use-org-policy-draft';
|
||||
|
||||
/** The first release of the plugin core took over from that steps back on its own. */
|
||||
export const PLUGIN_STEPS_BACK_FROM = '1.6.0';
|
||||
|
||||
function older(version: string, than: string): boolean {
|
||||
const a = version.split(/[-+]/)[0].split('.').map(Number);
|
||||
const b = than.split(/[-+]/)[0].split('.').map(Number);
|
||||
for (let i = 0; i < 3; i++) if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) < (b[i] || 0);
|
||||
return false;
|
||||
}
|
||||
|
||||
type InstalledPlugin = { version: string };
|
||||
|
||||
/** The External Mail Warning plugin when the admin installed it, else null. */
|
||||
function useInstalledPlugin(): InstalledPlugin | null {
|
||||
const [plugin, setPlugin] = useState<InstalledPlugin | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/plugins');
|
||||
const list = res.ok ? ((await res.json()) as Array<{ id: string; version?: string }>) : [];
|
||||
const found = Array.isArray(list) ? list.find((p) => p.id === EXTERNAL_MAIL_WARNING_PLUGIN_ID) : undefined;
|
||||
if (!cancelled) setPlugin(found ? { version: found.version ?? '' } : null);
|
||||
} catch {
|
||||
if (!cancelled) setPlugin(null);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
const onOff = (v: boolean) => (v ? 'on' : 'off');
|
||||
|
||||
/**
|
||||
* Organisation > Security > External mail: the "External" sender label,
|
||||
* the confirm before sending outside, which domains are internal, and the
|
||||
* hand-over from the External Mail Warning plugin.
|
||||
*/
|
||||
export function ExternalMailSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
const senderLabel = draft.orgValue('externalMail', 'senderLabel');
|
||||
const recipientWarning = draft.orgValue('externalMail', 'recipientWarning');
|
||||
const domains = draft.orgValue('externalMail', 'domains');
|
||||
const trustContacts = draft.orgValue('externalMail', 'trustContacts');
|
||||
const plugin = useInstalledPlugin();
|
||||
const anyOn = senderLabel.value || recipientWarning.value;
|
||||
|
||||
// The list is typed as text and taken apart when the field is left, so a
|
||||
// half-typed domain isn't thrown away on every key. It is stored in ASCII
|
||||
// (xn--) form and shown in Unicode; entries that aren't domains are named.
|
||||
const stored = domains.value.join('\n');
|
||||
const joined = domains.value.map(toUnicodeDomain).join('\n');
|
||||
const [text, setText] = useState(joined);
|
||||
const [rejected, setRejected] = useState<string[]>([]);
|
||||
useEffect(() => { setText(joined); }, [joined]);
|
||||
const commitDomains = (raw: string) => {
|
||||
const parsed = parseDomainList(raw) ?? { domains: [], rejected: [] };
|
||||
setRejected(parsed.rejected);
|
||||
if (parsed.domains.join('\n') !== stored) draft.setOrgValue('externalMail', 'domains', parsed.domains);
|
||||
else setText(joined);
|
||||
};
|
||||
|
||||
const [importing, setImporting] = useState(false);
|
||||
const importFromPlugin = async () => {
|
||||
setImporting(true);
|
||||
try {
|
||||
const res = await apiFetch(`/api/admin/plugins/${EXTERNAL_MAIL_WARNING_PLUGIN_ID}/config`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const config = (await res.json()) as Record<string, unknown>;
|
||||
const parsed = parseDomainList(config.safeDomains) ?? { domains: [], rejected: [] };
|
||||
const fromPlugin = parsed.domains;
|
||||
setRejected(parsed.rejected);
|
||||
const added = fromPlugin.filter((d) => !domains.value.includes(d));
|
||||
if (added.length > 0) draft.setOrgValue('externalMail', 'domains', [...domains.value, ...added]);
|
||||
draft.setMessage({
|
||||
type: 'success',
|
||||
text: added.length > 0
|
||||
? `Added ${added.length} ${added.length === 1 ? 'domain' : 'domains'} from the plugin. Save to apply them.`
|
||||
: fromPlugin.length > 0 ? 'The list already has every domain the plugin had.' : 'The plugin has no safe domains set.',
|
||||
});
|
||||
} catch {
|
||||
draft.setMessage({ type: 'error', text: 'Could not read the plugin settings' });
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const outdated = !!plugin && older(plugin.version, PLUGIN_STEPS_BACK_FROM);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="external"
|
||||
title="External mail"
|
||||
description="Label mail from senders outside the organisation and confirm before sending outside it. Both are off until you turn them on."
|
||||
>
|
||||
<SettingItem label="Label external senders" description="Shows an “External” chip next to the sender of mail from outside the organisation.">
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<ToggleSwitch checked={senderLabel.value} onChange={(v) => draft.setOrgValue('externalMail', 'senderLabel', v)} testId="org-external-sender-label" />
|
||||
<InheritNote draft={draft} own={senderLabel.own} everyone={onOff(senderLabel.everyone)} onReset={() => draft.clearOrgValue('externalMail', 'senderLabel')} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Confirm external recipients" description="Before a message goes out, lists the recipients outside the organisation and asks “Send anyway” or “Back”. Quick replies too.">
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<ToggleSwitch checked={recipientWarning.value} onChange={(v) => draft.setOrgValue('externalMail', 'recipientWarning', v)} testId="org-external-recipient-warning" />
|
||||
<InheritNote draft={draft} own={recipientWarning.own} everyone={onOff(recipientWarning.everyone)} onReset={() => draft.clearOrgValue('externalMail', 'recipientWarning')} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Trust contacts" description="Recipients in the user's contacts are sent to without asking.">
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<ToggleSwitch checked={trustContacts.value} onChange={(v) => draft.setOrgValue('externalMail', 'trustContacts', v)} testId="org-external-trust-contacts" />
|
||||
<InheritNote draft={draft} own={trustContacts.own} everyone={onOff(trustContacts.everyone)} onReset={() => draft.clearOrgValue('externalMail', 'trustContacts')} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<div className="space-y-2 py-3">
|
||||
<label htmlFor="org-external-domains" className="text-sm font-medium text-foreground">More organisation domains</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your users' own domains always count as internal. Add partner or sister-company domains here, one per line or separated by commas. A domain covers its subdomains: example.com also covers eu.example.com.
|
||||
</p>
|
||||
<textarea
|
||||
id="org-external-domains"
|
||||
data-testid="org-external-domains"
|
||||
rows={4}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={(e) => commitDomains(e.target.value)}
|
||||
placeholder="partner.com"
|
||||
spellCheck={false}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
{rejected.length > 0 && (
|
||||
<p className="text-xs text-destructive" role="status" data-testid="org-external-domains-rejected">
|
||||
Left out, not a domain: {rejected.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
<InheritNote
|
||||
draft={draft}
|
||||
own={domains.own}
|
||||
everyone={domains.everyone.length ? domains.everyone.map(toUnicodeDomain).join(', ') : 'none'}
|
||||
onReset={() => draft.clearOrgValue('externalMail', 'domains')}
|
||||
/>
|
||||
{plugin && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void importFromPlugin()} disabled={importing} className="gap-1.5" data-testid="org-external-import-plugin">
|
||||
<Download className="h-4 w-4" /> Import from the plugin
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{plugin && anyOn && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/40 p-3 text-sm" data-testid="org-external-plugin-note">
|
||||
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="font-medium text-foreground">
|
||||
{outdated ? 'Update or remove the External Mail Warning plugin' : 'You can remove the External Mail Warning plugin'}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{outdated
|
||||
? `Version ${plugin.version || 'unknown'} is installed. For each switch that is on, Bulwark skips the matching part of the plugin (its send confirm, or its banner and new-mail toast) so nobody is warned twice; version ${PLUGIN_STEPS_BACK_FROM} and later step back by themselves.`
|
||||
: 'It turns off its own send confirm, banner and toast for each switch that is on here.'}{' '}
|
||||
“Import from the plugin” copies its safe domains into the list above.{' '}
|
||||
<AdminLink href={`/admin/plugins/${EXTERNAL_MAIL_WARNING_PLUGIN_ID}`} className="underline underline-offset-2 hover:text-foreground">Open the plugin</AdminLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { SettingItem, SettingsSection } from '@/components/settings/settings-section';
|
||||
import { RESTRICTION_KEYS } from '@/lib/admin/restriction-keys';
|
||||
import { InheritNote, RestrictionRow } from '../org-page';
|
||||
import type { OrgPolicyDraft } from '../use-org-policy-draft';
|
||||
|
||||
const RECOVERY = RESTRICTION_KEYS.find((def) => def.key === 'deletedItemsRecovery')!;
|
||||
const MAX_DAYS = 3650;
|
||||
const DOCS_URL = 'https://bulwarkmail.org/docs/guides/organisation/recover-deleted-items';
|
||||
|
||||
function describeDays(days: number): string {
|
||||
return days > 0 ? `${days} days` : 'everything the server keeps';
|
||||
}
|
||||
|
||||
/** Organisation > Sharing > Deleted items: how far back users can recover deleted mail, and whether they can. */
|
||||
export function RecoverySection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
const { value, own, everyone } = draft.orgValue('recovery', 'days');
|
||||
return (
|
||||
<SettingsSection
|
||||
id="recovery"
|
||||
title="Recover deleted items"
|
||||
description="Users can bring back mail they deleted for good (an emptied Trash, for example) under Settings > Recover deleted items, and from a link in Trash."
|
||||
>
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground" data-testid="org-recovery-requirements">
|
||||
This needs a Stalwart Enterprise license and archiving turned on in Stalwart (Data Retention > Archiving, the
|
||||
archiveDeletedItemsFor setting). Without both, Bulwark asks the server, gets a refusal, and shows users nothing.{' '}
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="font-medium text-primary underline-offset-2 hover:underline">
|
||||
How to set it up
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<SettingItem
|
||||
label="Show deleted mail of the last"
|
||||
description="Limits the list to mail deleted in this many days. Stalwart's own setting decides how long mail is kept; this can only show less."
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="org-recovery-days"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={MAX_DAYS}
|
||||
step={1}
|
||||
aria-label="Show deleted mail of the last (days)"
|
||||
placeholder="All"
|
||||
value={value > 0 ? value : ''}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.trim();
|
||||
const parsed = raw === '' ? 0 : Math.round(Number(raw));
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
draft.setOrgValue('recovery', 'days', Math.min(MAX_DAYS, Math.max(0, parsed)));
|
||||
}}
|
||||
className="h-9 w-28 rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">days</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Empty shows everything the server keeps.</p>
|
||||
<InheritNote draft={draft} own={own} everyone={describeDays(everyone)} onReset={() => draft.clearOrgValue('recovery', 'days')} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<RestrictionRow draft={draft} def={RECOVERY} />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { SettingItem, SettingsSection } from '@/components/settings/settings-section';
|
||||
import { InheritNote } from '../org-page';
|
||||
import type { OrgPolicyDraft } from '../use-org-policy-draft';
|
||||
|
||||
interface SessionField {
|
||||
key: 'idleMinutes' | 'maxHours' | 'warnSeconds';
|
||||
label: string;
|
||||
description: string;
|
||||
unit: string;
|
||||
max: number;
|
||||
/** What 0 means. */
|
||||
zero: string;
|
||||
}
|
||||
|
||||
const FIELDS: SessionField[] = [
|
||||
{
|
||||
key: 'idleMinutes',
|
||||
label: 'Sign out after inactivity',
|
||||
description: 'Signs users out of every account in the browser after this many minutes without activity in any Bulwark tab. Open messages are saved as drafts first.',
|
||||
unit: 'minutes',
|
||||
max: 7 * 24 * 60,
|
||||
zero: 'Off',
|
||||
},
|
||||
{
|
||||
key: 'maxHours',
|
||||
label: 'Maximum session length',
|
||||
description: 'Signs users out this many hours after they signed in, active or not, also with "Remember me". The server enforces domain values too; group values only apply in the browser.',
|
||||
unit: 'hours',
|
||||
max: 24 * 365,
|
||||
zero: 'Off',
|
||||
},
|
||||
{
|
||||
key: 'warnSeconds',
|
||||
label: 'Warning before signing out',
|
||||
description: 'How long a countdown shows before either limit signs users out. During the inactivity warning users can choose to stay signed in.',
|
||||
unit: 'seconds',
|
||||
max: 600,
|
||||
zero: 'No warning',
|
||||
},
|
||||
];
|
||||
|
||||
function describe(value: number, field: SessionField): string {
|
||||
return value > 0 ? `${value} ${field.unit}` : field.zero;
|
||||
}
|
||||
|
||||
/** Organisation > Security > Sessions: the idle timeout and the maximum session length. */
|
||||
export function SessionsSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
return (
|
||||
<SettingsSection id="sessions" title="Sessions" description="Sign users out after a time without activity, or a fixed time after they signed in. Both are off until you set them.">
|
||||
{FIELDS.map((field) => {
|
||||
const { value, own, everyone } = draft.orgValue('session', field.key);
|
||||
const inputId = `org-session-${field.key}`;
|
||||
return (
|
||||
<SettingItem key={field.key} label={field.label} description={field.description}>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={inputId}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={field.max}
|
||||
step={1}
|
||||
aria-label={`${field.label} (${field.unit})`}
|
||||
placeholder={field.zero}
|
||||
value={value > 0 ? value : ''}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.trim();
|
||||
const parsed = raw === '' ? 0 : Math.round(Number(raw));
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
draft.setOrgValue('session', field.key, Math.min(field.max, Math.max(0, parsed)));
|
||||
}}
|
||||
className="h-9 w-28 rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{field.unit}</span>
|
||||
</div>
|
||||
<InheritNote
|
||||
draft={draft}
|
||||
own={own}
|
||||
everyone={describe(everyone, field)}
|
||||
onReset={() => draft.clearOrgValue('session', field.key)}
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
);
|
||||
})}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { restrictionKeysForPage } from '@/lib/admin/restriction-keys';
|
||||
import { RestrictionRow } from '../org-page';
|
||||
import type { OrgPolicyDraft } from '../use-org-policy-draft';
|
||||
|
||||
/**
|
||||
* Organisation feature 6: shared tags in group mailboxes. The tags live in
|
||||
* the group's Inbox on the mail server (Stalwart 1.0 metadata), so the only
|
||||
* thing to set here is who may change them.
|
||||
*/
|
||||
export function SharedTagsSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
return (
|
||||
<SettingsSection
|
||||
id="shared-tags"
|
||||
title="Shared tags"
|
||||
description="Members of a group mailbox see the same tag names and colors on its mail; members who may change the mailbox can edit them (Stalwart v1.0.0 or later)."
|
||||
>
|
||||
<div data-testid="org-shared-tags-section">
|
||||
{restrictionKeysForPage('org-sharing')
|
||||
.filter((def) => def.key === 'sharedTags')
|
||||
.map((def) => <RestrictionRow key={def.key} draft={draft} def={def} />)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useRef, useState } from 'react';
|
||||
import { Check } from '@/components/icons';
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { SIGNATURE_FIELDS, type SignatureField } from '@/lib/admin/organisation-policy';
|
||||
import { restrictionKeysForPage } from '@/lib/admin/restriction-keys';
|
||||
import { sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization';
|
||||
import {
|
||||
SIGNATURE_PLACEHOLDERS,
|
||||
buildOrgSignature,
|
||||
signatureFieldsUsed,
|
||||
signatureValuesFor,
|
||||
type SignaturePlaceholder,
|
||||
} from '@/lib/org-signature';
|
||||
import { getPlainTextSignature } from '@/lib/signature-utils';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { InheritNote, RestrictionRow } from '../org-page';
|
||||
import type { OrgPolicyDraft } from '../use-org-policy-draft';
|
||||
|
||||
const textareaClass =
|
||||
'block w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs text-foreground ' +
|
||||
'placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 ' +
|
||||
'focus-visible:ring-ring focus-visible:border-ring';
|
||||
const labelClass = 'mb-1 block text-sm font-medium text-foreground';
|
||||
const hintClass = 'mt-1 text-xs text-muted-foreground';
|
||||
|
||||
const PLACEHOLDER_HELP: Record<SignaturePlaceholder, string> = {
|
||||
name: 'name of the address the mail is sent from',
|
||||
email: 'that address',
|
||||
identity: 'name and address, as in From',
|
||||
company: 'company name from Branding, for the web address Bulwark is opened on',
|
||||
domain: 'domain of the address',
|
||||
title: 'job title the user fills in',
|
||||
department: 'department the user fills in',
|
||||
phone: 'phone number the user fills in',
|
||||
mobile: 'mobile number the user fills in',
|
||||
};
|
||||
|
||||
const FIELD_LABELS: Record<SignatureField, string> = {
|
||||
title: 'Job title',
|
||||
department: 'Department',
|
||||
phone: 'Phone',
|
||||
mobile: 'Mobile',
|
||||
};
|
||||
|
||||
const SAMPLE_DETAILS = { title: 'Head of Sales', department: 'Sales', phone: '+49 30 1234567', mobile: '+49 170 1234567' };
|
||||
const EMPTY_DETAILS = { title: '', department: '', phone: '', mobile: '' };
|
||||
|
||||
const describeText = (value: string) => {
|
||||
const first = value.trim().split('\n')[0] ?? '';
|
||||
return first ? (first.length > 60 ? `${first.slice(0, 60)}…` : first) : 'none';
|
||||
};
|
||||
|
||||
export interface FocusedTemplate {
|
||||
el: HTMLTextAreaElement;
|
||||
apply: (value: string) => void;
|
||||
}
|
||||
|
||||
/** A template field (HTML or plain text) at the selected scope. */
|
||||
export function TemplateField({ label, hint, value, own, everyone, rows, testId, onChange, onReset, draft, onFocusField }: {
|
||||
label: string;
|
||||
hint: string;
|
||||
value: string;
|
||||
own: boolean;
|
||||
everyone: string;
|
||||
rows: number;
|
||||
testId: string;
|
||||
onChange: (value: string) => void;
|
||||
onReset: () => void;
|
||||
draft: OrgPolicyDraft;
|
||||
onFocusField: (el: HTMLTextAreaElement) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className={labelClass}>{label}</label>
|
||||
<textarea
|
||||
id={id}
|
||||
data-testid={testId}
|
||||
rows={rows}
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
className={textareaClass}
|
||||
onFocus={(e) => onFocusField(e.currentTarget)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<p className={hintClass}>{hint}</p>
|
||||
<InheritNote draft={draft} own={own} everyone={describeText(everyone)} onReset={onReset} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a template looks like filled in with sample values: the HTML and the plain text. */
|
||||
export function TemplatePreview({ html, text, testId }: { html: string; text: string; testId: string }) {
|
||||
const plain = getPlainTextSignature({ textSignature: text, htmlSignature: html });
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2" data-testid={testId}>
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">HTML</p>
|
||||
<div className="min-h-[3rem] rounded-md border border-border bg-background p-3 text-sm break-words">
|
||||
{html
|
||||
? <div dangerouslySetInnerHTML={{ __html: sanitizeSignatureHtmlForDisplay(html) }} />
|
||||
: <span className="text-muted-foreground">{plain ? 'Plain text only' : 'Nothing'}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">Plain text{!text && html ? ' (from the HTML)' : ''}</p>
|
||||
<pre className="min-h-[3rem] whitespace-pre-wrap break-words rounded-md border border-border bg-background p-3 font-sans text-sm">{plain || ' '}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inserts `{placeholder}` at the caret of the template field last focused. */
|
||||
export function insertPlaceholder(target: FocusedTemplate | null, placeholder: SignaturePlaceholder): void {
|
||||
if (!target) return;
|
||||
const { el, apply } = target;
|
||||
const start = el.selectionStart ?? el.value.length;
|
||||
const end = el.selectionEnd ?? start;
|
||||
const token = `{${placeholder}}`;
|
||||
apply(`${el.value.slice(0, start)}${token}${el.value.slice(end)}`);
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
el.setSelectionRange(start + token.length, start + token.length);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Organisation > Signatures (feature 1): the organisation signature as an
|
||||
* HTML and a plain-text template with placeholders, the user details it
|
||||
* asks for, the `signature` restriction, and the disclaimer. Everything per
|
||||
* scope through the draft hook; stored in policy.json
|
||||
* `organisation.signature` and `organisation.disclaimer`.
|
||||
*/
|
||||
export function SignatureSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
const config = useConfig();
|
||||
const focused = useRef<FocusedTemplate | null>(null);
|
||||
const [emptyDetails, setEmptyDetails] = useState(false);
|
||||
|
||||
const html = draft.orgValue('signature', 'html');
|
||||
const text = draft.orgValue('signature', 'text');
|
||||
const fields = draft.orgValue('signature', 'fields');
|
||||
const disclaimerHtml = draft.orgValue('disclaimer', 'html');
|
||||
const disclaimerText = draft.orgValue('disclaimer', 'text');
|
||||
|
||||
// Which details users are asked for follows the templates. It is stored
|
||||
// with them (`fields`), so a hand-written policy.json can say it too.
|
||||
const setTemplate = (key: 'html' | 'text', value: string) => {
|
||||
draft.setOrgValue('signature', key, value);
|
||||
const used = signatureFieldsUsed({ html: key === 'html' ? value : html.value, text: key === 'text' ? value : text.value });
|
||||
if (!fields.own || used.join() !== fields.value.join()) draft.setOrgValue('signature', 'fields', used);
|
||||
};
|
||||
const resetTemplate = (key: 'html' | 'text') => {
|
||||
draft.clearOrgValue('signature', key);
|
||||
const otherOwn = key === 'html' ? text.own : html.own;
|
||||
if (!otherOwn) {
|
||||
draft.clearOrgValue('signature', 'fields');
|
||||
return;
|
||||
}
|
||||
draft.setOrgValue('signature', 'fields', signatureFieldsUsed({
|
||||
html: key === 'html' ? html.everyone : html.value,
|
||||
text: key === 'text' ? text.everyone : text.value,
|
||||
}));
|
||||
};
|
||||
|
||||
const domain = draft.scope?.match.domains?.[0]?.replace(/^\*\./, '') ?? 'example.com';
|
||||
const values = signatureValuesFor(
|
||||
{ name: 'Alex Example', email: `alex@${domain}` },
|
||||
emptyDetails ? EMPTY_DETAILS : SAMPLE_DETAILS,
|
||||
config.loginCompanyName || 'Example Ltd',
|
||||
);
|
||||
const sample = buildOrgSignature({ html: html.value, text: text.value }, values);
|
||||
const sampleDisclaimer = buildOrgSignature({ html: disclaimerHtml.value, text: disclaimerText.value }, values);
|
||||
const usedFields = signatureFieldsUsed({ html: html.value, text: text.value });
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
id="signature"
|
||||
title="Signature"
|
||||
description="The signature at the end of every message, filled in for the address it is sent from. Users without a signature of their own get it; lock it to replace everyone's."
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<TemplateField
|
||||
draft={draft}
|
||||
label="HTML signature"
|
||||
hint="Placeholders are filled in as text. A line whose placeholders are all empty is left out, so a user without a mobile number gets no “Mobile:” line."
|
||||
value={html.value}
|
||||
own={html.own}
|
||||
everyone={html.everyone}
|
||||
rows={6}
|
||||
testId="org-signature-html"
|
||||
onChange={(value) => setTemplate('html', value)}
|
||||
onReset={() => resetTemplate('html')}
|
||||
onFocusField={(el) => { focused.current = { el, apply: (value) => setTemplate('html', value) }; }}
|
||||
/>
|
||||
<TemplateField
|
||||
draft={draft}
|
||||
label="Plain-text signature"
|
||||
hint="For plain-text mail. Leave it empty to send the HTML signature as text."
|
||||
value={text.value}
|
||||
own={text.own}
|
||||
everyone={text.everyone}
|
||||
rows={4}
|
||||
testId="org-signature-text"
|
||||
onChange={(value) => setTemplate('text', value)}
|
||||
onReset={() => resetTemplate('text')}
|
||||
onFocusField={(el) => { focused.current = { el, apply: (value) => setTemplate('text', value) }; }}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p className={labelClass}>Placeholders</p>
|
||||
<p className={hintClass}>Click one to insert it at the cursor of the template you last edited.</p>
|
||||
<ul className="mt-2 grid grid-cols-1 gap-1.5 sm:grid-cols-2" data-testid="org-signature-placeholders">
|
||||
{SIGNATURE_PLACEHOLDERS.map((placeholder) => (
|
||||
<li key={placeholder} className="flex min-w-0 items-baseline gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-foreground hover:bg-accent"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => insertPlaceholder(focused.current, placeholder)}
|
||||
>
|
||||
{`{${placeholder}}`}
|
||||
</button>
|
||||
<span className="min-w-0 text-muted-foreground">{PLACEHOLDER_HELP[placeholder]}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<p className={labelClass}>Preview</p>
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input type="checkbox" className="rounded border-input" checked={emptyDetails} onChange={(e) => setEmptyDetails(e.target.checked)} />
|
||||
For a user who filled in no details
|
||||
</label>
|
||||
</div>
|
||||
{sample
|
||||
? <TemplatePreview html={sample.html} text={sample.text} testId="org-signature-preview" />
|
||||
: <p className="text-sm text-muted-foreground">No organisation signature: users send their own.</p>}
|
||||
</div>
|
||||
|
||||
{restrictionKeysForPage('org-signatures').map((def) => <RestrictionRow key={def.key} draft={draft} def={def} />)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="details"
|
||||
title="User details"
|
||||
description="Users fill these in once, under Settings > Account. They are asked only for what the signature uses."
|
||||
>
|
||||
<ul className="space-y-1.5" data-testid="org-signature-fields">
|
||||
{SIGNATURE_FIELDS.map((field) => {
|
||||
const used = usedFields.includes(field);
|
||||
return (
|
||||
<li key={field} className={cn('flex flex-wrap items-center gap-x-2 text-sm', used ? 'text-foreground' : 'text-muted-foreground')} data-used={used || undefined}>
|
||||
<Check className={cn('h-4 w-4 shrink-0', used ? 'text-primary' : 'opacity-0')} aria-hidden="true" />
|
||||
<span>{FIELD_LABELS[field]}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{used ? 'asked for' : <>add <code className="font-mono">{`{${field}}`}</code> to ask for it</>}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="disclaimer"
|
||||
title="Disclaimer"
|
||||
description="Added below everything, the quoted message included, when a message is sent. Never in drafts. Users see it under the editor and can't remove it."
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<TemplateField
|
||||
draft={draft}
|
||||
label="HTML disclaimer"
|
||||
hint="Goes into the HTML part, below a line. The placeholders work here too."
|
||||
value={disclaimerHtml.value}
|
||||
own={disclaimerHtml.own}
|
||||
everyone={disclaimerHtml.everyone}
|
||||
rows={4}
|
||||
testId="org-disclaimer-html"
|
||||
onChange={(value) => draft.setOrgValue('disclaimer', 'html', value)}
|
||||
onReset={() => draft.clearOrgValue('disclaimer', 'html')}
|
||||
onFocusField={(el) => { focused.current = { el, apply: (value) => draft.setOrgValue('disclaimer', 'html', value) }; }}
|
||||
/>
|
||||
<TemplateField
|
||||
draft={draft}
|
||||
label="Plain-text disclaimer"
|
||||
hint="Goes into the text part, after a blank line. Leave it empty to send the HTML disclaimer as text."
|
||||
value={disclaimerText.value}
|
||||
own={disclaimerText.own}
|
||||
everyone={disclaimerText.everyone}
|
||||
rows={3}
|
||||
testId="org-disclaimer-text"
|
||||
onChange={(value) => draft.setOrgValue('disclaimer', 'text', value)}
|
||||
onReset={() => draft.clearOrgValue('disclaimer', 'text')}
|
||||
onFocusField={(el) => { focused.current = { el, apply: (value) => draft.setOrgValue('disclaimer', 'text', value) }; }}
|
||||
/>
|
||||
{sampleDisclaimer && <TemplatePreview html={sampleDisclaimer.html} text={sampleDisclaimer.text} testId="org-disclaimer-sample" />}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Bulwark adds the signature and the disclaimer in the browser. Mail sent from other apps over IMAP and SMTP gets neither.
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Plus, RotateCcw, Trash2 } from '@/components/icons';
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { TemplateBodyPreview } from '@/components/templates/template-body-preview';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { OrgTemplate } from '@/lib/admin/organisation-policy';
|
||||
import { restrictionKeysForPage } from '@/lib/admin/restriction-keys';
|
||||
import { cn, generateUUID } from '@/lib/utils';
|
||||
import { RestrictionRow } from '../org-page';
|
||||
import type { OrgPolicyDraft } from '../use-org-policy-draft';
|
||||
|
||||
const inputClass =
|
||||
'block w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ' +
|
||||
'placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 ' +
|
||||
'focus-visible:ring-ring focus-visible:border-ring';
|
||||
const labelClass = 'mb-1 block text-xs font-medium text-foreground';
|
||||
|
||||
/** Same cap as the sanitiser (organisation-policy.ts): more are not saved. */
|
||||
const MAX_LIBRARY = 200;
|
||||
|
||||
function TemplateEditor({ template, onChange, onRemove }: {
|
||||
template: OrgTemplate;
|
||||
onChange: (template: OrgTemplate) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [open, setOpen] = useState(!template.name);
|
||||
const set = (patch: Partial<OrgTemplate>) => onChange({ ...template, ...patch });
|
||||
return (
|
||||
<li className="rounded-md border border-border" data-testid="org-template-item">
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-expanded={open}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded px-1 py-1 text-start text-sm hover:bg-muted"
|
||||
>
|
||||
{open ? <ChevronDown className="h-4 w-4 shrink-0" /> : <ChevronRight className="h-4 w-4 shrink-0" />}
|
||||
<span className={cn('truncate font-medium', !template.name && 'text-muted-foreground')}>{template.name || 'New template'}</span>
|
||||
{template.category && <span className="max-w-[40%] shrink-0 truncate rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">{template.category}</span>}
|
||||
</button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="h-8 shrink-0 px-2 hover:text-red-600" aria-label={`Remove ${template.name || 'template'}`}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{!template.name.trim() && (
|
||||
<p className="px-3 pb-2 text-xs text-red-600 dark:text-red-400">A template without a name is not saved. Give it one.</p>
|
||||
)}
|
||||
{open && (
|
||||
<div className="space-y-3 border-t border-border p-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor={`${id}-name`} className={labelClass}>Name</label>
|
||||
<input id={`${id}-name`} data-testid="org-template-name" className={inputClass} value={template.name} maxLength={200} onChange={(e) => set({ name: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-category`} className={labelClass}>Category</label>
|
||||
<input id={`${id}-category`} className={inputClass} value={template.category} maxLength={100} onChange={(e) => set({ category: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-subject`} className={labelClass}>Subject</label>
|
||||
<input id={`${id}-subject`} data-testid="org-template-subject" className={inputClass} value={template.subject} onChange={(e) => set({ subject: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-body`} className={labelClass}>Text</label>
|
||||
<textarea
|
||||
id={`${id}-body`}
|
||||
data-testid="org-template-body"
|
||||
rows={6}
|
||||
className={cn(inputClass, template.isHTML && 'font-mono text-xs')}
|
||||
value={template.body}
|
||||
onChange={(e) => set({ body: e.target.value })}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Placeholders such as {'{{recipient_name}}'} work as in users' own templates: users fill them in when they pick the template.
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-foreground">
|
||||
<input type="checkbox" className="rounded border-input" checked={!!template.isHTML} onChange={(e) => set({ isHTML: e.target.checked })} />
|
||||
The text is HTML
|
||||
</label>
|
||||
<div>
|
||||
<p className={labelClass}>Preview</p>
|
||||
<div className="rounded-md border border-border bg-background p-3" data-testid="org-template-preview">
|
||||
{template.subject && <p className="mb-2 text-sm font-medium">{template.subject}</p>}
|
||||
{template.body ? <TemplateBodyPreview template={template} /> : <p className="text-sm text-muted-foreground">No text</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Organisation > Templates (feature 3): the admin's template library
|
||||
* (policy.json `organisation.templates.library`) at the selected scope, and
|
||||
* the restrictions on users' own and team templates. A scope's library adds
|
||||
* to Everyone's by id; the same id replaces Everyone's template for that
|
||||
* scope (mergeOrganisation).
|
||||
*/
|
||||
export function TemplatesSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
const library = draft.orgValue('templates', 'library');
|
||||
const inScope = !!draft.scope;
|
||||
const own: OrgTemplate[] = inScope ? (library.own ? library.value : []) : library.value;
|
||||
const everyone = library.everyone;
|
||||
const ownIds = new Set(own.map((t) => t.id));
|
||||
|
||||
const setOwn = (next: OrgTemplate[]) => {
|
||||
if (inScope && next.length === 0) draft.clearOrgValue('templates', 'library');
|
||||
else draft.setOrgValue('templates', 'library', next);
|
||||
};
|
||||
const add = () => setOwn([...own, { id: generateUUID(), name: '', subject: '', body: '', isHTML: false, category: '' }]);
|
||||
const change = (index: number, template: OrgTemplate) => setOwn(own.map((t, i) => (i === index ? template : t)));
|
||||
const remove = (index: number) => setOwn(own.filter((_, i) => i !== index));
|
||||
const replaceInScope = (template: OrgTemplate) => setOwn([...own, { ...template }]);
|
||||
|
||||
const total = new Set([...everyone.map((t) => t.id), ...own.map((t) => t.id)]).size;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
id="library"
|
||||
title="Organisation library"
|
||||
description="Users see these templates under “Organisation” in the template picker and in Settings > Templates. They can use and copy them, not change them."
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{inScope && (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-foreground">From Everyone</p>
|
||||
{everyone.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Everyone's library is empty.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5" data-testid="org-template-inherited">
|
||||
{everyone.map((template) => (
|
||||
<li key={template.id} className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-dashed border-border px-3 py-2 text-sm">
|
||||
<span className="min-w-0 truncate">{template.name}</span>
|
||||
{ownIds.has(template.id) ? (
|
||||
<span className="text-xs text-muted-foreground">Changed for this scope</span>
|
||||
) : (
|
||||
<button type="button" onClick={() => replaceInScope(template)} className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-2 hover:underline">
|
||||
<RotateCcw className="h-3 w-3" /> Change for this scope
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-4 mb-1 text-sm font-medium text-foreground">For this scope</p>
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Users in this scope get Everyone's templates and these. A template changed here replaces Everyone's for this scope only.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{own.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground" data-testid="org-template-empty">
|
||||
{inScope ? 'Nothing added for this scope.' : 'No templates yet. Users only have their own.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2" data-testid="org-template-list">
|
||||
{own.map((template, index) => (
|
||||
<TemplateEditor key={template.id} template={template} onChange={(t) => change(index, t)} onRemove={() => remove(index)} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="button" variant="outline" size="sm" onClick={add} disabled={total >= MAX_LIBRARY} data-testid="org-template-add">
|
||||
<Plus className="me-1 h-4 w-4" /> Add template
|
||||
</Button>
|
||||
{total >= MAX_LIBRARY && <span className="text-xs text-muted-foreground">A library holds at most {MAX_LIBRARY} templates.</span>}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="user-templates"
|
||||
title="Personal and team templates"
|
||||
description="On Stalwart 1.0 and later, users' own templates are kept in their account, and the members of a group mailbox share its team templates. On older servers, users keep their own templates in the browser and there are no team templates."
|
||||
>
|
||||
<div>
|
||||
{restrictionKeysForPage('org-templates').map((def) => <RestrictionRow key={def.key} draft={draft} def={def} />)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Loader2 } from '@/components/icons';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
||||
import { ConfigNote, RevertButton, SourceBadge } from '@/components/admin/config-items';
|
||||
import { AdminLink } from '@/components/admin/admin-link';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import type { OutgoingHost } from '@/lib/admin/outgoing-hosts-registry';
|
||||
import type { ReachableHost } from '@/lib/admin/reachable-hosts';
|
||||
import type { OrgPolicyDraft } from '../use-org-policy-draft';
|
||||
|
||||
interface OutgoingHostsState {
|
||||
noThirdPartyTraffic: boolean;
|
||||
source: 'env' | 'admin' | 'default';
|
||||
readOnly: boolean;
|
||||
envLocked: boolean;
|
||||
builtIn: OutgoingHost[];
|
||||
reachable: ReachableHost[];
|
||||
networkPlugins: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
const SIDE_LABEL: Record<ReachableHost['side'], string> = {
|
||||
server: 'this server',
|
||||
browser: 'the browser',
|
||||
both: 'server and browser',
|
||||
};
|
||||
|
||||
/**
|
||||
* Organisation > Security > Third-party traffic (#903). The switch is config,
|
||||
* not policy: it is saved through /api/admin/config like the Server page's
|
||||
* keys, applies to the whole server whatever scope the scope bar shows, and
|
||||
* is read-only when the environment or a read-only config sets it.
|
||||
*/
|
||||
export function ThirdPartySection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
const [state, setState] = useState<OutgoingHostsState | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { setMessage } = draft;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/outgoing-hosts');
|
||||
if (!res.ok) throw new Error();
|
||||
setState(await res.json());
|
||||
setFailed(false);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
async function save(on: boolean) {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ noThirdPartyTraffic: on }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: on
|
||||
? 'Third-party traffic is off. The server stops at once; browsers follow the next time Bulwark loads.'
|
||||
: 'Third-party traffic is allowed again.',
|
||||
});
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setMessage({ type: 'error', text: data.error || 'Could not save the setting' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Could not save the setting' });
|
||||
} finally {
|
||||
await load();
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function revert() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: 'noThirdPartyTraffic' }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setMessage({ type: 'error', text: data.error || 'Could not revert the setting' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Could not revert the setting' });
|
||||
} finally {
|
||||
await load();
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const on = !!state?.noThirdPartyTraffic;
|
||||
const locked = !!state && (state.readOnly || state.envLocked);
|
||||
const relays = state?.reachable.filter((h) => h.kind === 'Push relay') ?? [];
|
||||
|
||||
return (
|
||||
<SettingsSection id="third-party" title="Third-party traffic" description="Whether Bulwark connects to anything outside your own servers.">
|
||||
<p className="text-sm text-muted-foreground" data-testid="third-party-scope-note">
|
||||
This switch is for the whole server, not per domain or group: most of what it stops, such as telemetry and the version check, runs for no particular user.
|
||||
</p>
|
||||
|
||||
{!state && !failed && (
|
||||
<div className="flex items-center py-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
)}
|
||||
{failed && <p className="text-sm text-destructive">Could not load the outgoing hosts.</p>}
|
||||
|
||||
{state && (
|
||||
<>
|
||||
<SettingItem
|
||||
label="No third-party traffic"
|
||||
description="Bulwark and your users' browsers stop contacting services outside your own servers: telemetry, the version check, the built-in push relay, the official extension directory, the connector, sender icons, MyMemory translation, map links and OpenPGP key lookups at other domains. Plugins that declare network access don't run; the others do. The page's security policy then allows only the hosts listed below."
|
||||
>
|
||||
<div className="flex items-center gap-1.5" data-testid="third-party-switch">
|
||||
<SourceBadge source={state.source} label={state.source === 'env' ? 'NO_THIRD_PARTY_TRAFFIC' : 'config.json'} />
|
||||
<ToggleSwitch checked={on} disabled={saving || locked} onChange={(checked) => void save(checked)} />
|
||||
{state.source === 'admin' && !locked && <RevertButton onClick={() => void revert()} />}
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{state.envLocked && (
|
||||
<ConfigNote>Set by NO_THIRD_PARTY_TRAFFIC in the environment. Change it there and restart Bulwark.</ConfigNote>
|
||||
)}
|
||||
{state.readOnly && !state.envLocked && (
|
||||
<ConfigNote>The configuration is read-only. Set noThirdPartyTraffic in config.json, or NO_THIRD_PARTY_TRAFFIC in the environment.</ConfigNote>
|
||||
)}
|
||||
{on && relays.length === 0 && (
|
||||
<ConfigNote>
|
||||
Push notifications now need a relay you run yourself. Add one under{' '}
|
||||
<AdminLink href="/admin/policy" className="underline underline-offset-2">User policy > Push relays</AdminLink>;
|
||||
until then users get new mail only while Bulwark is open.
|
||||
</ConfigNote>
|
||||
)}
|
||||
{on && state.networkPlugins.length > 0 && (
|
||||
<ConfigNote>
|
||||
Not running because they declare network access: {state.networkPlugins.map((p) => p.name).join(', ')}.
|
||||
</ConfigNote>
|
||||
)}
|
||||
|
||||
<div data-testid="third-party-reachable">
|
||||
<h4 className="text-sm font-medium text-foreground">{on ? 'Still reachable' : 'Stays reachable with the switch on'}</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Hosts you configured yourself, and what users enter themselves.</p>
|
||||
{state.reachable.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">None configured.</p>
|
||||
) : (
|
||||
<ul className="mt-2 divide-y divide-border rounded-md border border-border">
|
||||
{state.reachable.map((h) => (
|
||||
<li key={`${h.kind}|${h.host}|${h.setIn}`} className="flex flex-col gap-0.5 px-3 py-2 text-sm sm:flex-row sm:flex-wrap sm:items-baseline sm:gap-x-3">
|
||||
<span className="font-medium text-foreground sm:w-44 sm:shrink-0">{h.kind}</span>
|
||||
<span className={h.origin ? 'min-w-0 break-all font-mono text-xs text-foreground' : 'min-w-0 text-xs italic text-foreground'}>{h.host}</span>
|
||||
<span className="text-xs text-muted-foreground sm:ms-auto sm:text-end">{h.setIn} · {SIDE_LABEL[h.side]}</span>
|
||||
{h.note && <span className="text-xs text-amber-700 dark:text-amber-400 sm:basis-full">{h.note}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<details data-testid="third-party-builtin">
|
||||
<summary className="cursor-pointer text-sm font-medium text-foreground">
|
||||
Built-in outside hosts ({state.builtIn.length}){on ? ', all off' : ''}
|
||||
</summary>
|
||||
<ul className="mt-2 divide-y divide-border rounded-md border border-border">
|
||||
{state.builtIn.map((h) => (
|
||||
<li key={h.id} className="space-y-0.5 px-3 py-2 text-sm">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="break-all font-mono text-xs text-foreground">{h.host}</span>
|
||||
<span className="text-xs text-muted-foreground">{h.side === 'server' ? 'this server' : 'the browser'}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{h.usedBy}</p>
|
||||
<p className="text-xs text-muted-foreground">{on ? h.inMode : `Without the switch: ${h.off}`}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { create } from 'zustand';
|
||||
import type { FeatureGates, SettingRestriction, SettingsPolicy } from '@/lib/admin/types';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import {
|
||||
DEFAULT_ORGANISATION_POLICY,
|
||||
sanitizeOrganisationPolicy,
|
||||
type OrganisationPolicy,
|
||||
type OrganisationSection,
|
||||
} from '@/lib/admin/organisation-policy';
|
||||
import type { PolicyScope } from '@/lib/admin/policy-scopes';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
|
||||
/** The scope id the Organisation pages edit; '' is Everyone (the global policy). */
|
||||
export const EVERYONE = '';
|
||||
|
||||
/** The scope picked on one Organisation page stays picked on the others. */
|
||||
export const useOrgScopeStore = create<{ scopeId: string; setScopeId: (id: string) => void }>()((set) => ({
|
||||
scopeId: EVERYONE,
|
||||
setScopeId: (scopeId) => set({ scopeId }),
|
||||
}));
|
||||
|
||||
type SectionValues<S extends OrganisationSection> = OrganisationPolicy[S];
|
||||
|
||||
export interface OrgValue<T> {
|
||||
/** What applies at the selected scope: its own value, or the inherited one. */
|
||||
value: T;
|
||||
/** The selected scope sets it itself (always true for Everyone). */
|
||||
own: boolean;
|
||||
/** Everyone's value, shown as "From Everyone" in a scope. */
|
||||
everyone: T;
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading, editing and saving the user policy for one Organisation page.
|
||||
* Edits go to the scope chosen in the scope bar (Everyone or one domain or
|
||||
* group scope); in a scope, a value the scope doesn't set shows Everyone's
|
||||
* and can be reset to it. Saving writes the whole policy, so the User
|
||||
* policy page and every Organisation page work on the same policy.json.
|
||||
*/
|
||||
export function useOrgPolicyDraft(dirtyId: string) {
|
||||
const [policy, setPolicy] = useState<SettingsPolicy | null>(null);
|
||||
const [saved, setSaved] = useState<SettingsPolicy | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// Outcomes show in a toast, like every admin page's.
|
||||
const setMessage = useAdminFeedback();
|
||||
const scopeId = useOrgScopeStore((s) => s.scopeId);
|
||||
const setScopeId = useOrgScopeStore((s) => s.setScopeId);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/policy');
|
||||
if (!res.ok) throw new Error();
|
||||
const data = (await res.json()) as SettingsPolicy;
|
||||
const normalised = { ...DEFAULT_POLICY, ...data, organisation: sanitizeOrganisationPolicy(data.organisation), scopes: data.scopes ?? [] };
|
||||
setPolicy(normalised);
|
||||
setSaved(clone(normalised));
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Could not load the policy' });
|
||||
setPolicy((p) => p ?? { ...DEFAULT_POLICY, organisation: clone(DEFAULT_ORGANISATION_POLICY), scopes: [] });
|
||||
}
|
||||
}, [setMessage]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const scopes = useMemo(() => policy?.scopes ?? [], [policy]);
|
||||
const scope = scopes.find((s) => s.id === scopeId) ?? null;
|
||||
// A scope deleted elsewhere (or on another page) falls back to Everyone.
|
||||
useEffect(() => {
|
||||
if (policy && scopeId !== EVERYONE && !scope) setScopeId(EVERYONE);
|
||||
}, [policy, scopeId, scope, setScopeId]);
|
||||
|
||||
const dirty = !!policy && !!saved && JSON.stringify(policy) !== JSON.stringify(saved);
|
||||
useAdminUnsavedChanges(dirtyId, dirty);
|
||||
|
||||
const update = useCallback((change: (draft: SettingsPolicy) => void) => {
|
||||
setPolicy((current) => {
|
||||
if (!current) return current;
|
||||
const draft = clone(current);
|
||||
change(draft);
|
||||
return draft;
|
||||
});
|
||||
setMessage(null);
|
||||
}, [setMessage]);
|
||||
|
||||
const updateScope = useCallback((id: string, change: (scope: PolicyScope) => void) => {
|
||||
update((draft) => {
|
||||
const target = draft.scopes?.find((s) => s.id === id);
|
||||
if (target) change(target);
|
||||
});
|
||||
}, [update]);
|
||||
|
||||
const org = policy?.organisation ?? DEFAULT_ORGANISATION_POLICY;
|
||||
|
||||
function orgValue<S extends OrganisationSection, K extends keyof SectionValues<S>>(section: S, key: K): OrgValue<SectionValues<S>[K]> {
|
||||
const everyone = org[section][key];
|
||||
if (!scope) return { value: everyone, own: true, everyone };
|
||||
const override = (scope.policy.organisation?.[section] as Partial<SectionValues<S>> | undefined)?.[key];
|
||||
return override === undefined
|
||||
? { value: everyone, own: false, everyone }
|
||||
: { value: override as SectionValues<S>[K], own: true, everyone };
|
||||
}
|
||||
|
||||
function setOrgValue<S extends OrganisationSection, K extends keyof SectionValues<S>>(section: S, key: K, value: SectionValues<S>[K]) {
|
||||
if (!scope) {
|
||||
update((draft) => {
|
||||
const target = (draft.organisation ?? clone(DEFAULT_ORGANISATION_POLICY)) as OrganisationPolicy;
|
||||
(target[section] as SectionValues<S>)[key] = value;
|
||||
draft.organisation = target;
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateScope(scope.id, (s) => {
|
||||
const organisation = (s.policy.organisation ??= {}) as Record<string, Record<string, unknown>>;
|
||||
(organisation[section] ??= {})[key as string] = value;
|
||||
});
|
||||
}
|
||||
|
||||
/** In a scope: use Everyone's value again. */
|
||||
function clearOrgValue<S extends OrganisationSection>(section: S, key: keyof SectionValues<S>) {
|
||||
if (!scope) return;
|
||||
updateScope(scope.id, (s) => {
|
||||
const values = s.policy.organisation?.[section] as Record<string, unknown> | undefined;
|
||||
if (!values) return;
|
||||
delete values[key as string];
|
||||
if (Object.keys(values).length === 0) delete (s.policy.organisation as Record<string, unknown>)[section];
|
||||
});
|
||||
}
|
||||
|
||||
function restriction(key: string): OrgValue<SettingRestriction | undefined> {
|
||||
const everyone = policy?.restrictions[key];
|
||||
if (!scope) return { value: everyone, own: true, everyone };
|
||||
const override = scope.policy.restrictions?.[key];
|
||||
return override === undefined ? { value: everyone, own: false, everyone } : { value: override, own: true, everyone };
|
||||
}
|
||||
|
||||
/** Sets the restriction at the selected scope; undefined removes it (in a scope: inherit). */
|
||||
function setRestriction(key: string, value: SettingRestriction | undefined) {
|
||||
const empty = !value || (!value.locked && !value.hidden && !value.allowedValues?.length && value.min === undefined && value.max === undefined);
|
||||
if (!scope) {
|
||||
update((draft) => {
|
||||
if (empty) delete draft.restrictions[key];
|
||||
else draft.restrictions[key] = value!;
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateScope(scope.id, (s) => {
|
||||
const restrictions = (s.policy.restrictions ??= {});
|
||||
// In a scope, "nothing" is a real override: it unlocks what Everyone locks.
|
||||
if (value === undefined) delete restrictions[key];
|
||||
else restrictions[key] = empty ? {} : value;
|
||||
});
|
||||
}
|
||||
|
||||
function feature(key: keyof FeatureGates): OrgValue<boolean> {
|
||||
const everyone = policy?.features[key] ?? DEFAULT_POLICY.features[key];
|
||||
if (!scope) return { value: everyone, own: true, everyone };
|
||||
const override = scope.policy.features?.[key];
|
||||
return override === undefined ? { value: everyone, own: false, everyone } : { value: override, own: true, everyone };
|
||||
}
|
||||
|
||||
function setFeature(key: keyof FeatureGates, value: boolean | undefined) {
|
||||
if (!scope) {
|
||||
update((draft) => { draft.features[key] = value ?? DEFAULT_POLICY.features[key]; });
|
||||
return;
|
||||
}
|
||||
updateScope(scope.id, (s) => {
|
||||
const features = (s.policy.features ??= {});
|
||||
if (value === undefined) delete features[key];
|
||||
else features[key] = value;
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!policy) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/policy', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(policy),
|
||||
});
|
||||
if (res.ok) {
|
||||
// The server sanitises; show what it kept.
|
||||
await load();
|
||||
setMessage({ type: 'success', text: 'Saved. Users get the change the next time Bulwark loads.' });
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setMessage({ type: 'error', text: data.error || 'Could not save the policy' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Could not save the policy' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function discard() {
|
||||
if (saved) setPolicy(clone(saved));
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
return {
|
||||
policy,
|
||||
loading: policy === null,
|
||||
scopes,
|
||||
scope,
|
||||
scopeId: scope ? scope.id : EVERYONE,
|
||||
setScopeId,
|
||||
update,
|
||||
updateScope,
|
||||
orgValue,
|
||||
setOrgValue,
|
||||
clearOrgValue,
|
||||
restriction,
|
||||
setRestriction,
|
||||
feature,
|
||||
setFeature,
|
||||
dirty,
|
||||
saving,
|
||||
save,
|
||||
discard,
|
||||
setMessage,
|
||||
reload: load,
|
||||
};
|
||||
}
|
||||
|
||||
export type OrgPolicyDraft = ReturnType<typeof useOrgPolicyDraft>;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { EventDetailPopover } from '../event-detail-popover';
|
||||
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
// #903: an address opens Google Maps only while third-party traffic is allowed.
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
useFormatter: () => ({ dateTime: (d: Date | string) => String(d), relativeTime: (d: Date | string) => String(d), number: (n: number) => String(n) }),
|
||||
}));
|
||||
|
||||
const calendar = { id: 'cal-1', name: 'Work', myRights: { mayWriteAll: true, mayWriteOwn: true } } as unknown as Calendar;
|
||||
|
||||
function renderPopover() {
|
||||
const event = {
|
||||
id: 'evt-1', calendarIds: { 'cal-1': true }, title: 'Visit', start: '2026-11-20T10:00:00', duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin', showWithoutTime: false, participants: null, alerts: null, useDefaultAlerts: false,
|
||||
locations: { l1: { name: 'Hauptstrasse 1, Berlin' } },
|
||||
} as unknown as CalendarEvent;
|
||||
render(
|
||||
<EventDetailPopover event={event} calendar={calendar} anchorRect={new DOMRect(100, 100, 50, 20)}
|
||||
onEdit={vi.fn()} onDelete={vi.fn()} onDuplicate={vi.fn()} onClose={vi.fn()} onSaveNote={vi.fn()} />,
|
||||
);
|
||||
return screen.getByTitle('Hauptstrasse 1, Berlin');
|
||||
}
|
||||
|
||||
afterEach(() => setNoThirdPartyTraffic(false));
|
||||
|
||||
describe('event address link', () => {
|
||||
it('opens Google Maps with third-party traffic allowed', () => {
|
||||
expect(renderPopover().getAttribute('href')).toMatch(/^https:\/\/www\.google\.com\/maps\//);
|
||||
});
|
||||
|
||||
it('is plain text with no third-party traffic', () => {
|
||||
setNoThirdPartyTraffic(true);
|
||||
expect(renderPopover().getAttribute('href')).toBeNull();
|
||||
});
|
||||
|
||||
it('is plain text while the config is still unknown', () => {
|
||||
setNoThirdPartyTraffic('unknown');
|
||||
expect(renderPopover().getAttribute('href')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { EventModal } from '../event-modal';
|
||||
import { ExternalRecipientConfirmHost } from '@/components/email/external-recipient-confirm';
|
||||
import type { Calendar, CalendarEvent, Identity } from '@/lib/jmap/types';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store';
|
||||
import { resetSendPipeline } from '@/lib/outgoing/send-pipeline';
|
||||
|
||||
// Saving a meeting sends its invitations (the server does, on save), so a
|
||||
// newly invited person outside the organisation gets the same confirm as a
|
||||
// message. People already invited aren't asked about again.
|
||||
|
||||
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
|
||||
|
||||
const calendars = [{
|
||||
id: 'cal', name: 'Personal', color: '#123456', isDefault: true,
|
||||
myRights: {
|
||||
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
|
||||
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
|
||||
},
|
||||
}] as unknown as Calendar[];
|
||||
|
||||
const party = (address: string, owner: boolean) => ({
|
||||
'@type': 'Participant', calendarAddress: `mailto:${address}`, email: address,
|
||||
roles: owner ? { owner: true } : { attendee: true },
|
||||
participationStatus: owner ? 'accepted' : 'needs-action', scheduleAgent: 'server', expectReply: !owner, kind: 'individual',
|
||||
});
|
||||
|
||||
// The user's meeting, already sent to a partner outside.
|
||||
const meeting = {
|
||||
id: 'e1', uid: 'u1', '@type': 'Event', title: 'Planning', description: '', start: '2026-10-12T10:00:00',
|
||||
duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, calendarIds: { cal: true },
|
||||
organizerCalendarAddress: 'mailto:me@example.com', alerts: null, recurrenceRules: null,
|
||||
participants: { org: party('me@example.com', true), partner: party('partner@partner.example', false) },
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
function setWarning(on: boolean) {
|
||||
usePolicyStore.setState({
|
||||
policy: {
|
||||
...DEFAULT_POLICY,
|
||||
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, recipientWarning: on } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setWarning(true);
|
||||
useAuthStore.setState({ identities: [{ id: 'i1', name: 'Me', email: 'me@example.com' } as unknown as Identity] });
|
||||
useContactStore.setState({ contacts: [], contactsLoaded: true });
|
||||
useExternalRecipientConfirmStore.setState({ pending: null, queue: [] });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetSendPipeline();
|
||||
});
|
||||
|
||||
function editWithNewInvitee(onSave: ReturnType<typeof vi.fn>, invitee: string) {
|
||||
render(
|
||||
<>
|
||||
<EventModal event={meeting} calendars={calendars} onSave={onSave as never} onClose={vi.fn()} currentUserEmails={["me@example.com"]} />
|
||||
<ExternalRecipientConfirmHost />
|
||||
</>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('events.edit'));
|
||||
fireEvent.change(screen.getByPlaceholderText('email_placeholder'), { target: { value: invitee } });
|
||||
fireEvent.click(screen.getByText('form.save'));
|
||||
}
|
||||
|
||||
describe('invitations to people outside the organisation', () => {
|
||||
it('asks about the new external invitee only, and Back saves nothing', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
editWithNewInvitee(onSave, 'org-dave@org-two.test');
|
||||
await waitFor(() => expect(useExternalRecipientConfirmStore.getState().pending).not.toBeNull());
|
||||
expect(useExternalRecipientConfirmStore.getState().pending?.recipients.map((r) => r.address)).toEqual(['org-dave@org-two.test']);
|
||||
act(() => useExternalRecipientConfirmStore.getState().answer(false));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves and sends after Send anyway', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
editWithNewInvitee(onSave, 'org-dave@org-two.test');
|
||||
await waitFor(() => expect(useExternalRecipientConfirmStore.getState().pending).not.toBeNull());
|
||||
act(() => useExternalRecipientConfirmStore.getState().answer(true));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
expect(onSave.mock.calls[0][1]).toBe(true);
|
||||
});
|
||||
|
||||
it('saves without asking for an internal invitee or with the warning off', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
editWithNewInvitee(onSave, 'colleague@example.com');
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
cleanup();
|
||||
setWarning(false);
|
||||
const second = vi.fn().mockResolvedValue(undefined);
|
||||
editWithNewInvitee(second, 'org-dave@org-two.test');
|
||||
await waitFor(() => expect(second).toHaveBeenCalledTimes(1));
|
||||
expect(useExternalRecipientConfirmStore.getState().pending).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib
|
||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
|
||||
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links";
|
||||
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { eventToView } from "@/lib/plugin-projection";
|
||||
import { ParticipantStatusBadge, RsvpButtons } from "./rsvp-buttons";
|
||||
@@ -157,6 +158,8 @@ export function EventDetailPopover({
|
||||
const isMultiDay = !isSameDay(startDate, displayEndDate);
|
||||
|
||||
const locationName = useMemo(() => primaryLocationName(event) ?? null, [event]);
|
||||
// Re-computes the location link once the config is known (#903).
|
||||
const noThirdParty = useNoThirdPartyTraffic();
|
||||
|
||||
// Invitations (Teams above all) leave virtualLocations empty and bury the
|
||||
// join URL in the description; surface it as the meeting link anyway.
|
||||
@@ -169,8 +172,10 @@ export function EventDetailPopover({
|
||||
const locationHref = useMemo(() => {
|
||||
if (!locationName) return null;
|
||||
const action = locationAction(locationName, meeting);
|
||||
return action.kind === "url" ? action.uri : mapsUrl(action.query);
|
||||
}, [locationName, meeting]);
|
||||
if (action.kind === "url") return action.uri;
|
||||
// No Google Maps link while third-party traffic is off (#903).
|
||||
return noThirdParty ? null : mapsUrl(action.query);
|
||||
}, [locationName, meeting, noThirdParty]);
|
||||
const [locationCopied, setLocationCopied] = useState(false);
|
||||
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => () => {
|
||||
@@ -504,7 +509,7 @@ export function EventDetailPopover({
|
||||
href={locationHref ?? undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary hover:underline min-w-0 break-words"
|
||||
className={cn("text-sm min-w-0 break-words", locationHref ? "text-primary hover:underline" : "text-foreground")}
|
||||
dir="auto"
|
||||
title={locationName}
|
||||
>
|
||||
|
||||
@@ -39,6 +39,7 @@ import { buildDuplicateEventData } from "@/lib/calendar-duplicate";
|
||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||
import { useIsPaneScoped } from "@/hooks/use-pane-context";
|
||||
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
|
||||
import { confirmInvitationRecipients } from "@/lib/outgoing/external-recipients";
|
||||
import {
|
||||
calendarFormHooks,
|
||||
calendarHooks,
|
||||
@@ -891,6 +892,14 @@ export function EventModal({
|
||||
}
|
||||
|
||||
const shouldSendScheduling = userIsOrganizer && effectiveAttendees.length > 0 && sendInvitations;
|
||||
// Invitations are mail too: newly invited people outside the
|
||||
// organisation get the same confirm as a message (Back keeps the
|
||||
// editor open, nothing saved).
|
||||
if (shouldSendScheduling) {
|
||||
const known = new Set(initialAttendees.map((a) => a.email.trim().toLowerCase()));
|
||||
const invited = effectiveAttendees.map((a) => a.email).filter((email) => email && !known.has(email.trim().toLowerCase()));
|
||||
if (!(await confirmInvitationRecipients(invited))) return;
|
||||
}
|
||||
await onSave(data, shouldSendScheduling, {
|
||||
initialAttendees,
|
||||
attendees: effectiveAttendees,
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { resetSendPipeline } from '@/lib/outgoing/send-pipeline';
|
||||
import { ExternalRecipientConfirmHost } from '../external-recipient-confirm';
|
||||
import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
|
||||
// ─── Heavy component mocks (mirrors composer-reopened-draft-signature.test.tsx) ─
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [
|
||||
{ id: 'id-me', email: 'me@example.com', name: 'Me' },
|
||||
{ id: 'id-sales', email: 'sales@example.com', name: 'Sales' },
|
||||
] as Array<Record<string, unknown>>,
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
sendDelaySeconds: 0,
|
||||
// The default position: replies/forwards get the signature appended at
|
||||
// send time rather than embedded above the quote.
|
||||
signaturePosition: 'below_quote',
|
||||
signatureSeparatorEnabled: true,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
addressBooks: [],
|
||||
trustedSendersBookId: null,
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeSignatureHtmlForDisplay: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
sanitizePluginBodyHtml: (v: string) => v,
|
||||
escapeHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// The external-recipient confirm in the real composer: the check is
|
||||
// registered by the dialog host, asks only about the recipients outside the
|
||||
// organisation, and the composer sends or not by the user's answer.
|
||||
|
||||
const DRAFT = {
|
||||
to: 'bob@example.com, dave@org-two.test',
|
||||
cc: 'carol@example.com',
|
||||
bcc: '',
|
||||
subject: 'Hello',
|
||||
body: '<p>Hi</p>',
|
||||
showCc: true,
|
||||
showBcc: false,
|
||||
selectedIdentityId: 'id-me',
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
function setExternalMail(patch: Partial<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
|
||||
usePolicyStore.setState({
|
||||
policy: {
|
||||
...DEFAULT_POLICY,
|
||||
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function clickSend() {
|
||||
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
|
||||
}
|
||||
|
||||
function renderComposer(onSend: () => void) {
|
||||
return render(
|
||||
<>
|
||||
<EmailComposer initialData={DRAFT} onSend={onSend} />
|
||||
<ExternalRecipientConfirmHost />
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('composer: confirm before sending outside the organisation', () => {
|
||||
beforeEach(() => {
|
||||
resetSendPipeline();
|
||||
useExternalRecipientConfirmStore.setState({ pending: null, queue: [] });
|
||||
setExternalMail({ recipientWarning: true });
|
||||
// The user's identities, as the auth store holds them after sign-in:
|
||||
// example.com is internal because of them.
|
||||
const identities = [
|
||||
{ id: 'id-me', email: 'me@example.com', name: 'Me' },
|
||||
{ id: 'id-sales', email: 'sales@example.com', name: 'Sales' },
|
||||
];
|
||||
useAuthStore.setState({ identities } as never);
|
||||
useIdentityStore.setState({ identities } as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetSendPipeline();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('lists only the external recipient and sends nothing on Back', async () => {
|
||||
const onSend = vi.fn();
|
||||
const { container } = renderComposer(onSend);
|
||||
clickSend();
|
||||
const list = await waitFor(() => {
|
||||
const el = container.querySelector('[data-external-recipients]');
|
||||
expect(el).not.toBeNull();
|
||||
return el!;
|
||||
});
|
||||
expect(list.textContent).toContain('dave@org-two.test');
|
||||
expect(list.textContent).not.toContain('bob@example.com');
|
||||
expect(list.textContent).not.toContain('carol@example.com');
|
||||
fireEvent.click(container.querySelector('[data-external-recipient-back]')!);
|
||||
await waitFor(() => expect(container.querySelector('[data-external-recipient-confirm]')).toBeNull());
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends after Send anyway', async () => {
|
||||
const onSend = vi.fn();
|
||||
const { container } = renderComposer(onSend);
|
||||
clickSend();
|
||||
await waitFor(() => expect(container.querySelector('[data-external-recipient-send]')).not.toBeNull());
|
||||
fireEvent.click(container.querySelector('[data-external-recipient-send]')!);
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('leaves the identity\'s automatic Bcc out of the question', async () => {
|
||||
useIdentityStore.setState({
|
||||
identities: [
|
||||
{ id: 'id-me', email: 'me@example.com', name: 'Me', bcc: [{ name: '', email: 'archive@archive-service.example' }] },
|
||||
{ id: 'id-sales', email: 'sales@example.com', name: 'Sales' },
|
||||
],
|
||||
} as never);
|
||||
const { container } = renderComposer(vi.fn());
|
||||
clickSend();
|
||||
const list = await waitFor(() => {
|
||||
const el = container.querySelector('[data-external-recipients]');
|
||||
expect(el).not.toBeNull();
|
||||
return el!;
|
||||
});
|
||||
expect(list.textContent).toContain('dave@org-two.test');
|
||||
expect(list.textContent).not.toContain('archive@archive-service.example');
|
||||
});
|
||||
|
||||
it('sends without asking while the warning is off', async () => {
|
||||
setExternalMail({ recipientWarning: false, senderLabel: true });
|
||||
const onSend = vi.fn();
|
||||
const { container } = renderComposer(onSend);
|
||||
clickSend();
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
expect(container.querySelector('[data-external-recipient-confirm]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,607 @@
|
||||
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { registerOrgOutgoing } from '@/lib/org/outgoing';
|
||||
import { resetSendPipeline } from '@/lib/outgoing/send-pipeline';
|
||||
import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
|
||||
// ─── Heavy component mocks (mirrors composer-reply-signature.test.tsx) ─
|
||||
|
||||
// A stand-in for the TipTap editor: it hands the composer an editor object
|
||||
// whose content is whatever the composer last rendered into it, the way the
|
||||
// real editor reports itself ready from an effect after mounting.
|
||||
const editorState = vi.hoisted(() => ({ html: '' }));
|
||||
// A plugin that rewrites signatures (onSignatureRender), off unless a test turns it on.
|
||||
const signaturePlugin = vi.hoisted(() => ({ on: false }));
|
||||
// Send plugins: a footer added by onTransformOutgoingEmail, and a crypto
|
||||
// plugin taking the send over (onComposeSend).
|
||||
const sendPlugins = vi.hoisted(() => ({
|
||||
footer: false,
|
||||
takeOver: null as null | ((request: { htmlBody: string; textBody: string }) => void),
|
||||
}));
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: ({ content, onChange, onEditorReady }: {
|
||||
content: string;
|
||||
onChange: (html: string) => void;
|
||||
onEditorReady?: (editor: unknown) => void;
|
||||
}) => {
|
||||
editorState.html = content;
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
React.useEffect(() => {
|
||||
onEditorReady?.({
|
||||
isDestroyed: false,
|
||||
commands: {
|
||||
focus: () => {},
|
||||
setContent: (html: string) => {
|
||||
editorState.html = html;
|
||||
onChangeRef.current(html);
|
||||
},
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-html': content });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [
|
||||
{ id: 'id-me', email: 'me@example.com', name: 'Me', htmlSignature: '<b>Alice</b>', textSignature: 'Alice' },
|
||||
{ id: 'id-team', email: 'team@example.com', name: 'Team', htmlSignature: '', textSignature: '' },
|
||||
] as Array<Record<string, unknown>>,
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook, notifyIdentitySelected: () => {} };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: false,
|
||||
signatureDetails: { title: '', department: '', phone: '', mobile: '' },
|
||||
sendDelaySeconds: 0,
|
||||
// The default position: replies/forwards get the signature appended at
|
||||
// send time rather than embedded above the quote.
|
||||
signaturePosition: 'below_quote',
|
||||
signatureSeparatorEnabled: true,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
externalContentPolicy: 'block',
|
||||
isSenderTrusted: () => false,
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: {
|
||||
get size() { return signaturePlugin.on ? 1 : 0; },
|
||||
transform: async (value: { html: string; text: string }) => ({ html: `${value.html}<p>PLUGIN</p>`, text: `${value.text} PLUGIN` }),
|
||||
},
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: {
|
||||
takeOver: async (request: { htmlBody: string; textBody: string }) => {
|
||||
if (!sendPlugins.takeOver) return 'default';
|
||||
sendPlugins.takeOver(request);
|
||||
return 'handled';
|
||||
},
|
||||
},
|
||||
onTransformOutgoingEmail: {
|
||||
transform: async (email: { htmlBody: string; textBody: string }) => (sendPlugins.footer
|
||||
? { ...email, htmlBody: `${email.htmlBody}<p>Plugin footer</p>`, textBody: `${email.textBody}\n\nPlugin footer` }
|
||||
: email),
|
||||
},
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeSignatureHtmlForDisplay: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
sanitizePluginBodyHtml: (v: string) => v,
|
||||
escapeHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: (html: string) => `<div data-quoted-html="">${html}</div>`,
|
||||
serializeEditorContent: () => editorState.html,
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The organisation signature (lib/org-signature.ts) goes in where the
|
||||
* composer's own signature goes, before plugins, so every path that builds
|
||||
* a signature carries it: a new mail, a reply, a forward, a re-opened draft,
|
||||
* plain text, an identity switch. Each body must carry it exactly once.
|
||||
*/
|
||||
|
||||
const ORG_SIGNATURE = {
|
||||
html: '<p><b>{name}</b><br>{title}<br>Phone: {phone}<br>Mobile: {mobile}</p><p>ACME Ltd</p>',
|
||||
text: '{name}\n{title}\nPhone: {phone}\nMobile: {mobile}\nACME Ltd',
|
||||
fields: ['title', 'phone', 'mobile'] as Array<'title' | 'phone' | 'mobile'>,
|
||||
};
|
||||
|
||||
function setOrgPolicy(options: { locked?: boolean; signature?: typeof ORG_SIGNATURE | null } = {}) {
|
||||
const signature = options.signature === undefined ? ORG_SIGNATURE : options.signature;
|
||||
const policy: SettingsPolicy = {
|
||||
...DEFAULT_POLICY,
|
||||
restrictions: options.locked ? { signature: { locked: true } } : {},
|
||||
organisation: {
|
||||
...DEFAULT_ORGANISATION_POLICY,
|
||||
signature: signature ?? DEFAULT_ORGANISATION_POLICY.signature,
|
||||
},
|
||||
};
|
||||
usePolicyStore.setState({ policy, rawPolicy: policy, subject: null });
|
||||
}
|
||||
|
||||
const REPLY_TO = {
|
||||
from: [{ name: 'Bob', email: 'bob@example.com' }],
|
||||
to: [{ email: 'me@example.com' }],
|
||||
subject: 'Question',
|
||||
body: 'Original text',
|
||||
htmlBody: '<p>Original text</p>',
|
||||
receivedAt: '2026-08-01T10:00:00Z',
|
||||
};
|
||||
|
||||
const DRAFT = {
|
||||
to: 'bob@example.com',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: 'Re: Question',
|
||||
body: '<p>Thanks, looks good.</p><blockquote>original mail</blockquote>',
|
||||
showCc: false,
|
||||
showBcc: false,
|
||||
selectedIdentityId: 'id-me',
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: 'draft-v1',
|
||||
};
|
||||
|
||||
const sendButton = () => screen.getAllByTestId('composer-send')[0] as HTMLButtonElement;
|
||||
const fromSelect = () => screen.getByTestId('composer-from') as HTMLSelectElement;
|
||||
const countOf = (haystack: string, needle: string) => haystack.split(needle).length - 1;
|
||||
|
||||
async function send(onSend: ReturnType<typeof vi.fn>) {
|
||||
fireEvent.click(sendButton());
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
return onSend.mock.calls[0][0] as { body: string; htmlBody?: string };
|
||||
}
|
||||
|
||||
/** The body carries the organisation signature for `name` once, and no personal one. */
|
||||
function expectOrgSignatureOnce(sent: { body: string; htmlBody?: string }, name: string) {
|
||||
if (sent.htmlBody !== undefined) {
|
||||
expect(countOf(sent.htmlBody, 'ACME Ltd')).toBe(1);
|
||||
expect(sent.htmlBody).toContain(`<b>${name}</b><br>CEO<br>Phone: +49 30 1</p>`);
|
||||
expect(sent.htmlBody).not.toContain('Mobile:');
|
||||
expect(sent.htmlBody).not.toContain('Alice');
|
||||
}
|
||||
expect(countOf(sent.body, 'ACME Ltd')).toBe(1);
|
||||
// The text part of an HTML mail is made from the HTML, so its spacing
|
||||
// follows the paragraphs; a plain-text mail has the text template as is.
|
||||
const squash = (value: string) => value.replace(/\s+/g, ' ');
|
||||
if (sent.htmlBody !== undefined) expect(squash(sent.body)).toContain(`${name} CEO Phone: +49 30 1 ACME Ltd`);
|
||||
else expect(sent.body).toContain(`-- \n${name}\nCEO\nPhone: +49 30 1\nACME Ltd`);
|
||||
expect(sent.body).not.toContain('Mobile:');
|
||||
expect(sent.body).not.toContain('Alice');
|
||||
}
|
||||
|
||||
describe('organisation signature in the composer', () => {
|
||||
beforeEach(() => {
|
||||
resetSendPipeline();
|
||||
registerOrgOutgoing();
|
||||
useSettingsStore.setState({
|
||||
signatureDetails: { title: 'CEO', department: '', phone: '+49 30 1', mobile: '' },
|
||||
} as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
resetSendPipeline();
|
||||
useSettingsStore.setState({ plainTextMode: false, signaturePosition: 'below_quote' });
|
||||
setOrgPolicy({ signature: null });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('locked: it replaces every signature', () => {
|
||||
beforeEach(() => setOrgPolicy({ locked: true }));
|
||||
|
||||
it('signs a new mail once', async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer prefill={{ to: ['bob@example.com'] }} onSend={onSend} />);
|
||||
expectOrgSignatureOnce(await send(onSend), 'Me');
|
||||
});
|
||||
|
||||
it('signs a reply once, below the quote', async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer mode="reply" replyTo={REPLY_TO} onSend={onSend} />);
|
||||
const sent = await send(onSend);
|
||||
expectOrgSignatureOnce(sent, 'Me');
|
||||
expect(sent.htmlBody!.indexOf('Original text')).toBeLessThan(sent.htmlBody!.indexOf('ACME Ltd'));
|
||||
});
|
||||
|
||||
it('signs a reply above the quote once', async () => {
|
||||
useSettingsStore.setState({ signaturePosition: 'above_quote' });
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer mode="reply" replyTo={REPLY_TO} onSend={onSend} />);
|
||||
const sent = await send(onSend);
|
||||
expectOrgSignatureOnce(sent, 'Me');
|
||||
expect(sent.htmlBody!.indexOf('ACME Ltd')).toBeLessThan(sent.htmlBody!.indexOf('reply_line'));
|
||||
});
|
||||
|
||||
it('signs a forward once', async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer mode="forward" replyTo={REPLY_TO} prefill={{ to: ['carol@example.com'] }} onSend={onSend} />);
|
||||
expectOrgSignatureOnce(await send(onSend), 'Me');
|
||||
});
|
||||
|
||||
it('signs a re-opened draft once, also one that already carries it', async () => {
|
||||
const first = vi.fn();
|
||||
const { unmount } = render(<EmailComposer initialData={DRAFT} onSend={first} />);
|
||||
expectOrgSignatureOnce(await send(first), 'Me');
|
||||
const resent = first.mock.calls[0][0] as { htmlBody: string };
|
||||
unmount();
|
||||
|
||||
// The body as the composer saved it, signature range included.
|
||||
const withSignature = resent.htmlBody.replace(/^<div>|<\/div>$/g, '');
|
||||
const second = vi.fn();
|
||||
render(<EmailComposer initialData={{ ...DRAFT, body: withSignature }} onSend={second} />);
|
||||
expectOrgSignatureOnce(await send(second), 'Me');
|
||||
});
|
||||
|
||||
it('signs a plain-text mail and a plain-text reply once', async () => {
|
||||
useSettingsStore.setState({ plainTextMode: true });
|
||||
const compose = vi.fn();
|
||||
const { unmount } = render(<EmailComposer prefill={{ to: ['bob@example.com'] }} onSend={compose} />);
|
||||
const sent = await send(compose);
|
||||
expect(sent.htmlBody).toBeUndefined();
|
||||
expectOrgSignatureOnce(sent, 'Me');
|
||||
unmount();
|
||||
|
||||
const reply = vi.fn();
|
||||
render(<EmailComposer mode="reply" replyTo={REPLY_TO} onSend={reply} />);
|
||||
expectOrgSignatureOnce(await send(reply), 'Me');
|
||||
});
|
||||
|
||||
it('fills it in again for the identity picked in From', async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer prefill={{ to: ['bob@example.com'] }} onSend={onSend} />);
|
||||
fireEvent.change(fromSelect(), { target: { value: 'id-team' } });
|
||||
await waitFor(() => expect(editorState.html).toContain('<b>Team</b>'));
|
||||
const sent = await send(onSend);
|
||||
expectOrgSignatureOnce(sent, 'Team');
|
||||
expect(sent.htmlBody).not.toContain('<b>Me</b>');
|
||||
});
|
||||
|
||||
it('swaps it in a plain-text mail too', async () => {
|
||||
useSettingsStore.setState({ plainTextMode: true });
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer prefill={{ to: ['bob@example.com'] }} onSend={onSend} />);
|
||||
fireEvent.change(fromSelect(), { target: { value: 'id-team' } });
|
||||
const textarea = () => document.querySelector('textarea') as HTMLTextAreaElement;
|
||||
await waitFor(() => expect(textarea().value).toContain('Team\nCEO'));
|
||||
const sent = await send(onSend);
|
||||
expectOrgSignatureOnce(sent, 'Team');
|
||||
expect(sent.body).not.toContain('Me\nCEO');
|
||||
});
|
||||
});
|
||||
|
||||
describe('not locked: the default for identities without their own', () => {
|
||||
beforeEach(() => setOrgPolicy());
|
||||
|
||||
it('keeps the signature of an identity that has one', async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer prefill={{ to: ['bob@example.com'] }} onSend={onSend} />);
|
||||
const sent = await send(onSend);
|
||||
expect(sent.htmlBody).toContain('<b>Alice</b>');
|
||||
expect(sent.htmlBody).not.toContain('ACME Ltd');
|
||||
});
|
||||
|
||||
it('signs from an identity without one with the organisation signature', async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer prefill={{ to: ['bob@example.com'] }} onSend={onSend} />);
|
||||
fireEvent.change(fromSelect(), { target: { value: 'id-team' } });
|
||||
await waitFor(() => expect(editorState.html).toContain('ACME Ltd'));
|
||||
expectOrgSignatureOnce(await send(onSend), 'Team');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a signature that changes while the composer is open', () => {
|
||||
const PROPS = { prefill: { to: ['bob@example.com'] } };
|
||||
|
||||
it('swaps in a locked signature whose policy arrives after the composer opened', async () => {
|
||||
setOrgPolicy({ signature: null });
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer {...PROPS} onSend={onSend} />);
|
||||
await waitFor(() => expect(editorState.html).toContain('<b>Alice</b>'));
|
||||
act(() => setOrgPolicy({ locked: true }));
|
||||
await waitFor(() => expect(editorState.html).toContain('ACME Ltd'));
|
||||
expectOrgSignatureOnce(await send(onSend), 'Me');
|
||||
});
|
||||
|
||||
it('brings in a default that arrives late for an identity without a signature', async () => {
|
||||
setOrgPolicy({ signature: null });
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer {...PROPS} onSend={onSend} />);
|
||||
fireEvent.change(fromSelect(), { target: { value: 'id-team' } });
|
||||
await waitFor(() => expect(editorState.html).not.toContain('Alice'));
|
||||
act(() => setOrgPolicy());
|
||||
await waitFor(() => expect(editorState.html).toContain('ACME Ltd'));
|
||||
expectOrgSignatureOnce(await send(onSend), 'Team');
|
||||
});
|
||||
|
||||
it('fills in the details again when they change', async () => {
|
||||
setOrgPolicy({ locked: true });
|
||||
const onSend = vi.fn();
|
||||
const { rerender } = render(<EmailComposer {...PROPS} onSend={onSend} />);
|
||||
await waitFor(() => expect(editorState.html).toContain('CEO'));
|
||||
useSettingsStore.setState({ signatureDetails: { title: 'CTO', department: '', phone: '+49 30 1', mobile: '' } } as never);
|
||||
rerender(<EmailComposer {...PROPS} onSend={onSend} />);
|
||||
await waitFor(() => expect(editorState.html).toContain('CTO'));
|
||||
const sent = await send(onSend);
|
||||
expect(countOf(sent.htmlBody!, 'ACME Ltd')).toBe(1);
|
||||
expect(sent.htmlBody).not.toContain('CEO');
|
||||
});
|
||||
|
||||
it('keeps plugins away from a locked signature, and lets them change an unlocked one', async () => {
|
||||
signaturePlugin.on = true;
|
||||
try {
|
||||
setOrgPolicy({ locked: true });
|
||||
const locked = vi.fn();
|
||||
const { unmount } = render(<EmailComposer {...PROPS} onSend={locked} />);
|
||||
await waitFor(() => expect(editorState.html).toContain('ACME Ltd'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const sent = await send(locked);
|
||||
expect(sent.htmlBody).not.toContain('PLUGIN');
|
||||
expectOrgSignatureOnce(sent, 'Me');
|
||||
unmount();
|
||||
|
||||
setOrgPolicy();
|
||||
render(<EmailComposer {...PROPS} onSend={vi.fn()} />);
|
||||
await waitFor(() => expect(editorState.html).toContain('<b>Alice</b><p>PLUGIN</p>'));
|
||||
} finally {
|
||||
signaturePlugin.on = false;
|
||||
}
|
||||
});
|
||||
|
||||
it('swaps a plain-text signature whose policy arrives late', async () => {
|
||||
useSettingsStore.setState({ plainTextMode: true });
|
||||
setOrgPolicy({ signature: null });
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer {...PROPS} onSend={onSend} />);
|
||||
const textarea = () => document.querySelector('textarea') as HTMLTextAreaElement;
|
||||
await waitFor(() => expect(textarea().value).toContain('Alice'));
|
||||
act(() => setOrgPolicy({ locked: true }));
|
||||
await waitFor(() => expect(textarea().value).toContain('ACME Ltd'));
|
||||
expectOrgSignatureOnce(await send(onSend), 'Me');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('organisation disclaimer in the composer', () => {
|
||||
const DISCLAIMER = { html: '<p>Confidential, from {email}</p>', text: '' };
|
||||
|
||||
beforeEach(() => {
|
||||
registerOrgOutgoing();
|
||||
const policy: SettingsPolicy = {
|
||||
...DEFAULT_POLICY,
|
||||
organisation: { ...DEFAULT_ORGANISATION_POLICY, disclaimer: DISCLAIMER },
|
||||
};
|
||||
usePolicyStore.setState({ policy, rawPolicy: policy, subject: null });
|
||||
});
|
||||
afterEach(() => {
|
||||
resetSendPipeline();
|
||||
useAuthStore.setState({ client: null });
|
||||
useSettingsStore.setState({ plainTextMode: false });
|
||||
setOrgPolicy({ signature: null });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows it read-only under the editor', () => {
|
||||
render(<EmailComposer prefill={{ to: ['bob@example.com'] }} />);
|
||||
const preview = screen.getByTestId('org-disclaimer-preview');
|
||||
expect(preview).toHaveTextContent('disclaimer_added_on_send');
|
||||
expect(preview.innerHTML).toContain('<p>Confidential, from me@example.com</p>');
|
||||
expect(editorState.html).not.toContain('Confidential');
|
||||
});
|
||||
|
||||
it('sends it once below the quote, and saves the draft without it', async () => {
|
||||
const createDraft = vi.fn().mockResolvedValue('draft-1');
|
||||
useAuthStore.setState({
|
||||
client: { createDraft, getEmail: vi.fn().mockResolvedValue(null), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
|
||||
});
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer mode="reply" replyTo={REPLY_TO} onSend={onSend} />);
|
||||
// A change arms the autosave, which the send flushes first.
|
||||
fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Re: Question?' } });
|
||||
const sent = await send(onSend);
|
||||
|
||||
expect(createDraft).toHaveBeenCalled();
|
||||
for (const call of createDraft.mock.calls) {
|
||||
expect(`${call[2]} ${call[10]}`).not.toContain('Confidential');
|
||||
}
|
||||
expect(countOf(sent.htmlBody!, 'Confidential')).toBe(1);
|
||||
expect(sent.htmlBody).toMatch(/Original text[\s\S]*<hr><div data-org-disclaimer=""><p>Confidential, from me@example.com<\/p><\/div>$/);
|
||||
expect(countOf(sent.body, 'Confidential')).toBe(1);
|
||||
expect(sent.body).toMatch(/Original text[\s\S]*\n\nConfidential, from me@example.com$/);
|
||||
});
|
||||
|
||||
it('sends it once in a plain-text mail', async () => {
|
||||
useSettingsStore.setState({ plainTextMode: true });
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer prefill={{ to: ['bob@example.com'] }} onSend={onSend} />);
|
||||
const sent = await send(onSend);
|
||||
expect(sent.htmlBody).toBeUndefined();
|
||||
expect(countOf(sent.body, 'Confidential')).toBe(1);
|
||||
expect(sent.body.endsWith('\n\nConfidential, from me@example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('shows no block and adds no second copy when a draft back from Undo already ends with it', async () => {
|
||||
const onSend = vi.fn();
|
||||
// As the first send went out: the signature embedded, the disclaimer below it.
|
||||
const body = '<p>Hi Bob</p><p data-signature-block="separator">-- </p><div data-signature-block-node=""><b>Alice</b></div>'
|
||||
+ '<p data-signature-block="end"></p><hr><p>Confidential, from me@example.com</p>';
|
||||
render(<EmailComposer initialData={{ ...DRAFT, body }} onSend={onSend} />);
|
||||
await waitFor(() => expect(screen.queryByTestId('org-disclaimer-preview')).toBeNull());
|
||||
const sent = await send(onSend);
|
||||
expect(countOf(sent.htmlBody!, 'Confidential')).toBe(1);
|
||||
expect(countOf(sent.body, 'Confidential')).toBe(1);
|
||||
});
|
||||
|
||||
it('goes below a footer a plugin adds', async () => {
|
||||
sendPlugins.footer = true;
|
||||
try {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer prefill={{ to: ['bob@example.com'] }} onSend={onSend} />);
|
||||
const sent = await send(onSend);
|
||||
expect(sent.htmlBody).toMatch(/<p>Plugin footer<\/p><hr><div data-org-disclaimer=""><p>Confidential, from me@example.com<\/p><\/div>$/);
|
||||
expect(sent.body).toMatch(/Plugin footer\n\nConfidential, from me@example.com$/);
|
||||
} finally {
|
||||
sendPlugins.footer = false;
|
||||
}
|
||||
});
|
||||
|
||||
it('is in the message a signing or encrypting plugin takes over', async () => {
|
||||
const taken = vi.fn();
|
||||
sendPlugins.takeOver = taken;
|
||||
try {
|
||||
render(<EmailComposer prefill={{ to: ['bob@example.com'] }} onSend={vi.fn()} />);
|
||||
fireEvent.click(sendButton());
|
||||
await waitFor(() => expect(taken).toHaveBeenCalledTimes(1));
|
||||
const request = taken.mock.calls[0][0] as { htmlBody: string; textBody: string };
|
||||
expect(countOf(request.htmlBody, 'Confidential')).toBe(1);
|
||||
expect(request.textBody.endsWith('\n\nConfidential, from me@example.com')).toBe(true);
|
||||
} finally {
|
||||
sendPlugins.takeOver = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { registerOutgoingFinalizer, registerPreSendCheck, resetSendPipeline } from '@/lib/outgoing/send-pipeline';
|
||||
|
||||
// ─── Heavy component mocks (mirrors composer-reopened-draft-signature.test.tsx) ─
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [
|
||||
{ id: 'id-me', email: 'me@example.com', name: 'Me' },
|
||||
{ id: 'id-sales', email: 'sales@example.com', name: 'Sales' },
|
||||
] as Array<Record<string, unknown>>,
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
sendDelaySeconds: 0,
|
||||
// The default position: replies/forwards get the signature appended at
|
||||
// send time rather than embedded above the quote.
|
||||
signaturePosition: 'below_quote',
|
||||
signatureSeparatorEnabled: true,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeSignatureHtmlForDisplay: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
sanitizePluginBodyHtml: (v: string) => v,
|
||||
escapeHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Features add to every send through lib/outgoing/send-pipeline.ts: a check
|
||||
// can stop it, a finaliser changes what goes out. Both run in the composer's
|
||||
// send, after its own checks.
|
||||
|
||||
const DRAFT = {
|
||||
to: 'bob@example.com',
|
||||
cc: 'carol@example.com',
|
||||
bcc: '',
|
||||
subject: 'Hello',
|
||||
body: '<p>Hi</p>',
|
||||
showCc: true,
|
||||
showBcc: false,
|
||||
selectedIdentityId: 'id-me',
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
function clickSend() {
|
||||
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
|
||||
}
|
||||
|
||||
describe('composer send pipeline', () => {
|
||||
beforeEach(() => resetSendPipeline());
|
||||
afterEach(() => {
|
||||
resetSendPipeline();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sends what the finalisers return, with the recipients in the context', async () => {
|
||||
const seen: unknown[] = [];
|
||||
registerOutgoingFinalizer('footer', 10, (bodies, context) => {
|
||||
seen.push(context);
|
||||
return { html: `${bodies.html}<p>Footer</p>`, text: `${bodies.text}\n\nFooter` };
|
||||
});
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DRAFT} onSend={onSend} />);
|
||||
clickSend();
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
const sent = onSend.mock.calls[0][0] as { htmlBody?: string; body: string };
|
||||
expect(sent.htmlBody).toMatch(/<p>Footer<\/p>$/);
|
||||
expect(sent.body).toMatch(/\n\nFooter$/);
|
||||
expect(seen).toEqual([expect.objectContaining({
|
||||
surface: 'composer', to: ['bob@example.com'], cc: ['carol@example.com'], fromEmail: 'me@example.com', mode: 'compose', plainText: false,
|
||||
})]);
|
||||
});
|
||||
|
||||
it('sends nothing when a check says no, and sends after it says yes', async () => {
|
||||
let allow = false;
|
||||
const check = vi.fn(() => allow);
|
||||
registerPreSendCheck('ask', 10, check);
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DRAFT} onSend={onSend} />);
|
||||
clickSend();
|
||||
await waitFor(() => expect(check).toHaveBeenCalledTimes(1));
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
|
||||
allow = true;
|
||||
clickSend();
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer, type ComposerDraftData } from '../email-composer';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { flushComposers, resetComposeFlushRegistry } from '@/lib/compose-flush-registry';
|
||||
|
||||
// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ────────────────
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: (...args: unknown[]) => updateSetting(...args),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/signature-utils', () => ({
|
||||
appendPlainTextSignature: (body: string) => body,
|
||||
getPlainTextSignature: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Before an automatic sign-out (organisation idle timeout or maximum session
|
||||
* length) every composer saves what the server doesn't have yet, through the
|
||||
* compose flush registry.
|
||||
*/
|
||||
|
||||
const DRAFT: ComposerDraftData = {
|
||||
to: 'bob@example.com',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: 'Half-written',
|
||||
body: '<p>Still typing</p>',
|
||||
showCc: false,
|
||||
showBcc: false,
|
||||
selectedIdentityId: 'id-me',
|
||||
subAddressTag: '',
|
||||
mode: 'compose',
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
let createDraft: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
resetComposeFlushRegistry();
|
||||
createDraft = vi.fn().mockResolvedValue('draft-1');
|
||||
useAuthStore.setState({
|
||||
client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAuthStore.setState({ client: null });
|
||||
});
|
||||
|
||||
describe('composer flush before an automatic sign-out', () => {
|
||||
it('saves edits the autosave has not stored yet', async () => {
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
|
||||
|
||||
let result: Awaited<ReturnType<typeof flushComposers>> | undefined;
|
||||
await act(async () => { result = await flushComposers(5000); });
|
||||
|
||||
expect(result).toEqual({ total: 1, settled: 1, failed: 0, timedOut: false });
|
||||
expect(createDraft).toHaveBeenCalledTimes(1);
|
||||
expect(createDraft.mock.calls[0][1]).toBe('Half-written more');
|
||||
});
|
||||
|
||||
it('leaves a composer without changes alone', async () => {
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
|
||||
await act(async () => { await flushComposers(5000); });
|
||||
|
||||
expect(createDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves a minimised composer opened again with its stashed edits', async () => {
|
||||
render(<EmailComposer initialData={{ ...DRAFT, unsaved: true }} onClose={vi.fn()} />);
|
||||
|
||||
await act(async () => { await flushComposers(5000); });
|
||||
|
||||
expect(createDraft).toHaveBeenCalledTimes(1);
|
||||
expect(createDraft.mock.calls[0][1]).toBe('Half-written');
|
||||
});
|
||||
|
||||
it('waits for a send in flight and saves no draft of the sent message', async () => {
|
||||
let finishSend!: () => void;
|
||||
const onSend = vi.fn(() => new Promise<void>((resolve) => { finishSend = resolve; }));
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} onSend={onSend} />);
|
||||
fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
|
||||
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalled());
|
||||
const savesBeforeSignOut = createDraft.mock.calls.length;
|
||||
|
||||
let done = false;
|
||||
const flushed = flushComposers(5000).then((r) => { done = true; return r; });
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
expect(done).toBe(false);
|
||||
|
||||
await act(async () => { finishSend(); });
|
||||
await expect(flushed).resolves.toMatchObject({ total: 1, failed: 0, timedOut: false });
|
||||
expect(createDraft).toHaveBeenCalledTimes(savesBeforeSignOut);
|
||||
});
|
||||
|
||||
it('reports a save the server refused, so the login page can say so', async () => {
|
||||
createDraft.mockRejectedValue(new Error('refused'));
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
|
||||
|
||||
let result: Awaited<ReturnType<typeof flushComposers>> | undefined;
|
||||
await act(async () => { result = await flushComposers(5000); });
|
||||
expect(result).toMatchObject({ total: 1, failed: 1 });
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('hands an already-saved state back as saved', async () => {
|
||||
const onSaveState = vi.fn();
|
||||
const view = render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} onSaveState={onSaveState} />);
|
||||
fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
|
||||
await act(async () => { await flushComposers(5000); });
|
||||
view.unmount();
|
||||
|
||||
expect(onSaveState).toHaveBeenCalledWith(expect.objectContaining({ subject: 'Half-written more', unsaved: false }));
|
||||
});
|
||||
|
||||
it('marks the state it hands to the host on unmount as unsaved', () => {
|
||||
const onSaveState = vi.fn();
|
||||
const view = render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} onSaveState={onSaveState} />);
|
||||
fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
|
||||
view.unmount();
|
||||
|
||||
expect(onSaveState).toHaveBeenCalledWith(expect.objectContaining({ subject: 'Half-written more', unsaved: true }));
|
||||
});
|
||||
|
||||
it('unregisters when it unmounts', async () => {
|
||||
const view = render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
view.unmount();
|
||||
|
||||
await expect(flushComposers(1000)).resolves.toMatchObject({ total: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({
|
||||
PluginSlot: () => null,
|
||||
}));
|
||||
|
||||
import { EmailContextMenu } from '../email-context-menu';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { loadSharedTags, resetSharedTags, sharedTagsKey, useSharedTagsStore } from '@/stores/shared-tags-store';
|
||||
|
||||
// The context menu's tag list for a selection offers the tags of the
|
||||
// selection's account (commonTagScope, like the toolbar), not those of the
|
||||
// row that was right-clicked.
|
||||
|
||||
const TEAM = { loginId: 'login-1', accountId: 'team' };
|
||||
|
||||
// A message in the group account, right-clicked.
|
||||
const email = {
|
||||
id: 'e1',
|
||||
threadId: 't1',
|
||||
mailboxIds: { 'team:inbox': true },
|
||||
keywords: { $seen: true },
|
||||
from: [{ email: 'alice@example.com' }],
|
||||
subject: 'Hello',
|
||||
receivedAt: '2026-01-01T00:00:00Z',
|
||||
size: 100,
|
||||
sourceClientAccountId: 'login-1',
|
||||
sourceAccountId: 'team',
|
||||
} as unknown as Email;
|
||||
|
||||
const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[];
|
||||
|
||||
function openTags(batchTagScope: typeof TEAM | null) {
|
||||
render(
|
||||
<EmailContextMenu
|
||||
email={email}
|
||||
position={{ x: 10, y: 10 }}
|
||||
isOpen
|
||||
onClose={vi.fn()}
|
||||
menuRef={{ current: null }}
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox="inbox"
|
||||
currentMailboxRole="inbox"
|
||||
isMultiSelect
|
||||
selectedCount={2}
|
||||
batchTagIds={[]}
|
||||
batchPartialTagIds={[]}
|
||||
batchTagScope={batchTagScope}
|
||||
onBatchToggleTag={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
act(() => { fireEvent.mouseEnter(screen.getByTestId('ctx-tag')); });
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
resetSharedTags();
|
||||
// The group's tags come through the real loading path.
|
||||
const client = {
|
||||
getAccountId: () => 'me',
|
||||
getSieveAccountId: () => 'me',
|
||||
supportsSieve: () => false,
|
||||
getSharedAccounts: () => [],
|
||||
getAccountInfo: () => ({ isPersonal: false }),
|
||||
getPrincipalTypes: async () => ({ team: 'group' }),
|
||||
advertisesBulwarkMetadata: () => true,
|
||||
supportsBulwarkMetadata: () => true,
|
||||
getBulwarkMetadata: async () => ({ tags: { tags: { urgent: { label: 'Team urgent', color: 'red' } } } }),
|
||||
discoverKeywords: async () => ({ keywords: {}, scanned: 0, total: 0, complete: true }),
|
||||
};
|
||||
useAuthStore.setState({
|
||||
client: client as never,
|
||||
activeAccountId: 'login-1',
|
||||
getClientForAccount: ((id: string) => (id === 'login-1' ? client : undefined)) as never,
|
||||
});
|
||||
useSettingsStore.setState({ emailKeywords: [{ id: 'mine', label: 'Mine', color: 'blue' }], nestedTags: false });
|
||||
await loadSharedTags(sharedTagsKey(TEAM.loginId, TEAM.accountId), TEAM.accountId, client);
|
||||
expect(useSharedTagsStore.getState().entries[sharedTagsKey(TEAM.loginId, TEAM.accountId)]?.status).toBe('ready');
|
||||
});
|
||||
afterEach(() => resetSharedTags());
|
||||
|
||||
describe('EmailContextMenu tags for a selection', () => {
|
||||
it('offers the group\'s shared tags when the whole selection is in the group', async () => {
|
||||
openTags(TEAM);
|
||||
expect(await screen.findByText('Team urgent')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Mine')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers the own tags when the selection mixes accounts, even from a group row', async () => {
|
||||
openTags(null);
|
||||
expect(await screen.findByText('Mine')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Team urgent')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,11 @@ import type { Email } from '@/lib/jmap/types';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { EmailHoverActions } from '../email-hover-actions';
|
||||
|
||||
vi.mock('@/components/plugins/plugin-actions', () => ({ PluginActions: () => null, usePluginActions: () => [] }));
|
||||
vi.mock('@/components/plugins/plugin-actions', () => ({
|
||||
PluginActions: () => null,
|
||||
usePluginActions: () => [],
|
||||
useMessageActionTarget: () => ({ accountKind: 'personal', keywords: [] }),
|
||||
}));
|
||||
|
||||
// R10: one word per action. The row's quick actions say what the row's menu
|
||||
// says ("Report spam", "Mark as unread"), not "Mark as Spam" or "Mark Read /
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailList } from '../email-list';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { resetListFilters, setPluginListFilters } from '@/lib/plugin-sandbox/list-filters';
|
||||
import { confirmGroups, resetConfirmedGroups } from '@/lib/plugin-sandbox/account-kind';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
// The plugins' list filter chips in the message list: shown in a team
|
||||
// folder's own list, loading the list again when one is turned on, and not
|
||||
// in a search, a tag view or the merged views.
|
||||
|
||||
const client = vi.hoisted(() => ({
|
||||
getAccountId: () => 'ci',
|
||||
getAccountInfo: (id: string) => (id === 'ch' ? { name: 'org-team@example.org', isPersonal: false, isReadOnly: false, accountCapabilities: {} } : null),
|
||||
getSharedAccounts: () => [
|
||||
{ id: 'ci', name: 'org-alice@example.org', isPrimary: true, capabilities: { mail: true } },
|
||||
{ id: 'ch', name: 'org-team@example.org', isPrimary: false, capabilities: { mail: true } },
|
||||
],
|
||||
getPrincipalTypes: async () => ({ ch: 'group' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-email-drag', () => ({
|
||||
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
|
||||
}));
|
||||
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
|
||||
vi.mock('@/stores/auth-store', async () => {
|
||||
const { create } = await import('zustand');
|
||||
return {
|
||||
useAuthStore: create(() => ({
|
||||
client, identities: [], username: null, activeAccountId: 'acct-1',
|
||||
getClientForAccount: () => client, getAllConnectedClients: () => new Map(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { getAccountById: () => undefined };
|
||||
return {
|
||||
useAccountStore: Object.assign(
|
||||
(selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state),
|
||||
{ getState: () => state },
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const teamInbox = { id: 'ch:a', originalId: 'a', name: 'Inbox', role: 'inbox', isShared: true, accountId: 'ch', totalEmails: 0, unreadEmails: 0, sortOrder: 0 } as unknown as Mailbox;
|
||||
const initialEmailState = useEmailStore.getState();
|
||||
const fetchEmails = vi.fn(async () => {});
|
||||
|
||||
beforeEach(async () => {
|
||||
resetListFilters();
|
||||
resetConfirmedGroups();
|
||||
fetchEmails.mockClear();
|
||||
useEmailStore.setState(initialEmailState, true);
|
||||
useEmailStore.setState({ selectedMailbox: 'ch:a', mailboxes: [teamInbox], fetchEmails });
|
||||
setPluginListFilters('team-inbox', 'Team inbox', [{ id: 'open', label: 'Open', notKeyword: '$done', accounts: ['group'] }]);
|
||||
await confirmGroups(client as never);
|
||||
});
|
||||
|
||||
describe('plugin list filters in the message list', () => {
|
||||
it('shows the chip in a team folder and loads the list when it is turned on and off', async () => {
|
||||
render(<EmailList emails={[]} />);
|
||||
const chip = await screen.findByRole('button', { name: 'Open' });
|
||||
expect(fetchEmails).not.toHaveBeenCalled();
|
||||
act(() => { fireEvent.click(chip); });
|
||||
expect(chip).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(fetchEmails).toHaveBeenCalledTimes(1);
|
||||
expect(fetchEmails).toHaveBeenCalledWith(client);
|
||||
act(() => { fireEvent.click(chip); });
|
||||
expect(fetchEmails).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a search', { searchQuery: 'invoice' }],
|
||||
['a tag view', { selectedKeyword: 'work' }],
|
||||
['the merged views', { isUnifiedView: true }],
|
||||
])('leaves %s without chips', async (_name, state) => {
|
||||
useEmailStore.setState(state);
|
||||
render(<EmailList emails={[]} />);
|
||||
await act(async () => {});
|
||||
expect(screen.queryByTestId('plugin-list-filters')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { JMAPClient } from '@/lib/jmap/client';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useRecoveryStore } from '@/stores/recovery-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { EmailList } from '../email-list';
|
||||
|
||||
// The Trash folder's header links to Recover deleted items when the server
|
||||
// offers it; other folders don't show the line.
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-email-drag', () => ({ useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }) }));
|
||||
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ stalwartFeaturesEnabled: true, isLoading: false }) }));
|
||||
|
||||
const initialEmail = useEmailStore.getState();
|
||||
const initialAuth = useAuthStore.getState();
|
||||
const initialPolicy = usePolicyStore.getState();
|
||||
|
||||
function mailbox(id: string, role: string): Mailbox {
|
||||
return { id, name: id, role, accountId: 'c', totalEmails: 1, unreadEmails: 0, totalThreads: 1, unreadThreads: 0,
|
||||
myRights: { mayReadItems: true, mayAddItems: true, mayRemoveItems: true } } as Mailbox;
|
||||
}
|
||||
|
||||
function show(selected: 'trash' | 'inbox') {
|
||||
const emails = [{ id: 'm1', threadId: 't1', blobId: 'b1', mailboxIds: { [selected]: true }, keywords: {}, subject: 'Hello',
|
||||
receivedAt: '2026-10-01T00:00:00Z', from: [{ email: 'org-bob@example.org' }], size: 1, hasAttachment: false } as Email];
|
||||
useEmailStore.setState({ emails, mailboxes: [mailbox('trash', 'trash'), mailbox('inbox', 'inbox')], selectedMailbox: selected, totalEmails: 1 });
|
||||
return render(<EmailList emails={emails} />);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const client = JMAPClient.withBearer('https://list.example.invalid', 'synthetic', 'org-alice@example.org');
|
||||
Object.assign(client, { apiUrl: 'https://list.example.invalid/jmap', accountId: 'c' });
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ methodResponses: [] }))));
|
||||
useAuthStore.setState({ activeAccountId: 'acc-alice', client, getClientForAccount: () => client } as never);
|
||||
useEmailStore.setState(initialEmail, true);
|
||||
usePolicyStore.setState({ loaded: true, subject: { email: client.getUsername().toLowerCase(), groups: [] } });
|
||||
// Detection and the look for a recent message already answered for this account.
|
||||
useRecoveryStore.setState({
|
||||
checks: { 'acc-alice|c': { supported: true, retryAt: null } },
|
||||
recent: { 'acc-alice|c': { has: true, days: 0, checkedAt: Date.now() } },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useRecoveryStore.getState().reset();
|
||||
usePolicyStore.setState(initialPolicy, true);
|
||||
useAuthStore.setState(initialAuth, true);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('EmailList and Recover deleted items', () => {
|
||||
it('shows the link in Trash', async () => {
|
||||
show('trash');
|
||||
await waitFor(() => expect(screen.getByTestId('trash-recovery-link')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('leaves it out of other folders', async () => {
|
||||
show('inbox');
|
||||
await screen.findByText('no_more_emails');
|
||||
expect(screen.queryByTestId('trash-recovery-link')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { render, act, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ExternalRecipientConfirmHost } from '../external-recipient-confirm';
|
||||
import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store';
|
||||
import { resetSendPipeline } from '@/lib/outgoing/send-pipeline';
|
||||
|
||||
// The confirm dialog keeps its keys: the mail view's shortcuts listen on the
|
||||
// window, and must not see Escape (close the message), j/k (move the list)
|
||||
// or Enter (open a message) while the question is open. Real focus trap.
|
||||
|
||||
const RECIPIENTS = [{ address: 'org-dave@org-two.test', domain: 'org-two.test', field: 'to' as const }];
|
||||
const pageKeys = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
useExternalRecipientConfirmStore.setState({ pending: null, queue: [] });
|
||||
window.addEventListener('keydown', pageKeys);
|
||||
});
|
||||
afterEach(() => {
|
||||
window.removeEventListener('keydown', pageKeys);
|
||||
pageKeys.mockReset();
|
||||
cleanup();
|
||||
resetSendPipeline();
|
||||
});
|
||||
|
||||
function open() {
|
||||
const view = render(<ExternalRecipientConfirmHost />);
|
||||
let answer!: Promise<boolean>;
|
||||
act(() => { answer = useExternalRecipientConfirmStore.getState().ask(RECIPIENTS); });
|
||||
const back = view.container.querySelector('[data-external-recipient-back]') as HTMLButtonElement;
|
||||
const send = view.container.querySelector('[data-external-recipient-send]') as HTMLButtonElement;
|
||||
return { answer, back, send };
|
||||
}
|
||||
|
||||
describe('keys in the external recipient confirm', () => {
|
||||
it('puts the focus on Back and keeps shortcut keys from the page', () => {
|
||||
const { back } = open();
|
||||
expect(document.activeElement).toBe(back);
|
||||
for (const key of ['j', 'k', 'Enter', 'Escape']) {
|
||||
act(() => { fireEvent.keyDown(document.activeElement!, { key }); });
|
||||
// Re-open after Escape and Enter answered it.
|
||||
if (!useExternalRecipientConfirmStore.getState().pending) act(() => { void useExternalRecipientConfirmStore.getState().ask(RECIPIENTS); });
|
||||
}
|
||||
expect(pageKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers Back on Escape', async () => {
|
||||
const { answer } = open();
|
||||
act(() => { fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); });
|
||||
expect(await answer).toBe(false);
|
||||
});
|
||||
|
||||
it('presses the focused button on Enter and on Space', async () => {
|
||||
const first = open();
|
||||
first.send.focus();
|
||||
act(() => { fireEvent.keyDown(first.send, { key: 'Enter' }); });
|
||||
expect(await first.answer).toBe(true);
|
||||
|
||||
let second!: Promise<boolean>;
|
||||
act(() => { second = useExternalRecipientConfirmStore.getState().ask(RECIPIENTS); });
|
||||
act(() => { fireEvent.keyDown(document.activeElement!, { key: ' ' }); });
|
||||
// The focus went back to the first button, Back.
|
||||
expect(await second).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { render, fireEvent, cleanup, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Identity } from '@/lib/jmap/types';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
// Strings with their values, so the domain can be seen in names and text.
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, vars?: Record<string, unknown>) =>
|
||||
vars ? `${key}(${Object.values(vars).join(',')})` : key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
const { ExternalSenderChip } = await import('../external-sender-chip');
|
||||
|
||||
function setLabel(on: boolean) {
|
||||
usePolicyStore.setState({
|
||||
policy: {
|
||||
...DEFAULT_POLICY,
|
||||
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, senderLabel: on } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setLabel(true);
|
||||
useAuthStore.setState({ identities: [{ id: 'i1', name: 'Alice', email: 'alice@example.org' } as unknown as Identity] });
|
||||
});
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('External chip on phones and keyboards', () => {
|
||||
it('is a button whose name says the domain, and a tap shows it', () => {
|
||||
render(<ExternalSenderChip from={[{ email: 'dave@org-two.test' }]} />);
|
||||
const chip = screen.getByRole('button', { name: /org-two\.test/ });
|
||||
expect(chip.getAttribute('aria-expanded')).toBe('false');
|
||||
fireEvent.click(chip);
|
||||
expect(chip.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(document.querySelector('[data-external-sender-detail]')?.textContent).toBe('sender_tooltip(org-two.test)');
|
||||
// Escape and a tap elsewhere close it.
|
||||
fireEvent.keyDown(chip, { key: 'Escape' });
|
||||
expect(document.querySelector('[data-external-sender-detail]')).toBeNull();
|
||||
fireEvent.click(chip);
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(document.querySelector('[data-external-sender-detail]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows for any external one of several From addresses', () => {
|
||||
render(<ExternalSenderChip from={[{ email: 'bob@example.org' }, { email: 'eve@evil.example' }]} />);
|
||||
expect(screen.getByRole('button', { name: /evil\.example/ })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows for a From without a domain', () => {
|
||||
render(<ExternalSenderChip from={[{ email: 'ceo' }]} />);
|
||||
expect(screen.getByRole('button', { name: /sender_tooltip_no_domain/ })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('stays a plain label inside another button', () => {
|
||||
const { container } = render(<button type="button"><ExternalSenderChip from={[{ email: 'dave@org-two.test' }]} interactive={false} /></button>);
|
||||
expect(container.querySelectorAll('button')).toHaveLength(1);
|
||||
expect(container.querySelector('[data-external-sender-chip]')?.getAttribute('title')).toBe('sender_tooltip(org-two.test)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a message of another signed-in account (unified inbox)', () => {
|
||||
function signInDave(daveScope: Record<string, unknown> | null) {
|
||||
usePolicyStore.setState({
|
||||
rawPolicy: {
|
||||
...DEFAULT_POLICY,
|
||||
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, senderLabel: true } },
|
||||
scopes: daveScope ? [{ id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: { organisation: { externalMail: daveScope } } }] : [],
|
||||
},
|
||||
});
|
||||
const daveClient = {
|
||||
getUsername: () => 'org-dave@org-two.test',
|
||||
getSharedAccounts: () => [],
|
||||
getAccountInfo: () => null,
|
||||
getPrincipalTypes: async () => ({}),
|
||||
getIdentities: async () => [{ id: 'd1', name: 'Dave', email: 'org-dave@org-two.test' }],
|
||||
};
|
||||
useAuthStore.setState({
|
||||
activeAccountId: 'acc-alice',
|
||||
getClientForAccount: (id: string) => (id === 'acc-dave' ? daveClient : undefined),
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("is judged with that account's identities", async () => {
|
||||
signInDave(null);
|
||||
// For dave, a colleague at org-two.test is internal and alice is not.
|
||||
const colleague = render(<ExternalSenderChip from={[{ email: 'colleague@org-two.test' }]} accountId="acc-dave" />);
|
||||
const alice = render(<ExternalSenderChip from={[{ email: 'alice@example.org' }]} accountId="acc-dave" />);
|
||||
await waitFor(() => expect(alice.container.querySelector('[data-external-sender-chip]')).not.toBeNull());
|
||||
expect(colleague.container.querySelector('[data-external-sender-chip]')).toBeNull();
|
||||
});
|
||||
|
||||
it("follows that account's scope, not the active one's", async () => {
|
||||
signInDave({ senderLabel: false });
|
||||
const { container } = render(<ExternalSenderChip from={[{ email: 'someone@elsewhere.example' }]} accountId="acc-dave" />);
|
||||
// The active account has the label on; dave's scope turns it off.
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(container.querySelector('[data-external-sender-chip]')).toBeNull();
|
||||
const active = render(<ExternalSenderChip from={[{ email: 'someone@elsewhere.example' }]} />);
|
||||
expect(active.container.querySelector('[data-external-sender-chip]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { render, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Email, Identity, Mailbox } from '@/lib/jmap/types';
|
||||
import { clearAllHooks } from '@/lib/plugin-hooks';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
const { ExternalSenderChip } = await import('../external-sender-chip');
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
const message = (from: string) => ({
|
||||
id: 'm1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Sender', email: from }], to: [{ name: 'Org Alice', email: 'org-alice@example.org' }],
|
||||
subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
|
||||
textBody: [{ partId: '1', type: 'text/html' }], htmlBody: [{ partId: '1', type: 'text/html' }], attachments: [],
|
||||
bodyValues: { '1': { value: '<p>Body</p>' } },
|
||||
}) as unknown as Email;
|
||||
|
||||
function setExternalMail(patch: Partial<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
|
||||
usePolicyStore.setState({
|
||||
policy: {
|
||||
...DEFAULT_POLICY,
|
||||
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function openMessage(from: string) {
|
||||
const view = render(<EmailViewer email={message(from)} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
return view;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllHooks();
|
||||
setExternalMail({});
|
||||
useAuthStore.setState({ identities: [{ id: 'i1', name: 'Org Alice', email: 'org-alice@example.org' } as unknown as Identity] });
|
||||
});
|
||||
|
||||
describe('External sender chip', () => {
|
||||
it('names the sender domain in its tooltip when the label is on', () => {
|
||||
setExternalMail({ senderLabel: true });
|
||||
const { container } = render(<ExternalSenderChip from={[{ email: "org-dave@org-two.test" }]} />);
|
||||
const chip = container.querySelector('[data-external-sender-chip]');
|
||||
expect(chip?.textContent).toContain('sender_label');
|
||||
expect(chip?.getAttribute('title')).toBe('sender_tooltip');
|
||||
});
|
||||
|
||||
it('shows in the reader header for a sender outside the organisation', async () => {
|
||||
setExternalMail({ senderLabel: true });
|
||||
const { container } = await openMessage('org-dave@org-two.test');
|
||||
expect(container.querySelectorAll('[data-external-sender-chip]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('stays away for a sender in an identity domain or its subdomain', async () => {
|
||||
setExternalMail({ senderLabel: true });
|
||||
const { container } = await openMessage('org-bob@example.org');
|
||||
expect(container.querySelector('[data-external-sender-chip]')).toBeNull();
|
||||
const sub = render(<ExternalSenderChip from={[{ email: "someone@eu.example.org" }]} />);
|
||||
expect(sub.container.querySelector('[data-external-sender-chip]')).toBeNull();
|
||||
});
|
||||
|
||||
it('stays away for a domain the admin listed', async () => {
|
||||
setExternalMail({ senderLabel: true, domains: ['org-two.test'] });
|
||||
const { container } = await openMessage('org-dave@org-two.test');
|
||||
expect(container.querySelector('[data-external-sender-chip]')).toBeNull();
|
||||
});
|
||||
|
||||
it('stays away while the label is off, even with the recipient warning on', async () => {
|
||||
setExternalMail({ senderLabel: false, recipientWarning: true });
|
||||
const { container } = await openMessage('org-dave@org-two.test');
|
||||
expect(container.querySelector('[data-external-sender-chip]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { PluginListFilters } from '../plugin-list-filters';
|
||||
import {
|
||||
activeListFilterCondition, clearPluginListFilters, resetListFilters, setPluginListFilters,
|
||||
} from '@/lib/plugin-sandbox/list-filters';
|
||||
|
||||
// The chips plugins' list filters get in the list header (api.ui.setListFilters).
|
||||
// The global next-intl mock returns the key, so labels read as keys.
|
||||
|
||||
const TEAM = { role: 'inbox', accountKind: 'group' as const };
|
||||
|
||||
beforeEach(() => {
|
||||
resetListFilters();
|
||||
setPluginListFilters('team-inbox', 'Team inbox', [
|
||||
{ id: 'mine', label: 'Assigned to me', icon: 'user', hasKeyword: '$assigned:ci', accounts: ['group'], order: 1 },
|
||||
{ id: 'open', label: 'Open', notKeyword: '$done', accounts: ['group'], order: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
describe('PluginListFilters', () => {
|
||||
it('shows the chips of a team folder, off at first', () => {
|
||||
render(<PluginListFilters mailboxRole="inbox" accountKind="group" onChange={() => {}} />);
|
||||
const chips = screen.getAllByRole('button');
|
||||
expect(chips.map((c) => c.textContent)).toEqual(['Assigned to me', 'Open']);
|
||||
expect(chips.every((c) => c.getAttribute('aria-pressed') === 'false')).toBe(true);
|
||||
expect(screen.getByRole('group')).toHaveAttribute('aria-label', 'filters');
|
||||
});
|
||||
|
||||
it('shows nothing in the user\'s own folders', () => {
|
||||
const { container } = render(<PluginListFilters mailboxRole="inbox" accountKind="personal" onChange={() => {}} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('turns a filter on with a click, loads the list, and removes it with the next', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<PluginListFilters mailboxRole="inbox" accountKind="group" onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
|
||||
// The name stays the label; pressed says it's on, the tooltip how to remove it.
|
||||
const active = screen.getByRole('button', { name: 'Open' });
|
||||
expect(active).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(active).toHaveAttribute('title', 'remove_filter');
|
||||
expect(active.querySelector('svg')).not.toBeNull();
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(activeListFilterCondition(TEAM)).toEqual({ notKeyword: '$done' });
|
||||
|
||||
fireEvent.click(active);
|
||||
expect(screen.getByRole('button', { name: 'Open' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(onChange).toHaveBeenCalledTimes(2);
|
||||
expect(activeListFilterCondition(TEAM)).toBeNull();
|
||||
});
|
||||
|
||||
it('loads the list again when the plugin that offered an active filter goes away', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<PluginListFilters mailboxRole="inbox" accountKind="group" onChange={onChange} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
|
||||
onChange.mockClear();
|
||||
|
||||
act(() => clearPluginListFilters('team-inbox'));
|
||||
expect(screen.queryByRole('group')).toBeNull();
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -89,6 +89,22 @@ describe('plugin badges in message-list rows', () => {
|
||||
expect(ctx.email).not.toHaveProperty('mailboxIds.inbox');
|
||||
});
|
||||
|
||||
it('draws a badge\'s icon before its label, and none for a name that isn\'t one', async () => {
|
||||
renderHooks.onEmailListItemRender.register('test', async () => [
|
||||
{ key: 'done', label: 'Done', icon: 'check' },
|
||||
{ key: 'bad', label: 'Bad', icon: 'x"><script>' },
|
||||
]);
|
||||
|
||||
const { container } = render(row([makeEmail()]));
|
||||
|
||||
await screen.findByText('Done');
|
||||
const done = container.querySelector('[data-plugin-row-badge="done"]')!;
|
||||
expect(done.querySelector('svg[data-badge-icon="check"]')).not.toBeNull();
|
||||
expect(done).toHaveTextContent('Done');
|
||||
expect(container.querySelector('[data-plugin-row-badge="bad"] svg')).toBeNull();
|
||||
expect(normalizeBadges([{ key: 'bad', label: 'Bad', icon: 'x"><script>' }])[0]).not.toHaveProperty('icon');
|
||||
});
|
||||
|
||||
it('asks the plugin again when it requests a refresh, at most once a second', async () => {
|
||||
let label = 'Open';
|
||||
const handler = vi.fn(async () => [{ key: 'task', label }]);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
// #903: with no third-party traffic, remote content in mail never loads, so
|
||||
// nothing offers to load it or to trust the sender; the notice says the
|
||||
// organisation blocks it. Without the mode the controls are as before.
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false }));
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
const { ThreadConversationView } = await import('../thread-conversation-view');
|
||||
|
||||
const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[];
|
||||
const email = {
|
||||
id: 'e1', blobId: 'b-e1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'News', email: 'news@shop.example' }], to: [{ email: 'me@example.org' }],
|
||||
subject: 'Offer', receivedAt: '2026-10-01T10:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
|
||||
textBody: [], htmlBody: [{ partId: '1', type: 'text/html' }], attachments: [],
|
||||
bodyValues: { '1': { value: '<p>Hello</p><img src="https://track.shop.example/pixel.gif">' } },
|
||||
} as unknown as Email;
|
||||
|
||||
afterEach(() => {
|
||||
setNoThirdPartyTraffic(false);
|
||||
useSettingsStore.setState({ externalContentPolicy: 'ask' });
|
||||
});
|
||||
|
||||
async function renderViewer() {
|
||||
render(<EmailViewer email={email} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 30)); });
|
||||
return screen.findByTestId('external-content-notice');
|
||||
}
|
||||
|
||||
async function renderConversation() {
|
||||
const view = render(
|
||||
<ThreadConversationView thread={{ threadId: 't1', emails: [email], latestEmail: email } as never} emails={[email] as never} onBack={() => {}} />,
|
||||
);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 30)); });
|
||||
return view;
|
||||
}
|
||||
|
||||
describe('reading pane', () => {
|
||||
it('offers to load remote content without the mode', async () => {
|
||||
const notice = await renderViewer();
|
||||
expect(notice.textContent).toContain('external_content_warning');
|
||||
expect(screen.queryByText('load_external_content')).not.toBeNull();
|
||||
expect(screen.queryByText('trust_sender')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('says the organisation blocks it, with no load or trust button, even when the policy allows', async () => {
|
||||
setNoThirdPartyTraffic(true);
|
||||
useSettingsStore.setState({ externalContentPolicy: 'allow' });
|
||||
const notice = await renderViewer();
|
||||
expect(notice.textContent).toContain('remote_content_blocked');
|
||||
expect(screen.queryByText('load_external_content')).toBeNull();
|
||||
expect(screen.queryByText('trust_sender')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('conversation view', () => {
|
||||
it('offers to load remote content without the mode', async () => {
|
||||
await renderConversation();
|
||||
expect(screen.getByTestId('external-content-notice').textContent).toContain('email_viewer.external_content_warning');
|
||||
expect(screen.queryByText('email_viewer.load_external_content')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('says the organisation blocks it, keeps it blocked and offers nothing in the mode', async () => {
|
||||
setNoThirdPartyTraffic(true);
|
||||
useSettingsStore.setState({ externalContentPolicy: 'allow' });
|
||||
const { container } = await renderConversation();
|
||||
expect(screen.getByTestId('external-content-notice').textContent).toContain('third_party.remote_content_blocked');
|
||||
expect(screen.queryByText('email_viewer.load_external_content')).toBeNull();
|
||||
expect(screen.queryByText('email_viewer.trust_sender')).toBeNull();
|
||||
const srcdoc = container.querySelector('iframe')?.getAttribute('srcdoc') ?? '';
|
||||
expect(srcdoc).not.toMatch(/\ssrc="https:\/\/track\.shop\.example/);
|
||||
});
|
||||
});
|
||||
@@ -12,10 +12,13 @@ vi.mock('@/hooks/use-email-drag', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = { identities: [], username: null };
|
||||
return {
|
||||
useAuthStore: (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state),
|
||||
};
|
||||
const state = { identities: [], username: null, client: null, activeAccountId: null, getClientForAccount: () => undefined };
|
||||
// getState for the plugin action target (resolveEmailBlobContext).
|
||||
const useAuthStore = Object.assign(
|
||||
(selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state),
|
||||
{ getState: () => state },
|
||||
);
|
||||
return { useAuthStore };
|
||||
});
|
||||
|
||||
const ACCOUNT_TEAL = { id: 'acct-teal', avatarColor: '#0d9488', label: 'Teal account' };
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TrashRecoveryLink } from '../trash-recovery-link';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useRecoveryStore } from '@/stores/recovery-store';
|
||||
import { jmapError } from '@/lib/jmap/jmap-error';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
import { LICENSE_REFUSAL } from '@/lib/recovery/__tests__/fixtures';
|
||||
|
||||
const push = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push, back: vi.fn(), replace: vi.fn() }) }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ stalwartFeaturesEnabled: true, isLoading: false }) }));
|
||||
|
||||
const TRASH = { id: 't', name: 'Trash', role: 'trash', accountId: 'ci' } as Mailbox;
|
||||
|
||||
function makeClient(options: { supported?: boolean; has?: boolean } = {}) {
|
||||
return {
|
||||
getAccountId: () => 'ci',
|
||||
getUsername: () => 'org-alice@example.org',
|
||||
advertisesArchivedItems: () => true,
|
||||
queryArchivedItemIds: vi.fn(async () => {
|
||||
if (options.supported === false) throw jmapError(LICENSE_REFUSAL.description, LICENSE_REFUSAL);
|
||||
return { ids: [], total: null };
|
||||
}),
|
||||
hasArchivedEmail: vi.fn(async () => options.has ?? true),
|
||||
};
|
||||
}
|
||||
|
||||
const initialPolicy = usePolicyStore.getState();
|
||||
const initialAuth = useAuthStore.getState();
|
||||
|
||||
function setPolicy(patch: { hidden?: boolean; days?: number } = {}) {
|
||||
const policy = {
|
||||
...initialPolicy.policy,
|
||||
restrictions: (patch.hidden ? { deletedItemsRecovery: { hidden: true } } : {}) as Record<string, { hidden?: boolean }>,
|
||||
organisation: { ...DEFAULT_ORGANISATION_POLICY, recovery: { days: patch.days ?? 0 } },
|
||||
};
|
||||
usePolicyStore.setState({ loaded: true, rawPolicy: policy, policy, subject: { email: 'org-alice@example.org', groups: [] } });
|
||||
}
|
||||
|
||||
async function renderLink(mailbox: Mailbox | undefined, client = makeClient()) {
|
||||
const subject = usePolicyStore.getState().subject;
|
||||
useAuthStore.setState({ client: client as never, activeAccountId: 'acc-alice' });
|
||||
usePolicyStore.setState({ subject });
|
||||
const view = render(<TrashRecoveryLink mailbox={mailbox} />);
|
||||
await act(async () => {});
|
||||
return { ...view, client };
|
||||
}
|
||||
|
||||
beforeEach(() => setPolicy());
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
push.mockReset();
|
||||
useRecoveryStore.getState().reset();
|
||||
usePolicyStore.setState(initialPolicy, true);
|
||||
useAuthStore.setState(initialAuth, true);
|
||||
});
|
||||
|
||||
describe('TrashRecoveryLink', () => {
|
||||
it("links the account's own Trash to Recover deleted items when there is a recent message to recover", async () => {
|
||||
setPolicy({ days: 14 });
|
||||
const { client } = await renderLink(TRASH);
|
||||
expect(client.hasArchivedEmail).toHaveBeenCalledWith(undefined, { days: 14 });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'trash_link' }));
|
||||
expect(push).toHaveBeenCalledWith('/settings/recovery');
|
||||
});
|
||||
|
||||
it('is not there when the archive holds no message inside the days', async () => {
|
||||
const { container, client } = await renderLink(TRASH, makeClient({ has: false }));
|
||||
expect(client.hasArchivedEmail).toHaveBeenCalledTimes(1);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('is not there when the server refuses, and looks no further', async () => {
|
||||
const { container, client } = await renderLink(TRASH, makeClient({ supported: false }));
|
||||
expect(client.queryArchivedItemIds).toHaveBeenCalledTimes(1);
|
||||
expect(client.hasArchivedEmail).not.toHaveBeenCalled();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('appears once a later check finds the license, while Trash stays open', async () => {
|
||||
// An earlier refusal, due to be asked again shortly.
|
||||
useRecoveryStore.setState({ checks: { 'acc-alice|ci': { supported: false, retryAt: Date.now() + 50 } } });
|
||||
const { client } = await renderLink(TRASH);
|
||||
expect(screen.queryByTestId('trash-recovery-link')).toBeNull();
|
||||
expect(client.queryArchivedItemIds).not.toHaveBeenCalled();
|
||||
expect(await screen.findByTestId('trash-recovery-link')).toBeTruthy();
|
||||
expect(client.queryArchivedItemIds).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('forces the later check, so a timer that fires a little early still asks', async () => {
|
||||
useRecoveryStore.setState({ checks: { 'acc-alice|ci': { supported: false, retryAt: Date.now() + 50 } } });
|
||||
const realDetect = useRecoveryStore.getState().detect;
|
||||
const detect = vi.fn(realDetect);
|
||||
useRecoveryStore.setState({ detect });
|
||||
await renderLink(TRASH);
|
||||
await screen.findByTestId('trash-recovery-link');
|
||||
expect(detect).toHaveBeenCalledWith(expect.anything(), 'acc-alice|ci', { force: true });
|
||||
useRecoveryStore.setState({ detect: realDetect });
|
||||
});
|
||||
|
||||
it('is not there when the admin hides it, and asks nothing', async () => {
|
||||
setPolicy({ hidden: true });
|
||||
const { container, client } = await renderLink(TRASH);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(client.queryArchivedItemIds).not.toHaveBeenCalled();
|
||||
expect(client.hasArchivedEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks nothing before the policy is loaded', async () => {
|
||||
usePolicyStore.setState({ loaded: false });
|
||||
const { container, client } = await renderLink(TRASH);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(client.queryArchivedItemIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is not there in other folders or in a shared account's Trash", async () => {
|
||||
const inbox = await renderLink({ ...TRASH, role: 'inbox' });
|
||||
expect(inbox.container).toBeEmptyDOMElement();
|
||||
expect(inbox.client.hasArchivedEmail).not.toHaveBeenCalled();
|
||||
cleanup();
|
||||
const shared = await renderLink({ ...TRASH, accountId: 'ch', isShared: true });
|
||||
expect(shared.container).toBeEmptyDOMElement();
|
||||
cleanup();
|
||||
const sharedNoAccount = await renderLink({ ...TRASH, accountId: undefined, isShared: true });
|
||||
expect(sharedNoAccount.container).toBeEmptyDOMElement();
|
||||
cleanup();
|
||||
const otherAccount = await renderLink({ ...TRASH, accountId: 'ch' });
|
||||
expect(otherAccount.container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { render, act, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { clearAllHooks } from '@/lib/plugin-hooks';
|
||||
|
||||
// A quick reply that a pre-send question stopped (Back on the external
|
||||
// recipient confirm) must keep the typed text; one that went out clears it.
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
const message = {
|
||||
id: 'm1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Dave', email: 'org-dave@org-two.test' }], to: [{ name: 'Alice', email: 'org-alice@example.org' }],
|
||||
subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
|
||||
textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [{ partId: '1', type: 'text/plain' }], attachments: [],
|
||||
bodyValues: { '1': { value: 'Plain body' } },
|
||||
} as unknown as Email;
|
||||
|
||||
beforeEach(() => clearAllHooks());
|
||||
|
||||
async function typeAndSend(onQuickReply: (body: string) => Promise<boolean | void>) {
|
||||
render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={onQuickReply} />);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
const box = screen.getByPlaceholderText('quick_reply_placeholder') as HTMLTextAreaElement;
|
||||
fireEvent.focus(box);
|
||||
fireEvent.change(box, { target: { value: 'My answer' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send' }));
|
||||
await waitFor(() => expect(onQuickReply).toHaveBeenCalledWith('My answer'));
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
return box;
|
||||
}
|
||||
|
||||
describe('quick reply', () => {
|
||||
it('keeps the text when nothing was sent', async () => {
|
||||
const box = await typeAndSend(vi.fn(async () => false));
|
||||
expect(box.value).toBe('My answer');
|
||||
});
|
||||
|
||||
it('clears the text after a send', async () => {
|
||||
const box = await typeAndSend(vi.fn(async () => true));
|
||||
expect(box.value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('automatic sign-out while the external recipient question is open', () => {
|
||||
it('answers Back and keeps the typed reply as a draft, without asking again', async () => {
|
||||
const { useExternalRecipientConfirmStore } = await import('@/stores/external-recipient-confirm-store');
|
||||
const { flushComposers, resetComposeFlushRegistry } = await import('@/lib/compose-flush-registry');
|
||||
resetComposeFlushRegistry();
|
||||
useExternalRecipientConfirmStore.setState({ pending: null, queue: [] });
|
||||
const onQuickReply = vi.fn((body: string) => {
|
||||
void body;
|
||||
return useExternalRecipientConfirmStore.getState().ask([{ address: 'org-dave@org-two.test', domain: 'org-two.test', field: 'to' }]);
|
||||
});
|
||||
const onSaveQuickReplyDraft = vi.fn(async () => {});
|
||||
render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox"
|
||||
onQuickReply={onQuickReply} onSaveQuickReplyDraft={onSaveQuickReplyDraft} />);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
const box = screen.getByPlaceholderText('quick_reply_placeholder') as HTMLTextAreaElement;
|
||||
fireEvent.focus(box);
|
||||
fireEvent.change(box, { target: { value: 'My answer' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send' }));
|
||||
await waitFor(() => expect(useExternalRecipientConfirmStore.getState().pending).not.toBeNull());
|
||||
|
||||
await act(async () => { await flushComposers(2000); });
|
||||
expect(onSaveQuickReplyDraft).toHaveBeenCalledWith('My answer');
|
||||
expect(useExternalRecipientConfirmStore.getState().pending).toBeNull();
|
||||
expect(box.value).toBe('My answer');
|
||||
expect(onQuickReply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { toast } from '@/stores/toast-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { flushComposers, resetComposeFlushRegistry } from '@/lib/compose-flush-registry';
|
||||
|
||||
// Sending from the viewer - quick reply, "Send now" / "Reschedule" of a
|
||||
// scheduled message, and read receipts - used to fail silently or as an
|
||||
@@ -110,6 +111,21 @@ describe('viewer send failures', () => {
|
||||
expect((box as HTMLTextAreaElement).value).toBe('Thanks!');
|
||||
});
|
||||
|
||||
it('keeps typed quick-reply text as a draft before an automatic sign-out', async () => {
|
||||
resetComposeFlushRegistry();
|
||||
const onSaveQuickReplyDraft = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<EmailViewer email={makeEmail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox"
|
||||
onQuickReply={vi.fn()} onSaveQuickReplyDraft={onSaveQuickReplyDraft} />,
|
||||
);
|
||||
await act(async () => { await flushComposers(1000); });
|
||||
expect(onSaveQuickReplyDraft).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('quick_reply_placeholder'), { target: { value: 'On it.' } });
|
||||
await act(async () => { await flushComposers(1000); });
|
||||
expect(onSaveQuickReplyDraft).toHaveBeenCalledWith('On it.');
|
||||
});
|
||||
|
||||
const scheduled = () => makeEmail({
|
||||
isScheduled: true,
|
||||
scheduledUndoStatus: 'pending',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { Tag } from "@/components/icons";
|
||||
import { TagPicker } from "./tag-picker";
|
||||
import type { TagScope } from "@/hooks/use-account-tags";
|
||||
|
||||
const TAG_PICKER_WIDTH = 224;
|
||||
|
||||
@@ -22,6 +23,7 @@ export function BatchTagButton({
|
||||
onToggle,
|
||||
active,
|
||||
disabled,
|
||||
scope = null,
|
||||
}: {
|
||||
title: string;
|
||||
/** Tags every selected message carries. */
|
||||
@@ -32,6 +34,8 @@ export function BatchTagButton({
|
||||
/** Whether there is a selection to tag; the picker shuts without one. */
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
/** The account of the whole selection (commonTagScope); null: the user's own tags. */
|
||||
scope?: TagScope | null;
|
||||
}) {
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const pickerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -93,7 +97,7 @@ export function BatchTagButton({
|
||||
className="fixed z-50 w-56 max-w-[18rem] rounded-lg border border-border bg-popover py-1 shadow-lg"
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
>
|
||||
<TagPicker selectedIds={selectedIds} partialIds={partialIds} onToggle={onToggle} />
|
||||
<TagPicker selectedIds={selectedIds} partialIds={partialIds} onToggle={onToggle} scope={scope} />
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
@@ -87,9 +87,15 @@ import type { DirectoryEntry } from "@/stores/contact-store";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { FileNode } from "@/lib/jmap/types";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { useTemplateStore, renderTemplateWithPlugins, notifyTemplateApplied } from "@/stores/template-store";
|
||||
import { renderTemplateWithPlugins, notifyTemplateApplied } from "@/stores/template-store";
|
||||
import { useTemplateSources } from "@/hooks/use-template-sources";
|
||||
import { personalTemplatesFor } from "@/lib/templates/personal-templates";
|
||||
import { notifyIdentitySelected } from "@/stores/identity-store";
|
||||
import { usePluginSignature } from "@/hooks/use-plugin-signature";
|
||||
import { useOrgSignature, useOrgSignatureIdentity } from "@/hooks/use-org-signature";
|
||||
import { OrgDisclaimerPreview } from "@/components/email/org-disclaimer-preview";
|
||||
// Registers the organisation signature check and the disclaimer on the send path.
|
||||
import "@/lib/org/outgoing";
|
||||
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
|
||||
import { generateSubAddress } from "@/lib/sub-addressing";
|
||||
import { substitutePlaceholders, spliceTemplateAboveSignature, composeBodyHasUserContent } from "@/lib/template-utils";
|
||||
@@ -100,6 +106,7 @@ import { TemplateForm } from "@/components/templates/template-form";
|
||||
import type { EmailTemplate } from "@/lib/template-types";
|
||||
import { appendPlainTextSignature, getPlainTextSignature, plainTextBodyHasSignature, plainTextBodyWithoutSignature } from "@/lib/signature-utils";
|
||||
import { wantsSignatureSeparator } from "@/lib/signature-delimiter";
|
||||
import { finalizeOutgoing, runPreSendChecks, type OutgoingContext } from "@/lib/outgoing/send-pipeline";
|
||||
import { findComposeIdentityId, findDraftIdentityId, findReplyIdentityId, resolveReplyFrom } from "@/lib/reply-identity";
|
||||
import { buildReplyRecipients, isSelfSent, ownReplyAddresses } from "@/lib/reply-recipients";
|
||||
import { computeReplyThreadingHeaders, type ReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
@@ -136,6 +143,7 @@ import type { Editor } from "@tiptap/react";
|
||||
import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
|
||||
import { fileStorage } from "@/lib/plugin-storage";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { registerComposeFlush } from "@/lib/compose-flush-registry";
|
||||
import { toUnicodeEmail } from "@/lib/idn";
|
||||
|
||||
/**
|
||||
@@ -144,6 +152,9 @@ import { toUnicodeEmail } from "@/lib/idn";
|
||||
* <p> blocks are separated by a blank line, matching their visual rendering (#421).
|
||||
* Quotes get "> " and list items their markers, like any plain-text mail.
|
||||
*/
|
||||
/** How often an automatic sign-out looks whether a send in flight has finished. */
|
||||
const SEND_WAIT_POLL_MS = 100;
|
||||
|
||||
function htmlToPlainText(html: string): string {
|
||||
return htmlToPlainTextShared(html, { paragraphSpacing: true, mailMarkup: true });
|
||||
}
|
||||
@@ -234,6 +245,12 @@ export interface ComposerDraftData {
|
||||
references?: string[];
|
||||
/** Priority of a re-opened draft, read back from its headers (#970). */
|
||||
priority?: MessagePriority;
|
||||
/**
|
||||
* Handed to the host when the composer went away with edits the server may
|
||||
* not have: the composer opened again with it counts them as unsaved, so
|
||||
* an automatic sign-out saves them (lib/compose-flush-registry.ts).
|
||||
*/
|
||||
unsaved?: boolean;
|
||||
}
|
||||
|
||||
interface EmailComposerProps {
|
||||
@@ -540,9 +557,15 @@ export function EmailComposer({
|
||||
const initialCurrentIdentityForSig = initialData?.selectedIdentityId
|
||||
? identities.find((i) => i.id === initialData.selectedIdentityId) || primaryIdentity
|
||||
: primaryIdentity;
|
||||
const initialSignatureIdentity = (initialCurrentIdentityForSig?.htmlSignature || initialCurrentIdentityForSig?.textSignature)
|
||||
? initialCurrentIdentityForSig
|
||||
: primaryIdentity;
|
||||
// The organisation signature applies here as below (hooks/use-org-signature.ts).
|
||||
const initialSignatureIdentity = useOrgSignatureIdentity(
|
||||
(initialCurrentIdentityForSig?.htmlSignature || initialCurrentIdentityForSig?.textSignature)
|
||||
? initialCurrentIdentityForSig
|
||||
: primaryIdentity,
|
||||
initialCurrentIdentityForSig,
|
||||
(initialCurrentIdentityForSig?.id && stripCrossAccountIdentityPrefix(initialCurrentIdentityForSig.id).localAccountId)
|
||||
|| composerAccountIdRef.current,
|
||||
);
|
||||
const hasInitialSignature = !!(initialSignatureIdentity?.htmlSignature || initialSignatureIdentity?.textSignature);
|
||||
const shouldEmbedSignatureAboveQuote =
|
||||
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
||||
@@ -1005,6 +1028,10 @@ export function EmailComposer({
|
||||
const composerClient = currentIdentityParts.localAccountId
|
||||
? (useAuthStore.getState().getClientForAccount(currentIdentityParts.localAccountId) ?? client)
|
||||
: client;
|
||||
// The signed-in account the message goes out from, whose domain and group
|
||||
// scopes decide the organisation signature and disclaimer: the identity's
|
||||
// own account, else the one the composer was opened in.
|
||||
const composingAccountId = currentIdentityParts.localAccountId ?? composerAccountIdRef.current;
|
||||
// The upload callbacks below list only `client` as a dependency, so they read
|
||||
// the composing identity's client through a ref: uploads must land in the
|
||||
// account that owns the draft, and a direct substitution would capture a
|
||||
@@ -1121,8 +1148,13 @@ export function EmailComposer({
|
||||
const ownSignatureIdentity = identityPickedByUser || currentIdentity?.htmlSignature || currentIdentity?.textSignature
|
||||
? currentIdentity
|
||||
: primaryIdentity;
|
||||
// Plugins may rewrite the signature (onSignatureRender).
|
||||
const { identity: signatureIdentity, revision: pluginSignatureRevision } = usePluginSignature(ownSignatureIdentity);
|
||||
// Then the organisation signature: the default for an identity without
|
||||
// one, or in place of every signature when the admin locked it. Plugins
|
||||
// may rewrite the result (onSignatureRender), except a locked one.
|
||||
const orgSignature = useOrgSignature(ownSignatureIdentity, currentIdentity, composingAccountId);
|
||||
const { identity: signatureIdentity, revision: pluginSignatureRevision } = usePluginSignature(orgSignature.identity, {
|
||||
locked: orgSignature.enforced,
|
||||
});
|
||||
|
||||
// Hold the TipTap editor instance so we can swap the embedded signature
|
||||
// when the user switches identity in "above quote" mode without rebuilding
|
||||
@@ -1135,18 +1167,29 @@ export function EmailComposer({
|
||||
const prevSignatureSeparatorRef = useRef<boolean>(signatureSeparatorEnabled);
|
||||
const prevPluginSignatureRevisionRef = useRef(pluginSignatureRevision);
|
||||
const prevSignatureIdentityRef = useRef(signatureIdentity);
|
||||
// The signature's text, for a change under the same identity: the
|
||||
// organisation policy arriving after the composer opened, or the user's
|
||||
// details changing.
|
||||
const signatureContent = JSON.stringify([signatureIdentity?.htmlSignature ?? "", signatureIdentity?.textSignature ?? ""]);
|
||||
const prevSignatureContentRef = useRef(signatureContent);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
const identityChanged = prevSignatureIdentityIdRef.current !== signatureIdentity?.id;
|
||||
const separatorChanged = prevSignatureSeparatorRef.current !== signatureSeparatorEnabled;
|
||||
const pluginSignatureChanged = prevPluginSignatureRevisionRef.current !== pluginSignatureRevision;
|
||||
const contentChanged = prevSignatureContentRef.current !== signatureContent;
|
||||
const previousIdentity = prevSignatureIdentityRef.current;
|
||||
prevSignatureIdentityIdRef.current = signatureIdentity?.id;
|
||||
prevSignatureSeparatorRef.current = signatureSeparatorEnabled;
|
||||
prevPluginSignatureRevisionRef.current = pluginSignatureRevision;
|
||||
prevSignatureIdentityRef.current = signatureIdentity;
|
||||
if (!identityChanged && !separatorChanged && !pluginSignatureChanged) return;
|
||||
prevSignatureContentRef.current = signatureContent;
|
||||
if (!identityChanged && !separatorChanged && !pluginSignatureChanged && !contentChanged) return;
|
||||
// A signature that arrives where there was none comes into the body like
|
||||
// a new identity's; one the user removed stays removed.
|
||||
const hadSignature = !!(previousIdentity?.htmlSignature || previousIdentity?.textSignature);
|
||||
const bringsSignature = identityChanged || (contentChanged && !hadSignature);
|
||||
// Replies/forwards only embed when configured for "above quote". Compose
|
||||
// always embeds (see getInitialBody), so swap on identity change there too.
|
||||
const isReplyLike = mode === 'reply' || mode === 'replyAll' || mode === 'forward';
|
||||
@@ -1156,7 +1199,7 @@ export function EmailComposer({
|
||||
if (plainTextMode) {
|
||||
// A new plain-text message carries the signature at the end of the
|
||||
// body, a reply or forward above its quote header.
|
||||
if (!identityChanged) return;
|
||||
if (!identityChanged && !contentChanged) return;
|
||||
const options = { separator: signatureSeparatorEnabled };
|
||||
if (mode === 'compose') {
|
||||
setBody((prev) => replacePlainTextSignature(prev, previousIdentity, signatureIdentity, options));
|
||||
@@ -1179,8 +1222,8 @@ export function EmailComposer({
|
||||
// quoted body - see removeSignatureRange), or removed when the new
|
||||
// identity has no signature.
|
||||
const currentHtml = serializeEditorContent(editor);
|
||||
// Only a new identity brings a signature back into a body without one.
|
||||
if (!identityChanged && !containsEmbeddedSignature(currentHtml)) return;
|
||||
// Only a new identity (or a first signature) brings one into a body without one.
|
||||
if (!bringsSignature && !containsEmbeddedSignature(currentHtml)) return;
|
||||
const nextHtml = replaceEmbeddedSignature(currentHtml, buildEmbeddedSignatureHtml(signatureIdentity, {
|
||||
embed: true,
|
||||
separator: signatureSeparatorEnabled,
|
||||
@@ -1193,7 +1236,7 @@ export function EmailComposer({
|
||||
// would re-run on unrelated identity-field changes and re-splice the
|
||||
// signature into the live editor.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode, pluginSignatureRevision]);
|
||||
}, [signatureIdentity?.id, signatureContent, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode, pluginSignatureRevision]);
|
||||
|
||||
const openScheduleDialog = useCallback(() => {
|
||||
setShowScheduleDialog(true);
|
||||
@@ -1475,7 +1518,15 @@ export function EmailComposer({
|
||||
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
|
||||
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||
const addTemplate = useTemplateStore((s) => s.addTemplate);
|
||||
// "Save as template" keeps it with the account the message is written from.
|
||||
const addTemplate = useCallback(
|
||||
(data: Parameters<ReturnType<typeof personalTemplatesFor>['add']>[0]) => personalTemplatesFor(composingAccountId).add(data),
|
||||
[composingAccountId],
|
||||
);
|
||||
// The admin may leave users only organisation and team templates.
|
||||
const composingTemplates = useTemplateSources(composingAccountId);
|
||||
const personalTemplatesHidden = composingTemplates.personalHidden;
|
||||
const composingPersonalTemplates = composingTemplates.sources.find((source) => source.kind === 'personal')?.templates;
|
||||
// Sign/encrypt is provided by crypto plugins (S/MIME, PGP) via the
|
||||
// composer-toolbar slot + the onComposeSend hook — the host stays
|
||||
// crypto-agnostic.
|
||||
@@ -1536,7 +1587,8 @@ export function EmailComposer({
|
||||
// Bumped on every edit and compared with the edit the last successful save
|
||||
// covered. Closing a message autosave already stored asked about "unsaved
|
||||
// changes" that were not there.
|
||||
const changeVersionRef = useRef(0);
|
||||
// Stashed edits handed back by the host start out unsaved.
|
||||
const changeVersionRef = useRef(initialData?.unsaved ? 1 : 0);
|
||||
const savedVersionRef = useRef(0);
|
||||
const firstChangeRunRef = useRef(true);
|
||||
useEffect(() => {
|
||||
@@ -1610,6 +1662,9 @@ export function EmailComposer({
|
||||
mode,
|
||||
replyTo,
|
||||
priority: priorityRef.current,
|
||||
// Edits the last save didn't cover; a minimised composer with
|
||||
// none needs no save before an automatic sign-out.
|
||||
unsaved: changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -2661,6 +2716,24 @@ export function EmailComposer({
|
||||
// Keep saveDraftRef pointing to latest saveDraft
|
||||
saveDraftRef.current = saveDraft;
|
||||
|
||||
// An automatic sign-out saves what the server doesn't have yet, with the
|
||||
// same save the close and send paths use. A send in flight goes first:
|
||||
// the sign-out waits for it (within its own time limit), and a message
|
||||
// that went out needs no draft. A save the server refused throws, so the
|
||||
// login page can say a draft wasn't saved.
|
||||
useEffect(() => registerComposeFlush(async () => {
|
||||
while (isSendingRef.current) await new Promise((resolve) => setTimeout(resolve, SEND_WAIT_POLL_MS));
|
||||
if (sentRef.current) return;
|
||||
const unsaved = changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current;
|
||||
if (!unsaved) return;
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = null;
|
||||
}
|
||||
await saveDraftRef.current();
|
||||
if (saveFailedRef.current) throw new Error('The draft was not saved');
|
||||
}), []);
|
||||
|
||||
// The draft as plugins see it: onDraftChange, the frames above and below
|
||||
// the body, and clicks on plugin entries in the Send menu.
|
||||
const draftSnapshot = () => ({
|
||||
@@ -3044,6 +3117,8 @@ export function EmailComposer({
|
||||
// second click in the same tick - and isSending drives button disabling.
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const isSendingRef = useRef(false);
|
||||
// A pre-send check is waiting for the user (send-pipeline).
|
||||
const preSendCheckingRef = useRef(false);
|
||||
|
||||
// Attachments still uploading when Send is clicked used to be silently
|
||||
// dropped from the outgoing message (the filters below exclude anything
|
||||
@@ -3228,6 +3303,35 @@ export function EmailComposer({
|
||||
}
|
||||
}
|
||||
|
||||
// Checks other features register (lib/outgoing/send-pipeline.ts), such
|
||||
// as the external-recipient confirm. They may ask the user, so a second
|
||||
// click meanwhile does nothing.
|
||||
if (preSendCheckingRef.current) return;
|
||||
const outgoingContext: OutgoingContext = {
|
||||
surface: 'composer',
|
||||
to: toAddresses.map(r => r.email),
|
||||
cc: ccAddresses.map(r => r.email),
|
||||
// The identity's automatic Bcc (Identity.bcc) is the user's own setup,
|
||||
// not a recipient they picked: checks see only the typed Bcc.
|
||||
bcc: expandRecipients(withInput(bcc, bccInput)).map(r => r.email),
|
||||
fromEmail: (fromOverrideEnabled && fromOverrideEmail.trim()) || currentIdentity?.email || undefined,
|
||||
identityId: currentIdentity?.id,
|
||||
mode,
|
||||
plainText: plainTextMode,
|
||||
forwardedSeparator: tQuote('forwarded_separator'),
|
||||
sender: currentIdentity
|
||||
? { name: currentIdentity.name, email: currentIdentity.email, accountId: composingAccountId }
|
||||
: undefined,
|
||||
};
|
||||
preSendCheckingRef.current = true;
|
||||
let checksPassed = false;
|
||||
try {
|
||||
checksPassed = await runPreSendChecks(outgoingContext);
|
||||
} finally {
|
||||
preSendCheckingRef.current = false;
|
||||
}
|
||||
if (!checksPassed) return;
|
||||
|
||||
// Past every "don't send" early return - mark the send in flight so a
|
||||
// second click is a no-op until this resolves (reset in the finally below).
|
||||
isSendingRef.current = true;
|
||||
@@ -3300,7 +3404,7 @@ export function EmailComposer({
|
||||
|
||||
// In plain text mode, send text/plain only (no HTML body)
|
||||
const signatureOpts = { separator: signatureSeparatorEnabled };
|
||||
const finalBody = plainTextMode
|
||||
const builtTextBody = plainTextMode
|
||||
? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts))
|
||||
: (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity, signatureOpts));
|
||||
|
||||
@@ -3320,9 +3424,15 @@ export function EmailComposer({
|
||||
}
|
||||
|
||||
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
|
||||
const finalHtmlBody = plainTextMode
|
||||
? undefined
|
||||
: `<div>${rewritten!.html}</div>${buildSignatureHtml()}`;
|
||||
// What the send-pipeline finalisers add (organisation signature) goes in
|
||||
// here, on the send only: drafts never get it. The `last` stage (the
|
||||
// disclaimer) runs on what goes out once plugins had their say, below.
|
||||
const { html: finalHtmlBody, text: finalBody } = finalizeOutgoing({
|
||||
html: plainTextMode ? undefined : `<div>${rewritten!.html}</div>${buildSignatureHtml()}`,
|
||||
text: builtTextBody,
|
||||
}, outgoingContext);
|
||||
const lastStage = (html: string | undefined, text: string) =>
|
||||
finalizeOutgoing({ html: html || undefined, text }, outgoingContext, 'last');
|
||||
const inlineAttachments = rewritten?.attachments ?? [];
|
||||
|
||||
try {
|
||||
@@ -3361,14 +3471,16 @@ export function EmailComposer({
|
||||
// host then skips its own plaintext submission, cleans up the draft and
|
||||
// closes (through the scheduled-send callback for a delayed send). An
|
||||
// aborted or failed handler sent nothing: keep the message and its
|
||||
// draft, and never fall back to sending it unsigned (#1207).
|
||||
// draft, and never fall back to sending it unsigned (#1207). It signs
|
||||
// or encrypts the message with the `last` finalisers' additions.
|
||||
const sealed = lastStage(finalHtmlBody, finalBody);
|
||||
const composeSendRequest = {
|
||||
to: toAddresses.map(r => formatRecipient(r.name, r.email)),
|
||||
cc: ccAddresses.map(r => formatRecipient(r.name, r.email)),
|
||||
bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)),
|
||||
subject,
|
||||
htmlBody: finalHtmlBody || '',
|
||||
textBody: finalBody,
|
||||
htmlBody: sealed.html || '',
|
||||
textBody: sealed.text,
|
||||
identityId: currentIdentity?.id || '',
|
||||
fromEmail,
|
||||
fromName,
|
||||
@@ -3433,6 +3545,8 @@ export function EmailComposer({
|
||||
inReplyTo: threadingHeaders?.inReplyTo?.[0],
|
||||
};
|
||||
const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput);
|
||||
// Below whatever the plugins added (a footer), the `last` finalisers.
|
||||
const sealedOutgoing = lastStage(outgoing.htmlBody, outgoing.textBody);
|
||||
|
||||
// Strip the cross-account namespace from the identity id before
|
||||
// handing it to the parent - the JMAP server only knows the raw
|
||||
@@ -3465,8 +3579,8 @@ export function EmailComposer({
|
||||
cc: outgoing.cc,
|
||||
bcc: outgoing.bcc,
|
||||
subject: outgoing.subject,
|
||||
body: outgoing.textBody,
|
||||
htmlBody: outgoing.htmlBody || undefined,
|
||||
body: sealedOutgoing.text,
|
||||
htmlBody: sealedOutgoing.html || undefined,
|
||||
draftId: finalDraftId && !draftElsewhere ? finalDraftId : undefined,
|
||||
draftAccountId: finalDraftId && !draftElsewhere ? draftAccountAtSend : undefined,
|
||||
fromEmail,
|
||||
@@ -4300,6 +4414,8 @@ export function EmailComposer({
|
||||
dangerouslySetInnerHTML={{ __html: `${wantsSignatureSeparator(signatureSeparatorEnabled, signatureIdentity) ? '<div>-- </div>' : ''}${composerSignatureHtml}` }}
|
||||
/>
|
||||
) : null}
|
||||
{/* The admin's disclaimer: added on send (lib/org/outgoing.ts), never part of the body. */}
|
||||
<OrgDisclaimerPreview sending={currentIdentity} accountId={composingAccountId} plainText={plainTextMode} body={body} />
|
||||
{wantsPluginDraft && (
|
||||
<PluginSlot name="composer-below-body" className="w-full" extraProps={pluginDraftProps} />
|
||||
)}
|
||||
@@ -4499,9 +4615,11 @@ export function EmailComposer({
|
||||
<ComposerToolbarMenuItem icon={FileText} onSelect={() => { close(); setShowTemplatePicker(true); }}>
|
||||
{t('insert_template')}
|
||||
</ComposerToolbarMenuItem>
|
||||
<ComposerToolbarMenuItem icon={BookmarkPlus} onSelect={() => { close(); setShowSaveAsTemplate(true); }}>
|
||||
{t('save_as_template')}
|
||||
</ComposerToolbarMenuItem>
|
||||
{!personalTemplatesHidden && (
|
||||
<ComposerToolbarMenuItem icon={BookmarkPlus} onSelect={() => { close(); setShowSaveAsTemplate(true); }}>
|
||||
{t('save_as_template')}
|
||||
</ComposerToolbarMenuItem>
|
||||
)}
|
||||
</>}
|
||||
</ComposerToolbarMenu>
|
||||
)}
|
||||
@@ -4643,6 +4761,7 @@ export function EmailComposer({
|
||||
isOpen={showTemplatePicker}
|
||||
onClose={() => setShowTemplatePicker(false)}
|
||||
onSelect={handleTemplateSelect}
|
||||
accountId={composingAccountId}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -4673,6 +4792,7 @@ export function EmailComposer({
|
||||
>
|
||||
<div className="pt-4">
|
||||
<TemplateForm
|
||||
categorySource={composingPersonalTemplates}
|
||||
initialData={{
|
||||
subject,
|
||||
body,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useEmailStore, removalFolder, canRemoveFromFolder } from "@/stores/email-store";
|
||||
import { useEmailTagScope, type TagScope } from "@/hooks/use-account-tags";
|
||||
import { Email, Mailbox } from "@/lib/jmap/types";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
ContextMenuHeader,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { PluginActions, useMessageActionTarget, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import {
|
||||
Mail,
|
||||
MailOpen,
|
||||
@@ -98,6 +99,11 @@ interface EmailContextMenuProps {
|
||||
/** Tags every selected message carries, and those only some of them do. */
|
||||
batchTagIds?: string[];
|
||||
batchPartialTagIds?: string[];
|
||||
/**
|
||||
* The account of the whole selection (commonTagScope): its tags are
|
||||
* offered. Null when the selection mixes accounts: the user's own tags.
|
||||
*/
|
||||
batchTagScope?: TagScope | null;
|
||||
/** Takes the tag off when the whole selection has it, else puts it on all. */
|
||||
onBatchToggleTag?: (tagId: string) => void;
|
||||
onBatchDelete?: () => void;
|
||||
@@ -159,6 +165,7 @@ export function EmailContextMenu({
|
||||
onBatchMarkAsRead,
|
||||
batchTagIds,
|
||||
batchPartialTagIds,
|
||||
batchTagScope = null,
|
||||
onBatchToggleTag,
|
||||
onBatchDelete,
|
||||
onBatchArchive,
|
||||
@@ -188,6 +195,8 @@ export function EmailContextMenu({
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const currentTagIds = getEmailTagIds(email.keywords);
|
||||
// The row's account names its tags; a selection uses batchTagScope.
|
||||
const tagScope = useEmailTagScope(email);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
// Marking your own outgoing mail as spam makes no sense - hide the action
|
||||
@@ -307,7 +316,12 @@ export function EmailContextMenu({
|
||||
// opens this menu, so it is where those buttons live there. Below, the
|
||||
// selection's entries in the default layout's order or, under Mountain
|
||||
// View, in Gmail's.
|
||||
const hasRowPluginActions = usePluginActions("message-row-actions", { mailboxRole: currentMailboxRole ?? null }).length > 0;
|
||||
const rowPluginTarget = useMessageActionTarget(email);
|
||||
const hasRowPluginActions = usePluginActions("message-row-actions", {
|
||||
mailboxRole: currentMailboxRole ?? null,
|
||||
accountKind: rowPluginTarget.accountKind,
|
||||
keywords: rowPluginTarget.keywords,
|
||||
}).length > 0;
|
||||
const rowPluginContext = useMemo(() => ({ emailId: email.id }), [email.id]);
|
||||
const rowPluginBlock = !showBatchActions && hasRowPluginActions ? (
|
||||
<>
|
||||
@@ -316,6 +330,7 @@ export function EmailContextMenu({
|
||||
surface="message-row-actions"
|
||||
variant="menu"
|
||||
context={rowPluginContext}
|
||||
target={rowPluginTarget}
|
||||
mailboxRole={currentMailboxRole ?? null}
|
||||
onRan={onClose}
|
||||
/>
|
||||
@@ -411,6 +426,7 @@ export function EmailContextMenu({
|
||||
selectedIds={batchTagIds ?? []}
|
||||
partialIds={batchPartialTagIds}
|
||||
onToggle={(tagId) => onBatchToggleTag?.(tagId)}
|
||||
scope={batchTagScope}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuSubMenu>
|
||||
@@ -454,7 +470,7 @@ export function EmailContextMenu({
|
||||
return (
|
||||
<ContextMenuSubMenu key={entry.key} icon={entry.icon} label={entry.label} testId={entry.testId}>
|
||||
<div className="w-56 max-w-[18rem]">
|
||||
<TagPicker selectedIds={currentTagIds} onToggle={(tagId) => onSetTag?.(tagId)} />
|
||||
<TagPicker selectedIds={currentTagIds} onToggle={(tagId) => onSetTag?.(tagId)} scope={tagScope} />
|
||||
</div>
|
||||
</ContextMenuSubMenu>
|
||||
);
|
||||
|
||||
@@ -11,8 +11,9 @@ import { useArchiveActionEnabled, withoutArchiveIfOff } from "@/lib/archive-gate
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { TagPicker } from "@/components/email/tag-picker";
|
||||
import { useEmailTagScope } from "@/hooks/use-account-tags";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { PluginActions, useMessageActionTarget, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
|
||||
interface EmailHoverActionsProps {
|
||||
email: Email;
|
||||
@@ -111,6 +112,7 @@ export function EmailHoverActions({
|
||||
spamApplicable = true,
|
||||
mailboxRole = null,
|
||||
}: EmailHoverActionsProps) {
|
||||
const tagScope = useEmailTagScope(email);
|
||||
const configuredHoverActions = useSettingsStore((state) => state.hoverActions);
|
||||
const mountainView = useSettingsStore((state) => state.interfaceLayout === 'mountain-view');
|
||||
const archiveActionEnabled = useArchiveActionEnabled();
|
||||
@@ -124,7 +126,10 @@ export function EmailHoverActions({
|
||||
const t = useTranslations("context_menu");
|
||||
const isMobile = useIsMobile();
|
||||
// Buttons plugins add to the row (message-row-actions), after the app's own.
|
||||
const pluginActionCount = usePluginActions("message-row-actions", { mailboxRole }).length;
|
||||
const pluginTarget = useMessageActionTarget(email);
|
||||
const pluginActionCount = usePluginActions("message-row-actions", {
|
||||
mailboxRole, accountKind: pluginTarget.accountKind, keywords: pluginTarget.keywords,
|
||||
}).length;
|
||||
const pluginContext = useMemo(() => ({ emailId: email.id }), [email.id]);
|
||||
|
||||
// The tag quick-action opens the same picker the context menu and the reading
|
||||
@@ -275,6 +280,7 @@ export function EmailHoverActions({
|
||||
key="plugin-actions"
|
||||
surface="message-row-actions"
|
||||
context={pluginContext}
|
||||
target={pluginTarget}
|
||||
mailboxRole={mailboxRole}
|
||||
compact
|
||||
className="flex-nowrap gap-0.5"
|
||||
@@ -296,6 +302,7 @@ export function EmailHoverActions({
|
||||
>
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
scope={tagScope}
|
||||
onToggle={(tagId) => onSetTag(tagId)}
|
||||
/>
|
||||
</div>,
|
||||
|
||||
@@ -43,11 +43,14 @@ import { getOwnAddresses } from "@/lib/filters/quick-rule-target";
|
||||
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
|
||||
import { normalizeAddress } from "@/lib/filters/quick-rules";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { PluginListFilters } from "./plugin-list-filters";
|
||||
import { PluginActions, useFolderAccountKind } from "@/components/plugins/plugin-actions";
|
||||
import { useUnreadFirstSection } from "@/hooks/use-unread-first-section";
|
||||
import { unreadSectionRowCount } from "@/lib/unread-first";
|
||||
import { MvListSectionHeader } from "./mv-list-section-header";
|
||||
import { type OpenableEmail, PluginSearchResults } from "./plugin-search-results";
|
||||
import { commonTagScope } from "@/hooks/use-account-tags";
|
||||
import { TrashRecoveryLink } from "./trash-recovery-link";
|
||||
|
||||
interface EmailListProps {
|
||||
emails: Email[];
|
||||
@@ -274,6 +277,7 @@ export function EmailList({
|
||||
const listHold = useEmailStore((state) => state.listHold);
|
||||
const viewingAccountId = useEmailStore((state) => state.viewingAccountId);
|
||||
const selectedKeyword = useEmailStore((state) => state.selectedKeyword);
|
||||
const selectedKeywordAccountId = useEmailStore((state) => state.selectedKeywordAccountId);
|
||||
const searchMailboxId = useEmailStore((state) => state.searchMailboxId);
|
||||
const activeAccountId = useAuthStore((state) => state.activeAccountId);
|
||||
const activeTabId = useMessageListTabsStore((state) => state.activeTabId);
|
||||
@@ -508,6 +512,7 @@ export function EmailList({
|
||||
viewingAccountId,
|
||||
selectedMailbox,
|
||||
selectedKeyword,
|
||||
selectedKeywordAccountId,
|
||||
isUnifiedView && (crossView ?? unifiedRole),
|
||||
isScheduledView,
|
||||
activeTabId,
|
||||
@@ -530,6 +535,7 @@ export function EmailList({
|
||||
[emails, threadEmailsCache, selectedEmailKeys],
|
||||
);
|
||||
const bulkActionContext = useMemo(() => ({ emailIds: selectedEmailIds }), [selectedEmailIds]);
|
||||
const folderAccountKind = useFolderAccountKind();
|
||||
// "Remove from this folder" (#615): offered for a selection only when every
|
||||
// selected message is also in another folder.
|
||||
const removeFromFolder = useRemoveFromFolder();
|
||||
@@ -583,7 +589,8 @@ export function EmailList({
|
||||
const all: string[] = [];
|
||||
const some: string[] = [];
|
||||
for (const [tagId, count] of counts) (count === selected.length ? all : some).push(tagId);
|
||||
return { all, some };
|
||||
// A selection within one group account offers that account's shared tags.
|
||||
return { all, some, scope: commonTagScope(selected) };
|
||||
}, [emails, selectedEmailKeys]);
|
||||
|
||||
// A tag every selected message has comes off; any other goes onto all of
|
||||
@@ -918,6 +925,7 @@ export function EmailList({
|
||||
title={tContextMenu('tag')}
|
||||
selectedIds={selectionTags.all}
|
||||
partialIds={selectionTags.some}
|
||||
scope={selectionTags.scope}
|
||||
onToggle={handleBatchToggleTag}
|
||||
active={hasSelection && !isScheduledView}
|
||||
disabled={isProcessing}
|
||||
@@ -958,6 +966,7 @@ export function EmailList({
|
||||
<PluginActions
|
||||
surface="message-bulk-actions"
|
||||
context={bulkActionContext}
|
||||
accountKind={folderAccountKind}
|
||||
mailboxRole={effectiveMailboxRole ?? null}
|
||||
compact
|
||||
overflow={batchNarrow}
|
||||
@@ -1008,6 +1017,9 @@ export function EmailList({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Recover deleted items, from the account's own Trash (mounted only there) */}
|
||||
{currentMailbox?.role === 'trash' && <TrashRecoveryLink mailbox={currentMailbox} />}
|
||||
|
||||
{/* Empty Folder Banner for Junk/Trash */}
|
||||
{isEmptyableFolder && emails.length > 0 && !hasSelection && (
|
||||
<div className="px-4 py-2 border-b border-border bg-muted/30 flex items-center justify-between">
|
||||
@@ -1032,6 +1044,15 @@ export function EmailList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugins' keyword filters, in a folder's own list (not search, tags or merged views). */}
|
||||
{!isSearching && !selectedKeyword && !isUnifiedView && !isScheduledView && selectedMailbox && (
|
||||
<PluginListFilters
|
||||
mailboxRole={effectiveMailboxRole ?? null}
|
||||
accountKind={folderAccountKind}
|
||||
onChange={() => { if (client) void useEmailStore.getState().fetchEmails(client); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PluginSlot name="message-list-header" className="w-full" extraProps={listSlotProps} />
|
||||
|
||||
<PluginSearchResults surface="search-results" onOpenEmail={onOpenSearchResult} />
|
||||
@@ -1238,6 +1259,7 @@ export function EmailList({
|
||||
onBatchMarkAsRead={(read) => markSelectionRead(read)}
|
||||
batchTagIds={selectionTags.all}
|
||||
batchPartialTagIds={selectionTags.some}
|
||||
batchTagScope={selectionTags.scope}
|
||||
onBatchToggleTag={handleBatchToggleTag}
|
||||
onBatchDelete={() => void handleBatchDelete()}
|
||||
onBatchArchive={() => void handleBatchArchive()}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { TagBadge } from "./tag-badge";
|
||||
import { TagPicker } from "./tag-picker";
|
||||
import { useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useAccountTags, useEmailTagScope, type TagScope } from "@/hooks/use-account-tags";
|
||||
import { getEmailTagIds, emailKeyFor } from "@/lib/thread-utils";
|
||||
import { getSecurityStatus, extractListHeaders, getSenderVerification } from "@/lib/email-headers";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
@@ -107,6 +108,8 @@ import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
|
||||
import { registerComposeFlush } from "@/lib/compose-flush-registry";
|
||||
import { useExternalRecipientConfirmStore } from "@/stores/external-recipient-confirm-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
@@ -119,6 +122,7 @@ import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore, resolveEmailBlobContext, emailWriteSource, type EmailBlobContext } from "@/stores/email-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
import { ExternalSenderChip } from "./external-sender-chip";
|
||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||
import { ReadReceiptBanner } from "./read-receipt-banner";
|
||||
@@ -141,7 +145,7 @@ import { VerificationCodeChip } from "./verification-code-chip";
|
||||
import { ConversationEmailBody, type ConversationAttachmentHandler, useConversationExternalContent, useMarkReadWhenShown } from "./thread-conversation-view";
|
||||
import type { TnefAttachment } from "@/lib/tnef";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { PluginActions, useMessageActionTarget, type MessageActionTarget } from "@/components/plugins/plugin-actions";
|
||||
import { AttachmentPluginFrames, AttachmentPluginMenu, PluginPanelEntries, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
|
||||
import { RulesPanel, useRulesAvailability } from "./rules-menu";
|
||||
import type { QuickRuleTarget } from "@/lib/filters/quick-rule-target";
|
||||
@@ -154,6 +158,7 @@ import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
|
||||
import { useAttachmentDrag, isDragOutSupported, type AttachmentDragSource } from "@/hooks/use-attachment-drag";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import { useArchiveActionEnabled } from "@/lib/archive-gate";
|
||||
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
|
||||
|
||||
/** The More menu's two drill-downs: a folder list and a tag list. */
|
||||
type MoreMenuSub = MessageSubmenuKey;
|
||||
@@ -196,7 +201,10 @@ interface EmailViewerProps {
|
||||
onMarkAsRead?: (emailId: string, read: boolean, opts?: { automatic?: boolean }) => void;
|
||||
onSetTag?: (emailId: string, tagId: string | null) => void;
|
||||
onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean, source?: EmailBlobContext) => void;
|
||||
onQuickReply?: (body: string) => Promise<void>;
|
||||
/** Resolves false when nothing was sent (the user went back), so the text stays. */
|
||||
onQuickReply?: (body: string) => Promise<boolean | void>;
|
||||
/** Keeps the typed quick reply as a draft before an automatic sign-out (lib/compose-flush-registry.ts). */
|
||||
onSaveQuickReplyDraft?: (body: string) => Promise<void>;
|
||||
onMarkAsSpam?: () => void;
|
||||
onUndoSpam?: () => void;
|
||||
onMoveToMailbox?: (mailboxId: string) => void;
|
||||
@@ -880,6 +888,7 @@ export function MvExpandedConversationCard({
|
||||
{!compact && sender?.name && sender.email && (
|
||||
<span className="text-xs text-muted-foreground truncate"><{sender.email}></span>
|
||||
)}
|
||||
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} className="self-center" />
|
||||
<span className="ms-auto ps-2 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)}
|
||||
</span>
|
||||
@@ -1120,10 +1129,14 @@ interface ReaderMorePanelBodyProps {
|
||||
onMoveToMailbox?: (mailboxId: string) => void;
|
||||
currentTagIds: string[];
|
||||
sortedTagIds: string[];
|
||||
/** The message's account when it has shared tags (useEmailTagScope). */
|
||||
tagScope: TagScope | null;
|
||||
onToggleTag: (tagId: string) => void;
|
||||
/** Where filter rules go; null hides Rules (shared account, no Sieve). */
|
||||
rulesTarget: QuickRuleTarget | null;
|
||||
pluginContext: Record<string, unknown>;
|
||||
/** The message the plugin entries are about (useMessageActionTarget). */
|
||||
pluginTarget: MessageActionTarget;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1141,12 +1154,14 @@ function ReaderMorePanelBody({
|
||||
onMoveToMailbox,
|
||||
currentTagIds,
|
||||
sortedTagIds,
|
||||
tagScope,
|
||||
onToggleTag,
|
||||
rulesTarget,
|
||||
pluginContext,
|
||||
pluginTarget,
|
||||
}: ReaderMorePanelBodyProps) {
|
||||
const { email } = options;
|
||||
const { tagColor } = useKeywordFormat();
|
||||
const { tagColor } = useKeywordFormat(tagScope);
|
||||
const { copyTargets, copyToAccount } = useCopyToAccount(email, true);
|
||||
const entries = useMessageActionList({
|
||||
...options,
|
||||
@@ -1185,7 +1200,7 @@ function ReaderMorePanelBody({
|
||||
);
|
||||
}
|
||||
if (sub === 'tag') {
|
||||
return <TagPicker touch selectedIds={currentTagIds} onToggle={onToggleTag} />;
|
||||
return <TagPicker touch selectedIds={currentTagIds} onToggle={onToggleTag} scope={tagScope} />;
|
||||
}
|
||||
if (sub === 'rules') {
|
||||
return rulesTarget ? <RulesPanel email={email} target={rulesTarget} variant="mobile" onDone={close} /> : null;
|
||||
@@ -1237,6 +1252,7 @@ function ReaderMorePanelBody({
|
||||
<PluginPanelEntries
|
||||
surface="reader-actions"
|
||||
context={pluginContext}
|
||||
target={pluginTarget}
|
||||
mailboxRole={options.currentMailboxRole ?? null}
|
||||
onRan={close}
|
||||
/>
|
||||
@@ -1259,6 +1275,7 @@ export function EmailViewer({
|
||||
onSetTag,
|
||||
onDownloadAttachment,
|
||||
onQuickReply,
|
||||
onSaveQuickReplyDraft,
|
||||
onMarkAsSpam,
|
||||
onUndoSpam,
|
||||
onMoveToMailbox,
|
||||
@@ -1301,6 +1318,10 @@ export function EmailViewer({
|
||||
const tWelcome = useTranslations('welcome');
|
||||
const quickReplyEnabled = useSettingsStore((state) => state.quickReplyEnabled);
|
||||
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
||||
// No third-party traffic (#903): remote content never loads, whatever the
|
||||
// policy or trust says, and nothing offers to load it.
|
||||
const remoteBlockedByOrg = useNoThirdPartyTraffic();
|
||||
const tThirdParty = useTranslations('third_party');
|
||||
const messageSpacing = useSettingsStore((state) => state.messageSpacing);
|
||||
const plainTextFont = useSettingsStore((state) => state.plainTextFont);
|
||||
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
|
||||
@@ -1322,8 +1343,10 @@ export function EmailViewer({
|
||||
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
|
||||
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const { sortTagIds } = useKeywordFormat();
|
||||
// A message in a group account is tagged with that account's shared tags.
|
||||
const tagScope = useEmailTagScope(email);
|
||||
const emailKeywords = useAccountTags(tagScope).definitions;
|
||||
const { sortTagIds } = useKeywordFormat(tagScope);
|
||||
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
||||
const showToolbarLabelsSetting = useSettingsStore((state) => state.showToolbarLabels);
|
||||
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
|
||||
@@ -1560,13 +1583,36 @@ export function EmailViewer({
|
||||
const [quickReplyText, setQuickReplyText] = useState("");
|
||||
const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false);
|
||||
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
|
||||
// An automatic sign-out keeps typed quick-reply text as a draft; text on
|
||||
// its way out as a reply needs none. A reply still waiting on the
|
||||
// "Send outside your organisation?" question hasn't gone out: the question
|
||||
// is answered with Back and the text kept as a draft.
|
||||
const quickReplyDraftRef = useRef({ text: quickReplyText, sending: isSendingQuickReply, save: onSaveQuickReplyDraft });
|
||||
quickReplyDraftRef.current = { text: quickReplyText, sending: isSendingQuickReply, save: onSaveQuickReplyDraft };
|
||||
const hasQuickReplyDraft = quickReplyText.trim().length > 0 && !!onSaveQuickReplyDraft;
|
||||
useEffect(() => {
|
||||
if (!hasQuickReplyDraft) return;
|
||||
return registerComposeFlush(async () => {
|
||||
const { text, sending, save } = quickReplyDraftRef.current;
|
||||
if (!text.trim() || !save) return;
|
||||
if (sending) {
|
||||
const confirm = useExternalRecipientConfirmStore.getState();
|
||||
if (!confirm.pending) return;
|
||||
confirm.cancelAll();
|
||||
}
|
||||
await save(text);
|
||||
});
|
||||
}, [hasQuickReplyDraft]);
|
||||
const handleSendQuickReply = async () => {
|
||||
if (!quickReplyText.trim() || !onQuickReply || isSendingQuickReply) return;
|
||||
setIsSendingQuickReply(true);
|
||||
try {
|
||||
await onQuickReply(quickReplyText);
|
||||
setQuickReplyText("");
|
||||
setIsQuickReplyFocused(false);
|
||||
const sent = await onQuickReply(quickReplyText);
|
||||
// Back on a pre-send question keeps what the user typed.
|
||||
if (sent !== false) {
|
||||
setQuickReplyText("");
|
||||
setIsQuickReplyFocused(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send quick reply:", error);
|
||||
// The text stays in the box, so say why it did not go out.
|
||||
@@ -1753,6 +1799,7 @@ export function EmailViewer({
|
||||
const emailIdRef = useRef(email?.id);
|
||||
emailIdRef.current = email?.id;
|
||||
const readerActionContext = useMemo(() => ({ emailId: email?.id }), [email?.id]);
|
||||
const readerActionTarget = useMessageActionTarget(email);
|
||||
const pluginFooterProps = useMemo(() => ({ emailId: email?.id }), [email?.id]);
|
||||
// The user's choice; until they make one, a panel whose plugin asked to
|
||||
// start folded (a chat opened on demand) is folded, the rest open.
|
||||
@@ -2622,6 +2669,7 @@ export function EmailViewer({
|
||||
// 'ask' = block until the user allows this message or trusts the sender.
|
||||
const shouldBlockExternal = useMemo(() => {
|
||||
if (!email) return false;
|
||||
if (remoteBlockedByOrg) return true;
|
||||
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
||||
const senderIsTrusted = senderEmail
|
||||
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
|
||||
@@ -2634,7 +2682,7 @@ export function EmailViewer({
|
||||
// permission changes, so they're deliberately omitted from deps (matches
|
||||
// the srcDoc rebuild contract described on `emailContent`).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [email, externalContentPolicy, allowExternalContent]);
|
||||
}, [email, externalContentPolicy, allowExternalContent, remoteBlockedByOrg]);
|
||||
|
||||
// Sanitize and prepare email HTML content
|
||||
const emailContent = useMemo(() => {
|
||||
@@ -4171,6 +4219,7 @@ export function EmailViewer({
|
||||
<PluginActions
|
||||
surface="reader-actions"
|
||||
context={readerActionContext}
|
||||
target={readerActionTarget}
|
||||
mailboxRole={currentMailboxRole ?? null}
|
||||
compact={!showToolbarLabels}
|
||||
/>
|
||||
@@ -4262,6 +4311,7 @@ export function EmailViewer({
|
||||
<div className="w-56">
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
scope={tagScope}
|
||||
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||
/>
|
||||
</div>
|
||||
@@ -4569,9 +4619,11 @@ export function EmailViewer({
|
||||
onMoveToMailbox={onMoveToMailbox}
|
||||
currentTagIds={currentTagIds}
|
||||
sortedTagIds={sortedTagIds}
|
||||
tagScope={tagScope}
|
||||
onToggleTag={(tagId) => onSetTag?.(email.id, tagId)}
|
||||
rulesTarget={rulesAvailability === 'available' ? rulesTarget ?? null : null}
|
||||
pluginContext={readerActionContext}
|
||||
pluginTarget={readerActionTarget}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -4654,6 +4706,7 @@ export function EmailViewer({
|
||||
key={tagId}
|
||||
tagId={tagId}
|
||||
variant={headerTagVariant}
|
||||
scope={tagScope}
|
||||
onRemove={onSetTag && email ? () => onSetTag(email.id, tagId) : undefined}
|
||||
/>
|
||||
))}
|
||||
@@ -4791,6 +4844,7 @@ export function EmailViewer({
|
||||
{mvViewer && sender?.email && sender?.name && (
|
||||
<span className="text-xs text-muted-foreground truncate"><{sender.email}></span>
|
||||
)}
|
||||
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} />
|
||||
<EmailIdentityBadge email={email} identities={identities} />
|
||||
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
|
||||
<UnsubscribeBanner
|
||||
@@ -5091,6 +5145,7 @@ export function EmailViewer({
|
||||
) : (
|
||||
<span className="text-sm font-semibold text-foreground">{t('unknown_sender')}</span>
|
||||
)}
|
||||
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} />
|
||||
<EmailIdentityBadge email={email} identities={identities} />
|
||||
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
|
||||
<UnsubscribeBanner
|
||||
@@ -5283,6 +5338,7 @@ export function EmailViewer({
|
||||
onViewContact={handleViewContactSidebar}
|
||||
className="text-sm text-start"
|
||||
/>
|
||||
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} />
|
||||
{senderVerification && (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -5586,7 +5642,7 @@ export function EmailViewer({
|
||||
|
||||
{/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */}
|
||||
{(senderVerification ||
|
||||
(hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
|
||||
(hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow'))) ||
|
||||
hasCalendarInvitation ||
|
||||
(readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && (
|
||||
<div className="border-b border-border bg-muted/30 isolate">
|
||||
@@ -5616,8 +5672,8 @@ export function EmailViewer({
|
||||
)}
|
||||
|
||||
{/* External Content Controls */}
|
||||
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
|
||||
<div className="flex items-start gap-3 py-1">
|
||||
{hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow')) && (
|
||||
<div className="flex items-start gap-3 py-1" data-testid="external-content-notice">
|
||||
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
<Image className="w-5 h-5" />
|
||||
</div>
|
||||
@@ -5627,9 +5683,10 @@ export function EmailViewer({
|
||||
External Content
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground break-words">
|
||||
{t('external_content_warning')}
|
||||
{remoteBlockedByOrg ? tThirdParty('remote_content_blocked') : t('external_content_warning')}
|
||||
</div>
|
||||
</div>
|
||||
{!remoteBlockedByOrg && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
{externalContentPolicy === 'ask' && (
|
||||
<button
|
||||
@@ -5662,6 +5719,7 @@ export function EmailViewer({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId, type KeyboardEvent } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Building } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
import { registerExternalRecipientCheck } from '@/lib/outgoing/external-recipients';
|
||||
import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store';
|
||||
|
||||
/**
|
||||
* "Send outside your organisation?", asked once per send with every
|
||||
* external recipient listed. Mounted once in the app layout; while it is
|
||||
* mounted the pre-send check is registered, so the composer and quick reply
|
||||
* both ask through it. Sits above the composer like the plugin dialogs.
|
||||
*/
|
||||
export function ExternalRecipientConfirmHost() {
|
||||
const t = useTranslations('external_mail');
|
||||
const pending = useExternalRecipientConfirmStore((s) => s.pending);
|
||||
const answer = useExternalRecipientConfirmStore((s) => s.answer);
|
||||
const id = useId();
|
||||
|
||||
useEffect(() => registerExternalRecipientCheck(), []);
|
||||
|
||||
// A send can't be waiting on a dialog that is gone.
|
||||
useEffect(() => () => useExternalRecipientConfirmStore.getState().cancelAll(), []);
|
||||
|
||||
// Escape is handled below with the other keys, not by the trap: one key, one answer.
|
||||
const dialogRef = useFocusTrap({ isActive: !!pending, restoreFocus: true });
|
||||
|
||||
// Keys stay in the dialog: Escape must not also close the message behind
|
||||
// it, j/k must not move the list, Enter must not open a message. Enter and
|
||||
// Space press the focused button here, whatever a page listener did.
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
answer(false);
|
||||
return;
|
||||
}
|
||||
if ((e.key === 'Enter' || e.key === ' ') && e.target instanceof HTMLButtonElement) {
|
||||
e.preventDefault();
|
||||
e.target.click();
|
||||
}
|
||||
};
|
||||
|
||||
if (!pending) return null;
|
||||
const { recipients } = pending;
|
||||
const field = { to: t('field_to'), cc: t('field_cc'), bcc: t('field_bcc') };
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100000] flex items-center justify-center bg-black/50 p-4 backdrop-blur-[1px] animate-in fade-in duration-150"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) answer(false);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={`${id}-title`}
|
||||
aria-describedby={`${id}-message`}
|
||||
data-external-recipient-confirm=""
|
||||
className="flex max-h-[90vh] w-full max-w-md flex-col overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-xl animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col px-5 pt-5 sm:px-6 sm:pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-warning/15 text-warning">
|
||||
<Building className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id={`${id}-title`} className="text-lg font-semibold text-foreground">{t('confirm_title')}</h2>
|
||||
<p id={`${id}-message`} className="mt-1 text-sm text-muted-foreground">
|
||||
{t('confirm_message', { count: recipients.length })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="mt-3 min-h-0 flex-1 space-y-1 overflow-y-auto rounded-md border border-border bg-muted/30 p-2 text-sm" data-external-recipients="">
|
||||
{recipients.map((recipient) => (
|
||||
<li key={recipient.address} className="flex items-baseline gap-2">
|
||||
<span className="w-8 flex-shrink-0 text-xs text-muted-foreground">{field[recipient.field]}</span>
|
||||
<span className="min-w-0 [overflow-wrap:anywhere]">{recipient.address}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 px-5 py-4 sm:px-6">
|
||||
{/* Back comes first, so the focus trap puts the focus on it: sending outside takes a deliberate click. */}
|
||||
<Button variant="outline" onClick={() => answer(false)} data-external-recipient-back="">
|
||||
{t('back')}
|
||||
</Button>
|
||||
<Button onClick={() => answer(true)} data-external-recipient-send="">
|
||||
{t('send_anyway')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Building } from '@/components/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
externalMailContextFor,
|
||||
externalSenders,
|
||||
internalDomains,
|
||||
isOtherAccount,
|
||||
type ExternalMailContext,
|
||||
type ExternalSender,
|
||||
} from '@/lib/external-mail';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
type FromList = readonly { email?: string | null }[] | null | undefined;
|
||||
|
||||
/**
|
||||
* The context of the account a message belongs to: the active one from the
|
||||
* stores, another signed-in one (the unified inbox) resolved for that
|
||||
* account, so its own scopes and identities decide.
|
||||
*/
|
||||
function useAccountContext(accountId: string | null | undefined): ExternalMailContext | null {
|
||||
const externalMail = usePolicyStore((s) => s.policy.organisation?.externalMail);
|
||||
const rawPolicy = usePolicyStore((s) => s.rawPolicy);
|
||||
const identities = useAuthStore((s) => s.identities);
|
||||
const other = useAuthStore(() => isOtherAccount(accountId));
|
||||
const [otherContext, setOtherContext] = useState<{ accountId: string; context: ExternalMailContext } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!other || !accountId) return;
|
||||
let cancelled = false;
|
||||
void externalMailContextFor(accountId, 'senderLabel').then((context) => {
|
||||
if (!cancelled) setOtherContext({ accountId, context });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [other, accountId, rawPolicy]);
|
||||
return useMemo(() => {
|
||||
if (other) return otherContext && otherContext.accountId === accountId ? otherContext.context : null;
|
||||
if (!externalMail) return null;
|
||||
return { policy: externalMail, identityEmails: (identities ?? []).map((identity) => identity.email) };
|
||||
}, [other, otherContext, accountId, externalMail, identities]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The From addresses outside the organisation when the admin turned the
|
||||
* "External" label on for the message's account (lib/external-mail.ts);
|
||||
* empty otherwise.
|
||||
*/
|
||||
export function useExternalSenders(from: FromList, accountId?: string | null): ExternalSender[] {
|
||||
const context = useAccountContext(accountId);
|
||||
return useMemo(() => {
|
||||
if (!context?.policy.senderLabel) return [];
|
||||
return externalSenders(from, internalDomains(context.identityEmails, context.policy.domains));
|
||||
}, [context, from]);
|
||||
}
|
||||
|
||||
/**
|
||||
* "External" next to the sender's name. A chip and not a banner, so it sits
|
||||
* beside the unverified-sender warning without adding a second banner, and
|
||||
* it keeps to one short word so the narrow phone header doesn't wrap more.
|
||||
*
|
||||
* It is a button: a tap (phones have no hover) or Enter opens a line naming
|
||||
* the domain. Inside another button (a conversation card's header) it can't
|
||||
* be one, so `interactive={false}` keeps it a label with a tooltip.
|
||||
*/
|
||||
export function ExternalSenderChip({ from, accountId, className, interactive = true }: {
|
||||
from: FromList;
|
||||
/** The signed-in account the message belongs to (Email.sourceClientAccountId); the active one when not set. */
|
||||
accountId?: string | null;
|
||||
className?: string;
|
||||
interactive?: boolean;
|
||||
}) {
|
||||
const t = useTranslations('external_mail');
|
||||
const senders = useExternalSenders(from, accountId);
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLSpanElement>(null);
|
||||
const popoverId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: Event) => {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', close);
|
||||
document.addEventListener('focusin', close);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', close);
|
||||
document.removeEventListener('focusin', close);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (senders.length === 0) return null;
|
||||
const domains = [...new Set(senders.map((s) => s.domain))];
|
||||
const named = domains.filter(Boolean).join(', ');
|
||||
const description = named
|
||||
? t('sender_tooltip', { domain: named })
|
||||
: t('sender_tooltip_no_domain');
|
||||
|
||||
const chipClass = cn(
|
||||
'inline-flex flex-shrink-0 items-center gap-1 whitespace-nowrap rounded-md border px-1.5 py-0.5 text-xs font-medium leading-none',
|
||||
'border-warning/30 bg-warning/10 text-warning',
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
<Building className="h-3 w-3 flex-shrink-0" aria-hidden="true" />
|
||||
{t('sender_label')}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!interactive) {
|
||||
return (
|
||||
<span data-external-sender-chip="" title={description} className={cn(chipClass, 'cursor-help', className)}>
|
||||
{content}
|
||||
<span className="sr-only">{` (${description})`}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span ref={wrapRef} className={cn('relative inline-flex flex-shrink-0', className)}>
|
||||
<button
|
||||
type="button"
|
||||
data-external-sender-chip=""
|
||||
title={description}
|
||||
aria-label={`${t('sender_label')}: ${description}`}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? popoverId : undefined}
|
||||
onClick={(e) => {
|
||||
// The header around it folds or opens things on a click.
|
||||
e.stopPropagation();
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && open) {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
className={cn(chipClass, 'cursor-pointer hover:bg-warning/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring')}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
{open && (
|
||||
<span
|
||||
id={popoverId}
|
||||
role="status"
|
||||
data-external-sender-detail=""
|
||||
className="absolute start-0 top-full z-30 mt-1 w-max max-w-[16rem] whitespace-normal rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs text-popover-foreground shadow-md"
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { cn, type MailboxNode } from "@/lib/utils";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { useUnreadFirstSection } from "@/hooks/use-unread-first-section";
|
||||
import { commonTagScope } from "@/hooks/use-account-tags";
|
||||
|
||||
interface MvListToolbarProps {
|
||||
/** Loaded conversation count, for the right-hand range readout. */
|
||||
@@ -583,7 +584,7 @@ export function MvListToolbar({
|
||||
</button>
|
||||
<ContextMenu ref={tagMenu.menuRef} isOpen={tagOpen} position={tagMenu.contextMenu.position} onClose={closeTag}>
|
||||
<div className="w-64">
|
||||
<TagPicker selectedIds={appliedTags} partialIds={partialTags} onToggle={handleToggleTag} />
|
||||
<TagPicker selectedIds={appliedTags} partialIds={partialTags} onToggle={handleToggleTag} scope={commonTagScope(selectedEmails)} />
|
||||
</div>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useDeferredValue, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Identity } from "@/lib/jmap/types";
|
||||
import { sanitizeSignatureHtmlForDisplay } from "@/lib/email-sanitization";
|
||||
import { getPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { htmlCarriesDisclaimer, textCarriesDisclaimer } from "@/lib/org/body-match";
|
||||
import { useOrgDisclaimer } from "@/hooks/use-org-signature";
|
||||
|
||||
/**
|
||||
* The admin's disclaimer under the composer's editor, read-only, with
|
||||
* "Added when you send". It is added by the send finaliser
|
||||
* (lib/org/outgoing.ts), so it is never part of the body or a draft. A body
|
||||
* that already ends with it (a message back from Undo) gets no second copy
|
||||
* on send, so the block isn't shown then either.
|
||||
*/
|
||||
export function OrgDisclaimerPreview({ sending, accountId, plainText, body = "" }: {
|
||||
sending: Pick<Identity, "name" | "email"> | null | undefined;
|
||||
/** The signed-in account the message goes out from; the active one when left out. */
|
||||
accountId?: string | null;
|
||||
plainText: boolean;
|
||||
/** The composer's body: HTML, or text in plain-text mode. */
|
||||
body?: string;
|
||||
}) {
|
||||
const t = useTranslations("org_signature");
|
||||
const disclaimer = useOrgDisclaimer(sending, accountId);
|
||||
// Parsing the body on every keystroke isn't needed for a hint.
|
||||
const deferredBody = useDeferredValue(body);
|
||||
const carried = useMemo(
|
||||
() => !!disclaimer && !!deferredBody && (plainText
|
||||
? textCarriesDisclaimer(deferredBody, disclaimer)
|
||||
: htmlCarriesDisclaimer(deferredBody, disclaimer)),
|
||||
[disclaimer, deferredBody, plainText],
|
||||
);
|
||||
if (!disclaimer || carried) return null;
|
||||
const html = !plainText && disclaimer.html ? sanitizeSignatureHtmlForDisplay(disclaimer.html) : "";
|
||||
return (
|
||||
<div
|
||||
className="mx-4 mb-3 border-t border-border pt-2 text-xs text-muted-foreground break-words"
|
||||
data-testid="org-disclaimer-preview"
|
||||
>
|
||||
<p className="mb-1 font-medium">{t("disclaimer_added_on_send")}</p>
|
||||
{html ? (
|
||||
<div className="[&_a]:underline min-w-0" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap min-w-0">{getPlainTextSignature({ textSignature: disclaimer.text, htmlSignature: disclaimer.html })}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { iconForName, X } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
activeListFilterKeys,
|
||||
listFilterApplies,
|
||||
listFilterRevision,
|
||||
listFilters,
|
||||
setListFilterActive,
|
||||
subscribeListFilters,
|
||||
} from "@/lib/plugin-sandbox/list-filters";
|
||||
import type { MailAccountKind } from "@/lib/plugin-sandbox/account-kind";
|
||||
|
||||
const NONE = new Set<string>();
|
||||
|
||||
/**
|
||||
* The keyword filters plugins offer for this folder (api.ui.setListFilters),
|
||||
* as chips in the list header. A chip turns its filter on; an active chip
|
||||
* is pressed, shows a cross and turns it off again. `onChange` loads the list again
|
||||
* whenever the applied filters change, also when a plugin drops one that
|
||||
* was on.
|
||||
*/
|
||||
export function PluginListFilters({
|
||||
mailboxRole,
|
||||
accountKind,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
mailboxRole: string | null;
|
||||
accountKind: MailAccountKind | null;
|
||||
onChange: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const t = useTranslations("plugin_list");
|
||||
const filters = useSyncExternalStore(subscribeListFilters, listFilters, listFilters);
|
||||
const active = useSyncExternalStore(subscribeListFilters, activeListFilterKeys, () => NONE);
|
||||
const revision = useSyncExternalStore(subscribeListFilters, listFilterRevision, listFilterRevision);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
const seenRevision = useRef(revision);
|
||||
useEffect(() => {
|
||||
if (seenRevision.current === revision) return;
|
||||
seenRevision.current = revision;
|
||||
onChangeRef.current();
|
||||
}, [revision]);
|
||||
|
||||
const shown = filters.filter((f) => listFilterApplies(f, { role: mailboxRole, accountKind }));
|
||||
if (shown.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("filters")}
|
||||
data-testid="plugin-list-filters"
|
||||
className={cn("px-4 py-1.5 border-b border-border flex items-center gap-2 flex-wrap", className)}
|
||||
>
|
||||
{shown.map((filter) => {
|
||||
const on = active.has(filter.key);
|
||||
const Icon = iconForName(filter.icon);
|
||||
return (
|
||||
<button
|
||||
key={filter.key}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
// The name stays the filter's label; aria-pressed says it is on,
|
||||
// and the tooltip says a click removes it.
|
||||
title={on ? t("remove_filter", { label: filter.label }) : filter.pluginName}
|
||||
data-plugin-list-filter={filter.key}
|
||||
onClick={() => setListFilterActive(filter.key)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 h-7 max-w-[12rem] px-2.5 rounded-full text-xs border transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
on
|
||||
? "bg-primary/10 text-primary border-primary/30 hover:bg-primary/15"
|
||||
: "border-border text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="w-3.5 h-3.5 flex-shrink-0" aria-hidden />}
|
||||
<span className="truncate font-medium">{filter.label}</span>
|
||||
{on && <X className="w-3 h-3 flex-shrink-0" aria-hidden />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import type { EmailListBadge } from "@/lib/plugin-types";
|
||||
import { renderHooks } from "@/lib/plugin-hooks";
|
||||
import { emailSummary } from "@/lib/plugin-projection";
|
||||
import { rowBadgeGeneration, subscribeRowBadges } from "@/lib/plugin-sandbox/row-badges";
|
||||
import { PLUGIN_ICON_RE } from "@/lib/plugin-sandbox/actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { iconForName } from "@/components/icons";
|
||||
import { TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge";
|
||||
|
||||
// Badges plugins add to a message-list row through onEmailListItemRender.
|
||||
@@ -67,6 +69,8 @@ export function normalizeBadges(raw: unknown): EmailListBadge[] {
|
||||
if (color && SAFE_COLOR.test(color)) badge.color = color;
|
||||
const title = cleanText(candidate.title, MAX_TITLE_LENGTH);
|
||||
if (title) badge.title = title;
|
||||
const icon = cleanText(candidate.icon, 40);
|
||||
if (icon && PLUGIN_ICON_RE.test(icon)) badge.icon = icon;
|
||||
out.push(badge);
|
||||
}
|
||||
return out;
|
||||
@@ -141,24 +145,30 @@ export function PluginRowBadges({ email, className }: { email: Email; className?
|
||||
if (badges.length === 0) return null;
|
||||
return (
|
||||
<span className={cn(TAG_GROUP_CLASS, className)} data-testid="plugin-row-badges">
|
||||
{badges.map((badge) => (
|
||||
<span
|
||||
key={badge.key}
|
||||
title={badge.title}
|
||||
className={cn(
|
||||
TAG_LOZENGE_CLASS,
|
||||
"max-w-[8rem] truncate border",
|
||||
!badge.color && "border-border bg-muted text-muted-foreground",
|
||||
)}
|
||||
style={badge.color ? {
|
||||
color: badge.color,
|
||||
borderColor: badge.color,
|
||||
backgroundColor: `color-mix(in srgb, ${badge.color} 12%, transparent)`,
|
||||
} : undefined}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
))}
|
||||
{badges.map((badge) => {
|
||||
const Icon = iconForName(badge.icon);
|
||||
return (
|
||||
<span
|
||||
key={badge.key}
|
||||
title={badge.title}
|
||||
data-plugin-row-badge={badge.key}
|
||||
className={cn(
|
||||
TAG_LOZENGE_CLASS,
|
||||
"max-w-[8rem] truncate border",
|
||||
Icon && "gap-0.5",
|
||||
!badge.color && "border-border bg-muted text-muted-foreground",
|
||||
)}
|
||||
style={badge.color ? {
|
||||
color: badge.color,
|
||||
borderColor: badge.color,
|
||||
backgroundColor: `color-mix(in srgb, ${badge.color} 12%, transparent)`,
|
||||
} : undefined}
|
||||
>
|
||||
{Icon && <Icon className="w-3 h-3 flex-shrink-0" aria-hidden data-badge-icon={badge.icon} />}
|
||||
{Icon ? <span className="truncate">{badge.label}</span> : badge.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createPortal } from "react-dom";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { iconForName, MoreVertical, Puzzle } from "@/components/icons";
|
||||
import { ContextMenu } from "@/components/ui/context-menu";
|
||||
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { PluginActions, usePluginActions, type MessageActionTarget } from "@/components/plugins/plugin-actions";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
@@ -128,14 +128,18 @@ export function PluginPanelEntries({
|
||||
surface,
|
||||
context,
|
||||
mailboxRole,
|
||||
target,
|
||||
onRan,
|
||||
}: {
|
||||
surface: string;
|
||||
context: Record<string, unknown>;
|
||||
mailboxRole?: string | null;
|
||||
/** The open message (useMessageActionTarget). */
|
||||
target?: MessageActionTarget;
|
||||
onRan?: () => void;
|
||||
}) {
|
||||
const actions = usePluginActions(surface, { mailboxRole });
|
||||
const actions = usePluginActions(surface, { mailboxRole, accountKind: target?.accountKind ?? null, keywords: target?.keywords ?? null });
|
||||
const clickContext = target?.accountId ? { ...context, accountId: target.accountId } : context;
|
||||
if (actions.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
@@ -147,7 +151,7 @@ export function PluginPanelEntries({
|
||||
key={`${action.pluginId}:${action.id}`}
|
||||
role="menuitem"
|
||||
title={action.pluginName}
|
||||
onClick={() => { onRan?.(); void runAction(action, surface, context); }}
|
||||
onClick={() => { onRan?.(); void runAction(action, surface, clickContext); }}
|
||||
className={cn(
|
||||
"w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3",
|
||||
action.danger ? "text-destructive" : "text-foreground",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslations } from "next-intl";
|
||||
import { X } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import type { TagScope } from "@/hooks/use-account-tags";
|
||||
import { useShortenedText } from "@/hooks/use-shortened-text";
|
||||
|
||||
/**
|
||||
@@ -42,6 +43,7 @@ export function TagBadge({
|
||||
variant,
|
||||
onRemove,
|
||||
className,
|
||||
scope,
|
||||
}: {
|
||||
tagId: string;
|
||||
variant: TagBadgeVariant;
|
||||
@@ -51,9 +53,11 @@ export function TagBadge({
|
||||
*/
|
||||
onRemove?: () => void;
|
||||
className?: string;
|
||||
/** The message's account (useEmailTagScope); undefined: TagScopeContext. */
|
||||
scope?: TagScope | null;
|
||||
}) {
|
||||
const t = useTranslations("email_viewer");
|
||||
const { tagName, tagNameCandidates, tagColor } = useKeywordFormat();
|
||||
const { tagName, tagNameCandidates, tagColor } = useKeywordFormat(scope);
|
||||
const [labelRef, shortenedName] = useShortenedText(tagNameCandidates(tagId));
|
||||
const color = tagColor(tagId);
|
||||
const name = tagName(tagId);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useSettingsStore, KEYWORD_PALETTE_ROWS } from "@/stores/settings-store";
|
||||
import { buildKeywordTree, composeKeywordId, MAX_KEYWORD_ID_LENGTH, type KeywordNode } from "@/lib/keyword-nesting";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useAccountTags, type TagScope } from "@/hooks/use-account-tags";
|
||||
|
||||
/** Below this many tags a filter box costs more room than it saves. */
|
||||
const SEARCH_THRESHOLD = 10;
|
||||
@@ -30,6 +31,7 @@ export function TagPicker({
|
||||
partialIds = NO_IDS,
|
||||
onToggle,
|
||||
touch = false,
|
||||
scope,
|
||||
}: {
|
||||
selectedIds: string[];
|
||||
/**
|
||||
@@ -40,11 +42,18 @@ export function TagPicker({
|
||||
onToggle: (tagId: string) => void;
|
||||
/** Larger hit areas for the mobile sheet. */
|
||||
touch?: boolean;
|
||||
/**
|
||||
* The account whose tags to offer (a group account offers its shared
|
||||
* tags). Undefined: the TagScopeContext around the picker; null: the
|
||||
* user's own tags.
|
||||
*/
|
||||
scope?: TagScope | null;
|
||||
}) {
|
||||
const t = useTranslations("email_viewer");
|
||||
const keywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const tShared = useTranslations("shared_tags");
|
||||
const { definitions: keywords, shared } = useAccountTags(scope);
|
||||
const nestedTags = useSettingsStore((state) => state.nestedTags);
|
||||
const { tagName, tagColor } = useKeywordFormat();
|
||||
const { tagName, tagColor } = useKeywordFormat(scope);
|
||||
const [query, setQuery] = useState("");
|
||||
const addKeyword = useSettingsStore((state) => state.addKeyword);
|
||||
// "New tag" right here (MAIL-31): a name field in the picker; the tag gets
|
||||
@@ -137,6 +146,14 @@ export function TagPicker({
|
||||
|
||||
return (
|
||||
<>
|
||||
{shared && (
|
||||
<p
|
||||
className={cn("text-xs text-muted-foreground", touch ? "px-4 pb-2" : "px-3 pb-1")}
|
||||
data-testid="tag-picker-shared"
|
||||
>
|
||||
{tShared("picker_heading")}
|
||||
</p>
|
||||
)}
|
||||
{showSearch && (
|
||||
<div className={cn("relative", touch ? "px-3 pb-2" : "px-2 pb-1")}>
|
||||
<Search className="absolute start-4 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
|
||||
@@ -11,6 +11,7 @@ import { emailDisplayDate } from "@/lib/email-date";
|
||||
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
|
||||
import { findVerificationCode, verificationCodeBodyText } from "@/lib/verification-code";
|
||||
import { VerificationCodeChip } from "./verification-code-chip";
|
||||
import { ExternalSenderChip } from "./external-sender-chip";
|
||||
import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
|
||||
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
|
||||
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
|
||||
@@ -51,12 +52,13 @@ import { useOwnDomainAddress } from "@/hooks/use-own-domain-address";
|
||||
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection } from "./reader-selection-menu";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { PluginActions, useMessageActionTarget } from "@/components/plugins/plugin-actions";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { usePluginRenderedBody } from "@/hooks/use-plugin-rendered-body";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import { AttachmentPluginMenu, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
|
||||
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
|
||||
import { ConversationHeaderActions, type ConversationActions } from "./conversation-header-actions";
|
||||
|
||||
export type ConversationAttachmentHandler = (
|
||||
@@ -120,6 +122,8 @@ export function useConversationExternalContent() {
|
||||
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
||||
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
|
||||
const [allowedIds, setAllowedIds] = useState<Set<string>>(new Set());
|
||||
// No third-party traffic (#903): nothing remote loads and nothing offers to.
|
||||
const remoteBlockedByOrg = useNoThirdPartyTraffic();
|
||||
|
||||
const allow = useCallback((emailId: string) => {
|
||||
setAllowedIds(prev => new Set(prev).add(emailId));
|
||||
@@ -130,12 +134,13 @@ export function useConversationExternalContent() {
|
||||
const senderIsTrusted = senderEmail
|
||||
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
|
||||
: false;
|
||||
if (remoteBlockedByOrg) return false;
|
||||
return externalContentPolicy === 'allow' || !!senderIsTrusted || allowedIds.has(email.id);
|
||||
};
|
||||
|
||||
const trustSender = (email: Email) => {
|
||||
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
||||
if (!senderEmail) return undefined;
|
||||
if (!senderEmail || remoteBlockedByOrg) return undefined;
|
||||
return () => {
|
||||
if (trustedSendersAddressBook && client) {
|
||||
addToTrustedSendersBook(client, senderEmail).catch(console.error);
|
||||
@@ -333,7 +338,8 @@ function useConversationPluginProps(email: Email) {
|
||||
const deviceType = isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop';
|
||||
const emailProps = useMemo(() => ({ email: wantsView ? emailToReadView(email) : null, deviceType }), [email, wantsView, deviceType]);
|
||||
const idProps = useMemo(() => ({ emailId: email.id }), [email.id]);
|
||||
return { emailProps, idProps };
|
||||
const actionTarget = useMessageActionTarget(email);
|
||||
return { emailProps, idProps, actionTarget };
|
||||
}
|
||||
|
||||
function EmailCard({
|
||||
@@ -395,6 +401,7 @@ function EmailCard({
|
||||
)}>
|
||||
{sender?.name || (sender?.email && formatAddress(sender.email)) || t('email_viewer.unknown_sender')}
|
||||
</span>
|
||||
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} interactive={false} />
|
||||
{isStarred && (
|
||||
<Star className="w-4 h-4 fill-amber-400 text-amber-400 flex-shrink-0" />
|
||||
)}
|
||||
@@ -484,6 +491,7 @@ function EmailCard({
|
||||
<PluginActions
|
||||
surface="reader-actions"
|
||||
context={plugin.idProps}
|
||||
target={plugin.actionTarget}
|
||||
mailboxRole={mailboxRole ?? null}
|
||||
overflow
|
||||
className="flex-shrink-0 rounded-md border border-border"
|
||||
@@ -523,6 +531,7 @@ export function ConversationEmailBody({
|
||||
bodyStyle,
|
||||
className,
|
||||
}: ConversationEmailBodyProps) {
|
||||
const remoteBlockedByOrg = useNoThirdPartyTraffic();
|
||||
const t = useTranslations();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
|
||||
@@ -832,10 +841,11 @@ export function ConversationEmailBody({
|
||||
<div className={className}>
|
||||
{/* External content warning */}
|
||||
{hasBlockedContent && !allowExternal && (
|
||||
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between text-sm">
|
||||
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between text-sm" data-testid="external-content-notice">
|
||||
<span className="text-muted-foreground">
|
||||
{t("email_viewer.external_content_warning")}
|
||||
{remoteBlockedByOrg ? t("third_party.remote_content_blocked") : t("email_viewer.external_content_warning")}
|
||||
</span>
|
||||
{!remoteBlockedByOrg && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -860,6 +870,7 @@ export function ConversationEmailBody({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { emailKeyFor, getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useEmailTagScope } from "@/hooks/use-account-tags";
|
||||
import { useTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { TagBadge } from "./tag-badge";
|
||||
import { useListRowShape, LIST_COLUMN_CLASS } from "@/hooks/use-list-row-shape";
|
||||
@@ -43,7 +44,8 @@ export function ThreadEmailItem({
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
const { sortTagIds } = useKeywordFormat();
|
||||
const tagScope = useEmailTagScope(email);
|
||||
const { sortTagIds } = useKeywordFormat(tagScope);
|
||||
const { variant: tagVariant } = useTagDisplay();
|
||||
// A message inside an expanded thread carries its own tags; the collapsed
|
||||
// header pools them, so without this they disappear on the way in.
|
||||
@@ -164,7 +166,7 @@ export function ThreadEmailItem({
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
|
||||
))}
|
||||
<span className={cn("min-w-0 truncate", isUnread ? "text-muted-foreground" : "text-muted-foreground/70")}>
|
||||
{cleanPreview(email.preview) || t('no_preview_available')}
|
||||
@@ -295,7 +297,7 @@ export function ThreadEmailItem({
|
||||
<Paperclip className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useEmailStore, isUnscopedSearchActive } from "@/stores/email-store";
|
||||
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
|
||||
import { emailKeyFor, getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useEmailTagScope } from "@/hooks/use-account-tags";
|
||||
import { useTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { TagBadge, TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge";
|
||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
@@ -388,7 +389,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
subject: email.subject || t('no_subject'),
|
||||
});
|
||||
const formatAddress = useOwnDomainAddress();
|
||||
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||
const tagScope = useEmailTagScope(email);
|
||||
const { sortTagIds, tagColor } = useKeywordFormat(tagScope);
|
||||
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount);
|
||||
@@ -703,7 +705,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{tagIds.length > 0 && (
|
||||
<span className={cn(TAG_GROUP_CLASS, mvRow && "me-2")}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
@@ -784,7 +786,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{tagPlacement === 'sender' && tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
@@ -846,7 +848,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{tagPlacement === 'subject' && tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
@@ -1064,7 +1066,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
}
|
||||
: threadLongPressHandlers;
|
||||
|
||||
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||
const tagScope = useEmailTagScope(latestEmail);
|
||||
const { sortTagIds, tagColor } = useKeywordFormat(tagScope);
|
||||
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount);
|
||||
@@ -1418,7 +1421,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
/>
|
||||
)}
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
@@ -1507,7 +1510,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
title={t('messages_tooltip', { count: emailCount })}
|
||||
/>
|
||||
{tagPlacement === 'sender' && tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
|
||||
))}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -1568,7 +1571,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{tagPlacement === 'subject' && tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { RotateCcw } from "@/components/icons";
|
||||
import { buildSettingsPath } from "@/lib/deep-links";
|
||||
import { RECOVERY_SETTINGS_TAB } from "@/lib/recovery/archived-items";
|
||||
import { useDeletedItemsRecovery } from "@/hooks/use-deleted-items-recovery";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useRecoveryStore } from "@/stores/recovery-store";
|
||||
import type { Mailbox } from "@/lib/jmap/types";
|
||||
|
||||
/**
|
||||
* A line in the Trash folder's header that leads to Settings > Recover
|
||||
* deleted items: only when the server offers it for this account and its
|
||||
* archive holds at least one message inside the admin's days window, so it
|
||||
* never leads to an empty page. Only in the account's own Trash: a shared or
|
||||
* group Trash has an archive of its own that the page doesn't show.
|
||||
*/
|
||||
export function TrashRecoveryLink({ mailbox }: { mailbox: Mailbox | undefined }) {
|
||||
const t = useTranslations("recover_deleted");
|
||||
const router = useRouter();
|
||||
const { available, client, key } = useDeletedItemsRecovery();
|
||||
const days = usePolicyStore((s) => s.getOrganisation().recovery.days);
|
||||
const recent = useRecoveryStore((s) => (key ? s.recent[key] : undefined));
|
||||
const checkRecent = useRecoveryStore((s) => s.checkRecent);
|
||||
const ownTrash = !!client && mailbox?.role === "trash" && !mailbox.isShared
|
||||
&& (!mailbox.accountId || mailbox.accountId === client.getAccountId());
|
||||
|
||||
useEffect(() => {
|
||||
if (!available || !ownTrash || !client || !key) return;
|
||||
void checkRecent(client, key, days);
|
||||
}, [available, ownTrash, client, key, days, checkRecent]);
|
||||
|
||||
if (!available || !ownTrash || !recent?.has || recent.days !== days) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-1.5 text-xs text-muted-foreground" data-testid="trash-recovery-link">
|
||||
<span className="min-w-0 truncate">{t("trash_hint")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(buildSettingsPath(RECOVERY_SETTINGS_TAB))}
|
||||
className="ms-auto inline-flex min-h-11 shrink-0 items-center gap-1 font-medium text-primary hover:underline sm:min-h-0"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
{t("trash_link")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitiza
|
||||
import { getEffectiveTimeZone } from "@/lib/timezone";
|
||||
import { stripBidiControls } from "@/lib/bidi";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
|
||||
|
||||
// A parsed message/rfc822 (.eml), as produced by postal-mime. Only the fields
|
||||
// this preview renders are typed.
|
||||
@@ -43,7 +44,9 @@ function escapeHtml(s: string): string {
|
||||
// (FilePreviewModal); this is pure presentation.
|
||||
export function EmlPreview({ message }: { message: ParsedEml }) {
|
||||
const t = useTranslations("email_viewer");
|
||||
const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow";
|
||||
// Always blocked with no third-party traffic (#903).
|
||||
const remoteBlockedByOrg = useNoThirdPartyTraffic();
|
||||
const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg;
|
||||
|
||||
// dir="auto" on a wrapper we control lets the first strong character pick
|
||||
// the direction, matching the main viewer.
|
||||
|
||||
@@ -16,6 +16,7 @@ import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitiza
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useCloseOnBack } from "@/hooks/use-close-on-back";
|
||||
import type { FileResourceView } from "@/lib/plugin-types";
|
||||
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
|
||||
|
||||
/**
|
||||
* An HTML attachment as a document for a fully locked frame (sandbox="",
|
||||
@@ -161,7 +162,9 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
|
||||
// open-in-new-tab fallback use.
|
||||
const [pdfBlob, setPdfBlob] = useState<Blob | null>(null);
|
||||
const [htmlDoc, setHtmlDoc] = useState<string | null>(null);
|
||||
const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow";
|
||||
// Always blocked with no third-party traffic (#903).
|
||||
const remoteBlockedByOrg = useNoThirdPartyTraffic();
|
||||
const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg;
|
||||
|
||||
// Decide whether to render the PDF in a plain <iframe> (desktop) or with the
|
||||
// pdf.js canvas viewer (mobile). navigator.pdfViewerEnabled is the standard
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user