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:
co-authored by
Claude Sonnet 4.6
parent
6c27ad655b
commit
7c000dcee5
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)}',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user