diff --git a/codeberg-runner/README.md b/codeberg-runner/README.md new file mode 100644 index 0000000..d423c5b --- /dev/null +++ b/codeberg-runner/README.md @@ -0,0 +1,5 @@ +# SharedInbox CI Runner + +Installed like explained here: + +https://forgejo.org/docs/next/admin/actions/installation/binary/ diff --git a/lib/core/services/connection_test_service.dart b/lib/core/services/connection_test_service.dart index 1db5b29..00a5e74 100644 --- a/lib/core/services/connection_test_service.dart +++ b/lib/core/services/connection_test_service.dart @@ -111,7 +111,9 @@ class ConnectionTestServiceImpl implements ConnectionTestService { } try { await client.quit(); - } catch (_) {/* best-effort */} + } catch (_) { + /* best-effort */ + } } Future _testManageSieve( @@ -137,12 +139,16 @@ class ConnectionTestServiceImpl implements ConnectionTestService { } catch (e) { try { await client.logout(); - } catch (_) {/* best-effort */} + } catch (_) { + /* best-effort */ + } throw Exception('ManageSieve: $e'); } try { await client.logout(); - } catch (_) {/* best-effort */} + } catch (_) { + /* best-effort */ + } } Future _testJmap(Account account, String password) async { @@ -163,8 +169,9 @@ class ConnectionTestServiceImpl implements ConnectionTestService { }, ).timeout(const Duration(seconds: 10)); if (resp.statusCode == 401 || resp.statusCode == 403) { - lastError = - Exception('Authentication failed: wrong username or password'); + lastError = Exception( + 'Authentication failed: wrong username or password', + ); continue; } if (resp.statusCode != 200) { diff --git a/lib/data/jmap/jmap_client.dart b/lib/data/jmap/jmap_client.dart index 6cf3063..47e90f6 100644 --- a/lib/data/jmap/jmap_client.dart +++ b/lib/data/jmap/jmap_client.dart @@ -7,10 +7,7 @@ import 'package:http/http.dart' as http; import 'package:sharedinbox/data/imap/imap_client_factory.dart' show verboseLogKey; -const _coreUsing = [ - 'urn:ietf:params:jmap:core', - 'urn:ietf:params:jmap:mail', -]; +const _coreUsing = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail']; const _submissionCapability = 'urn:ietf:params:jmap:submission'; const _sieveCapability = 'urn:ietf:params:jmap:sieve'; @@ -72,7 +69,9 @@ class JmapClient { while (true) { resp = await httpClient.get( jmapUrl, - headers: {'Authorization': 'Basic $credentials'}, + headers: { + 'Authorization': 'Basic $credentials', + }, ).timeout(const Duration(seconds: 10)); if (resp.statusCode != 429 || attempt >= 4) { break; @@ -135,10 +134,7 @@ class JmapClient { if (withSubmission) _submissionCapability, if (withSieve) _sieveCapability, ]; - final body = jsonEncode({ - 'using': using, - 'methodCalls': methodCalls, - }); + final body = jsonEncode({'using': using, 'methodCalls': methodCalls}); final resp = await _httpClient .post( @@ -224,7 +220,9 @@ class JmapClient { ); final resp = await _httpClient.get( url, - headers: {'Authorization': 'Basic $_credentials'}, + headers: { + 'Authorization': 'Basic $_credentials', + }, ).timeout(const Duration(seconds: 30)); if (resp.statusCode != 200) { throw JmapException('Blob download failed (HTTP ${resp.statusCode})'); diff --git a/lib/data/jmap/sieve_repository.dart b/lib/data/jmap/sieve_repository.dart index 365e51c..cc22a5b 100644 --- a/lib/data/jmap/sieve_repository.dart +++ b/lib/data/jmap/sieve_repository.dart @@ -36,22 +36,19 @@ class SieveRepository { Future> listScripts(String accountId) async { final account = await _requireAccount(accountId); if (account.type == AccountType.imap) { - return _withManageSieve( - account, - (c) async { - final scripts = await c.listScripts(); - return scripts - .map( - (s) => SieveScript( - id: s.name, - name: s.name, - blobId: s.name, - isActive: s.isActive, - ), - ) - .toList(); - }, - ); + return _withManageSieve(account, (c) async { + final scripts = await c.listScripts(); + return scripts + .map( + (s) => SieveScript( + id: s.name, + name: s.name, + blobId: s.name, + isActive: s.isActive, + ), + ) + .toList(); + }); } return _withJmap(account, (jmap) async { final responses = await jmap.call( @@ -108,12 +105,7 @@ class SieveRepository { if (id != null && id != name) { await c.deleteScript(id); } - return SieveScript( - id: name, - name: name, - blobId: name, - isActive: false, - ); + return SieveScript(id: name, name: name, blobId: name, isActive: false); }); } return _withJmap(account, (jmap) async { diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index f83d9af..d24b095 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -176,16 +176,18 @@ class EmailRepositoryImpl implements EmailRepository { participantsJson: Value(jsonEncode(participants)), preview: Value(latest.preview), latestEmailId: latest.id, - emailIdsJson: - Value(jsonEncode(threadEmails.map((e) => e.id).toList())), + emailIdsJson: Value( + jsonEncode(threadEmails.map((e) => e.id).toList()), + ), ), ); } @override Future getEmail(String emailId) async { - final row = await (_db.select(_db.emails) - ..where((t) => t.id.equals(emailId))) + final row = await (_db.select( + _db.emails, + )..where((t) => t.id.equals(emailId))) .getSingleOrNull(); return row == null ? null : _toModel(row); } @@ -196,8 +198,9 @@ class EmailRepositoryImpl implements EmailRepository { @override Future getEmailBody(String emailId) async { - final cached = await (_db.select(_db.emailBodies) - ..where((t) => t.emailId.equals(emailId))) + final cached = await (_db.select( + _db.emailBodies, + )..where((t) => t.emailId.equals(emailId))) .getSingleOrNull(); if (cached != null) { // Re-fetch if cachedAt is null (legacy row) or older than the TTL. @@ -207,8 +210,9 @@ class EmailRepositoryImpl implements EmailRepository { if (age <= _bodyCacheTtl) return _bodyRowToModel(cached); } - final emailRow = await (_db.select(_db.emails) - ..where((t) => t.id.equals(emailId))) + final emailRow = await (_db.select( + _db.emails, + )..where((t) => t.id.equals(emailId))) .getSingle(); final account = (await _accounts.getAccount(emailRow.accountId))!; final password = await _accounts.getPassword(account.id); @@ -217,8 +221,11 @@ class EmailRepositoryImpl implements EmailRepository { return _getEmailBodyJmap(emailId, account, password); } - final client = - await _imapConnect(account, _effectiveUsername(account), password); + final client = await _imapConnect( + account, + _effectiveUsername(account), + password, + ); try { await client.selectMailboxByPath(emailRow.mailboxPath); final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])'); @@ -364,8 +371,11 @@ class EmailRepositoryImpl implements EmailRepository { String password, String mailboxPath, ) async { - final client = - await _imapConnect(account, _effectiveUsername(account), password); + final client = await _imapConnect( + account, + _effectiveUsername(account), + password, + ); try { // Only request CONDSTORE if the server advertises it. Servers that don't // support the extension may reject SELECT with (CONDSTORE) with BAD. @@ -394,7 +404,9 @@ class EmailRepositoryImpl implements EmailRepository { } // 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(searchCriteria: 'ALL')) + final allUids = (await client.uidSearchMessages( + searchCriteria: 'ALL', + )) .matchingSequence ?.toList() ?? []; @@ -454,11 +466,12 @@ class EmailRepositoryImpl implements EmailRepository { } // Detect remote deletions. - final serverUids = - (await client.uidSearchMessages(searchCriteria: 'ALL')) - .matchingSequence - ?.toList() ?? - []; + final serverUids = (await client.uidSearchMessages( + searchCriteria: 'ALL', + )) + .matchingSequence + ?.toList() ?? + []; await _reconcileDeletedImap(account.id, mailboxPath, serverUids); final maxUid = serverUids.isEmpty ? lastUid : serverUids.reduce(math.max); @@ -519,15 +532,19 @@ class EmailRepositoryImpl implements EmailRepository { final fetch = sequence.isUidSequence ? await client.uidFetchMessages(sequence, fetchItems) : await client.fetchMessages(sequence, fetchItems); - final pendingByUid = - await _pendingDeleteOrMoveUids(account.id, mailboxPath); + final pendingByUid = await _pendingDeleteOrMoveUids( + account.id, + mailboxPath, + ); var bytes = 0; final affectedThreads = {}; await _db.transaction(() async { for (final msg in fetch.messages) { final envelope = msg.envelope; if (envelope == null) { - log('IMAP: skipping message with no envelope (uid=${msg.uid}, mailbox=$mailboxPath)'); + log( + 'IMAP: skipping message with no envelope (uid=${msg.uid}, mailbox=$mailboxPath)', + ); continue; } final uid = msg.uid; @@ -718,11 +735,16 @@ class EmailRepositoryImpl implements EmailRepository { String password, String mailboxPath, ) async { - final client = - await _imapConnect(account, _effectiveUsername(account), password); + final client = await _imapConnect( + account, + _effectiveUsername(account), + password, + ); try { await client.selectMailboxByPath(mailboxPath); - final serverUids = (await client.uidSearchMessages(searchCriteria: 'ALL')) + final serverUids = (await client.uidSearchMessages( + searchCriteria: 'ALL', + )) .matchingSequence ?.toList() ?? []; @@ -818,7 +840,7 @@ class EmailRepositoryImpl implements EmailRepository { 'position': position, }, '0', - ] + ], ]); final queryResult = _responseArgs(responses, 0, 'Email/query'); final ids = List.from(queryResult['ids'] as List); @@ -863,7 +885,7 @@ class EmailRepositoryImpl implements EmailRepository { 'properties': ['id', 'keywords'], }, '0', - ] + ], ]); final getResult = _responseArgs(responses, 0, 'Email/get'); final list = getResult['list'] as List; @@ -1031,7 +1053,7 @@ class EmailRepositoryImpl implements EmailRepository { 'Email/changes', {'accountId': jmap.accountId, 'sinceState': sinceState}, '0', - ] + ], ]); final changes = _responseArgs(responses, 0, 'Email/changes'); @@ -1054,7 +1076,7 @@ class EmailRepositoryImpl implements EmailRepository { ..._emailGetBodyOptions, }, '1', - ] + ], ]); final getResult = _responseArgs(getResponses, 0, 'Email/get'); final list = getResult['list'] as List; @@ -1120,12 +1142,15 @@ class EmailRepositoryImpl implements EmailRepository { affectedByMailbox.putIfAbsent(mailboxPath, () => {}).add(jmapThreadId); // JMAP messageId/inReplyTo/references are arrays; join to space-separated. - final jmapMessageId = - _joinJmapStringList(m['messageId'] as List?); - final jmapInReplyTo = - _joinJmapStringList(m['inReplyTo'] as List?); - final jmapReferences = - _joinJmapStringList(m['references'] as List?); + final jmapMessageId = _joinJmapStringList( + m['messageId'] as List?, + ); + final jmapInReplyTo = _joinJmapStringList( + m['inReplyTo'] as List?, + ); + final jmapReferences = _joinJmapStringList( + m['references'] as List?, + ); await _db.into(_db.emails).insertOnConflictUpdate( EmailsCompanion.insert( @@ -1227,10 +1252,14 @@ class EmailRepositoryImpl implements EmailRepository { Future _recordChangeError(PendingChangeRow row, Object error) async { final next = row.attempts + 1; if (next >= _maxChangeAttempts) { - await (_db.delete(_db.pendingChanges)..where((t) => t.id.equals(row.id))) + await (_db.delete( + _db.pendingChanges, + )..where((t) => t.id.equals(row.id))) .go(); } else { - await (_db.update(_db.pendingChanges)..where((t) => t.id.equals(row.id))) + await (_db.update( + _db.pendingChanges, + )..where((t) => t.id.equals(row.id))) .write( PendingChangesCompanion( attempts: Value(next), @@ -1311,8 +1340,9 @@ class EmailRepositoryImpl implements EmailRepository { return; } - final credentials = base64 - .encode(utf8.encode('${_effectiveUsername(account)}:$password')); + final credentials = base64.encode( + utf8.encode('${_effectiveUsername(account)}:$password'), + ); http.StreamedResponse response; try { @@ -1404,13 +1434,10 @@ class EmailRepositoryImpl implements EmailRepository { // ── Mutations ────────────────────────────────────────────────────────────── @override - Future setFlag( - String emailId, { - bool? seen, - bool? flagged, - }) async { - final row = await (_db.select(_db.emails) - ..where((t) => t.id.equals(emailId))) + Future setFlag(String emailId, {bool? seen, bool? flagged}) async { + final row = await (_db.select( + _db.emails, + )..where((t) => t.id.equals(emailId))) .getSingle(); final account = (await _accounts.getAccount(row.accountId))!; @@ -1451,9 +1478,11 @@ class EmailRepositoryImpl implements EmailRepository { account.id, emailId, 'flag_seen', - jsonEncode( - {'uid': row.uid, 'mailboxPath': row.mailboxPath, 'seen': seen}, - ), + jsonEncode({ + 'uid': row.uid, + 'mailboxPath': row.mailboxPath, + 'seen': seen, + }), ); } if (flagged != null) { @@ -1483,8 +1512,9 @@ class EmailRepositoryImpl implements EmailRepository { @override Future moveEmail(String emailId, String destMailboxPath) async { - final row = await (_db.select(_db.emails) - ..where((t) => t.id.equals(emailId))) + final row = await (_db.select( + _db.emails, + )..where((t) => t.id.equals(emailId))) .getSingle(); final account = (await _accounts.getAccount(row.accountId))!; @@ -1550,8 +1580,9 @@ class EmailRepositoryImpl implements EmailRepository { @override Future deleteEmail(String emailId) async { - final row = await (_db.select(_db.emails) - ..where((t) => t.id.equals(emailId))) + final row = await (_db.select( + _db.emails, + )..where((t) => t.id.equals(emailId))) .getSingle(); final account = (await _accounts.getAccount(row.accountId))!; @@ -1637,8 +1668,9 @@ class EmailRepositoryImpl implements EmailRepository { final row = await query.getSingleOrNull(); if (row != null) { - final count = await (_db.delete(_db.pendingChanges) - ..where((t) => t.id.equals(row.id))) + final count = await (_db.delete( + _db.pendingChanges, + )..where((t) => t.id.equals(row.id))) .go(); return count > 0; } @@ -1647,8 +1679,9 @@ class EmailRepositoryImpl implements EmailRepository { @override Future snoozeEmail(String emailId, DateTime until) async { - final row = await (_db.select(_db.emails) - ..where((t) => t.id.equals(emailId))) + final row = await (_db.select( + _db.emails, + )..where((t) => t.id.equals(emailId))) .getSingle(); final account = (await _accounts.getAccount(row.accountId))!; @@ -1696,11 +1729,7 @@ class EmailRepositoryImpl implements EmailRepository { row.mailboxPath, row.threadId ?? emailId, ); - await _updateThread( - row.accountId, - destPath, - row.threadId ?? emailId, - ); + await _updateThread(row.accountId, destPath, row.threadId ?? emailId); } @override @@ -1730,11 +1759,7 @@ class EmailRepositoryImpl implements EmailRepository { accountId, row.id, 'unsnooze', - jsonEncode({ - 'uid': row.uid, - 'src': row.mailboxPath, - 'dest': dest, - }), + jsonEncode({'uid': row.uid, 'src': row.mailboxPath, 'dest': dest}), ); // Optimistic local update. @@ -1822,10 +1847,14 @@ class EmailRepositoryImpl implements EmailRepository { for (final row in rows) { try { - final newState = - await _applyPendingChangeJmap(jmap, row, ifInState: ifInState); - await (_db.delete(_db.pendingChanges) - ..where((t) => t.id.equals(row.id))) + final newState = await _applyPendingChangeJmap( + jmap, + row, + ifInState: ifInState, + ); + await (_db.delete( + _db.pendingChanges, + )..where((t) => t.id.equals(row.id))) .go(); applied++; // Keep our checkpoint in sync with whatever the server returned. @@ -1852,8 +1881,9 @@ class EmailRepositoryImpl implements EmailRepository { } on JmapSetItemException catch (e) { // Permanent per-item rejection (e.g. notFound, forbidden) — discard // the change so the queue doesn't grow unboundedly. - await (_db.delete(_db.pendingChanges) - ..where((t) => t.id.equals(row.id))) + await (_db.delete( + _db.pendingChanges, + )..where((t) => t.id.equals(row.id))) .go(); log('JMAP permanent error for change ${row.id}: $e'); } catch (e) { @@ -1870,8 +1900,11 @@ class EmailRepositoryImpl implements EmailRepository { ) async { imap.ImapClient? client; try { - client = - await _imapConnect(account, _effectiveUsername(account), password); + client = await _imapConnect( + account, + _effectiveUsername(account), + password, + ); } catch (e) { // Connection-level failure — bump all rows, they'll retry next cycle. for (final row in rows) { @@ -1884,8 +1917,9 @@ class EmailRepositoryImpl implements EmailRepository { for (final row in rows) { try { await _applyPendingChangeImap(client, row); - await (_db.delete(_db.pendingChanges) - ..where((t) => t.id.equals(row.id))) + await (_db.delete( + _db.pendingChanges, + )..where((t) => t.id.equals(row.id))) .go(); applied++; } catch (e) { @@ -1993,7 +2027,7 @@ class EmailRepositoryImpl implements EmailRepository { }, }), '0', - ] + ], ]); case 'flag_flagged': @@ -2007,7 +2041,7 @@ class EmailRepositoryImpl implements EmailRepository { }, }), '0', - ] + ], ]); case 'move': @@ -2025,7 +2059,7 @@ class EmailRepositoryImpl implements EmailRepository { }, }), '0', - ] + ], ]); case 'delete': @@ -2036,7 +2070,7 @@ class EmailRepositoryImpl implements EmailRepository { 'destroy': [jmapEmailId], }), '0', - ] + ], ]); case 'snooze': @@ -2058,7 +2092,7 @@ class EmailRepositoryImpl implements EmailRepository { }, }), '0', - ] + ], ]); case 'unsnooze': @@ -2074,7 +2108,7 @@ class EmailRepositoryImpl implements EmailRepository { 'properties': ['keywords'], }, '0', - ] + ], ]); final getResult = _responseArgs(getResponses, 0, 'Email/get'); final email = (getResult['list'] as List).firstOrNull as Map?; @@ -2098,7 +2132,7 @@ class EmailRepositoryImpl implements EmailRepository { 'update': {jmapEmailId: update}, }), '0', - ] + ], ]); default: @@ -2163,8 +2197,11 @@ class EmailRepositoryImpl implements EmailRepository { await builder.addFile(file, mediaType); } final mimeMessage = builder.buildMimeMessage(); - final smtpClient = - await _smtpConnect(account, _effectiveUsername(account), password); + final smtpClient = await _smtpConnect( + account, + _effectiveUsername(account), + password, + ); try { await smtpClient.sendMessage(mimeMessage); } finally { @@ -2172,8 +2209,11 @@ class EmailRepositoryImpl implements EmailRepository { } // Save a copy to the Sent folder via IMAP APPEND. // Create the folder first — many servers don't pre-create it. - final imapClient = - await _imapConnect(account, _effectiveUsername(account), password); + final imapClient = await _imapConnect( + account, + _effectiveUsername(account), + password, + ); try { try { await imapClient.createMailbox('Sent'); @@ -2224,7 +2264,9 @@ 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')) + ..where( + (t) => t.accountId.equals(account.id) & t.role.equals('sent'), + ) ..limit(1)) .getSingleOrNull(); final sentJmapId = sentMailbox?.path; @@ -2370,8 +2412,9 @@ class EmailRepositoryImpl implements EmailRepository { ); } - final emailRow = await (_db.select(_db.emails) - ..where((t) => t.id.equals(emailId))) + final emailRow = await (_db.select( + _db.emails, + )..where((t) => t.id.equals(emailId))) .getSingle(); final account = (await _accounts.getAccount(emailRow.accountId))!; final password = await _accounts.getPassword(account.id); @@ -2392,8 +2435,11 @@ class EmailRepositoryImpl implements EmailRepository { return file.path; } - final client = - await _imapConnect(account, _effectiveUsername(account), password); + final client = await _imapConnect( + account, + _effectiveUsername(account), + password, + ); try { await client.selectMailboxByPath(emailRow.mailboxPath); final fetch = await client.uidFetchMessage( @@ -2404,9 +2450,7 @@ class EmailRepositoryImpl implements EmailRepository { final part = msg.getPart(attachment.fetchPartId) ?? msg; final bytes = part.decodeContentBinary(); if (bytes == null) { - throw StateError( - 'Failed to decode attachment ${attachment.filename}.', - ); + throw StateError('Failed to decode attachment ${attachment.filename}.'); } await file.writeAsBytes(bytes); return file.path; @@ -2475,8 +2519,11 @@ class EmailRepositoryImpl implements EmailRepository { ) async { final account = (await _accounts.getAccount(accountId))!; final password = await _accounts.getPassword(accountId); - final client = - await _imapConnect(account, _effectiveUsername(account), password); + final client = await _imapConnect( + account, + _effectiveUsername(account), + password, + ); try { await client.selectMailboxByPath(mailboxPath); final terms = @@ -2524,12 +2571,7 @@ class EmailRepositoryImpl implements EmailRepository { List _toAddressList(List? addresses) => (addresses ?? const []) - .map( - (a) => model.EmailAddress( - name: a.personalName, - email: a.email, - ), - ) + .map((a) => model.EmailAddress(name: a.personalName, email: a.email)) .toList(); // ── Helpers ──────────────────────────────────────────────────────────────── @@ -2694,10 +2736,7 @@ class EmailRepositoryImpl implements EmailRepository { @override Future retryMutation(int id) async { await (_db.update(_db.pendingChanges)..where((t) => t.id.equals(id))).write( - const PendingChangesCompanion( - attempts: Value(0), - lastError: Value(null), - ), + const PendingChangesCompanion(attempts: Value(0), lastError: Value(null)), ); } }