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: 88.0% 190 / 0 / 216
Functions: 100.0% 12 / 0 / 12
Branches: 73.6% 254 / 5 / 350

data/MailCacheSchema.cpp
Line Branch Exec Source
1 #include "data/MailCacheSchema.h"
2
3 #include "data/MailCacheSql.h"
4 #include "data/ThreadProjectionSchema.h"
5 #include <QLoggingCategory>
6 #include <QSqlError>
7 #include <QSqlQuery>
8 #include <QVariant>
9
10 // Same category as MailCache (defined there), see docs/LOGGING.md.
11 Q_DECLARE_LOGGING_CATEGORY(lcCache)
12
13 using MailCacheSql::AppendPayloadSchema;
14
15 5035 MailCacheSchema::MailCacheSchema(QSqlDatabase &db, QString &lastError)
16 5035 : m_db(db), m_lastError(lastError) {}
17
18 // Install and backfill derived counts atomically. Reopening a complete cache
19 // only inspects schema metadata; it never recounts all headers.
20 1691 static bool ensureHeaderCounts(QSqlDatabase &db, QString *error) {
21
1/1
✓ Branch 2 → 3 taken 1691 times.
1691 QSqlQuery query(db);
22 3249 const auto complete = [&]() -> std::optional<bool> {
23
3/8
✓ Branch 13 → 14 taken 3249 times.
✗ Branch 13 → 15 not taken.
✓ Branch 15 → 16 taken 2 times.
✓ Branch 15 → 31 taken 3247 times.
✗ Branch 40 → 41 not taken.
✗ Branch 40 → 42 not taken.
✗ Branch 44 → 45 not taken.
✗ Branch 44 → 46 not taken.
6498 if (!query.exec(QStringLiteral(
24 "SELECT count(*) FROM sqlite_master WHERE "
25 "(type='table' AND name='header_counts') OR "
26 "(type='trigger' AND name IN "
27
4/5
✓ Branch 5 → 6 taken 3249 times.
✓ Branch 6 → 7 taken 3247 times.
✓ Branch 6 → 9 taken 2 times.
✓ Branch 11 → 12 taken 3249 times.
✗ Branch 11 → 13 not taken.
9745 "('header_counts_ai','header_counts_ad','header_counts_au'))")) ||
28
2/3
✓ Branch 7 → 8 taken 3247 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 3247 times.
3247 !query.next()) {
29
3/3
✓ Branch 16 → 17 taken 2 times.
✓ Branch 17 → 18 taken 2 times.
✓ Branch 21 → 22 taken 2 times.
4 *error = QStringLiteral("Cannot inspect header count schema: ") + query.lastError().text();
30 2 return std::nullopt;
31 }
32
2/2
✓ Branch 31 → 32 taken 3247 times.
✓ Branch 32 → 33 taken 3247 times.
3247 const bool ready = query.value(0).toInt() == 4;
33
1/1
✓ Branch 34 → 35 taken 3247 times.
3247 query.finish();
34 3247 return ready;
35 1691 };
36
1/1
✓ Branch 3 → 4 taken 1691 times.
1691 auto ready = complete();
37
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 1690 times.
1691 if (!ready) return false;
38
2/2
✓ Branch 8 → 9 taken 131 times.
✓ Branch 8 → 10 taken 1559 times.
1690 if (*ready) return true;
39
3/3
✓ Branch 13 → 14 taken 1559 times.
✓ Branch 16 → 17 taken 1 time.
✓ Branch 16 → 23 taken 1558 times.
1559 if (!query.exec(QStringLiteral("BEGIN IMMEDIATE"))) {
40
2/2
✓ Branch 17 → 18 taken 1 time.
✓ Branch 18 → 19 taken 1 time.
1 *error = query.lastError().text();
41 1 return false;
42 }
43 1567 auto rollback = qScopeGuard([&] { db.rollback(); });
44 // Another connection may have completed installation while we waited.
45
1/1
✓ Branch 24 → 25 taken 1558 times.
1558 ready = complete();
46
2/2
✓ Branch 26 → 27 taken 1 time.
✓ Branch 26 → 28 taken 1557 times.
1558 if (!ready) return false;
47
1/2
✓ Branch 29 → 30 taken 1557 times.
✗ Branch 29 → 85 not taken.
1557 if (!*ready) {
48 const QStringList statements = {
49 ✗ QStringLiteral(
50 "CREATE TABLE IF NOT EXISTS header_counts ("
51 "folder_id INTEGER PRIMARY KEY REFERENCES folders(id) ON DELETE CASCADE,"
52 "total INTEGER NOT NULL CHECK(total>=0),"
53 "unread INTEGER NOT NULL CHECK(unread>=0 AND unread<=total))"),
54 1557 QStringLiteral("DELETE FROM header_counts"),
55 1557 QStringLiteral(
56 "INSERT INTO header_counts SELECT folder_id,count(*),"
57 "sum(coalesce((flags & 1)=0,0)) FROM headers GROUP BY folder_id"),
58 1557 QStringLiteral(
59 "CREATE TRIGGER IF NOT EXISTS header_counts_ai AFTER INSERT ON headers BEGIN "
60 "INSERT INTO header_counts VALUES(new.folder_id,1,coalesce((new.flags & 1)=0,0)) "
61 "ON CONFLICT(folder_id) DO UPDATE SET total=total+1,unread=unread+excluded.unread; END"),
62 1557 QStringLiteral(
63 "CREATE TRIGGER IF NOT EXISTS header_counts_ad AFTER DELETE ON headers BEGIN "
64 "UPDATE header_counts SET total=total-1,unread=unread-coalesce((old.flags & 1)=0,0) "
65 "WHERE folder_id=old.folder_id; END"),
66 1557 QStringLiteral(
67 "CREATE TRIGGER IF NOT EXISTS header_counts_au AFTER UPDATE OF folder_id,flags ON headers "
68 "WHEN old.folder_id IS NOT new.folder_id OR (old.flags & 1) IS NOT (new.flags & 1) BEGIN "
69 "UPDATE header_counts SET total=total-1,unread=unread-coalesce((old.flags & 1)=0,0) "
70 "WHERE folder_id=old.folder_id; "
71 "INSERT INTO header_counts VALUES(new.folder_id,1,coalesce((new.flags & 1)=0,0)) "
72 "ON CONFLICT(folder_id) DO UPDATE SET total=total+1,unread=unread+excluded.unread; END"),
73
2/4
✓ Branch 50 → 51 taken 9342 times.
✓ Branch 50 → 52 taken 1557 times.
✗ Branch 110 → 111 not taken.
✗ Branch 110 → 112 not taken.
10899 };
74
2/2
✓ Branch 78 → 60 taken 9324 times.
✓ Branch 78 → 79 taken 1550 times.
10874 for (const auto &statement : statements) {
75
3/3
✓ Branch 61 → 62 taken 9324 times.
✓ Branch 62 → 63 taken 7 times.
✓ Branch 62 → 76 taken 9317 times.
9324 if (!query.exec(statement)) {
76
3/3
✓ Branch 63 → 64 taken 7 times.
✓ Branch 64 → 65 taken 7 times.
✓ Branch 68 → 69 taken 7 times.
14 *error = QStringLiteral("Cannot initialize header counts: ") + query.lastError().text();
77 7 return false;
78 }
79 }
80 1557 }
81
3/3
✓ Branch 85 → 86 taken 1550 times.
✓ Branch 86 → 87 taken 1 time.
✓ Branch 86 → 93 taken 1549 times.
1550 if (!db.commit()) {
82
2/2
✓ Branch 87 → 88 taken 1 time.
✓ Branch 88 → 89 taken 1 time.
1 *error = db.lastError().text();
83 1 return false;
84 }
85 1549 rollback.dismiss();
86 1549 return true;
87 12590 }
88
89 // Tokens change only when cached message identities change. A fresh token on
90 // repair also invalidates predictor checkpoints from an older cache generation.
91 1680 static bool ensureTrainingRevisions(QSqlDatabase &db, QString *error) {
92
1/1
✓ Branch 2 → 3 taken 1680 times.
1680 QSqlQuery query(db);
93
2/8
✓ Branch 14 → 15 taken 1680 times.
✗ Branch 14 → 16 not taken.
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 23 taken 1680 times.
✗ Branch 103 → 104 not taken.
✗ Branch 103 → 105 not taken.
✗ Branch 107 → 108 not taken.
✗ Branch 107 → 109 not taken.
3360 if (!query.exec(QStringLiteral(
94 "SELECT count(*) FROM sqlite_master WHERE "
95 "(type='table' AND name='training_revisions') OR "
96 "(type='trigger' AND name IN ('training_revisions_ai',"
97
5/8
✓ Branch 6 → 7 taken 1680 times.
✓ Branch 7 → 8 taken 1680 times.
✗ Branch 7 → 10 not taken.
✓ Branch 8 → 9 taken 1680 times.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 1680 times.
✓ Branch 12 → 13 taken 1680 times.
✗ Branch 12 → 14 not taken.
3360 "'training_revisions_ad','training_revisions_au'))")) || !query.next()) {
98 ✗ *error = query.lastError().text();
99 ✗ return false;
100 }
101
2/2
✓ Branch 23 → 24 taken 1680 times.
✓ Branch 24 → 25 taken 1680 times.
1680 const bool ready = query.value(0).toInt() == 4;
102
1/1
✓ Branch 26 → 27 taken 1680 times.
1680 query.finish();
103
2/2
✓ Branch 27 → 28 taken 129 times.
✓ Branch 27 → 29 taken 1551 times.
1680 if (ready) return true;
104
3/3
✓ Branch 32 → 33 taken 1551 times.
✓ Branch 35 → 36 taken 1 time.
✓ Branch 35 → 42 taken 1550 times.
1551 if (!query.exec(QStringLiteral("BEGIN IMMEDIATE"))) {
105
2/2
✓ Branch 36 → 37 taken 1 time.
✓ Branch 37 → 38 taken 1 time.
1 *error = query.lastError().text(); return false;
106 }
107 1556 auto rollback = qScopeGuard([&] { db.rollback(); });
108 const QStringList statements = {
109 ✗ QStringLiteral("CREATE TABLE IF NOT EXISTS training_revisions ("
110 "folder_id INTEGER PRIMARY KEY REFERENCES folders(id) ON DELETE CASCADE,"
111 "revision TEXT NOT NULL)"),
112 1550 QStringLiteral("INSERT INTO training_revisions SELECT id,hex(randomblob(16)) FROM folders WHERE true "
113 "ON CONFLICT(folder_id) DO UPDATE SET revision=excluded.revision"),
114 1550 QStringLiteral("CREATE TRIGGER IF NOT EXISTS training_revisions_ai AFTER INSERT ON headers BEGIN "
115 "INSERT INTO training_revisions VALUES(new.folder_id,hex(randomblob(16))) "
116 "ON CONFLICT(folder_id) DO UPDATE SET revision=excluded.revision; END"),
117 1550 QStringLiteral("CREATE TRIGGER IF NOT EXISTS training_revisions_ad AFTER DELETE ON headers BEGIN "
118 "UPDATE training_revisions SET revision=hex(randomblob(16)) WHERE folder_id=old.folder_id; END"),
119 1550 QStringLiteral("CREATE TRIGGER IF NOT EXISTS training_revisions_au AFTER UPDATE OF folder_id,uid ON headers "
120 "WHEN old.folder_id IS NOT new.folder_id OR old.uid IS NOT new.uid BEGIN "
121 "UPDATE training_revisions SET revision=hex(randomblob(16)) WHERE folder_id=old.folder_id; "
122 "INSERT INTO training_revisions VALUES(new.folder_id,hex(randomblob(16))) "
123 "ON CONFLICT(folder_id) DO UPDATE SET revision=excluded.revision; END")
124
2/4
✓ Branch 60 → 61 taken 7750 times.
✓ Branch 60 → 62 taken 1550 times.
✗ Branch 129 → 130 not taken.
✗ Branch 129 → 131 not taken.
9300 };
125
2/2
✓ Branch 87 → 69 taken 7740 times.
✓ Branch 87 → 88 taken 1545 times.
9285 for (const auto &statement : statements) {
126
3/3
✓ Branch 70 → 71 taken 7740 times.
✓ Branch 71 → 72 taken 5 times.
✓ Branch 71 → 85 taken 7735 times.
7740 if (!query.exec(statement)) {
127
3/3
✓ Branch 72 → 73 taken 5 times.
✓ Branch 73 → 74 taken 5 times.
✓ Branch 77 → 78 taken 5 times.
10 *error = QStringLiteral("Cannot initialize predictor revisions: ") + query.lastError().text();
128 5 return false;
129 }
130 }
131
5/5
✓ Branch 88 → 89 taken 1545 times.
✓ Branch 89 → 90 taken 1 time.
✓ Branch 89 → 96 taken 1544 times.
✓ Branch 90 → 91 taken 1 time.
✓ Branch 91 → 92 taken 1 time.
1545 if (!db.commit()) { *error = db.lastError().text(); return false; }
132 1544 rollback.dismiss();
133 1544 return true;
134 10980 }
135
136 // createSchema step: folders, headers and bodies with their indexes and derived counters.
137 1694 bool MailCacheSchema::createMessageTables() {
138
1/1
✓ Branch 2 → 3 taken 1694 times.
1694 QSqlQuery q(m_db);
139
140
1/1
✓ Branch 6 → 7 taken 1694 times.
1694 bool ok = q.exec(QStringLiteral("CREATE TABLE IF NOT EXISTS folders ("
141 " id INTEGER PRIMARY KEY AUTOINCREMENT,"
142 " account TEXT NOT NULL,"
143 " path TEXT NOT NULL,"
144 " uidvalidity INTEGER DEFAULT 0,"
145 " last_sync INTEGER DEFAULT 0,"
146 " highest_modseq INTEGER DEFAULT 0,"
147 " UNIQUE(account, path)"
148 ")"));
149
2/2
✓ Branch 9 → 10 taken 1 time.
✓ Branch 9 → 26 taken 1693 times.
1694 if (!ok) {
150
5/5
✓ Branch 10 → 11 taken 1 time.
✓ Branch 14 → 15 taken 1 time.
✓ Branch 15 → 16 taken 1 time.
✓ Branch 24 → 12 taken 1 time.
✓ Branch 24 → 25 taken 1 time.
2 qCWarning(lcCache) << "Failed to create folders table:"
151
3/3
✓ Branch 16 → 17 taken 1 time.
✓ Branch 17 → 18 taken 1 time.
✓ Branch 18 → 19 taken 1 time.
1 << q.lastError().text();
152 1 return false;
153 }
154
155
1/1
✓ Branch 29 → 30 taken 1693 times.
1693 ok = q.exec(QStringLiteral(
156 "CREATE TABLE IF NOT EXISTS headers ("
157 " id INTEGER PRIMARY KEY AUTOINCREMENT,"
158 " folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,"
159 " uid INTEGER NOT NULL,"
160 " subject TEXT,"
161 " from_addr TEXT,"
162 " to_addr TEXT,"
163 " date INTEGER,"
164 " flags INTEGER DEFAULT 0,"
165 " size INTEGER DEFAULT 0,"
166 " has_attachments INTEGER DEFAULT 0,"
167 " message_id TEXT,"
168 " in_reply_to TEXT,"
169 " ref_ids TEXT,"
170 " is_spam INTEGER DEFAULT 0,"
171 " cc_addr TEXT," // NULL = not known (S95.1.11)
172 " list_id TEXT,"
173 // Sprint 105 S105.2.3: the server spam filter (NULL = no filter field).
174 " spam_score REAL,"
175 " spam_threshold REAL,"
176 " spam_tool TEXT,"
177 " UNIQUE(folder_id, uid)"
178 ")"));
179
2/2
✓ Branch 32 → 33 taken 1 time.
✓ Branch 32 → 49 taken 1692 times.
1693 if (!ok) {
180
5/5
✓ Branch 33 → 34 taken 1 time.
✓ Branch 37 → 38 taken 1 time.
✓ Branch 38 → 39 taken 1 time.
✓ Branch 47 → 35 taken 1 time.
✓ Branch 47 → 48 taken 1 time.
2 qCWarning(lcCache) << "Failed to create headers table:"
181
3/3
✓ Branch 39 → 40 taken 1 time.
✓ Branch 40 → 41 taken 1 time.
✓ Branch 41 → 42 taken 1 time.
1 << q.lastError().text();
182 1 return false;
183 }
184
185
1/1
✓ Branch 52 → 53 taken 1692 times.
1692 ok = q.exec(QStringLiteral("CREATE TABLE IF NOT EXISTS bodies ("
186 " header_id INTEGER PRIMARY KEY REFERENCES "
187 "headers(id) ON DELETE CASCADE,"
188 " text_plain TEXT,"
189 " text_html TEXT,"
190 " raw_body BLOB,"
191 " fetched_at INTEGER"
192 ")"));
193
2/2
✓ Branch 55 → 56 taken 1 time.
✓ Branch 55 → 72 taken 1691 times.
1692 if (!ok) {
194
5/5
✓ Branch 56 → 57 taken 1 time.
✓ Branch 60 → 61 taken 1 time.
✓ Branch 61 → 62 taken 1 time.
✓ Branch 70 → 58 taken 1 time.
✓ Branch 70 → 71 taken 1 time.
2 qCWarning(lcCache) << "Failed to create bodies table:"
195
3/3
✓ Branch 62 → 63 taken 1 time.
✓ Branch 63 → 64 taken 1 time.
✓ Branch 64 → 65 taken 1 time.
1 << q.lastError().text();
196 1 return false;
197 }
198
199 // Indexes for fast folder-based queries
200
1/1
✓ Branch 75 → 76 taken 1691 times.
1691 q.exec(QStringLiteral("CREATE INDEX IF NOT EXISTS idx_headers_folder_date "
201 "ON headers(folder_id, date DESC)"));
202
1/1
✓ Branch 81 → 82 taken 1691 times.
1691 q.exec(QStringLiteral("CREATE INDEX IF NOT EXISTS idx_headers_folder_page "
203 "ON headers(folder_id, date DESC, uid DESC)"));
204
1/1
✓ Branch 87 → 88 taken 1691 times.
1691 q.exec(QStringLiteral("CREATE INDEX IF NOT EXISTS idx_headers_folder_uid "
205 "ON headers(folder_id, uid)"));
206 // Both unread aggregates and bounded mark-read pages use this predicate.
207 // Avoid fetching every wide header row just to inspect its Seen bit.
208
1/1
✓ Branch 93 → 94 taken 1691 times.
1691 q.exec(QStringLiteral("CREATE INDEX IF NOT EXISTS idx_headers_unread_uid "
209 "ON headers(folder_id, uid) WHERE (flags & 1) = 0"));
210
3/3
✓ Branch 96 → 97 taken 1691 times.
✓ Branch 97 → 98 taken 11 times.
✓ Branch 97 → 99 taken 1680 times.
1691 if (!ensureHeaderCounts(m_db, &m_lastError))
211 11 return false;
212
3/3
✓ Branch 99 → 100 taken 1680 times.
✓ Branch 100 → 101 taken 7 times.
✓ Branch 100 → 102 taken 1673 times.
1680 if (!ensureTrainingRevisions(m_db, &m_lastError))
213 7 return false;
214 // T-096: Index for thread lookups by message_id
215
1/1
✓ Branch 105 → 106 taken 1673 times.
1673 q.exec(QStringLiteral("CREATE INDEX IF NOT EXISTS idx_headers_msgid "
216 "ON headers(message_id)"));
217
1/1
✓ Branch 111 → 112 taken 1673 times.
1673 q.exec(QStringLiteral("CREATE INDEX IF NOT EXISTS idx_bodies_fetched_at "
218 "ON bodies(fetched_at)"));
219 1673 return true;
220 1694 }
221
222 // createSchema step: attachments, folder badges and IMAP keyword labels.
223 1673 bool MailCacheSchema::createAttachmentAndLabelTables() {
224
1/1
✓ Branch 2 → 3 taken 1673 times.
1673 QSqlQuery q(m_db);
225 1673 bool ok = false;
226 // Attachments table: BLOB data stored for lazy loading
227
1/1
✓ Branch 6 → 7 taken 1673 times.
1673 ok = q.exec(QStringLiteral(
228 "CREATE TABLE IF NOT EXISTS attachments ("
229 " id INTEGER PRIMARY KEY AUTOINCREMENT,"
230 " header_id INTEGER NOT NULL REFERENCES headers(id) ON DELETE CASCADE,"
231 " filename TEXT,"
232 " content_type TEXT,"
233 " size INTEGER DEFAULT 0,"
234 " content_id TEXT,"
235 " presentation INTEGER DEFAULT 0,"
236 " data BLOB"
237 ")"));
238
2/2
✓ Branch 9 → 10 taken 1 time.
✓ Branch 9 → 26 taken 1672 times.
1673 if (!ok) {
239
5/5
✓ Branch 10 → 11 taken 1 time.
✓ Branch 14 → 15 taken 1 time.
✓ Branch 15 → 16 taken 1 time.
✓ Branch 24 → 12 taken 1 time.
✓ Branch 24 → 25 taken 1 time.
2 qCWarning(lcCache) << "Failed to create attachments table:"
240
3/3
✓ Branch 16 → 17 taken 1 time.
✓ Branch 17 → 18 taken 1 time.
✓ Branch 18 → 19 taken 1 time.
1 << q.lastError().text();
241 1 return false;
242 }
243
244
1/1
✓ Branch 29 → 30 taken 1672 times.
1672 q.exec(QStringLiteral("CREATE INDEX IF NOT EXISTS idx_attachments_header "
245 "ON attachments(header_id)"));
246
247 // T-075: Badge cache table for persisting STATUS UNSEEN counts
248
1/1
✓ Branch 35 → 36 taken 1672 times.
1672 ok = q.exec(QStringLiteral(
249 "CREATE TABLE IF NOT EXISTS folder_badges ("
250 " folder_id INTEGER PRIMARY KEY REFERENCES folders(id) ON DELETE CASCADE,"
251 " unseen INTEGER DEFAULT 0,"
252 " updated_at INTEGER DEFAULT 0"
253 ")"));
254
2/2
✓ Branch 38 → 39 taken 1 time.
✓ Branch 38 → 55 taken 1671 times.
1672 if (!ok) {
255
5/5
✓ Branch 39 → 40 taken 1 time.
✓ Branch 43 → 44 taken 1 time.
✓ Branch 44 → 45 taken 1 time.
✓ Branch 53 → 41 taken 1 time.
✓ Branch 53 → 54 taken 1 time.
2 qCWarning(lcCache) << "Failed to create folder_badges table:"
256
3/3
✓ Branch 45 → 46 taken 1 time.
✓ Branch 46 → 47 taken 1 time.
✓ Branch 47 → 48 taken 1 time.
1 << q.lastError().text();
257 1 return false;
258 }
259
260 // T-086: Labels table for IMAP keywords
261
1/1
✓ Branch 58 → 59 taken 1671 times.
1671 ok = q.exec(QStringLiteral(
262 "CREATE TABLE IF NOT EXISTS mail_labels ("
263 " id INTEGER PRIMARY KEY AUTOINCREMENT,"
264 " header_id INTEGER NOT NULL REFERENCES headers(id) ON DELETE CASCADE,"
265 " label TEXT NOT NULL,"
266 " UNIQUE(header_id, label)"
267 ")"));
268
2/2
✓ Branch 61 → 62 taken 1 time.
✓ Branch 61 → 78 taken 1670 times.
1671 if (!ok) {
269
5/5
✓ Branch 62 → 63 taken 1 time.
✓ Branch 66 → 67 taken 1 time.
✓ Branch 67 → 68 taken 1 time.
✓ Branch 76 → 64 taken 1 time.
✓ Branch 76 → 77 taken 1 time.
2 qCWarning(lcCache) << "Failed to create mail_labels table:"
270
3/3
✓ Branch 68 → 69 taken 1 time.
✓ Branch 69 → 70 taken 1 time.
✓ Branch 70 → 71 taken 1 time.
1 << q.lastError().text();
271 1 return false;
272 }
273
274
1/1
✓ Branch 81 → 82 taken 1670 times.
1670 q.exec(QStringLiteral("CREATE INDEX IF NOT EXISTS idx_mail_labels_header "
275 "ON mail_labels(header_id)"));
276
277 // Sprint 105 S105.5.1 (D-7, schema v26): one row per mail and address
278 // (MailAddressIndex: normalized, role 0 From / 1 To / 2 Cc) for the
279 // contact history. Rows follow their header (ON DELETE CASCADE); the
280 // writer replaces them with the header row. cache_meta
281 // 'address_index_watermark' exists only in caches migrated from v25: the
282 // highest header id the background backfill indexed so far (no row =
283 // complete; rows written since v26 are always indexed by the writer).
284
1/1
✓ Branch 87 → 88 taken 1670 times.
1670 ok = q.exec(QStringLiteral(
285 "CREATE TABLE IF NOT EXISTS message_addresses ("
286 " header_id INTEGER NOT NULL REFERENCES headers(id) ON DELETE CASCADE,"
287 " address TEXT NOT NULL,"
288 " role INTEGER NOT NULL,"
289 " date INTEGER,"
290 " PRIMARY KEY(header_id, role, address)"
291 ") WITHOUT ROWID"));
292
1/2
✗ Branch 90 → 91 not taken.
✓ Branch 90 → 107 taken 1670 times.
1670 if (!ok) {
293 ✗ qCWarning(lcCache) << "Failed to create message_addresses table:" << q.lastError().text();
294 ✗ return false;
295 }
296
1/1
✓ Branch 110 → 111 taken 1670 times.
1670 q.exec(QStringLiteral("CREATE INDEX IF NOT EXISTS idx_message_addresses_address_date "
297 "ON message_addresses(address, date DESC)"));
298 1670 return true;
299 1673 }
300
301 // createSchema step: the durable mutation journal and its APPEND payloads.
302 1670 bool MailCacheSchema::createMutationJournalTables() {
303
1/1
✓ Branch 2 → 3 taken 1670 times.
1670 QSqlQuery q(m_db);
304 1670 bool ok = false;
305 // Sprint 84: cache-adjacent durable intent. Do not add message content or
306 // credentials here. The bounded text columns contain only mailbox identity,
307 // flag/target, Message-ID, UIDPLUS evidence, and a terminal diagnostic.
308
1/1
✓ Branch 6 → 7 taken 1670 times.
1670 ok = q.exec(QStringLiteral(
309 "CREATE TABLE IF NOT EXISTS mutation_journal ("
310 " token INTEGER PRIMARY KEY AUTOINCREMENT,"
311 " account_id TEXT NOT NULL,"
312 " folder_id INTEGER NOT NULL,"
313 " folder_path TEXT NOT NULL,"
314 " uid INTEGER NOT NULL,"
315 " kind INTEGER NOT NULL,"
316 " replay_class INTEGER NOT NULL,"
317 " phase INTEGER NOT NULL,"
318 " flag_name TEXT DEFAULT '',"
319 " desired_state INTEGER DEFAULT 0,"
320 " target_folder TEXT DEFAULT '',"
321 " message_id TEXT DEFAULT '',"
322 " uidvalidity INTEGER DEFAULT 0,"
323 " uidplus_evidence TEXT DEFAULT '',"
324 " error TEXT DEFAULT '',"
325 " created_at INTEGER NOT NULL,"
326 " updated_at INTEGER NOT NULL"
327 ")"));
328
1/2
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 26 taken 1670 times.
1670 if (!ok) {
329 ✗ qCWarning(lcCache) << "Failed to create mutation_journal table:"
330 ✗ << q.lastError().text();
331 ✗ return false;
332 }
333
1/1
✓ Branch 29 → 30 taken 1670 times.
1670 q.exec(QStringLiteral(
334 "CREATE INDEX IF NOT EXISTS idx_mutation_journal_account_phase "
335 "ON mutation_journal(account_id, phase, token)"));
336
1/1
✓ Branch 35 → 36 taken 1670 times.
1670 q.exec(QStringLiteral(
337 "CREATE INDEX IF NOT EXISTS idx_mutation_journal_identity "
338 "ON mutation_journal(account_id, folder_id, uid, flag_name)"));
339 // Page reads exclude pending moves per row (headerPageSelection).
340
1/1
✓ Branch 41 → 42 taken 1670 times.
1670 q.exec(QStringLiteral(
341 "CREATE INDEX IF NOT EXISTS idx_mutation_journal_folder_uid "
342 "ON mutation_journal(folder_id, uid)"));
343
344
2/3
✓ Branch 44 → 45 taken 1670 times.
✗ Branch 45 → 46 not taken.
✓ Branch 45 → 52 taken 1670 times.
1670 if (!q.exec(AppendPayloadSchema)) {
345 ✗ m_lastError = q.lastError().text();
346 ✗ return false;
347 }
348 1670 return true;
349 1670 }
350
351 // createSchema step: whitelist, cache_meta and the payload byte-counter triggers.
352 1670 bool MailCacheSchema::createMetaTables() {
353
1/1
✓ Branch 2 → 3 taken 1670 times.
1670 QSqlQuery q(m_db);
354 1670 bool ok = false;
355 // T-122: External content whitelist
356
1/1
✓ Branch 6 → 7 taken 1670 times.
1670 ok = q.exec(QStringLiteral(
357 "CREATE TABLE IF NOT EXISTS external_content_whitelist ("
358 " id INTEGER PRIMARY KEY AUTOINCREMENT,"
359 " type TEXT NOT NULL CHECK(type IN ('sender','domain')),"
360 " value TEXT NOT NULL,"
361 " created_at TEXT DEFAULT (datetime('now')),"
362 " UNIQUE(type, value)"
363 ")"));
364
2/2
✓ Branch 9 → 10 taken 1 time.
✓ Branch 9 → 26 taken 1669 times.
1670 if (!ok) {
365
5/5
✓ Branch 10 → 11 taken 1 time.
✓ Branch 14 → 15 taken 1 time.
✓ Branch 15 → 16 taken 1 time.
✓ Branch 24 → 12 taken 1 time.
✓ Branch 24 → 25 taken 1 time.
2 qCWarning(lcCache) << "Failed to create whitelist table:"
366
3/3
✓ Branch 16 → 17 taken 1 time.
✓ Branch 17 → 18 taken 1 time.
✓ Branch 18 → 19 taken 1 time.
1 << q.lastError().text();
367 1 return false;
368 }
369
370 // SEC-2026-07-21-23: cache_meta table for crash-safe FTS rebuild watermark.
371
1/1
✓ Branch 29 → 30 taken 1669 times.
1669 ok = q.exec(QStringLiteral("CREATE TABLE IF NOT EXISTS cache_meta ("
372 " key TEXT PRIMARY KEY,"
373 " value TEXT"
374 ")"));
375
1/2
✗ Branch 32 → 33 not taken.
✓ Branch 32 → 49 taken 1669 times.
1669 if (!ok) {
376 ✗ qCWarning(lcCache) << "Failed to create cache_meta table:" // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; schema result remains measured.
377 ✗ << q.lastError().text();
378 ✗ return false;
379 }
380
381
1/1
✓ Branch 52 → 53 taken 1669 times.
1669 ok = q.exec(QStringLiteral(
382 "INSERT OR IGNORE INTO cache_meta(key, value) SELECT "
383 "'payload_cache_bytes', CAST("
384 "COALESCE((SELECT SUM(COALESCE(length(CAST(text_plain AS BLOB)), 0) + "
385 "COALESCE(length(CAST(text_html AS BLOB)), 0) + "
386 "COALESCE(length(raw_body), 0)) FROM bodies), 0) + "
387 "COALESCE((SELECT SUM(COALESCE(length(data), 0)) FROM attachments), 0) "
388 "AS TEXT) WHERE NOT EXISTS "
389 "(SELECT 1 FROM cache_meta WHERE key='payload_cache_bytes')"));
390
2/2
✓ Branch 55 → 56 taken 1 time.
✓ Branch 55 → 72 taken 1668 times.
1669 if (!ok) {
391
5/5
✓ Branch 56 → 57 taken 1 time.
✓ Branch 60 → 61 taken 1 time.
✓ Branch 61 → 62 taken 1 time.
✓ Branch 70 → 58 taken 1 time.
✓ Branch 70 → 71 taken 1 time.
2 qCWarning(lcCache) << "Failed to initialize payload byte counter:"
392
3/3
✓ Branch 62 → 63 taken 1 time.
✓ Branch 63 → 64 taken 1 time.
✓ Branch 64 → 65 taken 1 time.
1 << q.lastError().text();
393 1 return false;
394 }
395
396 1668 const QString bodyBytes = QStringLiteral(
397 "COALESCE(length(CAST(%1.text_plain AS BLOB)), 0) + "
398 "COALESCE(length(CAST(%1.text_html AS BLOB)), 0) + "
399 "COALESCE(length(%1.raw_body), 0)");
400 const QStringList payloadTriggers = {
401 3336 QStringLiteral(
402 "CREATE TRIGGER IF NOT EXISTS payload_bodies_ai AFTER INSERT ON "
403 "bodies BEGIN UPDATE cache_meta SET value = CAST(CAST(value AS "
404 "INTEGER) + (%1) AS TEXT) WHERE key='payload_cache_bytes'; END")
405
1/1
✓ Branch 84 → 85 taken 1668 times.
3336 .arg(bodyBytes.arg(QStringLiteral("new"))),
406 3336 QStringLiteral(
407 "CREATE TRIGGER IF NOT EXISTS payload_bodies_ad AFTER DELETE ON "
408 "bodies BEGIN UPDATE cache_meta SET value = CAST(CAST(value AS "
409 "INTEGER) - (%1) AS TEXT) WHERE key='payload_cache_bytes'; END")
410
1/1
✓ Branch 94 → 95 taken 1668 times.
3336 .arg(bodyBytes.arg(QStringLiteral("old"))),
411 3336 QStringLiteral(
412 "CREATE TRIGGER IF NOT EXISTS payload_bodies_au AFTER UPDATE ON "
413 "bodies BEGIN UPDATE cache_meta SET value = CAST(CAST(value AS "
414 "INTEGER) + (%1) - (%2) AS TEXT) WHERE "
415 "key='payload_cache_bytes'; END")
416
1/1
✓ Branch 108 → 109 taken 1668 times.
3336 .arg(bodyBytes.arg(QStringLiteral("new")),
417
1/1
✓ Branch 103 → 104 taken 1668 times.
3336 bodyBytes.arg(QStringLiteral("old"))),
418 1668 QStringLiteral(
419 "CREATE TRIGGER IF NOT EXISTS payload_attachments_ai AFTER INSERT "
420 "ON attachments BEGIN UPDATE cache_meta SET value = "
421 "CAST(CAST(value AS INTEGER) + COALESCE(length(new.data), 0) AS "
422 "TEXT) WHERE key='payload_cache_bytes'; END"),
423 1668 QStringLiteral(
424 "CREATE TRIGGER IF NOT EXISTS payload_attachments_ad AFTER DELETE "
425 "ON attachments BEGIN UPDATE cache_meta SET value = "
426 "CAST(CAST(value AS INTEGER) - COALESCE(length(old.data), 0) AS "
427 "TEXT) WHERE key='payload_cache_bytes'; END"),
428 1668 QStringLiteral(
429 "CREATE TRIGGER IF NOT EXISTS payload_attachments_au AFTER UPDATE "
430 "ON attachments BEGIN UPDATE cache_meta SET value = "
431 "CAST(CAST(value AS INTEGER) + COALESCE(length(new.data), 0) - "
432 "COALESCE(length(old.data), 0) AS TEXT) WHERE "
433 "key='payload_cache_bytes'; END"),
434
2/4
✓ Branch 121 → 122 taken 10008 times.
✓ Branch 121 → 123 taken 1668 times.
✗ Branch 226 → 227 not taken.
✗ Branch 226 → 228 not taken.
11676 };
435
2/2
✓ Branch 167 → 146 taken 9993 times.
✓ Branch 167 → 168 taken 1662 times.
11655 for (const QString &trigger : payloadTriggers) {
436
3/3
✓ Branch 147 → 148 taken 9993 times.
✓ Branch 148 → 149 taken 6 times.
✓ Branch 148 → 165 taken 9987 times.
9993 if (!q.exec(trigger)) {
437
5/5
✓ Branch 149 → 150 taken 6 times.
✓ Branch 153 → 154 taken 6 times.
✓ Branch 154 → 155 taken 6 times.
✓ Branch 163 → 151 taken 6 times.
✓ Branch 163 → 164 taken 6 times.
12 qCWarning(lcCache) << "Failed to create payload accounting trigger:"
438
3/3
✓ Branch 155 → 156 taken 6 times.
✓ Branch 156 → 157 taken 6 times.
✓ Branch 157 → 158 taken 6 times.
6 << q.lastError().text();
439 6 return false;
440 }
441 }
442 1662 return true;
443 16682 }
444
445 // createSchema step: the search projection, the FTS5 index and its triggers.
446 1662 bool MailCacheSchema::createSearchTables() {
447
1/1
✓ Branch 2 → 3 taken 1662 times.
1662 QSqlQuery q(m_db);
448 1662 bool ok = false;
449 // Canonical folded projection for search. FTS uses this as external content
450 // so the virtual table does not retain another complete copy of message
451 // fields. Writers update this row in the same transaction as header/body
452 // durability and the triggers apply exactly one index replacement.
453
1/1
✓ Branch 6 → 7 taken 1662 times.
1662 ok = q.exec(QStringLiteral(
454 "CREATE TABLE IF NOT EXISTS search_documents ("
455 " header_id INTEGER PRIMARY KEY REFERENCES headers(id) ON DELETE CASCADE,"
456 " subject TEXT NOT NULL DEFAULT '',"
457 " from_addr TEXT NOT NULL DEFAULT '',"
458 " to_addr TEXT NOT NULL DEFAULT '',"
459 " body_text TEXT NOT NULL DEFAULT ''"
460 ")"));
461
1/2
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 26 taken 1662 times.
1662 if (!ok) {
462 ✗ qCWarning(lcCache) << "Failed to create search document table:"
463 ✗ << q.lastError().text();
464 ✗ return false;
465 }
466
467 // T-179: FTS5 full-text search index on cached emails.
468 // Uses the trigram tokenizer for substring matching ANYWHERE in a word
469 // (e.g. "rechnung" matches "Stromrechnung") — important for German compound
470 // words. Diacritic insensitivity is handled by folding the text ourselves
471 // (foldForSearch) before it is stored, because trigram's remove_diacritics
472 // option only exists in SQLite >= 3.45. Trigram requires search terms of at
473 // least 3 characters.
474
1/1
✓ Branch 29 → 30 taken 1662 times.
1662 ok = q.exec(QStringLiteral(
475 "CREATE VIRTUAL TABLE IF NOT EXISTS mail_fts USING fts5("
476 " subject, from_addr, to_addr, body_text,"
477 " content='search_documents', content_rowid='header_id',"
478 " columnsize=0,"
479 " tokenize = 'trigram'"
480 ")"));
481 1662 const bool ftsAvailable = ok;
482
2/2
✓ Branch 32 → 33 taken 2 times.
✓ Branch 32 → 49 taken 1660 times.
1662 if (!ok) {
483
5/5
✓ Branch 33 → 34 taken 2 times.
✓ Branch 37 → 38 taken 2 times.
✓ Branch 38 → 39 taken 2 times.
✓ Branch 47 → 35 taken 2 times.
✓ Branch 47 → 48 taken 2 times.
4 qCWarning(lcCache) << "Failed to create FTS5 table:"
484
3/3
✓ Branch 39 → 40 taken 2 times.
✓ Branch 40 → 41 taken 2 times.
✓ Branch 41 → 42 taken 2 times.
2 << q.lastError().text();
485 // Non-fatal: search will be unavailable but core functionality works
486 }
487
488
1/1
✓ Branch 52 → 53 taken 1662 times.
1662 ok = q.exec(QStringLiteral(
489 "INSERT OR IGNORE INTO cache_meta(key,value) SELECT "
490 "'fts_indexed_documents', CAST(CASE WHEN EXISTS("
491 "SELECT 1 FROM cache_meta WHERE key='fts_rebuild_in_progress') "
492 "THEN 0 ELSE (SELECT COUNT(*) FROM search_documents) END AS TEXT)"));
493
1/2
✗ Branch 55 → 56 not taken.
✓ Branch 55 → 72 taken 1662 times.
1662 if (!ok) {
494 ✗ qCWarning(lcCache) << "Failed to initialize FTS document counter:"
495 ✗ << q.lastError().text();
496 ✗ return false;
497 }
498
499 const QStringList searchTriggers = {
500 ✗ QStringLiteral(
501 "CREATE TRIGGER IF NOT EXISTS search_documents_ai AFTER INSERT ON "
502 "search_documents BEGIN INSERT INTO mail_fts(rowid, subject, "
503 "from_addr, to_addr, body_text) VALUES (new.header_id, new.subject, "
504 "new.from_addr, new.to_addr, new.body_text); UPDATE cache_meta SET "
505 "value=CAST(CAST(value AS INTEGER)+1 AS TEXT) WHERE "
506 "key='fts_indexed_documents'; END"),
507 1662 QStringLiteral(
508 "CREATE TRIGGER IF NOT EXISTS search_documents_ad AFTER DELETE ON "
509 "search_documents BEGIN INSERT INTO mail_fts(mail_fts, rowid, "
510 "subject, from_addr, to_addr, body_text) VALUES ('delete', "
511 "old.header_id, old.subject, old.from_addr, old.to_addr, "
512 "old.body_text); UPDATE cache_meta SET value=CAST(MAX(0, "
513 "CAST(value AS INTEGER)-1) AS TEXT) WHERE "
514 "key='fts_indexed_documents'; END"),
515 1662 QStringLiteral(
516 "CREATE TRIGGER IF NOT EXISTS search_documents_au AFTER UPDATE ON "
517 "search_documents BEGIN INSERT INTO mail_fts(mail_fts, rowid, "
518 "subject, from_addr, to_addr, body_text) VALUES ('delete', "
519 "old.header_id, old.subject, old.from_addr, old.to_addr, "
520 "old.body_text); INSERT INTO mail_fts(rowid, subject, from_addr, "
521 "to_addr, body_text) VALUES (new.header_id, new.subject, "
522 "new.from_addr, new.to_addr, new.body_text); END"),
523
2/4
✓ Branch 83 → 84 taken 4986 times.
✓ Branch 83 → 85 taken 1662 times.
✗ Branch 203 → 204 not taken.
✗ Branch 203 → 205 not taken.
6648 };
524
2/2
✓ Branch 88 → 89 taken 2 times.
✓ Branch 88 → 122 taken 1660 times.
1662 if (!ftsAvailable) {
525 // S106-111: triggers into a missing mail_fts would make every write to
526 // search_documents fail (SQLite resolves trigger bodies when they fire),
527 // so "non-fatal" holds only without them.
528
2/2
✓ Branch 120 → 91 taken 6 times.
✓ Branch 120 → 121 taken 2 times.
8 for (const char *name : {"search_documents_ai", "search_documents_ad", "search_documents_au"}) {
529
4/5
✓ Branch 92 → 93 taken 6 times.
✓ Branch 96 → 97 taken 6 times.
✓ Branch 97 → 98 taken 6 times.
✗ Branch 102 → 103 not taken.
✓ Branch 102 → 119 taken 6 times.
12 if (!q.exec(QStringLiteral("DROP TRIGGER IF EXISTS ") + QLatin1String(name))) {
530 ✗ qCWarning(lcCache) << "Failed to drop search document trigger:" << q.lastError().text();
531 ✗ return false;
532 }
533 }
534 2 return true;
535 }
536
2/2
✓ Branch 145 → 124 taken 4980 times.
✓ Branch 145 → 146 taken 1660 times.
6640 for (const QString &trigger : searchTriggers) {
537
2/3
✓ Branch 125 → 126 taken 4980 times.
✗ Branch 126 → 127 not taken.
✓ Branch 126 → 143 taken 4980 times.
4980 if (!q.exec(trigger)) {
538 ✗ qCWarning(lcCache) << "Failed to create search document trigger:"
539 ✗ << q.lastError().text();
540 ✗ return false;
541 }
542 }
543 1660 return true;
544 8310 }
545
546 // Create database schema (tables + indexes); formerly MailCache::createSchema().
547 1694 bool MailCacheSchema::createSchema() {
548
2/2
✓ Branch 5 → 6 taken 1670 times.
✓ Branch 5 → 15 taken 3 times.
3367 return createMessageTables() && createAttachmentAndLabelTables() &&
549
3/4
✓ Branch 7 → 8 taken 1670 times.
✗ Branch 7 → 15 not taken.
✓ Branch 9 → 10 taken 1662 times.
✓ Branch 9 → 15 taken 8 times.
1670 createMutationJournalTables() && createMetaTables() &&
550
4/6
✓ Branch 3 → 4 taken 1673 times.
✓ Branch 3 → 15 taken 21 times.
✓ Branch 11 → 12 taken 1662 times.
✗ Branch 11 → 15 not taken.
✓ Branch 13 → 14 taken 1662 times.
✗ Branch 13 → 15 not taken.
3367 createSearchTables() && ensureThreadProjectionSchema(m_db, &m_lastError);
551 }
552