feat(search): include email notes in search results
Extend searchEmailsGlobal and searchEmails to also match emails whose associated notes contain the query words, so users can find emails by content they wrote in their notes. Closes #488 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
57b266a82b
commit
e3ed097de7
@@ -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<List<Email>> searchEmailsGlobal(String? accountId, String query);
|
||||
|
||||
/// Returns all locally cached emails in any mailbox of [accountId] (or all
|
||||
|
||||
@@ -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 = <String>{};
|
||||
final merged = <model.Email>[];
|
||||
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<List<model.Email>> _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<String>('%$w%')).toList();
|
||||
|
||||
final extraConditions = StringBuffer();
|
||||
final extraVars = <Variable<String>>[];
|
||||
if (accountId != null) {
|
||||
extraConditions.write(' AND e.account_id = ?');
|
||||
extraVars.add(Variable<String>(accountId));
|
||||
}
|
||||
if (mailboxPath != null) {
|
||||
extraConditions.write(' AND e.mailbox_path = ?');
|
||||
extraVars.add(Variable<String>(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 = <String>{};
|
||||
final merged = <model.Email>[];
|
||||
for (final e in [...emailRows.map(_toModel), ...noteRows]) {
|
||||
if (seen.add(e.id)) merged.add(e);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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('<msg1@example.com>'),
|
||||
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: '<msg1@example.com>',
|
||||
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('<msg1@example.com>'),
|
||||
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('<msg2@example.com>'),
|
||||
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: '<msg1@example.com>',
|
||||
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: '<msg2@example.com>',
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user