From 7c000dcee5ba77daa2ba1bb1e8b33658acfd5f89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Thu, 16 Apr 2026 11:13:33 +0200 Subject: [PATCH] Add IMAP search: server-side OR SUBJECT/TEXT, inline results in email list - EmailRepository: add searchEmails(accountId, mailboxPath, query) - EmailRepositoryImpl: UID SEARCH with OR SUBJECT/TEXT criteria, fetch ENVELOPE+FLAGS for matching UIDs - EmailListScreen: toggle search bar in AppBar; submit triggers server search; results replace the stream list; ESC/back closes search - Refactored list into _buildList() shared by stream and search views - README/PLAN.md updated Co-Authored-By: Claude Sonnet 4.6 --- PLAN.md | 1 - README.md | 1 + lib/core/repositories/email_repository.dart | 8 + .../repositories/email_repository_impl.dart | 59 +++++ lib/ui/screens/email_list_screen.dart | 243 +++++++++++++----- 5 files changed, 240 insertions(+), 72 deletions(-) diff --git a/PLAN.md b/PLAN.md index 55a039a..b6ea0a0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -30,7 +30,6 @@ UI never touches the network. The sync layer runs independently. ## Next candidates -- Search (IMAP `SEARCH` command) - Thread view (group by `References` / `In-Reply-To`) - Attachment download + open - Draft auto-save diff --git a/README.md b/README.md index d52942a..e4510d8 100644 --- a/README.md +++ b/README.md @@ -151,4 +151,5 @@ test/ - **Attachment indicators** — paperclip icon in email list; filename + size in detail - **Delete email** — removes from server (IMAP expunge) and local DB - **Settings** — list and remove accounts +- **Search** — IMAP server-side search (subject + body); results shown inline, no navigation change - **Offline-first** — all reads come from local Drift/SQLite DB; network only for sync and send diff --git a/lib/core/repositories/email_repository.dart b/lib/core/repositories/email_repository.dart index c02bde2..25bf362 100644 --- a/lib/core/repositories/email_repository.dart +++ b/lib/core/repositories/email_repository.dart @@ -13,4 +13,12 @@ abstract class EmailRepository { Future moveEmail(String emailId, String destMailboxPath); Future deleteEmail(String emailId); Future sendEmail(String accountId, EmailDraft draft); + + /// Returns emails in [mailboxPath] whose subject or body contain [query]. + /// Results come from the server (IMAP SEARCH) and are not cached. + Future> searchEmails( + String accountId, + String mailboxPath, + String query, + ); } diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 13b24c6..adbd9c0 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -238,6 +238,65 @@ class EmailRepositoryImpl implements EmailRepository { } } + @override + Future> searchEmails( + String accountId, + String mailboxPath, + String query, + ) async { + final account = (await _accounts.getAccount(accountId))!; + final password = await _accounts.getPassword(accountId); + final client = await connectImap(account, password); + try { + await client.selectMailboxByPath(mailboxPath); + final escaped = query.replaceAll('"', '\\"'); + final result = await client.uidSearchMessages( + searchCriteria: 'OR SUBJECT "$escaped" TEXT "$escaped"', + ); + final uids = result.matchingSequence?.toList() ?? []; + if (uids.isEmpty) return []; + + final fetch = await client.fetchMessages( + imap.MessageSequence.fromIds(uids, isUid: true), + '(UID FLAGS ENVELOPE)', + ); + return fetch.messages + .where((msg) => msg.uid != null && msg.envelope != null) + .map((msg) { + final envelope = msg.envelope!; + final uid = msg.uid!; + final emailId = '$accountId:$uid'; + return model.Email( + id: emailId, + accountId: accountId, + mailboxPath: mailboxPath, + uid: uid, + subject: envelope.subject, + sentAt: envelope.date, + receivedAt: envelope.date ?? DateTime.now(), + from: _toAddressList(envelope.from), + to: _toAddressList(envelope.to), + cc: _toAddressList(envelope.cc), + isSeen: msg.flags?.contains(r'\Seen') ?? false, + isFlagged: msg.flags?.contains(r'\Flagged') ?? false, + hasAttachment: msg.hasAttachments(), + ); + }).toList(); + } finally { + await client.logout(); + } + } + + List _toAddressList(List? addresses) => + (addresses ?? const []) + .map( + (a) => model.EmailAddress( + name: a.personalName, + email: a.email, + ), + ) + .toList(); + // ── Helpers ──────────────────────────────────────────────────────────────── String _encodeAddresses(List? addresses) => jsonEncode( diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index 3e06889..5f8f29c 100644 --- a/lib/ui/screens/email_list_screen.dart +++ b/lib/ui/screens/email_list_screen.dart @@ -3,11 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; +import '../../core/models/email.dart'; +import '../../core/repositories/email_repository.dart'; import '../../di.dart'; final _dateFmt = DateFormat('MMM d'); -class EmailListScreen extends ConsumerWidget { +class EmailListScreen extends ConsumerStatefulWidget { const EmailListScreen({ super.key, required this.accountId, @@ -18,81 +20,180 @@ class EmailListScreen extends ConsumerWidget { final String mailboxPath; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _EmailListScreenState(); +} + +class _EmailListScreenState extends ConsumerState { + bool _searching = false; + final _searchCtrl = TextEditingController(); + List? _searchResults; + bool _searchLoading = false; + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + Future _runSearch(String query) async { + if (query.trim().isEmpty) { + setState(() => _searchResults = null); + return; + } + setState(() => _searchLoading = true); + try { + final results = await ref.read(emailRepositoryProvider).searchEmails( + widget.accountId, + widget.mailboxPath, + query.trim(), + ); + if (mounted) setState(() => _searchResults = results); + } finally { + if (mounted) setState(() => _searchLoading = false); + } + } + + void _closeSearch() { + setState(() { + _searching = false; + _searchResults = null; + _searchCtrl.clear(); + }); + } + + @override + Widget build(BuildContext context) { final repo = ref.watch(emailRepositoryProvider); return Scaffold( - appBar: AppBar( - title: Text(mailboxPath), - actions: [ - IconButton( - icon: const Icon(Icons.sync), - onPressed: () => - repo.syncEmails(accountId, mailboxPath), + appBar: _searching ? _searchBar() : _normalBar(repo), + body: _searching ? _buildSearchBody() : _buildStreamBody(repo), + ); + } + + AppBar _normalBar(EmailRepository emailRepo) { + return AppBar( + title: Text(widget.mailboxPath), + actions: [ + IconButton( + icon: const Icon(Icons.search), + tooltip: 'Search', + onPressed: () => setState(() => _searching = true), + ), + IconButton( + icon: const Icon(Icons.sync), + onPressed: () => + emailRepo.syncEmails(widget.accountId, widget.mailboxPath), + ), + IconButton( + icon: const Icon(Icons.edit), + onPressed: () => context.push( + '/compose', + extra: {'accountId': widget.accountId}, ), - IconButton( - icon: const Icon(Icons.edit), - onPressed: () => context.push( - '/compose', - extra: {'accountId': accountId}, - ), - ), - ], + ), + ], + ); + } + + AppBar _searchBar() { + return AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: _closeSearch, ), - body: StreamBuilder( - stream: repo.observeEmails(accountId, mailboxPath), - builder: (ctx, snap) { - if (!snap.hasData) { - return const Center(child: CircularProgressIndicator()); - } - final emails = snap.data!; - if (emails.isEmpty) { - return const Center(child: Text('No emails')); - } - return ListView.builder( - itemCount: emails.length, - itemBuilder: (ctx, i) { - final e = emails[i]; - 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, - ), - 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/$accountId/mailboxes/${Uri.encodeComponent(mailboxPath)}/emails/${Uri.encodeComponent(e.id)}', - ), - ); + title: TextField( + controller: _searchCtrl, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Search…', + border: InputBorder.none, + ), + onSubmitted: _runSearch, + textInputAction: TextInputAction.search, + ), + actions: [ + if (_searchCtrl.text.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchCtrl.clear(); + setState(() => _searchResults = null); }, - ); - }, - ), + ), + ], + ); + } + + Widget _buildSearchBody() { + if (_searchLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (_searchResults == null) { + return const Center(child: Text('Type a query and press Enter')); + } + if (_searchResults!.isEmpty) { + return const Center(child: Text('No results')); + } + return _buildList(_searchResults!); + } + + Widget _buildStreamBody(EmailRepository emailRepo) { + return StreamBuilder>( + stream: emailRepo.observeEmails(widget.accountId, widget.mailboxPath), + builder: (ctx, snap) { + if (!snap.hasData) { + return const Center(child: CircularProgressIndicator()); + } + final emails = snap.data!; + if (emails.isEmpty) { + return const Center(child: Text('No emails')); + } + return _buildList(emails); + }, + ); + } + + Widget _buildList(List emails) { + return ListView.builder( + itemCount: emails.length, + itemBuilder: (ctx, i) { + final e = emails[i]; + 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, + ), + 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)}', + ), + ); + }, ); } }