diff --git a/lib/core/repositories/email_repository.dart b/lib/core/repositories/email_repository.dart index 46d2ade..2ce430f 100644 --- a/lib/core/repositories/email_repository.dart +++ b/lib/core/repositories/email_repository.dart @@ -104,6 +104,12 @@ abstract class EmailRepository { /// IMAP UID changed (e.g. after a server-applied move assigned a new UID). Future findEmailByMessageId(String accountId, String messageId); + /// Applies locally stored active Sieve rules to INBOX emails that have not + /// been processed yet. Records each processed email in LocalSieveApplied so + /// the same email is never filtered twice (across restarts or re-syncs). + /// Returns the number of emails where a rule matched and an action was taken. + Future applySieveRules(String accountId); + /// Emits the accountId whenever a new change is enqueued locally. /// Used by AccountSyncManager to trigger an immediate flush. Stream get onChangesQueued; diff --git a/lib/core/sync/account_sync_manager.dart b/lib/core/sync/account_sync_manager.dart index 770ba1d..75bfa49 100644 --- a/lib/core/sync/account_sync_manager.dart +++ b/lib/core/sync/account_sync_manager.dart @@ -360,6 +360,7 @@ class _AccountSync implements _SyncLoop { ), ); } + await _emails.applySieveRules(account.id); return _SyncStats( emailsFetched: emailResult.fetched, emailsSkipped: emailResult.skipped, @@ -613,6 +614,7 @@ class _JmapAccountSync implements _SyncLoop { ), ); } + await _emails.applySieveRules(account.id); return _SyncStats( emailsFetched: emailResult.fetched, emailsSkipped: emailResult.skipped, diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index 9bc0f4a..f323554 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -287,6 +287,21 @@ class UndoActions extends Table { Set get primaryKey => {id}; } +/// Records which emails have already had local Sieve rules applied. +/// Keyed by (accountId, messageId) so the same email is never processed twice, +/// even across restarts or re-syncs. +@DataClassName('LocalSieveAppliedRow') +class LocalSieveApplied extends Table { + TextColumn get accountId => + text().references(Accounts, #id, onDelete: KeyAction.cascade)(); + // RFC 2822 Message-ID header value — stable across folder moves. + TextColumn get messageId => text()(); + DateTimeColumn get appliedAt => dateTime()(); + + @override + Set get primaryKey => {accountId, messageId}; +} + // ── Database ────────────────────────────────────────────────────────────────── @DriftDatabase( @@ -305,6 +320,7 @@ class UndoActions extends Table { UndoActions, SearchHistoryEntries, LocalSieveScripts, + LocalSieveApplied, ShareKeys, ], ) @@ -312,7 +328,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 31; + int get schemaVersion => 32; Future _createEmailFts() async { await customStatement(''' @@ -550,6 +566,9 @@ class AppDatabase extends _$AppDatabase { if (from < 31) { await m.createTable(shareKeys); } + if (from < 32) { + await m.createTable(localSieveApplied); + } }, ); } diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 19c4690..25d4272 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -13,6 +13,9 @@ import 'package:sharedinbox/core/models/account.dart' as account_model; import 'package:sharedinbox/core/models/email.dart' as model; import 'package:sharedinbox/core/repositories/account_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart'; +import 'package:sharedinbox/core/sieve/sieve_interpreter.dart'; +import 'package:sharedinbox/core/sieve/sieve_parser.dart'; +import 'package:sharedinbox/core/sieve/sieve_rule.dart'; import 'package:sharedinbox/core/utils/cid_utils.dart'; import 'package:sharedinbox/core/utils/logger.dart'; import 'package:sharedinbox/data/db/database.dart'; @@ -1917,6 +1920,218 @@ class EmailRepositoryImpl implements EmailRepository { } } + /// Applies locally stored active Sieve rules to INBOX emails that have not + /// been processed yet. See [EmailRepository.applySieveRules] for details. + @override + Future applySieveRules(String accountId) async { + final scriptRow = await (_db.select(_db.localSieveScripts) + ..where( + (t) => t.accountId.equals(accountId) & t.isActive.equals(true), + ) + ..limit(1)) + .getSingleOrNull(); + if (scriptRow == null) return 0; + + List rules; + try { + rules = SieveParser().parse(scriptRow.content); + } catch (e) { + log('Sieve parse error for account $accountId: $e'); + return 0; + } + if (rules.isEmpty) return 0; + + final inboxMailbox = await (_db.select(_db.mailboxes) + ..where( + (t) => t.accountId.equals(accountId) & t.role.equals('inbox'), + ) + ..limit(1)) + .getSingleOrNull(); + final inboxPath = inboxMailbox?.path ?? 'INBOX'; + + final alreadyApplied = await (_db.select(_db.localSieveApplied) + ..where((t) => t.accountId.equals(accountId))) + .get(); + final appliedIds = alreadyApplied.map((r) => r.messageId).toSet(); + + final inboxEmails = await (_db.select(_db.emails) + ..where( + (t) => + t.accountId.equals(accountId) & + t.mailboxPath.equals(inboxPath) & + t.messageId.isNotNull(), + )) + .get(); + + final account = (await _accounts.getAccount(accountId))!; + final interpreter = SieveInterpreter(); + var matched = 0; + + for (final row in inboxEmails) { + final msgId = row.messageId!; + if (appliedIds.contains(msgId)) continue; + + final emailCtx = _buildSieveContext(row); + + SieveExecutionContext result; + try { + result = interpreter.execute(rules, emailCtx); + } catch (e) { + log('Sieve interpreter error for message $msgId: $e'); + await _markSieveApplied(accountId, msgId); + continue; + } + + await _markSieveApplied(accountId, msgId); + + if (result.isCancelled) { + await _enqueueSieveDelete(account, row); + matched++; + } else if (result.targetFolders.isNotEmpty) { + final dest = result.targetFolders.first; + await _enqueueSieveMove(account, row, dest); + matched++; + } else if (result.flagsToAdd.isNotEmpty) { + await _enqueueSieveFlagSeen(account, row); + matched++; + } + } + return matched; + } + + SieveEmailContext _buildSieveContext(Email row) { + String formatAddrs(String json) { + try { + final list = jsonDecode(json) as List; + return list.map((e) { + final m = e as Map; + final name = m['name'] as String? ?? ''; + final email = m['email'] as String? ?? ''; + return name.isEmpty ? email : '$name <$email>'; + }).join(', '); + } catch (_) { + return ''; + } + } + + return SieveEmailContext( + headers: { + if (row.subject != null && row.subject!.isNotEmpty) + 'subject': [row.subject!], + 'from': [formatAddrs(row.fromJson)], + 'to': [formatAddrs(row.toAddresses)], + 'cc': [formatAddrs(row.ccJson)], + if (row.messageId != null) 'message-id': [row.messageId!], + }, + ); + } + + Future _markSieveApplied(String accountId, String messageId) async { + await _db.into(_db.localSieveApplied).insertOnConflictUpdate( + LocalSieveAppliedCompanion.insert( + accountId: accountId, + messageId: messageId, + appliedAt: DateTime.now(), + ), + ); + } + + Future _enqueueSieveMove( + account_model.Account account, + Email row, + String folder, + ) async { + String destPath; + if (account.type == account_model.AccountType.jmap) { + final destMailbox = await (_db.select(_db.mailboxes) + ..where( + (t) => t.accountId.equals(account.id) & t.name.equals(folder), + ) + ..limit(1)) + .getSingleOrNull(); + if (destMailbox == null) { + log('Sieve: JMAP mailbox "$folder" not found for account ${account.id}'); + return; + } + destPath = destMailbox.path; + await _enqueueChange( + account.id, + row.id, + 'move', + jsonEncode({'src': row.mailboxPath, 'dest': destPath}), + ); + } else { + destPath = folder; + await _enqueueChange( + account.id, + row.id, + 'move', + jsonEncode({ + 'uid': row.uid, + 'mailboxPath': row.mailboxPath, + 'dest': destPath, + }), + ); + } + await (_db.update(_db.emails)..where((t) => t.id.equals(row.id))).write( + EmailsCompanion(mailboxPath: Value(destPath)), + ); + await _updateThread(account.id, row.mailboxPath, row.threadId ?? row.id); + await _updateThread(account.id, destPath, row.threadId ?? row.id); + } + + Future _enqueueSieveDelete( + account_model.Account account, + Email row, + ) async { + if (account.type == account_model.AccountType.jmap) { + await _enqueueChange( + account.id, + row.id, + 'delete', + jsonEncode({}), + ); + } else { + await _enqueueChange( + account.id, + row.id, + 'delete', + jsonEncode({'uid': row.uid, 'mailboxPath': row.mailboxPath}), + ); + } + await (_db.delete(_db.emails)..where((t) => t.id.equals(row.id))).go(); + await _updateThread(account.id, row.mailboxPath, row.threadId ?? row.id); + } + + Future _enqueueSieveFlagSeen( + account_model.Account account, + Email row, + ) async { + if (account.type == account_model.AccountType.jmap) { + await _enqueueChange( + account.id, + row.id, + 'flag_seen', + jsonEncode({'seen': true}), + ); + } else { + await _enqueueChange( + account.id, + row.id, + 'flag_seen', + jsonEncode({ + 'uid': row.uid, + 'mailboxPath': row.mailboxPath, + 'seen': true, + }), + ); + } + await (_db.update(_db.emails)..where((t) => t.id.equals(row.id))).write( + const EmailsCompanion(isSeen: Value(true)), + ); + await _updateThread(account.id, row.mailboxPath, row.threadId ?? row.id); + } + /// Drains pending changes for [accountId] via the appropriate protocol. /// Called at the start of each sync cycle. Returns count of applied changes. @override @@ -2032,7 +2247,18 @@ class EmailRepositoryImpl implements EmailRepository { .go(); applied++; } catch (e) { - await _recordChangeError(row, e); + if (_isImapNotFoundError(e)) { + // Email already gone on the server — treat as success so the + // pending change doesn't accumulate or block future changes. + await (_db.delete( + _db.pendingChanges, + )..where((t) => t.id.equals(row.id))) + .go(); + applied++; + log('IMAP change ${row.id} skipped: message already gone ($e)'); + } else { + await _recordChangeError(row, e); + } } } } finally { @@ -2041,6 +2267,11 @@ class EmailRepositoryImpl implements EmailRepository { return applied; } + bool _isImapNotFoundError(Object e) { + final s = e.toString().toLowerCase(); + return s.contains('nonexistent') || s.contains('not found'); + } + Future _applyPendingChangeImap( imap.ImapClient client, PendingChangeRow row, diff --git a/test/backend/account_sync_manager_test.dart b/test/backend/account_sync_manager_test.dart index aa969ab..70602ce 100644 --- a/test/backend/account_sync_manager_test.dart +++ b/test/backend/account_sync_manager_test.dart @@ -277,6 +277,9 @@ class _FakeEmails implements EmailRepository { @override Future clearForResync(String accountId) async {} + + @override + Future applySieveRules(String accountId) async => 0; } class _FakeLogs implements SyncLogRepository { diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index 15ac211..55371a5 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -128,6 +128,9 @@ class FakeEmailRepository implements EmailRepository { @override Future clearForResync(String accountId) async {} + + @override + Future applySieveRules(String accountId) async => 0; } class _Log { diff --git a/test/unit/account_sync_manager_test.mocks.dart b/test/unit/account_sync_manager_test.mocks.dart index db928fb..e0a5932 100644 --- a/test/unit/account_sync_manager_test.mocks.dart +++ b/test/unit/account_sync_manager_test.mocks.dart @@ -622,6 +622,15 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { returnValue: _i4.Future<_i2.Email?>.value(), ) as _i4.Future<_i2.Email?>); + @override + _i4.Future applySieveRules(String? accountId) => (super.noSuchMethod( + Invocation.method( + #applySieveRules, + [accountId], + ), + returnValue: _i4.Future.value(0), + ) as _i4.Future); + @override _i4.Stream watchJmapPush( String? accountId, diff --git a/test/unit/apply_sieve_rules_test.dart b/test/unit/apply_sieve_rules_test.dart new file mode 100644 index 0000000..e09bc9a --- /dev/null +++ b/test/unit/apply_sieve_rules_test.dart @@ -0,0 +1,303 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart' show Value; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sharedinbox/core/models/account.dart'; +import 'package:sharedinbox/data/db/database.dart' hide Account; +import 'package:sharedinbox/data/repositories/account_repository_impl.dart'; +import 'package:sharedinbox/data/repositories/email_repository_impl.dart'; + +import 'account_repository_impl_test.dart' show MapSecureStorage; +import 'db_test_helper.dart'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const _account = Account( + id: 'sieve-acc', + displayName: 'Sieve Test', + email: 'sieve@example.com', + imapHost: 'imap.example.com', + smtpHost: 'smtp.example.com', +); + +Future<(AppDatabase, EmailRepositoryImpl)> _makeSetup() async { + final db = openTestDatabase(); + final storage = MapSecureStorage(); + final accounts = AccountRepositoryImpl(db, storage); + await accounts.addAccount(_account, 'password'); + + final repo = EmailRepositoryImpl(db, accounts); + return (db, repo); +} + +/// Inserts a minimal email row in the INBOX. Returns the row id. +Future _insertInboxEmail( + AppDatabase db, { + required String id, + required String messageId, + String subject = 'Test', + String from = 'sender@example.com', + String mailboxPath = 'INBOX', +}) async { + await db.into(db.emails).insert( + EmailsCompanion.insert( + id: id, + accountId: _account.id, + mailboxPath: mailboxPath, + uid: int.parse(id.split(':').last), + subject: Value(subject), + receivedAt: DateTime.now(), + fromJson: Value( + jsonEncode([ + {'name': '', 'email': from}, + ]), + ), + messageId: Value(messageId), + ), + ); + // Insert a thread row so _updateThread does not throw. + await db.into(db.threads).insertOnConflictUpdate( + ThreadsCompanion.insert( + id: id, + accountId: _account.id, + mailboxPath: mailboxPath, + latestDate: DateTime.now(), + latestEmailId: id, + ), + ); + return id; +} + +/// Creates an active Sieve script for the test account. +Future _insertSieveScript(AppDatabase db, String content) async { + await db.into(db.localSieveScripts).insert( + LocalSieveScriptsCompanion.insert( + accountId: _account.id, + name: 'test-script', + content: Value(content), + isActive: const Value(true), + ), + ); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +void main() { + setUpAll(configureSqliteForTests); + + group('applySieveRules', () { + test('returns 0 when no active script exists', () async { + final (_, repo) = await _makeSetup(); + expect(await repo.applySieveRules(_account.id), 0); + }); + + test('returns 0 when script has no matching rules', () async { + final (db, repo) = await _makeSetup(); + await _insertSieveScript(db, ''' +require ["fileinto"]; +if header :contains "subject" ["NEVER_MATCHES_XYZ"] { + fileinto "Archive"; +} +'''); + await _insertInboxEmail( + db, + id: 'sieve-acc:1', + messageId: '', + subject: 'Hello world', + ); + expect(await repo.applySieveRules(_account.id), 0); + }); + + test('applies fileinto rule and queues a move pending change', () async { + final (db, repo) = await _makeSetup(); + await _insertSieveScript(db, ''' +require ["fileinto"]; +if header :contains "subject" ["SPAM"] { + fileinto "Archive"; +} +'''); + await _insertInboxEmail( + db, + id: 'sieve-acc:1', + messageId: '', + subject: 'THIS IS SPAM', + ); + + final count = await repo.applySieveRules(_account.id); + expect(count, 1); + + final pending = await db.select(db.pendingChanges).get(); + expect(pending, hasLength(1)); + expect(pending.first.changeType, 'move'); + final payload = jsonDecode(pending.first.payload) as Map; + expect(payload['dest'], 'Archive'); + }); + + test('applies discard rule and queues a delete pending change', () async { + final (db, repo) = await _makeSetup(); + await _insertSieveScript(db, ''' +if header :contains "from" ["spam@evil.com"] { + discard; +} +'''); + await _insertInboxEmail( + db, + id: 'sieve-acc:1', + messageId: '', + from: 'spam@evil.com', + ); + + final count = await repo.applySieveRules(_account.id); + expect(count, 1); + + final pending = await db.select(db.pendingChanges).get(); + expect(pending, hasLength(1)); + expect(pending.first.changeType, 'delete'); + }); + + test('records email in LocalSieveApplied after processing', () async { + final (db, repo) = await _makeSetup(); + await _insertSieveScript(db, ''' +require ["fileinto"]; +if header :contains "subject" ["SPAM"] { + fileinto "Archive"; +} +'''); + await _insertInboxEmail( + db, + id: 'sieve-acc:1', + messageId: '', + subject: 'SPAM email', + ); + + await repo.applySieveRules(_account.id); + + final applied = await db.select(db.localSieveApplied).get(); + expect(applied, hasLength(1)); + expect(applied.first.messageId, ''); + expect(applied.first.accountId, _account.id); + }); + + test('does not reprocess an email already in LocalSieveApplied', () async { + final (db, repo) = await _makeSetup(); + await _insertSieveScript(db, ''' +require ["fileinto"]; +if header :contains "subject" ["SPAM"] { + fileinto "Archive"; +} +'''); + await _insertInboxEmail( + db, + id: 'sieve-acc:1', + messageId: '', + subject: 'SPAM email', + ); + + // First run applies the rule. + expect(await repo.applySieveRules(_account.id), 1); + + // Restore the email to INBOX to simulate a re-sync (e.g. second device + // moved it back). The LocalSieveApplied record prevents reprocessing. + await (db.update(db.emails)..where((t) => t.id.equals('sieve-acc:1'))) + .write(const EmailsCompanion(mailboxPath: Value('INBOX'))); + + // Second run must not produce another pending change. + expect(await repo.applySieveRules(_account.id), 0); + final pending = await db.select(db.pendingChanges).get(); + // Only the original move from the first run; no duplicates. + expect(pending, hasLength(1)); + }); + + test('skips emails with no messageId', () async { + final (db, repo) = await _makeSetup(); + await _insertSieveScript(db, ''' +require ["fileinto"]; +if header :contains "subject" ["SPAM"] { + fileinto "Archive"; +} +'''); + // Insert without messageId. + await db.into(db.emails).insert( + EmailsCompanion.insert( + id: 'sieve-acc:2', + accountId: _account.id, + mailboxPath: 'INBOX', + uid: 2, + subject: const Value('SPAM without id'), + receivedAt: DateTime.now(), + ), + ); + await db.into(db.threads).insertOnConflictUpdate( + ThreadsCompanion.insert( + id: 'sieve-acc:2', + accountId: _account.id, + mailboxPath: 'INBOX', + latestDate: DateTime.now(), + latestEmailId: 'sieve-acc:2', + ), + ); + + expect(await repo.applySieveRules(_account.id), 0); + expect(await db.select(db.pendingChanges).get(), isEmpty); + }); + + test('emails not in INBOX are ignored', () async { + final (db, repo) = await _makeSetup(); + await _insertSieveScript(db, ''' +require ["fileinto"]; +if header :contains "subject" ["SPAM"] { + fileinto "Archive"; +} +'''); + await _insertInboxEmail( + db, + id: 'sieve-acc:1', + messageId: '', + subject: 'SPAM in Sent', + mailboxPath: 'Sent', + ); + + expect(await repo.applySieveRules(_account.id), 0); + expect(await db.select(db.pendingChanges).get(), isEmpty); + }); + + test('processes multiple emails independently', () async { + final (db, repo) = await _makeSetup(); + await _insertSieveScript(db, ''' +require ["fileinto"]; +if header :contains "subject" ["SPAM"] { + fileinto "Archive"; +} +'''); + await _insertInboxEmail( + db, + id: 'sieve-acc:1', + messageId: '', + subject: 'SPAM', + ); + await _insertInboxEmail( + db, + id: 'sieve-acc:2', + messageId: '', + subject: 'Hello', + ); + await _insertInboxEmail( + db, + id: 'sieve-acc:3', + messageId: '', + subject: 'More SPAM', + ); + + final count = await repo.applySieveRules(_account.id); + expect(count, 2); + + final applied = await db.select(db.localSieveApplied).get(); + // All three emails should be in LocalSieveApplied. + expect(applied, hasLength(3)); + + final pending = await db.select(db.pendingChanges).get(); + expect(pending, hasLength(2)); + }); + }); +} diff --git a/test/unit/migration_test.dart b/test/unit/migration_test.dart index 8446b40..5d174a5 100644 --- a/test/unit/migration_test.dart +++ b/test/unit/migration_test.dart @@ -14,7 +14,7 @@ void main() { group('Migration', () { test('schemaVersion matches expected value', () async { final db = AppDatabase(NativeDatabase.memory()); - expect(db.schemaVersion, 31); + expect(db.schemaVersion, 32); await db.close(); }); @@ -191,6 +191,9 @@ void main() { await _tableColumns(db, 'sync_log_mailboxes'); expect(syncLogMailboxColumns, contains('duration_ms')); + // v32: local_sieve_applied table. + await db.customSelect('SELECT count(*) FROM local_sieve_applied').get(); + await db.close(); if (dbFile.existsSync()) dbFile.deleteSync(); }); @@ -382,7 +385,7 @@ void main() { if (dbFile.existsSync()) dbFile.deleteSync(); }); - test('fresh install creates all tables at schemaVersion 31', () async { + test('fresh install creates all tables at schemaVersion 32', () async { final db = AppDatabase(NativeDatabase.memory()); await db.select(db.accounts).get(); @@ -408,6 +411,7 @@ void main() { 'search_history_entries', 'local_sieve_scripts', // v29 'share_keys', // v31 + 'local_sieve_applied', // v32 ]), ); diff --git a/test/unit/reliability_runner_check_now_test.dart b/test/unit/reliability_runner_check_now_test.dart index 8e7dc19..f6e0a41 100644 --- a/test/unit/reliability_runner_check_now_test.dart +++ b/test/unit/reliability_runner_check_now_test.dart @@ -150,6 +150,8 @@ class _FakeEmails implements EmailRepository { Future wakeUpEmails(String accountId) async => 0; @override Future clearForResync(String accountId) async {} + @override + Future applySieveRules(String accountId) async => 0; } // --------------------------------------------------------------------------- diff --git a/test/unit/reliability_runner_test.dart b/test/unit/reliability_runner_test.dart index 9989387..f660872 100644 --- a/test/unit/reliability_runner_test.dart +++ b/test/unit/reliability_runner_test.dart @@ -159,6 +159,8 @@ class _CountingEmails implements EmailRepository { ReliabilityResult.healthy; @override Future clearForResync(String accountId) async {} + @override + Future applySieveRules(String accountId) async => 0; } class _FakeSyncLog implements SyncLogRepository { diff --git a/test/unit/undo_service_test.mocks.dart b/test/unit/undo_service_test.mocks.dart index b006fd3..cf3d41d 100644 --- a/test/unit/undo_service_test.mocks.dart +++ b/test/unit/undo_service_test.mocks.dart @@ -482,6 +482,15 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository { returnValue: _i4.Future<_i2.Email?>.value(), ) as _i4.Future<_i2.Email?>); + @override + _i4.Future applySieveRules(String? accountId) => (super.noSuchMethod( + Invocation.method( + #applySieveRules, + [accountId], + ), + returnValue: _i4.Future.value(0), + ) as _i4.Future); + @override _i4.Stream watchJmapPush( String? accountId, diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index ff70375..e29cd19 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -334,6 +334,9 @@ class FakeEmailRepository implements EmailRepository { @override Future clearForResync(String accountId) async {} + + @override + Future applySieveRules(String accountId) async => 0; } // ---------------------------------------------------------------------------