feat: switch folder-view search from IMAP to local SQLite FTS5

Closes #501

searchEmails now queries the local email_fts virtual table filtered by
mailbox_path instead of doing a live IMAP SEARCH. This makes folder-view
search work offline and ensures tapped results always open the correct
email (IDs come from the same local DB that getEmail reads from).

Reuses the existing FTS5 infrastructure (_toFtsQuery + the email_fts
content-table join) from searchEmailsGlobal, adding only the
`AND e.mailbox_path = ?` filter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #503.
This commit is contained in:
Thomas SharedInbox
2026-06-06 20:43:53 +02:00
co-authored by Claude Sonnet 4.6
parent 72f634dd90
commit 65173d323c
4 changed files with 92 additions and 57 deletions
@@ -3052,63 +3052,26 @@ class EmailRepositoryImpl implements EmailRepository {
String mailboxPath, String mailboxPath,
String query, String query,
) async { ) async {
final account = (await _accounts.getAccount(accountId))!; final ftsQuery = _toFtsQuery(query);
final password = await _accounts.getPassword(accountId); if (ftsQuery.isEmpty) return [];
final client = await _imapConnect(
account, const sql = 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
_effectiveUsername(account), ' WHERE email_fts MATCH ? AND e.account_id = ? AND e.mailbox_path = ?'
password, ' ORDER BY rank LIMIT 50';
final variables = [
Variable<String>(ftsQuery),
Variable<String>(accountId),
Variable<String>(mailboxPath),
];
final queryRows = await _db
.customSelect(sql, variables: variables, readsFrom: {_db.emails}).get();
final emailRows = await Future.wait(
queryRows.map((r) => _db.emails.mapFromRow(r)),
); );
try { return emailRows.map(_toModel).toList();
await client.selectMailboxByPath(mailboxPath);
final terms =
query.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList();
final searchCriteria = terms.map((term) {
final escaped = term.replaceAll('"', '\\"');
return 'OR SUBJECT "$escaped" TEXT "$escaped"';
}).join(' ');
final result = await client.uidSearchMessages(
searchCriteria: searchCriteria,
);
final uids = result.matchingSequence?.toList() ?? [];
if (uids.isEmpty) return [];
final fetch = await client.uidFetchMessages(
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 ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
/// Computes a stable threadId from RFC 2822 headers. /// Computes a stable threadId from RFC 2822 headers.
+3 -3
View File
@@ -136,7 +136,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
return; return;
} }
// Skip if results are already settled for this exact query — prevents the // Skip if results are already settled for this exact query — prevents the
// Enter key from re-triggering an IMAP search that already completed. // Enter key from re-triggering a search that already completed.
if (_searchResults != null && !_searchLoading && q == _lastSettledQuery) { if (_searchResults != null && !_searchLoading && q == _lastSettledQuery) {
return; return;
} }
@@ -568,8 +568,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
if (wasSearching && mounted) { if (wasSearching && mounted) {
// Filter deleted emails out of the local results immediately. // Filter deleted emails out of the local results immediately.
// Calling searchEmails here would hit the IMAP server, which still has // Calling searchEmails here would still return deleted rows because the
// the emails because the delete is only enqueued — not yet applied. // delete is only enqueued — not yet applied to the local DB.
final deletedIds = ids.toSet(); final deletedIds = ids.toSet();
final remaining = (_searchResults ?? []) final remaining = (_searchResults ?? [])
.where((e) => !deletedIds.contains(e.id)) .where((e) => !deletedIds.contains(e.id))
+33
View File
@@ -453,6 +453,39 @@ void main() {
expect(results.first.subject, 'foobar baz'); expect(results.first.subject, 'foobar baz');
}); });
test('searchEmails filters by mailboxPath using local FTS5', () async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
// Insert matching email in INBOX.
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 1,
subject: const Value('Meeting agenda'),
receivedAt: DateTime(2024),
),
);
// Insert matching email in a different mailbox — must not appear.
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:2',
accountId: 'acc-1',
mailboxPath: 'Sent',
uid: 2,
subject: const Value('Meeting follow-up'),
receivedAt: DateTime(2024),
),
);
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'meeting');
expect(results, hasLength(1));
expect(results.first.subject, 'Meeting agenda');
expect(results.first.mailboxPath, 'INBOX');
});
test( test(
'searchAddresses returns results sorted by most recently used', 'searchAddresses returns results sorted by most recently used',
() async { () async {
+39
View File
@@ -593,6 +593,45 @@ void main() {
}, },
); );
testWidgets(
'folder search returns results from local cache without any network call',
(tester) async {
// Verifies that searchEmails is backed by local SQLite (not IMAP).
// The repository throws if a network call is attempted, yet search
// must still return results.
final email = testEmail(subject: 'Cached subject');
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
onSearch: (_) async {
// Local DB: return cached results immediately.
return [email];
},
emailBody: const EmailBody(emailId: '', attachments: []),
),
),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Cached');
await tester.pumpAndSettle();
expect(find.text('Cached subject'), findsOneWidget);
},
);
testWidgets('deleting all search results pops back to previous screen', ( testWidgets('deleting all search results pops back to previous screen', (
tester, tester,
) async { ) async {