MailJDTourDownloadDocsQualityTestsCoverage

Which source lines and branches the tests execute. Lines 91.5 %, branches 75.0 % (minimum 85 % and 72 %). main @ 399ffe3d · 21 October 2026 · pipeline 7238.

GCC Code Coverage Report


Directory: src/
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 93.7% 1589 / 0 / 1696
Functions: 100.0% 83 / 0 / 83
Branches: 82.9% 2508 / 40 / 3064

data/MailCache.cpp
Line Branch Exec Source
1 #include "MailCache.h"
2 #include "DatabaseSecurity.h"
3 #include "ThreadProjectionSchema.h"
4
5 #include <QDir>
6 #include <QCryptographicHash>
7 #include <QDateTime>
8 #include <QFileInfo>
9 #include <QFile>
10 #include <QLoggingCategory>
11 #include <QJsonArray>
12 #include <QJsonDocument>
13 #include <QRegularExpression>
14 #include <QSqlError>
15 #include <QSqlQuery>
16 #include <QScopeGuard>
17 #include <QElapsedTimer>
18 #include <QSqlDriver>
19 #include <sqlite3.h>
20 #include <QUuid>
21 #include <QUrl>
22 #include <QtEndian>
23
24 #include <limits>
25
26 #include "data/MailCacheSql.h"
27 #include "data/MailSort.h"
28 #include "data/MailSortSql.h"
29 #include "util/MessageId.h"
30 #include "util/Utf8Digest.h"
31
32
4/7
✓ Branch 2 → 3 taken 77 times.
✓ Branch 2 → 8 taken 3722 times.
✓ Branch 4 → 5 taken 77 times.
✗ Branch 4 → 8 not taken.
✓ Branch 5 → 6 taken 77 times.
✗ Branch 10 → 11 not taken.
✗ Branch 10 → 12 not taken.
3799 Q_LOGGING_CATEGORY(lcCache, "mailjd.cache")
33
34 using MailCacheSql::AppendPayloadSchema;
35 using MailCacheSql::ScopedWriteUnit;
36 using MailCacheSql::decodeThreadIds;
37 using MailCacheSql::encodeThreadIds;
38 using MailCacheSql::beginWriteTransaction;
39
40
41
42 31566 static qint64 utf8ByteSize(QStringView text) {
43 31566 qint64 bytes = 0;
44
2/2
✓ Branch 24 → 3 taken 24453756 times.
✓ Branch 24 → 25 taken 31566 times.
24485322 for (qsizetype index = 0; index < text.size(); ++index) {
45 24453756 const ushort codeUnit = text.at(index).unicode();
46
2/2
✓ Branch 5 → 6 taken 24453046 times.
✓ Branch 5 → 7 taken 710 times.
24453756 if (codeUnit <= 0x7f) {
47 24453046 ++bytes;
48
2/2
✓ Branch 7 → 8 taken 292 times.
✓ Branch 7 → 9 taken 418 times.
710 } else if (codeUnit <= 0x7ff) {
49 292 bytes += 2;
50
4/6
✓ Branch 10 → 11 taken 3 times.
✓ Branch 10 → 18 taken 415 times.
✓ Branch 12 → 13 taken 3 times.
✗ Branch 12 → 18 not taken.
✓ Branch 16 → 17 taken 3 times.
✗ Branch 16 → 18 not taken.
421 } else if (QChar::isHighSurrogate(codeUnit) && index + 1 < text.size() &&
51
2/2
✓ Branch 19 → 20 taken 3 times.
✓ Branch 19 → 21 taken 415 times.
421 QChar::isLowSurrogate(text.at(index + 1).unicode())) {
52 3 bytes += 4;
53 3 ++index;
54 } else {
55 415 bytes += 3;
56 }
57 }
58 31566 return bytes;
59 }
60
61 16997 static bool execCacheMigrationStatement(QSqlQuery &q,
62 const QString &statement,
63 const QString &context,
64 QString *errorOut) {
65
3/3
✓ Branch 2 → 3 taken 16997 times.
✓ Branch 3 → 4 taken 16994 times.
✓ Branch 3 → 5 taken 3 times.
16997 if (q.exec(statement))
66 16994 return true;
67
2/2
✓ Branch 5 → 6 taken 3 times.
✓ Branch 6 → 7 taken 3 times.
3 const QString error = q.lastError().text();
68
7/7
✓ Branch 8 → 9 taken 3 times.
✓ Branch 12 → 13 taken 3 times.
✓ Branch 13 → 14 taken 3 times.
✓ Branch 14 → 15 taken 3 times.
✓ Branch 15 → 16 taken 3 times.
✓ Branch 19 → 10 taken 3 times.
✓ Branch 19 → 20 taken 3 times.
6 qCWarning(lcCache) << context << error << statement;
69
1/2
✓ Branch 20 → 21 taken 3 times.
✗ Branch 20 → 30 not taken.
3 if (errorOut)
70
1/1
✓ Branch 24 → 25 taken 3 times.
3 *errorOut = QStringLiteral("%1 %2").arg(context, error);
71 3 return false;
72 3 }
73
74
75
76 1552 static bool runCacheMigration(QSqlDatabase &db,
77 int currentVersion,
78 int targetVersion,
79 QString *errorOut,
80 bool *needsVersionAfterSchema) {
81
1/2
✓ Branch 2 → 3 taken 1552 times.
✗ Branch 2 → 4 not taken.
1552 if (needsVersionAfterSchema)
82 1552 *needsVersionAfterSchema = false;
83
84
1/1
✓ Branch 4 → 5 taken 1552 times.
1552 QSqlQuery q(db);
85 1552 QStringList statements;
86
87
2/2
✓ Branch 5 → 6 taken 1534 times.
✓ Branch 5 → 71 taken 18 times.
1552 if (currentVersion == 0) {
88
7/7
✓ Branch 6 → 7 taken 1534 times.
✓ Branch 10 → 11 taken 1534 times.
✓ Branch 11 → 12 taken 1534 times.
✓ Branch 12 → 13 taken 1534 times.
✓ Branch 13 → 14 taken 1534 times.
✓ Branch 19 → 8 taken 1534 times.
✓ Branch 19 → 20 taken 1534 times.
3068 qCInfo(lcCache) << "Schema upgrade:" << currentVersion << "->"
89
2/2
✓ Branch 14 → 15 taken 1534 times.
✓ Branch 15 → 16 taken 1534 times.
1534 << targetVersion << "- purging old data";
90
2/4
✓ Branch 55 → 56 taken 16874 times.
✓ Branch 55 → 58 taken 1534 times.
✗ Branch 708 → 709 not taken.
✗ Branch 708 → 711 not taken.
18408 statements = {
91 ✗ QStringLiteral("DROP TABLE IF EXISTS mail_labels"),
92 1534 QStringLiteral("DROP TABLE IF EXISTS folder_badges"),
93 1534 QStringLiteral("DROP TABLE IF EXISTS attachments"),
94 1534 QStringLiteral("DROP TABLE IF EXISTS bodies"),
95 1534 QStringLiteral("DROP TABLE IF EXISTS training_revisions"),
96 1534 QStringLiteral("DROP TABLE IF EXISTS header_counts"),
97 1534 QStringLiteral("DROP TABLE IF EXISTS headers"),
98 1534 QStringLiteral("DROP TABLE IF EXISTS folders"),
99 1534 QStringLiteral("DROP TABLE IF EXISTS mail_fts"),
100 1534 QStringLiteral("DROP TABLE IF EXISTS append_payloads"),
101 1534 QStringLiteral("DROP TABLE IF EXISTS mutation_journal"),
102 18408 };
103
1/2
✓ Branch 69 → 70 taken 1534 times.
✗ Branch 69 → 623 not taken.
1534 if (needsVersionAfterSchema)
104 1534 *needsVersionAfterSchema = true;
105 } else {
106
2/2
✓ Branch 71 → 72 taken 4 times.
✓ Branch 71 → 101 taken 14 times.
18 if (currentVersion <= 9) {
107
5/5
✓ Branch 72 → 73 taken 4 times.
✓ Branch 76 → 77 taken 4 times.
✓ Branch 77 → 78 taken 4 times.
✓ Branch 81 → 74 taken 4 times.
✓ Branch 81 → 82 taken 4 times.
8 qCInfo(lcCache) << "Schema upgrade v9 -> v10: adding threading columns";
108
1/1
✓ Branch 85 → 86 taken 4 times.
8 statements << QStringLiteral(
109 "ALTER TABLE headers ADD COLUMN message_id TEXT")
110
1/1
✓ Branch 89 → 90 taken 4 times.
8 << QStringLiteral(
111 "ALTER TABLE headers ADD COLUMN in_reply_to TEXT")
112
1/1
✓ Branch 93 → 94 taken 4 times.
4 << QStringLiteral(
113 "ALTER TABLE headers ADD COLUMN ref_ids TEXT");
114 }
115
2/2
✓ Branch 101 → 102 taken 5 times.
✓ Branch 101 → 113 taken 13 times.
18 if (currentVersion <= 10) {
116 // Table is created in createSchema() via CREATE TABLE IF NOT EXISTS.
117
5/5
✓ Branch 102 → 103 taken 5 times.
✓ Branch 106 → 107 taken 5 times.
✓ Branch 107 → 108 taken 5 times.
✓ Branch 111 → 104 taken 5 times.
✓ Branch 111 → 112 taken 5 times.
10 qCInfo(lcCache) << "Schema upgrade v10 -> v11: adding whitelist table";
118 }
119
2/2
✓ Branch 113 → 114 taken 5 times.
✓ Branch 113 → 131 taken 13 times.
18 if (currentVersion <= 11) {
120
5/5
✓ Branch 114 → 115 taken 5 times.
✓ Branch 118 → 119 taken 5 times.
✓ Branch 119 → 120 taken 5 times.
✓ Branch 123 → 116 taken 5 times.
✓ Branch 123 → 124 taken 5 times.
10 qCInfo(lcCache) << "Schema upgrade v11 -> v12: adding highest_modseq";
121
1/1
✓ Branch 127 → 128 taken 5 times.
5 statements << QStringLiteral(
122 "ALTER TABLE folders ADD COLUMN highest_modseq INTEGER DEFAULT 0");
123 }
124
2/2
✓ Branch 131 → 132 taken 6 times.
✓ Branch 131 → 149 taken 12 times.
18 if (currentVersion <= 12) {
125
5/5
✓ Branch 132 → 133 taken 6 times.
✓ Branch 136 → 137 taken 6 times.
✓ Branch 137 → 138 taken 6 times.
✓ Branch 141 → 134 taken 6 times.
✓ Branch 141 → 142 taken 6 times.
12 qCInfo(lcCache) << "Schema upgrade v12 -> v13: adding is_spam column";
126
1/1
✓ Branch 145 → 146 taken 6 times.
6 statements << QStringLiteral(
127 "ALTER TABLE headers ADD COLUMN is_spam INTEGER DEFAULT 0");
128 }
129
2/2
✓ Branch 149 → 150 taken 7 times.
✓ Branch 149 → 167 taken 11 times.
18 if (currentVersion <= 13) {
130
5/5
✓ Branch 150 → 151 taken 7 times.
✓ Branch 154 → 155 taken 7 times.
✓ Branch 155 → 156 taken 7 times.
✓ Branch 159 → 152 taken 7 times.
✓ Branch 159 → 160 taken 7 times.
14 qCInfo(lcCache) << "Schema upgrade v13 -> v14: resetting FTS index";
131
1/1
✓ Branch 163 → 164 taken 7 times.
7 statements << QStringLiteral("DROP TABLE IF EXISTS mail_fts");
132 }
133
2/2
✓ Branch 167 → 168 taken 8 times.
✓ Branch 167 → 185 taken 10 times.
18 if (currentVersion <= 14) {
134 // Drop the old unicode61 FTS table; createSchema() recreates it with the
135 // trigram tokenizer and the background rebuild repopulates it with folded
136 // text (substring + diacritic-insensitive search).
137
5/5
✓ Branch 168 → 169 taken 8 times.
✓ Branch 172 → 173 taken 8 times.
✓ Branch 173 → 174 taken 8 times.
✓ Branch 177 → 170 taken 8 times.
✓ Branch 177 → 178 taken 8 times.
16 qCInfo(lcCache) << "Schema upgrade v14 -> v15: trigram FTS + folding";
138
1/1
✓ Branch 181 → 182 taken 8 times.
8 statements << QStringLiteral("DROP TABLE IF EXISTS mail_fts");
139 }
140
2/2
✓ Branch 185 → 186 taken 9 times.
✓ Branch 185 → 203 taken 9 times.
18 if (currentVersion <= 15) {
141 // Drop the FTS table so the background rebuild re-indexes bodies with the
142 // new HTML-fallback (HTML-only mails were previously not body-searchable).
143
5/5
✓ Branch 186 → 187 taken 9 times.
✓ Branch 190 → 191 taken 9 times.
✓ Branch 191 → 192 taken 9 times.
✓ Branch 195 → 188 taken 9 times.
✓ Branch 195 → 196 taken 9 times.
18 qCInfo(lcCache) << "Schema upgrade v15 -> v16: index HTML mail bodies";
144
1/1
✓ Branch 199 → 200 taken 9 times.
9 statements << QStringLiteral("DROP TABLE IF EXISTS mail_fts");
145 }
146
2/2
✓ Branch 203 → 204 taken 11 times.
✓ Branch 203 → 281 taken 7 times.
18 if (currentVersion <= 16) {
147
4/4
✓ Branch 204 → 205 taken 11 times.
✓ Branch 208 → 209 taken 11 times.
✓ Branch 213 → 206 taken 11 times.
✓ Branch 213 → 214 taken 11 times.
22 qCInfo(lcCache)
148
1/1
✓ Branch 209 → 210 taken 11 times.
11 << "Schema upgrade v16 -> v17: persisting attachment presentation";
149
1/1
✓ Branch 214 → 215 taken 11 times.
11 QSqlQuery tableCheck(db);
150
1/1
✓ Branch 218 → 219 taken 11 times.
11 tableCheck.prepare(QStringLiteral(
151 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = :name"));
152
1/1
✓ Branch 229 → 230 taken 11 times.
22 tableCheck.bindValue(QStringLiteral(":name"),
153 22 QStringLiteral("attachments"));
154 const bool attachmentTableExists =
155
5/6
✓ Branch 235 → 236 taken 11 times.
✓ Branch 236 → 237 taken 11 times.
✗ Branch 236 → 240 not taken.
✓ Branch 237 → 238 taken 11 times.
✓ Branch 238 → 239 taken 8 times.
✓ Branch 238 → 240 taken 3 times.
11 tableCheck.exec() && tableCheck.next();
156 11 bool presentationColumnExists = false;
157
2/2
✓ Branch 241 → 242 taken 8 times.
✓ Branch 241 → 248 taken 3 times.
19 if (attachmentTableExists &&
158
8/13
✓ Branch 245 → 246 taken 8 times.
✓ Branch 246 → 247 taken 8 times.
✗ Branch 246 → 248 not taken.
✓ Branch 249 → 250 taken 8 times.
✓ Branch 249 → 251 taken 3 times.
✓ Branch 251 → 252 taken 8 times.
✓ Branch 251 → 253 taken 3 times.
✓ Branch 253 → 254 taken 8 times.
✓ Branch 253 → 270 taken 3 times.
✗ Branch 861 → 862 not taken.
✗ Branch 861 → 863 not taken.
✗ Branch 865 → 866 not taken.
✗ Branch 865 → 867 not taken.
19 tableCheck.exec(QStringLiteral("PRAGMA table_info(attachments)"))) {
159
3/3
✓ Branch 267 → 268 taken 54 times.
✓ Branch 268 → 255 taken 51 times.
✓ Branch 268 → 269 taken 3 times.
54 while (tableCheck.next()) {
160
2/2
✓ Branch 258 → 259 taken 51 times.
✓ Branch 259 → 260 taken 51 times.
51 if (tableCheck.value(1).toString() ==
161
2/2
✓ Branch 265 → 266 taken 5 times.
✓ Branch 265 → 267 taken 46 times.
102 QStringLiteral("presentation")) {
162 5 presentationColumnExists = true;
163 5 break;
164 }
165 }
166 }
167
4/4
✓ Branch 270 → 271 taken 8 times.
✓ Branch 270 → 279 taken 3 times.
✓ Branch 271 → 272 taken 3 times.
✓ Branch 271 → 279 taken 5 times.
11 if (attachmentTableExists && !presentationColumnExists) {
168
1/1
✓ Branch 275 → 276 taken 3 times.
3 statements << QStringLiteral(
169 "ALTER TABLE attachments ADD COLUMN presentation INTEGER "
170 "DEFAULT 0");
171 }
172 11 }
173
2/2
✓ Branch 281 → 282 taken 12 times.
✓ Branch 281 → 293 taken 6 times.
18 if (currentVersion <= 17) {
174
4/4
✓ Branch 282 → 283 taken 12 times.
✓ Branch 286 → 287 taken 12 times.
✓ Branch 291 → 284 taken 12 times.
✓ Branch 291 → 292 taken 12 times.
24 qCInfo(lcCache)
175
1/1
✓ Branch 287 → 288 taken 12 times.
12 << "Schema upgrade v17 -> v18: adding durable mutation journal";
176 // createSchema() creates the table and indexes after this transaction.
177 }
178
2/2
✓ Branch 293 → 294 taken 13 times.
✓ Branch 293 → 305 taken 5 times.
18 if (currentVersion <= 18) {
179
4/4
✓ Branch 294 → 295 taken 13 times.
✓ Branch 298 → 299 taken 13 times.
✓ Branch 303 → 296 taken 13 times.
✓ Branch 303 → 304 taken 13 times.
26 qCInfo(lcCache)
180
1/1
✓ Branch 299 → 300 taken 13 times.
13 << "Schema upgrade v18 -> v19: canonical threading token arrays";
181 // Existing columns remain compatible. Fresh writes use JSON arrays;
182 // legacy whitespace-separated rows are decoded and canonicalized on
183 // read because raw RFC header blocks were intentionally not cached.
184 }
185
2/2
✓ Branch 305 → 306 taken 13 times.
✓ Branch 305 → 335 taken 5 times.
18 if (currentVersion <= 19) {
186
4/4
✓ Branch 306 → 307 taken 13 times.
✓ Branch 310 → 311 taken 13 times.
✓ Branch 315 → 308 taken 13 times.
✓ Branch 315 → 316 taken 13 times.
26 qCInfo(lcCache)
187
1/1
✓ Branch 311 → 312 taken 13 times.
13 << "Schema upgrade v19 -> v20: external-content FTS documents";
188
1/1
✓ Branch 319 → 320 taken 13 times.
26 statements << QStringLiteral(
189 "CREATE TABLE IF NOT EXISTS cache_meta ("
190 "key TEXT PRIMARY KEY, value TEXT)")
191
1/1
✓ Branch 323 → 324 taken 13 times.
26 << QStringLiteral(
192 "INSERT OR REPLACE INTO cache_meta(key,value) "
193 "VALUES('fts_rebuild_in_progress','1')")
194
1/1
✓ Branch 327 → 328 taken 13 times.
13 << QStringLiteral("DROP TABLE IF EXISTS mail_fts");
195 }
196
2/2
✓ Branch 335 → 336 taken 13 times.
✓ Branch 335 → 402 taken 5 times.
18 if (currentVersion <= 20) {
197
4/4
✓ Branch 336 → 337 taken 13 times.
✓ Branch 340 → 341 taken 13 times.
✓ Branch 345 → 338 taken 13 times.
✓ Branch 345 → 346 taken 13 times.
26 qCInfo(lcCache)
198
1/1
✓ Branch 341 → 342 taken 13 times.
13 << "Schema upgrade v20 -> v21: transactional payload accounting";
199 // Some pre-v17 databases (and the historical migration fixture) have a
200 // minimal attachments table. The v21 accounting triggers reference the
201 // payload column, so make that dependency explicit before createSchema
202 // initializes the counter. CREATE TABLE IF NOT EXISTS cannot add it.
203
1/1
✓ Branch 346 → 347 taken 13 times.
13 QSqlQuery attachmentColumns(db);
204 13 bool attachmentTableExists = false;
205 13 bool attachmentDataExists = false;
206
3/8
✓ Branch 358 → 359 taken 13 times.
✗ Branch 358 → 360 not taken.
✓ Branch 360 → 361 taken 9 times.
✓ Branch 360 → 362 taken 4 times.
✗ Branch 926 → 927 not taken.
✗ Branch 926 → 928 not taken.
✗ Branch 930 → 931 not taken.
✗ Branch 930 → 932 not taken.
26 if (attachmentColumns.exec(QStringLiteral(
207 "SELECT 1 FROM sqlite_master WHERE type='table' "
208
3/5
✓ Branch 350 → 351 taken 13 times.
✓ Branch 351 → 352 taken 13 times.
✗ Branch 351 → 355 not taken.
✓ Branch 356 → 357 taken 13 times.
✗ Branch 356 → 358 not taken.
39 "AND name='attachments'")) &&
209
3/3
✓ Branch 352 → 353 taken 13 times.
✓ Branch 353 → 354 taken 9 times.
✓ Branch 353 → 355 taken 4 times.
13 attachmentColumns.next()) {
210 9 attachmentTableExists = true;
211 }
212
2/2
✓ Branch 362 → 363 taken 9 times.
✓ Branch 362 → 369 taken 4 times.
22 if (attachmentTableExists &&
213
2/3
✓ Branch 366 → 367 taken 9 times.
✓ Branch 367 → 368 taken 9 times.
✗ Branch 367 → 369 not taken.
9 attachmentColumns.exec(
214
6/10
✓ Branch 370 → 371 taken 9 times.
✓ Branch 370 → 372 taken 4 times.
✓ Branch 372 → 373 taken 9 times.
✓ Branch 372 → 374 taken 4 times.
✓ Branch 374 → 375 taken 9 times.
✓ Branch 374 → 391 taken 4 times.
✗ Branch 934 → 935 not taken.
✗ Branch 934 → 936 not taken.
✗ Branch 938 → 939 not taken.
✗ Branch 938 → 940 not taken.
22 QStringLiteral("PRAGMA table_info(attachments)"))) {
215
3/3
✓ Branch 388 → 389 taken 65 times.
✓ Branch 389 → 376 taken 64 times.
✓ Branch 389 → 390 taken 1 time.
65 while (attachmentColumns.next()) {
216
2/2
✓ Branch 379 → 380 taken 64 times.
✓ Branch 380 → 381 taken 64 times.
64 if (attachmentColumns.value(1).toString() ==
217
2/2
✓ Branch 386 → 387 taken 8 times.
✓ Branch 386 → 388 taken 56 times.
128 QStringLiteral("data")) {
218 8 attachmentDataExists = true;
219 8 break;
220 }
221 }
222 }
223
4/4
✓ Branch 391 → 392 taken 9 times.
✓ Branch 391 → 400 taken 4 times.
✓ Branch 392 → 393 taken 1 time.
✓ Branch 392 → 400 taken 8 times.
13 if (attachmentTableExists && !attachmentDataExists)
224
1/1
✓ Branch 396 → 397 taken 1 time.
1 statements << QStringLiteral(
225 "ALTER TABLE attachments ADD COLUMN data BLOB");
226 // createSchema installs the counter and triggers. INSERT OR IGNORE there
227 // computes the legacy value exactly once before trigger maintenance.
228 13 }
229
2/2
✓ Branch 402 → 403 taken 13 times.
✓ Branch 402 → 432 taken 5 times.
18 if (currentVersion <= 21) {
230
4/4
✓ Branch 403 → 404 taken 13 times.
✓ Branch 407 → 408 taken 13 times.
✓ Branch 412 → 405 taken 13 times.
✓ Branch 412 → 413 taken 13 times.
26 qCInfo(lcCache)
231
1/1
✓ Branch 408 → 409 taken 13 times.
13 << "Schema upgrade v21 -> v22: exact FTS document accounting";
232 // Recreated by createSchema() with transactional counter maintenance.
233
1/1
✓ Branch 416 → 417 taken 13 times.
26 statements << QStringLiteral(
234 "DROP TRIGGER IF EXISTS search_documents_ai")
235
1/1
✓ Branch 420 → 421 taken 13 times.
26 << QStringLiteral(
236 "DROP TRIGGER IF EXISTS search_documents_ad")
237
1/1
✓ Branch 424 → 425 taken 13 times.
13 << QStringLiteral(
238 "DROP TRIGGER IF EXISTS search_documents_au");
239 }
240
3/3
✓ Branch 432 → 433 taken 15 times.
✓ Branch 432 → 434 taken 3 times.
✓ Branch 433 → 434 taken 15 times.
18 if (currentVersion <= 22) statements << AppendPayloadSchema;
241
2/2
✓ Branch 434 → 435 taken 16 times.
✓ Branch 434 → 502 taken 2 times.
18 if (currentVersion <= 23) {
242 // Sprint 95 S95.1.11: every Cc address and the List-Id for server rules
243 // and `to:`/`list:` search. Existing rows stay NULL ("not known") and
244 // are filled when a sync fetches their header again. A database
245 // without a headers table gets the columns from createSchema().
246
5/5
✓ Branch 435 → 436 taken 16 times.
✓ Branch 439 → 440 taken 16 times.
✓ Branch 440 → 441 taken 16 times.
✓ Branch 444 → 437 taken 16 times.
✓ Branch 444 → 445 taken 16 times.
32 qCInfo(lcCache) << "Schema upgrade v23 -> v24: cc_addr and list_id";
247
1/1
✓ Branch 445 → 446 taken 16 times.
16 QSqlQuery columns(db);
248 16 QStringList present;
249
2/3
✓ Branch 449 → 450 taken 16 times.
✓ Branch 452 → 453 taken 16 times.
✗ Branch 452 → 462 not taken.
16 if (columns.exec(QStringLiteral("PRAGMA table_info(headers)")))
250
6/6
✓ Branch 454 → 455 taken 225 times.
✓ Branch 455 → 456 taken 225 times.
✓ Branch 456 → 457 taken 225 times.
✓ Branch 460 → 461 taken 241 times.
✓ Branch 461 → 454 taken 225 times.
✓ Branch 461 → 462 taken 16 times.
241 while (columns.next()) present << columns.value(1).toString();
251
2/2
✓ Branch 492 → 472 taken 32 times.
✓ Branch 492 → 493 taken 16 times.
112 for (const QString column : {QStringLiteral("cc_addr"), QStringLiteral("list_id")})
252
6/6
✓ Branch 474 → 475 taken 28 times.
✓ Branch 474 → 478 taken 4 times.
✓ Branch 476 → 477 taken 10 times.
✓ Branch 476 → 478 taken 18 times.
✓ Branch 479 → 480 taken 10 times.
✓ Branch 479 → 490 taken 22 times.
32 if (!present.isEmpty() && !present.contains(column))
253
4/6
✓ Branch 484 → 485 taken 10 times.
✓ Branch 485 → 486 taken 10 times.
✓ Branch 494 → 495 taken 32 times.
✓ Branch 494 → 498 taken 16 times.
✗ Branch 1014 → 1015 not taken.
✗ Branch 1014 → 1018 not taken.
90 statements << QStringLiteral("ALTER TABLE headers ADD COLUMN %1 TEXT").arg(column);
254 16 }
255
2/2
✓ Branch 502 → 503 taken 17 times.
✓ Branch 502 → 595 taken 1 time.
18 if (currentVersion <= 24) {
256 // Sprint 105 S105.2.3: the server spam filter's score, threshold and
257 // tool, stored at header sync. Older rows stay NULL ("unknown"); the
258 // reader then reads the raw prefix or fetches the fields once.
259 // Downgrade: v24 code ignores the three columns.
260
5/5
✓ Branch 503 → 504 taken 17 times.
✓ Branch 507 → 508 taken 17 times.
✓ Branch 508 → 509 taken 17 times.
✓ Branch 512 → 505 taken 17 times.
✓ Branch 512 → 513 taken 17 times.
34 qCInfo(lcCache) << "Schema upgrade v24 -> v25: spam_score, spam_threshold, spam_tool";
261
1/1
✓ Branch 513 → 514 taken 17 times.
17 QSqlQuery columns(db);
262 17 QStringList present;
263
2/3
✓ Branch 517 → 518 taken 17 times.
✓ Branch 520 → 521 taken 17 times.
✗ Branch 520 → 530 not taken.
17 if (columns.exec(QStringLiteral("PRAGMA table_info(headers)")))
264
6/6
✓ Branch 522 → 523 taken 241 times.
✓ Branch 523 → 524 taken 241 times.
✓ Branch 524 → 525 taken 241 times.
✓ Branch 528 → 529 taken 258 times.
✓ Branch 529 → 522 taken 241 times.
✓ Branch 529 → 530 taken 17 times.
258 while (columns.next()) present << columns.value(1).toString();
265 170 for (const auto &[column, type] : {std::pair{QStringLiteral("spam_score"), QStringLiteral("REAL")},
266 51 std::pair{QStringLiteral("spam_threshold"), QStringLiteral("REAL")},
267
2/2
✓ Branch 585 → 566 taken 51 times.
✓ Branch 585 → 586 taken 17 times.
221 std::pair{QStringLiteral("spam_tool"), QStringLiteral("TEXT")}})
268
6/6
✓ Branch 569 → 570 taken 45 times.
✓ Branch 569 → 573 taken 6 times.
✓ Branch 571 → 572 taken 15 times.
✓ Branch 571 → 573 taken 30 times.
✓ Branch 574 → 575 taken 15 times.
✓ Branch 574 → 584 taken 36 times.
51 if (!present.isEmpty() && !present.contains(column))
269
4/6
✓ Branch 578 → 579 taken 15 times.
✓ Branch 579 → 580 taken 15 times.
✓ Branch 587 → 588 taken 51 times.
✓ Branch 587 → 591 taken 17 times.
✗ Branch 1053 → 1054 not taken.
✗ Branch 1053 → 1057 not taken.
83 statements << QStringLiteral("ALTER TABLE headers ADD COLUMN %1 %2").arg(column, type);
270 17 }
271
1/2
✓ Branch 595 → 596 taken 18 times.
✗ Branch 595 → 613 not taken.
18 if (currentVersion <= 25) {
272 // Sprint 105 S105.5.1: the address index starts empty; createSchema()
273 // creates it, the background backfill (indexAddressBatch) fills the
274 // existing rows. Downgrade: v25 code ignores the table.
275
5/5
✓ Branch 596 → 597 taken 18 times.
✓ Branch 600 → 601 taken 18 times.
✓ Branch 601 → 602 taken 18 times.
✓ Branch 605 → 598 taken 18 times.
✓ Branch 605 → 606 taken 18 times.
36 qCInfo(lcCache) << "Schema upgrade v25 -> v26: message_addresses (backfill in the background)";
276 // Rows up to here need the backfill; a cache created at v26 has no
277 // watermark (its writer indexed every row) and counts as complete.
278
1/1
✓ Branch 609 → 610 taken 18 times.
18 statements << QStringLiteral(
279 "INSERT OR REPLACE INTO cache_meta(key, value) VALUES ('address_index_watermark', '0')");
280 }
281 36 statements << QStringLiteral("PRAGMA user_version = %1")
282
2/2
✓ Branch 617 → 618 taken 18 times.
✓ Branch 618 → 619 taken 18 times.
36 .arg(targetVersion);
283 }
284
285
3/3
✓ Branch 623 → 624 taken 1552 times.
✓ Branch 624 → 625 taken 3 times.
✓ Branch 624 → 652 taken 1549 times.
1552 if (!beginWriteTransaction(db)) {
286
2/2
✓ Branch 625 → 626 taken 3 times.
✓ Branch 626 → 627 taken 3 times.
3 const QString error = db.lastError().text();
287 6 qCWarning(lcCache) << "Schema migration transaction:" << error; // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; transaction result remains measured.
288
1/2
✓ Branch 639 → 640 taken 3 times.
✗ Branch 639 → 650 not taken.
3 if (errorOut)
289 6 *errorOut = QStringLiteral("Schema migration transaction: %1")
290
1/1
✓ Branch 644 → 645 taken 3 times.
6 .arg(error);
291 3 return false;
292 3 }
293
294
4/4
✓ Branch 652 → 653 taken 1549 times.
✓ Branch 653 → 654 taken 1549 times.
✓ Branch 667 → 655 taken 16997 times.
✓ Branch 667 → 668 taken 1546 times.
18543 for (const auto &statement : statements) {
295
1/1
✓ Branch 659 → 660 taken 16997 times.
16997 if (!execCacheMigrationStatement(q, statement,
296
2/2
✓ Branch 662 → 663 taken 3 times.
✓ Branch 662 → 665 taken 16994 times.
33994 QStringLiteral("Schema migration:"),
297 errorOut)) {
298
1/1
✓ Branch 663 → 664 taken 3 times.
3 db.rollback();
299 3 return false;
300 }
301 }
302
303
2/3
✓ Branch 668 → 669 taken 1546 times.
✗ Branch 669 → 670 not taken.
✓ Branch 669 → 697 taken 1546 times.
1546 if (!db.commit()) {
304 ✗ const QString error = db.lastError().text();
305 ✗ qCWarning(lcCache) << "Schema migration commit:" << error; // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; commit result remains measured.
306 ✗ if (errorOut)
307 ✗ *errorOut = QStringLiteral("Schema migration commit: %1").arg(error);
308 ✗ return false;
309 ✗ }
310
311 1546 return true;
312 19960 }
313
314
8/18
✓ Branch 4 → 5 taken 5035 times.
✓ Branch 7 → 8 taken 5035 times.
✓ Branch 14 → 15 taken 5035 times.
✓ Branch 17 → 18 taken 5035 times.
✓ Branch 20 → 21 taken 5035 times.
✓ Branch 25 → 26 taken 5035 times.
✓ Branch 30 → 31 taken 5035 times.
✓ Branch 33 → 34 taken 5035 times.
✗ Branch 38 → 39 not taken.
✗ Branch 38 → 40 not taken.
✗ Branch 47 → 48 not taken.
✗ Branch 47 → 49 not taken.
✗ Branch 50 → 51 not taken.
✗ Branch 50 → 52 not taken.
✗ Branch 56 → 57 not taken.
✗ Branch 56 → 58 not taken.
✗ Branch 59 → 60 not taken.
✗ Branch 59 → 61 not taken.
5035 MailCache::MailCache(QObject *parent) : QObject(parent) {}
315
316 7480 MailCache::~MailCache() { close(); }
317
318 // A successful cache mutation and its in-flight state notification form one
319 // publication boundary. Header workers take the same lock before BEGIN, so
320 // they cannot acquire SQLite between a local commit and its notification.
321 5012 static std::shared_ptr<QRecursiveMutex> cacheWriteMutex(const QString &path, QString *domain) {
322
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 15 taken 5012 times.
5012 if (path == QStringLiteral(":memory:")) {
323 ✗ *domain = QUuid::createUuid().toString();
324 ✗ return std::make_shared<QRecursiveMutex>();
325 }
326
1/1
✓ Branch 15 → 16 taken 5012 times.
5012 const QFileInfo file(path);
327
1/1
✓ Branch 16 → 17 taken 5012 times.
5012 const auto canonical = file.canonicalFilePath();
328
3/3
✓ Branch 18 → 19 taken 15 times.
✓ Branch 18 → 20 taken 4997 times.
✓ Branch 19 → 21 taken 15 times.
5012 const auto key = canonical.isEmpty() ? file.absoluteFilePath() : canonical;
329 5012 *domain = key;
330
3/4
✓ Branch 22 → 23 taken 77 times.
✓ Branch 22 → 27 taken 4935 times.
✓ Branch 24 → 25 taken 77 times.
✗ Branch 24 → 27 not taken.
5012 static QMutex registryMutex;
331
3/4
✓ Branch 27 → 28 taken 77 times.
✓ Branch 27 → 32 taken 4935 times.
✓ Branch 29 → 30 taken 77 times.
✗ Branch 29 → 32 not taken.
5012 static QHash<QString, std::weak_ptr<QRecursiveMutex>> registry;
332 5012 QMutexLocker lock(&registryMutex);
333
3/3
✓ Branch 33 → 34 taken 5012 times.
✓ Branch 44 → 35 taken 9283 times.
✓ Branch 44 → 45 taken 5012 times.
14295 for (auto it = registry.begin(); it != registry.end();) {
334
3/3
✓ Branch 37 → 38 taken 1527 times.
✓ Branch 37 → 41 taken 7756 times.
✓ Branch 39 → 40 taken 1527 times.
9283 if (it.value().expired()) it = registry.erase(it); else ++it;
335 }
336
4/4
✓ Branch 49 → 50 taken 3388 times.
✓ Branch 49 → 52 taken 1624 times.
✓ Branch 54 → 55 taken 1624 times.
✓ Branch 54 → 57 taken 3388 times.
5012 if (const auto existing = registry.value(key).lock()) return existing;
337
1/1
✓ Branch 56 → 58 taken 1624 times.
1624 auto result = std::make_shared<QRecursiveMutex>();
338
1/1
✓ Branch 59 → 60 taken 1624 times.
1624 registry.insert(key, result);
339 1624 return result;
340 5012 }
341
342 namespace {
343 // mailjd_fold(text): Unicode case folding for page filters (SQLite lower()
344 // only folds ASCII). Deterministic, so SQLite may evaluate it per row freely.
345 184 void foldFunction(sqlite3_context *context, int, sqlite3_value **values) {
346
1/1
✓ Branch 2 → 3 taken 184 times.
184 const auto *text = reinterpret_cast<const char *>(sqlite3_value_text(values[0]));
347
3/3
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 6 taken 182 times.
✓ Branch 4 → 5 taken 2 times.
184 if (!text) { sqlite3_result_null(context); return; }
348
4/4
✓ Branch 6 → 7 taken 182 times.
✓ Branch 7 → 8 taken 182 times.
✓ Branch 8 → 9 taken 182 times.
✓ Branch 9 → 10 taken 182 times.
182 const QByteArray folded = QString::fromUtf8(text, sqlite3_value_bytes(values[0])).toCaseFolded().toUtf8();
349
1/1
✓ Branch 14 → 15 taken 182 times.
182 sqlite3_result_text(context, folded.constData(), int(folded.size()), SQLITE_TRANSIENT);
350 182 }
351 4997 void registerSqlFunctions(QSqlDatabase &db) {
352
2/2
✓ Branch 2 → 3 taken 4997 times.
✓ Branch 3 → 4 taken 4997 times.
4997 const auto handle = db.driver()->handle();
353
9/21
✓ Branch 4 → 5 taken 4997 times.
✓ Branch 5 → 6 taken 4997 times.
✗ Branch 5 → 12 not taken.
✓ Branch 8 → 9 taken 4997 times.
✓ Branch 9 → 10 taken 4997 times.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 4997 times.
✓ Branch 14 → 15 taken 4997 times.
✗ Branch 14 → 16 not taken.
✓ Branch 16 → 17 taken 4997 times.
✗ Branch 16 → 18 not taken.
✓ Branch 18 → 19 taken 4997 times.
✗ Branch 18 → 20 not taken.
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 22 taken 4997 times.
✗ Branch 33 → 34 not taken.
✗ Branch 33 → 35 not taken.
✗ Branch 37 → 38 not taken.
✗ Branch 37 → 39 not taken.
✗ Branch 41 → 42 not taken.
✗ Branch 41 → 43 not taken.
4997 if (!handle.isValid() || QByteArray(handle.typeName()) != QByteArrayLiteral("sqlite3*")) return;
354 4997 auto *connection = *static_cast<sqlite3 **>(const_cast<void *>(handle.constData()));
355
1/2
✓ Branch 23 → 24 taken 4997 times.
✗ Branch 23 → 25 not taken.
4997 if (connection)
356
1/1
✓ Branch 24 → 25 taken 4997 times.
4997 sqlite3_create_function_v2(connection, "mailjd_fold", 1, SQLITE_UTF8 | SQLITE_DETERMINISTIC,
357 nullptr, foldFunction, nullptr, nullptr, nullptr);
358
1/1
✓ Branch 25 → 26 taken 4997 times.
4997 MailSort::registerSqlFunctions(db);
359 4997 }
360 } // namespace
361
362 1662 bool MailCache::open(const QString &dbPath) {
363
1/1
✓ Branch 2 → 3 taken 1662 times.
1662 close(); // S106-105: a re-open drops the previous connection's registration
364 1662 m_lastError.clear();
365
3/3
✓ Branch 4 → 5 taken 1662 times.
✓ Branch 5 → 6 taken 8 times.
✓ Branch 5 → 24 taken 1654 times.
1662 if (!DatabaseSecurity::preparePath(dbPath)) {
366 8 m_lastError = QStringLiteral("Failed to create private database file");
367
6/6
✓ Branch 12 → 13 taken 8 times.
✓ Branch 16 → 17 taken 8 times.
✓ Branch 17 → 18 taken 8 times.
✓ Branch 18 → 19 taken 8 times.
✓ Branch 22 → 14 taken 8 times.
✓ Branch 22 → 23 taken 8 times.
16 qCWarning(lcCache) << m_lastError << dbPath;
368 8 return false;
369 }
370
371 1654 m_dbPath = dbPath;
372
1/1
✓ Branch 25 → 26 taken 1654 times.
1654 m_writeMutex = cacheWriteMutex(dbPath, &m_writeDomain);
373
374 // Use a unique connection name to avoid conflicts with other QSqlDatabase
375 // users
376 m_connectionName =
377
3/3
✓ Branch 28 → 29 taken 1654 times.
✓ Branch 29 → 30 taken 1654 times.
✓ Branch 33 → 34 taken 1654 times.
3308 QStringLiteral("mailjd_cache_") + QUuid::createUuid().toString();
378 1654 m_addressStatements.reset(); // bound to the previous connection
379
2/2
✓ Branch 43 → 44 taken 1654 times.
✓ Branch 44 → 45 taken 1654 times.
3308 m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
380
1/1
✓ Branch 48 → 49 taken 1654 times.
1654 m_db.setDatabaseName(dbPath);
381
382
2/3
✓ Branch 49 → 50 taken 1654 times.
✗ Branch 50 → 51 not taken.
✓ Branch 50 → 68 taken 1654 times.
1654 if (!m_db.open()) {
383 ✗ m_lastError = m_db.lastError().text();
384 ✗ qCWarning(lcCache) << "Failed to open database:" << m_lastError; // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; open result remains measured.
385 ✗ return false;
386 }
387
1/1
✓ Branch 68 → 69 taken 1654 times.
1654 registerSqlFunctions(m_db);
388
2/3
✓ Branch 69 → 70 taken 1654 times.
✗ Branch 70 → 71 not taken.
✓ Branch 70 → 90 taken 1654 times.
1654 if (!DatabaseSecurity::restrictExistingFile(dbPath)) {
389 ✗ m_lastError = QStringLiteral("Failed to restrict database permissions");
390 ✗ qCWarning(lcCache) << m_lastError << dbPath; // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; path-security result remains measured.
391 ✗ m_db.close();
392 ✗ return false;
393 }
394
395 // Performance: WAL journal for fast concurrent reads + writes
396
1/1
✓ Branch 90 → 91 taken 1654 times.
1654 QSqlQuery pragma(m_db);
397
1/1
✓ Branch 94 → 95 taken 1654 times.
1654 pragma.exec(QStringLiteral("PRAGMA journal_mode = WAL"));
398
1/1
✓ Branch 100 → 101 taken 1654 times.
1654 pragma.exec(QStringLiteral("PRAGMA synchronous = NORMAL"));
399
1/1
✓ Branch 106 → 107 taken 1654 times.
1654 pragma.exec(QStringLiteral("PRAGMA foreign_keys = ON"));
400 // T-619/SEC-19: Prevent SQLITE_BUSY errors during concurrent access
401
1/1
✓ Branch 112 → 113 taken 1654 times.
1654 pragma.exec(QStringLiteral("PRAGMA busy_timeout = 5000"));
402
403 // Schema versioning
404 1654 int currentVersion = 0;
405
9/16
✓ Branch 118 → 119 taken 1654 times.
✓ Branch 119 → 120 taken 1651 times.
✓ Branch 119 → 123 taken 3 times.
✓ Branch 120 → 121 taken 1651 times.
✓ Branch 121 → 122 taken 1651 times.
✗ Branch 121 → 123 not taken.
✓ Branch 124 → 125 taken 1654 times.
✗ Branch 124 → 126 not taken.
✓ Branch 126 → 127 taken 1654 times.
✗ Branch 126 → 128 not taken.
✓ Branch 128 → 129 taken 1651 times.
✓ Branch 128 → 133 taken 3 times.
✗ Branch 314 → 315 not taken.
✗ Branch 314 → 316 not taken.
✗ Branch 318 → 319 not taken.
✗ Branch 318 → 320 not taken.
3308 if (pragma.exec(QStringLiteral("PRAGMA user_version")) && pragma.next()) {
406
2/2
✓ Branch 129 → 130 taken 1651 times.
✓ Branch 130 → 131 taken 1651 times.
1651 currentVersion = pragma.value(0).toInt();
407 }
408
1/1
✓ Branch 133 → 134 taken 1654 times.
1654 pragma.finish();
409 // S106-104: a cache written by a newer MailJD (after a downgrade) is not
410 // opened — this version would write old-layout rows into the newer
411 // schema. The two secondary open paths refuse the same mismatch.
412
2/2
✓ Branch 134 → 135 taken 3 times.
✓ Branch 134 → 169 taken 1651 times.
1654 if (currentVersion > SchemaVersion) {
413 6 m_lastError = QStringLiteral("The mail cache was written by a newer MailJD (schema version %1; "
414 "this version knows %2). Start the newer version, or move %3 "
415 "aside to rebuild the cache.")
416
5/5
✓ Branch 139 → 140 taken 3 times.
✓ Branch 141 → 142 taken 3 times.
✓ Branch 143 → 144 taken 3 times.
✓ Branch 144 → 145 taken 3 times.
✓ Branch 145 → 146 taken 3 times.
9 .arg(currentVersion).arg(SchemaVersion).arg(QFileInfo(dbPath).fileName());
417
8/8
✓ Branch 154 → 155 taken 3 times.
✓ Branch 158 → 159 taken 3 times.
✓ Branch 159 → 160 taken 3 times.
✓ Branch 160 → 161 taken 3 times.
✓ Branch 161 → 162 taken 3 times.
✓ Branch 162 → 163 taken 3 times.
✓ Branch 166 → 156 taken 3 times.
✓ Branch 166 → 167 taken 3 times.
6 qCWarning(lcCache) << "Refusing a newer cache schema" << currentVersion << ">" << SchemaVersion;
418
1/1
✓ Branch 167 → 168 taken 3 times.
3 m_db.close();
419 3 return false;
420 }
421 1651 bool needsVersionAfterSchema = false;
422
2/2
✓ Branch 169 → 170 taken 1552 times.
✓ Branch 169 → 174 taken 99 times.
1651 if (currentVersion < SchemaVersion) {
423
3/3
✓ Branch 170 → 171 taken 1552 times.
✓ Branch 171 → 172 taken 6 times.
✓ Branch 171 → 174 taken 1546 times.
1552 if (!runCacheMigration(m_db, currentVersion, SchemaVersion,
424 &m_lastError, &needsVersionAfterSchema)) {
425
1/1
✓ Branch 172 → 173 taken 6 times.
6 m_db.close();
426 6 return false;
427 }
428 }
429
430
3/3
✓ Branch 174 → 175 taken 1645 times.
✓ Branch 175 → 176 taken 8 times.
✓ Branch 175 → 197 taken 1637 times.
1645 if (!m_schema.createSchema()) {
431
2/2
✓ Branch 177 → 178 taken 7 times.
✓ Branch 177 → 185 taken 1 time.
8 if (m_lastError.isEmpty())
432 7 m_lastError = QStringLiteral("Failed to create schema");
433
5/5
✓ Branch 185 → 186 taken 8 times.
✓ Branch 189 → 190 taken 8 times.
✓ Branch 190 → 191 taken 8 times.
✓ Branch 194 → 187 taken 8 times.
✓ Branch 194 → 195 taken 8 times.
16 qCWarning(lcCache) << m_lastError;
434
1/1
✓ Branch 195 → 196 taken 8 times.
8 m_db.close();
435 8 return false;
436 }
437
438
2/2
✓ Branch 197 → 198 taken 1531 times.
✓ Branch 197 → 206 taken 106 times.
3168 if (needsVersionAfterSchema &&
439
5/11
✓ Branch 203 → 204 taken 1531 times.
✗ Branch 204 → 205 not taken.
✓ Branch 204 → 206 taken 1531 times.
✓ Branch 211 → 212 taken 1531 times.
✓ Branch 211 → 213 taken 106 times.
✗ Branch 213 → 214 not taken.
✓ Branch 213 → 232 taken 1637 times.
✗ Branch 362 → 363 not taken.
✗ Branch 362 → 364 not taken.
✗ Branch 366 → 367 not taken.
✗ Branch 366 → 368 not taken.
4699 !pragma.exec(QStringLiteral("PRAGMA user_version = %1")
440
5/7
✓ Branch 202 → 203 taken 1531 times.
✓ Branch 207 → 208 taken 1531 times.
✓ Branch 207 → 209 taken 106 times.
✓ Branch 209 → 210 taken 1531 times.
✓ Branch 209 → 211 taken 106 times.
✗ Branch 357 → 358 not taken.
✗ Branch 357 → 359 not taken.
3168 .arg(SchemaVersion))) {
441 ✗ m_lastError = pragma.lastError().text();
442 ✗ qCWarning(lcCache) << "Failed to set schema version:" << m_lastError; // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; schema result remains measured.
443 ✗ m_db.close();
444 ✗ return false;
445 }
446
447
1/1
✓ Branch 232 → 233 taken 1637 times.
1637 m_payloadLimits.refreshPayloadCacheBytes();
448
1/1
✓ Branch 233 → 234 taken 1637 times.
1637 m_payloadLimits.enforcePayloadCacheLimit();
449
450 1637 m_folderIdentity.clear();
451 1637 m_cacheFolderIdentity = true;
452
6/6
✓ Branch 235 → 236 taken 1637 times.
✓ Branch 239 → 240 taken 1637 times.
✓ Branch 240 → 241 taken 1637 times.
✓ Branch 241 → 242 taken 1637 times.
✓ Branch 245 → 237 taken 1637 times.
✓ Branch 245 → 246 taken 1637 times.
3274 qCInfo(lcCache) << "Database opened:" << dbPath;
453 1637 return true;
454 1654 }
455
456 3056 bool MailCache::openReadOnly(const QString &dbPath, int busyTimeoutMs) {
457
1/1
✓ Branch 2 → 3 taken 3056 times.
3056 close();
458 3056 m_dbPath = dbPath;
459
1/1
✓ Branch 4 → 5 taken 3056 times.
3056 m_writeMutex = cacheWriteMutex(dbPath, &m_writeDomain);
460
3/3
✓ Branch 7 → 8 taken 3056 times.
✓ Branch 8 → 9 taken 3056 times.
✓ Branch 12 → 13 taken 3056 times.
6112 m_connectionName = QStringLiteral("mailjd_reader_") + QUuid::createUuid().toString();
461 3056 m_addressStatements.reset(); // bound to the previous connection
462
2/2
✓ Branch 22 → 23 taken 3056 times.
✓ Branch 23 → 24 taken 3056 times.
6112 m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
463
1/1
✓ Branch 27 → 28 taken 3056 times.
3056 m_db.setDatabaseName(dbPath);
464
1/1
✓ Branch 34 → 35 taken 3056 times.
12224 m_db.setConnectOptions(QStringLiteral("QSQLITE_OPEN_READONLY;QSQLITE_BUSY_TIMEOUT=%1")
465
2/2
✓ Branch 32 → 33 taken 3056 times.
✓ Branch 33 → 34 taken 3056 times.
6112 .arg(qBound(0, busyTimeoutMs, 5000)));
466
3/3
✓ Branch 38 → 39 taken 3056 times.
✓ Branch 39 → 40 taken 6 times.
✓ Branch 39 → 46 taken 3050 times.
3056 if (!m_db.open()) {
467
2/2
✓ Branch 40 → 41 taken 6 times.
✓ Branch 41 → 42 taken 6 times.
6 m_lastError = m_db.lastError().text();
468 6 return false;
469 }
470
1/1
✓ Branch 46 → 47 taken 3050 times.
3050 registerSqlFunctions(m_db);
471
1/1
✓ Branch 47 → 48 taken 3050 times.
3050 QSqlQuery query(m_db);
472
3/8
✓ Branch 73 → 74 taken 3050 times.
✗ Branch 73 → 75 not taken.
✓ Branch 75 → 76 taken 6 times.
✓ Branch 75 → 85 taken 3044 times.
✗ Branch 139 → 140 not taken.
✗ Branch 139 → 141 not taken.
✗ Branch 143 → 144 not taken.
✗ Branch 143 → 145 not taken.
6100 if (!query.exec(QStringLiteral("PRAGMA query_only = ON")) ||
473
9/17
✓ Branch 51 → 52 taken 3050 times.
✓ Branch 52 → 53 taken 3050 times.
✗ Branch 52 → 63 not taken.
✓ Branch 56 → 57 taken 3050 times.
✓ Branch 57 → 58 taken 3044 times.
✓ Branch 57 → 63 taken 6 times.
✓ Branch 58 → 59 taken 3044 times.
✓ Branch 59 → 60 taken 3044 times.
✗ Branch 59 → 63 not taken.
✓ Branch 69 → 70 taken 3050 times.
✗ Branch 69 → 71 not taken.
✓ Branch 71 → 72 taken 3050 times.
✗ Branch 71 → 73 not taken.
✗ Branch 131 → 132 not taken.
✗ Branch 131 → 133 not taken.
✗ Branch 135 → 136 not taken.
✗ Branch 135 → 137 not taken.
15244 !query.exec(QStringLiteral("PRAGMA user_version")) || !query.next() ||
474
6/10
✓ Branch 60 → 61 taken 3044 times.
✓ Branch 61 → 62 taken 3044 times.
✗ Branch 62 → 63 not taken.
✓ Branch 62 → 64 taken 3044 times.
✓ Branch 65 → 66 taken 3044 times.
✓ Branch 65 → 67 taken 6 times.
✓ Branch 67 → 68 taken 3050 times.
✗ Branch 67 → 69 not taken.
✗ Branch 127 → 128 not taken.
✗ Branch 127 → 129 not taken.
6094 query.value(0).toInt() != SchemaVersion) {
475 6 m_lastError = QStringLiteral("Reader requires the current initialized cache schema");
476
1/1
✓ Branch 82 → 83 taken 6 times.
6 query.finish();
477
1/1
✓ Branch 83 → 84 taken 6 times.
6 m_db.close();
478 6 return false;
479 }
480 3044 m_lastError.clear();
481 3044 return true;
482 3050 }
483
484 302 bool MailCache::openExistingWriter(const QString &dbPath, int busyTimeoutMs) {
485
1/1
✓ Branch 2 → 3 taken 302 times.
302 close();
486 302 m_dbPath = dbPath;
487
1/1
✓ Branch 4 → 5 taken 302 times.
302 m_writeMutex = cacheWriteMutex(dbPath, &m_writeDomain);
488
3/3
✓ Branch 7 → 8 taken 302 times.
✓ Branch 8 → 9 taken 302 times.
✓ Branch 12 → 13 taken 302 times.
604 m_connectionName = QStringLiteral("mailjd_writer_") + QUuid::createUuid().toString();
489 302 m_addressStatements.reset(); // bound to the previous connection
490
2/2
✓ Branch 22 → 23 taken 302 times.
✓ Branch 23 → 24 taken 302 times.
604 m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
491
3/3
✓ Branch 27 → 28 taken 302 times.
✓ Branch 28 → 29 taken 302 times.
✓ Branch 29 → 30 taken 302 times.
302 QUrl uri = QUrl::fromLocalFile(QFileInfo(dbPath).absoluteFilePath());
492
1/1
✓ Branch 35 → 36 taken 302 times.
302 uri.setQuery(QStringLiteral("mode=rw"));
493
3/3
✓ Branch 39 → 40 taken 302 times.
✓ Branch 40 → 41 taken 302 times.
✓ Branch 41 → 42 taken 302 times.
302 m_db.setDatabaseName(QString::fromUtf8(uri.toEncoded()));
494
1/1
✓ Branch 50 → 51 taken 302 times.
1208 m_db.setConnectOptions(QStringLiteral("QSQLITE_OPEN_URI;QSQLITE_BUSY_TIMEOUT=%1")
495
2/2
✓ Branch 48 → 49 taken 302 times.
✓ Branch 49 → 50 taken 302 times.
604 .arg(qBound(0, busyTimeoutMs, 5000)));
496
3/3
✓ Branch 54 → 55 taken 302 times.
✓ Branch 55 → 56 taken 9 times.
✓ Branch 55 → 62 taken 293 times.
302 if (!m_db.open()) {
497
2/2
✓ Branch 56 → 57 taken 9 times.
✓ Branch 57 → 58 taken 9 times.
9 m_lastError = m_db.lastError().text();
498 9 return false;
499 }
500
1/1
✓ Branch 62 → 63 taken 293 times.
293 registerSqlFunctions(m_db);
501
1/1
✓ Branch 63 → 64 taken 293 times.
293 QSqlQuery query(m_db);
502
3/8
✓ Branch 98 → 99 taken 293 times.
✗ Branch 98 → 100 not taken.
✓ Branch 100 → 101 taken 1 time.
✓ Branch 100 → 110 taken 292 times.
✗ Branch 216 → 217 not taken.
✗ Branch 216 → 218 not taken.
✗ Branch 220 → 221 not taken.
✗ Branch 220 → 222 not taken.
586 if (!query.exec(QStringLiteral("PRAGMA foreign_keys = ON")) ||
503
5/11
✓ Branch 72 → 73 taken 293 times.
✓ Branch 73 → 74 taken 292 times.
✓ Branch 73 → 84 taken 1 time.
✓ Branch 94 → 95 taken 293 times.
✗ Branch 94 → 96 not taken.
✓ Branch 96 → 97 taken 293 times.
✗ Branch 96 → 98 not taken.
✗ Branch 208 → 209 not taken.
✗ Branch 208 → 210 not taken.
✗ Branch 212 → 213 not taken.
✗ Branch 212 → 214 not taken.
586 !query.exec(QStringLiteral("PRAGMA synchronous = NORMAL")) ||
504
9/17
✓ Branch 67 → 68 taken 293 times.
✓ Branch 68 → 69 taken 293 times.
✗ Branch 68 → 84 not taken.
✓ Branch 77 → 78 taken 292 times.
✓ Branch 78 → 79 taken 292 times.
✗ Branch 78 → 84 not taken.
✓ Branch 79 → 80 taken 292 times.
✓ Branch 80 → 81 taken 292 times.
✗ Branch 80 → 84 not taken.
✓ Branch 90 → 91 taken 292 times.
✓ Branch 90 → 92 taken 1 time.
✓ Branch 92 → 93 taken 293 times.
✗ Branch 92 → 94 not taken.
✗ Branch 200 → 201 not taken.
✗ Branch 200 → 202 not taken.
✗ Branch 204 → 205 not taken.
✗ Branch 204 → 206 not taken.
1464 !query.exec(QStringLiteral("PRAGMA user_version")) || !query.next() ||
505
7/10
✓ Branch 81 → 82 taken 292 times.
✓ Branch 82 → 83 taken 292 times.
✗ Branch 83 → 84 not taken.
✓ Branch 83 → 85 taken 292 times.
✓ Branch 86 → 87 taken 292 times.
✓ Branch 86 → 88 taken 1 time.
✓ Branch 88 → 89 taken 292 times.
✓ Branch 88 → 90 taken 1 time.
✗ Branch 196 → 197 not taken.
✗ Branch 196 → 198 not taken.
585 query.value(0).toInt() != SchemaVersion) {
506 1 m_lastError = QStringLiteral("Writer requires the current initialized cache schema");
507
1/1
✓ Branch 107 → 108 taken 1 time.
1 query.finish();
508
1/1
✓ Branch 108 → 109 taken 1 time.
1 m_db.close();
509 1 return false;
510 }
511
1/1
✓ Branch 110 → 111 taken 292 times.
292 query.finish();
512
5/13
✓ Branch 114 → 115 taken 292 times.
✓ Branch 115 → 116 taken 292 times.
✗ Branch 115 → 118 not taken.
✓ Branch 120 → 121 taken 292 times.
✗ Branch 120 → 122 not taken.
✓ Branch 122 → 123 taken 292 times.
✗ Branch 122 → 124 not taken.
✗ Branch 124 → 125 not taken.
✓ Branch 124 → 134 taken 292 times.
✗ Branch 224 → 225 not taken.
✗ Branch 224 → 226 not taken.
✗ Branch 228 → 229 not taken.
✗ Branch 228 → 230 not taken.
876 if (!query.exec(QStringLiteral("SELECT value FROM cache_meta WHERE key='payload_cache_bytes'")) ||
513
2/3
✓ Branch 116 → 117 taken 292 times.
✗ Branch 117 → 118 not taken.
✓ Branch 117 → 119 taken 292 times.
292 !query.next()) {
514 ✗ m_lastError = QStringLiteral("Writer requires initialized payload accounting");
515 ✗ query.finish();
516 ✗ m_db.close();
517 ✗ return false;
518 }
519 292 m_lastError.clear();
520 292 return true;
521 302 }
522
523 11341 void MailCache::close() {
524 11341 m_addressStatements.reset(); // prepared on m_db: finish before the connection goes
525 11341 m_folderIdentity.clear();
526 11341 m_cacheFolderIdentity = false;
527
2/2
✓ Branch 5 → 6 taken 4973 times.
✓ Branch 5 → 7 taken 6368 times.
11341 if (m_db.isOpen()) {
528 4973 m_db.close();
529 }
530 // T-402/Bug 20: Release reference before removeDatabase
531
2/2
✓ Branch 7 → 8 taken 11341 times.
✓ Branch 8 → 9 taken 11341 times.
11341 m_db = QSqlDatabase();
532
2/2
✓ Branch 11 → 12 taken 5012 times.
✓ Branch 11 → 14 taken 6329 times.
11341 if (!m_connectionName.isEmpty()) {
533 5012 QSqlDatabase::removeDatabase(m_connectionName);
534 5012 m_connectionName.clear();
535 }
536 11341 }
537
538 4154 bool MailCache::isOpen() const { return m_db.isOpen(); }
539
540 128 QString MailCache::lastError() const { return m_lastError; }
541
542 // --- Folder operations ---
543
544 3057 qint64 MailCache::ensureFolder(const QString &account, const QString &path) {
545
1/1
✓ Branch 2 → 3 taken 3057 times.
3057 const qint64 existingId = findFolderId(account, path);
546
2/2
✓ Branch 3 → 4 taken 313 times.
✓ Branch 3 → 5 taken 2744 times.
3057 if (existingId > 0)
547 313 return existingId;
548
549 // Insert new
550
1/1
✓ Branch 5 → 6 taken 2744 times.
2744 QSqlQuery q(m_db);
551
1/1
✓ Branch 9 → 10 taken 2744 times.
2744 q.prepare(QStringLiteral(
552 "INSERT INTO folders (account, path) VALUES (:account, :path)"));
553
1/1
✓ Branch 17 → 18 taken 2744 times.
5488 q.bindValue(QStringLiteral(":account"), account);
554
1/1
✓ Branch 26 → 27 taken 2744 times.
5488 q.bindValue(QStringLiteral(":path"), path);
555
556
3/3
✓ Branch 30 → 31 taken 2744 times.
✓ Branch 31 → 32 taken 5 times.
✓ Branch 31 → 49 taken 2739 times.
2744 if (!q.exec()) {
557
2/2
✓ Branch 32 → 33 taken 5 times.
✓ Branch 33 → 34 taken 5 times.
5 m_lastError = q.lastError().text();
558
6/6
✓ Branch 37 → 38 taken 5 times.
✓ Branch 41 → 42 taken 5 times.
✓ Branch 42 → 43 taken 5 times.
✓ Branch 43 → 44 taken 5 times.
✓ Branch 47 → 39 taken 5 times.
✓ Branch 47 → 48 taken 5 times.
10 qCWarning(lcCache) << "Failed to insert folder:" << m_lastError;
559 5 return -1;
560 }
561
562
2/2
✓ Branch 49 → 50 taken 2739 times.
✓ Branch 50 → 51 taken 2739 times.
2739 return q.lastInsertId().toLongLong();
563 2744 }
564
565 818 QList<MailCache::FolderIdentity> MailCache::folderIdentities(const QString &account) const {
566
1/1
✓ Branch 2 → 3 taken 818 times.
818 QSqlQuery q(m_db);
567
1/1
✓ Branch 6 → 7 taken 818 times.
818 q.prepare(QStringLiteral("SELECT id, path, uidvalidity FROM folders WHERE account = :account"));
568
1/1
✓ Branch 14 → 15 taken 818 times.
1636 q.bindValue(QStringLiteral(":account"), account);
569 818 QList<FolderIdentity> result;
570
2/3
✓ Branch 18 → 19 taken 818 times.
✓ Branch 19 → 20 taken 818 times.
✗ Branch 19 → 35 not taken.
818 if (q.exec())
571
5/5
✓ Branch 21 → 22 taken 2136 times.
✓ Branch 22 → 23 taken 2136 times.
✓ Branch 33 → 34 taken 2954 times.
✓ Branch 34 → 21 taken 2136 times.
✓ Branch 34 → 35 taken 818 times.
5090 while (q.next()) result.append({q.value(0).toLongLong(),
572
1/1
✓ Branch 26 → 27 taken 2136 times.
4272 q.value(1).toString(), q.value(2).toUInt()});
573 818 return result;
574 5090 }
575
576 4276 qint64 MailCache::findFolderId(const QString &account,
577 const QString &path) const {
578
1/1
✓ Branch 2 → 3 taken 4276 times.
4276 QSqlQuery query(m_db);
579
1/1
✓ Branch 6 → 7 taken 4276 times.
4276 query.prepare(QStringLiteral(
580 "SELECT id FROM folders WHERE account = :account AND path = :path"));
581
1/1
✓ Branch 14 → 15 taken 4276 times.
8552 query.bindValue(QStringLiteral(":account"), account);
582
1/1
✓ Branch 23 → 24 taken 4276 times.
8552 query.bindValue(QStringLiteral(":path"), path);
583
8/8
✓ Branch 27 → 28 taken 4276 times.
✓ Branch 28 → 29 taken 4261 times.
✓ Branch 28 → 32 taken 15 times.
✓ Branch 29 → 30 taken 4261 times.
✓ Branch 30 → 31 taken 1300 times.
✓ Branch 30 → 32 taken 2961 times.
✓ Branch 33 → 34 taken 1300 times.
✓ Branch 33 → 39 taken 2976 times.
4276 if (query.exec() && query.next())
584
2/2
✓ Branch 34 → 35 taken 1300 times.
✓ Branch 35 → 36 taken 1300 times.
1300 return query.value(0).toLongLong();
585 2976 return -1;
586 4276 }
587
588 1978 QString MailCache::folderPath(qint64 folderId) const {
589
2/2
✓ Branch 2 → 3 taken 1780 times.
✓ Branch 2 → 16 taken 198 times.
1978 if (m_cacheFolderIdentity) {
590 1780 const auto it = m_folderIdentity.constFind(folderId);
591
6/6
✓ Branch 6 → 7 taken 948 times.
✓ Branch 6 → 10 taken 832 times.
✓ Branch 8 → 9 taken 623 times.
✓ Branch 8 → 10 taken 325 times.
✓ Branch 11 → 12 taken 623 times.
✓ Branch 11 → 14 taken 1157 times.
1780 if (it != m_folderIdentity.constEnd() && it->pathKnown) return it->path;
592 }
593
1/1
✓ Branch 16 → 17 taken 1355 times.
1355 QSqlQuery q(m_db);
594
1/1
✓ Branch 20 → 21 taken 1355 times.
1355 q.prepare(QStringLiteral("SELECT path FROM folders WHERE id = :id"));
595
1/1
✓ Branch 28 → 29 taken 1355 times.
2710 q.bindValue(QStringLiteral(":id"), folderId);
596
8/8
✓ Branch 32 → 33 taken 1355 times.
✓ Branch 33 → 34 taken 1353 times.
✓ Branch 33 → 37 taken 2 times.
✓ Branch 34 → 35 taken 1353 times.
✓ Branch 35 → 36 taken 523 times.
✓ Branch 35 → 37 taken 830 times.
✓ Branch 38 → 39 taken 523 times.
✓ Branch 38 → 49 taken 832 times.
1355 if (q.exec() && q.next()) {
597
2/2
✓ Branch 39 → 40 taken 523 times.
✓ Branch 40 → 41 taken 523 times.
523 const QString path = q.value(0).toString();
598
2/2
✓ Branch 42 → 43 taken 326 times.
✓ Branch 42 → 46 taken 197 times.
523 if (m_cacheFolderIdentity) {
599
1/1
✓ Branch 43 → 44 taken 326 times.
326 auto &entry = m_folderIdentity[folderId];
600 326 entry.path = path;
601 326 entry.pathKnown = true;
602 }
603 523 return path;
604 523 }
605 832 return {};
606 1355 }
607
608 std::optional<QPair<qint64,qint64>>
609 5 MailCache::findByMessageId(const QString &messageId) const {
610
1/1
✓ Branch 2 → 3 taken 5 times.
5 const QString canonical = MessageId::canonicalFirst(messageId);
611
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 5 times.
5 if (canonical.isEmpty()) return std::nullopt;
612
1/1
✓ Branch 6 → 7 taken 5 times.
5 QSqlQuery q(m_db);
613
1/1
✓ Branch 10 → 11 taken 5 times.
5 q.prepare(QStringLiteral(
614 "SELECT folder_id, uid FROM headers WHERE message_id = :mid "
615 "ORDER BY folder_id, uid LIMIT 1"));
616
1/1
✓ Branch 18 → 19 taken 5 times.
10 q.bindValue(QStringLiteral(":mid"), canonical);
617
8/8
✓ Branch 22 → 23 taken 5 times.
✓ Branch 23 → 24 taken 3 times.
✓ Branch 23 → 27 taken 2 times.
✓ Branch 24 → 25 taken 3 times.
✓ Branch 25 → 26 taken 2 times.
✓ Branch 25 → 27 taken 1 time.
✓ Branch 28 → 29 taken 2 times.
✓ Branch 28 → 38 taken 3 times.
5 if (q.exec() && q.next())
618
2/2
✓ Branch 29 → 30 taken 2 times.
✓ Branch 30 → 31 taken 2 times.
4 return QPair<qint64,qint64>{q.value(0).toLongLong(),
619
2/2
✓ Branch 31 → 32 taken 2 times.
✓ Branch 32 → 33 taken 2 times.
2 q.value(1).toLongLong()};
620 3 return std::nullopt;
621 5 }
622
623 14 std::optional<QPair<qint64, qint64>> MailCache::findByMessageId(
624 const QString &accountId, const QString &messageId) const {
625
1/1
✓ Branch 2 → 3 taken 14 times.
14 const QString canonical = MessageId::canonicalFirst(messageId);
626
3/6
✓ Branch 4 → 5 taken 14 times.
✗ Branch 4 → 7 not taken.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 14 times.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 14 times.
14 if (accountId.isEmpty() || canonical.isEmpty())
627 ✗ return std::nullopt;
628
1/1
✓ Branch 11 → 12 taken 14 times.
14 QSqlQuery q(m_db);
629
1/1
✓ Branch 15 → 16 taken 14 times.
14 q.prepare(QStringLiteral(
630 "SELECT h.folder_id, h.uid FROM headers h "
631 "JOIN folders f ON f.id=h.folder_id "
632 "WHERE f.account=:account AND h.message_id=:mid "
633 "ORDER BY h.folder_id, h.uid LIMIT 1"));
634
1/1
✓ Branch 23 → 24 taken 14 times.
28 q.bindValue(QStringLiteral(":account"), accountId);
635
1/1
✓ Branch 32 → 33 taken 14 times.
28 q.bindValue(QStringLiteral(":mid"), canonical);
636
7/8
✓ Branch 36 → 37 taken 14 times.
✓ Branch 37 → 38 taken 14 times.
✗ Branch 37 → 41 not taken.
✓ Branch 38 → 39 taken 14 times.
✓ Branch 39 → 40 taken 13 times.
✓ Branch 39 → 41 taken 1 time.
✓ Branch 42 → 43 taken 13 times.
✓ Branch 42 → 52 taken 1 time.
14 if (q.exec() && q.next())
637
2/2
✓ Branch 43 → 44 taken 13 times.
✓ Branch 44 → 45 taken 13 times.
26 return QPair<qint64, qint64>{q.value(0).toLongLong(),
638
2/2
✓ Branch 45 → 46 taken 13 times.
✓ Branch 46 → 47 taken 13 times.
13 q.value(1).toLongLong()};
639 1 return std::nullopt;
640 14 }
641
642 1562 bool MailCache::setUidValidity(qint64 folderId, quint32 uidvalidity) {
643
1/1
✓ Branch 2 → 3 taken 1562 times.
1562 const quint32 current = this->uidValidity(folderId);
644
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 1561 times.
1562 if (current == uidvalidity) return true;
645
646 // F-63/F-72: the epoch transition is ONE transaction — quarantine stale
647 // UID actions, purge the epoch-owned rows, store the new epoch and reset
648 // the CONDSTORE watermark (RFC 7162 §3.1.2.1 MUST) together. Any failed
649 // step rolls the whole transition back: the persisted epoch stays at the
650 // old value, so header admission (storeStreamingHeaders checks the
651 // persisted epoch) can never pair a new epoch's message with the old
652 // epoch's body, and highestModseq() can never report the previous
653 // epoch's mod-sequence.
654 1561 const auto writeMutex = m_writeMutex;
655 1561 QMutexLocker publication(writeMutex.get());
656
3/3
✓ Branch 8 → 9 taken 1561 times.
✓ Branch 9 → 10 taken 2 times.
✓ Branch 9 → 27 taken 1559 times.
1561 if (!beginWriteTransaction(m_db)) {
657
2/2
✓ Branch 10 → 11 taken 2 times.
✓ Branch 11 → 12 taken 2 times.
2 m_lastError = m_db.lastError().text();
658
5/5
✓ Branch 15 → 16 taken 2 times.
✓ Branch 19 → 20 taken 2 times.
✓ Branch 20 → 21 taken 2 times.
✓ Branch 25 → 17 taken 2 times.
✓ Branch 25 → 26 taken 2 times.
4 qCWarning(lcCache) << "Failed to start epoch transition transaction:"
659
1/1
✓ Branch 21 → 22 taken 2 times.
2 << m_lastError;
660 2 return false;
661 }
662
663 1559 const bool purge = current != 0;
664
2/2
✓ Branch 27 → 28 taken 48 times.
✓ Branch 27 → 150 taken 1511 times.
1559 if (purge) {
665
7/7
✓ Branch 28 → 29 taken 48 times.
✓ Branch 32 → 33 taken 48 times.
✓ Branch 33 → 34 taken 48 times.
✓ Branch 34 → 35 taken 48 times.
✓ Branch 35 → 36 taken 48 times.
✓ Branch 43 → 30 taken 48 times.
✓ Branch 43 → 44 taken 48 times.
96 qCInfo(lcCache) << "UIDVALIDITY changed for folder" << folderId << "from"
666
4/4
✓ Branch 36 → 37 taken 48 times.
✓ Branch 37 → 38 taken 48 times.
✓ Branch 38 → 39 taken 48 times.
✓ Branch 39 → 40 taken 48 times.
48 << current << "to" << uidvalidity << "- purging cache";
667
1/1
✓ Branch 44 → 45 taken 48 times.
48 QSqlQuery quarantine(m_db);
668
1/1
✓ Branch 48 → 49 taken 48 times.
48 quarantine.prepare(QStringLiteral(
669 "UPDATE mutation_journal SET phase = :phase, "
670 "error = :error, updated_at = :updated "
671 "WHERE folder_id = :folder AND phase NOT IN (:confirmed, :failed, "
672 ":quarantined)"));
673
1/1
✓ Branch 56 → 57 taken 48 times.
96 quarantine.bindValue(QStringLiteral(":phase"),
674 static_cast<int>(MutationPhase::Quarantined));
675
1/1
✓ Branch 68 → 69 taken 48 times.
96 quarantine.bindValue(
676 96 QStringLiteral(":error"),
677 96 QStringLiteral("UIDVALIDITY changed; stale UID was not replayed"));
678
1/1
✓ Branch 80 → 81 taken 48 times.
96 quarantine.bindValue(QStringLiteral(":updated"),
679 QDateTime::currentSecsSinceEpoch());
680
1/1
✓ Branch 89 → 90 taken 48 times.
96 quarantine.bindValue(QStringLiteral(":folder"), folderId);
681
1/1
✓ Branch 98 → 99 taken 48 times.
96 quarantine.bindValue(QStringLiteral(":confirmed"),
682 static_cast<int>(MutationPhase::Confirmed));
683
1/1
✓ Branch 107 → 108 taken 48 times.
96 quarantine.bindValue(QStringLiteral(":failed"),
684 static_cast<int>(MutationPhase::Failed));
685
1/1
✓ Branch 116 → 117 taken 48 times.
96 quarantine.bindValue(QStringLiteral(":quarantined"),
686 static_cast<int>(MutationPhase::Quarantined));
687
2/3
✓ Branch 120 → 121 taken 48 times.
✗ Branch 121 → 122 not taken.
✓ Branch 121 → 140 taken 48 times.
48 if (!quarantine.exec()) {
688 ✗ m_lastError = quarantine.lastError().text();
689 ✗ m_db.rollback();
690 ✗ qCWarning(lcCache) << "Failed to quarantine stale mutations:"
691 ✗ << m_lastError;
692 ✗ return false;
693 }
694
3/3
✓ Branch 140 → 141 taken 48 times.
✓ Branch 141 → 142 taken 1 time.
✓ Branch 141 → 144 taken 47 times.
48 if (!m_payloadLimits.purgeFolderInTransaction(folderId)) {
695
1/1
✓ Branch 142 → 143 taken 1 time.
1 m_db.rollback();
696 1 return false;
697 }
698 48 }
699
700
1/1
✓ Branch 150 → 151 taken 1558 times.
1558 QSqlQuery q(m_db);
701 // highest_modseq resets with the epoch (F-72): the old epoch's watermark
702 // has no meaning for the recreated mailbox.
703
1/1
✓ Branch 154 → 155 taken 1558 times.
1558 q.prepare(QStringLiteral(
704 "UPDATE folders SET uidvalidity = :uv, highest_modseq = 0 "
705 "WHERE id = :id"));
706
1/1
✓ Branch 162 → 163 taken 1558 times.
3116 q.bindValue(QStringLiteral(":uv"), uidvalidity);
707
1/1
✓ Branch 171 → 172 taken 1558 times.
3116 q.bindValue(QStringLiteral(":id"), folderId);
708
3/3
✓ Branch 175 → 176 taken 1558 times.
✓ Branch 176 → 177 taken 1 time.
✓ Branch 176 → 197 taken 1557 times.
1558 if (!q.exec()) {
709
2/2
✓ Branch 177 → 178 taken 1 time.
✓ Branch 178 → 179 taken 1 time.
1 m_lastError = q.lastError().text();
710
1/1
✓ Branch 182 → 183 taken 1 time.
1 m_db.rollback();
711
5/5
✓ Branch 183 → 184 taken 1 time.
✓ Branch 187 → 188 taken 1 time.
✓ Branch 188 → 189 taken 1 time.
✓ Branch 195 → 185 taken 1 time.
✓ Branch 195 → 196 taken 1 time.
2 qCWarning(lcCache) << "Failed to store new UIDVALIDITY for folder"
712
3/3
✓ Branch 189 → 190 taken 1 time.
✓ Branch 190 → 191 taken 1 time.
✓ Branch 191 → 192 taken 1 time.
1 << folderId << ":" << m_lastError;
713 1 return false;
714 }
715
2/3
✓ Branch 197 → 198 taken 1557 times.
✗ Branch 198 → 199 not taken.
✓ Branch 198 → 219 taken 1557 times.
1557 if (!m_db.commit()) {
716 ✗ m_lastError = m_db.lastError().text();
717 ✗ m_db.rollback();
718 ✗ qCWarning(lcCache) << "Failed to commit epoch transition for folder"
719 ✗ << folderId << ":" << m_lastError;
720 ✗ return false;
721 }
722
723
2/2
✓ Branch 219 → 220 taken 1556 times.
✓ Branch 219 → 222 taken 1 time.
1557 if (m_cacheFolderIdentity) {
724
1/1
✓ Branch 220 → 221 taken 1556 times.
1556 auto &entry = m_folderIdentity[folderId];
725 1556 entry.uidValidity = uidvalidity;
726 1556 entry.epochKnown = true;
727 }
728 // Signals only after the committed boundary: listeners drop derived state
729 // (thread rows, body fences) exactly when the new epoch is durable.
730
1/1
✓ Branch 222 → 223 taken 1557 times.
1557 emit folderIdentityInvalidated(folderId);
731
3/3
✓ Branch 223 → 224 taken 47 times.
✓ Branch 223 → 225 taken 1510 times.
✓ Branch 224 → 225 taken 47 times.
1557 if (purge) emit folderHeadersRemoved(folderId);
732
1/1
✓ Branch 225 → 226 taken 1557 times.
1557 emit folderUidValidityChanged(folderId, uidvalidity);
733 1557 return true;
734 1561 }
735
736 169 qint64 MailCache::threadRevision(qint64 folderId) const {
737
1/1
✓ Branch 2 → 3 taken 169 times.
169 QSqlQuery query(m_db);
738
1/1
✓ Branch 6 → 7 taken 169 times.
169 query.prepare(QStringLiteral("SELECT coalesce(r.revision,0) FROM folders f LEFT JOIN thread_revisions r ON r.folder_id=f.id WHERE f.id=?"));
739
1/1
✓ Branch 11 → 12 taken 169 times.
169 query.addBindValue(folderId);
740
7/12
✓ Branch 13 → 14 taken 169 times.
✓ Branch 14 → 15 taken 169 times.
✗ Branch 14 → 20 not taken.
✓ Branch 15 → 16 taken 169 times.
✓ Branch 16 → 17 taken 169 times.
✗ Branch 16 → 20 not taken.
✓ Branch 17 → 18 taken 169 times.
✓ Branch 18 → 19 taken 169 times.
✓ Branch 22 → 23 taken 169 times.
✗ Branch 22 → 24 not taken.
✗ Branch 37 → 38 not taken.
✗ Branch 37 → 39 not taken.
338 return query.exec() && query.next() ? query.value(0).toLongLong() : -1;
741 169 }
742
743 734 MailCache::FolderPresentationState MailCache::folderPresentationState(qint64 folderId) const {
744
1/1
✓ Branch 2 → 3 taken 734 times.
734 QSqlQuery query(m_db);
745
1/1
✓ Branch 6 → 7 taken 734 times.
734 query.prepare(QStringLiteral(
746 "SELECT coalesce(c.total,0),coalesce(r.revision,0),f.uidvalidity "
747 "FROM folders f "
748 "LEFT JOIN header_counts c ON c.folder_id=f.id "
749 "LEFT JOIN thread_revisions r ON r.folder_id=f.id WHERE f.id=?"));
750
1/1
✓ Branch 11 → 12 taken 734 times.
734 query.addBindValue(folderId);
751
5/8
✓ Branch 13 → 14 taken 734 times.
✓ Branch 14 → 15 taken 734 times.
✗ Branch 14 → 18 not taken.
✓ Branch 15 → 16 taken 734 times.
✓ Branch 16 → 17 taken 734 times.
✗ Branch 16 → 18 not taken.
✓ Branch 19 → 20 taken 734 times.
✗ Branch 19 → 31 not taken.
734 if (query.exec() && query.next())
752
6/6
✓ Branch 20 → 21 taken 734 times.
✓ Branch 21 → 22 taken 734 times.
✓ Branch 22 → 23 taken 734 times.
✓ Branch 23 → 24 taken 734 times.
✓ Branch 24 → 25 taken 734 times.
✓ Branch 25 → 26 taken 734 times.
734 return {query.value(0).toInt(), query.value(1).toLongLong(), query.value(2).toUInt()};
753 // A read-only legacy cache can predate the aggregate table. Preserve the
754 // old presentation behavior until its writable connection migrates it.
755 ✗ return {headerCount(folderId), threadRevision(folderId), uidValidity(folderId)};
756 734 }
757
758 17834 quint32 MailCache::uidValidity(qint64 folderId) const {
759
2/2
✓ Branch 2 → 3 taken 17616 times.
✓ Branch 2 → 16 taken 218 times.
17834 if (m_cacheFolderIdentity) {
760 17616 const auto it = m_folderIdentity.constFind(folderId);
761
6/6
✓ Branch 6 → 7 taken 16044 times.
✓ Branch 6 → 10 taken 1572 times.
✓ Branch 8 → 9 taken 16043 times.
✓ Branch 8 → 10 taken 1 time.
✓ Branch 11 → 12 taken 16043 times.
✓ Branch 11 → 14 taken 1573 times.
17616 if (it != m_folderIdentity.constEnd() && it->epochKnown) return it->uidValidity;
762 }
763
1/1
✓ Branch 16 → 17 taken 1791 times.
1791 QSqlQuery q(m_db);
764
1/1
✓ Branch 20 → 21 taken 1791 times.
1791 q.prepare(QStringLiteral("SELECT uidvalidity FROM folders WHERE id = :id"));
765
1/1
✓ Branch 28 → 29 taken 1791 times.
3582 q.bindValue(QStringLiteral(":id"), folderId);
766
767
8/8
✓ Branch 32 → 33 taken 1791 times.
✓ Branch 33 → 34 taken 1787 times.
✓ Branch 33 → 37 taken 4 times.
✓ Branch 34 → 35 taken 1787 times.
✓ Branch 35 → 36 taken 1780 times.
✓ Branch 35 → 37 taken 7 times.
✓ Branch 38 → 39 taken 1780 times.
✓ Branch 38 → 46 taken 11 times.
1791 if (q.exec() && q.next()) {
768
2/2
✓ Branch 39 → 40 taken 1780 times.
✓ Branch 40 → 41 taken 1780 times.
1780 const quint32 epoch = q.value(0).toUInt();
769
2/2
✓ Branch 42 → 43 taken 1564 times.
✓ Branch 42 → 45 taken 216 times.
1780 if (m_cacheFolderIdentity) {
770
1/1
✓ Branch 43 → 44 taken 1564 times.
1564 auto &entry = m_folderIdentity[folderId];
771 1564 entry.uidValidity = epoch;
772 1564 entry.epochKnown = true;
773 }
774 1780 return epoch;
775 }
776 11 return 0;
777 1791 }
778
779 // T-208: CONDSTORE HIGHESTMODSEQ persistence
780 12 bool MailCache::setHighestModseq(qint64 folderId, quint64 modseq) {
781
1/1
✓ Branch 2 → 3 taken 12 times.
12 QSqlQuery q(m_db);
782
1/1
✓ Branch 6 → 7 taken 12 times.
12 q.prepare(QStringLiteral(
783 "UPDATE folders SET highest_modseq = :ms WHERE id = :id"));
784
1/1
✓ Branch 14 → 15 taken 12 times.
24 q.bindValue(QStringLiteral(":ms"), static_cast<qint64>(modseq));
785
1/1
✓ Branch 23 → 24 taken 12 times.
24 q.bindValue(QStringLiteral(":id"), folderId);
786 // S106-106: a lost watermark only costs a larger next sync, but it is
787 // reported like every other failed write.
788
3/3
✓ Branch 27 → 28 taken 12 times.
✓ Branch 28 → 29 taken 4 times.
✓ Branch 28 → 48 taken 8 times.
12 if (!q.exec()) {
789
2/2
✓ Branch 29 → 30 taken 4 times.
✓ Branch 30 → 31 taken 4 times.
4 m_lastError = q.lastError().text();
790
8/8
✓ Branch 34 → 35 taken 4 times.
✓ Branch 38 → 39 taken 4 times.
✓ Branch 39 → 40 taken 4 times.
✓ Branch 40 → 41 taken 4 times.
✓ Branch 41 → 42 taken 4 times.
✓ Branch 42 → 43 taken 4 times.
✓ Branch 46 → 36 taken 4 times.
✓ Branch 46 → 47 taken 4 times.
8 qCWarning(lcCache) << "Failed to store HIGHESTMODSEQ for folder" << folderId << ":" << m_lastError;
791 4 return false;
792 }
793 8 return true;
794 12 }
795
796 17 quint64 MailCache::highestModseq(qint64 folderId) const {
797
1/1
✓ Branch 2 → 3 taken 17 times.
17 QSqlQuery q(m_db);
798
1/1
✓ Branch 6 → 7 taken 17 times.
17 q.prepare(
799 34 QStringLiteral("SELECT highest_modseq FROM folders WHERE id = :id"));
800
1/1
✓ Branch 14 → 15 taken 17 times.
34 q.bindValue(QStringLiteral(":id"), folderId);
801
802
7/8
✓ Branch 18 → 19 taken 17 times.
✓ Branch 19 → 20 taken 15 times.
✓ Branch 19 → 23 taken 2 times.
✓ Branch 20 → 21 taken 15 times.
✓ Branch 21 → 22 taken 15 times.
✗ Branch 21 → 23 not taken.
✓ Branch 24 → 25 taken 15 times.
✓ Branch 24 → 30 taken 2 times.
17 if (q.exec() && q.next()) {
803
2/2
✓ Branch 25 → 26 taken 15 times.
✓ Branch 26 → 27 taken 15 times.
15 return q.value(0).toULongLong();
804 }
805 2 return 0;
806 17 }
807
808 // T-209: Last sync timestamp
809 17 bool MailCache::setLastSync(qint64 folderId) {
810
1/1
✓ Branch 2 → 3 taken 17 times.
17 QSqlQuery q(m_db);
811
1/1
✓ Branch 6 → 7 taken 17 times.
17 q.prepare(QStringLiteral(
812 "UPDATE folders SET last_sync = :ts WHERE id = :id"));
813
1/1
✓ Branch 15 → 16 taken 17 times.
34 q.bindValue(QStringLiteral(":ts"), QDateTime::currentSecsSinceEpoch());
814
1/1
✓ Branch 24 → 25 taken 17 times.
34 q.bindValue(QStringLiteral(":id"), folderId);
815
3/3
✓ Branch 28 → 29 taken 17 times.
✓ Branch 29 → 30 taken 4 times.
✓ Branch 29 → 49 taken 13 times.
17 if (!q.exec()) {
816
2/2
✓ Branch 30 → 31 taken 4 times.
✓ Branch 31 → 32 taken 4 times.
4 m_lastError = q.lastError().text();
817
8/8
✓ Branch 35 → 36 taken 4 times.
✓ Branch 39 → 40 taken 4 times.
✓ Branch 40 → 41 taken 4 times.
✓ Branch 41 → 42 taken 4 times.
✓ Branch 42 → 43 taken 4 times.
✓ Branch 43 → 44 taken 4 times.
✓ Branch 47 → 37 taken 4 times.
✓ Branch 47 → 48 taken 4 times.
8 qCWarning(lcCache) << "Failed to store the last sync of folder" << folderId << ":" << m_lastError;
818 4 return false;
819 }
820 13 return true;
821 17 }
822
823 25 qint64 MailCache::lastSync(qint64 folderId) const {
824
1/1
✓ Branch 2 → 3 taken 25 times.
25 QSqlQuery q(m_db);
825
1/1
✓ Branch 6 → 7 taken 25 times.
25 q.prepare(
826 50 QStringLiteral("SELECT last_sync FROM folders WHERE id = :id"));
827
1/1
✓ Branch 14 → 15 taken 25 times.
50 q.bindValue(QStringLiteral(":id"), folderId);
828
829
7/8
✓ Branch 18 → 19 taken 25 times.
✓ Branch 19 → 20 taken 23 times.
✓ Branch 19 → 23 taken 2 times.
✓ Branch 20 → 21 taken 23 times.
✓ Branch 21 → 22 taken 23 times.
✗ Branch 21 → 23 not taken.
✓ Branch 24 → 25 taken 23 times.
✓ Branch 24 → 30 taken 2 times.
25 if (q.exec() && q.next()) {
830
2/2
✓ Branch 25 → 26 taken 23 times.
✓ Branch 26 → 27 taken 23 times.
23 return q.value(0).toLongLong();
831 }
832 2 return 0;
833 25 }
834
835 // --- Header operations ---
836
837 15436 bool MailCache::storeHeaders(qint64 folderId, const QList<MailHeader> &headers) {
838 15436 const auto writeMutex = m_writeMutex;
839 15436 QMutexLocker publication(writeMutex.get());
840 15436 auto batch = headers;
841
3/3
✓ Branch 7 → 8 taken 15436 times.
✓ Branch 9 → 10 taken 17 times.
✓ Branch 9 → 11 taken 15419 times.
15436 if (!storeHeaderBatch(folderId, batch, {})) return false;
842
5/5
✓ Branch 11 → 12 taken 15419 times.
✓ Branch 12 → 13 taken 15419 times.
✓ Branch 15 → 16 taken 413752 times.
✓ Branch 18 → 14 taken 413752 times.
✓ Branch 18 → 19 taken 15419 times.
429171 for (const auto &header : batch) emit headerStateReplaced(folderId, header);
843 15419 return true;
844 15436 }
845
846 // storeHeaderBatch step (P90.3): snapshot the canonical state of every row
847 // of the batch that already exists, with set queries instead of one SELECT
848 // per header. Rolls back and returns false on a failed read.
849 15461 bool MailCache::loadExistingHeaders(qint64 folderId, const QList<MailHeader> &headers,
850 QHash<qint64, ExistingHeader> &existing) {
851
2/2
✓ Branch 2 → 3 taken 15461 times.
✓ Branch 3 → 4 taken 15461 times.
15461 QSqlQuery rowQ(m_db), labelSetQ(m_db);
852
2/2
✓ Branch 205 → 5 taken 16271 times.
✓ Branch 205 → 206 taken 15460 times.
31731 for (int at = 0; at < headers.size(); at += 400) {
853 16271 const int count = qMin(400, headers.size() - at);
854 16271 QStringList marks;
855
1/1
✓ Branch 7 → 8 taken 16271 times.
16271 marks.reserve(count);
856
3/3
✓ Branch 12 → 13 taken 413812 times.
✓ Branch 16 → 9 taken 413812 times.
✓ Branch 16 → 17 taken 16271 times.
430083 for (int i = 0; i < count; ++i) marks << QStringLiteral("?");
857
1/1
✓ Branch 25 → 26 taken 16271 times.
48813 rowQ.prepare(QStringLiteral(
858 "SELECT uid,id,subject,from_addr,to_addr,date,flags,size,"
859 "has_attachments,message_id,in_reply_to,ref_ids,is_spam,cc_addr,list_id,"
860 "spam_score,spam_threshold,spam_tool "
861 "FROM headers WHERE folder_id=? AND uid IN(%1)")
862
2/2
✓ Branch 23 → 24 taken 16271 times.
✓ Branch 24 → 25 taken 16271 times.
48813 .arg(marks.join(QLatin1Char(','))));
863
1/1
✓ Branch 32 → 33 taken 16271 times.
16271 rowQ.addBindValue(folderId);
864
3/3
✓ Branch 38 → 39 taken 413812 times.
✓ Branch 41 → 35 taken 413812 times.
✓ Branch 41 → 42 taken 16271 times.
430083 for (int i = 0; i < count; ++i) rowQ.addBindValue(headers.at(at + i).uid);
865
3/3
✓ Branch 42 → 43 taken 16271 times.
✓ Branch 43 → 44 taken 1 time.
✓ Branch 43 → 51 taken 16270 times.
16271 if (!rowQ.exec()) {
866
2/2
✓ Branch 44 → 45 taken 1 time.
✓ Branch 45 → 46 taken 1 time.
1 m_lastError = rowQ.lastError().text();
867
1/1
✓ Branch 49 → 50 taken 1 time.
1 m_db.rollback();
868 1 return false;
869 }
870
3/3
✓ Branch 147 → 148 taken 19002 times.
✓ Branch 148 → 52 taken 2732 times.
✓ Branch 148 → 149 taken 16270 times.
19002 while (rowQ.next()) {
871
3/3
✓ Branch 52 → 53 taken 2732 times.
✓ Branch 53 → 54 taken 2732 times.
✓ Branch 54 → 55 taken 2732 times.
2732 ExistingHeader &row = existing[rowQ.value(0).toLongLong()];
872
2/2
✓ Branch 56 → 57 taken 2732 times.
✓ Branch 57 → 58 taken 2732 times.
2732 row.id = rowQ.value(1).toLongLong();
873
2/2
✓ Branch 59 → 60 taken 2732 times.
✓ Branch 60 → 61 taken 2732 times.
2732 row.subject = rowQ.value(2).toString();
874
2/2
✓ Branch 64 → 65 taken 2732 times.
✓ Branch 65 → 66 taken 2732 times.
2732 row.from = rowQ.value(3).toString();
875
2/2
✓ Branch 69 → 70 taken 2732 times.
✓ Branch 70 → 71 taken 2732 times.
2732 row.to = rowQ.value(4).toString();
876
1/1
✓ Branch 74 → 75 taken 2732 times.
2732 row.date = rowQ.value(5);
877
2/2
✓ Branch 77 → 78 taken 2732 times.
✓ Branch 78 → 79 taken 2732 times.
2732 row.flags = rowQ.value(6).toUInt();
878
2/2
✓ Branch 80 → 81 taken 2732 times.
✓ Branch 81 → 82 taken 2732 times.
2732 row.size = rowQ.value(7).toLongLong();
879
2/2
✓ Branch 83 → 84 taken 2732 times.
✓ Branch 84 → 85 taken 2732 times.
2732 row.hasAttachments = rowQ.value(8).toInt();
880
2/2
✓ Branch 86 → 87 taken 2732 times.
✓ Branch 87 → 88 taken 2732 times.
2732 row.messageId = rowQ.value(9).toString();
881
2/2
✓ Branch 91 → 92 taken 2732 times.
✓ Branch 92 → 93 taken 2732 times.
2732 row.inReplyTo = rowQ.value(10).toString();
882
2/2
✓ Branch 96 → 97 taken 2732 times.
✓ Branch 97 → 98 taken 2732 times.
2732 row.refIds = rowQ.value(11).toString();
883
2/2
✓ Branch 101 → 102 taken 2732 times.
✓ Branch 102 → 103 taken 2732 times.
2732 row.isSpam = rowQ.value(12).toInt();
884
2/2
✓ Branch 104 → 105 taken 2732 times.
✓ Branch 105 → 106 taken 2732 times.
2732 row.recipientsKnown = !rowQ.value(13).isNull();
885
6/6
✓ Branch 107 → 108 taken 2732 times.
✓ Branch 108 → 109 taken 2732 times.
✓ Branch 110 → 111 taken 2 times.
✓ Branch 110 → 116 taken 2730 times.
✓ Branch 111 → 112 taken 2 times.
✓ Branch 112 → 113 taken 2 times.
2732 if (!rowQ.value(15).isNull()) row.spamScore = rowQ.value(15).toDouble();
886
6/6
✓ Branch 116 → 117 taken 2732 times.
✓ Branch 117 → 118 taken 2732 times.
✓ Branch 119 → 120 taken 2 times.
✓ Branch 119 → 125 taken 2730 times.
✓ Branch 120 → 121 taken 2 times.
✓ Branch 121 → 122 taken 2 times.
2732 if (!rowQ.value(16).isNull()) row.spamThreshold = rowQ.value(16).toDouble();
887
8/10
✓ Branch 125 → 126 taken 2732 times.
✓ Branch 126 → 127 taken 2732 times.
✓ Branch 127 → 128 taken 2730 times.
✓ Branch 127 → 129 taken 2 times.
✓ Branch 129 → 130 taken 2 times.
✓ Branch 130 → 131 taken 2 times.
✓ Branch 133 → 134 taken 2 times.
✓ Branch 133 → 135 taken 2730 times.
✗ Branch 307 → 308 not taken.
✗ Branch 307 → 309 not taken.
2732 row.spamTool = rowQ.value(17).isNull() ? QString() : rowQ.value(17).toString();
888
2/2
✓ Branch 136 → 137 taken 2732 times.
✓ Branch 137 → 138 taken 2732 times.
2732 row.cc = rowQ.value(13).toString();
889
2/2
✓ Branch 141 → 142 taken 2732 times.
✓ Branch 142 → 143 taken 2732 times.
2732 row.listId = rowQ.value(14).toString();
890 }
891
1/1
✓ Branch 149 → 150 taken 16270 times.
16270 rowQ.finish();
892
1/1
✓ Branch 158 → 159 taken 16270 times.
48810 labelSetQ.prepare(QStringLiteral(
893 "SELECT h.uid,ml.label FROM mail_labels ml "
894 "JOIN headers h ON h.id=ml.header_id WHERE h.folder_id=? AND h.uid IN(%1)")
895
2/2
✓ Branch 156 → 157 taken 16270 times.
✓ Branch 157 → 158 taken 16270 times.
48810 .arg(marks.join(QLatin1Char(','))));
896
1/1
✓ Branch 165 → 166 taken 16270 times.
16270 labelSetQ.addBindValue(folderId);
897
3/3
✓ Branch 171 → 172 taken 413811 times.
✓ Branch 174 → 168 taken 413811 times.
✓ Branch 174 → 175 taken 16270 times.
430081 for (int i = 0; i < count; ++i) labelSetQ.addBindValue(headers.at(at + i).uid);
898
2/3
✓ Branch 175 → 176 taken 16270 times.
✗ Branch 176 → 177 not taken.
✓ Branch 176 → 184 taken 16270 times.
16270 if (!labelSetQ.exec()) {
899 ✗ m_lastError = labelSetQ.lastError().text();
900 ✗ m_db.rollback();
901 ✗ return false;
902 }
903
3/3
✓ Branch 195 → 196 taken 16299 times.
✓ Branch 196 → 185 taken 29 times.
✓ Branch 196 → 197 taken 16270 times.
16299 while (labelSetQ.next())
904
6/6
✓ Branch 185 → 186 taken 29 times.
✓ Branch 186 → 187 taken 29 times.
✓ Branch 187 → 188 taken 29 times.
✓ Branch 188 → 189 taken 29 times.
✓ Branch 189 → 190 taken 29 times.
✓ Branch 190 → 191 taken 29 times.
29 existing[labelSetQ.value(0).toLongLong()].labels.insert(labelSetQ.value(1).toString());
905
1/1
✓ Branch 197 → 198 taken 16270 times.
16270 labelSetQ.finish();
906 16271 }
907 15460 return true;
908 15461 }
909
910 // storeHeaderBatch step (T-79.F3/M10, P90.3): reconcile a written header's
911 // keyword set with the prepared label statements; priorLabels is null for a
912 // new row. Rolls back and returns false on a failed statement.
913 411957 bool MailCache::reconcileHeaderLabels(qint64 hid, const QSet<QString> *priorLabelsOrNull,
914 const QSet<QString> &labelSet,
915 const QStringList &labels, QSqlQuery &labelQ,
916 QSqlQuery &labelDeleteQ) {
917
2/2
✓ Branch 2 → 3 taken 885 times.
✓ Branch 2 → 76 taken 411072 times.
411957 if (priorLabelsOrNull) {
918 885 const auto &priorLabels = *priorLabelsOrNull;
919
2/2
✓ Branch 38 → 5 taken 27 times.
✓ Branch 38 → 39 taken 884 times.
911 for (const auto &label : priorLabels) {
920
2/2
✓ Branch 7 → 8 taken 14 times.
✓ Branch 7 → 9 taken 13 times.
27 if (labelSet.contains(label)) continue;
921
1/1
✓ Branch 14 → 15 taken 13 times.
26 labelDeleteQ.bindValue(QStringLiteral(":hid"), hid);
922
1/1
✓ Branch 23 → 24 taken 13 times.
26 labelDeleteQ.bindValue(QStringLiteral(":label"), label);
923
3/3
✓ Branch 27 → 28 taken 13 times.
✓ Branch 28 → 29 taken 1 time.
✓ Branch 28 → 36 taken 12 times.
13 if (!labelDeleteQ.exec()) {
924
2/2
✓ Branch 29 → 30 taken 1 time.
✓ Branch 30 → 31 taken 1 time.
1 m_lastError = labelDeleteQ.lastError().text();
925
1/1
✓ Branch 34 → 35 taken 1 time.
1 m_db.rollback();
926 1 return false;
927 }
928 }
929
2/2
✓ Branch 74 → 41 taken 287 times.
✓ Branch 74 → 75 taken 882 times.
1169 for (const auto &label : labels) {
930
2/2
✓ Branch 43 → 44 taken 14 times.
✓ Branch 43 → 45 taken 273 times.
287 if (priorLabels.contains(label)) continue;
931
1/1
✓ Branch 50 → 51 taken 273 times.
546 labelQ.bindValue(QStringLiteral(":hid"), hid);
932
1/1
✓ Branch 59 → 60 taken 273 times.
546 labelQ.bindValue(QStringLiteral(":label"), label);
933
3/3
✓ Branch 63 → 64 taken 273 times.
✓ Branch 64 → 65 taken 2 times.
✓ Branch 64 → 72 taken 271 times.
273 if (!labelQ.exec()) {
934
2/2
✓ Branch 65 → 66 taken 2 times.
✓ Branch 66 → 67 taken 2 times.
2 m_lastError = labelQ.lastError().text();
935
1/1
✓ Branch 70 → 71 taken 2 times.
2 m_db.rollback();
936 2 return false;
937 }
938 }
939 } else {
940
2/2
✓ Branch 108 → 78 taken 20894 times.
✓ Branch 108 → 109 taken 411072 times.
431966 for (const auto &label : labels) {
941
1/1
✓ Branch 84 → 85 taken 20894 times.
41788 labelQ.bindValue(QStringLiteral(":hid"), hid);
942
1/1
✓ Branch 93 → 94 taken 20894 times.
41788 labelQ.bindValue(QStringLiteral(":label"), label);
943
2/3
✓ Branch 97 → 98 taken 20894 times.
✗ Branch 98 → 99 not taken.
✓ Branch 98 → 106 taken 20894 times.
20894 if (!labelQ.exec()) {
944 ✗ m_lastError = labelQ.lastError().text();
945 ✗ m_db.rollback();
946 ✗ return false;
947 }
948 }
949 }
950 411954 return true;
951 }
952
953 // storeHeaderBatch step: refresh the FTS projection of rows whose searchable
954 // fields changed, then commit the batch (rolls back on failure).
955 15451 bool MailCache::commitHeaderBatch(qint64 folderId, const QList<QPair<qint64, int>> &unreadDeltas,
956 const QList<qint64> &changedRowIds) {
957 // P90.3: keep derived unread aggregates exact for seen flips that arrive
958 // through the sync path (the flags-only trigger bumped the revision).
959 // S106-107: a failed walk rolls the batch back instead of committing
960 // stale unread_descendants with a success result.
961
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 17 taken 15450 times.
15451 if (!adjustThreadUnreadAggregates(folderId, unreadDeltas)) {
962
6/6
✓ Branch 4 → 5 taken 1 time.
✓ Branch 8 → 9 taken 1 time.
✓ Branch 9 → 10 taken 1 time.
✓ Branch 10 → 11 taken 1 time.
✓ Branch 14 → 6 taken 1 time.
✓ Branch 14 → 15 taken 1 time.
2 qCWarning(lcCache) << "Failed to adjust thread unread counts:" << m_lastError;
963 1 m_db.rollback();
964 1 return false;
965 }
966
2/2
✓ Branch 18 → 19 taken 15407 times.
✓ Branch 18 → 38 taken 43 times.
15450 if (!changedRowIds.isEmpty()) {
967 15407 m_searchIndex.diagnosticCounters().requests += changedRowIds.size();
968
1/1
✓ Branch 21 → 22 taken 15407 times.
15407 const auto documents = m_searchIndex.searchDocumentsByIds(changedRowIds);
969
7/7
✓ Branch 23 → 24 taken 15406 times.
✓ Branch 23 → 27 taken 1 time.
✓ Branch 25 → 26 taken 15406 times.
✓ Branch 26 → 27 taken 2 times.
✓ Branch 26 → 28 taken 15404 times.
✓ Branch 29 → 30 taken 3 times.
✓ Branch 29 → 32 taken 15404 times.
15407 if (!documents || !m_searchIndex.replaceSearchDocuments(*documents)) {
970
1/1
✓ Branch 30 → 31 taken 3 times.
3 m_db.rollback();
971 3 return false;
972 }
973 15407 }
974
2/2
✓ Branch 39 → 40 taken 1 time.
✓ Branch 39 → 47 taken 15446 times.
15447 if (!m_db.commit()) {
975
2/2
✓ Branch 40 → 41 taken 1 time.
✓ Branch 41 → 42 taken 1 time.
1 m_lastError = m_db.lastError().text();
976 1 m_db.rollback();
977 1 return false;
978 }
979 15446 return true;
980 }
981
982 15472 bool MailCache::storeHeaderBatch(qint64 folderId, QList<MailHeader> &headers,
983 const std::function<bool()> &prepare) {
984 15472 m_lastError.clear();
985
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 15472 times.
15472 if (headers.isEmpty())
986 ✗ return true;
987
988
3/3
✓ Branch 6 → 7 taken 15472 times.
✓ Branch 7 → 8 taken 5 times.
✓ Branch 7 → 9 taken 15467 times.
15472 if (!beginWriteTransaction(m_db, &m_lastError)) return false;
989
1/1
✓ Branch 9 → 10 taken 15467 times.
15467 m_pendingAddressRows.clear(); // a failed earlier batch left nothing behind
990
991
7/7
✓ Branch 11 → 12 taken 35 times.
✓ Branch 11 → 15 taken 15432 times.
✓ Branch 12 → 13 taken 35 times.
✓ Branch 13 → 14 taken 6 times.
✓ Branch 13 → 15 taken 29 times.
✓ Branch 16 → 17 taken 6 times.
✓ Branch 16 → 19 taken 15461 times.
15467 if (prepare && !prepare()) {
992
1/1
✓ Branch 17 → 18 taken 6 times.
6 m_db.rollback();
993 6 return false;
994 }
995
996
1/1
✓ Branch 19 → 20 taken 15461 times.
15461 QSqlQuery q(m_db);
997
1/1
✓ Branch 23 → 24 taken 15461 times.
15461 q.prepare(QStringLiteral(
998 "INSERT INTO headers "
999 "(folder_id, uid, subject, from_addr, to_addr, date, flags, size, "
1000 "has_attachments, message_id, in_reply_to, ref_ids, is_spam, cc_addr, list_id, "
1001 "spam_score, spam_threshold, spam_tool) "
1002 "VALUES (:fid, :uid, :subj, :from, :to, :date, :flags, :size, :att, "
1003 ":msgid, :irt, :refs, :spam, :cc, :list, :sscore, :sthreshold, :stool) "
1004 "ON CONFLICT(folder_id, uid) DO UPDATE SET "
1005 "subject = excluded.subject, "
1006 "from_addr = excluded.from_addr, "
1007 "to_addr = excluded.to_addr, "
1008 "date = excluded.date, "
1009 "flags = excluded.flags, "
1010 "size = excluded.size, "
1011 "has_attachments = excluded.has_attachments, "
1012 "message_id = excluded.message_id, "
1013 "in_reply_to = excluded.in_reply_to, "
1014 "ref_ids = excluded.ref_ids, "
1015 "is_spam = excluded.is_spam, "
1016 // NULL = not known by this writer: keep what an envelope sync stored.
1017 "cc_addr = coalesce(excluded.cc_addr, headers.cc_addr), "
1018 "list_id = coalesce(excluded.list_id, headers.list_id), "
1019 // Sprint 105: spam_tool NULL = this writer did not read the filter
1020 // fields (keep the stored verdict); '' = read, no filter wrote any.
1021 "spam_score = CASE WHEN excluded.spam_tool IS NULL THEN headers.spam_score ELSE excluded.spam_score END, "
1022 "spam_threshold = CASE WHEN excluded.spam_tool IS NULL THEN headers.spam_threshold "
1023 "ELSE excluded.spam_threshold END, "
1024 "spam_tool = coalesce(excluded.spam_tool, headers.spam_tool) "
1025 "RETURNING id"));
1026
1027
1/1
✓ Branch 26 → 27 taken 15461 times.
15461 QSqlQuery labelQ(m_db);
1028
1/1
✓ Branch 30 → 31 taken 15461 times.
15461 labelQ.prepare(QStringLiteral(
1029 "INSERT OR IGNORE INTO mail_labels (header_id, label) "
1030 "VALUES (:hid, :label)"));
1031
1032
1/1
✓ Branch 33 → 34 taken 15461 times.
15461 QSqlQuery labelDeleteQ(m_db);
1033
1/1
✓ Branch 37 → 38 taken 15461 times.
15461 labelDeleteQ.prepare(
1034 30922 QStringLiteral("DELETE FROM mail_labels WHERE header_id = :hid AND label = :label"));
1035
1036 // P90.3: snapshot the canonical state of every existing row in the batch
1037 // with set queries instead of one SELECT per header. A byte-identical
1038 // header with an identical label set is then a true no-op: no row rewrite,
1039 // no label delete/insert, no revision bump, no FTS round trip.
1040 15461 QHash<qint64, ExistingHeader> existing;
1041
1/1
✓ Branch 41 → 42 taken 15461 times.
15461 existing.reserve(headers.size());
1042
3/3
✓ Branch 42 → 43 taken 15461 times.
✓ Branch 43 → 44 taken 1 time.
✓ Branch 43 → 45 taken 15460 times.
15461 if (!loadExistingHeaders(folderId, headers, existing)) return false;
1043 413808 const auto canonicalDate = [](const MailHeader &header) {
1044
2/2
✓ Branch 3 → 4 taken 406384 times.
✓ Branch 3 → 6 taken 7424 times.
413808 return header.date.isValid() ? QVariant(header.date.toSecsSinceEpoch()) : QVariant();
1045 };
1046
1047 15460 QList<qint64> changedRowIds;
1048
1/1
✓ Branch 46 → 47 taken 15460 times.
15460 changedRowIds.reserve(headers.size());
1049 15460 QList<QPair<qint64, int>> unreadDeltas;
1050
1051 // A FETCH stream must not carry a UID twice; if it still does, keep the
1052 // documented last-wins semantics of the previous sequential UPSERTs and
1053 // write each uid exactly once (the snapshot above cannot observe
1054 // intra-batch writes).
1055
1/1
✓ Branch 47 → 48 taken 15460 times.
15460 const QList<int> writes = lastWinsWriteOrder(headers);
1056
1057
2/2
✓ Branch 204 → 50 taken 413808 times.
✓ Branch 204 → 205 taken 15451 times.
429259 for (const int at : writes) {
1058 413808 const auto &h = headers.at(at);
1059 413808 QStringList replyIds = h.inReplyToIds;
1060
6/6
✓ Branch 54 → 55 taken 407897 times.
✓ Branch 54 → 58 taken 5911 times.
✓ Branch 56 → 57 taken 33 times.
✓ Branch 56 → 58 taken 407864 times.
✓ Branch 59 → 60 taken 33 times.
✓ Branch 59 → 64 taken 413775 times.
413808 if (replyIds.isEmpty() && !h.inReplyTo.isEmpty())
1061
1/1
✓ Branch 60 → 61 taken 33 times.
33 replyIds = MessageId::parseTokens(h.inReplyTo);
1062
1/1
✓ Branch 64 → 65 taken 413808 times.
413808 const QString encodedReplyIds = encodeThreadIds(replyIds);
1063
1/1
✓ Branch 65 → 66 taken 413808 times.
413808 const QString encodedReferences = encodeThreadIds(h.references);
1064
1/1
✓ Branch 66 → 67 taken 413808 times.
413808 const QString canonicalMessageId = MessageId::canonicalFirst(h.messageId);
1065
1/1
✓ Branch 67 → 68 taken 413808 times.
413808 const QVariant dateValue = canonicalDate(h);
1066
1/1
✓ Branch 70 → 71 taken 413808 times.
413808 const QSet<QString> labelSet(h.labels.cbegin(), h.labels.cend());
1067
1068 413808 const auto known = existing.constFind(h.uid);
1069
2/2
✓ Branch 74 → 75 taken 2732 times.
✓ Branch 74 → 92 taken 411076 times.
413808 if (known != existing.constEnd()) {
1070 2732 const auto &prior = known.value();
1071
1/1
✓ Branch 76 → 77 taken 2732 times.
2732 const bool rowMatches = storedRowMatches(prior, h, dateValue, canonicalMessageId, encodedReplyIds,
1072 encodedReferences);
1073
6/6
✓ Branch 77 → 78 taken 1851 times.
✓ Branch 77 → 81 taken 881 times.
✓ Branch 79 → 80 taken 1845 times.
✓ Branch 79 → 81 taken 6 times.
✓ Branch 82 → 83 taken 1845 times.
✓ Branch 82 → 85 taken 887 times.
2732 if (rowMatches && prior.labels == labelSet) {
1074 // Semantically unchanged sync: keep it a real no-op (no revision,
1075 // no trigger, no FTS pass, no label churn).
1076 1845 ++m_searchIndex.diagnosticCounters().unchangedSkipped;
1077 1845 continue;
1078 }
1079
2/2
✓ Branch 85 → 86 taken 38 times.
✓ Branch 85 → 92 taken 849 times.
887 if ((prior.flags ^ h.flags) & MailFlag::Seen)
1080
3/3
✓ Branch 86 → 87 taken 23 times.
✓ Branch 86 → 88 taken 15 times.
✓ Branch 90 → 91 taken 38 times.
38 unreadDeltas.append({h.uid, (h.flags & MailFlag::Seen) ? -1 : 1});
1081 }
1082
1083
1/1
✓ Branch 92 → 93 taken 411963 times.
411963 bindHeaderUpsert(q, folderId, h, dateValue, canonicalMessageId, encodedReplyIds,
1084 encodedReferences);
1085
1086
3/3
✓ Branch 93 → 94 taken 411963 times.
✓ Branch 94 → 95 taken 6 times.
✓ Branch 94 → 119 taken 411957 times.
411963 if (!q.exec()) {
1087
7/7
✓ Branch 95 → 96 taken 6 times.
✓ Branch 99 → 100 taken 6 times.
✓ Branch 100 → 101 taken 6 times.
✓ Branch 101 → 102 taken 6 times.
✓ Branch 102 → 103 taken 6 times.
✓ Branch 111 → 97 taken 6 times.
✓ Branch 111 → 112 taken 6 times.
12 qCWarning(lcCache) << "Failed to insert header UID" << h.uid << ":"
1088
3/3
✓ Branch 103 → 104 taken 6 times.
✓ Branch 104 → 105 taken 6 times.
✓ Branch 105 → 106 taken 6 times.
6 << q.lastError().text();
1089
2/2
✓ Branch 112 → 113 taken 6 times.
✓ Branch 113 → 114 taken 6 times.
6 m_lastError = q.lastError().text();
1090
1/1
✓ Branch 117 → 118 taken 6 times.
6 m_db.rollback();
1091 6 return false;
1092 }
1093
1094 411957 qint64 hid = -1;
1095
2/3
✓ Branch 119 → 120 taken 411957 times.
✓ Branch 120 → 121 taken 411957 times.
✗ Branch 120 → 125 not taken.
411957 if (q.next())
1096
2/2
✓ Branch 121 → 122 taken 411957 times.
✓ Branch 122 → 123 taken 411957 times.
411957 hid = q.value(0).toLongLong();
1097
1/1
✓ Branch 125 → 126 taken 411957 times.
411957 q.finish();
1098
1/2
✗ Branch 126 → 127 not taken.
✓ Branch 126 → 135 taken 411957 times.
411957 if (hid <= 0) {
1099 ✗ m_lastError = QStringLiteral("Header UPSERT did not return an identity");
1100 ✗ m_db.rollback();
1101 ✗ return false;
1102 }
1103 411957 const bool searchableContentChanged = known == existing.constEnd() ||
1104
6/6
✓ Branch 137 → 138 taken 885 times.
✓ Branch 137 → 147 taken 411072 times.
✓ Branch 140 → 141 taken 749 times.
✓ Branch 140 → 147 taken 136 times.
✓ Branch 143 → 144 taken 748 times.
✓ Branch 143 → 147 taken 1 time.
412705 known.value().subject != h.subject || known.value().from != h.from ||
1105
2/2
✓ Branch 146 → 147 taken 4 times.
✓ Branch 146 → 148 taken 744 times.
748 known.value().to != h.to;
1106
2/2
✓ Branch 149 → 150 taken 411213 times.
✓ Branch 149 → 151 taken 744 times.
411957 if (searchableContentChanged)
1107
1/1
✓ Branch 150 → 153 taken 411213 times.
411213 changedRowIds.append(hid);
1108 else
1109 744 ++m_searchIndex.diagnosticCounters().unchangedSkipped;
1110
1111 // T-79.F3/M10: headers from FETCH carry the authoritative keyword
1112 // list — reconcile instead of insert-only, otherwise keywords removed
1113 // on the server (or in another client) persist locally forever and
1114 // tag filters keep matching mails that no longer carry them.
1115 // P90.3: reconcile as a set diff. Only removed labels are deleted and
1116 // only new labels are inserted, so an unchanged keyword list no longer
1117 // fires label triggers or bumps the thread revision.
1118
5/5
✓ Branch 155 → 156 taken 885 times.
✓ Branch 155 → 158 taken 411072 times.
✓ Branch 159 → 160 taken 411957 times.
✓ Branch 160 → 161 taken 3 times.
✓ Branch 160 → 162 taken 411954 times.
411957 if (!reconcileHeaderLabels(hid, known != existing.constEnd() ? &known.value().labels : nullptr,
1119 411957 labelSet, h.labels, labelQ, labelDeleteQ))
1120 3 return false;
1121
4/5
✓ Branch 164 → 165 taken 882 times.
✓ Branch 164 → 166 taken 411072 times.
✓ Branch 167 → 168 taken 411954 times.
✗ Branch 168 → 169 not taken.
✓ Branch 168 → 170 taken 411954 times.
411954 if (!indexHeaderAddresses(hid, h, dateValue, known != existing.constEnd() ? &known.value() : nullptr))
1122 ✗ return false;
1123 423078 }
1124
1125
2/3
✓ Branch 205 → 206 taken 15451 times.
✗ Branch 206 → 207 not taken.
✓ Branch 206 → 208 taken 15451 times.
15451 if (!flushAddressRows()) return false;
1126
1/1
✓ Branch 208 → 209 taken 15451 times.
15451 return commitHeaderBatch(folderId, unreadDeltas, changedRowIds);
1127 15461 }
1128
1129 QList<MailHeader>
1130 633 MailCache::headers(qint64 folderId,
1131 const std::function<void()> &headerRowsLoaded) const {
1132 633 QList<MailHeader> result;
1133
1/1
✓ Branch 2 → 3 taken 633 times.
633 QSqlQuery q(m_db);
1134
1/1
✓ Branch 6 → 7 taken 633 times.
633 q.prepare(QStringLiteral(
1135 "SELECT uid, subject, from_addr, to_addr, date, flags, size, "
1136 "has_attachments, message_id, in_reply_to, ref_ids, is_spam "
1137 "FROM headers WHERE folder_id = :fid ORDER BY date DESC"));
1138
1/1
✓ Branch 14 → 15 taken 633 times.
1266 q.bindValue(QStringLiteral(":fid"), folderId);
1139
1140
3/3
✓ Branch 18 → 19 taken 633 times.
✓ Branch 19 → 20 taken 3 times.
✓ Branch 19 → 24 taken 630 times.
633 if (!q.exec()) {
1141
2/2
✓ Branch 21 → 22 taken 1 time.
✓ Branch 21 → 23 taken 2 times.
3 if (headerRowsLoaded)
1142
1/1
✓ Branch 22 → 23 taken 1 time.
1 headerRowsLoaded();
1143 3 return result;
1144 }
1145
1146
3/3
✓ Branch 95 → 96 taken 259574 times.
✓ Branch 96 → 25 taken 258944 times.
✓ Branch 96 → 97 taken 630 times.
259574 while (q.next()) {
1147 258944 MailHeader h;
1148 258944 h.folderId = folderId;
1149
2/2
✓ Branch 26 → 27 taken 258944 times.
✓ Branch 27 → 28 taken 258944 times.
258944 h.uid = q.value(0).toLongLong();
1150
2/2
✓ Branch 29 → 30 taken 258944 times.
✓ Branch 30 → 31 taken 258944 times.
258944 h.subject = q.value(1).toString();
1151
2/2
✓ Branch 34 → 35 taken 258944 times.
✓ Branch 35 → 36 taken 258944 times.
258944 h.from = q.value(2).toString();
1152
2/2
✓ Branch 39 → 40 taken 258944 times.
✓ Branch 40 → 41 taken 258944 times.
258944 h.to = q.value(3).toString();
1153
4/4
✓ Branch 44 → 45 taken 258944 times.
✓ Branch 45 → 46 taken 258944 times.
✓ Branch 47 → 48 taken 258943 times.
✓ Branch 47 → 55 taken 1 time.
258944 if (!q.value(4).isNull())
1154
3/3
✓ Branch 48 → 49 taken 258943 times.
✓ Branch 49 → 50 taken 258943 times.
✓ Branch 50 → 51 taken 258943 times.
258943 h.date = QDateTime::fromSecsSinceEpoch(q.value(4).toLongLong());
1155
2/2
✓ Branch 55 → 56 taken 258944 times.
✓ Branch 56 → 57 taken 258944 times.
258944 h.flags = q.value(5).toUInt();
1156
2/2
✓ Branch 58 → 59 taken 258944 times.
✓ Branch 59 → 60 taken 258944 times.
258944 h.size = q.value(6).toLongLong();
1157
2/2
✓ Branch 61 → 62 taken 258944 times.
✓ Branch 62 → 63 taken 258944 times.
258944 h.hasAttachments = q.value(7).toBool();
1158
3/3
✓ Branch 64 → 65 taken 258944 times.
✓ Branch 65 → 66 taken 258944 times.
✓ Branch 66 → 67 taken 258944 times.
258944 h.messageId = MessageId::canonicalFirst(q.value(8).toString());
1159
3/3
✓ Branch 71 → 72 taken 258944 times.
✓ Branch 72 → 73 taken 258944 times.
✓ Branch 73 → 74 taken 258944 times.
258944 h.inReplyToIds = decodeThreadIds(q.value(9).toString());
1160
2/2
✓ Branch 79 → 80 taken 51856 times.
✓ Branch 79 → 82 taken 207088 times.
258944 if (!h.inReplyToIds.isEmpty())
1161
1/1
✓ Branch 80 → 81 taken 51856 times.
51856 h.inReplyTo = h.inReplyToIds.first();
1162
3/3
✓ Branch 82 → 83 taken 258944 times.
✓ Branch 83 → 84 taken 258944 times.
✓ Branch 84 → 85 taken 258944 times.
258944 h.references = decodeThreadIds(q.value(10).toString());
1163
2/2
✓ Branch 89 → 90 taken 258944 times.
✓ Branch 90 → 91 taken 258944 times.
258944 h.isSpam = q.value(11).toBool();
1164
1/1
✓ Branch 92 → 93 taken 258944 times.
258944 result.append(h);
1165 258944 }
1166
2/2
✓ Branch 98 → 99 taken 1 time.
✓ Branch 98 → 100 taken 629 times.
630 if (headerRowsLoaded)
1167
1/1
✓ Branch 99 → 100 taken 1 time.
1 headerRowsLoaded();
1168
1169 // Load labels for all headers in this folder
1170
1/2
✓ Branch 101 → 102 taken 630 times.
✗ Branch 101 → 149 not taken.
630 if (!result.isEmpty()) {
1171
1/1
✓ Branch 102 → 103 taken 630 times.
630 QSqlQuery lq(m_db);
1172
1/1
✓ Branch 106 → 107 taken 630 times.
630 lq.prepare(QStringLiteral(
1173 "SELECT h.uid, ml.label FROM mail_labels ml "
1174 "JOIN headers h ON ml.header_id = h.id "
1175 "WHERE h.folder_id = :fid"));
1176
1/1
✓ Branch 114 → 115 taken 630 times.
1260 lq.bindValue(QStringLiteral(":fid"), folderId);
1177
3/3
✓ Branch 118 → 119 taken 630 times.
✓ Branch 119 → 120 taken 629 times.
✓ Branch 119 → 147 taken 1 time.
630 if (lq.exec()) {
1178 // Build uid → index map for efficient lookup
1179 629 QHash<qint64, int> uidIndex;
1180
2/2
✓ Branch 125 → 121 taken 258943 times.
✓ Branch 125 → 126 taken 629 times.
259572 for (int i = 0; i < result.size(); ++i) {
1181
2/2
✓ Branch 121 → 122 taken 258943 times.
✓ Branch 122 → 123 taken 258943 times.
258943 uidIndex[result[i].uid] = i;
1182 }
1183
3/3
✓ Branch 143 → 144 taken 17017 times.
✓ Branch 144 → 127 taken 16388 times.
✓ Branch 144 → 145 taken 629 times.
17017 while (lq.next()) {
1184
2/2
✓ Branch 127 → 128 taken 16388 times.
✓ Branch 128 → 129 taken 16388 times.
16388 qint64 uid = lq.value(0).toLongLong();
1185
1/1
✓ Branch 130 → 131 taken 16388 times.
16388 auto it = uidIndex.find(uid);
1186
1/2
✓ Branch 133 → 134 taken 16388 times.
✗ Branch 133 → 142 not taken.
16388 if (it != uidIndex.end()) {
1187
4/4
✓ Branch 135 → 136 taken 16388 times.
✓ Branch 136 → 137 taken 16388 times.
✓ Branch 137 → 138 taken 16388 times.
✓ Branch 138 → 139 taken 16388 times.
16388 result[*it].labels.append(lq.value(1).toString());
1188 }
1189 }
1190 629 }
1191 630 }
1192
1193 630 return result;
1194 633 }
1195
1196 20 qint64 MailCache::externalDataVersion() const {
1197
1/1
✓ Branch 2 → 3 taken 20 times.
20 QSqlQuery query(m_db);
1198
7/16
✓ Branch 6 → 7 taken 20 times.
✓ Branch 7 → 8 taken 20 times.
✗ Branch 7 → 10 not taken.
✓ Branch 8 → 9 taken 20 times.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 20 times.
✓ Branch 12 → 13 taken 20 times.
✗ Branch 12 → 14 not taken.
✓ Branch 14 → 15 taken 20 times.
✗ Branch 14 → 16 not taken.
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 18 taken 20 times.
✗ Branch 26 → 27 not taken.
✗ Branch 26 → 28 not taken.
✗ Branch 30 → 31 not taken.
✗ Branch 30 → 32 not taken.
40 if (!query.exec(QStringLiteral("PRAGMA data_version")) || !query.next()) return -1;
1199
2/2
✓ Branch 18 → 19 taken 20 times.
✓ Branch 19 → 20 taken 20 times.
20 return query.value(0).toLongLong();
1200 20 }
1201
1202 // F100-66: the reading order of a page. Canonical order is the sort key
1203 // descending, then UID descending; an ascending sort reads that order in
1204 // reverse. `reverse` is true when the SQL reads against the canonical order
1205 // (a previous page of a descending sort, a next page of an ascending one).
1206 3392 static bool readsReversed(bool before, const MailSortOrder &sort) { return before != sort.ascending; }
1207
1208 4432 static QString pageOrder(const MailSortOrder &sort, bool reverse, const QString &alias = {}) {
1209
3/3
✓ Branch 3 → 4 taken 2249 times.
✓ Branch 3 → 5 taken 2183 times.
✓ Branch 7 → 8 taken 2183 times.
4432 const QString prefix = alias.isEmpty() ? QString() : alias + QLatin1Char('.');
1210
6/6
✓ Branch 8 → 9 taken 536 times.
✓ Branch 8 → 12 taken 3896 times.
✓ Branch 15 → 16 taken 3896 times.
✓ Branch 15 → 17 taken 536 times.
✓ Branch 17 → 18 taken 536 times.
✓ Branch 17 → 19 taken 3896 times.
8864 const QString direction = reverse ? QStringLiteral("ASC") : QStringLiteral("DESC");
1211
4/8
✓ Branch 25 → 26 taken 3668 times.
✓ Branch 25 → 27 taken 764 times.
✓ Branch 27 → 28 taken 3668 times.
✓ Branch 27 → 29 taken 764 times.
✗ Branch 43 → 44 not taken.
✗ Branch 43 → 45 not taken.
✗ Branch 47 → 48 not taken.
✗ Branch 47 → 49 not taken.
8100 const QString key = sort.column == MailSortOrder::Column::Date ? prefix + QStringLiteral("date")
1212
4/4
✓ Branch 19 → 20 taken 3668 times.
✓ Branch 19 → 24 taken 764 times.
✓ Branch 23 → 25 taken 3668 times.
✓ Branch 24 → 25 taken 764 times.
8100 : MailSort::sqlKey(sort.column, alias);
1213
1/1
✓ Branch 32 → 33 taken 4432 times.
8864 return QStringLiteral("ORDER BY %1 %2, %3uid %2").arg(key, direction, prefix);
1214 4432 }
1215
1216 350 static QString headerKeysetPredicate(bool dated, bool before) {
1217
2/2
✓ Branch 2 → 3 taken 326 times.
✓ Branch 2 → 16 taken 24 times.
350 if (dated)
1218 return before
1219 326 ? QStringLiteral("AND (date > :date OR (date = :date AND uid > :uid)) ")
1220
6/6
✓ Branch 3 → 4 taken 73 times.
✓ Branch 3 → 7 taken 253 times.
✓ Branch 11 → 12 taken 253 times.
✓ Branch 11 → 13 taken 73 times.
✓ Branch 13 → 14 taken 73 times.
✓ Branch 13 → 15 taken 253 times.
978 : QStringLiteral("AND (date < :date OR (date = :date AND uid < :uid) OR date IS NULL) ");
1221 return before
1222 24 ? QStringLiteral("AND (date IS NOT NULL OR (date IS NULL AND uid > :uid)) ")
1223
6/6
✓ Branch 16 → 17 taken 12 times.
✓ Branch 16 → 20 taken 12 times.
✓ Branch 24 → 25 taken 12 times.
✓ Branch 24 → 26 taken 12 times.
✓ Branch 26 → 27 taken 12 times.
✓ Branch 26 → 28 taken 12 times.
72 : QStringLiteral("AND date IS NULL AND uid < :uid ");
1224 }
1225
1226 70 MailDisplaySnapshot MailCache::navigationSnapshot(const QString &accountId,
1227 qint64 folderId, qint64 uid, bool before,
1228 const MailSortOrder &sort) {
1229 70 MailDisplaySnapshot result;
1230
2/5
✓ Branch 3 → 4 taken 70 times.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 12 taken 70 times.
✗ Branch 5 → 6 not taken.
✗ Branch 6 → 7 not taken.
70 if (!m_db.transaction()) { result.error = m_db.lastError().text(); return result; }
1231 70 const auto finish = [&](MailDisplaySnapshot::Outcome outcome, const QString &error = {}) {
1232 70 result.outcome = outcome;
1233 70 result.error = error;
1234 70 m_db.rollback(); // Read-only snapshot, including the anchor and its neighbour.
1235 70 return result;
1236 70 };
1237
1/1
✓ Branch 12 → 13 taken 70 times.
70 QSqlQuery query(m_db);
1238
1/1
✓ Branch 16 → 17 taken 70 times.
70 query.prepare(QStringLiteral("SELECT h.date, CASE WHEN length(CAST(f.path AS BLOB)) <= 4096 THEN f.path END, "
1239 "h.from_addr, h.to_addr, h.subject, h.size, h.flags "
1240 "FROM headers h JOIN folders f ON f.id=h.folder_id "
1241 "WHERE f.account=:account AND f.id=:folder AND h.uid=:uid"));
1242
2/2
✓ Branch 21 → 22 taken 70 times.
✓ Branch 22 → 23 taken 70 times.
70 query.bindValue(":account", accountId);
1243
2/2
✓ Branch 27 → 28 taken 70 times.
✓ Branch 28 → 29 taken 70 times.
70 query.bindValue(":folder", folderId);
1244
2/2
✓ Branch 33 → 34 taken 70 times.
✓ Branch 34 → 35 taken 70 times.
70 query.bindValue(":uid", uid);
1245
2/6
✓ Branch 37 → 38 taken 70 times.
✗ Branch 38 → 39 not taken.
✓ Branch 38 → 46 taken 70 times.
✗ Branch 39 → 40 not taken.
✗ Branch 40 → 41 not taken.
✗ Branch 41 → 42 not taken.
70 if (!query.exec()) return finish(MailDisplaySnapshot::Outcome::Failed, query.lastError().text());
1246
5/5
✓ Branch 46 → 47 taken 70 times.
✓ Branch 47 → 48 taken 4 times.
✓ Branch 47 → 61 taken 66 times.
✓ Branch 50 → 51 taken 4 times.
✓ Branch 51 → 52 taken 4 times.
78 if (!query.next()) return finish(query.lastError().isValid() ? MailDisplaySnapshot::Outcome::Failed
1247
4/5
✓ Branch 48 → 49 taken 4 times.
✓ Branch 49 → 50 taken 4 times.
✗ Branch 52 → 53 not taken.
✓ Branch 52 → 54 taken 4 times.
✓ Branch 55 → 56 taken 4 times.
12 : MailDisplaySnapshot::Outcome::HeaderMissing, query.lastError().text());
1248
1/1
✓ Branch 61 → 62 taken 66 times.
66 const auto date = query.value(0);
1249
3/5
✓ Branch 62 → 63 taken 66 times.
✓ Branch 63 → 64 taken 66 times.
✗ Branch 65 → 66 not taken.
✓ Branch 65 → 71 taken 66 times.
✗ Branch 67 → 68 not taken.
66 if (query.value(1).isNull()) return finish(MailDisplaySnapshot::Outcome::Limited);
1250
2/2
✓ Branch 71 → 72 taken 66 times.
✓ Branch 72 → 73 taken 66 times.
66 result.folderPath = query.value(1).toString();
1251 66 MailHeader anchor;
1252 66 anchor.uid = uid;
1253
2/2
✓ Branch 77 → 78 taken 66 times.
✓ Branch 78 → 79 taken 66 times.
66 anchor.from = query.value(2).toString();
1254
2/2
✓ Branch 82 → 83 taken 66 times.
✓ Branch 83 → 84 taken 66 times.
66 anchor.to = query.value(3).toString();
1255
2/2
✓ Branch 87 → 88 taken 66 times.
✓ Branch 88 → 89 taken 66 times.
66 anchor.subject = query.value(4).toString();
1256
2/2
✓ Branch 92 → 93 taken 66 times.
✓ Branch 93 → 94 taken 66 times.
66 anchor.size = query.value(5).toLongLong();
1257
2/2
✓ Branch 95 → 96 taken 66 times.
✓ Branch 96 → 97 taken 66 times.
66 anchor.flags = query.value(6).toUInt();
1258
1/1
✓ Branch 98 → 99 taken 66 times.
66 query.finish();
1259 // Limit the SQL projection itself: huge metadata, labels and payloads never
1260 // enter a navigation result. Use the same ordering as paging (F100-66).
1261 66 const bool reverse = readsReversed(before, sort);
1262 66 const bool byDate = sort.column == MailSortOrder::Column::Date;
1263
6/6
✓ Branch 100 → 101 taken 35 times.
✓ Branch 100 → 104 taken 31 times.
✓ Branch 107 → 108 taken 31 times.
✓ Branch 107 → 109 taken 35 times.
✓ Branch 109 → 110 taken 35 times.
✓ Branch 109 → 111 taken 31 times.
132 const QString past = reverse ? QStringLiteral(">") : QStringLiteral("<");
1264
5/7
✓ Branch 111 → 112 taken 36 times.
✓ Branch 111 → 113 taken 30 times.
✓ Branch 114 → 115 taken 30 times.
✓ Branch 115 → 116 taken 30 times.
✓ Branch 115 → 117 taken 36 times.
✗ Branch 308 → 309 not taken.
✗ Branch 308 → 310 not taken.
66 const QString key = byDate ? QString() : MailSort::sqlKey(sort.column);
1265
1/1
✓ Branch 135 → 136 taken 66 times.
198 query.prepare(QStringLiteral("SELECT uid, substr(subject,1,1024), date FROM headers WHERE folder_id=:folder ")
1266
5/5
✓ Branch 122 → 123 taken 36 times.
✓ Branch 122 → 125 taken 30 times.
✓ Branch 123 → 124 taken 36 times.
✓ Branch 124 → 129 taken 36 times.
✓ Branch 128 → 129 taken 30 times.
228 + (byDate ? headerKeysetPredicate(!date.isNull(), reverse)
1267
5/9
✓ Branch 132 → 133 taken 66 times.
✓ Branch 142 → 143 taken 30 times.
✓ Branch 142 → 144 taken 36 times.
✓ Branch 144 → 145 taken 30 times.
✓ Branch 144 → 146 taken 36 times.
✗ Branch 326 → 327 not taken.
✗ Branch 326 → 328 not taken.
✗ Branch 330 → 331 not taken.
✗ Branch 330 → 332 not taken.
162 : QStringLiteral("AND (%1 %2 :skey OR (%1 = :skey AND uid %2 :uid)) ").arg(key, past))
1268
3/3
✓ Branch 121 → 122 taken 66 times.
✓ Branch 133 → 134 taken 66 times.
✓ Branch 134 → 135 taken 66 times.
264 + pageOrder(sort, reverse) + QStringLiteral(" LIMIT 1"));
1269
2/2
✓ Branch 152 → 153 taken 66 times.
✓ Branch 153 → 154 taken 66 times.
66 query.bindValue(":folder", folderId);
1270
2/2
✓ Branch 158 → 159 taken 66 times.
✓ Branch 159 → 160 taken 66 times.
66 query.bindValue(":uid", uid);
1271
5/5
✓ Branch 162 → 163 taken 30 times.
✓ Branch 162 → 170 taken 36 times.
✓ Branch 164 → 165 taken 30 times.
✓ Branch 165 → 166 taken 30 times.
✓ Branch 166 → 167 taken 30 times.
96 if (!byDate) query.bindValue(":skey", MailSort::key(anchor, sort.column));
1272
5/5
✓ Branch 170 → 171 taken 36 times.
✓ Branch 171 → 172 taken 23 times.
✓ Branch 171 → 177 taken 13 times.
✓ Branch 173 → 174 taken 23 times.
✓ Branch 174 → 175 taken 23 times.
59 else if (!date.isNull()) query.bindValue(":date", date);
1273
2/6
✓ Branch 177 → 178 taken 66 times.
✗ Branch 178 → 179 not taken.
✓ Branch 178 → 186 taken 66 times.
✗ Branch 179 → 180 not taken.
✗ Branch 180 → 181 not taken.
✗ Branch 181 → 182 not taken.
66 if (!query.exec()) return finish(MailDisplaySnapshot::Outcome::Failed, query.lastError().text());
1274
5/5
✓ Branch 186 → 187 taken 66 times.
✓ Branch 187 → 188 taken 15 times.
✓ Branch 187 → 201 taken 51 times.
✓ Branch 190 → 191 taken 15 times.
✓ Branch 191 → 192 taken 15 times.
96 if (!query.next()) return finish(query.lastError().isValid() ? MailDisplaySnapshot::Outcome::Failed
1275
4/5
✓ Branch 188 → 189 taken 15 times.
✓ Branch 189 → 190 taken 15 times.
✗ Branch 192 → 193 not taken.
✓ Branch 192 → 194 taken 15 times.
✓ Branch 195 → 196 taken 15 times.
45 : MailDisplaySnapshot::Outcome::Boundary, query.lastError().text());
1276 51 result.header.folderId = folderId;
1277
2/2
✓ Branch 201 → 202 taken 51 times.
✓ Branch 202 → 203 taken 51 times.
51 result.header.uid = query.value(0).toLongLong();
1278
2/2
✓ Branch 204 → 205 taken 51 times.
✓ Branch 205 → 206 taken 51 times.
51 result.header.subject = query.value(1).toString();
1279
7/7
✓ Branch 209 → 210 taken 51 times.
✓ Branch 210 → 211 taken 51 times.
✓ Branch 212 → 213 taken 40 times.
✓ Branch 212 → 220 taken 11 times.
✓ Branch 213 → 214 taken 40 times.
✓ Branch 214 → 215 taken 40 times.
✓ Branch 215 → 216 taken 40 times.
51 if (!query.value(2).isNull()) result.header.date = QDateTime::fromSecsSinceEpoch(query.value(2).toLongLong());
1280
1/1
✓ Branch 220 → 221 taken 51 times.
51 query.finish();
1281
1/1
✓ Branch 222 → 223 taken 51 times.
51 return finish(MailDisplaySnapshot::Outcome::Ready);
1282 70 }
1283
1284 2183 static QString headerPageSelection(const MailHeader *after, bool before,
1285 bool includeAnchor = false,
1286 const MailHeaderFilter *filter = nullptr,
1287 const MailSortOrder &sort = {}) {
1288 2183 const bool reverse = readsReversed(before, sort);
1289 // P90.16: a mail with a pending (journaled, not yet confirmed) move never
1290 // enters a flat page, like the thread index's move exclusions; otherwise a
1291 // refreshed or reopened page re-admits the visually removed row.
1292 4366 QString inner = QStringLiteral(
1293 "SELECT * FROM headers WHERE folder_id=:folder "
1294 "AND NOT EXISTS(SELECT 1 FROM mutation_journal j WHERE j.folder_id=headers.folder_id "
1295 "AND j.uid=headers.uid AND j.kind=%1 AND j.phase NOT IN (%2,%3,%4)) ")
1296
2/2
✓ Branch 7 → 8 taken 2183 times.
✓ Branch 9 → 10 taken 2183 times.
6549 .arg(int(MutationKind::Move)).arg(int(MutationPhase::Confirmed))
1297
2/2
✓ Branch 11 → 12 taken 2183 times.
✓ Branch 13 → 14 taken 2183 times.
4366 .arg(int(MutationPhase::Failed)).arg(int(MutationPhase::Quarantined));
1298
5/6
✓ Branch 19 → 20 taken 79 times.
✓ Branch 19 → 23 taken 2104 times.
✓ Branch 21 → 22 taken 79 times.
✗ Branch 21 → 23 not taken.
✓ Branch 24 → 25 taken 79 times.
✓ Branch 24 → 85 taken 2104 times.
2183 if (filter && filter->active()) {
1299
2/2
✓ Branch 26 → 27 taken 39 times.
✓ Branch 26 → 34 taken 40 times.
79 if (!filter->text.isEmpty())
1300
1/1
✓ Branch 30 → 31 taken 39 times.
39 inner += QStringLiteral("AND (instr(mailjd_fold(subject),:ftext)>0 "
1301 "OR instr(mailjd_fold(from_addr),:ftext)>0) ");
1302
2/2
✓ Branch 34 → 35 taken 14 times.
✓ Branch 34 → 69 taken 65 times.
79 if (filter->unread) {
1303 14 QStringList retained;
1304
4/4
✓ Branch 38 → 39 taken 3 times.
✓ Branch 39 → 40 taken 3 times.
✓ Branch 43 → 37 taken 3 times.
✓ Branch 43 → 44 taken 14 times.
17 for (const qint64 uid : filter->retainedUnread) retained << QString::number(uid);
1305
3/5
✓ Branch 45 → 46 taken 11 times.
✓ Branch 45 → 49 taken 3 times.
✓ Branch 56 → 57 taken 3 times.
✗ Branch 199 → 200 not taken.
✗ Branch 199 → 201 not taken.
56 inner += retained.isEmpty() ? QStringLiteral("AND (flags & 1)=0 ")
1306
10/16
✓ Branch 55 → 56 taken 3 times.
✓ Branch 57 → 58 taken 14 times.
✓ Branch 59 → 60 taken 3 times.
✓ Branch 59 → 61 taken 11 times.
✓ Branch 61 → 62 taken 3 times.
✓ Branch 61 → 63 taken 11 times.
✓ Branch 63 → 64 taken 3 times.
✓ Branch 63 → 65 taken 11 times.
✓ Branch 65 → 66 taken 11 times.
✓ Branch 65 → 67 taken 3 times.
✗ Branch 184 → 185 not taken.
✗ Branch 184 → 186 not taken.
✗ Branch 191 → 192 not taken.
✗ Branch 191 → 193 not taken.
✗ Branch 195 → 196 not taken.
✗ Branch 195 → 197 not taken.
34 : QStringLiteral("AND ((flags & 1)=0 OR uid IN(%1)) ").arg(retained.join(QLatin1Char(',')));
1307 14 }
1308
3/3
✓ Branch 69 → 70 taken 22 times.
✓ Branch 69 → 77 taken 57 times.
✓ Branch 73 → 74 taken 22 times.
101 if (filter->starred) inner += QStringLiteral("AND (flags & 4)!=0 ");
1309
3/3
✓ Branch 77 → 78 taken 13 times.
✓ Branch 77 → 85 taken 66 times.
✓ Branch 81 → 82 taken 13 times.
92 if (filter->attachments) inner += QStringLiteral("AND has_attachments!=0 ");
1310 }
1311
4/4
✓ Branch 85 → 86 taken 649 times.
✓ Branch 85 → 120 taken 1534 times.
✓ Branch 86 → 87 taken 335 times.
✓ Branch 86 → 120 taken 314 times.
2183 if (after && sort.column != MailSortOrder::Column::Date) {
1312 // Key, then UID, both against the reading direction of the anchor.
1313
1/1
✓ Branch 88 → 89 taken 335 times.
335 const QString key = MailSort::sqlKey(sort.column);
1314
6/6
✓ Branch 90 → 91 taken 171 times.
✓ Branch 90 → 94 taken 164 times.
✓ Branch 97 → 98 taken 164 times.
✓ Branch 97 → 99 taken 171 times.
✓ Branch 99 → 100 taken 171 times.
✓ Branch 99 → 101 taken 164 times.
670 const QString past = reverse ? QStringLiteral(">") : QStringLiteral("<");
1315 670 inner += QStringLiteral("AND (%1 %2 :skey OR (%1 = :skey AND uid %2%3 :uid)) ")
1316
4/8
✗ Branch 104 → 105 not taken.
✓ Branch 104 → 108 taken 335 times.
✓ Branch 109 → 110 taken 335 times.
✓ Branch 110 → 111 taken 335 times.
✗ Branch 113 → 114 not taken.
✓ Branch 113 → 115 taken 335 times.
✗ Branch 226 → 227 not taken.
✗ Branch 226 → 228 not taken.
335 .arg(key, past, includeAnchor ? QStringLiteral("=") : QString());
1317
2/2
✓ Branch 120 → 121 taken 314 times.
✓ Branch 120 → 139 taken 1534 times.
2183 } else if (after) {
1318
3/3
✓ Branch 121 → 122 taken 314 times.
✓ Branch 122 → 123 taken 303 times.
✓ Branch 122 → 127 taken 11 times.
314 if (after->date.isValid()) {
1319
2/2
✓ Branch 123 → 124 taken 303 times.
✓ Branch 124 → 125 taken 303 times.
303 inner += headerKeysetPredicate(true, reverse);
1320
1/2
✗ Branch 127 → 128 not taken.
✓ Branch 127 → 135 taken 11 times.
11 } else if (includeAnchor) {
1321 // T-87.D1 random-seek anchor: uid-only keyset (no date known).
1322 ✗ inner += QStringLiteral("AND uid <= :uid ");
1323 } else {
1324 // An undated row (listed after every dated one, newest first): a
1325 // uid-only keyset re-read dated rows or skipped them in reverse.
1326
2/2
✓ Branch 135 → 136 taken 11 times.
✓ Branch 136 → 137 taken 11 times.
11 inner += headerKeysetPredicate(false, reverse);
1327 }
1328 }
1329 // Read the bounded selection in its direction; callers expose every page
1330 // in display order (pageOrder with the sort's own direction).
1331
3/3
✓ Branch 143 → 144 taken 2183 times.
✓ Branch 144 → 145 taken 2183 times.
✓ Branch 145 → 146 taken 2183 times.
2183 inner += pageOrder(sort, reverse) + QStringLiteral(" LIMIT :limit");
1332
1333 2183 return inner;
1334 ✗ }
1335
1336 2183 static void bindHeaderPage(QSqlQuery &query, qint64 folderId, int limit, const MailHeader *after,
1337 const MailHeaderFilter *filter = nullptr, const MailSortOrder &sort = {}) {
1338
1/1
✓ Branch 7 → 8 taken 2183 times.
4366 query.bindValue(QStringLiteral(":folder"), folderId);
1339
7/7
✓ Branch 11 → 12 taken 79 times.
✓ Branch 11 → 15 taken 2104 times.
✓ Branch 13 → 14 taken 39 times.
✓ Branch 13 → 15 taken 40 times.
✓ Branch 16 → 17 taken 39 times.
✓ Branch 16 → 27 taken 2144 times.
✓ Branch 22 → 23 taken 39 times.
2261 if (filter && !filter->text.isEmpty()) query.bindValue(QStringLiteral(":ftext"), filter->text);
1340
2/2
✓ Branch 27 → 28 taken 649 times.
✓ Branch 27 → 62 taken 1534 times.
2183 if (after) {
1341
2/2
✓ Branch 28 → 29 taken 335 times.
✓ Branch 28 → 39 taken 314 times.
649 if (sort.column != MailSortOrder::Column::Date)
1342
2/2
✓ Branch 30 → 31 taken 335 times.
✓ Branch 34 → 35 taken 335 times.
670 query.bindValue(QStringLiteral(":skey"), MailSort::key(*after, sort.column));
1343
2/2
✓ Branch 40 → 41 taken 303 times.
✓ Branch 40 → 52 taken 11 times.
314 else if (after->date.isValid())
1344
2/2
✓ Branch 42 → 43 taken 303 times.
✓ Branch 47 → 48 taken 303 times.
606 query.bindValue(QStringLiteral(":date"), after->date.toSecsSinceEpoch());
1345
1/1
✓ Branch 57 → 58 taken 649 times.
1298 query.bindValue(QStringLiteral(":uid"), after->uid);
1346 }
1347
1/1
✓ Branch 67 → 68 taken 2183 times.
4366 query.bindValue(QStringLiteral(":limit"), limit);
1348 2183 }
1349
1350 1042 QList<MailHeader> MailCache::headerPage(qint64 folderId, int limit,
1351 const MailHeader *after, bool before, QString *error,
1352 bool includeAnchor, const MailHeaderFilter *filter,
1353 const MailSortOrder &sort) const {
1354 1042 QList<MailHeader> result;
1355
2/2
✓ Branch 2 → 3 taken 1009 times.
✓ Branch 2 → 4 taken 33 times.
1042 if (error) error->clear();
1356
7/7
✓ Branch 4 → 5 taken 1042 times.
✓ Branch 5 → 6 taken 1041 times.
✓ Branch 5 → 7 taken 1 time.
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 8 taken 1040 times.
✓ Branch 9 → 10 taken 2 times.
✓ Branch 9 → 19 taken 1040 times.
1042 if (!m_db.isOpen() || limit <= 0) {
1357
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 18 taken 2 times.
2 if (error) *error = QStringLiteral("Invalid cache page request");
1358 2 return result;
1359 }
1360 // Limit complete headers before projecting labels so labels cannot split a page.
1361
1/1
✓ Branch 19 → 20 taken 1040 times.
1040 const auto inner = headerPageSelection(after, before, includeAnchor, filter, sort);
1362
1/1
✓ Branch 20 → 21 taken 1040 times.
1040 QSqlQuery query(m_db);
1363
1/1
✓ Branch 29 → 30 taken 1040 times.
3120 query.prepare(QStringLiteral(
1364 "SELECT h.uid, h.subject, h.from_addr, h.to_addr, h.date, "
1365 "h.flags, h.size, h.has_attachments, h.message_id, "
1366 "h.in_reply_to, h.ref_ids, h.is_spam, "
1367 "(SELECT json_group_array(label) FROM mail_labels "
1368 " WHERE header_id=h.id ORDER BY label) "
1369 "FROM (%1) h %2")
1370
2/2
✓ Branch 27 → 28 taken 1040 times.
✓ Branch 28 → 29 taken 1040 times.
2080 .arg(inner, pageOrder(sort, sort.ascending, QStringLiteral("h"))));
1371
1/1
✓ Branch 36 → 37 taken 1040 times.
1040 bindHeaderPage(query, folderId, limit, after, filter, sort);
1372
3/3
✓ Branch 37 → 38 taken 1040 times.
✓ Branch 38 → 39 taken 1 time.
✓ Branch 38 → 47 taken 1039 times.
1040 if (!query.exec()) {
1373
1/4
✗ Branch 39 → 40 not taken.
✓ Branch 39 → 46 taken 1 time.
✗ Branch 40 → 41 not taken.
✗ Branch 41 → 42 not taken.
1 if (error) *error = query.lastError().text();
1374 1 return result;
1375 }
1376
3/3
✓ Branch 139 → 140 taken 49065 times.
✓ Branch 140 → 48 taken 48026 times.
✓ Branch 140 → 141 taken 1039 times.
49065 while (query.next()) {
1377 48026 MailHeader header;
1378 48026 header.folderId = folderId;
1379
2/2
✓ Branch 49 → 50 taken 48026 times.
✓ Branch 50 → 51 taken 48026 times.
48026 header.uid = query.value(0).toLongLong();
1380
2/2
✓ Branch 52 → 53 taken 48026 times.
✓ Branch 53 → 54 taken 48026 times.
48026 header.subject = query.value(1).toString();
1381
2/2
✓ Branch 57 → 58 taken 48026 times.
✓ Branch 58 → 59 taken 48026 times.
48026 header.from = query.value(2).toString();
1382
2/2
✓ Branch 62 → 63 taken 48026 times.
✓ Branch 63 → 64 taken 48026 times.
48026 header.to = query.value(3).toString();
1383
4/4
✓ Branch 67 → 68 taken 48026 times.
✓ Branch 68 → 69 taken 48026 times.
✓ Branch 70 → 71 taken 47864 times.
✓ Branch 70 → 78 taken 162 times.
48026 if (!query.value(4).isNull())
1384
3/3
✓ Branch 71 → 72 taken 47864 times.
✓ Branch 72 → 73 taken 47864 times.
✓ Branch 73 → 74 taken 47864 times.
47864 header.date = QDateTime::fromSecsSinceEpoch(query.value(4).toLongLong());
1385
2/2
✓ Branch 78 → 79 taken 48026 times.
✓ Branch 79 → 80 taken 48026 times.
48026 header.flags = query.value(5).toUInt();
1386
2/2
✓ Branch 81 → 82 taken 48026 times.
✓ Branch 82 → 83 taken 48026 times.
48026 header.size = query.value(6).toLongLong();
1387
2/2
✓ Branch 84 → 85 taken 48026 times.
✓ Branch 85 → 86 taken 48026 times.
48026 header.hasAttachments = query.value(7).toBool();
1388
3/3
✓ Branch 87 → 88 taken 48026 times.
✓ Branch 88 → 89 taken 48026 times.
✓ Branch 89 → 90 taken 48026 times.
48026 header.messageId = MessageId::canonicalFirst(query.value(8).toString());
1389
3/3
✓ Branch 94 → 95 taken 48026 times.
✓ Branch 95 → 96 taken 48026 times.
✓ Branch 96 → 97 taken 48026 times.
48026 header.inReplyToIds = decodeThreadIds(query.value(9).toString());
1390
2/2
✓ Branch 102 → 103 taken 100 times.
✓ Branch 102 → 105 taken 47926 times.
48026 if (!header.inReplyToIds.isEmpty())
1391
1/1
✓ Branch 103 → 104 taken 100 times.
100 header.inReplyTo = header.inReplyToIds.first();
1392
3/3
✓ Branch 105 → 106 taken 48026 times.
✓ Branch 106 → 107 taken 48026 times.
✓ Branch 107 → 108 taken 48026 times.
48026 header.references = decodeThreadIds(query.value(10).toString());
1393
2/2
✓ Branch 112 → 113 taken 48026 times.
✓ Branch 113 → 114 taken 48026 times.
48026 header.isSpam = query.value(11).toBool();
1394 const QJsonDocument labels =
1395
3/3
✓ Branch 115 → 116 taken 48026 times.
✓ Branch 116 → 117 taken 48026 times.
✓ Branch 117 → 118 taken 48026 times.
48026 QJsonDocument::fromJson(query.value(12).toByteArray());
1396
6/6
✓ Branch 120 → 121 taken 48026 times.
✓ Branch 121 → 122 taken 48026 times.
✓ Branch 122 → 123 taken 48026 times.
✓ Branch 125 → 126 taken 2983 times.
✓ Branch 132 → 124 taken 2983 times.
✓ Branch 132 → 133 taken 48026 times.
51009 for (const QJsonValue &label : labels.array())
1397
2/2
✓ Branch 126 → 127 taken 2983 times.
✓ Branch 127 → 128 taken 2983 times.
51009 header.labels.append(label.toString());
1398
1/1
✓ Branch 135 → 136 taken 48026 times.
48026 result.append(std::move(header));
1399 48026 }
1400
3/4
✓ Branch 141 → 142 taken 1039 times.
✓ Branch 142 → 143 taken 1039 times.
✗ Branch 144 → 145 not taken.
✓ Branch 144 → 153 taken 1039 times.
1039 if (query.lastError().isValid()) {
1401 ✗ if (error) *error = query.lastError().text();
1402 ✗ result.clear();
1403 }
1404 1039 return result;
1405 1040 }
1406
1407 // Byte-budgeted page read: charges metadata before materializing rows so a
1408 // page can never exceed MailHeaderPageSnapshot::MaximumBytes. reservedBytes
1409 // accounts rows already charged against the same snapshot (anchor window).
1410 1143 QList<MailHeader> MailCache::admittedHeaderPage(qint64 folderId, const MailHeader *cursor,
1411 bool before, bool includeAnchor, int limit,
1412 qint64 reservedBytes,
1413 MailHeaderPageSnapshot &state,
1414 const MailHeaderFilter *filter,
1415 const MailSortOrder &sort) const {
1416 1143 QList<MailHeader> rows;
1417
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 1143 times.
1143 if (limit <= 0) return rows;
1418
1/1
✓ Branch 4 → 5 taken 1143 times.
1143 QSqlQuery sizes(m_db);
1419
1/1
✓ Branch 15 → 16 taken 1143 times.
3429 sizes.prepare(QStringLiteral(
1420 "SELECT coalesce(length(CAST(h.subject AS BLOB)),0)+coalesce(length(CAST(h.from_addr AS BLOB)),0)"
1421 "+coalesce(length(CAST(h.to_addr AS BLOB)),0)+coalesce(length(CAST(h.message_id AS BLOB)),0),"
1422 "coalesce(length(CAST(h.in_reply_to AS BLOB)),0)+coalesce(length(CAST(h.ref_ids AS BLOB)),0),"
1423 "(SELECT coalesce(sum(length(CAST(label AS BLOB))),0) FROM mail_labels WHERE header_id=h.id),"
1424 "(SELECT count(*) FROM mail_labels WHERE header_id=h.id) FROM (%1) h %2")
1425
2/2
✓ Branch 13 → 14 taken 1143 times.
✓ Branch 14 → 15 taken 1143 times.
2286 .arg(headerPageSelection(cursor, before, includeAnchor, filter, sort),
1426
1/1
✓ Branch 12 → 13 taken 1143 times.
2286 pageOrder(sort, readsReversed(before, sort), QStringLiteral("h"))));
1427
1/1
✓ Branch 23 → 24 taken 1143 times.
1143 bindHeaderPage(sizes, folderId, limit, cursor, filter, sort);
1428
5/5
✓ Branch 24 → 25 taken 1143 times.
✓ Branch 25 → 26 taken 1 time.
✓ Branch 25 → 32 taken 1142 times.
✓ Branch 26 → 27 taken 1 time.
✓ Branch 27 → 28 taken 1 time.
1143 if (!sizes.exec()) { state.error = sizes.lastError().text(); return rows; }
1429 1142 int admitted = 0;
1430 1142 qint64 bytes = reservedBytes;
1431
3/3
✓ Branch 57 → 58 taken 47329 times.
✓ Branch 58 → 33 taken 46190 times.
✓ Branch 58 → 59 taken 1139 times.
47329 while (sizes.next()) {
1432
2/2
✓ Branch 33 → 34 taken 46190 times.
✓ Branch 34 → 35 taken 46190 times.
46190 const qint64 labels = sizes.value(3).toLongLong();
1433 // Include decoded strings, JSON escape/array overhead and container entries
1434 // before any header text is returned to Qt. A page may contain fewer rows
1435 // than requested; the compact anchor still makes forward progress.
1436
2/2
✓ Branch 36 → 37 taken 46190 times.
✓ Branch 37 → 38 taken 46190 times.
46190 const qint64 charge = 512 + 2 * sizes.value(0).toLongLong() +
1437
4/4
✓ Branch 38 → 39 taken 46190 times.
✓ Branch 39 → 40 taken 46190 times.
✓ Branch 40 → 41 taken 46190 times.
✓ Branch 41 → 42 taken 46190 times.
46190 32 * sizes.value(1).toLongLong() + 8 * sizes.value(2).toLongLong() + 64 * labels;
1438
1/2
✓ Branch 45 → 46 taken 46190 times.
✗ Branch 45 → 47 not taken.
46190 if (labels > MailDisplaySnapshot::MaxLabels ||
1439
2/2
✓ Branch 46 → 47 taken 3 times.
✓ Branch 46 → 56 taken 46187 times.
46190 charge > MailHeaderPageSnapshot::MaximumBytes - bytes) {
1440
2/2
✓ Branch 47 → 48 taken 1 time.
✓ Branch 47 → 55 taken 2 times.
3 if (!admitted) {
1441 1 state.outcome = MailHeaderPageSnapshot::Outcome::Limited;
1442 1 state.error = QStringLiteral("Cached header exceeds the page metadata budget");
1443 1 return rows;
1444 }
1445 2 break;
1446 }
1447 46187 bytes += charge;
1448 46187 ++admitted;
1449 }
1450
3/6
✓ Branch 59 → 60 taken 1141 times.
✓ Branch 60 → 61 taken 1141 times.
✗ Branch 62 → 63 not taken.
✓ Branch 62 → 69 taken 1141 times.
✗ Branch 63 → 64 not taken.
✗ Branch 64 → 65 not taken.
1141 if (sizes.lastError().isValid()) { state.error = sizes.lastError().text(); return rows; }
1451
1/1
✓ Branch 69 → 70 taken 1141 times.
1141 sizes.finish();
1452
2/2
✓ Branch 70 → 71 taken 1009 times.
✓ Branch 70 → 75 taken 132 times.
1141 if (admitted)
1453
1/1
✓ Branch 71 → 72 taken 1009 times.
2018 rows = headerPage(folderId, admitted, cursor, before, &state.error,
1454 1009 includeAnchor, filter, sort);
1455 1141 return rows;
1456 1143 }
1457
1458 1146 MailHeaderPageSnapshot MailCache::headerPageSnapshot(
1459 const MailHeaderPageRequest &request, const std::atomic_bool *cancelled) {
1460 1146 MailHeaderPageSnapshot result;
1461 5738 const auto superseded = [&] {
1462
4/4
✓ Branch 2 → 3 taken 4610 times.
✓ Branch 2 → 6 taken 1128 times.
✓ Branch 4 → 5 taken 3 times.
✓ Branch 4 → 6 taken 4607 times.
5738 return cancelled && cancelled->load(std::memory_order_relaxed);
1463 1146 };
1464
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 6 taken 1146 times.
1146 if (superseded()) return result;
1465
1/2
✓ Branch 8 → 9 taken 21 times.
✗ Branch 8 → 21 not taken.
21 if ((request.countsOnly ? (!request.includeCounts || request.limit != 0 ||
1466
2/4
✓ Branch 9 → 10 taken 21 times.
✗ Branch 9 → 21 not taken.
✓ Branch 10 → 12 taken 21 times.
✗ Branch 10 → 21 not taken.
21 request.anchorUid != 0 || request.anchoredRestore)
1467
1/2
✓ Branch 11 → 12 taken 1125 times.
✗ Branch 11 → 21 not taken.
1125 : request.limit <= 0) ||
1468
2/2
✓ Branch 12 → 13 taken 1145 times.
✓ Branch 12 → 21 taken 1 time.
1146 request.limit > MailHeaderPageSnapshot::MaximumRows ||
1469
3/6
✓ Branch 13 → 14 taken 1145 times.
✗ Branch 13 → 21 not taken.
✓ Branch 15 → 16 taken 1145 times.
✗ Branch 15 → 21 not taken.
✓ Branch 17 → 18 taken 1145 times.
✗ Branch 17 → 21 not taken.
1145 request.folderId <= 0 || request.account.isEmpty() || request.folder.isEmpty() ||
1470
10/12
✓ Branch 6 → 7 taken 21 times.
✓ Branch 6 → 11 taken 1125 times.
✓ Branch 7 → 8 taken 21 times.
✗ Branch 7 → 21 not taken.
✓ Branch 18 → 19 taken 1145 times.
✗ Branch 18 → 21 not taken.
✓ Branch 19 → 20 taken 27 times.
✓ Branch 19 → 22 taken 1118 times.
✓ Branch 20 → 21 taken 1 time.
✓ Branch 20 → 22 taken 26 times.
✓ Branch 23 → 24 taken 2 times.
✓ Branch 23 → 32 taken 1144 times.
2292 request.anchorUid < 0 || (request.anchoredRestore && request.anchorUid <= 0)) {
1471 2 result.error = QStringLiteral("Invalid header page identity or row limit");
1472 2 return result;
1473 }
1474 1144 sqlite3 *database = nullptr;
1475
2/2
✓ Branch 32 → 33 taken 923 times.
✓ Branch 32 → 78 taken 221 times.
1144 if (cancelled) {
1476
5/8
✓ Branch 33 → 34 taken 923 times.
✓ Branch 34 → 35 taken 923 times.
✗ Branch 34 → 37 not taken.
✓ Branch 35 → 36 taken 923 times.
✗ Branch 36 → 37 not taken.
✓ Branch 36 → 38 taken 923 times.
✗ Branch 39 → 40 not taken.
✓ Branch 39 → 48 taken 923 times.
923 if (!m_db.isOpen() || !m_db.driver()) {
1477 ✗ result.error = QStringLiteral("Header page reader is not open");
1478 ✗ return result;
1479 }
1480
2/2
✓ Branch 48 → 49 taken 923 times.
✓ Branch 49 → 50 taken 923 times.
923 auto handle = m_db.driver()->handle();
1481
7/13
✓ Branch 50 → 51 taken 923 times.
✓ Branch 51 → 52 taken 923 times.
✗ Branch 51 → 56 not taken.
✓ Branch 52 → 53 taken 923 times.
✓ Branch 53 → 54 taken 923 times.
✗ Branch 55 → 56 not taken.
✓ Branch 55 → 57 taken 923 times.
✓ Branch 58 → 59 taken 923 times.
✗ Branch 58 → 60 not taken.
✗ Branch 60 → 61 not taken.
✓ Branch 60 → 69 taken 923 times.
✗ Branch 285 → 286 not taken.
✗ Branch 285 → 287 not taken.
923 if (!handle.isValid() || QByteArray(handle.typeName()) != "sqlite3*") {
1482 ✗ result.error = QStringLiteral("Header page reader requires interruptible SQLite");
1483 ✗ return result;
1484 }
1485
1/1
✓ Branch 69 → 70 taken 923 times.
923 database = *static_cast<sqlite3 **>(handle.data());
1486
1/1
✓ Branch 71 → 72 taken 923 times.
923 sqlite3_progress_handler(database, 1000, [](void *context) {
1487 7886 return static_cast<const std::atomic_bool *>(context)->load(
1488
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 7885 times.
7886 std::memory_order_relaxed) ? 1 : 0;
1489 }, const_cast<std::atomic_bool *>(cancelled));
1490 923 }
1491 3432 const auto clearProgress = qScopeGuard([database] {
1492
2/2
✓ Branch 2 → 3 taken 923 times.
✓ Branch 2 → 4 taken 221 times.
1144 if (database) sqlite3_progress_handler(database, 0, nullptr, nullptr);
1493 1144 });
1494
2/5
✓ Branch 79 → 80 taken 1144 times.
✗ Branch 80 → 81 not taken.
✓ Branch 80 → 88 taken 1144 times.
✗ Branch 81 → 82 not taken.
✗ Branch 82 → 83 not taken.
1144 if (!m_db.transaction()) { result.error = m_db.lastError().text(); return result; }
1495 3432 const auto rollback = qScopeGuard([&] {
1496
2/2
✓ Branch 2 → 3 taken 923 times.
✓ Branch 2 → 4 taken 221 times.
1144 if (database) sqlite3_progress_handler(database, 0, nullptr, nullptr);
1497 1144 m_db.rollback();
1498 1144 });
1499
2/2
✓ Branch 90 → 91 taken 1 time.
✓ Branch 90 → 93 taken 1143 times.
1144 if (superseded()) return result;
1500
1/1
✓ Branch 93 → 94 taken 1143 times.
1143 QSqlQuery identity(m_db);
1501
1/1
✓ Branch 97 → 98 taken 1143 times.
1143 identity.prepare(QStringLiteral("SELECT 1 FROM folders WHERE id=? AND account=? AND path=? AND uidvalidity=?"));
1502
2/2
✓ Branch 102 → 103 taken 1143 times.
✓ Branch 106 → 107 taken 1143 times.
1143 identity.addBindValue(request.folderId); identity.addBindValue(request.account);
1503
2/2
✓ Branch 110 → 111 taken 1143 times.
✓ Branch 114 → 115 taken 1143 times.
1143 identity.addBindValue(request.folder); identity.addBindValue(request.uidValidity);
1504
7/8
✓ Branch 116 → 117 taken 1143 times.
✓ Branch 117 → 118 taken 1143 times.
✗ Branch 117 → 120 not taken.
✓ Branch 118 → 119 taken 1143 times.
✓ Branch 119 → 120 taken 3 times.
✓ Branch 119 → 121 taken 1140 times.
✓ Branch 122 → 123 taken 3 times.
✓ Branch 122 → 140 taken 1140 times.
1143 if (!identity.exec() || !identity.next()) {
1505
3/8
✓ Branch 123 → 124 taken 3 times.
✓ Branch 124 → 125 taken 3 times.
✗ Branch 125 → 126 not taken.
✓ Branch 125 → 128 taken 3 times.
✗ Branch 126 → 127 not taken.
✗ Branch 127 → 131 not taken.
✗ Branch 324 → 325 not taken.
✗ Branch 324 → 326 not taken.
12 result.error = identity.lastError().isValid() ? identity.lastError().text()
1506
2/6
✓ Branch 133 → 134 taken 3 times.
✗ Branch 133 → 135 not taken.
✗ Branch 135 → 136 not taken.
✓ Branch 135 → 137 taken 3 times.
✗ Branch 320 → 321 not taken.
✗ Branch 320 → 322 not taken.
6 : QStringLiteral("Mailbox identity changed before reading its page");
1507 3 return result;
1508 }
1509
2/2
✓ Branch 141 → 142 taken 1 time.
✓ Branch 141 → 144 taken 1139 times.
1140 if (superseded()) return result;
1510
2/2
✓ Branch 144 → 145 taken 804 times.
✓ Branch 144 → 149 taken 335 times.
1139 if (request.includeCounts) {
1511 // One aggregate query keeps both counts inside the same page snapshot.
1512
1/1
✓ Branch 145 → 146 taken 804 times.
804 const auto [total, unread] = folderCounts(request.folderId);
1513 804 result.totalCount = total;
1514 804 result.unreadCount = unread;
1515 }
1516
1/2
✗ Branch 150 → 151 not taken.
✓ Branch 150 → 153 taken 1139 times.
1139 if (superseded()) return result;
1517
2/2
✓ Branch 153 → 154 taken 20 times.
✓ Branch 153 → 156 taken 1119 times.
1139 if (request.countsOnly) {
1518 20 result.outcome = MailHeaderPageSnapshot::Outcome::Ready;
1519 20 return result;
1520 }
1521
2/2
✓ Branch 157 → 158 taken 48 times.
✓ Branch 157 → 159 taken 1071 times.
1119 const MailHeaderFilter *pageFilter = request.filter.active() ? &request.filter : nullptr;
1522 1119 MailHeader anchor;
1523 1119 anchor.uid = request.anchorUid; anchor.date = request.anchorDate;
1524 1119 anchor.from = request.anchorFrom; anchor.to = request.anchorTo;
1525 1119 anchor.subject = request.anchorSubject; anchor.size = request.anchorSize;
1526 1119 anchor.flags = request.anchorFlags;
1527 // A vanished restore anchor must degrade to the plain first page: its
1528 // synthetic cursor carries no valid date, so never feed it to the keyset.
1529 1119 const auto *cursor =
1530
4/4
✓ Branch 165 → 166 taken 1093 times.
✓ Branch 165 → 168 taken 26 times.
✓ Branch 166 → 167 taken 290 times.
✓ Branch 166 → 168 taken 803 times.
1119 !request.anchoredRestore && request.anchorUid > 0 ? &anchor : nullptr;
1531
2/2
✓ Branch 169 → 170 taken 26 times.
✓ Branch 169 → 268 taken 1093 times.
1119 if (request.anchoredRestore) {
1532 // Session restoration: the anchor row plus a balanced window around it,
1533 // all inside one snapshot transaction. A vanished anchor degrades to the
1534 // plain first page instead of blocking startup.
1535
1/1
✓ Branch 170 → 171 taken 26 times.
26 const auto restored = header(request.folderId, request.anchorUid);
1536
1/2
✗ Branch 172 → 173 not taken.
✓ Branch 172 → 175 taken 26 times.
26 if (superseded()) return result;
1537
2/2
✓ Branch 176 → 177 taken 24 times.
✓ Branch 176 → 262 taken 2 times.
26 if (restored) {
1538 // Charge the anchor with the writer's batch formula (headerBatchBytes
1539 // precedent) so one restore window stays inside the page byte budget.
1540 24 qint64 anchorCharge = 256 + 2LL * (restored->subject.size() + restored->from.size() +
1541 24 restored->to.size() + restored->messageId.size() +
1542 24 restored->inReplyTo.size());
1543
2/2
✓ Branch 203 → 195 taken 72 times.
✓ Branch 203 → 204 taken 24 times.
168 for (const auto &list : {restored->labels, restored->references, restored->inReplyToIds})
1544
1/2
✗ Branch 201 → 197 not taken.
✓ Branch 201 → 202 taken 72 times.
72 for (const auto &value : list)
1545
2/2
✓ Branch 205 → 206 taken 72 times.
✓ Branch 205 → 209 taken 24 times.
96 anchorCharge += 32 + 2LL * value.size();
1546
3/6
✓ Branch 212 → 213 taken 24 times.
✗ Branch 212 → 214 not taken.
✗ Branch 213 → 214 not taken.
✓ Branch 213 → 215 taken 24 times.
✗ Branch 216 → 217 not taken.
✓ Branch 216 → 225 taken 24 times.
24 if (restored->labels.size() > MailDisplaySnapshot::MaxLabels ||
1547 anchorCharge > MailHeaderPageSnapshot::MaximumBytes) {
1548 ✗ result.outcome = MailHeaderPageSnapshot::Outcome::Limited;
1549 ✗ result.error = QStringLiteral("Cached anchor exceeds the page metadata budget");
1550 ✗ return result;
1551 }
1552 24 const int window = qMin(request.limit, MailHeaderPageSnapshot::MaximumRows);
1553 24 const int beforeRows = window / 2;
1554 24 const int afterRows = window - beforeRows - 1;
1555 48 auto beforePart = admittedHeaderPage(request.folderId, &*restored, true,
1556 false, beforeRows, anchorCharge,
1557
1/1
✓ Branch 227 → 228 taken 24 times.
24 result, pageFilter, request.sort);
1558
1/2
✗ Branch 229 → 230 not taken.
✓ Branch 229 → 232 taken 24 times.
24 if (superseded()) return result;
1559
1/2
✗ Branch 232 → 233 not taken.
✓ Branch 232 → 235 taken 24 times.
24 if (result.outcome == MailHeaderPageSnapshot::Outcome::Limited) {
1560 // An oversized neighbour shrinks its side of the window; the restore
1561 // remains useful with the anchor and the opposite side.
1562 ✗ result.outcome = MailHeaderPageSnapshot::Outcome::Failed;
1563 ✗ result.error.clear();
1564 ✗ beforePart.clear();
1565 }
1566
1/2
✗ Branch 236 → 237 not taken.
✓ Branch 236 → 239 taken 24 times.
24 if (!result.error.isEmpty()) return result;
1567 48 auto afterPart = admittedHeaderPage(request.folderId, &*restored, false,
1568 false, afterRows, anchorCharge,
1569
1/1
✓ Branch 240 → 241 taken 24 times.
24 result, pageFilter, request.sort);
1570
1/2
✗ Branch 242 → 243 not taken.
✓ Branch 242 → 245 taken 24 times.
24 if (superseded()) return result;
1571
1/2
✗ Branch 245 → 246 not taken.
✓ Branch 245 → 248 taken 24 times.
24 if (result.outcome == MailHeaderPageSnapshot::Outcome::Limited) {
1572 ✗ result.outcome = MailHeaderPageSnapshot::Outcome::Failed;
1573 ✗ result.error.clear();
1574 ✗ afterPart.clear();
1575 }
1576
1/2
✗ Branch 249 → 250 not taken.
✓ Branch 249 → 252 taken 24 times.
24 if (!result.error.isEmpty()) return result;
1577 24 result.headers = std::move(beforePart);
1578
1/1
✓ Branch 255 → 256 taken 24 times.
24 result.headers.append(*restored);
1579
1/1
✓ Branch 256 → 257 taken 24 times.
24 result.headers += afterPart;
1580 24 result.anchored = true;
1581 24 result.outcome = MailHeaderPageSnapshot::Outcome::Ready;
1582 24 return result;
1583 24 }
1584 26 }
1585 1095 result.headers = admittedHeaderPage(request.folderId, cursor, request.before,
1586 1095 request.includeAnchor, request.limit, 0,
1587
1/1
✓ Branch 268 → 269 taken 1095 times.
1095 result, pageFilter, request.sort);
1588
2/2
✓ Branch 272 → 273 taken 1 time.
✓ Branch 272 → 274 taken 1094 times.
1095 if (superseded()) return {};
1589
2/2
✓ Branch 275 → 276 taken 1 time.
✓ Branch 275 → 277 taken 1093 times.
1094 if (!result.error.isEmpty()) return result;
1590 1093 result.outcome = MailHeaderPageSnapshot::Outcome::Ready;
1591 1093 return result;
1592 1146 }
1593
1594 // T-87.D1: uid-quantized rank buckets. One covering-index scan over
1595 // (folder_id, uid) with GROUP BY uid/:stride; bounded bucket count.
1596 5 MailCache::MailRankIndex MailCache::rankIndex(qint64 folderId,
1597 int bucketCount) const {
1598 5 MailRankIndex index;
1599
4/4
✓ Branch 2 → 3 taken 4 times.
✓ Branch 2 → 4 taken 1 time.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 3 times.
5 if (bucketCount <= 0 || bucketCount > 1024) return index;
1600
1/1
✓ Branch 5 → 6 taken 3 times.
3 QSqlQuery q(m_db);
1601
1/1
✓ Branch 9 → 10 taken 3 times.
3 q.prepare(QStringLiteral("SELECT MAX(uid) FROM headers WHERE folder_id = :fid"));
1602
1/1
✓ Branch 17 → 18 taken 3 times.
6 q.bindValue(QStringLiteral(":fid"), folderId);
1603
5/8
✓ Branch 21 → 22 taken 3 times.
✓ Branch 22 → 23 taken 3 times.
✗ Branch 22 → 25 not taken.
✓ Branch 23 → 24 taken 3 times.
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 26 taken 3 times.
✗ Branch 27 → 28 not taken.
✓ Branch 27 → 29 taken 3 times.
3 if (!q.exec() || !q.next()) return index;
1604
2/2
✓ Branch 29 → 30 taken 3 times.
✓ Branch 30 → 31 taken 3 times.
3 index.maxUid = q.value(0).toLongLong();
1605
2/2
✓ Branch 32 → 33 taken 1 time.
✓ Branch 32 → 34 taken 2 times.
3 if (index.maxUid <= 0) return index;
1606
1/1
✓ Branch 34 → 35 taken 2 times.
2 q.finish();
1607
1608 const qint64 stride =
1609 2 qMax<qint64>(1, (index.maxUid + bucketCount - 1) / bucketCount);
1610
1/1
✓ Branch 39 → 40 taken 2 times.
2 q.prepare(QStringLiteral(
1611 "SELECT MIN(uid), COUNT(*) FROM headers WHERE folder_id = :fid "
1612 "GROUP BY uid / :stride ORDER BY MIN(uid) ASC"));
1613
1/1
✓ Branch 47 → 48 taken 2 times.
4 q.bindValue(QStringLiteral(":fid"), folderId);
1614
1/1
✓ Branch 56 → 57 taken 2 times.
4 q.bindValue(QStringLiteral(":stride"), stride);
1615
2/4
✓ Branch 60 → 61 taken 2 times.
✗ Branch 61 → 62 not taken.
✓ Branch 61 → 64 taken 2 times.
✗ Branch 62 → 63 not taken.
2 if (!q.exec()) { index.buckets.clear(); return index; }
1616
3/3
✓ Branch 73 → 74 taken 7 times.
✓ Branch 74 → 65 taken 5 times.
✓ Branch 74 → 75 taken 2 times.
7 while (q.next()) {
1617 5 MailRankBucket bucket;
1618
2/2
✓ Branch 65 → 66 taken 5 times.
✓ Branch 66 → 67 taken 5 times.
5 bucket.minUid = q.value(0).toLongLong();
1619
2/2
✓ Branch 68 → 69 taken 5 times.
✓ Branch 69 → 70 taken 5 times.
5 bucket.count = q.value(1).toLongLong();
1620
1/1
✓ Branch 71 → 72 taken 5 times.
5 index.buckets.append(bucket);
1621 }
1622 2 return index;
1623 3 }
1624
1625 // Rank semantics: rank 0 = newest row, ascending toward older rows. Walk
1626 // buckets newest-first (descending uid); buckets entirely newer than the uid
1627 // add their count, the straddling bucket adds an approximate offset, older
1628 // buckets add nothing. Gappy uid spaces make the in-bucket offset an
1629 // approximation — adequate for anchoring a seek, not for exact ranks.
1630 9 qint64 MailCache::rankForUidFromIndex(const MailRankIndex &index,
1631 qint64 uid) const {
1632 9 qint64 rank = 0; // rows newer than the requested uid
1633
4/4
✓ Branch 13 → 14 taken 15 times.
✓ Branch 15 → 16 taken 24 times.
✓ Branch 16 → 3 taken 22 times.
✓ Branch 16 → 17 taken 2 times.
24 for (auto it = index.buckets.crbegin(); it != index.buckets.crend(); ++it) {
1634
3/3
✓ Branch 3 → 4 taken 22 times.
✓ Branch 4 → 5 taken 13 times.
✓ Branch 4 → 7 taken 9 times.
22 if (it->minUid > uid) {
1635
1/1
✓ Branch 5 → 6 taken 13 times.
13 rank += it->count; // entirely newer
1636 13 continue;
1637 }
1638
2/2
✓ Branch 7 → 8 taken 9 times.
✓ Branch 8 → 9 taken 9 times.
9 const qint64 bucketMax = it->minUid + it->count - 1;
1639
2/2
✓ Branch 9 → 10 taken 7 times.
✓ Branch 9 → 13 taken 2 times.
9 if (uid <= bucketMax) { // straddling bucket
1640
2/2
✓ Branch 10 → 11 taken 7 times.
✓ Branch 11 → 12 taken 7 times.
7 rank += qBound<qint64>(0, bucketMax - uid, it->count);
1641 7 return rank;
1642 }
1643 // entirely older: contributes nothing
1644 }
1645 2 return rank;
1646 }
1647
1648 8 qint64 MailCache::uidAtRankFromIndex(const MailRankIndex &index,
1649 qint64 rank) const {
1650 8 qint64 seen = 0;
1651
3/3
✓ Branch 13 → 14 taken 18 times.
✓ Branch 14 → 3 taken 17 times.
✓ Branch 14 → 15 taken 1 time.
18 for (auto it = index.buckets.crbegin(); it != index.buckets.crend(); ++it) {
1652
3/3
✓ Branch 3 → 4 taken 17 times.
✓ Branch 4 → 5 taken 7 times.
✓ Branch 4 → 10 taken 10 times.
17 if (rank < seen + it->count) {
1653
2/2
✓ Branch 5 → 6 taken 7 times.
✓ Branch 6 → 7 taken 7 times.
7 const qint64 offset = qBound<qint64>(0, rank - seen, it->count - 1);
1654
2/2
✓ Branch 7 → 8 taken 7 times.
✓ Branch 8 → 9 taken 7 times.
7 return it->minUid + it->count - 1 - offset;
1655 }
1656
2/2
✓ Branch 10 → 11 taken 10 times.
✓ Branch 11 → 12 taken 10 times.
10 seen += it->count;
1657 }
1658
1/2
✗ Branch 16 → 18 not taken.
✓ Branch 16 → 19 taken 1 time.
1 return index.buckets.isEmpty() ? qint64(0) : index.buckets.first().minUid;
1659 }
1660
1661 4469 std::optional<MailHeader> MailCache::header(qint64 folderId, qint64 uid) const {
1662
1/1
✓ Branch 2 → 3 taken 4469 times.
4469 QSqlQuery q(m_db);
1663
1/1
✓ Branch 6 → 7 taken 4469 times.
4469 q.prepare(
1664 8938 QStringLiteral("SELECT subject, from_addr, to_addr, date, flags, size, "
1665 "has_attachments, message_id, in_reply_to, ref_ids, "
1666 "is_spam, id "
1667 "FROM headers WHERE folder_id = :fid AND uid = :uid"));
1668
1/1
✓ Branch 14 → 15 taken 4469 times.
8938 q.bindValue(QStringLiteral(":fid"), folderId);
1669
1/1
✓ Branch 23 → 24 taken 4469 times.
8938 q.bindValue(QStringLiteral(":uid"), uid);
1670
1671
8/8
✓ Branch 27 → 28 taken 4469 times.
✓ Branch 28 → 29 taken 4467 times.
✓ Branch 28 → 32 taken 2 times.
✓ Branch 29 → 30 taken 4467 times.
✓ Branch 30 → 31 taken 4397 times.
✓ Branch 30 → 32 taken 70 times.
✓ Branch 33 → 34 taken 4397 times.
✓ Branch 33 → 134 taken 72 times.
4469 if (q.exec() && q.next()) {
1672 4397 MailHeader h;
1673 4397 h.uid = uid;
1674 4397 h.folderId = folderId;
1675
2/2
✓ Branch 35 → 36 taken 4397 times.
✓ Branch 36 → 37 taken 4397 times.
4397 h.subject = q.value(0).toString();
1676
2/2
✓ Branch 40 → 41 taken 4397 times.
✓ Branch 41 → 42 taken 4397 times.
4397 h.from = q.value(1).toString();
1677
2/2
✓ Branch 45 → 46 taken 4397 times.
✓ Branch 46 → 47 taken 4397 times.
4397 h.to = q.value(2).toString();
1678
4/4
✓ Branch 50 → 51 taken 4397 times.
✓ Branch 51 → 52 taken 4397 times.
✓ Branch 53 → 54 taken 4336 times.
✓ Branch 53 → 61 taken 61 times.
4397 if (!q.value(3).isNull())
1679
3/3
✓ Branch 54 → 55 taken 4336 times.
✓ Branch 55 → 56 taken 4336 times.
✓ Branch 56 → 57 taken 4336 times.
4336 h.date = QDateTime::fromSecsSinceEpoch(q.value(3).toLongLong());
1680
2/2
✓ Branch 61 → 62 taken 4397 times.
✓ Branch 62 → 63 taken 4397 times.
4397 h.flags = q.value(4).toUInt();
1681
2/2
✓ Branch 64 → 65 taken 4397 times.
✓ Branch 65 → 66 taken 4397 times.
4397 h.size = q.value(5).toLongLong();
1682
2/2
✓ Branch 67 → 68 taken 4397 times.
✓ Branch 68 → 69 taken 4397 times.
4397 h.hasAttachments = q.value(6).toBool();
1683
3/3
✓ Branch 70 → 71 taken 4397 times.
✓ Branch 71 → 72 taken 4397 times.
✓ Branch 72 → 73 taken 4397 times.
4397 h.messageId = MessageId::canonicalFirst(q.value(7).toString());
1684
3/3
✓ Branch 77 → 78 taken 4397 times.
✓ Branch 78 → 79 taken 4397 times.
✓ Branch 79 → 80 taken 4397 times.
4397 h.inReplyToIds = decodeThreadIds(q.value(8).toString());
1685
2/2
✓ Branch 85 → 86 taken 57 times.
✓ Branch 85 → 88 taken 4340 times.
4397 if (!h.inReplyToIds.isEmpty())
1686
1/1
✓ Branch 86 → 87 taken 57 times.
57 h.inReplyTo = h.inReplyToIds.first();
1687
3/3
✓ Branch 88 → 89 taken 4397 times.
✓ Branch 89 → 90 taken 4397 times.
✓ Branch 90 → 91 taken 4397 times.
4397 h.references = decodeThreadIds(q.value(9).toString());
1688
2/2
✓ Branch 95 → 96 taken 4397 times.
✓ Branch 96 → 97 taken 4397 times.
4397 h.isSpam = q.value(10).toBool();
1689
1690 // Load labels
1691 // The row was just selected; fetch its label key without another lookup.
1692
2/2
✓ Branch 98 → 99 taken 4397 times.
✓ Branch 99 → 100 taken 4397 times.
4397 const qint64 hid = q.value(11).toLongLong();
1693
1/2
✓ Branch 101 → 102 taken 4397 times.
✗ Branch 101 → 131 not taken.
4397 if (hid > 0) {
1694
1/1
✓ Branch 102 → 103 taken 4397 times.
4397 QSqlQuery lq(m_db);
1695
1/1
✓ Branch 106 → 107 taken 4397 times.
4397 lq.prepare(QStringLiteral(
1696 "SELECT label FROM mail_labels WHERE header_id = :hid"));
1697
1/1
✓ Branch 114 → 115 taken 4397 times.
8794 lq.bindValue(QStringLiteral(":hid"), hid);
1698
3/3
✓ Branch 118 → 119 taken 4397 times.
✓ Branch 119 → 120 taken 4396 times.
✓ Branch 119 → 129 taken 1 time.
4397 if (lq.exec()) {
1699
3/3
✓ Branch 127 → 128 taken 4768 times.
✓ Branch 128 → 121 taken 372 times.
✓ Branch 128 → 129 taken 4396 times.
4768 while (lq.next()) {
1700
3/3
✓ Branch 121 → 122 taken 372 times.
✓ Branch 122 → 123 taken 372 times.
✓ Branch 123 → 124 taken 372 times.
372 h.labels.append(lq.value(0).toString());
1701 }
1702 }
1703 4397 }
1704 4397 return h;
1705 4397 }
1706 72 return std::nullopt;
1707 4469 }
1708
1709 1637 QList<MailHeader> MailCache::headersByIds(const QList<MailId> &mailIds) const {
1710 1637 QList<MailHeader> result;
1711
7/7
✓ Branch 2 → 3 taken 1637 times.
✓ Branch 3 → 4 taken 1636 times.
✓ Branch 3 → 6 taken 1 time.
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 1635 times.
✓ Branch 8 → 9 taken 2 times.
✓ Branch 8 → 10 taken 1635 times.
1637 if (!m_db.isOpen() || mailIds.isEmpty())
1712 2 return result;
1713
1714 // Three binds per identity keep a 200-result page below SQLite's commonly
1715 // configured 999-variable limit. The ordinal preserves the caller's FTS or
1716 // server rank even when dates and UIDs collide.
1717 1635 constexpr int chunkSize = 200;
1718
1/1
✓ Branch 11 → 12 taken 1635 times.
1635 result.reserve(mailIds.size());
1719
2/2
✓ Branch 248 → 13 taken 1636 times.
✓ Branch 248 → 249 taken 1634 times.
3270 for (int start = 0; start < mailIds.size(); start += chunkSize) {
1720 const int count =
1721 1636 std::min(chunkSize, static_cast<int>(mailIds.size()) - start);
1722 1636 QStringList values;
1723
1/1
✓ Branch 15 → 16 taken 1636 times.
1636 values.reserve(count);
1724
2/2
✓ Branch 27 → 17 taken 2034 times.
✓ Branch 27 → 28 taken 1636 times.
3670 for (int i = 0; i < count; ++i)
1725
2/2
✓ Branch 21 → 22 taken 2034 times.
✓ Branch 22 → 23 taken 2034 times.
4068 values.append(QStringLiteral("(:ord%1,:folder%1,:uid%1)").arg(i));
1726
1727
1/1
✓ Branch 28 → 29 taken 1636 times.
1636 QSqlQuery query(m_db);
1728
1/1
✓ Branch 37 → 38 taken 1636 times.
4908 query.prepare(QStringLiteral(
1729 "WITH requested(ord, folder_id, uid) AS (VALUES %1) "
1730 "SELECT requested.ord, h.uid, h.folder_id, h.subject, "
1731 "h.from_addr, h.to_addr, h.date, h.flags, h.size, "
1732 "h.has_attachments, h.message_id, h.in_reply_to, "
1733 "h.ref_ids, h.is_spam, ml.label, h.cc_addr, h.list_id, "
1734 "h.spam_score, h.spam_threshold, h.spam_tool "
1735 "FROM requested JOIN headers h "
1736 "ON h.folder_id=requested.folder_id AND h.uid=requested.uid "
1737 "LEFT JOIN mail_labels ml ON ml.header_id=h.id "
1738 "ORDER BY requested.ord, ml.label")
1739
2/2
✓ Branch 35 → 36 taken 1636 times.
✓ Branch 36 → 37 taken 1636 times.
3272 .arg(values.join(QLatin1Char(','))));
1740
2/2
✓ Branch 81 → 43 taken 2034 times.
✓ Branch 81 → 82 taken 1636 times.
3670 for (int i = 0; i < count; ++i) {
1741 2034 const MailId &id = mailIds.at(start + i);
1742
2/2
✓ Branch 50 → 51 taken 2034 times.
✓ Branch 51 → 52 taken 2034 times.
6102 query.bindValue(QStringLiteral(":ord%1").arg(i), i);
1743
2/2
✓ Branch 62 → 63 taken 2034 times.
✓ Branch 63 → 64 taken 2034 times.
6102 query.bindValue(QStringLiteral(":folder%1").arg(i), id.folderId);
1744
2/2
✓ Branch 74 → 75 taken 2034 times.
✓ Branch 75 → 76 taken 2034 times.
6102 query.bindValue(QStringLiteral(":uid%1").arg(i), id.uid);
1745 }
1746
3/3
✓ Branch 82 → 83 taken 1636 times.
✓ Branch 83 → 84 taken 1 time.
✓ Branch 83 → 106 taken 1635 times.
1636 if (!query.exec()) {
1747 // S106-109: keep the chunks already read — every caller enriches
1748 // best-effort (search hits, summaries, filing records), and a later
1749 // chunk's failure used to empty the whole answer.
1750
8/8
✓ Branch 84 → 85 taken 1 time.
✓ Branch 88 → 89 taken 1 time.
✓ Branch 89 → 90 taken 1 time.
✓ Branch 90 → 91 taken 1 time.
✓ Branch 91 → 92 taken 1 time.
✓ Branch 92 → 93 taken 1 time.
✓ Branch 104 → 86 taken 1 time.
✓ Branch 104 → 105 taken 1 time.
2 qCWarning(lcCache) << "Failed to hydrate header page:" << query.lastError().text()
1751
4/4
✓ Branch 93 → 94 taken 1 time.
✓ Branch 95 → 96 taken 1 time.
✓ Branch 96 → 97 taken 1 time.
✓ Branch 98 → 99 taken 1 time.
1 << "- returning" << result.size() << "of" << mailIds.size();
1752 1 return result;
1753 }
1754
1755 1635 int currentOrdinal = -1;
1756
3/3
✓ Branch 235 → 236 taken 3668 times.
✓ Branch 236 → 107 taken 2033 times.
✓ Branch 236 → 237 taken 1635 times.
3668 while (query.next()) {
1757
2/2
✓ Branch 107 → 108 taken 2033 times.
✓ Branch 108 → 109 taken 2033 times.
2033 const int ordinal = query.value(0).toInt();
1758
2/2
✓ Branch 110 → 111 taken 2027 times.
✓ Branch 110 → 224 taken 6 times.
2033 if (ordinal != currentOrdinal) {
1759 2027 MailHeader header;
1760
2/2
✓ Branch 112 → 113 taken 2027 times.
✓ Branch 113 → 114 taken 2027 times.
2027 header.uid = query.value(1).toLongLong();
1761
2/2
✓ Branch 115 → 116 taken 2027 times.
✓ Branch 116 → 117 taken 2027 times.
2027 header.folderId = query.value(2).toLongLong();
1762
2/2
✓ Branch 118 → 119 taken 2027 times.
✓ Branch 119 → 120 taken 2027 times.
2027 header.subject = query.value(3).toString();
1763
2/2
✓ Branch 123 → 124 taken 2027 times.
✓ Branch 124 → 125 taken 2027 times.
2027 header.from = query.value(4).toString();
1764
2/2
✓ Branch 128 → 129 taken 2027 times.
✓ Branch 129 → 130 taken 2027 times.
2027 header.to = query.value(5).toString();
1765
4/4
✓ Branch 133 → 134 taken 2027 times.
✓ Branch 134 → 135 taken 2027 times.
✓ Branch 136 → 137 taken 2003 times.
✓ Branch 136 → 144 taken 24 times.
2027 if (!query.value(6).isNull())
1766
3/3
✓ Branch 137 → 138 taken 2003 times.
✓ Branch 138 → 139 taken 2003 times.
✓ Branch 139 → 140 taken 2003 times.
2003 header.date = QDateTime::fromSecsSinceEpoch(query.value(6).toLongLong());
1767
2/2
✓ Branch 144 → 145 taken 2027 times.
✓ Branch 145 → 146 taken 2027 times.
2027 header.flags = query.value(7).toUInt();
1768
2/2
✓ Branch 147 → 148 taken 2027 times.
✓ Branch 148 → 149 taken 2027 times.
2027 header.size = query.value(8).toLongLong();
1769
2/2
✓ Branch 150 → 151 taken 2027 times.
✓ Branch 151 → 152 taken 2027 times.
2027 header.hasAttachments = query.value(9).toBool();
1770
3/3
✓ Branch 153 → 154 taken 2027 times.
✓ Branch 154 → 155 taken 2027 times.
✓ Branch 155 → 156 taken 2027 times.
2027 header.messageId = MessageId::canonicalFirst(query.value(10).toString());
1771
3/3
✓ Branch 160 → 161 taken 2027 times.
✓ Branch 161 → 162 taken 2027 times.
✓ Branch 162 → 163 taken 2027 times.
2027 header.inReplyToIds = decodeThreadIds(query.value(11).toString());
1772
2/2
✓ Branch 168 → 169 taken 241 times.
✓ Branch 168 → 171 taken 1786 times.
2027 if (!header.inReplyToIds.isEmpty())
1773
1/1
✓ Branch 169 → 170 taken 241 times.
241 header.inReplyTo = header.inReplyToIds.first();
1774
3/3
✓ Branch 171 → 172 taken 2027 times.
✓ Branch 172 → 173 taken 2027 times.
✓ Branch 173 → 174 taken 2027 times.
2027 header.references = decodeThreadIds(query.value(12).toString());
1775
2/2
✓ Branch 178 → 179 taken 2027 times.
✓ Branch 179 → 180 taken 2027 times.
2027 header.isSpam = query.value(13).toBool();
1776
2/2
✓ Branch 181 → 182 taken 2027 times.
✓ Branch 182 → 183 taken 2027 times.
2027 header.recipientsKnown = !query.value(15).isNull();
1777
2/2
✓ Branch 184 → 185 taken 2027 times.
✓ Branch 185 → 186 taken 2027 times.
2027 header.cc = query.value(15).toString();
1778
2/2
✓ Branch 189 → 190 taken 2027 times.
✓ Branch 190 → 191 taken 2027 times.
2027 header.listId = query.value(16).toString();
1779
2/2
✓ Branch 194 → 195 taken 2027 times.
✓ Branch 195 → 196 taken 2027 times.
2027 header.spamKnown = !query.value(19).isNull(); // Sprint 105 (v25)
1780
6/6
✓ Branch 197 → 198 taken 2027 times.
✓ Branch 198 → 199 taken 2027 times.
✓ Branch 200 → 201 taken 6 times.
✓ Branch 200 → 206 taken 2021 times.
✓ Branch 201 → 202 taken 6 times.
✓ Branch 202 → 203 taken 6 times.
2027 if (!query.value(17).isNull()) header.spamScore = query.value(17).toDouble();
1781
6/6
✓ Branch 206 → 207 taken 2027 times.
✓ Branch 207 → 208 taken 2027 times.
✓ Branch 209 → 210 taken 6 times.
✓ Branch 209 → 215 taken 2021 times.
✓ Branch 210 → 211 taken 6 times.
✓ Branch 211 → 212 taken 6 times.
2027 if (!query.value(18).isNull()) header.spamThreshold = query.value(18).toDouble();
1782
2/2
✓ Branch 215 → 216 taken 2027 times.
✓ Branch 216 → 217 taken 2027 times.
2027 header.spamTool = query.value(19).toString();
1783
1/1
✓ Branch 221 → 222 taken 2027 times.
2027 result.append(std::move(header));
1784 2027 currentOrdinal = ordinal;
1785 2027 }
1786
4/4
✓ Branch 224 → 225 taken 2033 times.
✓ Branch 225 → 226 taken 2033 times.
✓ Branch 227 → 228 taken 43 times.
✓ Branch 227 → 235 taken 1990 times.
2033 if (!query.value(14).isNull())
1787
4/4
✓ Branch 228 → 229 taken 43 times.
✓ Branch 229 → 230 taken 43 times.
✓ Branch 230 → 231 taken 43 times.
✓ Branch 231 → 232 taken 43 times.
43 result.last().labels.append(query.value(14).toString());
1788 }
1789 1637 }
1790 1634 return result;
1791 ✗ }
1792
1793 27 qint64 MailCache::maxUid(qint64 folderId) const {
1794
1/1
✓ Branch 2 → 3 taken 27 times.
27 QSqlQuery q(m_db);
1795
1/1
✓ Branch 6 → 7 taken 27 times.
27 q.prepare(
1796 54 QStringLiteral("SELECT MAX(uid) FROM headers WHERE folder_id = :fid"));
1797
1/1
✓ Branch 14 → 15 taken 27 times.
54 q.bindValue(QStringLiteral(":fid"), folderId);
1798
1799
7/8
✓ Branch 18 → 19 taken 27 times.
✓ Branch 19 → 20 taken 25 times.
✓ Branch 19 → 23 taken 2 times.
✓ Branch 20 → 21 taken 25 times.
✓ Branch 21 → 22 taken 25 times.
✗ Branch 21 → 23 not taken.
✓ Branch 24 → 25 taken 25 times.
✓ Branch 24 → 30 taken 2 times.
27 if (q.exec() && q.next()) {
1800
2/2
✓ Branch 25 → 26 taken 25 times.
✓ Branch 26 → 27 taken 25 times.
25 return q.value(0).toLongLong(); // Returns 0 if NULL (no rows)
1801 }
1802 2 return 0;
1803 27 }
1804
1805 160 int MailCache::headerCount(qint64 folderId) const {
1806
1/1
✓ Branch 2 → 3 taken 160 times.
160 QSqlQuery q(m_db);
1807
1/1
✓ Branch 6 → 7 taken 160 times.
160 q.prepare(QStringLiteral("SELECT total FROM header_counts WHERE folder_id=:fid"));
1808
1/1
✓ Branch 14 → 15 taken 160 times.
320 q.bindValue(QStringLiteral(":fid"), folderId);
1809
10/12
✓ Branch 18 → 19 taken 160 times.
✓ Branch 19 → 20 taken 147 times.
✓ Branch 19 → 30 taken 13 times.
✓ Branch 20 → 21 taken 147 times.
✓ Branch 21 → 22 taken 140 times.
✓ Branch 21 → 25 taken 7 times.
✓ Branch 22 → 23 taken 140 times.
✓ Branch 23 → 24 taken 140 times.
✓ Branch 27 → 28 taken 140 times.
✓ Branch 27 → 29 taken 7 times.
✗ Branch 77 → 78 not taken.
✗ Branch 77 → 79 not taken.
160 if (q.exec()) return q.next() ? q.value(0).toInt() : 0;
1810 // Read-only access to a pre-upgrade schema-22 cache remains supported.
1811
1/1
✓ Branch 33 → 34 taken 13 times.
13 q.prepare(
1812 26 QStringLiteral("SELECT COUNT(*) FROM headers WHERE folder_id = :fid"));
1813
1/1
✓ Branch 41 → 42 taken 13 times.
26 q.bindValue(QStringLiteral(":fid"), folderId);
1814
1815
7/8
✓ Branch 45 → 46 taken 13 times.
✓ Branch 46 → 47 taken 11 times.
✓ Branch 46 → 50 taken 2 times.
✓ Branch 47 → 48 taken 11 times.
✓ Branch 48 → 49 taken 11 times.
✗ Branch 48 → 50 not taken.
✓ Branch 51 → 52 taken 11 times.
✓ Branch 51 → 57 taken 2 times.
13 if (q.exec() && q.next()) {
1816
2/2
✓ Branch 52 → 53 taken 11 times.
✓ Branch 53 → 54 taken 11 times.
11 return q.value(0).toInt();
1817 }
1818 2 return 0;
1819 160 }
1820
1821 804 QPair<int, int> MailCache::folderCounts(qint64 folderId) const {
1822
1/1
✓ Branch 2 → 3 taken 804 times.
804 QSqlQuery q(m_db);
1823
1/1
✓ Branch 6 → 7 taken 804 times.
804 q.prepare(QStringLiteral("SELECT total,unread FROM header_counts WHERE folder_id=:fid"));
1824
1/1
✓ Branch 14 → 15 taken 804 times.
1608 q.bindValue(QStringLiteral(":fid"), folderId);
1825
2/3
✓ Branch 18 → 19 taken 804 times.
✓ Branch 19 → 20 taken 804 times.
✗ Branch 19 → 34 not taken.
804 if (q.exec())
1826
8/12
✓ Branch 20 → 21 taken 804 times.
✓ Branch 22 → 23 taken 733 times.
✓ Branch 23 → 24 taken 733 times.
✓ Branch 24 → 25 taken 733 times.
✓ Branch 25 → 26 taken 733 times.
✓ Branch 26 → 28 taken 733 times.
✓ Branch 31 → 32 taken 733 times.
✓ Branch 31 → 33 taken 71 times.
✗ Branch 90 → 91 not taken.
✗ Branch 90 → 92 not taken.
✗ Branch 95 → 96 not taken.
✗ Branch 95 → 97 not taken.
2341 return q.next() ? qMakePair(q.value(0).toInt(), q.value(1).toInt())
1827
5/5
✓ Branch 21 → 22 taken 733 times.
✓ Branch 21 → 27 taken 71 times.
✓ Branch 27 → 28 taken 71 times.
✓ Branch 29 → 30 taken 733 times.
✓ Branch 29 → 31 taken 71 times.
1608 : qMakePair(0, 0);
1828 // Preserve the legacy read-only fallback for a pre-aggregate cache.
1829 ✗ q.prepare(QStringLiteral("SELECT count(*),coalesce(sum((flags & 1)=0),0) "
1830 "FROM headers WHERE folder_id=:fid"));
1831 ✗ q.bindValue(QStringLiteral(":fid"), folderId);
1832 ✗ if (q.exec() && q.next())
1833 ✗ return {q.value(0).toInt(), q.value(1).toInt()};
1834 ✗ return {0, 0};
1835 804 }
1836
1837 // --- Body operations ---
1838
1839 761 qint64 MailCache::headerRowId(qint64 folderId, qint64 uid) const {
1840
1/1
✓ Branch 2 → 3 taken 761 times.
761 QSqlQuery q(m_db);
1841
1/1
✓ Branch 6 → 7 taken 761 times.
761 q.prepare(QStringLiteral(
1842 "SELECT id FROM headers WHERE folder_id = :fid AND uid = :uid"));
1843
1/1
✓ Branch 14 → 15 taken 761 times.
1522 q.bindValue(QStringLiteral(":fid"), folderId);
1844
1/1
✓ Branch 23 → 24 taken 761 times.
1522 q.bindValue(QStringLiteral(":uid"), uid);
1845
1846
8/8
✓ Branch 27 → 28 taken 761 times.
✓ Branch 28 → 29 taken 750 times.
✓ Branch 28 → 32 taken 11 times.
✓ Branch 29 → 30 taken 750 times.
✓ Branch 30 → 31 taken 747 times.
✓ Branch 30 → 32 taken 3 times.
✓ Branch 33 → 34 taken 747 times.
✓ Branch 33 → 39 taken 14 times.
761 if (q.exec() && q.next()) {
1847
2/2
✓ Branch 34 → 35 taken 747 times.
✓ Branch 35 → 36 taken 747 times.
747 return q.value(0).toLongLong();
1848 }
1849 14 return -1;
1850 761 }
1851
1852 15384 bool MailCache::storeBody(qint64 folderId, qint64 uid, const MailBody &body) {
1853 15384 return storeBodyPayload(folderId, uid, body, nullptr, nullptr);
1854 }
1855
1856 384 bool MailCache::storeMessageContent(qint64 folderId, qint64 uid,
1857 const MailBody &body,
1858 const QList<Attachment> &attachments,
1859 const QList<QByteArray> &blobs) {
1860
2/2
✓ Branch 4 → 5 taken 5 times.
✓ Branch 4 → 12 taken 379 times.
384 if (attachments.size() != blobs.size()) {
1861 5 m_lastError = QStringLiteral("Attachment/BLOB count mismatch");
1862 5 return false;
1863 }
1864 379 return storeBodyPayload(folderId, uid, body, &attachments, &blobs);
1865 }
1866
1867 30 bool MailCache::storeMessageContentForIdentity(const MailId &mailId,
1868 quint32 uidValidity, const MailBody &body, const QList<Attachment> &attachments,
1869 const QList<QByteArray> &blobs) {
1870
3/6
✓ Branch 3 → 4 taken 30 times.
✗ Branch 3 → 7 not taken.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 30 times.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 17 taken 30 times.
30 if (!mailId.isValid() || attachments.size() != blobs.size()) {
1871 ✗ m_lastError = QStringLiteral("Invalid payload identity or attachment count");
1872 ✗ return false;
1873 }
1874 30 return storeBodyPayload(mailId.folderId, mailId.uid, body, &attachments, &blobs,
1875 30 &mailId, uidValidity);
1876 }
1877
1878 15793 bool MailCache::storeBodyPayload(qint64 folderId, qint64 uid, const MailBody &body,
1879 const QList<Attachment> *attachments,
1880 const QList<QByteArray> *blobs,
1881 const MailId *expectedId, quint32 expectedValidity) {
1882 15793 m_lastError.clear();
1883
3/3
✓ Branch 3 → 4 taken 15793 times.
✓ Branch 4 → 5 taken 4 times.
✓ Branch 4 → 6 taken 15789 times.
15793 if (!beginWriteTransaction(m_db, &m_lastError)) {
1884 4 return false;
1885 }
1886 31601 auto rollback = qScopeGuard([this]() {
1887 23 m_db.rollback();
1888 23 m_payloadLimits.refreshPayloadCacheBytes();
1889 15789 });
1890
2/2
✓ Branch 7 → 8 taken 29 times.
✓ Branch 7 → 71 taken 15760 times.
15789 if (expectedId) {
1891
1/1
✓ Branch 8 → 9 taken 29 times.
29 QSqlQuery identity(m_db);
1892
1/1
✓ Branch 12 → 13 taken 29 times.
29 identity.prepare(QStringLiteral("SELECT 1 FROM folders WHERE id=:fid "
1893 "AND account=:account AND uidvalidity=:validity"));
1894
1/1
✓ Branch 20 → 21 taken 29 times.
58 identity.bindValue(QStringLiteral(":fid"), expectedId->folderId);
1895
1/1
✓ Branch 29 → 30 taken 29 times.
58 identity.bindValue(QStringLiteral(":account"), expectedId->accountId);
1896
1/1
✓ Branch 38 → 39 taken 29 times.
58 identity.bindValue(QStringLiteral(":validity"), expectedValidity);
1897
7/8
✓ Branch 42 → 43 taken 29 times.
✓ Branch 43 → 44 taken 29 times.
✗ Branch 43 → 46 not taken.
✓ Branch 44 → 45 taken 29 times.
✓ Branch 45 → 46 taken 3 times.
✓ Branch 45 → 47 taken 26 times.
✓ Branch 48 → 49 taken 3 times.
✓ Branch 48 → 65 taken 26 times.
29 if (!identity.exec() || !identity.next()) {
1898
3/8
✓ Branch 49 → 50 taken 3 times.
✓ Branch 50 → 51 taken 3 times.
✗ Branch 51 → 52 not taken.
✓ Branch 51 → 54 taken 3 times.
✗ Branch 52 → 53 not taken.
✗ Branch 53 → 57 not taken.
✗ Branch 380 → 381 not taken.
✗ Branch 380 → 382 not taken.
12 m_lastError = identity.lastError().isValid() ? identity.lastError().text()
1899
2/6
✓ Branch 59 → 60 taken 3 times.
✗ Branch 59 → 61 not taken.
✗ Branch 61 → 62 not taken.
✓ Branch 61 → 63 taken 3 times.
✗ Branch 376 → 377 not taken.
✗ Branch 376 → 378 not taken.
6 : QStringLiteral("Mailbox identity changed before payload persistence");
1900 3 return false;
1901 }
1902 29 }
1903
1/1
✓ Branch 71 → 72 taken 15786 times.
15786 QSqlQuery projection(m_db);
1904
1/1
✓ Branch 75 → 76 taken 15786 times.
15786 projection.prepare(QStringLiteral(
1905 "SELECT h.id, h.subject, h.from_addr, h.to_addr, "
1906 "COALESCE(length(CAST(b.text_plain AS BLOB)), 0) + "
1907 "COALESCE(length(CAST(b.text_html AS BLOB)), 0) + "
1908 "COALESCE(length(b.raw_body), 0) "
1909 "FROM headers h LEFT JOIN bodies b ON b.header_id = h.id "
1910 "WHERE h.folder_id = :fid AND h.uid = :uid"));
1911
1/1
✓ Branch 83 → 84 taken 15786 times.
31572 projection.bindValue(QStringLiteral(":fid"), folderId);
1912
1/1
✓ Branch 92 → 93 taken 15786 times.
31572 projection.bindValue(QStringLiteral(":uid"), uid);
1913
8/8
✓ Branch 96 → 97 taken 15786 times.
✓ Branch 97 → 98 taken 15784 times.
✓ Branch 97 → 100 taken 2 times.
✓ Branch 98 → 99 taken 15784 times.
✓ Branch 99 → 100 taken 1 time.
✓ Branch 99 → 101 taken 15783 times.
✓ Branch 102 → 103 taken 3 times.
✓ Branch 102 → 130 taken 15783 times.
15786 if (!projection.exec() || !projection.next()) {
1914
6/8
✓ Branch 103 → 104 taken 3 times.
✓ Branch 104 → 105 taken 3 times.
✓ Branch 105 → 106 taken 2 times.
✓ Branch 105 → 108 taken 1 time.
✓ Branch 106 → 107 taken 2 times.
✓ Branch 107 → 111 taken 2 times.
✗ Branch 421 → 422 not taken.
✗ Branch 421 → 423 not taken.
10 m_lastError = projection.lastError().isValid() ? projection.lastError().text()
1915
4/6
✓ Branch 113 → 114 taken 1 time.
✓ Branch 113 → 115 taken 2 times.
✓ Branch 115 → 116 taken 2 times.
✓ Branch 115 → 117 taken 1 time.
✗ Branch 417 → 418 not taken.
✗ Branch 417 → 419 not taken.
6 : QStringLiteral("Message header no longer exists");
1916
6/6
✓ Branch 118 → 119 taken 3 times.
✓ Branch 122 → 123 taken 3 times.
✓ Branch 123 → 124 taken 3 times.
✓ Branch 124 → 125 taken 3 times.
✓ Branch 128 → 120 taken 3 times.
✓ Branch 128 → 129 taken 3 times.
6 qCWarning(lcCache) << "Cannot store body: header not found for UID" << uid;
1917 3 return false;
1918 }
1919
1920
2/2
✓ Branch 130 → 131 taken 15783 times.
✓ Branch 131 → 132 taken 15783 times.
15783 const qint64 hid = projection.value(0).toLongLong();
1921 SearchDocument document{hid,
1922 ✗ projection.value(1).toString(),
1923 15783 projection.value(2).toString(),
1924 15783 projection.value(3).toString(),
1925 15783 body.textPlain,
1926
6/10
✓ Branch 133 → 134 taken 15783 times.
✓ Branch 134 → 135 taken 15783 times.
✓ Branch 135 → 136 taken 15783 times.
✓ Branch 136 → 137 taken 15783 times.
✓ Branch 137 → 138 taken 15783 times.
✓ Branch 138 → 139 taken 15783 times.
✗ Branch 440 → 441 not taken.
✗ Branch 440 → 442 not taken.
✗ Branch 446 → 447 not taken.
✗ Branch 446 → 448 not taken.
15783 body.textHtml};
1927
2/2
✓ Branch 144 → 145 taken 15783 times.
✓ Branch 145 → 146 taken 15783 times.
15783 qint64 previousBytes = projection.value(4).toLongLong();
1928
1/1
✓ Branch 147 → 148 taken 15783 times.
15783 projection.finish();
1929
1930 15783 qint64 currentBytes = utf8ByteSize(body.textPlain) +
1931 15783 utf8ByteSize(body.textHtml) +
1932 15783 body.rawSource.size();
1933
2/2
✓ Branch 153 → 154 taken 404 times.
✓ Branch 153 → 210 taken 15379 times.
15783 if (blobs) {
1934
1/1
✓ Branch 154 → 155 taken 404 times.
404 QSqlQuery previous(m_db);
1935
1/1
✓ Branch 158 → 159 taken 404 times.
404 previous.prepare(QStringLiteral(
1936 "SELECT COALESCE(SUM(length(data)), 0) FROM attachments WHERE header_id = :hid"));
1937
1/1
✓ Branch 166 → 167 taken 404 times.
808 previous.bindValue(QStringLiteral(":hid"), hid);
1938
5/8
✓ Branch 170 → 171 taken 404 times.
✓ Branch 171 → 172 taken 404 times.
✗ Branch 171 → 174 not taken.
✓ Branch 172 → 173 taken 404 times.
✗ Branch 173 → 174 not taken.
✓ Branch 173 → 175 taken 404 times.
✗ Branch 176 → 177 not taken.
✓ Branch 176 → 183 taken 404 times.
404 if (!previous.exec() || !previous.next()) {
1939 ✗ m_lastError = previous.lastError().text();
1940 ✗ return false;
1941 }
1942
2/2
✓ Branch 183 → 184 taken 404 times.
✓ Branch 184 → 185 taken 404 times.
404 previousBytes += previous.value(0).toLongLong();
1943
1/1
✓ Branch 186 → 187 taken 404 times.
404 previous.finish();
1944
2/2
✓ Branch 203 → 189 taken 1155 times.
✓ Branch 203 → 204 taken 404 times.
1559 for (const auto &blob : *blobs) {
1945
1/2
✗ Branch 192 → 193 not taken.
✓ Branch 192 → 200 taken 1155 times.
1155 if (blob.size() > std::numeric_limits<qint64>::max() - currentBytes) {
1946 ✗ m_lastError = QStringLiteral("Message payload size overflow");
1947 ✗ return false;
1948 }
1949 1155 currentBytes += blob.size();
1950 }
1951 404 }
1952 15783 const qint64 additionalBytes = qMax<qint64>(0, currentBytes - previousBytes);
1953
3/3
✓ Branch 211 → 212 taken 15783 times.
✓ Branch 212 → 213 taken 4 times.
✓ Branch 212 → 234 taken 15779 times.
15783 if (!m_payloadLimits.enforcePayloadCacheLimitInTransaction(hid, additionalBytes)) {
1954 4 m_lastError = QStringLiteral("Body exceeds the available payload cache budget");
1955
5/5
✓ Branch 219 → 220 taken 4 times.
✓ Branch 223 → 224 taken 4 times.
✓ Branch 224 → 225 taken 4 times.
✓ Branch 232 → 221 taken 4 times.
✓ Branch 232 → 233 taken 4 times.
8 qCWarning(lcCache) << "Refusing to store body above payload cache budget"
1956
4/4
✓ Branch 225 → 226 taken 4 times.
✓ Branch 226 → 227 taken 4 times.
✓ Branch 227 → 228 taken 4 times.
✓ Branch 228 → 229 taken 4 times.
4 << "for UID" << uid << "bytes" << currentBytes;
1957 4 return false;
1958 }
1959
1960
1/1
✓ Branch 234 → 235 taken 15779 times.
15779 QSqlQuery q(m_db);
1961
1/1
✓ Branch 238 → 239 taken 15779 times.
15779 q.prepare(QStringLiteral(
1962 "INSERT INTO bodies (header_id, text_plain, text_html, raw_body, "
1963 "fetched_at) VALUES (:hid, :plain, :html, :raw, :now) "
1964 "ON CONFLICT(header_id) DO UPDATE SET text_plain=excluded.text_plain, "
1965 "text_html=excluded.text_html, raw_body=excluded.raw_body, "
1966 "fetched_at=excluded.fetched_at"));
1967
1/1
✓ Branch 246 → 247 taken 15779 times.
31558 q.bindValue(QStringLiteral(":hid"), hid);
1968
1/1
✓ Branch 255 → 256 taken 15779 times.
31558 q.bindValue(QStringLiteral(":plain"), body.textPlain);
1969
1/1
✓ Branch 264 → 265 taken 15779 times.
31558 q.bindValue(QStringLiteral(":html"), body.textHtml);
1970
1/1
✓ Branch 273 → 274 taken 15779 times.
31558 q.bindValue(QStringLiteral(":raw"), body.rawSource);
1971
1/1
✓ Branch 283 → 284 taken 15779 times.
31558 q.bindValue(QStringLiteral(":now"), QDateTime::currentMSecsSinceEpoch());
1972
1973
3/3
✓ Branch 287 → 288 taken 15779 times.
✓ Branch 288 → 289 taken 6 times.
✓ Branch 288 → 306 taken 15773 times.
15779 if (!q.exec()) {
1974
2/2
✓ Branch 289 → 290 taken 6 times.
✓ Branch 290 → 291 taken 6 times.
6 m_lastError = q.lastError().text();
1975
6/6
✓ Branch 294 → 295 taken 6 times.
✓ Branch 298 → 299 taken 6 times.
✓ Branch 299 → 300 taken 6 times.
✓ Branch 300 → 301 taken 6 times.
✓ Branch 304 → 296 taken 6 times.
✓ Branch 304 → 305 taken 6 times.
12 qCWarning(lcCache) << "Failed to store body:" << m_lastError;
1976 6 return false;
1977 } else {
1978 15773 ++m_searchIndex.diagnosticCounters().requests;
1979
5/7
✓ Branch 309 → 310 taken 15773 times.
✓ Branch 312 → 313 taken 15773 times.
✓ Branch 312 → 314 taken 15773 times.
✓ Branch 314 → 315 taken 3 times.
✓ Branch 314 → 316 taken 15770 times.
✗ Branch 554 → 555 not taken.
✗ Branch 554 → 556 not taken.
31546 if (!m_searchIndex.replaceSearchDocuments({document})) {
1980 3 return false;
1981 }
1982
7/7
✓ Branch 316 → 317 taken 398 times.
✓ Branch 316 → 320 taken 15372 times.
✓ Branch 317 → 318 taken 398 times.
✓ Branch 318 → 319 taken 4 times.
✓ Branch 318 → 320 taken 394 times.
✓ Branch 321 → 322 taken 4 times.
✓ Branch 321 → 323 taken 15766 times.
15770 if (attachments && !m_attachments.replaceAttachmentsInTransaction(hid, *attachments, *blobs)) {
1983 4 return false;
1984 }
1985
2/3
✓ Branch 323 → 324 taken 15766 times.
✗ Branch 324 → 325 not taken.
✓ Branch 324 → 331 taken 15766 times.
15766 if (!m_db.commit()) {
1986 ✗ m_lastError = m_db.lastError().text();
1987 ✗ return false;
1988 }
1989 15766 rollback.dismiss();
1990
1/1
✓ Branch 332 → 333 taken 15766 times.
15766 m_payloadLimits.refreshPayloadCacheBytes();
1991 }
1992 15766 return true;
1993 31562 }
1994
1995 160 std::optional<MailBody> MailCache::body(qint64 folderId, qint64 uid) const {
1996
1/1
✓ Branch 2 → 3 taken 160 times.
160 QSqlQuery q(m_db);
1997
1/1
✓ Branch 6 → 7 taken 160 times.
160 q.prepare(QStringLiteral(
1998 "SELECT b.text_plain, b.text_html, b.raw_body, f.uidvalidity, f.account FROM bodies b "
1999 "JOIN headers h ON b.header_id = h.id JOIN folders f ON f.id=h.folder_id "
2000 "WHERE h.folder_id = :fid AND h.uid = :uid"));
2001
1/1
✓ Branch 14 → 15 taken 160 times.
320 q.bindValue(QStringLiteral(":fid"), folderId);
2002
1/1
✓ Branch 23 → 24 taken 160 times.
320 q.bindValue(QStringLiteral(":uid"), uid);
2003
2004
8/8
✓ Branch 27 → 28 taken 160 times.
✓ Branch 28 → 29 taken 158 times.
✓ Branch 28 → 32 taken 2 times.
✓ Branch 29 → 30 taken 158 times.
✓ Branch 30 → 31 taken 136 times.
✓ Branch 30 → 32 taken 22 times.
✓ Branch 33 → 34 taken 136 times.
✓ Branch 33 → 60 taken 24 times.
160 if (q.exec() && q.next()) {
2005 136 MailBody b;
2006 136 b.uid = uid;
2007
2/2
✓ Branch 34 → 35 taken 136 times.
✓ Branch 35 → 36 taken 136 times.
136 b.textPlain = q.value(0).toString();
2008
2/2
✓ Branch 39 → 40 taken 136 times.
✓ Branch 40 → 41 taken 136 times.
136 b.textHtml = q.value(1).toString();
2009
2/2
✓ Branch 44 → 45 taken 136 times.
✓ Branch 45 → 46 taken 136 times.
136 b.rawSource = q.value(2).toByteArray();
2010
2/2
✓ Branch 49 → 50 taken 136 times.
✓ Branch 50 → 51 taken 136 times.
136 b.cacheUidValidity = q.value(3).toUInt();
2011
2/2
✓ Branch 52 → 53 taken 136 times.
✓ Branch 53 → 54 taken 136 times.
136 b.cacheAccountId = q.value(4).toString();
2012 136 return b;
2013 136 }
2014 24 return std::nullopt;
2015 160 }
2016
2017 271 std::optional<MailBody> MailCache::replySnapshot(qint64 folderId,
2018 qint64 uid) const {
2019 // P90.14: reply composition needs the envelope header block plus the
2020 // plain text — never the complete raw source. The bounded prefix covers
2021 // the header block of any realistic MIME message; call sites fall back to
2022 // header-row fields when a pathological message exceeds it.
2023
1/1
✓ Branch 2 → 3 taken 271 times.
271 QSqlQuery q(m_db);
2024
1/1
✓ Branch 6 → 7 taken 271 times.
271 q.prepare(QStringLiteral(
2025 "SELECT substr(b.text_plain,1,:chars), substr(CAST(b.raw_body AS BLOB),1,65536), f.uidvalidity, f.account FROM bodies b "
2026 "JOIN headers h ON b.header_id = h.id JOIN folders f ON f.id=h.folder_id "
2027 "WHERE h.folder_id = :fid AND h.uid = :uid"));
2028
1/1
✓ Branch 14 → 15 taken 271 times.
542 q.bindValue(QStringLiteral(":chars"), QuoteTextCharacters);
2029
1/1
✓ Branch 23 → 24 taken 271 times.
542 q.bindValue(QStringLiteral(":fid"), folderId);
2030
1/1
✓ Branch 32 → 33 taken 271 times.
542 q.bindValue(QStringLiteral(":uid"), uid);
2031
7/8
✓ Branch 36 → 37 taken 271 times.
✓ Branch 37 → 38 taken 271 times.
✗ Branch 37 → 41 not taken.
✓ Branch 38 → 39 taken 271 times.
✓ Branch 39 → 40 taken 267 times.
✓ Branch 39 → 41 taken 4 times.
✓ Branch 42 → 43 taken 267 times.
✓ Branch 42 → 64 taken 4 times.
271 if (q.exec() && q.next()) {
2032 267 MailBody b;
2033 267 b.uid = uid;
2034
2/2
✓ Branch 43 → 44 taken 267 times.
✓ Branch 44 → 45 taken 267 times.
267 b.textPlain = q.value(0).toString();
2035
2/2
✓ Branch 48 → 49 taken 267 times.
✓ Branch 49 → 50 taken 267 times.
267 b.rawSource = q.value(1).toByteArray();
2036
2/2
✓ Branch 53 → 54 taken 267 times.
✓ Branch 54 → 55 taken 267 times.
267 b.cacheUidValidity = q.value(2).toUInt();
2037
2/2
✓ Branch 56 → 57 taken 267 times.
✓ Branch 57 → 58 taken 267 times.
267 b.cacheAccountId = q.value(3).toString();
2038 267 return b;
2039 267 }
2040 4 return std::nullopt;
2041 271 }
2042
2043 10 std::optional<MailBody> MailCache::displayBody(qint64 folderId,
2044 qint64 uid) const {
2045
1/1
✓ Branch 2 → 3 taken 10 times.
10 QSqlQuery q(m_db);
2046
1/1
✓ Branch 6 → 7 taken 10 times.
10 q.prepare(QStringLiteral(
2047 "SELECT substr(b.text_plain,1,:chars), substr(b.text_html,1,:chars), f.uidvalidity, f.account FROM bodies b "
2048 "JOIN headers h ON b.header_id = h.id JOIN folders f ON f.id=h.folder_id "
2049 "WHERE h.folder_id = :fid AND h.uid = :uid"));
2050
1/1
✓ Branch 14 → 15 taken 10 times.
20 q.bindValue(QStringLiteral(":chars"), QuoteTextCharacters);
2051
1/1
✓ Branch 23 → 24 taken 10 times.
20 q.bindValue(QStringLiteral(":fid"), folderId);
2052
1/1
✓ Branch 32 → 33 taken 10 times.
20 q.bindValue(QStringLiteral(":uid"), uid);
2053
2054
8/8
✓ Branch 36 → 37 taken 10 times.
✓ Branch 37 → 38 taken 8 times.
✓ Branch 37 → 41 taken 2 times.
✓ Branch 38 → 39 taken 8 times.
✓ Branch 39 → 40 taken 7 times.
✓ Branch 39 → 41 taken 1 time.
✓ Branch 42 → 43 taken 7 times.
✓ Branch 42 → 64 taken 3 times.
10 if (q.exec() && q.next()) {
2055 7 MailBody b;
2056 7 b.uid = uid;
2057
2/2
✓ Branch 43 → 44 taken 7 times.
✓ Branch 44 → 45 taken 7 times.
7 b.textPlain = q.value(0).toString();
2058
2/2
✓ Branch 48 → 49 taken 7 times.
✓ Branch 49 → 50 taken 7 times.
7 b.textHtml = q.value(1).toString();
2059
2/2
✓ Branch 53 → 54 taken 7 times.
✓ Branch 54 → 55 taken 7 times.
7 b.cacheUidValidity = q.value(2).toUInt();
2060
2/2
✓ Branch 56 → 57 taken 7 times.
✓ Branch 57 → 58 taken 7 times.
7 b.cacheAccountId = q.value(3).toString();
2061 7 return b;
2062 7 }
2063 3 return std::nullopt;
2064 10 }
2065
2066 22 QByteArray MailCache::rawBody(qint64 folderId, qint64 uid) const {
2067
1/1
✓ Branch 2 → 3 taken 22 times.
22 QSqlQuery q(m_db);
2068
1/1
✓ Branch 6 → 7 taken 22 times.
22 q.prepare(QStringLiteral(
2069 "SELECT b.raw_body FROM bodies b "
2070 "JOIN headers h ON b.header_id = h.id "
2071 "WHERE h.folder_id = :fid AND h.uid = :uid"));
2072
1/1
✓ Branch 14 → 15 taken 22 times.
44 q.bindValue(QStringLiteral(":fid"), folderId);
2073
1/1
✓ Branch 23 → 24 taken 22 times.
44 q.bindValue(QStringLiteral(":uid"), uid);
2074
10/12
✓ Branch 27 → 28 taken 22 times.
✓ Branch 28 → 29 taken 20 times.
✓ Branch 28 → 34 taken 2 times.
✓ Branch 29 → 30 taken 20 times.
✓ Branch 30 → 31 taken 19 times.
✓ Branch 30 → 34 taken 1 time.
✓ Branch 31 → 32 taken 19 times.
✓ Branch 32 → 33 taken 19 times.
✓ Branch 36 → 37 taken 19 times.
✓ Branch 36 → 38 taken 3 times.
✗ Branch 67 → 68 not taken.
✗ Branch 67 → 69 not taken.
66 return q.exec() && q.next() ? q.value(0).toByteArray() : QByteArray{};
2075 22 }
2076
2077 5 bool MailCache::hasRawBody(qint64 folderId, qint64 uid) const {
2078
1/1
✓ Branch 2 → 3 taken 5 times.
5 QSqlQuery q(m_db);
2079
1/1
✓ Branch 6 → 7 taken 5 times.
5 q.prepare(QStringLiteral(
2080 "SELECT 1 FROM bodies b "
2081 "JOIN headers h ON b.header_id = h.id "
2082 "WHERE h.folder_id = :fid AND h.uid = :uid "
2083 "AND length(COALESCE(b.raw_body, X'')) > 0"));
2084
1/1
✓ Branch 14 → 15 taken 5 times.
10 q.bindValue(QStringLiteral(":fid"), folderId);
2085
1/1
✓ Branch 23 → 24 taken 5 times.
10 q.bindValue(QStringLiteral(":uid"), uid);
2086
6/6
✓ Branch 27 → 28 taken 5 times.
✓ Branch 28 → 29 taken 3 times.
✓ Branch 28 → 32 taken 2 times.
✓ Branch 29 → 30 taken 3 times.
✓ Branch 30 → 31 taken 1 time.
✓ Branch 30 → 32 taken 2 times.
10 return q.exec() && q.next();
2087 5 }
2088
2089 51 bool MailCache::hasBody(qint64 folderId, qint64 uid) const {
2090
1/1
✓ Branch 2 → 3 taken 51 times.
51 QSqlQuery q(m_db);
2091
1/1
✓ Branch 6 → 7 taken 51 times.
51 q.prepare(QStringLiteral("SELECT 1 FROM bodies b "
2092 "JOIN headers h ON b.header_id = h.id "
2093 "WHERE h.folder_id = :fid AND h.uid = :uid"));
2094
1/1
✓ Branch 14 → 15 taken 51 times.
102 q.bindValue(QStringLiteral(":fid"), folderId);
2095
1/1
✓ Branch 23 → 24 taken 51 times.
102 q.bindValue(QStringLiteral(":uid"), uid);
2096
2097
6/6
✓ Branch 27 → 28 taken 51 times.
✓ Branch 28 → 29 taken 49 times.
✓ Branch 28 → 32 taken 2 times.
✓ Branch 29 → 30 taken 49 times.
✓ Branch 30 → 31 taken 18 times.
✓ Branch 30 → 32 taken 31 times.
102 return q.exec() && q.next();
2098 51 }
2099
2100 // --- Flag operations ---
2101
2102 133 void MailCache::updateFlags(qint64 folderId, qint64 uid, quint32 flags) {
2103 133 const auto writeMutex = m_writeMutex;
2104 133 QMutexLocker publication(writeMutex.get());
2105 133 m_lastError.clear();
2106
1/1
✓ Branch 6 → 7 taken 133 times.
133 ScopedWriteUnit unit(m_db, &m_lastError);
2107
2/2
✓ Branch 8 → 9 taken 2 times.
✓ Branch 8 → 10 taken 131 times.
133 if (!unit.started()) return;
2108
3/3
✓ Branch 10 → 11 taken 131 times.
✓ Branch 11 → 12 taken 2 times.
✓ Branch 11 → 13 taken 129 times.
131 if (!writeFlags(folderId, uid, flags)) return; // the unit rolls back
2109
2/3
✓ Branch 13 → 14 taken 129 times.
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 129 times.
129 if (!unit.commit(&m_lastError)) return;
2110
1/1
✓ Branch 16 → 17 taken 129 times.
129 emit headerFlagsChanged(folderId, uid, flags);
2111 141 }
2112
2113 // The flag write of updateFlags() without publication, for callers inside
2114 // their own transaction; they emit headerFlagsChanged after their commit
2115 // (S106-110).
2116 289 bool MailCache::writeFlags(qint64 folderId, qint64 uid, quint32 flags) {
2117
1/1
✓ Branch 2 → 3 taken 289 times.
289 QSqlQuery previous(m_db);
2118
1/1
✓ Branch 6 → 7 taken 289 times.
289 previous.prepare(QStringLiteral("SELECT flags FROM headers WHERE folder_id=:fid AND uid=:uid"));
2119
1/1
✓ Branch 14 → 15 taken 289 times.
578 previous.bindValue(QStringLiteral(":fid"), folderId);
2120
1/1
✓ Branch 23 → 24 taken 289 times.
578 previous.bindValue(QStringLiteral(":uid"), uid);
2121
3/3
✓ Branch 27 → 28 taken 289 times.
✓ Branch 28 → 29 taken 1 time.
✓ Branch 28 → 35 taken 288 times.
289 if (!previous.exec()) {
2122
2/2
✓ Branch 29 → 30 taken 1 time.
✓ Branch 30 → 31 taken 1 time.
1 m_lastError = previous.lastError().text();
2123 1 return false;
2124 }
2125
7/9
✓ Branch 35 → 36 taken 288 times.
✓ Branch 36 → 37 taken 287 times.
✓ Branch 36 → 40 taken 1 time.
✓ Branch 37 → 38 taken 287 times.
✓ Branch 38 → 39 taken 287 times.
✓ Branch 41 → 42 taken 287 times.
✓ Branch 41 → 43 taken 1 time.
✗ Branch 144 → 145 not taken.
✗ Branch 144 → 146 not taken.
288 const quint32 oldFlags = previous.next() ? previous.value(0).toUInt() : 0;
2126
1/1
✓ Branch 43 → 44 taken 288 times.
288 previous.finish();
2127
1/1
✓ Branch 44 → 45 taken 288 times.
288 QSqlQuery q(m_db);
2128
1/1
✓ Branch 48 → 49 taken 288 times.
288 q.prepare(QStringLiteral("UPDATE headers SET flags = :flags "
2129 "WHERE folder_id = :fid AND uid = :uid"));
2130
1/1
✓ Branch 56 → 57 taken 288 times.
576 q.bindValue(QStringLiteral(":flags"), flags);
2131
1/1
✓ Branch 65 → 66 taken 288 times.
576 q.bindValue(QStringLiteral(":fid"), folderId);
2132
1/1
✓ Branch 74 → 75 taken 288 times.
576 q.bindValue(QStringLiteral(":uid"), uid);
2133
2134
3/3
✓ Branch 78 → 79 taken 288 times.
✓ Branch 79 → 80 taken 1 time.
✓ Branch 79 → 97 taken 287 times.
288 if (!q.exec()) {
2135
2/2
✓ Branch 80 → 81 taken 1 time.
✓ Branch 81 → 82 taken 1 time.
1 m_lastError = q.lastError().text();
2136
6/6
✓ Branch 85 → 86 taken 1 time.
✓ Branch 89 → 90 taken 1 time.
✓ Branch 90 → 91 taken 1 time.
✓ Branch 91 → 92 taken 1 time.
✓ Branch 95 → 87 taken 1 time.
✓ Branch 95 → 96 taken 1 time.
2 qCWarning(lcCache) << "Failed to update flags:" << m_lastError;
2137 1 return false;
2138 }
2139 // P90.3: keep derived unread aggregates exact without a topology rebuild.
2140 // The revision bump itself comes from the thread_flags_au trigger.
2141
2/2
✓ Branch 97 → 98 taken 208 times.
✓ Branch 97 → 105 taken 79 times.
495 return ((oldFlags ^ flags) & MailFlag::Seen) == 0 ||
2142
7/10
✓ Branch 98 → 99 taken 173 times.
✓ Branch 98 → 100 taken 35 times.
✓ Branch 102 → 103 taken 208 times.
✓ Branch 103 → 104 taken 208 times.
✓ Branch 104 → 105 taken 208 times.
✗ Branch 104 → 106 not taken.
✓ Branch 107 → 108 taken 208 times.
✓ Branch 107 → 109 taken 79 times.
✗ Branch 193 → 194 not taken.
✗ Branch 193 → 195 not taken.
495 adjustThreadUnreadAggregates(folderId, {{uid, (flags & MailFlag::Seen) ? -1 : 1}});
2143 289 }
2144
2145 15 void MailCache::batchUpdateFlags(
2146 qint64 folderId, const QList<QPair<qint64, quint32>> &uidFlags) {
2147 15 const auto writeMutex = m_writeMutex;
2148 15 QMutexLocker publication(writeMutex.get());
2149
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 15 times.
15 if (uidFlags.isEmpty())
2150 ✗ return;
2151
2152 15 m_lastError.clear();
2153
1/1
✓ Branch 9 → 10 taken 15 times.
15 ScopedWriteUnit unit(m_db, &m_lastError);
2154
2/2
✓ Branch 11 → 12 taken 2 times.
✓ Branch 11 → 13 taken 13 times.
15 if (!unit.started()) return;
2155
2156
1/1
✓ Branch 13 → 14 taken 13 times.
13 QSqlQuery previous(m_db);
2157
1/1
✓ Branch 17 → 18 taken 13 times.
13 previous.prepare(QStringLiteral("SELECT flags FROM headers WHERE folder_id=:fid AND uid=:uid"));
2158
1/1
✓ Branch 20 → 21 taken 13 times.
13 QSqlQuery q(m_db);
2159
1/1
✓ Branch 24 → 25 taken 13 times.
13 q.prepare(QStringLiteral("UPDATE headers SET flags = :flags "
2160 "WHERE folder_id = :fid AND uid = :uid"));
2161 13 QList<QPair<qint64, int>> unreadDeltas;
2162
2163
2/2
✓ Branch 111 → 29 taken 2066 times.
✓ Branch 111 → 112 taken 11 times.
2077 for (const auto &[uid, flags] : uidFlags) {
2164
1/1
✓ Branch 37 → 38 taken 2066 times.
4132 previous.bindValue(QStringLiteral(":fid"), folderId);
2165
1/1
✓ Branch 46 → 47 taken 2066 times.
4132 previous.bindValue(QStringLiteral(":uid"), uid);
2166
3/3
✓ Branch 50 → 51 taken 2066 times.
✓ Branch 51 → 52 taken 1 time.
✓ Branch 51 → 58 taken 2065 times.
2066 if (!previous.exec()) {
2167
2/2
✓ Branch 52 → 53 taken 1 time.
✓ Branch 53 → 54 taken 1 time.
1 m_lastError = previous.lastError().text();
2168 2 return;
2169 }
2170
5/9
✓ Branch 58 → 59 taken 2065 times.
✓ Branch 59 → 60 taken 2065 times.
✗ Branch 59 → 63 not taken.
✓ Branch 60 → 61 taken 2065 times.
✓ Branch 61 → 62 taken 2065 times.
✓ Branch 64 → 65 taken 2065 times.
✗ Branch 64 → 66 not taken.
✗ Branch 205 → 206 not taken.
✗ Branch 205 → 207 not taken.
2065 const quint32 oldFlags = previous.next() ? previous.value(0).toUInt() : 0;
2171
1/1
✓ Branch 66 → 67 taken 2065 times.
2065 previous.finish();
2172
1/1
✓ Branch 72 → 73 taken 2065 times.
4130 q.bindValue(QStringLiteral(":flags"), flags);
2173
1/1
✓ Branch 81 → 82 taken 2065 times.
4130 q.bindValue(QStringLiteral(":fid"), folderId);
2174
1/1
✓ Branch 90 → 91 taken 2065 times.
4130 q.bindValue(QStringLiteral(":uid"), uid);
2175
3/3
✓ Branch 94 → 95 taken 2065 times.
✓ Branch 95 → 96 taken 1 time.
✓ Branch 95 → 102 taken 2064 times.
2065 if (!q.exec()) {
2176
2/2
✓ Branch 96 → 97 taken 1 time.
✓ Branch 97 → 98 taken 1 time.
1 m_lastError = q.lastError().text();
2177 1 return;
2178 }
2179
2/2
✓ Branch 102 → 103 taken 2061 times.
✓ Branch 102 → 109 taken 3 times.
2064 if (((oldFlags ^ flags) & MailFlag::Seen) != 0)
2180
3/3
✓ Branch 103 → 104 taken 2059 times.
✓ Branch 103 → 105 taken 2 times.
✓ Branch 107 → 108 taken 2061 times.
2061 unreadDeltas.append({uid, (flags & MailFlag::Seen) ? -1 : 1});
2181 }
2182 // P90.3: presentation-only maintenance of unread_descendants; the
2183 // thread_flags_au trigger advanced the revision already.
2184
3/3
✓ Branch 112 → 114 taken 11 times.
✓ Branch 114 → 115 taken 1 time.
✓ Branch 114 → 116 taken 10 times.
11 if (!adjustThreadUnreadAggregates(folderId, unreadDeltas))
2185 1 return; // the unit rolls back
2186
2187
2/3
✓ Branch 116 → 117 taken 10 times.
✗ Branch 117 → 118 not taken.
✓ Branch 117 → 119 taken 10 times.
10 if (!unit.commit(&m_lastError)) return;
2188
3/3
✓ Branch 124 → 125 taken 2062 times.
✓ Branch 127 → 121 taken 2062 times.
✓ Branch 127 → 128 taken 10 times.
2072 for (const auto &[uid, flags] : uidFlags) emit headerFlagsChanged(folderId, uid, flags);
2189
6/6
✓ Branch 128 → 129 taken 10 times.
✓ Branch 132 → 133 taken 10 times.
✓ Branch 133 → 134 taken 10 times.
✓ Branch 135 → 136 taken 10 times.
✓ Branch 141 → 130 taken 10 times.
✓ Branch 141 → 142 taken 10 times.
20 qCInfo(lcCache) << "Batch-updated flags for" << uidFlags.size()
2190
2/2
✓ Branch 136 → 137 taken 10 times.
✓ Branch 137 → 138 taken 10 times.
10 << "headers in folder" << folderId;
2191 34 }
2192
2193 15681 bool MailCache::adjustThreadUnreadAggregates(
2194 qint64 folderId, const QList<QPair<qint64, int>> &uidDeltas) {
2195 // P90.3: a seen/unseen flip changes exactly the unread_descendants value
2196 // of every stored ancestor. Walk the published parent chain with bounded
2197 // PK lookups (cheaper than one component rebuild, cycle-safe by limit).
2198 // Uids without a stored projection row contribute nothing: their pending
2199 // structural rebuild recomputes the whole component from live flags.
2200
2/2
✓ Branch 3 → 4 taken 15420 times.
✓ Branch 3 → 5 taken 261 times.
15681 if (uidDeltas.isEmpty()) return true;
2201
1/1
✓ Branch 5 → 6 taken 261 times.
261 QSqlQuery parent(m_db);
2202
1/1
✓ Branch 9 → 10 taken 261 times.
261 parent.prepare(QStringLiteral(
2203 "SELECT parent_uid FROM thread_projection WHERE folder_id=:fid AND uid=:uid"));
2204
1/1
✓ Branch 12 → 13 taken 261 times.
261 QSqlQuery adjust(m_db);
2205
1/1
✓ Branch 16 → 17 taken 261 times.
261 adjust.prepare(QStringLiteral(
2206 "UPDATE thread_projection SET unread_descendants=unread_descendants+:delta "
2207 "WHERE folder_id=:fid AND uid=:uid"));
2208 261 constexpr int MaximumWalk = 128; // strictly above ThreadBuilder::MaximumDepth
2209
2/2
✓ Branch 100 → 21 taken 2328 times.
✓ Branch 100 → 101 taken 258 times.
2586 for (const auto &[uid, delta] : uidDeltas) {
2210
1/2
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 26 taken 2328 times.
2328 if (delta == 0) continue;
2211 2328 qint64 current = uid;
2212
1/2
✓ Branch 97 → 27 taken 2355 times.
✗ Branch 97 → 98 not taken.
2355 for (int depth = 0; depth < MaximumWalk; ++depth) {
2213
1/1
✓ Branch 32 → 33 taken 2355 times.
4710 parent.bindValue(QStringLiteral(":fid"), folderId);
2214
1/1
✓ Branch 41 → 42 taken 2355 times.
4710 parent.bindValue(QStringLiteral(":uid"), current);
2215
2/5
✓ Branch 45 → 46 taken 2355 times.
✗ Branch 46 → 47 not taken.
✓ Branch 46 → 53 taken 2355 times.
✗ Branch 47 → 48 not taken.
✗ Branch 48 → 49 not taken.
2358 if (!parent.exec()) { m_lastError = parent.lastError().text(); return false; }
2216
3/3
✓ Branch 53 → 54 taken 2355 times.
✓ Branch 54 → 55 taken 2315 times.
✓ Branch 54 → 56 taken 40 times.
2355 if (!parent.next()) break;
2217
2/2
✓ Branch 56 → 57 taken 40 times.
✓ Branch 57 → 58 taken 40 times.
40 current = parent.value(0).toLongLong();
2218
2/2
✓ Branch 59 → 60 taken 10 times.
✓ Branch 59 → 61 taken 30 times.
40 if (current <= 0) break;
2219
1/1
✓ Branch 66 → 67 taken 30 times.
60 adjust.bindValue(QStringLiteral(":delta"), delta);
2220
1/1
✓ Branch 75 → 76 taken 30 times.
60 adjust.bindValue(QStringLiteral(":fid"), folderId);
2221
1/1
✓ Branch 84 → 85 taken 30 times.
60 adjust.bindValue(QStringLiteral(":uid"), current);
2222
5/5
✓ Branch 88 → 89 taken 30 times.
✓ Branch 89 → 90 taken 3 times.
✓ Branch 89 → 96 taken 27 times.
✓ Branch 90 → 91 taken 3 times.
✓ Branch 91 → 92 taken 3 times.
30 if (!adjust.exec()) { m_lastError = adjust.lastError().text(); return false; }
2223 }
2224 }
2225 258 return true;
2226 261 }
2227
2228 134 void MailCache::removeHeader(qint64 folderId, qint64 uid) {
2229 134 const auto writeMutex = m_writeMutex;
2230 134 QMutexLocker publication(writeMutex.get());
2231
1/1
✓ Branch 5 → 6 taken 134 times.
134 const qint64 rowId = headerRowId(folderId, uid);
2232
2/2
✓ Branch 6 → 7 taken 4 times.
✓ Branch 6 → 8 taken 130 times.
134 if (rowId <= 0)
2233 4 return;
2234
2235
3/3
✓ Branch 8 → 9 taken 130 times.
✓ Branch 9 → 10 taken 2 times.
✓ Branch 9 → 26 taken 128 times.
130 if (!beginWriteTransaction(m_db)) {
2236 4 qCWarning(lcCache) << "Failed to start header removal transaction:" // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; transaction result remains measured.
2237
3/3
✓ Branch 16 → 17 taken 2 times.
✓ Branch 17 → 18 taken 2 times.
✓ Branch 18 → 19 taken 2 times.
2 << m_db.lastError().text();
2238 2 return;
2239 }
2240
2241 // CASCADE deletes body and attachments automatically.
2242
1/1
✓ Branch 26 → 27 taken 128 times.
128 QSqlQuery q(m_db);
2243
1/1
✓ Branch 30 → 31 taken 128 times.
128 q.prepare(QStringLiteral(
2244 "DELETE FROM headers WHERE folder_id = :fid AND uid = :uid"));
2245
1/1
✓ Branch 38 → 39 taken 128 times.
256 q.bindValue(QStringLiteral(":fid"), folderId);
2246
1/1
✓ Branch 47 → 48 taken 128 times.
256 q.bindValue(QStringLiteral(":uid"), uid);
2247
2248
2/3
✓ Branch 51 → 52 taken 128 times.
✗ Branch 52 → 53 not taken.
✓ Branch 52 → 70 taken 128 times.
128 if (!q.exec()) {
2249 ✗ m_db.rollback();
2250 ✗ qCWarning(lcCache) << "Failed to remove header:" << q.lastError().text();
2251 ✗ return;
2252 }
2253
2/3
✓ Branch 70 → 71 taken 128 times.
✗ Branch 71 → 72 not taken.
✓ Branch 71 → 89 taken 128 times.
128 if (!m_db.commit()) {
2254 ✗ m_db.rollback();
2255 ✗ qCWarning(lcCache) << "Failed to commit header removal:" // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; commit result remains measured.
2256 ✗ << m_db.lastError().text();
2257 ✗ return;
2258 }
2259
2260
1/1
✓ Branch 89 → 90 taken 128 times.
128 emit headerRemoved(folderId, uid);
2261
8/8
✓ Branch 90 → 91 taken 128 times.
✓ Branch 94 → 95 taken 128 times.
✓ Branch 95 → 96 taken 128 times.
✓ Branch 96 → 97 taken 128 times.
✓ Branch 97 → 98 taken 128 times.
✓ Branch 98 → 99 taken 128 times.
✓ Branch 102 → 92 taken 128 times.
✓ Branch 102 → 103 taken 128 times.
256 qCInfo(lcCache) << "Removed header UID" << uid << "from folder" << folderId;
2262 140 }
2263
2264 6 bool MailCache::removeHeaders(qint64 folderId, const QList<qint64> &uids) {
2265
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 5 times.
6 if (uids.isEmpty()) return true;
2266 5 const auto writeMutex = m_writeMutex;
2267 5 QMutexLocker publication(writeMutex.get());
2268
2/3
✓ Branch 8 → 9 taken 5 times.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 26 taken 5 times.
5 if (!beginWriteTransaction(m_db)) {
2269 ✗ qCWarning(lcCache) << "Failed to start bulk header removal:" << m_db.lastError().text();
2270 ✗ return false;
2271 }
2272 5 QList<qint64> removed;
2273
1/1
✓ Branch 27 → 28 taken 5 times.
5 removed.reserve(uids.size());
2274 // Chunked far below SQLite's host-parameter limit. CASCADE deletes bodies.
2275
2/2
✓ Branch 107 → 29 taken 7 times.
✓ Branch 107 → 108 taken 5 times.
12 for (qsizetype at = 0; at < uids.size(); at += 500) {
2276
1/1
✓ Branch 29 → 30 taken 7 times.
7 const auto chunk = uids.mid(at, 500);
2277
1/1
✓ Branch 34 → 35 taken 7 times.
7 QStringList marks(chunk.size(), QStringLiteral("?"));
2278
1/1
✓ Branch 37 → 38 taken 7 times.
7 QSqlQuery q(m_db);
2279
1/1
✓ Branch 46 → 47 taken 7 times.
21 q.prepare(QStringLiteral("DELETE FROM headers WHERE folder_id=? AND uid IN(%1) RETURNING uid")
2280
2/2
✓ Branch 44 → 45 taken 7 times.
✓ Branch 45 → 46 taken 7 times.
21 .arg(marks.join(QLatin1Char(','))));
2281
1/1
✓ Branch 53 → 54 taken 7 times.
7 q.addBindValue(folderId);
2282
3/3
✓ Branch 60 → 61 taken 1106 times.
✓ Branch 64 → 57 taken 1106 times.
✓ Branch 64 → 65 taken 7 times.
1113 for (const qint64 uid : chunk) q.addBindValue(uid);
2283
2/3
✓ Branch 65 → 66 taken 7 times.
✗ Branch 66 → 67 not taken.
✓ Branch 66 → 84 taken 7 times.
7 if (!q.exec()) {
2284 ✗ qCWarning(lcCache) << "Failed to remove headers:" << q.lastError().text();
2285 ✗ m_db.rollback();
2286 ✗ return false;
2287 }
2288
6/6
✓ Branch 85 → 86 taken 1105 times.
✓ Branch 86 → 87 taken 1105 times.
✓ Branch 87 → 88 taken 1105 times.
✓ Branch 90 → 91 taken 1112 times.
✓ Branch 91 → 85 taken 1105 times.
✓ Branch 91 → 92 taken 7 times.
1112 while (q.next()) removed.append(q.value(0).toLongLong());
2289 7 }
2290
2/3
✓ Branch 108 → 109 taken 5 times.
✗ Branch 109 → 110 not taken.
✓ Branch 109 → 127 taken 5 times.
5 if (!m_db.commit()) {
2291 ✗ qCWarning(lcCache) << "Failed to commit bulk header removal:" << m_db.lastError().text();
2292 ✗ m_db.rollback();
2293 ✗ return false;
2294 }
2295
3/3
✓ Branch 131 → 132 taken 1105 times.
✓ Branch 134 → 130 taken 1105 times.
✓ Branch 134 → 135 taken 5 times.
1110 for (const qint64 uid : std::as_const(removed)) emit headerRemoved(folderId, uid);
2296
8/8
✓ Branch 135 → 136 taken 5 times.
✓ Branch 139 → 140 taken 5 times.
✓ Branch 140 → 141 taken 5 times.
✓ Branch 142 → 143 taken 5 times.
✓ Branch 143 → 144 taken 5 times.
✓ Branch 144 → 145 taken 5 times.
✓ Branch 148 → 137 taken 5 times.
✓ Branch 148 → 149 taken 5 times.
10 qCInfo(lcCache) << "Removed" << removed.size() << "headers from folder" << folderId;
2297 5 return true;
2298 5 }
2299
2300 762 int MailCache::unreadCount(qint64 folderId) const {
2301
1/1
✓ Branch 2 → 3 taken 762 times.
762 QSqlQuery q(m_db);
2302
1/1
✓ Branch 6 → 7 taken 762 times.
762 q.prepare(QStringLiteral("SELECT unread FROM header_counts WHERE folder_id=:fid"));
2303
1/1
✓ Branch 14 → 15 taken 762 times.
1524 q.bindValue(QStringLiteral(":fid"), folderId);
2304
10/12
✓ Branch 18 → 19 taken 762 times.
✓ Branch 19 → 20 taken 749 times.
✓ Branch 19 → 30 taken 13 times.
✓ Branch 20 → 21 taken 749 times.
✓ Branch 21 → 22 taken 701 times.
✓ Branch 21 → 25 taken 48 times.
✓ Branch 22 → 23 taken 701 times.
✓ Branch 23 → 24 taken 701 times.
✓ Branch 27 → 28 taken 701 times.
✓ Branch 27 → 29 taken 48 times.
✗ Branch 77 → 78 not taken.
✗ Branch 77 → 79 not taken.
762 if (q.exec()) return q.next() ? q.value(0).toInt() : 0;
2305 // Read-only access to a pre-upgrade schema-22 cache remains supported.
2306
1/1
✓ Branch 33 → 34 taken 13 times.
13 q.prepare(QStringLiteral("SELECT COUNT(*) FROM headers "
2307 "WHERE folder_id = :fid AND (flags & 1) = 0"));
2308
1/1
✓ Branch 41 → 42 taken 13 times.
26 q.bindValue(QStringLiteral(":fid"), folderId);
2309
2310
7/8
✓ Branch 45 → 46 taken 13 times.
✓ Branch 46 → 47 taken 11 times.
✓ Branch 46 → 50 taken 2 times.
✓ Branch 47 → 48 taken 11 times.
✓ Branch 48 → 49 taken 11 times.
✗ Branch 48 → 50 not taken.
✓ Branch 51 → 52 taken 11 times.
✓ Branch 51 → 57 taken 2 times.
13 if (q.exec() && q.next()) {
2311
2/2
✓ Branch 52 → 53 taken 11 times.
✓ Branch 53 → 54 taken 11 times.
11 return q.value(0).toInt();
2312 }
2313 2 return 0;
2314 762 }
2315
2316 48 std::optional<QList<qint64>> MailCache::unreadUidPage(
2317 qint64 folderId, qint64 afterUid, qint64 throughUid) const {
2318
1/1
✓ Branch 2 → 3 taken 48 times.
48 QSqlQuery q(m_db);
2319
1/1
✓ Branch 6 → 7 taken 48 times.
48 q.prepare(QStringLiteral("SELECT uid FROM headers WHERE folder_id=? "
2320 "AND uid>? AND uid<=? AND (flags & 1)=0 ORDER BY uid LIMIT 16"));
2321
1/1
✓ Branch 11 → 12 taken 48 times.
48 q.addBindValue(folderId);
2322
1/1
✓ Branch 15 → 16 taken 48 times.
48 q.addBindValue(afterUid);
2323
1/1
✓ Branch 19 → 20 taken 48 times.
48 q.addBindValue(throughUid);
2324
2/3
✓ Branch 21 → 22 taken 48 times.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 24 taken 48 times.
48 if (!q.exec()) return std::nullopt;
2325 48 QList<qint64> result;
2326
6/6
✓ Branch 25 → 26 taken 70 times.
✓ Branch 26 → 27 taken 70 times.
✓ Branch 27 → 28 taken 70 times.
✓ Branch 30 → 31 taken 118 times.
✓ Branch 31 → 25 taken 70 times.
✓ Branch 31 → 32 taken 48 times.
118 while (q.next()) result.append(q.value(0).toLongLong());
2327
3/4
✓ Branch 32 → 33 taken 48 times.
✓ Branch 33 → 34 taken 48 times.
✗ Branch 35 → 36 not taken.
✓ Branch 35 → 37 taken 48 times.
48 if (q.lastError().isValid()) return std::nullopt;
2328 48 return result;
2329 48 }
2330
2331 // --- Label operations (T-261) ---
2332
2333 388 void MailCache::addLabel(qint64 folderId, qint64 uid, const QString &label) {
2334 388 const auto writeMutex = m_writeMutex;
2335 388 QMutexLocker publication(writeMutex.get());
2336
1/1
✓ Branch 5 → 6 taken 388 times.
388 qint64 hid = headerRowId(folderId, uid);
2337
2/2
✓ Branch 6 → 7 taken 2 times.
✓ Branch 6 → 8 taken 386 times.
388 if (hid < 0)
2338 2 return;
2339
1/1
✓ Branch 8 → 9 taken 386 times.
386 QSqlQuery q(m_db);
2340
1/1
✓ Branch 12 → 13 taken 386 times.
386 q.prepare(QStringLiteral(
2341 "INSERT OR IGNORE INTO mail_labels (header_id, label) "
2342 "VALUES (:hid, :label)"));
2343
1/1
✓ Branch 20 → 21 taken 386 times.
772 q.bindValue(QStringLiteral(":hid"), hid);
2344
1/1
✓ Branch 29 → 30 taken 386 times.
772 q.bindValue(QStringLiteral(":label"), label);
2345
3/3
✓ Branch 33 → 34 taken 386 times.
✓ Branch 34 → 35 taken 2 times.
✓ Branch 34 → 51 taken 384 times.
386 if (!q.exec()) {
2346
8/8
✓ Branch 35 → 36 taken 2 times.
✓ Branch 39 → 40 taken 2 times.
✓ Branch 40 → 41 taken 2 times.
✓ Branch 41 → 42 taken 2 times.
✓ Branch 42 → 43 taken 2 times.
✓ Branch 43 → 44 taken 2 times.
✓ Branch 49 → 37 taken 2 times.
✓ Branch 49 → 50 taken 2 times.
4 qCWarning(lcCache) << "Failed to add label:" << q.lastError().text();
2347 2 return;
2348 }
2349
1/1
✓ Branch 51 → 52 taken 384 times.
384 emit headerLabelChanged(folderId, uid, label, true);
2350 394 }
2351
2352 22 void MailCache::removeLabel(qint64 folderId, qint64 uid,
2353 const QString &label) {
2354 22 const auto writeMutex = m_writeMutex;
2355 22 QMutexLocker publication(writeMutex.get());
2356
1/1
✓ Branch 5 → 6 taken 22 times.
22 qint64 hid = headerRowId(folderId, uid);
2357
2/2
✓ Branch 6 → 7 taken 3 times.
✓ Branch 6 → 8 taken 19 times.
22 if (hid < 0)
2358 3 return;
2359
1/1
✓ Branch 8 → 9 taken 19 times.
19 QSqlQuery q(m_db);
2360
1/1
✓ Branch 12 → 13 taken 19 times.
19 q.prepare(QStringLiteral(
2361 "DELETE FROM mail_labels WHERE header_id = :hid AND label = :label"));
2362
1/1
✓ Branch 20 → 21 taken 19 times.
38 q.bindValue(QStringLiteral(":hid"), hid);
2363
1/1
✓ Branch 29 → 30 taken 19 times.
38 q.bindValue(QStringLiteral(":label"), label);
2364
3/3
✓ Branch 33 → 34 taken 19 times.
✓ Branch 34 → 35 taken 2 times.
✓ Branch 34 → 51 taken 17 times.
19 if (!q.exec()) {
2365
8/8
✓ Branch 35 → 36 taken 2 times.
✓ Branch 39 → 40 taken 2 times.
✓ Branch 40 → 41 taken 2 times.
✓ Branch 41 → 42 taken 2 times.
✓ Branch 42 → 43 taken 2 times.
✓ Branch 43 → 44 taken 2 times.
✓ Branch 49 → 37 taken 2 times.
✓ Branch 49 → 50 taken 2 times.
4 qCWarning(lcCache) << "Failed to remove label:" << q.lastError().text();
2366 2 return;
2367 }
2368
1/1
✓ Branch 51 → 52 taken 17 times.
17 emit headerLabelChanged(folderId, uid, label, false);
2369 29 }
2370
2371 // --- Durable mutation journal (Sprint 84): see MutationJournalStore ---
2372
2373 std::optional<MailCache::FlagMutationResult>
2374 159 MailCache::enqueueFlagMutationAndUpdate(const MutationRecord &record) {
2375 159 m_lastError.clear();
2376
2/4
✓ Branch 6 → 7 taken 159 times.
✗ Branch 6 → 25 not taken.
✓ Branch 8 → 9 taken 159 times.
✗ Branch 8 → 25 not taken.
159 if (!m_db.isOpen() || !m_writeMutex || !record.isValid() ||
2377
1/2
✓ Branch 9 → 10 taken 159 times.
✗ Branch 9 → 25 not taken.
159 record.replayClass != MutationReplayClass::DesiredState ||
2378
3/4
✓ Branch 10 → 11 taken 43 times.
✓ Branch 10 → 12 taken 116 times.
✓ Branch 11 → 12 taken 43 times.
✗ Branch 11 → 25 not taken.
159 (record.kind != MutationKind::Seen && record.kind != MutationKind::Flagged) ||
2379
6/12
✓ Branch 12 → 13 taken 116 times.
✓ Branch 12 → 18 taken 43 times.
✓ Branch 17 → 18 taken 116 times.
✗ Branch 17 → 25 not taken.
✓ Branch 33 → 34 taken 116 times.
✓ Branch 33 → 35 taken 43 times.
✗ Branch 35 → 36 not taken.
✓ Branch 35 → 43 taken 159 times.
✗ Branch 163 → 164 not taken.
✗ Branch 163 → 165 not taken.
✗ Branch 167 → 168 not taken.
✗ Branch 167 → 169 not taken.
434 (record.kind == MutationKind::Seen && record.flag != QStringLiteral("\\Seen")) ||
2380
11/17
✓ Branch 3 → 4 taken 159 times.
✓ Branch 4 → 5 taken 159 times.
✗ Branch 4 → 25 not taken.
✓ Branch 18 → 19 taken 43 times.
✓ Branch 18 → 24 taken 116 times.
✓ Branch 23 → 24 taken 43 times.
✗ Branch 23 → 25 not taken.
✓ Branch 27 → 28 taken 43 times.
✓ Branch 27 → 29 taken 116 times.
✓ Branch 29 → 30 taken 43 times.
✓ Branch 29 → 31 taken 116 times.
✓ Branch 31 → 32 taken 116 times.
✓ Branch 31 → 33 taken 43 times.
✗ Branch 155 → 156 not taken.
✗ Branch 155 → 157 not taken.
✗ Branch 159 → 160 not taken.
✗ Branch 159 → 161 not taken.
520 (record.kind == MutationKind::Flagged && record.flag != QStringLiteral("\\Flagged")) ||
2381
1/2
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 26 taken 159 times.
159 record.uidValidity == 0) {
2382 ✗ m_lastError = QStringLiteral("Invalid desired flag mutation");
2383 ✗ return std::nullopt;
2384 }
2385 159 const auto writeMutex = m_writeMutex;
2386 159 QMutexLocker publication(writeMutex.get());
2387
1/1
✓ Branch 46 → 47 taken 159 times.
159 ScopedWriteUnit unit(m_db, &m_lastError);
2388
1/2
✗ Branch 48 → 49 not taken.
✓ Branch 48 → 50 taken 159 times.
159 if (!unit.started()) return std::nullopt;
2389
2390
1/1
✓ Branch 50 → 51 taken 159 times.
159 QSqlQuery identity(m_db);
2391
1/1
✓ Branch 54 → 55 taken 159 times.
159 identity.prepare(QStringLiteral(
2392 "SELECT h.flags FROM headers h JOIN folders f ON f.id=h.folder_id "
2393 "WHERE f.account=:account AND f.id=:folder AND f.path=:path "
2394 "AND f.uidvalidity=:validity AND h.uid=:uid"));
2395
1/1
✓ Branch 62 → 63 taken 159 times.
318 identity.bindValue(QStringLiteral(":account"), record.mailId.accountId);
2396
1/1
✓ Branch 71 → 72 taken 159 times.
318 identity.bindValue(QStringLiteral(":folder"), record.mailId.folderId);
2397
1/1
✓ Branch 80 → 81 taken 159 times.
318 identity.bindValue(QStringLiteral(":path"), record.folderPath);
2398
1/1
✓ Branch 89 → 90 taken 159 times.
318 identity.bindValue(QStringLiteral(":validity"), record.uidValidity);
2399
1/1
✓ Branch 98 → 99 taken 159 times.
318 identity.bindValue(QStringLiteral(":uid"), record.mailId.uid);
2400
5/8
✓ Branch 102 → 103 taken 159 times.
✓ Branch 103 → 104 taken 159 times.
✗ Branch 103 → 106 not taken.
✓ Branch 104 → 105 taken 159 times.
✗ Branch 105 → 106 not taken.
✓ Branch 105 → 107 taken 159 times.
✗ Branch 108 → 109 not taken.
✓ Branch 108 → 125 taken 159 times.
159 if (!identity.exec() || !identity.next()) {
2401 ✗ m_lastError = identity.lastError().isValid() ? identity.lastError().text()
2402 ✗ : QStringLiteral("Flag target changed before commit");
2403 ✗ return std::nullopt;
2404 }
2405
2/2
✓ Branch 125 → 126 taken 159 times.
✓ Branch 126 → 127 taken 159 times.
159 const quint32 oldFlags = identity.value(0).toUInt();
2406
1/1
✓ Branch 128 → 129 taken 159 times.
159 identity.finish();
2407
2/2
✓ Branch 129 → 130 taken 116 times.
✓ Branch 129 → 131 taken 43 times.
159 const quint32 bit = record.kind == MutationKind::Seen ? MailFlag::Seen
2408 : MailFlag::Flagged;
2409
2/2
✓ Branch 132 → 133 taken 128 times.
✓ Branch 132 → 134 taken 31 times.
159 const quint32 flags = record.desiredState ? (oldFlags | bit)
2410 31 : (oldFlags & ~bit);
2411
1/1
✓ Branch 135 → 136 taken 159 times.
159 const auto token = m_journal.enqueueMutation(record);
2412
2/2
✓ Branch 137 → 138 taken 1 time.
✓ Branch 137 → 139 taken 158 times.
159 if (!token) return std::nullopt;
2413
2/3
✓ Branch 139 → 140 taken 158 times.
✗ Branch 140 → 141 not taken.
✓ Branch 140 → 142 taken 158 times.
158 if (!writeFlags(record.mailId.folderId, record.mailId.uid, flags)) return std::nullopt;
2414
3/3
✓ Branch 142 → 143 taken 158 times.
✓ Branch 143 → 144 taken 1 time.
✓ Branch 143 → 145 taken 157 times.
158 if (!unit.commit(&m_lastError)) return std::nullopt;
2415 // S106-110: published only after the journal entry and the flags commit
2416 // together; a failed commit leaves listeners on the stored state.
2417
1/1
✓ Branch 145 → 146 taken 157 times.
157 emit headerFlagsChanged(record.mailId.folderId, record.mailId.uid, flags);
2418 157 return FlagMutationResult{*token, flags};
2419 159 }
2420
2421 std::optional<qint64>
2422 34 MailCache::enqueueKeywordMutationAndUpdate(const MutationRecord &record) {
2423 34 m_lastError.clear();
2424
3/5
✓ Branch 3 → 4 taken 34 times.
✓ Branch 6 → 7 taken 34 times.
✗ Branch 6 → 18 not taken.
✓ Branch 8 → 9 taken 34 times.
✗ Branch 8 → 18 not taken.
68 if (!m_db.isOpen() || !m_writeMutex || !record.isValid() ||
2425
1/2
✓ Branch 9 → 10 taken 34 times.
✗ Branch 9 → 18 not taken.
34 record.replayClass != MutationReplayClass::DesiredState ||
2426
2/4
✓ Branch 10 → 11 taken 34 times.
✗ Branch 10 → 18 not taken.
✓ Branch 12 → 13 taken 34 times.
✗ Branch 12 → 18 not taken.
34 record.kind != MutationKind::Keyword || record.flag.isEmpty() ||
2427
5/9
✓ Branch 4 → 5 taken 34 times.
✗ Branch 4 → 18 not taken.
✓ Branch 15 → 16 taken 34 times.
✓ Branch 16 → 17 taken 34 times.
✗ Branch 16 → 18 not taken.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 34 times.
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 30 taken 34 times.
68 record.flag.startsWith(QLatin1Char('\\')) || record.uidValidity == 0) {
2428 ✗ m_lastError = QStringLiteral("Invalid desired label mutation");
2429 ✗ return std::nullopt;
2430 }
2431 34 const auto writeMutex = m_writeMutex;
2432 34 QMutexLocker publication(writeMutex.get());
2433
1/1
✓ Branch 33 → 34 taken 34 times.
34 ScopedWriteUnit unit(m_db, &m_lastError);
2434
1/2
✗ Branch 35 → 36 not taken.
✓ Branch 35 → 39 taken 34 times.
34 if (!unit.started()) return std::nullopt;
2435
2436
1/1
✓ Branch 39 → 40 taken 34 times.
34 QSqlQuery identity(m_db);
2437
1/1
✓ Branch 43 → 44 taken 34 times.
34 identity.prepare(QStringLiteral(
2438 "SELECT h.id FROM headers h JOIN folders f ON f.id=h.folder_id "
2439 "WHERE f.account=:account AND f.id=:folder AND f.path=:path "
2440 "AND f.uidvalidity=:validity AND h.uid=:uid"));
2441
1/1
✓ Branch 51 → 52 taken 34 times.
68 identity.bindValue(QStringLiteral(":account"), record.mailId.accountId);
2442
1/1
✓ Branch 60 → 61 taken 34 times.
68 identity.bindValue(QStringLiteral(":folder"), record.mailId.folderId);
2443
1/1
✓ Branch 69 → 70 taken 34 times.
68 identity.bindValue(QStringLiteral(":path"), record.folderPath);
2444
1/1
✓ Branch 78 → 79 taken 34 times.
68 identity.bindValue(QStringLiteral(":validity"), record.uidValidity);
2445
1/1
✓ Branch 87 → 88 taken 34 times.
68 identity.bindValue(QStringLiteral(":uid"), record.mailId.uid);
2446
5/8
✓ Branch 91 → 92 taken 34 times.
✓ Branch 92 → 93 taken 34 times.
✗ Branch 92 → 95 not taken.
✓ Branch 93 → 94 taken 34 times.
✗ Branch 94 → 95 not taken.
✓ Branch 94 → 96 taken 34 times.
✗ Branch 97 → 98 not taken.
✓ Branch 97 → 116 taken 34 times.
34 if (!identity.exec() || !identity.next()) {
2447 ✗ m_lastError = identity.lastError().isValid() ? identity.lastError().text()
2448 ✗ : QStringLiteral("Label target changed before commit");
2449 ✗ return std::nullopt;
2450 }
2451
2/2
✓ Branch 116 → 117 taken 34 times.
✓ Branch 117 → 118 taken 34 times.
34 const qint64 headerId = identity.value(0).toLongLong();
2452
1/1
✓ Branch 119 → 120 taken 34 times.
34 identity.finish();
2453
1/1
✓ Branch 120 → 121 taken 34 times.
34 const auto token = m_journal.enqueueMutation(record);
2454
1/2
✗ Branch 122 → 123 not taken.
✓ Branch 122 → 126 taken 34 times.
34 if (!token) return std::nullopt;
2455
1/1
✓ Branch 126 → 127 taken 34 times.
34 QSqlQuery label(m_db);
2456
1/1
✓ Branch 134 → 135 taken 34 times.
34 label.prepare(record.desiredState
2457
2/4
✓ Branch 127 → 128 taken 21 times.
✓ Branch 127 → 131 taken 13 times.
✗ Branch 260 → 261 not taken.
✗ Branch 260 → 262 not taken.
136 ? QStringLiteral("INSERT OR IGNORE INTO mail_labels (header_id, label) VALUES (:hid, :label)")
2458
4/6
✓ Branch 136 → 137 taken 13 times.
✓ Branch 136 → 138 taken 21 times.
✓ Branch 138 → 139 taken 21 times.
✓ Branch 138 → 140 taken 13 times.
✗ Branch 256 → 257 not taken.
✗ Branch 256 → 258 not taken.
34 : QStringLiteral("DELETE FROM mail_labels WHERE header_id = :hid AND label = :label"));
2459
1/1
✓ Branch 145 → 146 taken 34 times.
68 label.bindValue(QStringLiteral(":hid"), headerId);
2460
1/1
✓ Branch 154 → 155 taken 34 times.
68 label.bindValue(QStringLiteral(":label"), record.flag);
2461
2/3
✓ Branch 158 → 159 taken 34 times.
✗ Branch 159 → 160 not taken.
✓ Branch 159 → 168 taken 34 times.
34 if (!label.exec()) {
2462 ✗ m_lastError = label.lastError().text();
2463 ✗ return std::nullopt;
2464 }
2465
2/3
✓ Branch 168 → 169 taken 34 times.
✗ Branch 169 → 170 not taken.
✓ Branch 169 → 173 taken 34 times.
34 if (!unit.commit(&m_lastError)) return std::nullopt;
2466 34 return token;
2467 34 }
2468
2469 // --- Attachment operations ---
2470
2471