diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 6b0cad9..2d5218d 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -3052,63 +3052,26 @@ class EmailRepositoryImpl implements EmailRepository { String mailboxPath, String query, ) async { - final account = (await _accounts.getAccount(accountId))!; - final password = await _accounts.getPassword(accountId); - final client = await _imapConnect( - account, - _effectiveUsername(account), - password, + final ftsQuery = _toFtsQuery(query); + if (ftsQuery.isEmpty) return []; + + const sql = 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid' + ' WHERE email_fts MATCH ? AND e.account_id = ? AND e.mailbox_path = ?' + ' ORDER BY rank LIMIT 50'; + final variables = [ + Variable(ftsQuery), + Variable(accountId), + Variable(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 { - 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(); - } + return emailRows.map(_toModel).toList(); } - List _toAddressList(List? addresses) => - (addresses ?? const []) - .map((a) => model.EmailAddress(name: a.personalName, email: a.email)) - .toList(); - // ── Helpers ──────────────────────────────────────────────────────────────── /// Computes a stable threadId from RFC 2822 headers. diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index 60a0aba..fa2fbfe 100644 --- a/lib/ui/screens/email_list_screen.dart +++ b/lib/ui/screens/email_list_screen.dart @@ -136,7 +136,7 @@ class _EmailListScreenState extends ConsumerState { return; } // 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) { return; } @@ -568,8 +568,8 @@ class _EmailListScreenState extends ConsumerState { if (wasSearching && mounted) { // Filter deleted emails out of the local results immediately. - // Calling searchEmails here would hit the IMAP server, which still has - // the emails because the delete is only enqueued — not yet applied. + // Calling searchEmails here would still return deleted rows because the + // delete is only enqueued — not yet applied to the local DB. final deletedIds = ids.toSet(); final remaining = (_searchResults ?? []) .where((e) => !deletedIds.contains(e.id)) diff --git a/test/unit/email_repository_impl_test.dart b/test/unit/email_repository_impl_test.dart index d2edc48..78ed2f9 100644 --- a/test/unit/email_repository_impl_test.dart +++ b/test/unit/email_repository_impl_test.dart @@ -453,6 +453,39 @@ void main() { 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( 'searchAddresses returns results sorted by most recently used', () async { diff --git a/test/widget/email_list_screen_test.dart b/test/widget/email_list_screen_test.dart index 73cf0c1..60b1823 100644 --- a/test/widget/email_list_screen_test.dart +++ b/test/widget/email_list_screen_test.dart @@ -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', ( tester, ) async {