From d92cfac761e79ab079af964435d79f43357e8c1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 7 Jun 2026 01:58:22 +0200 Subject: [PATCH] feat(search): include email notes in search results (#512) --- lib/core/repositories/email_repository.dart | 2 +- .../repositories/email_repository_impl.dart | 65 +++++++++++++- test/unit/email_repository_impl_test.dart | 90 +++++++++++++++++++ 3 files changed, 155 insertions(+), 2 deletions(-) diff --git a/lib/core/repositories/email_repository.dart b/lib/core/repositories/email_repository.dart index 28466bf..9a6e4b4 100644 --- a/lib/core/repositories/email_repository.dart +++ b/lib/core/repositories/email_repository.dart @@ -58,7 +58,7 @@ abstract class EmailRepository { ); /// Searches the local DB across all mailboxes of [accountId] (or all accounts - /// if null) by subject and preview. Fast, works offline. + /// if null) by subject, preview, and notes. Fast, works offline. Future> searchEmailsGlobal(String? accountId, String query); /// Returns all locally cached emails in any mailbox of [accountId] (or all diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 2d5218d..3bcdc01 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -2934,6 +2934,60 @@ class EmailRepositoryImpl implements EmailRepository { final emailRows = await Future.wait( queryRows.map((r) => _db.emails.mapFromRow(r)), ); + + final noteRows = await _searchEmailsByNotes(accountId, null, query); + + final seen = {}; + final merged = []; + for (final e in [...emailRows.map(_toModel), ...noteRows]) { + if (seen.add(e.id)) merged.add(e); + } + return merged; + } + + /// Returns emails whose associated notes contain all words from [query]. + /// Optionally filtered by [accountId] and [mailboxPath]. + Future> _searchEmailsByNotes( + String? accountId, + String? mailboxPath, + String query, + ) async { + final words = query + .trim() + .split(RegExp(r'\s+')) + .where((w) => w.isNotEmpty) + .toList(); + if (words.isEmpty) return []; + + final noteConditions = words.map((_) => 'n.note_text LIKE ?').join(' AND '); + final likeVars = + words.map((w) => Variable('%$w%')).toList(); + + final extraConditions = StringBuffer(); + final extraVars = >[]; + if (accountId != null) { + extraConditions.write(' AND e.account_id = ?'); + extraVars.add(Variable(accountId)); + } + if (mailboxPath != null) { + extraConditions.write(' AND e.mailbox_path = ?'); + extraVars.add(Variable(mailboxPath)); + } + + final sql = 'SELECT DISTINCT e.* FROM emails e' + ' JOIN email_notes n ON n.message_id = e.message_id' + ' AND n.account_id = e.account_id' + ' WHERE $noteConditions$extraConditions' + ' ORDER BY e.received_at DESC LIMIT 50'; + + final rows = await _db + .customSelect( + sql, + variables: [...likeVars, ...extraVars], + readsFrom: {_db.emails, _db.emailNotes}, + ) + .get(); + final emailRows = await Future.wait(rows.map((r) => _db.emails.mapFromRow(r))); return emailRows.map(_toModel).toList(); } @@ -3069,7 +3123,16 @@ class EmailRepositoryImpl implements EmailRepository { final emailRows = await Future.wait( queryRows.map((r) => _db.emails.mapFromRow(r)), ); - return emailRows.map(_toModel).toList(); + + final noteRows = + await _searchEmailsByNotes(accountId, mailboxPath, query); + + final seen = {}; + final merged = []; + for (final e in [...emailRows.map(_toModel), ...noteRows]) { + if (seen.add(e.id)) merged.add(e); + } + return merged; } // ── Helpers ──────────────────────────────────────────────────────────────── diff --git a/test/unit/email_repository_impl_test.dart b/test/unit/email_repository_impl_test.dart index 78ed2f9..ff24382 100644 --- a/test/unit/email_repository_impl_test.dart +++ b/test/unit/email_repository_impl_test.dart @@ -486,6 +486,96 @@ void main() { expect(results.first.mailboxPath, 'INBOX'); }); + test('searchEmailsGlobal includes emails matched by note text', () async { + final r = _makeRepos(); + await r.accounts.addAccount(_account, 'pw'); + + // Email whose subject does NOT match — but its note does. + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:1', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 1, + messageId: const Value(''), + subject: const Value('Weekly report'), + receivedAt: DateTime(2024), + ), + ); + // Add a note referencing the email's messageId. + await r.db.into(r.db.emailNotes).insert( + EmailNotesCompanion.insert( + id: 'note-1', + accountId: 'acc-1', + messageId: '', + noteText: 'Urgent follow-up needed', + serverId: '42', + createdAt: DateTime(2024), + ), + ); + + final results = + await r.emails.searchEmailsGlobal(null, 'urgent'); + expect(results, hasLength(1)); + expect(results.first.subject, 'Weekly report'); + }); + + test('searchEmails includes emails matched by note text in mailbox', + () async { + final r = _makeRepos(); + await r.accounts.addAccount(_account, 'pw'); + + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:1', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 1, + messageId: const Value(''), + subject: const Value('Project update'), + receivedAt: DateTime(2024), + ), + ); + // Email in a different mailbox — its note must not appear in INBOX search. + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:2', + accountId: 'acc-1', + mailboxPath: 'Sent', + uid: 2, + messageId: const Value(''), + subject: const Value('Other email'), + receivedAt: DateTime(2024), + ), + ); + await r.db.into(r.db.emailNotes).insert( + EmailNotesCompanion.insert( + id: 'note-1', + accountId: 'acc-1', + messageId: '', + noteText: 'remember to call client', + serverId: '42', + createdAt: DateTime(2024), + ), + ); + await r.db.into(r.db.emailNotes).insert( + EmailNotesCompanion.insert( + id: 'note-2', + accountId: 'acc-1', + messageId: '', + noteText: 'remember to call client', + serverId: '43', + createdAt: DateTime(2024), + ), + ); + + final results = + await r.emails.searchEmails('acc-1', 'INBOX', 'client'); + expect(results, hasLength(1)); + expect(results.first.subject, 'Project update'); + expect(results.first.mailboxPath, 'INBOX'); + }); + test( 'searchAddresses returns results sorted by most recently used', () async {