diff --git a/LATER.md b/LATER.md index c41fae0..440093a 100644 --- a/LATER.md +++ b/LATER.md @@ -1,6 +1,10 @@ # Later -Push to guettli@thomas-guettler via ssh+git +LINTING.md + +--- + +Sieve: JMAP, easy. Per IMAP... --- diff --git a/lib/core/models/email.dart b/lib/core/models/email.dart index f7bac52..c457f9c 100644 --- a/lib/core/models/email.dart +++ b/lib/core/models/email.dart @@ -74,6 +74,29 @@ class EmailAttachment { }); } +/// A pending local mutation (flag, move, delete) that has failed at least once +/// and may be stuck in the outbound queue. +class FailedMutation { + final int id; + final String accountId; + /// "flag_seen" | "flag_flagged" | "move" | "delete" + final String changeType; + final String resourceId; + final String lastError; + final int attempts; + final DateTime createdAt; + + const FailedMutation({ + required this.id, + required this.accountId, + required this.changeType, + required this.resourceId, + required this.lastError, + required this.attempts, + required this.createdAt, + }); +} + /// Outgoing email — used for compose / reply. class EmailDraft { final EmailAddress from; diff --git a/lib/core/repositories/email_repository.dart b/lib/core/repositories/email_repository.dart index 0f76adc..ce01ee4 100644 --- a/lib/core/repositories/email_repository.dart +++ b/lib/core/repositories/email_repository.dart @@ -31,6 +31,17 @@ abstract class EmailRepository { /// No-op for IMAP accounts (mutations are applied synchronously). Future flushPendingChanges(String accountId, String password); + /// Emits the list of pending mutations that have failed at least once for + /// [accountId]. Updates live whenever the queue changes. + Stream> observeFailedMutations(String accountId); + + /// Permanently removes the pending mutation with [id] from the queue. + Future discardMutation(int id); + + /// Resets the attempt counter for mutation [id] so the next sync cycle + /// retries it. + Future retryMutation(int id); + /// Returns a stream that emits once for each JMAP push event (RFC 8887 /// `StateChange`) received from the server's EventSource URL. /// diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index 5c80475..2582800 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -77,6 +77,9 @@ class EmailBodies extends Table { // JSON-encoded List<{filename,contentType,size}> TextColumn get attachmentsJson => text().withDefault(const Constant('[]'))(); + // Added in schema v9: when the body was last fetched from the server. + // Null for rows cached before this column was added (treated as expired). + DateTimeColumn get cachedAt => dateTime().nullable()(); @override Set get primaryKey => {emailId}; @@ -152,7 +155,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 8; + int get schemaVersion => 9; @override MigrationStrategy get migration => MigrationStrategy( @@ -179,6 +182,9 @@ class AppDatabase extends _$AppDatabase { if (from < 8) { await m.addColumn(mailboxes, mailboxes.role); } + if (from < 9) { + await m.addColumn(emailBodies, emailBodies.cachedAt); + } }, ); } diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index a692e7e..a5fe407 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -74,12 +74,20 @@ class EmailRepositoryImpl implements EmailRepository { // ── Body (on-demand) ─────────────────────────────────────────────────────── + static const _bodyCacheTtl = Duration(days: 7); + @override Future getEmailBody(String emailId) async { final cached = await (_db.select(_db.emailBodies) ..where((t) => t.emailId.equals(emailId))) .getSingleOrNull(); - if (cached != null) return _bodyRowToModel(cached); + if (cached != null) { + // Re-fetch if cachedAt is null (legacy row) or older than the TTL. + final age = cached.cachedAt == null + ? _bodyCacheTtl + const Duration(seconds: 1) + : DateTime.now().difference(cached.cachedAt!); + if (age <= _bodyCacheTtl) return _bodyRowToModel(cached); + } final emailRow = await (_db.select(_db.emails) ..where((t) => t.id.equals(emailId))) @@ -120,6 +128,7 @@ class EmailRepositoryImpl implements EmailRepository { textBody: Value(textBody), htmlBody: Value(htmlBody), attachmentsJson: Value(attachmentsJson), + cachedAt: Value(DateTime.now()), ), ); return model.EmailBody( @@ -182,6 +191,7 @@ class EmailRepositoryImpl implements EmailRepository { textBody: Value(textBody), htmlBody: Value(htmlBody), attachmentsJson: Value(attachmentsJson), + cachedAt: Value(DateTime.now()), ), ); @@ -215,8 +225,12 @@ class EmailRepositoryImpl implements EmailRepository { final client = await _imapConnect(account, _effectiveUsername(account), password); try { - final selectedMailbox = await client.selectMailboxByPath(mailboxPath); + // Enable CONDSTORE so the server returns HIGHESTMODSEQ in SELECT and + // honours CHANGEDSINCE modifiers on FETCH (RFC 7162). + final selectedMailbox = await client.selectMailboxByPath( + mailboxPath, enableCondStore: true); final uidValidity = selectedMailbox.uidValidity ?? 0; + final serverModSeq = selectedMailbox.highestModSequence; final resourceType = 'IMAP:$mailboxPath'; final checkpoint = await _loadImapCheckpoint(account.id, resourceType); @@ -234,19 +248,38 @@ class EmailRepositoryImpl implements EmailRepository { client, account, mailboxPath, imap.MessageSequence.fromAll()); final maxUid = await _maxLocalUid(account.id, mailboxPath); await _saveImapCheckpoint( - account.id, resourceType, uidValidity, maxUid); + account.id, resourceType, uidValidity, maxUid, + highestModSeq: serverModSeq); } else { // Incremental sync. final lastUid = checkpoint['lastUid'] as int; + final storedModSeq = checkpoint['highestModSeq'] as int?; + + // CONDSTORE fast-path: nothing has changed on the server. + if (serverModSeq != null && + storedModSeq != null && + serverModSeq == storedModSeq) { + return; + } + + // Fetch new messages. final newUids = - (await client.uidSearchMessages(searchCriteria: 'UID ${lastUid + 1}:*')) - .matchingSequence - ?.toList() ?? - []; + (await client.uidSearchMessages( + searchCriteria: 'UID ${lastUid + 1}:*')) + .matchingSequence + ?.toList() ?? + []; if (newUids.isNotEmpty) { await _fetchAndUpsertImap(client, account, mailboxPath, imap.MessageSequence.fromIds(newUids, isUid: true)); } + + // CONDSTORE flag update: refresh flags only for messages that changed. + if (serverModSeq != null && storedModSeq != null) { + await _refreshFlagsImap( + client, account, mailboxPath, storedModSeq); + } + // Detect remote deletions. final serverUids = (await client.uidSearchMessages(searchCriteria: 'ALL')) @@ -257,13 +290,40 @@ class EmailRepositoryImpl implements EmailRepository { final maxUid = serverUids.isEmpty ? lastUid : serverUids.reduce(math.max); await _saveImapCheckpoint( - account.id, resourceType, uidValidity, maxUid); + account.id, resourceType, uidValidity, maxUid, + highestModSeq: serverModSeq); } } finally { await client.logout(); } } + /// Fetches FLAGS for all messages modified since [sinceModSeq] and updates + /// the local DB. Only messages whose modseq is > [sinceModSeq] are returned + /// by the server (RFC 7162 §3.2). + Future _refreshFlagsImap( + imap.ImapClient client, + account_model.Account account, + String mailboxPath, + int sinceModSeq, + ) async { + final result = await client.uidFetchMessages( + imap.MessageSequence.fromAll(), + 'FLAGS', + changedSinceModSequence: sinceModSeq, + ); + for (final msg in result.messages) { + final uid = msg.uid; + if (uid == null) continue; + final emailId = '${account.id}:$uid'; + await (_db.update(_db.emails)..where((t) => t.id.equals(emailId))) + .write(EmailsCompanion( + isSeen: Value(msg.flags?.contains(r'\Seen') ?? false), + isFlagged: Value(msg.flags?.contains(r'\Flagged') ?? false), + )); + } + } + Future _fetchAndUpsertImap( imap.ImapClient client, account_model.Account account, @@ -315,10 +375,19 @@ class EmailRepositoryImpl implements EmailRepository { return jsonDecode(raw) as Map; } - Future _saveImapCheckpoint(String accountId, String resourceType, - int uidValidity, int lastUid) async { - await _saveSyncState(accountId, resourceType, - jsonEncode({'uidValidity': uidValidity, 'lastUid': lastUid})); + Future _saveImapCheckpoint( + String accountId, + String resourceType, + int uidValidity, + int lastUid, { + int? highestModSeq, + }) async { + final data = { + 'uidValidity': uidValidity, + 'lastUid': lastUid, + }; + if (highestModSeq != null) data['highestModSeq'] = highestModSeq; + await _saveSyncState(accountId, resourceType, jsonEncode(data)); } Future _reconcileDeletedImap( @@ -516,6 +585,7 @@ class EmailRepositoryImpl implements EmailRepository { textBody: Value(textBody), htmlBody: Value(htmlBody), attachmentsJson: Value(attachmentsJson), + cachedAt: Value(DateTime.now()), ), ); } @@ -1422,4 +1492,41 @@ class EmailRepositoryImpl implements EmailRepository { ) .toList(); } + + // ── Failed mutations (offline compose queue) ───────────────────────────── + + @override + Stream> observeFailedMutations( + String accountId) { + return (_db.select(_db.pendingChanges) + ..where((t) => + t.accountId.equals(accountId) & t.lastError.isNotNull()) + ..orderBy([(t) => OrderingTerm.asc(t.createdAt)])) + .watch() + .map((rows) => rows + .map((r) => model.FailedMutation( + id: r.id, + accountId: r.accountId, + changeType: r.changeType, + resourceId: r.resourceId, + lastError: r.lastError!, + attempts: r.attempts, + createdAt: r.createdAt, + )) + .toList()); + } + + @override + Future discardMutation(int id) async { + await (_db.delete(_db.pendingChanges)..where((t) => t.id.equals(id))).go(); + } + + @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), + )); + } } diff --git a/lib/ui/screens/mailbox_list_screen.dart b/lib/ui/screens/mailbox_list_screen.dart index 99a68a5..2fafc59 100644 --- a/lib/ui/screens/mailbox_list_screen.dart +++ b/lib/ui/screens/mailbox_list_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../core/models/email.dart'; +import '../../core/repositories/email_repository.dart'; import '../../di.dart'; class MailboxListScreen extends ConsumerWidget { @@ -10,33 +12,122 @@ class MailboxListScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final repo = ref.watch(mailboxRepositoryProvider); + final mailboxRepo = ref.watch(mailboxRepositoryProvider); + final emailRepo = ref.watch(emailRepositoryProvider); return Scaffold( appBar: AppBar(title: const Text('Mailboxes')), - body: StreamBuilder( - stream: repo.observeMailboxes(accountId), - builder: (ctx, snap) { - if (!snap.hasData) { - return const Center(child: CircularProgressIndicator()); - } - final mailboxes = snap.data!; - return ListView.builder( - itemCount: mailboxes.length, - itemBuilder: (ctx, i) { - final mb = mailboxes[i]; - return ListTile( - leading: const Icon(Icons.folder), - title: Text(mb.name), - trailing: mb.unreadCount > 0 - ? Badge(label: Text('${mb.unreadCount}')) - : null, - onTap: () => context.push( - '/accounts/$accountId/mailboxes/${Uri.encodeComponent(mb.path)}/emails', - ), + body: Column( + children: [ + // ── Failed-mutation banner ─────────────────────────────────────── + StreamBuilder>( + stream: emailRepo.observeFailedMutations(accountId), + builder: (ctx, snap) { + final mutations = snap.data ?? []; + if (mutations.isEmpty) return const SizedBox.shrink(); + return _FailedMutationBanner( + mutations: mutations, + emailRepo: emailRepo, ); }, - ); - }, + ), + // ── Mailbox list ───────────────────────────────────────────────── + Expanded( + child: StreamBuilder( + stream: mailboxRepo.observeMailboxes(accountId), + builder: (ctx, snap) { + if (!snap.hasData) { + return const Center(child: CircularProgressIndicator()); + } + final mailboxes = snap.data!; + return ListView.builder( + itemCount: mailboxes.length, + itemBuilder: (ctx, i) { + final mb = mailboxes[i]; + return ListTile( + leading: const Icon(Icons.folder), + title: Text(mb.name), + trailing: mb.unreadCount > 0 + ? Badge(label: Text('${mb.unreadCount}')) + : null, + onTap: () => context.push( + '/accounts/$accountId/mailboxes/${Uri.encodeComponent(mb.path)}/emails', + ), + ); + }, + ); + }, + ), + ), + ], + ), + ); + } +} + +class _FailedMutationBanner extends StatelessWidget { + const _FailedMutationBanner({ + required this.mutations, + required this.emailRepo, + }); + + final List mutations; + final EmailRepository emailRepo; + + String _label(FailedMutation m) { + final noun = switch (m.changeType) { + 'flag_seen' || 'flag_flagged' => 'flag change', + 'move' => 'move', + 'delete' => 'deletion', + _ => 'change', + }; + return '${mutations.length} pending $noun${mutations.length > 1 ? 's' : ''} failed'; + } + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(Icons.warning_amber, + color: Theme.of(context).colorScheme.onErrorContainer, + size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + _label(mutations.first), + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer), + ), + ), + TextButton( + onPressed: () async { + for (final m in mutations) { + await emailRepo.retryMutation(m.id); + } + }, + child: Text( + 'Retry', + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer), + ), + ), + TextButton( + onPressed: () async { + for (final m in mutations) { + await emailRepo.discardMutation(m.id); + } + }, + child: Text( + 'Discard', + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer), + ), + ), + ], + ), ), ); } diff --git a/test/integration/account_sync_manager_test.dart b/test/integration/account_sync_manager_test.dart index d0801ce..abc17c7 100644 --- a/test/integration/account_sync_manager_test.dart +++ b/test/integration/account_sync_manager_test.dart @@ -99,6 +99,16 @@ class _FakeEmails implements EmailRepository { @override Stream watchJmapPush(String accountId, String password) => const Stream.empty(); + + @override + Stream> observeFailedMutations(String accountId) => + Stream.value([]); + + @override + Future discardMutation(int id) async {} + + @override + Future retryMutation(int id) async {} } // ── Tests ───────────────────────────────────────────────────────────────────── diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index e39c24e..cb73fa5 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -104,6 +104,16 @@ class FakeEmailRepository implements EmailRepository { @override Stream watchJmapPush(String accountId, String password) => const Stream.empty(); + + @override + Stream> observeFailedMutations(String accountId) => + Stream.value([]); + + @override + Future discardMutation(int id) async {} + + @override + Future retryMutation(int id) async {} } // ── Helpers ─────────────────────────────────────────────────────────────────── diff --git a/test/unit/email_repository_impl_test.dart b/test/unit/email_repository_impl_test.dart index 710abc7..6e8ac69 100644 --- a/test/unit/email_repository_impl_test.dart +++ b/test/unit/email_repository_impl_test.dart @@ -318,6 +318,7 @@ void main() { emailId: 'acc-1:1', textBody: const Value('Hello'), htmlBody: const Value('

