From 1e2d1b6063313516df41c4bd9dafc4f06dd0a72e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Tue, 2 Jun 2026 11:10:29 +0200 Subject: [PATCH] chore: migrate to SOPS and SSH for Dagger engine access --- 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 | 28 +- lib/core/services/undo_service.dart | 3 +- lib/core/services/update_service.dart | 4 +- lib/core/sieve/sieve_interpreter.dart | 10 +- lib/core/sieve/sieve_parser.dart | 16 +- lib/core/sync/account_sync_manager.dart | 132 +-- lib/core/sync/background_sync.dart | 23 +- lib/core/sync/reliability_runner.dart | 7 +- lib/core/utils/cid_utils.dart | 5 +- lib/data/db/database.dart | 434 ++++---- lib/data/db/local_sieve_repository.dart | 56 +- 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 | 78 +- .../repositories/email_repository_impl.dart | 813 +++++++-------- .../repositories/mailbox_repository_impl.dart | 93 +- .../search_history_repository_impl.dart | 36 +- .../share_key_repository_impl.dart | 17 +- .../sync_log_repository_impl.dart | 17 +- .../repositories/undo_repository_impl.dart | 13 +- .../user_preferences_repository_impl.dart | 18 +- lib/di.dart | 88 +- lib/main.dart | 6 +- lib/ui/screens/about_screen.dart | 26 +- lib/ui/screens/account_receive_screen.dart | 32 +- lib/ui/screens/account_send_screen.dart | 23 +- lib/ui/screens/add_account_screen.dart | 29 +- lib/ui/screens/address_emails_screen.dart | 63 +- lib/ui/screens/changelog_screen.dart | 5 +- lib/ui/screens/compose_screen.dart | 31 +- lib/ui/screens/crash_screen.dart | 6 +- lib/ui/screens/edit_account_screen.dart | 20 +- lib/ui/screens/email_action_helpers.dart | 5 +- lib/ui/screens/email_detail_screen.dart | 106 +- lib/ui/screens/email_list_screen.dart | 99 +- lib/ui/screens/search_screen.dart | 17 +- lib/ui/screens/sieve_script_edit_screen.dart | 16 +- lib/ui/screens/sieve_scripts_screen.dart | 22 +- lib/ui/screens/sync_log_screen.dart | 65 +- lib/ui/screens/thread_detail_screen.dart | 14 +- lib/ui/screens/undo_log_screen.dart | 14 +- lib/ui/screens/user_preferences_screen.dart | 12 +- lib/ui/utils/about_markdown.dart | 10 +- lib/ui/widgets/email_tile.dart | 8 +- lib/ui/widgets/folder_drawer.dart | 8 +- lib/ui/widgets/secure_email_webview.dart | 36 +- scripts/setup_dagger_remote.sh | 165 ++-- secrets.enc.yaml | 23 + test/backend/account_sync_manager_test.dart | 193 ++-- test/backend/concurrent_sync_test.dart | 5 +- test/backend/email_repository_imap_test.dart | 110 ++- test/backend/email_repository_jmap_test.dart | 48 +- .../backend/mailbox_repository_imap_test.dart | 3 +- test/backend/sync_reliability_test.dart | 4 +- .../account_repository_contract_test.dart | 16 +- test/unit/account_sync_manager_test.dart | 112 +-- test/unit/apply_sieve_rules_test.dart | 20 +- test/unit/background_sync_test.dart | 17 +- test/unit/cid_utils_test.dart | 8 +- 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 | 25 +- test/unit/email_repository_impl_test.dart | 935 ++++++++++-------- 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 | 9 +- test/unit/mailbox_repository_impl_test.dart | 255 ++--- test/unit/managesieve_probe_service_test.dart | 84 +- test/unit/migration_test.dart | 167 ++-- test/unit/notification_service_test.dart | 17 +- .../reliability_runner_check_now_test.dart | 25 +- test/unit/reliability_runner_test.dart | 57 +- test/unit/share_encryption_service_test.dart | 4 +- test/unit/sieve_interpreter_test.dart | 12 +- test/unit/sieve_parser_test.dart | 5 +- test/unit/sync_log_repository_impl_test.dart | 63 +- test/unit/undo_logic_test.dart | 84 +- test/unit/undo_service_test.dart | 128 +-- test/widget/about_screen_test.dart | 19 +- test/widget/account_export_screen_test.dart | 10 +- test/widget/account_list_screen_test.dart | 82 +- test/widget/crash_screen_test.dart | 134 +-- test/widget/edit_account_screen_test.dart | 99 +- test/widget/email_detail_screen_test.dart | 242 +++-- .../widget/email_list_screen_golden_test.dart | 70 +- test/widget/email_list_screen_test.dart | 96 +- test/widget/helpers.dart | 171 ++-- test/widget/search_screen_test.dart | 8 +- test/widget/secure_email_webview_test.dart | 49 +- test/widget/sieve_scripts_screen_test.dart | 8 +- test/widget/thread_detail_screen_test.dart | 33 +- test/widget/try_connection_button_test.dart | 12 +- test/widget/undo_shell_test.dart | 37 +- test/widget/user_preferences_screen_test.dart | 79 +- 103 files changed, 3416 insertions(+), 3279 deletions(-) create mode 100644 secrets.enc.yaml diff --git a/lib/core/models/email.dart b/lib/core/models/email.dart index c61e868..d3787c4 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 d032995..72a5000 100644 --- a/lib/core/services/account_discovery_service.dart +++ b/lib/core/services/account_discovery_service.dart @@ -35,8 +35,9 @@ 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 00a5e74..2d8be62 100644 --- a/lib/core/services/connection_test_service.dart +++ b/lib/core/services/connection_test_service.dart @@ -6,30 +6,24 @@ 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. @@ -43,9 +37,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; @@ -162,12 +156,9 @@ 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 51f83e0..10e4d39 100644 --- a/lib/core/services/managesieve_probe_service.dart +++ b/lib/core/services/managesieve_probe_service.dart @@ -4,11 +4,12 @@ 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, @@ -65,22 +66,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 cf26623..418f07d 100644 --- a/lib/core/services/notification_service.dart +++ b/lib/core/services/notification_service.dart @@ -18,7 +18,8 @@ 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 2dc37eb..a237803 100644 --- a/lib/core/services/share_encryption_service.dart +++ b/lib/core/services/share_encryption_service.dart @@ -92,8 +92,9 @@ class ShareEncryptionService { ) { if (!s.startsWith(_pubKeyPrefix)) return null; try { - final data = - Uint8List.fromList(base64.decode(s.substring(_pubKeyPrefix.length))); + final data = Uint8List.fromList( + base64.decode(s.substring(_pubKeyPrefix.length)), + ); if (data.length != _keyIdLen + _pubKeyLen) return null; return ( keyId: data.sublist(0, _keyIdLen), @@ -165,17 +166,18 @@ 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 ff43661..70d4a2a 100644 --- a/lib/core/services/undo_service.dart +++ b/lib/core/services/undo_service.dart @@ -62,7 +62,8 @@ 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 0a2fb4b..133f7e2 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 780fa97..505c818 100644 --- a/lib/core/sieve/sieve_interpreter.dart +++ b/lib/core/sieve/sieve_interpreter.dart @@ -64,8 +64,9 @@ 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), }; } @@ -108,8 +109,9 @@ class SieveInterpreter { } bool _globMatch(String value, String pattern) { - final regexStr = - RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.'); + final regexStr = RegExp.escape( + pattern, + ).replaceAll(r'\*', '.*').replaceAll(r'\?', '.'); return RegExp('^$regexStr\$').hasMatch(value); } diff --git a/lib/core/sieve/sieve_parser.dart b/lib/core/sieve/sieve_parser.dart index 75c6b95..fbdd54f 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++; @@ -466,9 +466,7 @@ class _Scanner { String readTaggedArg() { if (!isAtEnd && _src[_pos] == ':') return readWord(); - throw SieveParseException( - 'Expected tagged argument at position $_pos', - ); + throw SieveParseException('Expected tagged argument at position $_pos'); } String? peekSizeUnit() { @@ -480,9 +478,7 @@ class _Scanner { String readDigits() { if (isAtEnd || !_isDigit(_src[_pos])) { - throw SieveParseException( - 'Expected number at position $_pos', - ); + throw SieveParseException('Expected number at position $_pos'); } final start = _pos; while (!isAtEnd && _isDigit(_src[_pos])) { @@ -493,9 +489,7 @@ class _Scanner { String readQuotedString() { if (_src[_pos] != '"') { - throw SieveParseException( - 'Expected " at position $_pos', - ); + throw SieveParseException('Expected " at position $_pos'); } _pos++; // skip opening quote final buf = StringBuffer(); diff --git a/lib/core/sync/account_sync_manager.dart b/lib/core/sync/account_sync_manager.dart index fba2b0f..6c8014f 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,8 +379,9 @@ 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 { @@ -396,12 +397,13 @@ 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(); @@ -443,8 +445,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; @@ -640,13 +642,15 @@ 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 1189854..eb45d7e 100644 --- a/lib/core/sync/background_sync.dart +++ b/lib/core/sync/background_sync.dart @@ -83,8 +83,9 @@ 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( @@ -93,16 +94,18 @@ 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 90d8014..a505ffd 100644 --- a/lib/core/sync/reliability_runner.dart +++ b/lib/core/sync/reliability_runner.dart @@ -76,11 +76,14 @@ 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/core/utils/cid_utils.dart b/lib/core/utils/cid_utils.dart index 1a761e9..ca081fe 100644 --- a/lib/core/utils/cid_utils.dart +++ b/lib/core/utils/cid_utils.dart @@ -35,10 +35,7 @@ String injectInlineImages(String html, imap.MimeMessage msg) { .replaceAll('src="cid:$bareCid"', 'src="$dataUri"') .replaceAll("src='cid:$bareCid'", "src='$dataUri'") .replaceAll('src="cid:${bareCid.toLowerCase()}"', 'src="$dataUri"') - .replaceAll( - "src='cid:${bareCid.toLowerCase()}'", - "src='$dataUri'", - ); + .replaceAll("src='cid:${bareCid.toLowerCase()}'", "src='$dataUri'"); } return result; } diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index 01164d5..41576de 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -388,231 +388,228 @@ 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()), - ), - 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);', + 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()), ), - ); - // 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(); + 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(); - 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(). @@ -663,7 +660,8 @@ 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 f84e2e3..3a85355 100644 --- a/lib/data/db/local_sieve_repository.dart +++ b/lib/data/db/local_sieve_repository.dart @@ -9,9 +9,9 @@ class LocalSieveRepository { final AppDatabase _db; Future> listScripts(String accountId) async { - final rows = await (_db.select(_db.localSieveScripts) - ..where((t) => t.accountId.equals(accountId))) - .get(); + final rows = await (_db.select( + _db.localSieveScripts, + )..where((t) => t.accountId.equals(accountId))).get(); return rows .map( (r) => SieveScript( @@ -26,11 +26,11 @@ 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; } @@ -44,20 +44,18 @@ class LocalSieveRepository { if (id != null) { final rowId = int.parse(id); await (_db.update(_db.localSieveScripts) - ..where( - (t) => t.id.equals(rowId) & t.accountId.equals(accountId), - )) + ..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, @@ -65,7 +63,9 @@ 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, @@ -78,11 +78,9 @@ class LocalSieveRepository { Future deleteScript(String accountId, String scriptId) async { final rowId = int.parse(scriptId); - await (_db.delete(_db.localSieveScripts) - ..where( - (t) => t.id.equals(rowId) & t.accountId.equals(accountId), - )) - .go(); + await (_db.delete( + _db.localSieveScripts, + )..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId))).go(); } Future activateScript(String accountId, String scriptId) async { @@ -92,9 +90,7 @@ class LocalSieveRepository { .write(const LocalSieveScriptsCompanion(isActive: Value(false))); final rowId = int.parse(scriptId); await (_db.update(_db.localSieveScripts) - ..where( - (t) => t.id.equals(rowId) & t.accountId.equals(accountId), - )) + ..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId))) .write(const LocalSieveScriptsCompanion(isActive: Value(true))); }); } diff --git a/lib/data/imap/imap_client_factory.dart b/lib/data/imap/imap_client_factory.dart index edc9e6f..ceceeab 100644 --- a/lib/data/imap/imap_client_factory.dart +++ b/lib/data/imap/imap_client_factory.dart @@ -6,11 +6,12 @@ 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] @@ -64,8 +65,9 @@ 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 47e90f6..9fb60bc 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,12 +67,9 @@ 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; } @@ -218,12 +215,9 @@ 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})'); } @@ -246,7 +240,8 @@ 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 cc22a5b..f39d496 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,16 +51,13 @@ 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) { @@ -126,12 +123,9 @@ 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?; @@ -170,19 +164,16 @@ 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)) { @@ -201,16 +192,13 @@ 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); }); } @@ -231,8 +219,9 @@ 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), @@ -258,8 +247,9 @@ 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 a2b5423..2c3dc0c 100644 --- a/lib/data/repositories/account_repository_impl.dart +++ b/lib/data/repositories/account_repository_impl.dart @@ -23,14 +23,15 @@ 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, @@ -58,8 +59,7 @@ 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 162afa6..78ff3fc 100644 --- a/lib/data/repositories/draft_repository_impl.dart +++ b/lib/data/repositories/draft_repository_impl.dart @@ -9,11 +9,8 @@ import 'package:sharedinbox/data/db/database.dart'; import 'package:sharedinbox/data/imap/imap_client_factory.dart'; class DraftRepositoryImpl implements DraftRepository { - DraftRepositoryImpl( - this._db, - this._accounts, { - ImapConnectFn? imapConnect, - }) : _imapConnect = imapConnect; + DraftRepositoryImpl(this._db, this._accounts, {ImapConnectFn? imapConnect}) + : _imapConnect = imapConnect; final AppDatabase _db; final AccountRepository _accounts; @@ -54,7 +51,9 @@ 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), @@ -95,8 +94,7 @@ 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); } @@ -113,8 +111,9 @@ 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); @@ -124,10 +123,7 @@ class DraftRepositoryImpl implements DraftRepository { } } - Future _syncWithServer( - imap.ImapClient client, - String accountId, - ) async { + Future _syncWithServer(imap.ImapClient client, String accountId) async { // Create/select the Drafts folder. try { await client.createMailbox('Drafts'); @@ -138,11 +134,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() @@ -156,24 +152,26 @@ 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; if (uid != null) { - await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id))) - .write(DraftsCompanion(imapServerId: Value(uid))); + await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id))).write( + DraftsCompanion(imapServerId: Value(uid)), + ); } } // 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(); @@ -184,7 +182,9 @@ 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 +210,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 c9a2de5..d45d762 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -22,11 +22,12 @@ 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 { @@ -37,10 +38,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; @@ -131,27 +132,27 @@ class EmailRepositoryImpl implements EmailRepository { String mailboxPath, String threadId, ) async { - final threadEmails = await (_db.select(_db.emails) - ..where( + 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( (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( - (t) => - t.accountId.equals(accountId) & - t.mailboxPath.equals(mailboxPath) & - t.id.equals(threadId), - )) + t.id.equals(threadId), + )) .go(); return; } @@ -172,7 +173,9 @@ class EmailRepositoryImpl implements EmailRepository { } } - await _db.into(_db.threads).insertOnConflictUpdate( + await _db + .into(_db.threads) + .insertOnConflictUpdate( ThreadsCompanion.insert( id: threadId, accountId: accountId, @@ -196,8 +199,7 @@ 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); } @@ -209,8 +211,7 @@ 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 @@ -221,8 +222,7 @@ 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,8 +246,9 @@ 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( @@ -256,7 +257,8 @@ 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, @@ -273,7 +275,9 @@ 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), @@ -331,13 +335,7 @@ class EmailRepositoryImpl implements EmailRepository { ], 'fetchHTMLBodyValues': true, 'fetchTextBodyValues': true, - 'bodyProperties': [ - 'partId', - 'type', - 'name', - 'size', - 'subParts', - ], + 'bodyProperties': ['partId', 'type', 'name', 'size', 'subParts'], }, '0', ], @@ -363,7 +361,9 @@ 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,7 +415,8 @@ 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, @@ -430,21 +431,19 @@ 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) { @@ -478,11 +477,10 @@ 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) { @@ -502,15 +500,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, @@ -606,7 +604,8 @@ 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, @@ -629,7 +628,9 @@ class EmailRepositoryImpl implements EmailRepository { } } - await _db.into(_db.emails).insertOnConflictUpdate( + await _db + .into(_db.emails) + .insertOnConflictUpdate( EmailsCompanion.insert( id: emailId, accountId: account.id, @@ -667,14 +668,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 { @@ -718,13 +719,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). @@ -780,21 +781,20 @@ 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 +888,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,7 +1193,9 @@ 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, @@ -1221,7 +1223,9 @@ 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), @@ -1296,13 +1300,11 @@ 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()), @@ -1314,13 +1316,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; } @@ -1329,7 +1331,9 @@ 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, @@ -1409,27 +1413,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); + (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 + } } - } catch (_) { - // Malformed JSON — ignore line - } - } - }, - onDone: () => controller.close(), - onError: (_) => controller.close(), - cancelOnError: true, - ); + }, + onDone: () => controller.close(), + onError: (_) => controller.close(), + cancelOnError: true, + ); } catch (e) { log('JMAP push: unexpected error: $e'); await controller.close(); @@ -1479,8 +1483,7 @@ 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))!; @@ -1556,14 +1559,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 { @@ -1590,22 +1593,20 @@ 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))); }); } @@ -1614,8 +1615,7 @@ 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 +1683,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,7 +1741,9 @@ 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', @@ -1772,8 +1774,7 @@ 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; @@ -1783,24 +1784,27 @@ 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'; @@ -1837,24 +1841,25 @@ 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( @@ -1885,20 +1890,24 @@ 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, @@ -1930,12 +1939,13 @@ 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; @@ -1947,27 +1957,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(); + final alreadyApplied = await (_db.select( + _db.localSieveApplied, + )..where((t) => t.accountId.equals(accountId))).get(); final appliedIds = alreadyApplied.map((r) => r.messageId).toSet(); - final inboxEmails = await (_db.select(_db.emails) - ..where( - (t) => - t.accountId.equals(accountId) & - t.mailboxPath.equals(inboxPath) & - t.messageId.isNotNull(), - )) - .get(); + final 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(); @@ -2009,12 +2020,14 @@ 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 ''; } @@ -2033,7 +2046,9 @@ 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, @@ -2049,14 +2064,17 @@ 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}'); + log( + 'Sieve: JMAP mailbox "$folder" not found for account ${account.id}', + ); return; } destPath = destMailbox.path; @@ -2142,10 +2160,11 @@ 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))!; @@ -2184,8 +2203,7 @@ 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) { @@ -2195,12 +2213,11 @@ 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, @@ -2213,8 +2230,7 @@ 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); @@ -2249,8 +2265,7 @@ 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)) { @@ -2258,8 +2273,7 @@ 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 { @@ -2356,10 +2370,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) { @@ -2443,8 +2457,9 @@ 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([ @@ -2631,12 +2646,13 @@ 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. @@ -2714,28 +2730,25 @@ 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( @@ -2782,8 +2795,7 @@ 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); @@ -2814,10 +2826,7 @@ class EmailRepositoryImpl implements EmailRepository { // Content-Transfer-Encoding) and getPart() can decode the part correctly. // A partial BODY.PEEK[n] fetch omits those headers, causing // decodeContentBinary() to return raw base64 instead of decoded bytes. - final fetch = await client.uidFetchMessage( - emailRow.uid, - 'BODY.PEEK[]', - ); + final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]'); final msg = fetch.messages.firstOrNull; if (msg == null) { throw StateError( @@ -2840,8 +2849,7 @@ 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); @@ -2885,10 +2893,7 @@ class EmailRepositoryImpl implements EmailRepository { ); try { await client.selectMailboxByPath(emailRow.mailboxPath); - final fetch = await client.uidFetchMessage( - emailRow.uid, - 'BODY.PEEK[]', - ); + final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]'); final msg = fetch.messages.firstOrNull; if (msg == null) { throw StateError( @@ -2911,15 +2916,16 @@ 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)), ); @@ -2947,20 +2953,22 @@ 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(); } @@ -2972,19 +2980,21 @@ 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 = []; @@ -3025,12 +3035,16 @@ 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, ); @@ -3044,25 +3058,26 @@ 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(); } @@ -3102,10 +3117,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( @@ -3167,13 +3182,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; @@ -3185,15 +3200,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 []; @@ -3269,15 +3284,15 @@ class EmailRepositoryImpl implements EmailRepository { await _db.customStatement('PRAGMA foreign_keys = OFF'); try { await _db.transaction(() async { - await (_db.delete(_db.emails) - ..where((t) => t.accountId.equals(accountId))) - .go(); - await (_db.delete(_db.pendingChanges) - ..where((t) => t.accountId.equals(accountId))) - .go(); - await (_db.delete(_db.syncStates) - ..where((t) => t.accountId.equals(accountId))) - .go(); + await (_db.delete( + _db.emails, + )..where((t) => t.accountId.equals(accountId))).go(); + await (_db.delete( + _db.pendingChanges, + )..where((t) => t.accountId.equals(accountId))).go(); + await (_db.delete( + _db.syncStates, + )..where((t) => t.accountId.equals(accountId))).go(); }); } finally { await _db.customStatement('PRAGMA foreign_keys = ON'); @@ -3289,8 +3304,10 @@ 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', @@ -3308,12 +3325,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 38d1ee4..68ec31e 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,12 +45,13 @@ 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); } @@ -82,9 +83,9 @@ class MailboxRepositoryImpl implements MailboxRepository { // Pre-load existing DB roles so we can preserve manually-set roles for // 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(); + final existingRows = await (_db.select( + _db.mailboxes, + )..where((t) => t.accountId.equals(account.id))).get(); final existingRoles = {for (final r in existingRows) r.id: r.role}; for (final mb in mailboxes) { @@ -110,7 +111,9 @@ 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, @@ -215,8 +218,7 @@ 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); @@ -237,7 +239,9 @@ 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, @@ -254,13 +258,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; } @@ -269,7 +273,9 @@ 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, @@ -298,14 +304,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) { @@ -320,9 +326,9 @@ class MailboxRepositoryImpl implements MailboxRepository { @override Future clearForResync(String accountId) async { - await (_db.delete(_db.mailboxes) - ..where((t) => t.accountId.equals(accountId))) - .go(); + await (_db.delete( + _db.mailboxes, + )..where((t) => t.accountId.equals(accountId))).go(); } @override @@ -358,7 +364,9 @@ 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, @@ -367,8 +375,9 @@ class MailboxRepositoryImpl implements MailboxRepository { role: Value(role), ), ); - final row = await (_db.select(_db.mailboxes)..where((t) => t.id.equals(id))) - .getSingle(); + final row = await (_db.select( + _db.mailboxes, + )..where((t) => t.id.equals(id))).getSingle(); return _toModel(row); } @@ -410,7 +419,9 @@ 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, @@ -419,9 +430,9 @@ class MailboxRepositoryImpl implements MailboxRepository { role: Value(role), ), ); - final row = await (_db.select(_db.mailboxes) - ..where((t) => t.id.equals(dbId))) - .getSingle(); + final row = await (_db.select( + _db.mailboxes, + )..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 ef81140..31202f5 100644 --- a/lib/data/repositories/search_history_repository_impl.dart +++ b/lib/data/repositories/search_history_repository_impl.dart @@ -10,10 +10,11 @@ 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(); } @@ -24,11 +25,13 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository { await _db.transaction(() async { // Remove existing entry for same query (deduplication). - await (_db.delete(_db.searchHistoryEntries) - ..where((t) => t.query.equals(trimmed))) - .go(); + await (_db.delete( + _db.searchHistoryEntries, + )..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(), @@ -36,16 +39,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(); + await (_db.delete( + _db.searchHistoryEntries, + )..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 4953141..25df102 100644 --- a/lib/data/repositories/share_key_repository_impl.dart +++ b/lib/data/repositories/share_key_repository_impl.dart @@ -23,7 +23,9 @@ 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), @@ -40,9 +42,9 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository { await _pruneExpired(); final keyIdHex = _hex(keyId); - final row = await (_db.select(_db.shareKeys) - ..where((t) => t.id.equals(keyIdHex))) - .getSingleOrNull(); + final row = await (_db.select( + _db.shareKeys, + )..where((t) => t.id.equals(keyIdHex))).getSingleOrNull(); if (row == null) return null; if (row.expiresAt.isBefore(DateTime.now().toUtc())) return null; @@ -55,10 +57,9 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository { } Future _pruneExpired() async { - await (_db.delete(_db.shareKeys) - ..where( - (t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()), - )) + await (_db.delete( + _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 a6f004b..04c5917 100644 --- a/lib/data/repositories/sync_log_repository_impl.dart +++ b/lib/data/repositories/sync_log_repository_impl.dart @@ -27,7 +27,9 @@ 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', @@ -46,7 +48,9 @@ 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, @@ -70,10 +74,11 @@ 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 7241162..5177139 100644 --- a/lib/data/repositories/undo_repository_impl.dart +++ b/lib/data/repositories/undo_repository_impl.dart @@ -11,7 +11,9 @@ 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, @@ -29,10 +31,11 @@ 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 ca02c07..a035d0d 100644 --- a/lib/data/repositories/user_preferences_repository_impl.dart +++ b/lib/data/repositories/user_preferences_repository_impl.dart @@ -11,14 +11,16 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository { @override Stream observePreferences() { - return (_db.select(_db.userPreferences)..where((t) => t.id.equals(_rowId))) - .watchSingleOrNull() - .map(_rowToModel); + return (_db.select( + _db.userPreferences, + )..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), @@ -28,7 +30,9 @@ 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), @@ -40,7 +44,9 @@ 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 f239062..b0ed6c8 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -101,8 +101,9 @@ final undoRepositoryProvider = Provider((ref) { return UndoRepositoryImpl(ref.watch(dbProvider)); }); -final searchHistoryRepositoryProvider = - Provider((ref) { +final searchHistoryRepositoryProvider = Provider(( + ref, +) { return SearchHistoryRepositoryImpl(ref.watch(dbProvider)); }); @@ -110,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( @@ -126,17 +127,18 @@ 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, accountId) { +final isSyncingProvider = StreamProvider.autoDispose.family(( + ref, + accountId, +) { return ref.watch(syncManagerProvider).watchSyncing(accountId); }); @@ -185,15 +187,16 @@ final manageSieveProbeServiceProvider = Provider(( return ManageSieveProbeService(ref.watch(accountRepositoryProvider)); }); -final undoServiceProvider = - NotifierProvider>(UndoService.new); +final undoServiceProvider = NotifierProvider>( + UndoService.new, +); /// Loads email header + body and marks the email as seen. /// 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); @@ -211,33 +214,38 @@ 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) { +final userPreferencesRepositoryProvider = Provider(( + ref, +) { return UserPreferencesRepositoryImpl(ref.watch(dbProvider)); }); -final userPreferencesProvider = - StreamProvider.autoDispose((ref) { +final userPreferencesProvider = StreamProvider.autoDispose(( + ref, +) { return ref.watch(userPreferencesRepositoryProvider).observePreferences(); }); diff --git a/lib/main.dart b/lib/main.dart index 66bf511..dc42650 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 97f4d9d..b8f66ab 100644 --- a/lib/ui/screens/about_screen.dart +++ b/lib/ui/screens/about_screen.dart @@ -72,8 +72,10 @@ class _AboutScreenState extends ConsumerState { Future _launchUrl(BuildContext context, Uri url) async { try { - final launched = - await launchUrl(url, mode: LaunchMode.externalApplication); + final launched = await launchUrl( + url, + mode: LaunchMode.externalApplication, + ); if (!launched && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -121,8 +123,10 @@ class _AboutScreenState extends ConsumerState { 'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body', ); try { - final launched = - await launchUrl(url, mode: LaunchMode.externalApplication); + final launched = await launchUrl( + url, + mode: LaunchMode.externalApplication, + ); if (!launched && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -149,10 +153,12 @@ 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')), @@ -176,9 +182,7 @@ class _AboutScreenState extends ConsumerState { selectable: true, onTapLink: (text, href, title) { if (href != null) { - unawaited( - _launchUrl(context, Uri.parse(href)), - ); + unawaited(_launchUrl(context, Uri.parse(href))); } }, ); diff --git a/lib/ui/screens/account_receive_screen.dart b/lib/ui/screens/account_receive_screen.dart index 0be5c89..cc41621 100644 --- a/lib/ui/screens/account_receive_screen.dart +++ b/lib/ui/screens/account_receive_screen.dart @@ -209,28 +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, - ), - ), + 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: 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 9049fed..2a6382e 100644 --- a/lib/ui/screens/account_send_screen.dart +++ b/lib/ui/screens/account_send_screen.dart @@ -117,8 +117,10 @@ 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; @@ -158,10 +160,7 @@ class _AccountSendScreenState extends ConsumerState { for (final account in selected) { final password = await repo.getPassword(account.id); payloads.add( - AccountPayload( - accountJson: account.toJson(), - password: password, - ), + AccountPayload(accountJson: account.toJson(), password: password), ); } @@ -198,11 +197,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'), ), + ), }, ); } @@ -361,9 +360,7 @@ class _AccountSendScreenState extends ConsumerState { unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!))); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text( - 'Encrypted code copied to clipboard', - ), + content: Text('Encrypted code copied to clipboard'), ), ); }, diff --git a/lib/ui/screens/add_account_screen.dart b/lib/ui/screens/add_account_screen.dart index 01ed21c..1d0465a 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,7 +494,8 @@ 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 fd1b56a..4dfb8ed 100644 --- a/lib/ui/screens/address_emails_screen.dart +++ b/lib/ui/screens/address_emails_screen.dart @@ -51,38 +51,37 @@ 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/changelog_screen.dart b/lib/ui/screens/changelog_screen.dart index b240b4d..4008da2 100644 --- a/lib/ui/screens/changelog_screen.dart +++ b/lib/ui/screens/changelog_screen.dart @@ -12,8 +12,9 @@ class ChangeLogScreen extends StatelessWidget { return Scaffold( appBar: AppBar(title: const Text('ChangeLog')), body: FutureBuilder( - future: - DefaultAssetBundle.of(context).loadString('assets/changelog.txt'), + future: DefaultAssetBundle.of( + context, + ).loadString('assets/changelog.txt'), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); diff --git a/lib/ui/screens/compose_screen.dart b/lib/ui/screens/compose_screen.dart index aea2c31..765d558 100644 --- a/lib/ui/screens/compose_screen.dart +++ b/lib/ui/screens/compose_screen.dart @@ -70,7 +70,8 @@ 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()); @@ -81,8 +82,10 @@ 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; @@ -194,9 +197,7 @@ class _ComposeScreenState extends ConsumerState { await OpenFilex.open(path); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar( + ScaffoldMessenger.of(context).showSnackBar( SnackBar( duration: const Duration(seconds: 5), content: Text('Failed to open file: $e'), @@ -213,9 +214,7 @@ class _ComposeScreenState extends ConsumerState { Future _send() async { if (_accountId == null) { - ScaffoldMessenger.of( - context, - ).showSnackBar( + ScaffoldMessenger.of(context).showSnackBar( const SnackBar( duration: Duration(seconds: 5), content: Text('Select an account first'), @@ -225,8 +224,9 @@ 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 @@ -255,9 +255,7 @@ class _ComposeScreenState extends ConsumerState { if (mounted) context.pop(); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar( + ScaffoldMessenger.of(context).showSnackBar( SnackBar( duration: const Duration(seconds: 5), content: Text('Send failed: $e'), @@ -401,8 +399,9 @@ 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/crash_screen.dart b/lib/ui/screens/crash_screen.dart index 0573f8b..1567556 100644 --- a/lib/ui/screens/crash_screen.dart +++ b/lib/ui/screens/crash_screen.dart @@ -81,9 +81,9 @@ class CrashScreen extends StatelessWidget { builder: (context, snapshot) => Text( 'v${snapshot.data ?? '…'} • $_buildMode • ' '${Platform.operatingSystem} ${Platform.operatingSystemVersion}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.grey[600], - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Colors.grey[600]), textAlign: TextAlign.center, ), ), diff --git a/lib/ui/screens/edit_account_screen.dart b/lib/ui/screens/edit_account_screen.dart index 56bb76b..af5cc6d 100644 --- a/lib/ui/screens/edit_account_screen.dart +++ b/lib/ui/screens/edit_account_screen.dart @@ -117,7 +117,8 @@ 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; @@ -138,10 +139,12 @@ 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, ); } @@ -151,8 +154,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; @@ -392,7 +395,8 @@ 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_action_helpers.dart b/lib/ui/screens/email_action_helpers.dart index 91288fa..07b5dee 100644 --- a/lib/ui/screens/email_action_helpers.dart +++ b/lib/ui/screens/email_action_helpers.dart @@ -54,8 +54,9 @@ Future resolveMailboxByRole( style: TextStyle(fontWeight: FontWeight.bold), ), ), - for (final m - in mailboxes.where((m) => m.path != currentMailboxPath)) + for (final m in mailboxes.where( + (m) => m.path != currentMailboxPath, + )) ListTile( leading: const Icon(Icons.folder_outlined), title: Text(m.name), diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index b274abf..8ac7616 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -55,7 +55,8 @@ 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( @@ -72,9 +73,7 @@ class _EmailDetailScreenState extends ConsumerState { onPressed: header == null ? null : () { - unawaited( - _replyWithRecipientDialog(context, header, body), - ); + unawaited(_replyWithRecipientDialog(context, header, body)); }, ), IconButton( @@ -95,7 +94,9 @@ 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, @@ -126,22 +127,10 @@ class _EmailDetailScreenState extends ConsumerState { ), PopupMenuButton( itemBuilder: (ctx) => [ - const PopupMenuItem( - value: 'forward', - child: Text('Forward'), - ), - const PopupMenuItem( - value: 'move', - child: Text('Move to folder'), - ), - const PopupMenuItem( - value: 'snooze', - child: Text('Snooze'), - ), - const PopupMenuItem( - value: 'spam', - child: Text('Mark as spam'), - ), + const PopupMenuItem(value: 'forward', child: Text('Forward')), + const PopupMenuItem(value: 'move', child: Text('Move to folder')), + const PopupMenuItem(value: 'snooze', child: Text('Snooze')), + const PopupMenuItem(value: 'spam', child: Text('Mark as spam')), const PopupMenuItem( value: 'mark_unread', child: Text('Mark as unread'), @@ -155,10 +144,7 @@ class _EmailDetailScreenState extends ConsumerState { value: 'structure', child: Text('Show Mail Structure'), ), - const PopupMenuItem( - value: 'rfc', - child: Text('Show Raw Email'), - ), + const PopupMenuItem(value: 'rfc', child: Text('Show Raw Email')), ], onSelected: (value) async { if (value == 'forward' && header != null) { @@ -264,8 +250,9 @@ class _EmailDetailScreenState extends ConsumerState { .observeThreads(header.accountId, header.mailboxPath) .first; - final currentIndex = - threads.indexWhere((t) => t.emailIds.contains(widget.emailId)); + final currentIndex = threads.indexWhere( + (t) => t.emailIds.contains(widget.emailId), + ); if (currentIndex >= 0 && currentIndex + 1 < threads.length) { return threads[currentIndex + 1].latestEmailId; } @@ -337,8 +324,9 @@ 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 @@ -352,8 +340,9 @@ 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 = {}; @@ -456,7 +445,9 @@ 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, @@ -492,7 +483,9 @@ 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, @@ -520,10 +513,7 @@ class _EmailDetailScreenState extends ConsumerState { unawaited( context.push( '/compose', - extra: { - 'prefillSubject': subject, - 'prefillBody': quoted, - }, + extra: {'prefillSubject': subject, 'prefillBody': quoted}, ), ); } @@ -532,12 +522,14 @@ 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; @@ -567,7 +559,9 @@ 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, @@ -625,9 +619,9 @@ class _EmailDetailScreenState extends ConsumerState { .fetchRawRfc822(widget.emailId); } catch (e) { if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to fetch raw email: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to fetch raw email: $e'))); return; } @@ -647,8 +641,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( @@ -792,9 +786,7 @@ class _EmailDetailScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( duration: Duration(seconds: 5), - content: Text( - 'Structure not available. Try re-syncing the email.', - ), + content: Text('Structure not available. Try re-syncing the email.'), ), ); return; @@ -830,8 +822,8 @@ class _EmailDetailScreenState extends ConsumerState { child: Text( row.label, style: Theme.of(ctx).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), + fontFamily: 'monospace', + ), ), ), ], @@ -903,14 +895,8 @@ class _ReplyAllDialogState extends State<_ReplyAllDialog> { SegmentedButton<_Placement>( showSelectedIcon: false, segments: const [ - ButtonSegment( - value: _Placement.to, - label: Text('To'), - ), - ButtonSegment( - value: _Placement.cc, - label: Text('Cc'), - ), + ButtonSegment(value: _Placement.to, label: Text('To')), + ButtonSegment(value: _Placement.cc, label: Text('Cc')), ButtonSegment( value: _Placement.skip, label: Text('Skip'), diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index a10e85a..f2f5339 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,8 +182,9 @@ 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, @@ -277,8 +278,8 @@ class _EmailListScreenState extends ConsumerState { tooltip: isSyncing ? 'Syncing…' : hasError - ? 'Sync error' - : 'Sync', + ? 'Sync error' + : 'Sync', icon: isSyncing ? const SizedBox( width: 20, @@ -286,8 +287,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 { @@ -381,11 +382,7 @@ class _EmailListScreenState extends ConsumerState { } return MaterialBanner( padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), - content: Text( - error, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), + content: Text(error, maxLines: 2, overflow: TextOverflow.ellipsis), leading: Icon( Icons.sync_problem, color: Theme.of(context).colorScheme.error, @@ -399,9 +396,8 @@ class _EmailListScreenState extends ConsumerState { child: const Text('Retry'), ), TextButton( - onPressed: () => context.push( - '/accounts/${widget.accountId}/sync-log', - ), + onPressed: () => + context.push('/accounts/${widget.accountId}/sync-log'), child: const Text('View log'), ), TextButton( @@ -470,9 +466,7 @@ 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); @@ -491,10 +485,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; @@ -533,9 +527,7 @@ 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) { @@ -574,10 +566,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; @@ -585,8 +577,9 @@ 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; @@ -618,9 +611,7 @@ 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); @@ -651,9 +642,7 @@ 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); @@ -694,8 +683,10 @@ 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( @@ -707,8 +698,9 @@ 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( @@ -768,12 +760,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), ); @@ -781,8 +773,9 @@ 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, @@ -804,9 +797,7 @@ 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 87fc7ac..e36d5b4 100644 --- a/lib/ui/screens/search_screen.dart +++ b/lib/ui/screens/search_screen.dart @@ -10,8 +10,9 @@ import 'package:sharedinbox/core/utils/logger.dart'; import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/ui/widgets/email_tile.dart'; -final _searchHistoryProvider = - FutureProvider.autoDispose>((ref) async { +final _searchHistoryProvider = FutureProvider.autoDispose>(( + ref, +) async { return ref.watch(searchHistoryRepositoryProvider).getRecentSearches(); }); @@ -83,10 +84,9 @@ 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,8 +306,9 @@ 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 a7d2db7..e74ec09 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,14 +87,18 @@ 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 0f23ebd..a6fe5d0 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; @@ -137,9 +137,7 @@ class _SieveScriptsScreenState extends ConsumerState { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text( - widget.isLocal ? 'Local Filters' : 'Remote Filters', - ), + title: Text(widget.isLocal ? 'Local Filters' : 'Remote Filters'), ), body: _buildBody(), floatingActionButton: FloatingActionButton( @@ -209,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, @@ -230,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 e706f0b..85f9018 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,8 +125,10 @@ 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; @@ -204,16 +206,17 @@ 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( @@ -338,18 +341,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), - ), - ), - Expanded(child: Text(value, style: const TextStyle(fontSize: 12))), - ], + 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))), + ], + ), + ); } diff --git a/lib/ui/screens/thread_detail_screen.dart b/lib/ui/screens/thread_detail_screen.dart index 2bddb64..47a6a87 100644 --- a/lib/ui/screens/thread_detail_screen.dart +++ b/lib/ui/screens/thread_detail_screen.dart @@ -101,8 +101,9 @@ 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( @@ -229,8 +230,9 @@ 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 ?? ''}'; @@ -290,7 +292,9 @@ 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 9a36d9c..0fe05aa 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( @@ -84,9 +84,7 @@ class _UndoActionTile extends ConsumerWidget { .read(undoServiceProvider.notifier) .undo(actionId: action.id); if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar( + ScaffoldMessenger.of(context).showSnackBar( const SnackBar( duration: Duration(seconds: 5), content: Text('Action undone.'), diff --git a/lib/ui/screens/user_preferences_screen.dart b/lib/ui/screens/user_preferences_screen.dart index e1dd6de..08749ff 100644 --- a/lib/ui/screens/user_preferences_screen.dart +++ b/lib/ui/screens/user_preferences_screen.dart @@ -90,9 +90,7 @@ class UserPreferencesScreen extends ConsumerWidget { ), RadioListTile( title: Text('Top'), - subtitle: Text( - 'Show the back button in the top bar.', - ), + subtitle: Text('Show the back button in the top bar.'), value: MenuPosition.top, ), ], @@ -122,16 +120,12 @@ class UserPreferencesScreen extends ConsumerWidget { children: [ RadioListTile( title: Text('Next message (default)'), - subtitle: Text( - 'Show the next message in the mailbox.', - ), + subtitle: Text('Show the next message in the mailbox.'), value: AfterMailViewAction.nextMessage, ), RadioListTile( title: Text('Return to mailbox'), - subtitle: Text( - 'Return to the message list.', - ), + subtitle: Text('Return to the message list.'), value: AfterMailViewAction.showMailbox, ), ], diff --git a/lib/ui/utils/about_markdown.dart b/lib/ui/utils/about_markdown.dart index 33ffa77..720202b 100644 --- a/lib/ui/utils/about_markdown.dart +++ b/lib/ui/utils/about_markdown.dart @@ -26,14 +26,16 @@ String buildAboutMarkdown({ final osName = _capitalize(Platform.operatingSystem); final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark; final locale = Localizations.localeOf(context).toString(); - final textScale = - MediaQuery.of(context).textScaler.scale(1.0).toStringAsFixed(1); + final textScale = MediaQuery.of( + context, + ).textScaler.scale(1.0).toStringAsFixed(1); 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 d8d5794..f2561a7 100644 --- a/lib/ui/widgets/email_tile.dart +++ b/lib/ui/widgets/email_tile.dart @@ -37,15 +37,17 @@ 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 b4c8dd1..7fd0e34 100644 --- a/lib/ui/widgets/folder_drawer.dart +++ b/lib/ui/widgets/folder_drawer.dart @@ -43,11 +43,9 @@ 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 fd6e44d..6b2aaec 100644 --- a/lib/ui/widgets/secure_email_webview.dart +++ b/lib/ui/widgets/secure_email_webview.dart @@ -16,7 +16,8 @@ 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'; " @@ -106,9 +107,9 @@ class _SecureEmailWebViewState extends State { } String _buildHtml() => buildEmailHtml( - widget.htmlBody, - loadRemoteImages: widget.loadRemoteImages, - ); + widget.htmlBody, + loadRemoteImages: widget.loadRemoteImages, + ); Future _measureHeight(String _) async { try { @@ -140,13 +141,14 @@ 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, @@ -191,12 +193,14 @@ class _SecureEmailWebViewState extends State { ); if (confirmed == true && mounted) { - final launched = - await launchUrl(uri, mode: LaunchMode.externalApplication); + final launched = await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); if (!launched && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Could not open: $url')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Could not open: $url'))); } } } diff --git a/scripts/setup_dagger_remote.sh b/scripts/setup_dagger_remote.sh index 2506487..64fb616 100755 --- a/scripts/setup_dagger_remote.sh +++ b/scripts/setup_dagger_remote.sh @@ -1,108 +1,83 @@ #!/usr/bin/env bash -# Establishes a secure tunnel to a remote Dagger Engine via stunnel. +# Establishes a secure tunnel to a remote Dagger Engine via SSH using SOPS secrets. set -euo pipefail -if [ -z "${DAGGER_STUNNEL_URL:-}" ]; then - echo "Error: DAGGER_STUNNEL_URL must be set." +# 0. Check for old environment variables +if [ -n "${DAGGER_STUNNEL_URL:-}" ] || [ -n "${DAGGER_CA_CERT:-}" ] || [ -n "${DAGGER_SSH_KEY:-}" ]; then + echo "ERROR: Old environment variables (DAGGER_STUNNEL_URL, DAGGER_CA_CERT, or DAGGER_SSH_KEY) are present in the environment." + echo "Only SOPS_AGE_KEY should be set in Codeberg secrets." exit 1 fi -# Parse host and port (e.g., example.com:8774 or just example.com) -host=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f1) -port=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f2) -if [ "$host" == "$port" ]; then - port="8774" -fi - -MAX_PROBE_ATTEMPTS=5 -PROBE_DELAY=30 -for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do - echo "Probing $host:$port (attempt $attempt/$MAX_PROBE_ATTEMPTS)..." - if nc -zw 5 "$host" "$port" 2>/dev/null; then - echo "Found active server on $host:$port" - break - fi - if [ "$attempt" -eq "$MAX_PROBE_ATTEMPTS" ]; then - echo "Warning: No Dagger server responded on $host:$port after $MAX_PROBE_ATTEMPTS attempts" - if ! timeout 30 docker info >/dev/null 2>&1; then - echo "Error: Remote Dagger engine is unavailable AND local Docker daemon is not running." - echo "Cannot proceed. Ensure either the remote server at $host:$port is accessible" - echo "or that Docker is running locally (check: sudo systemctl start docker)." - exit 1 - fi - echo "Remote engine unavailable — CI will use the local Dagger engine." - exit 0 - fi - echo "Dagger server not responding, waiting ${PROBE_DELAY}s before retry..." - sleep $PROBE_DELAY -done - -# 2a. Try plain TCP connection first (works when server is a plain TCP proxy, no TLS) -echo "Trying plain TCP Dagger connection at tcp://$host:$port..." -if _DAGGER_RUNNER_HOST="tcp://$host:$port" \ - _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" \ - timeout 8 dagger version >/dev/null 2>&1; then - echo "Plain TCP Dagger connection succeeded — no TLS stunnel needed." - if [ -n "${GITHUB_ENV:-}" ]; then - echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV" - echo "_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV" - else - export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" - export _DAGGER_RUNNER_HOST="tcp://$host:$port" - echo "Dagger configured at tcp://$host:$port (plain TCP)" - fi - exit 0 -fi -echo "Plain TCP connection not available; trying TLS stunnel..." - -# 2b. Setup TLS credentials (passed as env vars from secrets) -mkdir -p /tmp/dagger-tls -echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt -echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt -echo "$DAGGER_CLIENT_KEY" > /tmp/dagger-tls/client.key -chmod 600 /tmp/dagger-tls/client.key - -# 3. Configure and start stunnel -STUNNEL_CONF="/tmp/stunnel-dagger.conf" -cat << EOF > "$STUNNEL_CONF" -client = yes -foreground = yes -pid = /tmp/stunnel.pid -debug = warning -; TCP keepalive on the remote side to prevent NAT/firewall from resetting the connection -socket = r:SO_KEEPALIVE=1 -socket = r:TCP_KEEPIDLE=10 -socket = r:TCP_KEEPINTVL=5 -socket = r:TCP_KEEPCNT=3 - -[dagger] -accept = 127.0.0.1:1774 -connect = $host:$port -CAfile = /tmp/dagger-tls/ca.crt -cert = /tmp/dagger-tls/client.crt -key = /tmp/dagger-tls/client.key -verifyChain = yes -EOF - -# Start stunnel in the background -stunnel "$STUNNEL_CONF" & -TUNNEL_PID=$! - -# Give it a moment to establish -sleep 2 - -if ! kill -0 "$TUNNEL_PID" 2>/dev/null; then - echo "Error: stunnel failed to start" +if [ -z "${SOPS_AGE_KEY:-}" ]; then + echo "Error: SOPS_AGE_KEY must be set." exit 1 fi +# 1. Decrypt secrets using SOPS +# We assume sops is available in the nix environment +echo "Decrypting secrets with SOPS..." +# Exporting for SOPS +export SOPS_AGE_KEY="$SOPS_AGE_KEY" + +# Create a temporary file to store decrypted secrets +SECRETS_JSON=$(mktemp) +trap "rm -f $SECRETS_JSON" EXIT + +# Decrypt the SOPS file (must be in the repo root) +sops --decrypt secrets.enc.yaml > "$SECRETS_JSON" + +DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON") +DAGGER_ENGINE_HOST=$(jq -r '.DAGGER_ENGINE_HOST' "$SECRETS_JSON") + +if [ "$DAGGER_SSH_KEY" == "null" ] || [ -z "$DAGGER_SSH_KEY" ]; then + echo "Error: DAGGER_SSH_KEY not found in secrets.enc.yaml" + exit 1 +fi + +if [ "$DAGGER_ENGINE_HOST" == "null" ] || [ -z "$DAGGER_ENGINE_HOST" ]; then + echo "Error: DAGGER_ENGINE_HOST not found in secrets.enc.yaml" + exit 1 +fi + +# 2. Setup SSH key +mkdir -p ~/.ssh +chmod 700 ~/.ssh +echo "$DAGGER_SSH_KEY" > ~/.ssh/dagger_key +chmod 600 ~/.ssh/dagger_key + +# 3. Configure SSH for Dagger +cat << SSHEOF > ~/.ssh/config.dagger +Host dagger-engine + HostName $DAGGER_ENGINE_HOST + User dagger + IdentityFile ~/.ssh/dagger_key + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + ControlMaster auto + ControlPath ~/.ssh/dagger-%r@%h:%p + ControlPersist 10m +SSHEOF + +# Append to main ssh config if not already there +if ! grep -q "config.dagger" ~/.ssh/config 2>/dev/null; then + echo "Include ~/.ssh/config.dagger" >> ~/.ssh/config +fi + # 4. Export environment for subsequent CI steps +export DAGGER_HOST="ssh://dagger-engine" + if [ -n "${GITHUB_ENV:-}" ]; then - echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV" - echo "_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV" - echo "Tunnel established. Dagger is configured to use the remote engine." + echo "DAGGER_HOST=ssh://dagger-engine" >> "$GITHUB_ENV" + echo "Tunnel established via SSH. Dagger is configured to use the remote engine at $DAGGER_ENGINE_HOST" else - export _EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774 - export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774 - echo "Tunnel established. Run: export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" + echo "Dagger configured at ssh://dagger-engine" fi + +# 5. Verify connection +echo "Verifying Dagger connection..." +if ! timeout 30 dagger query '{ version }' >/dev/null 2>&1; then + echo "Error: Dagger engine is unreachable via SSH at $DAGGER_ENGINE_HOST" + exit 1 +fi +echo "Dagger connection verified." diff --git a/secrets.enc.yaml b/secrets.enc.yaml new file mode 100644 index 0000000..b764763 --- /dev/null +++ b/secrets.enc.yaml @@ -0,0 +1,23 @@ +DAGGER_ENGINE_HOST: ENC[AES256_GCM,data:pMblsGAO/r4=,iv:LlCE8sIM4rFM1Ia3nBdqKCt8xI56wfiZKrNQdDY0VZU=,tag:hyDGXW6jw60x3jZXLJFa/Q==,type:str] +DAGGER_SSH_KEY: ENC[AES256_GCM,data:fD9Wd7jgO34Bs156KF+VLZdfbkbOeyLioPNdxbAjH53UeUOd4lnxSWfDldeufHR+TYCjIka+5PiD5NNvH1cQPrycqHptewjuA2+V00RfkXPKi6+U4TkYmtRobHoc6wT+P5saClGl6QerIrBIWz+f1svZCn+4C65pQ4IpWjzM6iSHn+SSNtijUPuBXpzgiUg/i2m6KTI8QL+9MelkB4F0cRMgI9gfU4QvtI3IoKDKqWAGiHB/WyroylhzFoUnS2VkA0hu7K2PolS6ThWVIuClEItSvoUz7VrHfakjFv6oA23H5iIJwAX7LR8HRYW0qj0pbozEYgJhomQrR8fjQvOq+p2NKvgc6gBMO7hN2wdoYUSjoD/9WsAtDSICpFhtB7E7WWIaFzUTWFXOrXll3GOdfIqUouCzzEk8Y6tp3KHr69paeHcqNYsCCfa57N8osgV6MWMTNOIuijUwvQbbWN2uSfpcNXMV85MltDYd8xnVHiZCV/DNKK60bjYRcX2c+gGy6a9BmrWQp35rbwVnAaxgYvDwrCn7d6JLNSZs,iv:5cpyTi0r2UTuNaqVd351ds63rr7V4U1Y9NqqGZ2D0ro=,tag:DrRd8GxscAPdDG9T8OOuyw==,type:str] +NETCUP_API_KEY: ENC[AES256_GCM,data:Dnwp+wSxKWCrWXrOAr0NqD5odZnitL7dUFZBpTmx/vIBv7l/63DU6HDiWgWConkYfGo=,iv:by+yyCzv/jLAm2BQZJIwe9cArms+G2AxmgzGRketCfQ=,tag:1Wj/Em39+3FeBqUjkQouDQ==,type:str] +NETCUP_API_PASSWORD: ENC[AES256_GCM,data:GU8P9dQmambwV3gaHXeuTyS51dBWTPoyzDXQFdAGdlDEYG5iEoPs158sgTjoD3AB1iU=,iv:b3tOjaxJ/Nfn4NSXqDEwMfDwyli1T2mlQD2g1HrJQRk=,tag:o0ENCpV1IZdeve0o+WMtdA==,type:str] +NETCUP_CUSTOMER_NUMBER: ENC[AES256_GCM,data:QIzD/sSd,iv:5sp4zhQzH5pla7svsuDC3aZdk4tLlWvQOrkOG5Zbp2A=,tag:FyIFvcKWdRGtuy+XAGBYiQ==,type:str] +NTFY_ALERT_MESSAGE_URL: ENC[AES256_GCM,data:l80HCLWo6FMZrLtxMXAUKvxNgcmSJA+MnA==,iv:9+R1YO7JRP+q1CF/TRNwf/Riiq01QtngaZ2WAMy8FKo=,tag:5t9IbpE11SuS4ooCtYuGJg==,type:str] +WIREGUARD_PRIVATE_KEY_P16: ENC[AES256_GCM,data:u3GNdUsUWcwkRxjrfQAkUty0P3m4axoTTmK8Hhnfy5dV7r3s/IP4mWqS25o=,iv:mHFQODMqJD/VVM0udpyyz3qEt4EZCSquqqurwhC/Hsw=,tag:L/abimAshyCm4wyG1h2Jag==,type:str] +WIREGUARD_PRIVATE_KEY_SHAREDINBOX_DE: ENC[AES256_GCM,data:hF7MBGQwEYlhxg9PRyNaFXw3BFvR+Fg+2sL54QfEJMNDkJJBEV5uhY0fyKA=,iv:SI6l2+l/gZAwu1CD4zf4mFtg3cPvMYGz1I8whiJz/+Q=,tag:C92QqcS6dRJQzjOY5S+08A==,type:str] +sops: + age: + - recipient: age1r0k34dkgzppaew7etm3ka7p0dgxcd365gxe66kuuqsnw6hqax9qswda0sh + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1c1o3dzRzYndUVUplSTVB + MjFsZ0Z4MmpBaXZxTys5SEFKa2VjeUJNVVZZCjI2b3MrSWg5MEtVN3ZLZ2FDZHNu + OTM0QXBlUlRJcEdYM2hvWnhGL2JxUVkKLS0tIFB4a1dQNGtoRnFXdUVRSmpneDl3 + NVF4N1dlaEtMQmZZSlFmamRMWUdsem8K38dzAcQNcZnOZztJQ/fHlXTbkG09GF71 + V0njc2VB7Way3NuYjgXdHhYESiX92W6NMUaK0zzED5Q7jVm4D14AHg== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-06-02T09:02:11Z" + mac: ENC[AES256_GCM,data:8TduuqQ9DeE9b93RQxZsgnv7QOWUn6JD5kAMPWLaSPyqBYhq7qAhUnCa3xds/BybcZSN1uDERwebg0YLLQR8S/QTieAusRU7GZX0Bpb8/lVfADEniyXBpM5063cq7fGWT0cM/Wb+DzBa/koLOv+7OMUU2s4chd+YJgY7ByciiZQ=,iv:SHOJ4IJVwiY4kjIE1KH8uuinJYfXo7SJK4sQHcJzx5M=,tag:0mPIpu7GXOjv5Ews3YdQvQ==,type:str] + unencrypted_suffix: _unencrypted + version: 3.12.2 diff --git a/test/backend/account_sync_manager_test.dart b/test/backend/account_sync_manager_test.dart index f42857b..ad9e661 100644 --- a/test/backend/account_sync_manager_test.dart +++ b/test/backend/account_sync_manager_test.dart @@ -16,91 +16,94 @@ 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('AccountSyncManager schedules IMAP sync for multiple accounts', - () async { - final accounts = _FakeAccounts('pw'); - final mailboxes = _FakeMailboxes(); - final emails = _FakeEmails(); - final logs = _FakeLogs(); + test( + 'AccountSyncManager schedules IMAP sync for multiple accounts', + () async { + final accounts = _FakeAccounts('pw'); + final mailboxes = _FakeMailboxes(); + final emails = _FakeEmails(); + final logs = _FakeLogs(); - final manager = AccountSyncManager( - accounts, - mailboxes, - emails, - syncLog: logs, - imapConnect: _fakeImapConnect, - ); + final manager = AccountSyncManager( + accounts, + mailboxes, + emails, + syncLog: logs, + imapConnect: _fakeImapConnect, + ); - final a1 = _account('1'); - final a2 = _account('2'); + final a1 = _account('1'); + final a2 = _account('2'); - manager.start(); - accounts.push([a1, a2]); + manager.start(); + accounts.push([a1, a2]); - // Allow some time for listeners to fire. - await Future.delayed(const Duration(milliseconds: 100)); + // Allow some time for listeners to fire. + await Future.delayed(const Duration(milliseconds: 100)); - expect(emails.syncCounts['1'], greaterThanOrEqualTo(1)); - expect(emails.syncCounts['2'], greaterThanOrEqualTo(1)); + expect(emails.syncCounts['1'], greaterThanOrEqualTo(1)); + expect(emails.syncCounts['2'], greaterThanOrEqualTo(1)); - manager.dispose(); - }); + manager.dispose(); + }, + ); - test('AccountSyncManager schedules JMAP sync for multiple accounts', - () async { - final accounts = _FakeAccounts('pw'); - final mailboxes = _FakeMailboxes(); - final emails = _FakeEmails(); - final logs = _FakeLogs(); + test( + 'AccountSyncManager schedules JMAP sync for multiple accounts', + () async { + final accounts = _FakeAccounts('pw'); + final mailboxes = _FakeMailboxes(); + final emails = _FakeEmails(); + final logs = _FakeLogs(); - final manager = AccountSyncManager( - accounts, - mailboxes, - emails, - syncLog: logs, - ); + final manager = AccountSyncManager( + accounts, + mailboxes, + emails, + syncLog: logs, + ); - final a1 = _jmapAccount('1'); - final a2 = _jmapAccount('2'); + final a1 = _jmapAccount('1'); + final a2 = _jmapAccount('2'); - manager.start(); - accounts.push([a1, a2]); + manager.start(); + accounts.push([a1, a2]); - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed(const Duration(milliseconds: 100)); - expect(emails.syncCounts['1'], greaterThanOrEqualTo(1)); - expect(emails.syncCounts['2'], greaterThanOrEqualTo(1)); + expect(emails.syncCounts['1'], greaterThanOrEqualTo(1)); + expect(emails.syncCounts['2'], greaterThanOrEqualTo(1)); - manager.dispose(); - }); + manager.dispose(); + }, + ); } 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); @@ -129,16 +132,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; @@ -155,27 +158,22 @@ 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 { final syncCounts = {}; @override - Stream> observeEmails( - String a, - String m, { - int limit = 50, - }) => + Stream> observeEmails(String a, String m, {int limit = 50}) => Stream.value([]); @override @@ -183,8 +181,7 @@ class _FakeEmails implements EmailRepository { String a, String m, { int limit = 50, - }) => - Stream.value([]); + }) => Stream.value([]); @override Stream> observeEmailsInThread(String a, String m, String t) => @@ -228,8 +225,7 @@ class _FakeEmails implements EmailRepository { Future findEmailByMessageId( String accountId, String messageId, - ) async => - null; + ) async => null; @override Future deleteEmail(String id) async => null; @@ -247,8 +243,7 @@ class _FakeEmails implements EmailRepository { Future downloadAttachment( String emailId, EmailAttachment attachment, - ) async => - '/tmp/${attachment.filename}'; + ) async => '/tmp/${attachment.filename}'; @override Future fetchRawRfc822(String emailId) async => ''; @@ -267,8 +262,7 @@ class _FakeEmails implements EmailRepository { String? a, String q, { int limit = 10, - }) async => - []; + }) async => []; @override Stream watchJmapPush(String accountId, String password) => @@ -278,8 +272,7 @@ 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 1eda29f..8f5a0c4 100644 --- a/test/backend/concurrent_sync_test.dart +++ b/test/backend/concurrent_sync_test.dart @@ -246,8 +246,9 @@ 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 c83421b..b11b382 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,7 +346,9 @@ 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'), @@ -372,7 +374,9 @@ 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'), @@ -566,59 +570,61 @@ void main() { expect(pending.first.changeType, 'delete'); }); - test('downloadAttachment fetches binary attachment bytes from IMAP', - () async { - final attachmentBytes = Uint8List.fromList( - List.generate(32, (i) => i + 1), - ); - const attachmentName = 'hello.bin'; - const attachmentMime = 'application/octet-stream'; - - // Build a multipart email with a binary attachment and append it. - final client = await _imapConnect( - host: imapHost, - port: imapPort, - user: userEmail, - pass: userPass, - ); - try { - final builder = MessageBuilder() - ..from = [MailAddress('Alice', userEmail)] - ..to = [MailAddress('Alice', userEmail)] - ..subject = 'attach-${DateTime.now().millisecondsSinceEpoch}' - ..text = 'See attachment.'; - builder.addBinary( - attachmentBytes, - MediaType.fromText(attachmentMime), - filename: attachmentName, + test( + 'downloadAttachment fetches binary attachment bytes from IMAP', + () async { + final attachmentBytes = Uint8List.fromList( + List.generate(32, (i) => i + 1), ); - await client.appendMessage( - builder.buildMimeMessage(), - targetMailboxPath: 'INBOX', + const attachmentName = 'hello.bin'; + const attachmentMime = 'application/octet-stream'; + + // Build a multipart email with a binary attachment and append it. + final client = await _imapConnect( + host: imapHost, + port: imapPort, + user: userEmail, + pass: userPass, ); - } finally { - await client.logout(); - } + try { + final builder = MessageBuilder() + ..from = [MailAddress('Alice', userEmail)] + ..to = [MailAddress('Alice', userEmail)] + ..subject = 'attach-${DateTime.now().millisecondsSinceEpoch}' + ..text = 'See attachment.'; + builder.addBinary( + attachmentBytes, + MediaType.fromText(attachmentMime), + filename: attachmentName, + ); + await client.appendMessage( + builder.buildMimeMessage(), + targetMailboxPath: 'INBOX', + ); + } finally { + await client.logout(); + } - final r = makeRepo(); - await r.accounts.addAccount(account, userPass); - await r.emails.syncEmails('test', 'INBOX'); + final r = makeRepo(); + await r.accounts.addAccount(account, userPass); + await r.emails.syncEmails('test', 'INBOX'); - final emails = await r.emails.observeEmails('test', 'INBOX').first; - expect(emails, hasLength(1)); - expect(emails.first.hasAttachment, isTrue); + final emails = await r.emails.observeEmails('test', 'INBOX').first; + expect(emails, hasLength(1)); + expect(emails.first.hasAttachment, isTrue); - final body = await r.emails.getEmailBody(emails.first.id); - expect(body.attachments, hasLength(1)); - expect(body.attachments.first.filename, attachmentName); - expect(body.attachments.first.contentType, attachmentMime); - expect(body.attachments.first.fetchPartId, isNotEmpty); + final body = await r.emails.getEmailBody(emails.first.id); + expect(body.attachments, hasLength(1)); + expect(body.attachments.first.filename, attachmentName); + expect(body.attachments.first.contentType, attachmentMime); + expect(body.attachments.first.fetchPartId, isNotEmpty); - final path = await r.emails.downloadAttachment( - emails.first.id, - body.attachments.first, - ); - final downloaded = await File(path).readAsBytes(); - expect(downloaded, equals(attachmentBytes)); - }); + final path = await r.emails.downloadAttachment( + emails.first.id, + body.attachments.first, + ); + final downloaded = await File(path).readAsBytes(); + expect(downloaded, equals(attachmentBytes)); + }, + ); } diff --git a/test/backend/email_repository_jmap_test.dart b/test/backend/email_repository_jmap_test.dart index 8cc015b..f4e8595 100644 --- a/test/backend/email_repository_jmap_test.dart +++ b/test/backend/email_repository_jmap_test.dart @@ -107,7 +107,8 @@ void main() { AccountRepositoryImpl accounts, EmailRepositoryImpl emails, MailboxRepositoryImpl mailboxes, - }) makeRepo() { + }) + makeRepo() { final db = openTestDatabase(); final accounts = AccountRepositoryImpl(db, MapSecureStorage()); final emails = EmailRepositoryImpl( @@ -127,12 +128,13 @@ 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; } @@ -270,18 +272,21 @@ 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. @@ -348,12 +353,13 @@ 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 acf56b2..0146e28 100644 --- a/test/backend/mailbox_repository_imap_test.dart +++ b/test/backend/mailbox_repository_imap_test.dart @@ -76,7 +76,8 @@ 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 bcd36db..49526d0 100644 --- a/test/backend/sync_reliability_test.dart +++ b/test/backend/sync_reliability_test.dart @@ -107,7 +107,9 @@ 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_repository_contract_test.dart b/test/unit/account_repository_contract_test.dart index 32acede..5e78e99 100644 --- a/test/unit/account_repository_contract_test.dart +++ b/test/unit/account_repository_contract_test.dart @@ -73,13 +73,15 @@ abstract class AccountRepositoryContract { expect(await repo.getPassword(_a.id), 'new'); }); - test('removeAccount makes account disappear from observeAccounts', - () async { - final repo = makeRepo(); - await repo.addAccount(_a, 'pw'); - await repo.removeAccount(_a.id); - expect(await repo.observeAccounts().first, isEmpty); - }); + test( + 'removeAccount makes account disappear from observeAccounts', + () async { + final repo = makeRepo(); + await repo.addAccount(_a, 'pw'); + await repo.removeAccount(_a.id); + expect(await repo.observeAccounts().first, isEmpty); + }, + ); test('getAccount returns null after removeAccount', () async { final repo = makeRepo(); diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index 1ab9f7b..7d71cc7 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -37,52 +37,48 @@ void main() { // MissingPluginException (channel unavailable on the device), the IMAP sync // loop must stop permanently instead of retrying indefinitely with backoff. test( - 'MissingPluginException from secure storage stops IMAP sync loop permanently', - () async { - final syncLog = FakeSyncLogRepository(); + 'MissingPluginException from secure storage stops IMAP sync loop permanently', + () async { + final syncLog = FakeSyncLogRepository(); - final m = AccountSyncManager( - _AccountRepositoryWithMissingPlugin(), - FakeMailboxRepositoryWithInbox(), - FakeEmailRepository(), - syncLog: syncLog, - ); + final m = AccountSyncManager( + _AccountRepositoryWithMissingPlugin(), + FakeMailboxRepositoryWithInbox(), + FakeEmailRepository(), + syncLog: syncLog, + ); - m.start(); + m.start(); - // Allow the first sync cycle to run and fail. - await Future.delayed(const Duration(milliseconds: 100)); + // Allow the first sync cycle to run and fail. + await Future.delayed(const Duration(milliseconds: 100)); - expect(syncLog.logs, hasLength(1)); - expect(syncLog.logs.first.success, isFalse); + expect(syncLog.logs, hasLength(1)); + expect(syncLog.logs.first.success, isFalse); - // Kicking the loop should have no effect once it has stopped permanently. - m.syncNow('1'); - await Future.delayed(const Duration(milliseconds: 100)); + // Kicking the loop should have no effect once it has stopped permanently. + m.syncNow('1'); + await Future.delayed(const Duration(milliseconds: 100)); - // Before the fix: kick triggers a retry → 2 log entries. - // After the fix: loop is permanently stopped → still exactly 1 entry. - expect(syncLog.logs, hasLength(1)); + // Before the fix: kick triggers a retry → 2 log entries. + // After the fix: loop is permanently stopped → still exactly 1 entry. + expect(syncLog.logs, hasLength(1)); - m.dispose(); - }); + m.dispose(); + }, + ); } class FakeEmailRepository implements EmailRepository { @override - Stream> observeEmails( - String a, - String m, { - int limit = 50, - }) => + Stream> observeEmails(String a, String m, {int limit = 50}) => Stream.value([]); @override Stream> observeThreads( String a, String m, { int limit = 50, - }) => - Stream.value([]); + }) => Stream.value([]); @override Stream> observeEmailsInThread(String a, String m, String t) => Stream.value([]); @@ -117,8 +113,7 @@ class FakeEmailRepository implements EmailRepository { Future findEmailByMessageId( String accountId, String messageId, - ) async => - null; + ) async => null; @override Future deleteEmail(String id) async => null; @@ -143,8 +138,7 @@ class FakeEmailRepository implements EmailRepository { String? a, String q, { int limit = 10, - }) async => - []; + }) async => []; @override Stream watchJmapPush(String a, String p) => const Stream.empty(); @override @@ -159,8 +153,7 @@ class FakeEmailRepository implements EmailRepository { Future verifySyncReliability( String accountId, String mailboxPath, - ) async => - ReliabilityResult.healthy; + ) async => ReliabilityResult.healthy; @override Future clearForResync(String accountId) async {} @@ -208,16 +201,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 @@ -229,16 +222,15 @@ 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 { @@ -256,11 +248,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 e09bc9a..1adcad9 100644 --- a/test/unit/apply_sieve_rules_test.dart +++ b/test/unit/apply_sieve_rules_test.dart @@ -40,7 +40,9 @@ 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, @@ -57,7 +59,9 @@ 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, @@ -71,7 +75,9 @@ 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', @@ -218,7 +224,9 @@ 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, @@ -228,7 +236,9 @@ 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/background_sync_test.dart b/test/unit/background_sync_test.dart index 0c3b273..8feb346 100644 --- a/test/unit/background_sync_test.dart +++ b/test/unit/background_sync_test.dart @@ -9,12 +9,13 @@ void main() { // startup, throwing PlatformException(channel-error, ...). // registerBackgroundSync() must absorb the failure and let the app continue. test( - 'registerBackgroundSync completes without throwing when plugin is unavailable', - () async { - // In the unit-test environment the native WorkManager plugin is not - // registered, so Workmanager().initialize() throws a PlatformException or - // MissingPluginException. The fix catches it. This test fails before the - // fix (exception propagates) and passes after it (exception is swallowed). - await expectLater(registerBackgroundSync(), completes); - }); + 'registerBackgroundSync completes without throwing when plugin is unavailable', + () async { + // In the unit-test environment the native WorkManager plugin is not + // registered, so Workmanager().initialize() throws a PlatformException or + // MissingPluginException. The fix catches it. This test fails before the + // fix (exception propagates) and passes after it (exception is swallowed). + await expectLater(registerBackgroundSync(), completes); + }, + ); } diff --git a/test/unit/cid_utils_test.dart b/test/unit/cid_utils_test.dart index 55d236b..93d4d43 100644 --- a/test/unit/cid_utils_test.dart +++ b/test/unit/cid_utils_test.dart @@ -59,7 +59,8 @@ 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'; @@ -86,8 +87,9 @@ void main() { final result = injectInlineImages(html, msg); // Extract base64 payload from the data URI. - final match = - RegExp(r'data:image/png;base64,([A-Za-z0-9+/=]+)').firstMatch(result); + final match = RegExp( + r'data:image/png;base64,([A-Za-z0-9+/=]+)', + ).firstMatch(result); expect(match, isNotNull); final decoded = base64.decode(match!.group(1)!); expect(decoded.length, greaterThan(0)); diff --git a/test/unit/connection_test_service_test.dart b/test/unit/connection_test_service_test.dart index fc3d5ba..5b6297b 100644 --- a/test/unit/connection_test_service_test.dart +++ b/test/unit/connection_test_service_test.dart @@ -23,7 +23,8 @@ 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"' @@ -116,14 +117,15 @@ 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); @@ -142,12 +144,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 9f3adcb..5b91a6d 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 e815a9f..2c9cd5d 100644 --- a/test/unit/email_repository_cancel_change_test.dart +++ b/test/unit/email_repository_cancel_change_test.dart @@ -34,7 +34,9 @@ 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', @@ -53,7 +55,9 @@ 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', @@ -74,7 +78,9 @@ 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', @@ -84,7 +90,9 @@ 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 41e0110..d4bc70d 100644 --- a/test/unit/email_repository_contract_test.dart +++ b/test/unit/email_repository_contract_test.dart @@ -44,10 +44,7 @@ abstract class EmailRepositoryContract { void run() { test('observeEmails starts empty', () async { final repo = await makeRepo(); - expect( - await repo.observeEmails(_account.id, 'INBOX').first, - isEmpty, - ); + expect(await repo.observeEmails(_account.id, 'INBOX').first, isEmpty); }); test('observeEmails emits inserted email', () async { @@ -61,10 +58,7 @@ abstract class EmailRepositoryContract { test('observeEmails only returns emails for the given mailbox', () async { final repo = await makeRepo(); await insertEmail(repo, id: 'er-acc:1', mailboxPath: 'INBOX'); - expect( - await repo.observeEmails(_account.id, 'Sent').first, - isEmpty, - ); + expect(await repo.observeEmails(_account.id, 'Sent').first, isEmpty); }); test('observeEmails orders by receivedAt descending', () async { @@ -116,11 +110,7 @@ abstract class EmailRepositoryContract { test('setFlag flagged updates isFlagged', () async { final repo = await makeRepo(); - await insertEmail( - repo, - id: 'er-acc:11', - mailboxPath: 'INBOX', - ); + await insertEmail(repo, id: 'er-acc:11', mailboxPath: 'INBOX'); await repo.setFlag('er-acc:11', flagged: true); final email = await repo.getEmail('er-acc:11'); expect(email!.isFlagged, isTrue); @@ -157,10 +147,7 @@ abstract class EmailRepositoryContract { test('observeThreads starts empty', () async { final repo = await makeRepo(); - expect( - await repo.observeThreads(_account.id, 'INBOX').first, - isEmpty, - ); + expect(await repo.observeThreads(_account.id, 'INBOX').first, isEmpty); }); } } @@ -199,7 +186,9 @@ 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 a3f4fff..c3ca5cb 100644 --- a/test/unit/email_repository_impl_test.dart +++ b/test/unit/email_repository_impl_test.dart @@ -68,26 +68,25 @@ 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, @@ -95,40 +94,38 @@ 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, @@ -136,25 +133,24 @@ 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')); @@ -163,7 +159,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, @@ -203,7 +199,9 @@ 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', @@ -223,7 +221,9 @@ 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,7 +247,9 @@ 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', @@ -274,7 +276,9 @@ 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', @@ -292,7 +296,9 @@ 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', @@ -301,7 +307,9 @@ 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'), @@ -322,7 +330,9 @@ 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', @@ -349,7 +359,9 @@ 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', @@ -359,7 +371,9 @@ 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', @@ -370,8 +384,9 @@ 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'}); }); @@ -386,7 +401,9 @@ 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', @@ -396,7 +413,9 @@ 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', @@ -425,7 +444,9 @@ 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', @@ -435,7 +456,9 @@ 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', @@ -453,47 +476,53 @@ void main() { expect(results.first.subject, 'foobar baz'); }); - test('searchAddresses returns results sorted by most recently used', - () async { - final r = _makeRepos(); - await r.accounts.addAccount(_account, 'pw'); + test( + 'searchAddresses returns results sorted by most recently used', + () async { + final r = _makeRepos(); + await r.accounts.addAccount(_account, 'pw'); - final older = DateTime(2024); - final newer = DateTime(2024, 6); + final older = DateTime(2024); + final newer = DateTime(2024, 6); - // Two emails — older one has alice@, newer one has bob@. - await r.db.into(r.db.emails).insert( - EmailsCompanion.insert( - id: 'acc-1:old', - accountId: 'acc-1', - mailboxPath: 'INBOX', - uid: 1, - receivedAt: older, - toAddresses: const Value( - '[{"name":"Alice","email":"alice@example.com"}]', + // Two emails — older one has alice@, newer one has bob@. + await r.db + .into(r.db.emails) + .insert( + EmailsCompanion.insert( + id: 'acc-1:old', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 1, + receivedAt: older, + toAddresses: const Value( + '[{"name":"Alice","email":"alice@example.com"}]', + ), ), - ), - ); - await r.db.into(r.db.emails).insert( - EmailsCompanion.insert( - id: 'acc-1:new', - accountId: 'acc-1', - mailboxPath: 'Sent', - uid: 2, - receivedAt: newer, - toAddresses: const Value( - '[{"name":"Bob","email":"bob@example.com"}]', + ); + await r.db + .into(r.db.emails) + .insert( + EmailsCompanion.insert( + id: 'acc-1:new', + accountId: 'acc-1', + mailboxPath: 'Sent', + uid: 2, + receivedAt: newer, + toAddresses: const Value( + '[{"name":"Bob","email":"bob@example.com"}]', + ), ), - ), - ); + ); - // Query matching both; newer (bob) should come first. - final results = await r.emails.searchAddresses(null, 'example'); - expect( - results.map((a) => a.email).toList(), - ['bob@example.com', 'alice@example.com'], - ); - }); + // Query matching both; newer (bob) should come first. + final results = await r.emails.searchAddresses(null, 'example'); + expect(results.map((a) => a.email).toList(), [ + 'bob@example.com', + 'alice@example.com', + ]); + }, + ); // ── IMAP method tests ──────────────────────────────────────────────────── @@ -502,7 +531,9 @@ 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', @@ -528,7 +559,9 @@ 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', @@ -552,7 +585,9 @@ 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', @@ -575,7 +610,9 @@ 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', @@ -599,7 +636,9 @@ 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', @@ -626,7 +665,9 @@ 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', @@ -650,7 +691,9 @@ 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', @@ -671,7 +714,9 @@ 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', @@ -697,54 +742,60 @@ void main() { expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); }); - test('snooze flush selects src mailbox and moves email to Snoozed', - () async { - final spy = SnoozeSpyImapClient(); - final r = _makeRepos( - imapConnect: (_, __, ___) async => spy, - ); - await r.accounts.addAccount(_account, 'pw'); - await r.db.into(r.db.emails).insert( - EmailsCompanion.insert( - id: 'acc-1:5', - accountId: 'acc-1', - mailboxPath: 'Snoozed', - uid: 5, - receivedAt: DateTime(2024), - ), - ); - await r.db.into(r.db.pendingChanges).insert( - PendingChangesCompanion.insert( - accountId: 'acc-1', - resourceType: 'Email', - resourceId: 'acc-1:5', - changeType: 'snooze', - payload: jsonEncode({ - 'uid': 5, - 'src': 'INBOX', - 'dest': 'Snoozed', - 'until': '2026-05-10T15:00:00.000', - }), - createdAt: DateTime.now(), - ), - ); + test( + 'snooze flush selects src mailbox and moves email to Snoozed', + () async { + final spy = SnoozeSpyImapClient(); + final r = _makeRepos(imapConnect: (_, __, ___) async => spy); + await r.accounts.addAccount(_account, 'pw'); + await r.db + .into(r.db.emails) + .insert( + EmailsCompanion.insert( + id: 'acc-1:5', + accountId: 'acc-1', + mailboxPath: 'Snoozed', + uid: 5, + receivedAt: DateTime(2024), + ), + ); + await r.db + .into(r.db.pendingChanges) + .insert( + PendingChangesCompanion.insert( + accountId: 'acc-1', + resourceType: 'Email', + resourceId: 'acc-1:5', + changeType: 'snooze', + payload: jsonEncode({ + 'uid': 5, + 'src': 'INBOX', + 'dest': 'Snoozed', + 'until': '2026-05-10T15:00:00.000', + }), + createdAt: DateTime.now(), + ), + ); - await r.emails.flushPendingChanges('acc-1', 'pw'); + await r.emails.flushPendingChanges('acc-1', 'pw'); - // Change successfully applied — removed from queue. - expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); - // Source mailbox extracted from 'src', not 'mailboxPath'. - expect(spy.selectedMailbox, 'INBOX'); - expect(spy.createdMailbox, 'Snoozed'); - expect(spy.movedToMailbox, 'Snoozed'); - }); + // Change successfully applied — removed from queue. + expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); + // Source mailbox extracted from 'src', not 'mailboxPath'. + expect(spy.selectedMailbox, 'INBOX'); + expect(spy.createdMailbox, 'Snoozed'); + expect(spy.movedToMailbox, 'Snoozed'); + }, + ); }); group('Snooze', () { 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', @@ -772,7 +823,9 @@ 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', @@ -783,7 +836,9 @@ 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', @@ -812,64 +867,65 @@ 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', + }) => 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': [ { - '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': [], - }, + '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': [], }, - '0', ], - ], - }), - 200, - ); - }); + }, + '0', + ], + ], + }), + 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', @@ -938,7 +994,9 @@ 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', @@ -1017,7 +1075,9 @@ 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', @@ -1047,7 +1107,9 @@ 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', @@ -1126,7 +1188,9 @@ 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', @@ -1136,7 +1200,9 @@ 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', @@ -1146,7 +1212,9 @@ 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', @@ -1173,7 +1241,9 @@ 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', @@ -1228,7 +1298,9 @@ 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', @@ -1344,7 +1416,9 @@ 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', @@ -1458,7 +1532,9 @@ 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', @@ -1466,7 +1542,9 @@ 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', @@ -1527,7 +1605,9 @@ 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', @@ -1535,7 +1615,9 @@ 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', @@ -1600,7 +1682,9 @@ 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', @@ -1622,7 +1706,9 @@ 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', @@ -1640,119 +1726,125 @@ void main() { expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); }); - test('snooze creates Snoozed folder via Mailbox/set when dest is Snoozed', - () async { - final List> capturedBodies = []; - final client = 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, - ); - } - final body = jsonDecode(req.body) as Map; - capturedBodies.add(body); - final calls = body['methodCalls'] as List; - final methodName = (calls.first as List)[0] as String; - if (methodName == 'Mailbox/set') { + test( + 'snooze creates Snoozed folder via Mailbox/set when dest is Snoozed', + () async { + final List> capturedBodies = []; + final client = 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, + ); + } + final body = jsonDecode(req.body) as Map; + capturedBodies.add(body); + final calls = body['methodCalls'] as List; + final methodName = (calls.first as List)[0] as String; + if (methodName == 'Mailbox/set') { + return http.Response( + jsonEncode({ + 'sessionState': 's1', + 'methodResponses': [ + [ + 'Mailbox/set', + { + 'accountId': 'acct1', + 'created': { + 'new-snoozed': {'id': 'mbx-snoozed'}, + }, + }, + '0', + ], + ], + }), + 200, + ); + } return http.Response( jsonEncode({ 'sessionState': 's1', 'methodResponses': [ [ - 'Mailbox/set', - { - 'accountId': 'acct1', - 'created': { - 'new-snoozed': {'id': 'mbx-snoozed'}, - }, - }, + 'Email/set', + {'accountId': 'acct1', 'updated': {}}, '0', ], ], }), 200, ); - } - return http.Response( - jsonEncode({ - 'sessionState': 's1', - 'methodResponses': [ - [ - 'Email/set', - {'accountId': 'acct1', 'updated': {}}, - '0', - ], - ], + }); + + final r = _makeRepos(httpClient: client); + await seedChange( + r.db, + r.accounts, + changeType: 'snooze', + payload: jsonEncode({ + 'uid': 0, + 'src': 'mbx-inbox', + 'dest': 'Snoozed', + 'until': '2026-05-10T15:00:00.000', }), - 200, ); - }); - final r = _makeRepos(httpClient: client); - await seedChange( - r.db, - r.accounts, - changeType: 'snooze', - payload: jsonEncode({ - 'uid': 0, - 'src': 'mbx-inbox', - 'dest': 'Snoozed', - 'until': '2026-05-10T15:00:00.000', - }), - ); + await r.emails.flushPendingChanges('jmap-1', 'pw'); - await r.emails.flushPendingChanges('jmap-1', 'pw'); + // Change successfully applied — removed from queue. + expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); - // Change successfully applied — removed from queue. - expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); + // First API call should be Mailbox/set to create the Snoozed folder. + expect(capturedBodies, hasLength(2)); + final firstCall = + ((capturedBodies.first['methodCalls'] as List).first as List)[0]; + expect(firstCall, 'Mailbox/set'); - // First API call should be Mailbox/set to create the Snoozed folder. - expect(capturedBodies, hasLength(2)); - final firstCall = - ((capturedBodies.first['methodCalls'] as List).first as List)[0]; - 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; + expect(update['mailboxIds/mbx-snoozed'], true); + }, + ); - // 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; - expect(update['mailboxIds/mbx-snoozed'], true); - }); + test( + 'snooze uses existing mailbox ID when dest is already a JMAP ID', + () async { + final r = _makeRepos(httpClient: mockFlush(200)); + await seedChange( + r.db, + r.accounts, + changeType: 'snooze', + payload: jsonEncode({ + 'uid': 0, + 'src': 'mbx-inbox', + 'dest': 'mbx-snoozed', + 'until': '2026-05-10T15:00:00.000', + }), + ); - test('snooze uses existing mailbox ID when dest is already a JMAP ID', - () async { - final r = _makeRepos(httpClient: mockFlush(200)); - await seedChange( - r.db, - r.accounts, - changeType: 'snooze', - payload: jsonEncode({ - 'uid': 0, - 'src': 'mbx-inbox', - 'dest': 'mbx-snoozed', - 'until': '2026-05-10T15:00:00.000', - }), - ); + await r.emails.flushPendingChanges('jmap-1', 'pw'); - await r.emails.flushPendingChanges('jmap-1', 'pw'); - - // Change applied without needing Mailbox/set (dest was already a valid ID). - expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); - }); + // Change applied without needing Mailbox/set (dest was already a valid ID). + expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); + }, + ); }); group('JMAP syncEmails body caching', () { @@ -1761,31 +1853,30 @@ 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, - }, - if (htmlContent != null) - 'html1': { - 'value': htmlContent, - '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, }, - 'attachments': [], - }; + if (htmlContent != null) + 'html1': { + 'value': htmlContent, + 'isEncodingProblem': false, + 'isTruncated': false, + }, + }, + 'attachments': [], + }; test('full sync caches bodies when bodyValues are present', () async { final r = _makeRepos( @@ -2073,7 +2164,9 @@ 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', @@ -2174,7 +2267,9 @@ 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', @@ -2183,7 +2278,9 @@ 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'), @@ -2203,7 +2300,9 @@ 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', @@ -2214,7 +2313,9 @@ 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', @@ -2237,7 +2338,9 @@ 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', @@ -2259,7 +2362,9 @@ 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', @@ -2282,41 +2387,45 @@ void main() { group('concurrent moves', () { test( - 'two simultaneous moves enqueue two changes and leave email in last destination', - () async { - final r = _makeRepos(); - await r.accounts.addAccount(_account, 'pw'); - await r.db.into(r.db.emails).insert( - EmailsCompanion.insert( - id: 'acc-1:5', - accountId: 'acc-1', - mailboxPath: 'INBOX', - uid: 5, - receivedAt: DateTime(2024), - ), - ); + 'two simultaneous moves enqueue two changes and leave email in last destination', + () async { + final r = _makeRepos(); + await r.accounts.addAccount(_account, 'pw'); + await r.db + .into(r.db.emails) + .insert( + EmailsCompanion.insert( + id: 'acc-1:5', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 5, + receivedAt: DateTime(2024), + ), + ); - // Fire both moves without awaiting to exercise concurrent enqueue logic. - final f1 = r.emails.moveEmail('acc-1:5', 'Archive'); - final f2 = r.emails.moveEmail('acc-1:5', 'Trash'); - await Future.wait([f1, f2]); + // Fire both moves without awaiting to exercise concurrent enqueue logic. + final f1 = r.emails.moveEmail('acc-1:5', 'Archive'); + final f2 = r.emails.moveEmail('acc-1:5', 'Trash'); + await Future.wait([f1, f2]); - final changes = await r.db.select(r.db.pendingChanges).get(); - expect(changes, hasLength(2)); - expect(changes.map((c) => c.changeType), everyElement('move')); + final changes = await r.db.select(r.db.pendingChanges).get(); + expect(changes, hasLength(2)); + expect(changes.map((c) => c.changeType), everyElement('move')); - final destinations = - changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet(); - expect(destinations, containsAll(['Archive', 'Trash'])); + 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'); - expect( - email!.mailboxPath, - anyOf('Archive', 'Trash'), - reason: - 'email must be optimistically moved to one of the two destinations', - ); - }); + final email = await r.emails.getEmail('acc-1:5'); + expect( + email!.mailboxPath, + anyOf('Archive', 'Trash'), + reason: + 'email must be optimistically moved to one of the two destinations', + ); + }, + ); }); group('IMAP SMTP auth failure', () { @@ -2358,7 +2467,9 @@ 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', @@ -2367,7 +2478,9 @@ 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', @@ -2379,7 +2492,9 @@ 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', @@ -2395,13 +2510,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); @@ -2420,22 +2535,20 @@ 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 0df8b84..801f3e8 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,8 +53,7 @@ class SnoozeSpyImapClient extends FakeImapClient { imap.StoreAction? action, bool? silent, int? unchangedSinceModSequence, - }) async => - imap.StoreImapResult(); + }) async => imap.StoreImapResult(); @override Future uidMove( @@ -72,8 +71,7 @@ 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 010bfb9..49efccf 100644 --- a/test/unit/html_utils_test.dart +++ b/test/unit/html_utils_test.dart @@ -56,7 +56,8 @@ 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 dee4770..d41fbb5 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 14f856b..eff8be9 100644 --- a/test/unit/mailbox_repository_contract_test.dart +++ b/test/unit/mailbox_repository_contract_test.dart @@ -61,10 +61,7 @@ abstract class MailboxRepositoryContract { test('findMailboxByRole returns null when no match', () async { final repo = await makeRepo(); - expect( - await repo.findMailboxByRole(_account.id, 'archive'), - isNull, - ); + expect(await repo.findMailboxByRole(_account.id, 'archive'), isNull); }); test('findMailboxByRole returns the matching mailbox', () async { @@ -114,7 +111,9 @@ 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 d74971b..4dcf5ef 100644 --- a/test/unit/mailbox_repository_impl_test.dart +++ b/test/unit/mailbox_repository_impl_test.dart @@ -66,17 +66,16 @@ 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, @@ -84,25 +83,24 @@ 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')); @@ -111,7 +109,8 @@ 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( @@ -145,7 +144,9 @@ 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', @@ -178,7 +179,9 @@ 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', @@ -186,7 +189,9 @@ 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', @@ -205,7 +210,9 @@ 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', @@ -305,7 +312,9 @@ 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', @@ -315,7 +324,9 @@ 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', @@ -323,7 +334,9 @@ 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', @@ -351,7 +364,9 @@ 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', @@ -419,7 +434,9 @@ 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', @@ -486,8 +503,11 @@ void main() { ); await r.accounts.addAccount(_jmapAccount, 'pw'); - final result = await r.mailboxes - .createMailboxWithRole('jmap-1', 'Archive', 'archive'); + final result = await r.mailboxes.createMailboxWithRole( + 'jmap-1', + 'Archive', + 'archive', + ); expect(result.name, 'Archive'); expect(result.role, 'archive'); @@ -498,81 +518,82 @@ void main() { expect(found!.name, 'Archive'); }); - test( - 'JMAP: throws when server returns no created ID', - () async { - final r = _makeRepos( - httpClient: _mockJmap( - apiResponses: [ - { - 'sessionState': 'sess1', - 'methodResponses': [ - [ - 'Mailbox/set', - { - 'accountId': 'acct1', - 'created': null, - 'notCreated': { - 'new-mailbox': {'type': 'serverFail'}, - }, + test('JMAP: throws when server returns no created ID', () async { + final r = _makeRepos( + httpClient: _mockJmap( + apiResponses: [ + { + 'sessionState': 'sess1', + 'methodResponses': [ + [ + 'Mailbox/set', + { + 'accountId': 'acct1', + 'created': null, + 'notCreated': { + 'new-mailbox': {'type': 'serverFail'}, }, - '0', - ], + }, + '0', ], - }, - ], - ), - ); - await r.accounts.addAccount(_jmapAccount, 'pw'); + ], + }, + ], + ), + ); + await r.accounts.addAccount(_jmapAccount, 'pw'); - await expectLater( - r.mailboxes.createMailboxWithRole('jmap-1', 'Archive', 'archive'), - throwsA(isA()), - ); - }, - ); + await expectLater( + r.mailboxes.createMailboxWithRole('jmap-1', 'Archive', 'archive'), + throwsA(isA()), + ); + }); }); group('syncMailboxes IMAP preserves manually-set role', () { - test('existing role is kept when server returns no special-use flag', - () async { - final spy = SnoozeSpyImapClient(); - // Make listMailboxes return a plain folder without \Archive. - final db = openTestDatabase(); - final accounts = AccountRepositoryImpl(db, MapSecureStorage()); + test( + 'existing role is kept when server returns no special-use flag', + () async { + final spy = SnoozeSpyImapClient(); + // Make listMailboxes return a plain folder without \Archive. + final db = openTestDatabase(); + final accounts = AccountRepositoryImpl(db, MapSecureStorage()); - // Override listMailboxes to return one plain folder. - final fakeClient = _PlainArchiveImapClient(); - final mailboxes = MailboxRepositoryImpl( - db, - accounts, - imapConnect: (_, __, ___) async => fakeClient, - ); - await accounts.addAccount(_account, 'pw'); + // Override listMailboxes to return one plain folder. + final fakeClient = _PlainArchiveImapClient(); + final mailboxes = MailboxRepositoryImpl( + db, + accounts, + imapConnect: (_, __, ___) async => fakeClient, + ); + await accounts.addAccount(_account, 'pw'); - // Pre-seed the DB with role='archive' (as if user created the folder). - await db.into(db.mailboxes).insert( - MailboxesCompanion.insert( - id: 'acc-1:Archive', - accountId: 'acc-1', - path: 'Archive', - name: 'Archive', - role: const Value('archive'), - ), - ); + // Pre-seed the DB with role='archive' (as if user created the folder). + await db + .into(db.mailboxes) + .insert( + MailboxesCompanion.insert( + id: 'acc-1:Archive', + accountId: 'acc-1', + path: 'Archive', + name: 'Archive', + role: const Value('archive'), + ), + ); - await mailboxes.syncMailboxes('acc-1'); + await mailboxes.syncMailboxes('acc-1'); - final found = await mailboxes.findMailboxByRole('acc-1', 'archive'); - expect( - found, - isNotNull, - reason: 'Manually-set role should be preserved after sync', - ); - expect(found!.path, 'Archive'); - // Suppress unused warning on spy. - expect(spy, isNotNull); - }); + final found = await mailboxes.findMailboxByRole('acc-1', 'archive'); + expect( + found, + isNotNull, + reason: 'Manually-set role should be preserved after sync', + ); + expect(found!.path, 'Archive'); + // Suppress unused warning on spy. + expect(spy, isNotNull); + }, + ); }); }); } @@ -587,22 +608,20 @@ 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 6b59d5d..76c4e39 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,14 +71,15 @@ 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', @@ -97,14 +98,15 @@ 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', @@ -123,16 +125,17 @@ 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', @@ -155,14 +158,15 @@ 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 ac36bab..48eb9fd 100644 --- a/test/unit/migration_test.dart +++ b/test/unit/migration_test.dart @@ -162,8 +162,9 @@ 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']), @@ -178,17 +179,17 @@ void main() { // v28: mime_tree_json column on email_bodies. await db - .customSelect( - 'SELECT mime_tree_json FROM email_bodies LIMIT 0', - ) + .customSelect('SELECT mime_tree_json FROM email_bodies LIMIT 0') .get(); // v29: local_sieve_scripts table. await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get(); // v30: duration_ms column on sync_log_mailboxes. - final syncLogMailboxColumns = - await _tableColumns(db, 'sync_log_mailboxes'); + final syncLogMailboxColumns = await _tableColumns( + db, + 'sync_log_mailboxes', + ); expect(syncLogMailboxColumns, contains('duration_ms')); // v32: local_sieve_applied table. @@ -214,14 +215,14 @@ void main() { }); test( - 'upgrade from v22 to latest adds list_unsubscribe_header and imap_server_id', - () async { - final dbFile = File('test_migration_v22.db'); - if (dbFile.existsSync()) dbFile.deleteSync(); + 'upgrade from v22 to latest adds list_unsubscribe_header and imap_server_id', + () async { + final dbFile = File('test_migration_v22.db'); + if (dbFile.existsSync()) dbFile.deleteSync(); - // Build a v22 database schema directly with raw SQL. - final rawDb = sqlite.sqlite3.open(dbFile.path); - rawDb.execute(''' + // Build a v22 database schema directly with raw SQL. + final rawDb = sqlite.sqlite3.open(dbFile.path); + rawDb.execute(''' CREATE TABLE accounts ( id TEXT NOT NULL PRIMARY KEY, display_name TEXT NOT NULL, @@ -242,7 +243,7 @@ void main() { verbose INTEGER NOT NULL DEFAULT 0 CHECK ("verbose" IN (0, 1)) ); '''); - rawDb.execute(''' + rawDb.execute(''' CREATE TABLE drafts ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, account_id TEXT NULL, @@ -254,7 +255,7 @@ void main() { updated_at INTEGER NOT NULL ); '''); - rawDb.execute(''' + rawDb.execute(''' CREATE TABLE mailboxes ( id TEXT NOT NULL PRIMARY KEY, account_id TEXT NOT NULL, @@ -265,7 +266,7 @@ void main() { role TEXT NULL ); '''); - rawDb.execute(''' + rawDb.execute(''' CREATE TABLE emails ( id TEXT NOT NULL PRIMARY KEY, account_id TEXT NOT NULL, @@ -289,7 +290,7 @@ void main() { snoozed_from_mailbox_path TEXT NULL ); '''); - rawDb.execute(''' + rawDb.execute(''' CREATE TABLE threads ( account_id TEXT NOT NULL, mailbox_path TEXT NOT NULL, @@ -306,7 +307,7 @@ void main() { PRIMARY KEY (account_id, mailbox_path, id) ); '''); - rawDb.execute(''' + rawDb.execute(''' CREATE TABLE email_bodies ( email_id TEXT NOT NULL PRIMARY KEY REFERENCES emails(id) ON DELETE CASCADE, text_body TEXT NULL, @@ -316,7 +317,7 @@ void main() { headers_json TEXT NULL ); '''); - rawDb.execute(''' + rawDb.execute(''' CREATE TABLE sync_logs ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, account_id TEXT NOT NULL, @@ -333,7 +334,7 @@ void main() { protocol_log TEXT NULL ); '''); - rawDb.execute(''' + rawDb.execute(''' CREATE TABLE sync_log_mailboxes ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, sync_log_id INTEGER NOT NULL REFERENCES sync_logs (id) ON DELETE CASCADE, @@ -343,77 +344,81 @@ void main() { bytes_transferred INTEGER NOT NULL DEFAULT 0 ); '''); - rawDb.execute('PRAGMA user_version = 22;'); - rawDb.close(); + rawDb.execute('PRAGMA user_version = 22;'); + rawDb.close(); - final db = AppDatabase(NativeDatabase(dbFile)); - // Trigger migration. - await db.select(db.accounts).get(); + final db = AppDatabase(NativeDatabase(dbFile)); + // Trigger migration. + await db.select(db.accounts).get(); - final emailColumns = await _tableColumns(db, 'emails'); - expect(emailColumns, contains('list_unsubscribe_header')); + final emailColumns = await _tableColumns(db, 'emails'); + expect(emailColumns, contains('list_unsubscribe_header')); - final draftColumns = await _tableColumns(db, 'drafts'); - expect(draftColumns, contains('imap_server_id')); + final draftColumns = await _tableColumns(db, 'drafts'); + expect(draftColumns, contains('imap_server_id')); - // v25: new indexes on mailboxes and threads. - final allIndexes = await db - .customSelect("SELECT name FROM sqlite_master WHERE type='index'") - .get(); - final indexNames = allIndexes.map((r) => r.read('name')).toSet(); - expect(indexNames, contains('mailboxes_account_id')); - expect(indexNames, contains('threads_latest_date')); + // v25: new indexes on mailboxes and threads. + final allIndexes = await db + .customSelect("SELECT name FROM sqlite_master WHERE type='index'") + .get(); + final indexNames = allIndexes + .map((r) => r.read('name')) + .toSet(); + expect(indexNames, contains('mailboxes_account_id')); + expect(indexNames, contains('threads_latest_date')); - // v26: FTS5 virtual table and triggers. - final allTriggers = await db - .customSelect("SELECT name FROM sqlite_master WHERE type='trigger'") - .get(); - final triggerNames = - allTriggers.map((r) => r.read('name')).toSet(); - expect( - triggerNames, - containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']), - ); - await db.customSelect('SELECT count(*) FROM email_fts').get(); + // v26: FTS5 virtual table and triggers. + final allTriggers = await db + .customSelect("SELECT name FROM sqlite_master WHERE type='trigger'") + .get(); + final triggerNames = allTriggers + .map((r) => r.read('name')) + .toSet(); + expect( + triggerNames, + containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']), + ); + await db.customSelect('SELECT count(*) FROM email_fts').get(); - // v27: search_history_entries table. - await db - .customSelect('SELECT count(*) FROM search_history_entries') - .get(); + // v27: search_history_entries table. + await db + .customSelect('SELECT count(*) FROM search_history_entries') + .get(); - // v28: mime_tree_json column on email_bodies. - await db - .customSelect( - 'SELECT mime_tree_json FROM email_bodies LIMIT 0', - ) - .get(); + // v28: mime_tree_json column on email_bodies. + await db + .customSelect('SELECT mime_tree_json FROM email_bodies LIMIT 0') + .get(); - // v29: local_sieve_scripts table. - await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get(); + // v29: local_sieve_scripts table. + await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get(); - // v30: duration_ms column on sync_log_mailboxes. - final syncLogMailboxColumns = - await _tableColumns(db, 'sync_log_mailboxes'); - expect(syncLogMailboxColumns, contains('duration_ms')); + // v30: duration_ms column on sync_log_mailboxes. + final syncLogMailboxColumns = await _tableColumns( + db, + 'sync_log_mailboxes', + ); + expect(syncLogMailboxColumns, contains('duration_ms')); - // v33: error_stack_trace and is_permanent columns on sync_logs. - final syncLogColumns = await _tableColumns(db, 'sync_logs'); - expect(syncLogColumns, contains('error_stack_trace')); - expect(syncLogColumns, contains('is_permanent')); + // v33: error_stack_trace and is_permanent columns on sync_logs. + final syncLogColumns = await _tableColumns(db, 'sync_logs'); + expect(syncLogColumns, contains('error_stack_trace')); + expect(syncLogColumns, contains('is_permanent')); - // v34: user_preferences table. - await db.customSelect('SELECT count(*) FROM user_preferences').get(); + // v34: user_preferences table. + await db.customSelect('SELECT count(*) FROM user_preferences').get(); - // v35: mail_view_button_position column on user_preferences. - final userPrefsColumns = await _tableColumns(db, 'user_preferences'); - expect(userPrefsColumns, contains('mail_view_button_position')); + // v35: mail_view_button_position column on user_preferences. + final userPrefsColumns = await _tableColumns(db, 'user_preferences'); + expect(userPrefsColumns, contains('mail_view_button_position')); - // v36: after_mail_view_action column on user_preferences. - expect(userPrefsColumns, contains('after_mail_view_action')); + // v36: after_mail_view_action column on user_preferences. + expect(userPrefsColumns, contains('after_mail_view_action')); - await db.close(); - if (dbFile.existsSync()) dbFile.deleteSync(); - }); + await db.close(); + if (dbFile.existsSync()) dbFile.deleteSync(); + }, + ); test('fresh install creates all tables at schemaVersion 36', () async { final db = AppDatabase(NativeDatabase.memory()); @@ -453,8 +458,10 @@ void main() { expect(draftColumns, contains('imap_server_id')); // v30: duration_ms column on sync_log_mailboxes. - final syncLogMailboxColumns = - await _tableColumns(db, 'sync_log_mailboxes'); + final syncLogMailboxColumns = await _tableColumns( + db, + 'sync_log_mailboxes', + ); expect(syncLogMailboxColumns, contains('duration_ms')); // v33: error_stack_trace and is_permanent columns on sync_logs. diff --git a/test/unit/notification_service_test.dart b/test/unit/notification_service_test.dart index f876f42..915daae 100644 --- a/test/unit/notification_service_test.dart +++ b/test/unit/notification_service_test.dart @@ -9,14 +9,15 @@ void main() { // absent at startup, throwing MissingPluginException (or a similar error). // initNotifications() must absorb the failure and let the app continue. test( - 'initNotifications completes without throwing when plugin is unavailable', - () async { - // In the unit-test environment the native plugin is not registered, so - // _plugin.initialize() throws. The fix catches it and keeps _initialized - // false. This test fails before the fix (exception propagates) and passes - // after it (exception is swallowed). - await expectLater(initNotifications(), completes); - }); + 'initNotifications completes without throwing when plugin is unavailable', + () async { + // In the unit-test environment the native plugin is not registered, so + // _plugin.initialize() throws. The fix catches it and keeps _initialized + // false. This test fails before the fix (exception propagates) and passes + // after it (exception is swallowed). + await expectLater(initNotifications(), completes); + }, + ); test('showNewMailNotification completes without throwing', () async { // Platform.isAndroid is false in tests, so this returns early without diff --git a/test/unit/reliability_runner_check_now_test.dart b/test/unit/reliability_runner_check_now_test.dart index e823b2f..af93fe4 100644 --- a/test/unit/reliability_runner_check_now_test.dart +++ b/test/unit/reliability_runner_check_now_test.dart @@ -67,16 +67,15 @@ 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 { @@ -100,8 +99,7 @@ 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([]); @@ -138,8 +136,7 @@ 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 268696e..09cb372 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; @@ -26,11 +26,9 @@ class _FakeAccounts implements AccountRepository { @override Stream> observeAccounts() => Stream.value(accounts); @override - Future getAccount(String id) async => - accounts.cast().firstWhere( - (a) => a?.id == id, - orElse: () => null, - ); + Future getAccount(String id) async => accounts + .cast() + .firstWhere((a) => a?.id == id, orElse: () => null); @override Future addAccount(Account account, String password) async {} @override @@ -59,16 +57,15 @@ 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 { @@ -94,19 +91,14 @@ class _CountingEmails implements EmailRepository { @override Future flushPendingChanges(String accountId, String password) async => 0; @override - Stream> observeEmails( - String a, - String m, { - int limit = 50, - }) => + Stream> observeEmails(String a, String m, {int limit = 50}) => Stream.value([]); @override Stream> observeThreads( String a, String m, { int limit = 50, - }) => - Stream.value([]); + }) => Stream.value([]); @override Stream> observeEmailsInThread(String a, String m, String t) => Stream.value([]); @@ -140,8 +132,7 @@ class _CountingEmails implements EmailRepository { String? a, String q, { int limit = 10, - }) async => - []; + }) async => []; @override Stream> observeFailedMutations(String a) => Stream.value([]); @@ -159,8 +150,7 @@ class _CountingEmails implements EmailRepository { Future findEmailByMessageId( String accountId, String messageId, - ) async => - null; + ) async => null; @override Stream get onChangesQueued => const Stream.empty(); @override @@ -170,8 +160,7 @@ class _CountingEmails implements EmailRepository { Future verifySyncReliability( String accountId, String mailboxPath, - ) async => - ReliabilityResult.healthy; + ) async => ReliabilityResult.healthy; @override Future clearForResync(String accountId) async {} @override @@ -383,7 +372,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/share_encryption_service_test.dart b/test/unit/share_encryption_service_test.dart index 552bb96..abe5d3c 100644 --- a/test/unit/share_encryption_service_test.dart +++ b/test/unit/share_encryption_service_test.dart @@ -47,9 +47,7 @@ void main() { test('parsePublicKeyQr returns null for invalid input', () { expect(ShareEncryptionService.parsePublicKeyQr('not-valid'), isNull); expect( - ShareEncryptionService.parsePublicKeyQr( - 'sharedinbox.de:pubkey:v1:!!!', - ), + ShareEncryptionService.parsePublicKeyQr('sharedinbox.de:pubkey:v1:!!!'), isNull, ); expect( diff --git a/test/unit/sieve_interpreter_test.dart b/test/unit/sieve_interpreter_test.dart index aad360f..e56141c 100644 --- a/test/unit/sieve_interpreter_test.dart +++ b/test/unit/sieve_interpreter_test.dart @@ -73,11 +73,7 @@ void main() { SieveRule( joinType: 'single', conditions: [ - HeaderCondition( - ['from', 'reply-to'], - ':is', - ['boss@work.com'], - ), + HeaderCondition(['from', 'reply-to'], ':is', ['boss@work.com']), ], actions: [ FlagAction([r'\Important']), @@ -121,8 +117,10 @@ void main() { ), ]; - final ctx = - interp.execute(rules, _email(subject: 'Weekly Newsletter Issue')); + final ctx = interp.execute( + rules, + _email(subject: 'Weekly Newsletter Issue'), + ); expect(ctx.targetFolders, contains('Bulk')); }); }); diff --git a/test/unit/sieve_parser_test.dart b/test/unit/sieve_parser_test.dart index f718693..d6cb511 100644 --- a/test/unit/sieve_parser_test.dart +++ b/test/unit/sieve_parser_test.dart @@ -261,8 +261,9 @@ if exists "X-Spam-Flag" { group('SieveParser — rule model', () { test('simple if produces one rule with branchGroupId', () { - final rules = - parser.parse('if header :contains "Subject" "x" { discard; }'); + final rules = parser.parse( + 'if header :contains "Subject" "x" { discard; }', + ); expect(rules, hasLength(1)); expect(rules.first.branchGroupId, isNotNull); expect(rules.first.conditions, hasLength(1)); diff --git a/test/unit/sync_log_repository_impl_test.dart b/test/unit/sync_log_repository_impl_test.dart index 1f35150..c09be4d 100644 --- a/test/unit/sync_log_repository_impl_test.dart +++ b/test/unit/sync_log_repository_impl_test.dart @@ -11,7 +11,9 @@ 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', @@ -120,40 +122,41 @@ 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'); }); - test('stores and retrieves stackTrace and isPermanent on error entries', - () async { - final repo = SyncLogRepositoryImpl(db); - final start = DateTime(2024, 3, 1, 9); - final end = DateTime(2024, 3, 1, 9, 0, 1); - const fakeTrace = '#0 main (file:///app/lib/main.dart:10:5)'; + test( + 'stores and retrieves stackTrace and isPermanent on error entries', + () async { + final repo = SyncLogRepositoryImpl(db); + final start = DateTime(2024, 3, 1, 9); + final end = DateTime(2024, 3, 1, 9, 0, 1); + const fakeTrace = '#0 main (file:///app/lib/main.dart:10:5)'; - await repo.log( - accountId: 'acc1', - success: false, - errorMessage: 'MissingPluginException', - stackTrace: fakeTrace, - isPermanent: true, - protocol: 'imap', - emailsFetched: 0, - emailsSkipped: 0, - mailboxesSynced: 0, - pendingFlushed: 0, - bytesTransferred: 0, - startedAt: start, - finishedAt: end, - ); + await repo.log( + accountId: 'acc1', + success: false, + errorMessage: 'MissingPluginException', + stackTrace: fakeTrace, + isPermanent: true, + protocol: 'imap', + emailsFetched: 0, + emailsSkipped: 0, + mailboxesSynced: 0, + pendingFlushed: 0, + bytesTransferred: 0, + startedAt: start, + finishedAt: end, + ); - final entries = await repo.observeSyncLogs('acc1').first; - final entry = entries.firstWhere((e) => e.startedAt == start); - expect(entry.stackTrace, fakeTrace); - expect(entry.isPermanent, true); - expect(entry.errorMessage, 'MissingPluginException'); - }); + final entries = await repo.observeSyncLogs('acc1').first; + final entry = entries.firstWhere((e) => e.startedAt == start); + expect(entry.stackTrace, fakeTrace); + expect(entry.isPermanent, true); + expect(entry.errorMessage, 'MissingPluginException'); + }, + ); } diff --git a/test/unit/undo_logic_test.dart b/test/unit/undo_logic_test.dart index 2a696b0..ed4bea4 100644 --- a/test/unit/undo_logic_test.dart +++ b/test/unit/undo_logic_test.dart @@ -48,7 +48,9 @@ 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', @@ -56,7 +58,9 @@ void main() { name: 'Inbox', ), ); - await db.into(db.mailboxes).insert( + await db + .into(db.mailboxes) + .insert( MailboxesCompanion.insert( id: 'acc1:Trash', accountId: 'acc1', @@ -67,7 +71,9 @@ 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', @@ -94,10 +100,11 @@ 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 @@ -113,10 +120,11 @@ 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, @@ -141,7 +149,9 @@ 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', @@ -150,7 +160,9 @@ 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', @@ -161,7 +173,9 @@ 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', @@ -176,10 +190,11 @@ 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 @@ -194,10 +209,11 @@ 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, @@ -234,10 +250,11 @@ 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) @@ -260,8 +277,9 @@ void main() { expect(original!.messageId, isNull); // set a messageId so lookup works // Seed a messageId so undo can find the email after UID change. - await (db.update(db.emails)..where((t) => t.id.equals(oldEmailId))) - .write(const EmailsCompanion(messageId: Value('msg-101@test'))); + await (db.update(db.emails)..where((t) => t.id.equals(oldEmailId))).write( + const EmailsCompanion(messageId: Value('msg-101@test')), + ); final originalWithMsgId = await repo.getEmail(oldEmailId); @@ -272,7 +290,9 @@ 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', @@ -303,9 +323,9 @@ void main() { await container.read(undoServiceProvider.notifier).undo(); // 4. Verify the current email row is now in INBOX. - final inInbox = await (db.select(db.emails) - ..where((t) => t.mailboxPath.equals('INBOX'))) - .get(); + final inInbox = await (db.select( + db.emails, + )..where((t) => t.mailboxPath.equals('INBOX'))).get(); expect( inInbox, isNotEmpty, diff --git a/test/unit/undo_service_test.dart b/test/unit/undo_service_test.dart index e0f4a6c..ad5818e 100644 --- a/test/unit/undo_service_test.dart +++ b/test/unit/undo_service_test.dart @@ -122,70 +122,74 @@ void main() { verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1); }); - test('undo pushes inverse action into log when destinationMailboxPath is set', - () async { - final action = UndoAction( - id: 'del1', - accountId: 'acc1', - type: UndoType.delete, - emailIds: ['e1'], - sourceMailboxPath: 'INBOX', - destinationMailboxPath: 'Trash', - ); + test( + 'undo pushes inverse action into log when destinationMailboxPath is set', + () async { + final action = UndoAction( + id: 'del1', + accountId: 'acc1', + type: UndoType.delete, + emailIds: ['e1'], + sourceMailboxPath: 'INBOX', + destinationMailboxPath: 'Trash', + ); - when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); - when( - mockEmailRepo.cancelPendingChange(any, any), - ).thenAnswer((_) async => false); + when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); + when( + mockEmailRepo.cancelPendingChange(any, any), + ).thenAnswer((_) async => false); - final notifier = container.read(undoServiceProvider.notifier); - await notifier.init(); - await notifier.pushAction(action); - await notifier.undo(actionId: 'del1'); + final notifier = container.read(undoServiceProvider.notifier); + await notifier.init(); + await notifier.pushAction(action); + await notifier.undo(actionId: 'del1'); - // Original entry stays; inverse is added. - final log = container.read(undoServiceProvider); - expect(log.length, 2); - expect(log[0].id, 'del1'); - final inv = log[1]; - expect(inv.id, 'del1-inv'); - expect(inv.type, UndoType.move); - expect(inv.emailIds, ['e1']); - expect(inv.sourceMailboxPath, 'Trash'); - expect(inv.destinationMailboxPath, 'INBOX'); - verify( - mockUndoRepo.saveAction( - argThat(predicate((a) => a.id == 'del1-inv')), - ), - ).called(1); - }); + // Original entry stays; inverse is added. + final log = container.read(undoServiceProvider); + expect(log.length, 2); + expect(log[0].id, 'del1'); + final inv = log[1]; + expect(inv.id, 'del1-inv'); + expect(inv.type, UndoType.move); + expect(inv.emailIds, ['e1']); + expect(inv.sourceMailboxPath, 'Trash'); + expect(inv.destinationMailboxPath, 'INBOX'); + verify( + mockUndoRepo.saveAction( + argThat(predicate((a) => a.id == 'del1-inv')), + ), + ).called(1); + }, + ); - test('undo without destinationMailboxPath does not push inverse action', - () async { - final action = UndoAction( - id: 'mv1', - accountId: 'acc1', - type: UndoType.move, - emailIds: ['e1'], - sourceMailboxPath: 'INBOX', - // no destinationMailboxPath - ); + test( + 'undo without destinationMailboxPath does not push inverse action', + () async { + final action = UndoAction( + id: 'mv1', + accountId: 'acc1', + type: UndoType.move, + emailIds: ['e1'], + sourceMailboxPath: 'INBOX', + // no destinationMailboxPath + ); - when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); - when( - mockEmailRepo.cancelPendingChange(any, any), - ).thenAnswer((_) async => false); + when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); + when( + mockEmailRepo.cancelPendingChange(any, any), + ).thenAnswer((_) async => false); - final notifier = container.read(undoServiceProvider.notifier); - await notifier.init(); - await notifier.pushAction(action); - await notifier.undo(actionId: 'mv1'); + final notifier = container.read(undoServiceProvider.notifier); + await notifier.init(); + await notifier.pushAction(action); + await notifier.undo(actionId: 'mv1'); - // Original entry stays; no inverse since no destinationMailboxPath. - final log = container.read(undoServiceProvider); - expect(log.length, 1); - expect(log.first.id, 'mv1'); - }); + // Original entry stays; no inverse since no destinationMailboxPath. + final log = container.read(undoServiceProvider); + expect(log.length, 1); + expect(log.first.id, 'mv1'); + }, + ); test('undo with actionId removes and undos specific action', () async { // action1 has no destination → no inverse action @@ -350,13 +354,9 @@ void main() { ); // Simulate slow DB load - when( - mockUndoRepo.getHistory(limit: anyNamed('limit')), - ).thenAnswer( - (_) => Future.delayed( - const Duration(milliseconds: 10), - () => [persisted], - ), + when(mockUndoRepo.getHistory(limit: anyNamed('limit'))).thenAnswer( + (_) => + Future.delayed(const Duration(milliseconds: 10), () => [persisted]), ); final notifier = container.read(undoServiceProvider.notifier); diff --git a/test/widget/about_screen_test.dart b/test/widget/about_screen_test.dart index 5c86718..990842f 100644 --- a/test/widget/about_screen_test.dart +++ b/test/widget/about_screen_test.dart @@ -37,7 +37,8 @@ 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".', ); } @@ -46,8 +47,9 @@ class ThrowingUrlLauncher extends Mock Widget _buildScreen({List accounts = const []}) { return ProviderScope( overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository(accounts)), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository(accounts), + ), ], child: const MaterialApp(home: AboutScreen()), ); @@ -151,8 +153,10 @@ void main() { }, ); addTearDown( - () => tester.binding.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, null), + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), ); await tester.pumpWidget(_buildScreen()); @@ -173,10 +177,7 @@ void main() { expect(clipboardText, contains('Locale')); expect(clipboardText, contains('Text Scale')); expect(clipboardText, contains('DB Schema Version')); - expect( - clipboardText, - contains('[sharedinbox.de](https://sharedinbox.de)'), - ); + expect(clipboardText, contains('[sharedinbox.de](https://sharedinbox.de)')); }); testWidgets('AboutScreen create-issue button opens Codeberg URL', ( diff --git a/test/widget/account_export_screen_test.dart b/test/widget/account_export_screen_test.dart index 5f2259e..a9c641c 100644 --- a/test/widget/account_export_screen_test.dart +++ b/test/widget/account_export_screen_test.dart @@ -74,10 +74,7 @@ void main() { recipientKeyId: material.keyId, recipientPublicKeyBytes: material.publicKeyBytes, accounts: [ - AccountPayload( - accountJson: account.toJson(), - password: 'secret', - ), + AccountPayload(accountJson: account.toJson(), password: 'secret'), ], ); @@ -99,10 +96,7 @@ void main() { await tester.tap(find.text('Import')); await tester.pumpAndSettle(); - expect( - find.text('Imported 1 account successfully.'), - findsOneWidget, - ); + expect(find.text('Imported 1 account successfully.'), findsOneWidget); }, ); diff --git a/test/widget/account_list_screen_test.dart b/test/widget/account_list_screen_test.dart index d4159fe..b5248cb 100644 --- a/test/widget/account_list_screen_test.dart +++ b/test/widget/account_list_screen_test.dart @@ -227,54 +227,52 @@ void main() { expect(find.textContaining('Healthy'), findsOneWidget); }); - testWidgets( - 'shows discrepancy details when sync health has discrepancies', - (tester) async { - const summary = - '{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}'; - await tester.pumpWidget( - buildApp( - initialLocation: '/accounts', - overrides: baseOverrides( - accounts: [kTestAccount], - syncHealth: SyncHealthRow( - accountId: kTestAccount.id, - lastVerifiedAt: DateTime(2024, 6), - isHealthy: false, - discrepancySummary: summary, - ), + testWidgets('shows discrepancy details when sync health has discrepancies', ( + tester, + ) async { + const summary = + '{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}'; + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts', + overrides: baseOverrides( + accounts: [kTestAccount], + syncHealth: SyncHealthRow( + accountId: kTestAccount.id, + lastVerifiedAt: DateTime(2024, 6), + isHealthy: false, + discrepancySummary: summary, ), ), - ); - await tester.pumpAndSettle(); + ), + ); + await tester.pumpAndSettle(); - expect(find.textContaining('missing locally: 3'), findsOneWidget); - expect(find.textContaining('flag mismatches: 1'), findsOneWidget); - }, - ); + expect(find.textContaining('missing locally: 3'), findsOneWidget); + expect(find.textContaining('flag mismatches: 1'), findsOneWidget); + }); - testWidgets( - 'sync health row is positioned below the account name row', - (tester) async { - await tester.pumpWidget( - buildApp( - initialLocation: '/accounts', - overrides: baseOverrides( - accounts: [kTestAccount], - syncHealth: SyncHealthRow( - accountId: kTestAccount.id, - lastVerifiedAt: DateTime(2024, 6), - isHealthy: true, - ), + testWidgets('sync health row is positioned below the account name row', ( + tester, + ) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts', + overrides: baseOverrides( + accounts: [kTestAccount], + syncHealth: SyncHealthRow( + accountId: kTestAccount.id, + lastVerifiedAt: DateTime(2024, 6), + isHealthy: true, ), ), - ); - await tester.pumpAndSettle(); + ), + ); + await tester.pumpAndSettle(); - final namePos = tester.getTopLeft(find.text('Alice')).dy; - final healthPos = tester.getTopLeft(find.textContaining('Healthy')).dy; - expect(healthPos, greaterThan(namePos)); - }, - ); + final namePos = tester.getTopLeft(find.text('Alice')).dy; + final healthPos = tester.getTopLeft(find.textContaining('Healthy')).dy; + expect(healthPos, greaterThan(namePos)); + }); }); } diff --git a/test/widget/crash_screen_test.dart b/test/widget/crash_screen_test.dart index f191220..8f0d11f 100644 --- a/test/widget/crash_screen_test.dart +++ b/test/widget/crash_screen_test.dart @@ -96,8 +96,10 @@ void main() { }, ); addTearDown( - () => tester.binding.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, null), + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), ); const exception = 'TestException: clipboard test'; @@ -126,79 +128,77 @@ void main() { }, ); - testWidgets( - 'CrashScreen shows git hash as clickable link above stacktrace', - (tester) async { - tester.view.physicalSize = const Size(800, 1200); - tester.view.devicePixelRatio = 1.0; - addTearDown(() => tester.view.resetPhysicalSize()); + testWidgets('CrashScreen shows git hash as clickable link above stacktrace', ( + tester, + ) async { + tester.view.physicalSize = const Size(800, 1200); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); - final mock = MockUrlLauncher(); - UrlLauncherPlatform.instance = mock; + final mock = MockUrlLauncher(); + UrlLauncherPlatform.instance = mock; - const exception = 'TestException: git hash test'; - final stackTrace = StackTrace.current; - const testHash = 'abc1234'; + const exception = 'TestException: git hash test'; + final stackTrace = StackTrace.current; + const testHash = 'abc1234'; - await tester.pumpWidget( - CrashScreen( - exception: exception, - stackTrace: stackTrace, - gitHash: testHash, - ), - ); - await tester.pumpAndSettle(); + await tester.pumpWidget( + CrashScreen( + exception: exception, + stackTrace: stackTrace, + gitHash: testHash, + ), + ); + await tester.pumpAndSettle(); - // Git hash link should be present - final gitLinkFinder = find.textContaining('Git Commit: abc1234'); - expect(gitLinkFinder, findsOneWidget); + // Git hash link should be present + final gitLinkFinder = find.textContaining('Git Commit: abc1234'); + expect(gitLinkFinder, findsOneWidget); - // Link must appear above the stack trace - final stackTraceFinder = find.text('Stack Trace:'); - expect( - tester.getTopLeft(gitLinkFinder).dy, - lessThan(tester.getTopLeft(stackTraceFinder).dy), - ); + // Link must appear above the stack trace + final stackTraceFinder = find.text('Stack Trace:'); + expect( + tester.getTopLeft(gitLinkFinder).dy, + lessThan(tester.getTopLeft(stackTraceFinder).dy), + ); - // Tapping the link should open the Codeberg commit URL - await tester.tap(gitLinkFinder); - await tester.pumpAndSettle(); + // Tapping the link should open the Codeberg commit URL + await tester.tap(gitLinkFinder); + await tester.pumpAndSettle(); - expect( - mock.launchedUrl, - equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'), - ); - }, - ); + expect( + mock.launchedUrl, + equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'), + ); + }); - testWidgets( - 'CrashScreen shows version, build mode, and platform in the UI', - (tester) async { - tester.view.physicalSize = const Size(800, 1200); - tester.view.devicePixelRatio = 1.0; - addTearDown(() => tester.view.resetPhysicalSize()); + testWidgets('CrashScreen shows version, build mode, and platform in the UI', ( + tester, + ) async { + tester.view.physicalSize = const Size(800, 1200); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); - const exception = 'TestException: info row test'; - final stackTrace = StackTrace.current; + const exception = 'TestException: info row test'; + final stackTrace = StackTrace.current; - await tester.pumpWidget( - MaterialApp( - home: CrashScreen(exception: exception, stackTrace: stackTrace), - ), - ); - await tester.pumpAndSettle(); + await tester.pumpWidget( + MaterialApp( + home: CrashScreen(exception: exception, stackTrace: stackTrace), + ), + ); + await tester.pumpAndSettle(); - // Info row shows app version (from mock), build mode, and platform OS. - expect(find.textContaining('1.0.0+42'), findsWidgets); - // In test builds kDebugMode is true. - expect(find.textContaining('debug'), findsOneWidget); - // Platform OS is always present (linux in CI, android/ios on device). - expect( - find.textContaining(RegExp(r'linux|android|ios|windows|macos')), - findsWidgets, - ); - }, - ); + // Info row shows app version (from mock), build mode, and platform OS. + expect(find.textContaining('1.0.0+42'), findsWidgets); + // In test builds kDebugMode is true. + expect(find.textContaining('debug'), findsOneWidget); + // Platform OS is always present (linux in CI, android/ios on device). + expect( + find.textContaining(RegExp(r'linux|android|ios|windows|macos')), + findsWidgets, + ); + }); testWidgets( 'CrashScreen shows app version as clickable link when git hash is set', @@ -264,8 +264,10 @@ void main() { }, ); addTearDown( - () => tester.binding.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, null), + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), ); const exception = 'TestException: version link clipboard test'; diff --git a/test/widget/edit_account_screen_test.dart b/test/widget/edit_account_screen_test.dart index e06bba5..66a77dc 100644 --- a/test/widget/edit_account_screen_test.dart +++ b/test/widget/edit_account_screen_test.dart @@ -106,62 +106,62 @@ void main() { }); testWidgets( - 'try connection button is disabled when no password stored or entered', - ( - tester, - ) async { - tester.view.physicalSize = const Size(800, 1400); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); + 'try connection button is disabled when no password stored or entered', + (tester) async { + tester.view.physicalSize = const Size(800, 1400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget( - buildApp( - initialLocation: '/accounts/acc-1/edit', - overrides: baseOverrides( - accounts: [kTestAccount], - hasStoredPassword: false, + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/edit', + overrides: baseOverrides( + accounts: [kTestAccount], + hasStoredPassword: false, + ), ), - ), - ); - await tester.pumpAndSettle(); + ); + await tester.pumpAndSettle(); - final button = tester.widget( - find.byKey(const Key('editTryConnectionButton')), - ); - expect(button.onPressed, isNull); - }); + final button = tester.widget( + find.byKey(const Key('editTryConnectionButton')), + ); + expect(button.onPressed, isNull); + }, + ); testWidgets( - 'try connection button is enabled after typing password with no stored password', - (tester) async { - tester.view.physicalSize = const Size(800, 1400); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); + 'try connection button is enabled after typing password with no stored password', + (tester) async { + tester.view.physicalSize = const Size(800, 1400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget( - buildApp( - initialLocation: '/accounts/acc-1/edit', - overrides: baseOverrides( - accounts: [kTestAccount], - hasStoredPassword: false, + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/edit', + overrides: baseOverrides( + accounts: [kTestAccount], + hasStoredPassword: false, + ), ), - ), - ); - await tester.pumpAndSettle(); + ); + await tester.pumpAndSettle(); - await tester.enterText( - find.byKey(const Key('editPasswordField')), - 'mypassword', - ); - await tester.pump(); + await tester.enterText( + find.byKey(const Key('editPasswordField')), + 'mypassword', + ); + await tester.pump(); - final button = tester.widget( - find.byKey(const Key('editTryConnectionButton')), - ); - expect(button.onPressed, isNotNull); - }); + final button = tester.widget( + find.byKey(const Key('editTryConnectionButton')), + ); + expect(button.onPressed, isNotNull); + }, + ); testWidgets('save button is disabled when no password stored or entered', ( tester, @@ -182,8 +182,9 @@ void main() { ); await tester.pumpAndSettle(); - final button = tester - .widget(find.widgetWithText(FilledButton, 'Save')); + final button = tester.widget( + find.widgetWithText(FilledButton, 'Save'), + ); expect(button.onPressed, isNull); }); diff --git a/test/widget/email_detail_screen_test.dart b/test/widget/email_detail_screen_test.dart index 6e59d10..911ba12 100644 --- a/test/widget/email_detail_screen_test.dart +++ b/test/widget/email_detail_screen_test.dart @@ -41,23 +41,19 @@ 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', () { @@ -191,45 +187,45 @@ void main() { await tester.pumpAndSettle(); expect( - find.byWidgetPredicate( - (w) => w is Tooltip && w.message == 'Reply all', - ), + find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply all'), findsNothing, ); }); - testWidgets('Reply on single-recipient email navigates directly to compose', - (tester) async { - // testEmail has from=[bob], to=[alice]. After removing alice (own), - // only bob remains → no dialog, navigate straight to compose. - final email = testEmail(); - await tester.pumpWidget( - buildApp( - initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', - overrides: [ - ..._overrides( - body: const EmailBody(emailId: 'acc-1:42', attachments: []), - email: email, - ), - draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), - ], - ), - ); - await tester.pumpAndSettle(); + testWidgets( + 'Reply on single-recipient email navigates directly to compose', + (tester) async { + // testEmail has from=[bob], to=[alice]. After removing alice (own), + // only bob remains → no dialog, navigate straight to compose. + final email = testEmail(); + await tester.pumpWidget( + buildApp( + initialLocation: + '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', + overrides: [ + ..._overrides( + body: const EmailBody(emailId: 'acc-1:42', attachments: []), + email: email, + ), + draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), + ], + ), + ); + await tester.pumpAndSettle(); - await tester.tap( - find.byWidgetPredicate( - (w) => w is Tooltip && w.message == 'Reply', - ), - ); - await tester.pumpAndSettle(); + await tester.tap( + find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply'), + ); + await tester.pumpAndSettle(); - // No dialog shown — straight navigation to compose. - expect(find.text('Reply All'), findsNothing); - }); + // No dialog shown — straight navigation to compose. + expect(find.text('Reply All'), findsNothing); + }, + ); - testWidgets('Reply on multi-recipient email shows Reply All dialog', - (tester) async { + testWidgets('Reply on multi-recipient email shows Reply All dialog', ( + tester, + ) async { // Email with an extra Cc recipient so the dialog is triggered. final email = Email( id: 'acc-1:42', @@ -258,9 +254,7 @@ void main() { await tester.pumpAndSettle(); await tester.tap( - find.byWidgetPredicate( - (w) => w is Tooltip && w.message == 'Reply', - ), + find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply'), ); await tester.pumpAndSettle(); @@ -271,8 +265,9 @@ void main() { expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1)); }); - testWidgets('Mark as spam is in popup menu, not a standalone button', - (tester) async { + testWidgets('Mark as spam is in popup menu, not a standalone button', ( + tester, + ) async { await tester.pumpWidget( buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', @@ -298,8 +293,9 @@ void main() { expect(find.text('Mark as spam'), findsOneWidget); }); - testWidgets('Mark as spam shows dialog when no junk folder', - (tester) async { + testWidgets('Mark as spam shows dialog when no junk folder', ( + tester, + ) async { // FakeMailboxRepository has no mailboxes by default → findMailboxByRole // returns null → dialog shown. await tester.pumpWidget( @@ -334,9 +330,7 @@ void main() { await tester.pumpAndSettle(); expect( - find.byWidgetPredicate( - (w) => w is Tooltip && w.message == 'Archive', - ), + find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Archive'), findsOneWidget, ); }); @@ -355,17 +349,16 @@ void main() { await tester.pumpAndSettle(); await tester.tap( - find.byWidgetPredicate( - (w) => w is Tooltip && w.message == 'Archive', - ), + find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Archive'), ); await tester.pumpAndSettle(); expect(find.text('No archive folder found'), findsOneWidget); }); - testWidgets('Mark as unread is in popup menu, not a standalone button', - (tester) async { + testWidgets('Mark as unread is in popup menu, not a standalone button', ( + tester, + ) async { await tester.pumpWidget( buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', @@ -401,13 +394,16 @@ void main() { accountRepositoryProvider.overrideWithValue( FakeAccountRepository([kTestAccount]), ), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), emailRepositoryProvider.overrideWithValue( FakeEmailRepository( emailDetail: testEmail(), - emailBody: - const EmailBody(emailId: 'acc-1:42', attachments: []), + emailBody: const EmailBody( + emailId: 'acc-1:42', + attachments: [], + ), rawRfc822: rawContent, ), ), @@ -436,13 +432,16 @@ void main() { accountRepositoryProvider.overrideWithValue( FakeAccountRepository([kTestAccount]), ), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), emailRepositoryProvider.overrideWithValue( FakeEmailRepository( emailDetail: testEmail(), - emailBody: - const EmailBody(emailId: 'acc-1:42', attachments: []), + emailBody: const EmailBody( + emailId: 'acc-1:42', + attachments: [], + ), rawRfc822: 'Subject: test\r\n\r\nBody', ), ), @@ -483,43 +482,37 @@ void main() { expect(find.text('Share'), findsOneWidget); }); - testWidgets( - 'long-press on unsubscribe chip shows URL tooltip', - (tester) async { - final email = testEmail( - listUnsubscribeHeader: '', - ); - await tester.pumpWidget( - buildApp( - initialLocation: - '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', - overrides: _overrides( - body: const EmailBody(emailId: 'acc-1:42', attachments: []), - email: email, - ), + testWidgets('long-press on unsubscribe chip shows URL tooltip', ( + tester, + ) async { + final email = testEmail( + listUnsubscribeHeader: '', + ); + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', + overrides: _overrides( + body: const EmailBody(emailId: 'acc-1:42', attachments: []), + email: email, ), - ); - await tester.pumpAndSettle(); + ), + ); + await tester.pumpAndSettle(); - expect(find.text('Unsubscribe'), findsOneWidget); + expect(find.text('Unsubscribe'), findsOneWidget); - expect( - find.byWidgetPredicate( - (w) => - w is Tooltip && w.message == 'https://example.com/unsubscribe', - ), - findsOneWidget, - ); + expect( + find.byWidgetPredicate( + (w) => w is Tooltip && w.message == 'https://example.com/unsubscribe', + ), + findsOneWidget, + ); - await tester.longPress(find.text('Unsubscribe')); - await tester.pumpAndSettle(); + await tester.longPress(find.text('Unsubscribe')); + await tester.pumpAndSettle(); - expect( - find.text('https://example.com/unsubscribe'), - findsOneWidget, - ); - }, - ); + expect(find.text('https://example.com/unsubscribe'), findsOneWidget); + }); testWidgets('Show Mail Structure opens dialog with MIME parts', ( tester, @@ -563,36 +556,31 @@ void main() { expect(find.textContaining('application/pdf'), findsOneWidget); }); - testWidgets( - 'Show Mail Structure shows snackbar when mimeTree is absent', - (tester) async { - const body = EmailBody( - emailId: 'acc-1:42', - textBody: 'Hello', - attachments: [], - // mimeTree is null — not yet cached or not available. - ); - await tester.pumpWidget( - buildApp( - initialLocation: - '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', - overrides: _overrides(body: body), - ), - ); - await tester.pumpAndSettle(); + testWidgets('Show Mail Structure shows snackbar when mimeTree is absent', ( + tester, + ) async { + const body = EmailBody( + emailId: 'acc-1:42', + textBody: 'Hello', + attachments: [], + // mimeTree is null — not yet cached or not available. + ); + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', + overrides: _overrides(body: body), + ), + ); + await tester.pumpAndSettle(); - await tester.tap(find.byType(PopupMenuButton)); - await tester.pumpAndSettle(); + await tester.tap(find.byType(PopupMenuButton)); + await tester.pumpAndSettle(); - await tester.tap(find.text('Show Mail Structure')); - await tester.pumpAndSettle(); + await tester.tap(find.text('Show Mail Structure')); + await tester.pumpAndSettle(); - expect( - find.textContaining('Structure not available'), - findsOneWidget, - ); - }, - ); + expect(find.textContaining('Structure not available'), findsOneWidget); + }); }); } diff --git a/test/widget/email_list_screen_golden_test.dart b/test/widget/email_list_screen_golden_test.dart index 5ac9051..337fe93 100644 --- a/test/widget/email_list_screen_golden_test.dart +++ b/test/widget/email_list_screen_golden_test.dart @@ -15,46 +15,42 @@ 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', () { @@ -122,9 +118,7 @@ void main() { buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', overrides: _overrides( - searchResults: [ - _email(id: 'acc-1:5', subject: 'Project proposal'), - ], + searchResults: [_email(id: 'acc-1:5', subject: 'Project proposal')], ), ), ); diff --git a/test/widget/email_list_screen_test.dart b/test/widget/email_list_screen_test.dart index 3bfca9a..96321b9 100644 --- a/test/widget/email_list_screen_test.dart +++ b/test/widget/email_list_screen_test.dart @@ -27,8 +27,7 @@ class _MutableFakeEmailRepository extends FakeEmailRepository { String accountId, String mailboxPath, String query, - ) async => - _results; + ) async => _results; } final _kDate = DateTime(2024, 6); @@ -430,63 +429,62 @@ void main() { expect(find.text('Result email'), findsWidgets); }); - testWidgets( - 'deleting all search results pops back to previous screen', - (tester) async { - final email = testEmail(subject: 'Needle'); + testWidgets('deleting all search results pops back to previous screen', ( + tester, + ) async { + final email = testEmail(subject: 'Needle'); - // Start at the mailbox list so the email list is pushed on top of it, - // making context.canPop() == true inside EmailListScreen. - await tester.pumpWidget( - buildApp( - initialLocation: '/accounts/acc-1/mailboxes', - overrides: [ - accountRepositoryProvider.overrideWithValue( - FakeAccountRepository([kTestAccount]), - ), - mailboxRepositoryProvider.overrideWithValue( - FakeMailboxRepository([kTestMailbox]), - ), - emailRepositoryProvider.overrideWithValue( - FakeEmailRepository(searchResults: [email]), - ), - ], - ), - ); - await tester.pumpAndSettle(); + // Start at the mailbox list so the email list is pushed on top of it, + // making context.canPop() == true inside EmailListScreen. + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes', + overrides: [ + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository([kTestMailbox]), + ), + emailRepositoryProvider.overrideWithValue( + FakeEmailRepository(searchResults: [email]), + ), + ], + ), + ); + await tester.pumpAndSettle(); - expect(find.byType(MailboxListScreen), findsOneWidget); + expect(find.byType(MailboxListScreen), findsOneWidget); - // Navigate into INBOX (pushes EmailListScreen onto the stack). - await tester.tap(find.text('INBOX')); - await tester.pumpAndSettle(); + // Navigate into INBOX (pushes EmailListScreen onto the stack). + await tester.tap(find.text('INBOX')); + await tester.pumpAndSettle(); - expect(find.byType(EmailListScreen), findsOneWidget); + expect(find.byType(EmailListScreen), findsOneWidget); - // Search for the email. - await tester.enterText(find.byType(TextField), 'Needle'); - await tester.testTextInput.receiveAction(TextInputAction.search); - await tester.pumpAndSettle(); + // Search for the email. + await tester.enterText(find.byType(TextField), 'Needle'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); - // 'Needle' also appears in the SearchBar input, so match at least one. - expect(find.text('Needle'), findsAtLeastNWidgets(1)); + // 'Needle' also appears in the SearchBar input, so match at least one. + expect(find.text('Needle'), findsAtLeastNWidgets(1)); - // Long-press the sender name (unique to the email tile) to enter - // selection mode. - await tester.longPress(find.text('Bob')); - await tester.pumpAndSettle(); + // Long-press the sender name (unique to the email tile) to enter + // selection mode. + await tester.longPress(find.text('Bob')); + await tester.pumpAndSettle(); - await tester.tap(find.byIcon(Icons.select_all)); - await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.select_all)); + await tester.pumpAndSettle(); - await tester.tap(find.byIcon(Icons.delete)); - await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.delete)); + await tester.pumpAndSettle(); - // Should have popped back to the mailbox list. - expect(find.byType(EmailListScreen), findsNothing); - expect(find.byType(MailboxListScreen), findsOneWidget); - }, - ); + // Should have popped back to the mailbox list. + expect(find.byType(EmailListScreen), findsNothing); + expect(find.byType(MailboxListScreen), findsOneWidget); + }); testWidgets( 'deleting some search results updates the list without popping', diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index bfb0360..e59c63a 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,8 +137,7 @@ 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; } @@ -156,7 +155,7 @@ class FakeMailboxRepository implements MailboxRepository { final List _mailboxes; FakeMailboxRepository([List? mailboxes]) - : _mailboxes = mailboxes ?? []; + : _mailboxes = mailboxes ?? []; @override Stream> observeMailboxes(String? accountId) => @@ -206,52 +205,49 @@ 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; @@ -263,8 +259,7 @@ 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 {} @@ -290,8 +285,7 @@ class FakeEmailRepository implements EmailRepository { Future findEmailByMessageId( String accountId, String messageId, - ) async => - null; + ) async => null; @override Future deleteEmail(String emailId) async => null; @@ -309,8 +303,7 @@ 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; @@ -320,30 +313,26 @@ 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) => @@ -353,8 +342,7 @@ class FakeEmailRepository implements EmailRepository { Future verifySyncReliability( String accountId, String mailboxPath, - ) async => - ReliabilityResult.healthy; + ) async => ReliabilityResult.healthy; @override Stream> observeFailedMutations(String accountId) => @@ -553,28 +541,26 @@ 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 @@ -604,23 +590,22 @@ 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({ @@ -635,12 +620,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/search_screen_test.dart b/test/widget/search_screen_test.dart index d9c5c34..871f766 100644 --- a/test/widget/search_screen_test.dart +++ b/test/widget/search_screen_test.dart @@ -89,9 +89,7 @@ void main() { expect(find.text('No results'), findsOneWidget); }); - testWidgets('shows email results under "Messages" section', ( - tester, - ) async { + testWidgets('shows email results under "Messages" section', (tester) async { final email = testEmail(subject: 'Invoice Q3'); await tester.pumpWidget( buildApp( @@ -122,9 +120,7 @@ void main() { expect(find.text('Invoice Q3'), findsOneWidget); }); - testWidgets('shows folder results under "Folders" section', ( - tester, - ) async { + testWidgets('shows folder results under "Folders" section', (tester) async { const archiveMailbox = Mailbox( id: 'acc-1:Archive', accountId: 'acc-1', diff --git a/test/widget/secure_email_webview_test.dart b/test/widget/secure_email_webview_test.dart index 0871966..a486058 100644 --- a/test/widget/secure_email_webview_test.dart +++ b/test/widget/secure_email_webview_test.dart @@ -11,19 +11,21 @@ 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', () { - test('forces light color-scheme to prevent black-on-black in dark mode', - () { - _expectLightMode(buildEmailHtml('

Hello

')); - }); + test( + 'forces light color-scheme to prevent black-on-black in dark mode', + () { + _expectLightMode(buildEmailHtml('

Hello

')); + }, + ); test('includes email body content', () { final html = buildEmailHtml('

Test body

'); @@ -42,10 +44,10 @@ void main() { _expectLightMode(html); }); - test('prevents horizontal overflow so wide HTML emails are not cut off', - () { - final html = - buildEmailHtml('
x
'); + test('prevents horizontal overflow so wide HTML emails are not cut off', () { + final html = buildEmailHtml( + '
x
', + ); // Body clips overflow so fixed-width email tables don't escape the viewport. expect(html, contains('overflow-x: hidden')); // Tables are forced to full viewport width so fixed pixel widths don't overflow. @@ -62,11 +64,7 @@ void main() { group('SecureEmailWebView (Linux plain-text fallback)', () { testWidgets('renders extracted text from HTML', (tester) async { await tester.pumpWidget( - _wrap( - const SecureEmailWebView( - htmlBody: '

Hello world

', - ), - ), + _wrap(const SecureEmailWebView(htmlBody: '

Hello world

')), ); expect(find.textContaining('Hello'), findsOneWidget); expect(find.textContaining('world'), findsOneWidget); @@ -92,12 +90,11 @@ void main() { expect(find.byType(SelectableText), findsOneWidget); }); - testWidgets('toggling loadRemoteImages rebuilds without error', - (tester) async { + testWidgets('toggling loadRemoteImages rebuilds without error', ( + tester, + ) async { await tester.pumpWidget( - _wrap( - const SecureEmailWebView(htmlBody: '

