feat: sieve transpilation to intermediate rule-list with parser and interpreter (#117)

Implements a three-phase Sieve email filtering pipeline:
- Data models (SieveCondition, SieveAction, SieveRule) as sealed Dart classes
- SieveParser: converts RFC 5228 Sieve scripts to a flat SieveRule list,
  supporting if/elsif/else, allof/anyof, header/size/exists tests, and all
  common actions (fileinto, keep, discard, flag, mark)
- SieveInterpreter: evaluates compiled rules against a SieveEmailContext,
  tracking routing state in SieveExecutionContext with implicit keep behaviour
- 40 unit tests covering parser correctness and interpreter execution

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas SharedInbox
2026-05-16 22:55:46 +02:00
co-authored by Claude Sonnet 4.6
parent 7a3661dda4
commit 606958e675
7 changed files with 1428 additions and 0 deletions
+17
View File
@@ -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<String> flags;
}
+14
View File
@@ -0,0 +1,14 @@
sealed class SieveCondition {}
final class HeaderCondition extends SieveCondition {
HeaderCondition(this.headers, this.matchType, this.keyList);
final List<String> headers;
final String matchType; // ':contains', ':is', ':matches'
final List<String> keyList;
}
final class SizeCondition extends SieveCondition {
SizeCondition(this.comparison, this.bytes);
final String comparison; // ':over' or ':under'
final int bytes;
}
+135
View File
@@ -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<String, List<String>> headers;
final int sizeBytes;
List<String> getHeader(String name) =>
headers[name.toLowerCase()] ?? const [];
}
/// Tracks the outcome of running a Sieve script against one email.
class SieveExecutionContext {
bool isCancelled = false;
Set<String> targetFolders = {};
Set<String> 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<SieveRule> rules,
SieveEmailContext email,
) {
final ctx = SieveExecutionContext();
final firedGroups = <int>{};
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<SieveAction> 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);
}
}
}
}
+593
View File
@@ -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<SieveRule> parse(String script) {
final scanner = _Scanner(script);
final rules = <SieveRule>[];
_parseStatements(scanner, rules);
return rules;
}
void _parseStatements(_Scanner s, List<SieveRule> 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<SieveRule> 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<SieveAction> _parseBlock(_Scanner s) {
s.expectChar('{');
final blockRules = <SieveRule>[];
_parseStatements(s, blockRules);
s.skipWhitespaceAndComments();
s.expectChar('}');
return blockRules.expand((r) => r.actions).toList();
}
/// Returns (joinType, conditions).
(String, List<SieveCondition>) _parseTest(_Scanner s) {
s.skipWhitespaceAndComments();
final word = s.peekWord();
if (word == 'allof' || word == 'anyof') {
s.readWord();
s.skipWhitespaceAndComments();
s.expectChar('(');
final conditions = <SieveCondition>[];
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<String> _parseStringOrList(_Scanner s) {
s.skipWhitespaceAndComments();
if (s.peek() == '[') {
s.advance(); // consume '['
final items = <String>[];
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<String> 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<lines>\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;
}
}
+21
View File
@@ -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<SieveCondition> conditions;
final List<SieveAction> actions;
// Non-null groups this rule into an if/elsif/else chain.
final int? branchGroupId;
// True for the unconditional else branch.
final bool isElseBranch;
}
+343
View File
@@ -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<String, List<String>> 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);
});
});
}
+305
View File
@@ -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<DiscardAction>());
});
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'));
});
});
}