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