diff --git a/lib/core/repositories/email_repository.dart b/lib/core/repositories/email_repository.dart index 0314fab..4feefb8 100644 --- a/lib/core/repositories/email_repository.dart +++ b/lib/core/repositories/email_repository.dart @@ -99,4 +99,9 @@ abstract class EmailRepository { String accountId, String mailboxPath, ); + + /// Deletes all locally-cached email rows and pending changes for [accountId], + /// while preserving EmailBodies so already-downloaded content is not lost. + /// Also resets sync-state checkpoints so the next sync fetches everything fresh. + Future clearForResync(String accountId); } diff --git a/lib/core/repositories/mailbox_repository.dart b/lib/core/repositories/mailbox_repository.dart index 9ad09e5..58e4a4e 100644 --- a/lib/core/repositories/mailbox_repository.dart +++ b/lib/core/repositories/mailbox_repository.dart @@ -8,4 +8,7 @@ abstract class MailboxRepository { /// Returns the first mailbox with the given [role] for [accountId], or null. Future findMailboxByRole(String accountId, String role); + + /// Deletes all locally-cached mailbox rows for [accountId]. + Future clearForResync(String accountId); } diff --git a/lib/core/sync/account_sync_manager.dart b/lib/core/sync/account_sync_manager.dart index 53dd258..5cf87c0 100644 --- a/lib/core/sync/account_sync_manager.dart +++ b/lib/core/sync/account_sync_manager.dart @@ -88,6 +88,43 @@ class AccountSyncManager { void syncNow(String accountId) { _active[accountId]?.kick(); } + + /// Clears all locally-cached emails and mailboxes for [accountId], then + /// immediately starts a fresh sync cycle. Use this as an escape hatch when + /// the local DB is believed to be out of sync with the server. + Future forceResync(String accountId) async { + _active.remove(accountId)?.stop(); + + await _emails.clearForResync(accountId); + await _mailboxes.clearForResync(accountId); + + final accounts = await _accounts.observeAccounts().first; + final account = accounts.cast().firstWhere( + (a) => a?.id == accountId, + orElse: () => null, + ); + if (account == null) return; + + final loop = switch (account.type) { + AccountType.imap => _AccountSync( + account, + _accounts, + _mailboxes, + _emails, + _imapConnect, + _syncLog, + ), + AccountType.jmap => _JmapAccountSync( + account, + _mailboxes, + _emails, + _accounts, + _syncLog, + ), + }; + _active[accountId] = loop; + loop.start(); + } } // ── Shared interface ────────────────────────────────────────────────────────── diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index d24b095..cebc792 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -2739,4 +2739,27 @@ class EmailRepositoryImpl implements EmailRepository { const PendingChangesCompanion(attempts: Value(0), lastError: Value(null)), ); } + + @override + Future clearForResync(String accountId) async { + // Disable FK constraints so EmailBodies rows survive the emails deletion. + // When emails are re-inserted after the next sync with the same IDs, the + // cached body content will be reused without a network round-trip. + 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(); + }); + } finally { + await _db.customStatement('PRAGMA foreign_keys = ON'); + } + } } diff --git a/lib/data/repositories/mailbox_repository_impl.dart b/lib/data/repositories/mailbox_repository_impl.dart index 2c26bba..ebdba45 100644 --- a/lib/data/repositories/mailbox_repository_impl.dart +++ b/lib/data/repositories/mailbox_repository_impl.dart @@ -303,4 +303,11 @@ class MailboxRepositoryImpl implements MailboxRepository { if (mb.isJunk) return 'junk'; return null; } + + @override + Future clearForResync(String accountId) async { + await (_db.delete(_db.mailboxes) + ..where((t) => t.accountId.equals(accountId))) + .go(); + } } diff --git a/lib/ui/screens/edit_account_screen.dart b/lib/ui/screens/edit_account_screen.dart index 30f6c7d..386cb43 100644 --- a/lib/ui/screens/edit_account_screen.dart +++ b/lib/ui/screens/edit_account_screen.dart @@ -43,6 +43,7 @@ class _EditAccountScreenState extends ConsumerState { bool _tryTesting = false; String? _tryOk; String? _tryErr; + bool _resyncing = false; @override void initState() { @@ -170,6 +171,43 @@ class _EditAccountScreenState extends ConsumerState { } } + Future _forceResync() async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Force full sync?'), + content: const Text( + 'This clears all locally-cached emails and mailboxes for this ' + 'account and immediately re-downloads everything from the server. ' + 'Previously viewed email content will not need to be re-downloaded.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Force sync'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _resyncing = true); + try { + await ref.read(syncManagerProvider).forceResync(widget.accountId); + if (mounted) context.pop(); + } catch (e) { + if (mounted) { + setState(() { + _resyncing = false; + _errorMessage = 'Force sync failed: $e'; + }); + } + } + } + Future _save() async { if (!_formKey.currentState!.validate()) return; final password = _passwordCtrl.text.isNotEmpty ? _passwordCtrl.text : null; @@ -230,11 +268,9 @@ class _EditAccountScreenState extends ConsumerState { Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Edit account')), - body: _loading + body: _loading || _saving || _resyncing ? const Center(child: CircularProgressIndicator()) - : _saving - ? const Center(child: CircularProgressIndicator()) - : _buildForm(), + : _buildForm(), ); } @@ -350,6 +386,15 @@ class _EditAccountScreenState extends ConsumerState { ), const SizedBox(height: 8), FilledButton(onPressed: _save, child: const Text('Save')), + const SizedBox(height: 8), + OutlinedButton.icon( + icon: const Icon(Icons.sync_problem), + label: const Text('Force full sync'), + style: OutlinedButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + onPressed: _forceResync, + ), ], ), ), diff --git a/test/integration/account_sync_manager_test.dart b/test/integration/account_sync_manager_test.dart index 6ced071..efa859c 100644 --- a/test/integration/account_sync_manager_test.dart +++ b/test/integration/account_sync_manager_test.dart @@ -96,6 +96,9 @@ class _FakeMailboxes implements MailboxRepository { @override Future findMailboxByRole(String accountId, String role) async => null; + + @override + Future clearForResync(String accountId) async {} } class _FakeEmails implements EmailRepository { @@ -191,6 +194,9 @@ class _FakeEmails implements EmailRepository { @override Future retryMutation(int id) async {} + + @override + Future clearForResync(String accountId) async {} } class _FakeLogs implements SyncLogRepository { diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index 84f5b50..c7981b7 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -98,6 +98,9 @@ class FakeEmailRepository implements EmailRepository { String mailboxPath, ) async => ReliabilityResult.healthy; + + @override + Future clearForResync(String accountId) async {} } class _Log { @@ -148,4 +151,6 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository { Future syncMailboxes(String id) async => 1; @override Future findMailboxByRole(String id, String role) async => null; + @override + Future clearForResync(String accountId) async {} } diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 045e648..310152d 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -132,6 +132,8 @@ class FakeMailboxRepository implements MailboxRepository { @override Future findMailboxByRole(String accountId, String role) async => _mailboxes.where((m) => m.role == role).firstOrNull; + @override + Future clearForResync(String accountId) async {} } class FakeEmailRepository implements EmailRepository { @@ -279,6 +281,9 @@ class FakeEmailRepository implements EmailRepository { @override Future retryMutation(int id) async {} + + @override + Future clearForResync(String accountId) async {} } // ---------------------------------------------------------------------------