From 8ea8d71f421e894266b89e35ed0d5bc7e70e53e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Tue, 2 Jun 2026 17:10:16 +0200 Subject: [PATCH] fix: format, analyze-fix and update mocks --- ci/main.go | 2 +- lib/core/models/email.dart | 8 +- .../services/account_discovery_service.dart | 5 +- .../services/connection_test_service.dart | 43 +- .../services/managesieve_probe_service.dart | 47 +- lib/core/services/notification_service.dart | 3 +- .../services/share_encryption_service.dart | 23 +- lib/core/services/undo_service.dart | 3 +- lib/core/services/update_service.dart | 4 +- lib/core/sieve/sieve_interpreter.dart | 5 +- lib/core/sieve/sieve_parser.dart | 4 +- lib/core/sync/account_sync_manager.dart | 132 ++- lib/core/sync/background_sync.dart | 23 +- lib/core/sync/reliability_runner.dart | 7 +- lib/data/db/database.dart | 434 +++++----- lib/data/db/local_sieve_repository.dart | 39 +- lib/data/imap/imap_client_factory.dart | 16 +- lib/data/jmap/jmap_client.dart | 37 +- lib/data/jmap/sieve_repository.dart | 78 +- .../repositories/account_repository_impl.dart | 46 +- .../repositories/draft_repository_impl.dart | 63 +- .../repositories/email_repository_impl.dart | 779 +++++++++--------- .../repositories/mailbox_repository_impl.dart | 82 +- .../search_history_repository_impl.dart | 30 +- .../share_key_repository_impl.dart | 11 +- .../sync_log_repository_impl.dart | 17 +- .../repositories/undo_repository_impl.dart | 13 +- .../user_preferences_repository_impl.dart | 16 +- lib/di.dart | 62 +- lib/main.dart | 6 +- lib/ui/screens/about_screen.dart | 10 +- lib/ui/screens/account_receive_screen.dart | 30 +- lib/ui/screens/account_send_screen.dart | 14 +- lib/ui/screens/add_account_screen.dart | 29 +- lib/ui/screens/address_emails_screen.dart | 63 +- lib/ui/screens/compose_screen.dart | 19 +- lib/ui/screens/edit_account_screen.dart | 20 +- lib/ui/screens/email_detail_screen.dart | 47 +- lib/ui/screens/email_list_screen.dart | 88 +- lib/ui/screens/search_screen.dart | 12 +- lib/ui/screens/sieve_script_edit_screen.dart | 16 +- lib/ui/screens/sieve_scripts_screen.dart | 18 +- lib/ui/screens/sync_log_screen.dart | 65 +- lib/ui/screens/thread_detail_screen.dart | 14 +- lib/ui/screens/undo_log_screen.dart | 10 +- lib/ui/utils/about_markdown.dart | 5 +- lib/ui/widgets/email_tile.dart | 8 +- lib/ui/widgets/folder_drawer.dart | 8 +- lib/ui/widgets/secure_email_webview.dart | 24 +- test/backend/account_sync_manager_test.dart | 95 ++- test/backend/concurrent_sync_test.dart | 5 +- test/backend/email_repository_imap_test.dart | 10 +- test/backend/email_repository_jmap_test.dart | 48 +- .../backend/mailbox_repository_imap_test.dart | 3 +- test/backend/sync_reliability_test.dart | 4 +- test/unit/account_sync_manager_test.dart | 61 +- test/unit/apply_sieve_rules_test.dart | 20 +- test/unit/cid_utils_test.dart | 3 +- test/unit/connection_test_service_test.dart | 32 +- test/unit/email_model_test.dart | 4 +- .../email_repository_cancel_change_test.dart | 16 +- test/unit/email_repository_contract_test.dart | 4 +- test/unit/email_repository_impl_test.dart | 552 +++++-------- test/unit/fake_imap.dart | 16 +- test/unit/html_utils_test.dart | 3 +- test/unit/jmap_client_test.dart | 34 +- .../mailbox_repository_contract_test.dart | 4 +- test/unit/mailbox_repository_impl_test.dart | 121 ++- test/unit/managesieve_probe_service_test.dart | 84 +- test/unit/migration_test.dart | 15 +- .../reliability_runner_check_now_test.dart | 25 +- test/unit/reliability_runner_test.dart | 43 +- test/unit/sync_log_repository_impl_test.dart | 7 +- test/unit/undo_logic_test.dart | 76 +- test/widget/about_screen_test.dart | 3 +- test/widget/account_list_screen_test.dart | 3 +- test/widget/email_detail_screen_test.dart | 19 +- .../widget/email_list_screen_golden_test.dart | 64 +- test/widget/email_list_screen_test.dart | 3 +- test/widget/helpers.dart | 171 ++-- test/widget/secure_email_webview_test.dart | 15 +- test/widget/thread_detail_screen_test.dart | 33 +- test/widget/try_connection_button_test.dart | 12 +- test/widget/user_preferences_screen_test.dart | 27 +- 84 files changed, 1972 insertions(+), 2201 deletions(-) diff --git a/ci/main.go b/ci/main.go index 15ed11c..ed10fa9 100644 --- a/ci/main.go +++ b/ci/main.go @@ -181,7 +181,7 @@ func New( // Used as the base for pubGetLayer so flutter pub get is execution-cached between runs. func (m *Ci) toolchain() *dagger.Container { return dag.Container(). - From("ghcr.io/cirruslabs/flutter:3.41.6"). + From("ghcr.io/cirruslabs/flutter:3.44.0"). WithExec([]string{"apt-get", "-qq", "update"}). WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}). WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}). diff --git a/lib/core/models/email.dart b/lib/core/models/email.dart index d3787c4..c61e868 100644 --- a/lib/core/models/email.dart +++ b/lib/core/models/email.dart @@ -346,10 +346,10 @@ class SyncEmailsResult { ); SyncEmailsResult operator +(SyncEmailsResult other) => SyncEmailsResult( - fetched: fetched + other.fetched, - skipped: skipped + other.skipped, - bytesTransferred: bytesTransferred + other.bytesTransferred, - ); + fetched: fetched + other.fetched, + skipped: skipped + other.skipped, + bytesTransferred: bytesTransferred + other.bytesTransferred, + ); } class ReliabilityResult { diff --git a/lib/core/services/account_discovery_service.dart b/lib/core/services/account_discovery_service.dart index 72a5000..d032995 100644 --- a/lib/core/services/account_discovery_service.dart +++ b/lib/core/services/account_discovery_service.dart @@ -35,9 +35,8 @@ class AccountDiscoveryServiceImpl implements AccountDiscoveryService { try { final url = Uri.https(domain, '/.well-known/jmap'); final request = http.Request('GET', url)..followRedirects = false; - final streamed = await _client - .send(request) - .timeout(const Duration(seconds: 5)); + final streamed = + await _client.send(request).timeout(const Duration(seconds: 5)); String sessionUrl; if (streamed.statusCode >= 300 && streamed.statusCode < 400) { diff --git a/lib/core/services/connection_test_service.dart b/lib/core/services/connection_test_service.dart index 2d8be62..00a5e74 100644 --- a/lib/core/services/connection_test_service.dart +++ b/lib/core/services/connection_test_service.dart @@ -6,24 +6,30 @@ import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/data/imap/imap_client_factory.dart'; import 'package:sharedinbox/data/imap/managesieve_client.dart'; -typedef ImapConnectForTestFn = - Future Function(Account, String username, String password); +typedef ImapConnectForTestFn = Future Function( + Account, + String username, + String password, +); -typedef SmtpConnectForTestFn = - Future Function(Account, String username, String password); +typedef SmtpConnectForTestFn = Future Function( + Account, + String username, + String password, +); -typedef ManageSieveConnectForTestFn = - Future Function({ - required String host, - required int port, - required bool useTls, - }); +typedef ManageSieveConnectForTestFn = Future Function({ + required String host, + required int port, + required bool useTls, +}); Future _defaultManageSieveConnect({ required String host, required int port, required bool useTls, -}) => ManageSieveClient.connect(host: host, port: port, useTls: useTls); +}) => + ManageSieveClient.connect(host: host, port: port, useTls: useTls); abstract class ConnectionTestService { /// Verifies credentials and returns the effective username used. @@ -37,9 +43,9 @@ class ConnectionTestServiceImpl implements ConnectionTestService { ImapConnectForTestFn imapConnect = connectImap, SmtpConnectForTestFn smtpConnect = connectSmtp, ManageSieveConnectForTestFn manageSieveConnect = _defaultManageSieveConnect, - }) : _imapConnect = imapConnect, - _smtpConnect = smtpConnect, - _manageSieveConnect = manageSieveConnect; + }) : _imapConnect = imapConnect, + _smtpConnect = smtpConnect, + _manageSieveConnect = manageSieveConnect; final http.Client _httpClient; final ImapConnectForTestFn _imapConnect; @@ -156,9 +162,12 @@ class ConnectionTestServiceImpl implements ConnectionTestService { for (final username in candidates) { try { final credentials = base64.encode(utf8.encode('$username:$password')); - final resp = await _httpClient - .get(sessionUri, headers: {'Authorization': 'Basic $credentials'}) - .timeout(const Duration(seconds: 10)); + final resp = await _httpClient.get( + sessionUri, + headers: { + 'Authorization': 'Basic $credentials', + }, + ).timeout(const Duration(seconds: 10)); if (resp.statusCode == 401 || resp.statusCode == 403) { lastError = Exception( 'Authentication failed: wrong username or password', diff --git a/lib/core/services/managesieve_probe_service.dart b/lib/core/services/managesieve_probe_service.dart index 10e4d39..51f83e0 100644 --- a/lib/core/services/managesieve_probe_service.dart +++ b/lib/core/services/managesieve_probe_service.dart @@ -4,12 +4,11 @@ import 'package:sharedinbox/core/utils/logger.dart'; import 'package:sharedinbox/data/imap/managesieve_client.dart'; /// Returns true if the endpoint accepts a ManageSieve handshake. -typedef ManageSieveProbeFn = - Future Function({ - required String host, - required int port, - required bool useTls, - }); +typedef ManageSieveProbeFn = Future Function({ + required String host, + required int port, + required bool useTls, +}); Future _defaultManageSieveProbe({ required String host, @@ -66,22 +65,22 @@ class ManageSieveProbeService { } Account _withAvailability(Account a, bool available) => Account( - id: a.id, - displayName: a.displayName, - email: a.email, - username: a.username, - type: a.type, - imapHost: a.imapHost, - imapPort: a.imapPort, - imapSsl: a.imapSsl, - smtpHost: a.smtpHost, - smtpPort: a.smtpPort, - smtpSsl: a.smtpSsl, - manageSieveHost: a.manageSieveHost, - manageSievePort: a.manageSievePort, - manageSieveSsl: a.manageSieveSsl, - manageSieveAvailable: available, - jmapUrl: a.jmapUrl, - verbose: a.verbose, - ); + id: a.id, + displayName: a.displayName, + email: a.email, + username: a.username, + type: a.type, + imapHost: a.imapHost, + imapPort: a.imapPort, + imapSsl: a.imapSsl, + smtpHost: a.smtpHost, + smtpPort: a.smtpPort, + smtpSsl: a.smtpSsl, + manageSieveHost: a.manageSieveHost, + manageSievePort: a.manageSievePort, + manageSieveSsl: a.manageSieveSsl, + manageSieveAvailable: available, + jmapUrl: a.jmapUrl, + verbose: a.verbose, + ); } diff --git a/lib/core/services/notification_service.dart b/lib/core/services/notification_service.dart index 418f07d..cf26623 100644 --- a/lib/core/services/notification_service.dart +++ b/lib/core/services/notification_service.dart @@ -18,8 +18,7 @@ Future initNotifications() async { ); await _plugin .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >() + AndroidFlutterLocalNotificationsPlugin>() ?.requestNotificationsPermission(); _initialized = true; } on MissingPluginException { diff --git a/lib/core/services/share_encryption_service.dart b/lib/core/services/share_encryption_service.dart index a237803..23ca071 100644 --- a/lib/core/services/share_encryption_service.dart +++ b/lib/core/services/share_encryption_service.dart @@ -166,18 +166,17 @@ class ShareEncryptionService { final cipherBytes = Uint8List.fromList(box.cipherText); final macBytes = Uint8List.fromList(box.mac.bytes); - final out = - Uint8List( - _keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length + _macLen, - ) - ..setAll(0, recipientKeyId) - ..setAll(_keyIdLen, ephPubBytes) - ..setAll(_keyIdLen + _pubKeyLen, nonce) - ..setAll(_keyIdLen + _pubKeyLen + _nonceLen, cipherBytes) - ..setAll( - _keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length, - macBytes, - ); + final out = Uint8List( + _keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length + _macLen, + ) + ..setAll(0, recipientKeyId) + ..setAll(_keyIdLen, ephPubBytes) + ..setAll(_keyIdLen + _pubKeyLen, nonce) + ..setAll(_keyIdLen + _pubKeyLen + _nonceLen, cipherBytes) + ..setAll( + _keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length, + macBytes, + ); return '$_encAccountsPrefix${base64.encode(out)}'; } diff --git a/lib/core/services/undo_service.dart b/lib/core/services/undo_service.dart index 70d4a2a..ff43661 100644 --- a/lib/core/services/undo_service.dart +++ b/lib/core/services/undo_service.dart @@ -62,8 +62,7 @@ class UndoService extends Notifier> { for (final id in action.emailIds) { // 1. Try to cancel the original change (if not started yet). - final cancelled = - await repo.cancelPendingChange(id, 'delete') || + final cancelled = await repo.cancelPendingChange(id, 'delete') || await repo.cancelPendingChange(id, 'move') || await repo.cancelPendingChange(id, 'snooze'); diff --git a/lib/core/services/update_service.dart b/lib/core/services/update_service.dart index 133f7e2..0a2fb4b 100644 --- a/lib/core/services/update_service.dart +++ b/lib/core/services/update_service.dart @@ -21,8 +21,8 @@ final updateInfoProvider = FutureProvider((ref) async { final platformKey = Platform.isLinux ? 'linux' : Platform.isWindows - ? 'windows' - : null; + ? 'windows' + : null; if (platformKey == null || _kAppVersion.isEmpty) return null; try { diff --git a/lib/core/sieve/sieve_interpreter.dart b/lib/core/sieve/sieve_interpreter.dart index 505c818..d45680b 100644 --- a/lib/core/sieve/sieve_interpreter.dart +++ b/lib/core/sieve/sieve_interpreter.dart @@ -64,9 +64,8 @@ class SieveInterpreter { 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), + _ => rule.conditions.length == 1 && + _evalCondition(rule.conditions.first, email), }; } diff --git a/lib/core/sieve/sieve_parser.dart b/lib/core/sieve/sieve_parser.dart index fbdd54f..959419f 100644 --- a/lib/core/sieve/sieve_parser.dart +++ b/lib/core/sieve/sieve_parser.dart @@ -421,8 +421,8 @@ class _Scanner { if (_isWordChar(ch)) { final start = _pos; var end = _pos + 1; - while (end < _src.length && - (_isWordChar(_src[end]) || _src[end] == ':')) { + while ( + end < _src.length && (_isWordChar(_src[end]) || _src[end] == ':')) { // Include trailing colon for "text:" multiline token. if (_src[end] == ':') { end++; diff --git a/lib/core/sync/account_sync_manager.dart b/lib/core/sync/account_sync_manager.dart index 6c8014f..fba2b0f 100644 --- a/lib/core/sync/account_sync_manager.dart +++ b/lib/core/sync/account_sync_manager.dart @@ -29,10 +29,10 @@ class AccountSyncManager { SyncLogRepository syncLog = const NoOpSyncLogRepository(), DraftRepository? drafts, OnNewMailCallback? onNewMail, - }) : _imapConnect = imapConnect, - _syncLog = syncLog, - _drafts = drafts, - _onNewMail = onNewMail; + }) : _imapConnect = imapConnect, + _syncLog = syncLog, + _drafts = drafts, + _onNewMail = onNewMail; final AccountRepository _accounts; final MailboxRepository _mailboxes; @@ -69,26 +69,26 @@ class AccountSyncManager { final id = account.id; final loop = switch (account.type) { AccountType.imap => _AccountSync( - account, - _accounts, - _mailboxes, - _emails, - _imapConnect, - _syncLog, - _drafts, - _onNewMail, - onSyncStart: () => _emitSyncing(id, syncing: true), - onSyncEnd: () => _emitSyncing(id, syncing: false), - ), + account, + _accounts, + _mailboxes, + _emails, + _imapConnect, + _syncLog, + _drafts, + _onNewMail, + onSyncStart: () => _emitSyncing(id, syncing: true), + onSyncEnd: () => _emitSyncing(id, syncing: false), + ), AccountType.jmap => _JmapAccountSync( - account, - _mailboxes, - _emails, - _accounts, - _syncLog, - onSyncStart: () => _emitSyncing(id, syncing: true), - onSyncEnd: () => _emitSyncing(id, syncing: false), - ), + account, + _mailboxes, + _emails, + _accounts, + _syncLog, + onSyncStart: () => _emitSyncing(id, syncing: true), + onSyncEnd: () => _emitSyncing(id, syncing: false), + ), }; _active[account.id] = loop; loop.start(); @@ -129,33 +129,33 @@ class AccountSyncManager { final accounts = await _accounts.observeAccounts().first; final account = accounts.cast().firstWhere( - (a) => a?.id == accountId, - orElse: () => null, - ); + (a) => a?.id == accountId, + orElse: () => null, + ); if (account == null) return; final loop = switch (account.type) { AccountType.imap => _AccountSync( - account, - _accounts, - _mailboxes, - _emails, - _imapConnect, - _syncLog, - _drafts, - _onNewMail, - onSyncStart: () => _emitSyncing(accountId, syncing: true), - onSyncEnd: () => _emitSyncing(accountId, syncing: false), - ), + account, + _accounts, + _mailboxes, + _emails, + _imapConnect, + _syncLog, + _drafts, + _onNewMail, + onSyncStart: () => _emitSyncing(accountId, syncing: true), + onSyncEnd: () => _emitSyncing(accountId, syncing: false), + ), AccountType.jmap => _JmapAccountSync( - account, - _mailboxes, - _emails, - _accounts, - _syncLog, - onSyncStart: () => _emitSyncing(accountId, syncing: true), - onSyncEnd: () => _emitSyncing(accountId, syncing: false), - ), + account, + _mailboxes, + _emails, + _accounts, + _syncLog, + onSyncStart: () => _emitSyncing(accountId, syncing: true), + onSyncEnd: () => _emitSyncing(accountId, syncing: false), + ), }; _active[accountId] = loop; loop.start(); @@ -184,8 +184,8 @@ class _AccountSync implements _SyncLoop { this._onNewMail, { void Function()? onSyncStart, void Function()? onSyncEnd, - }) : _onSyncStart = onSyncStart, - _onSyncEnd = onSyncEnd; + }) : _onSyncStart = onSyncStart, + _onSyncEnd = onSyncEnd; final Account account; final AccountRepository _accounts; @@ -379,9 +379,8 @@ class _AccountSync implements _SyncLoop { if (!_running) return; _stopSignal = Completer(); final password = await _accounts.getPassword(account.id); - final username = account.username.isNotEmpty - ? account.username - : account.email; + final username = + account.username.isNotEmpty ? account.username : account.email; final client = await _imapConnect(account, username, password); _idleClient = client; try { @@ -397,13 +396,12 @@ class _AccountSync implements _SyncLoop { e is imap.ImapMessagesExistEvent || e is imap.ImapExpungeEvent, ) .listen((e) { - if (e is imap.ImapMessagesExistEvent && - e.newMessagesExists > e.oldMessagesExists) { - hasNewMail = true; - } - if (!newMessageCompleter.isCompleted) - newMessageCompleter.complete(); - }); + if (e is imap.ImapMessagesExistEvent && + e.newMessagesExists > e.oldMessagesExists) { + hasNewMail = true; + } + if (!newMessageCompleter.isCompleted) newMessageCompleter.complete(); + }); await client.idleStart(); @@ -445,8 +443,8 @@ class _JmapAccountSync implements _SyncLoop { this._syncLog, { void Function()? onSyncStart, void Function()? onSyncEnd, - }) : _onSyncStart = onSyncStart, - _onSyncEnd = onSyncEnd; + }) : _onSyncStart = onSyncStart, + _onSyncEnd = onSyncEnd; final Account account; final MailboxRepository _mailboxes; @@ -642,15 +640,13 @@ class _JmapAccountSync implements _SyncLoop { // Try JMAP push (RFC 8887 EventSource). Falls back to poll timer when // the server doesn't advertise an eventSourceUrl or the connection fails. final pushReady = Completer(); - final pushSub = _emails - .watchJmapPush(account.id, password) - .listen( - (_) { - if (!pushReady.isCompleted) pushReady.complete(); - }, - onDone: () {}, - onError: (_) {}, - ); + final pushSub = _emails.watchJmapPush(account.id, password).listen( + (_) { + if (!pushReady.isCompleted) pushReady.complete(); + }, + onDone: () {}, + onError: (_) {}, + ); final pollTimer = Timer(_pollInterval, () { if (_stopSignal != null && !_stopSignal!.isCompleted) { diff --git a/lib/core/sync/background_sync.dart b/lib/core/sync/background_sync.dart index eb45d7e..1189854 100644 --- a/lib/core/sync/background_sync.dart +++ b/lib/core/sync/background_sync.dart @@ -83,9 +83,8 @@ Future _checkAccount( ) async { try { final password = await accountRepo.getPassword(account.id); - final username = account.username.isNotEmpty - ? account.username - : account.email; + final username = + account.username.isNotEmpty ? account.username : account.email; final client = await connectImap(account, username, password); try { final status = await client.statusMailbox( @@ -94,18 +93,16 @@ Future _checkAccount( ); final currentUidNext = status.uidNext; - final stored = - await (db.select(db.syncStates)..where( - (t) => - t.accountId.equals(account.id) & - t.resourceType.equals(_kResourceType), - )) - .getSingleOrNull(); + final stored = await (db.select(db.syncStates) + ..where( + (t) => + t.accountId.equals(account.id) & + t.resourceType.equals(_kResourceType), + )) + .getSingleOrNull(); final lastUidNext = _parseUidNext(stored?.state); - await db - .into(db.syncStates) - .insertOnConflictUpdate( + await db.into(db.syncStates).insertOnConflictUpdate( SyncStatesCompanion.insert( accountId: account.id, resourceType: _kResourceType, diff --git a/lib/core/sync/reliability_runner.dart b/lib/core/sync/reliability_runner.dart index a505ffd..90d8014 100644 --- a/lib/core/sync/reliability_runner.dart +++ b/lib/core/sync/reliability_runner.dart @@ -76,14 +76,11 @@ class ReliabilityRunner { } } - final isHealthy = - totalMissingLocally == 0 && + final isHealthy = totalMissingLocally == 0 && totalMissingOnServer == 0 && totalFlagMismatches == 0; - await _db - .into(_db.syncHealth) - .insertOnConflictUpdate( + await _db.into(_db.syncHealth).insertOnConflictUpdate( SyncHealthCompanion.insert( accountId: accountId, lastVerifiedAt: DateTime.now(), diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index 41576de..01164d5 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -388,228 +388,231 @@ class AppDatabase extends _$AppDatabase { @override MigrationStrategy get migration => MigrationStrategy( - onCreate: (m) async { - await m.createAll(); - await _createEmailFts(); - }, - onUpgrade: (m, from, to) async { - // NOTE: m.createTable(T) creates the LATEST version of table T. - // If you later add a column C to T in version X, you must guard - // addColumn(T, T.C) with `if (from >= creationVersionOfT && from < X)`. - if (from < 2) { - await m.addColumn(accounts, accounts.accountType); - await m.addColumn(accounts, accounts.jmapUrl); - } - if (from < 3) { - await m.addColumn(accounts, accounts.username); - } - if (from < 4) { - await m.createTable(drafts); - } - if (from < 5) { - await m.createTable(syncStates); - } - if (from < 6) { - await m.createTable(pendingChanges); - } - if (from < 7) { - await m.createTable(syncLogs); - } - if (from < 8) { - await m.addColumn(mailboxes, mailboxes.role); - } - if (from < 9) { - await m.addColumn(emailBodies, emailBodies.cachedAt); - } - if (from >= 7 && from < 10) { - await m.addColumn(syncLogs, syncLogs.protocol); - await m.addColumn(syncLogs, syncLogs.mailboxesSynced); - await m.addColumn(syncLogs, syncLogs.pendingFlushed); - } - if (from >= 7 && from < 11) { - await m.addColumn(syncLogs, syncLogs.emailsSkipped); - await m.addColumn(syncLogs, syncLogs.bytesTransferred); - } - if (from < 12) { - await m.createTable(syncLogMailboxes); - } - if (from < 13) { - await m.addColumn(accounts, accounts.verbose); - if (from >= 7) { - await m.addColumn(syncLogs, syncLogs.protocolLog); - } - } - if (from < 14) { - await m.addColumn(emails, emails.threadId); - await m.addColumn(emails, emails.messageId); - await m.addColumn(emails, emails.inReplyTo); - await m.addColumn(emails, emails.references); - } - if (from < 15) { - await m.addColumn(accounts, accounts.manageSieveHost); - await m.addColumn(accounts, accounts.manageSievePort); - await m.addColumn(accounts, accounts.manageSieveSsl); - } - if (from < 16) { - await m.addColumn(accounts, accounts.manageSieveAvailable); - } - if (from < 17) { - await m.createTable(threads); - // Populate threads from existing emails. - final allRows = await select(emails).get(); - final groups = >{}; - for (final row in allRows) { - final key = - '${row.accountId}:${row.mailboxPath}:${row.threadId ?? row.id}'; - groups.putIfAbsent(key, () => []).add(row); - } + onCreate: (m) async { + await m.createAll(); + await _createEmailFts(); + }, + onUpgrade: (m, from, to) async { + // NOTE: m.createTable(T) creates the LATEST version of table T. + // If you later add a column C to T in version X, you must guard + // addColumn(T, T.C) with `if (from >= creationVersionOfT && from < X)`. + if (from < 2) { + await m.addColumn(accounts, accounts.accountType); + await m.addColumn(accounts, accounts.jmapUrl); + } + if (from < 3) { + await m.addColumn(accounts, accounts.username); + } + if (from < 4) { + await m.createTable(drafts); + } + if (from < 5) { + await m.createTable(syncStates); + } + if (from < 6) { + await m.createTable(pendingChanges); + } + if (from < 7) { + await m.createTable(syncLogs); + } + if (from < 8) { + await m.addColumn(mailboxes, mailboxes.role); + } + if (from < 9) { + await m.addColumn(emailBodies, emailBodies.cachedAt); + } + if (from >= 7 && from < 10) { + await m.addColumn(syncLogs, syncLogs.protocol); + await m.addColumn(syncLogs, syncLogs.mailboxesSynced); + await m.addColumn(syncLogs, syncLogs.pendingFlushed); + } + if (from >= 7 && from < 11) { + await m.addColumn(syncLogs, syncLogs.emailsSkipped); + await m.addColumn(syncLogs, syncLogs.bytesTransferred); + } + if (from < 12) { + await m.createTable(syncLogMailboxes); + } + if (from < 13) { + await m.addColumn(accounts, accounts.verbose); + if (from >= 7) { + await m.addColumn(syncLogs, syncLogs.protocolLog); + } + } + if (from < 14) { + await m.addColumn(emails, emails.threadId); + await m.addColumn(emails, emails.messageId); + await m.addColumn(emails, emails.inReplyTo); + await m.addColumn(emails, emails.references); + } + if (from < 15) { + await m.addColumn(accounts, accounts.manageSieveHost); + await m.addColumn(accounts, accounts.manageSievePort); + await m.addColumn(accounts, accounts.manageSieveSsl); + } + if (from < 16) { + await m.addColumn(accounts, accounts.manageSieveAvailable); + } + if (from < 17) { + await m.createTable(threads); + // Populate threads from existing emails. + final allRows = await select(emails).get(); + final groups = >{}; + for (final row in allRows) { + final key = + '${row.accountId}:${row.mailboxPath}:${row.threadId ?? row.id}'; + groups.putIfAbsent(key, () => []).add(row); + } - for (final threadEmails in groups.values) { - threadEmails.sort((a, b) { - final da = a.sentAt ?? a.receivedAt; - final db = b.sentAt ?? b.receivedAt; - return da.compareTo(db); - }); - final latest = threadEmails.last; + for (final threadEmails in groups.values) { + threadEmails.sort((a, b) { + final da = a.sentAt ?? a.receivedAt; + final db = b.sentAt ?? b.receivedAt; + return da.compareTo(db); + }); + final latest = threadEmails.last; - await into(threads).insert( - ThreadsCompanion.insert( - id: latest.threadId ?? latest.id, - accountId: latest.accountId, - mailboxPath: latest.mailboxPath, - subject: Value(latest.subject), - latestDate: latest.sentAt ?? latest.receivedAt, - messageCount: Value(threadEmails.length), - hasUnread: Value(threadEmails.any((e) => !e.isSeen)), - isFlagged: Value(threadEmails.any((e) => e.isFlagged)), - preview: Value(latest.preview), - latestEmailId: latest.id, - emailIdsJson: Value( - jsonEncode(threadEmails.map((e) => e.id).toList()), + await into(threads).insert( + ThreadsCompanion.insert( + id: latest.threadId ?? latest.id, + accountId: latest.accountId, + mailboxPath: latest.mailboxPath, + subject: Value(latest.subject), + latestDate: latest.sentAt ?? latest.receivedAt, + messageCount: Value(threadEmails.length), + hasUnread: Value(threadEmails.any((e) => !e.isSeen)), + isFlagged: Value(threadEmails.any((e) => e.isFlagged)), + preview: Value(latest.preview), + latestEmailId: latest.id, + emailIdsJson: Value( + jsonEncode(threadEmails.map((e) => e.id).toList()), + ), + participantsJson: Value( + latest.fromJson, + ), // Good enough for migration + ), + ); + } + } + if (from < 18) { + // Index for sorting email list by date. + await m.createIndex( + Index( + 'emails_received_at', + 'CREATE INDEX emails_received_at ON emails (account_id, mailbox_path, received_at DESC);', ), - participantsJson: Value( - latest.fromJson, - ), // Good enough for migration - ), - ); - } - } - if (from < 18) { - // Index for sorting email list by date. - await m.createIndex( - Index( - 'emails_received_at', - 'CREATE INDEX emails_received_at ON emails (account_id, mailbox_path, received_at DESC);', - ), - ); - // Index for finding emails in a thread. - await m.createIndex( - Index( - 'emails_thread_id', - 'CREATE INDEX emails_thread_id ON emails (account_id, mailbox_path, thread_id);', - ), - ); - // Index for pending changes queue. - await m.createIndex( - Index( - 'pending_changes_account_id', - 'CREATE INDEX pending_changes_account_id ON pending_changes (account_id);', - ), - ); - } - if (from < 19) { - await m.createTable(syncHealth); - } - if (from < 20) { - await m.addColumn(emailBodies, emailBodies.headersJson); - } - if (from < 21) { - await m.createTable(undoActions); - } - if (from < 22) { - final check = await customSelect('PRAGMA table_info(emails)').get(); - final names = check.map((row) => row.read('name')).toList(); + ); + // Index for finding emails in a thread. + await m.createIndex( + Index( + 'emails_thread_id', + 'CREATE INDEX emails_thread_id ON emails (account_id, mailbox_path, thread_id);', + ), + ); + // Index for pending changes queue. + await m.createIndex( + Index( + 'pending_changes_account_id', + 'CREATE INDEX pending_changes_account_id ON pending_changes (account_id);', + ), + ); + } + if (from < 19) { + await m.createTable(syncHealth); + } + if (from < 20) { + await m.addColumn(emailBodies, emailBodies.headersJson); + } + if (from < 21) { + await m.createTable(undoActions); + } + if (from < 22) { + final check = await customSelect('PRAGMA table_info(emails)').get(); + final names = check.map((row) => row.read('name')).toList(); - if (!names.contains('snoozed_until')) { - await m.addColumn(emails, emails.snoozedUntil); - } - if (!names.contains('snoozed_from_mailbox_path')) { - await m.addColumn(emails, emails.snoozedFromMailboxPath); - } + if (!names.contains('snoozed_until')) { + await m.addColumn(emails, emails.snoozedUntil); + } + if (!names.contains('snoozed_from_mailbox_path')) { + await m.addColumn(emails, emails.snoozedFromMailboxPath); + } - await m.createIndex( - Index( - 'emails_snoozed_until', - 'CREATE INDEX IF NOT EXISTS emails_snoozed_until ON emails (account_id, snoozed_until) WHERE snoozed_until IS NOT NULL;', - ), - ); - } - if (from < 23) { - await m.addColumn(emails, emails.listUnsubscribeHeader); - } - if (from >= 4 && from < 24) { - await m.addColumn(drafts, drafts.imapServerId); - } - if (from < 25) { - // For observeMailboxes: filter by account_id, sort by path. - await m.createIndex( - Index( - 'mailboxes_account_id', - 'CREATE INDEX IF NOT EXISTS mailboxes_account_id ON mailboxes (account_id, path);', - ), - ); - // For observeThreads: filter by account_id+mailbox_path, sort by latest_date. - await m.createIndex( - Index( - 'threads_latest_date', - 'CREATE INDEX IF NOT EXISTS threads_latest_date ON threads (account_id, mailbox_path, latest_date DESC);', - ), - ); - } - if (from < 26) { - await _createEmailFts(); - // Backfill FTS index from existing rows. - await customStatement(''' + await m.createIndex( + Index( + 'emails_snoozed_until', + 'CREATE INDEX IF NOT EXISTS emails_snoozed_until ON emails (account_id, snoozed_until) WHERE snoozed_until IS NOT NULL;', + ), + ); + } + if (from < 23) { + await m.addColumn(emails, emails.listUnsubscribeHeader); + } + if (from >= 4 && from < 24) { + await m.addColumn(drafts, drafts.imapServerId); + } + if (from < 25) { + // For observeMailboxes: filter by account_id, sort by path. + await m.createIndex( + Index( + 'mailboxes_account_id', + 'CREATE INDEX IF NOT EXISTS mailboxes_account_id ON mailboxes (account_id, path);', + ), + ); + // For observeThreads: filter by account_id+mailbox_path, sort by latest_date. + await m.createIndex( + Index( + 'threads_latest_date', + 'CREATE INDEX IF NOT EXISTS threads_latest_date ON threads (account_id, mailbox_path, latest_date DESC);', + ), + ); + } + if (from < 26) { + await _createEmailFts(); + // Backfill FTS index from existing rows. + await customStatement(''' INSERT INTO email_fts(rowid, subject, preview, from_json) SELECT rowid, subject, preview, from_json FROM emails '''); - } - if (from < 27) { - await m.createTable(searchHistoryEntries); - } - if (from < 28) { - await m.addColumn(emailBodies, emailBodies.mimeTreeJson); - } - if (from < 29) { - await m.createTable(localSieveScripts); - } - if (from >= 12 && from < 30) { - await m.addColumn(syncLogMailboxes, syncLogMailboxes.durationMs); - } - if (from < 31) { - await m.createTable(shareKeys); - } - if (from < 32) { - await m.createTable(localSieveApplied); - } - if (from >= 7 && from < 33) { - await m.addColumn(syncLogs, syncLogs.errorStackTrace); - await m.addColumn(syncLogs, syncLogs.isPermanent); - } - if (from < 34) { - await m.createTable(userPreferences); - } - if (from >= 34 && from < 35) { - await m.addColumn( - userPreferences, - userPreferences.mailViewButtonPosition, - ); - } - if (from >= 34 && from < 36) { - await m.addColumn(userPreferences, userPreferences.afterMailViewAction); - } - }, - ); + } + if (from < 27) { + await m.createTable(searchHistoryEntries); + } + if (from < 28) { + await m.addColumn(emailBodies, emailBodies.mimeTreeJson); + } + if (from < 29) { + await m.createTable(localSieveScripts); + } + if (from >= 12 && from < 30) { + await m.addColumn(syncLogMailboxes, syncLogMailboxes.durationMs); + } + if (from < 31) { + await m.createTable(shareKeys); + } + if (from < 32) { + await m.createTable(localSieveApplied); + } + if (from >= 7 && from < 33) { + await m.addColumn(syncLogs, syncLogs.errorStackTrace); + await m.addColumn(syncLogs, syncLogs.isPermanent); + } + if (from < 34) { + await m.createTable(userPreferences); + } + if (from >= 34 && from < 35) { + await m.addColumn( + userPreferences, + userPreferences.mailViewButtonPosition, + ); + } + if (from >= 34 && from < 36) { + await m.addColumn( + userPreferences, + userPreferences.afterMailViewAction, + ); + } + }, + ); } // Resolved once in main() via initDatabasePath() before runApp(). @@ -660,8 +663,7 @@ Future _resolveDatabasePath() async { } throw PlatformException( code: 'channel-error', - message: - 'path_provider unavailable after ${delays.length + 1} attempts — ' + message: 'path_provider unavailable after ${delays.length + 1} attempts — ' 'cannot open database.', ); } diff --git a/lib/data/db/local_sieve_repository.dart b/lib/data/db/local_sieve_repository.dart index 3a85355..a9d6f0f 100644 --- a/lib/data/db/local_sieve_repository.dart +++ b/lib/data/db/local_sieve_repository.dart @@ -11,7 +11,8 @@ class LocalSieveRepository { Future> listScripts(String accountId) async { final rows = await (_db.select( _db.localSieveScripts, - )..where((t) => t.accountId.equals(accountId))).get(); + )..where((t) => t.accountId.equals(accountId))) + .get(); return rows .map( (r) => SieveScript( @@ -26,11 +27,10 @@ class LocalSieveRepository { Future getScriptContent(String accountId, String blobId) async { final rowId = int.parse(blobId); - final row = - await (_db.select( - _db.localSieveScripts, - )..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId))) - .getSingleOrNull(); + final row = await (_db.select( + _db.localSieveScripts, + )..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId))) + .getSingleOrNull(); if (row == null) throw Exception('Local script not found: $blobId'); return row.content; } @@ -46,16 +46,16 @@ class LocalSieveRepository { await (_db.update(_db.localSieveScripts) ..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId))) .write( - LocalSieveScriptsCompanion( - name: Value(name), - content: Value(content), - ), - ); - final updated = - await (_db.select(_db.localSieveScripts)..where( - (t) => t.id.equals(rowId) & t.accountId.equals(accountId), - )) - .getSingleOrNull(); + LocalSieveScriptsCompanion( + name: Value(name), + content: Value(content), + ), + ); + final updated = await (_db.select(_db.localSieveScripts) + ..where( + (t) => t.id.equals(rowId) & t.accountId.equals(accountId), + )) + .getSingleOrNull(); return SieveScript( id: id, name: name, @@ -63,9 +63,7 @@ class LocalSieveRepository { isActive: updated?.isActive ?? false, ); } - final rowId = await _db - .into(_db.localSieveScripts) - .insert( + final rowId = await _db.into(_db.localSieveScripts).insert( LocalSieveScriptsCompanion.insert( accountId: accountId, name: name, @@ -80,7 +78,8 @@ class LocalSieveRepository { final rowId = int.parse(scriptId); await (_db.delete( _db.localSieveScripts, - )..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId))).go(); + )..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId))) + .go(); } Future activateScript(String accountId, String scriptId) async { diff --git a/lib/data/imap/imap_client_factory.dart b/lib/data/imap/imap_client_factory.dart index ceceeab..edc9e6f 100644 --- a/lib/data/imap/imap_client_factory.dart +++ b/lib/data/imap/imap_client_factory.dart @@ -6,12 +6,11 @@ import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/utils/host_utils.dart'; import 'package:sharedinbox/data/imap/tls_error.dart'; -typedef ImapConnectFn = - Future Function( - Account account, - String username, - String password, - ); +typedef ImapConnectFn = Future Function( + Account account, + String username, + String password, +); /// Zone value key signalling that a [StringBuffer] for protocol logging is /// active. When this key is non-null in the current zone, [connectImap] @@ -65,9 +64,8 @@ Future connectSmtp( // clientDomain is the sending domain advertised in EHLO — use the host part // of the sender email, falling back to the SMTP host. final atIndex = account.email.lastIndexOf('@'); - final clientDomain = atIndex != -1 - ? account.email.substring(atIndex + 1) - : account.smtpHost; + final clientDomain = + atIndex != -1 ? account.email.substring(atIndex + 1) : account.smtpHost; if (!account.smtpSsl && !isLocalhost(account.smtpHost)) { throw Exception( diff --git a/lib/data/jmap/jmap_client.dart b/lib/data/jmap/jmap_client.dart index 9fb60bc..47e90f6 100644 --- a/lib/data/jmap/jmap_client.dart +++ b/lib/data/jmap/jmap_client.dart @@ -26,14 +26,14 @@ class JmapClient { String? uploadUrl, String? downloadUrl, String? eventSourceUrl, - }) : _httpClient = httpClient, - _credentials = credentials, - _apiUrl = apiUrl, - _accountId = accountId, - _capabilities = capabilities, - _uploadUrl = uploadUrl, - _downloadUrl = downloadUrl, - _eventSourceUrl = eventSourceUrl; + }) : _httpClient = httpClient, + _credentials = credentials, + _apiUrl = apiUrl, + _accountId = accountId, + _capabilities = capabilities, + _uploadUrl = uploadUrl, + _downloadUrl = downloadUrl, + _eventSourceUrl = eventSourceUrl; final http.Client _httpClient; final String _credentials; @@ -67,9 +67,12 @@ class JmapClient { http.Response resp; var attempt = 0; while (true) { - resp = await httpClient - .get(jmapUrl, headers: {'Authorization': 'Basic $credentials'}) - .timeout(const Duration(seconds: 10)); + resp = await httpClient.get( + jmapUrl, + headers: { + 'Authorization': 'Basic $credentials', + }, + ).timeout(const Duration(seconds: 10)); if (resp.statusCode != 429 || attempt >= 4) { break; } @@ -215,9 +218,12 @@ class JmapClient { .replaceAll('{name}', Uri.encodeComponent(name)) .replaceAll('{type}', Uri.encodeComponent(type)), ); - final resp = await _httpClient - .get(url, headers: {'Authorization': 'Basic $_credentials'}) - .timeout(const Duration(seconds: 30)); + final resp = await _httpClient.get( + url, + headers: { + 'Authorization': 'Basic $_credentials', + }, + ).timeout(const Duration(seconds: 30)); if (resp.statusCode != 200) { throw JmapException('Blob download failed (HTTP ${resp.statusCode})'); } @@ -240,8 +246,7 @@ class JmapClient { static String _extractAccountId(Map session) { final primaryAccounts = session['primaryAccounts'] as Map?; - final id = - primaryAccounts?['urn:ietf:params:jmap:mail'] as String? ?? + final id = primaryAccounts?['urn:ietf:params:jmap:mail'] as String? ?? primaryAccounts?['urn:ietf:params:jmap:core'] as String?; if (id != null) return id; diff --git a/lib/data/jmap/sieve_repository.dart b/lib/data/jmap/sieve_repository.dart index f39d496..cc22a5b 100644 --- a/lib/data/jmap/sieve_repository.dart +++ b/lib/data/jmap/sieve_repository.dart @@ -9,18 +9,18 @@ import 'package:sharedinbox/core/repositories/account_repository.dart'; import 'package:sharedinbox/data/imap/managesieve_client.dart'; import 'package:sharedinbox/data/jmap/jmap_client.dart'; -typedef ManageSieveConnectFn = - Future Function({ - required String host, - required int port, - required bool useTls, - }); +typedef ManageSieveConnectFn = Future Function({ + required String host, + required int port, + required bool useTls, +}); Future _defaultManageSieveConnect({ required String host, required int port, required bool useTls, -}) => ManageSieveClient.connect(host: host, port: port, useTls: useTls); +}) => + ManageSieveClient.connect(host: host, port: port, useTls: useTls); class SieveRepository { SieveRepository( @@ -51,13 +51,16 @@ class SieveRepository { }); } return _withJmap(account, (jmap) async { - final responses = await jmap.call([ + final responses = await jmap.call( [ - 'SieveScript/get', - {'accountId': jmap.accountId, 'ids': null}, - '0', + [ + 'SieveScript/get', + {'accountId': jmap.accountId, 'ids': null}, + '0', + ], ], - ], withSieve: true); + withSieve: true, + ); final result = _responseArgs(responses, 0, 'SieveScript/get'); final list = result['list'] as List; return list.map((e) { @@ -123,9 +126,12 @@ class SieveRepository { id: {'name': name, 'blobId': blobId}, }, }; - final responses = await jmap.call([ - ['SieveScript/set', setArgs, '0'], - ], withSieve: true); + final responses = await jmap.call( + [ + ['SieveScript/set', setArgs, '0'], + ], + withSieve: true, + ); final result = _responseArgs(responses, 0, 'SieveScript/set'); if (id == null) { final created = result['created'] as Map?; @@ -164,16 +170,19 @@ class SieveRepository { return; } await _withJmap(account, (jmap) async { - final responses = await jmap.call([ + final responses = await jmap.call( [ - 'SieveScript/set', - { - 'accountId': jmap.accountId, - 'destroy': [scriptId], - }, - '0', + [ + 'SieveScript/set', + { + 'accountId': jmap.accountId, + 'destroy': [scriptId], + }, + '0', + ], ], - ], withSieve: true); + withSieve: true, + ); final result = _responseArgs(responses, 0, 'SieveScript/set'); final notDestroyed = result['notDestroyed'] as Map?; if (notDestroyed != null && notDestroyed.containsKey(scriptId)) { @@ -192,13 +201,16 @@ class SieveRepository { return; } await _withJmap(account, (jmap) async { - await jmap.call([ + await jmap.call( [ - 'SieveScript/activate', - {'accountId': jmap.accountId, 'id': scriptId}, - '0', + [ + 'SieveScript/activate', + {'accountId': jmap.accountId, 'id': scriptId}, + '0', + ], ], - ], withSieve: true); + withSieve: true, + ); }); } @@ -219,9 +231,8 @@ class SieveRepository { throw Exception('Account has no JMAP URL'); } final password = await _accounts.getPassword(account.id); - final username = account.username.isNotEmpty - ? account.username - : account.email; + final username = + account.username.isNotEmpty ? account.username : account.email; final jmap = await JmapClient.connect( httpClient: _httpClient, jmapUrl: Uri.parse(jmapUrl), @@ -247,9 +258,8 @@ class SieveRepository { throw Exception('Account has no ManageSieve host configured'); } final password = await _accounts.getPassword(account.id); - final username = account.username.isNotEmpty - ? account.username - : account.email; + final username = + account.username.isNotEmpty ? account.username : account.email; final client = await _manageSieveConnect( host: host, port: account.manageSievePort, diff --git a/lib/data/repositories/account_repository_impl.dart b/lib/data/repositories/account_repository_impl.dart index 2c3dc0c..a2b5423 100644 --- a/lib/data/repositories/account_repository_impl.dart +++ b/lib/data/repositories/account_repository_impl.dart @@ -23,15 +23,14 @@ class AccountRepositoryImpl implements AccountRepository { Future getAccount(String id) async { final row = await (_db.select( _db.accounts, - )..where((t) => t.id.equals(id))).getSingleOrNull(); + )..where((t) => t.id.equals(id))) + .getSingleOrNull(); return row == null ? null : _toModel(row); } @override Future addAccount(model.Account account, String password) async { - await _db - .into(_db.accounts) - .insertOnConflictUpdate( + await _db.into(_db.accounts).insertOnConflictUpdate( AccountsCompanion.insert( id: account.id, displayName: account.displayName, @@ -59,7 +58,8 @@ class AccountRepositoryImpl implements AccountRepository { Future updateAccount(model.Account account, {String? password}) async { await (_db.update( _db.accounts, - )..where((t) => t.id.equals(account.id))).write( + )..where((t) => t.id.equals(account.id))) + .write( AccountsCompanion( displayName: Value(account.displayName), email: Value(account.email), @@ -102,22 +102,22 @@ class AccountRepositoryImpl implements AccountRepository { String _passwordKey(String accountId) => 'account_password_$accountId'; model.Account _toModel(Account row) => model.Account( - id: row.id, - displayName: row.displayName, - email: row.email, - username: row.username, - type: model.AccountType.values.byName(row.accountType), - imapHost: row.imapHost, - imapPort: row.imapPort, - imapSsl: row.imapSsl, - smtpHost: row.smtpHost, - smtpPort: row.smtpPort, - smtpSsl: row.smtpSsl, - manageSieveHost: row.manageSieveHost, - manageSievePort: row.manageSievePort, - manageSieveSsl: row.manageSieveSsl, - manageSieveAvailable: row.manageSieveAvailable, - jmapUrl: row.jmapUrl, - verbose: row.verbose, - ); + id: row.id, + displayName: row.displayName, + email: row.email, + username: row.username, + type: model.AccountType.values.byName(row.accountType), + imapHost: row.imapHost, + imapPort: row.imapPort, + imapSsl: row.imapSsl, + smtpHost: row.smtpHost, + smtpPort: row.smtpPort, + smtpSsl: row.smtpSsl, + manageSieveHost: row.manageSieveHost, + manageSievePort: row.manageSievePort, + manageSieveSsl: row.manageSieveSsl, + manageSieveAvailable: row.manageSieveAvailable, + jmapUrl: row.jmapUrl, + verbose: row.verbose, + ); } diff --git a/lib/data/repositories/draft_repository_impl.dart b/lib/data/repositories/draft_repository_impl.dart index 78ff3fc..1f405d9 100644 --- a/lib/data/repositories/draft_repository_impl.dart +++ b/lib/data/repositories/draft_repository_impl.dart @@ -10,7 +10,7 @@ import 'package:sharedinbox/data/imap/imap_client_factory.dart'; class DraftRepositoryImpl implements DraftRepository { DraftRepositoryImpl(this._db, this._accounts, {ImapConnectFn? imapConnect}) - : _imapConnect = imapConnect; + : _imapConnect = imapConnect; final AppDatabase _db; final AccountRepository _accounts; @@ -51,9 +51,7 @@ class DraftRepositoryImpl implements DraftRepository { ); } - final newId = await _db - .into(_db.drafts) - .insert( + final newId = await _db.into(_db.drafts).insert( DraftsCompanion.insert( accountId: Value(accountId), replyToEmailId: Value(replyToEmailId), @@ -94,7 +92,8 @@ class DraftRepositoryImpl implements DraftRepository { Future getDraft(int id) async { final row = await (_db.select( _db.drafts, - )..where((t) => t.id.equals(id))).getSingleOrNull(); + )..where((t) => t.id.equals(id))) + .getSingleOrNull(); return row == null ? null : _toModel(row); } @@ -111,9 +110,8 @@ class DraftRepositoryImpl implements DraftRepository { final account = await _accounts.getAccount(accountId); if (account == null || account.type != AccountType.imap) return; - final username = account.username.isNotEmpty - ? account.username - : account.email; + final username = + account.username.isNotEmpty ? account.username : account.email; imap.ImapClient? client; try { client = await connect(account, username, password); @@ -134,11 +132,11 @@ class DraftRepositoryImpl implements DraftRepository { final messageCount = selectResult.messagesExists; // Upload local drafts that have no server counterpart. - final localDrafts = - await (_db.select(_db.drafts)..where( - (t) => t.accountId.equals(accountId) & t.imapServerId.isNull(), - )) - .get(); + final localDrafts = await (_db.select(_db.drafts) + ..where( + (t) => t.accountId.equals(accountId) & t.imapServerId.isNull(), + )) + .get(); for (final row in localDrafts) { final builder = imap.MessageBuilder() @@ -152,8 +150,8 @@ class DraftRepositoryImpl implements DraftRepository { targetMailboxPath: 'Drafts', flags: [r'\Draft'], ); - final uidList = appendResult.responseCodeAppendUid?.targetSequence - .toList(); + final uidList = + appendResult.responseCodeAppendUid?.targetSequence.toList(); final uid = (uidList != null && uidList.isNotEmpty) ? uidList.first.toString() : null; @@ -166,12 +164,11 @@ class DraftRepositoryImpl implements DraftRepository { // Download server drafts not tracked locally. if (messageCount > 0) { - final knownServerIds = - await (_db.select(_db.drafts)..where( - (t) => - t.accountId.equals(accountId) & t.imapServerId.isNotNull(), - )) - .get(); + final knownServerIds = await (_db.select(_db.drafts) + ..where( + (t) => t.accountId.equals(accountId) & t.imapServerId.isNotNull(), + )) + .get(); final knownIds = knownServerIds.map((r) => r.imapServerId!).toSet(); final seq = imap.MessageSequence.fromAll(); @@ -182,9 +179,7 @@ class DraftRepositoryImpl implements DraftRepository { if (msg.flags?.contains(r'\Deleted') ?? false) continue; final env = msg.envelope; final now = DateTime.now(); - await _db - .into(_db.drafts) - .insert( + await _db.into(_db.drafts).insert( DraftsCompanion.insert( accountId: Value(accountId), toText: Value(_addressListToText(env?.to)), @@ -210,14 +205,14 @@ class DraftRepositoryImpl implements DraftRepository { } SavedDraft _toModel(Draft row) => SavedDraft( - id: row.id, - accountId: row.accountId, - replyToEmailId: row.replyToEmailId, - toText: row.toText, - ccText: row.ccText, - subjectText: row.subjectText, - bodyText: row.bodyText, - updatedAt: row.updatedAt, - imapServerId: row.imapServerId, - ); + id: row.id, + accountId: row.accountId, + replyToEmailId: row.replyToEmailId, + toText: row.toText, + ccText: row.ccText, + subjectText: row.subjectText, + bodyText: row.bodyText, + updatedAt: row.updatedAt, + imapServerId: row.imapServerId, + ); } diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index d45d762..74463c2 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -22,12 +22,11 @@ import 'package:sharedinbox/data/db/database.dart'; import 'package:sharedinbox/data/imap/imap_client_factory.dart'; import 'package:sharedinbox/data/jmap/jmap_client.dart'; -typedef SmtpConnectFn = - Future Function( - account_model.Account account, - String username, - String password, - ); +typedef SmtpConnectFn = Future Function( + account_model.Account account, + String username, + String password, +); typedef GetCacheDirFn = Future Function(); class EmailRepositoryImpl implements EmailRepository { @@ -38,10 +37,10 @@ class EmailRepositoryImpl implements EmailRepository { SmtpConnectFn smtpConnect = connectSmtp, GetCacheDirFn getCacheDir = getTemporaryDirectory, http.Client? httpClient, - }) : _imapConnect = imapConnect, - _smtpConnect = smtpConnect, - _getCacheDir = getCacheDir, - _httpClient = httpClient ?? http.Client(); + }) : _imapConnect = imapConnect, + _smtpConnect = smtpConnect, + _getCacheDir = getCacheDir, + _httpClient = httpClient ?? http.Client(); final AppDatabase _db; final AccountRepository _accounts; @@ -132,27 +131,27 @@ class EmailRepositoryImpl implements EmailRepository { String mailboxPath, String threadId, ) async { - final threadEmails = - await (_db.select(_db.emails) - ..where( - (t) => - t.accountId.equals(accountId) & - t.mailboxPath.equals(mailboxPath) & - t.threadId.equals(threadId), - ) - ..orderBy([ - (t) => OrderingTerm.asc(t.sentAt), - (t) => OrderingTerm.asc(t.receivedAt), - ])) - .get(); - - if (threadEmails.isEmpty) { - await (_db.delete(_db.threads)..where( + final threadEmails = await (_db.select(_db.emails) + ..where( (t) => t.accountId.equals(accountId) & t.mailboxPath.equals(mailboxPath) & - t.id.equals(threadId), - )) + t.threadId.equals(threadId), + ) + ..orderBy([ + (t) => OrderingTerm.asc(t.sentAt), + (t) => OrderingTerm.asc(t.receivedAt), + ])) + .get(); + + if (threadEmails.isEmpty) { + await (_db.delete(_db.threads) + ..where( + (t) => + t.accountId.equals(accountId) & + t.mailboxPath.equals(mailboxPath) & + t.id.equals(threadId), + )) .go(); return; } @@ -173,9 +172,7 @@ class EmailRepositoryImpl implements EmailRepository { } } - await _db - .into(_db.threads) - .insertOnConflictUpdate( + await _db.into(_db.threads).insertOnConflictUpdate( ThreadsCompanion.insert( id: threadId, accountId: accountId, @@ -199,7 +196,8 @@ class EmailRepositoryImpl implements EmailRepository { Future getEmail(String emailId) async { final row = await (_db.select( _db.emails, - )..where((t) => t.id.equals(emailId))).getSingleOrNull(); + )..where((t) => t.id.equals(emailId))) + .getSingleOrNull(); return row == null ? null : _toModel(row); } @@ -211,7 +209,8 @@ class EmailRepositoryImpl implements EmailRepository { Future getEmailBody(String emailId) async { final cached = await (_db.select( _db.emailBodies, - )..where((t) => t.emailId.equals(emailId))).getSingleOrNull(); + )..where((t) => t.emailId.equals(emailId))) + .getSingleOrNull(); if (cached != null) { // Re-fetch if cachedAt is null (legacy row) or older than the TTL. final age = cached.cachedAt == null @@ -222,7 +221,8 @@ class EmailRepositoryImpl implements EmailRepository { final emailRow = await (_db.select( _db.emails, - )..where((t) => t.id.equals(emailId))).getSingle(); + )..where((t) => t.id.equals(emailId))) + .getSingle(); final account = (await _accounts.getAccount(emailRow.accountId))!; final password = await _accounts.getPassword(account.id); @@ -246,9 +246,8 @@ class EmailRepositoryImpl implements EmailRepository { } final textBody = msg.decodeTextPlainPart(); final rawHtml = msg.decodeTextHtmlPart(); - final htmlBody = rawHtml == null - ? null - : injectInlineImages(rawHtml, msg); + final htmlBody = + rawHtml == null ? null : injectInlineImages(rawHtml, msg); final contentInfos = msg.findContentInfo(); final attachmentsJson = jsonEncode( @@ -257,8 +256,7 @@ class EmailRepositoryImpl implements EmailRepository { (a) => { 'filename': a.fileName ?? '', 'contentType': a.contentType?.mediaType.text ?? '', - 'size': - a.size ?? + 'size': a.size ?? msg.getPart(a.fetchId)?.decodeContentBinary()?.length ?? 0, 'fetchPartId': a.fetchId, @@ -275,9 +273,7 @@ class EmailRepositoryImpl implements EmailRepository { final mimeTreeJson = _buildMimeTreeJson(msg); - await _db - .into(_db.emailBodies) - .insertOnConflictUpdate( + await _db.into(_db.emailBodies).insertOnConflictUpdate( EmailBodiesCompanion.insert( emailId: emailId, textBody: Value(textBody), @@ -361,9 +357,7 @@ class EmailRepositoryImpl implements EmailRepository { ? jsonEncode(_jmapBodyStructureToJson(rawBodyStructure)) : null; - await _db - .into(_db.emailBodies) - .insertOnConflictUpdate( + await _db.into(_db.emailBodies).insertOnConflictUpdate( EmailBodiesCompanion.insert( emailId: emailId, textBody: Value(textBody), @@ -415,8 +409,7 @@ class EmailRepositoryImpl implements EmailRepository { try { // Only request CONDSTORE if the server advertises it. Servers that don't // support the extension may reject SELECT with (CONDSTORE) with BAD. - final supportsCondStore = - client.serverInfo.supports('CONDSTORE') || + final supportsCondStore = client.serverInfo.supports('CONDSTORE') || client.serverInfo.supports('QRESYNC'); final selectedMailbox = await client.selectMailboxByPath( mailboxPath, @@ -431,19 +424,21 @@ class EmailRepositoryImpl implements EmailRepository { // First run or UID validity changed — full sync. if (checkpoint != null) { // UID validity changed: remove stale local emails for this mailbox. - await (_db.delete(_db.emails)..where( - (t) => - t.accountId.equals(account.id) & - t.mailboxPath.equals(mailboxPath), - )) + await (_db.delete(_db.emails) + ..where( + (t) => + t.accountId.equals(account.id) & + t.mailboxPath.equals(mailboxPath), + )) .go(); } // Use UID SEARCH ALL + UID FETCH so every message gets a reliable UID. // Regular FETCH 1:* may not populate msg.uid on all servers. - final allUids = - (await client.uidSearchMessages( + final allUids = (await client.uidSearchMessages( searchCriteria: 'ALL', - )).matchingSequence?.toList() ?? + )) + .matchingSequence + ?.toList() ?? []; var bytes = 0; if (allUids.isNotEmpty) { @@ -477,10 +472,11 @@ class EmailRepositoryImpl implements EmailRepository { // (including Stalwart 0.14.x) do not increment HIGHESTMODSEQ when new // mail is delivered via SMTP, causing newly arrived messages to be // silently missed when modseq values appear equal. - final newUids = - (await client.uidSearchMessages( + final newUids = (await client.uidSearchMessages( searchCriteria: 'UID ${lastUid + 1}:*', - )).matchingSequence?.toList() ?? + )) + .matchingSequence + ?.toList() ?? []; var bytes = 0; if (newUids.isNotEmpty) { @@ -500,15 +496,15 @@ class EmailRepositoryImpl implements EmailRepository { } // Detect remote deletions. - final serverUids = - (await client.uidSearchMessages( + final serverUids = (await client.uidSearchMessages( searchCriteria: 'ALL', - )).matchingSequence?.toList() ?? + )) + .matchingSequence + ?.toList() ?? []; await _reconcileDeletedImap(account.id, mailboxPath, serverUids); - final maxUid = serverUids.isEmpty - ? lastUid - : serverUids.reduce(math.max); + final maxUid = + serverUids.isEmpty ? lastUid : serverUids.reduce(math.max); await _saveImapCheckpoint( account.id, resourceType, @@ -604,8 +600,7 @@ class EmailRepositoryImpl implements EmailRepository { final inReplyTo = envelope.inReplyTo?.trim(); final refs = msg.getHeaderValue('References')?.trim(); final listUnsubscribe = msg.getHeaderValue('List-Unsubscribe')?.trim(); - final threadId = - _computeThreadId( + final threadId = _computeThreadId( emailId: emailId, messageId: msgId, inReplyTo: inReplyTo, @@ -628,9 +623,7 @@ class EmailRepositoryImpl implements EmailRepository { } } - await _db - .into(_db.emails) - .insertOnConflictUpdate( + await _db.into(_db.emails).insertOnConflictUpdate( EmailsCompanion.insert( id: emailId, accountId: account.id, @@ -668,14 +661,14 @@ class EmailRepositoryImpl implements EmailRepository { String accountId, String mailboxPath, ) async { - final rows = - await (_db.select(_db.pendingChanges)..where( - (t) => - t.accountId.equals(accountId) & - t.resourceType.equals('Email') & - (t.changeType.equals('delete') | t.changeType.equals('move')), - )) - .get(); + final rows = await (_db.select(_db.pendingChanges) + ..where( + (t) => + t.accountId.equals(accountId) & + t.resourceType.equals('Email') & + (t.changeType.equals('delete') | t.changeType.equals('move')), + )) + .get(); final result = {}; for (final r in rows) { try { @@ -719,13 +712,13 @@ class EmailRepositoryImpl implements EmailRepository { String mailboxPath, List serverUids, ) async { - final localRows = - await (_db.select(_db.emails)..where( - (t) => - t.accountId.equals(accountId) & - t.mailboxPath.equals(mailboxPath), - )) - .get(); + final localRows = await (_db.select(_db.emails) + ..where( + (t) => + t.accountId.equals(accountId) & + t.mailboxPath.equals(mailboxPath), + )) + .get(); // Guard: if the server returned no UIDs but we have local emails, the // server response is likely incomplete (network glitch, buggy IMAP server). @@ -781,20 +774,21 @@ class EmailRepositoryImpl implements EmailRepository { ); try { await client.selectMailboxByPath(mailboxPath); - final serverUids = - (await client.uidSearchMessages( + final serverUids = (await client.uidSearchMessages( searchCriteria: 'ALL', - )).matchingSequence?.toList() ?? + )) + .matchingSequence + ?.toList() ?? []; final serverUidSet = serverUids.toSet(); - final localRows = - await (_db.select(_db.emails)..where( - (t) => - t.accountId.equals(account.id) & - t.mailboxPath.equals(mailboxPath), - )) - .get(); + final localRows = await (_db.select(_db.emails) + ..where( + (t) => + t.accountId.equals(account.id) & + t.mailboxPath.equals(mailboxPath), + )) + .get(); final localUidSet = localRows.map((r) => r.uid).toSet(); final missingLocally = []; @@ -888,13 +882,13 @@ class EmailRepositoryImpl implements EmailRepository { } final serverIdSet = allServerIds.toSet(); - final localRows = - await (_db.select(_db.emails)..where( - (t) => - t.accountId.equals(account.id) & - t.mailboxPath.equals(mailboxJmapId), - )) - .get(); + final localRows = await (_db.select(_db.emails) + ..where( + (t) => + t.accountId.equals(account.id) & + t.mailboxPath.equals(mailboxJmapId), + )) + .get(); final localIdSet = localRows.map((r) => r.id.split(':').last).toSet(); final missingLocally = []; @@ -1193,9 +1187,7 @@ class EmailRepositoryImpl implements EmailRepository { final jmapListUnsubscribe = (m['header:List-Unsubscribe:asText'] as String?)?.trim(); - await _db - .into(_db.emails) - .insertOnConflictUpdate( + await _db.into(_db.emails).insertOnConflictUpdate( EmailsCompanion.insert( id: dbId, accountId: accountId, @@ -1223,9 +1215,7 @@ class EmailRepositoryImpl implements EmailRepository { // Cache body if the server included bodyValues in this response. if (m.containsKey('bodyValues')) { final (textBody, htmlBody, attachmentsJson) = _parseJmapBody(m); - await _db - .into(_db.emailBodies) - .insertOnConflictUpdate( + await _db.into(_db.emailBodies).insertOnConflictUpdate( EmailBodiesCompanion.insert( emailId: dbId, textBody: Value(textBody), @@ -1300,11 +1290,13 @@ class EmailRepositoryImpl implements EmailRepository { if (next >= _maxChangeAttempts) { await (_db.delete( _db.pendingChanges, - )..where((t) => t.id.equals(row.id))).go(); + )..where((t) => t.id.equals(row.id))) + .go(); } else { await (_db.update( _db.pendingChanges, - )..where((t) => t.id.equals(row.id))).write( + )..where((t) => t.id.equals(row.id))) + .write( PendingChangesCompanion( attempts: Value(next), lastError: Value(error.toString()), @@ -1316,13 +1308,13 @@ class EmailRepositoryImpl implements EmailRepository { // ── sync_state helpers ──────────────────────────────────────────────────── Future _loadSyncState(String accountId, String resourceType) async { - final row = - await (_db.select(_db.syncStates)..where( - (t) => - t.accountId.equals(accountId) & - t.resourceType.equals(resourceType), - )) - .getSingleOrNull(); + final row = await (_db.select(_db.syncStates) + ..where( + (t) => + t.accountId.equals(accountId) & + t.resourceType.equals(resourceType), + )) + .getSingleOrNull(); return row?.state; } @@ -1331,9 +1323,7 @@ class EmailRepositoryImpl implements EmailRepository { String resourceType, String state, ) async { - await _db - .into(_db.syncStates) - .insertOnConflictUpdate( + await _db.into(_db.syncStates).insertOnConflictUpdate( SyncStatesCompanion.insert( accountId: accountId, resourceType: resourceType, @@ -1413,27 +1403,27 @@ class EmailRepositoryImpl implements EmailRepository { .transform(utf8.decoder) .timeout(const Duration(minutes: 25)) .listen( - (chunk) { - buffer += chunk; - final lines = buffer.split('\n'); - buffer = lines.removeLast(); - for (final line in lines) { - if (!line.startsWith('data:')) continue; - final data = line.substring(5).trim(); - try { - final decoded = jsonDecode(data) as Map; - if (decoded['@type'] == 'StateChange') { - controller.add(null); - } - } catch (_) { - // Malformed JSON — ignore line - } + (chunk) { + buffer += chunk; + final lines = buffer.split('\n'); + buffer = lines.removeLast(); + for (final line in lines) { + if (!line.startsWith('data:')) continue; + final data = line.substring(5).trim(); + try { + final decoded = jsonDecode(data) as Map; + if (decoded['@type'] == 'StateChange') { + controller.add(null); } - }, - onDone: () => controller.close(), - onError: (_) => controller.close(), - cancelOnError: true, - ); + } catch (_) { + // Malformed JSON — ignore line + } + } + }, + onDone: () => controller.close(), + onError: (_) => controller.close(), + cancelOnError: true, + ); } catch (e) { log('JMAP push: unexpected error: $e'); await controller.close(); @@ -1483,7 +1473,8 @@ class EmailRepositoryImpl implements EmailRepository { Future setFlag(String emailId, {bool? seen, bool? flagged}) async { final row = await (_db.select( _db.emails, - )..where((t) => t.id.equals(emailId))).getSingleOrNull(); + )..where((t) => t.id.equals(emailId))) + .getSingleOrNull(); if (row == null) return; final account = (await _accounts.getAccount(row.accountId))!; @@ -1559,14 +1550,14 @@ class EmailRepositoryImpl implements EmailRepository { @override Future markAllAsRead(String accountId, String mailboxPath) async { final account = (await _accounts.getAccount(accountId))!; - final unread = - await (_db.select(_db.emails)..where( - (t) => - t.accountId.equals(accountId) & - t.mailboxPath.equals(mailboxPath) & - t.isSeen.equals(false), - )) - .get(); + final unread = await (_db.select(_db.emails) + ..where( + (t) => + t.accountId.equals(accountId) & + t.mailboxPath.equals(mailboxPath) & + t.isSeen.equals(false), + )) + .get(); if (unread.isEmpty) return; await _db.transaction(() async { @@ -1593,20 +1584,22 @@ class EmailRepositoryImpl implements EmailRepository { } // Bulk mark all unread emails in this mailbox as seen. - await (_db.update(_db.emails)..where( - (t) => - t.accountId.equals(accountId) & - t.mailboxPath.equals(mailboxPath) & - t.isSeen.equals(false), - )) + await (_db.update(_db.emails) + ..where( + (t) => + t.accountId.equals(accountId) & + t.mailboxPath.equals(mailboxPath) & + t.isSeen.equals(false), + )) .write(const EmailsCompanion(isSeen: Value(true))); // Update all threads in this mailbox to reflect no unread. - await (_db.update(_db.threads)..where( - (t) => - t.accountId.equals(accountId) & - t.mailboxPath.equals(mailboxPath), - )) + await (_db.update(_db.threads) + ..where( + (t) => + t.accountId.equals(accountId) & + t.mailboxPath.equals(mailboxPath), + )) .write(const ThreadsCompanion(hasUnread: Value(false))); }); } @@ -1615,7 +1608,8 @@ class EmailRepositoryImpl implements EmailRepository { Future moveEmail(String emailId, String destMailboxPath) async { final row = await (_db.select( _db.emails, - )..where((t) => t.id.equals(emailId))).getSingleOrNull(); + )..where((t) => t.id.equals(emailId))) + .getSingleOrNull(); if (row == null) return; final account = (await _accounts.getAccount(row.accountId))!; @@ -1683,18 +1677,18 @@ class EmailRepositoryImpl implements EmailRepository { Future deleteEmail(String emailId) async { final row = await (_db.select( _db.emails, - )..where((t) => t.id.equals(emailId))).getSingleOrNull(); + )..where((t) => t.id.equals(emailId))) + .getSingleOrNull(); if (row == null) return null; final account = (await _accounts.getAccount(row.accountId))!; // Move to Trash when possible so the user can recover the message. - final trashRow = - await (_db.select(_db.mailboxes) - ..where( - (t) => t.accountId.equals(account.id) & t.role.equals('trash'), - ) - ..limit(1)) - .getSingleOrNull(); + final trashRow = await (_db.select(_db.mailboxes) + ..where( + (t) => t.accountId.equals(account.id) & t.role.equals('trash'), + ) + ..limit(1)) + .getSingleOrNull(); if (trashRow != null && trashRow.path != row.mailboxPath) { await moveEmail(emailId, trashRow.path); @@ -1741,9 +1735,7 @@ class EmailRepositoryImpl implements EmailRepository { String changeType, String payload, ) async { - await _db - .into(_db.pendingChanges) - .insert( + await _db.into(_db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: accountId, resourceType: 'Email', @@ -1774,7 +1766,8 @@ class EmailRepositoryImpl implements EmailRepository { if (row != null) { final count = await (_db.delete( _db.pendingChanges, - )..where((t) => t.id.equals(row.id))).go(); + )..where((t) => t.id.equals(row.id))) + .go(); return count > 0; } return false; @@ -1784,27 +1777,24 @@ class EmailRepositoryImpl implements EmailRepository { Future snoozeEmail(String emailId, DateTime until) async { final row = await (_db.select( _db.emails, - )..where((t) => t.id.equals(emailId))).getSingle(); + )..where((t) => t.id.equals(emailId))) + .getSingle(); final account = (await _accounts.getAccount(row.accountId))!; // Find or create Snoozed mailbox. - var snoozedMailbox = - await (_db.select(_db.mailboxes) - ..where( - (t) => - t.accountId.equals(account.id) & t.role.equals('snoozed'), - ) - ..limit(1)) - .getSingleOrNull(); + var snoozedMailbox = await (_db.select(_db.mailboxes) + ..where( + (t) => t.accountId.equals(account.id) & t.role.equals('snoozed'), + ) + ..limit(1)) + .getSingleOrNull(); - snoozedMailbox ??= - await (_db.select(_db.mailboxes) - ..where( - (t) => - t.accountId.equals(account.id) & t.name.equals('Snoozed'), - ) - ..limit(1)) - .getSingleOrNull(); + snoozedMailbox ??= await (_db.select(_db.mailboxes) + ..where( + (t) => t.accountId.equals(account.id) & t.name.equals('Snoozed'), + ) + ..limit(1)) + .getSingleOrNull(); // Default path if not found; flush logic will attempt to create it. final destPath = snoozedMailbox?.path ?? 'Snoozed'; @@ -1841,25 +1831,24 @@ class EmailRepositoryImpl implements EmailRepository { @override Future wakeUpEmails(String accountId) async { final now = DateTime.now(); - final expired = - await (_db.select(_db.emails)..where( - (t) => - t.accountId.equals(accountId) & - t.snoozedUntil.isSmallerOrEqualValue(now), - )) - .get(); + final expired = await (_db.select(_db.emails) + ..where( + (t) => + t.accountId.equals(accountId) & + t.snoozedUntil.isSmallerOrEqualValue(now), + )) + .get(); if (expired.isEmpty) return 0; for (final row in expired) { // Per instructions: "get to inbox moved by app". - final inbox = - await (_db.select(_db.mailboxes) - ..where( - (t) => t.accountId.equals(accountId) & t.role.equals('inbox'), - ) - ..limit(1)) - .getSingleOrNull(); + final inbox = await (_db.select(_db.mailboxes) + ..where( + (t) => t.accountId.equals(accountId) & t.role.equals('inbox'), + ) + ..limit(1)) + .getSingleOrNull(); final dest = inbox?.path ?? 'INBOX'; await _enqueueChange( @@ -1890,24 +1879,20 @@ class EmailRepositoryImpl implements EmailRepository { String accountId, String messageId, ) async { - final row = - await (_db.select(_db.emails) - ..where( - (t) => - t.accountId.equals(accountId) & - t.messageId.equals(messageId), - ) - ..limit(1)) - .getSingleOrNull(); + final row = await (_db.select(_db.emails) + ..where( + (t) => + t.accountId.equals(accountId) & t.messageId.equals(messageId), + ) + ..limit(1)) + .getSingleOrNull(); return row == null ? null : _toModel(row); } @override Future restoreEmails(List emails) async { for (final e in emails) { - await _db - .into(_db.emails) - .insertOnConflictUpdate( + await _db.into(_db.emails).insertOnConflictUpdate( EmailsCompanion.insert( id: e.id, accountId: e.accountId, @@ -1939,13 +1924,12 @@ class EmailRepositoryImpl implements EmailRepository { /// 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(); + 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; @@ -1957,28 +1941,28 @@ class EmailRepositoryImpl implements EmailRepository { } 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 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(); + )..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 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(); @@ -2020,14 +2004,12 @@ class EmailRepositoryImpl implements EmailRepository { 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(', '); + 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 ''; } @@ -2046,9 +2028,7 @@ class EmailRepositoryImpl implements EmailRepository { } Future _markSieveApplied(String accountId, String messageId) async { - await _db - .into(_db.localSieveApplied) - .insertOnConflictUpdate( + await _db.into(_db.localSieveApplied).insertOnConflictUpdate( LocalSieveAppliedCompanion.insert( accountId: accountId, messageId: messageId, @@ -2064,13 +2044,12 @@ class EmailRepositoryImpl implements EmailRepository { ) 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(); + 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}', @@ -2160,11 +2139,10 @@ class EmailRepositoryImpl implements EmailRepository { /// Called at the start of each sync cycle. Returns count of applied changes. @override Future flushPendingChanges(String accountId, String password) async { - final rows = - await (_db.select(_db.pendingChanges) - ..where((t) => t.accountId.equals(accountId)) - ..orderBy([(t) => OrderingTerm.asc(t.createdAt)])) - .get(); + final rows = await (_db.select(_db.pendingChanges) + ..where((t) => t.accountId.equals(accountId)) + ..orderBy([(t) => OrderingTerm.asc(t.createdAt)])) + .get(); if (rows.isEmpty) return 0; final account = (await _accounts.getAccount(accountId))!; @@ -2203,7 +2181,8 @@ class EmailRepositoryImpl implements EmailRepository { ); await (_db.delete( _db.pendingChanges, - )..where((t) => t.id.equals(row.id))).go(); + )..where((t) => t.id.equals(row.id))) + .go(); applied++; // Keep our checkpoint in sync with whatever the server returned. if (newState != null) { @@ -2213,11 +2192,12 @@ class EmailRepositoryImpl implements EmailRepository { // Server rejected the mutation because our state token is stale. // Drop the cached state so the next sync cycle does a full re-fetch, // after which this change will be retried with a fresh token. - await (_db.delete(_db.syncStates)..where( - (t) => - t.accountId.equals(account.id) & - t.resourceType.equals('Email'), - )) + await (_db.delete(_db.syncStates) + ..where( + (t) => + t.accountId.equals(account.id) & + t.resourceType.equals('Email'), + )) .go(); await _recordChangeError( row, @@ -2230,7 +2210,8 @@ class EmailRepositoryImpl implements EmailRepository { // the change so the queue doesn't grow unboundedly. await (_db.delete( _db.pendingChanges, - )..where((t) => t.id.equals(row.id))).go(); + )..where((t) => t.id.equals(row.id))) + .go(); log('JMAP permanent error for change ${row.id}: $e'); } catch (e) { await _recordChangeError(row, e); @@ -2265,7 +2246,8 @@ class EmailRepositoryImpl implements EmailRepository { await _applyPendingChangeImap(client, row); await (_db.delete( _db.pendingChanges, - )..where((t) => t.id.equals(row.id))).go(); + )..where((t) => t.id.equals(row.id))) + .go(); applied++; } catch (e) { if (_isImapNotFoundError(e)) { @@ -2273,7 +2255,8 @@ class EmailRepositoryImpl implements EmailRepository { // pending change doesn't accumulate or block future changes. await (_db.delete( _db.pendingChanges, - )..where((t) => t.id.equals(row.id))).go(); + )..where((t) => t.id.equals(row.id))) + .go(); applied++; log('IMAP change ${row.id} skipped: message already gone ($e)'); } else { @@ -2370,10 +2353,10 @@ class EmailRepositoryImpl implements EmailRepository { : row.resourceId; Map setArgs(Map extra) => { - 'accountId': jmap.accountId, - if (ifInState != null) 'ifInState': ifInState, - ...extra, - }; + 'accountId': jmap.accountId, + if (ifInState != null) 'ifInState': ifInState, + ...extra, + }; List responses; switch (row.changeType) { @@ -2457,9 +2440,8 @@ class EmailRepositoryImpl implements EmailRepository { ]); final createResult = _responseArgs(createResps, 0, 'Mailbox/set'); final created = createResult['created'] as Map?; - final newId = - (created?['new-snoozed'] as Map?)?['id'] - as String?; + final newId = (created?['new-snoozed'] + as Map?)?['id'] as String?; if (newId != null) destMailboxId = newId; } responses = await jmap.call([ @@ -2646,13 +2628,12 @@ class EmailRepositoryImpl implements EmailRepository { } // Look up the Sent mailbox JMAP ID from the local DB. - final sentMailbox = - await (_db.select(_db.mailboxes) - ..where( - (t) => t.accountId.equals(account.id) & t.role.equals('sent'), - ) - ..limit(1)) - .getSingleOrNull(); + final sentMailbox = await (_db.select(_db.mailboxes) + ..where( + (t) => t.accountId.equals(account.id) & t.role.equals('sent'), + ) + ..limit(1)) + .getSingleOrNull(); final sentJmapId = sentMailbox?.path; // Build the email body. @@ -2730,25 +2711,28 @@ class EmailRepositoryImpl implements EmailRepository { } // Then submit the created email. - final submissionResponses = await jmap.call([ + final submissionResponses = await jmap.call( [ - 'EmailSubmission/set', - { - 'accountId': jmap.accountId, - 'create': { - 'sub1': { - 'emailId': emailId, - 'identityId': identityId, - 'envelope': { - 'mailFrom': {'email': draft.from.email}, - 'rcptTo': allRecipients, + [ + 'EmailSubmission/set', + { + 'accountId': jmap.accountId, + 'create': { + 'sub1': { + 'emailId': emailId, + 'identityId': identityId, + 'envelope': { + 'mailFrom': {'email': draft.from.email}, + 'rcptTo': allRecipients, + }, }, }, }, - }, - '1', + '1', + ], ], - ], withSubmission: true); + withSubmission: true, + ); // Check EmailSubmission/set for submission errors. final subResult = _responseArgs( @@ -2795,7 +2779,8 @@ class EmailRepositoryImpl implements EmailRepository { final emailRow = await (_db.select( _db.emails, - )..where((t) => t.id.equals(emailId))).getSingle(); + )..where((t) => t.id.equals(emailId))) + .getSingle(); final account = (await _accounts.getAccount(emailRow.accountId))!; final password = await _accounts.getPassword(account.id); @@ -2849,7 +2834,8 @@ class EmailRepositoryImpl implements EmailRepository { Future fetchRawRfc822(String emailId) async { final emailRow = await (_db.select( _db.emails, - )..where((t) => t.id.equals(emailId))).getSingle(); + )..where((t) => t.id.equals(emailId))) + .getSingle(); final account = (await _accounts.getAccount(emailRow.accountId))!; final password = await _accounts.getPassword(account.id); @@ -2916,16 +2902,15 @@ class EmailRepositoryImpl implements EmailRepository { final sql = accountId != null ? 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid' - ' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY rank LIMIT 50' + ' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY rank LIMIT 50' : 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid' - ' WHERE email_fts MATCH ? ORDER BY rank LIMIT 50'; + ' WHERE email_fts MATCH ? ORDER BY rank LIMIT 50'; final variables = accountId != null ? [Variable(ftsQuery), Variable(accountId)] : [Variable(ftsQuery)]; final queryRows = await _db - .customSelect(sql, variables: variables, readsFrom: {_db.emails}) - .get(); + .customSelect(sql, variables: variables, readsFrom: {_db.emails}).get(); final emailRows = await Future.wait( queryRows.map((r) => _db.emails.mapFromRow(r)), ); @@ -2953,22 +2938,20 @@ class EmailRepositoryImpl implements EmailRepository { String address, ) async { final pattern = '%${address.toLowerCase()}%'; - final rows = - await (_db.select(_db.emails) - ..where((t) { - Expression condition = const Constant(true); - if (accountId != null) { - condition = t.accountId.equals(accountId); - } - condition = - condition & - (t.fromJson.like(pattern) | - t.toAddresses.like(pattern) | - t.ccJson.like(pattern)); - return condition; - }) - ..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])) - .get(); + final rows = await (_db.select(_db.emails) + ..where((t) { + Expression condition = const Constant(true); + if (accountId != null) { + condition = t.accountId.equals(accountId); + } + condition = condition & + (t.fromJson.like(pattern) | + t.toAddresses.like(pattern) | + t.ccJson.like(pattern)); + return condition; + }) + ..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])) + .get(); return rows.map(_toModel).toList(); } @@ -2980,21 +2963,19 @@ class EmailRepositoryImpl implements EmailRepository { }) async { if (query.length < 2) return []; final pattern = '%${query.toLowerCase()}%'; - final rows = - await (_db.select(_db.emails) - ..where((t) { - Expression cond = const Constant(true); - if (accountId != null) cond = t.accountId.equals(accountId); - cond = - cond & - (t.fromJson.like(pattern) | - t.toAddresses.like(pattern) | - t.ccJson.like(pattern)); - return cond; - }) - ..orderBy([(t) => OrderingTerm.desc(t.receivedAt)]) - ..limit(100)) - .get(); + final rows = await (_db.select(_db.emails) + ..where((t) { + Expression cond = const Constant(true); + if (accountId != null) cond = t.accountId.equals(accountId); + cond = cond & + (t.fromJson.like(pattern) | + t.toAddresses.like(pattern) | + t.ccJson.like(pattern)); + return cond; + }) + ..orderBy([(t) => OrderingTerm.desc(t.receivedAt)]) + ..limit(100)) + .get(); final seen = {}; final results = []; @@ -3035,16 +3016,12 @@ class EmailRepositoryImpl implements EmailRepository { ); try { await client.selectMailboxByPath(mailboxPath); - final terms = query - .split(RegExp(r'\s+')) - .where((t) => t.isNotEmpty) - .toList(); - final searchCriteria = terms - .map((term) { - final escaped = term.replaceAll('"', '\\"'); - return 'OR SUBJECT "$escaped" TEXT "$escaped"'; - }) - .join(' '); + final terms = + query.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList(); + final searchCriteria = terms.map((term) { + final escaped = term.replaceAll('"', '\\"'); + return 'OR SUBJECT "$escaped" TEXT "$escaped"'; + }).join(' '); final result = await client.uidSearchMessages( searchCriteria: searchCriteria, ); @@ -3058,26 +3035,25 @@ class EmailRepositoryImpl implements EmailRepository { return fetch.messages .where((msg) => msg.uid != null && msg.envelope != null) .map((msg) { - final envelope = msg.envelope!; - final uid = msg.uid!; - final emailId = '$accountId:$uid'; - return model.Email( - id: emailId, - accountId: accountId, - mailboxPath: mailboxPath, - uid: uid, - subject: envelope.subject, - sentAt: envelope.date, - receivedAt: envelope.date ?? DateTime.now(), - from: _toAddressList(envelope.from), - to: _toAddressList(envelope.to), - cc: _toAddressList(envelope.cc), - isSeen: msg.flags?.contains(r'\Seen') ?? false, - isFlagged: msg.flags?.contains(r'\Flagged') ?? false, - hasAttachment: msg.hasAttachments(), - ); - }) - .toList(); + final envelope = msg.envelope!; + final uid = msg.uid!; + final emailId = '$accountId:$uid'; + return model.Email( + id: emailId, + accountId: accountId, + mailboxPath: mailboxPath, + uid: uid, + subject: envelope.subject, + sentAt: envelope.date, + receivedAt: envelope.date ?? DateTime.now(), + from: _toAddressList(envelope.from), + to: _toAddressList(envelope.to), + cc: _toAddressList(envelope.cc), + isSeen: msg.flags?.contains(r'\Seen') ?? false, + isFlagged: msg.flags?.contains(r'\Flagged') ?? false, + hasAttachment: msg.hasAttachments(), + ); + }).toList(); } finally { await client.logout(); } @@ -3117,10 +3093,10 @@ class EmailRepositoryImpl implements EmailRepository { } String _encodeAddresses(List? addresses) => jsonEncode( - (addresses ?? const []) - .map((a) => {'name': a.personalName, 'email': a.email}) - .toList(), - ); + (addresses ?? const []) + .map((a) => {'name': a.personalName, 'email': a.email}) + .toList(), + ); @override Stream> observeEmailsInThread( @@ -3182,13 +3158,13 @@ class EmailRepositoryImpl implements EmailRepository { } model.EmailBody _bodyRowToModel(EmailBody row) => model.EmailBody( - emailId: row.emailId, - textBody: row.textBody, - htmlBody: row.htmlBody, - attachments: _parseAttachments(row.attachmentsJson), - headers: _parseHeaders(row.headersJson), - mimeTree: _parseMimeTree(row.mimeTreeJson), - ); + emailId: row.emailId, + textBody: row.textBody, + htmlBody: row.htmlBody, + attachments: _parseAttachments(row.attachmentsJson), + headers: _parseHeaders(row.headersJson), + mimeTree: _parseMimeTree(row.mimeTreeJson), + ); model.MimePart? _parseMimeTree(String? jsonStr) { if (jsonStr == null || jsonStr.isEmpty) return null; @@ -3200,15 +3176,15 @@ class EmailRepositoryImpl implements EmailRepository { } model.MimePart _mimePartFromJson(Map m) => model.MimePart( - contentType: m['contentType'] as String? ?? 'application/octet-stream', - filename: m['filename'] as String?, - size: m['size'] as int?, - encoding: m['encoding'] as String?, - children: ((m['children'] as List?) ?? []) - .cast>() - .map(_mimePartFromJson) - .toList(), - ); + contentType: m['contentType'] as String? ?? 'application/octet-stream', + filename: m['filename'] as String?, + size: m['size'] as int?, + encoding: m['encoding'] as String?, + children: ((m['children'] as List?) ?? []) + .cast>() + .map(_mimePartFromJson) + .toList(), + ); List _parseHeaders(String? jsonStr) { if (jsonStr == null || jsonStr.isEmpty) return []; @@ -3286,13 +3262,16 @@ class EmailRepositoryImpl implements EmailRepository { await _db.transaction(() async { await (_db.delete( _db.emails, - )..where((t) => t.accountId.equals(accountId))).go(); + )..where((t) => t.accountId.equals(accountId))) + .go(); await (_db.delete( _db.pendingChanges, - )..where((t) => t.accountId.equals(accountId))).go(); + )..where((t) => t.accountId.equals(accountId))) + .go(); await (_db.delete( _db.syncStates, - )..where((t) => t.accountId.equals(accountId))).go(); + )..where((t) => t.accountId.equals(accountId))) + .go(); }); } finally { await _db.customStatement('PRAGMA foreign_keys = ON'); @@ -3304,10 +3283,8 @@ class EmailRepositoryImpl implements EmailRepository { Map _mimePartToJson(imap.MimePart part) { final ct = part.getHeaderContentType(); final disposition = part.getHeaderContentDisposition(); - final rawEncoding = part - .getHeader('content-transfer-encoding') - ?.firstOrNull - ?.value; + final rawEncoding = + part.getHeader('content-transfer-encoding')?.firstOrNull?.value; final encoding = rawEncoding?.split(';').first.trim().toLowerCase(); return { 'contentType': ct?.mediaType.text ?? 'application/octet-stream', @@ -3325,12 +3302,12 @@ String _buildMimeTreeJson(imap.MimeMessage msg) => /// Converts a JMAP `bodyStructure` object into the same JSON format used by /// [_mimePartToJson], so [_parseMimeTree] can deserialise it uniformly. Map _jmapBodyStructureToJson(Map m) => { - 'contentType': m['type'] as String? ?? 'application/octet-stream', - 'filename': m['name'], - 'size': m['size'], - 'encoding': null, - 'children': ((m['subParts'] as List?) ?? []) - .cast>() - .map(_jmapBodyStructureToJson) - .toList(), -}; + 'contentType': m['type'] as String? ?? 'application/octet-stream', + 'filename': m['name'], + 'size': m['size'], + 'encoding': null, + 'children': ((m['subParts'] as List?) ?? []) + .cast>() + .map(_jmapBodyStructureToJson) + .toList(), + }; diff --git a/lib/data/repositories/mailbox_repository_impl.dart b/lib/data/repositories/mailbox_repository_impl.dart index 68ec31e..00b8646 100644 --- a/lib/data/repositories/mailbox_repository_impl.dart +++ b/lib/data/repositories/mailbox_repository_impl.dart @@ -17,8 +17,8 @@ class MailboxRepositoryImpl implements MailboxRepository { this._accounts, { ImapConnectFn imapConnect = connectImap, http.Client? httpClient, - }) : _imapConnect = imapConnect, - _httpClient = httpClient ?? http.Client(); + }) : _imapConnect = imapConnect, + _httpClient = httpClient ?? http.Client(); final AppDatabase _db; final AccountRepository _accounts; @@ -45,13 +45,12 @@ class MailboxRepositoryImpl implements MailboxRepository { String accountId, String role, ) async { - final row = - await (_db.select(_db.mailboxes) - ..where( - (t) => t.accountId.equals(accountId) & t.role.equals(role), - ) - ..limit(1)) - .getSingleOrNull(); + final row = await (_db.select(_db.mailboxes) + ..where( + (t) => t.accountId.equals(accountId) & t.role.equals(role), + ) + ..limit(1)) + .getSingleOrNull(); return row == null ? null : _toModel(row); } @@ -85,7 +84,8 @@ class MailboxRepositoryImpl implements MailboxRepository { // folders the server doesn't tag with a special-use attribute. final existingRows = await (_db.select( _db.mailboxes, - )..where((t) => t.accountId.equals(account.id))).get(); + )..where((t) => t.accountId.equals(account.id))) + .get(); final existingRoles = {for (final r in existingRows) r.id: r.role}; for (final mb in mailboxes) { @@ -111,9 +111,7 @@ class MailboxRepositoryImpl implements MailboxRepository { // when the IMAP server does not expose a special-use attribute. final role = _imapRole(mb) ?? existingRoles[id]; - await _db - .into(_db.mailboxes) - .insertOnConflictUpdate( + await _db.into(_db.mailboxes).insertOnConflictUpdate( MailboxesCompanion.insert( id: id, accountId: account.id, @@ -218,7 +216,8 @@ class MailboxRepositoryImpl implements MailboxRepository { for (final jmapId in destroyed) { await (_db.delete( _db.mailboxes, - )..where((t) => t.id.equals('$accountId:$jmapId'))).go(); + )..where((t) => t.id.equals('$accountId:$jmapId'))) + .go(); } await _saveSyncState(accountId, 'Mailbox', newState); @@ -239,9 +238,7 @@ class MailboxRepositoryImpl implements MailboxRepository { final dbId = '$accountId:$jmapId'; // For JMAP accounts, path stores the JMAP mailbox ID so that // Email rows can reference it via mailboxPath. - await _db - .into(_db.mailboxes) - .insertOnConflictUpdate( + await _db.into(_db.mailboxes).insertOnConflictUpdate( MailboxesCompanion.insert( id: dbId, accountId: accountId, @@ -258,13 +255,13 @@ class MailboxRepositoryImpl implements MailboxRepository { // ── sync_state helpers ──────────────────────────────────────────────────── Future _loadSyncState(String accountId, String resourceType) async { - final row = - await (_db.select(_db.syncStates)..where( - (t) => - t.accountId.equals(accountId) & - t.resourceType.equals(resourceType), - )) - .getSingleOrNull(); + final row = await (_db.select(_db.syncStates) + ..where( + (t) => + t.accountId.equals(accountId) & + t.resourceType.equals(resourceType), + )) + .getSingleOrNull(); return row?.state; } @@ -273,9 +270,7 @@ class MailboxRepositoryImpl implements MailboxRepository { String resourceType, String state, ) async { - await _db - .into(_db.syncStates) - .insertOnConflictUpdate( + await _db.into(_db.syncStates).insertOnConflictUpdate( SyncStatesCompanion.insert( accountId: accountId, resourceType: resourceType, @@ -304,14 +299,14 @@ class MailboxRepositoryImpl implements MailboxRepository { } model.Mailbox _toModel(MailboxRow row) => model.Mailbox( - id: row.id, - accountId: row.accountId, - path: row.path, - name: row.name, - unreadCount: row.unreadCount, - totalCount: row.totalCount, - role: row.role, - ); + id: row.id, + accountId: row.accountId, + path: row.path, + name: row.name, + unreadCount: row.unreadCount, + totalCount: row.totalCount, + role: row.role, + ); /// Maps enough_mail special-use flags (RFC 6154) to JMAP role strings (RFC 8621). static String? _imapRole(imap.Mailbox mb) { @@ -328,7 +323,8 @@ class MailboxRepositoryImpl implements MailboxRepository { Future clearForResync(String accountId) async { await (_db.delete( _db.mailboxes, - )..where((t) => t.accountId.equals(accountId))).go(); + )..where((t) => t.accountId.equals(accountId))) + .go(); } @override @@ -364,9 +360,7 @@ class MailboxRepositoryImpl implements MailboxRepository { await client.logout(); } final id = '${account.id}:$name'; - await _db - .into(_db.mailboxes) - .insertOnConflictUpdate( + await _db.into(_db.mailboxes).insertOnConflictUpdate( MailboxesCompanion.insert( id: id, accountId: account.id, @@ -377,7 +371,8 @@ class MailboxRepositoryImpl implements MailboxRepository { ); final row = await (_db.select( _db.mailboxes, - )..where((t) => t.id.equals(id))).getSingle(); + )..where((t) => t.id.equals(id))) + .getSingle(); return _toModel(row); } @@ -419,9 +414,7 @@ class MailboxRepositoryImpl implements MailboxRepository { ); } final dbId = '${account.id}:$newId'; - await _db - .into(_db.mailboxes) - .insertOnConflictUpdate( + await _db.into(_db.mailboxes).insertOnConflictUpdate( MailboxesCompanion.insert( id: dbId, accountId: account.id, @@ -432,7 +425,8 @@ class MailboxRepositoryImpl implements MailboxRepository { ); final row = await (_db.select( _db.mailboxes, - )..where((t) => t.id.equals(dbId))).getSingle(); + )..where((t) => t.id.equals(dbId))) + .getSingle(); return _toModel(row); } } diff --git a/lib/data/repositories/search_history_repository_impl.dart b/lib/data/repositories/search_history_repository_impl.dart index 31202f5..8549905 100644 --- a/lib/data/repositories/search_history_repository_impl.dart +++ b/lib/data/repositories/search_history_repository_impl.dart @@ -10,11 +10,10 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository { @override Future> getRecentSearches() async { - final rows = - await (_db.select(_db.searchHistoryEntries) - ..orderBy([(t) => OrderingTerm.desc(t.searchedAt)]) - ..limit(_maxEntries)) - .get(); + final rows = await (_db.select(_db.searchHistoryEntries) + ..orderBy([(t) => OrderingTerm.desc(t.searchedAt)]) + ..limit(_maxEntries)) + .get(); return rows.map((r) => r.query).toList(); } @@ -27,11 +26,10 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository { // Remove existing entry for same query (deduplication). await (_db.delete( _db.searchHistoryEntries, - )..where((t) => t.query.equals(trimmed))).go(); + )..where((t) => t.query.equals(trimmed))) + .go(); - await _db - .into(_db.searchHistoryEntries) - .insert( + await _db.into(_db.searchHistoryEntries).insert( SearchHistoryEntriesCompanion.insert( query: trimmed, searchedAt: DateTime.now(), @@ -39,17 +37,17 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository { ); // Prune to the most recent _maxEntries. - final keepIds = - await (_db.select(_db.searchHistoryEntries) - ..orderBy([(t) => OrderingTerm.desc(t.searchedAt)]) - ..limit(_maxEntries)) - .map((r) => r.id) - .get(); + final keepIds = await (_db.select(_db.searchHistoryEntries) + ..orderBy([(t) => OrderingTerm.desc(t.searchedAt)]) + ..limit(_maxEntries)) + .map((r) => r.id) + .get(); if (keepIds.isNotEmpty) { await (_db.delete( _db.searchHistoryEntries, - )..where((t) => t.id.isNotIn(keepIds))).go(); + )..where((t) => t.id.isNotIn(keepIds))) + .go(); } }); } diff --git a/lib/data/repositories/share_key_repository_impl.dart b/lib/data/repositories/share_key_repository_impl.dart index 25df102..6f8e746 100644 --- a/lib/data/repositories/share_key_repository_impl.dart +++ b/lib/data/repositories/share_key_repository_impl.dart @@ -23,9 +23,7 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository { final keyIdHex = _hex(material.keyId); final expiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20)); - await _db - .into(_db.shareKeys) - .insert( + await _db.into(_db.shareKeys).insert( ShareKeysCompanion.insert( id: keyIdHex, publicKey: base64.encode(material.publicKeyBytes), @@ -44,7 +42,8 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository { final keyIdHex = _hex(keyId); final row = await (_db.select( _db.shareKeys, - )..where((t) => t.id.equals(keyIdHex))).getSingleOrNull(); + )..where((t) => t.id.equals(keyIdHex))) + .getSingleOrNull(); if (row == null) return null; if (row.expiresAt.isBefore(DateTime.now().toUtc())) return null; @@ -58,8 +57,8 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository { Future _pruneExpired() async { await (_db.delete( - _db.shareKeys, - )..where((t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()))) + _db.shareKeys, + )..where((t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()))) .go(); } diff --git a/lib/data/repositories/sync_log_repository_impl.dart b/lib/data/repositories/sync_log_repository_impl.dart index 04c5917..a6f004b 100644 --- a/lib/data/repositories/sync_log_repository_impl.dart +++ b/lib/data/repositories/sync_log_repository_impl.dart @@ -27,9 +27,7 @@ class SyncLogRepositoryImpl implements SyncLogRepository { String? protocolLog, }) async { await _db.transaction(() async { - final logId = await _db - .into(_db.syncLogs) - .insert( + final logId = await _db.into(_db.syncLogs).insert( SyncLogsCompanion.insert( accountId: accountId, result: success ? 'ok' : 'error', @@ -48,9 +46,7 @@ class SyncLogRepositoryImpl implements SyncLogRepository { ), ); for (final s in mailboxStats) { - await _db - .into(_db.syncLogMailboxes) - .insert( + await _db.into(_db.syncLogMailboxes).insert( SyncLogMailboxesCompanion.insert( syncLogId: logId, mailboxPath: s.mailboxPath, @@ -74,11 +70,10 @@ class SyncLogRepositoryImpl implements SyncLogRepository { return logsQuery.watch().asyncMap((rows) async { final entries = []; for (final r in rows) { - final mailboxRows = - await (_db.select(_db.syncLogMailboxes) - ..where((t) => t.syncLogId.equals(r.id)) - ..orderBy([(t) => OrderingTerm.asc(t.mailboxPath)])) - .get(); + final mailboxRows = await (_db.select(_db.syncLogMailboxes) + ..where((t) => t.syncLogId.equals(r.id)) + ..orderBy([(t) => OrderingTerm.asc(t.mailboxPath)])) + .get(); entries.add( SyncLogEntry( id: r.id, diff --git a/lib/data/repositories/undo_repository_impl.dart b/lib/data/repositories/undo_repository_impl.dart index 5177139..7241162 100644 --- a/lib/data/repositories/undo_repository_impl.dart +++ b/lib/data/repositories/undo_repository_impl.dart @@ -11,9 +11,7 @@ class UndoRepositoryImpl implements UndoRepository { @override Future saveAction(UndoAction action) async { - await _db - .into(_db.undoActions) - .insert( + await _db.into(_db.undoActions).insert( UndoActionsCompanion.insert( id: action.id, accountId: action.accountId, @@ -31,11 +29,10 @@ class UndoRepositoryImpl implements UndoRepository { @override Future> getHistory({int limit = 10}) async { - final rows = - await (_db.select(_db.undoActions) - ..orderBy([(t) => OrderingTerm.desc(t.createdAt)]) - ..limit(limit)) - .get(); + final rows = await (_db.select(_db.undoActions) + ..orderBy([(t) => OrderingTerm.desc(t.createdAt)]) + ..limit(limit)) + .get(); return rows.map((row) { return UndoAction.fromJson( jsonDecode(row.dataJson) as Map, diff --git a/lib/data/repositories/user_preferences_repository_impl.dart b/lib/data/repositories/user_preferences_repository_impl.dart index a035d0d..55d1b4a 100644 --- a/lib/data/repositories/user_preferences_repository_impl.dart +++ b/lib/data/repositories/user_preferences_repository_impl.dart @@ -13,14 +13,14 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository { Stream observePreferences() { return (_db.select( _db.userPreferences, - )..where((t) => t.id.equals(_rowId))).watchSingleOrNull().map(_rowToModel); + )..where((t) => t.id.equals(_rowId))) + .watchSingleOrNull() + .map(_rowToModel); } @override Future updateMenuPosition(pref.MenuPosition position) async { - await _db - .into(_db.userPreferences) - .insertOnConflictUpdate( + await _db.into(_db.userPreferences).insertOnConflictUpdate( UserPreferencesCompanion( id: const Value(_rowId), menuPosition: Value(position.name), @@ -30,9 +30,7 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository { @override Future updateMailViewButtonPosition(pref.MenuPosition position) async { - await _db - .into(_db.userPreferences) - .insertOnConflictUpdate( + await _db.into(_db.userPreferences).insertOnConflictUpdate( UserPreferencesCompanion( id: const Value(_rowId), mailViewButtonPosition: Value(position.name), @@ -44,9 +42,7 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository { Future updateAfterMailViewAction( pref.AfterMailViewAction action, ) async { - await _db - .into(_db.userPreferences) - .insertOnConflictUpdate( + await _db.into(_db.userPreferences).insertOnConflictUpdate( UserPreferencesCompanion( id: const Value(_rowId), afterMailViewAction: Value(action.name), diff --git a/lib/di.dart b/lib/di.dart index b0ed6c8..7cb4674 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -111,10 +111,10 @@ final syncLogRepositoryProvider = Provider((ref) { return SyncLogRepositoryImpl(ref.watch(dbProvider)); }); -final syncLastErrorProvider = StreamProvider.autoDispose - .family((ref, accountId) { - return ref.watch(syncLogRepositoryProvider).observeLastError(accountId); - }); +final syncLastErrorProvider = + StreamProvider.autoDispose.family((ref, accountId) { + return ref.watch(syncLogRepositoryProvider).observeLastError(accountId); +}); final reliabilityRunnerProvider = Provider((ref) { final runner = ReliabilityRunner( @@ -127,13 +127,14 @@ final reliabilityRunnerProvider = Provider((ref) { return runner; }); -final syncHealthProvider = StreamProvider.autoDispose - .family((ref, accountId) { - final db = ref.watch(dbProvider); - return (db.select( - db.syncHealth, - )..where((t) => t.accountId.equals(accountId))).watchSingleOrNull(); - }); +final syncHealthProvider = + StreamProvider.autoDispose.family((ref, accountId) { + final db = ref.watch(dbProvider); + return (db.select( + db.syncHealth, + )..where((t) => t.accountId.equals(accountId))) + .watchSingleOrNull(); +}); final isSyncingProvider = StreamProvider.autoDispose.family(( ref, @@ -195,8 +196,8 @@ final undoServiceProvider = NotifierProvider>( /// Owned by [EmailDetailScreen]; decouples data loading from the widget tree. final emailDetailProvider = AsyncNotifierProvider.autoDispose .family( - EmailDetailNotifier.new, - ); + EmailDetailNotifier.new, +); class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> { EmailDetailNotifier(this._emailId); @@ -214,29 +215,26 @@ class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> { } } -final accountByIdProvider = StreamProvider.autoDispose - .family((ref, accountId) { - return ref - .watch(accountRepositoryProvider) - .observeAccounts() - .map( - (accounts) => accounts.cast().firstWhere( +final accountByIdProvider = + StreamProvider.autoDispose.family((ref, accountId) { + return ref.watch(accountRepositoryProvider).observeAccounts().map( + (accounts) => accounts.cast().firstWhere( (a) => a?.id == accountId, orElse: () => null, ), - ); - }); + ); +}); -final accountConnectionStatusProvider = FutureProvider.autoDispose - .family((ref, accountId) async { - final repo = ref.read(accountRepositoryProvider); - final account = await repo.getAccount(accountId); - if (account == null) throw Exception('Account not found'); - final password = await repo.getPassword(accountId); - await ref - .read(connectionTestServiceProvider) - .testConnection(account, password); - }); +final accountConnectionStatusProvider = + FutureProvider.autoDispose.family((ref, accountId) async { + final repo = ref.read(accountRepositoryProvider); + final account = await repo.getAccount(accountId); + if (account == null) throw Exception('Account not found'); + final password = await repo.getPassword(accountId); + await ref + .read(connectionTestServiceProvider) + .testConnection(account, password); +}); final userPreferencesRepositoryProvider = Provider(( ref, diff --git a/lib/main.dart b/lib/main.dart index dc42650..66bf511 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,9 +20,9 @@ void main({List overrides = const []}) async { // Catch errors during build (e.g. layout exceptions) and show CrashScreen. ErrorWidget.builder = (details) => CrashScreen( - exception: details.exception, - stackTrace: details.stack, - ); + exception: details.exception, + stackTrace: details.stack, + ); // Catch framework-level errors (e.g. from gestures, timers). FlutterError.onError = (details) { diff --git a/lib/ui/screens/about_screen.dart b/lib/ui/screens/about_screen.dart index b8f66ab..24c7f3a 100644 --- a/lib/ui/screens/about_screen.dart +++ b/lib/ui/screens/about_screen.dart @@ -153,12 +153,10 @@ class _AboutScreenState extends ConsumerState { stream: _accountsStream, builder: (context, accountSnapshot) { final accounts = accountSnapshot.data ?? []; - final imapCount = accounts - .where((a) => a.type == AccountType.imap) - .length; - final jmapCount = accounts - .where((a) => a.type == AccountType.jmap) - .length; + final imapCount = + accounts.where((a) => a.type == AccountType.imap).length; + final jmapCount = + accounts.where((a) => a.type == AccountType.jmap).length; return Scaffold( appBar: AppBar(title: const Text('About')), diff --git a/lib/ui/screens/account_receive_screen.dart b/lib/ui/screens/account_receive_screen.dart index cc41621..65b8574 100644 --- a/lib/ui/screens/account_receive_screen.dart +++ b/lib/ui/screens/account_receive_screen.dart @@ -209,24 +209,24 @@ class _AccountReceiveScreenState extends ConsumerState { _Step.showingPubKey => _buildPubKeyView(context), _Step.scanning => _buildScannerView(context), _Step.importing => const Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Importing accounts…'), - ], + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Importing accounts…'), + ], + ), ), - ), _Step.done => const Center( - child: Icon(Icons.check_circle, size: 64, color: Colors.green), - ), - _Step.error => Center( - child: Padding( - padding: const EdgeInsets.all(16), - child: Text('Error: $_errorMessage'), + child: Icon(Icons.check_circle, size: 64, color: Colors.green), + ), + _Step.error => Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text('Error: $_errorMessage'), + ), ), - ), }, ); } diff --git a/lib/ui/screens/account_send_screen.dart b/lib/ui/screens/account_send_screen.dart index 2a6382e..4dac369 100644 --- a/lib/ui/screens/account_send_screen.dart +++ b/lib/ui/screens/account_send_screen.dart @@ -117,10 +117,8 @@ class _AccountSendScreenState extends ConsumerState { } // Load all available accounts. - final accounts = await ref - .read(accountRepositoryProvider) - .observeAccounts() - .first; + final accounts = + await ref.read(accountRepositoryProvider).observeAccounts().first; if (!mounted) return; @@ -197,11 +195,11 @@ class _AccountSendScreenState extends ConsumerState { _Step.selectAccounts => _buildSelectStep(context), _Step.showEncrypted => _buildEncryptedQrStep(context), _Step.error => Center( - child: Padding( - padding: const EdgeInsets.all(16), - child: Text('Error: $_errorMessage'), + child: Padding( + padding: const EdgeInsets.all(16), + child: Text('Error: $_errorMessage'), + ), ), - ), }, ); } diff --git a/lib/ui/screens/add_account_screen.dart b/lib/ui/screens/add_account_screen.dart index 1d0465a..01ed21c 100644 --- a/lib/ui/screens/add_account_screen.dart +++ b/lib/ui/screens/add_account_screen.dart @@ -94,12 +94,12 @@ class _AddAccountScreenState extends ConsumerState { _jmapApiUrlCtrl.text = sessionUrl; setState(() => _step = _Step.jmapForm); case ImapSmtpDiscovery( - :final imapHost, - :final imapPort, - :final smtpHost, - :final smtpPort, - :final smtpSsl, - ): + :final imapHost, + :final imapPort, + :final smtpHost, + :final smtpPort, + :final smtpSsl, + ): _imapHostCtrl.text = imapHost; _imapPortCtrl.text = imapPort.toString(); _smtpHostCtrl.text = smtpHost; @@ -116,13 +116,13 @@ class _AddAccountScreenState extends ConsumerState { } Account _buildJmapAccount() => Account( - id: DateTime.now().millisecondsSinceEpoch.toString(), - displayName: _displayNameCtrl.text.trim(), - email: _emailCtrl.text.trim(), - username: _usernameCtrl.text.trim(), - type: AccountType.jmap, - jmapUrl: _jmapApiUrlCtrl.text.trim(), - ); + id: DateTime.now().millisecondsSinceEpoch.toString(), + displayName: _displayNameCtrl.text.trim(), + email: _emailCtrl.text.trim(), + username: _usernameCtrl.text.trim(), + type: AccountType.jmap, + jmapUrl: _jmapApiUrlCtrl.text.trim(), + ); Account _buildImapAccount() { final imapHost = _imapHostCtrl.text.trim(); @@ -494,8 +494,7 @@ class _AddAccountScreenState extends ConsumerState { labelText: label, border: const OutlineInputBorder(), ), - validator: - validator ?? + validator: validator ?? (required ? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null : null), diff --git a/lib/ui/screens/address_emails_screen.dart b/lib/ui/screens/address_emails_screen.dart index 4dfb8ed..fd1b56a 100644 --- a/lib/ui/screens/address_emails_screen.dart +++ b/lib/ui/screens/address_emails_screen.dart @@ -51,37 +51,38 @@ class _AddressEmailsScreenState extends ConsumerState { body: _loading ? const Center(child: CircularProgressIndicator()) : _emails!.isEmpty - ? const Center(child: Text('No emails')) - : ListView.builder( - itemCount: _emails!.length, - itemBuilder: (ctx, i) { - final e = _emails![i]; - final sender = e.from.isNotEmpty - ? (e.from.first.name ?? e.from.first.email) - : '(unknown)'; - return ListTile( - leading: Icon( - e.isSeen ? Icons.mail_outline : Icons.mail, - color: e.isSeen ? null : Theme.of(ctx).colorScheme.primary, - ), - title: Text(sender), - subtitle: Text( - e.subject ?? '(no subject)', - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: Text( - e.mailboxPath, - style: Theme.of(ctx).textTheme.bodySmall, - ), - onTap: () => context.push( - '/accounts/${widget.accountId}/mailboxes' - '/${Uri.encodeComponent(e.mailboxPath)}' - '/emails/${Uri.encodeComponent(e.id)}', - ), - ); - }, - ), + ? const Center(child: Text('No emails')) + : ListView.builder( + itemCount: _emails!.length, + itemBuilder: (ctx, i) { + final e = _emails![i]; + final sender = e.from.isNotEmpty + ? (e.from.first.name ?? e.from.first.email) + : '(unknown)'; + return ListTile( + leading: Icon( + e.isSeen ? Icons.mail_outline : Icons.mail, + color: + e.isSeen ? null : Theme.of(ctx).colorScheme.primary, + ), + title: Text(sender), + subtitle: Text( + e.subject ?? '(no subject)', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: Text( + e.mailboxPath, + style: Theme.of(ctx).textTheme.bodySmall, + ), + onTap: () => context.push( + '/accounts/${widget.accountId}/mailboxes' + '/${Uri.encodeComponent(e.mailboxPath)}' + '/emails/${Uri.encodeComponent(e.id)}', + ), + ); + }, + ), ); } } diff --git a/lib/ui/screens/compose_screen.dart b/lib/ui/screens/compose_screen.dart index 765d558..6d306ba 100644 --- a/lib/ui/screens/compose_screen.dart +++ b/lib/ui/screens/compose_screen.dart @@ -70,8 +70,7 @@ class _ComposeScreenState extends ConsumerState { unawaited(_loadAccounts()); // Only restore if no prefill fields were provided (avoids overwriting a // fresh reply with an old draft from a previous reply to the same email). - final hasPrefill = - widget.prefillTo != null || + final hasPrefill = widget.prefillTo != null || widget.prefillSubject != null || widget.prefillBody != null; if (!hasPrefill) unawaited(_restoreDraft()); @@ -82,10 +81,8 @@ class _ComposeScreenState extends ConsumerState { } Future _loadAccounts() async { - final accounts = await ref - .read(accountRepositoryProvider) - .observeAccounts() - .first; + final accounts = + await ref.read(accountRepositoryProvider).observeAccounts().first; if (!mounted) return; setState(() { _accounts = accounts; @@ -224,9 +221,8 @@ class _ComposeScreenState extends ConsumerState { } setState(() => _sending = true); try { - final account = (await ref - .read(accountRepositoryProvider) - .getAccount(_accountId!))!; + final account = + (await ref.read(accountRepositoryProvider).getAccount(_accountId!))!; final draft = EmailDraft( from: EmailAddress(name: account.displayName, email: account.email), to: _to.text @@ -399,9 +395,8 @@ class _ComposeScreenState extends ConsumerState { displayStringForOption: (option) { final text = ctrl.text; final lastComma = text.lastIndexOf(','); - final prefix = lastComma >= 0 - ? '${text.substring(0, lastComma + 1)} ' - : ''; + final prefix = + lastComma >= 0 ? '${text.substring(0, lastComma + 1)} ' : ''; return '$prefix${option.email}, '; }, optionsBuilder: (value) async { diff --git a/lib/ui/screens/edit_account_screen.dart b/lib/ui/screens/edit_account_screen.dart index af5cc6d..56bb76b 100644 --- a/lib/ui/screens/edit_account_screen.dart +++ b/lib/ui/screens/edit_account_screen.dart @@ -117,8 +117,7 @@ class _EditAccountScreenState extends ConsumerState { int.tryParse(_sievePortCtrl.text) ?? account.manageSievePort; // Reset the cached probe result when any field that affects the probe // changed; the post-save probe will refill it. - final sieveSettingsChanged = - imapHost != account.imapHost || + final sieveSettingsChanged = imapHost != account.imapHost || sieveHost != account.manageSieveHost || sievePort != account.manageSievePort || _sieveSsl != account.manageSieveSsl; @@ -139,12 +138,10 @@ class _EditAccountScreenState extends ConsumerState { manageSieveHost: sieveHost, manageSievePort: sievePort, manageSieveSsl: isLocalhost(effectiveSieveHost) ? _sieveSsl : true, - manageSieveAvailable: sieveSettingsChanged - ? null - : account.manageSieveAvailable, - jmapUrl: _jmapUrlCtrl.text.trim().isEmpty - ? null - : _jmapUrlCtrl.text.trim(), + manageSieveAvailable: + sieveSettingsChanged ? null : account.manageSieveAvailable, + jmapUrl: + _jmapUrlCtrl.text.trim().isEmpty ? null : _jmapUrlCtrl.text.trim(), verbose: _verbose, ); } @@ -154,8 +151,8 @@ class _EditAccountScreenState extends ConsumerState { final password = _passwordCtrl.text.isNotEmpty ? _passwordCtrl.text : await ref - .read(accountRepositoryProvider) - .getPassword(widget.accountId); + .read(accountRepositoryProvider) + .getPassword(widget.accountId); setState(() { _tryTesting = true; _tryOk = null; @@ -395,8 +392,7 @@ class _EditAccountScreenState extends ConsumerState { labelText: label, border: const OutlineInputBorder(), ), - validator: - validator ?? + validator: validator ?? (required ? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null : null), diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index 8ac7616..576dba2 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -55,8 +55,7 @@ class _EmailDetailScreenState extends ConsumerState { final header = detail.value?.$1; final body = detail.value?.$2; - final isMobile = - defaultTargetPlatform == TargetPlatform.android || + final isMobile = defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS; return Scaffold( @@ -94,9 +93,7 @@ class _EmailDetailScreenState extends ConsumerState { if (header != null) { unawaited( - ref - .read(undoServiceProvider.notifier) - .pushAction( + ref.read(undoServiceProvider.notifier).pushAction( UndoAction( id: DateTime.now().toIso8601String(), accountId: header.accountId, @@ -324,9 +321,8 @@ class _EmailDetailScreenState extends ConsumerState { Future _quotedBody(Email header, EmailBody? body) async { final date = header.sentAt != null ? _dateFmt.format(header.sentAt!) : ''; - final from = header.from.isNotEmpty - ? header.from.first.toString() - : '(unknown)'; + final from = + header.from.isNotEmpty ? header.from.first.toString() : '(unknown)'; final rawText = body?.textBody; final text = (rawText != null && rawText.isNotEmpty) ? rawText @@ -340,9 +336,8 @@ class _EmailDetailScreenState extends ConsumerState { Email header, EmailBody? body, ) async { - final account = await ref - .read(accountRepositoryProvider) - .getAccount(header.accountId); + final account = + await ref.read(accountRepositoryProvider).getAccount(header.accountId); final ownEmail = account?.email.toLowerCase() ?? ''; final seen = {}; @@ -445,9 +440,7 @@ class _EmailDetailScreenState extends ConsumerState { .moveEmail(widget.emailId, mailbox.path); unawaited( - ref - .read(undoServiceProvider.notifier) - .pushAction( + ref.read(undoServiceProvider.notifier).pushAction( UndoAction( id: DateTime.now().toIso8601String(), accountId: header.accountId, @@ -483,9 +476,7 @@ class _EmailDetailScreenState extends ConsumerState { .moveEmail(widget.emailId, mailbox.path); unawaited( - ref - .read(undoServiceProvider.notifier) - .pushAction( + ref.read(undoServiceProvider.notifier).pushAction( UndoAction( id: DateTime.now().toIso8601String(), accountId: header.accountId, @@ -522,14 +513,12 @@ class _EmailDetailScreenState extends ConsumerState { final nextEmailId = await _getNextEmailIdIfNeeded(header); final mailboxRepo = ref.read(mailboxRepositoryProvider); - final mailboxes = await mailboxRepo - .observeMailboxes(header.accountId) - .first; + final mailboxes = + await mailboxRepo.observeMailboxes(header.accountId).first; // Remove the current mailbox from the list. - final destinations = mailboxes - .where((m) => m.path != header.mailboxPath) - .toList(); + final destinations = + mailboxes.where((m) => m.path != header.mailboxPath).toList(); if (!context.mounted) return; @@ -559,9 +548,7 @@ class _EmailDetailScreenState extends ConsumerState { await ref.read(emailRepositoryProvider).moveEmail(widget.emailId, chosen); unawaited( - ref - .read(undoServiceProvider.notifier) - .pushAction( + ref.read(undoServiceProvider.notifier).pushAction( UndoAction( id: DateTime.now().toIso8601String(), accountId: header.accountId, @@ -641,8 +628,8 @@ class _EmailDetailScreenState extends ConsumerState { Text( fmtSize(raw.length), style: Theme.of(ctx).textTheme.bodySmall?.copyWith( - color: Theme.of(ctx).colorScheme.outline, - ), + color: Theme.of(ctx).colorScheme.outline, + ), ), const SizedBox(height: 4), Flexible( @@ -822,8 +809,8 @@ class _EmailDetailScreenState extends ConsumerState { child: Text( row.label, style: Theme.of(ctx).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), + fontFamily: 'monospace', + ), ), ), ], diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index f2f5339..952c7c4 100644 --- a/lib/ui/screens/email_list_screen.dart +++ b/lib/ui/screens/email_list_screen.dart @@ -92,9 +92,9 @@ class _EmailListScreenState extends ConsumerState { } void _clearSelection() => setState(() { - _selectedThreadIds.clear(); - _selectedSearchIds.clear(); - }); + _selectedThreadIds.clear(); + _selectedSearchIds.clear(); + }); void _selectAll() { setState(() { @@ -182,9 +182,8 @@ class _EmailListScreenState extends ConsumerState { AsyncValue accountAsync, { required bool menuAtBottom, }) { - final selectionCount = _searching - ? _selectedSearchIds.length - : _selectedThreadIds.length; + final selectionCount = + _searching ? _selectedSearchIds.length : _selectedThreadIds.length; return AppBar( automaticallyImplyLeading: !menuAtBottom, @@ -278,8 +277,8 @@ class _EmailListScreenState extends ConsumerState { tooltip: isSyncing ? 'Syncing…' : hasError - ? 'Sync error' - : 'Sync', + ? 'Sync error' + : 'Sync', icon: isSyncing ? const SizedBox( width: 20, @@ -287,8 +286,8 @@ class _EmailListScreenState extends ConsumerState { child: CircularProgressIndicator(strokeWidth: 2), ) : hasError - ? const Icon(Icons.sync_problem, color: Colors.red) - : const Icon(Icons.sync), + ? const Icon(Icons.sync_problem, color: Colors.red) + : const Icon(Icons.sync), onPressed: isSyncing ? null : () async { @@ -466,7 +465,9 @@ class _EmailListScreenState extends ConsumerState { // Fetch full email data before moving so we can restore them if user clicks Undo. final originalEmails = (await Future.wait( ids.map((id) => repo.getEmail(id)), - )).whereType().toList(); + )) + .whereType() + .toList(); for (final id in ids) { await repo.moveEmail(id, mailbox.path); @@ -485,10 +486,10 @@ class _EmailListScreenState extends ConsumerState { } Future _batchArchive() => _batchMoveToRole( - 'archive', - dialogTitle: 'No archive folder found', - createFolderName: 'Archive', - ); + 'archive', + dialogTitle: 'No archive folder found', + createFolderName: 'Archive', + ); Future _refreshSearchAndPopIfEmpty() async { if (!mounted || !_searching) return; @@ -527,7 +528,9 @@ class _EmailListScreenState extends ConsumerState { // This is especially important for IMAP where we hard-delete the row locally. final originalEmails = (await Future.wait( ids.map((id) => repo.getEmail(id)), - )).whereType().toList(); + )) + .whereType() + .toList(); String? lastDestPath; for (final id in ids) { @@ -566,10 +569,10 @@ class _EmailListScreenState extends ConsumerState { } Future _batchMarkSpam() => _batchMoveToRole( - 'junk', - dialogTitle: 'No spam folder found', - createFolderName: 'Junk', - ); + 'junk', + dialogTitle: 'No spam folder found', + createFolderName: 'Junk', + ); Future _batchMove() async { final ids = _selectedEmailIds; @@ -577,9 +580,8 @@ class _EmailListScreenState extends ConsumerState { .read(mailboxRepositoryProvider) .observeMailboxes(widget.accountId) .first; - final destinations = mailboxes - .where((m) => m.path != widget.mailboxPath) - .toList(); + final destinations = + mailboxes.where((m) => m.path != widget.mailboxPath).toList(); if (!mounted) return; @@ -611,7 +613,9 @@ class _EmailListScreenState extends ConsumerState { // Fetch full email data before moving so we can restore them if user clicks Undo. final originalEmails = (await Future.wait( ids.map((id) => repo.getEmail(id)), - )).whereType().toList(); + )) + .whereType() + .toList(); for (final id in ids) { await repo.moveEmail(id, chosen); @@ -642,7 +646,9 @@ class _EmailListScreenState extends ConsumerState { // Fetch full email data before snoozing so we can restore them if user clicks Undo. final originalEmails = (await Future.wait( ids.map((id) => repo.getEmail(id)), - )).whereType().toList(); + )) + .whereType() + .toList(); for (final id in ids) { await repo.snoozeEmail(id, until); @@ -683,10 +689,8 @@ class _EmailListScreenState extends ConsumerState { } final t = threads[i]; final isSelected = _selectedThreadIds.contains(t.threadId); - final senderNames = t.participants - .map((a) => a.name ?? a.email) - .take(3) - .join(', '); + final senderNames = + t.participants.map((a) => a.name ?? a.email).take(3).join(', '); final tile = ListTile( leading: SizedBox( @@ -698,9 +702,8 @@ class _EmailListScreenState extends ConsumerState { ) : Icon( t.hasUnread ? Icons.mail : Icons.mail_outline, - color: t.hasUnread - ? Theme.of(ctx).colorScheme.primary - : null, + color: + t.hasUnread ? Theme.of(ctx).colorScheme.primary : null, ), ), title: Row( @@ -760,12 +763,12 @@ class _EmailListScreenState extends ConsumerState { onTap: _selecting ? () => _toggleThreadSelection(t) : t.messageCount > 1 - ? () => context.push( - '/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/threads/${Uri.encodeComponent(t.threadId)}', - ) - : () => context.push( - '/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(t.latestEmailId)}', - ), + ? () => context.push( + '/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/threads/${Uri.encodeComponent(t.threadId)}', + ) + : () => context.push( + '/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(t.latestEmailId)}', + ), onLongPress: () => _toggleThreadSelection(t), ); @@ -773,9 +776,8 @@ class _EmailListScreenState extends ConsumerState { // (single-email threads) or the whole thread. return Dismissible( key: ValueKey(t.threadId), - direction: _selecting - ? DismissDirection.none - : DismissDirection.horizontal, + direction: + _selecting ? DismissDirection.none : DismissDirection.horizontal, background: _swipeBackground( alignment: Alignment.centerLeft, color: Colors.green, @@ -797,7 +799,9 @@ class _EmailListScreenState extends ConsumerState { // Fetch full email data before moving/deleting. final originalEmails = (await Future.wait( t.emailIds.map((id) => repo.getEmail(id)), - )).whereType().toList(); + )) + .whereType() + .toList(); if (direction == DismissDirection.startToEnd) { final archive = await ref diff --git a/lib/ui/screens/search_screen.dart b/lib/ui/screens/search_screen.dart index e36d5b4..903cf70 100644 --- a/lib/ui/screens/search_screen.dart +++ b/lib/ui/screens/search_screen.dart @@ -84,9 +84,10 @@ class _SearchScreenState extends ConsumerState { emailRepo.getEmailsByAddress(widget.accountId, query), ).wait; - final matchedMailboxes = - allMailboxes.where((m) => _hasWordPrefix(m.name, ql)).toList() - ..sort(compareMailboxes); + final matchedMailboxes = allMailboxes + .where((m) => _hasWordPrefix(m.name, ql)) + .toList() + ..sort(compareMailboxes); // Collect unique addresses from address-search results where the // email or display name contains the query. @@ -306,9 +307,8 @@ class _FolderTile extends StatelessWidget { : null, ), subtitle: Text(accountId, style: Theme.of(context).textTheme.bodySmall), - trailing: mb.unreadCount > 0 - ? Badge(label: Text('${mb.unreadCount}')) - : null, + trailing: + mb.unreadCount > 0 ? Badge(label: Text('${mb.unreadCount}')) : null, onTap: () => context.go( '/accounts/$accountId/mailboxes' '/${Uri.encodeComponent(mb.path)}/emails', diff --git a/lib/ui/screens/sieve_script_edit_screen.dart b/lib/ui/screens/sieve_script_edit_screen.dart index e74ec09..a7d2db7 100644 --- a/lib/ui/screens/sieve_script_edit_screen.dart +++ b/lib/ui/screens/sieve_script_edit_screen.dart @@ -56,11 +56,11 @@ class _SieveScriptEditScreenState extends ConsumerState { try { final content = widget.isLocal ? await ref - .read(localSieveRepositoryProvider) - .getScriptContent(widget.accountId, widget.script!.blobId) + .read(localSieveRepositoryProvider) + .getScriptContent(widget.accountId, widget.script!.blobId) : await ref - .read(sieveRepositoryProvider) - .getScriptContent(widget.accountId, widget.script!.blobId); + .read(sieveRepositoryProvider) + .getScriptContent(widget.accountId, widget.script!.blobId); if (mounted) { _contentController.text = content; setState(() => _loadingContent = false); @@ -87,18 +87,14 @@ class _SieveScriptEditScreenState extends ConsumerState { }); try { if (widget.isLocal) { - await ref - .read(localSieveRepositoryProvider) - .saveScript( + await ref.read(localSieveRepositoryProvider).saveScript( widget.accountId, id: widget.script?.id, name: name, content: _contentController.text, ); } else { - await ref - .read(sieveRepositoryProvider) - .saveScript( + await ref.read(sieveRepositoryProvider).saveScript( widget.accountId, id: widget.script?.id, name: name, diff --git a/lib/ui/screens/sieve_scripts_screen.dart b/lib/ui/screens/sieve_scripts_screen.dart index a6fe5d0..d8a52d6 100644 --- a/lib/ui/screens/sieve_scripts_screen.dart +++ b/lib/ui/screens/sieve_scripts_screen.dart @@ -46,11 +46,11 @@ class _SieveScriptsScreenState extends ConsumerState { try { final scripts = widget.isLocal ? await ref - .read(localSieveRepositoryProvider) - .listScripts(widget.accountId) + .read(localSieveRepositoryProvider) + .listScripts(widget.accountId) : await ref - .read(sieveRepositoryProvider) - .listScripts(widget.accountId); + .read(sieveRepositoryProvider) + .listScripts(widget.accountId); if (mounted) { setState(() { _scripts = scripts; @@ -207,10 +207,10 @@ class _SieveSourceBanner extends StatelessWidget { Widget build(BuildContext context) { final text = isLocal ? 'Local Filters run Sieve scripts directly on this device. ' - 'Remote Filters, which run on the mail server, are configured separately.' + 'Remote Filters, which run on the mail server, are configured separately.' : 'Remote Filters run Sieve scripts on the mail server ' - '(ManageSieve or JMAP). ' - 'Local Filters, which run on this device, are configured separately.'; + '(ManageSieve or JMAP). ' + 'Local Filters, which run on this device, are configured separately.'; return Container( width: double.infinity, color: Theme.of(context).colorScheme.surfaceContainerHighest, @@ -228,8 +228,8 @@ class _SieveSourceBanner extends StatelessWidget { child: Text( text, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ), ], diff --git a/lib/ui/screens/sync_log_screen.dart b/lib/ui/screens/sync_log_screen.dart index 85f9018..e706f0b 100644 --- a/lib/ui/screens/sync_log_screen.dart +++ b/lib/ui/screens/sync_log_screen.dart @@ -40,8 +40,8 @@ String _buildSyncEntryMarkdown(SyncLogEntry entry) { final statusLabel = entry.isOk ? 'OK' : entry.isPermanent - ? 'Error (permanent)' - : 'Error'; + ? 'Error (permanent)' + : 'Error'; buf.writeln('| Status | $statusLabel |'); buf.writeln('| Emails fetched | ${entry.emailsFetched} |'); buf.writeln('| Emails up-to-date | ${entry.emailsSkipped} |'); @@ -98,16 +98,16 @@ class _SyncLogScreenState extends ConsumerState { .read(syncLogRepositoryProvider) .observeSyncLogs(widget.accountId) .listen((entries) { - setState(() { - if (_syncing && - _presynCount != null && - entries.length > _presynCount!) { - _syncing = false; - _presynCount = null; - } - _entries = entries; - }); - }); + setState(() { + if (_syncing && + _presynCount != null && + entries.length > _presynCount!) { + _syncing = false; + _presynCount = null; + } + _entries = entries; + }); + }); } @override @@ -125,10 +125,8 @@ class _SyncLogScreenState extends ConsumerState { } Future _copyEntry(SyncLogEntry entry, BuildContext context) async { - final accounts = await ref - .read(accountRepositoryProvider) - .observeAccounts() - .first; + final accounts = + await ref.read(accountRepositoryProvider).observeAccounts().first; final imapCount = accounts.where((a) => a.type == AccountType.imap).length; final jmapCount = accounts.where((a) => a.type == AccountType.jmap).length; @@ -206,17 +204,16 @@ class _SyncLogTile extends StatelessWidget { @override Widget build(BuildContext context) { final durationLabel = _fmtDuration(entry.duration); - final proto = entry.protocol.isEmpty - ? '' - : ' · ${entry.protocol.toUpperCase()}'; + final proto = + entry.protocol.isEmpty ? '' : ' · ${entry.protocol.toUpperCase()}'; final theme = Theme.of(context); final errorColor = theme.colorScheme.error; final subtitleText = entry.isOk ? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel' : entry.isPermanent - ? 'Error (permanent) · took $durationLabel' - : 'Error · took $durationLabel'; + ? 'Error (permanent) · took $durationLabel' + : 'Error · took $durationLabel'; return ExpansionTile( leading: Icon( @@ -341,18 +338,18 @@ class _SyncLogTile extends StatelessWidget { } Widget _row(String label, String value) => Padding( - padding: const EdgeInsets.symmetric(vertical: 1), - child: Row( - children: [ - SizedBox( - width: 180, - child: Text( - label, - style: const TextStyle(fontSize: 12, color: Colors.grey), - ), + padding: const EdgeInsets.symmetric(vertical: 1), + child: Row( + children: [ + SizedBox( + width: 180, + child: Text( + label, + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + ), + Expanded(child: Text(value, style: const TextStyle(fontSize: 12))), + ], ), - Expanded(child: Text(value, style: const TextStyle(fontSize: 12))), - ], - ), - ); + ); } diff --git a/lib/ui/screens/thread_detail_screen.dart b/lib/ui/screens/thread_detail_screen.dart index 47a6a87..2bddb64 100644 --- a/lib/ui/screens/thread_detail_screen.dart +++ b/lib/ui/screens/thread_detail_screen.dart @@ -101,9 +101,8 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> { @override void initState() { super.initState(); - _bodyFuture = ref - .read(emailRepositoryProvider) - .getEmailBody(widget.email.id); + _bodyFuture = + ref.read(emailRepositoryProvider).getEmailBody(widget.email.id); _expanded = widget.isLatest; if (widget.email.isSeen == false) { unawaited( @@ -230,9 +229,8 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> { } void _reply(BuildContext context, EmailBody body, {required bool replyAll}) { - final to = widget.email.from.isNotEmpty - ? widget.email.from.first.email - : ''; + final to = + widget.email.from.isNotEmpty ? widget.email.from.first.email : ''; final subject = (widget.email.subject?.startsWith('Re:') ?? false) ? widget.email.subject! : 'Re: ${widget.email.subject ?? ''}'; @@ -292,9 +290,7 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> { if (!mounted) return; if (original != null) { unawaited( - ref - .read(undoServiceProvider.notifier) - .pushAction( + ref.read(undoServiceProvider.notifier).pushAction( UndoAction( id: DateTime.now().toIso8601String(), accountId: widget.email.accountId, diff --git a/lib/ui/screens/undo_log_screen.dart b/lib/ui/screens/undo_log_screen.dart index 0fe05aa..334e639 100644 --- a/lib/ui/screens/undo_log_screen.dart +++ b/lib/ui/screens/undo_log_screen.dart @@ -25,7 +25,7 @@ class UndoLogScreen extends ConsumerWidget { onPressed: history.isEmpty ? null : () => - unawaited(ref.read(undoServiceProvider.notifier).clear()), + unawaited(ref.read(undoServiceProvider.notifier).clear()), ), ], ), @@ -59,13 +59,13 @@ class _UndoActionTile extends ConsumerWidget { action.type == UndoType.delete ? Icons.delete_outline : (action.type == UndoType.snooze - ? Icons.access_time - : Icons.move_to_inbox), + ? Icons.access_time + : Icons.move_to_inbox), color: action.type == UndoType.delete ? Colors.redAccent : (action.type == UndoType.snooze - ? Colors.orangeAccent - : Colors.blueAccent), + ? Colors.orangeAccent + : Colors.blueAccent), ), title: Text('$subject$extraCount'), subtitle: Column( diff --git a/lib/ui/utils/about_markdown.dart b/lib/ui/utils/about_markdown.dart index 720202b..2f72bd6 100644 --- a/lib/ui/utils/about_markdown.dart +++ b/lib/ui/utils/about_markdown.dart @@ -33,9 +33,8 @@ String buildAboutMarkdown({ final gitCommitLine = _gitHash.isNotEmpty ? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n' : ''; - final deviceModelLine = deviceModel != null - ? '| Device Model | $deviceModel |\n' - : ''; + final deviceModelLine = + deviceModel != null ? '| Device Model | $deviceModel |\n' : ''; return '## [sharedinbox.de](https://sharedinbox.de)\n\n' '| Property | Value |\n' diff --git a/lib/ui/widgets/email_tile.dart b/lib/ui/widgets/email_tile.dart index f2561a7..d8d5794 100644 --- a/lib/ui/widgets/email_tile.dart +++ b/lib/ui/widgets/email_tile.dart @@ -37,17 +37,15 @@ class EmailTile extends StatelessWidget { final date = email.sentAt != null ? _dateFmt.format(email.sentAt!) : ''; return ListTile( - leading: - leading ?? + leading: leading ?? Icon( email.isSeen ? Icons.mail_outline : Icons.mail, color: email.isSeen ? null : Theme.of(context).colorScheme.primary, ), title: Text( sender, - style: email.isSeen - ? null - : const TextStyle(fontWeight: FontWeight.bold), + style: + email.isSeen ? null : const TextStyle(fontWeight: FontWeight.bold), overflow: TextOverflow.ellipsis, ), subtitle: Column( diff --git a/lib/ui/widgets/folder_drawer.dart b/lib/ui/widgets/folder_drawer.dart index 7fd0e34..b4c8dd1 100644 --- a/lib/ui/widgets/folder_drawer.dart +++ b/lib/ui/widgets/folder_drawer.dart @@ -43,9 +43,11 @@ class FolderDrawer extends ConsumerWidget { Text( account?.displayName ?? '', style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.onPrimaryContainer, - fontWeight: FontWeight.bold, - ), + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + fontWeight: FontWeight.bold, + ), ), Text( account?.email ?? '', diff --git a/lib/ui/widgets/secure_email_webview.dart b/lib/ui/widgets/secure_email_webview.dart index 6b2aaec..1e9d852 100644 --- a/lib/ui/widgets/secure_email_webview.dart +++ b/lib/ui/widgets/secure_email_webview.dart @@ -16,8 +16,7 @@ String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) { final imgSrc = loadRemoteImages ? 'https: http: data: blob:' : 'data: blob:'; // script-src 'none' blocks page scripts; JS mode stays unrestricted so the // controller can call runJavaScriptReturningResult for height measurement. - const cspBase = - "default-src 'none'; " + const cspBase = "default-src 'none'; " "style-src 'unsafe-inline'; " "script-src 'none'; " "object-src 'none'; " @@ -107,9 +106,9 @@ class _SecureEmailWebViewState extends State { } String _buildHtml() => buildEmailHtml( - widget.htmlBody, - loadRemoteImages: widget.loadRemoteImages, - ); + widget.htmlBody, + loadRemoteImages: widget.loadRemoteImages, + ); Future _measureHeight(String _) async { try { @@ -141,14 +140,13 @@ class _SecureEmailWebViewState extends State { final host = uri.host; final parts = host.split('.'); // Bold the registered domain (last two DNS labels) to aid phishing detection. - final boldStart = - (parts.length >= 2 - ? host.length - - parts.last.length - - 1 - - parts[parts.length - 2].length - : 0) - .clamp(0, host.length); + final boldStart = (parts.length >= 2 + ? host.length - + parts.last.length - + 1 - + parts[parts.length - 2].length + : 0) + .clamp(0, host.length); final confirmed = await showDialog( context: context, diff --git a/test/backend/account_sync_manager_test.dart b/test/backend/account_sync_manager_test.dart index ad9e661..48e8212 100644 --- a/test/backend/account_sync_manager_test.dart +++ b/test/backend/account_sync_manager_test.dart @@ -16,7 +16,8 @@ Future _fakeImapConnect( Account account, String username, String password, -) async => throw const SocketException('fake — no real IMAP server in tests'); +) async => + throw const SocketException('fake — no real IMAP server in tests'); void main() { test( @@ -83,27 +84,27 @@ void main() { } Account _account(String id) => Account( - id: id, - displayName: 'Account $id', - email: '$id@example.com', - imapHost: 'localhost', - imapPort: 143, - imapSsl: false, - smtpHost: 'localhost', - smtpPort: 25, - smtpSsl: false, -); + id: id, + displayName: 'Account $id', + email: '$id@example.com', + imapHost: 'localhost', + imapPort: 143, + imapSsl: false, + smtpHost: 'localhost', + smtpPort: 25, + smtpSsl: false, + ); Account _jmapAccount(String id) => Account( - id: id, - displayName: 'Account $id', - email: '$id@example.com', - type: AccountType.jmap, - jmapUrl: 'http://localhost:8080/.well-known/jmap', - smtpHost: 'localhost', - smtpPort: 25, - smtpSsl: false, -); + id: id, + displayName: 'Account $id', + email: '$id@example.com', + type: AccountType.jmap, + jmapUrl: 'http://localhost:8080/.well-known/jmap', + smtpHost: 'localhost', + smtpPort: 25, + smtpSsl: false, + ); class _FakeAccounts implements AccountRepository { _FakeAccounts(this.password); @@ -132,16 +133,16 @@ class _FakeAccounts implements AccountRepository { class _FakeMailboxes implements MailboxRepository { @override Stream> observeMailboxes(String? accountId) => Stream.value([ - Mailbox( - id: '$accountId:INBOX', - accountId: accountId ?? '', - path: 'INBOX', - name: 'INBOX', - unreadCount: 0, - totalCount: 0, - role: 'inbox', - ), - ]); + Mailbox( + id: '$accountId:INBOX', + accountId: accountId ?? '', + path: 'INBOX', + name: 'INBOX', + unreadCount: 0, + totalCount: 0, + role: 'inbox', + ), + ]); @override Future syncMailboxes(String accountId) async => 0; @@ -158,15 +159,16 @@ class _FakeMailboxes implements MailboxRepository { String accountId, String name, String role, - ) async => Mailbox( - id: '$accountId:$name', - accountId: accountId, - path: name, - name: name, - role: role, - unreadCount: 0, - totalCount: 0, - ); + ) async => + Mailbox( + id: '$accountId:$name', + accountId: accountId, + path: name, + name: name, + role: role, + unreadCount: 0, + totalCount: 0, + ); } class _FakeEmails implements EmailRepository { @@ -181,7 +183,8 @@ class _FakeEmails implements EmailRepository { String a, String m, { int limit = 50, - }) => Stream.value([]); + }) => + Stream.value([]); @override Stream> observeEmailsInThread(String a, String m, String t) => @@ -225,7 +228,8 @@ class _FakeEmails implements EmailRepository { Future findEmailByMessageId( String accountId, String messageId, - ) async => null; + ) async => + null; @override Future deleteEmail(String id) async => null; @@ -243,7 +247,8 @@ class _FakeEmails implements EmailRepository { Future downloadAttachment( String emailId, EmailAttachment attachment, - ) async => '/tmp/${attachment.filename}'; + ) async => + '/tmp/${attachment.filename}'; @override Future fetchRawRfc822(String emailId) async => ''; @@ -262,7 +267,8 @@ class _FakeEmails implements EmailRepository { String? a, String q, { int limit = 10, - }) async => []; + }) async => + []; @override Stream watchJmapPush(String accountId, String password) => @@ -272,7 +278,8 @@ class _FakeEmails implements EmailRepository { Future verifySyncReliability( String accountId, String mailboxPath, - ) async => ReliabilityResult.healthy; + ) async => + ReliabilityResult.healthy; @override Stream> observeFailedMutations(String accountId) => diff --git a/test/backend/concurrent_sync_test.dart b/test/backend/concurrent_sync_test.dart index 8f5a0c4..1eda29f 100644 --- a/test/backend/concurrent_sync_test.dart +++ b/test/backend/concurrent_sync_test.dart @@ -246,9 +246,8 @@ void main() { ); // Alice and bob each received at least msgCount messages. - final aliceEmails = allEmails - .where((e) => e.accountId == 'alice') - .toList(); + final aliceEmails = + allEmails.where((e) => e.accountId == 'alice').toList(); final bobEmails = allEmails.where((e) => e.accountId == 'bob').toList(); expect( aliceEmails.length, diff --git a/test/backend/email_repository_imap_test.dart b/test/backend/email_repository_imap_test.dart index b11b382..19e92d9 100644 --- a/test/backend/email_repository_imap_test.dart +++ b/test/backend/email_repository_imap_test.dart @@ -138,7 +138,7 @@ void main() { } ({AppDatabase db, AccountRepositoryImpl accounts, EmailRepositoryImpl emails}) - makeRepo() { + makeRepo() { final db = openTestDatabase(); final storage = MapSecureStorage(); final accounts = AccountRepositoryImpl(db, storage); @@ -346,9 +346,7 @@ void main() { final emailId = emails.first.id; // Simulate a legacy row with no cachedAt. - await r.db - .into(r.db.emailBodies) - .insertOnConflictUpdate( + await r.db.into(r.db.emailBodies).insertOnConflictUpdate( EmailBodiesCompanion.insert( emailId: emailId, textBody: const Value('stale text'), @@ -374,9 +372,7 @@ void main() { final emailId = emails.first.id; // Simulate a row cached 8 days ago. - await r.db - .into(r.db.emailBodies) - .insertOnConflictUpdate( + await r.db.into(r.db.emailBodies).insertOnConflictUpdate( EmailBodiesCompanion.insert( emailId: emailId, textBody: const Value('old text'), diff --git a/test/backend/email_repository_jmap_test.dart b/test/backend/email_repository_jmap_test.dart index f4e8595..8cc015b 100644 --- a/test/backend/email_repository_jmap_test.dart +++ b/test/backend/email_repository_jmap_test.dart @@ -107,8 +107,7 @@ void main() { AccountRepositoryImpl accounts, EmailRepositoryImpl emails, MailboxRepositoryImpl mailboxes, - }) - makeRepo() { + }) makeRepo() { final db = openTestDatabase(); final accounts = AccountRepositoryImpl(db, MapSecureStorage()); final emails = EmailRepositoryImpl( @@ -128,13 +127,12 @@ void main() { ) async { await accounts.addAccount(account, userPass); await mailboxes.syncMailboxes('test-jmap'); - final row = - await (db.select(db.mailboxes) - ..where( - (t) => t.accountId.equals('test-jmap') & t.role.equals('inbox'), - ) - ..limit(1)) - .getSingleOrNull(); + final row = await (db.select(db.mailboxes) + ..where( + (t) => t.accountId.equals('test-jmap') & t.role.equals('inbox'), + ) + ..limit(1)) + .getSingleOrNull(); if (row == null) throw StateError('INBOX not found after syncMailboxes'); return row.path; } @@ -272,21 +270,18 @@ void main() { ); // A sent copy should appear in the Sent mailbox. - final sentRow = - await (r.db.select(r.db.mailboxes) - ..where( - (t) => - t.accountId.equals('test-jmap') & t.role.equals('sent'), - ) - ..limit(1)) - .getSingleOrNull(); + final sentRow = await (r.db.select(r.db.mailboxes) + ..where( + (t) => t.accountId.equals('test-jmap') & t.role.equals('sent'), + ) + ..limit(1)) + .getSingleOrNull(); final sentId = sentRow?.path; if (sentId != null) { await r.emails.syncEmails('test-jmap', sentId); - final sentEmails = await r.emails - .observeEmails('test-jmap', sentId) - .first; + final sentEmails = + await r.emails.observeEmails('test-jmap', sentId).first; expect(sentEmails.any((e) => e.subject == subject), isTrue); } else { // If no Sent mailbox exists, just verify sendEmail didn't throw. @@ -353,13 +348,12 @@ void main() { await r.emails.syncEmails('test-jmap', inboxId); // Find a destination mailbox (Trash). - final trashRow = - await (r.db.select(r.db.mailboxes) - ..where( - (t) => t.accountId.equals('test-jmap') & t.role.equals('trash'), - ) - ..limit(1)) - .getSingleOrNull(); + final trashRow = await (r.db.select(r.db.mailboxes) + ..where( + (t) => t.accountId.equals('test-jmap') & t.role.equals('trash'), + ) + ..limit(1)) + .getSingleOrNull(); if (trashRow == null) { markTestSkipped('No trash mailbox found on this Stalwart instance'); return; diff --git a/test/backend/mailbox_repository_imap_test.dart b/test/backend/mailbox_repository_imap_test.dart index 0146e28..acf56b2 100644 --- a/test/backend/mailbox_repository_imap_test.dart +++ b/test/backend/mailbox_repository_imap_test.dart @@ -76,8 +76,7 @@ void main() { AppDatabase db, AccountRepositoryImpl accounts, MailboxRepositoryImpl mailboxes, - }) - makeRepo() { + }) makeRepo() { final db = openTestDatabase(); final accounts = AccountRepositoryImpl(db, MapSecureStorage()); final mailboxes = MailboxRepositoryImpl( diff --git a/test/backend/sync_reliability_test.dart b/test/backend/sync_reliability_test.dart index 49526d0..bcd36db 100644 --- a/test/backend/sync_reliability_test.dart +++ b/test/backend/sync_reliability_test.dart @@ -107,9 +107,7 @@ void main() { 'verifySyncReliability identifies extra local emails (missing on server)', () async { // 1. Manually insert a row into local DB that doesn't exist on server - await db - .into(db.emails) - .insert( + await db.into(db.emails).insert( EmailsCompanion.insert( id: 'test:999', accountId: 'test', diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index 7d71cc7..f03fe70 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -78,7 +78,8 @@ class FakeEmailRepository implements EmailRepository { String a, String m, { int limit = 50, - }) => Stream.value([]); + }) => + Stream.value([]); @override Stream> observeEmailsInThread(String a, String m, String t) => Stream.value([]); @@ -113,7 +114,8 @@ class FakeEmailRepository implements EmailRepository { Future findEmailByMessageId( String accountId, String messageId, - ) async => null; + ) async => + null; @override Future deleteEmail(String id) async => null; @@ -138,7 +140,8 @@ class FakeEmailRepository implements EmailRepository { String? a, String q, { int limit = 10, - }) async => []; + }) async => + []; @override Stream watchJmapPush(String a, String p) => const Stream.empty(); @override @@ -153,7 +156,8 @@ class FakeEmailRepository implements EmailRepository { Future verifySyncReliability( String accountId, String mailboxPath, - ) async => ReliabilityResult.healthy; + ) async => + ReliabilityResult.healthy; @override Future clearForResync(String accountId) async {} @@ -201,16 +205,16 @@ class FakeSyncLogRepository implements SyncLogRepository { class FakeMailboxRepositoryWithInbox implements MailboxRepository { @override Stream> observeMailboxes(String? accountId) => Stream.value([ - const Mailbox( - id: '1:INBOX', - accountId: '1', - path: 'INBOX', - name: 'INBOX', - unreadCount: 0, - totalCount: 0, - role: 'inbox', - ), - ]); + const Mailbox( + id: '1:INBOX', + accountId: '1', + path: 'INBOX', + name: 'INBOX', + unreadCount: 0, + totalCount: 0, + role: 'inbox', + ), + ]); @override Future syncMailboxes(String id) async => 1; @override @@ -222,15 +226,16 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository { String accountId, String name, String role, - ) async => Mailbox( - id: '$accountId:$name', - accountId: accountId, - path: name, - name: name, - role: role, - unreadCount: 0, - totalCount: 0, - ); + ) async => + Mailbox( + id: '$accountId:$name', + accountId: accountId, + path: name, + name: name, + role: role, + unreadCount: 0, + totalCount: 0, + ); } class _AccountRepositoryWithMissingPlugin implements AccountRepository { @@ -248,11 +253,11 @@ class _AccountRepositoryWithMissingPlugin implements AccountRepository { @override Future getPassword(String accountId) => Future.error( - MissingPluginException( - 'No implementation found for method read on channel ' - 'plugins.it.nomads.com/flutter_secure_storage', - ), - ); + MissingPluginException( + 'No implementation found for method read on channel ' + 'plugins.it.nomads.com/flutter_secure_storage', + ), + ); @override Future addAccount(Account account, String password) async {} diff --git a/test/unit/apply_sieve_rules_test.dart b/test/unit/apply_sieve_rules_test.dart index 1adcad9..e09bc9a 100644 --- a/test/unit/apply_sieve_rules_test.dart +++ b/test/unit/apply_sieve_rules_test.dart @@ -40,9 +40,7 @@ Future _insertInboxEmail( String from = 'sender@example.com', String mailboxPath = 'INBOX', }) async { - await db - .into(db.emails) - .insert( + await db.into(db.emails).insert( EmailsCompanion.insert( id: id, accountId: _account.id, @@ -59,9 +57,7 @@ Future _insertInboxEmail( ), ); // Insert a thread row so _updateThread does not throw. - await db - .into(db.threads) - .insertOnConflictUpdate( + await db.into(db.threads).insertOnConflictUpdate( ThreadsCompanion.insert( id: id, accountId: _account.id, @@ -75,9 +71,7 @@ Future _insertInboxEmail( /// Creates an active Sieve script for the test account. Future _insertSieveScript(AppDatabase db, String content) async { - await db - .into(db.localSieveScripts) - .insert( + await db.into(db.localSieveScripts).insert( LocalSieveScriptsCompanion.insert( accountId: _account.id, name: 'test-script', @@ -224,9 +218,7 @@ if header :contains "subject" ["SPAM"] { } '''); // Insert without messageId. - await db - .into(db.emails) - .insert( + await db.into(db.emails).insert( EmailsCompanion.insert( id: 'sieve-acc:2', accountId: _account.id, @@ -236,9 +228,7 @@ if header :contains "subject" ["SPAM"] { receivedAt: DateTime.now(), ), ); - await db - .into(db.threads) - .insertOnConflictUpdate( + await db.into(db.threads).insertOnConflictUpdate( ThreadsCompanion.insert( id: 'sieve-acc:2', accountId: _account.id, diff --git a/test/unit/cid_utils_test.dart b/test/unit/cid_utils_test.dart index 93d4d43..09ce347 100644 --- a/test/unit/cid_utils_test.dart +++ b/test/unit/cid_utils_test.dart @@ -59,8 +59,7 @@ void main() { test('leaves HTML unchanged when there are no inline parts', () { // A plain text-only message. - const plainMime = - 'MIME-Version: 1.0\r\n' + const plainMime = 'MIME-Version: 1.0\r\n' 'Content-Type: text/plain\r\n' '\r\n' 'Hello'; diff --git a/test/unit/connection_test_service_test.dart b/test/unit/connection_test_service_test.dart index 5b6297b..fc3d5ba 100644 --- a/test/unit/connection_test_service_test.dart +++ b/test/unit/connection_test_service_test.dart @@ -23,8 +23,7 @@ const _jmapAccount = Account( jmapUrl: 'https://example.com/jmap/session', ); -const _jmapSessionJson = - '{' +const _jmapSessionJson = '{' '"capabilities":{"urn:ietf:params:jmap:core":{},"urn:ietf:params:jmap:mail":{}},' '"accounts":{},"primaryAccounts":{},"username":"alice@example.com",' '"apiUrl":"https://example.com/jmap/","downloadUrl":"","uploadUrl":"","state":"0"' @@ -117,15 +116,14 @@ void main() { MockClient((_) async => http.Response('', 200)), imapConnect: (_, __, ___) async => FakeImapClient(), smtpConnect: (_, __, ___) async => FakeSmtpClient(), - manageSieveConnect: - ({ - required String host, - required int port, - required bool useTls, - }) async { - sieveCalled = true; - throw Exception('should not be called'); - }, + manageSieveConnect: ({ + required String host, + required int port, + required bool useTls, + }) async { + sieveCalled = true; + throw Exception('should not be called'); + }, ); await svc.testConnection(_imapAccount, 'pw'); expect(sieveCalled, false); @@ -144,12 +142,12 @@ void main() { MockClient((_) async => http.Response('', 200)), imapConnect: (_, __, ___) async => FakeImapClient(), smtpConnect: (_, __, ___) async => FakeSmtpClient(), - manageSieveConnect: - ({ - required String host, - required int port, - required bool useTls, - }) async => throw Exception('sieve boom'), + manageSieveConnect: ({ + required String host, + required int port, + required bool useTls, + }) async => + throw Exception('sieve boom'), ); expect( () => svc.testConnection(accountWithSieve, 'pw'), diff --git a/test/unit/email_model_test.dart b/test/unit/email_model_test.dart index 5b91a6d..9f3adcb 100644 --- a/test/unit/email_model_test.dart +++ b/test/unit/email_model_test.dart @@ -8,8 +8,8 @@ import 'package:test/test.dart'; // Mirrors the encoding logic in EmailRepositoryImpl so we can test it // independently without spinning up a database. String encodeAddresses(List addresses) => jsonEncode( - addresses.map((a) => {'name': a.name, 'email': a.email}).toList(), -); + addresses.map((a) => {'name': a.name, 'email': a.email}).toList(), + ); List decodeAddresses(String json) { final list = jsonDecode(json) as List; diff --git a/test/unit/email_repository_cancel_change_test.dart b/test/unit/email_repository_cancel_change_test.dart index 2c9cd5d..e815a9f 100644 --- a/test/unit/email_repository_cancel_change_test.dart +++ b/test/unit/email_repository_cancel_change_test.dart @@ -34,9 +34,7 @@ void main() { }); test('cancelPendingChange removes an unattempted change', () async { - await db - .into(db.pendingChanges) - .insert( + await db.into(db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'acc1', resourceType: 'Email', @@ -55,9 +53,7 @@ void main() { }); test('cancelPendingChange does not remove attempted changes', () async { - await db - .into(db.pendingChanges) - .insert( + await db.into(db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'acc1', resourceType: 'Email', @@ -78,9 +74,7 @@ void main() { test('cancelPendingChange only removes the latest matching change', () async { final now = DateTime.now(); - await db - .into(db.pendingChanges) - .insert( + await db.into(db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'acc1', resourceType: 'Email', @@ -90,9 +84,7 @@ void main() { createdAt: now, ), ); - await db - .into(db.pendingChanges) - .insert( + await db.into(db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'acc1', resourceType: 'Email', diff --git a/test/unit/email_repository_contract_test.dart b/test/unit/email_repository_contract_test.dart index d4bc70d..c001ee3 100644 --- a/test/unit/email_repository_contract_test.dart +++ b/test/unit/email_repository_contract_test.dart @@ -186,9 +186,7 @@ class _EmailRepositoryImplContract extends EmailRepositoryContract { bool isFlagged = false, DateTime? receivedAt, }) async { - await _db - .into(_db.emails) - .insert( + await _db.into(_db.emails).insert( EmailsCompanion.insert( id: id, accountId: _account.id, diff --git a/test/unit/email_repository_impl_test.dart b/test/unit/email_repository_impl_test.dart index c3ca5cb..256ea0b 100644 --- a/test/unit/email_repository_impl_test.dart +++ b/test/unit/email_repository_impl_test.dart @@ -68,25 +68,26 @@ Map _emailGetResponse({ required String state, required List> list, int? total, -}) => { - 'sessionState': 'sess1', - 'methodResponses': [ - [ - 'Email/query', - { - 'accountId': 'acct1', - 'ids': list.map((e) => e['id']).toList(), - 'total': total ?? list.length, - }, - '0', - ], - [ - 'Email/get', - {'accountId': 'acct1', 'state': state, 'list': list}, - '1', - ], - ], -}; +}) => + { + 'sessionState': 'sess1', + 'methodResponses': [ + [ + 'Email/query', + { + 'accountId': 'acct1', + 'ids': list.map((e) => e['id']).toList(), + 'total': total ?? list.length, + }, + '0', + ], + [ + 'Email/get', + {'accountId': 'acct1', 'state': state, 'list': list}, + '1', + ], + ], + }; Map _emailChangesResponse({ required String oldState, @@ -94,38 +95,40 @@ Map _emailChangesResponse({ List created = const [], List updated = const [], List destroyed = const [], -}) => { - 'sessionState': 'sess1', - 'methodResponses': [ - [ - 'Email/changes', - { - 'accountId': 'acct1', - 'oldState': oldState, - 'newState': newState, - 'hasMoreChanges': false, - 'created': created, - 'updated': updated, - 'destroyed': destroyed, - }, - '0', - ], - ], -}; +}) => + { + 'sessionState': 'sess1', + 'methodResponses': [ + [ + 'Email/changes', + { + 'accountId': 'acct1', + 'oldState': oldState, + 'newState': newState, + 'hasMoreChanges': false, + 'created': created, + 'updated': updated, + 'destroyed': destroyed, + }, + '0', + ], + ], + }; Map _emailGetOnly({ required String state, required List> list, -}) => { - 'sessionState': 'sess1', - 'methodResponses': [ - [ - 'Email/get', - {'accountId': 'acct1', 'state': state, 'list': list}, - '1', - ], - ], -}; +}) => + { + 'sessionState': 'sess1', + 'methodResponses': [ + [ + 'Email/get', + {'accountId': 'acct1', 'state': state, 'list': list}, + '1', + ], + ], + }; Map _jmapEmail({ required String id, @@ -133,24 +136,25 @@ Map _jmapEmail({ String subject = 'Hello', bool seen = false, String? threadId, -}) => { - 'id': id, - 'mailboxIds': {mailboxId: true}, - 'subject': subject, - 'sentAt': '2024-01-01T10:00:00Z', - 'receivedAt': '2024-01-01T10:00:01Z', - 'from': [ - {'name': 'Sender', 'email': 'sender@example.com'}, - ], - 'to': [ - {'name': 'Alice', 'email': 'alice@example.com'}, - ], - 'cc': [], - 'keywords': seen ? {r'$seen': true} : {}, - 'hasAttachment': false, - 'preview': 'Hello world', - 'threadId': threadId, -}; +}) => + { + 'id': id, + 'mailboxIds': {mailboxId: true}, + 'subject': subject, + 'sentAt': '2024-01-01T10:00:00Z', + 'receivedAt': '2024-01-01T10:00:01Z', + 'from': [ + {'name': 'Sender', 'email': 'sender@example.com'}, + ], + 'to': [ + {'name': 'Alice', 'email': 'alice@example.com'}, + ], + 'cc': [], + 'keywords': seen ? {r'$seen': true} : {}, + 'hasAttachment': false, + 'preview': 'Hello world', + 'threadId': threadId, + }; Future _noImapConnect(Account a, String u, String p) => Future.error(UnsupportedError('IMAP unavailable in unit tests')); @@ -159,7 +163,7 @@ Future _noSmtpConnect(Account a, String u, String p) => Future.error(UnsupportedError('SMTP unavailable in unit tests')); ({AppDatabase db, AccountRepositoryImpl accounts, EmailRepositoryImpl emails}) -_makeRepos({ + _makeRepos({ http.Client? httpClient, Future Function(Account, String, String)? imapConnect, Future Function(Account, String, String)? smtpConnect, @@ -199,9 +203,7 @@ void main() { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:42', accountId: 'acc-1', @@ -221,9 +223,7 @@ void main() { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:7', accountId: 'acc-1', @@ -247,9 +247,7 @@ void main() { (3, DateTime(2024, 3)), (2, DateTime(2024, 2)), ]) { - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:$uid', accountId: 'acc-1', @@ -276,9 +274,7 @@ void main() { test('getEmailBody propagates IMAP error when not cached', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:1', accountId: 'acc-1', @@ -296,9 +292,7 @@ void main() { test('getEmailBody returns cached body without IMAP call', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:1', accountId: 'acc-1', @@ -307,9 +301,7 @@ void main() { receivedAt: DateTime(2024), ), ); - await r.db - .into(r.db.emailBodies) - .insert( + await r.db.into(r.db.emailBodies).insert( EmailBodiesCompanion.insert( emailId: 'acc-1:1', textBody: const Value('Hello'), @@ -330,9 +322,7 @@ void main() { await r.accounts.addAccount(_account, 'pw'); final now = DateTime.now(); - await r.db - .into(r.db.threads) - .insert( + await r.db.into(r.db.threads).insert( ThreadsCompanion.insert( id: 'tid1', accountId: 'acc-1', @@ -359,9 +349,7 @@ void main() { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:1', accountId: 'acc-1', @@ -371,9 +359,7 @@ void main() { receivedAt: DateTime(2024), ), ); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:2', accountId: 'acc-1', @@ -384,9 +370,8 @@ void main() { ), ); - final emails = await r.emails - .observeEmailsInThread('acc-1', 'INBOX', 'tid1') - .first; + final emails = + await r.emails.observeEmailsInThread('acc-1', 'INBOX', 'tid1').first; expect(emails, hasLength(2)); expect(emails.map((e) => e.id).toSet(), {'acc-1:1', 'acc-1:2'}); }); @@ -401,9 +386,7 @@ void main() { 'pw', ); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:1', accountId: 'acc-1', @@ -413,9 +396,7 @@ void main() { receivedAt: DateTime(2024), ), ); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-2:1', accountId: 'acc-2', @@ -444,9 +425,7 @@ void main() { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:1', accountId: 'acc-1', @@ -456,9 +435,7 @@ void main() { receivedAt: DateTime(2024), ), ); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:2', accountId: 'acc-1', @@ -486,9 +463,7 @@ void main() { final newer = DateTime(2024, 6); // Two emails — older one has alice@, newer one has bob@. - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:old', accountId: 'acc-1', @@ -500,9 +475,7 @@ void main() { ), ), ); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:new', accountId: 'acc-1', @@ -531,9 +504,7 @@ void main() { () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:5', accountId: 'acc-1', @@ -559,9 +530,7 @@ void main() { () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:5', accountId: 'acc-1', @@ -585,9 +554,7 @@ void main() { test('setFlag flagged=true enqueues flag_flagged change', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:5', accountId: 'acc-1', @@ -610,9 +577,7 @@ void main() { () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:5', accountId: 'acc-1', @@ -636,9 +601,7 @@ void main() { () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:5', accountId: 'acc-1', @@ -665,9 +628,7 @@ void main() { () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:5', accountId: 'acc-1', @@ -691,9 +652,7 @@ void main() { final r = _makeRepos(); // _makeRepos uses _noImapConnect which throws UnsupportedError await r.accounts.addAccount(_account, 'pw'); - await r.db - .into(r.db.pendingChanges) - .insert( + await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'acc-1', resourceType: 'Email', @@ -714,9 +673,7 @@ void main() { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); // Pre-seed a flag_seen at attempts=4 - await r.db - .into(r.db.pendingChanges) - .insert( + await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: _account.id, resourceType: 'Email', @@ -748,9 +705,7 @@ void main() { final spy = SnoozeSpyImapClient(); final r = _makeRepos(imapConnect: (_, __, ___) async => spy); await r.accounts.addAccount(_account, 'pw'); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:5', accountId: 'acc-1', @@ -759,9 +714,7 @@ void main() { receivedAt: DateTime(2024), ), ); - await r.db - .into(r.db.pendingChanges) - .insert( + await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'acc-1', resourceType: 'Email', @@ -793,9 +746,7 @@ void main() { test('snoozeEmail enqueues snooze change and updates local DB', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:5', accountId: 'acc-1', @@ -823,9 +774,7 @@ void main() { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); // Seed Inbox mailbox - await r.db - .into(r.db.mailboxes) - .insert( + await r.db.into(r.db.mailboxes).insert( MailboxesCompanion.insert( id: 'acc-1:INBOX', accountId: 'acc-1', @@ -836,9 +785,7 @@ void main() { ); final past = DateTime.now().subtract(const Duration(hours: 1)); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:5', accountId: 'acc-1', @@ -867,65 +814,64 @@ void main() { http.Client mockBodyClient({ String text = 'Hello from JMAP', String html = '

Hello from JMAP

', - }) => MockClient((req) async { - if (req.url.path.contains('well-known')) { - return http.Response( - jsonEncode({ - 'apiUrl': 'https://jmap.example.com/api/', - 'accounts': { - 'acct1': {'name': 'alice@example.com', 'isPersonal': true}, - }, - 'primaryAccounts': { - 'urn:ietf:params:jmap:core': 'acct1', - 'urn:ietf:params:jmap:mail': 'acct1', - }, - 'capabilities': {}, - 'username': 'alice@example.com', - 'state': 'sess1', - }), - 200, - ); - } - return http.Response( - jsonEncode({ - 'sessionState': 'sess1', - 'methodResponses': [ - [ - 'Email/get', - { - 'accountId': 'acct1', - 'state': 'es1', - 'list': [ + }) => + MockClient((req) async { + if (req.url.path.contains('well-known')) { + return http.Response( + jsonEncode({ + 'apiUrl': 'https://jmap.example.com/api/', + 'accounts': { + 'acct1': {'name': 'alice@example.com', 'isPersonal': true}, + }, + 'primaryAccounts': { + 'urn:ietf:params:jmap:core': 'acct1', + 'urn:ietf:params:jmap:mail': 'acct1', + }, + 'capabilities': {}, + 'username': 'alice@example.com', + 'state': 'sess1', + }), + 200, + ); + } + return http.Response( + jsonEncode({ + 'sessionState': 'sess1', + 'methodResponses': [ + [ + 'Email/get', { - 'id': 'e1', - 'textBody': [ - {'partId': '1', 'type': 'text/plain'}, + 'accountId': 'acct1', + 'state': 'es1', + 'list': [ + { + 'id': 'e1', + 'textBody': [ + {'partId': '1', 'type': 'text/plain'}, + ], + 'htmlBody': [ + {'partId': '2', 'type': 'text/html'}, + ], + 'bodyValues': { + '1': {'value': text, 'isTruncated': false}, + '2': {'value': html, 'isTruncated': false}, + }, + 'attachments': [], + }, ], - 'htmlBody': [ - {'partId': '2', 'type': 'text/html'}, - ], - 'bodyValues': { - '1': {'value': text, 'isTruncated': false}, - '2': {'value': html, 'isTruncated': false}, - }, - 'attachments': [], }, + '0', ], - }, - '0', - ], - ], - }), - 200, - ); - }); + ], + }), + 200, + ); + }); test('fetches body via JMAP Email/get and caches it', () async { final r = _makeRepos(httpClient: mockBodyClient()); await r.accounts.addAccount(_jmapAccount, 'pw'); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'jmap-1:e1', accountId: 'jmap-1', @@ -994,9 +940,7 @@ void main() { }), ); await r.accounts.addAccount(_jmapAccount, 'pw'); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'jmap-1:e1', accountId: 'jmap-1', @@ -1075,9 +1019,7 @@ void main() { }), ); await r.accounts.addAccount(_jmapAccount, 'pw'); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'jmap-1:e1', accountId: 'jmap-1', @@ -1107,9 +1049,7 @@ void main() { test('mimeTree is null when bodyStructure is absent', () async { final r = _makeRepos(httpClient: mockBodyClient()); await r.accounts.addAccount(_jmapAccount, 'pw'); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'jmap-1:e1', accountId: 'jmap-1', @@ -1188,9 +1128,7 @@ void main() { await r.accounts.addAccount(_jmapAccount, 'pw'); // Pre-populate - await r.db - .into(r.db.emails) - .insertOnConflictUpdate( + await r.db.into(r.db.emails).insertOnConflictUpdate( EmailsCompanion.insert( id: 'jmap-1:e1', accountId: 'jmap-1', @@ -1200,9 +1138,7 @@ void main() { receivedAt: DateTime(2024), ), ); - await r.db - .into(r.db.emails) - .insertOnConflictUpdate( + await r.db.into(r.db.emails).insertOnConflictUpdate( EmailsCompanion.insert( id: 'jmap-1:e2', accountId: 'jmap-1', @@ -1212,9 +1148,7 @@ void main() { receivedAt: DateTime(2024), ), ); - await r.db - .into(r.db.syncStates) - .insertOnConflictUpdate( + await r.db.into(r.db.syncStates).insertOnConflictUpdate( SyncStatesCompanion.insert( accountId: 'jmap-1', resourceType: 'Email', @@ -1241,9 +1175,7 @@ void main() { ), ); await r.accounts.addAccount(_jmapAccount, 'pw'); - await r.db - .into(r.db.syncStates) - .insertOnConflictUpdate( + await r.db.into(r.db.syncStates).insertOnConflictUpdate( SyncStatesCompanion.insert( accountId: 'jmap-1', resourceType: 'Email', @@ -1298,9 +1230,7 @@ void main() { AccountRepositoryImpl accounts, ) async { await accounts.addAccount(_jmapAccount, 'pw'); - await db - .into(db.emails) - .insert( + await db.into(db.emails).insert( EmailsCompanion.insert( id: 'jmap-1:e1', accountId: 'jmap-1', @@ -1416,9 +1346,7 @@ void main() { String payload = '{"seen":true}', }) async { await accounts.addAccount(_jmapAccount, 'pw'); - await db - .into(db.pendingChanges) - .insert( + await db.into(db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'jmap-1', resourceType: 'Email', @@ -1532,9 +1460,7 @@ void main() { final r = _makeRepos(httpClient: client); await r.accounts.addAccount(_jmapAccount, 'pw'); - await r.db - .into(r.db.syncStates) - .insertOnConflictUpdate( + await r.db.into(r.db.syncStates).insertOnConflictUpdate( SyncStatesCompanion.insert( accountId: 'jmap-1', resourceType: 'Email', @@ -1542,9 +1468,7 @@ void main() { syncedAt: DateTime.now(), ), ); - await r.db - .into(r.db.pendingChanges) - .insert( + await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'jmap-1', resourceType: 'Email', @@ -1605,9 +1529,7 @@ void main() { final r = _makeRepos(httpClient: client); await r.accounts.addAccount(_jmapAccount, 'pw'); - await r.db - .into(r.db.syncStates) - .insertOnConflictUpdate( + await r.db.into(r.db.syncStates).insertOnConflictUpdate( SyncStatesCompanion.insert( accountId: 'jmap-1', resourceType: 'Email', @@ -1615,9 +1537,7 @@ void main() { syncedAt: DateTime.now(), ), ); - await r.db - .into(r.db.pendingChanges) - .insert( + await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'jmap-1', resourceType: 'Email', @@ -1682,9 +1602,7 @@ void main() { final r = _makeRepos(httpClient: client); await r.accounts.addAccount(_jmapAccount, 'pw'); - await r.db - .into(r.db.pendingChanges) - .insert( + await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'jmap-1', resourceType: 'Email', @@ -1706,9 +1624,7 @@ void main() { final r = _makeRepos(httpClient: mockFlush(500)); await r.accounts.addAccount(_jmapAccount, 'pw'); // Seed a change already at attempts=4 (one below the eviction threshold) - await r.db - .into(r.db.pendingChanges) - .insert( + await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'jmap-1', resourceType: 'Email', @@ -1813,12 +1729,10 @@ void main() { expect(firstCall, 'Mailbox/set'); // Second call should be Email/set using the newly created mailbox ID. - final secondCallArgs = - ((capturedBodies[1]['methodCalls'] as List).first as List)[1] - as Map; - final update = - (secondCallArgs['update'] as Map)['e1'] - as Map; + final secondCallArgs = ((capturedBodies[1]['methodCalls'] as List).first + as List)[1] as Map; + final update = (secondCallArgs['update'] as Map)['e1'] + as Map; expect(update['mailboxIds/mbx-snoozed'], true); }, ); @@ -1853,30 +1767,31 @@ void main() { required String mailboxId, String? textContent, String? htmlContent, - }) => { - ..._jmapEmail(id: id, mailboxId: mailboxId), - 'textBody': [ - if (textContent != null) {'partId': 'text1', 'type': 'text/plain'}, - ], - 'htmlBody': [ - if (htmlContent != null) {'partId': 'html1', 'type': 'text/html'}, - ], - 'bodyValues': { - if (textContent != null) - 'text1': { - 'value': textContent, - 'isEncodingProblem': false, - 'isTruncated': false, + }) => + { + ..._jmapEmail(id: id, mailboxId: mailboxId), + 'textBody': [ + if (textContent != null) {'partId': 'text1', 'type': 'text/plain'}, + ], + 'htmlBody': [ + if (htmlContent != null) {'partId': 'html1', 'type': 'text/html'}, + ], + 'bodyValues': { + if (textContent != null) + 'text1': { + 'value': textContent, + 'isEncodingProblem': false, + 'isTruncated': false, + }, + if (htmlContent != null) + 'html1': { + 'value': htmlContent, + 'isEncodingProblem': false, + 'isTruncated': false, + }, }, - if (htmlContent != null) - 'html1': { - 'value': htmlContent, - 'isEncodingProblem': false, - 'isTruncated': false, - }, - }, - 'attachments': [], - }; + 'attachments': [], + }; test('full sync caches bodies when bodyValues are present', () async { final r = _makeRepos( @@ -2164,9 +2079,7 @@ void main() { final r = _makeRepos(httpClient: client); await r.accounts.addAccount(_jmapAccount, 'pw'); // Seed a Sent mailbox with role='sent' - await r.db - .into(r.db.mailboxes) - .insert( + await r.db.into(r.db.mailboxes).insert( MailboxesCompanion.insert( id: 'jmap-1:sentMbx', accountId: 'jmap-1', @@ -2267,9 +2180,7 @@ void main() { // no IMAP connection was made. final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:1', accountId: 'acc-1', @@ -2278,9 +2189,7 @@ void main() { receivedAt: DateTime(2024), ), ); - await r.db - .into(r.db.emailBodies) - .insertOnConflictUpdate( + await r.db.into(r.db.emailBodies).insertOnConflictUpdate( EmailBodiesCompanion.insert( emailId: 'acc-1:1', textBody: const Value('cached text'), @@ -2300,9 +2209,7 @@ void main() { test('observeFailedMutations emits only rows with lastError set', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); - await r.db - .into(r.db.pendingChanges) - .insert( + await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'acc-1', resourceType: 'email', @@ -2313,9 +2220,7 @@ void main() { lastError: const Value('network error'), ), ); - await r.db - .into(r.db.pendingChanges) - .insert( + await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'acc-1', resourceType: 'email', @@ -2338,9 +2243,7 @@ void main() { test('discardMutation removes the row', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); - final rowId = await r.db - .into(r.db.pendingChanges) - .insert( + final rowId = await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'acc-1', resourceType: 'email', @@ -2362,9 +2265,7 @@ void main() { test('retryMutation resets attempts and clears lastError', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); - final rowId = await r.db - .into(r.db.pendingChanges) - .insert( + final rowId = await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'acc-1', resourceType: 'email', @@ -2391,9 +2292,7 @@ void main() { () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:5', accountId: 'acc-1', @@ -2412,9 +2311,8 @@ void main() { expect(changes, hasLength(2)); expect(changes.map((c) => c.changeType), everyElement('move')); - final destinations = changes - .map((c) => (jsonDecode(c.payload) as Map)['dest']) - .toSet(); + final destinations = + changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet(); expect(destinations, containsAll(['Archive', 'Trash'])); final email = await r.emails.getEmail('acc-1:5'); @@ -2467,9 +2365,7 @@ void main() { await r.accounts.addAccount(_account, 'pw'); // Pre-seed two emails from the old server epoch (uidValidity=123). - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:1', accountId: 'acc-1', @@ -2478,9 +2374,7 @@ void main() { receivedAt: DateTime(2024), ), ); - await r.db - .into(r.db.emails) - .insert( + await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:2', accountId: 'acc-1', @@ -2492,9 +2386,7 @@ void main() { // Seed an IMAP checkpoint with the old uidValidity so the code detects // a mismatch and triggers a full re-sync. - await r.db - .into(r.db.syncStates) - .insertOnConflictUpdate( + await r.db.into(r.db.syncStates).insertOnConflictUpdate( SyncStatesCompanion.insert( accountId: 'acc-1', resourceType: 'IMAP:INBOX', @@ -2510,13 +2402,13 @@ void main() { expect(remaining, isEmpty); // Checkpoint must be updated to the new uidValidity. - final stateRow = - await (r.db.select(r.db.syncStates)..where( - (t) => - t.accountId.equals('acc-1') & - t.resourceType.equals('IMAP:INBOX'), - )) - .getSingleOrNull(); + final stateRow = await (r.db.select(r.db.syncStates) + ..where( + (t) => + t.accountId.equals('acc-1') & + t.resourceType.equals('IMAP:INBOX'), + )) + .getSingleOrNull(); expect(stateRow, isNotNull); final state = jsonDecode(stateRow!.state) as Map; expect(state['uidValidity'], 456); @@ -2535,20 +2427,22 @@ class _FakeImapClientUidValidity extends FakeImapClient { String path, { bool enableCondStore = false, imap.QResyncParameters? qresync, - }) async => imap.Mailbox( - encodedName: path, - encodedPath: path, - flags: [], - pathSeparator: '/', - uidValidity: _uidValidity, - ); + }) async => + imap.Mailbox( + encodedName: path, + encodedPath: path, + flags: [], + pathSeparator: '/', + uidValidity: _uidValidity, + ); @override Future uidSearchMessages({ String searchCriteria = 'ALL', List? returnOptions, Duration? responseTimeout, - }) async => imap.SearchImapResult(); + }) async => + imap.SearchImapResult(); } // ── SSE test helper ────────────────────────────────────────────────────────── diff --git a/test/unit/fake_imap.dart b/test/unit/fake_imap.dart index 801f3e8..0df8b84 100644 --- a/test/unit/fake_imap.dart +++ b/test/unit/fake_imap.dart @@ -24,11 +24,11 @@ class SnoozeSpyImapClient extends FakeImapClient { String? movedToMailbox; imap.Mailbox _fakeMailbox(String path) => imap.Mailbox( - encodedName: path, - encodedPath: path, - pathSeparator: '/', - flags: [], - ); + encodedName: path, + encodedPath: path, + pathSeparator: '/', + flags: [], + ); @override Future selectMailboxByPath( @@ -53,7 +53,8 @@ class SnoozeSpyImapClient extends FakeImapClient { imap.StoreAction? action, bool? silent, int? unchangedSinceModSequence, - }) async => imap.StoreImapResult(); + }) async => + imap.StoreImapResult(); @override Future uidMove( @@ -71,7 +72,8 @@ class SnoozeSpyImapClient extends FakeImapClient { String? fetchContentDefinition, { int? changedSinceModSequence, Duration? responseTimeout, - }) async => const imap.FetchImapResult([], null); + }) async => + const imap.FetchImapResult([], null); } /// Minimal fake SMTP client; only `quit` is exercised by ConnectionTestService. diff --git a/test/unit/html_utils_test.dart b/test/unit/html_utils_test.dart index 49efccf..010bfb9 100644 --- a/test/unit/html_utils_test.dart +++ b/test/unit/html_utils_test.dart @@ -56,8 +56,7 @@ void main() { }); test('real-world HTML email snippet', () { - const html = - '

Hello Alice,

' + const html = '

Hello Alice,

' '

Please find the invoice attached.

' '

Best regards,
Bob

'; final result = htmlToPlain(html); diff --git a/test/unit/jmap_client_test.dart b/test/unit/jmap_client_test.dart index d41fbb5..dee4770 100644 --- a/test/unit/jmap_client_test.dart +++ b/test/unit/jmap_client_test.dart @@ -11,23 +11,23 @@ const _apiUrl = 'https://jmap.example.com/api/'; const _accountId = 'u1'; Map _sessionBody({String? apiUrl, String? accountId}) => { - 'apiUrl': apiUrl ?? _apiUrl, - 'accounts': { - accountId ?? _accountId: { - 'name': 'alice@example.com', - 'isPersonal': true, - 'isReadOnly': false, - 'accountCapabilities': {}, - }, - }, - 'primaryAccounts': { - 'urn:ietf:params:jmap:core': accountId ?? _accountId, - 'urn:ietf:params:jmap:mail': accountId ?? _accountId, - }, - 'capabilities': {}, - 'username': 'alice@example.com', - 'state': 'st1', -}; + 'apiUrl': apiUrl ?? _apiUrl, + 'accounts': { + accountId ?? _accountId: { + 'name': 'alice@example.com', + 'isPersonal': true, + 'isReadOnly': false, + 'accountCapabilities': {}, + }, + }, + 'primaryAccounts': { + 'urn:ietf:params:jmap:core': accountId ?? _accountId, + 'urn:ietf:params:jmap:mail': accountId ?? _accountId, + }, + 'capabilities': {}, + 'username': 'alice@example.com', + 'state': 'st1', + }; http.Client _sessionClient({ int sessionStatus = 200, diff --git a/test/unit/mailbox_repository_contract_test.dart b/test/unit/mailbox_repository_contract_test.dart index eff8be9..3f9c36f 100644 --- a/test/unit/mailbox_repository_contract_test.dart +++ b/test/unit/mailbox_repository_contract_test.dart @@ -111,9 +111,7 @@ class _MailboxRepositoryImplContract extends MailboxRepositoryContract { int unread = 0, int total = 0, }) async { - await _db - .into(_db.mailboxes) - .insert( + await _db.into(_db.mailboxes).insert( MailboxesCompanion.insert( id: id, accountId: _account.id, diff --git a/test/unit/mailbox_repository_impl_test.dart b/test/unit/mailbox_repository_impl_test.dart index 4dcf5ef..8842fb8 100644 --- a/test/unit/mailbox_repository_impl_test.dart +++ b/test/unit/mailbox_repository_impl_test.dart @@ -66,16 +66,17 @@ http.Client _mockJmap({required List> apiResponses}) { Map _mailboxGetResponse({ required String state, required List> list, -}) => { - 'sessionState': 'sess1', - 'methodResponses': [ - [ - 'Mailbox/get', - {'accountId': 'acct1', 'state': state, 'list': list}, - '0', - ], - ], -}; +}) => + { + 'sessionState': 'sess1', + 'methodResponses': [ + [ + 'Mailbox/get', + {'accountId': 'acct1', 'state': state, 'list': list}, + '0', + ], + ], + }; Map _mailboxChangesResponse({ required String oldState, @@ -83,24 +84,25 @@ Map _mailboxChangesResponse({ List created = const [], List updated = const [], List destroyed = const [], -}) => { - 'sessionState': 'sess1', - 'methodResponses': [ - [ - 'Mailbox/changes', - { - 'accountId': 'acct1', - 'oldState': oldState, - 'newState': newState, - 'hasMoreChanges': false, - 'created': created, - 'updated': updated, - 'destroyed': destroyed, - }, - '0', - ], - ], -}; +}) => + { + 'sessionState': 'sess1', + 'methodResponses': [ + [ + 'Mailbox/changes', + { + 'accountId': 'acct1', + 'oldState': oldState, + 'newState': newState, + 'hasMoreChanges': false, + 'created': created, + 'updated': updated, + 'destroyed': destroyed, + }, + '0', + ], + ], + }; Future _noImapConnect(Account a, String u, String p) => Future.error(UnsupportedError('IMAP unavailable in unit tests')); @@ -109,8 +111,7 @@ Future _noImapConnect(Account a, String u, String p) => AppDatabase db, AccountRepositoryImpl accounts, MailboxRepositoryImpl mailboxes, -}) -_makeRepos({http.Client? httpClient}) { +}) _makeRepos({http.Client? httpClient}) { final db = openTestDatabase(); final accounts = AccountRepositoryImpl(db, MapSecureStorage()); final mailboxes = MailboxRepositoryImpl( @@ -144,9 +145,7 @@ void main() { ('INBOX', 'Inbox'), ('Drafts', 'Drafts'), ]) { - await r.db - .into(r.db.mailboxes) - .insert( + await r.db.into(r.db.mailboxes).insert( MailboxesCompanion.insert( id: 'acc-1:$path', accountId: 'acc-1', @@ -179,9 +178,7 @@ void main() { ); await r.accounts.addAccount(other, 'pw2'); - await r.db - .into(r.db.mailboxes) - .insert( + await r.db.into(r.db.mailboxes).insert( MailboxesCompanion.insert( id: 'acc-1:INBOX', accountId: 'acc-1', @@ -189,9 +186,7 @@ void main() { name: 'Inbox', ), ); - await r.db - .into(r.db.mailboxes) - .insert( + await r.db.into(r.db.mailboxes).insert( MailboxesCompanion.insert( id: 'acc-2:INBOX', accountId: 'acc-2', @@ -210,9 +205,7 @@ void main() { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); - await r.db - .into(r.db.mailboxes) - .insert( + await r.db.into(r.db.mailboxes).insert( MailboxesCompanion.insert( id: 'acc-1:INBOX', accountId: 'acc-1', @@ -312,9 +305,7 @@ void main() { await r.accounts.addAccount(_jmapAccount, 'pw'); // Pre-populate DB with existing mailboxes and state - await r.db - .into(r.db.mailboxes) - .insertOnConflictUpdate( + await r.db.into(r.db.mailboxes).insertOnConflictUpdate( MailboxesCompanion.insert( id: 'jmap-1:mbx1', accountId: 'jmap-1', @@ -324,9 +315,7 @@ void main() { totalCount: const Value(10), ), ); - await r.db - .into(r.db.mailboxes) - .insertOnConflictUpdate( + await r.db.into(r.db.mailboxes).insertOnConflictUpdate( MailboxesCompanion.insert( id: 'jmap-1:mbx2', accountId: 'jmap-1', @@ -334,9 +323,7 @@ void main() { name: 'Sent', ), ); - await r.db - .into(r.db.syncStates) - .insertOnConflictUpdate( + await r.db.into(r.db.syncStates).insertOnConflictUpdate( SyncStatesCompanion.insert( accountId: 'jmap-1', resourceType: 'Mailbox', @@ -364,9 +351,7 @@ void main() { ), ); await r.accounts.addAccount(_jmapAccount, 'pw'); - await r.db - .into(r.db.syncStates) - .insertOnConflictUpdate( + await r.db.into(r.db.syncStates).insertOnConflictUpdate( SyncStatesCompanion.insert( accountId: 'jmap-1', resourceType: 'Mailbox', @@ -434,9 +419,7 @@ void main() { test('findMailboxByRole returns matching mailbox', () async { final r = _makeRepos(); await r.accounts.addAccount(_jmapAccount, 'pw'); - await r.db - .into(r.db.mailboxes) - .insert( + await r.db.into(r.db.mailboxes).insert( MailboxesCompanion.insert( id: 'jmap-1:mbx-inbox', accountId: 'jmap-1', @@ -569,9 +552,7 @@ void main() { await accounts.addAccount(_account, 'pw'); // Pre-seed the DB with role='archive' (as if user created the folder). - await db - .into(db.mailboxes) - .insert( + await db.into(db.mailboxes).insert( MailboxesCompanion.insert( id: 'acc-1:Archive', accountId: 'acc-1', @@ -608,20 +589,22 @@ class _PlainArchiveImapClient extends SnoozeSpyImapClient { List? mailboxPatterns, List? selectionOptions, List? returnOptions, - }) async => [ - imap.Mailbox( - encodedName: 'Archive', - encodedPath: 'Archive', - pathSeparator: '/', - flags: [], // No \Archive special-use flag - ), - ]; + }) async => + [ + imap.Mailbox( + encodedName: 'Archive', + encodedPath: 'Archive', + pathSeparator: '/', + flags: [], // No \Archive special-use flag + ), + ]; @override Future statusMailbox( imap.Mailbox mailbox, List flags, - ) async => mailbox; + ) async => + mailbox; @override Future logout() async {} diff --git a/test/unit/managesieve_probe_service_test.dart b/test/unit/managesieve_probe_service_test.dart index 76c4e39..6b59d5d 100644 --- a/test/unit/managesieve_probe_service_test.dart +++ b/test/unit/managesieve_probe_service_test.dart @@ -27,12 +27,12 @@ class _RecordingRepo implements AccountRepository { ManageSieveProbeService _service(_RecordingRepo repo, {required bool result}) { return ManageSieveProbeService( repo, - probeFn: - ({ - required String host, - required int port, - required bool useTls, - }) async => result, + probeFn: ({ + required String host, + required int port, + required bool useTls, + }) async => + result, ); } @@ -71,15 +71,14 @@ void main() { var probeCalled = false; final svc = ManageSieveProbeService( repo, - probeFn: - ({ - required String host, - required int port, - required bool useTls, - }) async { - probeCalled = true; - return true; - }, + probeFn: ({ + required String host, + required int port, + required bool useTls, + }) async { + probeCalled = true; + return true; + }, ); const jmap = Account( id: 'acc-2', @@ -98,15 +97,14 @@ void main() { var probeCalled = false; final svc = ManageSieveProbeService( repo, - probeFn: - ({ - required String host, - required int port, - required bool useTls, - }) async { - probeCalled = true; - return true; - }, + probeFn: ({ + required String host, + required int port, + required bool useTls, + }) async { + probeCalled = true; + return true; + }, ); const blank = Account( id: 'acc-3', @@ -125,17 +123,16 @@ void main() { bool? probedTls; final svc = ManageSieveProbeService( repo, - probeFn: - ({ - required String host, - required int port, - required bool useTls, - }) async { - probedHost = host; - probedPort = port; - probedTls = useTls; - return true; - }, + probeFn: ({ + required String host, + required int port, + required bool useTls, + }) async { + probedHost = host; + probedPort = port; + probedTls = useTls; + return true; + }, ); const account = Account( id: 'acc-1', @@ -158,15 +155,14 @@ void main() { String? probedHost; final svc = ManageSieveProbeService( repo, - probeFn: - ({ - required String host, - required int port, - required bool useTls, - }) async { - probedHost = host; - return true; - }, + probeFn: ({ + required String host, + required int port, + required bool useTls, + }) async { + probedHost = host; + return true; + }, ); await svc.probe(_imapAccount); expect(probedHost, 'imap.example.com'); diff --git a/test/unit/migration_test.dart b/test/unit/migration_test.dart index 48eb9fd..e0aadad 100644 --- a/test/unit/migration_test.dart +++ b/test/unit/migration_test.dart @@ -162,9 +162,8 @@ void main() { final allTriggers = await db .customSelect("SELECT name FROM sqlite_master WHERE type='trigger'") .get(); - final triggerNames = allTriggers - .map((r) => r.read('name')) - .toSet(); + final triggerNames = + allTriggers.map((r) => r.read('name')).toSet(); expect( triggerNames, containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']), @@ -361,9 +360,8 @@ void main() { final allIndexes = await db .customSelect("SELECT name FROM sqlite_master WHERE type='index'") .get(); - final indexNames = allIndexes - .map((r) => r.read('name')) - .toSet(); + final indexNames = + allIndexes.map((r) => r.read('name')).toSet(); expect(indexNames, contains('mailboxes_account_id')); expect(indexNames, contains('threads_latest_date')); @@ -371,9 +369,8 @@ void main() { final allTriggers = await db .customSelect("SELECT name FROM sqlite_master WHERE type='trigger'") .get(); - final triggerNames = allTriggers - .map((r) => r.read('name')) - .toSet(); + final triggerNames = + allTriggers.map((r) => r.read('name')).toSet(); expect( triggerNames, containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']), diff --git a/test/unit/reliability_runner_check_now_test.dart b/test/unit/reliability_runner_check_now_test.dart index af93fe4..e823b2f 100644 --- a/test/unit/reliability_runner_check_now_test.dart +++ b/test/unit/reliability_runner_check_now_test.dart @@ -67,15 +67,16 @@ class _FakeMailboxes implements MailboxRepository { String accountId, String name, String role, - ) async => Mailbox( - id: '$accountId:$name', - accountId: accountId, - path: name, - name: name, - role: role, - unreadCount: 0, - totalCount: 0, - ); + ) async => + Mailbox( + id: '$accountId:$name', + accountId: accountId, + path: name, + name: name, + role: role, + unreadCount: 0, + totalCount: 0, + ); } class _FakeEmails implements EmailRepository { @@ -99,7 +100,8 @@ class _FakeEmails implements EmailRepository { String a, String m, { int limit = 50, - }) => Stream.value([]); + }) => + Stream.value([]); @override Stream> observeEmailsInThread(String a, String m, String t) => Stream.value([]); @@ -136,7 +138,8 @@ class _FakeEmails implements EmailRepository { String? a, String q, { int limit = 10, - }) async => []; + }) async => + []; @override Stream> observeFailedMutations(String a) => Stream.value([]); diff --git a/test/unit/reliability_runner_test.dart b/test/unit/reliability_runner_test.dart index 09cb372..4b76606 100644 --- a/test/unit/reliability_runner_test.dart +++ b/test/unit/reliability_runner_test.dart @@ -13,11 +13,11 @@ import 'package:sharedinbox/core/sync/account_sync_manager.dart'; // ── helpers ─────────────────────────────────────────────────────────────────── Account _account({String id = 'a1'}) => Account( - id: id, - displayName: 'Test', - email: 'test@example.com', - imapHost: 'localhost', -); + id: id, + displayName: 'Test', + email: 'test@example.com', + imapHost: 'localhost', + ); class _FakeAccounts implements AccountRepository { final List accounts; @@ -57,15 +57,16 @@ class _FakeMailboxes implements MailboxRepository { String accountId, String name, String role, - ) async => Mailbox( - id: '$accountId:$name', - accountId: accountId, - path: name, - name: name, - role: role, - unreadCount: 0, - totalCount: 0, - ); + ) async => + Mailbox( + id: '$accountId:$name', + accountId: accountId, + path: name, + name: name, + role: role, + unreadCount: 0, + totalCount: 0, + ); } class _CountingEmails implements EmailRepository { @@ -98,7 +99,8 @@ class _CountingEmails implements EmailRepository { String a, String m, { int limit = 50, - }) => Stream.value([]); + }) => + Stream.value([]); @override Stream> observeEmailsInThread(String a, String m, String t) => Stream.value([]); @@ -132,7 +134,8 @@ class _CountingEmails implements EmailRepository { String? a, String q, { int limit = 10, - }) async => []; + }) async => + []; @override Stream> observeFailedMutations(String a) => Stream.value([]); @@ -150,7 +153,8 @@ class _CountingEmails implements EmailRepository { Future findEmailByMessageId( String accountId, String messageId, - ) async => null; + ) async => + null; @override Stream get onChangesQueued => const Stream.empty(); @override @@ -160,7 +164,8 @@ class _CountingEmails implements EmailRepository { Future verifySyncReliability( String accountId, String mailboxPath, - ) async => ReliabilityResult.healthy; + ) async => + ReliabilityResult.healthy; @override Future clearForResync(String accountId) async {} @override @@ -372,7 +377,7 @@ void main() { class _OverrideEmails extends _CountingEmails { _OverrideEmails({required Future Function(String) onSync}) - : _onSync = onSync; + : _onSync = onSync; final Future Function(String) _onSync; diff --git a/test/unit/sync_log_repository_impl_test.dart b/test/unit/sync_log_repository_impl_test.dart index c09be4d..75fc6e0 100644 --- a/test/unit/sync_log_repository_impl_test.dart +++ b/test/unit/sync_log_repository_impl_test.dart @@ -11,9 +11,7 @@ void main() { late final db = openTestDatabase(); setUpAll(() async { - await db - .into(db.accounts) - .insert( + await db.into(db.accounts).insert( AccountsCompanion.insert( id: 'acc1', displayName: 'Test', @@ -122,7 +120,8 @@ void main() { final rows = await (db.select( db.syncLogs, - )..where((r) => r.result.equals('error'))).get(); + )..where((r) => r.result.equals('error'))) + .get(); expect(rows, hasLength(1)); expect(rows.first.result, 'error'); expect(rows.first.errorMessage, 'Connection refused'); diff --git a/test/unit/undo_logic_test.dart b/test/unit/undo_logic_test.dart index ed4bea4..39ee258 100644 --- a/test/unit/undo_logic_test.dart +++ b/test/unit/undo_logic_test.dart @@ -48,9 +48,7 @@ void main() { await accounts.addAccount(account, 'password'); // Setup Inbox and Trash mailboxes - await db - .into(db.mailboxes) - .insert( + await db.into(db.mailboxes).insert( MailboxesCompanion.insert( id: 'acc1:INBOX', accountId: 'acc1', @@ -58,9 +56,7 @@ void main() { name: 'Inbox', ), ); - await db - .into(db.mailboxes) - .insert( + await db.into(db.mailboxes).insert( MailboxesCompanion.insert( id: 'acc1:Trash', accountId: 'acc1', @@ -71,9 +67,7 @@ void main() { ); // Setup an email in Inbox - await db - .into(db.emails) - .insert( + await db.into(db.emails).insert( EmailsCompanion.insert( id: 'acc1:101', accountId: 'acc1', @@ -100,11 +94,10 @@ void main() { await repo.deleteEmail(emailId); // Verify it moved from INBOX (locally deleted for IMAP move) - final inInbox = - await (db.select(db.emails) - ..where((t) => t.id.equals(emailId)) - ..where((t) => t.mailboxPath.equals('INBOX'))) - .get(); + final inInbox = await (db.select(db.emails) + ..where((t) => t.id.equals(emailId)) + ..where((t) => t.mailboxPath.equals('INBOX'))) + .get(); expect(inInbox, isEmpty, reason: 'Email should be gone from Inbox'); // 2. Push undo action and undo @@ -120,11 +113,10 @@ void main() { await container.read(undoServiceProvider.notifier).undo(); // 3. Verify it is back in Inbox - final restored = - await (db.select(db.emails) - ..where((t) => t.id.equals(emailId)) - ..where((t) => t.mailboxPath.equals('INBOX'))) - .get(); + final restored = await (db.select(db.emails) + ..where((t) => t.id.equals(emailId)) + ..where((t) => t.mailboxPath.equals('INBOX'))) + .get(); expect( restored, @@ -149,9 +141,7 @@ void main() { await accounts.addAccount(jmapAccount, 'password'); // Setup Inbox and Trash mailboxes for JMAP - await db - .into(db.mailboxes) - .insert( + await db.into(db.mailboxes).insert( MailboxesCompanion.insert( id: 'jmap1:INBOX', accountId: 'jmap1', @@ -160,9 +150,7 @@ void main() { role: const Value('inbox'), ), ); - await db - .into(db.mailboxes) - .insert( + await db.into(db.mailboxes).insert( MailboxesCompanion.insert( id: 'jmap1:Trash', accountId: 'jmap1', @@ -173,9 +161,7 @@ void main() { ); // Setup an email in JMAP Inbox - await db - .into(db.emails) - .insert( + await db.into(db.emails).insert( EmailsCompanion.insert( id: emailId, accountId: 'jmap1', @@ -190,11 +176,10 @@ void main() { await repo.deleteEmail(emailId); // Verify it moved to Trash locally (JMAP moveEmail updates mailboxPath) - final inTrash = - await (db.select(db.emails) - ..where((t) => t.id.equals(emailId)) - ..where((t) => t.mailboxPath.equals('Trash'))) - .get(); + final inTrash = await (db.select(db.emails) + ..where((t) => t.id.equals(emailId)) + ..where((t) => t.mailboxPath.equals('Trash'))) + .get(); expect(inTrash, isNotEmpty, reason: 'Email should be in Trash'); // 2. Push undo action and undo @@ -209,11 +194,10 @@ void main() { await container.read(undoServiceProvider.notifier).undo(); // 3. Verify it is back in Inbox - final restored = - await (db.select(db.emails) - ..where((t) => t.id.equals(emailId)) - ..where((t) => t.mailboxPath.equals('INBOX'))) - .get(); + final restored = await (db.select(db.emails) + ..where((t) => t.id.equals(emailId)) + ..where((t) => t.mailboxPath.equals('INBOX'))) + .get(); expect( restored, isNotEmpty, @@ -250,11 +234,10 @@ void main() { await container.read(undoServiceProvider.notifier).undo(); // 4. Verify local state - final restored = - await (db.select(db.emails) - ..where((t) => t.id.equals(emailId)) - ..where((t) => t.mailboxPath.equals('INBOX'))) - .get(); + final restored = await (db.select(db.emails) + ..where((t) => t.id.equals(emailId)) + ..where((t) => t.mailboxPath.equals('INBOX'))) + .get(); expect(restored, isNotEmpty); // 5. Verify a NEW pending change was enqueued (Trash -> INBOX) @@ -290,9 +273,7 @@ void main() { // 2. Simulate IMAP sync: the server assigned a new UID (205) in Trash. // The old row (acc1:101) is removed and a new row (acc1:205) is inserted. await (db.delete(db.emails)..where((t) => t.id.equals(oldEmailId))).go(); - await db - .into(db.emails) - .insert( + await db.into(db.emails).insert( EmailsCompanion.insert( id: 'acc1:205', accountId: 'acc1', @@ -325,7 +306,8 @@ void main() { // 4. Verify the current email row is now in INBOX. final inInbox = await (db.select( db.emails, - )..where((t) => t.mailboxPath.equals('INBOX'))).get(); + )..where((t) => t.mailboxPath.equals('INBOX'))) + .get(); expect( inInbox, isNotEmpty, diff --git a/test/widget/about_screen_test.dart b/test/widget/about_screen_test.dart index 990842f..abbf7b4 100644 --- a/test/widget/about_screen_test.dart +++ b/test/widget/about_screen_test.dart @@ -37,8 +37,7 @@ class ThrowingUrlLauncher extends Mock Future launchUrl(String? url, LaunchOptions? options) async { throw PlatformException( code: 'channel-error', - message: - 'Unable to establish connection on channel: ' + message: 'Unable to establish connection on channel: ' '"dev.flutter.pigeon.url_launcher_android.UrlLauncherApi.launchUrl".', ); } diff --git a/test/widget/account_list_screen_test.dart b/test/widget/account_list_screen_test.dart index b5248cb..fc662ca 100644 --- a/test/widget/account_list_screen_test.dart +++ b/test/widget/account_list_screen_test.dart @@ -227,7 +227,8 @@ void main() { expect(find.textContaining('Healthy'), findsOneWidget); }); - testWidgets('shows discrepancy details when sync health has discrepancies', ( + testWidgets('shows discrepancy details when sync health has discrepancies', + ( tester, ) async { const summary = diff --git a/test/widget/email_detail_screen_test.dart b/test/widget/email_detail_screen_test.dart index 911ba12..cdd0ba5 100644 --- a/test/widget/email_detail_screen_test.dart +++ b/test/widget/email_detail_screen_test.dart @@ -41,19 +41,20 @@ class _FakeFile extends Fake implements File { FileMode mode = FileMode.write, Encoding encoding = utf8, bool flush = false, - }) async => this; + }) async => + this; } // Shared overrides for email detail tests. List _overrides({required EmailBody body, Email? email}) => [ - accountRepositoryProvider.overrideWithValue( - FakeAccountRepository([kTestAccount]), - ), - mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository()), - emailRepositoryProvider.overrideWithValue( - FakeEmailRepository(emailDetail: email ?? testEmail(), emailBody: body), - ), -]; + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository()), + emailRepositoryProvider.overrideWithValue( + FakeEmailRepository(emailDetail: email ?? testEmail(), emailBody: body), + ), + ]; void main() { group('EmailDetailScreen', () { diff --git a/test/widget/email_list_screen_golden_test.dart b/test/widget/email_list_screen_golden_test.dart index 337fe93..37a1e53 100644 --- a/test/widget/email_list_screen_golden_test.dart +++ b/test/widget/email_list_screen_golden_test.dart @@ -15,42 +15,44 @@ Email _email({ String subject = 'Hello world', bool isSeen = true, bool isFlagged = false, -}) => Email( - id: id, - accountId: 'acc-1', - mailboxPath: 'INBOX', - uid: int.parse(id.split(':').last), - subject: subject, - receivedAt: _kDate, - sentAt: _kDate, - from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')], - to: const [EmailAddress(email: 'alice@example.com')], - cc: const [], - isSeen: isSeen, - isFlagged: isFlagged, - hasAttachment: false, -); +}) => + Email( + id: id, + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: int.parse(id.split(':').last), + subject: subject, + receivedAt: _kDate, + sentAt: _kDate, + from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')], + to: const [EmailAddress(email: 'alice@example.com')], + cc: const [], + isSeen: isSeen, + isFlagged: isFlagged, + hasAttachment: false, + ); List _overrides({ List emails = const [], List searchResults = const [], String? syncError, -}) => [ - accountRepositoryProvider.overrideWithValue( - FakeAccountRepository([kTestAccount]), - ), - mailboxRepositoryProvider.overrideWithValue( - FakeMailboxRepository([kTestMailbox]), - ), - emailRepositoryProvider.overrideWithValue( - FakeEmailRepository(emails: emails, searchResults: searchResults), - ), - draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), - searchHistoryRepositoryProvider.overrideWithValue( - FakeSearchHistoryRepository(), - ), - syncLastErrorProvider.overrideWith((ref, _) => Stream.value(syncError)), -]; +}) => + [ + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository([kTestMailbox]), + ), + emailRepositoryProvider.overrideWithValue( + FakeEmailRepository(emails: emails, searchResults: searchResults), + ), + draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), + searchHistoryRepositoryProvider.overrideWithValue( + FakeSearchHistoryRepository(), + ), + syncLastErrorProvider.overrideWith((ref, _) => Stream.value(syncError)), + ]; void main() { group('EmailListScreen goldens', () { diff --git a/test/widget/email_list_screen_test.dart b/test/widget/email_list_screen_test.dart index 96321b9..01dbecb 100644 --- a/test/widget/email_list_screen_test.dart +++ b/test/widget/email_list_screen_test.dart @@ -27,7 +27,8 @@ class _MutableFakeEmailRepository extends FakeEmailRepository { String accountId, String mailboxPath, String query, - ) async => _results; + ) async => + _results; } final _kDate = DateTime(2024, 6); diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index e59c63a..bfb0360 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -49,7 +49,7 @@ import 'package:sharedinbox/ui/screens/user_preferences_screen.dart'; class FakeAccountRepository implements AccountRepository { FakeAccountRepository([List? accounts]) - : _accounts = List.of(accounts ?? []); + : _accounts = List.of(accounts ?? []); final List _accounts; bool hasPassword = true; @@ -137,7 +137,8 @@ class FakeDraftRepository implements DraftRepository { final matches = _drafts.values.where((d) { if (replyToEmailId == null) return d.replyToEmailId == null; return d.replyToEmailId == replyToEmailId; - }).toList()..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + }).toList() + ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); return matches.isEmpty ? null : matches.first; } @@ -155,7 +156,7 @@ class FakeMailboxRepository implements MailboxRepository { final List _mailboxes; FakeMailboxRepository([List? mailboxes]) - : _mailboxes = mailboxes ?? []; + : _mailboxes = mailboxes ?? []; @override Stream> observeMailboxes(String? accountId) => @@ -205,49 +206,52 @@ class FakeEmailRepository implements EmailRepository { EmailBody? emailBody, List? searchResults, String rawRfc822 = '', - }) : _emails = emails ?? [], - _emailDetail = emailDetail, - _searchResults = searchResults ?? [], - _rawRfc822 = rawRfc822, - _emailBody = emailBody ?? const EmailBody(emailId: '', attachments: []); + }) : _emails = emails ?? [], + _emailDetail = emailDetail, + _searchResults = searchResults ?? [], + _rawRfc822 = rawRfc822, + _emailBody = emailBody ?? const EmailBody(emailId: '', attachments: []); @override Stream> observeEmails( String accountId, String mailboxPath, { int limit = 50, - }) => Stream.value(List.of(_emails)); + }) => + Stream.value(List.of(_emails)); @override Stream> observeThreads( String accountId, String mailboxPath, { int limit = 50, - }) => observeEmails(accountId, mailboxPath).map((emails) { - return emails.map((e) { - return EmailThread( - threadId: e.threadId ?? e.id, - subject: e.subject, - preview: e.preview, - participants: e.from, - latestDate: e.sentAt ?? e.receivedAt, - messageCount: 1, - hasUnread: !e.isSeen, - isFlagged: e.isFlagged, - latestEmailId: e.id, - emailIds: [e.id], - accountId: e.accountId, - mailboxPath: e.mailboxPath, - ); - }).toList(); - }); + }) => + observeEmails(accountId, mailboxPath).map((emails) { + return emails.map((e) { + return EmailThread( + threadId: e.threadId ?? e.id, + subject: e.subject, + preview: e.preview, + participants: e.from, + latestDate: e.sentAt ?? e.receivedAt, + messageCount: 1, + hasUnread: !e.isSeen, + isFlagged: e.isFlagged, + latestEmailId: e.id, + emailIds: [e.id], + accountId: e.accountId, + mailboxPath: e.mailboxPath, + ); + }).toList(); + }); @override Stream> observeEmailsInThread( String accountId, String mailboxPath, String threadId, - ) => Stream.value(_emails.where((e) => e.threadId == threadId).toList()); + ) => + Stream.value(_emails.where((e) => e.threadId == threadId).toList()); @override Future getEmail(String emailId) async => _emailDetail; @@ -259,7 +263,8 @@ class FakeEmailRepository implements EmailRepository { Future syncEmails( String accountId, String mailboxPath, - ) async => SyncEmailsResult.zero; + ) async => + SyncEmailsResult.zero; @override Future setFlag(String emailId, {bool? seen, bool? flagged}) async {} @@ -285,7 +290,8 @@ class FakeEmailRepository implements EmailRepository { Future findEmailByMessageId( String accountId, String messageId, - ) async => null; + ) async => + null; @override Future deleteEmail(String emailId) async => null; @@ -303,7 +309,8 @@ class FakeEmailRepository implements EmailRepository { Future downloadAttachment( String emailId, EmailAttachment attachment, - ) async => '/tmp/${attachment.filename}'; + ) async => + '/tmp/${attachment.filename}'; @override Future fetchRawRfc822(String emailId) async => _rawRfc822; @@ -313,26 +320,30 @@ class FakeEmailRepository implements EmailRepository { String accountId, String mailboxPath, String query, - ) async => _searchResults; + ) async => + _searchResults; @override Future> searchEmailsGlobal( String? accountId, String query, - ) async => _searchResults; + ) async => + _searchResults; @override Future> getEmailsByAddress( String? accountId, String address, - ) async => []; + ) async => + []; @override Future> searchAddresses( String? accountId, String query, { int limit = 10, - }) async => []; + }) async => + []; @override Stream watchJmapPush(String accountId, String password) => @@ -342,7 +353,8 @@ class FakeEmailRepository implements EmailRepository { Future verifySyncReliability( String accountId, String mailboxPath, - ) async => ReliabilityResult.healthy; + ) async => + ReliabilityResult.healthy; @override Stream> observeFailedMutations(String accountId) => @@ -541,26 +553,28 @@ List baseOverrides({ ShareKeyRepository? shareKeyRepository, bool hasStoredPassword = true, SyncHealthRow? syncHealth, -}) => [ - accountRepositoryProvider.overrideWithValue( - FakeAccountRepository(accounts)..hasPassword = hasStoredPassword, - ), - mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository(mailboxes)), - emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), - draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), - accountDiscoveryServiceProvider.overrideWithValue( - FakeDiscoveryService(discovery ?? UnknownDiscovery()), - ), - connectionTestServiceProvider.overrideWithValue( - FakeConnectionTestService(error: connectionError), - ), - shareKeyRepositoryProvider.overrideWithValue( - shareKeyRepository ?? FakeShareKeyRepository(), - ), - // syncHealthProvider is backed by a Drift StreamQuery; override with a - // plain stream to avoid "A Timer is still pending" in tests. - syncHealthProvider.overrideWith((ref, _) => Stream.value(syncHealth)), -]; +}) => + [ + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository(accounts)..hasPassword = hasStoredPassword, + ), + mailboxRepositoryProvider + .overrideWithValue(FakeMailboxRepository(mailboxes)), + emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), + draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), + accountDiscoveryServiceProvider.overrideWithValue( + FakeDiscoveryService(discovery ?? UnknownDiscovery()), + ), + connectionTestServiceProvider.overrideWithValue( + FakeConnectionTestService(error: connectionError), + ), + shareKeyRepositoryProvider.overrideWithValue( + shareKeyRepository ?? FakeShareKeyRepository(), + ), + // syncHealthProvider is backed by a Drift StreamQuery; override with a + // plain stream to avoid "A Timer is still pending" in tests. + syncHealthProvider.overrideWith((ref, _) => Stream.value(syncHealth)), + ]; // --------------------------------------------------------------------------- // Common test fixtures @@ -590,22 +604,23 @@ Email testEmail({ bool isFlagged = false, bool hasAttachment = false, String? listUnsubscribeHeader, -}) => Email( - id: id, - accountId: 'acc-1', - mailboxPath: 'INBOX', - uid: 42, - subject: subject, - receivedAt: DateTime(2024, 6), - sentAt: DateTime(2024, 6), - from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')], - to: const [EmailAddress(email: 'alice@example.com')], - cc: const [], - isSeen: isSeen, - isFlagged: isFlagged, - hasAttachment: hasAttachment, - listUnsubscribeHeader: listUnsubscribeHeader, -); +}) => + Email( + id: id, + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 42, + subject: subject, + receivedAt: DateTime(2024, 6), + sentAt: DateTime(2024, 6), + from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')], + to: const [EmailAddress(email: 'alice@example.com')], + cc: const [], + isSeen: isSeen, + isFlagged: isFlagged, + hasAttachment: hasAttachment, + listUnsubscribeHeader: listUnsubscribeHeader, + ); class FakeUserPreferencesRepository implements UserPreferencesRepository { FakeUserPreferencesRepository({ @@ -620,12 +635,12 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository { @override Stream observePreferences() => Stream.value( - UserPreferences( - menuPosition: menuPosition, - mailViewButtonPosition: mailViewButtonPosition, - afterMailViewAction: afterMailViewAction, - ), - ); + UserPreferences( + menuPosition: menuPosition, + mailViewButtonPosition: mailViewButtonPosition, + afterMailViewAction: afterMailViewAction, + ), + ); @override Future updateMenuPosition(MenuPosition position) async { diff --git a/test/widget/secure_email_webview_test.dart b/test/widget/secure_email_webview_test.dart index a486058..aea8951 100644 --- a/test/widget/secure_email_webview_test.dart +++ b/test/widget/secure_email_webview_test.dart @@ -11,12 +11,12 @@ void _expectLightMode(String html) { } Widget _wrap(Widget child) => MaterialApp( - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), - useMaterial3: true, - ), - home: Scaffold(body: child), -); + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), + useMaterial3: true, + ), + home: Scaffold(body: child), + ); void main() { group('buildEmailHtml', () { @@ -44,7 +44,8 @@ void main() { _expectLightMode(html); }); - test('prevents horizontal overflow so wide HTML emails are not cut off', () { + test('prevents horizontal overflow so wide HTML emails are not cut off', + () { final html = buildEmailHtml( '
x
', ); diff --git a/test/widget/thread_detail_screen_test.dart b/test/widget/thread_detail_screen_test.dart index 78996ad..e61f19d 100644 --- a/test/widget/thread_detail_screen_test.dart +++ b/test/widget/thread_detail_screen_test.dart @@ -11,22 +11,23 @@ Email _threadEmail({ String id = 'acc-1:10', bool isFlagged = false, bool isSeen = true, -}) => Email( - id: id, - accountId: 'acc-1', - mailboxPath: 'INBOX', - uid: 10, - threadId: 'thread-1', - subject: 'Project update', - receivedAt: DateTime(2024, 6), - sentAt: DateTime(2024, 6, 1, 9), - from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')], - to: const [EmailAddress(email: 'alice@example.com')], - cc: const [], - isSeen: isSeen, - isFlagged: isFlagged, - hasAttachment: false, -); +}) => + Email( + id: id, + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 10, + threadId: 'thread-1', + subject: 'Project update', + receivedAt: DateTime(2024, 6), + sentAt: DateTime(2024, 6, 1, 9), + from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')], + to: const [EmailAddress(email: 'alice@example.com')], + cc: const [], + isSeen: isSeen, + isFlagged: isFlagged, + hasAttachment: false, + ); void main() { group('ThreadDetailScreen', () { diff --git a/test/widget/try_connection_button_test.dart b/test/widget/try_connection_button_test.dart index 46e5589..bd4d489 100644 --- a/test/widget/try_connection_button_test.dart +++ b/test/widget/try_connection_button_test.dart @@ -4,12 +4,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:sharedinbox/ui/widgets/try_connection_button.dart'; Widget _wrap(Widget child) => MaterialApp( - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), - useMaterial3: true, - ), - home: Scaffold(body: child), -); + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), + useMaterial3: true, + ), + home: Scaffold(body: child), + ); void main() { group('TryConnectionButton', () { diff --git a/test/widget/user_preferences_screen_test.dart b/test/widget/user_preferences_screen_test.dart index 6d4d891..1e53b2a 100644 --- a/test/widget/user_preferences_screen_test.dart +++ b/test/widget/user_preferences_screen_test.dart @@ -88,11 +88,10 @@ void main() { await tester.tap(find.text('Top').first); await tester.pumpAndSettle(); - final repo = - ProviderScope.containerOf( - tester.element(find.byType(UserPreferencesScreen)), - ).read(userPreferencesRepositoryProvider) - as FakeUserPreferencesRepository; + final repo = ProviderScope.containerOf( + tester.element(find.byType(UserPreferencesScreen)), + ).read(userPreferencesRepositoryProvider) + as FakeUserPreferencesRepository; expect(repo.menuPosition, MenuPosition.top); }); @@ -111,11 +110,10 @@ void main() { await tester.tap(find.text('Top').last); await tester.pumpAndSettle(); - final repo = - ProviderScope.containerOf( - tester.element(find.byType(UserPreferencesScreen)), - ).read(userPreferencesRepositoryProvider) - as FakeUserPreferencesRepository; + final repo = ProviderScope.containerOf( + tester.element(find.byType(UserPreferencesScreen)), + ).read(userPreferencesRepositoryProvider) + as FakeUserPreferencesRepository; expect(repo.mailViewButtonPosition, MenuPosition.top); }, @@ -175,11 +173,10 @@ void main() { await tester.tap(find.text('Return to mailbox')); await tester.pumpAndSettle(); - final repo = - ProviderScope.containerOf( - tester.element(find.byType(UserPreferencesScreen)), - ).read(userPreferencesRepositoryProvider) - as FakeUserPreferencesRepository; + final repo = ProviderScope.containerOf( + tester.element(find.byType(UserPreferencesScreen)), + ).read(userPreferencesRepositoryProvider) + as FakeUserPreferencesRepository; expect(repo.afterMailViewAction, AfterMailViewAction.showMailbox); });