mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
# Conflicts: # CONTRIBUTING.md # LICENSE # README.md # app/api/calendar-agenda/route.ts # app/api/wopi/files/[fileId]/contents/route.ts # app/api/wopi/launch/route.ts # components/calendar/calendar-app.tsx # components/calendar/calendar-day-view.tsx # components/calendar/calendar-month-view.tsx # components/calendar/calendar-week-view.tsx # components/calendar/event-modal.tsx # components/contacts/contact-detail.tsx # components/email/email-composer.tsx # components/email/email-list.tsx # components/files/file-browser.tsx # components/files/files-app.tsx # components/files/folder-tree-sidebar.tsx # components/filters/filter-rule-modal.tsx # components/mail/mail-app.tsx # hooks/use-mailbox-drop.ts # integration/run-tests.sh # lib/__tests__/calendar-agenda-route.test.ts # lib/__tests__/jmap-draft-lifecycle.test.ts # lib/__tests__/web-push.test.ts # lib/account-state-manager.ts # lib/jmap/client-interface.ts # lib/jmap/client.ts # lib/jmap/types.ts # lib/message-list-order.ts # lib/sieve/generator.ts # lib/sieve/parser.ts # lib/telemetry/payload.ts # lib/thread-utils.ts # lib/unified-mailbox.ts # lib/web-push.ts # lib/wopi/token.ts # locales/ar/common.json # locales/ca/common.json # locales/cs/common.json # locales/da/common.json # locales/de/common.json # locales/en/common.json # locales/es/common.json # locales/fa/common.json # locales/fr/common.json # locales/he/common.json # locales/hu/common.json # locales/it/common.json # locales/ja/common.json # locales/ko/common.json # locales/lv/common.json # locales/mn/common.json # locales/nb/common.json # locales/nl/common.json # locales/pl/common.json # locales/pt/common.json # locales/ro/common.json # locales/ru/common.json # locales/sk/common.json # locales/tr/common.json # locales/uk/common.json # locales/zh-TW/common.json # locales/zh/common.json # scripts/lite/lib.mjs # stores/__tests__/calendar-store-synthetic-ids.test.ts # stores/__tests__/email-store-shared-folder-keywords.test.ts # stores/__tests__/email-store-unified-actions.test.ts # stores/__tests__/file-store.test.ts # stores/email-store.ts # stores/file-store.ts # stores/message-list-tabs-store.ts
305 lines
14 KiB
TypeScript
305 lines
14 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
||
import { parseScript } from '../parser';
|
||
import { generateScript } from '../generator';
|
||
import type { FilterRule } from '@/lib/jmap/sieve-types';
|
||
|
||
function makeRule(overrides: Partial<FilterRule> = {}): FilterRule {
|
||
return {
|
||
id: 'rule-1',
|
||
name: 'Test Rule',
|
||
enabled: true,
|
||
matchType: 'all',
|
||
conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }],
|
||
actions: [{ type: 'move', value: 'Archive' }],
|
||
stopProcessing: false,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
describe('parseScript', () => {
|
||
it('extracts rules from valid metadata', () => {
|
||
const rules = [makeRule()];
|
||
const script = generateScript(rules);
|
||
const result = parseScript(script);
|
||
expect(result.isOpaque).toBe(false);
|
||
expect(result.rules).toEqual(rules);
|
||
});
|
||
|
||
it('round-trips names and values containing */, which would end the metadata comment', () => {
|
||
const rules = [
|
||
makeRule({
|
||
id: 'r1',
|
||
name: 'Receipts */ and more',
|
||
conditions: [
|
||
{ field: 'subject', comparator: 'contains', value: ['*/', 'a */ b'] },
|
||
{ field: 'body', comparator: 'contains', value: 'end */ of comment' },
|
||
],
|
||
actions: [{ type: 'add_label', value: 'bills' }],
|
||
}),
|
||
makeRule({ id: 'r2', name: 'Second' }),
|
||
];
|
||
const vacation = { isEnabled: true, subject: 'Out */', textBody: 'Reply */ later' };
|
||
|
||
const result = parseScript(generateScript(rules, vacation));
|
||
|
||
expect(result.isOpaque).toBe(false);
|
||
expect(result.rules).toEqual(rules);
|
||
expect(result.vacation).toEqual(vacation);
|
||
});
|
||
|
||
it('reads back metadata a newer Bulwark wrote with */ escaped (older clients parse it too)', () => {
|
||
// JSON.parse turns `\/` back into `/`, so no reader needs to know.
|
||
const script = '/* @metadata:begin\n{"version":1,"rules":[{"id":"x","name":"a *\\/ b","enabled":true,"matchType":"all",'
|
||
+ '"conditions":[{"field":"from","comparator":"contains","value":"c"}],"actions":[{"type":"discard"}],"stopProcessing":false}]}\n@metadata:end */\n';
|
||
expect(parseScript(script).rules[0].name).toBe('a */ b');
|
||
});
|
||
|
||
it('recognises its own rule block when the rule name had a line break', () => {
|
||
// The # Rule: line flattens the break; the block must still be matched to
|
||
// the rule, or the next save would copy it as an "external" rule.
|
||
const rules = [makeRule({ name: 'Two\nlines' })];
|
||
const result = parseScript(generateScript(rules));
|
||
|
||
expect(result.rules).toEqual(rules);
|
||
expect(result.rules.some((r) => r.origin === 'external')).toBe(false);
|
||
});
|
||
|
||
it('parses external rules when no Bulwark metadata is present', () => {
|
||
const result = parseScript('require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }');
|
||
expect(result.isOpaque).toBe(false);
|
||
expect(result.rules).toHaveLength(1);
|
||
expect(result.rules[0].origin).toBe('external');
|
||
expect(result.rules[0].conditions[0]).toMatchObject({ field: 'from', comparator: 'contains', value: 'x' });
|
||
expect(result.rules[0].actions[0]).toEqual({ type: 'move', value: 'Y' });
|
||
expect(result.externalRequires).toContain('fileinto');
|
||
});
|
||
|
||
it('returns isOpaque for corrupted JSON', () => {
|
||
const script = '/* @metadata:begin\n{not valid json\n@metadata:end */';
|
||
const result = parseScript(script);
|
||
expect(result.isOpaque).toBe(true);
|
||
expect(result.rules).toEqual([]);
|
||
});
|
||
|
||
it('returns isOpaque for version mismatch', () => {
|
||
const script = '/* @metadata:begin\n{"version":3,"rules":[]}\n@metadata:end */';
|
||
const result = parseScript(script);
|
||
expect(result.isOpaque).toBe(true);
|
||
expect(result.rules).toEqual([]);
|
||
});
|
||
|
||
it('returns isOpaque for empty metadata block', () => {
|
||
const script = '/* @metadata:begin\n\n@metadata:end */';
|
||
const result = parseScript(script);
|
||
expect(result.isOpaque).toBe(true);
|
||
expect(result.rules).toEqual([]);
|
||
});
|
||
|
||
it('returns isOpaque for missing rules array', () => {
|
||
const script = '/* @metadata:begin\n{"version":1}\n@metadata:end */';
|
||
const result = parseScript(script);
|
||
expect(result.isOpaque).toBe(true);
|
||
expect(result.rules).toEqual([]);
|
||
});
|
||
|
||
it('returns isOpaque for invalid rule objects', () => {
|
||
const script = '/* @metadata:begin\n{"version":1,"rules":[{"id":"x"}]}\n@metadata:end */';
|
||
const result = parseScript(script);
|
||
expect(result.isOpaque).toBe(true);
|
||
expect(result.rules).toEqual([]);
|
||
});
|
||
|
||
it('handles metadata with extra whitespace', () => {
|
||
const rules = [makeRule()];
|
||
const json = JSON.stringify({ version: 1, rules });
|
||
const script = `/* @metadata:begin\n ${json} \n@metadata:end */\n\nrequire ["fileinto"];`;
|
||
const result = parseScript(script);
|
||
expect(result.isOpaque).toBe(false);
|
||
expect(result.rules).toEqual(rules);
|
||
});
|
||
|
||
it('handles script with only metadata block', () => {
|
||
const rules = [makeRule({ enabled: false })];
|
||
const json = JSON.stringify({ version: 1, rules });
|
||
const script = `/* @metadata:begin\n${json}\n@metadata:end */`;
|
||
const result = parseScript(script);
|
||
expect(result.isOpaque).toBe(false);
|
||
expect(result.rules).toEqual(rules);
|
||
});
|
||
|
||
it('returns isOpaque for missing end marker', () => {
|
||
const script = '/* @metadata:begin\n{"version":1,"rules":[]}';
|
||
const result = parseScript(script);
|
||
expect(result.isOpaque).toBe(true);
|
||
});
|
||
|
||
it('treats an empty string as an empty, editable script (not opaque)', () => {
|
||
const result = parseScript('');
|
||
expect(result.isOpaque).toBe(false);
|
||
expect(result.rules).toEqual([]);
|
||
});
|
||
|
||
describe('round-trip', () => {
|
||
it('preserves complex rules through generate → parse', () => {
|
||
const rules: FilterRule[] = [
|
||
makeRule({ id: '1', name: 'Newsletter', enabled: true, stopProcessing: true }),
|
||
makeRule({
|
||
id: '2',
|
||
name: 'VIP',
|
||
matchType: 'any',
|
||
conditions: [
|
||
{ field: 'from', comparator: 'is', value: 'boss@company.com' },
|
||
{ field: 'from', comparator: 'is', value: 'ceo@company.com' },
|
||
],
|
||
actions: [{ type: 'star' }, { type: 'mark_read' }],
|
||
}),
|
||
makeRule({ id: '3', name: 'Disabled', enabled: false }),
|
||
];
|
||
const script = generateScript(rules);
|
||
const result = parseScript(script);
|
||
expect(result.isOpaque).toBe(false);
|
||
expect(result.rules).toEqual(rules);
|
||
});
|
||
|
||
it('reads a rule whose name has runs of whitespace back as one rule, save after save', () => {
|
||
// A hand-written block, and one that only looks like Bulwark's.
|
||
const external = '# Hand\nif header :contains "subject" "hand" {\n keep;\n}\n'
|
||
+ '# Rule: Someone else\nif header :contains "subject" "other" {\n keep;\n}\n';
|
||
const rules = [
|
||
makeRule({ id: '1', name: 'Foo Bar' }),
|
||
makeRule({ id: '2', name: ' Tab\tand trailing ' }),
|
||
// No-break and ideographic spaces count as whitespace too.
|
||
makeRule({ id: '3', name: 'Büro Post' }),
|
||
// Not possible in the editor, but in metadata written elsewhere.
|
||
makeRule({ id: '4', name: '' }),
|
||
];
|
||
let script = `${generateScript(rules)}\n${external}`;
|
||
for (let save = 0; save < 3; save++) {
|
||
const result = parseScript(script);
|
||
expect(result.isOpaque).toBe(false);
|
||
// The four rules, and each other block once.
|
||
const own = result.rules.filter((r) => (r.origin ?? 'bulwark') === 'bulwark');
|
||
expect(own.map((r) => r.id)).toEqual(['1', '2', '3', '4']);
|
||
expect(result.rules.length - own.length).toBe(2);
|
||
script = generateScript(result.rules, result.vacation, { externalRequires: result.externalRequires });
|
||
}
|
||
expect(script.split('# Rule: Foo Bar\n').length).toBe(2);
|
||
expect(script.split('# Rule: Büro Post\n').length).toBe(2);
|
||
expect(script.split('# Rule: \n').length).toBe(2);
|
||
expect(script.split('if header :contains "subject" "hand"').length).toBe(2);
|
||
expect(script.split('# Rule: Someone else').length).toBe(2);
|
||
});
|
||
|
||
it('takes a block written before names were collapsed for the rule itself', () => {
|
||
// Up to 1.11.0 the "# Rule:" line carried the name as typed.
|
||
const rule = makeRule({ id: '1', name: 'Foo Bar' });
|
||
const asWritten = generateScript([rule]).replace('# Rule: Foo Bar', '# Rule: Foo Bar');
|
||
expect(asWritten).toContain('# Rule: Foo Bar');
|
||
expect(parseScript(asWritten).rules).toEqual([rule]);
|
||
});
|
||
|
||
it('drops the copies of such a rule an earlier save left behind', () => {
|
||
const rule = makeRule({ id: '1', name: 'Foo Bar' });
|
||
const block = generateScript([rule]).slice(generateScript([rule]).indexOf('# Rule: Foo Bar'));
|
||
// As written before: the rule, and two copies read back as someone else's.
|
||
const withCopies = `${generateScript([rule])}\n# --- External rules (managed outside Bulwark) ---\n${block}\n${block}`;
|
||
const result = parseScript(withCopies);
|
||
expect(result.rules).toEqual([rule]);
|
||
expect(generateScript(result.rules).split('# Rule: Foo Bar').length).toBe(2);
|
||
});
|
||
|
||
it('preserves rules with special characters', () => {
|
||
const rules = [makeRule({
|
||
conditions: [{ field: 'subject', comparator: 'contains', value: 'say "hello" \\ world' }],
|
||
actions: [{ type: 'move', value: 'My "Folder"' }],
|
||
})];
|
||
const script = generateScript(rules);
|
||
const result = parseScript(script);
|
||
expect(result.rules).toEqual(rules);
|
||
});
|
||
|
||
it('preserves all action types', () => {
|
||
const rules = [makeRule({
|
||
actions: [
|
||
{ type: 'move', value: 'Folder' },
|
||
{ type: 'copy', value: 'Backup' },
|
||
{ type: 'forward', value: 'fwd@x.com' },
|
||
{ type: 'mark_read' },
|
||
{ type: 'star' },
|
||
{ type: 'add_label', value: 'Tag' },
|
||
{ type: 'keep' },
|
||
],
|
||
})];
|
||
const script = generateScript(rules);
|
||
const result = parseScript(script);
|
||
expect(result.rules).toEqual(rules);
|
||
});
|
||
});
|
||
|
||
describe('validation edge cases', () => {
|
||
it('returns isOpaque when rule has non-string id', () => {
|
||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: 123, name: 'x', enabled: true, matchType: 'all', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
|
||
expect(parseScript(script).isOpaque).toBe(true);
|
||
});
|
||
|
||
it('returns isOpaque when rule has non-boolean enabled', () => {
|
||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: 'yes', matchType: 'all', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
|
||
expect(parseScript(script).isOpaque).toBe(true);
|
||
});
|
||
|
||
it('returns isOpaque when rule has invalid matchType', () => {
|
||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: true, matchType: 'none', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
|
||
expect(parseScript(script).isOpaque).toBe(true);
|
||
});
|
||
|
||
it('returns isOpaque when condition missing value', () => {
|
||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: true, matchType: 'all', conditions: [{ field: 'from', comparator: 'contains' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
|
||
expect(parseScript(script).isOpaque).toBe(true);
|
||
});
|
||
|
||
it('returns isOpaque when action missing type', () => {
|
||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: true, matchType: 'all', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ value: 'Inbox' }], stopProcessing: false }] })}\n@metadata:end */`;
|
||
expect(parseScript(script).isOpaque).toBe(true);
|
||
});
|
||
|
||
it('accepts valid empty rules array', () => {
|
||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [] })}\n@metadata:end */`;
|
||
const result = parseScript(script);
|
||
expect(result.isOpaque).toBe(false);
|
||
expect(result.rules).toEqual([]);
|
||
});
|
||
|
||
it('preserves all comparator types through round-trip', () => {
|
||
const comparators = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with', 'matches'] as const;
|
||
const rules = comparators.map((comparator, i) => makeRule({
|
||
id: `r${i}`,
|
||
name: `Rule ${comparator}`,
|
||
conditions: [{ field: 'from', comparator, value: 'test' }],
|
||
}));
|
||
const script = generateScript(rules);
|
||
const result = parseScript(script);
|
||
expect(result.isOpaque).toBe(false);
|
||
expect(result.rules).toEqual(rules);
|
||
});
|
||
|
||
it('preserves size comparators through round-trip', () => {
|
||
const rules = [
|
||
makeRule({ id: 'r1', conditions: [{ field: 'size', comparator: 'greater_than', value: '1000' }], actions: [{ type: 'discard' }] }),
|
||
makeRule({ id: 'r2', conditions: [{ field: 'size', comparator: 'less_than', value: '500' }], actions: [{ type: 'keep' }] }),
|
||
];
|
||
const script = generateScript(rules);
|
||
const result = parseScript(script);
|
||
expect(result.rules).toEqual(rules);
|
||
});
|
||
|
||
it('preserves header field with custom headerName', () => {
|
||
const rules = [makeRule({
|
||
conditions: [{ field: 'header', comparator: 'contains', value: 'test', headerName: 'X-My-Header' }],
|
||
})];
|
||
const script = generateScript(rules);
|
||
const result = parseScript(script);
|
||
expect(result.rules).toEqual(rules);
|
||
});
|
||
});
|
||
});
|