Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 bf59cf4621 feat: add 'Mark all as read' action to mailbox overflow menu (U8)
Adds markAllAsRead(accountId, mailboxPath) to EmailRepository — bulk
marks all unread emails as seen (optimistic local update + pending
change per email for server sync). A PopupMenuButton in EmailListScreen
exposes this as 'Mark all as read'. Contract test verifies the mailbox-
scoped behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:56:49 +02:00
10 changed files with 158 additions and 12 deletions
@@ -27,6 +27,7 @@ abstract class EmailRepository {
Future<EmailBody> getEmailBody(String emailId); Future<EmailBody> getEmailBody(String emailId);
Future<SyncEmailsResult> syncEmails(String accountId, String mailboxPath); Future<SyncEmailsResult> syncEmails(String accountId, String mailboxPath);
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}); Future<void> setFlag(String emailId, {bool? seen, bool? flagged});
Future<void> markAllAsRead(String accountId, String mailboxPath);
Future<void> moveEmail(String emailId, String destMailboxPath); Future<void> moveEmail(String emailId, String destMailboxPath);
/// Deletes the email. Returns the path of the mailbox it was moved to /// Deletes the email. Returns the path of the mailbox it was moved to
@@ -1520,6 +1520,63 @@ class EmailRepositoryImpl implements EmailRepository {
); );
} }
@override
Future<void> 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 @override
Future<void> moveEmail(String emailId, String destMailboxPath) async { Future<void> moveEmail(String emailId, String destMailboxPath) async {
final row = await (_db.select( final row = await (_db.select(
+16
View File
@@ -193,6 +193,22 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
extra: {'accountId': widget.accountId}, extra: {'accountId': widget.accountId},
), ),
), ),
PopupMenuButton<String>(
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( bottom: PreferredSize(
preferredSize: const Size.fromHeight(60), preferredSize: const Size.fromHeight(60),
@@ -140,6 +140,9 @@ class _FakeEmails implements EmailRepository {
@override @override
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {} Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
@override
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
@override @override
Future<void> moveEmail(String id, String dest) async {} Future<void> moveEmail(String id, String dest) async {}
+2
View File
@@ -61,6 +61,8 @@ class FakeEmailRepository implements EmailRepository {
@override @override
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {} Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
@override @override
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
@override
Future<void> moveEmail(String id, String dest) async {} Future<void> moveEmail(String id, String dest) async {}
@override @override
+23 -6
View File
@@ -215,9 +215,9 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
@override @override
_i4.Stream<List<_i2.Email>> observeEmails( _i4.Stream<List<_i2.Email>> observeEmails(
String accountId, String? accountId,
String mailboxPath, { String? mailboxPath, {
int limit = 50, int? limit = 50,
}) => }) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
@@ -233,9 +233,9 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
@override @override
_i4.Stream<List<_i2.EmailThread>> observeThreads( _i4.Stream<List<_i2.EmailThread>> observeThreads(
String accountId, String? accountId,
String mailboxPath, { String? mailboxPath, {
int limit = 50, int? limit = 50,
}) => }) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
@@ -337,6 +337,23 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>); ) as _i4.Future<void>);
@override
_i4.Future<void> markAllAsRead(
String? accountId,
String? mailboxPath,
) =>
(super.noSuchMethod(
Invocation.method(
#markAllAsRead,
[
accountId,
mailboxPath,
],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override @override
_i4.Future<void> moveEmail( _i4.Future<void> moveEmail(
String? emailId, String? emailId,
@@ -126,6 +126,35 @@ abstract class EmailRepositoryContract {
expect(email!.isFlagged, isTrue); 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 { test('observeThreads starts empty', () async {
final repo = await makeRepo(); final repo = await makeRepo();
expect( expect(
+2
View File
@@ -103,6 +103,8 @@ class _CountingEmails implements EmailRepository {
@override @override
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {} Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
@override @override
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
@override
Future<void> moveEmail(String id, String dest) async {} Future<void> moveEmail(String id, String dest) async {}
@override @override
Future<String?> deleteEmail(String id) async => null; Future<String?> deleteEmail(String id) async => null;
+23 -6
View File
@@ -75,9 +75,9 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
@override @override
_i4.Stream<List<_i2.Email>> observeEmails( _i4.Stream<List<_i2.Email>> observeEmails(
String accountId, String? accountId,
String mailboxPath, { String? mailboxPath, {
int limit = 50, int? limit = 50,
}) => }) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
@@ -93,9 +93,9 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
@override @override
_i4.Stream<List<_i2.EmailThread>> observeThreads( _i4.Stream<List<_i2.EmailThread>> observeThreads(
String accountId, String? accountId,
String mailboxPath, { String? mailboxPath, {
int limit = 50, int? limit = 50,
}) => }) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
@@ -197,6 +197,23 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>); ) as _i4.Future<void>);
@override
_i4.Future<void> markAllAsRead(
String? accountId,
String? mailboxPath,
) =>
(super.noSuchMethod(
Invocation.method(
#markAllAsRead,
[
accountId,
mailboxPath,
],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override @override
_i4.Future<void> moveEmail( _i4.Future<void> moveEmail(
String? emailId, String? emailId,
+2
View File
@@ -213,6 +213,8 @@ class FakeEmailRepository implements EmailRepository {
@override @override
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {} Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {}
@override
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
@override @override
Future<void> moveEmail(String emailId, String destMailboxPath) async {} Future<void> moveEmail(String emailId, String destMailboxPath) async {}