MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - controller - MailController.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 93.2 % 1595 1487
Test Date: 2026-07-27 17:53:44 Functions: 99.1 % 107 106
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 54.1 % 3552 1921

             Branch data     Line data    Source code
       1                 :             : #include "MailController.h"
       2                 :             : 
       3                 :             : #include <QFileInfo>
       4                 :             : #include <QLocale>
       5                 :             : #include <QLoggingCategory>
       6                 :             : 
       7                 :             : #include "data/MailCache.h"
       8                 :             : #include "util/AttachmentFileSecurity.h"
       9                 :             : #include "data/Models.h"
      10                 :             : #include "controller/UndoManager.h"
      11                 :             : #include "service/ImapService.h"
      12                 :             : #include "service/ConnectionHealthMonitor.h"
      13                 :             : #include "service/MimeParser.h"
      14                 :             : #include "ui/FolderTree.h"
      15                 :             : #include "ui/MailListModel.h"
      16                 :             : #include "ui/MailThreadModel.h"
      17                 :             : #include "ui/MailView.h"
      18                 :             : 
      19   [ +  +  +  -  :        1441 : Q_LOGGING_CATEGORY(lcController, "mailjd.controller")
             +  -  -  - ]
      20                 :             : 
      21                 :             : static constexpr int REVERSE_CHUNK_SIZE = 50;
      22                 :             : 
      23                 :             : static constexpr int POLLING_INTERVAL_MS = 60 * 1000;  // 60 seconds
      24                 :             : 
      25                 :         136 : MailController::MailController(ImapService *imap, MailCache *cache,
      26                 :             :                                MailListModel *model, MailView *view,
      27                 :         136 :                                QObject *parent)
      28                 :         136 :     : QObject(parent), m_imap(imap), m_cache(cache), m_model(model),
      29   [ +  -  +  -  :         136 :       m_view(view), m_pollingTimer(new QTimer(this)) {
          -  +  +  -  +  
          -  +  -  +  -  
             +  -  -  - ]
      30                 :             : 
      31                 :             :   // T-118: Debounce timer for IMAP SELECT — waits 50ms so rapid
      32                 :             :   // folder switches only trigger one SELECT for the final folder.
      33   [ +  -  +  -  :         136 :   m_folderSwitchTimer = new QTimer(this);
             -  +  -  - ]
      34         [ +  - ]:         136 :   m_folderSwitchTimer->setSingleShot(true);
      35         [ +  - ]:         136 :   m_folderSwitchTimer->setInterval(50);
      36                 :         136 :   connect(m_folderSwitchTimer, &QTimer::timeout, this,
      37         [ +  - ]:         136 :           &MailController::executeDeferredFolderSwitch);
      38                 :             :   // IMAP → controller connections
      39                 :         136 :   connect(m_imap, &ImapService::folderSelected, this,
      40         [ +  - ]:         136 :           &MailController::onFolderSelectedFromImap);
      41                 :         136 :   connect(m_imap, &ImapService::headersReceived, this,
      42         [ +  - ]:         136 :           &MailController::onHeadersReceived);
      43                 :         136 :   connect(m_imap, &ImapService::headerFetchComplete, this,
      44         [ +  - ]:         136 :           &MailController::onHeaderFetchComplete);
      45                 :         136 :   connect(m_imap, &ImapService::rawBodyReceived, this,
      46         [ +  - ]:         136 :           &MailController::onRawBodyReceived);
      47                 :         136 :   connect(m_imap, &ImapService::idleNewMessages, this,
      48         [ +  - ]:         136 :           &MailController::onIdleNewMessages);
      49                 :         136 :   connect(m_imap, &ImapService::idleFlagsChanged, this,
      50         [ +  - ]:         136 :           &MailController::onIdleFlagsChanged);
      51                 :         136 :   connect(m_imap, &ImapService::idleMessageExpunged, this,
      52         [ +  - ]:         136 :           &MailController::onIdleMessageExpunged);
      53                 :         136 :   connect(m_imap, &ImapService::idleFlagsNeedRefetch, this,
      54         [ +  - ]:         136 :           &MailController::onIdleFlagsNeedRefetch);
      55                 :         136 :   connect(m_imap, &ImapService::flagsReceived, this,
      56         [ +  - ]:         136 :           &MailController::onFlagsReceived);
      57                 :         136 :   connect(m_imap, &ImapService::folderStatusReceived, this,
      58         [ +  - ]:         136 :           &MailController::onFolderStatusReceived);
      59                 :         136 :   connect(m_imap, &ImapService::searchResultReceived, this,
      60         [ +  - ]:         136 :           &MailController::onSearchResultReceived);
      61                 :         136 :   connect(m_imap, &ImapService::messageMoved, this,
      62         [ +  - ]:         136 :           &MailController::onMessageMoved);
      63                 :         136 :   connect(m_imap, &ImapService::messagesMoved, this,
      64         [ +  - ]:         136 :           &MailController::onMessagesMoved);
      65                 :         136 :   connect(m_imap, &ImapService::moveError, this,
      66         [ +  - ]:         136 :           &MailController::onMoveError);
      67                 :             : 
      68                 :             :   // Polling timer
      69         [ +  - ]:         136 :   m_pollingTimer->setInterval(POLLING_INTERVAL_MS);
      70         [ +  - ]:         136 :   connect(m_pollingTimer, &QTimer::timeout, this, &MailController::pollFolders);
      71                 :             : 
      72                 :             :   // Expunge debounce timer: coalesces rapid IDLE expunge events into one fetchFlags
      73   [ +  -  +  -  :         136 :   m_expungeDebounceTimer = new QTimer(this);
             -  +  -  - ]
      74         [ +  - ]:         136 :   m_expungeDebounceTimer->setSingleShot(true);
      75         [ +  - ]:         136 :   m_expungeDebounceTimer->setInterval(200);
      76         [ +  - ]:         136 :   connect(m_expungeDebounceTimer, &QTimer::timeout, this, [this]() {
      77   [ #  #  #  #  :           0 :     qCInfo(lcController) << "Debounced expunge: triggering flag sync";
             #  #  #  # ]
      78         [ #  # ]:           0 :     m_imap->executeAfterIdle([this]() { m_imap->fetchFlags(); });
      79                 :           0 :   });
      80                 :         136 : }
      81                 :             : 
      82                 :             : // Bug 38: Clear undo stack to prevent use-after-free — undo lambdas capture `this`
      83                 :             : // T-720: Also deactivate + detach the body/search health monitors BEFORE
      84                 :             : // Qt's parent-child cleanup destroys m_bodyImap / m_searchImap. Without
      85                 :             : // this the monitor slots could react to the dying sockets' stateChanged.
      86                 :         262 : MailController::~MailController() {
      87         [ +  + ]:         136 :   if (m_bodyHealth) {
      88                 :          32 :     m_bodyHealth->setActive(false);
      89                 :          32 :     m_bodyHealth->detach();
      90                 :             :   }
      91         [ +  + ]:         136 :   if (m_searchHealth) {
      92                 :           8 :     m_searchHealth->setActive(false);
      93                 :           8 :     m_searchHealth->detach();
      94                 :             :   }
      95         [ +  + ]:         136 :   if (m_undoManager)
      96                 :         126 :     m_undoManager->clear();
      97                 :         262 : }
      98                 :             : 
      99                 :         137 : void MailController::setAccount(const QString &accountId) {
     100                 :         137 :   m_accountId = accountId;
     101                 :         137 : }
     102                 :             : 
     103                 :          30 : void MailController::setSubscribedFolders(const QStringList &folders) {
     104                 :          30 :   m_subscribedFolders = folders;
     105                 :          30 : }
     106                 :             : 
     107                 :             : // ═══════════════════════════════════════════════════════
     108                 :             : // Folder Selection Flow
     109                 :             : // ═══════════════════════════════════════════════════════
     110                 :             : 
     111                 :          43 : void MailController::onFolderSelected(const QString &folderPath) {
     112                 :             :   // Abort any in-progress reverse-chunk fetch for the previous folder
     113         [ +  - ]:          43 :   m_reverseChunks.clear();
     114                 :          43 :   m_pendingHeaderFetch = false;
     115                 :          43 :   ++m_folderGeneration; // Invalidate stale async callbacks
     116                 :             : 
     117                 :             :   // T-201: Do NOT call clearDeferredCommands() here.
     118                 :             :   // The generation check in executeDeferredFolderSwitch() already
     119                 :             :   // safely ignores stale SELECTs. Clearing the queue would destroy
     120                 :             :   // pending STORE/MOVE commands (user actions) — causing data loss.
     121                 :             : 
     122         [ +  - ]:          43 :   m_pollingTimer->stop();
     123                 :             :   // Ensure polling restarts even if IDLE doesn't start (e.g. IDLE race)
     124         [ +  + ]:          43 :   if (!m_subscribedFolders.isEmpty())
     125         [ +  - ]:          32 :     m_pollingTimer->start();
     126                 :          43 :   bool folderChanged = (folderPath != m_currentFolder); // T-545b
     127                 :          43 :   m_currentFolder = folderPath;
     128         [ +  + ]:          43 :   if (folderChanged) {
     129         [ +  - ]:          30 :     if (m_view)
     130         [ +  - ]:          30 :       m_view->clear();
     131                 :          30 :     m_pendingBodyUid = -1; // T-119: discard pending body on real folder switch
     132                 :          30 :     m_pendingBodyFolderId = -1;
     133                 :             :   }
     134                 :          43 :   m_pendingFlagUids.clear();     // T-201: old folder flags no longer relevant
     135                 :          43 :   m_pendingMoveUids.clear();     // T-201: old folder moves no longer relevant
     136                 :          43 :   m_fetchInProgress = true; // T-074: mark fetch in progress
     137                 :          43 :   m_folderSwitchStopwatch.start(); // T-210: start total folder-switch timing
     138                 :             : 
     139                 :             :   // Step 1: Ensure folder exists in cache
     140         [ +  - ]:          43 :   m_currentFolderId = m_cache->ensureFolder(m_accountId, folderPath);
     141         [ +  + ]:          43 :   if (m_currentFolderId < 0) {
     142   [ +  -  +  -  :           2 :     qCWarning(lcController)
                   +  + ]
     143   [ +  -  +  - ]:           1 :         << "Failed to ensure folder in cache:" << folderPath;
     144                 :           1 :     m_fetchInProgress = false;
     145                 :           1 :     return;
     146                 :             :   }
     147                 :             : 
     148                 :             :   // Step 2: Load cached headers -> display immediately (instant feedback)
     149         [ +  - ]:          42 :   auto cachedHeaders = m_cache->headers(m_currentFolderId);
     150         [ +  - ]:          42 :   m_model->setHeaders(cachedHeaders);
     151                 :             : 
     152                 :             :   // 67.A2: Remember whether the first INBOX sync starts from an empty
     153                 :             :   // cache (first-ever mailbox load → suppressed notifications are
     154                 :             :   // dropped, not flushed).
     155   [ +  +  +  +  :          55 :   if (!m_inboxFirstSyncSignaled && folderPath == QStringLiteral("INBOX"))
          +  +  +  +  +  
                      + ]
     156                 :          10 :     m_inboxCacheWasEmpty = cachedHeaders.isEmpty();
     157                 :             : 
     158                 :             :   // Step 3: T-074: Don't emit unreadCountChanged at all during fetch.
     159                 :             :   // The folder tree already shows the last polled badge value.
     160                 :             :   // We update the badge only when the fetch is complete (onHeaderFetchComplete)
     161                 :             :   // or when a streaming batch arrives (onHeadersReceived).
     162                 :             : 
     163         [ +  + ]:          42 :   if (!cachedHeaders.isEmpty()) {
     164   [ +  -  +  - ]:          52 :     emit statusMessage(QString("%1 \u2013 %2 mails (cache)")
     165         [ +  - ]:          52 :                            .arg(folderPath)
     166         [ +  - ]:          52 :                            .arg(cachedHeaders.size()));
     167   [ +  -  +  -  :          52 :     qCInfo(lcController) << "Loaded" << cachedHeaders.size()
          +  -  +  -  +  
                      + ]
     168   [ +  -  +  - ]:          26 :                          << "cached headers for" << folderPath;
     169                 :             :   }
     170                 :             : 
     171                 :             :   // Step 4: T-118 — Debounce the IMAP SELECT.
     172                 :             :   // The expensive part (stopping IDLE, SELECT round-trip, flag sync) is
     173                 :             :   // delayed by 50ms. If the user switches again within 50ms, the timer
     174                 :             :   // restarts and only the final folder gets SELECTed.
     175         [ +  - ]:          42 :   m_folderSwitchTimer->start(); // (re)start 50ms timer
     176                 :          42 : }
     177                 :             : 
     178                 :          31 : void MailController::executeDeferredFolderSwitch() {
     179                 :             :   // T-118: Timer fired — issue the IMAP SELECT for the current folder.
     180                 :          31 :   auto gen = m_folderGeneration;
     181                 :          31 :   auto folderPath = m_currentFolder;
     182   [ +  -  +  -  :          31 :   m_imap->executeAfterIdle([this, folderPath, gen]() {
                   -  - ]
     183         [ +  + ]:          31 :     if (gen != m_folderGeneration)
     184                 :           2 :       return; // folder changed before IDLE finished — discard
     185                 :             :     // T-207: Pipeline SELECT + FETCH FLAGS in one burst
     186                 :          29 :     m_imap->selectAndFetchFlags(folderPath);
     187                 :             :   });
     188                 :          31 : }
     189                 :             : 
     190                 :          33 : void MailController::onFolderSelectedFromImap(const QString &path,
     191                 :             :                                               int messageCount,
     192                 :             :                                               quint32 uidValidity,
     193                 :             :                                               quint64 highestModseq) {
     194                 :             :   // Body fetch SELECT: don't trigger sync pipeline
     195         [ +  + ]:          33 :   if (m_bodyFetchSelect) {
     196                 :           3 :     m_bodyFetchSelect = false;
     197                 :           3 :     return;
     198                 :             :   }
     199                 :             : 
     200         [ +  + ]:          30 :   if (path != m_currentFolder)
     201                 :           3 :     return;
     202                 :             : 
     203                 :             :   // T-113: Mark this generation as the active pipeline.
     204                 :             :   // All downstream callbacks (onFlagsReceived, onHeadersReceived, etc.)
     205                 :             :   // check m_activeFolderGen == m_folderGeneration to detect stale data.
     206                 :          27 :   m_activeFolderGen = m_folderGeneration;
     207                 :             : 
     208                 :          27 :   m_cache->setUidValidity(m_currentFolderId, uidValidity);
     209                 :             : 
     210                 :             :   // T-208: Store HIGHESTMODSEQ for future incremental sync
     211         [ +  + ]:          27 :   if (highestModseq > 0) {
     212                 :           1 :     m_cache->setHighestModseq(m_currentFolderId, highestModseq);
     213   [ +  -  +  -  :           2 :     qCInfo(lcController) << "T-208: Stored HIGHESTMODSEQ" << highestModseq
          +  -  +  -  +  
                      + ]
     214   [ +  -  +  - ]:           1 :                          << "for" << path;
     215                 :             :   }
     216                 :             : 
     217                 :          27 :   qint64 maxUid = m_cache->maxUid(m_currentFolderId);
     218   [ +  -  +  -  :          54 :   qCInfo(lcController) << "IMAP selected" << path << "with" << messageCount
          +  -  +  -  +  
             -  +  -  +  
                      + ]
     219   [ +  -  +  - ]:          27 :                        << "messages. Max cached UID:" << maxUid;
     220                 :             : 
     221                 :             :   // If cache was purged due to UIDVALIDITY change, clear UI
     222   [ +  +  +  -  :          27 :   if (maxUid == 0 && m_model->rowCount() > 0) {
             -  +  -  + ]
     223                 :           0 :     m_model->clear();
     224                 :             :   }
     225                 :             : 
     226                 :             :   // T-058: Flag sync FIRST to update stale cached flags immediately.
     227                 :             :   // After flag sync completes, onFlagsReceived will trigger header delta fetch.
     228                 :             :   // T-207: fetchFlags() is already pipelined via selectAndFetchFlags(),
     229                 :             :   // so we don't need to send it separately here.
     230                 :          27 :   m_pendingHeaderFetch = true;
     231   [ +  -  +  -  :          54 :   emit statusMessage(QString("%1 – syncing flags...").arg(path));
                   +  - ]
     232                 :             : }
     233                 :             : 
     234                 :             : // ═══════════════════════════════════════════════════════
     235                 :             : // Header Streaming (Bug 3 fix)
     236                 :             : // ═══════════════════════════════════════════════════════
     237                 :             : 
     238                 :          15 : void MailController::onHeadersReceived(const QList<MailHeader> &headers) {
     239                 :             :   // T-113: Discard headers from a stale folder pipeline
     240         [ +  + ]:          15 :   if (m_activeFolderGen != m_folderGeneration) {
     241   [ +  -  +  -  :           6 :     qCInfo(lcController) << "Discarding" << headers.size()
          +  -  +  -  +  
                      + ]
     242   [ +  -  +  - ]:           3 :                          << "stale headers (gen" << m_activeFolderGen
     243   [ +  -  +  -  :           3 :                          << "vs" << m_folderGeneration << ")";
                   +  - ]
     244                 :           3 :     return;
     245                 :             :   }
     246                 :             : 
     247         [ -  + ]:          12 :   if (headers.isEmpty())
     248                 :           0 :     return;
     249                 :             : 
     250                 :             :   // T-548: Protect optimistic flag updates from being overwritten by
     251                 :             :   // stale streaming headers. During backfill, FETCH_HEADERS was sent
     252                 :             :   // BEFORE the UID STORE, so incoming flags are stale. Preserve the
     253                 :             :   // cached (optimistic) flags for UIDs with pending flag updates.
     254                 :          12 :   QList<MailHeader> adjusted;
     255                 :          12 :   bool hasAdjustments = false;
     256         [ +  + ]:          12 :   if (!m_pendingFlagUids.isEmpty()) {
     257                 :           1 :     adjusted = headers;
     258   [ +  -  +  -  :           2 :     for (auto &h : adjusted) {
                   +  + ]
     259         [ +  - ]:           1 :       if (m_pendingFlagUids.contains(h.uid)) {
     260         [ +  - ]:           1 :         auto cached = m_cache->header(m_currentFolderId, h.uid);
     261         [ +  - ]:           1 :         if (cached) {
     262   [ +  -  +  -  :           2 :           qCInfo(lcController) << "T-548: Preserving optimistic flags for UID"
             +  -  +  + ]
     263   [ +  -  +  -  :           1 :                                << h.uid << "cached:" << cached->flags
                   +  - ]
     264   [ +  -  +  - ]:           1 :                                << "stale:" << h.flags;
     265                 :           1 :           h.flags = cached->flags;
     266                 :           1 :           hasAdjustments = true;
     267                 :             :         }
     268                 :           1 :       }
     269                 :             :     }
     270                 :             :   }
     271         [ +  + ]:          12 :   const auto &effectiveHeaders = hasAdjustments ? adjusted : headers;
     272                 :             : 
     273                 :             :   // Store in cache (UPSERT handles duplicates)
     274         [ +  - ]:          12 :   m_cache->storeHeaders(m_currentFolderId, effectiveHeaders);
     275                 :             : 
     276                 :             :   // T-179: Deferred batch FTS5 indexing — don't block the UI thread
     277                 :             :   {
     278                 :          12 :     QList<qint64> uids;
     279         [ +  - ]:          12 :     uids.reserve(headers.size());
     280         [ +  + ]:          47 :     for (const auto &h : headers)
     281         [ +  - ]:          35 :       uids.append(h.uid);
     282                 :          12 :     qint64 fid = m_currentFolderId;
     283         [ +  - ]:          12 :     QTimer::singleShot(0, this, [this, fid, uids]() {
     284                 :           9 :       m_cache->batchIndexForSearch(fid, uids);
     285                 :           9 :     });
     286                 :          12 :   }
     287                 :             : 
     288                 :             :   // T-176: Notify listeners (e.g. FolderPredictor) about stored headers
     289         [ +  - ]:          12 :   emit headersStored(m_currentFolder, effectiveHeaders);
     290                 :             : 
     291                 :             :   // Deduplicated append to model
     292                 :          12 :   QList<MailHeader> newOnly;
     293         [ +  + ]:          47 :   for (const auto &h : effectiveHeaders) {
     294   [ +  -  +  + ]:          35 :     if (m_model->rowForUid(h.uid, m_currentFolderId) < 0) {
     295         [ +  - ]:          33 :       newOnly.append(h);
     296                 :             :     }
     297                 :             :   }
     298         [ +  + ]:          12 :   if (!newOnly.isEmpty()) {
     299                 :             :     // T-548: IMAP-parsed headers have folderId=0 (parser doesn't know the folder).
     300                 :             :     // Set the correct folderId so appendHeaders indexes them under the right
     301                 :             :     // composite key MailKey{folderId, uid}. Without this, rowForUid() always
     302                 :             :     // returns -1 for streaming headers, breaking flag updates and mark-as-seen.
     303   [ +  -  +  -  :          43 :     for (auto &h : newOnly) {
                   +  + ]
     304                 :          33 :       h.folderId = m_currentFolderId;
     305                 :             :     }
     306         [ +  - ]:          10 :     m_model->appendHeaders(newOnly);
     307                 :             :   }
     308                 :             : 
     309                 :             :   // Update badge after each batch, but never lower than the polled count
     310                 :             :   // (during streaming the model is still incomplete).
     311         [ +  - ]:          12 :   int modelCount = m_model->unreadCount();
     312         [ +  - ]:          12 :   int polledCount = m_lastPolledUnread.value(m_currentFolder, 0);
     313         [ +  - ]:          12 :   emit unreadCountChanged(m_currentFolder, qMax(modelCount, polledCount));
     314                 :             : 
     315         [ +  - ]:          12 :   int total = m_cache->headerCount(m_currentFolderId);
     316   [ +  -  +  -  :          36 :   emit statusMessage(QString("%1 – %2 mails").arg(m_currentFolder).arg(total));
             +  -  +  - ]
     317   [ +  -  +  -  :          24 :   qCInfo(lcController) << "Streaming:" << headers.size() << "headers received,"
          +  -  +  -  +  
                -  +  + ]
     318   [ +  -  +  - ]:          12 :                        << total << "total in cache";
     319                 :          12 : }
     320                 :             : 
     321                 :          16 : void MailController::onHeaderFetchComplete() {
     322                 :             :   // T-113: Discard if folder changed
     323         [ +  + ]:          16 :   if (m_activeFolderGen != m_folderGeneration) {
     324   [ +  -  +  -  :          10 :     qCInfo(lcController) << "Discarding stale headerFetchComplete";
             +  -  +  + ]
     325                 :           5 :     m_reverseChunks.clear();
     326                 :           5 :     return;
     327                 :             :   }
     328                 :             : 
     329                 :             :   // All header batches delivered
     330   [ +  -  +  -  :          22 :   qCInfo(lcController) << "Header fetch complete for" << m_currentFolder;
          +  -  +  -  +  
                      + ]
     331                 :             : 
     332                 :             :   // T-061: If more reverse chunks remain, fetch next chunk directly
     333         [ -  + ]:          11 :   if (!m_reverseChunks.isEmpty()) {
     334                 :             :     // Yield to event loop so UI can repaint between chunks
     335         [ #  # ]:           0 :     QTimer::singleShot(0, this, &MailController::fetchNextChunk);
     336                 :           0 :     return;
     337                 :             :   }
     338                 :             : 
     339                 :             :   // T-074: Fetch complete -> emit true unread count and update polled cache
     340                 :          11 :   m_fetchInProgress = false;
     341                 :          11 :   int trueCount = m_model->unreadCount();
     342                 :          11 :   m_lastPolledUnread[m_currentFolder] = trueCount;
     343                 :          11 :   emit unreadCountChanged(m_currentFolder, trueCount);
     344                 :             : 
     345                 :             :   // 67.A2: Signal the end of the session's first INBOX sync exactly once
     346                 :             :   // (notification suppression window ends here).
     347   [ +  +  +  + ]:          15 :   if (!m_inboxFirstSyncSignaled &&
     348   [ +  +  +  +  :          15 :       m_currentFolder == QStringLiteral("INBOX")) {
                   +  + ]
     349                 :           3 :     m_inboxFirstSyncSignaled = true;
     350                 :           3 :     emit inboxFirstSyncCompleted(m_inboxCacheWasEmpty);
     351                 :             :   }
     352                 :             : 
     353                 :             :   // All chunks done -> sync flags (post-header)
     354                 :          11 :   m_pendingHeaderFetch = false; // T-058: next flag sync = final, then IDLE
     355                 :          11 :   m_imap->fetchFlags();
     356                 :             : }
     357                 :             : 
     358                 :             : // ═══════════════════════════════════════════════════════
     359                 :             : // Mail Body
     360                 :             : // ═══════════════════════════════════════════════════════
     361                 :             : 
     362                 :           2 : void MailController::showMessageAboveDownloadLimit(const MailHeader &header,
     363                 :             :                                                    qint64 maxBytes) {
     364                 :           2 :   const QString actual = header.size > 0
     365   [ +  -  -  - ]:           2 :                              ? QLocale().formattedDataSize(header.size)
     366   [ +  +  +  -  :           3 :                              : tr("an unknown size");
             +  -  +  + ]
     367   [ +  -  +  - ]:           4 :   const QString maximum = QLocale().formattedDataSize(maxBytes);
     368                 :             : 
     369                 :           2 :   MailBody notice;
     370                 :           2 :   notice.uid = header.uid;
     371                 :             :   notice.textPlain =
     372         [ +  - ]:           4 :       tr("This message was not downloaded because its size (%1) exceeds "
     373                 :             :          "the configured limit (%2). You can change the limit under "
     374                 :             :          "Settings > General > Message Downloads.")
     375         [ +  - ]:           2 :           .arg(actual, maximum);
     376         [ -  + ]:           2 :   if (m_view)
     377         [ #  # ]:           0 :     m_view->displayMail(header, notice);
     378         [ +  - ]:           2 :   emit statusMessage(
     379         [ +  - ]:           4 :       tr("Message not downloaded: size %1 exceeds limit %2")
     380         [ +  - ]:           4 :           .arg(actual, maximum));
     381                 :           2 : }
     382                 :             : 
     383                 :         117 : bool MailController::rejectMessageAboveDownloadLimit(
     384                 :             :     const MailHeader &header, bool showFeedback) {
     385                 :         117 :   const qint64 limit = ImapService::configuredMaxMessageBytes();
     386   [ +  +  +  + ]:         117 :   if (header.size <= 0 || header.size <= limit)
     387                 :         116 :     return false;
     388         [ +  - ]:           1 :   if (showFeedback)
     389                 :           1 :     showMessageAboveDownloadLimit(header, limit);
     390                 :           1 :   return true;
     391                 :             : }
     392                 :             : 
     393                 :          90 : void MailController::onMailSelected(qint64 uid) {
     394   [ +  -  +  -  :         180 :   qCInfo(lcController) << "onMailSelected: uid" << uid
          +  -  +  -  +  
                      + ]
     395   [ +  -  +  - ]:          90 :                        << "folderId" << m_currentFolderId
     396   [ +  -  +  - ]:          90 :                        << "folder" << m_currentFolder;
     397         [ +  - ]:          90 :   int row = m_model->rowForUid(uid, m_currentFolderId);
     398                 :          90 :   MailHeader hdrCopy; // T-545: stack copy for cache-fallback path
     399                 :          90 :   const MailHeader *header = nullptr;
     400                 :             : 
     401         [ +  + ]:          90 :   if (row >= 0) {
     402         [ +  - ]:          69 :     header = m_model->headerAt(row);
     403                 :             :   }
     404                 :             : 
     405         [ +  + ]:          90 :   if (!header) {
     406                 :             :     // T-545: Model doesn't have this UID yet (header streaming in progress).
     407                 :             :     // Fall back to SQLite cache which already has the header from storeHeaders().
     408         [ +  - ]:          21 :     auto cachedHdr = m_cache->header(m_currentFolderId, uid);
     409         [ +  + ]:          21 :     if (!cachedHdr) {
     410   [ +  -  +  -  :          38 :       qCWarning(lcController) << "onMailSelected: UID" << uid
          +  -  +  -  +  
                      + ]
     411         [ +  - ]:          19 :           << "not in model or cache — aborting";
     412                 :          19 :       return;
     413                 :             :     }
     414         [ +  - ]:           2 :     hdrCopy = cachedHdr.value();
     415                 :           2 :     header = &hdrCopy;
     416   [ +  -  +  -  :           4 :     qCInfo(lcController) << "T-545: Using cache fallback for UID" << uid
          +  -  +  -  +  
                      + ]
     417         [ +  - ]:           2 :                          << "(model incomplete during streaming)";
     418         [ +  + ]:          21 :   }
     419                 :             : 
     420                 :             :   // Cache hit → display immediately
     421         [ +  - ]:          71 :   auto cachedBody = m_cache->body(m_currentFolderId, uid);
     422         [ +  + ]:          71 :   if (cachedBody) {
     423         [ +  - ]:          24 :     MailBody body = cachedBody.value();
     424         [ +  - ]:          24 :     body.attachments = m_cache->attachments(m_currentFolderId, uid);
     425         [ +  - ]:          24 :     if (m_view)
     426         [ +  - ]:          24 :       m_view->displayMail(*header, body);
     427   [ +  -  +  -  :          48 :     qCInfo(lcController) << "Body cache hit for UID" << uid
          +  -  +  -  +  
                      + ]
     428   [ +  -  +  - ]:          24 :                          << "isSeen:" << header->isSeen()
     429   [ +  -  +  - ]:          24 :                          << "mainImapState:" << static_cast<int>(m_imap->state())
     430   [ +  -  +  - ]:          24 :                          << "isNotifying:" << m_imap->isNotifying()
     431   [ +  -  +  - ]:          24 :                          << "isIdling:" << m_imap->isIdling();
     432                 :             : 
     433                 :             :     // Mark as seen (T-059 fix: was missing for cache-hit path)
     434         [ +  - ]:          24 :     markMailAsSeen(uid);
     435                 :             : 
     436   [ +  +  +  - ]:          24 :     if (row >= 0) prefetchAdjacent(row); // T-545: skip prefetch without valid row
     437                 :          24 :     return;
     438                 :          24 :   }
     439                 :             : 
     440   [ +  -  +  + ]:          47 :   if (rejectMessageAboveDownloadLimit(*header, true))
     441                 :           1 :     return;
     442                 :             : 
     443                 :             :   // Cache miss → fetch from IMAP via dedicated body connection (T-205)
     444                 :          46 :   m_pendingBodyUid = uid;
     445                 :          46 :   m_pendingBodyFolderId = -1; // T-545: Reset stale cross-folder ID from search
     446   [ +  -  +  -  :          92 :   qCInfo(lcController) << "Body cache miss for UID" << uid << "— starting fetch";
          +  -  +  -  +  
                -  +  + ]
     447   [ +  -  +  -  :          92 :   emit statusMessage(QString("Fetching body for UID %1...").arg(uid));
                   +  - ]
     448                 :             : 
     449                 :             :   // T-548: Defer loading placeholder by 200ms to avoid flicker.
     450                 :             :   // If the body arrives within 200ms, the placeholder is never shown.
     451         [ +  + ]:          46 :   if (!m_loadingPlaceholderTimer) {
     452   [ +  -  +  -  :          13 :     m_loadingPlaceholderTimer = new QTimer(this);
             -  +  -  - ]
     453         [ +  - ]:          13 :     m_loadingPlaceholderTimer->setSingleShot(true);
     454                 :             :   }
     455         [ +  - ]:          46 :   m_loadingPlaceholderTimer->stop(); // Cancel any previous pending placeholder
     456                 :          46 :   MailHeader hdrSnapshot = *header; // Capture header for deferred lambda
     457         [ +  - ]:          46 :   m_loadingPlaceholderTimer->disconnect();
     458         [ +  - ]:          46 :   connect(m_loadingPlaceholderTimer, &QTimer::timeout, this,
     459                 :          92 :           [this, uid, hdrSnapshot]() {
     460         [ +  - ]:           4 :             if (m_pendingBodyUid == uid) { // Still waiting for this body
     461                 :           4 :               MailBody loadingBody;
     462                 :           4 :               loadingBody.uid = uid;
     463         [ +  - ]:           4 :               loadingBody.textPlain = tr("Loading body…");
     464         [ +  - ]:           4 :               if (m_view)
     465         [ +  - ]:           4 :                 m_view->displayMail(hdrSnapshot, loadingBody);
     466                 :           4 :             }
     467                 :           4 :           });
     468         [ +  - ]:          46 :   m_loadingPlaceholderTimer->start(200);
     469                 :             : 
     470                 :             :   // T-205: Use dedicated body connection — no executeAfterIdle needed
     471                 :             :   // T-540: Removed SingleShotConnection — the permanent stateChanged handler
     472                 :             :   // in ensureBodyConnection() already retries m_pendingBodyUid on Authenticated.
     473         [ +  - ]:          46 :   ensureBodyConnection();
     474   [ +  -  +  +  :          92 :   if (m_bodyImap->state() == ImapService::State::Authenticated ||
                   +  + ]
     475                 :          46 :       m_bodyImap->state() == ImapService::State::Selected) {
     476                 :             :     // T-211 fix: Always use selectAndFetchBody for robustness —
     477                 :             :     // avoids stale m_bodyImapSelectedFolder after connection drops.
     478                 :          18 :     m_bodyImapSelectedFolder = m_currentFolder;
     479         [ +  - ]:          18 :     m_bodyImap->selectAndFetchBody(m_currentFolder, uid);
     480   [ +  -  +  -  :          36 :     qCInfo(lcController) << "Body fetch dispatched immediately for UID" << uid;
          +  -  +  -  +  
                      + ]
     481                 :             :   } else {
     482   [ +  -  +  -  :          56 :     qCInfo(lcController) << "Body IMAP state:"
             +  -  +  + ]
     483         [ +  - ]:          28 :                          << static_cast<int>(m_bodyImap->state())
     484   [ +  -  +  -  :          28 :                          << "— waiting for Authenticated (pending UID" << uid << ")";
                   +  - ]
     485                 :             :   }
     486                 :             :   // else: body connection is still connecting — ensureBodyConnection()'s
     487                 :             :   // permanent stateChanged handler will retry using m_pendingBodyUid.
     488   [ +  +  +  + ]:         115 : }
     489                 :             : 
     490                 :          15 : void MailController::onMailSelectedInFolder(qint64 uid, qint64 folderId) {
     491                 :             :   // Search-mode variant: display a mail from a specific folder.
     492         [ +  - ]:          15 :   int row = m_model->rowForUid(uid, m_currentFolderId);
     493                 :          15 :   MailHeader hdrCopy; // T-545: stack copy for cache-fallback path
     494                 :          15 :   const MailHeader *header = nullptr;
     495                 :             : 
     496         [ +  + ]:          15 :   if (row >= 0) {
     497         [ +  - ]:           6 :     header = m_model->headerAt(row);
     498                 :             :   }
     499                 :             : 
     500         [ +  + ]:          15 :   if (!header) {
     501                 :             :     // T-545: Cache fallback during streaming
     502         [ +  - ]:           9 :     auto cachedHdr = m_cache->header(folderId, uid);
     503         [ +  + ]:           9 :     if (!cachedHdr) return;
     504         [ +  - ]:           8 :     hdrCopy = cachedHdr.value();
     505                 :           8 :     header = &hdrCopy;
     506   [ +  -  +  -  :          16 :     qCInfo(lcController) << "T-545: Using cache fallback for UID" << uid
          +  -  +  -  +  
                      + ]
     507   [ +  -  +  - ]:           8 :                          << "in folder" << folderId;
     508         [ +  + ]:           9 :   }
     509                 :             : 
     510         [ +  - ]:          14 :   auto cachedBody = m_cache->body(folderId, uid);
     511         [ +  + ]:          14 :   if (cachedBody) {
     512         [ +  - ]:           5 :     MailBody body = cachedBody.value();
     513         [ +  - ]:           5 :     body.attachments = m_cache->attachments(folderId, uid);
     514         [ +  + ]:           5 :     if (m_view)
     515         [ +  - ]:           4 :       m_view->displayMail(*header, body);
     516   [ +  -  +  -  :          10 :     qCInfo(lcController) << "Search: body cache hit for UID" << uid
          +  -  +  -  +  
                      + ]
     517   [ +  -  +  - ]:           5 :                          << "in folder" << folderId;
     518                 :             :     // Note: skip markSeen via IMAP in search mode — avoids cross-folder
     519                 :             :     // SELECT + STORE which corrupts IMAP state. Optimistic local update
     520                 :             :     // only; actual seen flag set when user navigates to the folder.
     521         [ +  - ]:           5 :     if (!header->isSeen()) {
     522                 :           5 :       quint32 newFlags = header->flags | MailFlag::Seen;
     523         [ +  - ]:           5 :       m_cache->updateFlags(folderId, uid, newFlags);
     524                 :             :       // T-79.E1/M4: the model keys rows by (folderId, uid) — updating with
     525                 :             :       // m_currentFolderId missed the search-result row (or hit an
     526                 :             :       // unrelated same-UID row of the current folder).
     527         [ +  - ]:           5 :       m_model->updateFlags(uid, newFlags, folderId);
     528         [ +  - ]:           5 :       if (m_threadModel)
     529         [ +  - ]:           5 :         m_threadModel->updateFlags(uid, newFlags, folderId);
     530                 :             :     }
     531                 :           5 :   } else {
     532   [ +  -  -  + ]:           9 :     if (rejectMessageAboveDownloadLimit(*header, true))
     533                 :           0 :       return;
     534                 :             : 
     535                 :             :     // T-548: Defer placeholder by 200ms to avoid flicker
     536         [ +  + ]:           9 :     if (!m_loadingPlaceholderTimer) {
     537   [ +  -  +  -  :           3 :       m_loadingPlaceholderTimer = new QTimer(this);
             -  +  -  - ]
     538         [ +  - ]:           3 :       m_loadingPlaceholderTimer->setSingleShot(true);
     539                 :             :     }
     540         [ +  - ]:           9 :     m_loadingPlaceholderTimer->stop();
     541                 :           9 :     MailHeader hdrSnapshot = *header;
     542         [ +  - ]:           9 :     m_loadingPlaceholderTimer->disconnect();
     543         [ +  - ]:           9 :     connect(m_loadingPlaceholderTimer, &QTimer::timeout, this,
     544                 :          18 :             [this, uid, hdrSnapshot]() {
     545         [ +  - ]:           1 :               if (m_pendingBodyUid == uid) {
     546                 :           1 :                 MailBody loadingBody;
     547                 :           1 :                 loadingBody.uid = uid;
     548         [ +  - ]:           1 :                 loadingBody.textPlain = tr("Loading body…");
     549         [ +  - ]:           1 :                 if (m_view)
     550         [ +  - ]:           1 :                   m_view->displayMail(hdrSnapshot, loadingBody);
     551                 :           1 :               }
     552                 :           1 :             });
     553         [ +  - ]:           9 :     m_loadingPlaceholderTimer->start(200);
     554                 :             : 
     555         [ +  - ]:           9 :     QString folderPath = m_cache->folderPath(folderId);
     556         [ -  + ]:           9 :     if (folderPath.isEmpty()) {
     557   [ #  #  #  #  :           0 :       qCWarning(lcController) << "No folder path for folderId" << folderId;
          #  #  #  #  #  
                      # ]
     558                 :           0 :       return;
     559                 :             :     }
     560   [ +  -  +  -  :          18 :     emit statusMessage(tr("Loading body from %1…").arg(folderPath));
                   +  - ]
     561                 :           9 :     m_pendingBodyUid = uid;
     562                 :           9 :     m_pendingBodyFolderId = folderId;
     563                 :             : 
     564                 :             :     // T-205: Use dedicated body connection for cross-folder fetch
     565                 :             :     // T-540: Removed SingleShotConnection — permanent handler retries.
     566         [ +  - ]:           9 :     ensureBodyConnection();
     567   [ +  -  -  +  :          18 :     if (m_bodyImap->state() == ImapService::State::Authenticated ||
                   -  + ]
     568                 :           9 :         m_bodyImap->state() == ImapService::State::Selected) {
     569                 :           0 :       m_bodyImapSelectedFolder = folderPath;
     570         [ #  # ]:           0 :       m_bodyImap->selectAndFetchBody(folderPath, uid);
     571                 :             :     }
     572                 :             :     // else: ensureBodyConnection()'s permanent handler retries m_pendingBodyUid.
     573   [ +  -  +  - ]:           9 :   }
     574   [ +  -  +  + ]:          15 : }
     575                 :             : 
     576                 :           3 : void MailController::onRawBodyReceived(qint64 uid, const QByteArray &rawBody) {
     577                 :             :   // Pending body fetch (from search click or normal): handle before gen check
     578                 :             :   // because cross-folder fetches (search mode) SELECT a different folder
     579         [ +  + ]:           3 :   if (uid == m_pendingBodyUid) {
     580                 :           2 :     m_pendingBodyUid = -1;
     581                 :             : 
     582                 :             :     // Find the header in the model to get the correct folderId
     583                 :             :     // (search results have folderId from their original folder)
     584         [ +  - ]:           2 :     int row = m_model->rowForUid(uid, m_currentFolderId);
     585   [ +  +  +  - ]:           2 :     auto *hdr = (row >= 0) ? m_model->headerAt(row) : nullptr;
     586         [ +  + ]:           2 :     if (!hdr) return;
     587                 :             : 
     588                 :           1 :     qint64 folderId = hdr->folderId;
     589                 :             : 
     590                 :             :     // Parse and store body in the correct folder
     591         [ +  - ]:           1 :     MimeMessage msg = MimeParser::parse(rawBody);
     592                 :           1 :     MailBody body;
     593                 :           1 :     body.uid = uid;
     594                 :           1 :     body.textPlain = msg.textPlain;
     595                 :           1 :     body.textHtml = msg.textHtml;
     596                 :           1 :     body.rawSource = rawBody;
     597         [ +  - ]:           1 :     m_cache->storeBody(folderId, uid, body);
     598         [ +  - ]:           1 :     m_cache->indexForSearch(folderId, uid);
     599                 :             : 
     600         [ +  - ]:           1 :     if (!msg.attachments.isEmpty()) {
     601                 :           1 :       QList<Attachment> attachments;
     602                 :           1 :       QList<QByteArray> blobs;
     603   [ +  -  +  -  :           2 :       for (const auto &part : msg.attachments) {
                   +  + ]
     604                 :           1 :         Attachment att;
     605                 :           1 :         att.filename = part.filename;
     606                 :           1 :         att.contentType = part.contentType;
     607                 :           1 :         att.size = part.body.size();
     608                 :           1 :         att.contentId = part.contentId;
     609         [ +  - ]:           1 :         attachments.append(att);
     610         [ +  - ]:           1 :         blobs.append(part.body);
     611                 :           1 :       }
     612         [ +  - ]:           1 :       m_cache->storeAttachments(folderId, uid, attachments, blobs);
     613         [ +  - ]:           1 :       m_model->setHasAttachments(uid, folderId, true);
     614                 :           1 :     }
     615                 :             : 
     616   [ +  -  +  -  :           2 :     qCInfo(lcController) << "Parsed body for UID" << uid
          +  -  +  -  +  
                      + ]
     617   [ +  -  +  - ]:           1 :                          << "folderId:" << folderId
     618   [ +  -  +  - ]:           1 :                          << "plain:" << msg.textPlain.size()
     619   [ +  -  +  - ]:           1 :                          << "html:" << msg.textHtml.size()
     620   [ +  -  +  - ]:           1 :                          << "attachments:" << msg.attachments.size();
     621                 :             : 
     622                 :             :     // Display in mail view
     623         [ +  - ]:           1 :     auto cachedBody = m_cache->body(folderId, uid);
     624         [ +  - ]:           1 :     if (cachedBody) {
     625         [ +  - ]:           1 :       MailBody displayBody = cachedBody.value();
     626         [ +  - ]:           1 :       displayBody.attachments = m_cache->attachments(folderId, uid);
     627         [ +  - ]:           1 :       if (m_view)
     628         [ +  - ]:           1 :         m_view->displayMail(*hdr, displayBody);
     629                 :           1 :     }
     630                 :             : 
     631   [ +  -  +  - ]:           1 :     emit statusMessage(tr("Body loaded."));
     632                 :             : 
     633                 :             :     // Always same-folder here: rowForUid(uid, m_currentFolderId) is keyed
     634                 :             :     // by the header's own folderId, so hdr->folderId == m_currentFolderId
     635                 :             :     // whenever the lookup hits. Cross-folder (search-mode) body fetches
     636                 :             :     // flow through onBodyImapRawBodyReceived on the dedicated body
     637                 :             :     // connection instead.
     638                 :             :     // NOTE: don't call startIdleIfPossible() here — rawBodyReceived fires
     639                 :             :     // BEFORE the FETCH_BODY tagged OK arrives. The STORE handler in
     640                 :             :     // ImapService already restarts IDLE after STORE completes.
     641         [ +  - ]:           1 :     markMailAsSeen(uid);
     642   [ +  -  +  - ]:           1 :     if (row >= 0) prefetchAdjacent(row);
     643                 :           1 :     return;
     644                 :           1 :   }
     645                 :             : 
     646                 :             :   // T-113: Discard if folder changed since the body was requested
     647         [ +  - ]:           1 :   if (m_activeFolderGen != m_folderGeneration) {
     648   [ +  -  +  -  :           2 :     qCInfo(lcController) << "Discarding stale body for UID" << uid;
          +  -  +  -  +  
                      + ]
     649                 :           1 :     return;
     650                 :             :   }
     651                 :             : 
     652                 :           0 :   processRawBody(uid, rawBody);
     653                 :             : }
     654                 :             : 
     655                 :             : // ═══════════════════════════════════════════════════════
     656                 :             : // IDLE Event Handlers (all use executeAfterIdle for Bug 5)
     657                 :             : // ═══════════════════════════════════════════════════════
     658                 :             : 
     659                 :           7 : void MailController::onIdleNewMessages(int newCount) {
     660   [ +  -  +  -  :          14 :   qCInfo(lcController) << "IDLE:" << newCount << "new messages in"
          +  -  +  -  +  
                -  +  + ]
     661         [ +  - ]:           7 :                        << m_currentFolder;
     662                 :             : 
     663                 :           7 :   qint64 maxUid = m_cache->maxUid(m_currentFolderId);
     664                 :             : 
     665                 :             :   // executeAfterIdle: sends DONE, waits for OK, then fetches
     666         [ +  - ]:           7 :   m_imap->executeAfterIdle(
     667                 :          14 :       [this, maxUid]() { m_imap->fetchHeaders(maxUid + 1); });
     668                 :             : 
     669         [ +  - ]:           7 :   emit statusMessage(
     670   [ +  -  +  -  :          28 :       QString("%1 – %2 new mail(s)!").arg(m_currentFolder).arg(newCount));
                   +  - ]
     671                 :           7 : }
     672                 :             : 
     673                 :          54 : void MailController::onIdleFlagsChanged(qint64 uid, quint32 flags) {
     674                 :             :   // This comes during IDLE (no stop needed – it's a push notification)
     675   [ +  -  +  -  :         108 :   qCInfo(lcController) << "IDLE: flags changed for UID" << uid << "→" << flags;
          +  -  +  -  +  
             -  +  -  +  
                      + ]
     676                 :             : 
     677                 :             :   // T-201: Server confirmed the flag change → remove from pending set
     678                 :          54 :   m_pendingFlagUids.remove(uid);
     679                 :             : 
     680                 :          54 :   m_cache->updateFlags(m_currentFolderId, uid, flags);
     681                 :          54 :   m_model->updateFlags(uid, flags, m_currentFolderId);
     682         [ +  - ]:          54 :   if (m_threadModel)
     683                 :          54 :     m_threadModel->updateFlags(uid, flags, m_currentFolderId);
     684                 :          54 :   emit unreadCountChanged(m_currentFolder, m_model->unreadCount());
     685                 :          54 : }
     686                 :             : 
     687                 :           3 : void MailController::onIdleMessageExpunged(int seqNo) {
     688   [ +  -  +  -  :           6 :   qCInfo(lcController) << "IDLE: message expunged, seqNo" << seqNo;
          +  -  +  -  +  
                      + ]
     689                 :             : 
     690                 :             :   // Debounce: coalesce rapid expunge events (e.g. bulk deletes)
     691                 :             :   // into a single fetchFlags call after 200ms of quiet.
     692                 :           3 :   m_expungeDebounceTimer->start();
     693                 :           3 : }
     694                 :             : 
     695                 :           1 : void MailController::onIdleFlagsNeedRefetch(int seqNo) {
     696   [ +  -  +  -  :           2 :   qCInfo(lcController) << "IDLE: flag change without UID, seqNo" << seqNo;
          +  -  +  -  +  
                      + ]
     697                 :             :   // T-065: Stop IDLE, fetch UID+FLAGS for this sequence number,
     698                 :             :   // result flows through onFlagsReceived which restarts IDLE.
     699         [ +  - ]:           1 :   m_imap->executeAfterIdle([this, seqNo]() {
     700                 :           1 :     m_imap->fetchUidForSeqNo(seqNo);
     701                 :           1 :   });
     702                 :           1 : }
     703                 :             : 
     704                 :             : // ═══════════════════════════════════════════════════════
     705                 :             : // Flag Sync
     706                 :             : // ═══════════════════════════════════════════════════════
     707                 :             : 
     708                 :          57 : void MailController::onFlagsReceived(
     709                 :             :     const QList<QPair<qint64, quint32>> &uidFlags) {
     710                 :             :   // T-113: Discard if folder changed
     711         [ +  + ]:          57 :   if (m_activeFolderGen != m_folderGeneration) {
     712   [ +  -  +  -  :           2 :     qCInfo(lcController) << "Discarding stale flags (gen" << m_activeFolderGen
          +  -  +  -  +  
                      + ]
     713   [ +  -  +  -  :           1 :                          << "vs" << m_folderGeneration << ")";
                   +  - ]
     714                 :           1 :     return;
     715                 :             :   }
     716                 :             : 
     717   [ +  -  +  -  :         112 :   qCInfo(lcController) << "Flag sync:" << uidFlags.size() << "UID/flag pairs";
          +  -  +  -  +  
                -  +  + ]
     718                 :             : 
     719                 :             :   // Build server UID set for expunge detection
     720                 :          56 :   QSet<qint64> serverUids;
     721                 :          56 :   QList<QPair<qint64, quint32>> changed;
     722                 :             : 
     723         [ +  + ]:         440 :   for (const auto &[uid, serverFlags] : uidFlags) {
     724         [ +  - ]:         384 :     serverUids.insert(uid);
     725                 :             :     // T-201: Skip UIDs with pending optimistic flag updates
     726         [ +  + ]:         384 :     if (m_pendingFlagUids.contains(uid))
     727                 :           1 :       continue;
     728         [ +  - ]:         383 :     int row = m_model->rowForUid(uid, m_currentFolderId);
     729         [ +  + ]:         383 :     if (row >= 0) {
     730         [ +  - ]:         352 :       auto *h = m_model->headerAt(row);
     731   [ +  -  +  + ]:         352 :       if (h && h->flags != serverFlags) {
     732         [ +  - ]:           3 :         changed.append({uid, serverFlags});
     733                 :             :       }
     734                 :             :     }
     735                 :             :   }
     736                 :             : 
     737                 :             :   // Detect expunged UIDs (in model but not on server)
     738                 :          56 :   QList<qint64> expunged;
     739   [ +  -  +  + ]:         411 :   for (int r = 0; r < m_model->rowCount(); ++r) {
     740         [ +  - ]:         355 :     auto *h = m_model->headerAt(r);
     741   [ +  -  +  +  :         355 :     if (h && !serverUids.contains(h->uid)) {
                   +  + ]
     742         [ +  - ]:           2 :       expunged.append(h->uid);
     743                 :             :     }
     744                 :             :   }
     745                 :             : 
     746                 :             :   // Detect missing UIDs (on server but not in model → need backfill)
     747                 :          56 :   QSet<qint64> modelUids;
     748   [ +  -  +  + ]:         411 :   for (int r = 0; r < m_model->rowCount(); ++r) {
     749         [ +  - ]:         355 :     auto *h = m_model->headerAt(r);
     750         [ +  - ]:         355 :     if (h)
     751         [ +  - ]:         355 :       modelUids.insert(h->uid);
     752                 :             :   }
     753                 :          56 :   QList<qint64> missingUids;
     754   [ +  -  +  -  :         440 :   for (qint64 uid : serverUids) {
                   +  + ]
     755         [ +  + ]:         384 :     if (!modelUids.contains(uid)) {
     756                 :             :       // T-201: Don't backfill UIDs that were optimistically moved
     757         [ -  + ]:          31 :       if (m_pendingMoveUids.contains(uid))
     758                 :           0 :         continue;
     759         [ +  - ]:          31 :       missingUids.append(uid);
     760                 :             :     }
     761                 :             :   }
     762                 :             : 
     763                 :             :   // Apply changes
     764         [ +  + ]:          56 :   if (!changed.isEmpty()) {
     765         [ +  - ]:           3 :     m_cache->batchUpdateFlags(m_currentFolderId, changed);
     766   [ +  -  +  -  :           6 :     for (const auto &[uid, flags] : changed) {
                   +  + ]
     767         [ +  - ]:           3 :       m_model->updateFlags(uid, flags, m_currentFolderId);
     768         [ +  - ]:           3 :       if (m_threadModel)
     769         [ +  - ]:           3 :         m_threadModel->updateFlags(uid, flags, m_currentFolderId);
     770                 :             :     }
     771   [ +  -  +  -  :           6 :     qCInfo(lcController) << "Flag sync: updated" << changed.size() << "flags";
          +  -  +  -  +  
                -  +  + ]
     772                 :             :   }
     773                 :             : 
     774   [ +  -  +  -  :          58 :   for (qint64 uid : expunged) {
                   +  + ]
     775         [ +  - ]:           2 :     m_cache->removeHeader(m_currentFolderId, uid);
     776         [ +  - ]:           2 :     m_model->removeByUid(uid, m_currentFolderId);
     777   [ +  -  +  -  :           4 :     qCInfo(lcController) << "Flag sync: removed expunged UID" << uid;
          +  -  +  -  +  
                      + ]
     778                 :             :   }
     779                 :             : 
     780                 :             :   // Only emit badge when model represents the full folder (not during
     781                 :             :   // initial header fetch where the model is empty/partial).
     782         [ +  + ]:          56 :   if (!m_pendingHeaderFetch) {
     783   [ +  -  +  - ]:          30 :     emit unreadCountChanged(m_currentFolder, m_model->unreadCount());
     784                 :             :   }
     785         [ +  - ]:          56 :   emit statusMessage(
     786   [ +  -  +  -  :         224 :       QString("%1 – %2 mails").arg(m_currentFolder).arg(m_model->rowCount()));
             +  -  +  - ]
     787                 :             : 
     788                 :             :   // T-209: Record successful sync timestamp
     789         [ +  - ]:          56 :   m_cache->setLastSync(m_currentFolderId);
     790                 :             : 
     791                 :             :   // T-058: If pending header fetch, do delta fetch now; otherwise start IDLE
     792         [ +  + ]:          56 :   if (m_pendingHeaderFetch) {
     793                 :          26 :     m_pendingHeaderFetch = false;
     794                 :             : 
     795                 :             :     // Backfill: if server has UIDs we don't have in cache, fetch them.
     796                 :             :     // Also handles first-visit folders where ALL UIDs are "missing".
     797                 :             :     // Use reverse-chunk pipeline so newest arrive first.
     798         [ +  + ]:          26 :     if (!missingUids.isEmpty()) {
     799   [ +  -  +  -  :          18 :       qCInfo(lcController) << "Flag sync: backfilling" << missingUids.size()
          +  -  +  -  +  
                      + ]
     800         [ +  - ]:           9 :                            << "missing UIDs from server";
     801                 :             :       // Feed into the reverse-chunk pipeline (same as onSearchResultReceived)
     802   [ +  -  +  -  :           9 :       std::sort(missingUids.begin(), missingUids.end(),
                   +  - ]
     803                 :             :                 std::greater<qint64>());
     804         [ +  - ]:           9 :       m_reverseChunks.clear();
     805         [ +  + ]:          18 :       for (int i = 0; i < missingUids.size(); i += REVERSE_CHUNK_SIZE) {
     806   [ +  -  +  - ]:           9 :         m_reverseChunks.append(missingUids.mid(i, REVERSE_CHUNK_SIZE));
     807                 :             :       }
     808   [ +  -  +  -  :          18 :       qCInfo(lcController) << "Backfill:" << m_reverseChunks.size()
          +  -  +  -  +  
                      + ]
     809   [ +  -  +  - ]:           9 :                            << "chunks of" << REVERSE_CHUNK_SIZE;
     810         [ +  - ]:           9 :       fetchNextChunk();
     811                 :             :     } else {
     812         [ +  - ]:          17 :       qint64 maxUid = m_cache->maxUid(m_currentFolderId);
     813                 :             : 
     814         [ +  + ]:          17 :       if (maxUid == 0) {
     815                 :             :         // T-061: Initial sync – use SEARCH to get UIDs, then fetch newest first
     816   [ +  -  +  -  :          14 :         qCInfo(lcController) << "Initial sync: searching all UIDs for reverse fetch";
             +  -  +  + ]
     817         [ +  - ]:           7 :         m_imap->searchAllUids();
     818                 :             :       } else {
     819                 :             :         // Delta sync – search new UIDs, then fetch newest first
     820   [ +  -  +  -  :          20 :         qCInfo(lcController) << "Delta sync: searching UIDs from"
             +  -  +  + ]
     821         [ +  - ]:          10 :                              << (maxUid + 1);
     822         [ +  - ]:          10 :         m_imap->searchAllUids(maxUid + 1);
     823                 :             :       }
     824                 :             :     }
     825                 :             :   } else {
     826                 :             :     // T-061: If reverse chunks remain, fetch next chunk
     827         [ -  + ]:          30 :     if (!m_reverseChunks.isEmpty()) {
     828         [ #  # ]:           0 :       fetchNextChunk();
     829                 :             :     } else {
     830                 :             :       // Flag sync done → start IDLE
     831         [ +  - ]:          30 :       startIdleIfPossible();
     832                 :             :     }
     833                 :             :   }
     834                 :          56 : }
     835                 :             : 
     836                 :             : // ═══════════════════════════════════════════════════════
     837                 :             : // Folder Polling (Bug 4 fix: pauses IDLE cleanly)
     838                 :             : // ═══════════════════════════════════════════════════════
     839                 :             : 
     840                 :           7 : void MailController::pollFolders() {
     841         [ +  + ]:           7 :   if (m_subscribedFolders.isEmpty())
     842                 :           5 :     return;
     843                 :             : 
     844   [ +  -  +  -  :           4 :   qCInfo(lcController) << "Polling" << m_subscribedFolders.size()
          +  -  +  -  +  
                      + ]
     845         [ +  - ]:           2 :                        << "subscribed folders for unread counts";
     846                 :             : 
     847                 :           2 :   m_pollingIndex = 0;
     848                 :             : 
     849                 :             :   // T-206: Use search connection for polling to avoid interrupting IDLE
     850                 :           2 :   ensureSearchConnection();
     851   [ +  -  +  -  :           6 :   if (m_searchImap &&
                   -  + ]
     852         [ -  + ]:           4 :       (m_searchImap->state() == ImapService::State::Authenticated ||
     853                 :           2 :        m_searchImap->state() == ImapService::State::Selected)) {
     854   [ #  #  #  #  :           0 :     qCInfo(lcController) << "T-206: Polling via search connection (IDLE preserved)";
             #  #  #  # ]
     855                 :           0 :     pollNextFolder();
     856                 :             :   } else {
     857                 :             :     // Fallback: pause IDLE → run all STATUS commands → re-IDLE
     858         [ +  - ]:           4 :     m_imap->executeAfterIdle([this]() { pollNextFolder(); });
     859                 :             :   }
     860                 :             : }
     861                 :             : 
     862                 :           5 : void MailController::triggerPollNow() {
     863                 :           5 :   pollFolders();
     864                 :           5 : }
     865                 :             : 
     866                 :          16 : void MailController::pollNextFolder() {
     867                 :             :   // Skip current folder (already live via IDLE)
     868   [ +  +  +  +  :          35 :   while (m_pollingIndex < m_subscribedFolders.size() &&
                   +  + ]
     869                 :          16 :          m_subscribedFolders.at(m_pollingIndex) == m_currentFolder) {
     870                 :           3 :     m_pollingIndex++;
     871                 :             :   }
     872                 :             : 
     873         [ +  + ]:          16 :   if (m_pollingIndex >= m_subscribedFolders.size()) {
     874                 :             :     // All folders polled → restart IDLE (only needed if using main connection)
     875   [ +  -  +  -  :           6 :     qCInfo(lcController) << "Polling complete";
             +  -  +  + ]
     876         [ +  - ]:           3 :     if (!m_imap->isIdling()) {
     877                 :           3 :       startIdleIfPossible();
     878                 :             :     }
     879                 :           3 :     return;
     880                 :             :   }
     881                 :             : 
     882                 :             :   // T-206: Use search connection for STATUS when available
     883   [ +  +  +  +  :          33 :   if (m_searchImap &&
                   +  + ]
     884         [ -  + ]:          20 :       (m_searchImap->state() == ImapService::State::Authenticated ||
     885                 :           8 :        m_searchImap->state() == ImapService::State::Selected)) {
     886                 :           4 :     m_searchImap->statusFolder(m_subscribedFolders.at(m_pollingIndex));
     887                 :             :   } else {
     888                 :           9 :     m_imap->statusFolder(m_subscribedFolders.at(m_pollingIndex));
     889                 :             :   }
     890                 :             : }
     891                 :             : 
     892                 :          13 : void MailController::onFolderStatusReceived(const StatusResult &result) {
     893   [ +  -  +  -  :          26 :   qCInfo(lcController) << "STATUS" << result.folderPath
          +  -  +  -  +  
                      + ]
     894   [ +  -  +  - ]:          13 :                        << "messages:" << result.messages
     895   [ +  -  +  - ]:          13 :                        << "unseen:" << result.unseen;
     896                 :             : 
     897                 :             :   // T-074: Store polled value for badge preservation during folder switch
     898                 :          13 :   m_lastPolledUnread[result.folderPath] = result.unseen;
     899                 :             : 
     900                 :             :   // T-075: Persist badge to cache for startup
     901                 :          13 :   qint64 fid = m_cache->ensureFolder(m_accountId, result.folderPath);
     902         [ +  - ]:          13 :   if (fid >= 0) {
     903                 :          13 :     m_cache->storeBadge(fid, result.unseen);
     904                 :             :   }
     905                 :             : 
     906         [ +  + ]:          13 :   if (m_folderTree) {
     907                 :          12 :     m_folderTree->setUnreadCount(result.folderPath, result.unseen);
     908                 :             :   }
     909                 :          13 :   emit unreadCountChanged(result.folderPath, result.unseen);
     910                 :             : 
     911                 :             :   // Only advance sequential polling when NOT using NOTIFY.
     912                 :             :   // With NOTIFY, STATUS pushes arrive asynchronously (no polling loop).
     913         [ +  - ]:          13 :   if (!m_imap->isNotifying()) {
     914                 :          13 :     m_pollingIndex++;
     915                 :          13 :     pollNextFolder();
     916                 :             :   }
     917                 :          13 : }
     918                 :             : 
     919                 :             : // ═══════════════════════════════════════════════════════
     920                 :             : // Helpers
     921                 :             : // ═══════════════════════════════════════════════════════
     922                 :             : 
     923                 :          39 : void MailController::startIdleIfPossible() {
     924                 :             :   // T-320: Prefer NOTIFY over IDLE — watches ALL folders on one connection
     925   [ -  +  -  -  :          39 :   if (m_imap->hasNotifyCapability() &&
                   -  + ]
     926                 :           0 :       m_imap->state() == ImapService::State::Selected) {
     927                 :           0 :     m_imap->startNotify(m_subscribedFolders);
     928                 :           0 :     m_pollingTimer->stop(); // NOTIFY replaces polling entirely
     929                 :             : 
     930                 :             :     // T-210: Log total folder-switch duration
     931         [ #  # ]:           0 :     if (m_folderSwitchStopwatch.isValid()) {
     932                 :           0 :       qint64 elapsed = m_folderSwitchStopwatch.elapsed();
     933   [ #  #  #  #  :           0 :       qCInfo(lcController) << "T-210: Folder switch total:"
             #  #  #  # ]
     934   [ #  #  #  #  :           0 :                            << m_currentFolder << "→" << elapsed << "ms";
             #  #  #  # ]
     935         [ #  # ]:           0 :       if (elapsed > 2000) {
     936   [ #  #  #  #  :           0 :         qCWarning(lcController) << "T-210: SLOW folder switch (>2s):"
             #  #  #  # ]
     937   [ #  #  #  #  :           0 :                                 << m_currentFolder << elapsed << "ms";
                   #  # ]
     938                 :             :       }
     939                 :           0 :       m_folderSwitchStopwatch.invalidate();
     940                 :             :     }
     941                 :             : 
     942   [ #  #  #  # ]:           0 :     emit statusMessage(QString("%1 – NOTIFY active – %2 mails")
     943         [ #  # ]:           0 :                            .arg(m_currentFolder)
     944   [ #  #  #  # ]:           0 :                            .arg(m_cache->headerCount(m_currentFolderId)));
     945                 :             : 
     946                 :             :     // T-066: Trigger initial poll-equivalent via NOTIFY STATUS pushes
     947                 :             :     // (server sends STATUS for all watched folders after NOTIFY SET)
     948                 :           0 :     m_initialPollDone = true;
     949                 :           0 :     return;
     950                 :             :   }
     951                 :             : 
     952                 :             :   // Fallback: IDLE + STATUS polling
     953   [ +  +  +  -  :          73 :   if (m_imap->hasIdleCapability() &&
                   +  + ]
     954                 :          34 :       m_imap->state() == ImapService::State::Selected) {
     955                 :          34 :     m_imap->startIdle();
     956                 :             : 
     957                 :             :     // T-210: Log total folder-switch duration
     958         [ +  + ]:          34 :     if (m_folderSwitchStopwatch.isValid()) {
     959                 :          26 :       qint64 elapsed = m_folderSwitchStopwatch.elapsed();
     960   [ +  -  +  -  :          52 :       qCInfo(lcController) << "T-210: Folder switch total:"
             +  -  +  + ]
     961   [ +  -  +  -  :          26 :                            << m_currentFolder << "→" << elapsed << "ms";
             +  -  +  - ]
     962         [ -  + ]:          26 :       if (elapsed > 2000) {
     963   [ #  #  #  #  :           0 :         qCWarning(lcController) << "T-210: SLOW folder switch (>2s):"
             #  #  #  # ]
     964   [ #  #  #  #  :           0 :                                 << m_currentFolder << elapsed << "ms";
                   #  # ]
     965                 :             :       }
     966                 :          26 :       m_folderSwitchStopwatch.invalidate();
     967                 :             :     }
     968                 :             : 
     969   [ +  -  +  - ]:          68 :     emit statusMessage(QString("%1 – IDLE active – %2 mails")
     970         [ +  - ]:          68 :                            .arg(m_currentFolder)
     971   [ +  -  +  - ]:          68 :                            .arg(m_cache->headerCount(m_currentFolderId)));
     972                 :             : 
     973                 :             :     // Start polling timer for non-IDLE folders
     974         [ +  - ]:          34 :     if (!m_subscribedFolders.isEmpty()) {
     975                 :          34 :       m_pollingTimer->start();
     976                 :             : 
     977                 :             :       // T-066: On first IDLE start, trigger an immediate poll for all
     978                 :             :       // subscribed folders so unread badges appear within seconds.
     979         [ +  + ]:          34 :       if (!m_initialPollDone) {
     980                 :           2 :         m_initialPollDone = true;
     981                 :             :         // Small delay to let IDLE establish before interrupting it
     982         [ +  - ]:           2 :         QTimer::singleShot(2000, this, &MailController::pollFolders);
     983                 :             :       }
     984                 :             :     }
     985                 :             :   }
     986                 :             : }
     987                 :             : 
     988                 :           2 : void MailController::processRawBody(qint64 uid, const QByteArray &rawBody) {
     989         [ +  - ]:           2 :   MimeMessage msg = MimeParser::parse(rawBody);
     990                 :             : 
     991                 :           2 :   MailBody body;
     992                 :           2 :   body.uid = uid;
     993                 :           2 :   body.textPlain = msg.textPlain;
     994                 :           2 :   body.textHtml = msg.textHtml;
     995                 :           2 :   body.rawSource = rawBody;
     996                 :             : 
     997         [ +  - ]:           2 :   m_cache->storeBody(m_currentFolderId, uid, body);
     998                 :             : 
     999                 :             :   // T-179: Re-index with body text for improved FTS5 search
    1000         [ +  - ]:           2 :   m_cache->indexForSearch(m_currentFolderId, uid);
    1001                 :             : 
    1002         [ +  + ]:           2 :   if (!msg.attachments.isEmpty()) {
    1003                 :           1 :     QList<Attachment> attachments;
    1004                 :           1 :     QList<QByteArray> blobs;
    1005   [ +  -  +  -  :           2 :     for (const auto &part : msg.attachments) {
                   +  + ]
    1006                 :           1 :       Attachment att;
    1007                 :           1 :       att.filename = part.filename;
    1008                 :           1 :       att.contentType = part.contentType;
    1009                 :           1 :       att.size = part.body.size();
    1010                 :           1 :       att.contentId = part.contentId;
    1011         [ +  - ]:           1 :       attachments.append(att);
    1012         [ +  - ]:           1 :       blobs.append(part.body);
    1013                 :           1 :     }
    1014         [ +  - ]:           1 :     m_cache->storeAttachments(m_currentFolderId, uid, attachments, blobs);
    1015         [ +  - ]:           1 :     m_model->setHasAttachments(uid, m_currentFolderId, true);
    1016                 :           1 :   }
    1017                 :             : 
    1018   [ +  -  +  -  :           4 :   qCInfo(lcController) << "Parsed body for UID" << uid
          +  -  +  -  +  
                      + ]
    1019   [ +  -  +  - ]:           2 :                        << "plain:" << msg.textPlain.size()
    1020   [ +  -  +  - ]:           2 :                        << "html:" << msg.textHtml.size()
    1021   [ +  -  +  - ]:           2 :                        << "attachments:" << msg.attachments.size();
    1022                 :           2 : }
    1023                 :             : 
    1024                 :          49 : void MailController::setImapConfig(const ImapConfig &config) {
    1025                 :          49 :   m_imapConfig = config;
    1026                 :          49 : }
    1027                 :             : 
    1028                 :          50 : void MailController::ensureSearchConnection() {
    1029         [ +  + ]:          50 :   if (!m_searchImap) {
    1030   [ +  -  -  +  :           8 :     m_searchImap = new ImapService(this);
                   -  - ]
    1031                 :             :     // Note: searchResultReceived is connected dynamically in searchNextFolder()
    1032                 :             :     // T-206: Also handle STATUS responses from search connection (polling)
    1033                 :           8 :     connect(m_searchImap, &ImapService::folderStatusReceived, this,
    1034         [ +  - ]:           8 :             &MailController::onFolderStatusReceived);
    1035                 :           8 :     connect(m_searchImap, &ImapService::stateChanged, this,
    1036         [ +  - ]:           8 :             [this](ImapService::State s) {
    1037         [ +  + ]:          23 :               if (s == ImapService::State::Authenticated) {
    1038   [ +  -  +  -  :           6 :                 qCInfo(lcController) << "Search IMAP connection ready";
             +  -  +  + ]
    1039                 :             :                 // T-720: Active server-search recovery. If the connection
    1040                 :             :                 // died mid-search (m_searchCurrentFolder /
    1041                 :             :                 // m_searchPendingFolders non-empty), the one-shot SELECT/
    1042                 :             :                 // SEARCH handlers were disconnected by Error/Disconnected.
    1043                 :             :                 // Resume the search here so the user's query completes
    1044                 :             :                 // instead of hanging forever (the stall the sprint plan
    1045                 :             :                 // calls out at MailController.cpp:1295-1358).
    1046   [ +  +  +  + ]:           5 :                 if (!m_searchCurrentFolder.isEmpty() ||
    1047         [ -  + ]:           2 :                     !m_searchPendingFolders.isEmpty()) {
    1048   [ +  -  +  -  :           2 :                   qCInfo(lcController) << "T-720: Resuming server search"
             +  -  +  + ]
    1049         [ +  - ]:           1 :                                        << "after search-connection reconnect"
    1050         [ +  - ]:           1 :                                        << "(current="
    1051         [ +  - ]:           1 :                                        << m_searchCurrentFolder
    1052         [ +  - ]:           1 :                                        << ", pending="
    1053   [ +  -  +  - ]:           1 :                                        << m_searchPendingFolders.size() << ")";
    1054                 :             :                   // Put the current folder back at the head of the queue so
    1055                 :             :                   // searchNextFolder() retries it, then drains the rest.
    1056         [ +  - ]:           1 :                   if (!m_searchCurrentFolder.isEmpty())
    1057                 :           1 :                     m_searchPendingFolders.prepend(m_searchCurrentFolder);
    1058                 :           1 :                   m_searchCurrentFolder.clear();
    1059                 :           1 :                   searchNextFolder();
    1060                 :             :                 }
    1061   [ +  +  -  + ]:          20 :               } else if (s == ImapService::State::Error ||
    1062                 :             :                          s == ImapService::State::Disconnected) {
    1063   [ +  -  +  -  :           6 :                 qCWarning(lcController) << "Search IMAP"
             +  -  +  + ]
    1064   [ +  -  +  - ]:           3 :                     << (s == ImapService::State::Error ? "error" : "disconnected");
    1065                 :             :                 // T-720: Disconnect the one-shot SELECT/SEARCH handlers so
    1066                 :             :                 // they cannot fire against the wrong socket state after
    1067                 :             :                 // reconnect. searchNextFolder() reconnects them.
    1068                 :           3 :                 QObject::disconnect(m_searchFolderConn);
    1069                 :           3 :                 QObject::disconnect(m_searchFailConn);
    1070                 :           3 :                 QObject::disconnect(m_searchResultConn);
    1071                 :             :               }
    1072                 :          23 :             });
    1073                 :             : 
    1074                 :             :     // T-720: Wrap the search connection in a monitor so it self-heals
    1075                 :             :     // instead of waiting for the next ensureSearchConnection() call.
    1076                 :             :     // systemWatchEnabled=false: the main connection (MainWindow) owns the
    1077                 :             :     // single set of system-wide hooks; secondaries react via probes/state.
    1078   [ +  -  -  +  :           8 :     m_searchHealth = new ConnectionHealthMonitor(false, this);
                   -  - ]
    1079                 :           8 :     m_searchHealth->attach(m_searchImap);
    1080                 :             :   }
    1081                 :             : 
    1082                 :             :   // (Re)install the reconnect config + activate the monitor every call.
    1083         [ +  - ]:          50 :   if (m_searchHealth) {
    1084                 :          50 :     m_searchHealth->setReconnectConfig(m_imapConfig);
    1085                 :          50 :     m_searchHealth->setActive(true);
    1086                 :             :   }
    1087                 :             : 
    1088                 :             :   // Reconnect not only from a clean Disconnected state but also from Error:
    1089                 :             :   // servers routinely drop idle secondary connections (TLS close), which lands
    1090                 :             :   // the search connection in Error. The monitor now handles this for us, but
    1091                 :             :   // we keep the explicit call for the initial connect path.
    1092   [ +  +  -  +  :          92 :   if (m_searchImap->state() == ImapService::State::Disconnected ||
                   +  + ]
    1093                 :          42 :       m_searchImap->state() == ImapService::State::Error) {
    1094   [ +  -  +  -  :          16 :     qCInfo(lcController) << "Connecting search IMAP (state:"
             +  -  +  + ]
    1095   [ +  -  +  - ]:           8 :                          << static_cast<int>(m_searchImap->state()) << ")...";
    1096                 :           8 :     m_searchImap->connectToServer(m_imapConfig);
    1097                 :             :   }
    1098                 :          50 : }
    1099                 :             : 
    1100                 :             : // T-205: Lazy-init dedicated IMAP connection for body fetch
    1101                 :         105 : void MailController::ensureBodyConnection() {
    1102         [ +  + ]:         105 :   if (!m_bodyImap) {
    1103   [ +  -  -  +  :          32 :     m_bodyImap = new ImapService(this);
                   -  - ]
    1104                 :          32 :     m_bodyImap->setAutoIdle(false); // T-205: Body connection must NOT idle
    1105                 :          32 :     connect(m_bodyImap, &ImapService::rawBodyReceived, this,
    1106         [ +  - ]:          32 :             &MailController::onBodyImapRawBodyReceived);
    1107                 :          32 :     connect(m_bodyImap, &ImapService::bodyFetchTooLarge, this,
    1108         [ +  - ]:          32 :             &MailController::onBodyFetchTooLarge);
    1109                 :             :     // T-79.E2/M6: cross-folder moves run on this connection — without
    1110                 :             :     // these handlers a successful move never cleaned the source-folder
    1111                 :             :     // cache and a failed move had neither rollback nor user feedback.
    1112                 :          32 :     connect(m_bodyImap, &ImapService::messagesMoved, this,
    1113         [ +  - ]:          32 :             &MailController::onBodyImapMessagesMoved);
    1114                 :          32 :     connect(m_bodyImap, &ImapService::moveError, this,
    1115         [ +  - ]:          32 :             &MailController::onBodyImapMoveError);
    1116                 :          32 :     connect(m_bodyImap, &ImapService::stateChanged, this,
    1117         [ +  - ]:          32 :             [this](ImapService::State s) {
    1118   [ +  -  +  -  :         164 :               qCInfo(lcController) << "Body IMAP state changed to"
             +  -  +  + ]
    1119         [ +  - ]:          82 :                                    << static_cast<int>(s);
    1120         [ +  + ]:          82 :               if (s == ImapService::State::Authenticated) {
    1121   [ +  -  +  -  :           8 :                 qCInfo(lcController) << "Body IMAP connection ready";
             +  -  +  + ]
    1122                 :             :                 // T-205: Re-issue the pending body fetch after a reconnect.
    1123                 :             :                 // The monitor (T-720) handled the reconnect itself; this
    1124                 :             :                 // branch is what re-fetches the body the user was waiting
    1125                 :             :                 // for. Kept verbatim per SPRINT-72.md.
    1126         [ +  - ]:           4 :                 if (m_pendingBodyUid > 0) {
    1127                 :           4 :                   QString folder = m_pendingBodyFolderId > 0
    1128         [ -  + ]:           4 :                       ? m_cache->folderPath(m_pendingBodyFolderId)
    1129         [ -  - ]:           4 :                       : m_currentFolder;
    1130         [ +  + ]:           4 :                   if (!folder.isEmpty()) {
    1131   [ +  -  +  -  :           6 :                     qCInfo(lcController) << "Retrying body fetch for UID"
             +  -  +  + ]
    1132   [ +  -  +  - ]:           3 :                                          << m_pendingBodyUid << "after reconnect";
    1133                 :           3 :                     m_bodyImapSelectedFolder = folder;
    1134         [ +  - ]:           3 :                     m_bodyImap->selectAndFetchBody(folder, m_pendingBodyUid);
    1135                 :             :                   } else {
    1136   [ +  -  +  -  :           2 :                     qCWarning(lcController) << "Body IMAP authenticated but"
             +  -  +  + ]
    1137         [ +  - ]:           1 :                         << "folder is empty — cannot dispatch pending UID"
    1138         [ +  - ]:           1 :                         << m_pendingBodyUid;
    1139                 :             :                   }
    1140                 :           4 :                 }
    1141                 :             :               }
    1142                 :          82 :             });
    1143                 :             : 
    1144                 :             :     // T-720: Health monitor replaces T-540 keepalive timer + T-544 manual
    1145                 :             :     // retry arms. Periodic liveness probes keep the body connection alive
    1146                 :             :     // (NOOP while Authenticated/Selected, IDLE DONE/OK while Idling) and
    1147                 :             :     // exponential-backoff reconnect covers silent socket death. The
    1148                 :             :     // Authenticated branch above re-fetches the pending body on reconnect.
    1149                 :             :     // systemWatchEnabled=false: secondary monitor — see m_searchHealth.
    1150   [ +  -  -  +  :          32 :     m_bodyHealth = new ConnectionHealthMonitor(false, this);
                   -  - ]
    1151                 :          32 :     m_bodyHealth->attach(m_bodyImap);
    1152                 :             :   }
    1153                 :             : 
    1154                 :             :   // (Re)install the reconnect config + activate the monitor every call —
    1155                 :             :   // setImapConfig() can be invoked later than the first ensureBodyConnection().
    1156         [ +  - ]:         105 :   if (m_bodyHealth) {
    1157                 :         105 :     m_bodyHealth->setReconnectConfig(m_imapConfig);
    1158                 :         105 :     m_bodyHealth->setActive(true);
    1159                 :             :   }
    1160                 :             : 
    1161   [ +  -  +  -  :         210 :   qCInfo(lcController) << "ensureBodyConnection: state"
             +  -  +  + ]
    1162         [ +  - ]:         105 :                        << static_cast<int>(m_bodyImap->state())
    1163   [ +  -  +  - ]:         105 :                        << "configValid" << !m_imapConfig.host.isEmpty();
    1164                 :             : 
    1165                 :             :   // Reconnect if disconnected or in error state. The monitor will pick up
    1166                 :             :   // the next silent death on its own; this call covers the initial connect
    1167                 :             :   // and the case where the user opens an uncached body after a clean
    1168                 :             :   // server-side disconnect (e.g. server idle timeout).
    1169   [ +  +  +  +  :         178 :   if (m_bodyImap->state() == ImapService::State::Disconnected ||
                   +  + ]
    1170                 :          73 :       m_bodyImap->state() == ImapService::State::Error) {
    1171   [ +  -  +  -  :          84 :     qCInfo(lcController) << "Connecting body IMAP...";
             +  -  +  + ]
    1172                 :          42 :     m_bodyImapSelectedFolder.clear(); // Reset stale folder selection
    1173                 :          42 :     m_bodyImap->connectToServer(m_imapConfig);
    1174                 :             :   }
    1175                 :         105 : }
    1176                 :             : 
    1177                 :           1 : void MailController::onBodyFetchTooLarge(qint64 uid, qint64 maxBytes) {
    1178         [ -  + ]:           1 :   if (uid != m_pendingBodyUid) {
    1179   [ #  #  #  #  :           0 :     qCInfo(lcController) << "Skipping oversized prefetched body for UID" << uid;
          #  #  #  #  #  
                      # ]
    1180                 :           0 :     return;
    1181                 :             :   }
    1182                 :             : 
    1183         [ -  + ]:           1 :   if (m_loadingPlaceholderTimer)
    1184         [ #  # ]:           0 :     m_loadingPlaceholderTimer->stop();
    1185                 :             : 
    1186                 :           2 :   const qint64 folderId = m_pendingBodyFolderId > 0
    1187         [ +  - ]:           1 :                               ? m_pendingBodyFolderId
    1188                 :             :                               : m_currentFolderId;
    1189                 :           1 :   MailHeader header;
    1190                 :           1 :   bool found = false;
    1191         [ +  - ]:           1 :   const int row = m_model->rowForUid(uid, folderId);
    1192         [ +  - ]:           1 :   if (row >= 0) {
    1193   [ +  -  +  - ]:           1 :     if (const auto *modelHeader = m_model->headerAt(row)) {
    1194                 :           1 :       header = *modelHeader;
    1195                 :           1 :       found = true;
    1196                 :             :     }
    1197                 :             :   }
    1198         [ -  + ]:           1 :   if (!found) {
    1199         [ #  # ]:           0 :     const auto cachedHeader = m_cache->header(folderId, uid);
    1200         [ #  # ]:           0 :     if (cachedHeader) {
    1201                 :           0 :       header = *cachedHeader;
    1202                 :           0 :       found = true;
    1203                 :             :     }
    1204                 :           0 :   }
    1205                 :             : 
    1206                 :           1 :   m_pendingBodyUid = -1;
    1207                 :           1 :   m_pendingBodyFolderId = -1;
    1208         [ +  - ]:           1 :   if (found) {
    1209         [ +  - ]:           1 :     if (header.size <= maxBytes)
    1210                 :           1 :       header.size = 0; // server-side RFC822.SIZE was missing or inconsistent
    1211         [ +  - ]:           1 :     showMessageAboveDownloadLimit(header, maxBytes);
    1212                 :             :   } else {
    1213         [ #  # ]:           0 :     emit statusMessage(
    1214         [ #  # ]:           0 :         tr("Message not downloaded because it exceeds the configured size "
    1215                 :             :            "limit."));
    1216                 :             :   }
    1217                 :           1 : }
    1218                 :             : 
    1219                 :             : // T-205: Handle body received from dedicated body connection
    1220                 :          59 : void MailController::onBodyImapRawBodyReceived(qint64 uid,
    1221                 :             :                                                  const QByteArray &rawBody) {
    1222   [ +  -  +  -  :         118 :   qCInfo(lcController) << "T-205: Body received from body connection, UID" << uid;
          +  -  +  -  +  
                      + ]
    1223                 :             : 
    1224         [ +  + ]:          59 :   if (uid != m_pendingBodyUid) {
    1225   [ +  -  +  -  :          76 :     qCWarning(lcController) << "Discarding body connection response for UID"
             +  -  +  + ]
    1226   [ +  -  +  - ]:          38 :                             << uid << "while waiting for"
    1227         [ +  - ]:          38 :                             << m_pendingBodyUid;
    1228                 :          38 :     return;
    1229                 :             :   }
    1230                 :             : 
    1231   [ +  -  -  + ]:          21 :   if (rawBody.size() > ImapService::configuredMaxMessageBytes()) {
    1232   [ #  #  #  # ]:           0 :     onBodyFetchTooLarge(uid, ImapService::configuredMaxMessageBytes());
    1233                 :           0 :     return;
    1234                 :             :   }
    1235                 :             : 
    1236                 :          42 :   const qint64 folderId = (m_pendingBodyFolderId > 0)
    1237         [ -  + ]:          21 :                               ? m_pendingBodyFolderId
    1238                 :             :                               : m_currentFolderId;
    1239                 :             :   // UIDs are mailbox-scoped. Never resolve the header in another folder.
    1240         [ +  - ]:          21 :   int row = m_model->rowForUid(uid, folderId);
    1241   [ +  -  +  - ]:          21 :   const MailHeader *hdr = (row >= 0) ? m_model->headerAt(row) : nullptr;
    1242                 :          21 :   MailHeader hdrCopy; // T-545: stack copy for cache-fallback path
    1243         [ -  + ]:          21 :   if (!hdr) {
    1244                 :             :     // T-545: Cache fallback during streaming
    1245         [ #  # ]:           0 :     auto cachedHdr = m_cache->header(folderId, uid);
    1246         [ #  # ]:           0 :     if (!cachedHdr) {
    1247   [ #  #  #  #  :           0 :       qCWarning(lcController) << "T-205: No header in model or cache for UID" << uid;
          #  #  #  #  #  
                      # ]
    1248                 :           0 :       return;
    1249                 :             :     }
    1250         [ #  # ]:           0 :     hdrCopy = cachedHdr.value();
    1251                 :           0 :     hdr = &hdrCopy;
    1252   [ #  #  #  #  :           0 :     qCInfo(lcController) << "T-545: Body handler using cache fallback for UID" << uid;
          #  #  #  #  #  
                      # ]
    1253         [ #  # ]:           0 :   }
    1254                 :             : 
    1255                 :             :   // Use the correct folderId — hdr->folderId may be 0 for IMAP-fetched headers
    1256                 :             :   // that haven't been reloaded from cache yet.
    1257                 :             :   // Parse and store body (same logic as processRawBody)
    1258         [ +  - ]:          21 :   MimeMessage msg = MimeParser::parse(rawBody);
    1259                 :          21 :   MailBody body;
    1260                 :          21 :   body.uid = uid;
    1261                 :          21 :   body.textPlain = msg.textPlain;
    1262                 :          21 :   body.textHtml = msg.textHtml;
    1263                 :          21 :   body.rawSource = rawBody;
    1264         [ +  - ]:          21 :   m_cache->storeBody(folderId, uid, body);
    1265         [ +  - ]:          21 :   m_cache->indexForSearch(folderId, uid);
    1266                 :             : 
    1267         [ +  + ]:          21 :   if (!msg.attachments.isEmpty()) {
    1268                 :           1 :     QList<Attachment> attachments;
    1269                 :           1 :     QList<QByteArray> blobs;
    1270   [ +  -  +  -  :           2 :     for (const auto &part : msg.attachments) {
                   +  + ]
    1271                 :           1 :       Attachment att;
    1272                 :           1 :       att.filename = part.filename;
    1273                 :           1 :       att.contentType = part.contentType;
    1274                 :           1 :       att.size = part.body.size();
    1275                 :           1 :       att.contentId = part.contentId;
    1276         [ +  - ]:           1 :       attachments.append(att);
    1277         [ +  - ]:           1 :       blobs.append(part.body);
    1278                 :           1 :     }
    1279         [ +  - ]:           1 :     m_cache->storeAttachments(folderId, uid, attachments, blobs);
    1280         [ +  - ]:           1 :     m_model->setHasAttachments(uid, folderId, true);
    1281                 :           1 :   }
    1282                 :             : 
    1283   [ +  -  +  -  :          42 :   qCInfo(lcController) << "T-205: Parsed body for UID" << uid
          +  -  +  -  +  
                      + ]
    1284   [ +  -  +  - ]:          21 :                        << "plain:" << msg.textPlain.size()
    1285   [ +  -  +  - ]:          21 :                        << "html:" << msg.textHtml.size()
    1286   [ +  -  +  - ]:          21 :                        << "attachments:" << msg.attachments.size();
    1287                 :             : 
    1288                 :             :   // Display if this is the pending body
    1289         [ +  - ]:          21 :   if (uid == m_pendingBodyUid) {
    1290                 :          21 :     m_pendingBodyUid = -1;
    1291                 :          21 :     m_pendingBodyFolderId = -1;
    1292                 :             : 
    1293                 :             :     // T-548: Cancel deferred loading placeholder — body arrived in time
    1294         [ +  - ]:          21 :     if (m_loadingPlaceholderTimer)
    1295         [ +  - ]:          21 :       m_loadingPlaceholderTimer->stop();
    1296                 :             : 
    1297                 :             :     // Display directly from parsed data (avoid re-reading from cache)
    1298                 :          21 :     MailBody displayBody;
    1299                 :          21 :     displayBody.uid = uid;
    1300                 :          21 :     displayBody.textPlain = msg.textPlain;
    1301                 :          21 :     displayBody.textHtml = msg.textHtml;
    1302                 :          21 :     displayBody.rawSource = rawBody; // T-263: Include raw source for Source button
    1303         [ +  - ]:          21 :     displayBody.attachments = m_cache->attachments(folderId, uid);
    1304         [ +  - ]:          21 :     if (m_view)
    1305         [ +  - ]:          21 :       m_view->displayMail(*hdr, displayBody);
    1306   [ +  -  +  - ]:          21 :     emit statusMessage(tr("Body geladen."));
    1307                 :             : 
    1308                 :             :     // Mark as seen + prefetch (only for same-folder, not cross-folder/search)
    1309         [ +  - ]:          21 :     if (folderId == m_currentFolderId) {
    1310         [ +  - ]:          21 :       markMailAsSeen(uid);
    1311   [ +  -  +  - ]:          21 :       if (row >= 0) prefetchAdjacent(row);
    1312                 :             :     }
    1313                 :          21 :   }
    1314                 :             : 
    1315                 :             :   // T-540: Notify listeners (e.g. tab widgets) that body is now available
    1316         [ +  - ]:          21 :   emit bodyLoaded(uid, folderId);
    1317         [ +  - ]:          21 : }
    1318                 :             : 
    1319                 :             : // Sprint 59 (S1): translate the local SearchFilter + free text into the
    1320                 :             : // server-mappable IMAP criteria. has:attachment has no standard SEARCH key, so
    1321                 :             : // it is intentionally dropped here and left to the local FTS/cache.
    1322                 :             : static ImapService::SearchCriteria
    1323                 :          84 : toImapCriteria(const QString &freeText,
    1324                 :             :                const MailCache::SearchFilter &filter) {
    1325                 :             :   using FTri = MailCache::SearchFilter::Tri;
    1326                 :             :   using ITri = ImapService::SearchTri;
    1327                 :         252 :   auto tri = [](FTri t) {
    1328   [ +  -  -  + ]:         252 :     return t == FTri::Yes ? ITri::Yes : t == FTri::No ? ITri::No : ITri::Any;
    1329                 :             :   };
    1330                 :             : 
    1331                 :          84 :   ImapService::SearchCriteria c;
    1332                 :          84 :   c.text = freeText;
    1333                 :          84 :   c.from = filter.fromFilter;
    1334                 :          84 :   c.to = filter.toFilter;
    1335                 :          84 :   c.subject = filter.subjectFilter;
    1336   [ +  -  -  + ]:          84 :   if (filter.dateFrom.isValid())
    1337         [ #  # ]:           0 :     c.since = filter.dateFrom.date();
    1338   [ +  -  -  + ]:          84 :   if (filter.dateTo.isValid())
    1339   [ #  #  #  # ]:           0 :     c.before = filter.dateTo.date().addDays(1); // inclusive UI → exclusive BEFORE
    1340                 :          84 :   c.unread = tri(filter.unread);
    1341                 :          84 :   c.flagged = tri(filter.flagged);
    1342                 :          84 :   c.answered = tri(filter.answered);
    1343                 :          84 :   c.keywords = filter.tags;
    1344                 :          84 :   return c;
    1345                 :           0 : }
    1346                 :             : 
    1347                 :          47 : void MailController::serverSearch(const QString &freeText,
    1348                 :             :                                  const MailCache::SearchFilter &filter) {
    1349         [ +  - ]:          47 :   ensureSearchConnection();
    1350                 :             : 
    1351                 :             :   // T-195: Disconnect any previous one-shot connections (re-entry safety)
    1352         [ +  - ]:          47 :   QObject::disconnect(m_searchStateConn);
    1353         [ +  - ]:          47 :   QObject::disconnect(m_searchFolderConn);
    1354         [ +  - ]:          47 :   QObject::disconnect(m_searchFailConn);
    1355         [ +  - ]:          47 :   QObject::disconnect(m_searchResultConn);
    1356                 :             : 
    1357   [ +  +  +  +  :          92 :   if (m_searchImap->state() != ImapService::State::Authenticated &&
                   +  + ]
    1358                 :          45 :       m_searchImap->state() != ImapService::State::Selected) {
    1359                 :             :     // Not ready yet — wait for authentication, then retry
    1360   [ +  -  +  -  :          46 :     qCInfo(lcController) << "Search IMAP not ready (state:"
             +  -  +  + ]
    1361         [ +  - ]:          23 :                          << static_cast<int>(m_searchImap->state())
    1362         [ +  - ]:          23 :                          << ") — waiting for Authenticated";
    1363                 :          23 :     m_searchStateConn = connect(
    1364                 :          23 :         m_searchImap, &ImapService::stateChanged, this,
    1365   [ +  -  -  - ]:          46 :         [this, freeText, filter](ImapService::State s) {
    1366   [ +  -  -  + ]:           2 :           if (s == ImapService::State::Authenticated ||
    1367                 :             :               s == ImapService::State::Selected) {
    1368                 :           0 :             QObject::disconnect(m_searchStateConn);
    1369                 :           0 :             serverSearch(freeText, filter); // retry
    1370                 :             :           }
    1371                 :          23 :         });
    1372                 :          24 :     return;
    1373                 :             :   }
    1374                 :             : 
    1375                 :          24 :   m_searchQuery = freeText;
    1376                 :          24 :   m_searchFilter = filter;
    1377                 :             : 
    1378                 :             :   // If nothing maps to a server SEARCH key (e.g. a has:attachment-only search),
    1379                 :             :   // skip the server scan entirely — the local FTS/cache already has the answer.
    1380   [ +  -  +  -  :          24 :   if (toImapCriteria(freeText, filter).isEmpty()) {
                   +  + ]
    1381   [ +  -  +  -  :           2 :     qCInfo(lcController) << "Server search: no server-mappable criteria — "
                   +  + ]
    1382         [ +  - ]:           1 :                             "serving from local cache only";
    1383         [ +  - ]:           1 :     emit serverSearchComplete();
    1384                 :           1 :     return;
    1385                 :             :   }
    1386                 :             : 
    1387                 :             :   // Build folder search queue: all subscribed folders, skip special ones.
    1388                 :             :   // Sprint 60 (S1): when folder filter(s) are given (from "folder:" prefixes),
    1389                 :             :   // only folders whose path contains ANY of the patterns are searched (OR).
    1390                 :          23 :   QStringList folderFilters;
    1391         [ +  + ]:          25 :   for (const QString &p : filter.folderPatterns)
    1392   [ +  -  +  - ]:           2 :     if (!p.trimmed().isEmpty())
    1393         [ +  - ]:           2 :       folderFilters.append(p);
    1394                 :          23 :   const bool hasFolderFilter = !folderFilters.isEmpty();
    1395         [ +  - ]:          23 :   m_searchPendingFolders.clear();
    1396                 :             :   static const QStringList skipFolders = {
    1397                 :           1 :       QStringLiteral("Trash"), QStringLiteral("Drafts"),
    1398                 :           1 :       QStringLiteral("Junk"),  QStringLiteral("Spam"),
    1399                 :           1 :       QStringLiteral("Sent"),
    1400   [ +  +  +  -  :          30 :   };
          +  +  -  -  -  
                      - ]
    1401   [ +  -  +  -  :         172 :   for (const QString &folder : m_subscribedFolders) {
                   +  + ]
    1402         [ +  + ]:         149 :     if (hasFolderFilter) {
    1403                 :           6 :       bool matchesAny = false;
    1404   [ +  -  +  -  :          11 :       for (const QString &f : folderFilters) {
                   +  + ]
    1405   [ +  -  +  + ]:           6 :         if (folder.contains(f, Qt::CaseInsensitive)) {
    1406                 :           1 :           matchesAny = true;
    1407                 :           1 :           break;
    1408                 :             :         }
    1409                 :             :       }
    1410         [ +  + ]:           6 :       if (!matchesAny)
    1411                 :           5 :         continue;
    1412                 :             :     }
    1413                 :         144 :     bool skip = false;
    1414                 :             :     // A folder explicitly targeted via folder: is searched even if it is one of
    1415                 :             :     // the normally-skipped special folders (the user asked for it).
    1416         [ +  + ]:         144 :     if (!hasFolderFilter) {
    1417         [ +  + ]:         658 :       for (const QString &ex : skipFolders) {
    1418                 :        2180 :         if (folder.compare(ex, Qt::CaseInsensitive) == 0 ||
    1419   [ +  +  +  -  :        1665 :             folder.endsWith(QLatin1Char('.') + ex, Qt::CaseInsensitive) ||
          +  -  +  -  +  
                +  -  - ]
    1420   [ +  -  +  -  :        1090 :             folder.endsWith(QLatin1Char('/') + ex, Qt::CaseInsensitive)) {
          -  +  +  +  +  
                +  -  - ]
    1421                 :          60 :           skip = true;
    1422                 :          60 :           break;
    1423                 :             :         }
    1424                 :             :       }
    1425                 :             :     }
    1426         [ +  + ]:         144 :     if (!skip)
    1427         [ +  - ]:          84 :       m_searchPendingFolders.append(folder);
    1428                 :             :   }
    1429                 :             : 
    1430   [ +  -  +  -  :          46 :   qCInfo(lcController) << "Server search: queued" << m_searchPendingFolders.size()
          +  -  +  -  +  
                      + ]
    1431   [ +  -  +  -  :          23 :                        << "folders for query" << freeText << "+ facets";
                   +  - ]
    1432         [ +  - ]:          23 :   searchNextFolder();
    1433   [ +  -  -  -  :          29 : }
                   -  - ]
    1434                 :             : 
    1435                 :          74 : void MailController::searchNextFolder() {
    1436         [ +  + ]:          74 :   if (m_searchPendingFolders.isEmpty()) {
    1437   [ +  -  +  -  :          14 :     qCInfo(lcController) << "Server search: all folders searched";
             +  -  +  + ]
    1438                 :           7 :     emit serverSearchComplete();
    1439                 :           7 :     return;
    1440                 :             :   }
    1441                 :             : 
    1442                 :             :   // Disconnect previous one-shot connections
    1443                 :          67 :   QObject::disconnect(m_searchFolderConn);
    1444                 :          67 :   QObject::disconnect(m_searchFailConn);
    1445                 :          67 :   QObject::disconnect(m_searchResultConn);
    1446                 :             : 
    1447         [ +  - ]:          67 :   m_searchCurrentFolder = m_searchPendingFolders.takeFirst();
    1448                 :          67 :   m_searchCurrentFolderId = resolveFolderId(m_searchCurrentFolder);
    1449                 :             : 
    1450   [ +  -  +  -  :         134 :   qCInfo(lcController) << "Server search: SELECT"
             +  -  +  + ]
    1451   [ +  -  +  - ]:          67 :                        << m_searchCurrentFolder << "("
    1452   [ +  -  +  - ]:          67 :                        << m_searchPendingFolders.size() << "remaining)";
    1453                 :             : 
    1454                 :             :   // Step 1: SELECT folder. We must react to BOTH outcomes:
    1455                 :             :   //  - folderSelected     → folder is ready, send SEARCH
    1456                 :             :   //  - folderSelectFailed → folder cannot be selected (e.g. \Noselect
    1457                 :             :   //                         container), skip it and continue. Without this the
    1458                 :             :   //                         whole search would stall forever on that folder.
    1459                 :             :   // NOTE: not Qt::SingleShotConnection — a mismatching path must NOT consume
    1460                 :             :   // the connection, otherwise the real folderSelected would be missed.
    1461                 :          67 :   m_searchFolderConn = connect(
    1462                 :          67 :       m_searchImap, &ImapService::folderSelected, this,
    1463         [ +  - ]:          67 :       [this](const QString &path, int, quint32, quint64) {
    1464         [ +  + ]:          61 :         if (path != m_searchCurrentFolder) return;
    1465                 :          60 :         QObject::disconnect(m_searchFolderConn);
    1466                 :          60 :         QObject::disconnect(m_searchFailConn);
    1467                 :             : 
    1468                 :             :         // Step 2: Connect result handler BEFORE sending SEARCH
    1469                 :          60 :         m_searchResultConn = connect(
    1470                 :          60 :             m_searchImap, &ImapService::searchResultReceived, this,
    1471         [ +  - ]:          60 :             [this](const QList<qint64> &uids) {
    1472                 :          49 :               QObject::disconnect(m_searchResultConn);
    1473   [ +  -  +  -  :          98 :               qCInfo(lcController) << "Server search:" << uids.size()
          +  -  +  -  +  
                      + ]
    1474   [ +  -  +  - ]:          49 :                                    << "results in" << m_searchCurrentFolder;
    1475         [ +  + ]:          49 :               if (!uids.isEmpty()) {
    1476                 :          19 :                 emit serverSearchResultReceived(
    1477                 :          19 :                     uids, m_searchCurrentFolderId, m_searchCurrentFolder);
    1478                 :             :               }
    1479                 :             :               // Continue with next folder
    1480                 :          49 :               searchNextFolder();
    1481                 :         109 :             });
    1482                 :             : 
    1483                 :             :         // Step 3: Send the composite SEARCH built from free text + facets.
    1484   [ +  -  +  - ]:          60 :         m_searchImap->search(toImapCriteria(m_searchQuery, m_searchFilter));
    1485                 :          67 :       });
    1486                 :             : 
    1487                 :          67 :   m_searchFailConn = connect(
    1488                 :          67 :       m_searchImap, &ImapService::folderSelectFailed, this,
    1489         [ +  - ]:          67 :       [this](const QString &path) {
    1490         [ -  + ]:           1 :         if (path != m_searchCurrentFolder) return;
    1491                 :           1 :         QObject::disconnect(m_searchFolderConn);
    1492                 :           1 :         QObject::disconnect(m_searchFailConn);
    1493   [ +  -  +  -  :           2 :         qCWarning(lcController) << "Server search: SELECT failed for"
             +  -  +  + ]
    1494   [ +  -  +  - ]:           1 :                                 << path << "— skipping folder";
    1495                 :           1 :         searchNextFolder();
    1496                 :          67 :       });
    1497                 :             : 
    1498                 :          67 :   m_searchImap->selectFolder(m_searchCurrentFolder);
    1499                 :             : }
    1500                 :             : 
    1501                 :          23 : void MailController::cancelServerSearch() {
    1502   [ +  -  +  -  :          46 :   qCInfo(lcController) << "Server search: cancelled ("
             +  -  +  + ]
    1503   [ +  -  +  - ]:          23 :                        << m_searchPendingFolders.size() << "folders remaining)";
    1504                 :          23 :   m_searchPendingFolders.clear();
    1505                 :          23 :   m_searchQuery.clear();
    1506                 :          23 :   m_searchFilter = {};
    1507                 :          23 :   QObject::disconnect(m_searchFolderConn);
    1508                 :          23 :   QObject::disconnect(m_searchFailConn);
    1509                 :          23 :   QObject::disconnect(m_searchResultConn);
    1510                 :          46 : }
    1511                 :             : 
    1512                 :          46 : void MailController::prefetchAdjacent(int currentRow) {
    1513                 :             :   // T-119: Don't prefetch during active sync (avoids unnecessary body fetches
    1514                 :             :   // that compete with header streaming for IMAP bandwidth)
    1515         [ +  + ]:          46 :   if (m_fetchInProgress)
    1516                 :          25 :     return;
    1517                 :             : 
    1518                 :             :   // T-205: Prefetch via dedicated body connection
    1519         [ +  - ]:          26 :   ensureBodyConnection();
    1520   [ +  -  +  + ]:          52 :   bool bodyReady = (m_bodyImap->state() == ImapService::State::Authenticated ||
    1521                 :          26 :                     m_bodyImap->state() == ImapService::State::Selected);
    1522         [ +  + ]:          26 :   if (!bodyReady)
    1523                 :           5 :     return; // Don't prefetch if body connection isn't ready yet
    1524                 :             : 
    1525                 :             :   // Ensure correct folder is selected on body connection.
    1526                 :             :   // T-79.E3/M7: the shadow variable is only stamped when a SELECT is
    1527                 :             :   // actually dispatched below — stamping it up front made a later
    1528                 :             :   // crossFolderStoreFlag()/crossFolderMove() skip its SELECT and operate
    1529                 :             :   // on whatever folder the body connection really had selected.
    1530                 :          21 :   const bool needSelect = (m_bodyImapSelectedFolder != m_currentFolder);
    1531                 :             : 
    1532         [ +  - ]:          21 :   QList<int> offsets = {1, -1, 2, -2};
    1533                 :          21 :   bool firstFetch = true;
    1534   [ +  -  +  -  :         105 :   for (int offset : offsets) {
                   +  + ]
    1535                 :          84 :     int r = currentRow + offset;
    1536   [ +  +  +  -  :          84 :     if (r < 0 || r >= m_model->rowCount())
             +  +  +  + ]
    1537                 :          23 :       continue;
    1538                 :             : 
    1539         [ +  - ]:          61 :     auto *h = m_model->headerAt(r);
    1540         [ -  + ]:          61 :     if (!h)
    1541                 :           0 :       continue;
    1542   [ +  -  -  + ]:          61 :     if (rejectMessageAboveDownloadLimit(*h, false)) {
    1543   [ #  #  #  #  :           0 :       qCInfo(lcController) << "Skipping oversized body prefetch for UID"
             #  #  #  # ]
    1544   [ #  #  #  #  :           0 :                            << h->uid << "size" << h->size;
                   #  # ]
    1545                 :           0 :       continue;
    1546                 :           0 :     }
    1547                 :             : 
    1548   [ +  -  +  + ]:          61 :     if (!m_cache->hasBody(m_currentFolderId, h->uid)) {
    1549   [ +  -  +  -  :          72 :       qCInfo(lcController) << "Prefetching body for adjacent UID" << h->uid;
          +  -  +  -  +  
                      + ]
    1550                 :             :       // T-205 fix: First body needs pipelined SELECT if folder changed
    1551   [ -  +  -  - ]:          36 :       if (needSelect && firstFetch) {
    1552                 :           0 :         m_bodyImapSelectedFolder = m_currentFolder;
    1553         [ #  # ]:           0 :         m_bodyImap->selectAndFetchBody(m_currentFolder, h->uid);
    1554                 :           0 :         firstFetch = false;
    1555                 :             :       } else {
    1556         [ +  - ]:          36 :         m_bodyImap->fetchBody(h->uid);
    1557                 :             :       }
    1558                 :             :     }
    1559                 :             :   }
    1560                 :          21 : }
    1561                 :             : 
    1562                 :             : // ═══════════════════════════════════════════════════════
    1563                 :             : // Reverse Header Fetch (T-061)
    1564                 :             : // ═══════════════════════════════════════════════════════
    1565                 :             : 
    1566                 :             : 
    1567                 :          22 : void MailController::onSearchResultReceived(const QList<qint64> &uids) {
    1568                 :             :   // T-113: Discard if folder changed
    1569         [ +  + ]:          22 :   if (m_activeFolderGen != m_folderGeneration) {
    1570   [ +  -  +  -  :           8 :     qCInfo(lcController) << "Discarding stale SEARCH result";
             +  -  +  + ]
    1571                 :          21 :     return;
    1572                 :             :   }
    1573                 :             : 
    1574   [ +  -  +  -  :          36 :   qCInfo(lcController) << "SEARCH returned" << uids.size()
          +  -  +  -  +  
                      + ]
    1575   [ +  -  +  - ]:          18 :                        << "UIDs for" << m_currentFolder;
    1576                 :             : 
    1577                 :             :   // Filter out UIDs we already have in cache (handles IMAP edge case where
    1578                 :             :   // UID SEARCH N:* returns the highest existing UID even when no new mail).
    1579         [ +  - ]:          18 :   qint64 maxCachedUid = m_cache->maxUid(m_currentFolderId);
    1580                 :          18 :   QList<qint64> newUids;
    1581         [ +  + ]:          21 :   for (qint64 uid : uids) {
    1582         [ +  - ]:           3 :     if (uid > maxCachedUid) {
    1583         [ +  - ]:           3 :       newUids.append(uid);
    1584                 :             :     }
    1585                 :             :   }
    1586                 :             : 
    1587         [ +  + ]:          18 :   if (newUids.isEmpty()) {
    1588   [ +  -  +  -  :          34 :     qCInfo(lcController) << "No new UIDs after filtering (maxCached ="
             +  -  +  + ]
    1589   [ +  -  +  - ]:          17 :                          << maxCachedUid << ")";
    1590                 :             :     // No new messages → go straight to flag sync / IDLE
    1591         [ +  - ]:          17 :     m_imap->fetchFlags();
    1592                 :          17 :     return;
    1593                 :             :   }
    1594                 :             : 
    1595                 :             :   // Sort descending (newest UID first)
    1596                 :           1 :   QList<qint64> sorted = newUids;
    1597   [ +  -  +  -  :           1 :   std::sort(sorted.begin(), sorted.end(), std::greater<qint64>());
                   +  - ]
    1598                 :             : 
    1599                 :             :   // Split into chunks of REVERSE_CHUNK_SIZE
    1600         [ +  - ]:           1 :   m_reverseChunks.clear();
    1601         [ +  + ]:           2 :   for (int i = 0; i < sorted.size(); i += REVERSE_CHUNK_SIZE) {
    1602   [ +  -  +  - ]:           1 :     m_reverseChunks.append(sorted.mid(i, REVERSE_CHUNK_SIZE));
    1603                 :             :   }
    1604                 :             : 
    1605   [ +  -  +  -  :           2 :   qCInfo(lcController) << "Reverse fetch:" << m_reverseChunks.size()
          +  -  +  -  +  
                      + ]
    1606   [ +  -  +  - ]:           1 :                        << "chunks of" << REVERSE_CHUNK_SIZE;
    1607                 :             : 
    1608                 :             :   // Start fetching first chunk (newest UIDs)
    1609         [ +  - ]:           1 :   fetchNextChunk();
    1610         [ +  + ]:          18 : }
    1611                 :             : 
    1612                 :          10 : void MailController::fetchNextChunk() {
    1613                 :             :   // T-113: Discard if folder changed since chunks were queued
    1614         [ -  + ]:          10 :   if (m_activeFolderGen != m_folderGeneration) {
    1615   [ #  #  #  #  :           0 :     qCInfo(lcController) << "Discarding stale chunks (folder changed)";
             #  #  #  # ]
    1616         [ #  # ]:           0 :     m_reverseChunks.clear();
    1617                 :           0 :     return;
    1618                 :             :   }
    1619                 :             : 
    1620         [ -  + ]:          10 :   if (m_reverseChunks.isEmpty()) {
    1621                 :             :     // All chunks fetched → final flag sync
    1622         [ #  # ]:           0 :     m_imap->fetchFlags();
    1623                 :           0 :     return;
    1624                 :             :   }
    1625                 :             : 
    1626         [ +  - ]:          10 :   auto chunk = m_reverseChunks.takeFirst();
    1627   [ +  -  +  -  :          20 :   qCInfo(lcController) << "Fetching chunk:" << chunk.size()
          +  -  +  -  +  
                      + ]
    1628   [ +  -  +  - ]:          10 :                        << "UIDs, remaining chunks:" << m_reverseChunks.size();
    1629         [ +  - ]:          10 :   m_imap->fetchHeadersByUids(chunk);
    1630                 :          10 : }
    1631                 :             : 
    1632                 :           8 : bool MailController::downloadAttachment(qint64 attachmentId,
    1633                 :             :                                         const QString &savePath,
    1634                 :             :                                         bool overwriteExisting) {
    1635         [ +  - ]:           8 :   QByteArray data = m_cache->attachmentData(attachmentId);
    1636         [ +  + ]:           8 :   if (data.isEmpty()) {
    1637   [ +  -  +  -  :           2 :     qCWarning(lcController) << "No data for attachment ID" << attachmentId;
          +  -  +  -  +  
                      + ]
    1638   [ +  -  +  - ]:           1 :     emit statusMessage(tr("Failed to save attachment"));
    1639                 :           1 :     return false;
    1640                 :             :   }
    1641                 :             : 
    1642                 :           7 :   QString writeError;
    1643                 :           7 :   const auto policy =
    1644                 :             :       overwriteExisting
    1645         [ +  + ]:           7 :           ? AttachmentFileSecurity::ExistingFilePolicy::Replace
    1646                 :             :           : AttachmentFileSecurity::ExistingFilePolicy::FailIfExists;
    1647   [ +  -  +  + ]:           7 :   if (AttachmentFileSecurity::writeAtomically(savePath, data, &writeError,
    1648                 :             :                                               policy)) {
    1649   [ +  -  +  -  :          10 :     qCInfo(lcController) << "Saved attachment" << attachmentId << "to"
          +  -  +  -  +  
                -  +  + ]
    1650         [ +  - ]:           5 :                          << savePath;
    1651         [ +  - ]:           5 :     emit statusMessage(
    1652   [ +  -  +  -  :          15 :         QString("Attachment saved: %1").arg(QFileInfo(savePath).fileName()));
             +  -  +  - ]
    1653                 :           5 :     return true;
    1654                 :             :   }
    1655                 :             : 
    1656   [ +  -  +  -  :           4 :   qCWarning(lcController) << "Failed to write attachment to" << savePath
          +  -  +  -  +  
                      + ]
    1657   [ +  -  +  - ]:           2 :                           << ":" << writeError;
    1658   [ +  -  +  - ]:           2 :   emit statusMessage(tr("Failed to save attachment"));
    1659                 :           2 :   return false;
    1660                 :           8 : }
    1661                 :             : 
    1662                 :             : // ═══════════════════════════════════════════════════════
    1663                 :             : // Flag Management (T-059)
    1664                 :             : // ═══════════════════════════════════════════════════════
    1665                 :             : 
    1666                 :          66 : void MailController::markMailAsSeen(qint64 uid) {
    1667                 :             :   // Check if already seen → skip
    1668         [ +  - ]:          66 :   auto header = m_cache->header(m_currentFolderId, uid);
    1669         [ +  + ]:          66 :   if (!header) {
    1670   [ +  -  +  -  :           6 :     qCWarning(lcController) << "markMailAsSeen: UID" << uid
             +  -  +  + ]
    1671   [ +  -  +  -  :           3 :                             << "not found in cache for folderId" << m_currentFolderId
                   +  - ]
    1672         [ +  - ]:           3 :                             << "— cannot mark as seen";
    1673                 :           3 :     return;
    1674                 :             :   }
    1675         [ +  + ]:          63 :   if (header->isSeen()) {
    1676   [ +  -  +  -  :          54 :     qCInfo(lcController) << "markMailAsSeen: UID" << uid
             +  -  +  + ]
    1677   [ +  -  +  -  :          27 :                          << "already seen (flags:" << header->flags << "), skipping";
             +  -  +  - ]
    1678                 :          27 :     return;
    1679                 :             :   }
    1680                 :             : 
    1681                 :             :   // Optimistic update: set local flags immediately
    1682                 :          36 :   quint32 newFlags = header->flags | MailFlag::Seen;
    1683         [ +  - ]:          36 :   m_cache->updateFlags(m_currentFolderId, uid, newFlags);
    1684         [ +  - ]:          36 :   m_model->updateFlags(uid, newFlags, m_currentFolderId);
    1685         [ +  + ]:          36 :   if (m_threadModel)
    1686         [ +  - ]:          33 :     m_threadModel->updateFlags(uid, newFlags, m_currentFolderId);
    1687   [ +  -  +  - ]:          36 :   emit unreadCountChanged(m_currentFolder, m_model->unreadCount());
    1688                 :             : 
    1689   [ +  -  +  -  :          72 :   qCInfo(lcController) << "Marking UID" << uid << "as seen (optimistic)"
          +  -  +  -  +  
                -  +  + ]
    1690   [ +  -  +  - ]:          36 :                        << "folder:" << m_currentFolder
    1691   [ +  -  +  - ]:          36 :                        << "imapState:" << static_cast<int>(m_imap->state())
    1692   [ +  -  +  - ]:          36 :                        << "isNotifying:" << m_imap->isNotifying()
    1693   [ +  -  +  - ]:          36 :                        << "isIdling:" << m_imap->isIdling();
    1694                 :             : 
    1695                 :             :   // Send STORE to server via executeAfterIdle (IDLE-safe)
    1696                 :             :   // T-201: Track pending flag to prevent onFlagsReceived reversion
    1697         [ +  - ]:          36 :   m_pendingFlagUids.insert(uid);
    1698                 :             :   // T-526: Capture folder to guard against stale commands after folder switch
    1699   [ +  -  +  - ]:          36 :   m_imap->executeAfterIdle(
    1700                 :          72 :       [this, uid, folder = m_currentFolder]() {
    1701         [ -  + ]:          36 :         if (folder != m_currentFolder) {
    1702   [ #  #  #  #  :           0 :           qCWarning(lcController) << "T-526: Discarding stale markSeen for UID"
             #  #  #  # ]
    1703   [ #  #  #  #  :           0 :                                   << uid << "(folder changed from" << folder
                   #  # ]
    1704   [ #  #  #  #  :           0 :                                   << "to" << m_currentFolder << ")";
                   #  # ]
    1705                 :           0 :           return;
    1706                 :             :         }
    1707   [ +  -  +  -  :          72 :         qCInfo(lcController) << "markMailAsSeen: sending STORE for UID" << uid
          +  -  +  -  +  
                      + ]
    1708   [ +  -  +  - ]:          36 :                              << "imapState:" << static_cast<int>(m_imap->state());
    1709                 :          36 :         m_imap->markSeen(uid);
    1710                 :             :       });
    1711         [ +  + ]:          66 : }
    1712                 :             : 
    1713                 :          11 : void MailController::markMailAsUnseen(qint64 uid) {
    1714                 :             :   // Check if already unseen → skip
    1715         [ +  - ]:          11 :   auto header = m_cache->header(m_currentFolderId, uid);
    1716         [ +  + ]:          11 :   if (!header) {
    1717   [ +  -  +  -  :           2 :     qCWarning(lcController) << "markMailAsUnseen: UID" << uid
             +  -  +  + ]
    1718   [ +  -  +  -  :           1 :                             << "not found in cache for folderId" << m_currentFolderId;
                   +  - ]
    1719                 :           1 :     return;
    1720                 :             :   }
    1721         [ +  + ]:          10 :   if (!header->isSeen()) {
    1722   [ +  -  +  -  :           2 :     qCInfo(lcController) << "markMailAsUnseen: UID" << uid
             +  -  +  + ]
    1723   [ +  -  +  -  :           1 :                          << "already unseen (flags:" << header->flags << "), skipping";
             +  -  +  - ]
    1724                 :           1 :     return;
    1725                 :             :   }
    1726                 :             : 
    1727                 :             :   // Optimistic update: clear Seen flag locally
    1728                 :           9 :   quint32 newFlags = header->flags & ~MailFlag::Seen;
    1729         [ +  - ]:           9 :   m_cache->updateFlags(m_currentFolderId, uid, newFlags);
    1730         [ +  - ]:           9 :   m_model->updateFlags(uid, newFlags, m_currentFolderId);
    1731         [ +  + ]:           9 :   if (m_threadModel)
    1732         [ +  - ]:           8 :     m_threadModel->updateFlags(uid, newFlags, m_currentFolderId);
    1733   [ +  -  +  - ]:           9 :   emit unreadCountChanged(m_currentFolder, m_model->unreadCount());
    1734                 :             : 
    1735   [ +  -  +  -  :          18 :   qCInfo(lcController) << "Marking UID" << uid << "as unseen (optimistic)"
          +  -  +  -  +  
                -  +  + ]
    1736   [ +  -  +  - ]:           9 :                        << "folder:" << m_currentFolder
    1737   [ +  -  +  - ]:           9 :                        << "imapState:" << static_cast<int>(m_imap->state());
    1738                 :             : 
    1739                 :             :   // Send STORE to server via executeAfterIdle (IDLE-safe)
    1740                 :             :   // T-201: Track pending flag to prevent onFlagsReceived reversion
    1741         [ +  - ]:           9 :   m_pendingFlagUids.insert(uid);
    1742                 :             :   // T-526: Folder guard
    1743   [ +  -  +  - ]:           9 :   m_imap->executeAfterIdle(
    1744                 :          18 :       [this, uid, folder = m_currentFolder]() {
    1745         [ -  + ]:           9 :         if (folder != m_currentFolder) {
    1746   [ #  #  #  #  :           0 :           qCWarning(lcController) << "T-526: Discarding stale markUnseen for UID"
             #  #  #  # ]
    1747   [ #  #  #  #  :           0 :                                   << uid << "(folder changed from" << folder
                   #  # ]
    1748   [ #  #  #  #  :           0 :                                   << "to" << m_currentFolder << ")";
                   #  # ]
    1749                 :           0 :           return;
    1750                 :             :         }
    1751   [ +  -  +  -  :          18 :         qCInfo(lcController) << "markMailAsUnseen: sending STORE for UID" << uid
          +  -  +  -  +  
                      + ]
    1752   [ +  -  +  - ]:           9 :                              << "imapState:" << static_cast<int>(m_imap->state());
    1753                 :           9 :         m_imap->markUnseen(uid);
    1754                 :             :       });
    1755         [ +  + ]:          11 : }
    1756                 :             : 
    1757                 :          12 : void MailController::toggleReadStatus(qint64 uid) {
    1758         [ +  - ]:          12 :   auto header = m_cache->header(m_currentFolderId, uid);
    1759         [ +  + ]:          12 :   if (!header)
    1760                 :           3 :     return;
    1761                 :             : 
    1762         [ +  + ]:           9 :   if (header->isSeen()) {
    1763         [ +  - ]:           2 :     markMailAsUnseen(uid);
    1764                 :             :   } else {
    1765         [ +  - ]:           7 :     markMailAsSeen(uid);
    1766                 :             :   }
    1767                 :             : 
    1768                 :             :   // T-403/Bug 23: Undo — call the seen setters directly to avoid creating
    1769                 :             :   // a new undo entry (which would cause an infinite chain)
    1770         [ +  + ]:           9 :   if (m_undoManager) {
    1771                 :           8 :     bool wasSeen = header->isSeen();
    1772                 :             :     // T-620/FUNC-01: Capture folderId by value — folder may change before undo
    1773                 :           8 :     qint64 folderId = m_currentFolderId;
    1774         [ +  - ]:           8 :     m_undoManager->push(
    1775   [ +  +  +  -  :          16 :         wasSeen ? tr("Marked as unread")
                   +  - ]
    1776                 :             :                 : tr("Marked as read"),
    1777         [ +  - ]:          16 :         [this, uid, folderId, wasSeen]() {
    1778                 :             :           // T-79.E1/M5: restore in the captured folder — after a folder
    1779                 :             :           // switch the plain setters would resolve the UID in the wrong
    1780                 :             :           // folder (silent no-op, or an unrelated mail flagged).
    1781         [ -  + ]:           2 :           if (wasSeen)
    1782                 :           0 :             markMailAsSeenInFolder(uid, folderId);
    1783                 :             :           else
    1784                 :           2 :             markMailAsUnseenInFolder(uid, folderId);
    1785                 :           2 :         });
    1786                 :             :   }
    1787         [ +  + ]:          12 : }
    1788                 :             : 
    1789                 :             : // T-79.E1/M5: folder-aware seen setters. Delegate to the current-folder
    1790                 :             : // path when possible; otherwise update cache/model under the mail's own
    1791                 :             : // folder key and STORE via the body connection (search-mode safe).
    1792                 :           1 : void MailController::markMailAsSeenInFolder(qint64 uid, qint64 folderId) {
    1793         [ -  + ]:           1 :   if (folderId == m_currentFolderId) {
    1794         [ #  # ]:           0 :     markMailAsSeen(uid);
    1795                 :           0 :     return;
    1796                 :             :   }
    1797         [ +  - ]:           1 :   auto header = m_cache->header(folderId, uid);
    1798   [ +  -  -  +  :           1 :   if (!header || header->isSeen())
                   -  + ]
    1799                 :           0 :     return;
    1800                 :           1 :   quint32 newFlags = header->flags | MailFlag::Seen;
    1801         [ +  - ]:           1 :   m_cache->updateFlags(folderId, uid, newFlags);
    1802         [ +  - ]:           1 :   m_model->updateFlags(uid, newFlags, folderId);
    1803         [ +  - ]:           1 :   if (m_threadModel)
    1804         [ +  - ]:           1 :     m_threadModel->updateFlags(uid, newFlags, folderId);
    1805   [ +  -  +  - ]:           1 :   crossFolderStoreFlag(m_cache->folderPath(folderId), uid,
    1806                 :           2 :                        QStringLiteral("\\Seen"), true);
    1807         [ +  - ]:           1 : }
    1808                 :             : 
    1809                 :           2 : void MailController::markMailAsUnseenInFolder(qint64 uid, qint64 folderId) {
    1810         [ +  + ]:           2 :   if (folderId == m_currentFolderId) {
    1811         [ +  - ]:           1 :     markMailAsUnseen(uid);
    1812                 :           1 :     return;
    1813                 :             :   }
    1814         [ +  - ]:           1 :   auto header = m_cache->header(folderId, uid);
    1815   [ +  -  -  +  :           1 :   if (!header || !header->isSeen())
                   -  + ]
    1816                 :           0 :     return;
    1817                 :           1 :   quint32 newFlags = header->flags & ~MailFlag::Seen;
    1818         [ +  - ]:           1 :   m_cache->updateFlags(folderId, uid, newFlags);
    1819         [ +  - ]:           1 :   m_model->updateFlags(uid, newFlags, folderId);
    1820         [ +  - ]:           1 :   if (m_threadModel)
    1821         [ +  - ]:           1 :     m_threadModel->updateFlags(uid, newFlags, folderId);
    1822   [ +  -  +  - ]:           1 :   crossFolderStoreFlag(m_cache->folderPath(folderId), uid,
    1823                 :           2 :                        QStringLiteral("\\Seen"), false);
    1824         [ +  - ]:           1 : }
    1825                 :             : 
    1826                 :             : // T-519: Idempotent star setter — only changes flag if state differs
    1827                 :           4 : void MailController::setStarred(qint64 uid, bool starred) {
    1828         [ +  - ]:           4 :   auto header = m_cache->header(m_currentFolderId, uid);
    1829         [ +  + ]:           4 :   if (!header)
    1830                 :           2 :     return;
    1831                 :             : 
    1832                 :             :   // Already in desired state → no-op
    1833         [ +  + ]:           2 :   if (header->isFlagged() == starred)
    1834                 :           1 :     return;
    1835                 :             : 
    1836         [ +  - ]:           1 :   toggleStarred(uid);
    1837         [ +  + ]:           4 : }
    1838                 :             : 
    1839                 :          24 : void MailController::toggleStarred(qint64 uid) {
    1840         [ +  - ]:          24 :   auto header = m_cache->header(m_currentFolderId, uid);
    1841         [ +  + ]:          24 :   if (!header)
    1842                 :           3 :     return;
    1843                 :             : 
    1844                 :          21 :   bool wasFlagged = header->isFlagged();
    1845         [ +  + ]:          21 :   quint32 newFlags = wasFlagged ? (header->flags & ~MailFlag::Flagged)
    1846                 :          13 :                                 : (header->flags | MailFlag::Flagged);
    1847                 :             : 
    1848                 :             :   // Optimistic update: local cache + model first
    1849         [ +  - ]:          21 :   m_cache->updateFlags(m_currentFolderId, uid, newFlags);
    1850         [ +  - ]:          21 :   m_model->updateFlags(uid, newFlags, m_currentFolderId);
    1851         [ +  + ]:          21 :   if (m_threadModel)
    1852         [ +  - ]:          17 :     m_threadModel->updateFlags(uid, newFlags, m_currentFolderId);
    1853                 :             : 
    1854   [ +  -  +  -  :          42 :   qCInfo(lcController) << "Toggling star for UID" << uid
             +  -  +  + ]
    1855   [ +  -  +  +  :          21 :                        << (wasFlagged ? "OFF" : "ON") << "(optimistic)";
             +  -  +  - ]
    1856                 :             : 
    1857                 :             :   // Send STORE to server via executeAfterIdle (IDLE-safe)
    1858                 :             :   // T-201: Track pending flag to prevent onFlagsReceived reversion
    1859         [ +  - ]:          21 :   m_pendingFlagUids.insert(uid);
    1860   [ +  -  +  - ]:          21 :   m_imap->executeAfterIdle([this, uid, wasFlagged]() {
    1861         [ +  - ]:          42 :     m_imap->storeFlag(uid, QStringLiteral("\\Flagged"), !wasFlagged);
    1862                 :          21 :   });
    1863                 :             : 
    1864                 :             :   // T-403/Bug 23: Undo — set flag directly to avoid infinite chain
    1865         [ +  + ]:          21 :   if (m_undoManager) {
    1866                 :             :     // T-620/FUNC-01: Capture folderId by value — folder may change before undo
    1867                 :          20 :     qint64 folderId = m_currentFolderId;
    1868         [ +  - ]:          20 :     m_undoManager->push(
    1869   [ +  +  +  -  :          40 :         wasFlagged ? tr("Flag removed")
                   +  - ]
    1870                 :             :                    : tr("Flag set"),
    1871         [ +  - ]:          40 :         [this, uid, folderId, wasFlagged]() {
    1872                 :             :           // Directly set flag state without creating another undo entry
    1873                 :           2 :           quint32 currentFlags = 0;
    1874         [ +  - ]:           2 :           auto h = m_cache->header(folderId, uid);
    1875         [ +  - ]:           2 :           if (h) currentFlags = h->flags;
    1876                 :           4 :           quint32 restore = wasFlagged
    1877         [ -  + ]:           2 :                                 ? (currentFlags | MailFlag::Flagged)
    1878                 :             :                                 : (currentFlags & ~MailFlag::Flagged);
    1879         [ +  - ]:           2 :           m_cache->updateFlags(folderId, uid, restore);
    1880         [ +  - ]:           2 :           m_model->updateFlags(uid, restore, folderId);
    1881         [ +  + ]:           2 :           if (m_threadModel)
    1882         [ +  - ]:           1 :             m_threadModel->updateFlags(uid, restore, folderId);
    1883         [ +  - ]:           2 :           m_pendingFlagUids.insert(uid);
    1884   [ +  -  +  - ]:           2 :           m_imap->executeAfterIdle([this, uid, wasFlagged]() {
    1885         [ +  - ]:           4 :             m_imap->storeFlag(uid, QStringLiteral("\\Flagged"), wasFlagged);
    1886                 :           2 :           });
    1887                 :           2 :         });
    1888                 :             :   }
    1889         [ +  + ]:          24 : }
    1890                 :             : 
    1891                 :          18 : void MailController::addLabel(qint64 uid, const QString &label) {
    1892         [ +  - ]:          18 :   int row = m_model->rowForUid(uid, m_currentFolderId);
    1893         [ +  + ]:          18 :   if (row < 0)
    1894                 :           6 :     return;
    1895         [ +  - ]:          14 :   auto *header = m_model->mutableHeaderAt(row);
    1896         [ -  + ]:          14 :   if (!header)
    1897                 :           0 :     return;
    1898                 :             : 
    1899         [ +  + ]:          14 :   if (header->labels.contains(label))
    1900                 :           2 :     return; // Already has this label
    1901                 :             : 
    1902                 :             :   // Optimistic update: add to model and sort for consistent order
    1903         [ +  - ]:          12 :   header->labels.append(label);
    1904         [ +  - ]:          12 :   header->labels.sort(Qt::CaseInsensitive);
    1905         [ +  - ]:          12 :   QModelIndex idx = m_model->index(row, 0);
    1906   [ +  -  +  -  :          12 :   emit m_model->dataChanged(idx, m_model->index(row, m_model->columnCount() - 1));
                   +  - ]
    1907                 :             : 
    1908                 :             :   // T-261: Sync to thread model (separate header copies)
    1909         [ +  + ]:          12 :   if (m_threadModel)
    1910         [ +  - ]:          10 :     m_threadModel->updateLabels(uid, header->labels, m_currentFolderId);
    1911                 :             : 
    1912   [ +  -  +  -  :          24 :   qCInfo(lcController) << "Adding label" << label << "to UID" << uid;
          +  -  +  -  +  
             -  +  -  +  
                      + ]
    1913                 :             : 
    1914                 :             :   // T-261: Persist to cache so labels survive folder switches
    1915         [ +  - ]:          12 :   m_cache->addLabel(m_currentFolderId, uid, label);
    1916                 :             : 
    1917                 :             :   // Send STORE to server (label = keyword flag)
    1918   [ +  -  +  - ]:          12 :   m_imap->executeAfterIdle([this, uid, label]() {
    1919                 :          12 :     m_imap->storeFlag(uid, label, true);
    1920                 :          12 :   });
    1921                 :             : 
    1922                 :             :   // T-211: Undo → removeLabel
    1923         [ +  + ]:          12 :   if (m_undoManager) {
    1924   [ +  -  +  - ]:          22 :     m_undoManager->push(
    1925   [ +  -  +  - ]:          33 :         tr("Label '%1' added").arg(label),
    1926                 :          23 :         [this, uid, label]() { removeLabel(uid, label); });
    1927                 :             :   }
    1928                 :             : }
    1929                 :             : 
    1930                 :          14 : void MailController::removeLabel(qint64 uid, const QString &label) {
    1931         [ +  - ]:          14 :   int row = m_model->rowForUid(uid, m_currentFolderId);
    1932         [ +  + ]:          14 :   if (row < 0)
    1933                 :           4 :     return;
    1934         [ +  - ]:          11 :   auto *header = m_model->mutableHeaderAt(row);
    1935         [ -  + ]:          11 :   if (!header)
    1936                 :           0 :     return;
    1937                 :             : 
    1938         [ +  + ]:          11 :   if (!header->labels.contains(label))
    1939                 :           1 :     return;
    1940                 :             : 
    1941                 :             :   // Optimistic update: remove from model
    1942         [ +  - ]:          10 :   header->labels.removeAll(label);
    1943         [ +  - ]:          10 :   QModelIndex idx = m_model->index(row, 0);
    1944   [ +  -  +  -  :          10 :   emit m_model->dataChanged(idx, m_model->index(row, m_model->columnCount() - 1));
                   +  - ]
    1945                 :             : 
    1946                 :             :   // T-261: Sync to thread model (separate header copies)
    1947         [ +  + ]:          10 :   if (m_threadModel)
    1948         [ +  - ]:           8 :     m_threadModel->updateLabels(uid, header->labels, m_currentFolderId);
    1949                 :             : 
    1950   [ +  -  +  -  :          20 :   qCInfo(lcController) << "Removing label" << label << "from UID" << uid;
          +  -  +  -  +  
             -  +  -  +  
                      + ]
    1951                 :             : 
    1952                 :             :   // T-261: Persist to cache
    1953         [ +  - ]:          10 :   m_cache->removeLabel(m_currentFolderId, uid, label);
    1954                 :             : 
    1955                 :             :   // Send STORE to server
    1956   [ +  -  +  - ]:          10 :   m_imap->executeAfterIdle([this, uid, label]() {
    1957                 :          10 :     m_imap->storeFlag(uid, label, false);
    1958                 :          10 :   });
    1959                 :             : 
    1960                 :             :   // T-211: Undo → addLabel
    1961         [ +  + ]:          10 :   if (m_undoManager) {
    1962   [ +  -  +  - ]:          18 :     m_undoManager->push(
    1963   [ +  -  +  - ]:          27 :         tr("Label '%1' removed").arg(label),
    1964                 :          20 :         [this, uid, label]() { addLabel(uid, label); });
    1965                 :             :   }
    1966                 :             : }
    1967                 :             : 
    1968                 :             : // ═══════════════════════════════════════════════════════
    1969                 :             : // Mail Move (T-100/T-101)
    1970                 :             : // ═══════════════════════════════════════════════════════
    1971                 :             : 
    1972                 :           4 : void MailController::moveMailToFolder(qint64 uid, const QString &targetFolder) {
    1973   [ +  -  +  - ]:           4 :   moveMailsToFolder({uid}, targetFolder);
    1974                 :           4 : }
    1975                 :             : 
    1976                 :          17 : void MailController::moveMailsToFolder(const QList<qint64> &uids,
    1977                 :             :                                        const QString &targetFolder) {
    1978         [ +  + ]:          17 :   if (targetFolder == m_currentFolder) {
    1979   [ +  -  +  -  :           6 :     qCInfo(lcController) << "Move: target is same as current folder, ignoring";
             +  -  +  + ]
    1980                 :           3 :     return;
    1981                 :             :   }
    1982                 :             : 
    1983   [ +  -  +  -  :          28 :   qCInfo(lcController) << "Moving" << uids.size() << "UIDs to" << targetFolder;
          +  -  +  -  +  
             -  +  -  +  
                      + ]
    1984         [ +  - ]:          14 :   emit statusMessage(
    1985   [ +  -  +  -  :          56 :       QString("Moving %1 mail(s) to %2...").arg(uids.size()).arg(targetFolder));
                   +  - ]
    1986                 :             : 
    1987                 :             :   // T-211: Snapshot headers for undo BEFORE removing from model
    1988                 :          14 :   QString sourceFolder = m_currentFolder;
    1989                 :          14 :   QList<MailHeader> snapshotHeaders;
    1990         [ +  - ]:          14 :   if (m_undoManager) {
    1991         [ +  + ]:          32 :     for (qint64 uid : uids) {
    1992         [ +  - ]:          18 :       auto h = m_cache->header(m_currentFolderId, uid);
    1993   [ +  -  +  - ]:          18 :       if (h) snapshotHeaders.append(*h);
    1994                 :          18 :     }
    1995                 :             :   }
    1996                 :             : 
    1997                 :             :   // T-201: Track pending move UIDs BEFORE executing IMAP command.
    1998                 :             :   // When NOTIFY is active, executeAfterIdle runs synchronously — if
    1999                 :             :   // moveMessages fails, onMoveError fires immediately and needs the
    2000                 :             :   // correct UIDs in m_pendingMoveUids to restore the right mails.
    2001         [ +  + ]:          32 :   for (qint64 uid : uids)
    2002         [ +  - ]:          18 :     m_pendingMoveUids.insert(uid);
    2003                 :             : 
    2004                 :             :   // Optimistic UI update: remove from model immediately so the mail
    2005                 :             :   // disappears from the list without waiting for the EXPUNGE response.
    2006         [ +  + ]:          32 :   for (qint64 uid : uids) {
    2007         [ +  - ]:          18 :     m_model->removeByUid(uid, m_currentFolderId);
    2008         [ +  + ]:          18 :     if (m_threadModel)
    2009         [ +  - ]:          16 :       m_threadModel->removeByUid(uid, m_currentFolderId);
    2010                 :             :   }
    2011                 :             : 
    2012   [ +  -  +  -  :          14 :   m_imap->executeAfterIdle([this, uids, targetFolder]() {
                   -  - ]
    2013                 :          14 :     m_imap->moveMessages(uids, targetFolder);
    2014                 :          14 :   });
    2015                 :             : 
    2016                 :             :   // T-211: Undo — move back via Message-ID search
    2017   [ +  -  +  -  :          14 :   if (m_undoManager && !snapshotHeaders.isEmpty()) {
                   +  - ]
    2018         [ +  - ]:          14 :     m_undoManager->push(
    2019         [ +  - ]:          14 :         tr("Moved to %1 (%2 mails)")
    2020   [ +  -  +  - ]:          42 :             .arg(targetFolder).arg(uids.size()),
    2021   [ +  -  -  -  :          28 :         [this, snapshotHeaders, sourceFolder, targetFolder]() {
                   -  - ]
    2022                 :           2 :           undoMove(snapshotHeaders, sourceFolder, targetFolder);
    2023                 :           2 :         });
    2024                 :             :   }
    2025                 :          14 : }
    2026                 :             : 
    2027                 :             : // ═══════════════════════════════════════════════════════
    2028                 :             : // T-407: Cross-folder action overloads for search mode
    2029                 :             : // ═══════════════════════════════════════════════════════
    2030                 :             : 
    2031                 :           7 : void MailController::toggleReadStatusInFolder(qint64 uid, qint64 folderId) {
    2032                 :             :   // If it's the current folder, delegate to the normal method
    2033         [ +  + ]:           7 :   if (folderId == m_currentFolderId) {
    2034         [ +  - ]:           4 :     toggleReadStatus(uid);
    2035                 :           5 :     return;
    2036                 :             :   }
    2037                 :             : 
    2038         [ +  - ]:           3 :   auto header = m_cache->header(folderId, uid);
    2039         [ +  + ]:           3 :   if (!header)
    2040                 :           1 :     return;
    2041                 :             : 
    2042                 :           2 :   bool wasSeen = header->isSeen();
    2043         [ +  + ]:           2 :   quint32 newFlags = wasSeen ? (header->flags & ~MailFlag::Seen)
    2044                 :           1 :                              : (header->flags | MailFlag::Seen);
    2045                 :             : 
    2046                 :             :   // Optimistic update: cache + model
    2047         [ +  - ]:           2 :   m_cache->updateFlags(folderId, uid, newFlags);
    2048         [ +  - ]:           2 :   m_model->updateFlags(uid, newFlags, folderId);
    2049         [ +  - ]:           2 :   if (m_threadModel)
    2050         [ +  - ]:           2 :     m_threadModel->updateFlags(uid, newFlags, folderId);
    2051                 :             : 
    2052   [ +  -  +  -  :           4 :   qCInfo(lcController) << "T-407: Toggling read for UID" << uid
          +  -  +  -  +  
                      + ]
    2053   [ +  -  +  - ]:           2 :                        << "in folder" << folderId
    2054   [ +  +  +  - ]:           2 :                        << (wasSeen ? "→ unread" : "→ read");
    2055                 :             : 
    2056                 :             :   // IMAP via body connection (cross-folder)
    2057         [ +  - ]:           2 :   QString folderPath = m_cache->folderPath(folderId);
    2058         [ +  - ]:           4 :   crossFolderStoreFlag(folderPath, uid, QStringLiteral("\\Seen"), !wasSeen);
    2059                 :             : 
    2060         [ +  - ]:           2 :   if (m_undoManager) {
    2061         [ +  - ]:           2 :     m_undoManager->push(
    2062   [ +  +  +  -  :           4 :         wasSeen ? tr("Marked as unread") : tr("Marked as read"),
                   +  - ]
    2063         [ +  - ]:           4 :         [this, uid, folderId, wasSeen]() {
    2064                 :           1 :           toggleReadStatusInFolder(uid, folderId);
    2065                 :           1 :         });
    2066                 :             :   }
    2067         [ +  + ]:           3 : }
    2068                 :             : 
    2069                 :           7 : void MailController::toggleStarredInFolder(qint64 uid, qint64 folderId) {
    2070         [ +  + ]:           7 :   if (folderId == m_currentFolderId) {
    2071         [ +  - ]:           4 :     toggleStarred(uid);
    2072                 :           5 :     return;
    2073                 :             :   }
    2074                 :             : 
    2075         [ +  - ]:           3 :   auto header = m_cache->header(folderId, uid);
    2076         [ +  + ]:           3 :   if (!header)
    2077                 :           1 :     return;
    2078                 :             : 
    2079                 :           2 :   bool wasFlagged = header->isFlagged();
    2080         [ -  + ]:           2 :   quint32 newFlags = wasFlagged ? (header->flags & ~MailFlag::Flagged)
    2081                 :           2 :                                 : (header->flags | MailFlag::Flagged);
    2082                 :             : 
    2083         [ +  - ]:           2 :   m_cache->updateFlags(folderId, uid, newFlags);
    2084         [ +  - ]:           2 :   m_model->updateFlags(uid, newFlags, folderId);
    2085         [ +  - ]:           2 :   if (m_threadModel)
    2086         [ +  - ]:           2 :     m_threadModel->updateFlags(uid, newFlags, folderId);
    2087                 :             : 
    2088   [ +  -  +  -  :           4 :   qCInfo(lcController) << "T-407: Toggling star for UID" << uid
          +  -  +  -  +  
                      + ]
    2089   [ +  -  +  - ]:           2 :                        << "in folder" << folderId
    2090   [ -  +  +  - ]:           2 :                        << (wasFlagged ? "OFF" : "ON");
    2091                 :             : 
    2092         [ +  - ]:           2 :   QString folderPath = m_cache->folderPath(folderId);
    2093         [ +  - ]:           4 :   crossFolderStoreFlag(folderPath, uid, QStringLiteral("\\Flagged"), !wasFlagged);
    2094                 :             : 
    2095         [ +  - ]:           2 :   if (m_undoManager) {
    2096         [ +  - ]:           2 :     m_undoManager->push(
    2097   [ -  +  -  -  :           4 :         wasFlagged ? tr("Flag removed") : tr("Flag set"),
                   +  - ]
    2098         [ +  - ]:           4 :         [this, uid, folderId, wasFlagged]() {
    2099         [ +  - ]:           2 :           auto h = m_cache->header(folderId, uid);
    2100         [ -  + ]:           2 :           if (!h) return;
    2101                 :           2 :           quint32 restore = wasFlagged
    2102         [ -  + ]:           2 :                                 ? (h->flags | MailFlag::Flagged)
    2103                 :           2 :                                 : (h->flags & ~MailFlag::Flagged);
    2104         [ +  - ]:           2 :           m_cache->updateFlags(folderId, uid, restore);
    2105                 :             :           // T-79.E1/M4: model rows are keyed (folderId, uid) — the undo
    2106                 :             :           // must target the mail's own folder, not the current one.
    2107         [ +  - ]:           2 :           m_model->updateFlags(uid, restore, folderId);
    2108         [ +  - ]:           2 :           if (m_threadModel)
    2109         [ +  - ]:           2 :             m_threadModel->updateFlags(uid, restore, folderId);
    2110         [ +  - ]:           2 :           QString fp = m_cache->folderPath(folderId);
    2111         [ +  - ]:           4 :           crossFolderStoreFlag(fp, uid, QStringLiteral("\\Flagged"), wasFlagged);
    2112         [ +  - ]:           2 :         });
    2113                 :             :   }
    2114         [ +  + ]:           3 : }
    2115                 :             : 
    2116                 :           6 : void MailController::addLabelInFolder(qint64 uid, qint64 folderId,
    2117                 :             :                                       const QString &label) {
    2118         [ +  + ]:           6 :   if (folderId == m_currentFolderId) {
    2119         [ +  - ]:           3 :     addLabel(uid, label);
    2120                 :           3 :     return;
    2121                 :             :   }
    2122                 :             : 
    2123         [ +  - ]:           3 :   int row = m_model->rowForUid(uid, folderId);
    2124         [ -  + ]:           3 :   if (row < 0)
    2125                 :           0 :     return;
    2126         [ +  - ]:           3 :   auto *header = m_model->mutableHeaderAt(row);
    2127   [ +  -  -  +  :           3 :   if (!header || header->labels.contains(label))
                   -  + ]
    2128                 :           0 :     return;
    2129                 :             : 
    2130         [ +  - ]:           3 :   header->labels.append(label);
    2131         [ +  - ]:           3 :   header->labels.sort(Qt::CaseInsensitive);
    2132         [ +  - ]:           3 :   QModelIndex idx = m_model->index(row, 0);
    2133   [ +  -  +  -  :           3 :   emit m_model->dataChanged(idx, m_model->index(row, m_model->columnCount() - 1));
                   +  - ]
    2134         [ +  + ]:           3 :   if (m_threadModel)
    2135         [ +  - ]:           2 :     m_threadModel->updateLabels(uid, header->labels, folderId);
    2136                 :             : 
    2137         [ +  - ]:           3 :   m_cache->addLabel(folderId, uid, label);
    2138                 :             : 
    2139         [ +  - ]:           3 :   QString folderPath = m_cache->folderPath(folderId);
    2140         [ +  - ]:           3 :   crossFolderStoreFlag(folderPath, uid, label, true);
    2141                 :             : 
    2142         [ +  - ]:           3 :   if (m_undoManager) {
    2143   [ +  -  +  - ]:           6 :     m_undoManager->push(
    2144   [ +  -  +  - ]:           9 :         tr("Label '%1' added").arg(label),
    2145                 :           6 :         [this, uid, folderId, label]() {
    2146                 :           1 :           removeLabelInFolder(uid, folderId, label);
    2147                 :           1 :         });
    2148                 :             :   }
    2149                 :           3 : }
    2150                 :             : 
    2151                 :           7 : void MailController::removeLabelInFolder(qint64 uid, qint64 folderId,
    2152                 :             :                                          const QString &label) {
    2153         [ +  + ]:           7 :   if (folderId == m_currentFolderId) {
    2154         [ +  - ]:           3 :     removeLabel(uid, label);
    2155                 :           5 :     return;
    2156                 :             :   }
    2157                 :             : 
    2158         [ +  - ]:           4 :   int row = m_model->rowForUid(uid, folderId);
    2159         [ +  + ]:           4 :   if (row < 0)
    2160                 :           1 :     return;
    2161         [ +  - ]:           3 :   auto *header = m_model->mutableHeaderAt(row);
    2162   [ +  -  +  +  :           3 :   if (!header || !header->labels.contains(label))
                   +  + ]
    2163                 :           1 :     return;
    2164                 :             : 
    2165         [ +  - ]:           2 :   header->labels.removeAll(label);
    2166         [ +  - ]:           2 :   QModelIndex idx = m_model->index(row, 0);
    2167   [ +  -  +  -  :           2 :   emit m_model->dataChanged(idx, m_model->index(row, m_model->columnCount() - 1));
                   +  - ]
    2168         [ +  - ]:           2 :   if (m_threadModel)
    2169         [ +  - ]:           2 :     m_threadModel->updateLabels(uid, header->labels, folderId);
    2170                 :             : 
    2171         [ +  - ]:           2 :   m_cache->removeLabel(folderId, uid, label);
    2172                 :             : 
    2173         [ +  - ]:           2 :   QString folderPath = m_cache->folderPath(folderId);
    2174         [ +  - ]:           2 :   crossFolderStoreFlag(folderPath, uid, label, false);
    2175                 :             : 
    2176         [ +  - ]:           2 :   if (m_undoManager) {
    2177   [ +  -  +  - ]:           4 :     m_undoManager->push(
    2178   [ +  -  +  - ]:           6 :         tr("Label '%1' removed").arg(label),
    2179                 :           4 :         [this, uid, folderId, label]() {
    2180                 :           1 :           addLabelInFolder(uid, folderId, label);
    2181                 :           1 :         });
    2182                 :             :   }
    2183                 :           2 : }
    2184                 :             : 
    2185                 :           7 : void MailController::moveMailsToFolderFrom(const QList<qint64> &uids,
    2186                 :             :                                            qint64 srcFolderId,
    2187                 :             :                                            const QString &srcFolder,
    2188                 :             :                                            const QString &targetFolder) {
    2189                 :             :   // If source is the current folder, delegate to normal method
    2190         [ +  + ]:           7 :   if (srcFolderId == m_currentFolderId) {
    2191         [ +  - ]:           3 :     moveMailsToFolder(uids, targetFolder);
    2192                 :           4 :     return;
    2193                 :             :   }
    2194                 :             : 
    2195         [ +  + ]:           4 :   if (srcFolder == targetFolder) {
    2196   [ +  -  +  -  :           2 :     qCInfo(lcController) << "T-407: Move: target is same as source, ignoring";
             +  -  +  + ]
    2197                 :           1 :     return;
    2198                 :             :   }
    2199                 :             : 
    2200   [ +  -  +  -  :           6 :   qCInfo(lcController) << "T-407: Moving" << uids.size() << "UIDs from"
          +  -  +  -  +  
                -  +  + ]
    2201   [ +  -  +  -  :           3 :                        << srcFolder << "to" << targetFolder;
                   +  - ]
    2202         [ +  - ]:           3 :   emit statusMessage(
    2203   [ +  -  +  -  :          12 :       QString("Moving %1 mail(s) to %2...").arg(uids.size()).arg(targetFolder));
                   +  - ]
    2204                 :             : 
    2205                 :             :   // Snapshot headers for undo
    2206                 :           3 :   QList<MailHeader> snapshotHeaders;
    2207         [ +  - ]:           3 :   if (m_undoManager) {
    2208         [ +  + ]:           7 :     for (qint64 uid : uids) {
    2209         [ +  - ]:           4 :       auto h = m_cache->header(srcFolderId, uid);
    2210         [ +  - ]:           4 :       if (h)
    2211         [ +  - ]:           4 :         snapshotHeaders.append(*h);
    2212                 :           4 :     }
    2213                 :             :   }
    2214                 :             : 
    2215                 :             :   // IMAP move via body connection (cross-folder)
    2216         [ +  - ]:           3 :   crossFolderMove(srcFolder, uids, targetFolder);
    2217                 :             : 
    2218                 :             :   // Optimistic UI: remove from model
    2219         [ +  + ]:           7 :   for (qint64 uid : uids) {
    2220         [ +  - ]:           4 :     m_model->removeByUid(uid, srcFolderId);
    2221         [ +  - ]:           4 :     if (m_threadModel)
    2222         [ +  - ]:           4 :       m_threadModel->removeByUid(uid, srcFolderId);
    2223                 :             :   }
    2224                 :             : 
    2225                 :             :   // Undo
    2226   [ +  -  +  -  :           3 :   if (m_undoManager && !snapshotHeaders.isEmpty()) {
                   +  - ]
    2227         [ +  - ]:           3 :     m_undoManager->push(
    2228         [ +  - ]:           3 :         tr("Moved to %1 (%2 mails)")
    2229   [ +  -  +  - ]:           9 :             .arg(targetFolder).arg(uids.size()),
    2230   [ +  -  -  -  :           6 :         [this, snapshotHeaders, srcFolder, targetFolder]() {
                   -  - ]
    2231                 :           1 :           undoMove(snapshotHeaders, srcFolder, targetFolder);
    2232                 :           1 :         });
    2233                 :             :   }
    2234                 :           3 : }
    2235                 :             : 
    2236                 :             : // ── T-407 Private helpers ──
    2237                 :             : 
    2238                 :          14 : void MailController::crossFolderStoreFlag(const QString &folderPath,
    2239                 :             :                                           qint64 uid, const QString &flag,
    2240                 :             :                                           bool add) {
    2241                 :          14 :   ensureBodyConnection();
    2242         [ -  + ]:          14 :   if (!m_bodyImap)
    2243                 :           0 :     return;
    2244                 :             : 
    2245         [ +  + ]:          14 :   if (m_bodyImapSelectedFolder == folderPath) {
    2246                 :             :     // Already on the right folder — execute immediately
    2247                 :           3 :     m_bodyImap->storeFlag(uid, flag, add);
    2248                 :             :   } else {
    2249                 :             :     // Need to SELECT first, then STORE
    2250                 :             :     auto conn = connect(
    2251                 :          11 :         m_bodyImap, &ImapService::folderSelected, this,
    2252         [ -  - ]:          22 :         [this, uid, flag, add, folderPath](const QString &path, int, quint32,
    2253                 :             :                                            quint64) {
    2254         [ +  - ]:           1 :           if (path == folderPath) {
    2255                 :           1 :             m_bodyImapSelectedFolder = path;
    2256                 :           1 :             m_bodyImap->storeFlag(uid, flag, add);
    2257                 :             :           }
    2258                 :           1 :         },
    2259         [ +  - ]:          11 :         Qt::SingleShotConnection);
    2260                 :             :     Q_UNUSED(conn);
    2261         [ +  - ]:          11 :     m_bodyImap->selectFolder(folderPath);
    2262                 :          11 :   }
    2263                 :             : }
    2264                 :             : 
    2265                 :           5 : void MailController::crossFolderMove(const QString &srcFolder,
    2266                 :             :                                      const QList<qint64> &uids,
    2267                 :             :                                      const QString &targetFolder) {
    2268                 :           5 :   ensureBodyConnection();
    2269         [ -  + ]:           5 :   if (!m_bodyImap)
    2270                 :           0 :     return;
    2271                 :             : 
    2272                 :             :   // T-79.E2/M6: remember the source folder so the result handlers can
    2273                 :             :   // clean (success) or restore (failure) the right rows.
    2274   [ +  -  +  - ]:           5 :   m_pendingCrossFolderMoves.append({resolveFolderId(srcFolder), uids});
    2275                 :             : 
    2276         [ +  + ]:           5 :   if (m_bodyImapSelectedFolder == srcFolder) {
    2277                 :           1 :     m_bodyImap->moveMessages(uids, targetFolder);
    2278                 :             :   } else {
    2279                 :             :     auto conn = connect(
    2280                 :           4 :         m_bodyImap, &ImapService::folderSelected, this,
    2281   [ -  -  -  - ]:           8 :         [this, uids, targetFolder, srcFolder](const QString &path, int,
    2282                 :             :                                                quint32, quint64) {
    2283         [ +  - ]:           1 :           if (path == srcFolder) {
    2284                 :           1 :             m_bodyImapSelectedFolder = path;
    2285                 :           1 :             m_bodyImap->moveMessages(uids, targetFolder);
    2286                 :             :           }
    2287                 :           1 :         },
    2288         [ +  - ]:           4 :         Qt::SingleShotConnection);
    2289                 :             :     Q_UNUSED(conn);
    2290         [ +  - ]:           4 :     m_bodyImap->selectFolder(srcFolder);
    2291                 :           4 :   }
    2292                 :             : }
    2293                 :             : 
    2294                 :             : // T-79.E2/M6: cross-folder move confirmed by the server (body connection).
    2295                 :             : // The model rows were already removed optimistically; clean the cache rows
    2296                 :             : // of the *source* folder so the stale copy cannot resurface.
    2297                 :           1 : void MailController::onBodyImapMessagesMoved(const QList<qint64> &uids,
    2298                 :             :                                              const QString &targetFolder) {
    2299         [ +  - ]:           1 :   for (int i = 0; i < m_pendingCrossFolderMoves.size(); ++i) {
    2300                 :           1 :     const auto &entry = m_pendingCrossFolderMoves.at(i);
    2301         [ -  + ]:           1 :     if (entry.second != uids)
    2302                 :           0 :       continue;
    2303   [ +  -  +  -  :           2 :     qCInfo(lcController) << "T-79.E2: Cross-folder move confirmed:"
             +  -  +  + ]
    2304   [ +  -  +  -  :           1 :                          << uids.size() << "UIDs from folderId" << entry.first
                   +  - ]
    2305   [ +  -  +  - ]:           1 :                          << "→" << targetFolder;
    2306         [ +  + ]:           2 :     for (qint64 uid : uids)
    2307         [ +  - ]:           1 :       m_cache->removeHeader(entry.first, uid);
    2308                 :           1 :     m_pendingCrossFolderMoves.removeAt(i);
    2309   [ +  -  +  - ]:           2 :     emit statusMessage(tr("Moved %1 mail(s) to %2")
    2310         [ +  - ]:           2 :                            .arg(uids.size())
    2311         [ +  - ]:           2 :                            .arg(targetFolder));
    2312                 :           1 :     return;
    2313                 :             :   }
    2314   [ #  #  #  #  :           0 :   qCWarning(lcController)
                   #  # ]
    2315         [ #  # ]:           0 :       << "T-79.E2: messagesMoved on body connection without pending entry";
    2316                 :             : }
    2317                 :             : 
    2318                 :             : // T-79.E2/M6: cross-folder move failed — restore the optimistically
    2319                 :             : // removed model rows from the cache and tell the user.
    2320                 :           3 : void MailController::onBodyImapMoveError(const QString &error) {
    2321   [ +  -  +  -  :           6 :   qCWarning(lcController) << "T-79.E2: Cross-folder move failed:" << error;
          +  -  +  -  +  
                      + ]
    2322   [ +  -  +  -  :           6 :   emit statusMessage(tr("Move failed: %1").arg(error));
                   +  - ]
    2323                 :             : 
    2324                 :           3 :   QList<MailHeader> restoreHeaders;
    2325         [ +  + ]:           6 :   for (const auto &entry : std::as_const(m_pendingCrossFolderMoves)) {
    2326         [ +  + ]:           7 :     for (qint64 uid : entry.second) {
    2327         [ +  - ]:           4 :       auto h = m_cache->header(entry.first, uid);
    2328         [ +  + ]:           4 :       if (h)
    2329         [ +  - ]:           1 :         restoreHeaders.append(*h);
    2330                 :           4 :     }
    2331                 :             :   }
    2332         [ +  - ]:           3 :   m_pendingCrossFolderMoves.clear();
    2333         [ +  + ]:           3 :   if (!restoreHeaders.isEmpty()) {
    2334   [ +  -  +  -  :           2 :     qCInfo(lcController) << "T-79.E2: Restoring" << restoreHeaders.size()
          +  -  +  -  +  
                      + ]
    2335         [ +  - ]:           1 :                          << "mails after failed cross-folder move";
    2336         [ +  - ]:           1 :     m_model->appendHeaders(restoreHeaders);
    2337         [ +  - ]:           1 :     if (m_threadModel)
    2338         [ +  - ]:           1 :       m_threadModel->setHeaders(m_model->allHeaders());
    2339                 :             :   }
    2340                 :           3 : }
    2341                 :             : 
    2342                 :             : // T-200: Mark all mails in a folder as read
    2343                 :           3 : void MailController::markFolderAllSeen(const QString &folderPath) {
    2344                 :             :   // Only works for the currently selected folder
    2345         [ -  + ]:           3 :   if (folderPath != m_currentFolder) {
    2346   [ #  #  #  #  :           0 :     qCInfo(lcController) << "markFolderAllSeen: folder" << folderPath
          #  #  #  #  #  
                      # ]
    2347         [ #  # ]:           0 :                          << "is not the currently selected folder, ignoring";
    2348                 :           0 :     return;
    2349                 :             :   }
    2350                 :             : 
    2351                 :             :   // Optimistic update: mark all unread mails as seen locally
    2352                 :           3 :   int updated = 0;
    2353   [ +  -  +  + ]:          22 :   for (int r = 0; r < m_model->rowCount(); ++r) {
    2354                 :          19 :     auto *h = m_model->headerAt(r);
    2355   [ +  -  +  +  :          19 :     if (h && !h->isSeen()) {
                   +  + ]
    2356                 :           7 :       quint32 newFlags = h->flags | MailFlag::Seen;
    2357                 :           7 :       m_cache->updateFlags(m_currentFolderId, h->uid, newFlags);
    2358                 :           7 :       m_model->updateFlags(h->uid, newFlags, m_currentFolderId);
    2359         [ +  - ]:           7 :       if (m_threadModel)
    2360                 :           7 :         m_threadModel->updateFlags(h->uid, newFlags, m_currentFolderId);
    2361                 :           7 :       ++updated;
    2362                 :             :     }
    2363                 :             :   }
    2364                 :             : 
    2365   [ +  -  +  -  :           6 :   qCInfo(lcController) << "T-200: Marked" << updated << "mails as seen in"
          +  -  +  -  +  
                -  +  + ]
    2366         [ +  - ]:           3 :                        << folderPath;
    2367                 :           3 :   emit unreadCountChanged(m_currentFolder, m_model->unreadCount());
    2368         [ +  - ]:           3 :   emit statusMessage(
    2369   [ +  -  +  - ]:           9 :       QString("%1 – alle als gelesen markiert").arg(m_currentFolder));
    2370                 :             : 
    2371                 :             :   // Send bulk STORE to server
    2372         [ +  - ]:           6 :   m_imap->executeAfterIdle([this]() { m_imap->markAllSeen(); });
    2373                 :             : }
    2374                 :             : 
    2375                 :             : 
    2376                 :          11 : void MailController::onMessageMoved(qint64 uid, const QString &targetFolder) {
    2377                 :             :   // T-79.E4/M8: during undo-move the main connection moves UIDs of the
    2378                 :             :   // *fromFolder* while m_currentFolderId still points at the folder the
    2379                 :             :   // user is viewing (undoMove bumps m_folderGeneration, the undo flow's
    2380                 :             :   // own SingleShot handlers reload afterwards). Removing by UID here
    2381                 :             :   // would evict unrelated cached headers with coincident UIDs.
    2382         [ +  + ]:          11 :   if (m_activeFolderGen != m_folderGeneration) {
    2383   [ +  -  +  -  :          10 :     qCInfo(lcController) << "T-79.E4: Ignoring messageMoved for UID" << uid
          +  -  +  -  +  
                      + ]
    2384         [ +  - ]:           5 :                          << "(stale folder generation)";
    2385                 :           5 :     return;
    2386                 :             :   }
    2387                 :             : 
    2388   [ +  -  +  -  :          12 :   qCInfo(lcController) << "Mail moved: UID" << uid << "→" << targetFolder;
          +  -  +  -  +  
             -  +  -  +  
                      + ]
    2389                 :             : 
    2390                 :             :   // Remove from local model and cache
    2391                 :           6 :   m_cache->removeHeader(m_currentFolderId, uid);
    2392                 :           6 :   m_model->removeByUid(uid, m_currentFolderId);
    2393                 :             : }
    2394                 :             : 
    2395                 :           6 : void MailController::onMessagesMoved(const QList<qint64> &uids,
    2396                 :             :                                      const QString &targetFolder) {
    2397   [ +  -  +  -  :          12 :   qCInfo(lcController) << "Batch move complete:" << uids.size()
          +  -  +  -  +  
                      + ]
    2398   [ +  -  +  - ]:           6 :                        << "UIDs →" << targetFolder;
    2399                 :             : 
    2400                 :             :   // T-201: Server confirmed move → remove from pending set
    2401         [ +  + ]:          17 :   for (qint64 uid : uids)
    2402         [ +  - ]:          11 :     m_pendingMoveUids.remove(uid);
    2403                 :             : 
    2404                 :           6 :   emit unreadCountChanged(m_currentFolder, m_model->unreadCount());
    2405         [ +  - ]:           6 :   emit statusMessage(
    2406         [ +  - ]:           6 :       QString("Moved %1 mail(s) to %2 – %3 mails")
    2407         [ +  - ]:          12 :           .arg(uids.size())
    2408         [ +  - ]:          12 :           .arg(targetFolder)
    2409   [ +  -  +  - ]:          12 :           .arg(m_model->rowCount()));
    2410                 :             : 
    2411                 :             :   // Restart IDLE after move completes
    2412                 :           6 :   startIdleIfPossible();
    2413                 :           6 : }
    2414                 :             : 
    2415                 :          12 : void MailController::onMoveError(const QString &error) {
    2416   [ +  -  +  -  :          24 :   qCWarning(lcController) << "Move failed:" << error;
          +  -  +  -  +  
                      + ]
    2417   [ +  -  +  -  :          24 :   emit statusMessage(QString("Move failed: %1").arg(error));
                   +  - ]
    2418                 :             : 
    2419                 :             :   // T-513: Rollback — restore optimistically removed messages from cache
    2420         [ +  + ]:          12 :   if (!m_pendingMoveUids.isEmpty()) {
    2421                 :          11 :     QList<MailHeader> restoreHeaders;
    2422   [ +  -  +  -  :          24 :     for (qint64 uid : m_pendingMoveUids) {
                   +  + ]
    2423         [ +  - ]:          13 :       auto h = m_cache->header(m_currentFolderId, uid);
    2424         [ +  - ]:          13 :       if (h)
    2425         [ +  - ]:          13 :         restoreHeaders.append(*h);
    2426                 :          13 :     }
    2427         [ +  - ]:          11 :     if (!restoreHeaders.isEmpty()) {
    2428   [ +  -  +  -  :          22 :       qCInfo(lcController) << "T-513: Restoring" << restoreHeaders.size()
          +  -  +  -  +  
                      + ]
    2429         [ +  - ]:          11 :                            << "mails after failed move";
    2430         [ +  - ]:          11 :       m_model->appendHeaders(restoreHeaders);
    2431         [ +  + ]:          11 :       if (m_threadModel)
    2432         [ +  - ]:          10 :         m_threadModel->setHeaders(m_model->allHeaders());
    2433                 :             :     }
    2434                 :          11 :     m_pendingMoveUids.clear();
    2435                 :          11 :   }
    2436                 :          12 : }
    2437                 :             : 
    2438                 :             : // ═════════════════════════════════════════════════════════
    2439                 :             : // T-211: Undo Move
    2440                 :             : // ═════════════════════════════════════════════════════════
    2441                 :             : 
    2442                 :           4 : void MailController::undoMove(const QList<MailHeader> &headers,
    2443                 :             :                               const QString &sourceFolder,
    2444                 :             :                               const QString &fromFolder) {
    2445         [ -  + ]:           4 :   if (headers.isEmpty()) return;
    2446                 :             : 
    2447   [ +  -  +  -  :           8 :   qCInfo(lcController) << "T-211: Undo move:" << headers.size()
          +  -  +  -  +  
                      + ]
    2448   [ +  -  +  -  :           4 :                        << "mails from" << fromFolder << "back to" << sourceFolder;
             +  -  +  - ]
    2449         [ +  - ]:           4 :   emit statusMessage(
    2450                 :           8 :       QStringLiteral("R\u00fcckg\u00e4ngig: Verschiebe %1 Mail(s) zur\u00fcck nach %2\u2026")
    2451   [ +  -  +  - ]:          12 :           .arg(headers.size()).arg(sourceFolder));
    2452                 :             : 
    2453                 :             : 
    2454                 :             :   // Collect Message-IDs for IMAP search in the target folder
    2455                 :           4 :   QStringList messageIds;
    2456         [ +  + ]:          11 :   for (const auto &h : headers) {
    2457         [ +  - ]:           7 :     if (!h.messageId.isEmpty()) {
    2458         [ +  - ]:           7 :       messageIds.append(h.messageId);
    2459                 :             :     }
    2460                 :             :   }
    2461                 :             : 
    2462         [ -  + ]:           4 :   if (messageIds.isEmpty()) {
    2463   [ #  #  #  #  :           0 :     qCWarning(lcController) << "T-211: No Message-IDs for undo move";
             #  #  #  # ]
    2464   [ #  #  #  # ]:           0 :     emit statusMessage(tr("Undo failed: No message IDs"));
    2465                 :           0 :     return;
    2466                 :             :   }
    2467                 :             : 
    2468                 :             :   // Async IMAP flow: SELECT fromFolder → SEARCH Message-ID → MOVE back
    2469                 :             :   // We use the main connection via executeAfterIdle.
    2470         [ +  - ]:           4 :   m_imap->executeAfterIdle(
    2471   [ +  -  -  -  :           8 :       [this, messageIds, sourceFolder, fromFolder]() {
                   -  - ]
    2472                 :             :         // T-211 fix: Invalidate all sync-pipeline handlers so that
    2473                 :             :         // onSearchResultReceived etc. discard the undo-flow results.
    2474                 :             :         // Only our SingleShot handlers will process them.
    2475                 :           4 :         ++m_folderGeneration;
    2476                 :             : 
    2477                 :             :         // We need to SELECT the target folder (where the mails are now)
    2478                 :             :         // to search for them by Message-ID and move them back.
    2479                 :           4 :         m_bodyFetchSelect = true; // Suppress sync pipeline
    2480                 :           4 :         m_imap->selectFolder(fromFolder);
    2481                 :             : 
    2482                 :             :         // Wait for SELECT to complete, then search + move
    2483                 :             :         // T-403/Bug 13: SingleShotConnection to prevent signal leak
    2484                 :           4 :         connect(m_imap, &ImapService::folderSelected, this,
    2485   [ +  -  -  -  :           8 :             [this, messageIds, sourceFolder, fromFolder](
                   -  - ]
    2486                 :             :                 const QString &path, int, quint32, quint64) {
    2487         [ -  + ]:           2 :               if (path != fromFolder) return;
    2488                 :             : 
    2489                 :             :               // T-522/HIGH-19: Run Message-ID searches sequentially.
    2490                 :             :               // ImapService has one SEARCH accumulator, so overlapping
    2491                 :             :               // SEARCH commands would clear each other's pending UID list.
    2492         [ +  - ]:           2 :               auto foundUids = std::make_shared<QList<qint64>>();
    2493         [ +  - ]:           2 :               auto nextIndex = std::make_shared<int>(0);
    2494                 :           2 :               auto capturedFolder = m_currentFolder;
    2495         [ +  - ]:           2 :               auto searchConn = std::make_shared<QMetaObject::Connection>();
    2496         [ +  - ]:           2 :               auto runNextSearch = std::make_shared<std::function<void()>>();
    2497                 :             : 
    2498                 :             :               auto finishSearches =
    2499                 :           6 :                   [this, foundUids, sourceFolder, fromFolder,
    2500                 :             :                    capturedFolder, searchConn]() {
    2501                 :           2 :                     QObject::disconnect(*searchConn);
    2502                 :             : 
    2503         [ -  + ]:           2 :                     if (foundUids->isEmpty()) {
    2504   [ #  #  #  #  :           0 :                       qCWarning(lcController)
                   #  # ]
    2505   [ #  #  #  # ]:           0 :                           << "T-211: No messages found in" << fromFolder;
    2506   [ #  #  #  # ]:           0 :                       emit statusMessage(tr("Undo failed: messages not found"));
    2507                 :             :                       // Revert to original folder
    2508         [ #  # ]:           0 :                       if (!capturedFolder.isEmpty())
    2509                 :           0 :                         onFolderSelected(capturedFolder);
    2510                 :           0 :                       return;
    2511                 :             :                     }
    2512                 :             : 
    2513   [ +  -  +  -  :           4 :                     qCInfo(lcController) << "T-522: Moving" << foundUids->size()
          +  -  +  -  +  
                      + ]
    2514   [ +  -  +  - ]:           2 :                                          << "UIDs back to" << sourceFolder;
    2515                 :           2 :                     m_imap->moveMessages(*foundUids, sourceFolder);
    2516                 :             : 
    2517                 :             :                     // After move-back: clean folder reload
    2518                 :           2 :                     connect(m_imap, &ImapService::messagesMoved, this,
    2519         [ +  - ]:           4 :                         [this](const QList<qint64> &, const QString &) {
    2520   [ +  -  +  -  :           4 :                           qCInfo(lcController) << "T-211: Move-back done";
             +  -  +  + ]
    2521         [ +  - ]:           2 :                           emit statusMessage(
    2522         [ +  - ]:           4 :                               tr("Undo completed"));
    2523                 :           2 :                           onFolderSelected(m_currentFolder);
    2524                 :           2 :                         }, Qt::SingleShotConnection);
    2525                 :           2 :                   };
    2526                 :             : 
    2527   [ -  -  -  -  :           4 :               *runNextSearch = [this, messageIds, nextIndex, runNextSearch,
                   -  - ]
    2528                 :             :                                 finishSearches]() {
    2529         [ +  + ]:           6 :                 if (*nextIndex >= messageIds.size()) {
    2530         [ +  - ]:           2 :                   finishSearches();
    2531                 :           2 :                   return;
    2532                 :             :                 }
    2533                 :           4 :                 const QString msgId = messageIds.at((*nextIndex)++);
    2534         [ +  - ]:           4 :                 m_imap->searchByMessageId(msgId);
    2535         [ +  - ]:           6 :               };
    2536                 :             : 
    2537                 :           4 :               *searchConn = connect(
    2538                 :           2 :                   m_imap, &ImapService::searchResultReceived, this,
    2539   [ +  -  -  - ]:           4 :                   [foundUids, runNextSearch](const QList<qint64> &uids) {
    2540                 :           4 :                     foundUids->append(uids);
    2541                 :           4 :                     (*runNextSearch)();
    2542                 :           2 :                   });
    2543                 :             : 
    2544         [ +  - ]:           2 :               (*runNextSearch)();
    2545                 :           2 :             }, Qt::SingleShotConnection);
    2546                 :           4 :       });
    2547         [ +  - ]:           4 : }
    2548                 :          85 : qint64 MailController::resolveFolderId(const QString &folderPath) {
    2549                 :          85 :   return m_cache->ensureFolder(m_accountId, folderPath);
    2550                 :             : }
        

Generated by: LCOV version 2.0-1