feat: swipe to archive/delete in email list, with role-based mailbox lookup

- Add `role` field to Mailbox model (surfacing what was already stored in DB)
- IMAP sync: map enough_mail special-use flags (RFC 6154) to role strings
- JMAP: role was already synced to DB, now passed through _toModel()
- Add MailboxRepository.findMailboxByRole() for role-based lookup
- EmailListScreen: swipe right → archive, swipe left → delete (Dismissible)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Güttler
2026-04-24 08:31:22 +02:00
co-authored by Claude Sonnet 4.6
parent a33e794583
commit 544cd9b335
7 changed files with 144 additions and 28 deletions
+4
View File
@@ -6,6 +6,9 @@ class Mailbox {
final String name; // last path component
final int unreadCount;
final int totalCount;
// JMAP role (RFC 8621) or mapped from IMAP special-use (RFC 6154).
// e.g. "inbox", "sent", "drafts", "junk", "trash", "archive"
final String? role;
const Mailbox({
required this.id,
@@ -14,5 +17,6 @@ class Mailbox {
required this.name,
required this.unreadCount,
required this.totalCount,
this.role,
});
}
@@ -5,4 +5,7 @@ abstract class MailboxRepository {
/// Returns the number of mailboxes synced.
Future<int> syncMailboxes(String accountId);
/// Returns the first mailbox with the given [role] for [accountId], or null.
Future<Mailbox?> findMailboxByRole(String accountId, String role);
}
@@ -37,6 +37,20 @@ class MailboxRepositoryImpl implements MailboxRepository {
.map((rows) => rows.map(_toModel).toList());
}
@override
Future<model.Mailbox?> findMailboxByRole(
String accountId,
String role,
) async {
final row = await (_db.select(_db.mailboxes)
..where(
(t) => t.accountId.equals(accountId) & t.role.equals(role),
)
..limit(1))
.getSingleOrNull();
return row == null ? null : _toModel(row);
}
@override
Future<int> syncMailboxes(String accountId) async {
final account = (await _accounts.getAccount(accountId))!;
@@ -84,6 +98,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
name: mb.name,
unreadCount: Value(unread),
totalCount: Value(total),
role: Value(_imapRole(mb)),
),
);
}
@@ -264,5 +279,17 @@ class MailboxRepositoryImpl implements MailboxRepository {
name: row.name,
unreadCount: row.unreadCount,
totalCount: row.totalCount,
role: row.role,
);
/// Maps enough_mail special-use flags (RFC 6154) to JMAP role strings (RFC 8621).
static String? _imapRole(imap.Mailbox mb) {
if (mb.isInbox) return 'inbox';
if (mb.isArchive) return 'archive';
if (mb.isTrash) return 'trash';
if (mb.isSent) return 'sent';
if (mb.isDrafts) return 'drafts';
if (mb.isJunk) return 'junk';
return null;
}
}
+90 -28
View File
@@ -178,6 +178,24 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
);
}
Future<void> _archiveEmail(Email email) async {
final mailboxRepo = ref.read(mailboxRepositoryProvider);
final archive =
await mailboxRepo.findMailboxByRole(widget.accountId, 'archive');
if (!mounted) return;
if (archive == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No archive folder found')),
);
return;
}
await ref.read(emailRepositoryProvider).moveEmail(email.id, archive.path);
}
Future<void> _deleteEmail(Email email) async {
await ref.read(emailRepositoryProvider).deleteEmail(email.id);
}
Widget _buildList(List<Email> emails) {
return ListView.builder(
itemCount: emails.length,
@@ -186,39 +204,83 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
final sender = e.from.isNotEmpty
? (e.from.first.name ?? e.from.first.email)
: '(unknown)';
return ListTile(
leading: Icon(
e.isSeen ? Icons.mail_outline : Icons.mail,
color: e.isSeen ? null : Theme.of(ctx).colorScheme.primary,
return Dismissible(
key: ValueKey(e.id),
background: _swipeBackground(
alignment: Alignment.centerLeft,
color: Colors.green,
icon: Icons.archive,
label: 'Archive',
),
title: Text(
sender,
style:
e.isSeen ? null : const TextStyle(fontWeight: FontWeight.bold),
secondaryBackground: _swipeBackground(
alignment: Alignment.centerRight,
color: Colors.red,
icon: Icons.delete,
label: 'Delete',
),
subtitle: Text(
e.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (e.isFlagged)
const Icon(Icons.star, color: Colors.amber, size: 16),
if (e.hasAttachment) const Icon(Icons.attach_file, size: 16),
const SizedBox(width: 4),
Text(
e.sentAt != null ? _dateFmt.format(e.sentAt!) : '',
style: Theme.of(ctx).textTheme.bodySmall,
),
],
),
onTap: () => context.push(
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(e.id)}',
onDismissed: (direction) async {
if (direction == DismissDirection.startToEnd) {
await _archiveEmail(e);
} else {
await _deleteEmail(e);
}
},
child: ListTile(
leading: Icon(
e.isSeen ? Icons.mail_outline : Icons.mail,
color: e.isSeen ? null : Theme.of(ctx).colorScheme.primary,
),
title: Text(
sender,
style: e.isSeen
? null
: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
e.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (e.isFlagged)
const Icon(Icons.star, color: Colors.amber, size: 16),
if (e.hasAttachment) const Icon(Icons.attach_file, size: 16),
const SizedBox(width: 4),
Text(
e.sentAt != null ? _dateFmt.format(e.sentAt!) : '',
style: Theme.of(ctx).textTheme.bodySmall,
),
],
),
onTap: () => context.push(
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(e.id)}',
),
),
);
},
);
}
Widget _swipeBackground({
required AlignmentGeometry alignment,
required Color color,
required IconData icon,
required String label,
}) {
return Container(
color: color,
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white),
const SizedBox(width: 8),
Text(label, style: const TextStyle(color: Colors.white)),
],
),
);
}
}
@@ -57,6 +57,10 @@ class _FakeMailboxes implements MailboxRepository {
@override
Future<int> syncMailboxes(String accountId) async => 0;
@override
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
null;
}
class _FakeEmails implements EmailRepository {
+12
View File
@@ -48,6 +48,10 @@ class FakeMailboxRepository implements MailboxRepository {
@override
Future<int> syncMailboxes(String accountId) async => 0;
@override
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
null;
}
class FailingMailboxRepository implements MailboxRepository {
@@ -57,6 +61,10 @@ class FailingMailboxRepository implements MailboxRepository {
@override
Future<int> syncMailboxes(String accountId) async =>
throw Exception('simulated sync failure');
@override
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
null;
}
class FakeEmailRepository implements EmailRepository {
@@ -170,6 +178,10 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
@override
Future<int> syncMailboxes(String accountId) async => 0;
@override
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
null;
}
class _CountingEmailRepository extends FakeEmailRepository {
+4
View File
@@ -127,6 +127,10 @@ class FakeMailboxRepository implements MailboxRepository {
@override
Future<int> syncMailboxes(String accountId) async => 0;
@override
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
_mailboxes.where((m) => m.role == role).firstOrNull;
}
class FakeEmailRepository implements EmailRepository {