MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - controller - MailController.h (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 88.9 % 9 8
Test Date: 2026-06-21 21:10:19 Functions: 88.9 % 9 8
Legend: Lines:     hit not hit

            Line data    Source code
       1              : #pragma once
       2              : 
       3              : #include <QElapsedTimer>
       4              : #include <QList>
       5              : #include <QMap>
       6              : #include <QObject>
       7              : #include <QPair>
       8              : #include <QSet>
       9              : #include <QString>
      10              : #include <QTimer>
      11              : 
      12              : #include "data/AccountConfig.h"
      13              : #include "data/MailCache.h" // Sprint 59 (S1): SearchFilter in serverSearch()
      14              : #include "data/Models.h"
      15              : 
      16              : class ImapService;
      17              : class MailCache;
      18              : class MailListModel;
      19              : class MailThreadModel;
      20              : class MailView;
      21              : class FolderTree;
      22              : class UndoManager;
      23              : class ConnectionHealthMonitor;
      24              : 
      25              : // MailController orchestrates the cache-first mail data flow with live updates:
      26              : //
      27              : //   Folder change:
      28              : //     1. Load cached headers → display immediately (with correct badges)
      29              : //     2. executeAfterIdle → SELECT folder on IMAP
      30              : //     3. Check UIDVALIDITY → if changed, purge + full sync
      31              : //     4. Fetch headers since maxUid → append (streaming in 50er batches)
      32              : //     5. headerFetchComplete → fetchFlags → sync with server
      33              : //     6. Flag sync done → startIdle
      34              : //
      35              : //   IDLE events (all use executeAfterIdle for safe timing):
      36              : //     - New messages → fetch headers → append
      37              : //     - Flag changes → update cache + model
      38              : //     - Expunge → re-fetch flags to detect removals
      39              : //
      40              : //   Polling (non-IDLE folders):
      41              : //     - Timer-triggered, pauses IDLE via executeAfterIdle
      42              : //     - Sequential STATUS for all subscribed folders
      43              : //     - Re-starts IDLE after all STATUS complete
      44              : class MailController : public QObject {
      45          106 :   Q_OBJECT
      46              : #ifdef MAILJD_UNIT_TEST
      47              :   friend class TestSprint52;
      48              :   friend class TestSprint55Controller;
      49              :   friend class TestMainWindow;
      50              :   friend class TestSprint49Desktop; // 67.A1: folder state seams
      51              :   friend class TestConnectionHealth; // T-720: body/search monitor seams
      52              :   friend class TestSprint76; // Sprint 76: folder state seams for activation tests
      53              : #endif
      54              : 
      55              : public:
      56              :   MailController(ImapService *imap, MailCache *cache, MailListModel *model,
      57              :                  MailView *view, QObject *parent = nullptr);
      58              :   ~MailController() override;
      59              : 
      60              :   void setAccount(const QString &accountId);
      61            6 :   QString accountId() const { return m_accountId; }
      62          144 :   qint64 currentFolderId() const { return m_currentFolderId; }
      63              :   qint64 resolveFolderId(const QString &folderPath);
      64           83 :   QString currentFolder() const { return m_currentFolder; }
      65            0 :   const QMap<QString, int> &lastPolledUnread() const { return m_lastPolledUnread; }
      66           57 :   void setFolderTree(FolderTree *tree) { m_folderTree = tree; }
      67          100 :   void setThreadModel(MailThreadModel *model) { m_threadModel = model; }
      68              :   void setSubscribedFolders(const QStringList &folders);
      69            2 :   QStringList subscribedFolders() const { return m_subscribedFolders; }
      70          116 :   void setUndoManager(UndoManager *mgr) { m_undoManager = mgr; }
      71              : 
      72              : public slots:
      73              :   void onFolderSelected(const QString &folderPath);
      74              :   void onMailSelected(qint64 uid);
      75              :   void onMailSelectedInFolder(qint64 uid, qint64 folderId);
      76              :   void toggleReadStatus(qint64 uid);
      77              :   void toggleStarred(qint64 uid);
      78              :   // T-519: Idempotent setters (for command bar mark-read/mark-unread/star/unstar)
      79              :   void markMailAsSeen(qint64 uid);
      80              :   void markMailAsUnseen(qint64 uid);
      81              :   void setStarred(qint64 uid, bool starred);
      82              :   void addLabel(qint64 uid, const QString &label);
      83              :   void removeLabel(qint64 uid, const QString &label);
      84              :   bool downloadAttachment(qint64 attachmentId, const QString &savePath);
      85              :   void moveMailToFolder(qint64 uid, const QString &targetFolder);
      86              :   void moveMailsToFolder(const QList<qint64> &uids, const QString &targetFolder);
      87              : 
      88              :   // T-407: Cross-folder action overloads for search mode.
      89              :   // These accept the source folderId/folderPath so they work correctly
      90              :   // when the mail is not in the controller's currently selected folder.
      91              :   void toggleReadStatusInFolder(qint64 uid, qint64 folderId);
      92              :   void toggleStarredInFolder(qint64 uid, qint64 folderId);
      93              :   void addLabelInFolder(qint64 uid, qint64 folderId, const QString &label);
      94              :   void removeLabelInFolder(qint64 uid, qint64 folderId, const QString &label);
      95              :   void moveMailsToFolderFrom(const QList<qint64> &uids, qint64 srcFolderId,
      96              :                              const QString &srcFolder,
      97              :                              const QString &targetFolder);
      98              : 
      99              :   // T-200: Mark all mails in a folder as read
     100              :   void markFolderAllSeen(const QString &folderPath);
     101              : 
     102              :   // Sprint 49: Trigger immediate poll of all subscribed folders
     103              :   void triggerPollNow();
     104              : 
     105              :   // Search: configure credentials for the dedicated search connection
     106              :   void setImapConfig(const ImapConfig &config);
     107              : 
     108              :   // Server-side IMAP SEARCH on dedicated second connection.
     109              :   // folderFilter (optional): if non-empty, only subscribed folders whose path
     110              :   // contains this substring (case-insensitive) are searched — mirrors the
     111              :   // "folder:" prefix of the local search so the server does not waste work
     112              :   // scanning every folder.
     113              :   // Sprint 59 (S1): the free-text term plus the structured filter. The folder
     114              :   // scope is taken from filter.folderPatterns (Sprint 60: OR over folders).
     115              :   // Server-mappable facets (from/to/
     116              :   // subject/date/is:/tags) are translated to a composite IMAP SEARCH; purely
     117              :   // local facets (e.g. has:attachment) are ignored here — the local FTS/cache
     118              :   // covers them. When nothing is server-mappable, no server scan is started.
     119              :   void serverSearch(const QString &freeText,
     120              :                     const MailCache::SearchFilter &filter);
     121              :   void cancelServerSearch();
     122              : 
     123              : signals:
     124              :   void statusMessage(const QString &message);
     125              :   void unreadCountChanged(const QString &folder, int count);
     126              :   // T-176: Emitted after headers are stored in cache (for predictor training)
     127              :   void headersStored(const QString &folderPath,
     128              :                      const QList<MailHeader> &headers);
     129              :   // Server-side search results from dedicated search connection (per folder)
     130              :   void serverSearchResultReceived(const QList<qint64> &uids,
     131              :                                   qint64 folderId, const QString &folderPath);
     132              :   void serverSearchComplete();
     133              :   // T-540: Body loaded notification (for tab body fetch)
     134              :   void bodyLoaded(qint64 uid, qint64 folderId);
     135              :   // 67.A2: First INBOX header sync of this session finished.
     136              :   // initialLoad = true when the INBOX cache was empty when the sync
     137              :   // started (first-ever mailbox load — nothing in it is "new mail").
     138              :   void inboxFirstSyncCompleted(bool initialLoad);
     139              : 
     140              : private slots:
     141              :   void onFolderSelectedFromImap(const QString &path, int messageCount,
     142              :                                 quint32 uidValidity,
     143              :                                 quint64 highestModseq); // T-208
     144              :   void onHeadersReceived(const QList<MailHeader> &headers);
     145              :   void onHeaderFetchComplete();
     146              :   void onRawBodyReceived(qint64 uid, const QByteArray &rawBody);
     147              : 
     148              :   // T-205: Body response from dedicated body connection
     149              :   void onBodyImapRawBodyReceived(qint64 uid, const QByteArray &rawBody);
     150              : 
     151              :   // IDLE event handlers
     152              :   void onIdleNewMessages(int newCount);
     153              :   void onIdleFlagsChanged(qint64 uid, quint32 flags);
     154              :   void onIdleFlagsNeedRefetch(int seqNo);
     155              :   void onIdleMessageExpunged(int seqNo);
     156              : 
     157              :   // Flag sync result
     158              :   void onFlagsReceived(const QList<QPair<qint64, quint32>> &uidFlags);
     159              :   void onSearchResultReceived(const QList<qint64> &uids);
     160              : 
     161              :   // Folder status (polling) result
     162              :   void onFolderStatusReceived(const StatusResult &result);
     163              : 
     164              :   // T-100: Move result handlers
     165              :   void onMessageMoved(qint64 uid, const QString &targetFolder);
     166              :   void onMessagesMoved(const QList<qint64> &uids, const QString &targetFolder);
     167              :   void onMoveError(const QString &error);
     168              : 
     169              :   // Polling timer
     170              :   void pollFolders();
     171              : 
     172              : private:
     173              :   void processRawBody(qint64 uid, const QByteArray &rawBody);
     174              :   void prefetchAdjacent(int currentRow);
     175              :   void startIdleIfPossible();
     176              :   void pollNextFolder();
     177              :   void fetchNextChunk();
     178              :   void executeDeferredFolderSwitch(); // T-118: fired by debounce timer
     179              :   void ensureSearchConnection();      // Lazy-init second IMAP for search
     180              :   void ensureBodyConnection();        // T-205: Lazy-init third IMAP for body
     181              : 
     182              :   // T-407: Execute a flag STORE on a different folder via m_bodyImap
     183              :   void crossFolderStoreFlag(const QString &folderPath, qint64 uid,
     184              :                             const QString &flag, bool add);
     185              :   // T-407: Cross-folder move via m_bodyImap
     186              :   void crossFolderMove(const QString &srcFolder, const QList<qint64> &uids,
     187              :                        const QString &targetFolder);
     188              : 
     189              :   // T-211: Undo a mail move (async IMAP flow)
     190              :   void undoMove(const QList<MailHeader> &headers,
     191              :                 const QString &sourceFolder, const QString &fromFolder);
     192              : 
     193              :   ImapService *m_imap;
     194              :   MailCache *m_cache;
     195              :   MailListModel *m_model;
     196              :   MailView *m_view;
     197              :   MailThreadModel *m_threadModel = nullptr;
     198              :   FolderTree *m_folderTree = nullptr;
     199              :   UndoManager *m_undoManager = nullptr; // T-211
     200              : 
     201              :   QTimer *m_pollingTimer = nullptr;
     202              :   QStringList m_subscribedFolders;
     203              :   int m_pollingIndex = 0;
     204              : 
     205              :   QString m_accountId;
     206              :   QString m_currentFolder;
     207              :   qint64 m_currentFolderId = -1;
     208              :   qint64 m_pendingBodyUid = -1;
     209              :   qint64 m_pendingBodyFolderId = -1; // folderId of pending body fetch
     210              :   bool m_bodyFetchSelect = false;    // Suppress onFolderSelectedFromImap during body fetch
     211              :   bool m_pendingHeaderFetch = false; // T-058: flag sync triggers header fetch
     212              :   bool m_initialPollDone = false;    // T-066: initial poll on first IDLE start
     213              : 
     214              :   // Second IMAP connection for server-side search (lazy-init)
     215              :   ImapService *m_searchImap = nullptr;
     216              :   // T-720: Health monitor for the search connection — silently dead
     217              :   // search connections are now detected + reconnected like the others.
     218              :   ConnectionHealthMonitor *m_searchHealth = nullptr;
     219              :   ImapConfig m_imapConfig;
     220              :   QMetaObject::Connection m_searchStateConn;  // T-195: auth wait
     221              :   QMetaObject::Connection m_searchFolderConn; // T-195: SELECT wait
     222              :   QMetaObject::Connection m_searchFailConn;   // SELECT-failure skip
     223              :   QMetaObject::Connection m_searchResultConn; // multi-folder search result
     224              : 
     225              :   // Multi-folder server search state
     226              :   QStringList m_searchPendingFolders; // Queue of folders still to search
     227              :   QString m_searchQuery;              // Current search free-text term
     228              :   MailCache::SearchFilter m_searchFilter; // Sprint 59 (S1): facets for SEARCH
     229              :   QString m_searchCurrentFolder;      // Folder currently being searched
     230              :   qint64 m_searchCurrentFolderId = -1;// Its folderId
     231              :   void searchNextFolder();            // Pop next folder and issue SEARCH
     232              : 
     233              :   // T-205: Third IMAP connection for body fetch (lazy-init)
     234              :   ImapService *m_bodyImap = nullptr;
     235              :   QString m_bodyImapSelectedFolder; // Currently selected folder on m_bodyImap
     236              :   // T-720: Health monitor for the body connection (liveness probe +
     237              :   // backoff reconnect). Replaces m_bodyKeepAliveTimer (T-540).
     238              :   ConnectionHealthMonitor *m_bodyHealth = nullptr;
     239              :   QTimer *m_loadingPlaceholderTimer = nullptr; // T-548: Deferred "Loading body…" display
     240              : 
     241              :   // T-061: Reverse-chunked fetch state
     242              :   QList<QList<qint64>> m_reverseChunks;
     243              : 
     244              :   // T-074: Last polled unread counts (preserve badges during folder switch)
     245              :   QMap<QString, int> m_lastPolledUnread;
     246              :   bool m_fetchInProgress = false;
     247              : 
     248              :   // Folder generation counter: incremented on each folder switch.
     249              :   // Async callbacks capture this value and discard themselves if it
     250              :   // has changed (meaning the user switched to a different folder).
     251              :   quint64 m_folderGeneration = 0;
     252              : 
     253              :   // T-113: Active pipeline generation — set when a SELECT actually completes
     254              :   // (in onFolderSelectedFromImap). All downstream callbacks (headers, flags,
     255              :   // search, chunks) compare m_activeFolderGen against m_folderGeneration.
     256              :   // If they differ, a new folder switch was initiated and the callback
     257              :   // should discard its data.
     258              :   quint64 m_activeFolderGen = 0;
     259              : 
     260              :   // Debounce timer for IDLE expunge events (avoid N× fetchFlags for bulk deletes)
     261              :   QTimer *m_expungeDebounceTimer = nullptr;
     262              : 
     263              :   // T-118: Debounce timer for IMAP SELECT.
     264              :   // Cache load is instant, but the IMAP SELECT (which stops IDLE) is
     265              :   // delayed by 50ms. Rapid switches only trigger one SELECT.
     266              :   QTimer *m_folderSwitchTimer = nullptr;
     267              : 
     268              :   // T-201: Track UIDs with pending server updates to prevent
     269              :   // onFlagsReceived from reverting optimistic changes
     270              :   QSet<qint64> m_pendingFlagUids;
     271              :   QSet<qint64> m_pendingMoveUids;
     272              : 
     273              :   // T-210: Folder-switch total timing (onFolderSelected → startIdleIfPossible)
     274              :   QElapsedTimer m_folderSwitchStopwatch;
     275              : 
     276              :   // 67.A2: first-INBOX-sync tracking for notification suppression
     277              :   bool m_inboxFirstSyncSignaled = false;
     278              :   bool m_inboxCacheWasEmpty = false;
     279              : };
        

Generated by: LCOV version 2.0-1