diff --git a/lib/core/repositories/email_repository.dart b/lib/core/repositories/email_repository.dart index df0e4ec..5365fdf 100644 --- a/lib/core/repositories/email_repository.dart +++ b/lib/core/repositories/email_repository.dart @@ -27,6 +27,7 @@ abstract class EmailRepository { Future getEmailBody(String emailId); Future syncEmails(String accountId, String mailboxPath); Future setFlag(String emailId, {bool? seen, bool? flagged}); + Future markAllAsRead(String accountId, String mailboxPath); Future moveEmail(String emailId, String destMailboxPath); /// Deletes the email. Returns the path of the mailbox it was moved to diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 0394703..09b6ed1 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -1520,6 +1520,63 @@ class EmailRepositoryImpl implements EmailRepository { ); } + @override + Future markAllAsRead(String accountId, String mailboxPath) async { + final account = (await _accounts.getAccount(accountId))!; + final unread = await (_db.select(_db.emails) + ..where( + (t) => + t.accountId.equals(accountId) & + t.mailboxPath.equals(mailboxPath) & + t.isSeen.equals(false), + )) + .get(); + if (unread.isEmpty) return; + + await _db.transaction(() async { + for (final row in unread) { + if (account.type == account_model.AccountType.jmap) { + await _enqueueChange( + accountId, + row.id, + 'flag_seen', + jsonEncode({'seen': true}), + ); + } else { + await _enqueueChange( + accountId, + row.id, + 'flag_seen', + jsonEncode({ + 'uid': row.uid, + 'mailboxPath': row.mailboxPath, + 'seen': true, + }), + ); + } + } + + // Bulk mark all unread emails in this mailbox as seen. + await (_db.update(_db.emails) + ..where( + (t) => + t.accountId.equals(accountId) & + t.mailboxPath.equals(mailboxPath) & + t.isSeen.equals(false), + )) + .write(const EmailsCompanion(isSeen: Value(true))); + + // Update all threads in this mailbox to reflect no unread. + await (_db.update(_db.threads) + ..where( + (t) => + t.accountId.equals(accountId) & + t.mailboxPath.equals(mailboxPath), + )) + .write(const ThreadsCompanion(hasUnread: Value(false))); + }); + } + @override Future moveEmail(String emailId, String destMailboxPath) async { final row = await (_db.select( diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index 62484fe..d354bc7 100644 --- a/lib/ui/screens/email_list_screen.dart +++ b/lib/ui/screens/email_list_screen.dart @@ -193,6 +193,22 @@ class _EmailListScreenState extends ConsumerState { extra: {'accountId': widget.accountId}, ), ), + PopupMenuButton( + onSelected: (value) async { + if (value == 'mark_all_read') { + await emailRepo.markAllAsRead( + widget.accountId, + widget.mailboxPath, + ); + } + }, + itemBuilder: (_) => const [ + PopupMenuItem( + value: 'mark_all_read', + child: Text('Mark all as read'), + ), + ], + ), ], bottom: PreferredSize( preferredSize: const Size.fromHeight(60), diff --git a/test/integration/account_sync_manager_test.dart b/test/integration/account_sync_manager_test.dart index 8ef5667..3ca6700 100644 --- a/test/integration/account_sync_manager_test.dart +++ b/test/integration/account_sync_manager_test.dart @@ -190,6 +190,9 @@ class _FakeEmails implements EmailRepository { @override Future setFlag(String id, {bool? seen, bool? flagged}) async {} + @override + Future markAllAsRead(String accountId, String mailboxPath) async {} + @override Future moveEmail(String id, String dest) async {} diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index 21e90ac..16431a4 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -61,6 +61,8 @@ class FakeEmailRepository implements EmailRepository { @override Future setFlag(String id, {bool? seen, bool? flagged}) async {} @override + Future markAllAsRead(String accountId, String mailboxPath) async {} + @override Future moveEmail(String id, String dest) async {} @override diff --git a/test/unit/account_sync_manager_test.mocks.dart b/test/unit/account_sync_manager_test.mocks.dart index aef5e12..8ad4784 100644 --- a/test/unit/account_sync_manager_test.mocks.dart +++ b/test/unit/account_sync_manager_test.mocks.dart @@ -337,6 +337,23 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future); + @override + _i4.Future markAllAsRead( + String? accountId, + String? mailboxPath, + ) => + (super.noSuchMethod( + Invocation.method( + #markAllAsRead, + [ + accountId, + mailboxPath, + ], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + @override _i4.Future moveEmail( String? emailId, diff --git a/test/unit/email_repository_contract_test.dart b/test/unit/email_repository_contract_test.dart index 8fc6d78..41e0110 100644 --- a/test/unit/email_repository_contract_test.dart +++ b/test/unit/email_repository_contract_test.dart @@ -126,6 +126,35 @@ abstract class EmailRepositoryContract { expect(email!.isFlagged, isTrue); }); + test('markAllAsRead marks every unread email in the mailbox', () async { + final repo = await makeRepo(); + await insertEmail( + repo, + id: 'er-acc:20', + mailboxPath: 'INBOX', + isSeen: false, + ); + await insertEmail( + repo, + id: 'er-acc:21', + mailboxPath: 'INBOX', + isSeen: false, + ); + await insertEmail( + repo, + id: 'er-acc:22', + mailboxPath: 'Sent', + isSeen: false, + ); + + await repo.markAllAsRead(_account.id, 'INBOX'); + + expect((await repo.getEmail('er-acc:20'))!.isSeen, isTrue); + expect((await repo.getEmail('er-acc:21'))!.isSeen, isTrue); + // Email in a different mailbox should be untouched. + expect((await repo.getEmail('er-acc:22'))!.isSeen, isFalse); + }); + test('observeThreads starts empty', () async { final repo = await makeRepo(); expect( diff --git a/test/unit/reliability_runner_test.dart b/test/unit/reliability_runner_test.dart index feb92c2..3393fd4 100644 --- a/test/unit/reliability_runner_test.dart +++ b/test/unit/reliability_runner_test.dart @@ -103,6 +103,8 @@ class _CountingEmails implements EmailRepository { @override Future setFlag(String id, {bool? seen, bool? flagged}) async {} @override + Future markAllAsRead(String accountId, String mailboxPath) async {} + @override Future moveEmail(String id, String dest) async {} @override Future deleteEmail(String id) async => null; diff --git a/test/unit/undo_service_test.mocks.dart b/test/unit/undo_service_test.mocks.dart index d7fbd6a..913da8f 100644 --- a/test/unit/undo_service_test.mocks.dart +++ b/test/unit/undo_service_test.mocks.dart @@ -197,6 +197,23 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository { returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future); + @override + _i4.Future markAllAsRead( + String? accountId, + String? mailboxPath, + ) => + (super.noSuchMethod( + Invocation.method( + #markAllAsRead, + [ + accountId, + mailboxPath, + ], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + @override _i4.Future moveEmail( String? emailId, diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 2b050b3..5031a4d 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -214,6 +214,8 @@ class FakeEmailRepository implements EmailRepository { @override Future setFlag(String emailId, {bool? seen, bool? flagged}) async {} + @override + Future markAllAsRead(String accountId, String mailboxPath) async {} @override Future moveEmail(String emailId, String destMailboxPath) async {}