Body

'), - ), + _wrap(const SecureEmailWebView(htmlBody: '

Body

')), ); await tester.pumpWidget( _wrap( @@ -111,9 +108,7 @@ void main() { }); testWidgets('handles empty HTML body', (tester) async { - await tester.pumpWidget( - _wrap(const SecureEmailWebView(htmlBody: '')), - ); + await tester.pumpWidget(_wrap(const SecureEmailWebView(htmlBody: ''))); expect(find.byType(SelectableText), findsOneWidget); }); }); diff --git a/test/widget/sieve_scripts_screen_test.dart b/test/widget/sieve_scripts_screen_test.dart index ed3453a..a51413f 100644 --- a/test/widget/sieve_scripts_screen_test.dart +++ b/test/widget/sieve_scripts_screen_test.dart @@ -27,13 +27,9 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ - sieveRepositoryProvider.overrideWith( - (ref) => _FakeSieveRepository(), - ), + sieveRepositoryProvider.overrideWith((ref) => _FakeSieveRepository()), ], - child: const MaterialApp( - home: SieveScriptsScreen(accountId: 'acc-1'), - ), + child: const MaterialApp(home: SieveScriptsScreen(accountId: 'acc-1')), ), ); await tester.pumpAndSettle(); diff --git a/test/widget/thread_detail_screen_test.dart b/test/widget/thread_detail_screen_test.dart index e61f19d..78996ad 100644 --- a/test/widget/thread_detail_screen_test.dart +++ b/test/widget/thread_detail_screen_test.dart @@ -11,23 +11,22 @@ 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 bd4d489..46e5589 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/undo_shell_test.dart b/test/widget/undo_shell_test.dart index 4b9ce7d..6d439d2 100644 --- a/test/widget/undo_shell_test.dart +++ b/test/widget/undo_shell_test.dart @@ -38,8 +38,9 @@ void main() { sourceMailboxPath: 'INBOX', timestamp: DateTime.now().subtract(const Duration(hours: 1)), ); - when(mockUndoRepo.getHistory(limit: anyNamed('limit'))) - .thenAnswer((_) async => [staleAction]); + when( + mockUndoRepo.getHistory(limit: anyNamed('limit')), + ).thenAnswer((_) async => [staleAction]); await tester.pumpWidget(buildShell(mockUndoRepo)); await tester.pumpAndSettle(); @@ -48,10 +49,12 @@ void main() { }, ); - testWidgets('shows snackbar for fresh action pushed in current session', - (tester) async { - when(mockUndoRepo.getHistory(limit: anyNamed('limit'))) - .thenAnswer((_) async => []); + testWidgets('shows snackbar for fresh action pushed in current session', ( + tester, + ) async { + when( + mockUndoRepo.getHistory(limit: anyNamed('limit')), + ).thenAnswer((_) async => []); await tester.pumpWidget(buildShell(mockUndoRepo)); await tester.pumpAndSettle(); @@ -64,18 +67,20 @@ void main() { emailIds: ['e1'], sourceMailboxPath: 'INBOX', ); - await ProviderScope.containerOf(context) - .read(undoServiceProvider.notifier) - .pushAction(freshAction); + await ProviderScope.containerOf( + context, + ).read(undoServiceProvider.notifier).pushAction(freshAction); await tester.pumpAndSettle(); expect(find.text('1 email(s) moved'), findsOneWidget); }); - testWidgets('shows correct text for delete action (moved to Trash)', - (tester) async { - when(mockUndoRepo.getHistory(limit: anyNamed('limit'))) - .thenAnswer((_) async => []); + testWidgets('shows correct text for delete action (moved to Trash)', ( + tester, + ) async { + when( + mockUndoRepo.getHistory(limit: anyNamed('limit')), + ).thenAnswer((_) async => []); await tester.pumpWidget(buildShell(mockUndoRepo)); await tester.pumpAndSettle(); @@ -88,9 +93,9 @@ void main() { emailIds: ['e1', 'e2'], sourceMailboxPath: 'INBOX', ); - await ProviderScope.containerOf(context) - .read(undoServiceProvider.notifier) - .pushAction(deleteAction); + await ProviderScope.containerOf( + context, + ).read(undoServiceProvider.notifier).pushAction(deleteAction); await tester.pumpAndSettle(); expect(find.text('2 email(s) moved to Trash'), findsOneWidget); diff --git a/test/widget/user_preferences_screen_test.dart b/test/widget/user_preferences_screen_test.dart index d41db2f..6d4d891 100644 --- a/test/widget/user_preferences_screen_test.dart +++ b/test/widget/user_preferences_screen_test.dart @@ -35,10 +35,7 @@ void main() { ); await tester.pumpAndSettle(); - expect( - find.text('Single mail view button position'), - findsOneWidget, - ); + expect(find.text('Single mail view button position'), findsOneWidget); }); testWidgets('menu position bottom option is selected by default', ( @@ -53,8 +50,9 @@ void main() { await tester.pumpAndSettle(); final radioGroups = find.byType(RadioGroup); - final menuGroup = - tester.widget>(radioGroups.first); + final menuGroup = tester.widget>( + radioGroups.first, + ); expect(menuGroup.groupValue, MenuPosition.bottom); }); @@ -70,8 +68,9 @@ void main() { await tester.pumpAndSettle(); final radioGroups = find.byType(RadioGroup); - final mailViewGroup = - tester.widget>(radioGroups.last); + final mailViewGroup = tester.widget>( + radioGroups.last, + ); expect(mailViewGroup.groupValue, MenuPosition.bottom); }); @@ -89,36 +88,38 @@ 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); }); testWidgets( - 'tapping Top in mail view button position section updates the repo', ( - tester, - ) async { - await tester.pumpWidget( - buildApp( - initialLocation: '/accounts/preferences', - overrides: baseOverrides(), - ), - ); - await tester.pumpAndSettle(); + 'tapping Top in mail view button position section updates the repo', + (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/preferences', + overrides: baseOverrides(), + ), + ); + await tester.pumpAndSettle(); - await tester.tap(find.text('Top').last); - await tester.pumpAndSettle(); + 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); - }); + expect(repo.mailViewButtonPosition, MenuPosition.top); + }, + ); testWidgets('shows after mail action section', (tester) async { await tester.pumpWidget( @@ -153,14 +154,13 @@ void main() { await tester.pumpAndSettle(); final radioGroups = find.byType(RadioGroup); - final group = - tester.widget>(radioGroups.first); + final group = tester.widget>( + radioGroups.first, + ); expect(group.groupValue, AfterMailViewAction.nextMessage); }); - testWidgets('tapping Return to mailbox updates the repo', ( - tester, - ) async { + testWidgets('tapping Return to mailbox updates the repo', (tester) async { await tester.pumpWidget( buildApp( initialLocation: '/accounts/preferences', @@ -175,10 +175,11 @@ 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); });