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: 90.3 % 1303 1176
Test Date: 2026-06-21 21:10:19 Functions: 100.0 % 71 71
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 52.2 % 3564 1860

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

Generated by: LCOV version 2.0-1