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

Generated by: LCOV version 2.0-1