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