mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
A sent message brought back by Undo, Cancel and edit or edit-as-new reopened with the default label, because only drafts carried the X-Bulwark-Label marker; it now falls back to the SIO-Label it went out with. A reply, quick reply or plugin forward takes over the original's label only when an internal sender set it and never one below the default, since anyone outside can write the header. The tests written for the earlier rule (any received label counts) now pass an internal sender, which is why their originals changed.
141 lines
7.3 KiB
TypeScript
141 lines
7.3 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import type { InstalledPlugin } from '../plugin-types';
|
||
import type { IJMAPClient } from '../jmap/client-interface';
|
||
import type { Email } from '../jmap/types';
|
||
import { useAuthStore } from '@/stores/auth-store';
|
||
import { useAccountStore } from '@/stores/account-store';
|
||
import { useEmailStore } from '@/stores/email-store';
|
||
import { useIdentityStore } from '@/stores/identity-store';
|
||
import { useSettingsStore } from '@/stores/settings-store';
|
||
import { usePolicyStore } from '@/stores/policy-store';
|
||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||
import { LABEL_PRESETS } from '@/lib/classification/presets';
|
||
import { head, resolveHead, resetDialogLimits } from '../plugin-sandbox/host-dialog';
|
||
import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input';
|
||
import { beginLabelTakeover, endLabelTakeover, resetLabelTakeovers } from '../outgoing/classification';
|
||
|
||
// Classification labels on the plugin paths: api.email.forward carries the
|
||
// original's label (else the default) and refuses when a required one is
|
||
// missing; a crypto plugin's raw send during a labelled takeover gets the
|
||
// label headers in its top-level header block.
|
||
|
||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }));
|
||
vi.mock('@/stores/toast-store', () => ({ toast }));
|
||
|
||
import { dispatchApiCall } from '../plugin-sandbox/host-api';
|
||
|
||
function plugin(perms: string[], id: string): InstalledPlugin {
|
||
return {
|
||
id, name: id, version: '1.0.0', author: 'test', description: '',
|
||
type: 'ui-extension', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
|
||
enabled: true, status: 'running', settings: {},
|
||
};
|
||
}
|
||
const FORWARDER = plugin(['email:forward'], 'e-invoice');
|
||
const SMIME = plugin(['email:raw-send'], 'smime');
|
||
const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args, { privileged: p.id === 'smime' });
|
||
|
||
const original = (headers: Record<string, string>, sender = 'colleague@example.com') => ({
|
||
id: 'E1', threadId: 'T1', mailboxIds: { inbox: true }, keywords: {}, size: 900, receivedAt: '2026-10-20T08:00:00Z',
|
||
from: [{ email: sender }], to: [{ email: 'ann@example.com' }], subject: 'Invoice',
|
||
textBody: [{ partId: '1', blobId: 'tb', size: 20, type: 'text/plain' }], bodyValues: { 1: { value: 'Hello' } },
|
||
attachments: [], headers,
|
||
}) as unknown as Email;
|
||
|
||
function useLabels(labels: Partial<typeof DEFAULT_ORGANISATION_POLICY.labels>) {
|
||
const org = { ...DEFAULT_ORGANISATION_POLICY, labels: { ...DEFAULT_ORGANISATION_POLICY.labels, list: LABEL_PRESETS[0].list, ...labels } };
|
||
usePolicyStore.setState({ loaded: true, policy: { ...DEFAULT_POLICY, organisation: org }, rawPolicy: { ...DEFAULT_POLICY, organisation: org } });
|
||
}
|
||
|
||
describe('labels on the plugin paths', () => {
|
||
let client: Record<string, ReturnType<typeof vi.fn>>;
|
||
let sendEmail: ReturnType<typeof vi.fn>;
|
||
let sendRawEmail: ReturnType<typeof vi.fn>;
|
||
let headers: Record<string, string> = {};
|
||
let sender = 'colleague@example.com';
|
||
|
||
beforeEach(() => {
|
||
resetPluginRateLimits();
|
||
resetDialogLimits();
|
||
localStorage.clear();
|
||
// The forward to this address is approved already: no dialog.
|
||
localStorage.setItem('plugin-forward-approvals', '{}');
|
||
client = {
|
||
getAccountId: vi.fn(() => 'own'),
|
||
getEmail: vi.fn(async () => original(headers, sender)),
|
||
getMailboxes: vi.fn(async () => []),
|
||
resolveSendAccountId: vi.fn(async () => 'own'),
|
||
copyBlobs: vi.fn(async () => ({})),
|
||
hasDelayedSend: vi.fn(() => false),
|
||
batchUpdateKeywords: vi.fn(async () => undefined),
|
||
submitRawEmail: vi.fn(async () => ({ scheduled: false })),
|
||
};
|
||
sendEmail = vi.fn(async () => ({ emailId: 'S1', scheduled: false }));
|
||
sendRawEmail = vi.fn(async () => ({ scheduled: false }));
|
||
useAuthStore.setState({ client: client as unknown as IJMAPClient });
|
||
useAccountStore.setState({ activeAccountId: 'acct-1' });
|
||
useEmailStore.setState({ sendEmail, sendRawEmail, emails: [], selectedEmail: null, mailboxes: [] } as never);
|
||
useIdentityStore.setState({ identities: [{ id: 'id-a', name: 'Ann', email: 'ann@example.com' }] as never, preferredPrimaryId: 'id-a' });
|
||
useAuthStore.setState({ identities: [{ id: 'id-a', name: 'Ann', email: 'ann@example.com' }] } as never);
|
||
useSettingsStore.setState({ sendDelaySeconds: 0 });
|
||
});
|
||
afterEach(() => {
|
||
while (head()) resolveHead(false);
|
||
useAuthStore.setState({ client: null });
|
||
usePolicyStore.setState({ policy: DEFAULT_POLICY, rawPolicy: DEFAULT_POLICY });
|
||
resetLabelTakeovers();
|
||
headers = {};
|
||
sender = 'colleague@example.com';
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
async function forward() {
|
||
const pending = call(FORWARDER, 'email.forward', 'E1', { to: ['acc@example.com'] });
|
||
await vi.waitFor(() => expect(head()).not.toBeNull());
|
||
resolveHead(true);
|
||
return pending;
|
||
}
|
||
|
||
it('forwards with the original’s label', async () => {
|
||
useLabels({ defaultId: 'internal' });
|
||
headers = { 'SIO-Label': ' marking="CONFIDENTIAL"' };
|
||
expect(await forward()).toMatchObject({ sent: true });
|
||
const args = sendEmail.mock.calls[0] as unknown[];
|
||
expect(args[2]).toBe('[Confidential] Fwd: Invoice');
|
||
expect((args[16] as { labelHeaders: Record<string, string> }).labelHeaders['header:SIO-Label:asRaw']).toContain('CONFIDENTIAL');
|
||
});
|
||
|
||
it('takes the default, not an outside sender’s label', async () => {
|
||
useLabels({ defaultId: 'internal' });
|
||
headers = { 'SIO-Label': ' marking="PUBLIC"' };
|
||
sender = 'billing@supplier.example';
|
||
expect(await forward()).toMatchObject({ sent: true });
|
||
expect((sendEmail.mock.calls[0] as unknown[])[2]).toBe('Fwd: Invoice');
|
||
const labelHeaders = ((sendEmail.mock.calls[0] as unknown[])[16] as { labelHeaders: Record<string, string> }).labelHeaders;
|
||
expect(labelHeaders['header:SIO-Label:asRaw']).toContain('INTERNAL');
|
||
});
|
||
|
||
it('refuses a forward that needs a label it has no way to get', async () => {
|
||
useLabels({ required: true, defaultId: '' });
|
||
expect(await call(FORWARDER, 'email.forward', 'E1', { to: ['acc@example.com'] })).toEqual({ sent: false, reason: 'stopped' });
|
||
expect(sendEmail).not.toHaveBeenCalled();
|
||
expect(toast.error).toHaveBeenCalled();
|
||
});
|
||
|
||
it('puts the label into a raw send during a labelled takeover only', async () => {
|
||
useLabels({});
|
||
const raw = new TextEncoder().encode('From: ann@example.com\r\nSubject: [Confidential] Hi\r\n\r\nENCRYPTED\r\n');
|
||
await call(SMIME, 'jmap.sendRaw', raw, 'id-a', {});
|
||
expect(await (sendRawEmail.mock.calls[0][1] as Blob).text()).not.toContain('SIO-Label');
|
||
|
||
const token = beginLabelTakeover(usePolicyStore.getState().policy.organisation!.labels, LABEL_PRESETS[0].list[2], 'id-a', '[Confidential] Hi');
|
||
await call(SMIME, 'jmap.sendRaw', raw, 'id-a', {});
|
||
await call(SMIME, 'jmap.submitRaw', raw, 'id-a', {});
|
||
endLabelTakeover(token);
|
||
const sent = await (sendRawEmail.mock.calls[1][1] as Blob).text();
|
||
expect(sent).toMatch(/^From: ann@example.com\r\nSubject: \[Confidential\] Hi\r\nSIO-Label: marking="CONFIDENTIAL"[^\r]*\r\nSensitivity: Company-Confidential\r\n\r\nENCRYPTED\r\n$/);
|
||
expect(await (client.submitRawEmail.mock.calls[0][0] as Blob).text()).toContain('SIO-Label: marking="CONFIDENTIAL"');
|
||
});
|
||
});
|