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
713 lines
29 KiB
TypeScript
713 lines
29 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { generateScript, isValidSieveHeaderName } from '../generator';
|
|
import { parseScript } from '../parser';
|
|
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('generateScript', () => {
|
|
it('outputs metadata and no require for empty rules', () => {
|
|
const script = generateScript([]);
|
|
expect(script).toContain('/* @metadata:begin');
|
|
expect(script).toContain('@metadata:end */');
|
|
expect(script).not.toContain('require');
|
|
});
|
|
|
|
it('embeds compact metadata JSON', () => {
|
|
const rules = [makeRule()];
|
|
const script = generateScript(rules);
|
|
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
|
|
expect(match).not.toBeNull();
|
|
const metadata = JSON.parse(match![1]);
|
|
expect(metadata.version).toBe(1);
|
|
expect(metadata.rules).toHaveLength(1);
|
|
expect(metadata.rules[0].id).toBe('rule-1');
|
|
});
|
|
|
|
it('generates single rule with from/contains', () => {
|
|
const script = generateScript([makeRule()]);
|
|
expect(script).toContain('# Rule: Test Rule');
|
|
expect(script).toContain('if header :contains "From" "test@example.com"');
|
|
expect(script).toContain('fileinto "Archive";');
|
|
});
|
|
|
|
describe('text that could break out of a comment', () => {
|
|
it('escapes */ inside the metadata comment so the JSON cannot end it early', () => {
|
|
const script = generateScript(
|
|
[makeRule({
|
|
name: 'Invoices */ keep',
|
|
conditions: [{ field: 'subject', comparator: 'contains', value: ['a*/b', '*/'] }],
|
|
actions: [{ type: 'move', value: 'Bills*/2026' }],
|
|
})],
|
|
{ isEnabled: true, subject: 'Away */', textBody: 'Back soon */ promise' },
|
|
);
|
|
const comment = script.slice(script.indexOf('/* @metadata:begin'), script.indexOf('@metadata:end */'));
|
|
expect(comment).not.toContain('*/');
|
|
expect(comment).toContain('Invoices *\\/ keep');
|
|
// `\/` is a JSON escape, so the JSON reads back exactly as written.
|
|
const json = script.match(/@metadata:begin\n(.*)\n@metadata:end/)![1];
|
|
const metadata = JSON.parse(json);
|
|
expect(metadata.rules[0].name).toBe('Invoices */ keep');
|
|
expect(metadata.rules[0].conditions[0].value).toEqual(['a*/b', '*/']);
|
|
expect(metadata.vacation.textBody).toBe('Back soon */ promise');
|
|
});
|
|
|
|
it('keeps the metadata byte-identical when nothing needs escaping', () => {
|
|
const rules = [makeRule({ name: 'Plain / slash and * star' })];
|
|
const json = generateScript(rules).match(/@metadata:begin\n(.*)\n@metadata:end/)![1];
|
|
expect(json).toBe(JSON.stringify({ version: 1, rules }));
|
|
});
|
|
|
|
it('keeps a line break in the rule name out of the # Rule: line', () => {
|
|
const script = generateScript([makeRule({ name: 'Line one\r\ndiscard;\nstop' })]);
|
|
expect(script).toContain('# Rule: Line one discard; stop\n');
|
|
expect(script).not.toMatch(/^discard;$/m);
|
|
});
|
|
});
|
|
|
|
describe('condition fields', () => {
|
|
it('maps to field to "To" header', () => {
|
|
const script = generateScript([makeRule({
|
|
conditions: [{ field: 'to', comparator: 'contains', value: 'me@x.com' }],
|
|
})]);
|
|
expect(script).toContain('header :contains "To" "me@x.com"');
|
|
});
|
|
|
|
it('maps cc field to "Cc" header', () => {
|
|
const script = generateScript([makeRule({
|
|
conditions: [{ field: 'cc', comparator: 'is', value: 'cc@x.com' }],
|
|
})]);
|
|
expect(script).toContain('header :is "Cc" "cc@x.com"');
|
|
});
|
|
|
|
it('maps subject field to "Subject" header', () => {
|
|
const script = generateScript([makeRule({
|
|
conditions: [{ field: 'subject', comparator: 'contains', value: 'hello' }],
|
|
})]);
|
|
expect(script).toContain('header :contains "Subject" "hello"');
|
|
});
|
|
|
|
it('maps header field with custom headerName', () => {
|
|
const script = generateScript([makeRule({
|
|
conditions: [{ field: 'header', comparator: 'contains', value: 'test', headerName: 'X-Custom' }],
|
|
})]);
|
|
expect(script).toContain('header :contains "X-Custom" "test"');
|
|
});
|
|
|
|
// sieve-rs (Stalwart 0.16 and 1.0) accepts only [A-Za-z0-9_-]+ and refuses
|
|
// the whole script otherwise, taking every other rule down with it.
|
|
it.each(['X-Spam Flag', 'X-Foo.Bar', 'X-Foo:', 'X-"Quoted"', 'Überschrift'])(
|
|
'skips a rule whose header name %j Stalwart would refuse, keeping the others',
|
|
(headerName) => {
|
|
const script = generateScript([
|
|
makeRule({ id: 'bad', name: 'Bad header', conditions: [{ field: 'header', comparator: 'contains', value: 'yes', headerName }] }),
|
|
makeRule({ id: 'good', name: 'Good rule' }),
|
|
]);
|
|
const code = script.slice(script.indexOf('@metadata:end */'));
|
|
expect(code).not.toContain('Bad header');
|
|
expect(code).not.toContain('"yes"');
|
|
expect(code).toContain('# Rule: Good rule');
|
|
// Kept in the metadata, so the rule can still be corrected in the UI.
|
|
expect(parseScript(script).rules.map((r) => r.id)).toEqual(['bad', 'good']);
|
|
},
|
|
);
|
|
|
|
it('accepts letters, digits, hyphens and underscores, trimming stray spaces', () => {
|
|
const script = generateScript([makeRule({
|
|
conditions: [{ field: 'header', comparator: 'is', value: 'yes', headerName: ' X-Spam_Flag-2 ' }],
|
|
})]);
|
|
expect(script).toContain('header :is "X-Spam_Flag-2" "yes"');
|
|
});
|
|
|
|
it('validates header names with the same rule the server applies', () => {
|
|
expect(isValidSieveHeaderName('X-Spam-Flag')).toBe(true);
|
|
expect(isValidSieveHeaderName('x_custom_2')).toBe(true);
|
|
expect(isValidSieveHeaderName('')).toBe(false);
|
|
expect(isValidSieveHeaderName('X-Spam Flag')).toBe(false);
|
|
expect(isValidSieveHeaderName('X-Foo.Bar')).toBe(false);
|
|
});
|
|
|
|
it('handles size greater_than', () => {
|
|
const script = generateScript([makeRule({
|
|
conditions: [{ field: 'size', comparator: 'greater_than', value: '1000000' }],
|
|
})]);
|
|
expect(script).toContain('size :over 1000000');
|
|
});
|
|
|
|
it('handles size less_than', () => {
|
|
const script = generateScript([makeRule({
|
|
conditions: [{ field: 'size', comparator: 'less_than', value: '500' }],
|
|
})]);
|
|
expect(script).toContain('size :under 500');
|
|
});
|
|
|
|
it('handles body contains', () => {
|
|
const script = generateScript([makeRule({
|
|
conditions: [{ field: 'body', comparator: 'contains', value: 'keyword' }],
|
|
})]);
|
|
expect(script).toContain('body :contains "keyword"');
|
|
expect(script).toContain('"body"');
|
|
});
|
|
|
|
it('handles body is', () => {
|
|
const script = generateScript([makeRule({
|
|
conditions: [{ field: 'body', comparator: 'is', value: 'exact' }],
|
|
})]);
|
|
expect(script).toContain('body :is "exact"');
|
|
});
|
|
});
|
|
|
|
describe('comparators', () => {
|
|
it('generates not_contains with not wrapper', () => {
|
|
const script = generateScript([makeRule({
|
|
conditions: [{ field: 'from', comparator: 'not_contains', value: 'spam' }],
|
|
})]);
|
|
expect(script).toContain('not header :contains "From" "spam"');
|
|
});
|
|
|
|
it('generates not_is with not wrapper', () => {
|
|
const script = generateScript([makeRule({
|
|
conditions: [{ field: 'from', comparator: 'not_is', value: 'bad@x.com' }],
|
|
})]);
|
|
expect(script).toContain('not header :is "From" "bad@x.com"');
|
|
});
|
|
|
|
it('generates starts_with as :matches with trailing *', () => {
|
|
const script = generateScript([makeRule({
|
|
conditions: [{ field: 'subject', comparator: 'starts_with', value: 'Re:' }],
|
|
})]);
|
|
expect(script).toContain('header :matches "Subject" "Re:*"');
|
|
});
|
|
|
|
it('generates ends_with as :matches with leading *', () => {
|
|
const script = generateScript([makeRule({
|
|
conditions: [{ field: 'subject', comparator: 'ends_with', value: 'urgent' }],
|
|
})]);
|
|
expect(script).toContain('header :matches "Subject" "*urgent"');
|
|
});
|
|
|
|
it('generates matches as :matches', () => {
|
|
const script = generateScript([makeRule({
|
|
conditions: [{ field: 'from', comparator: 'matches', value: '*@company.com' }],
|
|
})]);
|
|
expect(script).toContain('header :matches "From" "*@company.com"');
|
|
});
|
|
});
|
|
|
|
describe('match types', () => {
|
|
it('wraps multiple conditions with allof for matchType all', () => {
|
|
const script = generateScript([makeRule({
|
|
matchType: 'all',
|
|
conditions: [
|
|
{ field: 'from', comparator: 'contains', value: 'a' },
|
|
{ field: 'subject', comparator: 'contains', value: 'b' },
|
|
],
|
|
})]);
|
|
expect(script).toContain('allof(header :contains "From" "a", header :contains "Subject" "b")');
|
|
});
|
|
|
|
it('wraps multiple conditions with anyof for matchType any', () => {
|
|
const script = generateScript([makeRule({
|
|
matchType: 'any',
|
|
conditions: [
|
|
{ field: 'from', comparator: 'contains', value: 'x' },
|
|
{ field: 'to', comparator: 'contains', value: 'y' },
|
|
],
|
|
})]);
|
|
expect(script).toContain('anyof(header :contains "From" "x", header :contains "To" "y")');
|
|
});
|
|
|
|
it('uses no wrapper for single condition', () => {
|
|
const script = generateScript([makeRule()]);
|
|
expect(script).not.toContain('allof');
|
|
expect(script).not.toContain('anyof');
|
|
});
|
|
});
|
|
|
|
describe('actions', () => {
|
|
it('generates move as fileinto', () => {
|
|
const script = generateScript([makeRule({ actions: [{ type: 'move', value: 'Spam' }] })]);
|
|
expect(script).toContain('fileinto "Spam";');
|
|
});
|
|
|
|
it('generates copy as fileinto :copy', () => {
|
|
const script = generateScript([makeRule({ actions: [{ type: 'copy', value: 'Backup' }] })]);
|
|
expect(script).toContain('fileinto :copy "Backup";');
|
|
});
|
|
|
|
it('generates forward as redirect', () => {
|
|
const script = generateScript([makeRule({ actions: [{ type: 'forward', value: 'fwd@x.com' }] })]);
|
|
expect(script).toContain('redirect "fwd@x.com";');
|
|
});
|
|
|
|
it('generates mark_read as addflag \\Seen', () => {
|
|
const script = generateScript([makeRule({ actions: [{ type: 'mark_read' }] })]);
|
|
expect(script).toContain('addflag "\\\\Seen";');
|
|
});
|
|
|
|
it('generates star as addflag \\Flagged', () => {
|
|
const script = generateScript([makeRule({ actions: [{ type: 'star' }] })]);
|
|
expect(script).toContain('addflag "\\\\Flagged";');
|
|
});
|
|
|
|
it('generates add_label as addflag $label:Label', () => {
|
|
const script = generateScript([makeRule({ actions: [{ type: 'add_label', value: 'Important' }] })]);
|
|
expect(script).toContain('addflag "$label:Important";');
|
|
});
|
|
|
|
it('generates discard', () => {
|
|
const script = generateScript([makeRule({ actions: [{ type: 'discard' }] })]);
|
|
expect(script).toContain('discard;');
|
|
});
|
|
|
|
it('generates reject with message', () => {
|
|
const script = generateScript([makeRule({ actions: [{ type: 'reject', value: 'Go away' }] })]);
|
|
expect(script).toContain('reject "Go away";');
|
|
});
|
|
|
|
it('generates keep as an explicit fileinto "INBOX" (#1027)', () => {
|
|
// A bare `keep;` lets Stalwart's spam verdict decide the folder, so a
|
|
// "Keep in inbox" allow-list rule must file into INBOX explicitly.
|
|
const script = generateScript([makeRule({ actions: [{ type: 'keep' }] })]);
|
|
expect(script).toContain('fileinto "INBOX";');
|
|
expect(script).not.toMatch(/^\s*keep;/m);
|
|
const requireLine = script.split('\n').find(l => l.startsWith('require'))!;
|
|
expect(requireLine).toContain('"fileinto"');
|
|
});
|
|
|
|
it('generates stop', () => {
|
|
const script = generateScript([makeRule({ actions: [{ type: 'stop' }] })]);
|
|
expect(script).toContain('stop;');
|
|
});
|
|
});
|
|
|
|
describe('all messages', () => {
|
|
const ALL = { field: 'all', comparator: 'any', value: '' } as const;
|
|
|
|
it('matches every message', () => {
|
|
const script = generateScript([makeRule({ conditions: [ALL], actions: [{ type: 'mark_read' }] })]);
|
|
expect(script).toContain('if true {\n addflag "\\\\Seen";\n}');
|
|
// Nothing to require for it.
|
|
expect(script).toMatch(/^require \["imap4flags"\];$/m);
|
|
});
|
|
|
|
it('still leaves spam out of a move to a folder', () => {
|
|
const script = generateScript(
|
|
[makeRule({ conditions: [ALL], actions: [{ type: 'move', value: 'Archive' }] })],
|
|
undefined,
|
|
{ extensions: ['fileinto', 'spamtestplus', 'relational'] },
|
|
);
|
|
expect(script).toContain('if allof(true, not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50") {');
|
|
});
|
|
|
|
it('stands next to other conditions', () => {
|
|
const subject = { field: 'subject', comparator: 'contains', value: 'Rechnung' } as const;
|
|
expect(generateScript([makeRule({ conditions: [ALL, subject] })]))
|
|
.toContain('if allof(true, header :contains "Subject" "Rechnung") {');
|
|
expect(generateScript([makeRule({ matchType: 'any', conditions: [subject, ALL] })]))
|
|
.toContain('if anyof(header :contains "Subject" "Rechnung", true) {');
|
|
// Any of them, and still no spam into a folder.
|
|
expect(generateScript(
|
|
[makeRule({ matchType: 'any', conditions: [subject, ALL] })],
|
|
undefined,
|
|
{ extensions: ['fileinto', 'spamtestplus', 'relational'] },
|
|
)).toContain('if allof(anyof(header :contains "Subject" "Rechnung", true), not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50") {');
|
|
});
|
|
|
|
it('reads back as written', () => {
|
|
const rules = [makeRule({ conditions: [ALL], actions: [{ type: 'mark_read' }] })];
|
|
expect(parseScript(generateScript(rules)).rules).toEqual(rules);
|
|
});
|
|
});
|
|
|
|
describe('stopProcessing', () => {
|
|
it('appends stop when stopProcessing is true', () => {
|
|
const script = generateScript([makeRule({ stopProcessing: true })]);
|
|
const ifBlock = script.slice(script.indexOf('if '));
|
|
expect(ifBlock).toContain('stop;');
|
|
});
|
|
|
|
it('does not duplicate stop if last action is stop', () => {
|
|
const script = generateScript([makeRule({
|
|
actions: [{ type: 'move', value: 'X' }, { type: 'stop' }],
|
|
stopProcessing: true,
|
|
})]);
|
|
const matches = script.match(/stop;/g);
|
|
expect(matches).toHaveLength(1);
|
|
});
|
|
|
|
// discard and reject only cancel the implicit keep (RFC 5228 4.4,
|
|
// RFC 5429): without a stop, the rules below still act on the message.
|
|
it('appends stop after discard', () => {
|
|
const script = generateScript([makeRule({
|
|
actions: [{ type: 'discard' }],
|
|
stopProcessing: true,
|
|
})]);
|
|
expect(script).toContain(' discard;\n stop;\n}');
|
|
expect(script.match(/stop;/g)).toHaveLength(1);
|
|
});
|
|
|
|
it('appends stop after reject', () => {
|
|
const script = generateScript([makeRule({
|
|
actions: [{ type: 'reject', value: 'No' }],
|
|
stopProcessing: true,
|
|
})]);
|
|
expect(script).toContain(' reject "No";\n stop;\n}');
|
|
expect(script.match(/stop;/g)).toHaveLength(1);
|
|
});
|
|
|
|
it('leaves a rule that does not stop processing open after discard', () => {
|
|
const script = generateScript([makeRule({
|
|
actions: [{ type: 'discard' }],
|
|
stopProcessing: false,
|
|
})]);
|
|
expect(script).not.toContain('stop;');
|
|
});
|
|
|
|
it('writes one stop when the stop action ends up last behind the flags', () => {
|
|
// Flags are written first, so the stop action placed before a flag in
|
|
// the list still comes last.
|
|
const script = generateScript([makeRule({
|
|
actions: [{ type: 'stop' }, { type: 'mark_read' }],
|
|
stopProcessing: true,
|
|
})]);
|
|
expect(script).toContain(' addflag "\\\\Seen";\n stop;\n}');
|
|
expect(script.match(/stop;/g)).toHaveLength(1);
|
|
});
|
|
|
|
it('adds no second stop behind a stop action earlier in the block', () => {
|
|
// The block runs straight through, so that stop ends the script.
|
|
const script = generateScript([makeRule({
|
|
actions: [{ type: 'stop' }, { type: 'discard' }],
|
|
stopProcessing: true,
|
|
})]);
|
|
expect(script).toContain(' stop;\n discard;\n}');
|
|
expect(script.match(/stop;/g)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('action order and targets', () => {
|
|
it('sets flags before the move so the moved message keeps them', () => {
|
|
const script = generateScript([makeRule({
|
|
actions: [{ type: 'move', value: 'Archive' }, { type: 'star' }, { type: 'mark_read' }],
|
|
})]);
|
|
const flagged = script.indexOf('addflag "\\\\Flagged";');
|
|
const seen = script.indexOf('addflag "\\\\Seen";');
|
|
const move = script.indexOf('fileinto "Archive";');
|
|
expect(flagged).toBeGreaterThan(-1);
|
|
expect(flagged).toBeLessThan(move);
|
|
expect(seen).toBeLessThan(move);
|
|
});
|
|
|
|
it('targets the folder id when the server supports mailboxid', () => {
|
|
const rules = [makeRule({ actions: [{ type: 'move', value: 'Work/Old', mailboxId: 'm42' }] })];
|
|
const script = generateScript(rules, undefined, { extensions: ['fileinto', 'mailbox', 'mailboxid'] });
|
|
expect(script).toContain('fileinto :mailboxid "m42" "Work/Old";');
|
|
expect(script).toContain('require ["fileinto", "mailbox", "mailboxid"];');
|
|
});
|
|
|
|
it('falls back to the path without mailboxid support', () => {
|
|
const rules = [makeRule({ actions: [{ type: 'copy', value: 'Work', mailboxId: 'm42' }] })];
|
|
const script = generateScript(rules, undefined, { extensions: ['fileinto', 'copy'] });
|
|
expect(script).toContain('fileinto :copy "Work";');
|
|
expect(script).not.toContain('mailboxid');
|
|
});
|
|
|
|
it('keeps a copy of forwarded mail when asked', () => {
|
|
const script = generateScript([makeRule({ actions: [{ type: 'forward', value: 'a@b.c', keepCopy: true }] })]);
|
|
expect(script).toContain('redirect :copy "a@b.c";');
|
|
expect(script).toContain('require ["copy"];');
|
|
});
|
|
|
|
it('parses the new action forms from external scripts', () => {
|
|
const parsed = parseScript(
|
|
'require ["fileinto", "copy", "mailbox", "mailboxid"];\n' +
|
|
'if header :contains "From" "x" { fileinto :copy :mailboxid "m1" "A"; redirect :copy "a@b.c"; }',
|
|
);
|
|
expect(parsed.rules[0].actions).toEqual([
|
|
{ type: 'copy', value: 'A', mailboxId: 'm1' },
|
|
{ type: 'forward', value: 'a@b.c', keepCopy: true },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('spam guard', () => {
|
|
const exts = ['fileinto', 'relational', 'spamtest', 'spamtestplus'];
|
|
const guard = 'not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50"';
|
|
|
|
it('keeps spam out of folder moves', () => {
|
|
const script = generateScript([makeRule()], undefined, { extensions: exts });
|
|
expect(script).toContain(`if allof(header :contains "From" "test@example.com", ${guard}) {`);
|
|
expect(script).toContain('require ["comparator-i;ascii-numeric", "fileinto", "relational", "spamtestplus"];');
|
|
});
|
|
|
|
it('extends an allof and wraps an anyof', () => {
|
|
const conditions = [
|
|
{ field: 'from' as const, comparator: 'contains' as const, value: 'a' },
|
|
{ field: 'subject' as const, comparator: 'contains' as const, value: 'b' },
|
|
];
|
|
const all = generateScript([makeRule({ conditions })], undefined, { extensions: exts });
|
|
expect(all).toContain(`if allof(header :contains "From" "a", header :contains "Subject" "b", ${guard}) {`);
|
|
const any = generateScript([makeRule({ conditions, matchType: 'any' })], undefined, { extensions: exts });
|
|
expect(any).toContain(`if allof(anyof(header :contains "From" "a", header :contains "Subject" "b"), ${guard}) {`);
|
|
});
|
|
|
|
it('leaves keep rules, opted-in rules and servers without spamtestplus alone', () => {
|
|
const keep = makeRule({ actions: [{ type: 'keep' }] });
|
|
const optIn = makeRule({ id: 'r2', name: 'Opt in', includeSpam: true });
|
|
expect(generateScript([keep, optIn], undefined, { extensions: exts })).not.toContain('spamtest');
|
|
expect(generateScript([makeRule()], undefined, { extensions: ['fileinto'] })).not.toContain('spamtest');
|
|
});
|
|
|
|
it('round-trips the opt-in', () => {
|
|
const parsed = parseScript(generateScript([makeRule({ includeSpam: true })], undefined, { extensions: exts }));
|
|
expect(parsed.rules[0].includeSpam).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('vacation include', () => {
|
|
it('includes the server vacation script before the rules', () => {
|
|
const script = generateScript([makeRule()], undefined, { includeVacation: true });
|
|
expect(script).toContain('require ["fileinto", "include"];');
|
|
const include = script.indexOf('include :personal :optional "vacation";');
|
|
expect(include).toBeGreaterThan(-1);
|
|
expect(include).toBeLessThan(script.indexOf('# Rule: Test Rule'));
|
|
});
|
|
|
|
it('round-trips the flag and drops "include" once it is turned off', () => {
|
|
const parsed = parseScript(generateScript([makeRule()], undefined, { includeVacation: true }));
|
|
expect(parsed.includeVacation).toBe(true);
|
|
expect(parsed.rules).toHaveLength(1);
|
|
expect(parsed.externalRequires).not.toContain('include');
|
|
|
|
const off = generateScript(parsed.rules, parsed.vacation, { externalRequires: parsed.externalRequires });
|
|
expect(off).not.toContain('include');
|
|
expect(parseScript(off).includeVacation).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('disabled rules', () => {
|
|
it('excludes disabled rules from Sieve code', () => {
|
|
const script = generateScript([makeRule({ enabled: false, name: 'Hidden' })]);
|
|
expect(script).not.toContain('# Rule: Hidden');
|
|
expect(script).not.toContain('if header');
|
|
});
|
|
|
|
it('preserves disabled rules in metadata', () => {
|
|
const rules = [makeRule({ enabled: false })];
|
|
const script = generateScript(rules);
|
|
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
|
|
const metadata = JSON.parse(match![1]);
|
|
expect(metadata.rules[0].enabled).toBe(false);
|
|
});
|
|
|
|
it('handles mixed enabled and disabled rules', () => {
|
|
const rules = [
|
|
makeRule({ id: '1', name: 'Active', enabled: true }),
|
|
makeRule({ id: '2', name: 'Inactive', enabled: false }),
|
|
makeRule({ id: '3', name: 'Also Active', enabled: true }),
|
|
];
|
|
const script = generateScript(rules);
|
|
expect(script).toContain('# Rule: Active');
|
|
expect(script).not.toContain('# Rule: Inactive');
|
|
expect(script).toContain('# Rule: Also Active');
|
|
});
|
|
});
|
|
|
|
describe('require extensions', () => {
|
|
it('includes fileinto for move', () => {
|
|
const script = generateScript([makeRule({ actions: [{ type: 'move', value: 'X' }] })]);
|
|
expect(script).toContain('"fileinto"');
|
|
});
|
|
|
|
it('includes fileinto and copy for copy action', () => {
|
|
const script = generateScript([makeRule({ actions: [{ type: 'copy', value: 'X' }] })]);
|
|
expect(script).toContain('"copy"');
|
|
expect(script).toContain('"fileinto"');
|
|
});
|
|
|
|
it('includes imap4flags for mark_read', () => {
|
|
const script = generateScript([makeRule({ actions: [{ type: 'mark_read' }] })]);
|
|
expect(script).toContain('"imap4flags"');
|
|
});
|
|
|
|
it('includes imap4flags for star', () => {
|
|
const script = generateScript([makeRule({ actions: [{ type: 'star' }] })]);
|
|
expect(script).toContain('"imap4flags"');
|
|
});
|
|
|
|
it('includes imap4flags for add_label', () => {
|
|
const script = generateScript([makeRule({ actions: [{ type: 'add_label', value: 'X' }] })]);
|
|
expect(script).toContain('"imap4flags"');
|
|
});
|
|
|
|
it('includes reject for reject action', () => {
|
|
const script = generateScript([makeRule({ actions: [{ type: 'reject', value: 'No' }] })]);
|
|
expect(script).toContain('"reject"');
|
|
});
|
|
|
|
it('includes body extension for body conditions', () => {
|
|
const script = generateScript([makeRule({
|
|
conditions: [{ field: 'body', comparator: 'contains', value: 'test' }],
|
|
})]);
|
|
expect(script).toContain('"body"');
|
|
});
|
|
|
|
it('deduplicates extensions', () => {
|
|
const script = generateScript([
|
|
makeRule({ id: '1', actions: [{ type: 'star' }] }),
|
|
makeRule({ id: '2', actions: [{ type: 'mark_read' }] }),
|
|
]);
|
|
const matches = script.match(/"imap4flags"/g);
|
|
expect(matches).toHaveLength(1);
|
|
});
|
|
|
|
it('only considers enabled rules for requires', () => {
|
|
const script = generateScript([
|
|
makeRule({ id: '1', enabled: false, actions: [{ type: 'reject', value: 'X' }] }),
|
|
makeRule({ id: '2', enabled: true, actions: [{ type: 'move', value: 'Y' }] }),
|
|
]);
|
|
const requireLine = script.split('\n').find(l => l.startsWith('require'));
|
|
expect(requireLine).not.toContain('reject');
|
|
expect(requireLine).toContain('fileinto');
|
|
});
|
|
});
|
|
|
|
describe('escaping', () => {
|
|
it('escapes double quotes in values', () => {
|
|
const script = generateScript([makeRule({
|
|
conditions: [{ field: 'subject', comparator: 'contains', value: 'say "hello"' }],
|
|
})]);
|
|
expect(script).toContain('say \\"hello\\"');
|
|
});
|
|
|
|
it('escapes backslashes in values', () => {
|
|
const script = generateScript([makeRule({
|
|
conditions: [{ field: 'subject', comparator: 'contains', value: 'path\\to\\file' }],
|
|
})]);
|
|
expect(script).toContain('path\\\\to\\\\file');
|
|
});
|
|
|
|
it('escapes folder names in actions', () => {
|
|
const script = generateScript([makeRule({
|
|
actions: [{ type: 'move', value: 'My "Folder"' }],
|
|
})]);
|
|
expect(script).toContain('fileinto "My \\"Folder\\"";');
|
|
});
|
|
});
|
|
|
|
describe('round-trip', () => {
|
|
it('preserves rules through generate → parse cycle', () => {
|
|
const rules: FilterRule[] = [
|
|
makeRule({ id: '1', name: 'Rule A', enabled: true }),
|
|
makeRule({ id: '2', name: 'Rule B', enabled: false }),
|
|
makeRule({
|
|
id: '3',
|
|
name: 'Complex',
|
|
matchType: 'any',
|
|
conditions: [
|
|
{ field: 'from', comparator: 'contains', value: 'boss' },
|
|
{ field: 'subject', comparator: 'starts_with', value: 'URGENT' },
|
|
],
|
|
actions: [{ type: 'star' }, { type: 'mark_read' }],
|
|
stopProcessing: true,
|
|
}),
|
|
];
|
|
const script = generateScript(rules);
|
|
const result = parseScript(script);
|
|
expect(result.isOpaque).toBe(false);
|
|
expect(result.rules).toEqual(rules);
|
|
});
|
|
});
|
|
|
|
it('generates multiple rules in order', () => {
|
|
const rules = [
|
|
makeRule({ id: '1', name: 'First', actions: [{ type: 'move', value: 'A' }] }),
|
|
makeRule({ id: '2', name: 'Second', actions: [{ type: 'move', value: 'B' }] }),
|
|
];
|
|
const script = generateScript(rules);
|
|
const firstIdx = script.indexOf('# Rule: First');
|
|
const secondIdx = script.indexOf('# Rule: Second');
|
|
expect(firstIdx).toBeLessThan(secondIdx);
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('skips rules with empty conditions', () => {
|
|
const script = generateScript([makeRule({ name: 'Empty', conditions: [], actions: [{ type: 'keep' }] })]);
|
|
expect(script).not.toContain('# Rule: Empty');
|
|
expect(script).not.toContain('if ');
|
|
});
|
|
|
|
it('skips rules with empty actions', () => {
|
|
const script = generateScript([makeRule({ name: 'NoAction', actions: [] })]);
|
|
expect(script).not.toContain('# Rule: NoAction');
|
|
expect(script).not.toContain('if ');
|
|
});
|
|
|
|
it('uses X-Unknown for header field without headerName', () => {
|
|
const script = generateScript([makeRule({
|
|
conditions: [{ field: 'header', comparator: 'contains', value: 'test' }],
|
|
})]);
|
|
expect(script).toContain('header :contains "X-Unknown" "test"');
|
|
});
|
|
|
|
it('handles all enabled rules with different extensions combined', () => {
|
|
const rules = [
|
|
makeRule({ id: '1', actions: [{ type: 'move', value: 'A' }] }),
|
|
makeRule({ id: '2', actions: [{ type: 'reject', value: 'No' }] }),
|
|
makeRule({ id: '3', conditions: [{ field: 'body', comparator: 'contains', value: 'x' }], actions: [{ type: 'star' }] }),
|
|
];
|
|
const script = generateScript(rules);
|
|
const requireLine = script.split('\n').find(l => l.startsWith('require'))!;
|
|
expect(requireLine).toContain('"fileinto"');
|
|
expect(requireLine).toContain('"reject"');
|
|
expect(requireLine).toContain('"body"');
|
|
expect(requireLine).toContain('"imap4flags"');
|
|
});
|
|
|
|
it('generates no require line when only discard/stop/forward actions', () => {
|
|
const script = generateScript([makeRule({
|
|
actions: [{ type: 'forward', value: 'a@b.com' }, { type: 'stop' }],
|
|
})]);
|
|
expect(script).not.toContain('require');
|
|
});
|
|
|
|
it('handles multiple actions on same rule', () => {
|
|
const script = generateScript([makeRule({
|
|
actions: [
|
|
{ type: 'move', value: 'Folder' },
|
|
{ type: 'mark_read' },
|
|
{ type: 'star' },
|
|
{ type: 'stop' },
|
|
],
|
|
})]);
|
|
expect(script).toContain('fileinto "Folder";');
|
|
expect(script).toContain('addflag "\\\\Seen";');
|
|
expect(script).toContain('addflag "\\\\Flagged";');
|
|
expect(script).toContain('stop;');
|
|
});
|
|
|
|
it('generates valid script for all-disabled rules', () => {
|
|
const rules = [
|
|
makeRule({ id: '1', enabled: false }),
|
|
makeRule({ id: '2', enabled: false }),
|
|
];
|
|
const script = generateScript(rules);
|
|
expect(script).toContain('@metadata:begin');
|
|
expect(script).not.toContain('if ');
|
|
expect(script).not.toContain('require');
|
|
});
|
|
});
|
|
});
|