feat: extend sync log with skipped count and bytes transferred

Track how many emails were already up-to-date (skipped) and the
approximate bytes transferred per sync cycle. SyncEmailsResult
accumulates fetched/skipped/bytes across mailboxes; DB schema v11
adds emailsSkipped and bytesTransferred columns to sync_logs.
SyncLogScreen shows "X new · Y up-to-date · took Zs" in the tile
subtitle with full detail rows in the expansion panel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Güttler
2026-04-21 11:32:50 +02:00
co-authored by Claude Sonnet 4.6
parent 0435129434
commit 1ab915d73a
12 changed files with 156 additions and 34 deletions
+21
View File
@@ -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,
);
}
+1 -1
View File
@@ -4,7 +4,7 @@ abstract class EmailRepository {
Stream<List<Email>> observeEmails(String accountId, String mailboxPath);
Future<Email?> getEmail(String emailId);
Future<EmailBody> getEmailBody(String emailId);
Future<int> syncEmails(String accountId, String mailboxPath);
Future<SyncEmailsResult> syncEmails(String accountId, String mailboxPath);
Future<void> setFlag(
String emailId, {
bool? seen,
@@ -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 {}
+23 -6
View File
@@ -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;
}
+7 -1
View File
@@ -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);
}
},
);
}
@@ -208,7 +208,10 @@ class EmailRepositoryImpl implements EmailRepository {
// ── Sync ───────────────────────────────────────────────────────────────────
@override
Future<int> syncEmails(String accountId, String mailboxPath) async {
Future<model.SyncEmailsResult> 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<int> _syncEmailsImap(
Future<model.SyncEmailsResult> _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<void> _fetchAndUpsertImap(
// Returns the total bytes transferred (sum of RFC822.SIZE for each message).
Future<int> _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<Map<String, dynamic>?> _loadImapCheckpoint(
@@ -480,7 +502,7 @@ class EmailRepositoryImpl implements EmailRepository {
'fetchTextBodyValues': true,
};
Future<int> _syncEmailsJmap(
Future<model.SyncEmailsResult> _syncEmailsJmap(
account_model.Account account,
String password,
String mailboxJmapId,
@@ -506,7 +528,7 @@ class EmailRepositoryImpl implements EmailRepository {
}
}
Future<int> _jmapFullEmailSync(
Future<model.SyncEmailsResult> _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<dynamic>;
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<int> _jmapIncrementalEmailSync(
Future<model.SyncEmailsResult> _jmapIncrementalEmailSync(
String accountId,
JmapClient jmap,
String sinceState,
@@ -581,6 +608,7 @@ class EmailRepositoryImpl implements EmailRepository {
final destroyed = List<String>.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<dynamic>;
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<void> _upsertJmapEmails(String accountId, List<dynamic> emails) async {
// Returns total bytes transferred (sum of JMAP `size` fields).
Future<int> _upsertJmapEmails(String accountId, List<dynamic> emails) async {
var bytes = 0;
for (final e in emails) {
final m = e as Map<String, dynamic>;
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<String, dynamic>?;
@@ -662,6 +697,7 @@ class EmailRepositoryImpl implements EmailRepository {
);
}
}
return bytes;
}
/// Extracts text body, HTML body, and attachments JSON from a JMAP Email object
@@ -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,
),
+10 -1
View File
@@ -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),
@@ -71,7 +71,8 @@ class _FakeEmails implements EmailRepository {
const EmailBody(emailId: '', attachments: []);
@override
Future<int> syncEmails(String a, String m) async => 0;
Future<SyncEmailsResult> syncEmails(String a, String m) async =>
SyncEmailsResult.zero;
@override
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
+15 -5
View File
@@ -71,7 +71,11 @@ class FakeEmailRepository implements EmailRepository {
const EmailBody(emailId: '', attachments: []);
@override
Future<int> syncEmails(String accountId, String mailboxPath) async => 0;
Future<SyncEmailsResult> syncEmails(
String accountId,
String mailboxPath,
) async =>
SyncEmailsResult.zero;
@override
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {}
@@ -157,9 +161,12 @@ class _CountingEmailRepository extends FakeEmailRepository {
final List<String> syncedPaths = [];
@override
Future<int> syncEmails(String accountId, String mailboxPath) async {
Future<SyncEmailsResult> 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<int> syncEmails(String accountId, String mailboxPath) async {
Future<SyncEmailsResult> syncEmails(
String accountId,
String mailboxPath,
) async {
syncCount++;
if (syncCount == 1) throw Exception('simulated JMAP failure');
return 0;
return SyncEmailsResult.zero;
}
}
@@ -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,
);
+5 -1
View File
@@ -156,7 +156,11 @@ class FakeEmailRepository implements EmailRepository {
Future<EmailBody> getEmailBody(String emailId) async => _emailBody;
@override
Future<int> syncEmails(String accountId, String mailboxPath) async => 0;
Future<SyncEmailsResult> syncEmails(
String accountId,
String mailboxPath,
) async =>
SyncEmailsResult.zero;
@override
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {}