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 <noreply@anthropic.com>
This commit is contained in:
Thomas Güttler
2026-04-16 11:13:33 +02:00
co-authored by Claude Sonnet 4.6
parent 6c27ad655b
commit 7c000dcee5
5 changed files with 240 additions and 72 deletions
-1
View File
@@ -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
+1
View File
@@ -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
@@ -13,4 +13,12 @@ abstract class EmailRepository {
Future<void> moveEmail(String emailId, String destMailboxPath);
Future<void> deleteEmail(String emailId);
Future<void> 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<List<Email>> searchEmails(
String accountId,
String mailboxPath,
String query,
);
}
@@ -238,6 +238,65 @@ class EmailRepositoryImpl implements EmailRepository {
}
}
@override
Future<List<model.Email>> 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<model.EmailAddress> _toAddressList(List<imap.MailAddress>? addresses) =>
(addresses ?? const [])
.map(
(a) => model.EmailAddress(
name: a.personalName,
email: a.email,
),
)
.toList();
// ── Helpers ────────────────────────────────────────────────────────────────
String _encodeAddresses(List<imap.MailAddress>? addresses) => jsonEncode(
+172 -71
View File
@@ -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<EmailListScreen> createState() => _EmailListScreenState();
}
class _EmailListScreenState extends ConsumerState<EmailListScreen> {
bool _searching = false;
final _searchCtrl = TextEditingController();
List<Email>? _searchResults;
bool _searchLoading = false;
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
Future<void> _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<List<Email>>(
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<Email> 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)}',
),
);
},
);
}
}