diff --git a/lib/core/models/email.dart b/lib/core/models/email.dart index 47cb966..3f7ad47 100644 --- a/lib/core/models/email.dart +++ b/lib/core/models/email.dart @@ -119,3 +119,24 @@ class EmailDraft { this.attachmentFilePaths = const [], }); } + +class SyncEmailsResult { + const SyncEmailsResult({ + required this.fetched, + required this.skipped, + required this.bytesTransferred, + }); + + final int fetched; + final int skipped; + final int bytesTransferred; + + static const zero = + SyncEmailsResult(fetched: 0, skipped: 0, bytesTransferred: 0); + + SyncEmailsResult operator +(SyncEmailsResult other) => SyncEmailsResult( + fetched: fetched + other.fetched, + skipped: skipped + other.skipped, + bytesTransferred: bytesTransferred + other.bytesTransferred, + ); +} diff --git a/lib/core/repositories/email_repository.dart b/lib/core/repositories/email_repository.dart index 50af3a4..660b5d0 100644 --- a/lib/core/repositories/email_repository.dart +++ b/lib/core/repositories/email_repository.dart @@ -4,7 +4,7 @@ abstract class EmailRepository { Stream> observeEmails(String accountId, String mailboxPath); Future getEmail(String emailId); Future getEmailBody(String emailId); - Future syncEmails(String accountId, String mailboxPath); + Future syncEmails(String accountId, String mailboxPath); Future setFlag( String emailId, { bool? seen, diff --git a/lib/core/repositories/sync_log_repository.dart b/lib/core/repositories/sync_log_repository.dart index 84cd339..a421e64 100644 --- a/lib/core/repositories/sync_log_repository.dart +++ b/lib/core/repositories/sync_log_repository.dart @@ -5,8 +5,10 @@ class SyncLogEntry { this.errorMessage, required this.protocol, required this.emailsFetched, + required this.emailsSkipped, required this.mailboxesSynced, required this.pendingFlushed, + required this.bytesTransferred, required this.startedAt, required this.finishedAt, }); @@ -16,8 +18,10 @@ class SyncLogEntry { final String? errorMessage; final String protocol; // 'imap' or 'jmap' final int emailsFetched; + final int emailsSkipped; final int mailboxesSynced; final int pendingFlushed; + final int bytesTransferred; final DateTime startedAt; final DateTime finishedAt; @@ -32,8 +36,10 @@ abstract class SyncLogRepository { String? errorMessage, required String protocol, required int emailsFetched, + required int emailsSkipped, required int mailboxesSynced, required int pendingFlushed, + required int bytesTransferred, required DateTime startedAt, required DateTime finishedAt, }); @@ -51,8 +57,10 @@ class NoOpSyncLogRepository implements SyncLogRepository { String? errorMessage, required String protocol, required int emailsFetched, + required int emailsSkipped, required int mailboxesSynced, required int pendingFlushed, + required int bytesTransferred, required DateTime startedAt, required DateTime finishedAt, }) async {} diff --git a/lib/core/sync/account_sync_manager.dart b/lib/core/sync/account_sync_manager.dart index 66f953d..6f12ea1 100644 --- a/lib/core/sync/account_sync_manager.dart +++ b/lib/core/sync/account_sync_manager.dart @@ -4,6 +4,7 @@ import 'package:enough_mail/enough_mail.dart' as imap; import '../../data/imap/imap_client_factory.dart'; import '../models/account.dart'; +import '../models/email.dart' show SyncEmailsResult; import '../repositories/account_repository.dart'; import '../repositories/email_repository.dart'; import '../repositories/mailbox_repository.dart'; @@ -129,8 +130,10 @@ class _AccountSync implements _SyncLoop { success: true, protocol: 'imap', emailsFetched: stats.emailsFetched, + emailsSkipped: stats.emailsSkipped, mailboxesSynced: stats.mailboxesSynced, pendingFlushed: stats.pendingFlushed, + bytesTransferred: stats.bytesTransferred, startedAt: startedAt, finishedAt: DateTime.now(), ); @@ -143,8 +146,10 @@ class _AccountSync implements _SyncLoop { errorMessage: e.toString(), protocol: 'imap', emailsFetched: 0, + emailsSkipped: 0, mailboxesSynced: 0, pendingFlushed: 0, + bytesTransferred: 0, startedAt: startedAt, finishedAt: DateTime.now(), ); @@ -165,15 +170,17 @@ class _AccountSync implements _SyncLoop { await _emails.flushPendingChanges(account.id, password); final mailboxesSynced = await _mailboxes.syncMailboxes(account.id); final mailboxes = await _mailboxes.observeMailboxes(account.id).first; - var emailsFetched = 0; + var emailResult = SyncEmailsResult.zero; for (final mailbox in mailboxes) { if (!_running) break; - emailsFetched += await _emails.syncEmails(account.id, mailbox.path); + emailResult += await _emails.syncEmails(account.id, mailbox.path); } return _SyncStats( - emailsFetched: emailsFetched, + emailsFetched: emailResult.fetched, + emailsSkipped: emailResult.skipped, mailboxesSynced: mailboxesSynced, pendingFlushed: pendingFlushed, + bytesTransferred: emailResult.bytesTransferred, ); } @@ -267,8 +274,10 @@ class _JmapAccountSync implements _SyncLoop { success: true, protocol: 'jmap', emailsFetched: stats.emailsFetched, + emailsSkipped: stats.emailsSkipped, mailboxesSynced: stats.mailboxesSynced, pendingFlushed: stats.pendingFlushed, + bytesTransferred: stats.bytesTransferred, startedAt: startedAt, finishedAt: DateTime.now(), ); @@ -281,8 +290,10 @@ class _JmapAccountSync implements _SyncLoop { errorMessage: e.toString(), protocol: 'jmap', emailsFetched: 0, + emailsSkipped: 0, mailboxesSynced: 0, pendingFlushed: 0, + bytesTransferred: 0, startedAt: startedAt, finishedAt: DateTime.now(), ); @@ -307,15 +318,17 @@ class _JmapAccountSync implements _SyncLoop { final mailboxesSynced = await _mailboxes.syncMailboxes(account.id); final mailboxes = await _mailboxes.observeMailboxes(account.id).first; - var emailsFetched = 0; + var emailResult = SyncEmailsResult.zero; for (final mailbox in mailboxes) { if (!_running) break; - emailsFetched += await _emails.syncEmails(account.id, mailbox.path); + emailResult += await _emails.syncEmails(account.id, mailbox.path); } return _SyncStats( - emailsFetched: emailsFetched, + emailsFetched: emailResult.fetched, + emailsSkipped: emailResult.skipped, mailboxesSynced: mailboxesSynced, pendingFlushed: pendingFlushed, + bytesTransferred: emailResult.bytesTransferred, ); } @@ -359,11 +372,15 @@ class _JmapAccountSync implements _SyncLoop { class _SyncStats { const _SyncStats({ required this.emailsFetched, + required this.emailsSkipped, required this.mailboxesSynced, required this.pendingFlushed, + required this.bytesTransferred, }); final int emailsFetched; + final int emailsSkipped; final int mailboxesSynced; final int pendingFlushed; + final int bytesTransferred; } diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index 3b19c92..ca2a6e1 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -133,6 +133,8 @@ class SyncLogs extends Table { IntColumn get itemsSynced => integer().withDefault(const Constant(0))(); IntColumn get mailboxesSynced => integer().withDefault(const Constant(0))(); IntColumn get pendingFlushed => integer().withDefault(const Constant(0))(); + IntColumn get emailsSkipped => integer().withDefault(const Constant(0))(); + IntColumn get bytesTransferred => integer().withDefault(const Constant(0))(); DateTimeColumn get startedAt => dateTime()(); DateTimeColumn get finishedAt => dateTime()(); } @@ -169,7 +171,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 10; + int get schemaVersion => 11; @override MigrationStrategy get migration => MigrationStrategy( @@ -204,6 +206,10 @@ class AppDatabase extends _$AppDatabase { await m.addColumn(syncLogs, syncLogs.mailboxesSynced); await m.addColumn(syncLogs, syncLogs.pendingFlushed); } + if (from < 11) { + await m.addColumn(syncLogs, syncLogs.emailsSkipped); + await m.addColumn(syncLogs, syncLogs.bytesTransferred); + } }, ); } diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 1fd0645..07e006d 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -208,7 +208,10 @@ class EmailRepositoryImpl implements EmailRepository { // ── Sync ─────────────────────────────────────────────────────────────────── @override - Future syncEmails(String accountId, String mailboxPath) async { + Future syncEmails( + String accountId, + String mailboxPath, + ) async { final account = (await _accounts.getAccount(accountId))!; final password = await _accounts.getPassword(accountId); switch (account.type) { @@ -219,7 +222,7 @@ class EmailRepositoryImpl implements EmailRepository { } } - Future _syncEmailsImap( + Future _syncEmailsImap( account_model.Account account, String password, String mailboxPath, @@ -258,8 +261,9 @@ class EmailRepositoryImpl implements EmailRepository { .matchingSequence ?.toList() ?? []; + var bytes = 0; if (allUids.isNotEmpty) { - await _fetchAndUpsertImap( + bytes = await _fetchAndUpsertImap( client, account, mailboxPath, @@ -274,17 +278,26 @@ class EmailRepositoryImpl implements EmailRepository { maxUid, highestModSeq: serverModSeq, ); - return allUids.length; + return model.SyncEmailsResult( + fetched: allUids.length, + skipped: 0, + bytesTransferred: bytes, + ); } else { // Incremental sync. final lastUid = checkpoint['lastUid'] as int; final storedModSeq = checkpoint['highestModSeq'] as int?; + final totalOnServer = selectedMailbox.messagesExists; // CONDSTORE fast-path: nothing has changed on the server. if (serverModSeq != null && storedModSeq != null && serverModSeq == storedModSeq) { - return 0; + return model.SyncEmailsResult( + fetched: 0, + skipped: totalOnServer, + bytesTransferred: 0, + ); } // Fetch new messages. @@ -294,8 +307,9 @@ class EmailRepositoryImpl implements EmailRepository { .matchingSequence ?.toList() ?? []; + var bytes = 0; if (newUids.isNotEmpty) { - await _fetchAndUpsertImap( + bytes = await _fetchAndUpsertImap( client, account, mailboxPath, @@ -324,7 +338,11 @@ class EmailRepositoryImpl implements EmailRepository { maxUid, highestModSeq: serverModSeq, ); - return newUids.length; + return model.SyncEmailsResult( + fetched: newUids.length, + skipped: serverUids.length - newUids.length, + bytesTransferred: bytes, + ); } } finally { await client.logout(); @@ -358,7 +376,8 @@ class EmailRepositoryImpl implements EmailRepository { } } - Future _fetchAndUpsertImap( + // Returns the total bytes transferred (sum of RFC822.SIZE for each message). + Future _fetchAndUpsertImap( imap.ImapClient client, account_model.Account account, String mailboxPath, @@ -367,12 +386,13 @@ class EmailRepositoryImpl implements EmailRepository { final fetch = sequence.isUidSequence ? await client.uidFetchMessages( sequence, - '(UID FLAGS ENVELOPE BODYSTRUCTURE)', + '(UID FLAGS ENVELOPE BODYSTRUCTURE RFC822.SIZE)', ) : await client.fetchMessages( sequence, - '(UID FLAGS ENVELOPE BODYSTRUCTURE)', + '(UID FLAGS ENVELOPE BODYSTRUCTURE RFC822.SIZE)', ); + var bytes = 0; for (final msg in fetch.messages) { final envelope = msg.envelope; if (envelope == null) { @@ -384,6 +404,7 @@ class EmailRepositoryImpl implements EmailRepository { log('IMAP: skipping message with no uid (mailbox=$mailboxPath)'); continue; } + bytes += msg.size ?? 0; final emailId = '${account.id}:$uid'; await _db.into(_db.emails).insertOnConflictUpdate( EmailsCompanion.insert( @@ -403,6 +424,7 @@ class EmailRepositoryImpl implements EmailRepository { ), ); } + return bytes; } Future?> _loadImapCheckpoint( @@ -480,7 +502,7 @@ class EmailRepositoryImpl implements EmailRepository { 'fetchTextBodyValues': true, }; - Future _syncEmailsJmap( + Future _syncEmailsJmap( account_model.Account account, String password, String mailboxJmapId, @@ -506,7 +528,7 @@ class EmailRepositoryImpl implements EmailRepository { } } - Future _jmapFullEmailSync( + Future _jmapFullEmailSync( String accountId, JmapClient jmap, String mailboxJmapId, @@ -514,6 +536,7 @@ class EmailRepositoryImpl implements EmailRepository { int position = 0; String? firstState; var fetched = 0; + var bytes = 0; while (true) { final responses = await jmap.call([ @@ -550,7 +573,7 @@ class EmailRepositoryImpl implements EmailRepository { final getResult = _responseArgs(responses, 1, 'Email/get'); firstState ??= getResult['state'] as String; final list = getResult['list'] as List; - await _upsertJmapEmails(accountId, list); + bytes += await _upsertJmapEmails(accountId, list); fetched += list.length; position += ids.length; @@ -558,10 +581,14 @@ class EmailRepositoryImpl implements EmailRepository { } await _saveSyncState(accountId, 'Email', firstState); - return fetched; + return model.SyncEmailsResult( + fetched: fetched, + skipped: 0, + bytesTransferred: bytes, + ); } - Future _jmapIncrementalEmailSync( + Future _jmapIncrementalEmailSync( String accountId, JmapClient jmap, String sinceState, @@ -581,6 +608,7 @@ class EmailRepositoryImpl implements EmailRepository { final destroyed = List.from(changes['destroyed'] as List? ?? []); var fetched = 0; + var bytes = 0; final toFetch = [...created, ...updated]; if (toFetch.isNotEmpty) { final getResponses = await jmap.call([ @@ -597,7 +625,7 @@ class EmailRepositoryImpl implements EmailRepository { ]); final getResult = _responseArgs(getResponses, 0, 'Email/get'); final list = getResult['list'] as List; - await _upsertJmapEmails(accountId, list); + bytes += await _upsertJmapEmails(accountId, list); fetched += list.length; } @@ -608,14 +636,21 @@ class EmailRepositoryImpl implements EmailRepository { } await _saveSyncState(accountId, 'Email', newState); - return fetched; + return model.SyncEmailsResult( + fetched: fetched, + skipped: 0, + bytesTransferred: bytes, + ); } - Future _upsertJmapEmails(String accountId, List emails) async { + // Returns total bytes transferred (sum of JMAP `size` fields). + Future _upsertJmapEmails(String accountId, List emails) async { + var bytes = 0; for (final e in emails) { final m = e as Map; final jmapId = m['id'] as String; final dbId = '$accountId:$jmapId'; + bytes += (m['size'] as int?) ?? 0; // Use first mailbox ID as the primary mailboxPath. final mailboxIds = m['mailboxIds'] as Map?; @@ -662,6 +697,7 @@ class EmailRepositoryImpl implements EmailRepository { ); } } + return bytes; } /// Extracts text body, HTML body, and attachments JSON from a JMAP Email object diff --git a/lib/data/repositories/sync_log_repository_impl.dart b/lib/data/repositories/sync_log_repository_impl.dart index e114389..615816c 100644 --- a/lib/data/repositories/sync_log_repository_impl.dart +++ b/lib/data/repositories/sync_log_repository_impl.dart @@ -15,8 +15,10 @@ class SyncLogRepositoryImpl implements SyncLogRepository { String? errorMessage, required String protocol, required int emailsFetched, + required int emailsSkipped, required int mailboxesSynced, required int pendingFlushed, + required int bytesTransferred, required DateTime startedAt, required DateTime finishedAt, }) async { @@ -27,8 +29,10 @@ class SyncLogRepositoryImpl implements SyncLogRepository { errorMessage: Value(errorMessage), protocol: Value(protocol), itemsSynced: Value(emailsFetched), + emailsSkipped: Value(emailsSkipped), mailboxesSynced: Value(mailboxesSynced), pendingFlushed: Value(pendingFlushed), + bytesTransferred: Value(bytesTransferred), startedAt: startedAt, finishedAt: finishedAt, ), @@ -51,8 +55,10 @@ class SyncLogRepositoryImpl implements SyncLogRepository { errorMessage: r.errorMessage, protocol: r.protocol, emailsFetched: r.itemsSynced, + emailsSkipped: r.emailsSkipped, mailboxesSynced: r.mailboxesSynced, pendingFlushed: r.pendingFlushed, + bytesTransferred: r.bytesTransferred, startedAt: r.startedAt, finishedAt: r.finishedAt, ), diff --git a/lib/ui/screens/sync_log_screen.dart b/lib/ui/screens/sync_log_screen.dart index e026df0..b99339a 100644 --- a/lib/ui/screens/sync_log_screen.dart +++ b/lib/ui/screens/sync_log_screen.dart @@ -7,6 +7,13 @@ import '../../di.dart'; final _timeFmt = DateFormat('MMM d, HH:mm:ss'); +String _fmtBytes(int bytes) { + if (bytes <= 0) return '0 B'; + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; +} + class SyncLogScreen extends ConsumerWidget { const SyncLogScreen({super.key, required this.accountId}); @@ -63,7 +70,7 @@ class _SyncLogTile extends StatelessWidget { ), subtitle: Text( entry.isOk - ? '${entry.emailsFetched} emails · ${entry.mailboxesSynced} mailboxes · took $durationLabel' + ? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel' : 'Error · took $durationLabel', style: TextStyle( fontSize: 12, @@ -82,8 +89,10 @@ class _SyncLogTile extends StatelessWidget { if (entry.protocol.isNotEmpty) _row('Protocol', entry.protocol.toUpperCase()), _row('Emails fetched', '${entry.emailsFetched}'), + _row('Emails up-to-date', '${entry.emailsSkipped}'), _row('Mailboxes synced', '${entry.mailboxesSynced}'), _row('Pending changes flushed', '${entry.pendingFlushed}'), + _row('Data transferred', _fmtBytes(entry.bytesTransferred)), if (entry.errorMessage != null) Padding( padding: const EdgeInsets.only(top: 4), diff --git a/test/integration/account_sync_manager_test.dart b/test/integration/account_sync_manager_test.dart index f1265c4..5ed8823 100644 --- a/test/integration/account_sync_manager_test.dart +++ b/test/integration/account_sync_manager_test.dart @@ -71,7 +71,8 @@ class _FakeEmails implements EmailRepository { const EmailBody(emailId: '', attachments: []); @override - Future syncEmails(String a, String m) async => 0; + Future syncEmails(String a, String m) async => + SyncEmailsResult.zero; @override Future setFlag(String id, {bool? seen, bool? flagged}) async {} diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index 3df87f8..443609a 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -71,7 +71,11 @@ class FakeEmailRepository implements EmailRepository { const EmailBody(emailId: '', attachments: []); @override - Future syncEmails(String accountId, String mailboxPath) async => 0; + Future syncEmails( + String accountId, + String mailboxPath, + ) async => + SyncEmailsResult.zero; @override Future setFlag(String emailId, {bool? seen, bool? flagged}) async {} @@ -157,9 +161,12 @@ class _CountingEmailRepository extends FakeEmailRepository { final List syncedPaths = []; @override - Future syncEmails(String accountId, String mailboxPath) async { + Future syncEmails( + String accountId, + String mailboxPath, + ) async { syncedPaths.add(mailboxPath); - return 0; + return SyncEmailsResult.zero; } } @@ -167,10 +174,13 @@ class FailingJmapEmailRepository extends FakeEmailRepository { int syncCount = 0; @override - Future syncEmails(String accountId, String mailboxPath) async { + Future syncEmails( + String accountId, + String mailboxPath, + ) async { syncCount++; if (syncCount == 1) throw Exception('simulated JMAP failure'); - return 0; + return SyncEmailsResult.zero; } } diff --git a/test/unit/sync_log_repository_impl_test.dart b/test/unit/sync_log_repository_impl_test.dart index 9e6a4b8..3b9d33c 100644 --- a/test/unit/sync_log_repository_impl_test.dart +++ b/test/unit/sync_log_repository_impl_test.dart @@ -37,8 +37,10 @@ void main() { success: true, protocol: 'imap', emailsFetched: 5, + emailsSkipped: 0, mailboxesSynced: 3, pendingFlushed: 0, + bytesTransferred: 0, startedAt: start, finishedAt: end, ); @@ -65,8 +67,10 @@ void main() { errorMessage: 'Connection refused', protocol: 'imap', emailsFetched: 0, + emailsSkipped: 0, mailboxesSynced: 0, pendingFlushed: 0, + bytesTransferred: 0, startedAt: start, finishedAt: end, ); diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 78d10e2..0eea50b 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -156,7 +156,11 @@ class FakeEmailRepository implements EmailRepository { Future getEmailBody(String emailId) async => _emailBody; @override - Future syncEmails(String accountId, String mailboxPath) async => 0; + Future syncEmails( + String accountId, + String mailboxPath, + ) async => + SyncEmailsResult.zero; @override Future setFlag(String emailId, {bool? seen, bool? flagged}) async {}