Branch data Line data Source code
1 : : #include "MailCache.h"
2 : : #include "DatabaseSecurity.h"
3 : :
4 : : #include <QDir>
5 : : #include <QFileInfo>
6 : : #include <QLoggingCategory>
7 : : #include <QRegularExpression>
8 : : #include <QSqlError>
9 : : #include <QSqlQuery>
10 : : #include <QUuid>
11 : :
12 : : #include <limits>
13 : :
14 [ + + + - : 818 : Q_LOGGING_CATEGORY(lcCache, "mailjd.cache")
+ - - - ]
15 : :
16 : 1633 : static bool execCacheMigrationStatement(QSqlQuery &q,
17 : : const QString &statement,
18 : : const QString &context,
19 : : QString *errorOut) {
20 [ + - + + ]: 1633 : if (q.exec(statement))
21 : 1629 : return true;
22 [ + - + - ]: 4 : const QString error = q.lastError().text();
23 [ + - + - : 8 : qCWarning(lcCache) << context << error << statement;
+ - + - +
- + + ]
24 [ + - ]: 4 : if (errorOut)
25 [ + - ]: 4 : *errorOut = QStringLiteral("%1 %2").arg(context, error);
26 : 4 : return false;
27 : 4 : }
28 : :
29 : 5047 : static bool deleteSearchIndexEntry(QSqlDatabase &db, qint64 rowId) {
30 [ + - ]: 5047 : QSqlQuery q(db);
31 [ + - ]: 5047 : q.prepare(QStringLiteral("DELETE FROM mail_fts WHERE rowid = :rowid"));
32 [ + - ]: 10094 : q.bindValue(QStringLiteral(":rowid"), rowId);
33 [ + - ]: 10094 : return q.exec();
34 : 5047 : }
35 : :
36 : 238 : static bool runCacheMigration(QSqlDatabase &db,
37 : : int currentVersion,
38 : : int targetVersion,
39 : : QString *errorOut,
40 : : bool *needsVersionAfterSchema) {
41 [ + - ]: 238 : if (needsVersionAfterSchema)
42 : 238 : *needsVersionAfterSchema = false;
43 : :
44 [ + - ]: 238 : QSqlQuery q(db);
45 : 238 : QStringList statements;
46 : :
47 [ + + ]: 238 : if (currentVersion == 0) {
48 [ + - + - : 458 : qCInfo(lcCache) << "Schema upgrade:" << currentVersion << "->"
+ - + - +
- + + ]
49 [ + - + - ]: 229 : << targetVersion << "- purging old data";
50 [ + + - - ]: 1832 : statements = {
51 : 0 : QStringLiteral("DROP TABLE IF EXISTS mail_labels"),
52 : 229 : QStringLiteral("DROP TABLE IF EXISTS folder_badges"),
53 : 229 : QStringLiteral("DROP TABLE IF EXISTS attachments"),
54 : 229 : QStringLiteral("DROP TABLE IF EXISTS bodies"),
55 : 229 : QStringLiteral("DROP TABLE IF EXISTS headers"),
56 : 229 : QStringLiteral("DROP TABLE IF EXISTS folders"),
57 : 229 : QStringLiteral("DROP TABLE IF EXISTS mail_fts"),
58 : 1832 : };
59 [ + - ]: 229 : if (needsVersionAfterSchema)
60 : 229 : *needsVersionAfterSchema = true;
61 : : } else {
62 [ + + ]: 9 : if (currentVersion <= 9) {
63 [ + - + - : 8 : qCInfo(lcCache) << "Schema upgrade v9 -> v10: adding threading columns";
+ - + + ]
64 [ + - ]: 8 : statements << QStringLiteral(
65 : : "ALTER TABLE headers ADD COLUMN message_id TEXT")
66 [ + - ]: 8 : << QStringLiteral(
67 : : "ALTER TABLE headers ADD COLUMN in_reply_to TEXT")
68 [ + - ]: 4 : << QStringLiteral(
69 : : "ALTER TABLE headers ADD COLUMN ref_ids TEXT");
70 : : }
71 [ + + ]: 9 : if (currentVersion <= 10) {
72 : : // Table is created in createSchema() via CREATE TABLE IF NOT EXISTS.
73 [ + - + - : 10 : qCInfo(lcCache) << "Schema upgrade v10 -> v11: adding whitelist table";
+ - + + ]
74 : : }
75 [ + + ]: 9 : if (currentVersion <= 11) {
76 [ + - + - : 10 : qCInfo(lcCache) << "Schema upgrade v11 -> v12: adding highest_modseq";
+ - + + ]
77 [ + - ]: 5 : statements << QStringLiteral(
78 : : "ALTER TABLE folders ADD COLUMN highest_modseq INTEGER DEFAULT 0");
79 : : }
80 [ + + ]: 9 : if (currentVersion <= 12) {
81 [ + - + - : 12 : qCInfo(lcCache) << "Schema upgrade v12 -> v13: adding is_spam column";
+ - + + ]
82 [ + - ]: 6 : statements << QStringLiteral(
83 : : "ALTER TABLE headers ADD COLUMN is_spam INTEGER DEFAULT 0");
84 : : }
85 [ + + ]: 9 : if (currentVersion <= 13) {
86 [ + - + - : 14 : qCInfo(lcCache) << "Schema upgrade v13 -> v14: resetting FTS index";
+ - + + ]
87 [ + - ]: 7 : statements << QStringLiteral("DROP TABLE IF EXISTS mail_fts");
88 : : }
89 [ + + ]: 9 : if (currentVersion <= 14) {
90 : : // Drop the old unicode61 FTS table; createSchema() recreates it with the
91 : : // trigram tokenizer and the background rebuild repopulates it with folded
92 : : // text (substring + diacritic-insensitive search).
93 [ + - + - : 16 : qCInfo(lcCache) << "Schema upgrade v14 -> v15: trigram FTS + folding";
+ - + + ]
94 [ + - ]: 8 : statements << QStringLiteral("DROP TABLE IF EXISTS mail_fts");
95 : : }
96 [ + - ]: 9 : if (currentVersion <= 15) {
97 : : // Drop the FTS table so the background rebuild re-indexes bodies with the
98 : : // new HTML-fallback (HTML-only mails were previously not body-searchable).
99 [ + - + - : 18 : qCInfo(lcCache) << "Schema upgrade v15 -> v16: index HTML mail bodies";
+ - + + ]
100 [ + - ]: 9 : statements << QStringLiteral("DROP TABLE IF EXISTS mail_fts");
101 : : }
102 : 18 : statements << QStringLiteral("PRAGMA user_version = %1")
103 [ + - + - ]: 18 : .arg(targetVersion);
104 : : }
105 : :
106 [ + - - + ]: 238 : if (!db.transaction()) {
107 [ # # # # ]: 0 : const QString error = db.lastError().text();
108 [ # # # # : 0 : qCWarning(lcCache) << "Schema migration transaction:" << error;
# # # # #
# ]
109 [ # # ]: 0 : if (errorOut)
110 : 0 : *errorOut = QStringLiteral("Schema migration transaction: %1")
111 [ # # ]: 0 : .arg(error);
112 : 0 : return false;
113 : 0 : }
114 : :
115 [ + - + - : 1867 : for (const auto &statement : statements) {
+ + ]
116 [ + - ]: 1633 : if (!execCacheMigrationStatement(q, statement,
117 [ + + ]: 3266 : QStringLiteral("Schema migration:"),
118 : : errorOut)) {
119 [ + - ]: 4 : db.rollback();
120 : 4 : return false;
121 : : }
122 : : }
123 : :
124 [ + - - + ]: 234 : if (!db.commit()) {
125 [ # # # # ]: 0 : const QString error = db.lastError().text();
126 [ # # # # : 0 : qCWarning(lcCache) << "Schema migration commit:" << error;
# # # # #
# ]
127 [ # # ]: 0 : if (errorOut)
128 [ # # ]: 0 : *errorOut = QStringLiteral("Schema migration commit: %1").arg(error);
129 : 0 : return false;
130 : 0 : }
131 : :
132 : 234 : return true;
133 [ + - - - : 2070 : }
- - ]
134 : :
135 [ + - ]: 356 : MailCache::MailCache(QObject *parent) : QObject(parent) {}
136 : :
137 : 487 : MailCache::~MailCache() { close(); }
138 : :
139 : 353 : bool MailCache::open(const QString &dbPath) {
140 [ + - + + ]: 353 : if (!DatabaseSecurity::preparePath(dbPath)) {
141 : 6 : m_lastError = QStringLiteral("Failed to create private database file");
142 [ + - + - : 12 : qCWarning(lcCache) << m_lastError << dbPath;
+ - + - +
+ ]
143 : 6 : return false;
144 : : }
145 : :
146 : 347 : m_dbPath = dbPath;
147 : :
148 : : // Use a unique connection name to avoid conflicts with other QSqlDatabase
149 : : // users
150 : : m_connectionName =
151 [ + - + - : 694 : QStringLiteral("mailjd_cache_") + QUuid::createUuid().toString();
+ - ]
152 [ + - + - ]: 694 : m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
153 [ + - ]: 347 : m_db.setDatabaseName(dbPath);
154 : :
155 [ + - - + ]: 347 : if (!m_db.open()) {
156 [ # # # # ]: 0 : m_lastError = m_db.lastError().text();
157 [ # # # # : 0 : qCWarning(lcCache) << "Failed to open database:" << m_lastError;
# # # # #
# ]
158 : 0 : return false;
159 : : }
160 [ + - - + ]: 347 : if (!DatabaseSecurity::restrictExistingFile(dbPath)) {
161 : 0 : m_lastError = QStringLiteral("Failed to restrict database permissions");
162 [ # # # # : 0 : qCWarning(lcCache) << m_lastError << dbPath;
# # # # #
# ]
163 [ # # ]: 0 : m_db.close();
164 : 0 : return false;
165 : : }
166 : :
167 : : // Performance: WAL journal for fast concurrent reads + writes
168 [ + - ]: 347 : QSqlQuery pragma(m_db);
169 [ + - ]: 347 : pragma.exec(QStringLiteral("PRAGMA journal_mode = WAL"));
170 [ + - ]: 347 : pragma.exec(QStringLiteral("PRAGMA synchronous = NORMAL"));
171 [ + - ]: 347 : pragma.exec(QStringLiteral("PRAGMA foreign_keys = ON"));
172 : : // T-619/SEC-19: Prevent SQLITE_BUSY errors during concurrent access
173 [ + - ]: 347 : pragma.exec(QStringLiteral("PRAGMA busy_timeout = 5000"));
174 : :
175 : : // Schema versioning
176 : : static constexpr int SCHEMA_VERSION = 16; // index HTML mail bodies for search
177 : 347 : int currentVersion = 0;
178 [ + - + + : 694 : if (pragma.exec(QStringLiteral("PRAGMA user_version")) && pragma.next()) {
+ - + - +
- + - + +
- - - - ]
179 [ + - + - ]: 346 : currentVersion = pragma.value(0).toInt();
180 : : }
181 [ + - ]: 347 : pragma.finish();
182 : 347 : bool needsVersionAfterSchema = false;
183 [ + + ]: 347 : if (currentVersion < SCHEMA_VERSION) {
184 [ + - + + ]: 238 : if (!runCacheMigration(m_db, currentVersion, SCHEMA_VERSION,
185 : : &m_lastError, &needsVersionAfterSchema)) {
186 [ + - ]: 4 : m_db.close();
187 : 4 : return false;
188 : : }
189 : : }
190 : :
191 [ + - + + ]: 343 : if (!createSchema()) {
192 : 7 : m_lastError = QStringLiteral("Failed to create schema");
193 [ + - + - : 14 : qCWarning(lcCache) << m_lastError;
+ - + + ]
194 [ + - ]: 7 : m_db.close();
195 : 7 : return false;
196 : : }
197 : :
198 [ + + ]: 564 : if (needsVersionAfterSchema &&
199 [ + - - + : 792 : !pragma.exec(QStringLiteral("PRAGMA user_version = %1")
+ + - + -
- - - ]
200 [ + - + + : 564 : .arg(SCHEMA_VERSION))) {
+ + - - ]
201 [ # # # # ]: 0 : m_lastError = pragma.lastError().text();
202 [ # # # # : 0 : qCWarning(lcCache) << "Failed to set schema version:" << m_lastError;
# # # # #
# ]
203 [ # # ]: 0 : m_db.close();
204 : 0 : return false;
205 : : }
206 : :
207 [ + - ]: 336 : m_payloadCacheBytes = calculatePayloadCacheSize();
208 [ + - ]: 336 : enforcePayloadCacheLimit();
209 : :
210 [ + - + - : 672 : qCInfo(lcCache) << "Database opened:" << dbPath;
+ - + - +
+ ]
211 : 336 : return true;
212 : 347 : }
213 : :
214 : 480 : void MailCache::close() {
215 [ + + ]: 480 : if (m_db.isOpen()) {
216 : 336 : m_db.close();
217 : : }
218 : : // T-402/Bug 20: Release reference before removeDatabase
219 [ + - + - ]: 480 : m_db = QSqlDatabase();
220 [ + + ]: 480 : if (!m_connectionName.isEmpty()) {
221 : 347 : QSqlDatabase::removeDatabase(m_connectionName);
222 : 347 : m_connectionName.clear();
223 : : }
224 : 480 : }
225 : :
226 : 57 : bool MailCache::isOpen() const { return m_db.isOpen(); }
227 : :
228 : 11 : QString MailCache::lastError() const { return m_lastError; }
229 : :
230 : 343 : bool MailCache::createSchema() {
231 [ + - ]: 343 : QSqlQuery q(m_db);
232 : :
233 [ + - ]: 343 : bool ok = q.exec(QStringLiteral("CREATE TABLE IF NOT EXISTS folders ("
234 : : " id INTEGER PRIMARY KEY AUTOINCREMENT,"
235 : : " account TEXT NOT NULL,"
236 : : " path TEXT NOT NULL,"
237 : : " uidvalidity INTEGER DEFAULT 0,"
238 : : " last_sync INTEGER DEFAULT 0,"
239 : : " highest_modseq INTEGER DEFAULT 0,"
240 : : " UNIQUE(account, path)"
241 : : ")"));
242 [ + + ]: 343 : if (!ok) {
243 [ + - + - : 2 : qCWarning(lcCache) << "Failed to create folders table:"
+ - + + ]
244 [ + - + - : 1 : << q.lastError().text();
+ - ]
245 : 1 : return false;
246 : : }
247 : :
248 [ + - ]: 342 : ok = q.exec(QStringLiteral(
249 : : "CREATE TABLE IF NOT EXISTS headers ("
250 : : " id INTEGER PRIMARY KEY AUTOINCREMENT,"
251 : : " folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,"
252 : : " uid INTEGER NOT NULL,"
253 : : " subject TEXT,"
254 : : " from_addr TEXT,"
255 : : " to_addr TEXT,"
256 : : " date INTEGER,"
257 : : " flags INTEGER DEFAULT 0,"
258 : : " size INTEGER DEFAULT 0,"
259 : : " has_attachments INTEGER DEFAULT 0,"
260 : : " message_id TEXT,"
261 : : " in_reply_to TEXT,"
262 : : " ref_ids TEXT,"
263 : : " is_spam INTEGER DEFAULT 0,"
264 : : " UNIQUE(folder_id, uid)"
265 : : ")"));
266 [ + + ]: 342 : if (!ok) {
267 [ + - + - : 2 : qCWarning(lcCache) << "Failed to create headers table:"
+ - + + ]
268 [ + - + - : 1 : << q.lastError().text();
+ - ]
269 : 1 : return false;
270 : : }
271 : :
272 [ + - ]: 341 : ok = q.exec(QStringLiteral("CREATE TABLE IF NOT EXISTS bodies ("
273 : : " header_id INTEGER PRIMARY KEY REFERENCES "
274 : : "headers(id) ON DELETE CASCADE,"
275 : : " text_plain TEXT,"
276 : : " text_html TEXT,"
277 : : " raw_body BLOB,"
278 : : " fetched_at INTEGER"
279 : : ")"));
280 [ + + ]: 341 : if (!ok) {
281 [ + - + - : 2 : qCWarning(lcCache) << "Failed to create bodies table:"
+ - + + ]
282 [ + - + - : 1 : << q.lastError().text();
+ - ]
283 : 1 : return false;
284 : : }
285 : :
286 : : // Indexes for fast folder-based queries
287 [ + - ]: 340 : q.exec(QStringLiteral("CREATE INDEX IF NOT EXISTS idx_headers_folder_date "
288 : : "ON headers(folder_id, date DESC)"));
289 [ + - ]: 340 : q.exec(QStringLiteral("CREATE INDEX IF NOT EXISTS idx_headers_folder_uid "
290 : : "ON headers(folder_id, uid)"));
291 : : // T-096: Index for thread lookups by message_id
292 [ + - ]: 340 : q.exec(QStringLiteral("CREATE INDEX IF NOT EXISTS idx_headers_msgid "
293 : : "ON headers(message_id)"));
294 [ + - ]: 340 : q.exec(QStringLiteral("CREATE INDEX IF NOT EXISTS idx_bodies_fetched_at "
295 : : "ON bodies(fetched_at)"));
296 : :
297 : : // Attachments table: BLOB data stored for lazy loading
298 [ + - ]: 340 : ok = q.exec(QStringLiteral(
299 : : "CREATE TABLE IF NOT EXISTS attachments ("
300 : : " id INTEGER PRIMARY KEY AUTOINCREMENT,"
301 : : " header_id INTEGER NOT NULL REFERENCES headers(id) ON DELETE CASCADE,"
302 : : " filename TEXT,"
303 : : " content_type TEXT,"
304 : : " size INTEGER DEFAULT 0,"
305 : : " content_id TEXT,"
306 : : " data BLOB"
307 : : ")"));
308 [ + + ]: 340 : if (!ok) {
309 [ + - + - : 2 : qCWarning(lcCache) << "Failed to create attachments table:"
+ - + + ]
310 [ + - + - : 1 : << q.lastError().text();
+ - ]
311 : 1 : return false;
312 : : }
313 : :
314 [ + - ]: 339 : q.exec(QStringLiteral("CREATE INDEX IF NOT EXISTS idx_attachments_header "
315 : : "ON attachments(header_id)"));
316 : :
317 : : // T-075: Badge cache table for persisting STATUS UNSEEN counts
318 [ + - ]: 339 : ok = q.exec(QStringLiteral(
319 : : "CREATE TABLE IF NOT EXISTS folder_badges ("
320 : : " folder_id INTEGER PRIMARY KEY REFERENCES folders(id) ON DELETE CASCADE,"
321 : : " unseen INTEGER DEFAULT 0,"
322 : : " updated_at INTEGER DEFAULT 0"
323 : : ")"));
324 [ + + ]: 339 : if (!ok) {
325 [ + - + - : 2 : qCWarning(lcCache) << "Failed to create folder_badges table:"
+ - + + ]
326 [ + - + - : 1 : << q.lastError().text();
+ - ]
327 : 1 : return false;
328 : : }
329 : :
330 : : // T-086: Labels table for IMAP keywords
331 [ + - ]: 338 : ok = q.exec(QStringLiteral(
332 : : "CREATE TABLE IF NOT EXISTS mail_labels ("
333 : : " id INTEGER PRIMARY KEY AUTOINCREMENT,"
334 : : " header_id INTEGER NOT NULL REFERENCES headers(id) ON DELETE CASCADE,"
335 : : " label TEXT NOT NULL,"
336 : : " UNIQUE(header_id, label)"
337 : : ")"));
338 [ + + ]: 338 : if (!ok) {
339 [ + - + - : 2 : qCWarning(lcCache) << "Failed to create mail_labels table:"
+ - + + ]
340 [ + - + - : 1 : << q.lastError().text();
+ - ]
341 : 1 : return false;
342 : : }
343 : :
344 [ + - ]: 337 : q.exec(QStringLiteral("CREATE INDEX IF NOT EXISTS idx_mail_labels_header "
345 : : "ON mail_labels(header_id)"));
346 : :
347 : : // T-122: External content whitelist
348 [ + - ]: 337 : ok = q.exec(QStringLiteral(
349 : : "CREATE TABLE IF NOT EXISTS external_content_whitelist ("
350 : : " id INTEGER PRIMARY KEY AUTOINCREMENT,"
351 : : " type TEXT NOT NULL CHECK(type IN ('sender','domain')),"
352 : : " value TEXT NOT NULL,"
353 : : " created_at TEXT DEFAULT (datetime('now')),"
354 : : " UNIQUE(type, value)"
355 : : ")"));
356 [ + + ]: 337 : if (!ok) {
357 [ + - + - : 2 : qCWarning(lcCache) << "Failed to create whitelist table:"
+ - + + ]
358 [ + - + - : 1 : << q.lastError().text();
+ - ]
359 : 1 : return false;
360 : : }
361 : :
362 : : // SEC-2026-07-21-23: cache_meta table for crash-safe FTS rebuild watermark.
363 [ + - ]: 336 : ok = q.exec(QStringLiteral("CREATE TABLE IF NOT EXISTS cache_meta ("
364 : : " key TEXT PRIMARY KEY,"
365 : : " value TEXT"
366 : : ")"));
367 [ - + ]: 336 : if (!ok) {
368 [ # # # # : 0 : qCWarning(lcCache) << "Failed to create cache_meta table:"
# # # # ]
369 [ # # # # : 0 : << q.lastError().text();
# # ]
370 : 0 : return false;
371 : : }
372 : :
373 : : // T-179: FTS5 full-text search index on cached emails.
374 : : // Uses the trigram tokenizer for substring matching ANYWHERE in a word
375 : : // (e.g. "rechnung" matches "Stromrechnung") — important for German compound
376 : : // words. Diacritic insensitivity is handled by folding the text ourselves
377 : : // (foldForSearch) before it is stored, because trigram's remove_diacritics
378 : : // option only exists in SQLite >= 3.45. Trigram requires search terms of at
379 : : // least 3 characters.
380 [ + - ]: 336 : ok = q.exec(QStringLiteral(
381 : : "CREATE VIRTUAL TABLE IF NOT EXISTS mail_fts USING fts5("
382 : : " subject, from_addr, to_addr, body_text,"
383 : : " tokenize = 'trigram'"
384 : : ")"));
385 [ + + ]: 336 : if (!ok) {
386 [ + - + - : 2 : qCWarning(lcCache) << "Failed to create FTS5 table:"
+ - + + ]
387 [ + - + - : 1 : << q.lastError().text();
+ - ]
388 : : // Non-fatal: search will be unavailable but core functionality works
389 : : }
390 : :
391 : 336 : return true;
392 : 343 : }
393 : :
394 : : // --- Folder operations ---
395 : :
396 : 546 : qint64 MailCache::ensureFolder(const QString &account, const QString &path) {
397 [ + - ]: 546 : QSqlQuery q(m_db);
398 : :
399 : : // Try to find existing
400 [ + - ]: 546 : q.prepare(QStringLiteral(
401 : : "SELECT id FROM folders WHERE account = :account AND path = :path"));
402 [ + - ]: 1092 : q.bindValue(QStringLiteral(":account"), account);
403 [ + - ]: 1092 : q.bindValue(QStringLiteral(":path"), path);
404 : :
405 [ + - + + : 546 : if (q.exec() && q.next()) {
+ - + + +
+ ]
406 [ + - + - ]: 251 : return q.value(0).toLongLong();
407 : : }
408 : :
409 : : // Insert new
410 [ + - ]: 295 : q.prepare(QStringLiteral(
411 : : "INSERT INTO folders (account, path) VALUES (:account, :path)"));
412 [ + - ]: 590 : q.bindValue(QStringLiteral(":account"), account);
413 [ + - ]: 590 : q.bindValue(QStringLiteral(":path"), path);
414 : :
415 [ + - + + ]: 295 : if (!q.exec()) {
416 [ + - + - ]: 17 : m_lastError = q.lastError().text();
417 [ + - + - : 34 : qCWarning(lcCache) << "Failed to insert folder:" << m_lastError;
+ - + - +
+ ]
418 : 17 : return -1;
419 : : }
420 : :
421 [ + - + - ]: 278 : return q.lastInsertId().toLongLong();
422 : 546 : }
423 : :
424 : 62 : QString MailCache::folderPath(qint64 folderId) const {
425 [ + - ]: 62 : QSqlQuery q(m_db);
426 [ + - ]: 62 : q.prepare(QStringLiteral("SELECT path FROM folders WHERE id = :id"));
427 [ + - ]: 124 : q.bindValue(QStringLiteral(":id"), folderId);
428 [ + - + + : 62 : if (q.exec() && q.next())
+ - + - +
+ ]
429 [ + - + - ]: 61 : return q.value(0).toString();
430 : 1 : return {};
431 : 62 : }
432 : :
433 : : std::optional<QPair<qint64,qint64>>
434 : 6 : MailCache::findByMessageId(const QString &messageId) const {
435 [ - + ]: 6 : if (messageId.isEmpty()) return std::nullopt;
436 [ + - ]: 6 : QSqlQuery q(m_db);
437 [ + - ]: 6 : q.prepare(QStringLiteral(
438 : : "SELECT folder_id, uid FROM headers WHERE message_id = :mid LIMIT 1"));
439 [ + - ]: 12 : q.bindValue(QStringLiteral(":mid"), messageId);
440 [ + - + + : 6 : if (q.exec() && q.next())
+ - + + +
+ ]
441 [ + - + - ]: 6 : return QPair<qint64,qint64>{q.value(0).toLongLong(),
442 [ + - + - ]: 3 : q.value(1).toLongLong()};
443 : 3 : return std::nullopt;
444 : 6 : }
445 : :
446 : 37 : void MailCache::setUidValidity(qint64 folderId, quint32 uidvalidity) {
447 [ + - ]: 37 : quint32 current = this->uidValidity(folderId);
448 : :
449 : : // If UIDVALIDITY changed, all cached data is invalid (RFC 3501)
450 [ + + + + ]: 37 : if (current != 0 && current != uidvalidity) {
451 [ + - + - : 6 : qCInfo(lcCache) << "UIDVALIDITY changed for folder" << folderId << "from"
+ - + - +
- + + ]
452 [ + - + - : 3 : << current << "to" << uidvalidity << "- purging cache";
+ - + - ]
453 [ + - ]: 3 : purgeFolder(folderId);
454 : : }
455 : :
456 [ + - ]: 37 : QSqlQuery q(m_db);
457 [ + - ]: 37 : q.prepare(
458 : 74 : QStringLiteral("UPDATE folders SET uidvalidity = :uv WHERE id = :id"));
459 [ + - ]: 74 : q.bindValue(QStringLiteral(":uv"), uidvalidity);
460 [ + - ]: 74 : q.bindValue(QStringLiteral(":id"), folderId);
461 [ + - ]: 37 : q.exec();
462 : 37 : }
463 : :
464 : 43 : quint32 MailCache::uidValidity(qint64 folderId) const {
465 [ + - ]: 43 : QSqlQuery q(m_db);
466 [ + - ]: 43 : q.prepare(QStringLiteral("SELECT uidvalidity FROM folders WHERE id = :id"));
467 [ + - ]: 86 : q.bindValue(QStringLiteral(":id"), folderId);
468 : :
469 [ + - + + : 43 : if (q.exec() && q.next()) {
+ - + - +
+ ]
470 [ + - + - ]: 41 : return q.value(0).toUInt();
471 : : }
472 : 2 : return 0;
473 : 43 : }
474 : :
475 : : // T-208: CONDSTORE HIGHESTMODSEQ persistence
476 : 8 : void MailCache::setHighestModseq(qint64 folderId, quint64 modseq) {
477 [ + - ]: 8 : QSqlQuery q(m_db);
478 [ + - ]: 8 : q.prepare(QStringLiteral(
479 : : "UPDATE folders SET highest_modseq = :ms WHERE id = :id"));
480 [ + - ]: 16 : q.bindValue(QStringLiteral(":ms"), static_cast<qint64>(modseq));
481 [ + - ]: 16 : q.bindValue(QStringLiteral(":id"), folderId);
482 [ + - ]: 8 : q.exec();
483 : 8 : }
484 : :
485 : 8 : quint64 MailCache::highestModseq(qint64 folderId) const {
486 [ + - ]: 8 : QSqlQuery q(m_db);
487 [ + - ]: 8 : q.prepare(
488 : 16 : QStringLiteral("SELECT highest_modseq FROM folders WHERE id = :id"));
489 [ + - ]: 16 : q.bindValue(QStringLiteral(":id"), folderId);
490 : :
491 [ + - + + : 8 : if (q.exec() && q.next()) {
+ - + - +
+ ]
492 [ + - + - ]: 7 : return q.value(0).toULongLong();
493 : : }
494 : 1 : return 0;
495 : 8 : }
496 : :
497 : : // T-209: Last sync timestamp
498 : 60 : void MailCache::setLastSync(qint64 folderId) {
499 [ + - ]: 60 : QSqlQuery q(m_db);
500 [ + - ]: 60 : q.prepare(QStringLiteral(
501 : : "UPDATE folders SET last_sync = :ts WHERE id = :id"));
502 [ + - ]: 120 : q.bindValue(QStringLiteral(":ts"), QDateTime::currentSecsSinceEpoch());
503 [ + - ]: 120 : q.bindValue(QStringLiteral(":id"), folderId);
504 [ + - ]: 60 : q.exec();
505 : 60 : }
506 : :
507 : 5 : qint64 MailCache::lastSync(qint64 folderId) const {
508 [ + - ]: 5 : QSqlQuery q(m_db);
509 [ + - ]: 5 : q.prepare(
510 : 10 : QStringLiteral("SELECT last_sync FROM folders WHERE id = :id"));
511 [ + - ]: 10 : q.bindValue(QStringLiteral(":id"), folderId);
512 : :
513 [ + - + + : 5 : if (q.exec() && q.next()) {
+ - + - +
+ ]
514 [ + - + - ]: 4 : return q.value(0).toLongLong();
515 : : }
516 : 1 : return 0;
517 : 5 : }
518 : :
519 : : // --- Header operations ---
520 : :
521 : 281 : void MailCache::storeHeaders(qint64 folderId,
522 : : const QList<MailHeader> &headers) {
523 [ - + ]: 281 : if (headers.isEmpty())
524 : 0 : return;
525 : :
526 [ + - ]: 281 : m_db.transaction();
527 : :
528 [ + - ]: 281 : QSqlQuery q(m_db);
529 [ + - ]: 281 : q.prepare(QStringLiteral(
530 : : "INSERT INTO headers "
531 : : "(folder_id, uid, subject, from_addr, to_addr, date, flags, size, "
532 : : "has_attachments, message_id, in_reply_to, ref_ids, is_spam) "
533 : : "VALUES (:fid, :uid, :subj, :from, :to, :date, :flags, :size, :att, "
534 : : ":msgid, :irt, :refs, :spam) "
535 : : "ON CONFLICT(folder_id, uid) DO UPDATE SET "
536 : : "subject = excluded.subject, "
537 : : "from_addr = excluded.from_addr, "
538 : : "to_addr = excluded.to_addr, "
539 : : "date = excluded.date, "
540 : : "flags = excluded.flags, "
541 : : "size = excluded.size, "
542 : : "has_attachments = excluded.has_attachments, "
543 : : "message_id = excluded.message_id, "
544 : : "in_reply_to = excluded.in_reply_to, "
545 : : "ref_ids = excluded.ref_ids, "
546 : : "is_spam = excluded.is_spam"));
547 : :
548 [ + - ]: 281 : QSqlQuery labelQ(m_db);
549 [ + - ]: 281 : labelQ.prepare(QStringLiteral(
550 : : "INSERT OR IGNORE INTO mail_labels (header_id, label) "
551 : : "VALUES (:hid, :label)"));
552 : :
553 [ + + ]: 3469 : for (const auto &h : headers) {
554 [ + - ]: 6376 : q.bindValue(QStringLiteral(":fid"), folderId);
555 [ + - ]: 6376 : q.bindValue(QStringLiteral(":uid"), h.uid);
556 [ + - ]: 6376 : q.bindValue(QStringLiteral(":subj"), h.subject);
557 [ + - ]: 6376 : q.bindValue(QStringLiteral(":from"), h.from);
558 [ + - ]: 6376 : q.bindValue(QStringLiteral(":to"), h.to);
559 [ + - + - ]: 6376 : q.bindValue(QStringLiteral(":date"), h.date.toSecsSinceEpoch());
560 [ + - ]: 6376 : q.bindValue(QStringLiteral(":flags"), h.flags);
561 [ + - ]: 6376 : q.bindValue(QStringLiteral(":size"), h.size);
562 [ + + + - ]: 6376 : q.bindValue(QStringLiteral(":att"), h.hasAttachments ? 1 : 0);
563 [ + - ]: 6376 : q.bindValue(QStringLiteral(":msgid"), h.messageId);
564 [ + - ]: 6376 : q.bindValue(QStringLiteral(":irt"), h.inReplyTo);
565 [ + - + - ]: 6376 : q.bindValue(QStringLiteral(":refs"), h.references.join(' '));
566 [ + + + - ]: 6376 : q.bindValue(QStringLiteral(":spam"), h.isSpam ? 1 : 0);
567 : :
568 [ + - + + ]: 3188 : if (!q.exec()) {
569 [ + - + - : 4 : qCWarning(lcCache) << "Failed to insert header UID" << h.uid << ":"
+ - + - +
- + + ]
570 [ + - + - : 2 : << q.lastError().text();
+ - ]
571 : 2 : continue;
572 : 2 : }
573 : :
574 : : // T-79.F3/M10: headers from FETCH carry the authoritative keyword
575 : : // list — reconcile instead of insert-only, otherwise keywords removed
576 : : // on the server (or in another client) persist locally forever and
577 : : // tag filters keep matching mails that no longer carry them.
578 [ + - ]: 3186 : qint64 hid = headerRowId(folderId, h.uid);
579 [ + - ]: 3186 : if (hid > 0) {
580 [ + - ]: 3186 : QSqlQuery labelDeleteQ(m_db);
581 [ + + ]: 3186 : if (h.labels.isEmpty()) {
582 [ + - ]: 3135 : labelDeleteQ.prepare(QStringLiteral(
583 : : "DELETE FROM mail_labels WHERE header_id = ?"));
584 [ + - ]: 3135 : labelDeleteQ.addBindValue(hid);
585 : : } else {
586 : 51 : QStringList placeholders;
587 [ + + ]: 146 : for (int i = 0; i < h.labels.size(); ++i)
588 [ + - ]: 95 : placeholders << QStringLiteral("?");
589 [ + - ]: 51 : labelDeleteQ.prepare(
590 : 102 : QStringLiteral("DELETE FROM mail_labels WHERE header_id = ? "
591 : : "AND label NOT IN (%1)")
592 [ + - + - ]: 153 : .arg(placeholders.join(QLatin1Char(','))));
593 [ + - ]: 51 : labelDeleteQ.addBindValue(hid);
594 [ + + ]: 146 : for (const auto &label : h.labels)
595 [ + - ]: 95 : labelDeleteQ.addBindValue(label);
596 : 51 : }
597 [ + - - + ]: 3186 : if (!labelDeleteQ.exec()) {
598 [ # # # # : 0 : qCWarning(lcCache) << "Label reconcile failed for UID" << h.uid
# # # # #
# ]
599 [ # # # # : 0 : << ":" << labelDeleteQ.lastError().text();
# # # # ]
600 : : }
601 [ + + ]: 3281 : for (const auto &label : h.labels) {
602 [ + - ]: 190 : labelQ.bindValue(QStringLiteral(":hid"), hid);
603 [ + - ]: 190 : labelQ.bindValue(QStringLiteral(":label"), label);
604 [ + - ]: 95 : labelQ.exec();
605 : : }
606 : 3186 : }
607 : : }
608 : :
609 [ + - ]: 281 : m_db.commit();
610 : :
611 : : // Auto-index for FTS so subject/from/to are immediately searchable
612 [ + + ]: 3469 : for (const auto &h : headers) {
613 [ + - ]: 3188 : indexForSearch(folderId, h.uid);
614 : : }
615 : 281 : }
616 : :
617 : 67 : QList<MailHeader> MailCache::headers(qint64 folderId) const {
618 : 67 : QList<MailHeader> result;
619 [ + - ]: 67 : QSqlQuery q(m_db);
620 [ + - ]: 67 : q.prepare(QStringLiteral(
621 : : "SELECT uid, subject, from_addr, to_addr, date, flags, size, "
622 : : "has_attachments, message_id, in_reply_to, ref_ids, is_spam "
623 : : "FROM headers WHERE folder_id = :fid ORDER BY date DESC"));
624 [ + - ]: 134 : q.bindValue(QStringLiteral(":fid"), folderId);
625 : :
626 [ + - + + ]: 67 : if (!q.exec())
627 : 1 : return result;
628 : :
629 [ + - + + ]: 307 : while (q.next()) {
630 : 241 : MailHeader h;
631 : 241 : h.folderId = folderId;
632 [ + - + - ]: 241 : h.uid = q.value(0).toLongLong();
633 [ + - + - ]: 241 : h.subject = q.value(1).toString();
634 [ + - + - ]: 241 : h.from = q.value(2).toString();
635 [ + - + - ]: 241 : h.to = q.value(3).toString();
636 [ + - + - : 241 : h.date = QDateTime::fromSecsSinceEpoch(q.value(4).toLongLong());
+ - ]
637 [ + - + - ]: 241 : h.flags = q.value(5).toUInt();
638 [ + - + - ]: 241 : h.size = q.value(6).toLongLong();
639 [ + - + - ]: 241 : h.hasAttachments = q.value(7).toBool();
640 [ + - + - ]: 241 : h.messageId = q.value(8).toString();
641 [ + - + - ]: 241 : h.inReplyTo = q.value(9).toString();
642 [ + - + - ]: 241 : QString refs = q.value(10).toString();
643 [ + + ]: 241 : if (!refs.isEmpty())
644 [ + - ]: 35 : h.references = refs.split(' ', Qt::SkipEmptyParts);
645 [ + - + - ]: 241 : h.isSpam = q.value(11).toBool();
646 [ + - ]: 241 : result.append(h);
647 : 241 : }
648 : :
649 : : // Load labels for all headers in this folder
650 [ + + ]: 66 : if (!result.isEmpty()) {
651 [ + - ]: 46 : QSqlQuery lq(m_db);
652 [ + - ]: 46 : lq.prepare(QStringLiteral(
653 : : "SELECT h.uid, ml.label FROM mail_labels ml "
654 : : "JOIN headers h ON ml.header_id = h.id "
655 : : "WHERE h.folder_id = :fid"));
656 [ + - ]: 92 : lq.bindValue(QStringLiteral(":fid"), folderId);
657 [ + - + - ]: 46 : if (lq.exec()) {
658 : : // Build uid → index map for efficient lookup
659 : 46 : QHash<qint64, int> uidIndex;
660 [ + + ]: 287 : for (int i = 0; i < result.size(); ++i) {
661 [ + - + - ]: 241 : uidIndex[result[i].uid] = i;
662 : : }
663 [ + - + + ]: 48 : while (lq.next()) {
664 [ + - + - ]: 2 : qint64 uid = lq.value(0).toLongLong();
665 [ + - ]: 2 : auto it = uidIndex.find(uid);
666 [ + - ]: 2 : if (it != uidIndex.end()) {
667 [ + - + - : 2 : result[*it].labels.append(lq.value(1).toString());
+ - + - ]
668 : : }
669 : : }
670 : 46 : }
671 : 46 : }
672 : :
673 : 66 : return result;
674 : 67 : }
675 : :
676 : 513 : std::optional<MailHeader> MailCache::header(qint64 folderId, qint64 uid) const {
677 [ + - ]: 513 : QSqlQuery q(m_db);
678 [ + - ]: 513 : q.prepare(
679 : 1026 : QStringLiteral("SELECT subject, from_addr, to_addr, date, flags, size, "
680 : : "has_attachments, message_id, in_reply_to, ref_ids, "
681 : : "is_spam "
682 : : "FROM headers WHERE folder_id = :fid AND uid = :uid"));
683 [ + - ]: 1026 : q.bindValue(QStringLiteral(":fid"), folderId);
684 [ + - ]: 1026 : q.bindValue(QStringLiteral(":uid"), uid);
685 : :
686 [ + - + + : 513 : if (q.exec() && q.next()) {
+ - + + +
+ ]
687 : 444 : MailHeader h;
688 : 444 : h.uid = uid;
689 : 444 : h.folderId = folderId;
690 [ + - + - ]: 444 : h.subject = q.value(0).toString();
691 [ + - + - ]: 444 : h.from = q.value(1).toString();
692 [ + - + - ]: 444 : h.to = q.value(2).toString();
693 [ + - + - : 444 : h.date = QDateTime::fromSecsSinceEpoch(q.value(3).toLongLong());
+ - ]
694 [ + - + - ]: 444 : h.flags = q.value(4).toUInt();
695 [ + - + - ]: 444 : h.size = q.value(5).toLongLong();
696 [ + - + - ]: 444 : h.hasAttachments = q.value(6).toBool();
697 [ + - + - ]: 444 : h.messageId = q.value(7).toString();
698 [ + - + - ]: 444 : h.inReplyTo = q.value(8).toString();
699 [ + - + - ]: 444 : QString refs = q.value(9).toString();
700 [ + + ]: 444 : if (!refs.isEmpty())
701 [ + - ]: 59 : h.references = refs.split(' ', Qt::SkipEmptyParts);
702 [ + - + - ]: 444 : h.isSpam = q.value(10).toBool();
703 : :
704 : : // Load labels
705 [ + - ]: 444 : qint64 hid = headerRowId(folderId, uid);
706 [ + - ]: 444 : if (hid > 0) {
707 [ + - ]: 444 : QSqlQuery lq(m_db);
708 [ + - ]: 444 : lq.prepare(QStringLiteral(
709 : : "SELECT label FROM mail_labels WHERE header_id = :hid"));
710 [ + - ]: 888 : lq.bindValue(QStringLiteral(":hid"), hid);
711 [ + - + - ]: 444 : if (lq.exec()) {
712 [ + - + + ]: 484 : while (lq.next()) {
713 [ + - + - : 40 : h.labels.append(lq.value(0).toString());
+ - ]
714 : : }
715 : : }
716 : 444 : }
717 : 444 : return h;
718 : 444 : }
719 : 69 : return std::nullopt;
720 : 513 : }
721 : :
722 : 76 : qint64 MailCache::maxUid(qint64 folderId) const {
723 [ + - ]: 76 : QSqlQuery q(m_db);
724 [ + - ]: 76 : q.prepare(
725 : 152 : QStringLiteral("SELECT MAX(uid) FROM headers WHERE folder_id = :fid"));
726 [ + - ]: 152 : q.bindValue(QStringLiteral(":fid"), folderId);
727 : :
728 [ + - + + : 76 : if (q.exec() && q.next()) {
+ - + - +
+ ]
729 [ + - + - ]: 75 : return q.value(0).toLongLong(); // Returns 0 if NULL (no rows)
730 : : }
731 : 1 : return 0;
732 : 76 : }
733 : :
734 : 96 : int MailCache::headerCount(qint64 folderId) const {
735 [ + - ]: 96 : QSqlQuery q(m_db);
736 [ + - ]: 96 : q.prepare(
737 : 192 : QStringLiteral("SELECT COUNT(*) FROM headers WHERE folder_id = :fid"));
738 [ + - ]: 192 : q.bindValue(QStringLiteral(":fid"), folderId);
739 : :
740 [ + - + + : 96 : if (q.exec() && q.next()) {
+ - + - +
+ ]
741 [ + - + - ]: 95 : return q.value(0).toInt();
742 : : }
743 : 1 : return 0;
744 : 96 : }
745 : :
746 : : // --- Body operations ---
747 : :
748 : 7239 : qint64 MailCache::headerRowId(qint64 folderId, qint64 uid) const {
749 [ + - ]: 7239 : QSqlQuery q(m_db);
750 [ + - ]: 7239 : q.prepare(QStringLiteral(
751 : : "SELECT id FROM headers WHERE folder_id = :fid AND uid = :uid"));
752 [ + - ]: 14478 : q.bindValue(QStringLiteral(":fid"), folderId);
753 [ + - ]: 14478 : q.bindValue(QStringLiteral(":uid"), uid);
754 : :
755 [ + - + + : 7239 : if (q.exec() && q.next()) {
+ - + + +
+ ]
756 [ + - + - ]: 7224 : return q.value(0).toLongLong();
757 : : }
758 : 15 : return -1;
759 : 7239 : }
760 : :
761 : 82 : void MailCache::storeBody(qint64 folderId, qint64 uid, const MailBody &body) {
762 [ + - ]: 82 : qint64 hid = headerRowId(folderId, uid);
763 [ + + ]: 82 : if (hid < 0) {
764 [ + - + - : 2 : qCWarning(lcCache) << "Cannot store body: header not found for UID" << uid;
+ - + - +
+ ]
765 : 2 : return;
766 : : }
767 : :
768 : 81 : qint64 previousBytes = 0;
769 [ + - ]: 81 : QSqlQuery previous(m_db);
770 [ + - ]: 81 : previous.prepare(QStringLiteral(
771 : : "SELECT COALESCE(length(CAST(text_plain AS BLOB)), 0) + "
772 : : "COALESCE(length(CAST(text_html AS BLOB)), 0) + "
773 : : "COALESCE(length(raw_body), 0) "
774 : : "FROM bodies WHERE header_id = :hid"));
775 [ + - ]: 162 : previous.bindValue(QStringLiteral(":hid"), hid);
776 [ + - + - : 81 : if (previous.exec() && previous.next())
+ - + + +
+ ]
777 [ + - + - ]: 2 : previousBytes = previous.value(0).toLongLong();
778 : :
779 [ + - ]: 81 : const qint64 currentBytes = body.textPlain.toUtf8().size() +
780 [ + - ]: 162 : body.textHtml.toUtf8().size() +
781 : 81 : body.rawSource.size();
782 : 81 : const qint64 additionalBytes = qMax<qint64>(0, currentBytes - previousBytes);
783 [ + - + + ]: 81 : if (!enforcePayloadCacheLimit(hid, additionalBytes)) {
784 [ + - + - : 2 : qCWarning(lcCache) << "Refusing to store body above payload cache budget"
+ - + + ]
785 [ + - + - : 1 : << "for UID" << uid << "bytes" << currentBytes;
+ - + - ]
786 : 1 : return;
787 : : }
788 : :
789 [ + - ]: 80 : QSqlQuery q(m_db);
790 [ + - ]: 80 : q.prepare(QStringLiteral(
791 : : "INSERT OR REPLACE INTO bodies (header_id, text_plain, text_html, "
792 : : "raw_body, fetched_at) "
793 : : "VALUES (:hid, :plain, :html, :raw, :now)"));
794 [ + - ]: 160 : q.bindValue(QStringLiteral(":hid"), hid);
795 [ + - ]: 160 : q.bindValue(QStringLiteral(":plain"), body.textPlain);
796 [ + - ]: 160 : q.bindValue(QStringLiteral(":html"), body.textHtml);
797 [ + - ]: 160 : q.bindValue(QStringLiteral(":raw"), body.rawSource);
798 [ + - ]: 160 : q.bindValue(QStringLiteral(":now"), QDateTime::currentMSecsSinceEpoch());
799 : :
800 [ + - + + ]: 80 : if (!q.exec()) {
801 [ + - + - ]: 1 : m_lastError = q.lastError().text();
802 [ + - + - : 2 : qCWarning(lcCache) << "Failed to store body:" << m_lastError;
+ - + - +
+ ]
803 : : } else {
804 : 79 : m_payloadCacheBytes += currentBytes - previousBytes;
805 : : // Auto-index for FTS so body text is immediately searchable
806 [ + - ]: 79 : indexForSearch(folderId, uid);
807 [ + - ]: 79 : enforcePayloadCacheLimit(hid);
808 : : }
809 [ + + ]: 81 : }
810 : :
811 : 133 : std::optional<MailBody> MailCache::body(qint64 folderId, qint64 uid) const {
812 [ + - ]: 133 : QSqlQuery q(m_db);
813 [ + - ]: 133 : q.prepare(QStringLiteral(
814 : : "SELECT b.text_plain, b.text_html, b.raw_body FROM bodies b "
815 : : "JOIN headers h ON b.header_id = h.id "
816 : : "WHERE h.folder_id = :fid AND h.uid = :uid"));
817 [ + - ]: 266 : q.bindValue(QStringLiteral(":fid"), folderId);
818 [ + - ]: 266 : q.bindValue(QStringLiteral(":uid"), uid);
819 : :
820 [ + - + + : 133 : if (q.exec() && q.next()) {
+ - + + +
+ ]
821 : 57 : MailBody b;
822 : 57 : b.uid = uid;
823 [ + - + - ]: 57 : b.textPlain = q.value(0).toString();
824 [ + - + - ]: 57 : b.textHtml = q.value(1).toString();
825 [ + - + - ]: 57 : b.rawSource = q.value(2).toByteArray();
826 : 57 : return b;
827 : 57 : }
828 : 76 : return std::nullopt;
829 : 133 : }
830 : :
831 : 72 : bool MailCache::hasBody(qint64 folderId, qint64 uid) const {
832 [ + - ]: 72 : QSqlQuery q(m_db);
833 [ + - ]: 72 : q.prepare(QStringLiteral("SELECT 1 FROM bodies b "
834 : : "JOIN headers h ON b.header_id = h.id "
835 : : "WHERE h.folder_id = :fid AND h.uid = :uid"));
836 [ + - ]: 144 : q.bindValue(QStringLiteral(":fid"), folderId);
837 [ + - ]: 144 : q.bindValue(QStringLiteral(":uid"), uid);
838 : :
839 [ + - + + : 144 : return q.exec() && q.next();
+ - + + ]
840 : 72 : }
841 : :
842 : : // --- Flag operations ---
843 : :
844 : 148 : void MailCache::updateFlags(qint64 folderId, qint64 uid, quint32 flags) {
845 [ + - ]: 148 : QSqlQuery q(m_db);
846 [ + - ]: 148 : q.prepare(QStringLiteral("UPDATE headers SET flags = :flags "
847 : : "WHERE folder_id = :fid AND uid = :uid"));
848 [ + - ]: 296 : q.bindValue(QStringLiteral(":flags"), flags);
849 [ + - ]: 296 : q.bindValue(QStringLiteral(":fid"), folderId);
850 [ + - ]: 296 : q.bindValue(QStringLiteral(":uid"), uid);
851 : :
852 [ + - + + ]: 148 : if (!q.exec()) {
853 [ + - + - ]: 2 : m_lastError = q.lastError().text();
854 [ + - + - : 4 : qCWarning(lcCache) << "Failed to update flags:" << m_lastError;
+ - + - +
+ ]
855 : : }
856 : 148 : }
857 : :
858 : 8 : void MailCache::batchUpdateFlags(
859 : : qint64 folderId, const QList<QPair<qint64, quint32>> &uidFlags) {
860 [ - + ]: 8 : if (uidFlags.isEmpty())
861 : 0 : return;
862 : :
863 [ + - ]: 8 : m_db.transaction();
864 : :
865 [ + - ]: 8 : QSqlQuery q(m_db);
866 [ + - ]: 8 : q.prepare(QStringLiteral("UPDATE headers SET flags = :flags "
867 : : "WHERE folder_id = :fid AND uid = :uid"));
868 : :
869 [ + + ]: 19 : for (const auto &[uid, flags] : uidFlags) {
870 [ + - ]: 22 : q.bindValue(QStringLiteral(":flags"), flags);
871 [ + - ]: 22 : q.bindValue(QStringLiteral(":fid"), folderId);
872 [ + - ]: 22 : q.bindValue(QStringLiteral(":uid"), uid);
873 [ + - ]: 11 : q.exec();
874 : : }
875 : :
876 [ + - ]: 8 : m_db.commit();
877 [ + - + - : 16 : qCInfo(lcCache) << "Batch-updated flags for" << uidFlags.size()
+ - + - +
+ ]
878 [ + - + - ]: 8 : << "headers in folder" << folderId;
879 : 8 : }
880 : :
881 : 15 : void MailCache::removeHeader(qint64 folderId, qint64 uid) {
882 [ + - ]: 15 : const qint64 rowId = headerRowId(folderId, uid);
883 [ + + ]: 15 : if (rowId <= 0)
884 : 4 : return;
885 : :
886 [ + - - + ]: 12 : if (!m_db.transaction()) {
887 [ # # # # : 0 : qCWarning(lcCache) << "Failed to start header removal transaction:"
# # # # ]
888 [ # # # # : 0 : << m_db.lastError().text();
# # ]
889 : 0 : return;
890 : : }
891 : :
892 : : // CASCADE deletes body and attachments automatically.
893 [ + - ]: 12 : QSqlQuery q(m_db);
894 [ + - ]: 12 : q.prepare(QStringLiteral(
895 : : "DELETE FROM headers WHERE folder_id = :fid AND uid = :uid"));
896 [ + - ]: 24 : q.bindValue(QStringLiteral(":fid"), folderId);
897 [ + - ]: 24 : q.bindValue(QStringLiteral(":uid"), uid);
898 : :
899 [ + - + + ]: 12 : if (!q.exec()) {
900 [ + - ]: 1 : m_db.rollback();
901 [ + - + - : 2 : qCWarning(lcCache) << "Failed to remove header:" << q.lastError().text();
+ - + - +
- + - +
+ ]
902 : 1 : return;
903 : : }
904 [ + - - + ]: 11 : if (!deleteSearchIndexEntry(m_db, rowId)) {
905 [ # # ]: 0 : m_db.rollback();
906 [ # # # # : 0 : qCWarning(lcCache) << "Failed to remove header search index:"
# # # # ]
907 [ # # # # : 0 : << m_db.lastError().text();
# # ]
908 : 0 : return;
909 : : }
910 [ + - - + ]: 11 : if (!m_db.commit()) {
911 [ # # ]: 0 : m_db.rollback();
912 [ # # # # : 0 : qCWarning(lcCache) << "Failed to commit header removal:"
# # # # ]
913 [ # # # # : 0 : << m_db.lastError().text();
# # ]
914 : 0 : return;
915 : : }
916 : :
917 [ + - + - : 22 : qCInfo(lcCache) << "Removed header UID" << uid << "from folder" << folderId;
+ - + - +
- + - +
+ ]
918 [ + + ]: 12 : }
919 : :
920 : 5 : int MailCache::unreadCount(qint64 folderId) const {
921 [ + - ]: 5 : QSqlQuery q(m_db);
922 [ + - ]: 5 : q.prepare(QStringLiteral("SELECT COUNT(*) FROM headers "
923 : : "WHERE folder_id = :fid AND (flags & 1) = 0"));
924 [ + - ]: 10 : q.bindValue(QStringLiteral(":fid"), folderId);
925 : :
926 [ + - + + : 5 : if (q.exec() && q.next()) {
+ - + - +
+ ]
927 [ + - + - ]: 4 : return q.value(0).toInt();
928 : : }
929 : 1 : return 0;
930 : 5 : }
931 : :
932 : : // --- Label operations (T-261) ---
933 : :
934 : 36 : void MailCache::addLabel(qint64 folderId, qint64 uid, const QString &label) {
935 [ + - ]: 36 : qint64 hid = headerRowId(folderId, uid);
936 [ + + ]: 36 : if (hid < 0)
937 : 1 : return;
938 [ + - ]: 35 : QSqlQuery q(m_db);
939 [ + - ]: 35 : q.prepare(QStringLiteral(
940 : : "INSERT OR IGNORE INTO mail_labels (header_id, label) "
941 : : "VALUES (:hid, :label)"));
942 [ + - ]: 70 : q.bindValue(QStringLiteral(":hid"), hid);
943 [ + - ]: 70 : q.bindValue(QStringLiteral(":label"), label);
944 [ + - + + ]: 35 : if (!q.exec()) {
945 [ + - + - : 2 : qCWarning(lcCache) << "Failed to add label:" << q.lastError().text();
+ - + - +
- + - +
+ ]
946 : : }
947 : 35 : }
948 : :
949 : 19 : void MailCache::removeLabel(qint64 folderId, qint64 uid,
950 : : const QString &label) {
951 [ + - ]: 19 : qint64 hid = headerRowId(folderId, uid);
952 [ + + ]: 19 : if (hid < 0)
953 : 2 : return;
954 [ + - ]: 17 : QSqlQuery q(m_db);
955 [ + - ]: 17 : q.prepare(QStringLiteral(
956 : : "DELETE FROM mail_labels WHERE header_id = :hid AND label = :label"));
957 [ + - ]: 34 : q.bindValue(QStringLiteral(":hid"), hid);
958 [ + - ]: 34 : q.bindValue(QStringLiteral(":label"), label);
959 [ + - + + ]: 17 : if (!q.exec()) {
960 [ + - + - : 2 : qCWarning(lcCache) << "Failed to remove label:" << q.lastError().text();
+ - + - +
- + - +
+ ]
961 : : }
962 : 17 : }
963 : :
964 : : // --- Maintenance ---
965 : :
966 : 25 : void MailCache::purgeFolder(qint64 folderId) {
967 [ + - - + ]: 25 : if (!m_db.transaction()) {
968 [ # # # # : 0 : qCWarning(lcCache) << "Failed to start folder purge transaction:"
# # # # ]
969 [ # # # # : 0 : << m_db.lastError().text();
# # ]
970 : 3 : return;
971 : : }
972 : :
973 : 25 : QList<qint64> rowIds;
974 [ + - ]: 25 : QSqlQuery indexed(m_db);
975 [ + - ]: 25 : indexed.prepare(QStringLiteral(
976 : : "SELECT h.id FROM headers h WHERE h.folder_id = :fid"));
977 [ + - ]: 50 : indexed.bindValue(QStringLiteral(":fid"), folderId);
978 [ + - + + ]: 25 : if (indexed.exec()) {
979 [ + - + + ]: 49 : while (indexed.next())
980 [ + - + - : 25 : rowIds.append(indexed.value(0).toLongLong());
+ - ]
981 : : } else {
982 [ + - ]: 1 : m_db.rollback();
983 [ + - + - : 2 : qCWarning(lcCache) << "Failed to list folder index entries:"
+ - + + ]
984 [ + - + - : 1 : << indexed.lastError().text();
+ - ]
985 : 1 : return;
986 : : }
987 : :
988 : : // CASCADE will delete bodies and attachments automatically.
989 [ + - ]: 24 : QSqlQuery q(m_db);
990 [ + - ]: 24 : q.prepare(QStringLiteral("DELETE FROM headers WHERE folder_id = :fid"));
991 [ + - ]: 48 : q.bindValue(QStringLiteral(":fid"), folderId);
992 [ + - + + ]: 24 : if (!q.exec()) {
993 [ + - ]: 2 : m_db.rollback();
994 [ + - + - : 4 : qCWarning(lcCache) << "Failed to purge folder:" << q.lastError().text();
+ - + - +
- + - +
+ ]
995 : 2 : return;
996 : : }
997 : :
998 [ + - + - : 45 : for (qint64 rowId : rowIds) {
+ + ]
999 [ + - - + ]: 23 : if (!deleteSearchIndexEntry(m_db, rowId)) {
1000 [ # # ]: 0 : m_db.rollback();
1001 [ # # # # : 0 : qCWarning(lcCache) << "Failed to purge folder search index";
# # # # ]
1002 : 0 : return;
1003 : : }
1004 : : }
1005 : :
1006 [ + - - + ]: 22 : if (!m_db.commit()) {
1007 [ # # ]: 0 : m_db.rollback();
1008 [ # # # # : 0 : qCWarning(lcCache) << "Failed to commit folder purge:"
# # # # ]
1009 [ # # # # : 0 : << m_db.lastError().text();
# # ]
1010 : 0 : return;
1011 : : }
1012 : :
1013 [ + - + - : 44 : qCInfo(lcCache) << "Purged cache for folder" << folderId;
+ - + - +
+ ]
1014 [ + + + + : 30 : }
+ + ]
1015 : :
1016 : : // T-138: Path-based convenience overloads
1017 : :
1018 : 24 : int MailCache::cachedHeaderCount(const QString &account,
1019 : : const QString &folderPath) {
1020 : 24 : qint64 fid = ensureFolder(account, folderPath);
1021 [ + + ]: 24 : return fid > 0 ? headerCount(fid) : 0;
1022 : : }
1023 : :
1024 : 20 : int MailCache::cachedBodyCount(const QString &account,
1025 : : const QString &folderPath) {
1026 [ + - ]: 20 : qint64 fid = ensureFolder(account, folderPath);
1027 [ + + ]: 20 : if (fid <= 0)
1028 : 2 : return 0;
1029 : :
1030 [ + - ]: 18 : QSqlQuery q(m_db);
1031 [ + - ]: 18 : q.prepare(QStringLiteral(
1032 : : "SELECT COUNT(*) FROM bodies WHERE header_id IN "
1033 : : "(SELECT id FROM headers WHERE folder_id = :fid)"));
1034 [ + - ]: 36 : q.bindValue(QStringLiteral(":fid"), fid);
1035 [ + - + - : 18 : if (q.exec() && q.next())
+ - + - +
- ]
1036 [ + - + - ]: 18 : return q.value(0).toInt();
1037 : 0 : return 0;
1038 : 18 : }
1039 : :
1040 : 14 : void MailCache::purgeFolderByPath(const QString &account,
1041 : : const QString &folderPath) {
1042 : 14 : qint64 fid = ensureFolder(account, folderPath);
1043 [ + + ]: 14 : if (fid > 0)
1044 : 12 : purgeFolder(fid);
1045 : 14 : }
1046 : :
1047 : : // T-286: Total cached disk usage (bodies + attachments) in bytes
1048 : 18 : qint64 MailCache::cachedDiskUsage(const QString &account,
1049 : : const QString &folderPath) {
1050 [ + - ]: 18 : qint64 fid = ensureFolder(account, folderPath);
1051 [ + + ]: 18 : if (fid <= 0)
1052 : 2 : return 0;
1053 : :
1054 [ + - ]: 16 : QSqlQuery q(m_db);
1055 [ + - ]: 16 : q.prepare(QStringLiteral(
1056 : : // T-406/Bug 21: Use COALESCE per field — LENGTH(NULL) returns NULL
1057 : : "SELECT "
1058 : : "COALESCE((SELECT SUM(COALESCE(LENGTH(b.text_plain),0) "
1059 : : "+ COALESCE(LENGTH(b.text_html),0) "
1060 : : "+ COALESCE(LENGTH(b.raw_body),0)) "
1061 : : "FROM bodies b JOIN headers h ON b.header_id = h.id "
1062 : : "WHERE h.folder_id = :body_fid), 0) "
1063 : : "+ COALESCE((SELECT SUM(COALESCE(LENGTH(a.data),0)) "
1064 : : "FROM attachments a JOIN headers h ON a.header_id = h.id "
1065 : : "WHERE h.folder_id = :attachment_fid), 0)"));
1066 [ + - ]: 32 : q.bindValue(QStringLiteral(":body_fid"), fid);
1067 [ + - ]: 32 : q.bindValue(QStringLiteral(":attachment_fid"), fid);
1068 [ + - + - : 16 : if (q.exec() && q.next())
+ - + - +
- ]
1069 [ + - + - ]: 16 : return q.value(0).toLongLong();
1070 : 0 : return 0;
1071 : 16 : }
1072 : :
1073 : : // T-286: Total server-side size (SUM of RFC822.SIZE from headers)
1074 : 14 : qint64 MailCache::totalServerSize(const QString &account,
1075 : : const QString &folderPath) {
1076 [ + - ]: 14 : qint64 fid = ensureFolder(account, folderPath);
1077 [ + + ]: 14 : if (fid <= 0)
1078 : 2 : return 0;
1079 : :
1080 [ + - ]: 12 : QSqlQuery q(m_db);
1081 [ + - ]: 12 : q.prepare(QStringLiteral(
1082 : : "SELECT COALESCE(SUM(size), 0) FROM headers WHERE folder_id = :fid"));
1083 [ + - ]: 24 : q.bindValue(QStringLiteral(":fid"), fid);
1084 [ + - + - : 12 : if (q.exec() && q.next())
+ - + - +
- ]
1085 [ + - + - ]: 12 : return q.value(0).toLongLong();
1086 : 0 : return 0;
1087 : 12 : }
1088 : :
1089 : : // T-286: Average mail size (server-side)
1090 : 14 : qint64 MailCache::averageMailSize(const QString &account,
1091 : : const QString &folderPath) {
1092 [ + - ]: 14 : qint64 fid = ensureFolder(account, folderPath);
1093 [ + + ]: 14 : if (fid <= 0)
1094 : 2 : return 0;
1095 : :
1096 [ + - ]: 12 : QSqlQuery q(m_db);
1097 [ + - ]: 12 : q.prepare(QStringLiteral(
1098 : : "SELECT COALESCE(AVG(size), 0) FROM headers WHERE folder_id = :fid"));
1099 [ + - ]: 24 : q.bindValue(QStringLiteral(":fid"), fid);
1100 [ + - + - : 12 : if (q.exec() && q.next())
+ - + - +
- ]
1101 [ + - + - ]: 12 : return q.value(0).toLongLong();
1102 : 0 : return 0;
1103 : 12 : }
1104 : :
1105 : : // T-286: Purge only body cache (keep headers)
1106 : 7 : void MailCache::purgeBodyCache(const QString &account,
1107 : : const QString &folderPath) {
1108 [ + - ]: 7 : qint64 fid = ensureFolder(account, folderPath);
1109 [ + + ]: 7 : if (fid <= 0)
1110 : 4 : return;
1111 : :
1112 [ + - ]: 5 : m_db.transaction();
1113 : :
1114 [ + - ]: 5 : QSqlQuery attachments(m_db);
1115 [ + - ]: 5 : attachments.prepare(QStringLiteral(
1116 : : "DELETE FROM attachments WHERE header_id IN "
1117 : : "(SELECT id FROM headers WHERE folder_id = :fid)"));
1118 [ + - ]: 10 : attachments.bindValue(QStringLiteral(":fid"), fid);
1119 [ + - + + ]: 5 : if (!attachments.exec()) {
1120 [ + - ]: 2 : m_db.rollback();
1121 [ + - + - : 4 : qCWarning(lcCache) << "Failed to purge attachment cache:"
+ - + + ]
1122 [ + - + - : 2 : << attachments.lastError().text();
+ - ]
1123 : 2 : return;
1124 : : }
1125 [ + - ]: 3 : const int attachmentsDeleted = attachments.numRowsAffected();
1126 : :
1127 [ + - ]: 3 : QSqlQuery q(m_db);
1128 [ + - ]: 3 : q.prepare(QStringLiteral(
1129 : : "DELETE FROM bodies WHERE header_id IN "
1130 : : "(SELECT id FROM headers WHERE folder_id = :fid)"));
1131 [ + - ]: 6 : q.bindValue(QStringLiteral(":fid"), fid);
1132 [ + - - + ]: 3 : if (!q.exec()) {
1133 [ # # ]: 0 : m_db.rollback();
1134 [ # # # # : 0 : qCWarning(lcCache) << "Failed to purge body cache:"
# # # # ]
1135 [ # # # # : 0 : << q.lastError().text();
# # ]
1136 : 0 : return;
1137 : : }
1138 : :
1139 [ + - ]: 3 : m_db.commit();
1140 : :
1141 [ + - + - : 6 : qCInfo(lcCache) << "Purged body cache for folder" << folderPath
+ - + - +
+ ]
1142 [ + - + - : 3 : << "(" << q.numRowsAffected() << "bodies,"
+ - + - ]
1143 [ + - + - ]: 3 : << attachmentsDeleted << "attachments)";
1144 [ + - + + ]: 5 : }
1145 : :
1146 : : // T-286: Rename folder path in DB (for folder rename/move operations)
1147 : 8 : void MailCache::renameFolderPath(const QString &account,
1148 : : const QString &oldPath,
1149 : : const QString &newPath) {
1150 [ + - ]: 8 : QSqlQuery q(m_db);
1151 [ + - ]: 8 : q.prepare(QStringLiteral(
1152 : : "UPDATE folders SET path = :newPath "
1153 : : "WHERE account = :account AND path = :oldPath"));
1154 [ + - ]: 16 : q.bindValue(QStringLiteral(":newPath"), newPath);
1155 [ + - ]: 16 : q.bindValue(QStringLiteral(":account"), account);
1156 [ + - ]: 16 : q.bindValue(QStringLiteral(":oldPath"), oldPath);
1157 [ + - + + ]: 8 : if (q.exec()) {
1158 [ + - + - : 12 : qCInfo(lcCache) << "Renamed folder path" << oldPath << "->" << newPath;
+ - + - +
- + - +
+ ]
1159 : : } else {
1160 [ + - + - : 4 : qCWarning(lcCache) << "Failed to rename folder path:"
+ - + + ]
1161 [ + - + - : 2 : << q.lastError().text();
+ - ]
1162 : : }
1163 : 8 : }
1164 : :
1165 : : // T-167: Convenience methods for FolderPredictor pre-training
1166 : :
1167 : 54 : QStringList MailCache::allFolderPaths(const QString &account) const {
1168 : 54 : QStringList result;
1169 [ + - ]: 54 : QSqlQuery q(m_db);
1170 [ + - ]: 54 : q.prepare(QStringLiteral(
1171 : : "SELECT path FROM folders WHERE account = :account ORDER BY path"));
1172 [ + - ]: 108 : q.bindValue(QStringLiteral(":account"), account);
1173 [ + - + + ]: 54 : if (q.exec()) {
1174 [ + - + + ]: 80 : while (q.next()) {
1175 [ + - + - : 27 : result.append(q.value(0).toString());
+ - ]
1176 : : }
1177 : : }
1178 : 54 : return result;
1179 : 54 : }
1180 : :
1181 : 18 : QList<MailHeader> MailCache::headersByFolder(const QString &account,
1182 : : const QString &folderPath) const {
1183 [ + - ]: 18 : QSqlQuery fq(m_db);
1184 [ + - ]: 18 : fq.prepare(QStringLiteral(
1185 : : "SELECT id FROM folders WHERE account = :account AND path = :path"));
1186 [ + - ]: 36 : fq.bindValue(QStringLiteral(":account"), account);
1187 [ + - ]: 36 : fq.bindValue(QStringLiteral(":path"), folderPath);
1188 [ + - + + : 18 : if (!fq.exec() || !fq.next())
+ - - + +
+ ]
1189 : 1 : return {};
1190 : :
1191 [ + - + - ]: 17 : qint64 folderId = fq.value(0).toLongLong();
1192 [ + - ]: 17 : return headers(folderId);
1193 : 18 : }
1194 : :
1195 : : // --- Badge caching (T-075) ---
1196 : :
1197 : 23 : void MailCache::storeBadge(qint64 folderId, int unseenCount) {
1198 [ + - ]: 23 : QSqlQuery q(m_db);
1199 [ + - ]: 23 : q.prepare(QStringLiteral(
1200 : : "INSERT OR REPLACE INTO folder_badges (folder_id, unseen, updated_at) "
1201 : : "VALUES (:fid, :unseen, strftime('%s', 'now'))"));
1202 [ + - ]: 46 : q.bindValue(QStringLiteral(":fid"), folderId);
1203 [ + - ]: 46 : q.bindValue(QStringLiteral(":unseen"), unseenCount);
1204 : :
1205 [ + - + + ]: 23 : if (!q.exec()) {
1206 [ + - + - : 4 : qCWarning(lcCache) << "Failed to store badge:" << q.lastError().text();
+ - + - +
- + - +
+ ]
1207 : : }
1208 : 23 : }
1209 : :
1210 : 19 : QMap<QString, int> MailCache::loadAllBadges(const QString &account) const {
1211 : 19 : QMap<QString, int> result;
1212 [ + - ]: 19 : QSqlQuery q(m_db);
1213 [ + - ]: 19 : q.prepare(QStringLiteral(
1214 : : "SELECT f.path, b.unseen FROM folder_badges b "
1215 : : "JOIN folders f ON f.id = b.folder_id "
1216 : : "WHERE f.account = :account AND b.unseen > 0"));
1217 [ + - ]: 38 : q.bindValue(QStringLiteral(":account"), account);
1218 : :
1219 [ + - + + ]: 19 : if (q.exec()) {
1220 [ + - + + ]: 28 : while (q.next()) {
1221 [ + - + - : 10 : result[q.value(0).toString()] = q.value(1).toInt();
+ - + - +
- ]
1222 : : }
1223 : : } else {
1224 [ + - + - : 2 : qCWarning(lcCache) << "Failed to load badges:" << q.lastError().text();
+ - + - +
- + - +
+ ]
1225 : : }
1226 : 19 : return result;
1227 : 19 : }
1228 : :
1229 : : // --- Attachment operations ---
1230 : :
1231 : 21 : void MailCache::storeAttachments(qint64 folderId, qint64 uid,
1232 : : const QList<Attachment> &attachments,
1233 : : const QList<QByteArray> &blobs) {
1234 [ + - ]: 21 : qint64 hid = headerRowId(folderId, uid);
1235 [ + + ]: 21 : if (hid < 0) {
1236 [ + - + - : 2 : qCWarning(lcCache) << "Cannot store attachments: header not found for UID"
+ - + + ]
1237 [ + - ]: 1 : << uid;
1238 : 3 : return;
1239 : : }
1240 : :
1241 [ - + ]: 20 : if (attachments.size() != blobs.size()) {
1242 [ # # # # : 0 : qCWarning(lcCache) << "Attachment/BLOB count mismatch:"
# # # # ]
1243 [ # # # # : 0 : << attachments.size() << "vs" << blobs.size();
# # ]
1244 : 0 : return;
1245 : : }
1246 : :
1247 : 20 : qint64 previousBytes = 0;
1248 [ + - ]: 20 : QSqlQuery previous(m_db);
1249 [ + - ]: 20 : previous.prepare(QStringLiteral(
1250 : : "SELECT COALESCE(SUM(length(data)), 0) "
1251 : : "FROM attachments WHERE header_id = :hid"));
1252 [ + - ]: 40 : previous.bindValue(QStringLiteral(":hid"), hid);
1253 [ + - + - : 20 : if (previous.exec() && previous.next())
+ - + - +
- ]
1254 [ + - + - ]: 20 : previousBytes = previous.value(0).toLongLong();
1255 : :
1256 : 20 : qint64 currentBytes = 0;
1257 [ + + ]: 41 : for (const auto &blob : blobs) {
1258 [ - + ]: 21 : if (blob.size() > std::numeric_limits<qint64>::max() - currentBytes) {
1259 [ # # # # : 0 : qCWarning(lcCache) << "Attachment payload size overflow for UID" << uid;
# # # # #
# ]
1260 : 0 : return;
1261 : : }
1262 : 21 : currentBytes += blob.size();
1263 : : }
1264 : 20 : const qint64 additionalBytes = qMax<qint64>(0, currentBytes - previousBytes);
1265 [ + - + + ]: 20 : if (!enforcePayloadCacheLimit(hid, additionalBytes)) {
1266 [ + - + - : 2 : qCWarning(lcCache)
+ + ]
1267 [ + - ]: 1 : << "Refusing to store attachments above payload cache budget for UID"
1268 [ + - + - : 1 : << uid << "bytes" << currentBytes;
+ - ]
1269 : 1 : return;
1270 : : }
1271 : :
1272 [ + - - + ]: 19 : if (!m_db.transaction()) {
1273 [ # # # # : 0 : qCWarning(lcCache) << "Failed to start attachment transaction:"
# # # # ]
1274 [ # # # # : 0 : << m_db.lastError().text();
# # ]
1275 : 0 : return;
1276 : : }
1277 : :
1278 [ + - ]: 19 : QSqlQuery deleteExisting(m_db);
1279 [ + - ]: 19 : deleteExisting.prepare(QStringLiteral(
1280 : : "DELETE FROM attachments WHERE header_id = :hid"));
1281 [ + - ]: 38 : deleteExisting.bindValue(QStringLiteral(":hid"), hid);
1282 [ + - + + ]: 19 : if (!deleteExisting.exec()) {
1283 [ + - ]: 1 : m_db.rollback();
1284 [ + - + - : 2 : qCWarning(lcCache) << "Failed to replace attachments for UID" << uid
+ - + - +
+ ]
1285 [ + - + - : 1 : << ":" << deleteExisting.lastError().text();
+ - + - ]
1286 : 1 : return;
1287 : : }
1288 : :
1289 [ + - ]: 18 : QSqlQuery q(m_db);
1290 [ + - ]: 18 : q.prepare(QStringLiteral(
1291 : : "INSERT INTO attachments (header_id, filename, content_type, size, "
1292 : : "content_id, data) VALUES (:hid, :fn, :ct, :sz, :cid, :data)"));
1293 : :
1294 [ + + ]: 37 : for (int i = 0; i < attachments.size(); ++i) {
1295 : 19 : const auto &att = attachments[i];
1296 [ + - ]: 38 : q.bindValue(QStringLiteral(":hid"), hid);
1297 [ + - ]: 38 : q.bindValue(QStringLiteral(":fn"), att.filename);
1298 [ + - ]: 38 : q.bindValue(QStringLiteral(":ct"), att.contentType);
1299 [ + - ]: 38 : q.bindValue(QStringLiteral(":sz"), att.size);
1300 [ + - ]: 38 : q.bindValue(QStringLiteral(":cid"), att.contentId);
1301 [ + - ]: 38 : q.bindValue(QStringLiteral(":data"), blobs[i]);
1302 : :
1303 [ + - - + ]: 19 : if (!q.exec()) {
1304 [ # # # # : 0 : qCWarning(lcCache) << "Failed to insert attachment" << att.filename << ":"
# # # # #
# # # ]
1305 [ # # # # : 0 : << q.lastError().text();
# # ]
1306 [ # # ]: 0 : m_db.rollback();
1307 : 0 : return;
1308 : : }
1309 : : }
1310 : :
1311 [ + - - + ]: 18 : if (!m_db.commit()) {
1312 [ # # ]: 0 : m_db.rollback();
1313 [ # # # # : 0 : qCWarning(lcCache) << "Failed to commit attachments:"
# # # # ]
1314 [ # # # # : 0 : << m_db.lastError().text();
# # ]
1315 : 0 : return;
1316 : : }
1317 : :
1318 : 18 : m_payloadCacheBytes += currentBytes - previousBytes;
1319 : :
1320 [ + - ]: 18 : QSqlQuery uq(m_db);
1321 [ + - ]: 18 : uq.prepare(QStringLiteral(
1322 : : "UPDATE headers SET has_attachments = :has WHERE id = :hid"));
1323 [ - + + - ]: 36 : uq.bindValue(QStringLiteral(":has"), attachments.isEmpty() ? 0 : 1);
1324 [ + - ]: 36 : uq.bindValue(QStringLiteral(":hid"), hid);
1325 [ + - ]: 18 : uq.exec();
1326 : :
1327 [ + - ]: 18 : enforcePayloadCacheLimit(hid);
1328 : :
1329 [ + - + - : 36 : qCInfo(lcCache) << "Stored" << attachments.size() << "attachments for UID"
+ - + - +
- + + ]
1330 [ + - ]: 18 : << uid;
1331 [ + - + + : 21 : }
+ + ]
1332 : :
1333 : 78 : QList<Attachment> MailCache::attachments(qint64 folderId, qint64 uid) const {
1334 : 78 : QList<Attachment> result;
1335 [ + - ]: 78 : qint64 hid = headerRowId(folderId, uid);
1336 [ + + ]: 78 : if (hid < 0)
1337 : 3 : return result;
1338 : :
1339 [ + - ]: 75 : QSqlQuery q(m_db);
1340 : : // Intentionally omit 'data' column for lazy loading
1341 [ + - ]: 75 : q.prepare(
1342 : 150 : QStringLiteral("SELECT id, filename, content_type, size, content_id "
1343 : : "FROM attachments WHERE header_id = :hid ORDER BY id"));
1344 [ + - ]: 150 : q.bindValue(QStringLiteral(":hid"), hid);
1345 : :
1346 [ + - - + ]: 75 : if (!q.exec())
1347 : 0 : return result;
1348 : :
1349 [ + - + + ]: 93 : while (q.next()) {
1350 : 18 : Attachment att;
1351 [ + - + - ]: 18 : att.id = q.value(0).toLongLong();
1352 [ + - + - ]: 18 : att.filename = q.value(1).toString();
1353 [ + - + - ]: 18 : att.contentType = q.value(2).toString();
1354 [ + - + - ]: 18 : att.size = q.value(3).toLongLong();
1355 [ + - + - ]: 18 : att.contentId = q.value(4).toString();
1356 [ + - ]: 18 : result.append(att);
1357 : 18 : }
1358 : :
1359 : 75 : return result;
1360 : 75 : }
1361 : :
1362 : 15 : QByteArray MailCache::attachmentData(qint64 attachmentId) const {
1363 [ + - ]: 15 : QSqlQuery q(m_db);
1364 [ + - ]: 15 : q.prepare(QStringLiteral("SELECT data FROM attachments WHERE id = :id"));
1365 [ + - ]: 30 : q.bindValue(QStringLiteral(":id"), attachmentId);
1366 : :
1367 [ + - + + : 15 : if (q.exec() && q.next()) {
+ - + + +
+ ]
1368 [ + - + - ]: 13 : return q.value(0).toByteArray();
1369 : : }
1370 : 2 : return {};
1371 : 15 : }
1372 : :
1373 : : // --- External Content Whitelist (T-122) ---
1374 : :
1375 : 47 : bool MailCache::addWhitelistEntry(const QString &type, const QString &value) {
1376 [ + - ]: 47 : QSqlQuery q(m_db);
1377 [ + - ]: 47 : q.prepare(QStringLiteral(
1378 : : "INSERT OR IGNORE INTO external_content_whitelist (type, value) "
1379 : : "VALUES (:type, :value)"));
1380 [ + - ]: 94 : q.bindValue(QStringLiteral(":type"), type);
1381 [ + - + - ]: 94 : q.bindValue(QStringLiteral(":value"), value.toLower());
1382 : :
1383 [ + - + + ]: 47 : if (!q.exec()) {
1384 [ + - + - ]: 2 : m_lastError = q.lastError().text();
1385 [ + - + - : 4 : qCWarning(lcCache) << "Failed to add whitelist entry:" << m_lastError;
+ - + - +
+ ]
1386 : 2 : return false;
1387 : : }
1388 [ + - ]: 45 : return q.numRowsAffected() > 0;
1389 : 47 : }
1390 : :
1391 : 8 : bool MailCache::removeWhitelistEntry(qint64 id) {
1392 [ + - ]: 8 : QSqlQuery q(m_db);
1393 [ + - ]: 8 : q.prepare(QStringLiteral(
1394 : : "DELETE FROM external_content_whitelist WHERE id = :id"));
1395 [ + - ]: 16 : q.bindValue(QStringLiteral(":id"), id);
1396 : :
1397 [ + - + + ]: 8 : if (!q.exec()) {
1398 [ + - + - ]: 2 : m_lastError = q.lastError().text();
1399 [ + - + - : 4 : qCWarning(lcCache) << "Failed to remove whitelist entry:" << m_lastError;
+ - + - +
+ ]
1400 : 2 : return false;
1401 : : }
1402 [ + - ]: 6 : return q.numRowsAffected() > 0;
1403 : 8 : }
1404 : :
1405 : : // T-313: Remove all whitelist entries (for settings sync apply)
1406 : 12 : void MailCache::clearWhitelist() {
1407 [ + - ]: 12 : QSqlQuery q(m_db);
1408 [ + - + + ]: 12 : if (!q.exec(QStringLiteral(
1409 : : "DELETE FROM external_content_whitelist"))) {
1410 [ + - + - ]: 2 : m_lastError = q.lastError().text();
1411 [ + - + - : 4 : qCWarning(lcCache) << "Failed to clear whitelist:" << m_lastError;
+ - + - +
+ ]
1412 : : } else {
1413 [ + - + - : 20 : qCInfo(lcCache) << "Cleared whitelist (" << q.numRowsAffected()
+ - + - +
- + + ]
1414 [ + - ]: 10 : << "entries)";
1415 : : }
1416 : 12 : }
1417 : :
1418 : 9 : bool MailCache::replaceWhitelistEntries(
1419 : : const QList<QPair<QString, QString>> &entries) {
1420 [ + + ]: 20 : for (const auto &entry : entries) {
1421 : 12 : const QString type = entry.first;
1422 [ + + + + : 29 : if (type != QStringLiteral("sender") && type != QStringLiteral("domain")) {
+ + + + +
- + - +
+ ]
1423 [ + - ]: 2 : m_lastError = QStringLiteral("Invalid whitelist entry type: %1").arg(type);
1424 [ + - + - : 2 : qCWarning(lcCache) << m_lastError;
+ - + + ]
1425 : 1 : return false;
1426 : : }
1427 [ + - - + ]: 11 : if (entry.second.trimmed().isEmpty()) {
1428 : 0 : m_lastError = QStringLiteral("Invalid empty whitelist entry value");
1429 [ # # # # : 0 : qCWarning(lcCache) << m_lastError;
# # # # ]
1430 : 0 : return false;
1431 : : }
1432 [ + + ]: 12 : }
1433 : :
1434 [ + - - + ]: 8 : if (!m_db.transaction()) {
1435 [ # # # # ]: 0 : m_lastError = m_db.lastError().text();
1436 [ # # # # : 0 : qCWarning(lcCache) << "Failed to start whitelist transaction:"
# # # # ]
1437 [ # # ]: 0 : << m_lastError;
1438 : 0 : return false;
1439 : : }
1440 : :
1441 [ + - ]: 8 : QSqlQuery clear(m_db);
1442 [ + - + + ]: 8 : if (!clear.exec(QStringLiteral("DELETE FROM external_content_whitelist"))) {
1443 [ + - + - ]: 3 : m_lastError = clear.lastError().text();
1444 [ + - ]: 3 : m_db.rollback();
1445 [ + - + - : 6 : qCWarning(lcCache) << "Failed to clear whitelist in transaction:"
+ - + + ]
1446 [ + - ]: 3 : << m_lastError;
1447 : 3 : return false;
1448 : : }
1449 : :
1450 [ + - ]: 5 : QSqlQuery insert(m_db);
1451 [ + - ]: 5 : insert.prepare(QStringLiteral(
1452 : : "INSERT OR IGNORE INTO external_content_whitelist (type, value) "
1453 : : "VALUES (:type, :value)"));
1454 [ + + ]: 12 : for (const auto &entry : entries) {
1455 [ + - ]: 14 : insert.bindValue(QStringLiteral(":type"), entry.first);
1456 [ + - + - : 14 : insert.bindValue(QStringLiteral(":value"), entry.second.trimmed().toLower());
+ - ]
1457 [ + - - + ]: 7 : if (!insert.exec()) {
1458 [ # # # # ]: 0 : m_lastError = insert.lastError().text();
1459 [ # # ]: 0 : m_db.rollback();
1460 [ # # # # : 0 : qCWarning(lcCache) << "Failed to insert whitelist entry in transaction:"
# # # # ]
1461 [ # # ]: 0 : << m_lastError;
1462 : 0 : return false;
1463 : : }
1464 : : }
1465 : :
1466 [ + - - + ]: 5 : if (!m_db.commit()) {
1467 [ # # # # ]: 0 : m_lastError = m_db.lastError().text();
1468 [ # # ]: 0 : m_db.rollback();
1469 [ # # # # : 0 : qCWarning(lcCache) << "Failed to commit whitelist transaction:"
# # # # ]
1470 [ # # ]: 0 : << m_lastError;
1471 : 0 : return false;
1472 : : }
1473 : :
1474 [ + - + - : 10 : qCInfo(lcCache) << "Replaced whitelist with" << entries.size() << "entries";
+ - + - +
- + + ]
1475 : 5 : return true;
1476 : 8 : }
1477 : :
1478 : 42 : QList<WhitelistEntry> MailCache::whitelistEntries() const {
1479 : 42 : QList<WhitelistEntry> result;
1480 [ + - ]: 42 : QSqlQuery q(m_db);
1481 [ + - ]: 42 : q.exec(QStringLiteral(
1482 : : "SELECT id, type, value, created_at "
1483 : : "FROM external_content_whitelist ORDER BY created_at DESC"));
1484 : :
1485 [ + - + + ]: 80 : while (q.next()) {
1486 : 38 : WhitelistEntry e;
1487 [ + - + - ]: 38 : e.id = q.value(0).toLongLong();
1488 [ + - + - ]: 38 : e.type = q.value(1).toString();
1489 [ + - + - ]: 38 : e.value = q.value(2).toString();
1490 [ + - + - ]: 38 : e.createdAt = q.value(3).toString();
1491 [ + - ]: 38 : result.append(e);
1492 : 38 : }
1493 : 42 : return result;
1494 : 42 : }
1495 : :
1496 : 17 : bool MailCache::isWhitelisted(const QString &senderEmail) const {
1497 [ - + ]: 17 : if (senderEmail.isEmpty())
1498 : 0 : return false;
1499 : :
1500 [ + - ]: 17 : QString email = senderEmail.toLower();
1501 : :
1502 : : // Check sender match
1503 [ + - ]: 17 : QSqlQuery q(m_db);
1504 [ + - ]: 17 : q.prepare(QStringLiteral(
1505 : : "SELECT 1 FROM external_content_whitelist "
1506 : : "WHERE type = 'sender' AND value = :email"));
1507 [ + - ]: 34 : q.bindValue(QStringLiteral(":email"), email);
1508 [ + - + + : 17 : if (q.exec() && q.next())
+ - + + +
+ ]
1509 : 6 : return true;
1510 : :
1511 : : // Check domain match
1512 : 11 : int atPos = email.indexOf('@');
1513 [ + - ]: 11 : if (atPos >= 0) {
1514 [ + - ]: 11 : QString domain = email.mid(atPos + 1);
1515 [ + - ]: 11 : q.prepare(QStringLiteral(
1516 : : "SELECT 1 FROM external_content_whitelist "
1517 : : "WHERE type = 'domain' AND value = :domain"));
1518 [ + - ]: 22 : q.bindValue(QStringLiteral(":domain"), domain);
1519 [ + - + + : 11 : if (q.exec() && q.next())
+ - + + +
+ ]
1520 : 4 : return true;
1521 [ + + ]: 11 : }
1522 : 7 : return false;
1523 : 17 : }
1524 : :
1525 : 56 : QStringList MailCache::whitelistedDomains() const {
1526 : 56 : QStringList result;
1527 [ + - ]: 56 : QSqlQuery q(m_db);
1528 [ + - ]: 56 : q.exec(QStringLiteral(
1529 : : "SELECT value FROM external_content_whitelist WHERE type = 'domain'"));
1530 [ + - + + ]: 82 : while (q.next())
1531 [ + - + - : 26 : result.append(q.value(0).toString());
+ - ]
1532 : 56 : return result;
1533 : 56 : }
1534 : :
1535 : 56 : QStringList MailCache::whitelistedSenders() const {
1536 : 56 : QStringList result;
1537 [ + - ]: 56 : QSqlQuery q(m_db);
1538 [ + - ]: 56 : q.exec(QStringLiteral(
1539 : : "SELECT value FROM external_content_whitelist WHERE type = 'sender'"));
1540 [ + - + + ]: 78 : while (q.next())
1541 [ + - + - : 22 : result.append(q.value(0).toString());
+ - ]
1542 : 56 : return result;
1543 : 56 : }
1544 : :
1545 : : // ═══════════════════════════════════════════════════════
1546 : : // Full-Text Search (T-179)
1547 : : // ═══════════════════════════════════════════════════════
1548 : :
1549 : : // Lightweight HTML → plain-text reduction for the search index. We only need
1550 : : // searchable words, not faithful rendering, so a regex strip is enough and
1551 : : // avoids pulling QtGui (QTextDocument) into the data layer. Used as a fallback
1552 : : // for HTML-only mails whose text/plain part is empty.
1553 : 18 : static QString stripHtmlForIndex(const QString &html) {
1554 [ + + ]: 18 : if (html.isEmpty())
1555 : 2 : return QString();
1556 : 16 : QString s = html;
1557 : : // Drop <script>/<style> blocks entirely (content is not human text).
1558 : : static const QRegularExpression scriptStyle(
1559 : 8 : QStringLiteral("<(script|style)\\b[^>]*>.*?</\\1>"),
1560 : : QRegularExpression::CaseInsensitiveOption |
1561 [ + + + - : 20 : QRegularExpression::DotMatchesEverythingOption);
+ - - - ]
1562 [ + - ]: 16 : s.remove(scriptStyle);
1563 : : // Strip all remaining tags.
1564 [ + + + - : 20 : static const QRegularExpression tags(QStringLiteral("<[^>]+>"));
+ - - - ]
1565 [ + - ]: 16 : s.replace(tags, QStringLiteral(" "));
1566 : : // Decode the handful of entities that actually matter for word matching.
1567 [ + - ]: 32 : s.replace(QStringLiteral(" "), QStringLiteral(" "));
1568 [ + - ]: 32 : s.replace(QStringLiteral("&"), QStringLiteral("&"));
1569 [ + - ]: 32 : s.replace(QStringLiteral("<"), QStringLiteral("<"));
1570 [ + - ]: 32 : s.replace(QStringLiteral(">"), QStringLiteral(">"));
1571 [ + - ]: 32 : s.replace(QStringLiteral("""), QStringLiteral("\""));
1572 [ + - ]: 32 : s.replace(QStringLiteral("'"), QStringLiteral("'"));
1573 : : // Numeric entities (ä / ä) → the actual character.
1574 : : static const QRegularExpression numEntity(
1575 [ + + + - : 20 : QStringLiteral("&#(x?[0-9a-fA-F]+);"));
+ - - - ]
1576 [ + - ]: 16 : QRegularExpressionMatchIterator it = numEntity.globalMatch(s);
1577 : : // Build replacements without invalidating offsets: collect then apply.
1578 : 16 : QString result;
1579 [ + - ]: 16 : result.reserve(s.size());
1580 : 16 : int last = 0;
1581 [ + - + + ]: 20 : while (it.hasNext()) {
1582 [ + - ]: 4 : const QRegularExpressionMatch m = it.next();
1583 [ + - + - ]: 4 : result += QStringView{s}.mid(last, m.capturedStart() - last);
1584 [ + - ]: 4 : QString num = m.captured(1);
1585 : 4 : bool ok = false;
1586 [ + - ]: 4 : uint code = num.startsWith(QLatin1Char('x'), Qt::CaseInsensitive)
1587 [ + + + - : 6 : ? num.mid(1).toUInt(&ok, 16)
+ - - - ]
1588 [ + - + + ]: 4 : : num.toUInt(&ok, 10);
1589 [ + - + - ]: 4 : if (ok && code > 0)
1590 [ + - ]: 4 : result += QChar(code);
1591 [ + - ]: 4 : last = m.capturedEnd();
1592 : 4 : }
1593 [ + - ]: 16 : result += QStringView{s}.mid(last);
1594 [ + - ]: 16 : return result.simplified();
1595 : 16 : }
1596 : :
1597 : 20210 : QString MailCache::foldForSearch(const QString &text) {
1598 : : // Lowercase first, then expand ß (no canonical decomposition) to "ss".
1599 [ + - ]: 20210 : QString lower = text.toLower();
1600 [ + - ]: 20210 : lower.replace(QChar(0x00DF), QStringLiteral("ss")); // ß → ss
1601 : : // NFD decomposition splits accented letters into base + combining marks
1602 : : // (e.g. "ü" → "u" + ¨). Dropping the combining marks yields the base letter.
1603 [ + - ]: 20210 : const QString decomposed = lower.normalized(QString::NormalizationForm_D);
1604 : 20210 : QString out;
1605 [ + - ]: 20210 : out.reserve(decomposed.size());
1606 [ + + ]: 226201 : for (const QChar c : decomposed) {
1607 : 205991 : const QChar::Category cat = c.category();
1608 [ + + + - : 205991 : if (cat == QChar::Mark_NonSpacing || cat == QChar::Mark_SpacingCombining ||
- + ]
1609 : : cat == QChar::Mark_Enclosing)
1610 : 56 : continue; // strip diacritic / combining marks
1611 [ + - ]: 205935 : out.append(c);
1612 : : }
1613 : 20210 : return out;
1614 : 20210 : }
1615 : :
1616 : : // SEC-2026-07-21-22: Escape SQL LIKE wildcards in user-supplied search terms
1617 : : // so %, _ and \ are treated as literals, not as pattern metacharacters. Used
1618 : : // in conjunction with "ESCAPE '\'" appended to each LIKE clause.
1619 : 30 : QString MailCache::escapeLikePattern(const QString &raw) {
1620 : 30 : QString escaped;
1621 [ + - ]: 30 : escaped.reserve(raw.size() + 4);
1622 [ + + ]: 188 : for (const QChar &c : raw) {
1623 [ + - + - : 316 : if (c == QLatin1Char('%') || c == QLatin1Char('_') ||
- + ]
1624 [ - + ]: 316 : c == QLatin1Char('\\'))
1625 [ # # ]: 0 : escaped += QLatin1Char('\\');
1626 [ + - ]: 158 : escaped += c;
1627 : : }
1628 : 30 : return escaped;
1629 : 0 : }
1630 : :
1631 : : QList<MailCache::SearchResult>
1632 : 65 : MailCache::searchFts(const QString &query, int maxResults, int offset) const {
1633 [ + - - - : 65 : return searchFts(query, SearchFilter{}, maxResults, offset);
- - - - ]
1634 : : }
1635 : :
1636 : : QList<MailCache::SearchResult>
1637 : 157 : MailCache::searchFts(const QString &query, const SearchFilter &filter,
1638 : : int maxResults, int offset) const {
1639 : 157 : QList<SearchResult> results;
1640 [ + - + + ]: 157 : if (!m_db.isOpen())
1641 : 2 : return results;
1642 : :
1643 : : // Sprint 59: a search runs when there is a free-text term OR at least one
1644 : : // facet. A completely empty query AND empty filter still returns nothing —
1645 : : // we never silently "load everything".
1646 [ + - ]: 155 : const bool hasFacets = !filter.isEmpty();
1647 [ + - + + : 155 : if (query.trimmed().isEmpty() && !hasFacets)
+ + + - +
+ - - ]
1648 : 1 : return results;
1649 : :
1650 : : // Fold the query exactly like the indexed text, then split into words.
1651 : : // Trigram matches substrings but needs >= 3 characters per term, so collect
1652 : : // the usable terms. Each is wrapped in an FTS5 string literal ("" escapes a
1653 : : // quote) which both matches it as a literal substring and prevents query
1654 : : // injection via FTS operators (T-618/SEC-18: OR, NOT, NEAR, column filters).
1655 [ + - + - ]: 154 : const QString folded = foldForSearch(query).trimmed();
1656 : : const QStringList words =
1657 [ + - ]: 462 : folded.split(QRegularExpression(QStringLiteral("\\s+")),
1658 [ + - ]: 154 : Qt::SkipEmptyParts);
1659 : 154 : QStringList matchTerms;
1660 [ + + ]: 295 : for (QString w : words) {
1661 [ + + ]: 141 : if (w.size() < 3)
1662 : 6 : continue; // below trigram minimum — handled by LIKE fallback if alone
1663 [ + - ]: 135 : w.replace(QLatin1Char('"'), QStringLiteral("\"\""));
1664 [ + - + - : 135 : matchTerms.append(QLatin1Char('"') + w + QLatin1Char('"'));
+ - ]
1665 [ + + ]: 141 : }
1666 : 154 : const bool useMatch = !matchTerms.isEmpty();
1667 : : // Text condition kinds: FTS MATCH (>=3 char terms), LIKE fallback (short
1668 : : // terms only), or none (facets-only search with empty free text).
1669 [ + + + + ]: 154 : const bool useLike = !useMatch && !folded.isEmpty();
1670 [ + + + + ]: 154 : const bool hasText = useMatch || useLike;
1671 [ + + - + ]: 154 : if (!hasText && !hasFacets)
1672 : 0 : return results; // nothing to constrain on
1673 : :
1674 [ + - ]: 154 : QSqlQuery q(m_db);
1675 : : // Read actual metadata from headers via JOIN so search results stay in sync
1676 : : // with the canonical cache rows. Rank is not meaningful for trigram, so we
1677 : : // order by recency (newest first) instead.
1678 : 154 : QString sql = QStringLiteral(
1679 : : "SELECT f.rowid, h.subject, h.from_addr, 0 AS rank, "
1680 : : " h.folder_id, h.uid, fo.path "
1681 : : "FROM mail_fts f "
1682 : : "JOIN headers h ON h.id = f.rowid "
1683 : : "JOIN folders fo ON fo.id = h.folder_id ");
1684 : :
1685 : : // Collect WHERE conditions, starting from a constant so we can always append
1686 : : // " AND ..." regardless of whether a text condition exists.
1687 : 154 : QStringList where;
1688 [ + - ]: 154 : where.append(QStringLiteral("1=1"));
1689 [ + + ]: 154 : if (useMatch) {
1690 [ + - ]: 116 : where.append(QStringLiteral("mail_fts MATCH :query"));
1691 [ + + ]: 38 : } else if (useLike) {
1692 : : // All terms shorter than the trigram minimum (e.g. "ab"): substring LIKE
1693 : : // over the folded FTS columns so the user still gets hits.
1694 : : // SEC-2026-07-21-22: ESCAPE '\' so % and _ in user input are literal.
1695 [ + - ]: 4 : where.append(QStringLiteral(
1696 : : "(f.subject LIKE :like ESCAPE '\\' OR f.from_addr LIKE :like ESCAPE '\\' OR "
1697 : : "f.to_addr LIKE :like ESCAPE '\\' OR f.body_text LIKE :like ESCAPE '\\')"));
1698 : : }
1699 : :
1700 : : // Sprint 60 (B2): multiple folder patterns are OR-combined so a mail in ANY
1701 : : // selected folder matches. One bind per non-empty pattern.
1702 : 154 : QStringList folderPatterns;
1703 [ + + ]: 169 : for (const QString &p : filter.folderPatterns)
1704 [ + - + - ]: 15 : if (!p.trimmed().isEmpty())
1705 [ + - ]: 15 : folderPatterns.append(p);
1706 [ + + ]: 154 : if (!folderPatterns.isEmpty()) {
1707 : 12 : QStringList ors;
1708 [ + + ]: 27 : for (int i = 0; i < folderPatterns.size(); ++i)
1709 [ + - + - ]: 30 : ors.append(QStringLiteral("fo.path LIKE :folder%1 ESCAPE '\\'").arg(i));
1710 [ + - + - : 24 : where.append(QLatin1Char('(') + ors.join(QStringLiteral(" OR ")) +
+ - ]
1711 [ + - ]: 24 : QLatin1Char(')'));
1712 : 12 : }
1713 [ + - + + ]: 154 : if (filter.dateFrom.isValid())
1714 [ + - ]: 6 : where.append(QStringLiteral("h.date >= :dateFrom"));
1715 [ + - + + ]: 154 : if (filter.dateTo.isValid())
1716 [ + - ]: 4 : where.append(QStringLiteral("h.date <= :dateTo"));
1717 [ + + ]: 154 : if (!filter.fromFilter.isEmpty())
1718 [ + - ]: 7 : where.append(QStringLiteral("h.from_addr LIKE :fromFilter ESCAPE '\\'"));
1719 [ + + ]: 154 : if (!filter.toFilter.isEmpty())
1720 [ + - ]: 1 : where.append(QStringLiteral("h.to_addr LIKE :toFilter ESCAPE '\\'"));
1721 : :
1722 : : // Sprint 59 facets. Subject is matched against the folded FTS column so it
1723 : : // stays diacritics-blind and consistent with the free-text term.
1724 [ + + ]: 154 : if (!filter.subjectFilter.isEmpty())
1725 [ + - ]: 3 : where.append(QStringLiteral("f.subject LIKE :subject ESCAPE '\\'"));
1726 : : // Flags as bitmask constraints. unread=Yes ⇒ the Seen bit is NOT set.
1727 [ + + ]: 154 : if (filter.unread == SearchFilter::Tri::Yes)
1728 [ + - ]: 9 : where.append(QStringLiteral("(h.flags & :seenMask) = 0"));
1729 [ + + ]: 145 : else if (filter.unread == SearchFilter::Tri::No)
1730 [ + - ]: 1 : where.append(QStringLiteral("(h.flags & :seenMask) = :seenMask"));
1731 [ + + ]: 154 : if (filter.flagged == SearchFilter::Tri::Yes)
1732 [ + - ]: 5 : where.append(QStringLiteral("(h.flags & :flaggedMask) = :flaggedMask"));
1733 [ + + ]: 149 : else if (filter.flagged == SearchFilter::Tri::No)
1734 [ + - ]: 2 : where.append(QStringLiteral("(h.flags & :flaggedMask) = 0"));
1735 [ + + ]: 154 : if (filter.answered == SearchFilter::Tri::Yes)
1736 [ + - ]: 2 : where.append(QStringLiteral("(h.flags & :answeredMask) = :answeredMask"));
1737 [ + + ]: 152 : else if (filter.answered == SearchFilter::Tri::No)
1738 [ + - ]: 1 : where.append(QStringLiteral("(h.flags & :answeredMask) = 0"));
1739 [ + + ]: 154 : if (filter.hasAttachment == SearchFilter::Tri::Yes)
1740 [ + - ]: 6 : where.append(QStringLiteral("h.has_attachments = 1"));
1741 [ + + ]: 148 : else if (filter.hasAttachment == SearchFilter::Tri::No)
1742 [ + - ]: 1 : where.append(QStringLiteral("h.has_attachments = 0"));
1743 : : // Tags: one EXISTS subselect per label so semantics are AND ("has tag A and
1744 : : // tag B"). Empty labels are skipped.
1745 [ + + ]: 158 : for (int i = 0; i < filter.tags.size(); ++i) {
1746 [ + - - + ]: 4 : if (filter.tags.at(i).trimmed().isEmpty())
1747 : 0 : continue;
1748 [ + - ]: 8 : const QString bind = QStringLiteral(":tag%1").arg(i);
1749 [ + - ]: 12 : where.append(QStringLiteral("EXISTS (SELECT 1 FROM mail_labels ml "
1750 : : "WHERE ml.header_id = h.id AND ml.label = %1)")
1751 [ + - ]: 8 : .arg(bind));
1752 : 4 : }
1753 : :
1754 [ + - + - : 308 : sql += QStringLiteral("WHERE ") + where.join(QStringLiteral(" AND "));
+ - ]
1755 [ + - ]: 154 : sql += QStringLiteral(" ORDER BY h.date DESC, h.id DESC");
1756 [ + + ]: 154 : if (maxResults > 0) {
1757 [ + - ]: 83 : sql += QStringLiteral(" LIMIT :limit");
1758 [ + + ]: 83 : if (offset > 0)
1759 [ + - ]: 1 : sql += QStringLiteral(" OFFSET :offset");
1760 : : }
1761 [ + - ]: 154 : q.prepare(sql);
1762 : :
1763 [ + + ]: 154 : if (useMatch)
1764 [ + - + - : 116 : q.bindValue(":query", matchTerms.join(QLatin1Char(' ')));
+ - ]
1765 [ + + ]: 38 : else if (useLike)
1766 [ + - + - ]: 4 : q.bindValue(":like",
1767 [ + - + - : 8 : QLatin1Char('%') + escapeLikePattern(folded) + QLatin1Char('%'));
+ - ]
1768 [ + + ]: 154 : if (maxResults > 0) {
1769 [ + - + - ]: 83 : q.bindValue(":limit", maxResults);
1770 [ + + ]: 83 : if (offset > 0)
1771 [ + - + - ]: 1 : q.bindValue(":offset", offset);
1772 : : }
1773 : :
1774 : : // Bind optional filter parameters
1775 [ + + ]: 169 : for (int i = 0; i < folderPatterns.size(); ++i)
1776 [ + - + - ]: 45 : q.bindValue(QStringLiteral(":folder%1").arg(i),
1777 [ + - + - ]: 30 : QLatin1Char('%') + escapeLikePattern(folderPatterns.at(i)) +
1778 [ + - ]: 45 : QLatin1Char('%'));
1779 [ + - + + ]: 154 : if (filter.dateFrom.isValid())
1780 [ + - + - : 6 : q.bindValue(":dateFrom", filter.dateFrom.toSecsSinceEpoch());
+ - ]
1781 [ + - + + ]: 154 : if (filter.dateTo.isValid())
1782 [ + - + - : 4 : q.bindValue(":dateTo", filter.dateTo.toSecsSinceEpoch());
+ - ]
1783 [ + + ]: 154 : if (!filter.fromFilter.isEmpty())
1784 [ + - + - ]: 7 : q.bindValue(":fromFilter",
1785 [ + - + - ]: 14 : QLatin1Char('%') + escapeLikePattern(filter.fromFilter) +
1786 [ + - ]: 21 : QLatin1Char('%'));
1787 [ + + ]: 154 : if (!filter.toFilter.isEmpty())
1788 [ + - + - ]: 1 : q.bindValue(":toFilter",
1789 [ + - + - ]: 2 : QLatin1Char('%') + escapeLikePattern(filter.toFilter) +
1790 [ + - ]: 3 : QLatin1Char('%'));
1791 : :
1792 : : // Sprint 59 facet binds. Subject is folded so "müller" matches "Muller".
1793 [ + + ]: 154 : if (!filter.subjectFilter.isEmpty())
1794 [ + - + - ]: 3 : q.bindValue(":subject",
1795 : 6 : QLatin1Char('%') +
1796 [ + - + - : 12 : escapeLikePattern(foldForSearch(filter.subjectFilter)) +
+ - ]
1797 [ + - ]: 9 : QLatin1Char('%'));
1798 [ + + ]: 154 : if (filter.unread != SearchFilter::Tri::Any)
1799 [ + - + - ]: 10 : q.bindValue(":seenMask", static_cast<int>(MailFlag::Seen));
1800 [ + + ]: 154 : if (filter.flagged != SearchFilter::Tri::Any)
1801 [ + - + - ]: 7 : q.bindValue(":flaggedMask", static_cast<int>(MailFlag::Flagged));
1802 [ + + ]: 154 : if (filter.answered != SearchFilter::Tri::Any)
1803 [ + - + - ]: 3 : q.bindValue(":answeredMask", static_cast<int>(MailFlag::Answered));
1804 : : {
1805 : 154 : int tagIdx = 0;
1806 [ + + ]: 158 : for (const QString &tag : filter.tags) {
1807 [ + - - + ]: 4 : if (tag.trimmed().isEmpty()) {
1808 : 0 : ++tagIdx;
1809 : 0 : continue;
1810 : : }
1811 [ + - + - ]: 12 : q.bindValue(QStringLiteral(":tag%1").arg(tagIdx), tag);
1812 : 4 : ++tagIdx;
1813 : : }
1814 : : }
1815 : :
1816 [ + - + + ]: 154 : if (!q.exec()) {
1817 [ + - + - : 4 : qCWarning(lcCache) << "FTS5 search failed:" << q.lastError().text();
+ - + - +
- + - +
+ ]
1818 : 2 : return results;
1819 : : }
1820 : :
1821 [ + - + + ]: 882 : while (q.next()) {
1822 : 730 : SearchResult r;
1823 [ + - + - ]: 730 : r.subject = q.value(1).toString();
1824 [ + - + - ]: 730 : r.from = q.value(2).toString();
1825 [ + - + - ]: 730 : r.rank = q.value(3).toDouble();
1826 [ + - + - ]: 730 : r.folderId = q.value(4).toLongLong();
1827 [ + - + - ]: 730 : r.uid = q.value(5).toLongLong();
1828 [ + - + - ]: 730 : r.folderPath = q.value(6).toString();
1829 [ + - ]: 730 : results.append(r);
1830 : 730 : }
1831 : :
1832 : 152 : return results;
1833 : 154 : }
1834 : :
1835 : 43 : QStringList MailCache::knownLabels() const {
1836 : 43 : QStringList labels;
1837 [ + - - + ]: 43 : if (!m_db.isOpen())
1838 : 0 : return labels;
1839 : :
1840 [ + - ]: 43 : QSqlQuery q(m_db);
1841 : : // DISTINCT labels, case-insensitive order so the UI suggestion list is stable.
1842 [ + - + + ]: 43 : if (!q.exec(QStringLiteral("SELECT DISTINCT label FROM mail_labels "
1843 : : "ORDER BY label COLLATE NOCASE"))) {
1844 [ + - + - : 2 : qCWarning(lcCache) << "knownLabels query failed:" << q.lastError().text();
+ - + - +
- + - +
+ ]
1845 : 1 : return labels;
1846 : : }
1847 [ + - + + ]: 48 : while (q.next()) {
1848 [ + - + - ]: 6 : const QString label = q.value(0).toString();
1849 [ + - ]: 6 : if (!label.isEmpty())
1850 [ + - ]: 6 : labels.append(label);
1851 : 6 : }
1852 : 42 : return labels;
1853 : 43 : }
1854 : :
1855 : 3324 : void MailCache::indexForSearch(qint64 folderId, qint64 uid) {
1856 [ - + ]: 3324 : if (!m_db.isOpen())
1857 : 0 : return;
1858 : :
1859 : 3324 : qint64 rowId = headerRowId(folderId, uid);
1860 [ + + ]: 3324 : if (rowId <= 0)
1861 : 3 : return;
1862 : :
1863 [ - + ]: 3321 : if (!m_db.transaction()) {
1864 [ # # # # : 0 : qCWarning(lcCache) << "Failed to start FTS index transaction:"
# # # # ]
1865 [ # # # # : 0 : << m_db.lastError().text();
# # ]
1866 : 0 : return;
1867 : : }
1868 : :
1869 [ + + ]: 3321 : if (!indexHeaderById(rowId)) {
1870 : 1 : m_db.rollback();
1871 : 1 : return;
1872 : : }
1873 [ - + ]: 3320 : if (!m_db.commit()) {
1874 : 0 : m_db.rollback();
1875 [ # # # # : 0 : qCWarning(lcCache) << "Failed to commit FTS index transaction:"
# # # # ]
1876 [ # # # # : 0 : << m_db.lastError().text();
# # ]
1877 : : }
1878 : : }
1879 : :
1880 : 5013 : bool MailCache::indexHeaderById(qint64 rowId) {
1881 : : // Delete first inside the caller's transaction. This makes indexing
1882 : : // idempotent and lets SQLite serialize live updates with rebuild batches.
1883 [ + - + + ]: 5013 : if (!deleteSearchIndexEntry(m_db, rowId)) {
1884 [ + - + - : 4 : qCWarning(lcCache) << "Failed to replace FTS5 entry for header" << rowId;
+ - + - +
+ ]
1885 : 2 : return false;
1886 : : }
1887 : :
1888 : : // Get header fields
1889 [ + - ]: 5011 : QSqlQuery hq(m_db);
1890 [ + - ]: 5011 : hq.prepare(QStringLiteral(
1891 : : "SELECT subject, from_addr, to_addr FROM headers "
1892 : : "WHERE id = :rowId"));
1893 [ + - + - ]: 5011 : hq.bindValue(":rowId", rowId);
1894 [ + - - + ]: 5011 : if (!hq.exec()) {
1895 [ # # # # : 0 : qCWarning(lcCache) << "FTS header lookup failed for" << rowId << ":"
# # # # #
# # # ]
1896 [ # # # # : 0 : << hq.lastError().text();
# # ]
1897 : 0 : return false;
1898 : : }
1899 [ + - - + ]: 5011 : if (!hq.next())
1900 : 0 : return true;
1901 : :
1902 [ + - + - ]: 5011 : QString subject = hq.value(0).toString();
1903 [ + - + - ]: 5011 : QString fromAddr = hq.value(1).toString();
1904 [ + - + - ]: 5011 : QString toAddr = hq.value(2).toString();
1905 : :
1906 : : // Get body text if available. For HTML-only mails the text/plain part is
1907 : : // empty, so fall back to a stripped version of the HTML — otherwise the body
1908 : : // of such mails would never be searchable.
1909 : 5011 : QString bodyText;
1910 [ + - ]: 5011 : QSqlQuery bq(m_db);
1911 [ + - ]: 5011 : bq.prepare(QStringLiteral(
1912 : : "SELECT text_plain, text_html FROM bodies WHERE header_id = :rowId"));
1913 [ + - + - ]: 5011 : bq.bindValue(":rowId", rowId);
1914 [ + - - + ]: 5011 : if (!bq.exec()) {
1915 [ # # # # : 0 : qCWarning(lcCache) << "FTS body lookup failed for" << rowId << ":"
# # # # #
# # # ]
1916 [ # # # # : 0 : << bq.lastError().text();
# # ]
1917 : 0 : return false;
1918 : : }
1919 [ + - + + ]: 5011 : if (bq.next()) {
1920 [ + - + - ]: 112 : bodyText = bq.value(0).toString();
1921 [ + - + + ]: 112 : if (bodyText.trimmed().isEmpty())
1922 [ + - + - : 18 : bodyText = stripHtmlForIndex(bq.value(1).toString());
+ - ]
1923 : : }
1924 : :
1925 [ + - ]: 5011 : QSqlQuery iq(m_db);
1926 [ + - ]: 5011 : iq.prepare(QStringLiteral(
1927 : : "INSERT INTO mail_fts(rowid, subject, from_addr, to_addr, body_text) "
1928 : : "VALUES(:rowid, :subject, :from, :to, :body)"));
1929 [ + - + - ]: 5011 : iq.bindValue(":rowid", rowId);
1930 [ + - + - : 5011 : iq.bindValue(":subject", foldForSearch(subject));
+ - ]
1931 [ + - + - : 5011 : iq.bindValue(":from", foldForSearch(fromAddr));
+ - ]
1932 [ + - + - : 5011 : iq.bindValue(":to", foldForSearch(toAddr));
+ - ]
1933 [ + - + - : 5011 : iq.bindValue(":body", foldForSearch(bodyText));
+ - ]
1934 [ + - - + ]: 5011 : if (!iq.exec()) {
1935 [ # # # # : 0 : qCWarning(lcCache) << "FTS5 index failed for header" << rowId
# # # # #
# ]
1936 [ # # # # : 0 : << ":" << iq.lastError().text();
# # # # ]
1937 : 0 : return false;
1938 : : }
1939 : 5011 : return true;
1940 : 5011 : }
1941 : :
1942 : : // Batch-index multiple UIDs in a single transaction for performance
1943 : 11 : void MailCache::batchIndexForSearch(qint64 folderId, const QList<qint64> &uids) {
1944 [ + - - + : 11 : if (!m_db.isOpen() || uids.isEmpty())
- + ]
1945 : 0 : return;
1946 : :
1947 [ - + ]: 11 : if (!m_db.transaction())
1948 : 0 : return;
1949 [ + + ]: 43 : for (qint64 uid : uids) {
1950 [ + - ]: 33 : const qint64 rowId = headerRowId(folderId, uid);
1951 [ + + + - : 33 : if (rowId > 0 && !indexHeaderById(rowId)) {
+ + + + ]
1952 [ + - ]: 1 : m_db.rollback();
1953 : 1 : return;
1954 : : }
1955 : : }
1956 [ - + ]: 10 : if (!m_db.commit())
1957 : 0 : m_db.rollback();
1958 : : }
1959 : :
1960 : 72 : bool MailCache::searchIndexEmpty() const {
1961 [ + - - + ]: 72 : if (!m_db.isOpen()) return true;
1962 : : // SEC-2026-07-21-23: if a previous rebuild was interrupted (crash/power
1963 : : // failure after the autocommit DELETE but before all batches committed),
1964 : : // the index is known-incomplete. Treat it as empty so the startup trigger
1965 : : // rebuilds it.
1966 [ + - ]: 72 : QSqlQuery metaQ(m_db);
1967 [ + - + + ]: 72 : if (!metaQ.exec(QStringLiteral(
1968 : : "SELECT value FROM cache_meta WHERE key = 'fts_rebuild_in_progress'"))) {
1969 [ + - + - : 4 : qCWarning(lcCache) << "Cannot verify FTS rebuild watermark:"
+ - + + ]
1970 [ + - + - : 2 : << metaQ.lastError().text();
+ - ]
1971 : 2 : return true;
1972 : : }
1973 [ + - - + ]: 70 : if (metaQ.next()) {
1974 [ # # # # : 0 : qCWarning(lcCache) << "FTS index marked incomplete by interrupted rebuild";
# # # # ]
1975 : 0 : return true;
1976 : : }
1977 [ + - ]: 70 : QSqlQuery q(m_db);
1978 [ + - + + : 140 : if (!q.exec(QStringLiteral("SELECT COUNT(*) FROM mail_fts")) || !q.next()) {
+ - - + +
- + - + +
- - - - ]
1979 [ + - + - : 2 : qCWarning(lcCache) << "Cannot verify FTS index contents:"
+ - + + ]
1980 [ + - + - : 1 : << q.lastError().text();
+ - ]
1981 : 1 : return true;
1982 : : }
1983 [ + - + - ]: 69 : return q.value(0).toInt() == 0;
1984 : 72 : }
1985 : :
1986 : 19 : bool MailCache::rebuildSearchIndex() {
1987 [ + - - + ]: 19 : if (!m_db.isOpen())
1988 : 0 : return false;
1989 : :
1990 [ + - + - : 38 : qCInfo(lcCache) << "Rebuilding FTS5 search index...";
+ - + + ]
1991 : :
1992 : : // Commit the watermark and initial delete atomically. A crash can therefore
1993 : : // never leave a cleared index without the repair marker.
1994 [ + - - + ]: 19 : if (!m_db.transaction()) {
1995 [ # # # # : 0 : qCWarning(lcCache) << "Failed to start FTS reset transaction:"
# # # # ]
1996 [ # # # # : 0 : << m_db.lastError().text();
# # ]
1997 : 0 : return false;
1998 : : }
1999 [ + - ]: 19 : QSqlQuery meta(m_db);
2000 [ + - + + ]: 19 : if (!meta.exec(QStringLiteral(
2001 : : "INSERT OR REPLACE INTO cache_meta (key, value) "
2002 : : "VALUES ('fts_rebuild_in_progress', '1')"))) {
2003 [ + - + - : 6 : qCWarning(lcCache) << "Failed to set FTS rebuild watermark:"
+ - + + ]
2004 [ + - + - : 3 : << meta.lastError().text();
+ - ]
2005 [ + - ]: 3 : m_db.rollback();
2006 : 3 : return false;
2007 : : }
2008 : :
2009 [ + - ]: 16 : QSqlQuery q(m_db);
2010 [ + - + + ]: 16 : if (!q.exec(QStringLiteral("DELETE FROM mail_fts"))) {
2011 [ + - + - : 2 : qCWarning(lcCache) << "Failed to clear FTS index:"
+ - + + ]
2012 [ + - + - : 1 : << q.lastError().text();
+ - ]
2013 [ + - ]: 1 : m_db.rollback();
2014 : 1 : return false;
2015 : : }
2016 [ + - - + ]: 15 : if (!m_db.commit()) {
2017 [ # # # # : 0 : qCWarning(lcCache) << "Failed to commit FTS reset transaction:"
# # # # ]
2018 [ # # # # : 0 : << m_db.lastError().text();
# # ]
2019 [ # # ]: 0 : m_db.rollback();
2020 : 0 : return false;
2021 : : }
2022 : :
2023 : : // Collect header ids first (cheap) so we never hold a SELECT cursor open
2024 : : // across the commits below.
2025 : 15 : QList<qint64> ids;
2026 : : {
2027 [ + - ]: 15 : QSqlQuery sel(m_db);
2028 [ + - - + ]: 15 : if (!sel.exec(QStringLiteral("SELECT id FROM headers"))) {
2029 [ # # # # : 0 : qCWarning(lcCache) << "Failed to enumerate headers for FTS rebuild:"
# # # # ]
2030 [ # # # # : 0 : << sel.lastError().text();
# # ]
2031 : 0 : return false;
2032 : : }
2033 [ + - + + ]: 1668 : while (sel.next())
2034 [ + - + - : 1653 : ids.append(sel.value(0).toLongLong());
+ - ]
2035 [ + - ]: 15 : }
2036 : :
2037 : : // Re-index in batches, committing periodically. This runs on a background
2038 : : // thread; committing every BATCH rows releases the WAL write lock so the
2039 : : // main thread's sync writes (storeHeaders/indexForSearch) are not blocked
2040 : : // for the whole rebuild — which would freeze the UI on startup.
2041 : 15 : constexpr int BATCH = 500;
2042 : 15 : int count = 0;
2043 [ + + ]: 23 : for (int i = 0; i < ids.size();) {
2044 [ + - - + ]: 8 : if (!m_db.transaction()) {
2045 [ # # # # : 0 : qCWarning(lcCache) << "Failed to start FTS rebuild batch:"
# # # # ]
2046 [ # # # # : 0 : << m_db.lastError().text();
# # ]
2047 : 0 : return false;
2048 : : }
2049 : 8 : int batchCount = 0;
2050 [ + + + + : 1661 : for (int j = 0; j < BATCH && i < ids.size(); ++j, ++i) {
+ + ]
2051 [ + - + - : 1653 : if (!indexHeaderById(ids[i])) {
- + ]
2052 [ # # ]: 0 : m_db.rollback();
2053 [ # # # # : 0 : qCWarning(lcCache) << "FTS5 index rebuild failed";
# # # # ]
2054 : 0 : return false;
2055 : : }
2056 : 1653 : ++batchCount;
2057 : : }
2058 [ + - - + ]: 8 : if (!m_db.commit()) {
2059 [ # # ]: 0 : m_db.rollback();
2060 [ # # # # : 0 : qCWarning(lcCache) << "FTS5 index rebuild commit failed:"
# # # # ]
2061 [ # # # # : 0 : << m_db.lastError().text();
# # ]
2062 : 0 : return false;
2063 : : }
2064 : 8 : count += batchCount;
2065 : : }
2066 : :
2067 [ + - + - : 30 : qCInfo(lcCache) << "FTS5 index rebuilt:" << count << "entries";
+ - + - +
- + + ]
2068 : :
2069 : : // Clearing the marker is part of the observable success contract. If it
2070 : : // fails, the marker remains and startup safely retries the rebuild.
2071 [ + - ]: 15 : QSqlQuery clearMeta(m_db);
2072 [ + - - + ]: 15 : if (!clearMeta.exec(QStringLiteral(
2073 : : "DELETE FROM cache_meta WHERE key = 'fts_rebuild_in_progress'"))) {
2074 [ # # # # : 0 : qCWarning(lcCache) << "Failed to clear FTS rebuild watermark:"
# # # # ]
2075 [ # # # # : 0 : << clearMeta.lastError().text();
# # ]
2076 : 0 : return false;
2077 : : }
2078 : 15 : return true;
2079 : 19 : }
2080 : :
2081 : 874 : qint64 MailCache::calculatePayloadCacheSize() const {
2082 [ + - ]: 874 : QSqlQuery q(m_db);
2083 [ + - + + : 1748 : if (!q.exec(QStringLiteral(
- - - - ]
2084 : : "SELECT "
2085 : : "COALESCE((SELECT SUM("
2086 : : " COALESCE(length(CAST(text_plain AS BLOB)), 0) + "
2087 : : " COALESCE(length(CAST(text_html AS BLOB)), 0) + "
2088 : : " COALESCE(length(raw_body), 0)"
2089 : : ") FROM bodies), 0) + "
2090 : : "COALESCE((SELECT SUM(COALESCE(length(data), 0)) "
2091 [ + - + + : 2620 : "FROM attachments), 0)")) ||
+ - ]
2092 [ + - - + ]: 872 : !q.next()) {
2093 : 2 : return 0;
2094 : : }
2095 [ + - + - ]: 872 : return q.value(0).toLongLong();
2096 : 874 : }
2097 : :
2098 : 537 : bool MailCache::enforcePayloadCacheLimit(qint64 protectedHeaderId,
2099 : : qint64 reserveBytes) {
2100 [ + - + - : 537 : if (!m_db.isOpen() || m_payloadCacheLimitBytes < 0)
+ + + + ]
2101 : 1 : return true;
2102 : 536 : reserveBytes = qMax<qint64>(0, reserveBytes);
2103 [ + - ]: 536 : m_payloadCacheBytes = calculatePayloadCacheSize();
2104 [ + + ]: 536 : if (m_payloadCacheBytes <= m_payloadCacheLimitBytes - reserveBytes)
2105 : 532 : return true;
2106 : :
2107 : : struct Candidate {
2108 : : qint64 headerId;
2109 : : qint64 bytes;
2110 : : };
2111 : 4 : QList<Candidate> candidates;
2112 : 4 : qint64 bytesToFree =
2113 : 4 : m_payloadCacheBytes + reserveBytes - m_payloadCacheLimitBytes;
2114 : :
2115 [ + - ]: 4 : QSqlQuery select(m_db);
2116 [ + - ]: 4 : select.prepare(QStringLiteral(
2117 : : "SELECT h.id, "
2118 : : "COALESCE(length(CAST(b.text_plain AS BLOB)), 0) + "
2119 : : "COALESCE(length(CAST(b.text_html AS BLOB)), 0) + "
2120 : : "COALESCE(length(b.raw_body), 0) + "
2121 : : "COALESCE((SELECT SUM(COALESCE(length(a.data), 0)) "
2122 : : " FROM attachments a WHERE a.header_id = h.id), 0) AS bytes "
2123 : : "FROM headers h "
2124 : : "LEFT JOIN bodies b ON b.header_id = h.id "
2125 : : "WHERE b.header_id IS NOT NULL "
2126 : : " OR EXISTS (SELECT 1 FROM attachments a "
2127 : : " WHERE a.header_id = h.id AND a.data IS NOT NULL) "
2128 : : "ORDER BY CASE WHEN h.id = :protected THEN 1 ELSE 0 END, "
2129 : : " COALESCE(b.fetched_at, 0), h.id"));
2130 [ + - ]: 8 : select.bindValue(QStringLiteral(":protected"), protectedHeaderId);
2131 [ + - - + ]: 4 : if (!select.exec()) {
2132 [ # # # # : 0 : qCWarning(lcCache) << "Failed to select payload cache eviction candidates:"
# # # # ]
2133 [ # # # # : 0 : << select.lastError().text();
# # ]
2134 : 0 : return false;
2135 : : }
2136 : :
2137 : 4 : qint64 selectedBytes = 0;
2138 [ + - + + : 11 : while (select.next() && selectedBytes < bytesToFree) {
+ - + + ]
2139 [ + - + - ]: 7 : const qint64 headerId = select.value(0).toLongLong();
2140 [ + - + - ]: 7 : const qint64 bytes = select.value(1).toLongLong();
2141 [ + + - + ]: 7 : if (headerId == protectedHeaderId || bytes <= 0)
2142 : 1 : continue;
2143 [ + - ]: 6 : candidates.append({headerId, bytes});
2144 : 6 : selectedBytes += bytes;
2145 : : }
2146 [ + - ]: 4 : select.finish();
2147 : :
2148 [ + + ]: 4 : if (candidates.isEmpty())
2149 : 2 : return false;
2150 : :
2151 [ + - - + ]: 2 : if (!m_db.transaction()) {
2152 [ # # # # : 0 : qCWarning(lcCache) << "Failed to start payload cache eviction:"
# # # # ]
2153 [ # # # # : 0 : << m_db.lastError().text();
# # ]
2154 : 0 : return false;
2155 : : }
2156 : :
2157 [ + - ]: 2 : QSqlQuery indexed(m_db);
2158 [ + - ]: 2 : indexed.prepare(
2159 : 4 : QStringLiteral("SELECT 1 FROM mail_fts WHERE rowid = :rowid LIMIT 1"));
2160 [ + - ]: 2 : QSqlQuery deleteAttachments(m_db);
2161 [ + - ]: 2 : deleteAttachments.prepare(
2162 : 4 : QStringLiteral("DELETE FROM attachments WHERE header_id = :headerId"));
2163 [ + - ]: 2 : QSqlQuery deleteBody(m_db);
2164 [ + - ]: 2 : deleteBody.prepare(
2165 : 4 : QStringLiteral("DELETE FROM bodies WHERE header_id = :headerId"));
2166 : :
2167 [ + - + - : 8 : for (const auto &candidate : candidates) {
+ + ]
2168 [ + - ]: 12 : indexed.bindValue(QStringLiteral(":rowid"), candidate.headerId);
2169 [ + - + - : 6 : const bool wasIndexed = indexed.exec() && indexed.next();
+ - + - ]
2170 [ + - ]: 6 : indexed.finish();
2171 : :
2172 [ + - ]: 6 : deleteAttachments.bindValue(QStringLiteral(":headerId"),
2173 : 6 : candidate.headerId);
2174 [ + - ]: 12 : deleteBody.bindValue(QStringLiteral(":headerId"), candidate.headerId);
2175 [ + - + - : 12 : if (!deleteAttachments.exec() || !deleteBody.exec() ||
+ - + - +
- - + ]
2176 [ + - - + ]: 6 : (wasIndexed && !indexHeaderById(candidate.headerId))) {
2177 [ # # ]: 0 : m_db.rollback();
2178 [ # # ]: 0 : m_payloadCacheBytes = calculatePayloadCacheSize();
2179 [ # # # # : 0 : qCWarning(lcCache) << "Failed to evict cached mail payload";
# # # # ]
2180 : 0 : return false;
2181 : : }
2182 : : }
2183 : :
2184 [ + - - + ]: 2 : if (!m_db.commit()) {
2185 [ # # ]: 0 : m_db.rollback();
2186 [ # # ]: 0 : m_payloadCacheBytes = calculatePayloadCacheSize();
2187 [ # # # # : 0 : qCWarning(lcCache) << "Failed to commit payload cache eviction:"
# # # # ]
2188 [ # # # # : 0 : << m_db.lastError().text();
# # ]
2189 : 0 : return false;
2190 : : }
2191 : :
2192 [ + - ]: 2 : m_payloadCacheBytes = calculatePayloadCacheSize();
2193 [ + - + - : 4 : qCInfo(lcCache) << "Evicted" << candidates.size()
+ - + - +
+ ]
2194 [ + - ]: 2 : << "cached mail payloads; remaining bytes:"
2195 [ + - ]: 2 : << m_payloadCacheBytes;
2196 : 2 : return m_payloadCacheBytes <= m_payloadCacheLimitBytes - reserveBytes;
2197 : 4 : }
|