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 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<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 {
|
||||
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<model.EmailAddress> _toAddressList(List<imap.MailAddress>? addresses) =>
|
||||
(addresses ?? const [])
|
||||
.map((a) => model.EmailAddress(name: a.personalName, email: a.email))
|
||||
.toList();
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Computes a stable threadId from RFC 2822 headers.
|
||||
|
||||
Reference in New Issue
Block a user