588 lines
16 KiB
Dart
588 lines
16 KiB
Dart
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;
|
|
}
|
|
}
|