MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - data - MailCache.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 88.4 % 1406 1243
Test Date: 2026-07-27 17:53:44 Functions: 100.0 % 72 72
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 49.8 % 3902 1944

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

Generated by: LCOV version 2.0-1