Hello

'), + cachedAt: Value(DateTime.now()), ), ); @@ -1797,6 +1798,264 @@ void main() { await sseController.close(); }); }); + + // ── CONDSTORE tests ────────────────────────────────────────────────────────── + + group('CONDSTORE', () { + test('fast-path: skips search/fetch when modseq is unchanged', () async { + final r = _makeReposWithFakes(); + await r.accounts.addAccount(_account, 'pw'); + r.fakeImap.uidValidityResult = 1000; + r.fakeImap.highestModSequenceResult = 42; + await r.db.into(r.db.syncStates).insertOnConflictUpdate( + SyncStatesCompanion.insert( + accountId: 'acc-1', + resourceType: 'IMAP:INBOX', + state: jsonEncode( + {'uidValidity': 1000, 'lastUid': 5, 'highestModSeq': 42}), + syncedAt: DateTime.now(), + ), + ); + + await r.emails.syncEmails('acc-1', 'INBOX'); + + // No search or fetch calls because modseq is unchanged. + expect(r.fakeImap.uidFetchMessagesCalls, 0); + expect(r.fakeImap.logoutCalled, isTrue); + }); + + test('flag refresh: calls uidFetchMessages with changedSince when modseq changes', + () async { + final r = _makeReposWithFakes(); + await r.accounts.addAccount(_account, 'pw'); + r.fakeImap.uidValidityResult = 1000; + r.fakeImap.highestModSequenceResult = 55; // server advanced from 42 → 55 + await r.db.into(r.db.syncStates).insertOnConflictUpdate( + SyncStatesCompanion.insert( + accountId: 'acc-1', + resourceType: 'IMAP:INBOX', + state: jsonEncode( + {'uidValidity': 1000, 'lastUid': 5, 'highestModSeq': 42}), + syncedAt: DateTime.now(), + ), + ); + // No new UIDs; server returns [5] for both UID search calls. + r.fakeImap.searchCallQueue = [[], [5]]; + + await r.emails.syncEmails('acc-1', 'INBOX'); + + expect(r.fakeImap.uidFetchMessagesCalls, 1); + expect(r.fakeImap.lastChangedSinceModSequence, 42); + + // Checkpoint updated with new modseq. + final state = jsonDecode( + (await r.db.select(r.db.syncStates).get()).first.state) + as Map; + expect(state['highestModSeq'], 55); + }); + + test('flag refresh: updates flags in local DB', () async { + final r = _makeReposWithFakes(); + 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), + )); + r.fakeImap.uidValidityResult = 1000; + r.fakeImap.highestModSequenceResult = 55; + await r.db.into(r.db.syncStates).insertOnConflictUpdate( + SyncStatesCompanion.insert( + accountId: 'acc-1', + resourceType: 'IMAP:INBOX', + state: jsonEncode( + {'uidValidity': 1000, 'lastUid': 5, 'highestModSeq': 42}), + syncedAt: DateTime.now(), + ), + ); + r.fakeImap.searchCallQueue = [[], [5]]; + // Server says uid=5 is now \Seen. + r.fakeImap.uidFetchResults = [ + buildEnvelopeMessage(uid: 5, flags: [r'\Seen']), + ]; + + await r.emails.syncEmails('acc-1', 'INBOX'); + + final email = await r.emails.getEmail('acc-1:5'); + expect(email!.isSeen, isTrue); + }); + }); + + // ── Blob expiry (TTL) tests ─────────────────────────────────────────────────── + + group('blob expiry', () { + test('returns cached body when cachedAt is recent', () async { + final r = _makeReposWithFakes(); + await r.accounts.addAccount(_account, 'pw'); + await r.db.into(r.db.emails).insert(EmailsCompanion.insert( + id: 'acc-1:1', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 1, + receivedAt: DateTime(2024), + )); + await r.db.into(r.db.emailBodies).insertOnConflictUpdate( + EmailBodiesCompanion.insert( + emailId: 'acc-1:1', + textBody: const Value('cached text'), + cachedAt: Value(DateTime.now()), + ), + ); + + final body = await r.emails.getEmailBody('acc-1:1'); + + expect(body.textBody, 'cached text'); + expect(r.fakeImap.logoutCalled, isFalse); + }); + + test('re-fetches body when cachedAt is null (legacy row)', () async { + final r = _makeReposWithFakes(); + await r.accounts.addAccount(_account, 'pw'); + await r.db.into(r.db.emails).insert(EmailsCompanion.insert( + id: 'acc-1:1', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 1, + receivedAt: DateTime(2024), + )); + await r.db.into(r.db.emailBodies).insertOnConflictUpdate( + EmailBodiesCompanion.insert( + emailId: 'acc-1:1', + textBody: const Value('stale text'), + // cachedAt omitted → null + ), + ); + + final msg = imap.MimeMessage.parseFromText( + 'Subject: Hi\r\nContent-Type: text/plain\r\n\r\nfresh from IMAP', + ); + msg.uid = 1; + r.fakeImap.fetchResults = [msg]; + + final body = await r.emails.getEmailBody('acc-1:1'); + + expect(body.textBody, contains('fresh from IMAP')); + expect(r.fakeImap.logoutCalled, isTrue); + }); + + test('re-fetches body when cachedAt is older than 7 days', () async { + final r = _makeReposWithFakes(); + await r.accounts.addAccount(_account, 'pw'); + await r.db.into(r.db.emails).insert(EmailsCompanion.insert( + id: 'acc-1:1', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 1, + receivedAt: DateTime(2024), + )); + await r.db.into(r.db.emailBodies).insertOnConflictUpdate( + EmailBodiesCompanion.insert( + emailId: 'acc-1:1', + textBody: const Value('old text'), + cachedAt: Value(DateTime.now().subtract(const Duration(days: 8))), + ), + ); + + final msg = imap.MimeMessage.parseFromText( + 'Subject: Hi\r\nContent-Type: text/plain\r\n\r\nnew body', + ); + msg.uid = 1; + r.fakeImap.fetchResults = [msg]; + + final body = await r.emails.getEmailBody('acc-1:1'); + + expect(body.textBody, contains('new body')); + expect(r.fakeImap.logoutCalled, isTrue); + }); + }); + + // ── Failed mutations tests ──────────────────────────────────────────────────── + + group('failed mutations', () { + 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(PendingChangesCompanion.insert( + accountId: 'acc-1', + resourceType: 'email', + resourceId: 'acc-1:10', + changeType: 'flag_seen', + payload: '{"seen":true}', + createdAt: DateTime.now(), + attempts: const Value(1), + lastError: const Value('network error'), + )); + await r.db.into(r.db.pendingChanges).insert(PendingChangesCompanion.insert( + accountId: 'acc-1', + resourceType: 'email', + resourceId: 'acc-1:11', + changeType: 'move', + payload: '{"dest":"Archive"}', + createdAt: DateTime.now(), + // lastError not set → pending, not failed + )); + + final mutations = + await r.emails.observeFailedMutations('acc-1').first; + + expect(mutations, hasLength(1)); + expect(mutations.first.resourceId, 'acc-1:10'); + expect(mutations.first.changeType, 'flag_seen'); + expect(mutations.first.lastError, 'network error'); + }); + + 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( + PendingChangesCompanion.insert( + accountId: 'acc-1', + resourceType: 'email', + resourceId: 'acc-1:10', + changeType: 'delete', + payload: '{}', + createdAt: DateTime.now(), + attempts: const Value(3), + lastError: const Value('timeout'), + ), + ); + + await r.emails.discardMutation(rowId); + + final rows = await r.db.select(r.db.pendingChanges).get(); + expect(rows, isEmpty); + }); + + 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( + PendingChangesCompanion.insert( + accountId: 'acc-1', + resourceType: 'email', + resourceId: 'acc-1:10', + changeType: 'move', + payload: '{"dest":"Trash"}', + createdAt: DateTime.now(), + attempts: const Value(5), + lastError: const Value('connection refused'), + ), + ); + + await r.emails.retryMutation(rowId); + + final row = (await r.db.select(r.db.pendingChanges).get()).first; + expect(row.attempts, 0); + expect(row.lastError, isNull); + }); + }); } // ── SSE test helper ────────────────────────────────────────────────────────── diff --git a/test/unit/fake_imap.dart b/test/unit/fake_imap.dart index 402cde7..c532a89 100644 --- a/test/unit/fake_imap.dart +++ b/test/unit/fake_imap.dart @@ -6,12 +6,14 @@ class FakeImapClient extends imap.ImapClient { FakeImapClient() : super(); List fetchResults = []; + List uidFetchResults = []; List listMailboxesResult = []; List searchUids = []; /// If set, each [uidSearchMessages] call pops the first element. /// Falls back to [searchUids] when the queue is empty or null. List>? searchCallQueue; int uidValidityResult = 0; + int? highestModSequenceResult; bool logoutCalled = false; bool throwOnStatus = false; int markSeenCalls = 0; @@ -24,6 +26,8 @@ class FakeImapClient extends imap.ImapClient { int appendCalls = 0; String? lastAppendMailboxPath; int createMailboxCalls = 0; + int uidFetchMessagesCalls = 0; + int? lastChangedSinceModSequence; @override Future selectMailboxByPath( @@ -37,8 +41,21 @@ class FakeImapClient extends imap.ImapClient { flags: [], pathSeparator: '/', uidValidity: uidValidityResult, + highestModSequence: highestModSequenceResult, ); + @override + Future uidFetchMessages( + imap.MessageSequence sequence, + String? fetchContentDefinition, { + int? changedSinceModSequence, + Duration? responseTimeout, + }) async { + uidFetchMessagesCalls++; + lastChangedSinceModSequence = changedSinceModSequence; + return imap.FetchImapResult(List.of(uidFetchResults), null); + } + @override Future fetchMessages( imap.MessageSequence sequence, diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index c00f76e..141a1a9 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -190,6 +190,16 @@ class FakeEmailRepository implements EmailRepository { @override Stream watchJmapPush(String accountId, String password) => const Stream.empty(); + + @override + Stream> observeFailedMutations(String accountId) => + Stream.value([]); + + @override + Future discardMutation(int id) async {} + + @override + Future retryMutation(int id) async {} } // ---------------------------------------------------------------------------