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 : };
|