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:
co-authored by
Claude Sonnet 4.6
parent
72f634dd90
commit
65173d323c
@@ -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.
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user