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