Branch data Line data Source code
1 : : #include "ImapService.h"
2 : : #include "ImapResponseParser.h"
3 : : #include "util/SecureUtil.h"
4 : :
5 : : #include <QLocale>
6 : : #include <QLoggingCategory>
7 : : #include <QMetaEnum>
8 : : #include <QRegularExpression>
9 : : #include <QSet>
10 : : #include <QSettings>
11 : : #include <QSocketNotifier>
12 : :
13 : : // T-720: TCP keepalive platform headers.
14 : : #ifdef Q_OS_LINUX
15 : : #include <sys/socket.h>
16 : : #include <netinet/in.h>
17 : : #include <netinet/tcp.h>
18 : : #endif
19 : : #ifdef Q_OS_WIN
20 : : #include <winsock2.h>
21 : : #include <ws2tcpip.h>
22 : : #include <mstcpip.h>
23 : : #endif
24 : : #ifdef Q_OS_MACOS
25 : : #include <sys/socket.h>
26 : : #include <netinet/in.h>
27 : : #include <netinet/tcp.h>
28 : : #endif
29 : :
30 [ + + + - : 105764 : Q_LOGGING_CATEGORY(lcImap, "mailjd.imap")
+ - - - ]
31 [ + + + - : 486 : Q_LOGGING_CATEGORY(lcImapTiming, "mailjd.imap.timing")
+ - - - ]
32 : :
33 : 301 : ImapService::ImapService(QObject *parent)
34 [ + - - + : 301 : : QObject(parent), m_socket(new QSslSocket(this)),
- - ]
35 [ + - + - : 301 : m_timeoutTimer(new QTimer(this)),
- + - - ]
36 [ + - + - : 301 : m_commandTimeoutTimer(new QTimer(this)),
- + - - ]
37 [ + - + - : 301 : m_commandDeadlineTimer(new QTimer(this)),
- + - - ]
38 [ + - + - : 301 : m_idleRenewTimer(new QTimer(this)),
- + - - ]
39 [ + - + - : 301 : m_livenessProbeWatchdog(new QTimer(this)),
- + - - ]
40 [ + - + - : 602 : m_idleRenewWatchdog(new QTimer(this)) {
+ - - + +
- - - ]
41 [ + - ]: 301 : m_timeoutTimer->setSingleShot(true);
42 [ + - ]: 301 : m_commandTimeoutTimer->setSingleShot(true);
43 [ + - ]: 301 : m_commandDeadlineTimer->setSingleShot(true);
44 [ + - ]: 301 : m_commandDeadlineTimer->setTimerType(Qt::PreciseTimer);
45 [ + - ]: 301 : m_idleRenewTimer->setSingleShot(true);
46 [ + - ]: 301 : m_livenessProbeWatchdog->setSingleShot(true);
47 [ + - ]: 301 : m_idleRenewWatchdog->setSingleShot(true);
48 : :
49 : : // T-720/T-72.1: Enable SO_KEEPALIVE at the Qt level. Native interval
50 : : // tuning is applied in tuneKeepAlive() once the socket descriptor is
51 : : // valid (onConnected()/onEncrypted()). assertable on an unconnected
52 : : // socket per the sprint plan.
53 [ + - ]: 301 : m_socket->setSocketOption(QAbstractSocket::KeepAliveOption, 1);
54 : : // Keep encrypted socket buffering bounded as well as our parser buffer.
55 : : // Literals are consumed incrementally below, so this does not constrain
56 : : // legitimate messages up to the configured per-message limit.
57 [ + - ]: 301 : m_socket->setReadBufferSize(MAX_SOCKET_READ_BUFFER_SIZE);
58 : :
59 [ + - ]: 301 : connect(m_socket, &QSslSocket::connected, this, &ImapService::onConnected);
60 [ + - ]: 301 : connect(m_socket, &QSslSocket::encrypted, this, &ImapService::onEncrypted);
61 [ + - ]: 301 : connect(m_socket, &QSslSocket::readyRead, this, &ImapService::onReadyRead);
62 : 301 : connect(m_socket, &QAbstractSocket::errorOccurred, this,
63 [ + - ]: 301 : &ImapService::onSocketError);
64 : 301 : connect(m_socket, &QAbstractSocket::disconnected, this,
65 [ + - ]: 301 : &ImapService::clearCredentials);
66 : : // Bug 33: Log and forward SSL certificate errors
67 : 301 : connect(m_socket, &QSslSocket::sslErrors, this,
68 [ + - ]: 301 : [this](const QList<QSslError> &errors) {
69 [ + + ]: 18 : for (const auto &e : errors)
70 [ + - + - : 18 : qCWarning(lcImap) << "SSL error:" << e.errorString();
+ - + - +
- + + ]
71 : : // E2E testing: GreenMail uses a self-signed certificate.
72 : : // Qt rejects self-signed certs even if they're in the system
73 : : // trust store. Allow bypassing SSL errors via environment variable.
74 : : // T-607/SEC-03: SSL error bypass is now compile-time only.
75 : : // Only available when built with -DBUILD_E2E_TESTS=ON
76 : : // (CMake sets the MAILJD_E2E_TESTING define).
77 : : #ifdef MAILJD_E2E_TESTING
78 [ + - + - : 18 : qCWarning(lcImap) << "Ignoring SSL errors (E2E test build)";
+ - + + ]
79 : 9 : m_socket->ignoreSslErrors();
80 : : #endif
81 : 9 : });
82 [ + - ]: 301 : connect(m_timeoutTimer, &QTimer::timeout, this, &ImapService::onTimeout);
83 : 301 : connect(m_commandTimeoutTimer, &QTimer::timeout, this,
84 [ + - ]: 301 : &ImapService::onCommandTimeout);
85 : 301 : connect(m_commandDeadlineTimer, &QTimer::timeout, this,
86 [ + - ]: 301 : &ImapService::onCommandDeadline);
87 [ + - ]: 301 : connect(m_idleRenewTimer, &QTimer::timeout, this, &ImapService::onIdleRenew);
88 : : // T-720: Watchdog handlers — both end in failConnection() so a probe
89 : : // or IDLE renew that the server never ACKs cannot stall the monitor.
90 : 301 : connect(m_livenessProbeWatchdog, &QTimer::timeout, this,
91 [ + - ]: 301 : &ImapService::onLivenessProbeTimeout);
92 : 301 : connect(m_idleRenewWatchdog, &QTimer::timeout, this,
93 [ + - ]: 301 : &ImapService::onIdleRenewWatchdogTimeout);
94 : 301 : }
95 : :
96 : 483 : ImapService::~ImapService() {
97 : : // Block signals to prevent stateChanged emission during destruction.
98 : : // Without this, disconnect() → setState(Disconnected) → emits stateChanged,
99 : : // which can invoke connected lambdas on already-destroyed parent objects
100 : : // (e.g. MainWindow members) causing use-after-free crashes.
101 : 301 : blockSignals(true);
102 : 301 : disconnect();
103 : 483 : }
104 : :
105 : 113 : void ImapService::connectToServer(const ImapConfig &config) {
106 [ + + + + ]: 113 : if (m_state != State::Disconnected && m_state != State::Error) {
107 [ + - + - : 4 : qCWarning(lcImap) << "Already connected or connecting";
+ - + + ]
108 : 2 : return;
109 : : }
110 : :
111 : : // A reconnect must not overwrite a credential copy left by the previous
112 : : // attempt without zeroing it first.
113 : 111 : clearCredentials();
114 : 111 : m_config = config;
115 : 111 : m_keepAliveTuned = false;
116 : 111 : m_tagCounter = 0;
117 : 111 : m_readBuffer.clear();
118 : 111 : m_pendingCommands.clear();
119 : : // Clear any queued work left over from a previous (failed) session so a
120 : : // reconnect from Error does not replay stale commands.
121 : 111 : m_serializedCommands.clear();
122 : 111 : m_deferredCommands.clear();
123 : 111 : m_commandTimers.clear();
124 : 111 : m_pendingFolders.clear();
125 : 111 : m_pendingHeaders.clear();
126 : 111 : m_pendingFlags.clear();
127 : 111 : m_pendingSearchUids.clear();
128 : 111 : resetCommandAccumulators();
129 : 111 : m_capabilities.clear();
130 : 111 : m_selectedFolder.clear();
131 : 111 : m_selectedMessageCount = 0;
132 : 111 : m_selectedUidValidity = 0;
133 : : // T-79.A1/H1: a stale APPEND payload must never survive into a new session,
134 : : // or the next "+" continuation writes mail text into the command stream.
135 : 111 : m_pendingAppendData.clear();
136 : 111 : m_pendingAppendFolder.clear();
137 : 111 : m_literalBytesRemaining = 0;
138 : 111 : m_literalData.clear();
139 : 111 : m_literalLine.clear();
140 : 111 : m_isBodyLiteral = false;
141 : 111 : m_bodyLiteralUid = -1;
142 : 111 : m_discardingInvalidBody = false;
143 : 111 : m_discardingOversizedBody = false;
144 : 111 : m_skipFetchLiteralRemainder = false;
145 : 111 : m_activeBodyFetchUid = -1;
146 : 111 : m_activeBodyFetchLimit = 0;
147 : 111 : m_activeBodyFetchRequestBytes = 0;
148 : 111 : m_bodyFetchRequiresSelect = false;
149 : 111 : m_isIdling = false;
150 : 111 : m_idleTag.clear();
151 : 111 : m_isNotifying = false;
152 : 111 : m_notifyTag.clear();
153 : :
154 : 111 : setState(State::Connecting);
155 : 111 : m_timeoutTimer->start(TIMEOUT_MS);
156 : :
157 [ + + ]: 111 : if (config.security == "ssl") {
158 [ + - + - : 220 : qCInfo(lcImap) << "Connecting via SSL to" << config.host << ":"
+ - + - +
- + + ]
159 [ + - ]: 110 : << config.port;
160 [ + - ]: 110 : m_socket->connectToHostEncrypted(config.host, config.port);
161 : : } else {
162 [ + - + - : 2 : qCInfo(lcImap) << "Connecting via STARTTLS to" << config.host << ":"
+ - + - +
- + + ]
163 [ + - ]: 1 : << config.port;
164 [ + - ]: 1 : m_socket->connectToHost(config.host, config.port);
165 : : }
166 : : }
167 : :
168 : 328 : void ImapService::disconnect() {
169 : 328 : m_timeoutTimer->stop();
170 : 328 : m_commandTimeoutTimer->stop();
171 : 328 : m_commandDeadlineTimer->stop();
172 : : // T-720: Stop the probe/IDLE-renew watchdogs on explicit teardown so
173 : : // they cannot fire against a torn-down socket.
174 : 328 : m_livenessProbeWatchdog->stop();
175 : 328 : m_idleRenewWatchdog->stop();
176 : 328 : m_probeTag.clear();
177 [ + + ]: 328 : if (m_socket->state() != QAbstractSocket::UnconnectedState) {
178 : : // Try to send LOGOUT gracefully
179 [ + + + + ]: 68 : if (m_state == State::Authenticated || m_state == State::Selected) {
180 [ + - ]: 8 : auto tag = nextTag();
181 [ + - + - : 8 : m_socket->write((tag + " LOGOUT\r\n").toUtf8());
+ - ]
182 [ + - ]: 8 : m_socket->flush();
183 : 8 : }
184 : 68 : m_socket->disconnectFromHost();
185 : : }
186 : : // T-405/Bug 16: Reset all state variables to prevent corruption on reconnect
187 : 328 : m_isIdling = false;
188 : : // T-79.A1/H1: drop any in-flight APPEND payload with the session
189 : 328 : m_pendingAppendData.clear();
190 : 328 : m_pendingAppendFolder.clear();
191 : 328 : m_literalBytesRemaining = 0;
192 : 328 : m_literalData.clear();
193 : 328 : m_literalLine.clear();
194 : 328 : m_isBodyLiteral = false;
195 : 328 : m_bodyLiteralUid = -1;
196 : 328 : m_discardingInvalidBody = false;
197 : 328 : m_pendingCommands.clear();
198 : 328 : m_commandTimers.clear();
199 : 328 : m_deferredCommands.clear();
200 : 328 : m_serializedCommands.clear();
201 : 328 : m_pendingFolders.clear();
202 : 328 : m_pendingHeaders.clear();
203 : 328 : m_pendingFlags.clear();
204 : 328 : m_pendingSearchUids.clear();
205 : 328 : m_readBuffer.clear();
206 : 328 : m_discardingOversizedBody = false;
207 : 328 : m_skipFetchLiteralRemainder = false;
208 : 328 : m_activeBodyFetchUid = -1;
209 : 328 : m_activeBodyFetchLimit = 0;
210 : 328 : m_activeBodyFetchRequestBytes = 0;
211 : 328 : m_bodyFetchRequiresSelect = false;
212 : 328 : resetCommandAccumulators();
213 : 328 : clearCredentials();
214 : 328 : setState(State::Disconnected);
215 : 328 : }
216 : :
217 : 21 : void ImapService::listFolders() {
218 [ + + + + ]: 21 : if (m_state != State::Authenticated && m_state != State::Selected) {
219 [ + - + - : 24 : qCWarning(lcImap) << "Cannot list folders: not authenticated";
+ - + + ]
220 : 12 : return;
221 : : }
222 [ + + ]: 9 : if (hasStatefulCommandInFlight()) {
223 [ + - ]: 2 : enqueueSerializedCommand([this]() { listFolders(); });
224 : 1 : return;
225 : : }
226 : :
227 : 8 : m_pendingFolders.clear();
228 [ + - + - : 8 : sendCommand("LIST", R"(LIST "" "*")");
+ - ]
229 : : }
230 : :
231 : 107 : void ImapService::selectFolder(const QString &folderPath) {
232 [ + + + + ]: 107 : if (m_state != State::Authenticated && m_state != State::Selected) {
233 [ + - + - : 38 : qCWarning(lcImap) << "Cannot select folder: not authenticated";
+ - + + ]
234 : 19 : return;
235 : : }
236 [ + + ]: 88 : if (hasStatefulCommandInFlight()) {
237 [ + - + - ]: 26 : enqueueSerializedCommand([this, folderPath]() { selectFolder(folderPath); });
238 : 13 : return;
239 : : }
240 : :
241 : 75 : m_pendingSelectFolder = folderPath; // Bug 34: defer until SELECT OK
242 : 75 : m_selectedMessageCount = 0;
243 : 75 : m_selectedUidValidity = 0;
244 : : // T-79.A2/M11: reset like selectAndFetchFlags/-Body — otherwise a folder
245 : : // whose OK carries no HIGHESTMODSEQ reports the previous folder's value.
246 : 75 : m_selectedHighestModseq = 0;
247 [ + - + - : 150 : sendCommand("SELECT", QString("SELECT %1").arg(quoteImapString(folderPath)));
+ - + - +
- ]
248 : : }
249 : :
250 : 15 : void ImapService::fetchHeaders(qint64 uidFrom) {
251 [ + + ]: 15 : if (m_state != State::Selected) {
252 [ + - + - : 10 : qCWarning(lcImap) << "Cannot fetch: no folder selected";
+ - + + ]
253 : 5 : return;
254 : : }
255 [ + + ]: 10 : if (hasStatefulCommandInFlight()) {
256 [ + - ]: 9 : enqueueSerializedCommand([this, uidFrom]() { fetchHeaders(uidFrom); });
257 : 5 : return;
258 : : }
259 : :
260 : 5 : m_pendingHeaders.clear();
261 [ + - + - ]: 5 : sendCommand(
262 : : "FETCH_HEADERS",
263 : 0 : QString("UID FETCH %1:* (UID FLAGS RFC822.SIZE INTERNALDATE ENVELOPE "
264 [ + - + - ]: 15 : "BODY.PEEK[HEADER.FIELDS (References X-Spam X-Spam-Status X-Spam-Flag)])").arg(uidFrom));
265 : : }
266 : :
267 : 39 : void ImapService::fetchBody(qint64 uid) {
268 : 39 : const qint64 acceptedBytes = configuredMaxMessageBytes();
269 : 39 : fetchBodyWithLimits(uid, acceptedBytes, acceptedBytes + 1);
270 : 39 : }
271 : :
272 : 3 : void ImapService::fetchBody(qint64 uid, qint64 maxBytes) {
273 [ - + ]: 3 : if (maxBytes <= 0) {
274 : 0 : fetchBody(uid);
275 : 0 : return;
276 : : }
277 : 3 : const qint64 acceptedBytes = qMin(maxBytes, MAX_LITERAL_SIZE);
278 : 3 : fetchBodyWithLimits(uid, acceptedBytes, acceptedBytes);
279 : : }
280 : :
281 : 202 : qint64 ImapService::configuredMaxMessageBytes() {
282 [ + - ]: 202 : QSettings settings;
283 : 202 : bool ok = false;
284 : : const int configured =
285 [ + - ]: 606 : settings.value(QStringLiteral("network/maxMessageSizeMiB"),
286 : : DefaultMaxMessageSizeMiB)
287 [ + - ]: 202 : .toInt(&ok);
288 : : const int sizeMiB =
289 [ + - + - ]: 202 : ok ? qBound(MinMaxMessageSizeMiB, configured, MaxMaxMessageSizeMiB)
290 : 202 : : DefaultMaxMessageSizeMiB;
291 : 202 : return static_cast<qint64>(sizeMiB) * 1024 * 1024;
292 : 202 : }
293 : :
294 : 75 : void ImapService::fetchBodyWithLimits(qint64 uid, qint64 acceptedBytes,
295 : : qint64 requestedBytes) {
296 [ + + ]: 75 : if (m_state != State::Selected) {
297 [ + - + - : 4 : qCWarning(lcImap) << "Cannot fetch body: no folder selected";
+ - + + ]
298 : 2 : return;
299 : : }
300 [ + + ]: 73 : if (hasStatefulCommandInFlight()) {
301 [ + - + - ]: 35 : enqueueSerializedCommand([this, uid, acceptedBytes, requestedBytes]() {
302 : 33 : fetchBodyWithLimits(uid, acceptedBytes, requestedBytes);
303 : 33 : });
304 : 35 : return;
305 : : }
306 : :
307 : 38 : m_activeBodyFetchLimit = acceptedBytes;
308 : 38 : m_activeBodyFetchRequestBytes = requestedBytes;
309 : 38 : m_activeBodyFetchUid = uid;
310 : 38 : m_bodyFetchRequiresSelect = false;
311 [ + - + - : 38 : sendCommand("FETCH_BODY", buildFetchBodyCommand(uid, requestedBytes));
+ - ]
312 : : }
313 : :
314 : : // T-205: Pipeline SELECT + FETCH BODY — sends both commands back-to-back
315 : : // so the body connection doesn't need to wait for SELECT OK before fetching.
316 : 24 : void ImapService::selectAndFetchBody(const QString &folderPath, qint64 uid) {
317 [ + + + + ]: 24 : if (m_state != State::Authenticated && m_state != State::Selected) {
318 [ + - + - : 2 : qCWarning(lcImap) << "Cannot selectAndFetchBody: not authenticated";
+ - + + ]
319 : 1 : return;
320 : : }
321 [ + + ]: 23 : if (hasStatefulCommandInFlight()) {
322 [ + - ]: 1 : enqueueSerializedCommand(
323 [ + - - - ]: 3 : [this, folderPath, uid]() { selectAndFetchBody(folderPath, uid); });
324 : 1 : return;
325 : : }
326 : :
327 : 22 : m_pendingSelectFolder = folderPath; // Bug 34: defer until SELECT OK
328 : 22 : m_selectedMessageCount = 0;
329 : 22 : m_selectedUidValidity = 0;
330 : 22 : m_selectedHighestModseq = 0;
331 : :
332 : 22 : const qint64 acceptedBytes = configuredMaxMessageBytes();
333 : 22 : const qint64 requestedBytes = acceptedBytes + 1;
334 : 22 : m_activeBodyFetchLimit = acceptedBytes;
335 : 22 : m_activeBodyFetchRequestBytes = requestedBytes;
336 : 22 : m_activeBodyFetchUid = uid;
337 : 22 : m_bodyFetchRequiresSelect = true;
338 : :
339 : : // Send both commands back-to-back (pipelining)
340 [ + - + - : 44 : sendCommand("SELECT", QString("SELECT %1").arg(quoteImapString(folderPath)));
+ - + - +
- ]
341 [ + - + - : 22 : sendCommand("FETCH_BODY", buildFetchBodyCommand(uid, requestedBytes));
+ - ]
342 : :
343 [ + - + - : 44 : qCInfo(lcImap) << "T-205: Pipelined SELECT + FETCH_BODY for" << folderPath
+ - + - +
+ ]
344 [ + - + - : 22 : << "UID" << uid << "limit" << acceptedBytes << "bytes";
+ - + - +
- ]
345 : : }
346 : :
347 : 36 : void ImapService::markSeen(qint64 uid) {
348 [ + - ]: 36 : storeFlag(uid, QStringLiteral("\\Seen"), true);
349 : 36 : }
350 : :
351 : : // T-200: Mark ALL messages in the currently selected folder as seen
352 : 5 : void ImapService::markAllSeen() {
353 [ + + ]: 5 : if (m_state != State::Selected) {
354 [ + - + - : 4 : qCWarning(lcImap) << "Cannot markAllSeen: no folder selected";
+ - + + ]
355 : 2 : return;
356 : : }
357 [ + + ]: 3 : if (hasStatefulCommandInFlight()) {
358 [ + - ]: 2 : enqueueSerializedCommand([this]() { markAllSeen(); });
359 : 1 : return;
360 : : }
361 [ + - + - ]: 2 : sendCommand("STORE",
362 : 4 : QStringLiteral("UID STORE 1:* +FLAGS (\\Seen)"));
363 : : }
364 : :
365 : 9 : void ImapService::markUnseen(qint64 uid) {
366 [ + - ]: 9 : storeFlag(uid, QStringLiteral("\\Seen"), false);
367 : 9 : }
368 : :
369 : 108 : void ImapService::storeFlag(qint64 uid, const QString &flag, bool add) {
370 [ + + ]: 108 : if (m_state != State::Selected) {
371 [ + - + - : 116 : qCWarning(lcImap) << "Cannot store flag: no folder selected, state:"
+ - + + ]
372 [ + - + - : 58 : << static_cast<int>(m_state) << "UID:" << uid
+ - ]
373 [ + - + - : 58 : << "flag:" << flag << "add:" << add;
+ - + - ]
374 : 68 : return;
375 : : }
376 [ + - + + ]: 50 : if (hasStatefulCommandInFlight()) {
377 [ + - + - : 9 : enqueueSerializedCommand([this, uid, flag, add]() {
- - ]
378 : 3 : storeFlag(uid, flag, add);
379 : 3 : });
380 : 9 : return;
381 : : }
382 : :
383 : : // Reject malformed tokens instead of rewriting them into a different flag.
384 : : // In particular, this blocks a server-provided keyword from escaping the
385 : : // parenthesized STORE flag list.
386 [ + - + + ]: 41 : if (!isValidImapFlag(flag)) {
387 [ + - + - : 2 : qCWarning(lcImap) << "Refusing malformed IMAP flag token";
+ - + + ]
388 : 1 : return;
389 : : }
390 : :
391 [ + + + + : 80 : QString op = add ? QStringLiteral("+FLAGS") : QStringLiteral("-FLAGS");
+ + ]
392 [ + - + - ]: 40 : sendCommand("STORE",
393 [ + - + - : 200 : QString("UID STORE %1 %2 (%3)").arg(uid).arg(op).arg(flag));
+ - + - ]
394 : 40 : }
395 : :
396 : 3 : void ImapService::moveMessage(qint64 uid, const QString &targetFolder) {
397 [ + - + - ]: 3 : moveMessages({uid}, targetFolder);
398 : 3 : }
399 : :
400 : 23 : void ImapService::moveMessages(const QList<qint64> &uids,
401 : : const QString &targetFolder) {
402 [ + + ]: 23 : if (m_state != State::Selected) {
403 [ + - + - : 28 : qCWarning(lcImap) << "Cannot move messages: no folder selected";
+ - + + ]
404 [ + - ]: 14 : emit moveError(QStringLiteral("No folder selected"));
405 : 17 : return;
406 : : }
407 [ + - + + ]: 9 : if (hasStatefulCommandInFlight()) {
408 [ + - + - : 3 : enqueueSerializedCommand([this, uids, targetFolder]() {
- - ]
409 : 1 : moveMessages(uids, targetFolder);
410 : 1 : });
411 : 3 : return;
412 : : }
413 : :
414 : 6 : m_pendingMoveUids = uids;
415 : 6 : m_pendingMoveTarget = targetFolder;
416 : :
417 : : // Build UID set string: "100,200,300"
418 : 6 : QStringList uidStrs;
419 [ + + ]: 16 : for (qint64 u : uids)
420 [ + - + - ]: 10 : uidStrs.append(QString::number(u));
421 [ + - ]: 6 : auto uidSet = uidStrs.join(',');
422 : :
423 [ + + ]: 12 : if (m_capabilities.contains(QStringLiteral("MOVE"), Qt::CaseInsensitive)) {
424 [ + - + - ]: 5 : sendCommand("MOVE",
425 [ + - + - : 10 : QString("UID MOVE %1 %2").arg(uidSet, quoteImapString(targetFolder)));
+ - ]
426 : : } else {
427 [ + - + - ]: 1 : sendCommand("COPY",
428 [ + - + - : 2 : QString("UID COPY %1 %2").arg(uidSet, quoteImapString(targetFolder)));
+ - ]
429 : : }
430 : 6 : }
431 : :
432 : 3 : void ImapService::copyMessage(qint64 uid, const QString &targetFolder) {
433 [ - + ]: 3 : if (m_state != State::Selected) {
434 [ # # # # : 0 : qCWarning(lcImap) << "Cannot copy message: no folder selected";
# # # # ]
435 : 0 : return;
436 : : }
437 [ + + ]: 3 : if (hasStatefulCommandInFlight()) {
438 [ + - + - ]: 2 : enqueueSerializedCommand([this, uid, targetFolder]() {
439 : 1 : copyMessage(uid, targetFolder);
440 : 1 : });
441 : 2 : return;
442 : : }
443 : :
444 [ + - ]: 1 : m_pendingMoveUids = {uid};
445 : 1 : m_pendingMoveTarget = targetFolder;
446 [ + - + - ]: 1 : sendCommand("COPY_ONLY",
447 [ + - + - : 4 : QString("UID COPY %1 %2").arg(uid).arg(quoteImapString(targetFolder)));
+ - + - ]
448 : : }
449 : :
450 : : // T-176: IMAP APPEND – upload a message to a folder
451 : 15 : void ImapService::appendMessage(const QString &folder,
452 : : const QByteArray &rfcMessage,
453 : : const QString &flags) {
454 [ + + + + ]: 15 : if (m_state != State::Authenticated && m_state != State::Selected) {
455 [ + - + - : 8 : qCWarning(lcImap) << "Cannot APPEND: not authenticated";
+ - + + ]
456 [ + - ]: 4 : emit appendError(QStringLiteral("Not authenticated"));
457 : 6 : return;
458 : : }
459 [ + - + + ]: 11 : if (hasStatefulCommandInFlight()) {
460 [ + - + - : 1 : enqueueSerializedCommand([this, folder, rfcMessage, flags]() {
- - - - ]
461 : 1 : appendMessage(folder, rfcMessage, flags);
462 : 1 : });
463 : 1 : return;
464 : : }
465 : :
466 : : // Build: APPEND "folder" (\Flags) {size}
467 [ + - + - ]: 20 : QString cmd = QStringLiteral("APPEND %1").arg(quoteImapString(folder));
468 [ + + ]: 10 : if (!flags.isEmpty()) {
469 : 7 : const QStringList flagTokens = flags.split(QLatin1Char(' '),
470 [ + - ]: 7 : Qt::SkipEmptyParts);
471 [ + + ]: 17 : for (const QString &flag : flagTokens) {
472 [ + - + + ]: 11 : if (!isValidImapFlag(flag)) {
473 [ + - + - : 2 : qCWarning(lcImap) << "Refusing malformed APPEND flag token";
+ - + + ]
474 [ + - ]: 1 : emit appendError(QStringLiteral("Invalid IMAP flag token"));
475 : 1 : return;
476 : : }
477 : : }
478 [ + - + - ]: 12 : if (flagTokens.isEmpty() || flags.contains(QLatin1Char('\t')) ||
479 [ + - + - : 18 : flags.contains(QLatin1Char('\r')) || flags.contains(QLatin1Char('\n')) ||
+ - + - +
- ]
480 [ + - - + : 12 : flags.contains(QChar(0))) {
- + ]
481 [ # # ]: 0 : emit appendError(QStringLiteral("Invalid IMAP flag token"));
482 : 0 : return;
483 : : }
484 [ + - + - : 12 : cmd += QStringLiteral(" (%1)").arg(flagTokens.join(QLatin1Char(' ')));
+ - ]
485 [ + + ]: 7 : }
486 [ + - + - ]: 18 : cmd += QStringLiteral(" {%1}").arg(rfcMessage.size());
487 : :
488 : 9 : m_pendingAppendData = rfcMessage;
489 : 9 : m_pendingAppendFolder = folder;
490 [ + - + - ]: 9 : sendCommand("APPEND", cmd);
491 [ + + ]: 10 : }
492 : :
493 : : // T-176: EXPUNGE – permanently remove \Deleted messages
494 : 6 : void ImapService::expunge() {
495 [ + + ]: 6 : if (m_state != State::Selected) {
496 [ + - + - : 4 : qCWarning(lcImap) << "Cannot EXPUNGE: no folder selected";
+ - + + ]
497 : 2 : return;
498 : : }
499 [ + + ]: 4 : if (hasStatefulCommandInFlight()) {
500 [ + - ]: 3 : enqueueSerializedCommand([this]() { expunge(); });
501 : 2 : return;
502 : : }
503 [ + - + - : 2 : sendCommand("EXPUNGE", "EXPUNGE");
+ - ]
504 : : }
505 : :
506 : : // T-281: Create a new folder on the server (RFC 3501 §6.3.3)
507 : 12 : void ImapService::createFolder(const QString &folderPath) {
508 [ + + + + ]: 12 : if (m_state != State::Authenticated && m_state != State::Selected) {
509 [ + - ]: 6 : emit folderOperationError(QStringLiteral("CREATE"),
510 : 12 : QStringLiteral("Not authenticated"));
511 : 6 : return;
512 : : }
513 [ + + ]: 6 : if (hasStatefulCommandInFlight()) {
514 [ + - + - ]: 2 : enqueueSerializedCommand([this, folderPath]() { createFolder(folderPath); });
515 : 1 : return;
516 : : }
517 : 5 : m_pendingFolderOp = folderPath;
518 [ + - + - : 10 : sendCommand("CREATE", QString("CREATE %1").arg(quoteImapString(folderPath)));
+ - + - +
- ]
519 : : }
520 : :
521 : : // T-281: Delete a folder on the server (RFC 3501 §6.3.4)
522 : 9 : void ImapService::deleteFolder(const QString &folderPath) {
523 [ + + + + ]: 9 : if (m_state != State::Authenticated && m_state != State::Selected) {
524 [ + - ]: 5 : emit folderOperationError(QStringLiteral("DELETE"),
525 : 10 : QStringLiteral("Not authenticated"));
526 : 5 : return;
527 : : }
528 [ + + ]: 4 : if (hasStatefulCommandInFlight()) {
529 [ + - + - ]: 3 : enqueueSerializedCommand([this, folderPath]() { deleteFolder(folderPath); });
530 : 2 : return;
531 : : }
532 : 2 : m_pendingFolderOp = folderPath;
533 [ + - + - : 4 : sendCommand("DELETE", QString("DELETE %1").arg(quoteImapString(folderPath)));
+ - + - +
- ]
534 : : }
535 : :
536 : : // T-281: Rename (or move) a folder on the server (RFC 3501 §6.3.5)
537 : 7 : void ImapService::renameFolder(const QString &oldPath, const QString &newPath) {
538 [ + - + + ]: 7 : if (m_state != State::Authenticated && m_state != State::Selected) {
539 [ + - ]: 4 : emit folderOperationError(QStringLiteral("RENAME"),
540 : 8 : QStringLiteral("Not authenticated"));
541 : 4 : return;
542 : : }
543 [ + + ]: 3 : if (hasStatefulCommandInFlight()) {
544 [ + - + - : 1 : enqueueSerializedCommand([this, oldPath, newPath]() {
- - ]
545 : 1 : renameFolder(oldPath, newPath);
546 : 1 : });
547 : 1 : return;
548 : : }
549 : 2 : m_pendingFolderOp = oldPath;
550 : 2 : m_pendingFolderNewPath = newPath;
551 [ + - + - : 4 : sendCommand("RENAME", QString("RENAME %1 %2")
+ - ]
552 [ + - + - : 4 : .arg(quoteImapString(oldPath), quoteImapString(newPath)));
+ - ]
553 : : }
554 : :
555 : 21 : void ImapService::searchAllUids(qint64 fromUid) {
556 [ + + ]: 21 : if (m_state != State::Selected) {
557 [ + - + - : 2 : qCWarning(lcImap) << "Cannot search: no folder selected";
+ - + + ]
558 : 1 : return;
559 : : }
560 [ + + ]: 20 : if (hasStatefulCommandInFlight()) {
561 [ + - ]: 3 : enqueueSerializedCommand([this, fromUid]() { searchAllUids(fromUid); });
562 : 2 : return;
563 : : }
564 : :
565 : 18 : m_pendingSearchUids.clear();
566 [ + + ]: 18 : if (fromUid > 1) {
567 : : // Delta search: only UIDs >= fromUid
568 [ + - + - ]: 10 : sendCommand("SEARCH",
569 [ + - + - ]: 30 : QString("UID SEARCH UID %1:*").arg(fromUid));
570 : : } else {
571 [ + - + - : 8 : sendCommand("SEARCH", "UID SEARCH ALL");
+ - ]
572 : : }
573 : : }
574 : :
575 : : // T-187: IMAP text-based SEARCH
576 : 10 : void ImapService::searchText(const QString &query, const QString &criteria) {
577 [ + + ]: 10 : if (m_state != State::Selected) {
578 [ + - + - : 2 : qCWarning(lcImap) << "Cannot search: no folder selected";
+ - + + ]
579 : 1 : return;
580 : : }
581 [ + + ]: 9 : if (hasStatefulCommandInFlight()) {
582 [ + - + - : 4 : enqueueSerializedCommand([this, query, criteria]() {
- - ]
583 : 1 : searchText(query, criteria);
584 : 1 : });
585 : 4 : return;
586 : : }
587 : :
588 : 5 : m_pendingSearchUids.clear();
589 : : // RFC 3501: SEARCH <criteria> <quoted-string>
590 [ + - + - ]: 5 : sendCommand("SEARCH",
591 [ + - + - : 10 : QString("UID SEARCH %1 %2").arg(criteria, quoteImapString(query)));
+ - ]
592 : : }
593 : :
594 : : // Sprint 59 (S2): composite SEARCH for the visual search facets.
595 : 92 : bool ImapService::SearchCriteria::isEmpty() const {
596 [ + + + - : 109 : return text.isEmpty() && from.isEmpty() && to.isEmpty() && subject.isEmpty() &&
+ + ]
597 [ + + + - : 5 : !since.isValid() && !before.isValid() && unread == SearchTri::Any &&
+ + ]
598 [ + + + - : 112 : flagged == SearchTri::Any && answered == SearchTri::Any &&
+ - + - ]
599 : 95 : keywords.isEmpty();
600 : : }
601 : :
602 : 67 : QString ImapService::buildSearchCommand(const SearchCriteria &criteria) {
603 [ + - + + ]: 67 : if (criteria.isEmpty())
604 : 1 : return QString();
605 : :
606 : : // RFC 3501 date format is "dd-MMM-yyyy" with English month names; force the C
607 : : // locale so it never gets localized (e.g. "Feb" not "Févr").
608 : 2 : auto imapDate = [](const QDate &d) {
609 [ + - + - ]: 4 : return QLocale::c().toString(d, QStringLiteral("dd-MMM-yyyy"));
610 : : };
611 : :
612 : 66 : QStringList parts;
613 [ + + ]: 66 : if (!criteria.text.isEmpty())
614 [ + - + - : 54 : parts << QStringLiteral("TEXT") << quoteImapString(criteria.text);
+ - ]
615 [ + + ]: 66 : if (!criteria.from.isEmpty())
616 [ + - + - : 5 : parts << QStringLiteral("FROM") << quoteImapString(criteria.from);
+ - ]
617 [ - + ]: 66 : if (!criteria.to.isEmpty())
618 [ # # # # : 0 : parts << QStringLiteral("TO") << quoteImapString(criteria.to);
# # ]
619 [ + + ]: 66 : if (!criteria.subject.isEmpty())
620 [ + - + - : 6 : parts << QStringLiteral("SUBJECT") << quoteImapString(criteria.subject);
+ - ]
621 [ + - + + ]: 66 : if (criteria.since.isValid())
622 [ + - + - : 1 : parts << QStringLiteral("SINCE") << imapDate(criteria.since);
+ - ]
623 [ + - + + ]: 66 : if (criteria.before.isValid())
624 [ + - + - : 1 : parts << QStringLiteral("BEFORE") << imapDate(criteria.before);
+ - ]
625 [ + + ]: 66 : if (criteria.unread == SearchTri::Yes)
626 [ + - ]: 1 : parts << QStringLiteral("UNSEEN");
627 [ + + ]: 65 : else if (criteria.unread == SearchTri::No)
628 [ + - ]: 1 : parts << QStringLiteral("SEEN");
629 [ - + ]: 66 : if (criteria.flagged == SearchTri::Yes)
630 [ # # ]: 0 : parts << QStringLiteral("FLAGGED");
631 [ + + ]: 66 : else if (criteria.flagged == SearchTri::No)
632 [ + - ]: 1 : parts << QStringLiteral("UNFLAGGED");
633 [ + + ]: 66 : if (criteria.answered == SearchTri::Yes)
634 [ + - ]: 1 : parts << QStringLiteral("ANSWERED");
635 [ - + ]: 65 : else if (criteria.answered == SearchTri::No)
636 [ # # ]: 0 : parts << QStringLiteral("UNANSWERED");
637 [ + + ]: 67 : for (const QString &kw : criteria.keywords) {
638 [ + - + - ]: 1 : if (!kw.trimmed().isEmpty())
639 [ + - + - : 1 : parts << QStringLiteral("KEYWORD") << quoteImapString(kw);
+ - ]
640 : : }
641 : :
642 [ - + ]: 66 : if (parts.isEmpty())
643 : 0 : return QString();
644 [ + - + - ]: 132 : return QStringLiteral("UID SEARCH ") + parts.join(QLatin1Char(' '));
645 : 66 : }
646 : :
647 : 63 : void ImapService::search(const SearchCriteria &criteria) {
648 [ - + ]: 63 : if (m_state != State::Selected) {
649 [ # # # # : 0 : qCWarning(lcImap) << "Cannot search: no folder selected";
# # # # ]
650 : 1 : return;
651 : : }
652 [ + - ]: 63 : const QString command = buildSearchCommand(criteria);
653 [ - + ]: 63 : if (command.isEmpty()) {
654 [ # # # # : 0 : qCInfo(lcImap) << "search: no server-mappable criteria — skipping";
# # # # ]
655 : 0 : return;
656 : : }
657 [ + - + + ]: 63 : if (hasStatefulCommandInFlight()) {
658 [ + - + - ]: 2 : enqueueSerializedCommand([this, criteria]() { search(criteria); });
659 : 1 : return;
660 : : }
661 : :
662 [ + - ]: 62 : m_pendingSearchUids.clear();
663 [ + - + - ]: 62 : sendCommand("SEARCH", command);
664 [ + + ]: 63 : }
665 : :
666 : : // T-211: Search by Message-ID header (for undo-move)
667 : 9 : void ImapService::searchByMessageId(const QString &messageId) {
668 [ - + ]: 9 : if (m_state != State::Selected) {
669 [ # # # # : 0 : qCWarning(lcImap) << "Cannot search: no folder selected";
# # # # ]
670 : 0 : return;
671 : : }
672 [ + + ]: 9 : if (hasStatefulCommandInFlight()) {
673 [ + - + - ]: 4 : enqueueSerializedCommand(
674 : 9 : [this, messageId]() { searchByMessageId(messageId); });
675 : 4 : return;
676 : : }
677 : :
678 : 5 : m_pendingSearchUids.clear();
679 : : // RFC 3501: UID SEARCH HEADER Message-ID <message-id>
680 [ + - + - ]: 5 : sendCommand("SEARCH",
681 : 10 : QStringLiteral("UID SEARCH HEADER Message-ID %1")
682 [ + - + - ]: 15 : .arg(quoteImapString(messageId)));
683 : : }
684 : :
685 : 13 : void ImapService::fetchHeadersByUids(const QList<qint64> &uids) {
686 [ + + ]: 13 : if (m_state != State::Selected) {
687 [ + - + - : 2 : qCWarning(lcImap) << "Cannot fetch: no folder selected";
+ - + + ]
688 : 3 : return;
689 : : }
690 [ - + ]: 12 : if (uids.isEmpty())
691 : 0 : return;
692 [ + - + + ]: 12 : if (hasStatefulCommandInFlight()) {
693 [ + - + - ]: 3 : enqueueSerializedCommand([this, uids]() { fetchHeadersByUids(uids); });
694 : 2 : return;
695 : : }
696 : :
697 [ + - ]: 10 : m_pendingHeaders.clear();
698 : :
699 : : // Build comma-separated UID set, e.g. "100,99,98,97"
700 : 10 : QStringList uidStrings;
701 [ + - ]: 10 : uidStrings.reserve(uids.size());
702 [ + + ]: 43 : for (qint64 uid : uids) {
703 [ + - + - ]: 33 : uidStrings.append(QString::number(uid));
704 : : }
705 [ + - ]: 10 : QString uidSet = uidStrings.join(',');
706 : :
707 [ + - + - ]: 10 : sendCommand("FETCH_HEADERS",
708 : 0 : QString("UID FETCH %1 (UID FLAGS RFC822.SIZE INTERNALDATE ENVELOPE "
709 : : "BODY.PEEK[HEADER.FIELDS (References X-Spam X-Spam-Status X-Spam-Flag)])"
710 [ + - + - ]: 30 : ).arg(uidSet));
711 : 10 : }
712 : :
713 : 98 : void ImapService::startIdle() {
714 [ + + ]: 98 : if (m_state != State::Selected) {
715 [ + - + - : 14 : qCWarning(lcImap) << "Cannot start IDLE: not in Selected state";
+ - + + ]
716 : 15 : return;
717 : : }
718 [ - + ]: 91 : if (m_isIdling) {
719 [ # # # # : 0 : qCWarning(lcImap) << "Already idling";
# # # # ]
720 : 0 : return;
721 : : }
722 [ + - + + ]: 91 : if (!hasIdleCapability()) {
723 [ + - + - : 16 : qCWarning(lcImap) << "Server does not support IDLE";
+ - + + ]
724 : 8 : return;
725 : : }
726 : :
727 : 83 : m_isIdling = true;
728 [ + - ]: 83 : m_idleTag = nextTag();
729 [ + - + - ]: 83 : m_pendingCommands.insert(m_idleTag, "IDLE");
730 [ + - ]: 83 : auto cmd = m_idleTag + " IDLE\r\n";
731 [ + - + - : 166 : qCInfo(lcImap) << ">>>" << cmd.trimmed();
+ - + - +
- + + ]
732 [ + - + - ]: 83 : m_socket->write(cmd.toUtf8());
733 [ + - ]: 83 : setState(State::Idling);
734 [ + - ]: 83 : m_idleRenewTimer->start(IDLE_RENEW_MS);
735 : 83 : }
736 : :
737 : 92 : void ImapService::stopIdle() {
738 [ - + ]: 92 : if (!m_isIdling) {
739 : 0 : return;
740 : : }
741 [ + + ]: 92 : if (m_idleRenewWatchdog->isActive()) {
742 [ + - + - : 24 : qCDebug(lcImap) << "IDLE DONE/OK already pending — not sending DONE again";
+ - + + ]
743 : 12 : return;
744 : : }
745 : :
746 [ + - + - : 160 : qCInfo(lcImap) << ">>> DONE";
+ - + + ]
747 : 80 : m_socket->write("DONE\r\n");
748 : 80 : m_idleRenewTimer->stop();
749 : : // T-720: Arm a watchdog for the IDLE tagged OK that proves the
750 : : // DONE actually reached the server and was ACKed. Covers renew
751 : : // (onIdleRenew), executeAfterIdle and liveness-probe paths. The
752 : : // handleTagged() IDLE branch stops this timer on OK arrival.
753 : 80 : m_idleRenewWatchdog->start(PROBE_WATCHDOG_MS);
754 : : // m_isIdling will be cleared when we receive the tagged OK for IDLE
755 : : }
756 : :
757 : 1 : void ImapService::onIdleRenew() {
758 [ + - ]: 1 : if (!m_isIdling) {
759 : 1 : return;
760 : : }
761 [ # # # # : 0 : qCInfo(lcImap) << "IDLE renew: sending DONE + re-IDLE";
# # # # ]
762 : : // Stop current IDLE, will re-start after receiving tagged OK
763 : 0 : stopIdle();
764 : : // The re-start happens in handleTagged when IDLE OK is received
765 : : }
766 : :
767 : 132 : bool ImapService::hasIdleCapability() const {
768 [ + - ]: 132 : return m_capabilities.contains("IDLE", Qt::CaseInsensitive);
769 : : }
770 : :
771 : : // T-208: Check for CONDSTORE capability (RFC 4551)
772 : 2 : bool ImapService::hasCondstoreCapability() const {
773 [ + - ]: 2 : return m_capabilities.contains("CONDSTORE", Qt::CaseInsensitive);
774 : : }
775 : :
776 : : // T-320: Centralized push restart — prefers NOTIFY over IDLE.
777 : : // Called from every handler that previously did "if (m_autoIdle) startIdle()".
778 : 133 : void ImapService::restartPush() {
779 [ + + ]: 133 : if (!m_autoIdle) return;
780 [ + + ]: 71 : if (!m_serializedCommands.isEmpty()) {
781 : 14 : runNextSerializedCommand();
782 : 14 : return;
783 : : }
784 [ - + - - : 57 : if (hasNotifyCapability() && !m_notifyFolders.isEmpty()) {
- + ]
785 : 0 : startNotify(m_notifyFolders);
786 : : } else {
787 : 57 : startIdle();
788 : : }
789 : : }
790 : :
791 : : // ═══════════════════════════════════════════════════════
792 : : // T-320: IMAP NOTIFY (RFC 5465)
793 : : // ═══════════════════════════════════════════════════════
794 : :
795 : 107 : bool ImapService::hasNotifyCapability() const {
796 [ + - ]: 107 : return m_capabilities.contains("NOTIFY", Qt::CaseInsensitive);
797 : : }
798 : :
799 : 4 : void ImapService::startNotify(const QStringList &subscribedFolders) {
800 [ + + ]: 4 : if (m_state != State::Selected) {
801 [ + - + - : 2 : qCWarning(lcImap) << "Cannot start NOTIFY: not in Selected state";
+ - + + ]
802 : 1 : return;
803 : : }
804 [ + + ]: 3 : if (m_isNotifying) {
805 [ + - + - : 2 : qCWarning(lcImap) << "Already notifying";
+ - + + ]
806 : 1 : return;
807 : : }
808 [ - + ]: 2 : if (!hasNotifyCapability()) {
809 [ # # # # : 0 : qCWarning(lcImap) << "Server does not support NOTIFY";
# # # # ]
810 : 0 : return;
811 : : }
812 : :
813 : 2 : m_isNotifying = true;
814 : 2 : m_notifyFolders = subscribedFolders;
815 : : // T-720: Use sendTaggedCommand() so NOTIFY SET goes through the regular
816 : : // command timeout machinery (a server that never ACKs the SET can no
817 : : // longer stall push setup — it gets failed after COMMAND_TIMEOUT_MS).
818 [ + - ]: 4 : m_notifyTag = sendTaggedCommand(
819 : 4 : QStringLiteral("NOTIFY"),
820 : 4 : QStringLiteral("NOTIFY SET STATUS"
821 : : " (selected (MessageNew MessageExpunge FlagChange))"
822 : 2 : " (subscribed (MessageNew MessageExpunge FlagChange))"));
823 : 2 : setState(State::Selected); // NOTIFY doesn't change state like IDLE does
824 : : }
825 : :
826 : 3 : void ImapService::stopNotify() {
827 [ + + ]: 3 : if (!m_isNotifying) {
828 : 1 : return;
829 : : }
830 : :
831 : : // T-720: Use sendTaggedCommand() so NOTIFY NONE has command-timeout
832 : : // coverage symmetric to NOTIFY SET.
833 [ + - ]: 4 : m_notifyTag = sendTaggedCommand(QStringLiteral("NOTIFY_NONE"),
834 : 6 : QStringLiteral("NOTIFY NONE"));
835 : : // m_isNotifying cleared when NOTIFY_NONE OK arrives in handleTagged
836 : : }
837 : :
838 : 1 : void ImapService::executeAfterNotify(std::function<void()> command) {
839 : : // T-320: NOTIFY is NOT a blocking state like IDLE — commands can be
840 : : // sent freely while NOTIFY is active (RFC 5465). Just execute directly.
841 : 1 : command();
842 : 1 : }
843 : :
844 : 200 : void ImapService::executeAfterIdle(std::function<void()> command) {
845 [ - + ]: 200 : if (m_isNotifying) {
846 : : // T-320: NOTIFY doesn't block — execute immediately, no stop needed
847 : 0 : command();
848 [ + + ]: 200 : } else if (m_isIdling) {
849 : : // Queue command and stop IDLE – command runs on IDLE OK
850 : 85 : m_deferredCommands.enqueue(std::move(command));
851 : 85 : stopIdle();
852 : : } else {
853 : : // Not idling/notifying – execute immediately
854 : 115 : command();
855 : : }
856 : 200 : }
857 : :
858 : 2 : void ImapService::clearDeferredCommands() {
859 [ + + ]: 2 : if (!m_deferredCommands.isEmpty()) {
860 [ + - + - : 2 : qCInfo(lcImap) << "Clearing" << m_deferredCommands.size()
+ - + - +
+ ]
861 [ + - ]: 1 : << "deferred commands (folder switch)";
862 : 1 : m_deferredCommands.clear();
863 : : }
864 : 2 : }
865 : :
866 : 33 : void ImapService::fetchFlags() {
867 [ + + ]: 33 : if (m_state != State::Selected) {
868 [ + - + - : 6 : qCWarning(lcImap) << "Cannot fetch flags: no folder selected";
+ - + + ]
869 : 3 : return;
870 : : }
871 [ + + ]: 30 : if (hasStatefulCommandInFlight()) {
872 [ + - ]: 3 : enqueueSerializedCommand([this]() { fetchFlags(); });
873 : 2 : return;
874 : : }
875 : :
876 : 28 : m_pendingFlags.clear();
877 [ + - + - : 28 : sendCommand("FETCH_FLAGS", "UID FETCH 1:* (UID FLAGS)");
+ - ]
878 : : }
879 : :
880 : : // T-207: Pipeline SELECT + FETCH FLAGS — saves one round-trip.
881 : : // Both commands are sent immediately; the server processes them in-order.
882 : : // The FETCH FLAGS will execute in the context of the newly selected folder.
883 : 33 : void ImapService::selectAndFetchFlags(const QString &folderPath) {
884 [ + + + + ]: 33 : if (m_state != State::Authenticated && m_state != State::Selected) {
885 [ + - + - : 4 : qCWarning(lcImap) << "Cannot selectAndFetchFlags: not authenticated";
+ - + + ]
886 : 2 : return;
887 : : }
888 [ + + ]: 31 : if (hasStatefulCommandInFlight()) {
889 [ + - + - ]: 4 : enqueueSerializedCommand(
890 : 10 : [this, folderPath]() { selectAndFetchFlags(folderPath); });
891 : 4 : return;
892 : : }
893 : :
894 : 27 : m_pendingSelectFolder = folderPath; // Bug 34: defer until SELECT OK
895 : 27 : m_selectedMessageCount = 0;
896 : 27 : m_selectedUidValidity = 0;
897 : 27 : m_selectedHighestModseq = 0; // T-208: reset, will be set by untagged OK
898 : 27 : m_pendingFlags.clear();
899 : :
900 : : // Send both commands back-to-back (pipelining)
901 [ + - + - : 54 : sendCommand("SELECT", QString("SELECT %1").arg(quoteImapString(folderPath)));
+ - + - +
- ]
902 [ + - + - : 27 : sendCommand("FETCH_FLAGS", "UID FETCH 1:* (UID FLAGS)");
+ - ]
903 : :
904 [ + - + - : 54 : qCInfo(lcImap) << "T-207: Pipelined SELECT + FETCH_FLAGS for" << folderPath;
+ - + - +
+ ]
905 : : }
906 : :
907 : : // T-208: Incremental flag sync using CONDSTORE (RFC 4551)
908 : 2 : void ImapService::fetchFlagsChanged(quint64 modseq) {
909 [ - + ]: 2 : if (m_state != State::Selected) {
910 [ # # # # : 0 : qCWarning(lcImap) << "Cannot fetchFlagsChanged: no folder selected";
# # # # ]
911 : 0 : return;
912 : : }
913 [ + + ]: 2 : if (hasStatefulCommandInFlight()) {
914 [ + - ]: 1 : enqueueSerializedCommand([this, modseq]() { fetchFlagsChanged(modseq); });
915 : 1 : return;
916 : : }
917 : :
918 : 1 : m_pendingFlags.clear();
919 [ + - + - ]: 1 : sendCommand("FETCH_FLAGS",
920 [ + - + - ]: 3 : QString("UID FETCH 1:* (UID FLAGS) (CHANGEDSINCE %1)").arg(modseq));
921 [ + - + - : 2 : qCInfo(lcImap) << "T-208: CONDSTORE flag sync, CHANGEDSINCE" << modseq;
+ - + - +
+ ]
922 : : }
923 : :
924 : 3 : void ImapService::fetchUidForSeqNo(int seqNo) {
925 [ + + ]: 3 : if (m_state != State::Selected) {
926 [ + - + - : 2 : qCWarning(lcImap) << "Cannot fetch UID for seqNo: no folder selected";
+ - + + ]
927 : 1 : return;
928 : : }
929 [ + + ]: 2 : if (hasStatefulCommandInFlight()) {
930 [ + - ]: 2 : enqueueSerializedCommand([this, seqNo]() { fetchUidForSeqNo(seqNo); });
931 : 1 : return;
932 : : }
933 : : // T-065 fix: Use DISTINCT command type so the single-UID result
934 : : // is NOT treated as a full flag sync (which would purge all other mails).
935 : 1 : m_pendingFlags.clear();
936 [ + - + - ]: 1 : sendCommand("FETCH_SEQNO",
937 [ + - + - ]: 3 : QString("FETCH %1 (UID FLAGS)").arg(seqNo));
938 : : }
939 : :
940 : 21 : void ImapService::statusFolder(const QString &folderPath) {
941 [ + + + + ]: 21 : if (m_state != State::Authenticated && m_state != State::Selected) {
942 [ + - + - : 2 : qCWarning(lcImap) << "Cannot status: not authenticated or selected";
+ - + + ]
943 : 1 : return;
944 : : }
945 [ + + ]: 20 : if (hasStatefulCommandInFlight()) {
946 [ + - + - ]: 16 : enqueueSerializedCommand([this, folderPath]() { statusFolder(folderPath); });
947 : 8 : return;
948 : : }
949 : :
950 : 12 : m_pendingStatusFolder = folderPath;
951 [ + - + - : 24 : sendCommand("STATUS", QString("STATUS %1 (MESSAGES UNSEEN RECENT)")
+ - ]
952 [ + - + - ]: 24 : .arg(quoteImapString(folderPath)));
953 : : }
954 : :
955 : : // T-540: NOOP command for keep-alive (body connection)
956 : 4 : void ImapService::sendNoop() {
957 [ + + - + ]: 4 : if (m_state != State::Authenticated && m_state != State::Selected) {
958 : 0 : return; // silently skip if not connected
959 : : }
960 [ + + ]: 4 : if (hasStatefulCommandInFlight()) {
961 [ + - ]: 4 : enqueueSerializedCommand([this]() { sendNoop(); });
962 : 3 : return;
963 : : }
964 [ + - + - : 1 : sendCommand("NOOP", "NOOP");
+ - ]
965 : : }
966 : :
967 : : // ═══════════════════════════════════════════════════════
968 : : // T-720: Liveness probe + reconnect API (Sprint 72)
969 : : // ═══════════════════════════════════════════════════════
970 : :
971 : 42 : void ImapService::sendProbeNoop() {
972 : : // Caller ensures state is Authenticated/Selected and no stateful command
973 : : // is in flight. Tag is remembered so handleTagged() can clear it on the
974 : : // matching tagged response.
975 [ + - + - : 42 : m_probeTag = sendTaggedCommand("NOOP", "NOOP");
+ - ]
976 : 42 : m_livenessProbeWatchdog->start(PROBE_WATCHDOG_MS);
977 [ + - + - : 84 : qCInfo(lcImap) << "Liveness probe sent (NOOP" << m_probeTag
+ - + - +
+ ]
978 [ + - + - ]: 42 : << ") reason:" << m_lastProbeReason;
979 : 42 : }
980 : :
981 : 56 : void ImapService::requestLivenessProbe(const QString &reason) {
982 : : // Idempotent while a probe is already running.
983 [ + + ]: 56 : if (!m_probeTag.isEmpty()) {
984 [ + - + - : 6 : qCDebug(lcImap) << "Liveness probe already in flight — ignoring"
+ - + + ]
985 [ + - ]: 3 : << reason;
986 : 3 : return;
987 : : }
988 : 53 : m_lastProbeReason = reason;
989 : :
990 [ + + + - ]: 53 : switch (m_state) {
991 : 42 : case State::Authenticated:
992 : : case State::Selected: {
993 : : // NOOP is the canonical probe. If a stateful command is in flight,
994 : : // the live round-trip already proves liveness — reset the probe state
995 : : // and rely on the command timeout to catch a stuck command.
996 [ - + ]: 42 : if (hasStatefulCommandInFlight()) {
997 [ # # # # : 0 : qCDebug(lcImap) << "Liveness probe skipped — stateful command"
# # # # ]
998 [ # # ]: 0 : << "already in flight (command timeout covers it)";
999 : 0 : return;
1000 : : }
1001 : 42 : sendProbeNoop();
1002 : 42 : return;
1003 : : }
1004 : 7 : case State::Idling: {
1005 : : // NOOP is illegal during IDLE (RFC 2177). DONE → tagged OK is the
1006 : : // round-trip that proves liveness; restartPush() re-arms push after.
1007 [ + - + - : 14 : qCInfo(lcImap) << "Liveness probe via IDLE DONE/OK — reason:" << reason;
+ - + - +
+ ]
1008 : 7 : stopIdle();
1009 : 7 : return;
1010 : : }
1011 : 4 : case State::Connecting:
1012 : : case State::Connected:
1013 : : case State::Greeting:
1014 : : case State::Capability:
1015 : : case State::StartingTLS:
1016 : : case State::Authenticating:
1017 : : case State::Disconnected:
1018 : : case State::Error:
1019 : : // These states are either making progress (handled by the connect
1020 : : // timeout / command timeout) or already terminal. The monitor's
1021 : : // reconnect arm will pick them up.
1022 : 4 : return;
1023 : : }
1024 : : }
1025 : :
1026 : 3 : void ImapService::abortForReconnect(const QString &reason) {
1027 [ + - + - : 6 : qCWarning(lcImap) << "Forced reconnect requested:" << reason;
+ - + - +
+ ]
1028 : : // failConnection() clears pending state, aborts the socket, emits
1029 : : // errorOccurred and transitions to State::Error — the monitor's
1030 : : // scheduleReconnect() arm fires from there.
1031 [ + - + - ]: 6 : failConnection(QStringLiteral("Forced reconnect: %1").arg(reason));
1032 : 3 : }
1033 : :
1034 : 1 : void ImapService::onLivenessProbeTimeout() {
1035 [ + - ]: 1 : failConnection(
1036 [ + - ]: 3 : QStringLiteral("IMAP liveness probe timeout: %1").arg(m_lastProbeReason));
1037 : 1 : }
1038 : :
1039 : 1 : void ImapService::onIdleRenewWatchdogTimeout() {
1040 [ + - ]: 3 : failConnection(QStringLiteral(
1041 : : "IDLE DONE/OK round-trip timeout (probe reason: %1)")
1042 [ + - ]: 2 : .arg(m_lastProbeReason));
1043 : 1 : }
1044 : :
1045 : 46 : QString ImapService::sendTaggedCommand(const QString &type,
1046 : : const QString &command) {
1047 : : // Thin wrapper around sendCommand() that exposes the generated tag, so
1048 : : // the probe can match its response in handleTagged().
1049 : 46 : QString tag;
1050 : : // Inline copy of sendCommand() but return tag (cannot refactor callers
1051 : : // without disturbing the existing command-timeout/reflow behaviour).
1052 : : const bool startsCommandBudget =
1053 [ + - + - : 184 : type != QStringLiteral("IDLE") &&
- - - - ]
1054 [ + - + + : 92 : !hasTimeoutTrackedCommandInFlight();
+ - ]
1055 [ + + ]: 46 : if (startsCommandBudget)
1056 [ + - ]: 45 : resetCommandAccumulators();
1057 : :
1058 [ + - ]: 46 : tag = nextTag();
1059 [ + - ]: 46 : m_pendingCommands.insert(tag, type);
1060 : 46 : QElapsedTimer timer;
1061 : 46 : timer.start();
1062 [ + - ]: 46 : m_commandTimers.insert(tag, timer);
1063 [ + - + - : 46 : auto fullCommand = tag + " " + command + "\r\n";
+ - ]
1064 [ + - + - : 92 : qCDebug(lcImap) << ">>>" << fullCommand.trimmed();
+ - + - +
- + + ]
1065 [ + - + - ]: 46 : m_socket->write(fullCommand.toUtf8());
1066 [ + - ]: 46 : refreshCommandTimeout();
1067 [ + - ]: 46 : refreshCommandDeadline();
1068 : 46 : return tag;
1069 : 46 : }
1070 : :
1071 : 19 : void ImapService::tuneKeepAlive() {
1072 [ + + ]: 19 : if (m_keepAliveTuned)
1073 : 10 : return;
1074 : :
1075 : : // The Qt-level option (set in the constructor) is assertable on an
1076 : : // unconnected socket; here we tune the native intervals once the
1077 : : // socket descriptor is valid.
1078 [ + - ]: 10 : qintptr fd = m_socket->socketDescriptor();
1079 [ + + ]: 10 : if (fd < 0) {
1080 [ + - + - : 2 : qCDebug(lcImap) << "tuneKeepAlive: no valid socket descriptor yet";
+ - + + ]
1081 : 1 : return;
1082 : : }
1083 : :
1084 : 9 : m_keepAliveTuned = true;
1085 : :
1086 : : #ifdef Q_OS_LINUX
1087 : : // Linux: TCP_KEEPIDLE=60, TCP_KEEPINTVL=30, TCP_KEEPCNT=3.
1088 : : // Worst-case detection ~ 60 + 3*30 = 150 s with zero app traffic.
1089 : 9 : const int idle = 60;
1090 : 9 : const int intvl = 30;
1091 : 9 : const int cnt = 3;
1092 : 9 : ::setsockopt(static_cast<int>(fd), IPPROTO_TCP, TCP_KEEPIDLE, &idle,
1093 : : sizeof(idle));
1094 : 9 : ::setsockopt(static_cast<int>(fd), IPPROTO_TCP, TCP_KEEPINTVL, &intvl,
1095 : : sizeof(intvl));
1096 : 9 : ::setsockopt(static_cast<int>(fd), IPPROTO_TCP, TCP_KEEPCNT, &cnt,
1097 : : sizeof(cnt));
1098 [ + - + - : 18 : qCInfo(lcImap) << "TCP keepalive tuned (Linux): idle=60s intvl=30s cnt=3";
+ - + + ]
1099 : : #elif defined(Q_OS_MACOS)
1100 : : // macOS: single TCP_KEEPALIVE interval (seconds). Mirror the Linux
1101 : : // worst-case budget of ~150 s by setting an idle probe interval.
1102 : : const int macIdle = 60;
1103 : : ::setsockopt(static_cast<int>(fd), IPPROTO_TCP, TCP_KEEPALIVE, &macIdle,
1104 : : sizeof(macIdle));
1105 : : qCInfo(lcImap) << "TCP keepalive tuned (macOS): interval=60s";
1106 : : #elif defined(Q_OS_WIN)
1107 : : // Windows: SIO_KEEPALIVE_VALS ioctl. onoff=1, idle=60000ms, intvl=30000ms.
1108 : : tcp_keepalive ka;
1109 : : ka.onoff = 1;
1110 : : ka.keepalivetime = 60000;
1111 : : ka.keepaliveinterval = 30000;
1112 : : DWORD bytesReturned = 0;
1113 : : ::WSAIoctl(static_cast<SOCKET>(fd), SIO_KEEPALIVE_VALS, &ka, sizeof(ka),
1114 : : nullptr, 0, &bytesReturned, nullptr, nullptr);
1115 : : qCInfo(lcImap) << "TCP keepalive tuned (Windows): idle=60000ms intvl=30000ms";
1116 : : #else
1117 : : qCInfo(lcImap) << "TCP keepalive tuning not implemented on this platform;"
1118 : : << "relying on OS defaults";
1119 : : #endif
1120 : : }
1121 : :
1122 : 649 : bool ImapService::hasStatefulCommandInFlight() const {
1123 : : static const QSet<QString> statefulTypes = {
1124 : 8 : QStringLiteral("LIST"), QStringLiteral("SELECT"),
1125 : 8 : QStringLiteral("FETCH_HEADERS"), QStringLiteral("FETCH_BODY"),
1126 : 8 : QStringLiteral("FETCH_FLAGS"), QStringLiteral("FETCH_SEQNO"),
1127 : 8 : QStringLiteral("SEARCH"), QStringLiteral("STATUS"),
1128 : 8 : QStringLiteral("STORE"), QStringLiteral("MOVE"),
1129 : 8 : QStringLiteral("COPY"), QStringLiteral("STORE_DELETE"),
1130 : 8 : QStringLiteral("EXPUNGE_MOVE"), QStringLiteral("COPY_ONLY"),
1131 : 8 : QStringLiteral("EXPUNGE"), QStringLiteral("APPEND"),
1132 : 8 : QStringLiteral("CREATE"), QStringLiteral("DELETE"),
1133 [ + + + - : 825 : QStringLiteral("RENAME"), QStringLiteral("NOOP")};
+ + - - -
- ]
1134 : :
1135 [ + - + - : 660 : for (const QString &type : m_pendingCommands) {
+ + ]
1136 [ + + ]: 144 : if (statefulTypes.contains(type))
1137 : 133 : return true;
1138 : : }
1139 : 516 : return false;
1140 [ + - - - : 168 : }
- - ]
1141 : :
1142 : 108466 : bool ImapService::hasTimeoutTrackedCommandInFlight() const {
1143 [ + - + - : 108838 : for (const QString &type : m_pendingCommands) {
+ + ]
1144 [ + + ]: 6347 : if (type != QStringLiteral("IDLE"))
1145 : 5975 : return true;
1146 : : }
1147 : 102491 : return false;
1148 : : }
1149 : :
1150 : 110 : void ImapService::enqueueSerializedCommand(std::function<void()> command) {
1151 : 110 : m_serializedCommands.enqueue(std::move(command));
1152 [ + - + - : 220 : qCDebug(lcImap) << "Queued stateful IMAP command; queue size:"
+ - + + ]
1153 [ + - ]: 110 : << m_serializedCommands.size();
1154 : 110 : }
1155 : :
1156 : 629 : void ImapService::runNextSerializedCommand() {
1157 [ + + + - : 629 : if (m_serializedCommands.isEmpty() || hasStatefulCommandInFlight())
+ + + + ]
1158 : 547 : return;
1159 : :
1160 [ + - ]: 82 : auto command = m_serializedCommands.dequeue();
1161 [ + - ]: 82 : command();
1162 : 82 : }
1163 : :
1164 : 3336 : void ImapService::refreshCommandTimeout() {
1165 [ + + ]: 3336 : if (!hasTimeoutTrackedCommandInFlight()) {
1166 : 737 : m_commandTimeoutTimer->stop();
1167 : 737 : return;
1168 : : }
1169 : 2599 : m_commandTimeoutTimer->start(COMMAND_TIMEOUT_MS);
1170 : : }
1171 : :
1172 : 1122 : void ImapService::refreshCommandDeadline() {
1173 [ + - + + ]: 1122 : if (!hasTimeoutTrackedCommandInFlight()) {
1174 [ + - ]: 557 : m_commandDeadlineTimer->stop();
1175 : 557 : return;
1176 : : }
1177 : : // Absolute deadline: incoming progress may cause this helper to be called,
1178 : : // but it can only shorten the remaining timer. It never grants a fresh
1179 : : // window to a slow-drip server.
1180 : 565 : qint64 shortestRemaining = COMMAND_DEADLINE_MS;
1181 [ + - + - : 1189 : for (auto it = m_pendingCommands.cbegin(); it != m_pendingCommands.cend();
+ + ]
1182 : 624 : ++it) {
1183 [ + + ]: 624 : if (it.value() == QStringLiteral("IDLE"))
1184 : 11 : continue;
1185 [ + - ]: 623 : const auto timerIt = m_commandTimers.constFind(it.key());
1186 [ + - + + : 623 : if (timerIt == m_commandTimers.cend() || !timerIt->isValid())
- + + + ]
1187 : 10 : continue;
1188 : 613 : shortestRemaining =
1189 : 1226 : qMin(shortestRemaining,
1190 : 613 : static_cast<qint64>(COMMAND_DEADLINE_MS) - timerIt->elapsed());
1191 : : }
1192 : 1130 : m_commandDeadlineTimer->start(
1193 [ + - ]: 565 : static_cast<int>(qBound<qint64>(qint64(1), shortestRemaining,
1194 : 1130 : qint64(COMMAND_DEADLINE_MS))));
1195 : : }
1196 : :
1197 : 1521 : void ImapService::resetCommandAccumulators() {
1198 : 1521 : m_commandResponseBytes = 0;
1199 : 1521 : m_commandLiteralCount = 0;
1200 : 1521 : m_commandResultItems = 0;
1201 : 1521 : }
1202 : :
1203 : 2222 : bool ImapService::accountCommandResponseBytes(qint64 bytes) {
1204 [ + + + + : 2222 : if (bytes <= 0 || !hasTimeoutTrackedCommandInFlight())
+ + ]
1205 : 193 : return true;
1206 [ + + ]: 2029 : if (bytes > MAX_COMMAND_RESPONSE_SIZE - m_commandResponseBytes) {
1207 [ + - ]: 1 : failConnection(QStringLiteral("IMAP command response byte budget exceeded"));
1208 : 1 : return false;
1209 : : }
1210 : 2028 : m_commandResponseBytes += bytes;
1211 : 2028 : return true;
1212 : : }
1213 : :
1214 : 100120 : bool ImapService::accountCommandLiteral() {
1215 [ + + ]: 100120 : if (!hasTimeoutTrackedCommandInFlight())
1216 : 100003 : return true;
1217 [ + + ]: 117 : if (m_commandLiteralCount >= MAX_LITERALS_PER_COMMAND) {
1218 [ + - ]: 1 : failConnection(QStringLiteral("IMAP command literal budget exceeded"));
1219 : 1 : return false;
1220 : : }
1221 : 116 : ++m_commandLiteralCount;
1222 : 116 : return true;
1223 : : }
1224 : :
1225 : 554 : bool ImapService::accountCommandResultItems(qint64 items) {
1226 [ + - - + : 554 : if (items <= 0 || !hasTimeoutTrackedCommandInFlight())
- + ]
1227 : 0 : return true;
1228 [ + + ]: 554 : if (items > MAX_RESULT_ITEMS_PER_COMMAND - m_commandResultItems) {
1229 [ + - ]: 1 : failConnection(QStringLiteral("IMAP command result item budget exceeded"));
1230 : 1 : return false;
1231 : : }
1232 : 553 : m_commandResultItems += items;
1233 : 553 : return true;
1234 : : }
1235 : :
1236 : 530 : void ImapService::clearCredentials() {
1237 : 530 : SecureUtil::zeroMemory(m_config.password);
1238 : 530 : m_config.username.clear();
1239 : 530 : }
1240 : :
1241 : 1 : void ImapService::invalidateBodyFetch() {
1242 [ + - + - : 2 : for (auto it = m_pendingCommands.begin(); it != m_pendingCommands.end();) {
+ + ]
1243 [ + - ]: 1 : if (it.value() == QStringLiteral("FETCH_BODY")) {
1244 [ + - ]: 1 : m_commandTimers.remove(it.key());
1245 [ + - ]: 1 : it = m_pendingCommands.erase(it);
1246 : : } else {
1247 : 0 : ++it;
1248 : : }
1249 : : }
1250 : 1 : m_activeBodyFetchUid = -1;
1251 : 1 : m_activeBodyFetchLimit = 0;
1252 : 1 : m_activeBodyFetchRequestBytes = 0;
1253 : 1 : m_bodyFetchRequiresSelect = false;
1254 : 1 : refreshCommandTimeout();
1255 : 1 : refreshCommandDeadline();
1256 [ + - ]: 1 : if (!hasTimeoutTrackedCommandInFlight())
1257 : 1 : resetCommandAccumulators();
1258 : 1 : }
1259 : :
1260 : 71 : void ImapService::failConnection(const QString &error) {
1261 [ + - + - : 142 : qCWarning(lcImap) << error;
+ - + + ]
1262 : 71 : clearCredentials();
1263 : 71 : m_timeoutTimer->stop();
1264 : 71 : m_commandTimeoutTimer->stop();
1265 : 71 : m_commandDeadlineTimer->stop();
1266 : 71 : m_idleRenewTimer->stop();
1267 : : // T-720: Stop the liveness probe + IDLE renew watchdogs on failure.
1268 : 71 : m_livenessProbeWatchdog->stop();
1269 : 71 : m_idleRenewWatchdog->stop();
1270 : 71 : m_probeTag.clear();
1271 : 71 : m_pendingCommands.clear();
1272 : 71 : m_commandTimers.clear();
1273 : 71 : m_deferredCommands.clear();
1274 : 71 : m_serializedCommands.clear();
1275 : 71 : m_readBuffer.clear();
1276 : 71 : m_pendingFolders.clear();
1277 : 71 : m_pendingHeaders.clear();
1278 : 71 : m_pendingFlags.clear();
1279 : 71 : m_pendingSearchUids.clear();
1280 : 71 : resetCommandAccumulators();
1281 : 71 : m_isIdling = false;
1282 : 71 : m_idleTag.clear();
1283 : 71 : m_isNotifying = false;
1284 : 71 : m_notifyTag.clear();
1285 : : // T-79.A1/H1: drop any in-flight APPEND payload with the session
1286 : 71 : m_pendingAppendData.clear();
1287 : 71 : m_pendingAppendFolder.clear();
1288 : 71 : m_literalBytesRemaining = 0;
1289 : 71 : m_literalData.clear();
1290 : 71 : m_literalLine.clear();
1291 : 71 : m_isBodyLiteral = false;
1292 : 71 : m_bodyLiteralUid = -1;
1293 : 71 : m_discardingInvalidBody = false;
1294 : 71 : m_discardingOversizedBody = false;
1295 : 71 : m_skipFetchLiteralRemainder = false;
1296 : 71 : m_activeBodyFetchUid = -1;
1297 : 71 : m_activeBodyFetchLimit = 0;
1298 : 71 : m_activeBodyFetchRequestBytes = 0;
1299 : 71 : m_bodyFetchRequiresSelect = false;
1300 : 71 : m_socket->abort();
1301 : 71 : emit errorOccurred(error);
1302 : 71 : setState(State::Error);
1303 : 71 : }
1304 : :
1305 : 883 : void ImapService::setState(State newState) {
1306 [ + + ]: 883 : if (m_state != newState) {
1307 [ + - + - : 1214 : qCInfo(lcImap) << "State:"
+ - + + ]
1308 [ + - ]: 1214 : << QMetaEnum::fromType<State>().valueToKey(
1309 [ + - + - ]: 607 : static_cast<int>(newState));
1310 : 607 : m_state = newState;
1311 : 607 : emit stateChanged(newState);
1312 : : }
1313 : 883 : }
1314 : :
1315 : 460 : void ImapService::sendCommand(const QString &type, const QString &command) {
1316 : : const bool startsCommandBudget =
1317 [ + - + - : 1840 : type != QStringLiteral("IDLE") &&
- - - - ]
1318 [ + - + + : 920 : !hasTimeoutTrackedCommandInFlight();
+ - ]
1319 [ + + ]: 460 : if (startsCommandBudget)
1320 [ + - ]: 409 : resetCommandAccumulators();
1321 : :
1322 [ + - ]: 460 : auto tag = nextTag();
1323 [ + - ]: 460 : m_pendingCommands.insert(tag, type);
1324 : :
1325 : : // T-210: Start timing for this command
1326 : 460 : QElapsedTimer timer;
1327 : 460 : timer.start();
1328 [ + - ]: 460 : m_commandTimers.insert(tag, timer);
1329 : :
1330 [ + - + - : 460 : auto fullCommand = tag + " " + command + "\r\n";
+ - ]
1331 : : // T-400/Bug 6: Mask credentials in LOGIN commands
1332 [ + + ]: 460 : if (type == QStringLiteral("LOGIN")) {
1333 [ + - + - : 18 : qCDebug(lcImap) << ">>>" << tag << "LOGIN <user> <***>";
+ - + - +
- + + ]
1334 : : } else {
1335 [ + - + - : 902 : qCDebug(lcImap) << ">>>" << fullCommand.trimmed();
+ - + - +
- + + ]
1336 : : }
1337 [ + - + - ]: 460 : m_socket->write(fullCommand.toUtf8());
1338 [ + - ]: 460 : refreshCommandTimeout();
1339 [ + - ]: 460 : refreshCommandDeadline();
1340 : 460 : }
1341 : :
1342 : 10 : bool ImapService::beginLogin() {
1343 [ + + ]: 10 : if (!m_socket->isEncrypted()) {
1344 [ + - + - : 2 : qCWarning(lcImap) << "Refusing IMAP LOGIN over unencrypted connection";
+ - + + ]
1345 [ + - ]: 1 : failConnection(QStringLiteral("Connection is not encrypted"));
1346 : 1 : return false;
1347 : : }
1348 : :
1349 : 9 : setState(State::Authenticating);
1350 : : // SEC-2026-07-21-12: Re-arm for the LOGIN response wait.
1351 : 9 : m_timeoutTimer->start(SESSION_SETUP_TIMEOUT_MS);
1352 [ + - + - : 18 : sendCommand("LOGIN", QString("LOGIN %1 %2")
+ - ]
1353 [ + - + - ]: 18 : .arg(quoteImapString(m_config.username),
1354 [ + - ]: 18 : quoteImapString(
1355 [ + - ]: 18 : QString::fromUtf8(m_config.password))));
1356 : : // The socket owns any bytes still queued for transmission. The service does
1357 : : // not need another plaintext credential copy while awaiting the response.
1358 : 9 : clearCredentials();
1359 : 9 : return true;
1360 : : }
1361 : :
1362 : 597 : QString ImapService::nextTag() {
1363 [ + - + - ]: 597 : return QString("A%1").arg(++m_tagCounter, 3, 10, QChar('0'));
1364 : : }
1365 : :
1366 : 62 : QString ImapService::buildFetchBodyCommand(qint64 uid, qint64 maxBytes) {
1367 [ + + ]: 62 : if (maxBytes > 0) {
1368 : 122 : return QStringLiteral("UID FETCH %1 (UID BODY.PEEK[]<0.%2>)")
1369 [ + - ]: 183 : .arg(uid)
1370 [ + - ]: 61 : .arg(maxBytes);
1371 : : }
1372 [ + - ]: 2 : return QStringLiteral("UID FETCH %1 (UID BODY.PEEK[])").arg(uid);
1373 : : }
1374 : :
1375 : 258 : QString ImapService::quoteImapString(const QString &str) {
1376 : : // RFC 3501: quoted strings must not contain CR, LF, or NUL
1377 : 258 : QString escaped = str;
1378 [ + - ]: 258 : escaped.remove('\r');
1379 [ + - ]: 258 : escaped.remove('\n');
1380 [ + - ]: 258 : escaped.remove(QChar(0));
1381 [ + - + - ]: 258 : escaped.replace('\\', "\\\\");
1382 [ + - + - ]: 258 : escaped.replace('"', "\\\"");
1383 [ + - + - ]: 774 : return '"' + escaped + '"';
1384 : 258 : }
1385 : :
1386 : 55 : bool ImapService::isValidImapFlag(const QString &flag) {
1387 [ - + ]: 55 : if (flag.isEmpty())
1388 : 0 : return false;
1389 [ + - + + ]: 55 : const qsizetype start = flag.startsWith(QLatin1Char('\\')) ? 1 : 0;
1390 [ - + ]: 55 : if (start == flag.size())
1391 : 0 : return false;
1392 [ + + + - ]: 58 : static const QString atomSpecials = QStringLiteral("(){ %*\"\\]}");
1393 [ + + ]: 354 : for (qsizetype i = start; i < flag.size(); ++i) {
1394 : 302 : const QChar c = flag.at(i);
1395 : 302 : const ushort u = c.unicode();
1396 [ + - + - : 603 : if (u <= 0x20 || u == 0x7F || c == QLatin1Char('\\') ||
+ + + + ]
1397 [ + - + + ]: 301 : atomSpecials.contains(c)) {
1398 : 3 : return false;
1399 : : }
1400 : : }
1401 : 52 : return true;
1402 : : }
1403 : :
1404 : 9 : void ImapService::onConnected() {
1405 [ + - + - : 18 : qCInfo(lcImap) << "TCP connected";
+ - + + ]
1406 : : // SEC-2026-07-21-12: Re-arm the timer for the greeting wait phase instead
1407 : : // of stopping it. A silent server that accepts the TCP connection but
1408 : : // never sends a greeting would otherwise stall indefinitely.
1409 : 9 : m_timeoutTimer->start(SESSION_SETUP_TIMEOUT_MS);
1410 : : // T-720/T-72.1: Now that the socket descriptor is valid, tune the
1411 : : // native TCP keepalive intervals. Idempotent — sets m_keepAliveTuned.
1412 : 9 : tuneKeepAlive();
1413 : :
1414 [ - + ]: 9 : if (m_config.security == "starttls") {
1415 : : // For STARTTLS, we wait for the greeting first, then issue STARTTLS
1416 : 0 : setState(State::Connected);
1417 : : }
1418 : : // For SSL, wait for onEncrypted
1419 : 9 : }
1420 : :
1421 : 9 : void ImapService::onEncrypted() {
1422 [ + - + - : 18 : qCInfo(lcImap) << "TLS established";
+ - + + ]
1423 : : // SEC-2026-07-21-12: Re-arm for greeting wait (implicit-SSL path) or
1424 : : // post-STARTTLS handshake — the latter is covered by this re-arm because
1425 : : // onEncrypted fires for both implicit-SSL and STARTTLS upgrades.
1426 : 9 : m_timeoutTimer->start(SESSION_SETUP_TIMEOUT_MS);
1427 : : // T-720/T-72.1: For implicit-SSL connections onConnected() may not have
1428 : : // run (Qt emits encrypted directly). Tune here too — tuneKeepAlive() is
1429 : : // idempotent via the m_keepAliveTuned flag.
1430 : 9 : tuneKeepAlive();
1431 : 9 : setState(State::Connected);
1432 : 9 : }
1433 : :
1434 : 100127 : void ImapService::completeLiteral() {
1435 [ + + ]: 100127 : if (m_isBodyLiteral) {
1436 [ + + ]: 63 : if (m_discardingInvalidBody) {
1437 [ + - + - : 6 : qCWarning(lcImap) << "Discarded BODY[] literal not matching active UID"
+ - + + ]
1438 [ + - + - ]: 3 : << m_activeBodyFetchUid << "response UID"
1439 [ + - ]: 3 : << m_bodyLiteralUid;
1440 : : } else {
1441 : : // Consume the request before emitting: direct signal handlers may queue
1442 : : // another body fetch, which must not be overwritten after they return.
1443 : 60 : m_activeBodyFetchUid = -1;
1444 [ + + ]: 60 : if (m_discardingOversizedBody) {
1445 [ + - + - : 2 : qCWarning(lcImap) << "Discarded body above configured limit for UID"
+ - + + ]
1446 [ + - + - ]: 1 : << m_bodyLiteralUid << "limit"
1447 [ + - ]: 1 : << m_activeBodyFetchLimit;
1448 [ + - ]: 1 : emit bodyFetchTooLarge(m_bodyLiteralUid, m_activeBodyFetchLimit);
1449 : : } else {
1450 [ + - + - : 118 : qCInfo(lcImap) << "Body literal complete:" << m_literalData.size()
+ - + - +
+ ]
1451 [ + - + - ]: 59 : << "bytes for UID" << m_bodyLiteralUid;
1452 [ + - ]: 59 : emit rawBodyReceived(m_bodyLiteralUid, m_literalData);
1453 : : }
1454 : : }
1455 : :
1456 [ + - ]: 63 : m_literalData.clear();
1457 : 63 : m_literalLine.clear();
1458 : 63 : m_isBodyLiteral = false;
1459 : 63 : m_bodyLiteralUid = -1;
1460 : 63 : m_discardingInvalidBody = false;
1461 : 63 : m_discardingOversizedBody = false;
1462 : : // BODY FETCH has a closing parenthesis after the literal. It may arrive
1463 : : // in a later socket chunk, so remember to skip that physical line.
1464 : 63 : m_skipFetchLiteralRemainder = true;
1465 : 64 : return;
1466 : : }
1467 : :
1468 : : // Non-body literals are folded into the logical parser line.
1469 [ + - ]: 100064 : QString literalStr = QString::fromUtf8(m_literalData);
1470 [ + - + - ]: 100064 : literalStr.replace('\\', "\\\\");
1471 [ + - + - ]: 100064 : literalStr.replace('"', "\\\"");
1472 : : // SEC-2026-07-21-02: hard cap on the folded logical line. Without this, a
1473 : : // malicious server can accumulate endless non-body literals (BODY[HEADER],
1474 : : // BODY[1], ENVELOPE, …) into m_literalLine during pre-auth / IDLE / NOTIFY,
1475 : : // growing RSS without bound. 8 MiB is far above any legitimate folded line.
1476 : 100064 : const qsizetype foldedAddition = literalStr.size() + 2; // +2 for the quotes
1477 [ + + ]: 100064 : if (m_literalLine.size() + foldedAddition > MAX_NON_BODY_LITERAL_LINE_SIZE) {
1478 [ + - ]: 1 : failConnection(QStringLiteral(
1479 : : "IMAP folded literal line exceeded connection limit"));
1480 : 1 : return;
1481 : : }
1482 [ + - + - : 100063 : m_literalLine.append('"' + literalStr + '"');
+ - ]
1483 [ + - ]: 100063 : m_literalData.clear();
1484 [ + + ]: 100064 : }
1485 : :
1486 : 2221 : void ImapService::onReadyRead() {
1487 [ + - ]: 2221 : const QByteArray incoming = m_socket->readAll();
1488 [ + - - + ]: 2221 : if (!accountCommandResponseBytes(incoming.size()))
1489 : 0 : return;
1490 [ + - ]: 2221 : m_readBuffer.append(incoming);
1491 [ + + ]: 2221 : if (!incoming.isEmpty())
1492 [ + - ]: 2208 : refreshCommandTimeout();
1493 : :
1494 : : // Physical lines are bounded independently from literals. Literal payloads
1495 : : // are drained incrementally below and therefore never need to collect in
1496 : : // this parser buffer.
1497 [ - + ]: 2221 : if (m_readBuffer.size() > MAX_READ_BUFFER_SIZE) {
1498 [ # # ]: 0 : failConnection(QStringLiteral("Server response too large"));
1499 : 0 : return;
1500 : : }
1501 : :
1502 : : while (true) {
1503 [ + + ]: 204651 : if (m_skipFetchLiteralRemainder) {
1504 : 64 : const int remainderEnd = m_readBuffer.indexOf("\r\n");
1505 [ + + ]: 64 : if (remainderEnd < 0)
1506 : 1 : break;
1507 [ + - ]: 63 : m_readBuffer.remove(0, remainderEnd + 2);
1508 : 63 : m_skipFetchLiteralRemainder = false;
1509 : 100256 : continue;
1510 : 63 : }
1511 : :
1512 : : // Mode 1: stream the current literal out of the parser buffer. This
1513 : : // avoids the former read-buffer + literal-data double materialization.
1514 [ + + ]: 204587 : if (m_literalBytesRemaining > 0) {
1515 [ + + ]: 75 : if (m_readBuffer.isEmpty())
1516 : 1 : break;
1517 : : const qsizetype bytesToConsume = static_cast<qsizetype>(
1518 : 74 : qMin<qint64>(m_literalBytesRemaining, m_readBuffer.size()));
1519 [ + + + + ]: 74 : if (!m_discardingOversizedBody && !m_discardingInvalidBody) {
1520 [ + - ]: 69 : m_literalData.append(m_readBuffer.constData(), bytesToConsume);
1521 : : }
1522 [ + - ]: 74 : m_readBuffer.remove(0, bytesToConsume);
1523 : 74 : m_literalBytesRemaining -= bytesToConsume;
1524 [ + + ]: 74 : if (m_literalBytesRemaining == 0)
1525 [ + - ]: 73 : completeLiteral();
1526 : 74 : continue;
1527 : 74 : }
1528 : :
1529 : : // Mode 2: Read next \r\n-delimited line
1530 : 204512 : int idx = m_readBuffer.indexOf("\r\n");
1531 [ + + ]: 204512 : if (idx < 0)
1532 : 2218 : break;
1533 : :
1534 [ + - ]: 202294 : auto lineBytes = m_readBuffer.left(idx);
1535 [ + - ]: 202294 : m_readBuffer.remove(0, idx + 2);
1536 : :
1537 [ + - + - ]: 202294 : auto line = QString::fromUtf8(lineBytes).trimmed();
1538 [ - + ]: 202294 : if (line.isEmpty())
1539 : 0 : continue;
1540 : :
1541 : : // Check if this line ends with a literal marker {N}
1542 [ + + + - : 202294 : static QRegularExpression literalRx(R"(\{(\d+)\}$)");
+ - + - -
- ]
1543 [ + - ]: 202294 : auto match = literalRx.match(line);
1544 [ + - + + ]: 202294 : if (match.hasMatch()) {
1545 : : // T-401/Bug 9: Use qint64 to avoid overflow with >2GB literals
1546 : : bool ok;
1547 [ + - + - ]: 100120 : m_literalBytesRemaining = match.captured(1).toLongLong(&ok);
1548 [ + - - + ]: 100120 : if (!ok || m_literalBytesRemaining < 0) {
1549 [ # # # # : 0 : qCWarning(lcImap) << "Invalid literal size:" << match.captured(1);
# # # # #
# # # ]
1550 [ # # ]: 0 : failConnection(QStringLiteral("Invalid IMAP literal size"));
1551 : 1 : return;
1552 : : }
1553 [ + + ]: 100120 : if (m_literalBytesRemaining > MAX_LITERAL_SIZE) {
1554 [ + - + - : 2 : qCWarning(lcImap) << "Literal size exceeded" << MAX_LITERAL_SIZE
+ - + - +
+ ]
1555 [ + - + - ]: 1 : << "bytes:" << m_literalBytesRemaining;
1556 [ + - ]: 1 : failConnection(QStringLiteral("Server literal too large"));
1557 : 1 : return;
1558 : : }
1559 [ + - - + ]: 100119 : if (!accountCommandLiteral())
1560 : 0 : return;
1561 [ + - + - ]: 100119 : const QString literalPrefix = line.left(match.capturedStart());
1562 : : const bool isBodyLiteral =
1563 [ + - + - ]: 200238 : (m_literalLine + literalPrefix).contains(QStringLiteral("BODY[]"));
1564 [ + + - + ]: 200175 : if (!isBodyLiteral &&
1565 [ + - ]: 100056 : (m_literalBytesRemaining > MAX_NON_BODY_LITERAL_LINE_SIZE ||
1566 [ - + ]: 100056 : m_literalLine.size() + literalPrefix.size() >
1567 : : MAX_NON_BODY_LITERAL_LINE_SIZE)) {
1568 [ # # ]: 0 : failConnection(QStringLiteral(
1569 : : "IMAP folded literal line exceeded connection limit"));
1570 : 0 : return;
1571 : : }
1572 : : // Remove the {N} from the line — it will be replaced by the literal
1573 : : // content.
1574 [ + - ]: 100119 : m_literalLine.append(literalPrefix);
1575 [ + - ]: 100119 : m_literalData.clear();
1576 : :
1577 : : // Detect if this is a BODY[] literal fetch
1578 : : // The line looks like: "* N FETCH (UID 42 BODY[] {12345}"
1579 [ + + ]: 100119 : if (isBodyLiteral) {
1580 : 63 : m_isBodyLiteral = true;
1581 : : // Extract UID from the line
1582 [ + + + - : 63 : static QRegularExpression uidRx(R"(UID\s+(\d+))");
+ - + - -
- ]
1583 [ + - ]: 63 : auto uidMatch = uidRx.match(m_literalLine);
1584 : 63 : bool uidOk = false;
1585 [ + - + - ]: 63 : if (uidMatch.hasMatch())
1586 [ + - + - ]: 63 : m_bodyLiteralUid = uidMatch.captured(1).toLongLong(&uidOk);
1587 : : const bool fetchPending =
1588 [ + - ]: 126 : m_pendingCommands.values().contains(QStringLiteral("FETCH_BODY"));
1589 : 63 : m_discardingInvalidBody =
1590 [ + - + - ]: 61 : !fetchPending || !uidOk || m_activeBodyFetchUid <= 0 ||
1591 [ + + + + ]: 184 : m_bodyLiteralUid != m_activeBodyFetchUid ||
1592 [ - + ]: 60 : m_bodyFetchRequiresSelect;
1593 [ + + ]: 63 : if (m_discardingInvalidBody) {
1594 [ + - + - : 6 : qCWarning(lcImap) << "Discarding unsolicited or mismatched BODY[]"
+ - + + ]
1595 [ + - + - : 3 : << "for UID" << m_bodyLiteralUid << "expected"
+ - ]
1596 [ + - ]: 3 : << m_activeBodyFetchUid;
1597 : 3 : m_discardingOversizedBody = false;
1598 [ - + ]: 60 : } else if (m_literalBytesRemaining > m_activeBodyFetchRequestBytes) {
1599 [ # # ]: 0 : failConnection(
1600 : 0 : QStringLiteral("IMAP server exceeded requested body range"));
1601 : 0 : return;
1602 : : } else {
1603 : 60 : m_discardingOversizedBody =
1604 [ + - ]: 120 : m_activeBodyFetchLimit > 0 &&
1605 [ + + ]: 60 : m_literalBytesRemaining > m_activeBodyFetchLimit;
1606 : : }
1607 [ + - + - : 126 : qCInfo(lcImap) << "Collecting body literal:" << m_literalBytesRemaining
+ - + - +
+ ]
1608 [ + - + - ]: 63 : << "bytes for UID" << m_bodyLiteralUid;
1609 [ + - ]: 63 : } else {
1610 : 100056 : m_isBodyLiteral = false;
1611 : 100056 : m_discardingInvalidBody = false;
1612 : 100056 : m_discardingOversizedBody = false;
1613 : : }
1614 : :
1615 [ + + ]: 100119 : if (m_literalBytesRemaining == 0)
1616 [ + - ]: 100046 : completeLiteral();
1617 : 100119 : continue;
1618 [ - + ]: 100119 : }
1619 : :
1620 : : // If we were building a multi-literal line, finalize it
1621 [ + + ]: 102174 : if (!m_literalLine.isEmpty()) {
1622 [ - + ]: 100056 : if (m_literalLine.size() + line.size() >
1623 : : MAX_NON_BODY_LITERAL_LINE_SIZE) {
1624 [ # # ]: 0 : failConnection(QStringLiteral(
1625 : : "IMAP folded literal line exceeded connection limit"));
1626 : 0 : return;
1627 : : }
1628 [ + - ]: 100056 : m_literalLine.append(line);
1629 : 100056 : line = m_literalLine;
1630 : 100056 : m_literalLine.clear();
1631 : : }
1632 : :
1633 [ + - + - : 204348 : qCDebug(lcImap) << "<<<" << line.left(200)
+ - + - +
- + + ]
1634 [ + + + - ]: 102174 : << (line.length() > 200 ? "..." : "");
1635 [ + - ]: 102174 : processLine(line);
1636 [ + + + + : 604964 : }
+ + + +
+ ]
1637 [ + + ]: 2221 : }
1638 : :
1639 : 102189 : void ImapService::processLine(const QString &line) {
1640 [ + + ]: 102189 : if (ImapResponseParser::isUntagged(line)) {
1641 : 101552 : handleUntagged(line);
1642 [ + + ]: 637 : } else if (ImapResponseParser::isTagged(line)) {
1643 : 544 : handleTagged(line);
1644 [ + + ]: 93 : } else if (ImapResponseParser::isContinuation(line)) {
1645 : : // T-176: APPEND literal continuation – server sent "+"
1646 : : // T-79.A1/H1: only serve the payload while an APPEND is actually in
1647 : : // flight; a "+" from any other command (e.g. "+ idling") must never
1648 : : // flush a (stale) message into the command stream.
1649 : 92 : bool appendInFlight = false;
1650 [ + - + - : 176 : for (const QString &type : m_pendingCommands) {
+ + ]
1651 [ + + ]: 90 : if (type == QStringLiteral("APPEND")) {
1652 : 6 : appendInFlight = true;
1653 : 6 : break;
1654 : : }
1655 : : }
1656 [ + + + - : 92 : if (appendInFlight && !m_pendingAppendData.isEmpty()) {
+ + ]
1657 [ + - + - : 12 : qCInfo(lcImap) << "APPEND continuation: sending"
+ - + + ]
1658 [ + - + - ]: 6 : << m_pendingAppendData.size() << "bytes";
1659 : 6 : m_socket->write(m_pendingAppendData);
1660 : 6 : m_socket->write("\r\n");
1661 : 6 : m_pendingAppendData.clear();
1662 : 6 : refreshCommandTimeout();
1663 : : } else {
1664 [ + + ]: 86 : if (!m_pendingAppendData.isEmpty()) {
1665 [ + - + - : 2 : qCWarning(lcImap) << "Discarding stale APPEND payload"
+ - + + ]
1666 [ + - ]: 1 : << m_pendingAppendData.size()
1667 [ + - ]: 1 : << "bytes on unexpected continuation";
1668 : 1 : m_pendingAppendData.clear();
1669 : 1 : m_pendingAppendFolder.clear();
1670 : : }
1671 [ + - + - : 172 : qCDebug(lcImap) << "Continuation response (ignored):" << line;
+ - + - +
+ ]
1672 : : }
1673 : : } else {
1674 [ + - + - : 2 : qCWarning(lcImap) << "Unrecognized response:" << line;
+ - + - +
+ ]
1675 : : }
1676 : 102189 : }
1677 : :
1678 : 101552 : void ImapService::handleUntagged(const QString &line) {
1679 [ + - ]: 101552 : auto response = ImapResponseParser::parseUntaggedResponse(line);
1680 [ - + ]: 101552 : if (!response)
1681 : 0 : return;
1682 : :
1683 : 101552 : const auto &type = response->type;
1684 : 101552 : const auto &data = response->data;
1685 : :
1686 [ + + - + : 101552 : if (m_state == State::Connected && (type == "OK" || type == "PREAUTH")) {
- - + + ]
1687 : : // Server greeting
1688 [ + - + - : 22 : qCInfo(lcImap) << "Server greeting:" << data;
+ - + - +
+ ]
1689 [ + - ]: 11 : setState(State::Greeting);
1690 : : // SEC-2026-07-21-12: Re-arm for the CAPABILITY/STARTTLS phase.
1691 [ + - ]: 11 : m_timeoutTimer->start(SESSION_SETUP_TIMEOUT_MS);
1692 : :
1693 : : // Request capabilities
1694 [ + - + - : 11 : sendCommand("CAPABILITY", "CAPABILITY");
+ - ]
1695 : 11 : return;
1696 : : }
1697 : :
1698 [ + + ]: 101541 : if (type == "CAPABILITY") {
1699 [ + - ]: 10 : m_capabilities = ImapResponseParser::parseCapabilities(data);
1700 [ + - + - : 20 : qCInfo(lcImap) << "Capabilities:" << m_capabilities;
+ - + - +
+ ]
1701 : 10 : return;
1702 : : }
1703 : :
1704 [ + + ]: 101531 : if (type == "LIST") {
1705 [ + - ]: 60 : auto folder = ImapResponseParser::parseListResponse(data);
1706 [ + - ]: 60 : if (folder) {
1707 [ + - - + ]: 60 : if (!accountCommandResultItems())
1708 : 0 : return;
1709 [ + - + - ]: 60 : m_pendingFolders.append(folder.value());
1710 : : }
1711 : 60 : return;
1712 : 60 : }
1713 : :
1714 : : // FETCH responses: "* N FETCH (...)"
1715 [ + - + - : 101471 : if (data.startsWith("FETCH")) {
+ + ]
1716 : : // type is the sequence number, data starts with "FETCH"
1717 [ + - + - ]: 512 : auto fetchData = data.mid(5).trimmed(); // skip "FETCH"
1718 : :
1719 : : // Check if this is a flags-only fetch (from fetchFlags or IDLE/NOTIFY push)
1720 [ + - + - : 1477 : if (fetchData.contains("FLAGS") && !fetchData.contains("ENVELOPE") &&
+ + + - +
- + + + -
+ + - - -
- ]
1721 [ + - + - : 965 : !fetchData.contains("BODY[]")) {
+ + + + +
+ - - ]
1722 : : // Parse UID and FLAGS from "(UID 123 FLAGS (\Seen \Flagged))"
1723 [ + - ]: 452 : auto parsed = ImapResponseParser::parseFetchFlagsResponse(fetchData);
1724 : :
1725 : : // T-320: Determine if this is a command response or unsolicited push.
1726 : : // With IDLE: no commands in-flight → always unsolicited.
1727 : : // With NOTIFY: commands run concurrently → check pending commands.
1728 : : bool isCommandResponse =
1729 [ + - - - ]: 534 : m_pendingCommands.values().contains("FETCH_FLAGS") ||
1730 [ + - + + : 986 : m_pendingCommands.values().contains("FETCH_SEQNO") ||
+ - - + +
- - - ]
1731 [ + - + + : 534 : m_pendingCommands.values().contains("FETCH_HEADERS");
+ + - - ]
1732 : :
1733 [ + + ]: 452 : if (parsed) {
1734 [ + + ]: 451 : if (isCommandResponse) {
1735 : : // Response to explicit FETCH → accumulate for batch emit
1736 [ + - - + ]: 370 : if (!accountCommandResultItems())
1737 : 0 : return;
1738 [ + - + - ]: 370 : m_pendingFlags.append(parsed.value());
1739 : : } else {
1740 : : // Unsolicited push (IDLE or NOTIFY event) → emit immediately
1741 [ + - ]: 81 : emit idleFlagsChanged(parsed->first, parsed->second);
1742 : : }
1743 [ + - ]: 1 : } else if (!isCommandResponse) {
1744 : : // T-065: Unsolicited flag push WITHOUT UID (common for IMAP servers).
1745 : : // The sequence number is in the 'type' field from parseUntaggedResponse.
1746 : 1 : bool ok = false;
1747 [ + - ]: 1 : int seqNo = type.toInt(&ok);
1748 [ + - + - ]: 1 : if (ok && seqNo > 0) {
1749 [ + - + - : 2 : qCInfo(lcImap) << "Push flag change without UID, seqNo:" << seqNo;
+ - + - +
+ ]
1750 [ + - ]: 1 : emit idleFlagsNeedRefetch(seqNo);
1751 : : }
1752 : : }
1753 : 452 : return;
1754 : : }
1755 : :
1756 : : // Check if this is a header or body fetch
1757 [ + - + - : 60 : if (fetchData.contains("ENVELOPE")) {
+ + ]
1758 [ + - ]: 58 : auto header = ImapResponseParser::parseFetchHeaderResponse(fetchData);
1759 [ + + ]: 58 : if (header) {
1760 [ + - - + ]: 57 : if (!accountCommandResultItems())
1761 : 0 : return;
1762 [ + - + - ]: 57 : m_pendingHeaders.append(header.value());
1763 [ + - + - : 114 : qCDebug(lcImap) << "Parsed header for UID" << header->uid;
+ - + - +
+ ]
1764 : : } else {
1765 : : // parseFetchHeaderResponse() returns nullopt ONLY when the FETCH
1766 : : // data carries no UID (everything else is parsed tolerantly with
1767 : : // empty fields). The former T-067 "fallback header" branch here
1768 : : // re-extracted the UID from the same data and could therefore
1769 : : // never succeed — verified dead code, removed in Sprint 65.
1770 [ + - + - : 2 : qCWarning(lcImap) << "FETCH without UID – mail dropped! data:"
+ - + + ]
1771 [ + - + - ]: 1 : << fetchData.left(300);
1772 : : }
1773 : : // Streaming: emit batch when threshold reached
1774 [ - + ]: 58 : if (m_pendingHeaders.size() >= HEADER_BATCH_SIZE) {
1775 [ # # ]: 0 : emit headersReceived(m_pendingHeaders);
1776 [ # # ]: 0 : m_pendingHeaders.clear();
1777 : : }
1778 [ + - + - : 60 : } else if (fetchData.contains("BODY[]")) {
+ - + - ]
1779 [ + - ]: 2 : auto body = ImapResponseParser::parseFetchBodyResponse(fetchData);
1780 : : const bool fetchPending =
1781 [ + - ]: 4 : m_pendingCommands.values().contains(QStringLiteral("FETCH_BODY"));
1782 [ + + - + : 2 : if (body && fetchPending && !m_bodyFetchRequiresSelect &&
- - - + ]
1783 [ # # ]: 0 : body->first == m_activeBodyFetchUid) {
1784 : 0 : m_activeBodyFetchUid = -1;
1785 [ # # ]: 0 : emit rawBodyReceived(body->first, body->second);
1786 [ + + ]: 2 : } else if (body) {
1787 [ + - + - : 2 : qCWarning(lcImap) << "Discarding inline BODY[] for UID" << body->first
+ - + - +
+ ]
1788 [ + - + - ]: 1 : << "expected" << m_activeBodyFetchUid;
1789 : : }
1790 : 2 : }
1791 : 60 : return;
1792 : 512 : }
1793 : :
1794 [ + + ]: 100959 : if (type == "SEARCH") {
1795 : : // SEARCH response: data contains space-separated UIDs
1796 : : // e.g. type="SEARCH", data="1 2 3 45 67"
1797 [ + + ]: 87 : if (!data.isEmpty()) {
1798 [ + - + - : 95 : for (const auto &token : data.split(' ', Qt::SkipEmptyParts)) {
+ - + + ]
1799 : 66 : bool ok = false;
1800 [ + - ]: 66 : qint64 uid = token.toLongLong(&ok);
1801 [ + - ]: 66 : if (ok) {
1802 [ + - - + ]: 66 : if (!accountCommandResultItems())
1803 : 0 : return;
1804 [ + - ]: 66 : m_pendingSearchUids.append(uid);
1805 : : }
1806 [ + - ]: 29 : }
1807 : : }
1808 : 87 : return;
1809 : : }
1810 : :
1811 : : // EXISTS response: "* N EXISTS" → type="N", data="EXISTS"
1812 [ + + ]: 100872 : if (data == "EXISTS") {
1813 : 130 : bool ok = false;
1814 [ + - ]: 130 : int count = type.toInt(&ok);
1815 [ + - ]: 130 : if (ok) {
1816 : : // T-320: Only treat as new-message push if no command is in-flight
1817 : : // that would naturally produce EXISTS (SELECT, FETCH_HEADERS, etc.)
1818 : : bool isCommandResponse =
1819 [ + - - - ]: 143 : m_pendingCommands.values().contains("SELECT") ||
1820 [ + - + + : 273 : m_pendingCommands.values().contains("FETCH_HEADERS") ||
+ - - + +
- - - ]
1821 [ + - + + : 143 : m_pendingCommands.values().contains("FETCH_FLAGS");
+ + - - ]
1822 [ + + + + ]: 130 : if (!isCommandResponse && count > m_selectedMessageCount) {
1823 : : // New messages arrived (IDLE or NOTIFY push)
1824 [ + - + - : 20 : qCInfo(lcImap) << "Push: new messages detected," << count
+ - + - +
+ ]
1825 [ + - + - : 10 : << "total (was" << m_selectedMessageCount << ")";
+ - ]
1826 : 10 : int newCount = count - m_selectedMessageCount;
1827 : 10 : m_selectedMessageCount = count;
1828 [ + - ]: 10 : emit idleNewMessages(newCount);
1829 : 10 : } else {
1830 : 120 : m_selectedMessageCount = count;
1831 [ + - + - : 240 : qCInfo(lcImap) << "EXISTS:" << count << "messages";
+ - + - +
- + + ]
1832 : : }
1833 : : }
1834 : 130 : return;
1835 : : }
1836 : :
1837 : : // EXPUNGE response: "* N EXPUNGE" → type="N", data="EXPUNGE"
1838 [ + + ]: 100742 : if (data == "EXPUNGE") {
1839 : 15 : bool ok = false;
1840 [ + - ]: 15 : int seqNo = type.toInt(&ok);
1841 [ + - ]: 15 : if (ok) {
1842 : 15 : m_selectedMessageCount = qMax(0, m_selectedMessageCount - 1);
1843 [ + - + - : 30 : qCInfo(lcImap) << "EXPUNGE: message" << seqNo << "removed";
+ - + - +
- + + ]
1844 : : // T-320: Only emit push signal if no command caused this EXPUNGE
1845 : : bool isCommandResponse =
1846 [ + + - - ]: 30 : m_pendingCommands.values().contains("EXPUNGE") ||
1847 [ + - + - : 45 : m_pendingCommands.values().contains("MOVE") ||
+ - - + +
- - - ]
1848 [ + - + + : 21 : m_pendingCommands.values().contains("SELECT");
+ - - - ]
1849 [ + + ]: 15 : if (!isCommandResponse) {
1850 [ + - ]: 6 : emit idleMessageExpunged(seqNo);
1851 : : }
1852 : : }
1853 : 15 : return;
1854 : : }
1855 : :
1856 : : // STATUS response: "* STATUS "INBOX" (MESSAGES 42 UNSEEN 3 RECENT 0)"
1857 [ + + ]: 100727 : if (type == "STATUS") {
1858 : : // data = '"INBOX" (MESSAGES 42 UNSEEN 3 RECENT 0)'
1859 [ + - ]: 14 : auto parsed = ImapResponseParser::parseStatusResponse(data);
1860 [ + - ]: 14 : if (parsed) {
1861 [ + - + - ]: 14 : emit folderStatusReceived(parsed.value());
1862 : : }
1863 : 14 : return;
1864 : 14 : }
1865 : :
1866 : : // UIDVALIDITY in OK response: "* OK [UIDVALIDITY 12345]"
1867 [ + + ]: 100713 : if (type == "OK") {
1868 [ + - ]: 100473 : auto uv = ImapResponseParser::parseUidValidity(data);
1869 [ + + ]: 100473 : if (uv) {
1870 [ + - ]: 117 : m_selectedUidValidity = uv.value();
1871 [ + - + - : 234 : qCInfo(lcImap) << "UIDVALIDITY:" << m_selectedUidValidity;
+ - + - +
+ ]
1872 : : }
1873 : :
1874 : : // T-208: Parse HIGHESTMODSEQ from "* OK [HIGHESTMODSEQ 12345]"
1875 : : static QRegularExpression modseqRx(
1876 [ + + + - : 100473 : R"(\[HIGHESTMODSEQ\s+(\d+)\])", QRegularExpression::CaseInsensitiveOption);
+ - + - -
- ]
1877 [ + - ]: 100473 : auto modseqMatch = modseqRx.match(data);
1878 [ + - - + ]: 100473 : if (modseqMatch.hasMatch()) {
1879 [ # # # # ]: 0 : m_selectedHighestModseq = modseqMatch.captured(1).toULongLong();
1880 [ # # # # : 0 : qCInfo(lcImap) << "T-208: HIGHESTMODSEQ:" << m_selectedHighestModseq;
# # # # #
# ]
1881 : : }
1882 : 100473 : return;
1883 : 100473 : }
1884 : :
1885 [ + + ]: 240 : if (type == "BYE") {
1886 [ + - + - : 2 : qCInfo(lcImap) << "Server BYE:" << data;
+ - + - +
+ ]
1887 : : // Mark disconnected before the shared teardown so disconnect() does not
1888 : : // try to send LOGOUT back to a server that has already closed the session.
1889 [ + - ]: 1 : setState(State::Disconnected);
1890 [ + - ]: 1 : disconnect();
1891 : 1 : return;
1892 : : }
1893 [ + + ]: 101552 : }
1894 : :
1895 : 613 : void ImapService::handleTagged(const QString &line) {
1896 [ + - ]: 613 : auto response = ImapResponseParser::parseTaggedResponse(line);
1897 [ - + ]: 613 : if (!response)
1898 : 0 : return;
1899 : :
1900 [ + - ]: 613 : auto commandType = m_pendingCommands.take(response->tag);
1901 : : struct SerializedDrainGuard {
1902 : : ImapService *service = nullptr;
1903 : 613 : ~SerializedDrainGuard() {
1904 [ + - ]: 613 : if (service)
1905 : 613 : service->runNextSerializedCommand();
1906 : 613 : }
1907 : 613 : } drainGuard{this};
1908 : :
1909 : : // T-210: Log command duration
1910 [ + - ]: 613 : auto timerIt = m_commandTimers.find(response->tag);
1911 [ + - + + ]: 613 : if (timerIt != m_commandTimers.end()) {
1912 [ + - + - : 972 : qCInfo(lcImapTiming) << "IMAP timing:" << commandType
+ - + - +
+ ]
1913 [ + - + - ]: 486 : << m_selectedFolder << "→"
1914 [ + - + - ]: 486 : << timerIt->elapsed() << "ms";
1915 [ + - ]: 486 : m_commandTimers.erase(timerIt);
1916 : : }
1917 [ + - ]: 613 : refreshCommandTimeout();
1918 [ + - ]: 613 : refreshCommandDeadline();
1919 [ + - + + ]: 613 : if (!hasTimeoutTrackedCommandInFlight())
1920 [ + - ]: 556 : resetCommandAccumulators();
1921 : :
1922 : : // T-720: Clear the liveness probe state when its tagged response arrives
1923 : : // (any OK/NO from the server proves the socket is alive). BAD responses
1924 : : // are surfaced below by the regular errorOccurred handlers, which call
1925 : : // failConnection() and stop the watchdog there.
1926 [ + + + - : 613 : if (!m_probeTag.isEmpty() && m_probeTag == response->tag) {
+ + ]
1927 [ + - + - : 76 : qCInfo(lcImap) << "Liveness probe OK (" << m_probeTag
+ - + - +
+ ]
1928 [ + - ]: 38 : << ") — disarming watchdog";
1929 : 38 : m_probeTag.clear();
1930 [ + - ]: 38 : m_livenessProbeWatchdog->stop();
1931 : : }
1932 : :
1933 [ + - + - : 1226 : qCDebug(lcImap) << "Tagged response for" << commandType << ":"
+ - + - +
- + + ]
1934 [ + - + - ]: 613 : << response->status << response->message;
1935 : :
1936 [ + + ]: 613 : if (commandType == "CAPABILITY") {
1937 [ + - ]: 10 : if (response->status == "OK") {
1938 [ + - ]: 10 : setState(State::Capability);
1939 : :
1940 [ + + + + ]: 11 : if (m_config.security == "starttls" &&
1941 [ + - + - ]: 1 : !m_socket->isEncrypted()) {
1942 [ + - - + ]: 1 : if (m_capabilities.contains("STARTTLS", Qt::CaseInsensitive)) {
1943 : : // Need to upgrade to TLS
1944 [ # # ]: 0 : setState(State::StartingTLS);
1945 [ # # # # : 0 : sendCommand("STARTTLS", "STARTTLS");
# # ]
1946 : : } else {
1947 : : // T-400/Bug 2: Refuse plaintext login when STARTTLS was requested
1948 [ + - + - : 2 : qCWarning(lcImap) << "STARTTLS requested but not offered by server"
+ - + + ]
1949 [ + - ]: 1 : << "— refusing to send credentials in plaintext";
1950 [ + - ]: 1 : failConnection(QStringLiteral(
1951 : : "Server does not support STARTTLS — login refused for security"));
1952 : : }
1953 : : } else {
1954 : : // Proceed to login
1955 [ + - ]: 9 : beginLogin();
1956 : : }
1957 : : } else {
1958 [ # # # # ]: 0 : failConnection("CAPABILITY failed: " + response->message);
1959 : : }
1960 : 10 : return;
1961 : : }
1962 : :
1963 [ + + ]: 603 : if (commandType == "STARTTLS") {
1964 [ + - ]: 1 : if (response->status == "OK") {
1965 [ - + - - : 1 : if (!m_readBuffer.isEmpty() || m_socket->bytesAvailable() > 0) {
- - + - ]
1966 [ + - ]: 1 : failConnection(
1967 : 2 : QStringLiteral("Unexpected data before TLS handshake"));
1968 : 1 : return;
1969 : : }
1970 [ # # # # : 0 : qCInfo(lcImap) << "STARTTLS accepted, starting TLS handshake...";
# # # # ]
1971 : : // Upgrade connection to TLS
1972 : 0 : QObject::connect(
1973 : 0 : m_socket, &QSslSocket::encrypted, this,
1974 [ # # ]: 0 : [this]() {
1975 [ # # # # : 0 : qCInfo(lcImap) << "STARTTLS handshake complete";
# # # # ]
1976 : : // Now login
1977 : 0 : beginLogin();
1978 : 0 : },
1979 : : Qt::SingleShotConnection);
1980 [ # # ]: 0 : m_socket->startClientEncryption();
1981 : : } else {
1982 [ # # # # ]: 0 : failConnection("STARTTLS failed: " + response->message);
1983 : : }
1984 : 0 : return;
1985 : : }
1986 : :
1987 [ + + ]: 602 : if (commandType == "LOGIN") {
1988 [ + - ]: 10 : clearCredentials();
1989 [ + + ]: 10 : if (response->status == "OK") {
1990 [ + - + - : 18 : qCInfo(lcImap) << "Login successful";
+ - + + ]
1991 [ + - ]: 9 : setState(State::Authenticated);
1992 : : // SEC-2026-07-21-12: Session setup complete — stop the setup timer.
1993 : : // Command timeout/deadline timers take over from here.
1994 [ + - ]: 9 : m_timeoutTimer->stop();
1995 : : } else {
1996 [ + - + - ]: 1 : failConnection("Authentication failed: " + response->message);
1997 : : }
1998 : 10 : return;
1999 : : }
2000 : :
2001 [ + + ]: 592 : if (commandType == "LIST") {
2002 [ + - ]: 9 : if (response->status == "OK") {
2003 [ + - + - : 18 : qCInfo(lcImap) << "LIST complete:" << m_pendingFolders.size()
+ - + - +
+ ]
2004 [ + - ]: 9 : << "folders";
2005 [ + - ]: 9 : emit folderListReceived(m_pendingFolders);
2006 [ + - ]: 9 : m_pendingFolders.clear();
2007 : : } else {
2008 [ # # # # ]: 0 : emit errorOccurred("LIST failed: " + response->message);
2009 : : }
2010 : 9 : return;
2011 : : }
2012 : :
2013 [ + + ]: 583 : if (commandType == "SELECT") {
2014 [ + + ]: 124 : if (response->status == "OK") {
2015 : : // A pipelined body response is valid only after this SELECT succeeds.
2016 : 121 : m_bodyFetchRequiresSelect = false;
2017 : : // Bug 34: Only assign m_selectedFolder on SELECT OK
2018 : 121 : m_selectedFolder = m_pendingSelectFolder;
2019 [ + - + - : 242 : qCInfo(lcImap) << "SELECT complete:" << m_selectedFolder
+ - + - +
+ ]
2020 [ + - + - ]: 121 : << "msgs=" << m_selectedMessageCount
2021 [ + - + - ]: 121 : << "uidvalidity=" << m_selectedUidValidity;
2022 [ + - ]: 121 : setState(State::Selected);
2023 [ + - ]: 121 : emit folderSelected(m_selectedFolder, m_selectedMessageCount,
2024 : : m_selectedUidValidity, m_selectedHighestModseq);
2025 : : } else {
2026 [ + - + - ]: 3 : emit errorOccurred("SELECT failed: " + response->message);
2027 [ + + ]: 3 : if (m_bodyFetchRequiresSelect)
2028 [ + - ]: 1 : invalidateBodyFetch();
2029 : : // Notify multi-folder flows so they can skip this folder instead of
2030 : : // stalling (real servers reject \Noselect container folders here).
2031 [ + - ]: 3 : emit folderSelectFailed(m_pendingSelectFolder);
2032 : : }
2033 : : // T-401/Bug 10: Only drain deferred commands on SELECT success
2034 [ + + + + : 124 : if (response->status == "OK" && !m_deferredCommands.isEmpty()) {
+ + ]
2035 [ + - ]: 4 : auto cmd = m_deferredCommands.dequeue();
2036 [ + - ]: 4 : cmd();
2037 [ + + ]: 124 : } else if (response->status != "OK") {
2038 : : // Discard deferred commands — they'd operate on wrong folder
2039 [ + + ]: 3 : if (!m_deferredCommands.isEmpty()) {
2040 [ + - + - : 2 : qCWarning(lcImap) << "SELECT failed — discarding"
+ - + + ]
2041 [ + - ]: 1 : << m_deferredCommands.size()
2042 [ + - ]: 1 : << "deferred commands";
2043 [ + - ]: 1 : m_deferredCommands.clear();
2044 : : }
2045 : : }
2046 : 124 : return;
2047 : : }
2048 : :
2049 [ + + ]: 459 : if (commandType == "FETCH_HEADERS") {
2050 [ + - ]: 15 : if (response->status == "OK") {
2051 [ + - + - : 30 : qCInfo(lcImap) << "FETCH headers complete:" << m_pendingHeaders.size()
+ - + - +
+ ]
2052 [ + - ]: 15 : << "remaining";
2053 : : // Emit any remaining buffered headers
2054 [ + + ]: 15 : if (!m_pendingHeaders.isEmpty()) {
2055 [ + - ]: 11 : emit headersReceived(m_pendingHeaders);
2056 [ + - ]: 11 : m_pendingHeaders.clear();
2057 : : }
2058 : : // Signal that all header batches have been delivered
2059 [ + - ]: 15 : emit headerFetchComplete();
2060 : : } else {
2061 [ # # # # ]: 0 : emit errorOccurred("FETCH headers failed: " + response->message);
2062 : : }
2063 : 15 : return;
2064 : : }
2065 : :
2066 [ + + ]: 444 : if (commandType == "FETCH_BODY") {
2067 : 61 : m_activeBodyFetchUid = -1;
2068 : 61 : m_activeBodyFetchLimit = 0;
2069 : 61 : m_activeBodyFetchRequestBytes = 0;
2070 : 61 : m_bodyFetchRequiresSelect = false;
2071 [ - + ]: 61 : if (response->status != "OK") {
2072 [ # # # # ]: 0 : emit errorOccurred("FETCH body failed: " + response->message);
2073 : : }
2074 : : // Body is emitted in handleUntagged when the data arrives
2075 : : // Drain deferred commands (e.g. next body fetch in search mode)
2076 [ + + ]: 61 : if (!m_deferredCommands.isEmpty()) {
2077 [ + - ]: 1 : auto cmd = m_deferredCommands.dequeue();
2078 [ + - ]: 1 : cmd();
2079 : 1 : } else {
2080 : : // No deferred commands — restart IDLE if possible
2081 : : // (STORE handler restarts IDLE for markSeen; this covers already-seen mails)
2082 [ + - ]: 60 : restartPush();
2083 : : }
2084 : 61 : return;
2085 : : }
2086 : :
2087 [ + + ]: 383 : if (commandType == "FETCH_FLAGS") {
2088 [ + - ]: 55 : if (response->status == "OK") {
2089 [ + - + - : 110 : qCInfo(lcImap) << "FETCH flags complete:" << m_pendingFlags.size()
+ - + - +
+ ]
2090 [ + - ]: 55 : << "entries";
2091 [ + - ]: 55 : emit flagsReceived(m_pendingFlags);
2092 [ + - ]: 55 : m_pendingFlags.clear();
2093 : : } else {
2094 [ # # # # ]: 0 : emit errorOccurred("FETCH flags failed: " + response->message);
2095 : : }
2096 : 55 : return;
2097 : : }
2098 : :
2099 [ + + ]: 328 : if (commandType == "SEARCH") {
2100 [ + + ]: 92 : if (response->status == "OK") {
2101 [ + - + - : 182 : qCInfo(lcImap) << "SEARCH complete:" << m_pendingSearchUids.size()
+ - + - +
+ ]
2102 [ + - ]: 91 : << "UIDs";
2103 [ + - ]: 91 : emit searchResultReceived(m_pendingSearchUids);
2104 [ + - ]: 91 : m_pendingSearchUids.clear();
2105 : : } else {
2106 [ + - + - : 2 : qCWarning(lcImap) << "SEARCH failed:" << response->message;
+ - + - +
+ ]
2107 : : // Still emit a (empty) result so multi-folder search flows advance
2108 : : // instead of waiting forever for a result that will never arrive.
2109 [ + - ]: 1 : m_pendingSearchUids.clear();
2110 [ + - ]: 1 : emit searchResultReceived(m_pendingSearchUids);
2111 : : }
2112 : : // Drain deferred commands (e.g. body fetch queued during SEARCH)
2113 [ + + ]: 92 : if (!m_deferredCommands.isEmpty()) {
2114 [ + - ]: 4 : auto cmd = m_deferredCommands.dequeue();
2115 [ + - ]: 4 : cmd();
2116 : 4 : }
2117 : 92 : return;
2118 : : }
2119 : :
2120 : : // T-065 fix: Single-UID flag fetch (from IDLE seqNo resolution).
2121 : : // Emit each result directly as idleFlagsChanged → targeted update, no purge.
2122 [ + + ]: 236 : if (commandType == "FETCH_SEQNO") {
2123 [ + + ]: 3 : if (response->status == "OK") {
2124 [ + - + - : 4 : qCInfo(lcImap) << "FETCH_SEQNO complete:" << m_pendingFlags.size()
+ - + - +
+ ]
2125 [ + - ]: 2 : << "entries";
2126 [ + - + - : 4 : for (const auto &[uid, flags] : m_pendingFlags) {
+ + ]
2127 [ + - ]: 2 : emit idleFlagsChanged(uid, flags);
2128 : : }
2129 [ + - ]: 2 : m_pendingFlags.clear();
2130 : : } else {
2131 [ + - + - ]: 1 : emit errorOccurred("FETCH seqNo failed: " + response->message);
2132 : : }
2133 : : // Drain remaining deferred commands (e.g. more flag refetches from
2134 : : // multiple IDLE pushes) before restarting IDLE.
2135 [ + + ]: 3 : if (!m_deferredCommands.isEmpty()) {
2136 [ + - ]: 1 : auto cmd = m_deferredCommands.dequeue();
2137 [ + - ]: 1 : cmd();
2138 : 1 : } else {
2139 [ + - ]: 2 : restartPush();
2140 : : }
2141 : 3 : return;
2142 : : }
2143 : :
2144 [ + + ]: 233 : if (commandType == "IDLE") {
2145 : 83 : m_isIdling = false;
2146 [ + - ]: 83 : setState(State::Selected);
2147 [ + + ]: 83 : if (response->status == "OK") {
2148 : : // T-720: The DONE/OK round-trip (renew, executeAfterIdle, or liveness
2149 : : // probe) proved the connection is alive — disarm its watchdog.
2150 [ + - ]: 81 : m_idleRenewWatchdog->stop();
2151 [ + - + - : 162 : qCInfo(lcImap) << "IDLE ended normally";
+ - + + ]
2152 : : // Process deferred commands first
2153 [ + + ]: 81 : if (!m_deferredCommands.isEmpty()) {
2154 [ + - ]: 74 : auto cmd = m_deferredCommands.dequeue();
2155 [ + - ]: 74 : cmd();
2156 [ + - + + ]: 81 : } else if (!m_idleRenewTimer->isActive()) {
2157 : : // Timer expired = renew, restart IDLE
2158 [ + - ]: 6 : restartPush();
2159 : : }
2160 : : // Timer still active + no deferred = manual stop, don't restart
2161 : : } else {
2162 : : // T-720: Treat a non-OK IDLE response as a dead connection. The old
2163 : : // code only drained deferred commands and left the socket nominally
2164 : : // open — failing the sprint's dead-socket detection goal and leaving
2165 : : // the watchdog disarmed.
2166 [ + - + - : 4 : qCWarning(lcImap) << "IDLE rejected by server:" << response->message
+ - + - +
+ ]
2167 [ + - ]: 2 : << "— failing connection";
2168 [ + - ]: 2 : m_idleRenewWatchdog->stop();
2169 [ + - ]: 2 : failConnection(
2170 [ + - ]: 6 : QStringLiteral("IDLE rejected by server: %1").arg(response->message));
2171 : 2 : return;
2172 : : }
2173 : 81 : return;
2174 : : }
2175 : :
2176 : : // T-320: NOTIFY SET response
2177 [ + + ]: 150 : if (commandType == "NOTIFY") {
2178 [ + - ]: 1 : if (response->status == "OK") {
2179 [ + - + - : 2 : qCInfo(lcImap) << "NOTIFY active — watching"
+ - + + ]
2180 [ + - + - ]: 1 : << m_notifyFolders.size() << "folders";
2181 : : } else {
2182 [ # # # # : 0 : qCWarning(lcImap) << "NOTIFY failed:" << response->message;
# # # # #
# ]
2183 : 0 : m_isNotifying = false;
2184 : : // Fallback: try IDLE instead
2185 [ # # # # : 0 : if (m_autoIdle && hasIdleCapability()) {
# # # # ]
2186 [ # # # # : 0 : qCInfo(lcImap) << "Falling back to IDLE";
# # # # ]
2187 [ # # ]: 0 : startIdle();
2188 : : }
2189 : : }
2190 : 1 : return;
2191 : : }
2192 : :
2193 : : // T-320: NOTIFY NONE response
2194 [ + + ]: 149 : if (commandType == "NOTIFY_NONE") {
2195 : 2 : m_isNotifying = false;
2196 [ + + ]: 2 : if (response->status == "OK") {
2197 [ + - + - : 2 : qCInfo(lcImap) << "NOTIFY stopped";
+ - + + ]
2198 [ + - ]: 1 : if (!m_deferredCommands.isEmpty()) {
2199 [ + - ]: 1 : auto cmd = m_deferredCommands.dequeue();
2200 [ + - ]: 1 : cmd();
2201 : 1 : }
2202 : : } else {
2203 [ + - + - : 2 : qCWarning(lcImap) << "NOTIFY NONE failed:" << response->message;
+ - + - +
+ ]
2204 [ + - ]: 1 : if (!m_deferredCommands.isEmpty()) {
2205 [ + - ]: 1 : auto cmd = m_deferredCommands.dequeue();
2206 [ + - ]: 1 : cmd();
2207 : 1 : }
2208 : : }
2209 : 2 : return;
2210 : : }
2211 : :
2212 [ + + ]: 147 : if (commandType == "STATUS") {
2213 : : // STATUS responses are handled in handleUntagged
2214 [ - + ]: 12 : if (response->status != "OK") {
2215 : : // Non-fatal: folder may not exist (e.g. dovecot/sieve) – log, don't alarm
2216 [ # # # # : 0 : qCWarning(lcImap) << "STATUS failed for" << m_pendingStatusFolder
# # # # #
# ]
2217 [ # # # # ]: 0 : << ":" << response->message;
2218 : : }
2219 : 12 : m_pendingStatusFolder.clear();
2220 : 12 : return;
2221 : : }
2222 : :
2223 [ + + ]: 135 : if (commandType == "STORE") {
2224 [ + - ]: 42 : if (response->status == "OK") {
2225 [ + - ]: 42 : emit storeComplete();
2226 : : } else {
2227 [ # # # # ]: 0 : emit errorOccurred("STORE failed: " + response->message);
2228 : : }
2229 : : // Drain remaining deferred commands, then restart IDLE/NOTIFY
2230 [ + + ]: 42 : if (!m_deferredCommands.isEmpty()) {
2231 [ + - ]: 6 : auto cmd = m_deferredCommands.dequeue();
2232 [ + - ]: 6 : cmd();
2233 : 6 : } else {
2234 [ + - ]: 36 : restartPush();
2235 : : }
2236 : 42 : return;
2237 : : }
2238 : :
2239 : : // T-100: MOVE command (RFC 6851)
2240 [ + + ]: 93 : if (commandType == "MOVE") {
2241 [ + - ]: 6 : if (response->status == "OK") {
2242 [ + - + - : 12 : qCInfo(lcImap) << "MOVE complete:" << m_pendingMoveUids.size()
+ - + - +
+ ]
2243 [ + - + - ]: 6 : << "UIDs to" << m_pendingMoveTarget;
2244 [ + - + - : 15 : for (qint64 uid : m_pendingMoveUids)
+ + ]
2245 [ + - ]: 9 : emit messageMoved(uid, m_pendingMoveTarget);
2246 [ + - ]: 6 : emit messagesMoved(m_pendingMoveUids, m_pendingMoveTarget);
2247 : : } else {
2248 [ # # # # : 0 : qCWarning(lcImap) << "MOVE failed:" << response->message;
# # # # #
# ]
2249 [ # # ]: 0 : emit moveError(response->message);
2250 : : }
2251 [ + - ]: 6 : m_pendingMoveUids.clear();
2252 : 6 : m_pendingMoveTarget.clear();
2253 [ + + ]: 6 : if (!m_deferredCommands.isEmpty()) {
2254 [ + - ]: 1 : auto cmd = m_deferredCommands.dequeue();
2255 [ + - ]: 1 : cmd();
2256 : 1 : } else {
2257 [ + - ]: 5 : restartPush();
2258 : : }
2259 : 6 : return;
2260 : : }
2261 : :
2262 : : // T-100: COPY (fallback for MOVE: COPY + DELETE + EXPUNGE)
2263 [ + + ]: 87 : if (commandType == "COPY") {
2264 [ + + ]: 3 : if (response->status == "OK") {
2265 [ + - + - : 4 : qCInfo(lcImap) << "COPY (move-fallback) complete, marking deleted...";
+ - + + ]
2266 : : // Step 2: mark originals as \Deleted
2267 : 2 : QStringList uidStrs;
2268 [ + - + - : 5 : for (qint64 u : m_pendingMoveUids)
+ + ]
2269 [ + - + - ]: 3 : uidStrs.append(QString::number(u));
2270 [ + - + - ]: 2 : sendCommand("STORE_DELETE",
2271 [ + - + - : 6 : QString("UID STORE %1 +FLAGS (\\Deleted)").arg(uidStrs.join(',')));
+ - ]
2272 : 2 : } else {
2273 [ + - + - : 2 : qCWarning(lcImap) << "COPY failed:" << response->message;
+ - + - +
+ ]
2274 [ + - ]: 1 : emit moveError(response->message);
2275 [ + - ]: 1 : m_pendingMoveUids.clear();
2276 : 1 : m_pendingMoveTarget.clear();
2277 [ + - ]: 1 : restartPush();
2278 : : }
2279 : 3 : return;
2280 : : }
2281 : :
2282 : : // T-100: STORE_DELETE (part of COPY+DELETE+EXPUNGE fallback)
2283 [ + + ]: 84 : if (commandType == "STORE_DELETE") {
2284 [ + + ]: 3 : if (response->status == "OK") {
2285 [ + - + - : 4 : qCInfo(lcImap) << "Marked deleted, expunging...";
+ - + + ]
2286 [ + - + - : 2 : sendCommand("EXPUNGE_MOVE", "EXPUNGE");
+ - ]
2287 : : } else {
2288 [ + - + - : 2 : qCWarning(lcImap) << "STORE DELETE failed:" << response->message;
+ - + - +
+ ]
2289 [ + - ]: 1 : emit moveError(response->message);
2290 [ + - ]: 1 : m_pendingMoveUids.clear();
2291 : 1 : m_pendingMoveTarget.clear();
2292 [ + - ]: 1 : restartPush();
2293 : : }
2294 : 3 : return;
2295 : : }
2296 : :
2297 : : // T-100: EXPUNGE after COPY+DELETE (completes fallback MOVE)
2298 [ + + ]: 81 : if (commandType == "EXPUNGE_MOVE") {
2299 [ + + ]: 5 : if (response->status == "OK") {
2300 [ + - + - : 6 : qCInfo(lcImap) << "EXPUNGE complete, move-fallback done:"
+ - + + ]
2301 [ + - + - ]: 3 : << m_pendingMoveUids.size() << "UIDs to"
2302 [ + - ]: 3 : << m_pendingMoveTarget;
2303 [ + - + - : 6 : for (qint64 uid : m_pendingMoveUids)
+ + ]
2304 [ + - ]: 3 : emit messageMoved(uid, m_pendingMoveTarget);
2305 [ + - ]: 3 : emit messagesMoved(m_pendingMoveUids, m_pendingMoveTarget);
2306 : : } else {
2307 [ + - + - : 4 : qCWarning(lcImap) << "EXPUNGE failed:" << response->message;
+ - + - +
+ ]
2308 [ + - ]: 2 : emit moveError(response->message);
2309 : : }
2310 [ + - ]: 5 : m_pendingMoveUids.clear();
2311 : 5 : m_pendingMoveTarget.clear();
2312 [ + + ]: 5 : if (!m_deferredCommands.isEmpty()) {
2313 [ + - ]: 2 : auto cmd = m_deferredCommands.dequeue();
2314 [ + - ]: 2 : cmd();
2315 : 2 : } else {
2316 [ + - ]: 3 : restartPush();
2317 : : }
2318 : 5 : return;
2319 : : }
2320 : :
2321 : : // T-100: Standalone COPY (not part of MOVE fallback)
2322 [ + + ]: 76 : if (commandType == "COPY_ONLY") {
2323 [ + + ]: 5 : if (response->status == "OK") {
2324 [ + + + - ]: 3 : auto uid = m_pendingMoveUids.isEmpty() ? -1 : m_pendingMoveUids.first();
2325 [ + - + - : 6 : qCInfo(lcImap) << "COPY complete: UID" << uid
+ - + - +
+ ]
2326 [ + - + - ]: 3 : << "to" << m_pendingMoveTarget;
2327 [ + - ]: 3 : emit messageCopied(uid, m_pendingMoveTarget);
2328 : : } else {
2329 [ + - + - ]: 2 : emit errorOccurred("COPY failed: " + response->message);
2330 : : }
2331 [ + - ]: 5 : m_pendingMoveUids.clear();
2332 : 5 : m_pendingMoveTarget.clear();
2333 [ + + ]: 5 : if (!m_deferredCommands.isEmpty()) {
2334 [ + - ]: 2 : auto cmd = m_deferredCommands.dequeue();
2335 [ + - ]: 2 : cmd();
2336 : 2 : } else {
2337 [ + - ]: 3 : restartPush();
2338 : : }
2339 : 5 : return;
2340 : : }
2341 : :
2342 : : // T-176: Standalone EXPUNGE (used by T-177 Drafts to delete old drafts)
2343 [ + + ]: 71 : if (commandType == "EXPUNGE") {
2344 [ + + ]: 3 : if (response->status == "OK") {
2345 [ + - + - : 4 : qCInfo(lcImap) << "EXPUNGE complete";
+ - + + ]
2346 [ + - ]: 2 : emit expungeComplete();
2347 : : } else {
2348 [ + - + - : 2 : qCWarning(lcImap) << "EXPUNGE failed:" << response->message;
+ - + - +
+ ]
2349 : : }
2350 [ + + ]: 3 : if (!m_deferredCommands.isEmpty()) {
2351 [ + - ]: 2 : auto cmd = m_deferredCommands.dequeue();
2352 [ + - ]: 2 : cmd();
2353 : 2 : } else {
2354 [ + - ]: 1 : restartPush();
2355 : : }
2356 : 3 : return;
2357 : : }
2358 : :
2359 : : // T-176: APPEND command
2360 [ + + ]: 68 : if (commandType == "APPEND") {
2361 [ + + ]: 9 : if (response->status == "OK") {
2362 : : // Parse APPENDUID if present: [APPENDUID <uidvalidity> <uid>]
2363 : 8 : qint64 appendedUid = 0;
2364 : : static QRegularExpression appendUidRx(
2365 [ + + + - : 8 : R"(\[APPENDUID\s+\d+\s+(\d+)\])", QRegularExpression::CaseInsensitiveOption);
+ - + - -
- ]
2366 [ + - ]: 8 : auto uidMatch = appendUidRx.match(response->message);
2367 [ + - + + ]: 8 : if (uidMatch.hasMatch()) {
2368 [ + - + - ]: 6 : appendedUid = uidMatch.captured(1).toLongLong();
2369 [ + - + - : 12 : qCInfo(lcImap) << "APPEND complete, APPENDUID:" << appendedUid
+ - + - +
+ ]
2370 [ + - + - ]: 6 : << "to" << m_pendingAppendFolder;
2371 : : } else {
2372 [ + - + - : 4 : qCInfo(lcImap) << "APPEND complete (no UIDPLUS) to"
+ - + + ]
2373 [ + - ]: 2 : << m_pendingAppendFolder;
2374 : : }
2375 [ + - ]: 8 : emit messageAppended(m_pendingAppendFolder, appendedUid);
2376 : 8 : } else {
2377 [ + - + - : 2 : qCWarning(lcImap) << "APPEND failed:" << response->message;
+ - + - +
+ ]
2378 [ + - ]: 1 : emit appendError(response->message);
2379 : : }
2380 : 9 : m_pendingAppendFolder.clear();
2381 [ + - ]: 9 : m_pendingAppendData.clear();
2382 [ + + ]: 9 : if (!m_deferredCommands.isEmpty()) {
2383 [ + - ]: 2 : auto cmd = m_deferredCommands.dequeue();
2384 [ + - ]: 2 : cmd();
2385 : 2 : } else {
2386 [ + - ]: 7 : restartPush();
2387 : : }
2388 : 9 : return;
2389 : : }
2390 : :
2391 : : // T-281: CREATE folder
2392 [ + + ]: 59 : if (commandType == "CREATE") {
2393 [ + + ]: 6 : if (response->status == "OK") {
2394 [ + - + - : 10 : qCInfo(lcImap) << "CREATE complete:" << m_pendingFolderOp;
+ - + - +
+ ]
2395 [ + - ]: 5 : emit folderCreated(m_pendingFolderOp);
2396 : : } else {
2397 [ + - + - : 2 : qCWarning(lcImap) << "CREATE failed:" << response->message;
+ - + - +
+ ]
2398 [ + - ]: 2 : emit folderOperationError(QStringLiteral("CREATE"), response->message);
2399 : : }
2400 : 6 : m_pendingFolderOp.clear();
2401 [ + + ]: 6 : if (!m_deferredCommands.isEmpty()) {
2402 [ + - ]: 2 : auto cmd = m_deferredCommands.dequeue();
2403 [ + - ]: 2 : cmd();
2404 : 2 : } else {
2405 [ + - ]: 4 : restartPush();
2406 : : }
2407 : 6 : return;
2408 : : }
2409 : :
2410 : : // T-281: DELETE folder
2411 [ + + ]: 53 : if (commandType == "DELETE") {
2412 [ + + ]: 4 : if (response->status == "OK") {
2413 [ + - + - : 6 : qCInfo(lcImap) << "DELETE complete:" << m_pendingFolderOp;
+ - + - +
+ ]
2414 [ + - ]: 3 : emit folderDeleted(m_pendingFolderOp);
2415 : : } else {
2416 [ + - + - : 2 : qCWarning(lcImap) << "DELETE failed:" << response->message;
+ - + - +
+ ]
2417 [ + - ]: 2 : emit folderOperationError(QStringLiteral("DELETE"), response->message);
2418 : : }
2419 : 4 : m_pendingFolderOp.clear();
2420 [ + + ]: 4 : if (!m_deferredCommands.isEmpty()) {
2421 [ + - ]: 2 : auto cmd = m_deferredCommands.dequeue();
2422 [ + - ]: 2 : cmd();
2423 : 2 : } else {
2424 [ + - ]: 2 : restartPush();
2425 : : }
2426 : 4 : return;
2427 : : }
2428 : :
2429 : : // T-281: RENAME folder
2430 [ + + ]: 49 : if (commandType == "RENAME") {
2431 [ + + ]: 4 : if (response->status == "OK") {
2432 [ + - + - : 6 : qCInfo(lcImap) << "RENAME complete:" << m_pendingFolderOp
+ - + - +
+ ]
2433 [ + - + - ]: 3 : << "->" << m_pendingFolderNewPath;
2434 [ + - ]: 3 : emit folderRenamed(m_pendingFolderOp, m_pendingFolderNewPath);
2435 : : } else {
2436 [ + - + - : 2 : qCWarning(lcImap) << "RENAME failed:" << response->message;
+ - + - +
+ ]
2437 [ + - ]: 2 : emit folderOperationError(QStringLiteral("RENAME"), response->message);
2438 : : }
2439 : 4 : m_pendingFolderOp.clear();
2440 : 4 : m_pendingFolderNewPath.clear();
2441 [ + + ]: 4 : if (!m_deferredCommands.isEmpty()) {
2442 [ + - ]: 2 : auto cmd = m_deferredCommands.dequeue();
2443 [ + - ]: 2 : cmd();
2444 : 2 : } else {
2445 [ + - ]: 2 : restartPush();
2446 : : }
2447 : 4 : return;
2448 : : }
2449 [ + + + + : 1749 : }
+ + ]
2450 : :
2451 : 51 : void ImapService::onSocketError(QAbstractSocket::SocketError error) {
2452 : : Q_UNUSED(error)
2453 [ + - ]: 51 : auto errorMsg = m_socket->errorString();
2454 [ + - + - : 102 : qCWarning(lcImap) << "Socket error:" << errorMsg;
+ - + - +
+ ]
2455 [ + - ]: 51 : failConnection(errorMsg);
2456 : 51 : }
2457 : :
2458 : 1 : void ImapService::onTimeout() {
2459 [ + - ]: 1 : failConnection(QStringLiteral("Connection timeout"));
2460 : 1 : }
2461 : :
2462 : 1 : void ImapService::onCommandTimeout() {
2463 [ + - - + ]: 1 : if (!hasTimeoutTrackedCommandInFlight()) {
2464 [ # # ]: 0 : m_commandTimeoutTimer->stop();
2465 : 0 : return;
2466 : : }
2467 : :
2468 : 1 : QStringList pending;
2469 [ + - + - : 2 : for (auto it = m_pendingCommands.cbegin(); it != m_pendingCommands.cend();
+ + ]
2470 : 1 : ++it) {
2471 [ + - ]: 1 : if (it.value() != QStringLiteral("IDLE"))
2472 [ + - + - ]: 1 : pending.append(QStringLiteral("%1:%2").arg(it.key(), it.value()));
2473 : : }
2474 : :
2475 [ + - ]: 1 : failConnection(
2476 : 2 : QStringLiteral("IMAP command timeout (inactivity): %1")
2477 [ + - + - ]: 2 : .arg(pending.join(',')));
2478 : 1 : }
2479 : :
2480 : 0 : void ImapService::onCommandDeadline() {
2481 [ # # # # ]: 0 : if (!hasTimeoutTrackedCommandInFlight()) {
2482 [ # # ]: 0 : m_commandDeadlineTimer->stop();
2483 : 0 : return;
2484 : : }
2485 : :
2486 : 0 : QStringList pending;
2487 [ # # # # : 0 : for (auto it = m_pendingCommands.cbegin(); it != m_pendingCommands.cend();
# # ]
2488 : 0 : ++it) {
2489 [ # # ]: 0 : if (it.value() != QStringLiteral("IDLE"))
2490 [ # # # # ]: 0 : pending.append(QStringLiteral("%1:%2").arg(it.key(), it.value()));
2491 : : }
2492 : :
2493 [ # # ]: 0 : failConnection(
2494 : 0 : QStringLiteral("IMAP command absolute deadline exceeded: %1")
2495 [ # # # # ]: 0 : .arg(pending.join(',')));
2496 : 0 : }
|