data/MailCache.h
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #pragma once | ||
| 2 | |||
| 3 | #include <memory> | ||
| 4 | #include <QMutex> | ||
| 5 | |||
| 6 | #include <QList> | ||
| 7 | #include <QHash> | ||
| 8 | #include <QMap> | ||
| 9 | #include <QObject> | ||
| 10 | #include <QPair> | ||
| 11 | #include <QSet> | ||
| 12 | #include <QSqlDatabase> | ||
| 13 | #include <QSqlQuery> | ||
| 14 | |||
| 15 | struct sqlite3; | ||
| 16 | #include <atomic> | ||
| 17 | #include <functional> | ||
| 18 | #include <optional> | ||
| 19 | |||
| 20 | #include "data/Models.h" | ||
| 21 | #include "data/MailDisplaySnapshot.h" | ||
| 22 | #include "data/MailHeaderPage.h" | ||
| 23 | #include "data/MailSearchPage.h" | ||
| 24 | #include "data/MailSelectionSnapshot.h" | ||
| 25 | #include "data/MailSelectionMutation.h" | ||
| 26 | #include "data/MutationJournal.h" | ||
| 27 | #include "data/AttachmentStore.h" | ||
| 28 | #include "data/ExternalContentAllowlist.h" | ||
| 29 | #include "data/MailCacheSchema.h" | ||
| 30 | #include "data/MailSearchIndex.h" | ||
| 31 | #include "data/MutationJournalStore.h" | ||
| 32 | #include "data/PayloadCacheLimits.h" | ||
| 33 | #include "data/MailSnapshotReader.h" | ||
| 34 | #include "data/PredictorTrainingReader.h" | ||
| 35 | |||
| 36 | |||
| 37 | // MailCache provides persistent storage for mail headers and bodies using | ||
| 38 | // SQLite. Designed for cache-first architecture: all reads go through this | ||
| 39 | // class first, IMAP fetches only happen on cache miss or incremental sync. | ||
| 40 | // | ||
| 41 | // Performance: Uses WAL journal mode and batch transactions for fast writes. | ||
| 42 | // Thread safety: Not thread-safe. Use from a single thread or with external | ||
| 43 | // locking. | ||
| 44 | class MailCache : public QObject { | ||
| 45 | Q_OBJECT | ||
| 46 | |||
| 47 | public: | ||
| 48 | // Shared with deterministic fixture validation so schema migrations cannot | ||
| 49 | // silently leave generated performance artifacts on an older contract. | ||
| 50 | static constexpr int SchemaVersion = 26; | ||
| 51 | |||
| 52 | explicit MailCache(QObject *parent = nullptr); | ||
| 53 | ~MailCache() override; | ||
| 54 | |||
| 55 | // Open or create the SQLite database at the given path. | ||
| 56 | // Creates schema (tables + indexes) if the database is new. | ||
| 57 | // Returns false on failure (check lastError()). | ||
| 58 | bool open(const QString &dbPath); | ||
| 59 | // Worker-owned existing databases only: no migrations, eviction or writes. | ||
| 60 | bool openReadOnly(const QString &dbPath, int busyTimeoutMs = 100); | ||
| 61 | // Existing initialized cache only; worker owns connection and transactions. | ||
| 62 | // Writers wait for the lock like the product connection (5 s): the | ||
| 63 | // former 100 ms default (copied from readers, which WAL never blocks) | ||
| 64 | // failed background header, body and journal writes whenever another | ||
| 65 | // connection wrote a larger unit, aborting whole folder syncs. | ||
| 66 | bool openExistingWriter(const QString &dbPath, int busyTimeoutMs = 5000); | ||
| 67 | MailHeaderPageSnapshot headerPageSnapshot(const MailHeaderPageRequest &request, | ||
| 68 | const std::atomic_bool *cancelled = nullptr); | ||
| 69 | // Bounded header-only neighbour in the same account/folder, in list order. | ||
| 70 | // Ready supplies UID/date and a 1024-character title, never body or labels. | ||
| 71 | MailDisplaySnapshot navigationSnapshot(const QString &accountId, | ||
| 72 | qint64 folderId, qint64 uid, bool before, | ||
| 73 | const MailSortOrder &sort = {}); | ||
| 74 | // Close the database connection. | ||
| 75 | void close(); | ||
| 76 | |||
| 77 | // True if the database is currently open. | ||
| 78 | bool isOpen() const; | ||
| 79 | |||
| 80 | // Path of the database file (for opening secondary connections). | ||
| 81 | 8947 | QString databasePath() const { return m_dbPath; } | |
| 82 | // Test/diagnostic contract (Sprint 91 rule 8 replaced the former friend | ||
| 83 | // access): the owning connection and schema helper for fault-injection | ||
| 84 | // tests, and the header row lookup the stores receive through Deps. | ||
| 85 | 683 | QSqlDatabase &database() { return m_db; } | |
| 86 | 49 | MailCacheSchema &schema() { return m_schema; } | |
| 87 | qint64 headerRowId(qint64 folderId, qint64 uid) const; | ||
| 88 | 46820 | QString writeDomain() const { return m_writeDomain; } | |
| 89 | |||
| 90 | // Last error message from a failed operation. | ||
| 91 | QString lastError() const; | ||
| 92 | |||
| 93 | // --- Folder operations --- | ||
| 94 | |||
| 95 | // Ensure a folder entry exists for the given account + IMAP path. | ||
| 96 | // Returns the folder ID (creates if not exists, returns existing if found). | ||
| 97 | // Returns -1 on error. | ||
| 98 | qint64 ensureFolder(const QString &account, const QString &path); | ||
| 99 | |||
| 100 | // P90.17: bulk folder identity snapshot (id ↔ path) so UI admission paths | ||
| 101 | // answer from memory instead of one SQL round trip per call. | ||
| 102 | struct FolderIdentity { | ||
| 103 | qint64 id = -1; | ||
| 104 | QString path; | ||
| 105 | quint32 uidValidity = 0; | ||
| 106 | }; | ||
| 107 | QList<FolderIdentity> folderIdentities(const QString &account) const; | ||
| 108 | |||
| 109 | |||
| 110 | qint64 findFolderId(const QString &account, const QString &path) const; | ||
| 111 | QString folderPath(qint64 folderId) const; | ||
| 112 | |||
| 113 | // T-215: Find a mail by Message-ID across all folders. | ||
| 114 | // Returns {folderId, uid} or nullopt if not found. | ||
| 115 | std::optional<QPair<qint64,qint64>> findByMessageId(const QString &messageId) const; | ||
| 116 | // Account-scoped variant for user-facing links. A Message-ID is not an | ||
| 117 | // authorization boundary and can occur in more than one local account. | ||
| 118 | std::optional<QPair<qint64, qint64>> | ||
| 119 | findByMessageId(const QString &accountId, const QString &messageId) const; | ||
| 120 | |||
| 121 | // Set the UIDVALIDITY for a folder. If the value differs from the stored | ||
| 122 | // value, all cached data for that folder is purged (RFC 3501 requirement). | ||
| 123 | // F-63/F-72: atomic epoch transition — quarantine, purge, new epoch and | ||
| 124 | // the CONDSTORE-watermark reset commit together or not at all. Returns | ||
| 125 | // false (epoch unchanged) when any step fails. | ||
| 126 | bool setUidValidity(qint64 folderId, quint32 uidvalidity); | ||
| 127 | |||
| 128 | // Get the stored UIDVALIDITY for a folder. Returns 0 if not set. | ||
| 129 | quint32 uidValidity(qint64 folderId) const; | ||
| 130 | qint64 threadRevision(qint64 folderId) const; | ||
| 131 | struct FolderPresentationState { | ||
| 132 | int headerCount = 0; | ||
| 133 | qint64 threadRevision = -1; | ||
| 134 | quint32 uidValidity = 0; | ||
| 135 | }; | ||
| 136 | // One SQLite snapshot for the fields that select a folder's first Quick | ||
| 137 | // presentation path. The returned revision/epoch identify the indexed page. | ||
| 138 | FolderPresentationState folderPresentationState(qint64 folderId) const; | ||
| 139 | |||
| 140 | // T-208: CONDSTORE HIGHESTMODSEQ per folder | ||
| 141 | bool setHighestModseq(qint64 folderId, quint64 modseq); // false + lastError on failure | ||
| 142 | quint64 highestModseq(qint64 folderId) const; | ||
| 143 | |||
| 144 | // T-209: Last sync timestamp (seconds since epoch) | ||
| 145 | bool setLastSync(qint64 folderId); // false + lastError on failure | ||
| 146 | qint64 lastSync(qint64 folderId) const; | ||
| 147 | |||
| 148 | // --- Header operations (batch) --- | ||
| 149 | |||
| 150 | // Store a batch of headers for a folder. Uses a transaction for speed. | ||
| 151 | // Existing headers with the same UID are updated (UPSERT). | ||
| 152 | // True only when the complete header/label/search transaction committed. | ||
| 153 | bool storeHeaders(qint64 folderId, const QList<MailHeader> &headers); | ||
| 154 | // Headers fetched on another connection (server search) under the epoch | ||
| 155 | // of that connection's SELECT. S106-103: they are stored only while the | ||
| 156 | // folder still has that epoch; a folder without an epoch yet (never | ||
| 157 | // opened) adopts it first, so a later change purges these rows too. | ||
| 158 | bool storeEpochHeaders(qint64 folderId, quint32 uidValidity, const QList<MailHeader> &headers); | ||
| 159 | // Prepare a bounded streaming batch only after acquiring the writer lock and | ||
| 160 | // validating its mailbox. The callback may overlay newer local state. | ||
| 161 | bool storeStreamingHeaders(const QString &account, const QString &folder, | ||
| 162 | qint64 folderId, quint32 uidValidity, QList<MailHeader> &headers, | ||
| 163 | const std::function<QString(QList<MailHeader> &)> &prepare); | ||
| 164 | |||
| 165 | |||
| 166 | // Retrieve all cached headers for a folder, sorted by date DESC. | ||
| 167 | QList<MailHeader> headers( | ||
| 168 | qint64 folderId, | ||
| 169 | const std::function<void()> &headerRowsLoaded = {}) const; | ||
| 170 | |||
| 171 | // Stable keyset page for dense flat folders, newest first. Pass the final | ||
| 172 | // header from the previous page as `after`; null starts a new traversal. | ||
| 173 | QList<MailHeader> headerPage(qint64 folderId, int limit, | ||
| 174 | const MailHeader *after = nullptr, bool before = false, | ||
| 175 | QString *error = nullptr, bool includeAnchor = false, | ||
| 176 | const MailHeaderFilter *filter = nullptr, | ||
| 177 | const MailSortOrder &sort = {}) const; | ||
| 178 | // T-87.D1 random-seek index: uid-quantized rank buckets for a folder, | ||
| 179 | // newest-first ranks derivable by cumulative sums. Bounded bucket count; | ||
| 180 | // one covering-index scan per query (no OFFSET scans). | ||
| 181 | struct MailRankBucket { | ||
| 182 | qint64 minUid = 0; // smallest uid in the bucket | ||
| 183 | qint64 count = 0; // rows within [minUid, maxUid-of-bucket] | ||
| 184 | }; | ||
| 185 | struct MailRankIndex { | ||
| 186 | qint64 maxUid = 0; | ||
| 187 | QList<MailRankBucket> buckets; // ascending uid order | ||
| 188 | }; | ||
| 189 | MailRankIndex rankIndex(qint64 folderId, int bucketCount = 64) const; | ||
| 190 | // Rank of the first row with uid <= given uid (0-based, newest = rank 0); | ||
| 191 | // total row count when the uid is below the smallest stored uid. | ||
| 192 | qint64 rankForUidFromIndex(const MailRankIndex &index, qint64 uid) const; | ||
| 193 | // Newest uid whose rank is the first at-or-after the requested rank. | ||
| 194 | qint64 uidAtRankFromIndex(const MailRankIndex &index, qint64 rank) const; | ||
| 195 | |||
| 196 | // Retrieve a single header by folder + UID. Returns nullopt if not cached. | ||
| 197 | std::optional<MailHeader> header(qint64 folderId, qint64 uid) const; | ||
| 198 | |||
| 199 | // Hydrate an ordered result page with one joined query per bounded chunk. | ||
| 200 | // Missing identities are omitted and duplicate input identities are kept. | ||
| 201 | // A failed chunk ends the hydration; the chunks read before it are | ||
| 202 | // returned (logged). | ||
| 203 | QList<MailHeader> headersByIds(const QList<MailId> &mailIds) const; | ||
| 204 | |||
| 205 | // Get the maximum UID stored for a folder. Returns 0 if no headers cached. | ||
| 206 | // Used for incremental sync: FETCH UID maxUid+1:* | ||
| 207 | qint64 maxUid(qint64 folderId) const; | ||
| 208 | |||
| 209 | // Total number of cached headers for a folder. | ||
| 210 | int headerCount(qint64 folderId) const; | ||
| 211 | // One read snapshot for both badge totals during streamed-header publish. | ||
| 212 | QPair<int, int> folderCounts(qint64 folderId) const; | ||
| 213 | // Connection-local SQLite change token. Changes committed by other cache | ||
| 214 | // connections invalidate retained view snapshots; errors never yield a hit. | ||
| 215 | qint64 externalDataVersion() const; | ||
| 216 | |||
| 217 | // S91.1.4: bounded predictor-training reads on this connection. | ||
| 218 | 2415 | const PredictorTrainingReader &training() const { return m_training; } | |
| 219 | // S92.4.1: read-only worker snapshots (display, selection, flag compare, | ||
| 220 | // compose source) on this cache's connection. | ||
| 221 | 1350 | MailSnapshotReader &snapshots() { return m_snapshots; } | |
| 222 | const MailSnapshotReader &snapshots() const { return m_snapshots; } | ||
| 223 | using FlagSnapshotDiff = MailSnapshotReader::FlagSnapshotDiff; | ||
| 224 | using ComposeSourceSnapshot = MailSnapshotReader::ComposeSourceSnapshot; | ||
| 225 | |||
| 226 | // --- Body operations --- | ||
| 227 | |||
| 228 | // Store a mail body. The header must already exist in the cache. | ||
| 229 | // True only after body and search projection commit successfully. | ||
| 230 | bool storeBody(qint64 folderId, qint64 uid, const MailBody &body); | ||
| 231 | |||
| 232 | // Replace the complete fetched payload and search projection atomically. | ||
| 233 | bool storeMessageContent(qint64 folderId, qint64 uid, const MailBody &body, | ||
| 234 | const QList<Attachment> &attachments, | ||
| 235 | const QList<QByteArray> &blobs); | ||
| 236 | |||
| 237 | bool storeMessageContentForIdentity(const MailId &mailId, quint32 uidValidity, | ||
| 238 | const MailBody &body, const QList<Attachment> &attachments, | ||
| 239 | const QList<QByteArray> &blobs); | ||
| 240 | |||
| 241 | // Retrieve a cached mail body. Returns nullopt if not cached. | ||
| 242 | std::optional<MailBody> body(qint64 folderId, qint64 uid) const; | ||
| 243 | |||
| 244 | // Retrieve only the fields needed to render or quote a message. The raw | ||
| 245 | // RFC822 source is deliberately excluded so normal navigation does not | ||
| 246 | // allocate and retain a second copy of the complete message. | ||
| 247 | // S106-258/295: the texts are cut at QuoteTextCharacters — the callers | ||
| 248 | // quote or excerpt them (reply, forward, appointment from mail), and a | ||
| 249 | // single-line body could be as large as the payload budget. | ||
| 250 | static constexpr int QuoteTextCharacters = 256 * 1024; | ||
| 251 | std::optional<MailBody> displayBody(qint64 folderId, qint64 uid) const; | ||
| 252 | |||
| 253 | // P90.14: reply/forward composition projection — plain text plus a bounded | ||
| 254 | // envelope prefix of the raw source (header block only, never the whole | ||
| 255 | // message). rawSource carries that prefix; full raw access stays explicit | ||
| 256 | // via rawBody(). | ||
| 257 | std::optional<MailBody> replySnapshot(qint64 folderId, qint64 uid) const; | ||
| 258 | |||
| 259 | // Retrieve the raw RFC822 source only when the user explicitly requests it. | ||
| 260 | QByteArray rawBody(qint64 folderId, qint64 uid) const; | ||
| 261 | bool hasRawBody(qint64 folderId, qint64 uid) const; | ||
| 262 | |||
| 263 | // Check if a body is already cached (without loading the data). | ||
| 264 | bool hasBody(qint64 folderId, qint64 uid) const; | ||
| 265 | |||
| 266 | // --- Attachment operations --- | ||
| 267 | |||
| 268 | // S91.2.3: attachment metadata and BLOBs of cached mail. | ||
| 269 | 202 | AttachmentStore &attachments() { return m_attachments; } | |
| 270 | 9 | const AttachmentStore &attachments() const { return m_attachments; } | |
| 271 | |||
| 272 | // --- Flag operations --- | ||
| 273 | |||
| 274 | // Update flags for a single header. | ||
| 275 | void updateFlags(qint64 folderId, qint64 uid, quint32 flags); | ||
| 276 | // A separate writer committed the flag. Notify GUI-side projections and | ||
| 277 | // in-flight header fences without repeating the SQL write on this thread. | ||
| 278 | 148 | void announceFlagsCommitted(qint64 folderId, qint64 uid, quint32 flags) { | |
| 279 | 148 | emit headerFlagsChanged(folderId, uid, flags); | |
| 280 | 148 | } | |
| 281 | |||
| 282 | // Batch-update flags for multiple UIDs in a single transaction. | ||
| 283 | // Input: list of (UID, flags) pairs. | ||
| 284 | void batchUpdateFlags(qint64 folderId, | ||
| 285 | const QList<QPair<qint64, quint32>> &uidFlags); | ||
| 286 | |||
| 287 | // Remove a single header (and cascaded body/attachments) by UID. | ||
| 288 | void removeHeader(qint64 folderId, qint64 uid); | ||
| 289 | // Removes many headers (bodies cascade) in one write transaction and | ||
| 290 | // emits headerRemoved for each removed UID. Returns false on a failed write. | ||
| 291 | bool removeHeaders(qint64 folderId, const QList<qint64> &uids); | ||
| 292 | |||
| 293 | // Count of unread (unseen) headers for a folder. | ||
| 294 | int unreadCount(qint64 folderId) const; | ||
| 295 | std::optional<QList<qint64>> unreadUidPage(qint64 folderId, qint64 afterUid, | ||
| 296 | qint64 throughUid) const; | ||
| 297 | |||
| 298 | // --- Label operations (T-261) --- | ||
| 299 | |||
| 300 | // Add a label (keyword) to a cached header. No-op if already present. | ||
| 301 | void addLabel(qint64 folderId, qint64 uid, const QString &label); | ||
| 302 | |||
| 303 | // Remove a label (keyword) from a cached header. No-op if not present. | ||
| 304 | void removeLabel(qint64 folderId, qint64 uid, const QString &label); | ||
| 305 | // A separate writer committed the label. Notify GUI-side projections | ||
| 306 | // without repeating the SQL write on this thread. | ||
| 307 | 30 | void announceLabelCommitted(qint64 folderId, qint64 uid, const QString &label, bool present) { | |
| 308 | 30 | emit headerLabelChanged(folderId, uid, label, present); | |
| 309 | 30 | } | |
| 310 | |||
| 311 | // --- Durable mutation journal (Sprint 84) --- | ||
| 312 | |||
| 313 | // S91.3.2: the durable mutation journal (enqueue, claim, phases, APPEND | ||
| 314 | // payloads). mutationPhaseChanged / mutationRemoved stay MailCache signals. | ||
| 315 | 6874 | MutationJournalStore &journal() { return m_journal; } | |
| 316 | const MutationJournalStore &journal() const { return m_journal; } | ||
| 317 | struct FlagMutationResult { | ||
| 318 | qint64 token = 0; | ||
| 319 | quint32 flags = 0; | ||
| 320 | }; | ||
| 321 | // A background writer journals a Seen/Flagged desired state and updates | ||
| 322 | // its canonical header in one transaction. The row's account, folder, | ||
| 323 | // path and UIDVALIDITY are rechecked inside that transaction. | ||
| 324 | std::optional<FlagMutationResult> enqueueFlagMutationAndUpdate( | ||
| 325 | const MutationRecord &record); | ||
| 326 | // Same contract for a Keyword (label) desired state: journal and the | ||
| 327 | // mail_labels row commit in one transaction on a background writer. | ||
| 328 | // Returns the journal token. | ||
| 329 | std::optional<qint64> enqueueKeywordMutationAndUpdate(const MutationRecord &record); | ||
| 330 | using AppendPayload = MutationJournalStore::AppendPayload; | ||
| 331 | static constexpr qint64 MaximumAppendMessageBytes = MutationJournalStore::MaximumAppendMessageBytes; | ||
| 332 | static constexpr qint64 MaximumPendingAppendBytes = MutationJournalStore::MaximumPendingAppendBytes; | ||
| 333 | |||
| 334 | // Worker-owned writer: validate the complete captured selection and enqueue | ||
| 335 | // one action in the same transaction. No partial admission or UI mutation. | ||
| 336 | MailSelectionMoveResult enqueueSelectionMove( | ||
| 337 | std::shared_ptr<const MailSelectionSnapshot> selection, | ||
| 338 | const QString &targetFolder); | ||
| 339 | MailSelectionCancelResult cancelQueuedSelectionMove(const MailSelectionMoveResult &action); | ||
| 340 | // MAIL-SELECTION-ACTIONS, worker-owned writer: one desired state for all | ||
| 341 | // messages of a selection in one transaction (headers/labels and journal | ||
| 342 | // together); unchanged messages are skipped, nothing is admitted partially. | ||
| 343 | MailSelectionStateResult enqueueSelectionState(MailSelectionStateRequest request); | ||
| 344 | |||
| 345 | // --- Maintenance --- | ||
| 346 | |||
| 347 | // S91.2.4: payload budget, folder purges and cache statistics. | ||
| 348 | 1501 | PayloadCacheLimits &payloadLimits() { return m_payloadLimits; } | |
| 349 | const PayloadCacheLimits &payloadLimits() const { return m_payloadLimits; } | ||
| 350 | // F-51: `delimiter` also renames the folder's children. | ||
| 351 | void renameFolderPath(const QString &account, const QString &oldPath, | ||
| 352 | const QString &newPath, const QString &delimiter = QStringLiteral("/")); | ||
| 353 | // F-48 (SPRINT-104): re-key every cached row of an account to its new | ||
| 354 | // name — folders (and through them headers, bodies, attachments, search | ||
| 355 | // documents) and the queued journal actions. Folder ids survive, so | ||
| 356 | // nothing is re-downloaded. One transaction. | ||
| 357 | bool renameAccount(const QString &oldName, const QString &newName); | ||
| 358 | // F-43 (SPRINT-104): delete every cached row of an account (folders | ||
| 359 | // cascade over headers, bodies, attachments and search documents; the | ||
| 360 | // journal and its stored append payloads follow). The payload cache | ||
| 361 | // accounting refreshes with the rows. | ||
| 362 | bool removeAccountData(const QString &account); | ||
| 363 | |||
| 364 | // T-167: Convenience methods for FolderPredictor pre-training | ||
| 365 | QStringList allFolderPaths(const QString &account, QString *error = nullptr) const; | ||
| 366 | QList<MailHeader> headersByFolder(const QString &account, | ||
| 367 | const QString &folderPath) const; | ||
| 368 | |||
| 369 | // --- Badge caching (T-075) --- | ||
| 370 | |||
| 371 | // Store the polled unseen count for a folder. | ||
| 372 | void storeBadge(qint64 folderId, int unseenCount); | ||
| 373 | |||
| 374 | // Load all cached badges for an account. Returns map of folderPath → unseen. | ||
| 375 | QMap<QString, int> loadAllBadges(const QString &account) const; | ||
| 376 | |||
| 377 | // --- External Content Whitelist (T-122) --- | ||
| 378 | // S91.1.5: the allowlist store; whitelistChanged stays on MailCache. | ||
| 379 | 938 | ExternalContentAllowlist &allowlist() { return m_allowlist; } | |
| 380 | const ExternalContentAllowlist &allowlist() const { return m_allowlist; } | ||
| 381 | |||
| 382 | // --- Full-Text Search (T-179) --- | ||
| 383 | // S91.2.5: FTS projection, search queries and index maintenance. The | ||
| 384 | // search value types keep their MailCache names for existing callers. | ||
| 385 | using SearchResult = MailSearchIndex::SearchResult; | ||
| 386 | using SearchIndexDiagnostics = MailSearchIndex::SearchIndexDiagnostics; | ||
| 387 | using SearchFilter = MailSearchIndex::SearchFilter; | ||
| 388 | 2184 | MailSearchIndex &search() { return m_searchIndex; } | |
| 389 | const MailSearchIndex &search() const { return m_searchIndex; } | ||
| 390 | |||
| 391 | signals: | ||
| 392 | void mutationRemoved(qint64 token); | ||
| 393 | // P90.16: Move journal phase transition, including the initial Queued insert | ||
| 394 | // (Queued/Dispatched/Confirmed/Failed/…); | ||
| 395 | // indexed projections refresh their pending-move admission set from it. | ||
| 396 | void mutationPhaseChanged(const QString &account, qint64 folderId, | ||
| 397 | qint64 token, qint64 uid, bool pending); | ||
| 398 | void headerStateReplaced(qint64 folderId, const MailHeader &header); | ||
| 399 | void headerFlagsChanged(qint64 folderId, qint64 uid, quint32 flags); | ||
| 400 | void headerLabelChanged(qint64 folderId, qint64 uid, const QString &label, bool present); | ||
| 401 | void headerRemoved(qint64 folderId, qint64 uid); | ||
| 402 | void folderHeadersRemoved(qint64 folderId); | ||
| 403 | // Revoke any displayed body lease before a mailbox epoch changes or its | ||
| 404 | // cached rows are purged. An attempted change invalidates conservatively. | ||
| 405 | void folderIdentityInvalidated(qint64 folderId); | ||
| 406 | // Published only after a changed mailbox epoch reached the cache. | ||
| 407 | void folderUidValidityChanged(qint64 folderId, quint32 uidValidity); | ||
| 408 | // Emitted after a whitelist mutation actually changed the stored entries. | ||
| 409 | // Readers (running mail views) use it to refresh their external-image | ||
| 410 | // policy; a rejected or no-op write never emits. | ||
| 411 | void whitelistChanged(); | ||
| 412 | |||
| 413 | private: | ||
| 414 | QString m_writeDomain; | ||
| 415 | std::shared_ptr<QRecursiveMutex> m_writeMutex = std::make_shared<QRecursiveMutex>(); | ||
| 416 | // P90.3: presentation-only flag maintenance. Applies +/-1 unread deltas to | ||
| 417 | // the stored unread_descendants of every published ancestor so a seen flip | ||
| 418 | // needs no thread-topology rebuild. Caller owns the write transaction. | ||
| 419 | [[nodiscard]] bool writeFlags(qint64 folderId, qint64 uid, quint32 flags); | ||
| 420 | [[nodiscard]] bool adjustThreadUnreadAggregates(qint64 folderId, | ||
| 421 | const QList<QPair<qint64, int>> &uidDeltas); | ||
| 422 | bool storeHeaderBatch(qint64 folderId, QList<MailHeader> &headers, | ||
| 423 | const std::function<bool()> &prepare); | ||
| 424 | // storeHeaderBatch() steps (S91.3.9); each failing step has rolled back. | ||
| 425 | struct ExistingHeader { | ||
| 426 | qint64 id = -1; | ||
| 427 | quint32 flags = 0; | ||
| 428 | QString subject, from, to, messageId, inReplyTo, refIds, cc, listId; | ||
| 429 | bool recipientsKnown = false; // cc_addr IS NOT NULL | ||
| 430 | QVariant date; | ||
| 431 | qint64 size = -1; | ||
| 432 | int hasAttachments = -1; | ||
| 433 | int isSpam = -1; | ||
| 434 | std::optional<double> spamScore, spamThreshold; // Sprint 105 (v25) | ||
| 435 | QString spamTool; // NULL column -> null QString | ||
| 436 | QSet<QString> labels; | ||
| 437 | }; | ||
| 438 | bool loadExistingHeaders(qint64 folderId, const QList<MailHeader> &headers, | ||
| 439 | QHash<qint64, ExistingHeader> &existing); | ||
| 440 | static QList<int> lastWinsWriteOrder(const QList<MailHeader> &headers); | ||
| 441 | bool indexHeaderAddresses(qint64 hid, const MailHeader &h, const QVariant &dateValue, const ExistingHeader *prior); | ||
| 442 | bool flushAddressRows(); | ||
| 443 | public: | ||
| 444 | // S105.5.6 A/B only: header writes without address rows (never in the product). | ||
| 445 | static void setAddressIndexWritesForBenchmark(bool enabled); | ||
| 446 | private: | ||
| 447 | // Sprint 105: address rows of the batch being written, and the prepared | ||
| 448 | // 100-row INSERT of this connection (lazy). | ||
| 449 | struct PendingAddressRow { | ||
| 450 | qint64 headerId = -1; | ||
| 451 | QString address; | ||
| 452 | int role = 0; | ||
| 453 | QVariant date; | ||
| 454 | }; | ||
| 455 | QList<PendingAddressRow> m_pendingAddressRows; | ||
| 456 | std::unique_ptr<QSqlQuery> m_addressStatements; | ||
| 457 | static bool storedRowMatches(const ExistingHeader &prior, const MailHeader &h, const QVariant &dateValue, | ||
| 458 | const QString &canonicalMessageId, const QString &encodedReplyIds, | ||
| 459 | const QString &encodedReferences); | ||
| 460 | static void bindHeaderUpsert(QSqlQuery &q, qint64 folderId, const MailHeader &h, | ||
| 461 | const QVariant &dateValue, const QString &canonicalMessageId, | ||
| 462 | const QString &encodedReplyIds, const QString &encodedReferences); | ||
| 463 | bool reconcileHeaderLabels(qint64 hid, const QSet<QString> *priorLabelsOrNull, | ||
| 464 | const QSet<QString> &labelSet, const QStringList &labels, | ||
| 465 | QSqlQuery &labelQ, QSqlQuery &labelDeleteQ); | ||
| 466 | bool commitHeaderBatch(qint64 folderId, const QList<QPair<qint64, int>> &unreadDeltas, | ||
| 467 | const QList<qint64> &changedRowIds); | ||
| 468 | // Byte-budgeted single-direction page read shared by plain, restore and | ||
| 469 | // subsequent-page snapshots. Caller owns the transaction. Sets | ||
| 470 | // state.error/state.outcome (Limited) when the budget rejects the request. | ||
| 471 | QList<MailHeader> admittedHeaderPage(qint64 folderId, const MailHeader *cursor, | ||
| 472 | bool before, bool includeAnchor, int limit, | ||
| 473 | qint64 reservedBytes, | ||
| 474 | MailHeaderPageSnapshot &state, | ||
| 475 | const MailHeaderFilter *filter = nullptr, | ||
| 476 | const MailSortOrder &sort = {}) const; | ||
| 477 | bool storeBodyPayload(qint64 folderId, qint64 uid, const MailBody &body, | ||
| 478 | const QList<Attachment> *attachments, | ||
| 479 | const QList<QByteArray> *blobs, | ||
| 480 | const MailId *expectedId = nullptr, quint32 expectedValidity = 0); | ||
| 481 | |||
| 482 | using SearchDocument = MailSearchIndex::SearchDocument; | ||
| 483 | // Get the internal row ID for a header by folder + UID. | ||
| 484 | |||
| 485 | |||
| 486 | |||
| 487 | QSqlDatabase m_db; | ||
| 488 | PredictorTrainingReader m_training{m_db}; | ||
| 489 | MailSnapshotReader m_snapshots{m_db}; | ||
| 490 | // P90.17: folder identity (path, UIDVALIDITY) of the primary cache | ||
| 491 | // connection, which is the only writer of those columns (setUidValidity, | ||
| 492 | // renameFolderPath, ensureFolder). Read-only/worker instances do not cache. | ||
| 493 | // Filled lazily, updated by this instance's writers, cleared on (re)open. | ||
| 494 | struct FolderIdentityEntry { QString path; quint32 uidValidity = 0; bool pathKnown = false; bool epochKnown = false; }; | ||
| 495 | mutable QHash<qint64, FolderIdentityEntry> m_folderIdentity; | ||
| 496 | bool m_cacheFolderIdentity = false; | ||
| 497 | QString m_connectionName; | ||
| 498 | QString m_dbPath; | ||
| 499 | QString m_lastError; | ||
| 500 | // Sprint 91 stores: share m_db / m_lastError (declared above). | ||
| 501 | MailCacheSchema m_schema{m_db, m_lastError}; | ||
| 502 | MutationJournalStore m_journal{m_db, m_lastError, | ||
| 503 | 42 | {[this](const QString &account, qint64 folderId, qint64 token, qint64 uid, bool pending) { | |
| 504 | 42 | emit mutationPhaseChanged(account, folderId, token, uid, pending); | |
| 505 | 42 | }, | |
| 506 | 2904 | [this](qint64 token) { emit mutationRemoved(token); }}}; | |
| 507 | MailSearchIndex m_searchIndex{m_db, m_lastError, | ||
| 508 | 32 | {[this](qint64 folderId, qint64 uid) { return headerRowId(folderId, uid); }}}; | |
| 509 | PayloadCacheLimits m_payloadLimits{m_db, m_lastError, m_writeMutex, | ||
| 510 | 218 | {[this](const QString &account, const QString &path) { return findFolderId(account, path); }, | |
| 511 | 15 | [this](qint64 rowId) { return m_searchIndex.indexHeaderById(rowId); }, | |
| 512 | 11 | [this](qint64 folderId) { emit folderHeadersRemoved(folderId); }}}; | |
| 513 | AttachmentStore m_attachments{m_db, m_lastError, | ||
| 514 | 178 | {[this](qint64 folderId, qint64 uid) { return headerRowId(folderId, uid); }, | |
| 515 | 43 | [this](qint64 protectedHeaderId, qint64 reserveBytes) { | |
| 516 | 43 | return m_payloadLimits.enforcePayloadCacheLimitInTransaction(protectedHeaderId, reserveBytes); | |
| 517 | }, | ||
| 518 | 46 | [this] { m_payloadLimits.refreshPayloadCacheBytes(); }}}; | |
| 519 | 45 | ExternalContentAllowlist m_allowlist{m_db, m_lastError, [this] { emit whitelistChanged(); }}; | |
| 520 | }; | ||
| 521 |