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