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