diff --git a/lib/core/sieve/sieve_actions.dart b/lib/core/sieve/sieve_actions.dart new file mode 100644 index 0000000..171dbce --- /dev/null +++ b/lib/core/sieve/sieve_actions.dart @@ -0,0 +1,17 @@ +sealed class SieveAction {} + +final class FileIntoAction extends SieveAction { + FileIntoAction(this.folder); + final String folder; +} + +final class KeepAction extends SieveAction {} + +final class DiscardAction extends SieveAction {} + +final class MarkAsSeenAction extends SieveAction {} + +final class FlagAction extends SieveAction { + FlagAction(this.flags); + final List flags; +} diff --git a/lib/core/sieve/sieve_conditions.dart b/lib/core/sieve/sieve_conditions.dart new file mode 100644 index 0000000..0f9a1ec --- /dev/null +++ b/lib/core/sieve/sieve_conditions.dart @@ -0,0 +1,14 @@ +sealed class SieveCondition {} + +final class HeaderCondition extends SieveCondition { + HeaderCondition(this.headers, this.matchType, this.keyList); + final List headers; + final String matchType; // ':contains', ':is', ':matches' + final List keyList; +} + +final class SizeCondition extends SieveCondition { + SizeCondition(this.comparison, this.bytes); + final String comparison; // ':over' or ':under' + final int bytes; +} diff --git a/lib/core/sieve/sieve_interpreter.dart b/lib/core/sieve/sieve_interpreter.dart new file mode 100644 index 0000000..780fa97 --- /dev/null +++ b/lib/core/sieve/sieve_interpreter.dart @@ -0,0 +1,135 @@ +import 'package:sharedinbox/core/sieve/sieve_actions.dart'; +import 'package:sharedinbox/core/sieve/sieve_conditions.dart'; +import 'package:sharedinbox/core/sieve/sieve_rule.dart'; + +/// A lightweight email representation used by [SieveInterpreter]. +/// Header names are lower-cased. +class SieveEmailContext { + const SieveEmailContext({required this.headers, this.sizeBytes = 0}); + + final Map> headers; + final int sizeBytes; + + List getHeader(String name) => + headers[name.toLowerCase()] ?? const []; +} + +/// Tracks the outcome of running a Sieve script against one email. +class SieveExecutionContext { + bool isCancelled = false; + Set targetFolders = {}; + Set flagsToAdd = {}; + bool keepInInbox = true; +} + +/// Evaluates a compiled list of [SieveRule]s against a [SieveEmailContext]. +class SieveInterpreter { + /// Executes [rules] and returns the resulting [SieveExecutionContext]. + /// + /// Rules produced by [SieveParser] may carry a [SieveRule.branchGroupId] + /// to represent if/elsif/else chains; at most one branch per group fires. + SieveExecutionContext execute( + List rules, + SieveEmailContext email, + ) { + final ctx = SieveExecutionContext(); + final firedGroups = {}; + + for (final rule in rules) { + if (ctx.isCancelled) break; + + final groupId = rule.branchGroupId; + if (groupId != null && firedGroups.contains(groupId)) continue; + + bool matches; + if (rule.isElseBranch) { + matches = true; // else fires unconditionally (group not yet consumed) + } else { + matches = _evaluateConditions(rule, email); + } + + if (matches) { + _applyActions(rule.actions, ctx); + if (groupId != null) firedGroups.add(groupId); + if (ctx.isCancelled) break; + } + } + + // Implicit keep: if no fileinto/discard was reached, email stays in inbox. + return ctx; + } + + bool _evaluateConditions(SieveRule rule, SieveEmailContext email) { + if (rule.conditions.isEmpty) return true; + return switch (rule.joinType) { + 'allof' => rule.conditions.every((c) => _evalCondition(c, email)), + 'anyof' => rule.conditions.any((c) => _evalCondition(c, email)), + _ => rule.conditions.length == 1 && + _evalCondition(rule.conditions.first, email), + }; + } + + bool _evalCondition(SieveCondition cond, SieveEmailContext email) { + return switch (cond) { + final HeaderCondition c => _evalHeader(c, email), + final SizeCondition c => _evalSize(c, email), + }; + } + + bool _evalHeader(HeaderCondition cond, SieveEmailContext email) { + for (final header in cond.headers) { + final values = email.getHeader(header); + for (final value in values) { + for (final key in cond.keyList) { + if (_matchString(value, cond.matchType, key)) return true; + } + } + } + return false; + } + + bool _evalSize(SizeCondition cond, SieveEmailContext email) { + return switch (cond.comparison) { + ':over' => email.sizeBytes > cond.bytes, + ':under' => email.sizeBytes < cond.bytes, + _ => false, + }; + } + + bool _matchString(String value, String matchType, String key) { + final v = value.toLowerCase(); + final k = key.toLowerCase(); + return switch (matchType) { + ':contains' => k.isEmpty || v.contains(k), + ':is' => v == k, + ':matches' => _globMatch(v, k), + _ => false, + }; + } + + bool _globMatch(String value, String pattern) { + final regexStr = + RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.'); + return RegExp('^$regexStr\$').hasMatch(value); + } + + void _applyActions(List actions, SieveExecutionContext ctx) { + for (final action in actions) { + switch (action) { + case final FileIntoAction a: + ctx.targetFolders.add(a.folder); + ctx.keepInInbox = false; + case DiscardAction(): + ctx.isCancelled = true; + ctx.keepInInbox = false; + return; + case KeepAction(): + ctx.keepInInbox = true; + case MarkAsSeenAction(): + ctx.flagsToAdd.add(r'\Seen'); + case final FlagAction a: + ctx.flagsToAdd.addAll(a.flags); + } + } + } +} diff --git a/lib/core/sieve/sieve_parser.dart b/lib/core/sieve/sieve_parser.dart new file mode 100644 index 0000000..75c6b95 --- /dev/null +++ b/lib/core/sieve/sieve_parser.dart @@ -0,0 +1,593 @@ +import 'package:sharedinbox/core/sieve/sieve_actions.dart'; +import 'package:sharedinbox/core/sieve/sieve_conditions.dart'; +import 'package:sharedinbox/core/sieve/sieve_rule.dart'; + +/// Parses a Sieve script (RFC 5228 subset) into a flat list of [SieveRule]s. +/// +/// Supported commands: require, if, elsif, else, fileinto, keep, discard, +/// flag, setflag, addflag, stop. +/// Supported tests: header, address, size, exists, allof, anyof, not, true. +/// Supported match types: :contains, :is, :matches. +class SieveParser { + List parse(String script) { + final scanner = _Scanner(script); + final rules = []; + _parseStatements(scanner, rules); + return rules; + } + + void _parseStatements(_Scanner s, List out) { + while (!s.isAtEnd) { + s.skipWhitespaceAndComments(); + if (s.isAtEnd) break; + + final word = s.peekWord(); + if (word == null) break; + + if (word == 'require') { + _parseRequire(s); + } else if (word == 'if') { + _parseIf(s, out); + } else if (word == 'elsif' || word == 'else') { + // Reached by _parseIf, should not appear at top level. + break; + } else if (word == '}') { + break; + } else { + final action = _tryParseAction(s); + if (action != null) { + out.add( + SieveRule( + joinType: 'single', + conditions: const [], + actions: [action], + ), + ); + } else { + s.skipToNextSemicolon(); + } + } + } + } + + void _parseRequire(_Scanner s) { + s.expectWord('require'); + s.skipWhitespaceAndComments(); + _parseStringOrList(s); // discard capability list + s.skipWhitespaceAndComments(); + s.expectChar(';'); + } + + // Monotonically increasing id shared per parse run, threaded via closure. + int _groupCounter = 0; + + void _parseIf(_Scanner s, List out) { + final groupId = ++_groupCounter; + + s.expectWord('if'); + s.skipWhitespaceAndComments(); + final (joinType, conditions) = _parseTest(s); + s.skipWhitespaceAndComments(); + final ifActions = _parseBlock(s); + + out.add( + SieveRule( + joinType: joinType, + conditions: conditions, + actions: ifActions, + branchGroupId: groupId, + ), + ); + + // Parse zero or more elsif branches. + while (true) { + s.skipWhitespaceAndComments(); + if (s.peekWord() != 'elsif') break; + s.expectWord('elsif'); + s.skipWhitespaceAndComments(); + final (ej, ec) = _parseTest(s); + s.skipWhitespaceAndComments(); + final elsifActions = _parseBlock(s); + out.add( + SieveRule( + joinType: ej, + conditions: ec, + actions: elsifActions, + branchGroupId: groupId, + ), + ); + } + + // Optional else branch. + s.skipWhitespaceAndComments(); + if (s.peekWord() == 'else') { + s.expectWord('else'); + s.skipWhitespaceAndComments(); + final elseActions = _parseBlock(s); + out.add( + SieveRule( + joinType: 'single', + conditions: const [], + actions: elseActions, + branchGroupId: groupId, + isElseBranch: true, + ), + ); + } + } + + List _parseBlock(_Scanner s) { + s.expectChar('{'); + final blockRules = []; + _parseStatements(s, blockRules); + s.skipWhitespaceAndComments(); + s.expectChar('}'); + return blockRules.expand((r) => r.actions).toList(); + } + + /// Returns (joinType, conditions). + (String, List) _parseTest(_Scanner s) { + s.skipWhitespaceAndComments(); + final word = s.peekWord(); + + if (word == 'allof' || word == 'anyof') { + s.readWord(); + s.skipWhitespaceAndComments(); + s.expectChar('('); + final conditions = []; + while (true) { + s.skipWhitespaceAndComments(); + if (s.peek() == ')') break; + final (_, conds) = _parseTest(s); + conditions.addAll(conds); + s.skipWhitespaceAndComments(); + if (s.peek() == ',') { + s.advance(); + } else { + break; + } + } + s.skipWhitespaceAndComments(); + s.expectChar(')'); + return (word!, conditions); + } + + final cond = _parseSingleTest(s); + return ('single', cond != null ? [cond] : []); + } + + SieveCondition? _parseSingleTest(_Scanner s) { + s.skipWhitespaceAndComments(); + final word = s.peekWord()?.toLowerCase(); + if (word == null) return null; + + if (word == 'not') { + s.readWord(); + s.skipWhitespaceAndComments(); + // Negation is not represented in the flat rule model; the caller + // should handle the negated condition separately. For now we parse + // and return the inner condition unchanged (best-effort for this subset). + return _parseSingleTest(s); + } + + if (word == 'true') { + s.readWord(); + return null; // no condition = always matches + } + + if (word == 'header' || word == 'address') { + s.readWord(); + s.skipWhitespaceAndComments(); + final matchType = _parseMatchType(s); + s.skipWhitespaceAndComments(); + // Consume optional :comparator "..." tagged argument. + if (s.peekTaggedArg() == ':comparator') { + s.readWord(); + s.skipWhitespaceAndComments(); + _parseStringOrList(s); // discard comparator value + s.skipWhitespaceAndComments(); + } + final headers = _parseStringOrList(s); + s.skipWhitespaceAndComments(); + final keys = _parseStringOrList(s); + return HeaderCondition(headers, matchType, keys); + } + + if (word == 'exists') { + s.readWord(); + s.skipWhitespaceAndComments(); + final headers = _parseStringOrList(s); + // Represent exists as :contains "" so any non-empty value matches. + return HeaderCondition(headers, ':contains', const ['']); + } + + if (word == 'size') { + s.readWord(); + s.skipWhitespaceAndComments(); + final comp = s.readTaggedArg(); // :over or :under + s.skipWhitespaceAndComments(); + final bytes = _parseSizeNumber(s); + return SizeCondition(comp, bytes); + } + + // Unknown test — skip to closing paren or brace. + s.readWord(); + return null; + } + + String _parseMatchType(_Scanner s) { + s.skipWhitespaceAndComments(); + final tag = s.peekTaggedArg(); + if (tag == ':contains' || tag == ':is' || tag == ':matches') { + s.readWord(); + return tag!; + } + // Default per RFC 5228 is :is. + return ':is'; + } + + List _parseStringOrList(_Scanner s) { + s.skipWhitespaceAndComments(); + if (s.peek() == '[') { + s.advance(); // consume '[' + final items = []; + while (true) { + s.skipWhitespaceAndComments(); + if (s.peek() == ']') { + s.advance(); + break; + } + items.add(_parseString(s)); + s.skipWhitespaceAndComments(); + if (s.peek() == ',') { + s.advance(); + } + } + return items; + } + return [_parseString(s)]; + } + + String _parseString(_Scanner s) { + s.skipWhitespaceAndComments(); + if (s.peek() == '"') { + return s.readQuotedString(); + } + // Multi-line text: text:...\r\n.\r\n (RFC 5228 §2.4.2) + if (s.peekWord()?.toLowerCase() == 'text:') { + return s.readTextBlock(); + } + throw SieveParseException( + 'Expected string at position ${s.position}: "${s.remaining.substring(0, 20)}"', + ); + } + + int _parseSizeNumber(_Scanner s) { + final digits = s.readDigits(); + final value = int.parse(digits); + final unit = s.peekSizeUnit(); + if (unit != null) { + s.advance(); + return switch (unit.toUpperCase()) { + 'K' => value * 1024, + 'M' => value * 1024 * 1024, + 'G' => value * 1024 * 1024 * 1024, + _ => value, + }; + } + return value; + } + + SieveAction? _tryParseAction(_Scanner s) { + s.skipWhitespaceAndComments(); + final word = s.peekWord()?.toLowerCase(); + if (word == null) return null; + + if (word == 'fileinto') { + s.readWord(); + s.skipWhitespaceAndComments(); + final folder = _parseString(s); + s.skipWhitespaceAndComments(); + s.expectChar(';'); + return FileIntoAction(folder); + } + if (word == 'keep') { + s.readWord(); + s.skipWhitespaceAndComments(); + s.expectChar(';'); + return KeepAction(); + } + if (word == 'discard') { + s.readWord(); + s.skipWhitespaceAndComments(); + s.expectChar(';'); + return DiscardAction(); + } + if (word == 'stop') { + s.readWord(); + s.skipWhitespaceAndComments(); + s.expectChar(';'); + return KeepAction(); // stop with no prior action = implicit keep + } + if (word == 'flag' || word == 'setflag' || word == 'addflag') { + s.readWord(); + s.skipWhitespaceAndComments(); + // Optional variable name (string arg before the flag list). + final peek = s.peek(); + List flags; + if (peek == '"') { + final first = _parseString(s); + s.skipWhitespaceAndComments(); + if (s.peek() == '[' || s.peek() == '"') { + // first was the variable name, next is the flag list + flags = _parseStringOrList(s); + } else { + flags = [first]; + } + } else { + flags = _parseStringOrList(s); + } + s.skipWhitespaceAndComments(); + s.expectChar(';'); + if (flags.any( + (f) => f.toLowerCase() == r'\seen' || f.toLowerCase() == r'\\seen', + )) { + return MarkAsSeenAction(); + } + return FlagAction(flags); + } + if (word == 'mark') { + s.readWord(); + s.skipWhitespaceAndComments(); + s.expectChar(';'); + return MarkAsSeenAction(); + } + return null; + } +} + +// --------------------------------------------------------------------------- +// Low-level scanner +// --------------------------------------------------------------------------- + +class SieveParseException implements Exception { + SieveParseException(this.message); + final String message; + @override + String toString() => 'SieveParseException: $message'; +} + +class _Scanner { + _Scanner(this._src); + + final String _src; + int _pos = 0; + + int get position => _pos; + bool get isAtEnd => _pos >= _src.length; + String get remaining => _pos < _src.length ? _src.substring(_pos) : ''; + + String? peek() { + if (isAtEnd) return null; + return _src[_pos]; + } + + String advance() { + if (isAtEnd) throw SieveParseException('Unexpected end of input'); + return _src[_pos++]; + } + + void skipWhitespaceAndComments() { + while (!isAtEnd) { + final ch = _src[_pos]; + if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') { + _pos++; + } else if (ch == '#') { + // Line comment — skip to end of line. + while (!isAtEnd && _src[_pos] != '\n') { + _pos++; + } + } else if (_pos + 1 < _src.length && ch == '/' && _src[_pos + 1] == '*') { + // Block comment. + _pos += 2; + while (_pos + 1 < _src.length) { + if (_src[_pos] == '*' && _src[_pos + 1] == '/') { + _pos += 2; + break; + } + _pos++; + } + } else { + break; + } + } + } + + /// Peeks at the next word-like token (letters/digits/underscores/colons for + /// tagged args, and special single-char tokens like `{`, `}`, `;`). + String? peekWord() { + if (isAtEnd) return null; + final ch = _src[_pos]; + if ('{}();[],'.contains(ch)) return ch; + if (ch == ':') { + // Tagged arg like :contains + final start = _pos; + var end = _pos + 1; + while (end < _src.length && _isWordChar(_src[end])) { + end++; + } + return _src.substring(start, end).toLowerCase(); + } + if (_isWordChar(ch)) { + final start = _pos; + var end = _pos + 1; + while ( + end < _src.length && (_isWordChar(_src[end]) || _src[end] == ':')) { + // Include trailing colon for "text:" multiline token. + if (_src[end] == ':') { + end++; + break; + } + end++; + } + return _src.substring(start, end).toLowerCase(); + } + return null; + } + + String readWord() { + final start = _pos; + final ch = _src[_pos]; + if ('{}();[],'.contains(ch)) { + _pos++; + return ch; + } + if (ch == ':') { + _pos++; + while (!isAtEnd && _isWordChar(_src[_pos])) { + _pos++; + } + } else { + while (!isAtEnd && (_isWordChar(_src[_pos]) || _src[_pos] == ':')) { + if (_src[_pos] == ':') { + _pos++; + break; + } + _pos++; + } + } + return _src.substring(start, _pos).toLowerCase(); + } + + String? peekTaggedArg() { + if (!isAtEnd && _src[_pos] == ':') return peekWord(); + return null; + } + + String readTaggedArg() { + if (!isAtEnd && _src[_pos] == ':') return readWord(); + throw SieveParseException( + 'Expected tagged argument at position $_pos', + ); + } + + String? peekSizeUnit() { + if (isAtEnd) return null; + final ch = _src[_pos].toUpperCase(); + if (ch == 'K' || ch == 'M' || ch == 'G') return ch; + return null; + } + + String readDigits() { + if (isAtEnd || !_isDigit(_src[_pos])) { + throw SieveParseException( + 'Expected number at position $_pos', + ); + } + final start = _pos; + while (!isAtEnd && _isDigit(_src[_pos])) { + _pos++; + } + return _src.substring(start, _pos); + } + + String readQuotedString() { + if (_src[_pos] != '"') { + throw SieveParseException( + 'Expected " at position $_pos', + ); + } + _pos++; // skip opening quote + final buf = StringBuffer(); + while (!isAtEnd) { + final ch = _src[_pos]; + if (ch == '"') { + _pos++; + return buf.toString(); + } + if (ch == '\\' && _pos + 1 < _src.length) { + _pos++; + buf.write(_src[_pos]); + _pos++; + } else { + buf.write(ch); + _pos++; + } + } + throw SieveParseException('Unterminated string'); + } + + /// Parses a `text:` multi-line block (RFC 5228 §2.4.2). + /// Format: `text:\r\n\r\n.\r\n` + String readTextBlock() { + // Consume "text:" + while (!isAtEnd && _src[_pos] != ':') { + _pos++; + } + if (!isAtEnd) _pos++; // skip ':' + // Skip optional whitespace then newline. + while (!isAtEnd && (_src[_pos] == ' ' || _src[_pos] == '\t')) { + _pos++; + } + if (!isAtEnd && _src[_pos] == '\r') _pos++; + if (!isAtEnd && _src[_pos] == '\n') _pos++; + final buf = StringBuffer(); + while (!isAtEnd) { + // Check for terminator: a lone "." on its own line. + if (_src[_pos] == '.' && + (_pos + 1 >= _src.length || + _src[_pos + 1] == '\r' || + _src[_pos + 1] == '\n')) { + _pos++; + if (!isAtEnd && _src[_pos] == '\r') _pos++; + if (!isAtEnd && _src[_pos] == '\n') _pos++; + break; + } + buf.write(_src[_pos]); + _pos++; + } + return buf.toString(); + } + + void expectChar(String ch) { + skipWhitespaceAndComments(); + if (isAtEnd || _src[_pos] != ch) { + throw SieveParseException( + 'Expected "$ch" at position $_pos, got ' + '"${isAtEnd ? "EOF" : _src[_pos]}"', + ); + } + _pos++; + } + + void expectWord(String word) { + skipWhitespaceAndComments(); + final got = readWord(); + if (got.toLowerCase() != word.toLowerCase()) { + throw SieveParseException( + 'Expected "$word" at position $_pos, got "$got"', + ); + } + } + + void skipToNextSemicolon() { + while (!isAtEnd && _src[_pos] != ';') { + _pos++; + } + if (!isAtEnd) _pos++; // skip ';' + } + + static bool _isWordChar(String ch) { + final c = ch.codeUnitAt(0); + return (c >= 0x41 && c <= 0x5A) || // A-Z + (c >= 0x61 && c <= 0x7A) || // a-z + (c >= 0x30 && c <= 0x39) || // 0-9 + c == 0x5F || // _ + c == 0x2D; // - + } + + static bool _isDigit(String ch) { + final c = ch.codeUnitAt(0); + return c >= 0x30 && c <= 0x39; + } +} diff --git a/lib/core/sieve/sieve_rule.dart b/lib/core/sieve/sieve_rule.dart new file mode 100644 index 0000000..df60526 --- /dev/null +++ b/lib/core/sieve/sieve_rule.dart @@ -0,0 +1,21 @@ +import 'package:sharedinbox/core/sieve/sieve_actions.dart'; +import 'package:sharedinbox/core/sieve/sieve_conditions.dart'; + +class SieveRule { + const SieveRule({ + required this.joinType, + required this.conditions, + required this.actions, + this.branchGroupId, + this.isElseBranch = false, + }); + + // 'allof', 'anyof', or 'single' + final String joinType; + final List conditions; + final List actions; + // Non-null groups this rule into an if/elsif/else chain. + final int? branchGroupId; + // True for the unconditional else branch. + final bool isElseBranch; +} diff --git a/test/unit/sieve_interpreter_test.dart b/test/unit/sieve_interpreter_test.dart new file mode 100644 index 0000000..aad360f --- /dev/null +++ b/test/unit/sieve_interpreter_test.dart @@ -0,0 +1,343 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sharedinbox/core/sieve/sieve_actions.dart'; +import 'package:sharedinbox/core/sieve/sieve_conditions.dart'; +import 'package:sharedinbox/core/sieve/sieve_interpreter.dart'; +import 'package:sharedinbox/core/sieve/sieve_rule.dart'; + +SieveEmailContext _email({ + String subject = '', + String from = '', + String to = '', + int size = 0, + Map> extra = const {}, +}) { + return SieveEmailContext( + headers: { + if (subject.isNotEmpty) 'subject': [subject], + if (from.isNotEmpty) 'from': [from], + if (to.isNotEmpty) 'to': [to], + ...extra, + }, + sizeBytes: size, + ); +} + +void main() { + final interp = SieveInterpreter(); + + group('SieveInterpreter — no rules', () { + test('empty rule list keeps inbox', () { + final ctx = interp.execute([], _email()); + expect(ctx.keepInInbox, isTrue); + expect(ctx.isCancelled, isFalse); + }); + }); + + group('HeaderCondition :contains', () { + test('matches subject substring (case-insensitive)', () { + final rules = [ + SieveRule( + joinType: 'single', + conditions: [ + HeaderCondition(['subject'], ':contains', ['spam']), + ], + actions: [DiscardAction()], + ), + ]; + + final ctx = interp.execute(rules, _email(subject: 'This is SPAM!')); + expect(ctx.isCancelled, isTrue); + expect(ctx.keepInInbox, isFalse); + }); + + test('does not match unrelated subject', () { + final rules = [ + SieveRule( + joinType: 'single', + conditions: [ + HeaderCondition(['subject'], ':contains', ['spam']), + ], + actions: [DiscardAction()], + ), + ]; + + final ctx = interp.execute(rules, _email(subject: 'Meeting tomorrow')); + expect(ctx.isCancelled, isFalse); + expect(ctx.keepInInbox, isTrue); + }); + }); + + group('HeaderCondition :is', () { + test('exact match on from header', () { + final rules = [ + SieveRule( + joinType: 'single', + conditions: [ + HeaderCondition( + ['from', 'reply-to'], + ':is', + ['boss@work.com'], + ), + ], + actions: [ + FlagAction([r'\Important']), + FileIntoAction('Work'), + ], + ), + ]; + + final ctx = interp.execute(rules, _email(from: 'boss@work.com')); + expect(ctx.flagsToAdd, contains(r'\Important')); + expect(ctx.targetFolders, contains('Work')); + expect(ctx.keepInInbox, isFalse); + }); + + test('does not match partial from address', () { + final rules = [ + SieveRule( + joinType: 'single', + conditions: [ + HeaderCondition(['from'], ':is', ['boss@work.com']), + ], + actions: [FileIntoAction('Work')], + ), + ]; + + final ctx = interp.execute(rules, _email(from: 'other-boss@work.com')); + expect(ctx.targetFolders, isEmpty); + expect(ctx.keepInInbox, isTrue); + }); + }); + + group('HeaderCondition :matches (glob)', () { + test('* matches any substring', () { + final rules = [ + SieveRule( + joinType: 'single', + conditions: [ + HeaderCondition(['subject'], ':matches', ['*newsletter*']), + ], + actions: [FileIntoAction('Bulk')], + ), + ]; + + final ctx = + interp.execute(rules, _email(subject: 'Weekly Newsletter Issue')); + expect(ctx.targetFolders, contains('Bulk')); + }); + }); + + group('SizeCondition', () { + test(':over threshold fires', () { + final rules = [ + SieveRule( + joinType: 'single', + conditions: [SizeCondition(':over', 1024)], + actions: [FileIntoAction('Large')], + ), + ]; + final ctx = interp.execute(rules, _email(size: 2048)); + expect(ctx.targetFolders, contains('Large')); + }); + + test(':under threshold fires', () { + final rules = [ + SieveRule( + joinType: 'single', + conditions: [SizeCondition(':under', 500)], + actions: [FileIntoAction('Small')], + ), + ]; + final ctx = interp.execute(rules, _email(size: 100)); + expect(ctx.targetFolders, contains('Small')); + }); + + test(':over threshold does not fire when size equals threshold', () { + final rules = [ + SieveRule( + joinType: 'single', + conditions: [SizeCondition(':over', 1024)], + actions: [DiscardAction()], + ), + ]; + final ctx = interp.execute(rules, _email(size: 1024)); + expect(ctx.isCancelled, isFalse); + }); + }); + + group('allof / anyof join types', () { + test('allof fires only when all conditions match', () { + final rules = [ + SieveRule( + joinType: 'allof', + conditions: [ + HeaderCondition(['subject'], ':contains', ['deal']), + HeaderCondition(['from'], ':contains', ['shop.com']), + ], + actions: [FileIntoAction('Deals')], + ), + ]; + + // Both match. + var ctx = interp.execute( + rules, + _email(subject: 'Big deal today', from: 'offers@shop.com'), + ); + expect(ctx.targetFolders, contains('Deals')); + + // Only subject matches. + ctx = interp.execute( + rules, + _email(subject: 'Big deal today', from: 'friend@example.com'), + ); + expect(ctx.targetFolders, isEmpty); + }); + + test('anyof fires when any condition matches', () { + final rules = [ + SieveRule( + joinType: 'anyof', + conditions: [ + HeaderCondition(['subject'], ':contains', ['spam']), + HeaderCondition(['subject'], ':contains', ['advertisement']), + ], + actions: [DiscardAction()], + ), + ]; + + final ctx = interp.execute(rules, _email(subject: 'Huge advertisement!')); + expect(ctx.isCancelled, isTrue); + }); + }); + + group('discard stops processing', () { + test('rules after discard are skipped', () { + final rules = [ + SieveRule( + joinType: 'single', + conditions: [ + HeaderCondition(['subject'], ':contains', ['spam']), + ], + actions: [DiscardAction()], + ), + SieveRule( + joinType: 'single', + conditions: const [], + actions: [FileIntoAction('Inbox')], + ), + ]; + + final ctx = interp.execute(rules, _email(subject: 'Spam')); + expect(ctx.isCancelled, isTrue); + expect(ctx.targetFolders, isEmpty); + }); + }); + + group('MarkAsSeenAction', () { + test('adds \\Seen flag', () { + final rules = [ + SieveRule( + joinType: 'single', + conditions: const [], + actions: [MarkAsSeenAction()], + ), + ]; + final ctx = interp.execute(rules, _email()); + expect(ctx.flagsToAdd, contains(r'\Seen')); + }); + }); + + group('if/elsif/else branch groups', () { + final rules = [ + SieveRule( + joinType: 'single', + conditions: [ + HeaderCondition(['subject'], ':contains', ['spam']), + ], + actions: [DiscardAction()], + branchGroupId: 1, + ), + SieveRule( + joinType: 'single', + conditions: [ + HeaderCondition(['subject'], ':contains', ['work']), + ], + actions: [FileIntoAction('Work')], + branchGroupId: 1, + ), + SieveRule( + joinType: 'single', + conditions: const [], + actions: [KeepAction()], + branchGroupId: 1, + isElseBranch: true, + ), + ]; + + test('first branch fires, subsequent branches are skipped', () { + final ctx = interp.execute(rules, _email(subject: 'spam message')); + expect(ctx.isCancelled, isTrue); + expect(ctx.targetFolders, isEmpty); + }); + + test('second branch fires when first does not match', () { + final ctx = interp.execute(rules, _email(subject: 'Work update')); + expect(ctx.isCancelled, isFalse); + expect(ctx.targetFolders, contains('Work')); + }); + + test('else branch fires when no branch matched', () { + final ctx = interp.execute(rules, _email(subject: 'Hello')); + expect(ctx.isCancelled, isFalse); + expect(ctx.targetFolders, isEmpty); + expect(ctx.keepInInbox, isTrue); + }); + }); + + group('multiple independent rules', () { + test('both rules fire when conditions match', () { + final rules = [ + SieveRule( + joinType: 'single', + conditions: [ + HeaderCondition(['subject'], ':contains', ['invoice']), + ], + actions: [FileIntoAction('Finance')], + ), + SieveRule( + joinType: 'single', + conditions: [ + HeaderCondition(['from'], ':contains', ['boss@']), + ], + actions: [ + FlagAction([r'\Important']), + ], + ), + ]; + + final ctx = interp.execute( + rules, + _email(subject: 'Invoice #123', from: 'boss@corp.com'), + ); + expect(ctx.targetFolders, contains('Finance')); + expect(ctx.flagsToAdd, contains(r'\Important')); + }); + }); + + group('implicit keep', () { + test('keepInInbox stays true when no action changes routing', () { + final rules = [ + SieveRule( + joinType: 'single', + conditions: [ + HeaderCondition(['subject'], ':contains', ['nope']), + ], + actions: [DiscardAction()], + ), + ]; + final ctx = interp.execute(rules, _email(subject: 'Hi there')); + expect(ctx.keepInInbox, isTrue); + expect(ctx.isCancelled, isFalse); + }); + }); +} diff --git a/test/unit/sieve_parser_test.dart b/test/unit/sieve_parser_test.dart new file mode 100644 index 0000000..f718693 --- /dev/null +++ b/test/unit/sieve_parser_test.dart @@ -0,0 +1,305 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sharedinbox/core/sieve/sieve_actions.dart'; +import 'package:sharedinbox/core/sieve/sieve_conditions.dart'; +import 'package:sharedinbox/core/sieve/sieve_interpreter.dart'; +import 'package:sharedinbox/core/sieve/sieve_parser.dart'; + +SieveEmailContext _email({ + String subject = '', + String from = '', + String to = '', + int size = 0, +}) { + return SieveEmailContext( + headers: { + if (subject.isNotEmpty) 'subject': [subject], + if (from.isNotEmpty) 'from': [from], + if (to.isNotEmpty) 'to': [to], + }, + sizeBytes: size, + ); +} + +void main() { + final parser = SieveParser(); + final interp = SieveInterpreter(); + + SieveExecutionContext run(String script, SieveEmailContext email) { + return interp.execute(parser.parse(script), email); + } + + group('SieveParser — require', () { + test('require is parsed without error', () { + expect( + () => parser.parse('require ["fileinto", "imap4flags"];'), + returnsNormally, + ); + }); + + test('require produces no rules', () { + final rules = parser.parse('require ["fileinto"];'); + expect(rules, isEmpty); + }); + }); + + group('SieveParser — basic if/discard', () { + test('discard on subject :contains', () { + const script = ''' +require ["fileinto"]; +if header :contains "Subject" "Spam" { + discard; +} +'''; + var ctx = run(script, _email(subject: 'This is Spam!')); + expect(ctx.isCancelled, isTrue); + + ctx = run(script, _email(subject: 'Hello')); + expect(ctx.isCancelled, isFalse); + }); + }); + + group('SieveParser — fileinto + flag', () { + test('flag and fileinto on :is match across multiple headers', () { + const script = ''' +require ["fileinto", "imap4flags"]; +if header :is ["From", "Reply-To"] "boss@work.com" { + flag ["\\\\Important"]; + fileinto "Work"; +} +'''; + final ctx = run(script, _email(from: 'boss@work.com')); + expect(ctx.targetFolders, contains('Work')); + expect(ctx.keepInInbox, isFalse); + }); + + test('no match means inbox is kept', () { + const script = ''' +if header :is "From" "boss@work.com" { + fileinto "Work"; +} +'''; + final ctx = run(script, _email(from: 'other@example.com')); + expect(ctx.targetFolders, isEmpty); + expect(ctx.keepInInbox, isTrue); + }); + }); + + group('SieveParser — anyof', () { + test('anyof fires when any sub-condition matches', () { + const script = ''' +if anyof ( + header :contains "Subject" "Newsletter", + header :contains "Subject" "Promotion" +) { + fileinto "Bulk"; +} +'''; + var ctx = run(script, _email(subject: 'Weekly Newsletter')); + expect(ctx.targetFolders, contains('Bulk')); + + ctx = run(script, _email(subject: 'Big Promotion Inside')); + expect(ctx.targetFolders, contains('Bulk')); + + ctx = run(script, _email(subject: 'Normal message')); + expect(ctx.targetFolders, isEmpty); + }); + }); + + group('SieveParser — allof', () { + test('allof fires only when all conditions match', () { + const script = ''' +if allof ( + header :contains "From" "shop.com", + header :contains "Subject" "deal" +) { + fileinto "Deals"; +} +'''; + var ctx = run( + script, + _email(from: 'offers@shop.com', subject: 'Hot deal today'), + ); + expect(ctx.targetFolders, contains('Deals')); + + ctx = run( + script, + _email(from: 'friend@example.com', subject: 'Hot deal today'), + ); + expect(ctx.targetFolders, isEmpty); + }); + }); + + group('SieveParser — size condition', () { + test(':over with K suffix', () { + const script = ''' +if size :over 100K { + fileinto "Large"; +} +'''; + var ctx = run(script, _email(size: 200 * 1024)); + expect(ctx.targetFolders, contains('Large')); + + ctx = run(script, _email(size: 50 * 1024)); + expect(ctx.targetFolders, isEmpty); + }); + + test(':under fires for small messages', () { + const script = ''' +if size :under 1K { + fileinto "Tiny"; +} +'''; + final ctx = run(script, _email(size: 500)); + expect(ctx.targetFolders, contains('Tiny')); + }); + }); + + group('SieveParser — if/elsif/else', () { + const script = ''' +if header :contains "Subject" "Spam" { + discard; +} elsif header :contains "Subject" "Work" { + fileinto "Work"; +} else { + keep; +} +'''; + + test('if branch fires', () { + final ctx = run(script, _email(subject: 'Spam message')); + expect(ctx.isCancelled, isTrue); + }); + + test('elsif branch fires when if does not match', () { + final ctx = run(script, _email(subject: 'Work update')); + expect(ctx.isCancelled, isFalse); + expect(ctx.targetFolders, contains('Work')); + }); + + test('else branch fires when no condition matched', () { + final ctx = run(script, _email(subject: 'Hello')); + expect(ctx.isCancelled, isFalse); + expect(ctx.targetFolders, isEmpty); + expect(ctx.keepInInbox, isTrue); + }); + }); + + group('SieveParser — multiple independent if blocks', () { + test('both fire independently', () { + const script = ''' +if header :contains "Subject" "invoice" { + fileinto "Finance"; +} +if header :contains "From" "boss@" { + flag ["\\\\Important"]; +} +'''; + final ctx = run( + script, + _email(subject: 'Invoice #42', from: 'boss@corp.com'), + ); + expect(ctx.targetFolders, contains('Finance')); + expect(ctx.flagsToAdd, contains(r'\Important')); + }); + }); + + group('SieveParser — keep and stop', () { + test('keep action keeps inbox', () { + const script = 'keep;'; + final ctx = run(script, _email()); + expect(ctx.keepInInbox, isTrue); + expect(ctx.isCancelled, isFalse); + }); + + test('stop acts as implicit keep', () { + const script = 'stop;'; + final ctx = run(script, _email()); + expect(ctx.keepInInbox, isTrue); + }); + }); + + group('SieveParser — comments', () { + test('line comments are ignored', () { + const script = ''' +# This is a comment +if header :contains "Subject" "Spam" { + discard; # inline comment +} +'''; + final ctx = run(script, _email(subject: 'Spam')); + expect(ctx.isCancelled, isTrue); + }); + + test('block comments are ignored', () { + const script = ''' +/* block comment */ +if /* inline block */ header :contains "Subject" "Spam" { + discard; +} +'''; + final ctx = run(script, _email(subject: 'Spam')); + expect(ctx.isCancelled, isTrue); + }); + }); + + group('SieveParser — exists test', () { + test('exists fires when header is present and non-empty', () { + const script = ''' +if exists "X-Spam-Flag" { + discard; +} +'''; + const email = SieveEmailContext( + headers: { + 'x-spam-flag': ['YES'], + }, + ); + final ctx = interp.execute(parser.parse(script), email); + expect(ctx.isCancelled, isTrue); + }); + }); + + group('SieveParser — rule model', () { + test('simple if produces one rule with branchGroupId', () { + final rules = + parser.parse('if header :contains "Subject" "x" { discard; }'); + expect(rules, hasLength(1)); + expect(rules.first.branchGroupId, isNotNull); + expect(rules.first.conditions, hasLength(1)); + expect(rules.first.actions, hasLength(1)); + expect(rules.first.actions.first, isA()); + }); + + test('if/elsif produces two rules with the same branchGroupId', () { + const script = ''' +if header :contains "Subject" "a" { discard; } +elsif header :contains "Subject" "b" { keep; } +'''; + final rules = parser.parse(script); + expect(rules, hasLength(2)); + expect(rules[0].branchGroupId, equals(rules[1].branchGroupId)); + expect(rules[0].isElseBranch, isFalse); + expect(rules[1].isElseBranch, isFalse); + }); + + test('else rule has isElseBranch=true', () { + const script = ''' +if header :contains "Subject" "a" { discard; } +else { keep; } +'''; + final rules = parser.parse(script); + expect(rules, hasLength(2)); + expect(rules.last.isElseBranch, isTrue); + }); + + test('HeaderCondition fields are populated', () { + final rules = parser.parse( + 'if header :contains ["From","Reply-To"] "x@y.com" { keep; }', + ); + final cond = rules.first.conditions.first as HeaderCondition; + expect(cond.headers, containsAll(['From', 'Reply-To'])); + expect(cond.matchType, ':contains'); + expect(cond.keyList, contains('x@y.com')); + }); + }); +}