MailJDTourDownloadDocsQualityTestsCoverage

Which source lines and branches the tests execute. Lines 91.5 %, branches 75.0 % (minimum 85 % and 72 %). main @ 399ffe3d · 21 October 2026 · pipeline 7238.

GCC Code Coverage Report


Directory: src/
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 96.2% 902 / 0 / 938
Functions: 94.0% 142 / 0 / 151
Branches: 87.7% 1116 / 0 / 1273

service/ImapService.cpp
Line Branch Exec Source
1 #include "ImapService.h"
2 #include "ImapCommandTable.h"
3 #include "ImapConnectionSetup.h"
4 #include "ImapLineReader.h"
5 #include "ImapPushSession.h"
6 #include "ImapBodyFetch.h"
7 #include "ImapMutationCommands.h"
8 #include "ImapAppendStream.h"
9 #include "ImapResponseParser.h"
10
11 #include <QLoggingCategory>
12 #include <QMetaEnum>
13 #include <QRegularExpression>
14 #include <limits>
15 #include <QSet>
16 #include <QSettings>
17 #include <QSocketNotifier>
18
19 #include <algorithm>
20 #include <utility>
21 #include <chrono>
22
23 // The header fields every header sync requests besides the ENVELOPE:
24 // threading, List-Id (rules, search) and the server spam filter's fields
25 // (Sprint 105 S105.2.2: SpamAssassin and Rspamd score lines, SpamHeaders).
26 static const QString kHeaderFieldsItem = QStringLiteral(
27 "BODY.PEEK[HEADER.FIELDS (In-Reply-To References List-Id X-Spam X-Spam-Status X-Spam-Flag "
28 "X-Spam-Score X-Spam-Level X-Spamd-Result X-Rspamd-Score X-Rspamd-Action)]");
29
30
4/7
✓ Branch 2 → 3 taken 32 times.
✓ Branch 2 → 8 taken 105300 times.
✓ Branch 4 → 5 taken 32 times.
✗ Branch 4 → 8 not taken.
✓ Branch 5 → 6 taken 32 times.
✗ Branch 10 → 11 not taken.
✗ Branch 10 → 12 not taken.
105332 Q_LOGGING_CATEGORY(lcImap, "mailjd.imap")
31
4/7
✓ Branch 2 → 3 taken 10 times.
✓ Branch 2 → 8 taken 366 times.
✓ Branch 4 → 5 taken 10 times.
✗ Branch 4 → 8 not taken.
✓ Branch 5 → 6 taken 10 times.
✗ Branch 10 → 11 not taken.
✗ Branch 10 → 12 not taken.
376 Q_LOGGING_CATEGORY(lcImapTiming, "mailjd.imap.timing")
32
33 1401 ImapService::ImapService(QObject *parent)
34
2/5
✓ Branch 4 → 5 taken 1401 times.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 1401 times.
✗ Branch 88 → 89 not taken.
✗ Branch 88 → 90 not taken.
1401 : QObject(parent), m_socket(new QSslSocket(this)),
35
1/1
✓ Branch 8 → 9 taken 1401 times.
1401 m_commands(createCommandTable()),
36
1/1
✓ Branch 9 → 10 taken 1401 times.
1401 m_setup(createConnectionSetup()),
37
1/1
✓ Branch 10 → 11 taken 1401 times.
1401 m_push(createPushSession()),
38
1/1
✓ Branch 11 → 12 taken 1401 times.
1401 m_body(createBodyFetch()),
39
1/1
✓ Branch 12 → 13 taken 1401 times.
1401 m_mutations(createMutationCommands()),
40
1/1
✓ Branch 13 → 14 taken 1401 times.
1401 m_append(createAppendStream()),
41
2/2
✓ Branch 3 → 4 taken 1401 times.
✓ Branch 23 → 24 taken 1401 times.
4203 m_reader(createLineReader()) {
42
1/1
✓ Branch 26 → 27 taken 1401 times.
1401 m_setup->setupTimer()->setSingleShot(true);
43
1/1
✓ Branch 29 → 30 taken 1401 times.
1401 m_commands->commandTimeoutTimer()->setSingleShot(true);
44
1/1
✓ Branch 32 → 33 taken 1401 times.
1401 m_commands->commandDeadlineTimer()->setSingleShot(true);
45
1/1
✓ Branch 35 → 36 taken 1401 times.
1401 m_commands->commandDeadlineTimer()->setTimerType(Qt::PreciseTimer);
46
1/1
✓ Branch 38 → 39 taken 1401 times.
1401 m_push->idleRenewTimer()->setSingleShot(true);
47
1/1
✓ Branch 41 → 42 taken 1401 times.
1401 m_push->livenessProbeWatchdog()->setSingleShot(true);
48
1/1
✓ Branch 44 → 45 taken 1401 times.
1401 m_push->idleRenewWatchdog()->setSingleShot(true);
49
50 // T-720/T-72.1: Enable SO_KEEPALIVE at the Qt level. Native interval
51 // tuning is applied in tuneKeepAlive() once the socket descriptor is
52 // valid (onConnected()/onEncrypted()). assertable on an unconnected
53 // socket per the sprint plan.
54
1/1
✓ Branch 46 → 47 taken 1401 times.
1401 m_socket->setSocketOption(QAbstractSocket::KeepAliveOption, 1);
55 // Keep encrypted socket buffering bounded as well as our parser buffer.
56 // Literals are consumed incrementally below, so this does not constrain
57 // legitimate messages up to the configured per-message limit.
58
1/1
✓ Branch 48 → 49 taken 1401 times.
1401 m_socket->setReadBufferSize(MAX_SOCKET_READ_BUFFER_SIZE);
59
60
1/1
✓ Branch 49 → 50 taken 1401 times.
1459 connect(m_socket, &QSslSocket::connected, this, [this] { m_setup->onConnected(); });
61
1/1
✓ Branch 51 → 52 taken 1401 times.
1452 connect(m_socket, &QSslSocket::encrypted, this, [this] { m_setup->onEncrypted(); });
62
1/1
✓ Branch 53 → 54 taken 1401 times.
4335 connect(m_socket, &QSslSocket::readyRead, this, [this] { m_reader->onReadyRead(); });
63 1401 connect(m_socket, &QSslSocket::bytesWritten, this,
64
1/1
✓ Branch 55 → 56 taken 1401 times.
1771 [this](qint64) { m_append->scheduleAppendLiteralPump(); });
65 1401 connect(m_socket, &QAbstractSocket::errorOccurred, this,
66
1/1
✓ Branch 57 → 58 taken 1401 times.
1401 &ImapService::onSocketError);
67 1401 connect(m_socket, &QAbstractSocket::disconnected, this,
68
1/1
✓ Branch 59 → 60 taken 1401 times.
1451 [this] { m_setup->clearCredentials(); });
69 // Bug 33: Log and forward SSL certificate errors
70 1401 connect(m_socket, &QSslSocket::sslErrors, this,
71
1/1
✓ Branch 61 → 62 taken 1401 times.
1401 [this](const QList<QSslError> &errors) {
72
2/2
✓ Branch 20 → 4 taken 81 times.
✓ Branch 20 → 21 taken 41 times.
122 for (const auto &e : errors)
73
7/7
✓ Branch 5 → 6 taken 81 times.
✓ Branch 9 → 10 taken 81 times.
✓ Branch 10 → 11 taken 81 times.
✓ Branch 11 → 12 taken 81 times.
✓ Branch 12 → 13 taken 81 times.
✓ Branch 17 → 7 taken 81 times.
✓ Branch 17 → 18 taken 81 times.
162 qCWarning(lcImap) << "SSL error:" << e.errorString();
74 // E2E testing: GreenMail uses a self-signed certificate.
75 // Qt rejects self-signed certs even if they're in the system
76 // trust store. Allow bypassing SSL errors via environment variable.
77 // T-607/SEC-03: SSL error bypass is now compile-time only.
78 // Only available when built with -DBUILD_E2E_TESTS=ON
79 // (CMake sets the MAILJD_E2E_TESTING define).
80 #ifdef MAILJD_E2E_TESTING
81
5/5
✓ Branch 21 → 22 taken 41 times.
✓ Branch 25 → 26 taken 41 times.
✓ Branch 26 → 27 taken 41 times.
✓ Branch 30 → 23 taken 41 times.
✓ Branch 30 → 31 taken 41 times.
82 qCWarning(lcImap) << "Ignoring SSL errors (E2E test build)";
82 41 m_socket->ignoreSslErrors();
83 #endif
84 41 });
85
1/1
✓ Branch 65 → 66 taken 1401 times.
1401 connect(m_setup->setupTimer(), &QTimer::timeout, this, &ImapService::onTimeout);
86
1/1
✓ Branch 69 → 70 taken 1401 times.
1401 connect(m_commands->commandTimeoutTimer(), &QTimer::timeout, this,
87 1401 [this] { m_commands->onCommandTimeout(); });
88
1/1
✓ Branch 73 → 74 taken 1401 times.
1401 connect(m_commands->commandDeadlineTimer(), &QTimer::timeout, this,
89 1401 [this] { m_commands->onCommandDeadline(); });
90
1/1
✓ Branch 77 → 78 taken 1401 times.
1401 connect(m_push->idleRenewTimer(), &QTimer::timeout, this, [this] { m_push->onIdleRenew(); });
91 // T-720: Watchdog handlers — both end in failConnection() so a probe
92 // or IDLE renew that the server never ACKs cannot stall the monitor.
93
1/1
✓ Branch 81 → 82 taken 1401 times.
1401 connect(m_push->livenessProbeWatchdog(), &QTimer::timeout, this,
94 1401 [this] { m_push->onLivenessProbeTimeout(); });
95
1/1
✓ Branch 85 → 86 taken 1401 times.
1401 connect(m_push->idleRenewWatchdog(), &QTimer::timeout, this,
96 1401 [this] { m_push->onIdleRenewWatchdogTimeout(); });
97 1401 }
98
99 35 quint64 ImapService::appendMessage(const QString &folder, const QByteArray &rfcMessage,
100 const QString &flags, qint64 mutationToken) {
101 35 return m_append->appendMessage(folder, rfcMessage, flags, mutationToken);
102 }
103 4 quint64 ImapService::reserveAppendRequestId() { return m_append->reserveAppendRequestId(); }
104 10 void ImapService::appendMessage(quint64 requestId, const QString &folder,
105 const QByteArray &rfcMessage, const QString &flags,
106 qint64 mutationToken) {
107 10 m_append->appendMessage(requestId, folder, rfcMessage, flags, mutationToken);
108 10 }
109
110 1 void ImapService::markSeen(qint64 uid) { m_mutations->markSeen(uid); }
111 1 void ImapService::markUnseen(qint64 uid) { m_mutations->markUnseen(uid); }
112 33 void ImapService::storeFlag(qint64 uid, const QString &flag, bool add, qint64 mutationToken) {
113 33 m_mutations->storeFlag(uid, flag, add, mutationToken);
114 33 }
115 7 void ImapService::moveMessage(qint64 uid, const QString &targetFolder, qint64 mutationToken) {
116 7 m_mutations->moveMessage(uid, targetFolder, mutationToken);
117 7 }
118 16 void ImapService::moveMessages(const QList<qint64> &uids, const QString &targetFolder,
119 qint64 mutationToken) {
120 16 m_mutations->moveMessages(uids, targetFolder, mutationToken);
121 16 }
122 9 void ImapService::moveMessageSet(const QList<qint64> &uids, const QString &targetFolder,
123 const QList<qint64> &mutationTokens) {
124 9 m_mutations->moveMessageSet(uids, targetFolder, mutationTokens);
125 9 }
126 6 void ImapService::copyMessage(qint64 uid, const QString &targetFolder, qint64 mutationToken) {
127 6 m_mutations->copyMessage(uid, targetFolder, mutationToken);
128 6 }
129 12 void ImapService::expungeUids(const QList<qint64> &uids, const QString &folder,
130 quint32 uidValidity) {
131 12 m_mutations->expungeUids(uids, folder, uidValidity);
132 12 }
133 4 bool ImapService::hasUidExpungeCapability() const {
134 4 return m_mutations->hasUidExpungeCapability();
135 }
136
137 3 void ImapService::fetchBody(qint64 uid) { m_body->fetchBody(uid); }
138 34 void ImapService::fetchBody(qint64 uid, qint64 maxBytes) { m_body->fetchBody(uid, maxBytes); }
139 3 void ImapService::requestBody(quint64 requestId, qint64 uid) {
140 3 m_body->requestBody(requestId, uid);
141 3 }
142 182 qint64 ImapService::configuredMaxMessageBytes() {
143 182 return ImapBodyFetch::configuredMaxMessageBytes();
144 }
145 3 void ImapService::selectAndFetchBody(const QString &folderPath, qint64 uid) {
146 3 m_body->selectAndFetchBody(folderPath, uid);
147 3 }
148 15 void ImapService::requestBodyInFolder(quint64 requestId, const QString &folderPath,
149 qint64 uid) {
150 15 m_body->requestBodyInFolder(requestId, folderPath, uid);
151 15 }
152 #ifdef MAILJD_E2E_TESTING
153 1 void ImapService::setNextBodyCompletionForE2e(int delayMs, bool fail,
154 const QString &folderPath, qint64 uid) {
155 1 m_body->setNextBodyCompletionForE2e(delayMs, fail, folderPath, uid);
156 1 }
157 #endif
158
159 78 void ImapService::startIdle() { m_push->startIdle(); }
160 4 void ImapService::stopIdle() { m_push->stopIdle(); }
161 62 bool ImapService::isIdling() const { return m_push->isIdling(); }
162 9 void ImapService::startNotify(const QStringList &subscribedFolders) {
163 9 m_push->startNotify(subscribedFolders);
164 9 }
165 6 void ImapService::stopNotify() { m_push->stopNotify(); }
166 82 bool ImapService::isNotifying() const { return m_push->isNotifying(); }
167 97 bool ImapService::usesNotifyPush() const { return m_push->usesNotifyPush(); }
168 3 void ImapService::setAllowNotifyPush(bool allow) { m_push->allowNotifyPush() = allow; }
169 1 void ImapService::executeAfterNotify(std::function<void()> command) {
170
1/1
✓ Branch 5 → 6 taken 1 time.
1 m_push->executeAfterNotify(std::move(command));
171 1 }
172 84 void ImapService::setAutoIdle(bool enabled) { m_push->autoIdle() = enabled; }
173 4 void ImapService::sendNoop() { m_push->sendNoop(); }
174 24 void ImapService::requestLivenessProbe(const QString &reason) { m_push->requestLivenessProbe(reason); }
175 4 void ImapService::verifyAfterPathFailure(const QString &reason) { m_push->verifyAfterPathFailure(reason); }
176 7 bool ImapService::isLivenessProbeInFlight() const { return !m_push->probeTag().isEmpty(); }
177
178 3 bool ImapService::keepAliveTuned() const { return m_setup->keepAliveTuned(); }
179
180 ✗ quint64 ImapService::receivedBytes() const { return m_reader->receivedBytes(); }
181
182 2548 ImapService::~ImapService() {
183 // Block signals to prevent stateChanged emission during destruction.
184 // Without this, disconnect() → setState(Disconnected) → emits stateChanged,
185 // which can invoke connected lambdas on already-destroyed parent objects
186 // (e.g. MainWindow members) causing use-after-free crashes.
187 1401 blockSignals(true);
188 1401 disconnect(DisconnectReason::ApplicationShutdown);
189 2548 }
190
191 76 void ImapService::connectToServer(const ImapConfig &config) {
192
1/1
✓ Branch 6 → 7 taken 76 times.
152 m_mutations->completeOutstandingMutations(QStringLiteral("Connection replaced"));
193
4/4
✓ Branch 9 → 10 taken 7 times.
✓ Branch 9 → 22 taken 69 times.
✓ Branch 10 → 11 taken 2 times.
✓ Branch 10 → 22 taken 5 times.
76 if (m_state != State::Disconnected && m_state != State::Error) {
194
5/5
✓ Branch 11 → 12 taken 2 times.
✓ Branch 15 → 16 taken 2 times.
✓ Branch 16 → 17 taken 2 times.
✓ Branch 20 → 13 taken 2 times.
✓ Branch 20 → 21 taken 2 times.
4 qCWarning(lcImap) << "Already connected or connecting";
195 2 return;
196 }
197
198 74 emit connectionAttemptStarted();
199
200 // A reconnect must not overwrite a credential copy left by the previous
201 // attempt without zeroing it first.
202 74 m_setup->clearCredentials();
203 74 m_setup->config() = config;
204 74 m_setup->keepAliveTuned() = false;
205 74 m_commands->tagCounter() = 0;
206 74 m_reader->resetFraming();
207 // Clear any queued work left over from a previous (failed) session so a
208 // reconnect from Error does not replay stale commands.
209 74 m_commands->clearCommands();
210 74 m_body->cancelParkedRequests(); // S106-001: the dropped queue held them
211 74 m_queuedStatusFolders.clear();
212 74 m_push->resetRoundTrip(); // S92.5.3: a new path may have another RTT
213 74 m_push->idleStartPending() = false;
214 74 m_pendingFolders.clear();
215 74 m_pendingHeaders.clear();
216 74 m_pendingFlags.clear();
217 74 m_pendingFlagSnapshots.clear();
218 74 m_pendingSearchUids.clear();
219 74 m_capabilities.clear();
220 74 m_setup->pendingCapabilities().clear();
221 74 m_setup->setupPhase() = SetupPhase::AwaitingGreeting;
222 74 m_selectedFolder.clear();
223 74 m_selectedMessageCount = 0;
224 74 m_selectedUidValidity = 0;
225 // T-79.A1/H1: a stale APPEND payload must never survive into a new session,
226 // or the next "+" continuation writes mail text into the command stream.
227 74 m_append->resetForSession();
228 74 m_body->resetActiveRequest();
229 74 m_push->isIdling() = false;
230 74 m_push->idleTag().clear();
231 74 m_push->isNotifying() = false;
232 74 m_push->notifyTag().clear();
233
234 74 setState(State::Connecting);
235 74 m_setup->setupTimer()->start(TIMEOUT_MS);
236 #ifdef MAILJD_E2E_TESTING
237
1/1
✓ Branch 76 → 77 taken 62 times.
62 reportE2eTransportPhase(QStringLiteral("tcp-connect"));
238 #endif
239
240
3/4
✓ Branch 74 → 75 taken 12 times.
✗ Branch 74 → 91 not taken.
✓ Branch 80 → 81 taken 58 times.
✓ Branch 80 → 97 taken 4 times.
74 if (config.security == "ssl") {
241
12/12
✓ Branch 75 → 76 taken 12 times.
✓ Branch 79 → 80 taken 12 times.
✓ Branch 80 → 81 taken 12 times.
✓ Branch 81 → 82 taken 70 times.
✓ Branch 82 → 83 taken 12 times.
✓ Branch 85 → 86 taken 58 times.
✓ Branch 86 → 87 taken 58 times.
✓ Branch 87 → 77 taken 12 times.
✓ Branch 87 → 88 taken 70 times.
✓ Branch 88 → 89 taken 58 times.
✓ Branch 93 → 83 taken 58 times.
✓ Branch 93 → 94 taken 58 times.
140 qCInfo(lcImap) << "Connecting via SSL to" << config.host << ":"
242
2/2
✓ Branch 83 → 84 taken 12 times.
✓ Branch 89 → 90 taken 58 times.
70 << config.port;
243
2/2
✓ Branch 89 → 90 taken 12 times.
✓ Branch 95 → 96 taken 58 times.
70 m_socket->connectToHostEncrypted(config.host, config.port);
244 } else {
245
7/12
✗ Branch 91 → 92 not taken.
✗ Branch 95 → 96 not taken.
✗ Branch 96 → 97 not taken.
✓ Branch 97 → 98 taken 4 times.
✗ Branch 98 → 99 not taken.
✓ Branch 101 → 102 taken 4 times.
✓ Branch 102 → 103 taken 4 times.
✗ Branch 103 → 93 not taken.
✓ Branch 103 → 104 taken 4 times.
✓ Branch 104 → 105 taken 4 times.
✓ Branch 109 → 99 taken 4 times.
✓ Branch 109 → 110 taken 4 times.
8 qCInfo(lcImap) << "Connecting via STARTTLS to" << config.host << ":"
246
1/2
✗ Branch 99 → 100 not taken.
✓ Branch 105 → 106 taken 4 times.
4 << config.port;
247
1/2
✗ Branch 105 → 106 not taken.
✓ Branch 111 → 112 taken 4 times.
4 m_socket->connectToHost(config.host, config.port);
248 }
249 }
250
251 2342 void ImapService::disconnect(DisconnectReason reason) {
252 2342 emit disconnectReasonChanged(reason);
253
2/2
✓ Branch 4 → 5 taken 1424 times.
✓ Branch 4 → 6 taken 880 times.
2304 const bool deliberate = reason == DisconnectReason::UserRequested ||
254
4/4
✓ Branch 3 → 4 taken 2304 times.
✓ Branch 3 → 6 taken 38 times.
✓ Branch 5 → 6 taken 1422 times.
✓ Branch 5 → 7 taken 2 times.
4646 reason == DisconnectReason::AccountReload ||
255 reason == DisconnectReason::ApplicationShutdown;
256
2/2
✓ Branch 8 → 9 taken 2340 times.
✓ Branch 8 → 10 taken 2 times.
2342 if (deliberate)
257 2340 emit intentionalDisconnect();
258
1/1
✓ Branch 15 → 16 taken 2342 times.
4684 m_body->completeBodyRequest(BodyOutcome::Cancelled, {},
259 4684 QStringLiteral("Connection closed"));
260
1/1
✓ Branch 23 → 24 taken 2342 times.
4684 m_mutations->completeOutstandingMutations(QStringLiteral("Connection closed"));
261 2342 m_setup->setupTimer()->stop();
262 2342 m_commands->commandTimeoutTimer()->stop();
263 2342 m_commands->commandDeadlineTimer()->stop();
264 // T-720: Stop the probe/IDLE-renew watchdogs on explicit teardown so
265 // they cannot fire against a torn-down socket.
266 2342 m_push->livenessProbeWatchdog()->stop();
267 2342 m_push->idleRenewWatchdog()->stop();
268 2342 m_push->probeTag().clear();
269 2342 const bool appendInFlight = m_append->literalPending();
270
2/2
✓ Branch 47 → 48 taken 78 times.
✓ Branch 47 → 70 taken 2264 times.
2342 if (m_socket->state() != QAbstractSocket::UnconnectedState) {
271 // A LOGOUT inside a pending literal would become message data. Close the
272 // socket immediately and let journal reconciliation own the outcome.
273
1/2
✗ Branch 48 → 49 not taken.
✓ Branch 48 → 50 taken 78 times.
78 if (appendInFlight) {
274 ✗ m_socket->abort();
275 } else {
276 // Try to send LOGOUT gracefully.
277
1/2
✓ Branch 50 → 51 taken 78 times.
✗ Branch 50 → 63 not taken.
78 if (reason != DisconnectReason::RemoteBye &&
278
4/4
✓ Branch 51 → 52 taken 61 times.
✓ Branch 51 → 53 taken 17 times.
✓ Branch 52 → 53 taken 40 times.
✓ Branch 52 → 63 taken 21 times.
78 (m_state == State::Authenticated || m_state == State::Selected)) {
279
1/1
✓ Branch 54 → 55 taken 57 times.
57 auto tag = m_commands->nextTag();
280
3/3
✓ Branch 55 → 56 taken 57 times.
✓ Branch 56 → 57 taken 57 times.
✓ Branch 57 → 58 taken 57 times.
57 m_socket->write((tag + " LOGOUT\r\n").toUtf8());
281
1/1
✓ Branch 60 → 61 taken 57 times.
57 m_socket->flush();
282 57 }
283
3/4
✓ Branch 63 → 64 taken 78 times.
✗ Branch 63 → 68 not taken.
✓ Branch 64 → 65 taken 61 times.
✓ Branch 64 → 68 taken 17 times.
78 if (deliberate && m_state != State::Authenticated &&
284
3/4
✓ Branch 65 → 66 taken 21 times.
✓ Branch 65 → 68 taken 40 times.
✓ Branch 66 → 67 taken 21 times.
✗ Branch 66 → 68 not taken.
61 m_state != State::Selected && m_state != State::Idling)
285 21 m_socket->abort(); // Cancel lookup/connect/TLS instead of finishing setup.
286 else
287 57 m_socket->disconnectFromHost();
288 }
289 }
290 // T-405/Bug 16: Reset all state variables to prevent corruption on reconnect
291 2342 m_push->isIdling() = false;
292 // T-79.A1/H1: drop any in-flight APPEND payload with the session
293 2342 m_append->resetForSession();
294 2342 m_reader->resetFraming();
295 2342 m_commands->clearCommands();
296 2342 m_body->cancelParkedRequests(); // S106-001: the dropped queue held them
297 2342 m_queuedStatusFolders.clear();
298 2342 m_push->idleStartPending() = false;
299 2342 m_pendingFolders.clear();
300 2342 m_pendingHeaders.clear();
301 2342 m_pendingFlags.clear();
302 2342 m_pendingFlagSnapshots.clear();
303 2342 m_pendingSearchUids.clear();
304 2342 m_capabilities.clear();
305 2342 m_setup->pendingCapabilities().clear();
306 2342 m_setup->setupPhase() = SetupPhase::AwaitingGreeting;
307 2342 m_body->resetActiveRequest();
308 2342 m_setup->clearCredentials();
309 2342 setState(State::Disconnected);
310 2342 }
311
312 23 void ImapService::listFolders() {
313
4/4
✓ Branch 2 → 3 taken 5 times.
✓ Branch 2 → 15 taken 18 times.
✓ Branch 3 → 4 taken 3 times.
✓ Branch 3 → 15 taken 2 times.
23 if (m_state != State::Authenticated && m_state != State::Selected) {
314
5/5
✓ Branch 4 → 5 taken 3 times.
✓ Branch 8 → 9 taken 3 times.
✓ Branch 9 → 10 taken 3 times.
✓ Branch 13 → 6 taken 3 times.
✓ Branch 13 → 14 taken 3 times.
6 qCWarning(lcImap) << "Cannot list folders: not authenticated";
315 3 return;
316 }
317
2/2
✓ Branch 17 → 18 taken 1 time.
✓ Branch 17 → 23 taken 19 times.
20 if (m_commands->hasStatefulCommandInFlight()) {
318
1/1
✓ Branch 20 → 21 taken 1 time.
2 m_commands->enqueueSerializedCommand([this]() { listFolders(); });
319 1 return;
320 }
321
322 19 m_pendingFolders.clear();
323
3/3
✓ Branch 25 → 26 taken 19 times.
✓ Branch 26 → 27 taken 19 times.
✓ Branch 27 → 28 taken 19 times.
19 m_commands->sendCommand("LIST", R"(LIST "" "*")");
324 }
325
326 78 void ImapService::selectFolder(const QString &folderPath) {
327
4/4
✓ Branch 2 → 3 taken 27 times.
✓ Branch 2 → 15 taken 51 times.
✓ Branch 3 → 4 taken 9 times.
✓ Branch 3 → 15 taken 18 times.
78 if (m_state != State::Authenticated && m_state != State::Selected) {
328
5/5
✓ Branch 4 → 5 taken 9 times.
✓ Branch 8 → 9 taken 9 times.
✓ Branch 9 → 10 taken 9 times.
✓ Branch 13 → 6 taken 9 times.
✓ Branch 13 → 14 taken 9 times.
18 qCWarning(lcImap) << "Cannot select folder: not authenticated";
329 27 return;
330 }
331
2/3
✓ Branch 16 → 17 taken 69 times.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 20 taken 69 times.
69 if (!m_push->probeBeforeUse("SELECT"))
332 ✗ return selectFolder(folderPath);
333
3/3
✓ Branch 21 → 22 taken 69 times.
✓ Branch 22 → 23 taken 18 times.
✓ Branch 22 → 30 taken 51 times.
69 if (m_commands->hasStatefulCommandInFlight()) {
334
2/2
✓ Branch 25 → 26 taken 18 times.
✓ Branch 26 → 27 taken 18 times.
20 m_commands->enqueueSerializedCommand([this, folderPath]() { selectFolder(folderPath); });
335 18 return;
336 }
337
338 51 ImapPendingCommand select; // Bug 34: selected only on SELECT OK
339 51 select.selectFolder = folderPath;
340 51 m_selectedMessageCount = 0;
341 51 m_selectedUidValidity = 0;
342 // T-79.A2/M11: reset like selectAndFetchFlags/-Body — otherwise a folder
343 // whose OK carries no HIGHESTMODSEQ reports the previous folder's value.
344 51 m_selectedHighestModseq = 0;
345
5/5
✓ Branch 34 → 35 taken 51 times.
✓ Branch 36 → 37 taken 51 times.
✓ Branch 37 → 38 taken 51 times.
✓ Branch 38 → 39 taken 51 times.
✓ Branch 39 → 40 taken 51 times.
153 m_commands->sendCommand("SELECT", QString("SELECT %1").arg(quoteMailboxName(folderPath)),
346 51 std::move(select));
347 51 }
348
349 7 void ImapService::fetchHeaders(qint64 uidFrom) {
350
2/2
✓ Branch 2 → 3 taken 4 times.
✓ Branch 2 → 14 taken 3 times.
7 if (m_state != State::Selected) {
351
5/5
✓ Branch 3 → 4 taken 4 times.
✓ Branch 7 → 8 taken 4 times.
✓ Branch 8 → 9 taken 4 times.
✓ Branch 12 → 5 taken 4 times.
✓ Branch 12 → 13 taken 4 times.
8 qCWarning(lcImap) << "Cannot fetch: no folder selected";
352 4 return;
353 }
354
2/2
✓ Branch 16 → 17 taken 1 time.
✓ Branch 16 → 22 taken 2 times.
3 if (m_commands->hasStatefulCommandInFlight()) {
355
1/1
✓ Branch 19 → 20 taken 1 time.
2 m_commands->enqueueSerializedCommand([this, uidFrom]() { fetchHeaders(uidFrom); });
356 1 return;
357 }
358
359 2 m_pendingHeaders.clear();
360
2/2
✓ Branch 29 → 30 taken 2 times.
✓ Branch 30 → 31 taken 2 times.
6 m_commands->sendCommand(
361 "FETCH_HEADERS",
362
2/2
✓ Branch 27 → 28 taken 2 times.
✓ Branch 28 → 29 taken 2 times.
4 QStringLiteral("UID FETCH %1:* (UID FLAGS RFC822.SIZE INTERNALDATE ENVELOPE %2)").arg(QString::number(uidFrom), kHeaderFieldsItem));
363 }
364
365 // T-281: Create a new folder on the server (RFC 3501 §6.3.3)
366 13 void ImapService::createFolder(const QString &folderPath) {
367
4/4
✓ Branch 2 → 3 taken 10 times.
✓ Branch 2 → 16 taken 3 times.
✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 16 taken 6 times.
13 if (m_state != State::Authenticated && m_state != State::Selected) {
368
1/1
✓ Branch 10 → 11 taken 4 times.
4 emit folderOperationError(QStringLiteral("CREATE"),
369 8 QStringLiteral("Not authenticated"));
370 8 return;
371 }
372
3/3
✓ Branch 17 → 18 taken 9 times.
✓ Branch 18 → 19 taken 4 times.
✓ Branch 18 → 26 taken 5 times.
9 if (m_commands->hasStatefulCommandInFlight()) {
373
2/2
✓ Branch 21 → 22 taken 4 times.
✓ Branch 22 → 23 taken 4 times.
7 m_commands->enqueueSerializedCommand([this, folderPath]() { createFolder(folderPath); });
374 4 return;
375 }
376 5 ImapPendingCommand create;
377 5 create.folderPath = folderPath;
378
5/5
✓ Branch 30 → 31 taken 5 times.
✓ Branch 32 → 33 taken 5 times.
✓ Branch 33 → 34 taken 5 times.
✓ Branch 34 → 35 taken 5 times.
✓ Branch 35 → 36 taken 5 times.
15 m_commands->sendCommand("CREATE", QString("CREATE %1").arg(quoteMailboxName(folderPath)),
379 5 std::move(create));
380 5 }
381
382 // T-281: Delete a folder on the server (RFC 3501 §6.3.4)
383 9 void ImapService::deleteFolder(const QString &folderPath) {
384
4/4
✓ Branch 2 → 3 taken 4 times.
✓ Branch 2 → 16 taken 5 times.
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 16 taken 2 times.
9 if (m_state != State::Authenticated && m_state != State::Selected) {
385
1/1
✓ Branch 10 → 11 taken 2 times.
2 emit folderOperationError(QStringLiteral("DELETE"),
386 4 QStringLiteral("Not authenticated"));
387 6 return;
388 }
389
3/3
✓ Branch 17 → 18 taken 7 times.
✓ Branch 18 → 19 taken 4 times.
✓ Branch 18 → 26 taken 3 times.
7 if (m_commands->hasStatefulCommandInFlight()) {
390
2/2
✓ Branch 21 → 22 taken 4 times.
✓ Branch 22 → 23 taken 4 times.
7 m_commands->enqueueSerializedCommand([this, folderPath]() { deleteFolder(folderPath); });
391 4 return;
392 }
393 3 ImapPendingCommand remove;
394 3 remove.folderPath = folderPath;
395
5/5
✓ Branch 30 → 31 taken 3 times.
✓ Branch 32 → 33 taken 3 times.
✓ Branch 33 → 34 taken 3 times.
✓ Branch 34 → 35 taken 3 times.
✓ Branch 35 → 36 taken 3 times.
9 m_commands->sendCommand("DELETE", QString("DELETE %1").arg(quoteMailboxName(folderPath)),
396 3 std::move(remove));
397 3 }
398
399 // T-281: Rename (or move) a folder on the server (RFC 3501 §6.3.5)
400 9 void ImapService::renameFolder(const QString &oldPath, const QString &newPath) {
401
4/4
✓ Branch 2 → 3 taken 7 times.
✓ Branch 2 → 16 taken 2 times.
✓ Branch 3 → 4 taken 5 times.
✓ Branch 3 → 16 taken 2 times.
9 if (m_state != State::Authenticated && m_state != State::Selected) {
402
1/1
✓ Branch 10 → 11 taken 5 times.
5 emit folderOperationError(QStringLiteral("RENAME"),
403 10 QStringLiteral("Not authenticated"));
404 7 return;
405 }
406
3/3
✓ Branch 17 → 18 taken 4 times.
✓ Branch 18 → 19 taken 2 times.
✓ Branch 18 → 27 taken 2 times.
4 if (m_commands->hasStatefulCommandInFlight()) {
407
2/4
✓ Branch 22 → 23 taken 2 times.
✓ Branch 23 → 24 taken 2 times.
✗ Branch 64 → 65 not taken.
✗ Branch 64 → 66 not taken.
2 m_commands->enqueueSerializedCommand([this, oldPath, newPath]() {
408 2 renameFolder(oldPath, newPath);
409 2 });
410 2 return;
411 }
412 2 ImapPendingCommand rename;
413 2 rename.folderPath = oldPath;
414 2 rename.folderNewPath = newPath;
415
3/3
✓ Branch 32 → 33 taken 2 times.
✓ Branch 36 → 37 taken 2 times.
✓ Branch 37 → 38 taken 2 times.
6 m_commands->sendCommand("RENAME", QString("RENAME %1 %2")
416
3/3
✓ Branch 33 → 34 taken 2 times.
✓ Branch 34 → 35 taken 2 times.
✓ Branch 35 → 36 taken 2 times.
6 .arg(quoteMailboxName(oldPath), quoteMailboxName(newPath)), std::move(rename));
417 2 }
418
419 548 void ImapService::fetchHeadersByUids(const QList<qint64> &uids) {
420
2/2
✓ Branch 2 → 3 taken 538 times.
✓ Branch 2 → 14 taken 10 times.
548 if (m_state != State::Selected) {
421
5/5
✓ Branch 3 → 4 taken 538 times.
✓ Branch 7 → 8 taken 538 times.
✓ Branch 8 → 9 taken 538 times.
✓ Branch 12 → 5 taken 538 times.
✓ Branch 12 → 13 taken 538 times.
1076 qCWarning(lcImap) << "Cannot fetch: no folder selected";
422 545 return;
423 }
424
2/2
✓ Branch 15 → 16 taken 1 time.
✓ Branch 15 → 17 taken 9 times.
10 if (uids.isEmpty())
425 1 return;
426
3/3
✓ Branch 18 → 19 taken 9 times.
✓ Branch 19 → 20 taken 6 times.
✓ Branch 19 → 27 taken 3 times.
9 if (m_commands->hasStatefulCommandInFlight()) {
427
2/2
✓ Branch 22 → 23 taken 6 times.
✓ Branch 23 → 24 taken 6 times.
7 m_commands->enqueueSerializedCommand([this, uids]() { fetchHeadersByUids(uids); });
428 6 return;
429 }
430
431
1/1
✓ Branch 27 → 28 taken 3 times.
3 m_pendingHeaders.clear();
432
433 // Build comma-separated UID set, e.g. "100,99,98,97"
434 3 QStringList uidStrings;
435
1/1
✓ Branch 29 → 30 taken 3 times.
3 uidStrings.reserve(uids.size());
436
2/2
✓ Branch 38 → 32 taken 5 times.
✓ Branch 38 → 39 taken 3 times.
8 for (qint64 uid : uids) {
437
2/2
✓ Branch 33 → 34 taken 5 times.
✓ Branch 34 → 35 taken 5 times.
5 uidStrings.append(QString::number(uid));
438 }
439
1/1
✓ Branch 40 → 41 taken 3 times.
3 QString uidSet = uidStrings.join(',');
440
441
2/2
✓ Branch 46 → 47 taken 3 times.
✓ Branch 47 → 48 taken 3 times.
6 m_commands->sendCommand("FETCH_HEADERS",
442 6 QStringLiteral("UID FETCH %1 (UID FLAGS RFC822.SIZE INTERNALDATE ENVELOPE %2)")
443
1/1
✓ Branch 45 → 46 taken 3 times.
9 .arg(uidSet, kHeaderFieldsItem));
444 3 }
445
446 125 bool ImapService::hasIdleCapability() const {
447
1/1
✓ Branch 2 → 3 taken 125 times.
125 return m_capabilities.contains("IDLE", Qt::CaseInsensitive);
448 }
449
450 // T-208: Check for CONDSTORE capability (RFC 4551)
451 540 bool ImapService::hasCondstoreCapability() const {
452
1/1
✓ Branch 2 → 3 taken 540 times.
540 return m_capabilities.contains("CONDSTORE", Qt::CaseInsensitive);
453 }
454
455 5 bool ImapService::hasEsearchCapability() const {
456
1/1
✓ Branch 2 → 3 taken 5 times.
5 return m_capabilities.contains("ESEARCH", Qt::CaseInsensitive);
457 }
458
459 // ═══════════════════════════════════════════════════════
460 // T-320: IMAP NOTIFY (RFC 5465)
461 // ═══════════════════════════════════════════════════════
462
463 9 bool ImapService::hasNotifyCapability() const {
464
1/1
✓ Branch 2 → 3 taken 9 times.
9 return m_capabilities.contains("NOTIFY", Qt::CaseInsensitive);
465 }
466
467 1231 void ImapService::executeAfterIdle(std::function<void()> command) {
468
1/1
✓ Branch 5 → 6 taken 1231 times.
1231 m_commands->executeAfterIdle(std::move(command));
469 1231 }
470
471 28 void ImapService::clearDeferredCommands() { m_commands->clearDeferredCommands(); }
472
473 14 void ImapService::fetchFlags() {
474
2/2
✓ Branch 2 → 3 taken 8 times.
✓ Branch 2 → 14 taken 6 times.
14 if (m_state != State::Selected) {
475
5/5
✓ Branch 3 → 4 taken 8 times.
✓ Branch 7 → 8 taken 8 times.
✓ Branch 8 → 9 taken 8 times.
✓ Branch 12 → 5 taken 8 times.
✓ Branch 12 → 13 taken 8 times.
16 qCWarning(lcImap) << "Cannot fetch flags: no folder selected";
476 8 return;
477 }
478
2/2
✓ Branch 16 → 17 taken 1 time.
✓ Branch 16 → 22 taken 5 times.
6 if (m_commands->hasStatefulCommandInFlight()) {
479
1/1
✓ Branch 19 → 20 taken 1 time.
2 m_commands->enqueueSerializedCommand([this]() { fetchFlags(); });
480 1 return;
481 }
482
483 5 m_pendingFlags.clear();
484 5 m_pendingFlagSnapshots.clear();
485
3/3
✓ Branch 25 → 26 taken 5 times.
✓ Branch 26 → 27 taken 5 times.
✓ Branch 27 → 28 taken 5 times.
5 m_commands->sendCommand("FETCH_FLAGS", "UID FETCH 1:* (UID FLAGS)");
486 }
487
488 // F-38 (SPRINT-104): verification readback scoped to the changed message.
489 // A whole-folder UID FETCH 1:* costs one full flag snapshot (measured
490 // 838 kB / 84 ms on a 20,000-message Dovecot folder) per flag change; the
491 // mutation pipeline only needs the STOREd UID's flags back. The partial
492 // answer arrives on its own signal so FolderFlagSync never mistakes it
493 // for a complete snapshot.
494 6 void ImapService::fetchVerificationFlags(qint64 uid) {
495
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 14 taken 6 times.
6 if (m_state != State::Selected) {
496 ✗ qCWarning(lcImap) << "Cannot fetch verification flags: no folder selected";
497 ✗ return;
498 }
499
1/2
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 22 taken 6 times.
6 if (m_commands->hasStatefulCommandInFlight()) {
500 ✗ m_commands->enqueueSerializedCommand(
501 ✗ [this, uid]() { fetchVerificationFlags(uid); });
502 ✗ return;
503 }
504
505 6 m_pendingFlags.clear();
506 6 m_pendingFlagSnapshots.clear();
507 6 m_verificationUid = uid;
508
2/2
✓ Branch 28 → 29 taken 6 times.
✓ Branch 29 → 30 taken 6 times.
18 m_commands->sendCommand("FETCH_VERIFY_FLAGS",
509
2/2
✓ Branch 25 → 26 taken 6 times.
✓ Branch 27 → 28 taken 6 times.
18 QString("UID FETCH %1 (UID FLAGS)").arg(uid));
510 }
511
512 5 void ImapService::handleTaggedVerifyFlags(const TaggedResponse &response) {
513
1/2
✓ Branch 3 → 4 taken 5 times.
✗ Branch 3 → 18 not taken.
5 if (response.status == "OK") {
514
6/6
✓ Branch 4 → 5 taken 5 times.
✓ Branch 8 → 9 taken 5 times.
✓ Branch 9 → 10 taken 5 times.
✓ Branch 11 → 12 taken 5 times.
✓ Branch 16 → 6 taken 5 times.
✓ Branch 16 → 17 taken 5 times.
10 qCInfo(lcImap) << "Verification FETCH complete:" << m_pendingFlagSnapshots.size()
515
1/1
✓ Branch 12 → 13 taken 5 times.
5 << "snapshot(s)";
516 5 emit verifyFlagsReceived(m_pendingFlagSnapshots, m_selectedFolder, m_verificationUid);
517 } else {
518 ✗ qCWarning(lcImap) << "Verification FETCH failed:" << response.message;
519 ✗ emit verifyFlagsReceived({}, m_selectedFolder, m_verificationUid);
520 }
521 5 m_pendingFlags.clear();
522 5 m_pendingFlagSnapshots.clear();
523 5 }
524
525 // T-207: Pipeline SELECT + FETCH FLAGS — saves one round-trip.
526 // Both commands are sent immediately; the server processes them in-order.
527 // The FETCH FLAGS will execute in the context of the newly selected folder.
528 536 void ImapService::selectAndFetchFlags(const QString &folderPath) {
529
4/4
✓ Branch 2 → 3 taken 535 times.
✓ Branch 2 → 15 taken 1 time.
✓ Branch 3 → 4 taken 532 times.
✓ Branch 3 → 15 taken 3 times.
536 if (m_state != State::Authenticated && m_state != State::Selected) {
530
5/5
✓ Branch 4 → 5 taken 532 times.
✓ Branch 8 → 9 taken 532 times.
✓ Branch 9 → 10 taken 532 times.
✓ Branch 13 → 6 taken 532 times.
✓ Branch 13 → 14 taken 532 times.
1064 qCWarning(lcImap) << "Cannot selectAndFetchFlags: not authenticated";
531 534 return;
532 }
533
2/3
✓ Branch 16 → 17 taken 4 times.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 20 taken 4 times.
4 if (!m_push->probeBeforeUse("SELECT"))
534 ✗ return selectAndFetchFlags(folderPath);
535
3/3
✓ Branch 21 → 22 taken 4 times.
✓ Branch 22 → 23 taken 2 times.
✓ Branch 22 → 30 taken 2 times.
4 if (m_commands->hasStatefulCommandInFlight()) {
536
2/2
✓ Branch 25 → 26 taken 2 times.
✓ Branch 26 → 27 taken 2 times.
4 m_commands->enqueueSerializedCommand(
537 5 [this, folderPath]() { selectAndFetchFlags(folderPath); });
538 2 return;
539 }
540
541 2 ImapPendingCommand select; // Bug 34: selected only on SELECT OK
542 2 select.selectFolder = folderPath;
543 2 m_selectedMessageCount = 0;
544 2 m_selectedUidValidity = 0;
545 2 m_selectedHighestModseq = 0; // T-208: reset, will be set by untagged OK
546
1/1
✓ Branch 31 → 32 taken 2 times.
2 m_pendingFlags.clear();
547
1/1
✓ Branch 32 → 33 taken 2 times.
2 m_pendingFlagSnapshots.clear();
548
549 // Send both commands back-to-back (pipelining). SELECT (CONDSTORE) makes
550 // the server report HIGHESTMODSEQ, the base of the next reopen.
551
5/5
✓ Branch 36 → 37 taken 2 times.
✓ Branch 43 → 44 taken 2 times.
✓ Branch 44 → 45 taken 2 times.
✓ Branch 45 → 46 taken 2 times.
✓ Branch 46 → 47 taken 2 times.
8 m_commands->sendCommand("SELECT", QString("SELECT %1%2").arg(quoteMailboxName(folderPath),
552
5/7
✓ Branch 37 → 38 taken 2 times.
✓ Branch 38 → 39 taken 1 time.
✓ Branch 38 → 42 taken 1 time.
✓ Branch 51 → 52 taken 1 time.
✓ Branch 51 → 53 taken 1 time.
✗ Branch 98 → 99 not taken.
✗ Branch 98 → 100 not taken.
5 hasCondstoreCapability() ? QStringLiteral(" (CONDSTORE)") : QString()),
553 2 std::move(select));
554
3/3
✓ Branch 56 → 57 taken 2 times.
✓ Branch 57 → 58 taken 2 times.
✓ Branch 58 → 59 taken 2 times.
2 m_commands->sendCommand("FETCH_FLAGS", "UID FETCH 1:* (UID FLAGS)");
555
556
6/6
✓ Branch 62 → 63 taken 2 times.
✓ Branch 66 → 67 taken 2 times.
✓ Branch 67 → 68 taken 2 times.
✓ Branch 68 → 69 taken 2 times.
✓ Branch 72 → 64 taken 2 times.
✓ Branch 72 → 73 taken 2 times.
4 qCInfo(lcImap) << "T-207: Pipelined SELECT + FETCH_FLAGS for" << folderPath;
557 2 }
558
559 60 void ImapService::selectAndSearchAll(const QString &folderPath) {
560
4/4
✓ Branch 2 → 3 taken 58 times.
✓ Branch 2 → 15 taken 2 times.
✓ Branch 3 → 4 taken 56 times.
✓ Branch 3 → 15 taken 2 times.
60 if (m_state != State::Authenticated && m_state != State::Selected) {
561
5/5
✓ Branch 4 → 5 taken 56 times.
✓ Branch 8 → 9 taken 56 times.
✓ Branch 9 → 10 taken 56 times.
✓ Branch 13 → 6 taken 56 times.
✓ Branch 13 → 14 taken 56 times.
112 qCWarning(lcImap) << "Cannot selectAndSearchAll: not authenticated";
562 57 return;
563 }
564
2/3
✓ Branch 16 → 17 taken 4 times.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 20 taken 4 times.
4 if (!m_push->probeBeforeUse("SELECT"))
565 ✗ return selectAndSearchAll(folderPath);
566
3/3
✓ Branch 21 → 22 taken 4 times.
✓ Branch 22 → 23 taken 1 time.
✓ Branch 22 → 30 taken 3 times.
4 if (m_commands->hasStatefulCommandInFlight()) {
567
2/2
✓ Branch 25 → 26 taken 1 time.
✓ Branch 26 → 27 taken 1 time.
2 m_commands->enqueueSerializedCommand(
568 2 [this, folderPath]() { selectAndSearchAll(folderPath); });
569 1 return;
570 }
571
572 3 ImapPendingCommand select; // Bug 34: selected only on SELECT OK
573 3 select.selectFolder = folderPath;
574 3 m_selectedMessageCount = 0;
575 3 m_selectedUidValidity = 0;
576 3 m_selectedHighestModseq = 0;
577
1/1
✓ Branch 31 → 32 taken 3 times.
3 m_pendingSearchUids.clear();
578
579
5/5
✓ Branch 35 → 36 taken 3 times.
✓ Branch 42 → 43 taken 3 times.
✓ Branch 43 → 44 taken 3 times.
✓ Branch 44 → 45 taken 3 times.
✓ Branch 45 → 46 taken 3 times.
12 m_commands->sendCommand("SELECT", QString("SELECT %1%2").arg(quoteMailboxName(folderPath),
580
5/7
✓ Branch 36 → 37 taken 3 times.
✓ Branch 37 → 38 taken 1 time.
✓ Branch 37 → 41 taken 2 times.
✓ Branch 50 → 51 taken 1 time.
✓ Branch 50 → 52 taken 2 times.
✗ Branch 99 → 100 not taken.
✗ Branch 99 → 101 not taken.
7 hasCondstoreCapability() ? QStringLiteral(" (CONDSTORE)") : QString()),
581 3 std::move(select));
582 3 m_activeMessageIdSearch = {};
583
3/3
✓ Branch 57 → 58 taken 3 times.
✓ Branch 58 → 59 taken 3 times.
✓ Branch 59 → 60 taken 3 times.
3 m_commands->sendCommand("SEARCH", "UID SEARCH ALL");
584
585
6/6
✓ Branch 63 → 64 taken 3 times.
✓ Branch 67 → 68 taken 3 times.
✓ Branch 68 → 69 taken 3 times.
✓ Branch 69 → 70 taken 3 times.
✓ Branch 73 → 65 taken 3 times.
✓ Branch 73 → 74 taken 3 times.
6 qCInfo(lcImap) << "Pipelined SELECT + UID SEARCH ALL for" << folderPath;
586 3 }
587
588 4 void ImapService::selectAndFetchChangedFlags(const QString &folderPath, quint64 modseq) {
589
3/4
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 15 taken 3 times.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 15 taken 1 time.
4 if (m_state != State::Authenticated && m_state != State::Selected) {
590 ✗ qCWarning(lcImap) << "Cannot selectAndFetchChangedFlags: not authenticated";
591 ✗ return;
592 }
593
2/3
✓ Branch 16 → 17 taken 4 times.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 20 taken 4 times.
4 if (!m_push->probeBeforeUse("SELECT"))
594 ✗ return selectAndFetchChangedFlags(folderPath, modseq);
595
2/3
✓ Branch 21 → 22 taken 4 times.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 30 taken 4 times.
4 if (m_commands->hasStatefulCommandInFlight()) {
596 ✗ m_commands->enqueueSerializedCommand([this, folderPath, modseq]() {
597 ✗ selectAndFetchChangedFlags(folderPath, modseq);
598 ✗ });
599 ✗ return;
600 }
601 4 ImapPendingCommand select; // Bug 34: selected only on SELECT OK
602 4 select.selectFolder = folderPath;
603 4 m_selectedMessageCount = 0;
604 4 m_selectedUidValidity = 0;
605 4 m_selectedHighestModseq = 0;
606
1/1
✓ Branch 31 → 32 taken 4 times.
4 m_pendingFlags.clear();
607
1/1
✓ Branch 32 → 33 taken 4 times.
4 m_pendingFlagSnapshots.clear();
608
1/1
✓ Branch 33 → 34 taken 4 times.
4 m_pendingSearchUids.clear();
609
5/5
✓ Branch 37 → 38 taken 4 times.
✓ Branch 39 → 40 taken 4 times.
✓ Branch 40 → 41 taken 4 times.
✓ Branch 41 → 42 taken 4 times.
✓ Branch 42 → 43 taken 4 times.
12 m_commands->sendCommand("SELECT", QString("SELECT %1 (CONDSTORE)").arg(quoteMailboxName(folderPath)),
610 4 std::move(select));
611
2/2
✓ Branch 52 → 53 taken 4 times.
✓ Branch 53 → 54 taken 4 times.
8 m_commands->sendCommand("FETCH_FLAGS_CHANGED",
612
2/2
✓ Branch 49 → 50 taken 4 times.
✓ Branch 51 → 52 taken 4 times.
12 QString("UID FETCH 1:* (UID FLAGS) (CHANGEDSINCE %1)").arg(modseq));
613
3/3
✓ Branch 59 → 60 taken 4 times.
✓ Branch 67 → 68 taken 4 times.
✓ Branch 68 → 69 taken 4 times.
8 m_commands->sendCommand("SEARCH_MEMBERSHIP", hasEsearchCapability()
614
6/10
✓ Branch 60 → 61 taken 3 times.
✓ Branch 60 → 64 taken 1 time.
✓ Branch 71 → 72 taken 1 time.
✓ Branch 71 → 73 taken 3 times.
✓ Branch 73 → 74 taken 3 times.
✓ Branch 73 → 75 taken 1 time.
✗ Branch 141 → 142 not taken.
✗ Branch 141 → 143 not taken.
✗ Branch 145 → 146 not taken.
✗ Branch 145 → 147 not taken.
12 ? QStringLiteral("UID SEARCH RETURN (ALL) ALL") : QStringLiteral("UID SEARCH ALL"));
615
6/6
✓ Branch 76 → 77 taken 4 times.
✓ Branch 80 → 81 taken 4 times.
✓ Branch 81 → 82 taken 4 times.
✓ Branch 82 → 83 taken 4 times.
✓ Branch 88 → 78 taken 4 times.
✓ Branch 88 → 89 taken 4 times.
8 qCInfo(lcImap) << "Pipelined SELECT (CONDSTORE) + CHANGEDSINCE" << modseq
616
2/2
✓ Branch 83 → 84 taken 4 times.
✓ Branch 84 → 85 taken 4 times.
4 << "+ UID membership for" << folderPath;
617 4 }
618
619 // T-208: Incremental flag sync using CONDSTORE (RFC 4551)
620 6 void ImapService::fetchFlagsChanged(quint64 modseq) {
621
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 14 taken 5 times.
6 if (m_state != State::Selected) {
622
5/5
✓ Branch 3 → 4 taken 1 time.
✓ Branch 7 → 8 taken 1 time.
✓ Branch 8 → 9 taken 1 time.
✓ Branch 12 → 5 taken 1 time.
✓ Branch 12 → 13 taken 1 time.
2 qCWarning(lcImap) << "Cannot fetchFlagsChanged: no folder selected";
623 1 return;
624 }
625
2/2
✓ Branch 16 → 17 taken 2 times.
✓ Branch 16 → 22 taken 3 times.
5 if (m_commands->hasStatefulCommandInFlight()) {
626
1/1
✓ Branch 19 → 20 taken 2 times.
3 m_commands->enqueueSerializedCommand([this, modseq]() { fetchFlagsChanged(modseq); });
627 2 return;
628 }
629
630 3 m_pendingFlags.clear();
631 3 m_pendingFlagSnapshots.clear();
632
2/2
✓ Branch 28 → 29 taken 3 times.
✓ Branch 29 → 30 taken 3 times.
6 m_commands->sendCommand("FETCH_FLAGS",
633
2/2
✓ Branch 25 → 26 taken 3 times.
✓ Branch 27 → 28 taken 3 times.
9 QString("UID FETCH 1:* (UID FLAGS) (CHANGEDSINCE %1)").arg(modseq));
634
6/6
✓ Branch 34 → 35 taken 3 times.
✓ Branch 38 → 39 taken 3 times.
✓ Branch 39 → 40 taken 3 times.
✓ Branch 40 → 41 taken 3 times.
✓ Branch 44 → 36 taken 3 times.
✓ Branch 44 → 45 taken 3 times.
6 qCInfo(lcImap) << "T-208: CONDSTORE flag sync, CHANGEDSINCE" << modseq;
635 }
636
637 5 void ImapService::fetchUidForSeqNo(int seqNo) {
638
2/2
✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 14 taken 3 times.
5 if (m_state != State::Selected) {
639
5/5
✓ Branch 3 → 4 taken 2 times.
✓ Branch 7 → 8 taken 2 times.
✓ Branch 8 → 9 taken 2 times.
✓ Branch 12 → 5 taken 2 times.
✓ Branch 12 → 13 taken 2 times.
4 qCWarning(lcImap) << "Cannot fetch UID for seqNo: no folder selected";
640 2 return;
641 }
642
2/2
✓ Branch 16 → 17 taken 1 time.
✓ Branch 16 → 22 taken 2 times.
3 if (m_commands->hasStatefulCommandInFlight()) {
643
1/1
✓ Branch 19 → 20 taken 1 time.
2 m_commands->enqueueSerializedCommand([this, seqNo]() { fetchUidForSeqNo(seqNo); });
644 1 return;
645 }
646 // T-065 fix: Use DISTINCT command type so the single-UID result
647 // is NOT treated as a full flag sync (which would purge all other mails).
648 2 m_pendingFlags.clear();
649 2 m_pendingFlagSnapshots.clear();
650
2/2
✓ Branch 28 → 29 taken 2 times.
✓ Branch 29 → 30 taken 2 times.
6 m_commands->sendCommand("FETCH_SEQNO",
651
2/2
✓ Branch 25 → 26 taken 2 times.
✓ Branch 27 → 28 taken 2 times.
6 QString("FETCH %1 (UID FLAGS)").arg(seqNo));
652 }
653
654 130 void ImapService::statusFolder(const QString &folderPath) {
655
4/4
✓ Branch 2 → 3 taken 119 times.
✓ Branch 2 → 15 taken 11 times.
✓ Branch 3 → 4 taken 5 times.
✓ Branch 3 → 15 taken 114 times.
130 if (m_state != State::Authenticated && m_state != State::Selected) {
656
5/5
✓ Branch 4 → 5 taken 5 times.
✓ Branch 8 → 9 taken 5 times.
✓ Branch 9 → 10 taken 5 times.
✓ Branch 13 → 6 taken 5 times.
✓ Branch 13 → 14 taken 5 times.
10 qCWarning(lcImap) << "Cannot status: not authenticated or selected";
657 111 return;
658 }
659
3/3
✓ Branch 16 → 17 taken 125 times.
✓ Branch 17 → 18 taken 106 times.
✓ Branch 17 → 29 taken 19 times.
125 if (m_commands->hasStatefulCommandInFlight()) {
660
2/2
✓ Branch 19 → 20 taken 100 times.
✓ Branch 19 → 21 taken 6 times.
106 if (m_queuedStatusFolders.contains(folderPath)) return;
661
1/1
✓ Branch 21 → 22 taken 6 times.
6 m_queuedStatusFolders.insert(folderPath);
662
2/2
✓ Branch 24 → 25 taken 6 times.
✓ Branch 25 → 26 taken 6 times.
6 m_commands->enqueueSerializedCommand([this, folderPath]() {
663 2 m_queuedStatusFolders.remove(folderPath);
664 2 statusFolder(folderPath);
665 2 });
666 6 return;
667 }
668
669 19 ImapPendingCommand status;
670 19 status.statusFolder = folderPath;
671 19 status.statusStartedNs = std::chrono::duration_cast<std::chrono::nanoseconds>(
672 38 std::chrono::steady_clock::now().time_since_epoch()).count();
673
3/3
✓ Branch 37 → 38 taken 19 times.
✓ Branch 41 → 42 taken 19 times.
✓ Branch 42 → 43 taken 19 times.
57 m_commands->sendCommand("STATUS", QString("STATUS %1 (MESSAGES UNSEEN RECENT)")
674
2/2
✓ Branch 39 → 40 taken 19 times.
✓ Branch 40 → 41 taken 19 times.
57 .arg(quoteMailboxName(folderPath)), std::move(status));
675 19 }
676
677 // ═══════════════════════════════════════════════════════
678 // T-720: Liveness probe + reconnect API (Sprint 72)
679 // ═══════════════════════════════════════════════════════
680
681 20 void ImapService::abortForReconnect(const QString &reason) {
682
6/6
✓ Branch 2 → 3 taken 20 times.
✓ Branch 6 → 7 taken 20 times.
✓ Branch 7 → 8 taken 20 times.
✓ Branch 8 → 9 taken 20 times.
✓ Branch 12 → 4 taken 20 times.
✓ Branch 12 → 13 taken 20 times.
40 qCWarning(lcImap) << "Forced reconnect requested:" << reason;
683 // failConnection() clears pending state, aborts the socket, emits
684 // errorOccurred and transitions to State::Error — the monitor's
685 // scheduleReconnect() arm fires from there.
686
2/2
✓ Branch 17 → 18 taken 20 times.
✓ Branch 18 → 19 taken 20 times.
40 failConnection(QStringLiteral("Forced reconnect: %1").arg(reason),
687 DisconnectReason::ForcedHealthProbe);
688 20 }
689
690 66 void ImapService::failConnection(const QString &error,
691 DisconnectReason reason) {
692
5/5
✓ Branch 2 → 3 taken 66 times.
✓ Branch 6 → 7 taken 66 times.
✓ Branch 7 → 8 taken 66 times.
✓ Branch 11 → 4 taken 66 times.
✓ Branch 11 → 12 taken 66 times.
132 qCWarning(lcImap) << error;
693 66 emit disconnectReasonChanged(reason);
694
1/1
✓ Branch 15 → 16 taken 66 times.
66 m_body->completeBodyRequest(BodyOutcome::Failed, {}, error);
695 66 m_mutations->completeOutstandingMutations(error);
696 66 m_setup->clearCredentials();
697 66 m_setup->setupTimer()->stop();
698 66 m_commands->commandTimeoutTimer()->stop();
699 66 m_commands->commandDeadlineTimer()->stop();
700 66 m_push->idleRenewTimer()->stop();
701 // T-720: Stop the liveness probe + IDLE renew watchdogs on failure.
702 66 m_push->livenessProbeWatchdog()->stop();
703 66 m_push->idleRenewWatchdog()->stop();
704 66 m_push->probeTag().clear();
705 66 m_commands->clearCommands();
706 66 m_body->cancelParkedRequests(); // S106-001: the dropped queue held them
707 66 m_queuedStatusFolders.clear();
708 66 m_push->idleStartPending() = false;
709 66 m_reader->resetFraming();
710 66 m_pendingFolders.clear();
711 66 m_pendingHeaders.clear();
712 66 m_pendingFlags.clear();
713 66 m_pendingFlagSnapshots.clear();
714 66 m_pendingSearchUids.clear();
715 66 m_push->isIdling() = false;
716 66 m_push->idleTag().clear();
717 66 m_push->isNotifying() = false;
718 66 m_push->notifyTag().clear();
719 // T-79.A1/H1: drop any in-flight APPEND payload with the session
720 66 m_append->resetForSession();
721 66 m_body->resetActiveRequest();
722 66 m_socket->abort();
723 66 emit errorOccurred(error);
724 66 setState(State::Error);
725 66 }
726
727 2849 void ImapService::setState(State newState) {
728
2/2
✓ Branch 2 → 3 taken 789 times.
✓ Branch 2 → 17 taken 2060 times.
2849 if (m_state != newState) {
729
5/5
✓ Branch 3 → 4 taken 789 times.
✓ Branch 7 → 8 taken 789 times.
✓ Branch 8 → 9 taken 789 times.
✓ Branch 15 → 5 taken 789 times.
✓ Branch 15 → 16 taken 789 times.
1578 qCInfo(lcImap) << "State:"
730
1/1
✓ Branch 9 → 10 taken 789 times.
1578 << QMetaEnum::fromType<State>().valueToKey(
731
2/2
✓ Branch 10 → 11 taken 789 times.
✓ Branch 11 → 12 taken 789 times.
789 static_cast<int>(newState));
732 789 m_state = newState;
733 789 emit stateChanged(newState);
734 }
735 2849 }
736
737 #ifdef MAILJD_E2E_TESTING
738 798 quint64 ImapService::e2eRequestTokenFor(const ImapPendingCommand &command) const {
739 798 const QString &type = command.type;
740
2/2
✓ Branch 8 → 9 taken 66 times.
✓ Branch 8 → 12 taken 732 times.
798 if (type == QStringLiteral("FETCH_BODY"))
741 66 return m_body->activeBodyRequestId();
742
2/2
✓ Branch 18 → 19 taken 51 times.
✓ Branch 18 → 20 taken 681 times.
732 if (type == QStringLiteral("APPEND"))
743 51 return command.appendRequestId;
744
10/12
✓ Branch 29 → 30 taken 619 times.
✓ Branch 29 → 40 taken 28 times.
✓ Branch 34 → 35 taken 602 times.
✓ Branch 34 → 40 taken 17 times.
✓ Branch 52 → 53 taken 647 times.
✓ Branch 52 → 54 taken 34 times.
✓ Branch 54 → 55 taken 681 times.
✗ Branch 54 → 56 not taken.
✓ Branch 56 → 57 taken 681 times.
✗ Branch 56 → 58 not taken.
✓ Branch 58 → 59 taken 89 times.
✓ Branch 58 → 63 taken 592 times.
2628 if (type == QStringLiteral("STORE") || type == QStringLiteral("MOVE") ||
745
14/14
✓ Branch 24 → 25 taken 647 times.
✓ Branch 24 → 40 taken 34 times.
✓ Branch 39 → 40 taken 10 times.
✓ Branch 39 → 41 taken 592 times.
✓ Branch 42 → 43 taken 602 times.
✓ Branch 42 → 44 taken 79 times.
✓ Branch 44 → 45 taken 602 times.
✓ Branch 44 → 46 taken 79 times.
✓ Branch 46 → 47 taken 619 times.
✓ Branch 46 → 48 taken 62 times.
✓ Branch 48 → 49 taken 619 times.
✓ Branch 48 → 50 taken 62 times.
✓ Branch 50 → 51 taken 647 times.
✓ Branch 50 → 52 taken 34 times.
2628 type == QStringLiteral("COPY") || type == QStringLiteral("COPY_ONLY")) {
746
1/1
✓ Branch 59 → 60 taken 89 times.
89 return static_cast<quint64>(qMax(command.storeToken, command.mutationTokens.value(0)));
747 }
748 592 return 0;
749 }
750
751 1065 void ImapService::reportE2eTransportPhase(const QString &phase,
752 quint64 requestToken) {
753 // Setup and IDLE phases do not own a body/mutation request ID. Use the
754 // current tagged-command sequence (or 1 before the first tag) so external
755 // fault controls still receive a non-zero opaque correlation token.
756 const quint64 correlationToken =
757 1065 requestToken > 0
758
2/2
✓ Branch 2 → 3 taken 155 times.
✓ Branch 2 → 4 taken 910 times.
1975 ? requestToken
759 910 : qMax<quint64>(1, static_cast<quint64>(m_commands->tagCounter()));
760 1065 emit e2eTransportPhase(phase, correlationToken);
761 1065 }
762 #endif
763
764 158 QString ImapService::quoteImapString(const QString &str) {
765 // RFC 3501: quoted strings must not contain CR, LF, or NUL
766 158 QString escaped = str;
767
1/1
✓ Branch 4 → 5 taken 158 times.
158 escaped.remove('\r');
768
1/1
✓ Branch 6 → 7 taken 158 times.
158 escaped.remove('\n');
769
1/1
✓ Branch 8 → 9 taken 158 times.
158 escaped.remove(QChar(0));
770
2/2
✓ Branch 9 → 10 taken 158 times.
✓ Branch 11 → 12 taken 158 times.
158 escaped.replace('\\', "\\\\");
771
2/2
✓ Branch 13 → 14 taken 158 times.
✓ Branch 15 → 16 taken 158 times.
158 escaped.replace('"', "\\\"");
772
2/2
✓ Branch 19 → 20 taken 158 times.
✓ Branch 20 → 21 taken 158 times.
474 return '"' + escaped + '"';
773 158 }
774
775 165 QString ImapService::quoteMailboxName(const QString &path) {
776 // Mailbox paths are kept in their wire form (modified UTF-7, RFC 3501
777 // §5.1.3); this only quotes them (S106-968). The service keeps the
778 // forwarding name so every send site reads the same way.
779 165 return ImapResponseParser::quoteMailboxName(path);
780 }
781
782 82 bool ImapService::isValidImapFlag(const QString &flag) {
783
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 80 times.
82 if (flag.isEmpty())
784 2 return false;
785
3/3
✓ Branch 7 → 8 taken 80 times.
✓ Branch 8 → 9 taken 63 times.
✓ Branch 8 → 10 taken 17 times.
80 const qsizetype start = flag.startsWith(QLatin1Char('\\')) ? 1 : 0;
786
2/2
✓ Branch 12 → 13 taken 2 times.
✓ Branch 12 → 14 taken 78 times.
80 if (start == flag.size())
787 2 return false;
788
3/4
✓ Branch 14 → 15 taken 8 times.
✓ Branch 14 → 24 taken 70 times.
✓ Branch 16 → 17 taken 8 times.
✗ Branch 16 → 24 not taken.
86 static const QString atomSpecials = QStringLiteral("(){ %*\"\\]}");
789
2/2
✓ Branch 41 → 25 taken 382 times.
✓ Branch 41 → 42 taken 58 times.
440 for (qsizetype i = start; i < flag.size(); ++i) {
790 382 const QChar c = flag.at(i);
791 382 const ushort u = c.unicode();
792
8/8
✓ Branch 27 → 28 taken 376 times.
✓ Branch 27 → 35 taken 6 times.
✓ Branch 28 → 29 taken 375 times.
✓ Branch 28 → 35 taken 1 time.
✓ Branch 32 → 33 taken 373 times.
✓ Branch 32 → 35 taken 2 times.
✓ Branch 37 → 38 taken 20 times.
✓ Branch 37 → 39 taken 362 times.
755 if (u <= 0x20 || u == 0x7F || c == QLatin1Char('\\') ||
793
3/3
✓ Branch 33 → 34 taken 373 times.
✓ Branch 34 → 35 taken 11 times.
✓ Branch 34 → 36 taken 362 times.
373 atomSpecials.contains(c)) {
794 20 return false;
795 }
796 }
797 58 return true;
798 }
799
800 100444 void ImapService::handleUntagged(const QString &line) {
801
1/1
✓ Branch 2 → 3 taken 100444 times.
100444 auto response = ImapResponseParser::parseUntaggedResponse(line);
802
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 100444 times.
100444 if (!response)
803 ✗ return;
804
805 100444 const auto &type = response->type;
806 100444 const auto &data = response->data;
807
808
7/8
✓ Branch 8 → 9 taken 54 times.
✓ Branch 8 → 14 taken 100390 times.
✓ Branch 10 → 11 taken 2 times.
✓ Branch 10 → 13 taken 52 times.
✓ Branch 12 → 13 taken 2 times.
✗ Branch 12 → 14 not taken.
✓ Branch 15 → 16 taken 54 times.
✓ Branch 15 → 20 taken 100390 times.
100444 if (m_state == State::Connected && (type == "OK" || type == "PREAUTH")) {
809
1/1
✓ Branch 18 → 19 taken 54 times.
54 m_setup->handleGreeting(data, type == "PREAUTH");
810 54 return;
811 }
812
813
2/2
✓ Branch 21 → 22 taken 58 times.
✓ Branch 21 → 25 taken 100332 times.
100390 if (type == "CAPABILITY") {
814
1/1
✓ Branch 23 → 24 taken 58 times.
58 m_setup->collectCapabilities(data);
815 58 return;
816 }
817
818
2/2
✓ Branch 26 → 27 taken 22 times.
✓ Branch 26 → 29 taken 100310 times.
100332 if (type == "LIST") {
819
1/1
✓ Branch 27 → 28 taken 22 times.
22 handleUntaggedList(data);
820 22 return;
821 }
822
823 // FETCH responses: "* N FETCH (...)"
824
4/4
✓ Branch 29 → 30 taken 100310 times.
✓ Branch 30 → 31 taken 100310 times.
✓ Branch 32 → 33 taken 89 times.
✓ Branch 32 → 35 taken 100221 times.
100310 if (data.startsWith("FETCH")) {
825
1/1
✓ Branch 33 → 34 taken 89 times.
89 handleUntaggedFetch(type, data);
826 89 return;
827 }
828
829
2/2
✓ Branch 36 → 37 taken 4 times.
✓ Branch 36 → 39 taken 100217 times.
100221 if (type == "ESEARCH") {
830
1/1
✓ Branch 37 → 38 taken 4 times.
4 handleUntaggedEsearch(data);
831 4 return;
832 }
833
834
2/2
✓ Branch 40 → 41 taken 42 times.
✓ Branch 40 → 43 taken 100175 times.
100217 if (type == "SEARCH") {
835
1/1
✓ Branch 41 → 42 taken 42 times.
42 handleUntaggedSearch(data);
836 42 return;
837 }
838
839 // EXISTS response: "* N EXISTS" → type="N", data="EXISTS"
840
2/2
✓ Branch 44 → 45 taken 48 times.
✓ Branch 44 → 47 taken 100127 times.
100175 if (data == "EXISTS") {
841
1/1
✓ Branch 45 → 46 taken 48 times.
48 handleUntaggedExists(type);
842 48 return;
843 }
844
845 // EXPUNGE response: "* N EXPUNGE" → type="N", data="EXPUNGE"
846
2/2
✓ Branch 48 → 49 taken 6 times.
✓ Branch 48 → 51 taken 100121 times.
100127 if (data == "EXPUNGE") {
847
1/1
✓ Branch 49 → 50 taken 6 times.
6 handleUntaggedExpunge(type);
848 6 return;
849 }
850
851 // STATUS response: "* STATUS "INBOX" (MESSAGES 42 UNSEEN 3 RECENT 0)"
852
2/2
✓ Branch 52 → 53 taken 23 times.
✓ Branch 52 → 55 taken 100098 times.
100121 if (type == "STATUS") {
853
1/1
✓ Branch 53 → 54 taken 23 times.
23 handleUntaggedStatus(data);
854 23 return;
855 }
856
857 // UIDVALIDITY in OK response: "* OK [UIDVALIDITY 12345]"
858
2/2
✓ Branch 56 → 57 taken 100070 times.
✓ Branch 56 → 59 taken 28 times.
100098 if (type == "OK") {
859
1/1
✓ Branch 57 → 58 taken 100070 times.
100070 handleUntaggedOk(data);
860 100070 return;
861 }
862
863
2/2
✓ Branch 60 → 61 taken 2 times.
✓ Branch 60 → 74 taken 26 times.
28 if (type == "BYE") {
864
6/6
✓ Branch 61 → 62 taken 2 times.
✓ Branch 65 → 66 taken 2 times.
✓ Branch 66 → 67 taken 2 times.
✓ Branch 67 → 68 taken 2 times.
✓ Branch 71 → 63 taken 2 times.
✓ Branch 71 → 72 taken 2 times.
4 qCInfo(lcImap) << "Server BYE:" << data;
865
1/1
✓ Branch 72 → 73 taken 2 times.
2 disconnect(DisconnectReason::RemoteBye);
866 2 return;
867 }
868 100444 }
869
870 22 void ImapService::handleUntaggedList(const QString &data) {
871
1/1
✓ Branch 2 → 3 taken 22 times.
22 auto folder = ImapResponseParser::parseListResponse(data);
872
2/2
✓ Branch 4 → 5 taken 21 times.
✓ Branch 4 → 11 taken 1 time.
22 if (folder) {
873
2/3
✓ Branch 6 → 7 taken 21 times.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 21 times.
21 if (!m_commands->accountCommandResultItems())
874 ✗ return;
875
2/2
✓ Branch 9 → 10 taken 21 times.
✓ Branch 10 → 11 taken 21 times.
21 m_pendingFolders.append(folder.value());
876 }
877 22 }
878
879 // FETCH responses: "* N FETCH (...)"
880 89 void ImapService::handleUntaggedFetch(const QString &type, const QString &data) {
881 // type is the sequence number, data starts with "FETCH"
882
2/2
✓ Branch 2 → 3 taken 89 times.
✓ Branch 3 → 4 taken 89 times.
89 auto fetchData = data.mid(5).trimmed(); // skip "FETCH"
883
884 // Check if this is a flags-only fetch (from fetchFlags or IDLE/NOTIFY push)
885
11/16
✓ Branch 5 → 6 taken 89 times.
✓ Branch 6 → 7 taken 89 times.
✓ Branch 7 → 8 taken 88 times.
✓ Branch 7 → 15 taken 1 time.
✓ Branch 8 → 9 taken 88 times.
✓ Branch 9 → 10 taken 88 times.
✓ Branch 10 → 11 taken 23 times.
✓ Branch 10 → 15 taken 65 times.
✓ Branch 20 → 21 taken 89 times.
✗ Branch 20 → 22 not taken.
✓ Branch 22 → 23 taken 22 times.
✓ Branch 22 → 92 taken 67 times.
✗ Branch 156 → 157 not taken.
✗ Branch 156 → 158 not taken.
✗ Branch 160 → 161 not taken.
✗ Branch 160 → 162 not taken.
201 if (fetchData.contains("FLAGS") && !fetchData.contains("ENVELOPE") &&
886
8/10
✓ Branch 11 → 12 taken 23 times.
✓ Branch 12 → 13 taken 23 times.
✓ Branch 13 → 14 taken 22 times.
✓ Branch 13 → 15 taken 1 time.
✓ Branch 16 → 17 taken 23 times.
✓ Branch 16 → 18 taken 66 times.
✓ Branch 18 → 19 taken 88 times.
✓ Branch 18 → 20 taken 1 time.
✗ Branch 152 → 153 not taken.
✗ Branch 152 → 154 not taken.
112 !fetchData.contains("BODY[]")) {
887 // Parse UID and FLAGS from "(UID 123 FLAGS (\Seen \Flagged))"
888
1/1
✓ Branch 23 → 24 taken 22 times.
22 auto parsed = ImapResponseParser::parseFetchFlagSnapshot(fetchData);
889
890 // T-320: Determine if this is a command response or unsolicited push.
891 // With IDLE: no commands in-flight → always unsolicited.
892 // With NOTIFY: commands run concurrently → check pending commands.
893 bool isCommandResponse =
894
1/3
✓ Branch 25 → 26 taken 22 times.
✗ Branch 180 → 181 not taken.
✗ Branch 180 → 182 not taken.
44 m_commands->hasCommandType("FETCH_FLAGS") ||
895
5/8
✓ Branch 29 → 30 taken 14 times.
✓ Branch 30 → 31 taken 14 times.
✓ Branch 31 → 32 taken 9 times.
✓ Branch 31 → 44 taken 5 times.
✓ Branch 54 → 55 taken 22 times.
✗ Branch 54 → 56 not taken.
✗ Branch 176 → 177 not taken.
✗ Branch 176 → 178 not taken.
36 m_commands->hasCommandType("FETCH_FLAGS_CHANGED") ||
896
6/8
✓ Branch 33 → 34 taken 9 times.
✓ Branch 34 → 35 taken 9 times.
✓ Branch 35 → 36 taken 8 times.
✓ Branch 35 → 44 taken 1 time.
✓ Branch 52 → 53 taken 14 times.
✓ Branch 52 → 54 taken 8 times.
✗ Branch 172 → 173 not taken.
✗ Branch 172 → 174 not taken.
31 m_commands->hasCommandType("FETCH_VERIFY_FLAGS") ||
897
9/11
✓ Branch 26 → 27 taken 22 times.
✓ Branch 27 → 28 taken 14 times.
✓ Branch 27 → 44 taken 8 times.
✓ Branch 37 → 38 taken 8 times.
✓ Branch 38 → 39 taken 8 times.
✓ Branch 39 → 40 taken 6 times.
✓ Branch 39 → 44 taken 2 times.
✓ Branch 50 → 51 taken 9 times.
✓ Branch 50 → 52 taken 13 times.
✗ Branch 168 → 169 not taken.
✗ Branch 168 → 170 not taken.
86 m_commands->hasCommandType("FETCH_SEQNO") ||
898
8/10
✓ Branch 41 → 42 taken 6 times.
✓ Branch 42 → 43 taken 6 times.
✓ Branch 43 → 44 taken 1 time.
✓ Branch 43 → 45 taken 5 times.
✓ Branch 46 → 47 taken 6 times.
✓ Branch 46 → 48 taken 16 times.
✓ Branch 48 → 49 taken 8 times.
✓ Branch 48 → 50 taken 14 times.
✗ Branch 164 → 165 not taken.
✗ Branch 164 → 166 not taken.
28 m_commands->hasCommandType("FETCH_HEADERS");
899
900
2/2
✓ Branch 57 → 58 taken 17 times.
✓ Branch 57 → 72 taken 5 times.
22 if (parsed) {
901
2/2
✓ Branch 58 → 59 taken 16 times.
✓ Branch 58 → 69 taken 1 time.
17 if (isCommandResponse) {
902 // Response to explicit FETCH → accumulate for batch emit
903
2/3
✓ Branch 60 → 61 taken 16 times.
✗ Branch 61 → 62 not taken.
✓ Branch 61 → 63 taken 16 times.
16 if (!m_commands->accountCommandResultItems())
904 ✗ return;
905
1/1
✓ Branch 66 → 67 taken 16 times.
16 m_pendingFlags.append({parsed->uid, parsed->flags});
906
2/2
✓ Branch 67 → 68 taken 16 times.
✓ Branch 68 → 89 taken 16 times.
16 m_pendingFlagSnapshots.append(parsed.value());
907 } else {
908 // Unsolicited push (IDLE or NOTIFY event) → emit immediately
909
1/1
✓ Branch 71 → 89 taken 1 time.
1 emit idleFlagsChanged(parsed->uid, parsed->flags);
910 }
911
2/2
✓ Branch 72 → 73 taken 4 times.
✓ Branch 72 → 89 taken 1 time.
5 } else if (!isCommandResponse) {
912 // T-065: Unsolicited flag push WITHOUT UID (common for IMAP servers).
913 // The sequence number is in the 'type' field from parseUntaggedResponse.
914 4 bool ok = false;
915
1/1
✓ Branch 73 → 74 taken 4 times.
4 int seqNo = type.toInt(&ok);
916
4/4
✓ Branch 74 → 75 taken 3 times.
✓ Branch 74 → 88 taken 1 time.
✓ Branch 75 → 76 taken 2 times.
✓ Branch 75 → 88 taken 1 time.
4 if (ok && seqNo > 0) {
917
6/6
✓ Branch 76 → 77 taken 2 times.
✓ Branch 80 → 81 taken 2 times.
✓ Branch 81 → 82 taken 2 times.
✓ Branch 82 → 83 taken 2 times.
✓ Branch 86 → 78 taken 2 times.
✓ Branch 86 → 87 taken 2 times.
4 qCInfo(lcImap) << "Push flag change without UID, seqNo:" << seqNo;
918
1/1
✓ Branch 87 → 88 taken 2 times.
2 emit idleFlagsNeedRefetch(seqNo);
919 }
920 }
921 22 return;
922 22 }
923
924 // Check if this is a header or body fetch
925
4/4
✓ Branch 92 → 93 taken 67 times.
✓ Branch 93 → 94 taken 67 times.
✓ Branch 95 → 96 taken 65 times.
✓ Branch 95 → 142 taken 2 times.
67 if (fetchData.contains("ENVELOPE")) {
926
1/1
✓ Branch 96 → 97 taken 65 times.
65 auto header = ImapResponseParser::parseFetchHeaderResponse(fetchData);
927
2/2
✓ Branch 98 → 99 taken 64 times.
✓ Branch 98 → 118 taken 1 time.
65 if (header) {
928
2/3
✓ Branch 100 → 101 taken 64 times.
✗ Branch 101 → 102 not taken.
✓ Branch 101 → 103 taken 64 times.
64 if (!m_commands->accountCommandResultItems())
929 ✗ return;
930
2/2
✓ Branch 103 → 104 taken 64 times.
✓ Branch 104 → 105 taken 64 times.
64 m_pendingHeaders.append(header.value());
931
6/6
✓ Branch 105 → 106 taken 64 times.
✓ Branch 109 → 110 taken 64 times.
✓ Branch 110 → 111 taken 64 times.
✓ Branch 112 → 113 taken 64 times.
✓ Branch 116 → 107 taken 64 times.
✓ Branch 116 → 117 taken 64 times.
128 qCDebug(lcImap) << "Parsed header for UID" << header->uid;
932 } else {
933 // parseFetchHeaderResponse() returns nullopt ONLY when the FETCH
934 // data carries no UID (everything else is parsed tolerantly with
935 // empty fields). The former T-067 "fallback header" branch here
936 // re-extracted the UID from the same data and could therefore
937 // never succeed — verified dead code, removed in Sprint 65.
938
5/5
✓ Branch 118 → 119 taken 1 time.
✓ Branch 122 → 123 taken 1 time.
✓ Branch 123 → 124 taken 1 time.
✓ Branch 130 → 120 taken 1 time.
✓ Branch 130 → 131 taken 1 time.
2 qCWarning(lcImap) << "FETCH without UID – mail dropped! data:"
939
2/2
✓ Branch 124 → 125 taken 1 time.
✓ Branch 125 → 126 taken 1 time.
1 << fetchData.left(300);
940 }
941 // Streaming: emit batch when threshold reached
942
2/2
✓ Branch 133 → 134 taken 1 time.
✓ Branch 133 → 136 taken 64 times.
65 if (m_pendingHeaders.size() >= HEADER_BATCH_SIZE) {
943
1/1
✓ Branch 134 → 135 taken 1 time.
1 emit headersReceived(m_pendingHeaders);
944
1/1
✓ Branch 135 → 136 taken 1 time.
1 m_pendingHeaders.clear();
945 }
946 65 }
947 89 }
948
949 // S106-022: SEARCH/ESEARCH results count only while a search command is on
950 // the wire. Unsolicited ones (during IDLE no command budget is active) used
951 // to grow m_pendingSearchUids without bound until the next search.
952 46 bool ImapService::searchResultExpected() const {
953
4/9
✓ Branch 6 → 7 taken 46 times.
✓ Branch 7 → 8 taken 6 times.
✓ Branch 7 → 14 taken 40 times.
✓ Branch 22 → 23 taken 46 times.
✗ Branch 22 → 24 not taken.
✗ Branch 34 → 35 not taken.
✗ Branch 34 → 36 not taken.
✗ Branch 38 → 39 not taken.
✗ Branch 38 → 40 not taken.
144 return m_commands->hasCommandType(QStringLiteral("SEARCH")) ||
954
8/13
✓ Branch 12 → 13 taken 6 times.
✓ Branch 13 → 14 taken 3 times.
✓ Branch 13 → 15 taken 3 times.
✓ Branch 16 → 17 taken 6 times.
✓ Branch 16 → 18 taken 40 times.
✓ Branch 18 → 19 taken 6 times.
✓ Branch 18 → 20 taken 40 times.
✓ Branch 20 → 21 taken 46 times.
✗ Branch 20 → 22 not taken.
✗ Branch 26 → 27 not taken.
✗ Branch 26 → 28 not taken.
✗ Branch 30 → 31 not taken.
✗ Branch 30 → 32 not taken.
104 m_commands->hasCommandType(QStringLiteral("SEARCH_MEMBERSHIP"));
955 }
956
957 4 void ImapService::handleUntaggedEsearch(const QString &data) {
958 // RFC 4731: '(TAG "A5") UID ALL 1:4,6:30' – only ALL is requested.
959
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 15 taken 3 times.
4 if (!searchResultExpected()) {
960
5/5
✓ Branch 4 → 5 taken 1 time.
✓ Branch 8 → 9 taken 1 time.
✓ Branch 9 → 10 taken 1 time.
✓ Branch 13 → 6 taken 1 time.
✓ Branch 13 → 14 taken 1 time.
2 qCDebug(lcImap) << "Unsolicited ESEARCH ignored";
961 1 return;
962 }
963
1/1
✓ Branch 18 → 19 taken 3 times.
3 const int all = data.indexOf(QStringLiteral(" ALL "));
964
1/2
✓ Branch 21 → 22 taken 3 times.
✗ Branch 21 → 57 not taken.
3 if (all >= 0) {
965
3/3
✓ Branch 22 → 23 taken 3 times.
✓ Branch 23 → 24 taken 3 times.
✓ Branch 26 → 27 taken 3 times.
6 const auto set = data.mid(all + 5).trimmed().section(' ', 0, 0);
966
1/1
✓ Branch 29 → 30 taken 3 times.
3 const auto uids = ImapResponseParser::expandUidSet(set, MAX_RESULT_ITEMS_PER_COMMAND);
967
2/2
✓ Branch 31 → 32 taken 1 time.
✓ Branch 31 → 39 taken 2 times.
3 if (!uids) {
968
1/1
✓ Branch 35 → 36 taken 1 time.
1 failConnection(QStringLiteral("Malformed or oversized ESEARCH result"));
969 1 return;
970 }
971
2/3
✓ Branch 42 → 43 taken 2 times.
✗ Branch 43 → 44 not taken.
✓ Branch 43 → 45 taken 2 times.
2 if (!m_commands->accountCommandResultItems(uids->size()))
972 ✗ return;
973
1/1
✓ Branch 46 → 47 taken 2 times.
2 m_pendingSearchUids.append(*uids);
974 4 }
975 }
976
977 42 void ImapService::handleUntaggedSearch(const QString &data) {
978 // SEARCH response: data contains space-separated UIDs
979 // e.g. type="SEARCH", data="1 2 3 45 67"
980
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 15 taken 40 times.
42 if (!searchResultExpected()) {
981
5/5
✓ Branch 4 → 5 taken 2 times.
✓ Branch 8 → 9 taken 2 times.
✓ Branch 9 → 10 taken 2 times.
✓ Branch 13 → 6 taken 2 times.
✓ Branch 13 → 14 taken 2 times.
4 qCDebug(lcImap) << "Unsolicited SEARCH ignored";
982 2 return;
983 }
984
2/2
✓ Branch 16 → 17 taken 39 times.
✓ Branch 16 → 40 taken 1 time.
40 if (!data.isEmpty()) {
985
5/5
✓ Branch 19 → 20 taken 39 times.
✓ Branch 20 → 21 taken 39 times.
✓ Branch 21 → 22 taken 39 times.
✓ Branch 33 → 23 taken 56 times.
✓ Branch 33 → 34 taken 39 times.
95 for (const auto &token : data.split(' ', Qt::SkipEmptyParts)) {
986 56 bool ok = false;
987
1/1
✓ Branch 24 → 25 taken 56 times.
56 qint64 uid = token.toLongLong(&ok);
988
1/2
✓ Branch 25 → 26 taken 56 times.
✗ Branch 25 → 31 not taken.
56 if (ok) {
989
2/3
✓ Branch 27 → 28 taken 56 times.
✗ Branch 28 → 29 not taken.
✓ Branch 28 → 30 taken 56 times.
56 if (!m_commands->accountCommandResultItems())
990 ✗ return;
991
1/1
✓ Branch 30 → 31 taken 56 times.
56 m_pendingSearchUids.append(uid);
992 }
993 39 }
994 }
995 }
996
997 // EXISTS response: "* N EXISTS" → type="N", data="EXISTS"
998 48 void ImapService::handleUntaggedExists(const QString &type) {
999 48 bool ok = false;
1000
1/1
✓ Branch 2 → 3 taken 48 times.
48 int count = type.toInt(&ok);
1001
2/2
✓ Branch 3 → 4 taken 47 times.
✓ Branch 3 → 40 taken 1 time.
48 if (ok) {
1002 // T-320: only SELECT reports the mailbox size as part of its answer.
1003 // Growth while a FETCH runs is a real arrival: servers announce new
1004 // mail between any responses, and a FETCH 1:* may already be past the
1005 // new message. Dropping it lost the arrival until the next sync.
1006
2/2
✓ Branch 5 → 6 taken 47 times.
✓ Branch 6 → 7 taken 47 times.
47 bool isCommandResponse = m_commands->hasCommandType("SELECT");
1007
4/4
✓ Branch 8 → 9 taken 8 times.
✓ Branch 8 → 26 taken 39 times.
✓ Branch 9 → 10 taken 7 times.
✓ Branch 9 → 26 taken 1 time.
47 if (!isCommandResponse && count > m_selectedMessageCount) {
1008 // New messages arrived (IDLE or NOTIFY push)
1009
6/6
✓ Branch 10 → 11 taken 7 times.
✓ Branch 14 → 15 taken 7 times.
✓ Branch 15 → 16 taken 7 times.
✓ Branch 16 → 17 taken 7 times.
✓ Branch 23 → 12 taken 7 times.
✓ Branch 23 → 24 taken 7 times.
14 qCInfo(lcImap) << "Push: new messages detected," << count
1010
3/3
✓ Branch 17 → 18 taken 7 times.
✓ Branch 18 → 19 taken 7 times.
✓ Branch 19 → 20 taken 7 times.
7 << "total (was" << m_selectedMessageCount << ")";
1011 7 int newCount = count - m_selectedMessageCount;
1012 7 m_selectedMessageCount = count;
1013
1/1
✓ Branch 24 → 25 taken 7 times.
7 emit idleNewMessages(newCount);
1014 7 } else {
1015 40 m_selectedMessageCount = count;
1016
7/7
✓ Branch 26 → 27 taken 40 times.
✓ Branch 30 → 31 taken 40 times.
✓ Branch 31 → 32 taken 40 times.
✓ Branch 32 → 33 taken 40 times.
✓ Branch 33 → 34 taken 40 times.
✓ Branch 37 → 28 taken 40 times.
✓ Branch 37 → 38 taken 40 times.
80 qCInfo(lcImap) << "EXISTS:" << count << "messages";
1017 }
1018 }
1019 48 }
1020
1021 // EXPUNGE response: "* N EXPUNGE" → type="N", data="EXPUNGE"
1022 6 void ImapService::handleUntaggedExpunge(const QString &type) {
1023 6 bool ok = false;
1024
1/1
✓ Branch 2 → 3 taken 6 times.
6 int seqNo = type.toInt(&ok);
1025
2/2
✓ Branch 3 → 4 taken 5 times.
✓ Branch 3 → 39 taken 1 time.
6 if (ok) {
1026 5 m_selectedMessageCount = qMax(0, m_selectedMessageCount - 1);
1027
7/7
✓ Branch 5 → 6 taken 5 times.
✓ Branch 9 → 10 taken 5 times.
✓ Branch 10 → 11 taken 5 times.
✓ Branch 11 → 12 taken 5 times.
✓ Branch 12 → 13 taken 5 times.
✓ Branch 16 → 7 taken 5 times.
✓ Branch 16 → 17 taken 5 times.
10 qCInfo(lcImap) << "EXPUNGE: message" << seqNo << "removed";
1028 // T-320: Only emit push signal if no command caused this EXPUNGE
1029 bool isCommandResponse =
1030
1/3
✓ Branch 18 → 19 taken 5 times.
✗ Branch 53 → 54 not taken.
✗ Branch 53 → 55 not taken.
10 m_commands->hasCommandType("EXPUNGE") ||
1031
8/11
✓ Branch 19 → 20 taken 5 times.
✓ Branch 20 → 21 taken 4 times.
✓ Branch 20 → 29 taken 1 time.
✓ Branch 22 → 23 taken 4 times.
✓ Branch 23 → 24 taken 4 times.
✓ Branch 24 → 25 taken 3 times.
✓ Branch 24 → 29 taken 1 time.
✓ Branch 35 → 36 taken 5 times.
✗ Branch 35 → 37 not taken.
✗ Branch 49 → 50 not taken.
✗ Branch 49 → 51 not taken.
18 m_commands->hasCommandType("MOVE") ||
1032
8/10
✓ Branch 26 → 27 taken 3 times.
✓ Branch 27 → 28 taken 3 times.
✓ Branch 28 → 29 taken 1 time.
✓ Branch 28 → 30 taken 2 times.
✓ Branch 31 → 32 taken 3 times.
✓ Branch 31 → 33 taken 2 times.
✓ Branch 33 → 34 taken 4 times.
✓ Branch 33 → 35 taken 1 time.
✗ Branch 45 → 46 not taken.
✗ Branch 45 → 47 not taken.
8 m_commands->hasCommandType("SELECT");
1033
2/2
✓ Branch 37 → 38 taken 2 times.
✓ Branch 37 → 39 taken 3 times.
5 if (!isCommandResponse) {
1034
1/1
✓ Branch 38 → 39 taken 2 times.
2 emit idleMessageExpunged(seqNo);
1035 }
1036 }
1037 6 }
1038
1039 // STATUS response: "* STATUS "INBOX" (MESSAGES 42 UNSEEN 3 RECENT 0)"
1040 23 void ImapService::handleUntaggedStatus(const QString &data) {
1041 // data = '"INBOX" (MESSAGES 42 UNSEEN 3 RECENT 0)'
1042
1/1
✓ Branch 2 → 3 taken 23 times.
23 auto parsed = ImapResponseParser::parseStatusResponse(data);
1043
2/2
✓ Branch 4 → 5 taken 21 times.
✓ Branch 4 → 30 taken 2 times.
23 if (parsed) {
1044
1/1
✓ Branch 9 → 10 taken 21 times.
42 ImapPendingCommand *status = m_commands->recordOfType(QStringLiteral("STATUS"));
1045
6/6
✓ Branch 12 → 13 taken 14 times.
✓ Branch 12 → 17 taken 7 times.
✓ Branch 15 → 16 taken 13 times.
✓ Branch 15 → 17 taken 1 time.
✓ Branch 18 → 19 taken 13 times.
✓ Branch 18 → 28 taken 8 times.
21 if (status && parsed->folderPath == status->statusFolder) {
1046 // A queued NOTIFY push may precede the actual reply. Publish the last
1047 // matching snapshot only after the command's tagged success boundary.
1048
6/6
✓ Branch 20 → 21 taken 3 times.
✓ Branch 20 → 23 taken 10 times.
✓ Branch 22 → 23 taken 2 times.
✓ Branch 22 → 24 taken 1 time.
✓ Branch 25 → 26 taken 12 times.
✓ Branch 25 → 30 taken 1 time.
13 if (parsed->hasUnseen || !status->status)
1049 12 status->status = *parsed;
1050 } else {
1051
1/1
✓ Branch 29 → 30 taken 8 times.
8 emit folderStatusReceived(*parsed);
1052 }
1053 }
1054 23 }
1055
1056 // UIDVALIDITY in OK response: "* OK [UIDVALIDITY 12345]"
1057 100070 void ImapService::handleUntaggedOk(const QString &data) {
1058 // S106-023: UIDVALIDITY and HIGHESTMODSEQ describe the mailbox a SELECT
1059 // opens. Outside one they are ignored — adopting them decoupled the epoch
1060 // and the CONDSTORE watermark from the mailbox actually selected (a
1061 // raised watermark makes the next CHANGEDSINCE skip unseen changes).
1062
3/3
✓ Branch 6 → 7 taken 100070 times.
✓ Branch 9 → 10 taken 100004 times.
✓ Branch 9 → 34 taken 66 times.
200140 if (!m_commands->hasCommandType(QStringLiteral("SELECT"))) {
1063
3/3
✓ Branch 11 → 12 taken 100004 times.
✓ Branch 12 → 13 taken 100003 times.
✓ Branch 12 → 16 taken 1 time.
200007 if (data.contains(QLatin1String("[UIDVALIDITY"), Qt::CaseInsensitive) ||
1064
5/5
✓ Branch 14 → 15 taken 100003 times.
✓ Branch 15 → 16 taken 1 time.
✓ Branch 15 → 17 taken 100002 times.
✓ Branch 18 → 19 taken 2 times.
✓ Branch 18 → 33 taken 100002 times.
200007 data.contains(QLatin1String("[HIGHESTMODSEQ"), Qt::CaseInsensitive))
1065
7/7
✓ Branch 19 → 20 taken 2 times.
✓ Branch 23 → 24 taken 2 times.
✓ Branch 24 → 25 taken 2 times.
✓ Branch 25 → 26 taken 2 times.
✓ Branch 26 → 27 taken 2 times.
✓ Branch 31 → 21 taken 2 times.
✓ Branch 31 → 32 taken 2 times.
4 qCInfo(lcImap) << "Mailbox epoch outside a SELECT ignored:" << data.left(80);
1066 100004 return;
1067 }
1068
1/1
✓ Branch 34 → 35 taken 66 times.
66 auto uv = ImapResponseParser::parseUidValidity(data);
1069
2/2
✓ Branch 36 → 37 taken 39 times.
✓ Branch 36 → 50 taken 27 times.
66 if (uv) {
1070
1/1
✓ Branch 37 → 38 taken 39 times.
39 m_selectedUidValidity = uv.value();
1071
6/6
✓ Branch 38 → 39 taken 39 times.
✓ Branch 42 → 43 taken 39 times.
✓ Branch 43 → 44 taken 39 times.
✓ Branch 44 → 45 taken 39 times.
✓ Branch 48 → 40 taken 39 times.
✓ Branch 48 → 49 taken 39 times.
78 qCInfo(lcImap) << "UIDVALIDITY:" << m_selectedUidValidity;
1072 }
1073
1074 // T-208: Parse HIGHESTMODSEQ from "* OK [HIGHESTMODSEQ 12345]"
1075 static QRegularExpression modseqRx(
1076
5/8
✓ Branch 50 → 51 taken 4 times.
✓ Branch 50 → 60 taken 62 times.
✓ Branch 52 → 53 taken 4 times.
✗ Branch 52 → 60 not taken.
✓ Branch 54 → 55 taken 4 times.
✓ Branch 55 → 56 taken 4 times.
✗ Branch 107 → 108 not taken.
✗ Branch 107 → 109 not taken.
66 R"(\[HIGHESTMODSEQ\s+(\d+)\])", QRegularExpression::CaseInsensitiveOption);
1077
1/1
✓ Branch 61 → 62 taken 66 times.
66 auto modseqMatch = modseqRx.match(data);
1078
3/3
✓ Branch 62 → 63 taken 66 times.
✓ Branch 63 → 64 taken 25 times.
✓ Branch 63 → 79 taken 41 times.
66 if (modseqMatch.hasMatch()) {
1079
2/2
✓ Branch 64 → 65 taken 25 times.
✓ Branch 65 → 66 taken 25 times.
25 m_selectedHighestModseq = modseqMatch.captured(1).toULongLong();
1080
6/6
✓ Branch 67 → 68 taken 25 times.
✓ Branch 71 → 72 taken 25 times.
✓ Branch 72 → 73 taken 25 times.
✓ Branch 73 → 74 taken 25 times.
✓ Branch 77 → 69 taken 25 times.
✓ Branch 77 → 78 taken 25 times.
50 qCInfo(lcImap) << "T-208: HIGHESTMODSEQ:" << m_selectedHighestModseq;
1081 }
1082 66 }
1083
1084 475 void ImapService::handleTagged(const QString &line) {
1085
1/1
✓ Branch 2 → 3 taken 475 times.
475 auto response = ImapResponseParser::parseTaggedResponse(line);
1086
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 475 times.
475 if (!response)
1087 ✗ return;
1088
1089
1/1
✓ Branch 8 → 9 taken 475 times.
475 ImapPendingCommand command = m_commands->takeRecord(response->tag);
1090 475 const QString commandType = command.type;
1091 #ifdef MAILJD_E2E_TESTING
1092
2/2
✓ Branch 10 → 11 taken 405 times.
✓ Branch 16 → 17 taken 405 times.
810 reportE2eTransportPhase(
1093 810 QStringLiteral("command-tagged:%1:%2")
1094
1/1
✓ Branch 15 → 16 taken 405 times.
810 .arg(commandType, response->status),
1095 e2eRequestTokenFor(command));
1096 #endif
1097 struct SerializedDrainGuard {
1098 ImapCommandTable *commands = nullptr;
1099 475 ~SerializedDrainGuard() {
1100
1/2
✓ Branch 2 → 3 taken 475 times.
✗ Branch 2 → 4 not taken.
475 if (commands)
1101 475 commands->runNextSerializedCommand();
1102 475 }
1103 475 } drainGuard{m_commands.get()};
1104
1105 // T-210: Log command duration
1106
4/4
✓ Branch 12 → 13 taken 69 times.
✓ Branch 12 → 31 taken 1 time.
✓ Branch 22 → 23 taken 307 times.
✓ Branch 22 → 41 taken 98 times.
475 if (command.timer) {
1107
11/11
✓ Branch 13 → 14 taken 69 times.
✓ Branch 17 → 18 taken 69 times.
✓ Branch 18 → 19 taken 69 times.
✓ Branch 19 → 20 taken 69 times.
✓ Branch 23 → 24 taken 307 times.
✓ Branch 27 → 28 taken 307 times.
✓ Branch 28 → 29 taken 307 times.
✓ Branch 29 → 15 taken 69 times.
✓ Branch 29 → 30 taken 376 times.
✓ Branch 39 → 25 taken 307 times.
✓ Branch 39 → 40 taken 307 times.
752 qCInfo(lcImapTiming) << "IMAP timing:" << commandType
1108
4/4
✓ Branch 20 → 21 taken 69 times.
✓ Branch 21 → 22 taken 69 times.
✓ Branch 30 → 31 taken 307 times.
✓ Branch 31 → 32 taken 307 times.
376 << m_selectedFolder << "→"
1109
4/4
✓ Branch 24 → 25 taken 69 times.
✓ Branch 25 → 26 taken 69 times.
✓ Branch 34 → 35 taken 307 times.
✓ Branch 35 → 36 taken 307 times.
376 << command.timer->elapsed() << "ms";
1110 }
1111
2/2
✓ Branch 32 → 33 taken 70 times.
✓ Branch 42 → 43 taken 405 times.
475 m_commands->refreshCommandTimeout();
1112
2/2
✓ Branch 34 → 35 taken 70 times.
✓ Branch 44 → 45 taken 405 times.
475 m_commands->refreshCommandDeadline();
1113
6/6
✓ Branch 36 → 37 taken 70 times.
✓ Branch 37 → 38 taken 69 times.
✓ Branch 37 → 40 taken 1 time.
✓ Branch 46 → 47 taken 405 times.
✓ Branch 47 → 48 taken 385 times.
✓ Branch 47 → 50 taken 20 times.
475 if (!m_commands->hasTimeoutTrackedCommandInFlight())
1114
2/2
✓ Branch 39 → 40 taken 69 times.
✓ Branch 49 → 50 taken 385 times.
454 m_commands->resetCommandAccumulators();
1115
1116 // T-720: Clear the liveness probe state when its tagged response arrives
1117 // (any OK/NO from the server proves the socket is alive). BAD responses
1118 // are surfaced below by the regular errorOccurred handlers, which call
1119 // failConnection() and stop the watchdog there.
1120
7/12
✗ Branch 43 → 44 not taken.
✓ Branch 43 → 50 taken 70 times.
✗ Branch 48 → 49 not taken.
✗ Branch 48 → 50 not taken.
✗ Branch 51 → 52 not taken.
✓ Branch 51 → 78 taken 70 times.
✓ Branch 53 → 54 taken 5 times.
✓ Branch 53 → 60 taken 400 times.
✓ Branch 58 → 59 taken 5 times.
✗ Branch 58 → 60 not taken.
✓ Branch 61 → 62 taken 5 times.
✓ Branch 61 → 88 taken 400 times.
475 if (!m_push->probeTag().isEmpty() && m_push->probeTag() == response->tag) {
1121
6/12
✗ Branch 52 → 53 not taken.
✗ Branch 56 → 57 not taken.
✗ Branch 57 → 58 not taken.
✗ Branch 60 → 61 not taken.
✓ Branch 62 → 63 taken 5 times.
✗ Branch 65 → 54 not taken.
✗ Branch 65 → 66 not taken.
✓ Branch 66 → 67 taken 5 times.
✓ Branch 67 → 68 taken 5 times.
✓ Branch 70 → 71 taken 5 times.
✓ Branch 75 → 64 taken 5 times.
✓ Branch 75 → 76 taken 5 times.
10 qCInfo(lcImap) << "Liveness probe OK (" << m_push->probeTag()
1122
1/2
✗ Branch 61 → 62 not taken.
✓ Branch 71 → 72 taken 5 times.
5 << ") — disarming watchdog";
1123 5 m_push->probeTag().clear();
1124
1/2
✗ Branch 71 → 72 not taken.
✓ Branch 81 → 82 taken 5 times.
5 m_push->livenessProbeWatchdog()->stop();
1125
1/4
✗ Branch 73 → 74 not taken.
✗ Branch 73 → 78 not taken.
✓ Branch 83 → 84 taken 5 times.
✗ Branch 83 → 88 not taken.
5 if (command.timer)
1126
1/2
✗ Branch 77 → 78 not taken.
✓ Branch 87 → 88 taken 5 times.
5 m_push->noteRoundTrip(command.timer->elapsed());
1127 }
1128
1129
13/13
✓ Branch 78 → 79 taken 70 times.
✓ Branch 82 → 83 taken 70 times.
✓ Branch 83 → 84 taken 70 times.
✓ Branch 84 → 85 taken 70 times.
✓ Branch 85 → 86 taken 70 times.
✓ Branch 88 → 89 taken 405 times.
✓ Branch 92 → 93 taken 405 times.
✓ Branch 93 → 80 taken 70 times.
✓ Branch 93 → 94 taken 475 times.
✓ Branch 94 → 95 taken 405 times.
✓ Branch 95 → 96 taken 405 times.
✓ Branch 103 → 90 taken 405 times.
✓ Branch 103 → 104 taken 405 times.
950 qCDebug(lcImap) << "Tagged response for" << commandType << ":"
1130
4/4
✓ Branch 87 → 88 taken 70 times.
✓ Branch 89 → 90 taken 70 times.
✓ Branch 97 → 98 taken 405 times.
✓ Branch 99 → 100 taken 405 times.
475 << response->status << response->message;
1131
1132
2/2
✓ Branch 95 → 96 taken 70 times.
✓ Branch 105 → 106 taken 405 times.
475 dispatchTagged(command, *response);
1133 475 }
1134
1135 // S92.1.8: tagged completion → handler of the unit that owns the command, in
1136 // the order of the former if-chain. Types without a handler (NOOP, LOGOUT)
1137 // only run the common completion steps of handleTagged.
1138 475 void ImapService::dispatchTagged(ImapPendingCommand &command,
1139 const TaggedResponse &response) {
1140 using Command = ImapPendingCommand;
1141 using Handler = void (*)(ImapService &, const TaggedResponse &, Command &);
1142 static const std::pair<QLatin1StringView, Handler> handlers[] = {
1143 {QLatin1StringView("CAPABILITY"),
1144 63 [](ImapService &s, const TaggedResponse &r, Command &) { s.m_setup->handleTaggedCapability(r); }},
1145 {QLatin1StringView("STARTTLS"),
1146 3 [](ImapService &s, const TaggedResponse &r, Command &) { s.m_setup->handleTaggedStartTls(r); }},
1147 {QLatin1StringView("LOGIN"),
1148 55 [](ImapService &s, const TaggedResponse &r, Command &) { s.m_setup->handleTaggedLogin(r); }},
1149 {QLatin1StringView("LIST"),
1150 20 [](ImapService &s, const TaggedResponse &r, Command &) { s.handleTaggedList(r); }},
1151 {QLatin1StringView("SELECT"),
1152 48 [](ImapService &s, const TaggedResponse &r, Command &c) { s.handleTaggedSelect(r, c); }},
1153 {QLatin1StringView("FETCH_HEADERS"),
1154 5 [](ImapService &s, const TaggedResponse &r, Command &) { s.handleTaggedFetchHeaders(r); }},
1155 {QLatin1StringView("FETCH_BODY"),
1156 42 [](ImapService &s, const TaggedResponse &r, Command &) { s.m_body->handleTaggedFetchBody(r); }},
1157 {QLatin1StringView("FETCH_FLAGS"),
1158 9 [](ImapService &s, const TaggedResponse &r, Command &) { s.handleTaggedFetchFlags(r); }},
1159 {QLatin1StringView("FETCH_VERIFY_FLAGS"),
1160 5 [](ImapService &s, const TaggedResponse &r, Command &) { s.handleTaggedVerifyFlags(r); }},
1161 {QLatin1StringView("FETCH_FLAGS_CHANGED"),
1162 3 [](ImapService &s, const TaggedResponse &r, Command &) { s.handleTaggedFetchFlagsChanged(r); }},
1163 {QLatin1StringView("SEARCH_MEMBERSHIP"),
1164 2 [](ImapService &s, const TaggedResponse &r, Command &) { s.handleTaggedSearchMembership(r); }},
1165 {QLatin1StringView("SEARCH"),
1166 48 [](ImapService &s, const TaggedResponse &r, Command &) { s.handleTaggedSearch(r); }},
1167 {QLatin1StringView("FETCH_SEQNO"),
1168 4 [](ImapService &s, const TaggedResponse &r, Command &) { s.handleTaggedFetchSeqno(r); }},
1169 {QLatin1StringView("IDLE"),
1170 10 [](ImapService &s, const TaggedResponse &r, Command &) { s.m_push->handleTaggedIdle(r); }},
1171 {QLatin1StringView("NOTIFY"), // T-320: NOTIFY SET
1172 6 [](ImapService &s, const TaggedResponse &r, Command &) { s.m_push->handleTaggedNotify(r); }},
1173 {QLatin1StringView("NOTIFY_NONE"),
1174 7 [](ImapService &s, const TaggedResponse &r, Command &) { s.m_push->handleTaggedNotifyNone(r); }},
1175 {QLatin1StringView("STATUS"),
1176 13 [](ImapService &s, const TaggedResponse &r, Command &c) { s.handleTaggedStatus(r, c); }},
1177 {QLatin1StringView("STORE"),
1178 15 [](ImapService &s, const TaggedResponse &r, Command &c) { s.m_mutations->handleTaggedStore(r, c); }},
1179 {QLatin1StringView("MOVE"), // T-100: RFC 6851
1180 16 [](ImapService &s, const TaggedResponse &r, Command &c) { s.m_mutations->handleTaggedMove(r, c); }},
1181 {QLatin1StringView("COPY"), // T-100: MOVE fallback COPY + DELETE + EXPUNGE
1182 8 [](ImapService &s, const TaggedResponse &r, Command &c) { s.m_mutations->handleTaggedCopy(r, c); }},
1183 {QLatin1StringView("STORE_DELETE"),
1184 8 [](ImapService &s, const TaggedResponse &r, Command &c) { s.m_mutations->handleTaggedStoreDelete(r, c); }},
1185 {QLatin1StringView("EXPUNGE_MOVE"),
1186 9 [](ImapService &s, const TaggedResponse &r, Command &c) { s.m_mutations->handleTaggedExpungeMove(r, c); }},
1187 {QLatin1StringView("COPY_ONLY"), // T-100: standalone COPY
1188 6 [](ImapService &s, const TaggedResponse &r, Command &c) { s.m_mutations->handleTaggedCopyOnly(r, c); }},
1189 {QLatin1StringView("EXPUNGE"), // T-176: standalone EXPUNGE
1190 8 [](ImapService &s, const TaggedResponse &r, Command &) { s.m_mutations->handleTaggedExpunge(r); }},
1191 {QLatin1StringView("APPEND"),
1192 34 [](ImapService &s, const TaggedResponse &r, Command &c) { s.m_append->handleTaggedAppend(r, c); }},
1193 {QLatin1StringView("CREATE"),
1194 6 [](ImapService &s, const TaggedResponse &r, Command &c) { s.handleTaggedCreate(r, c); }},
1195 {QLatin1StringView("DELETE"),
1196 5 [](ImapService &s, const TaggedResponse &r, Command &c) { s.handleTaggedDelete(r, c); }},
1197 {QLatin1StringView("RENAME"),
1198 4 [](ImapService &s, const TaggedResponse &r, Command &c) { s.handleTaggedRename(r, c); }},
1199 };
1200
2/2
✓ Branch 10 → 3 taken 5285 times.
✓ Branch 10 → 11 taken 13 times.
5298 for (const auto &[type, handler] : handlers) {
1201
2/2
✓ Branch 6 → 7 taken 462 times.
✓ Branch 6 → 9 taken 4823 times.
5285 if (command.type == type) {
1202 462 handler(*this, response, command);
1203 462 return;
1204 }
1205 }
1206 }
1207
1208 20 void ImapService::handleTaggedList(const TaggedResponse &response) {
1209
2/2
✓ Branch 3 → 4 taken 19 times.
✓ Branch 3 → 19 taken 1 time.
20 if (response.status == "OK") {
1210
6/6
✓ Branch 4 → 5 taken 19 times.
✓ Branch 8 → 9 taken 19 times.
✓ Branch 9 → 10 taken 19 times.
✓ Branch 11 → 12 taken 19 times.
✓ Branch 16 → 6 taken 19 times.
✓ Branch 16 → 17 taken 19 times.
38 qCInfo(lcImap) << "LIST complete:" << m_pendingFolders.size()
1211
1/1
✓ Branch 12 → 13 taken 19 times.
19 << "folders";
1212 19 emit folderListReceived(m_pendingFolders);
1213 19 m_pendingFolders.clear();
1214 } else {
1215
2/2
✓ Branch 19 → 20 taken 1 time.
✓ Branch 20 → 21 taken 1 time.
1 emit errorOccurred("LIST failed: " + response.message);
1216 }
1217 20 }
1218
1219 48 void ImapService::handleTaggedSelect(const TaggedResponse &response,
1220 ImapPendingCommand &command) {
1221 const quint64 bodyRequestId =
1222
2/2
✓ Branch 4 → 5 taken 5 times.
✓ Branch 4 → 8 taken 43 times.
48 m_body->bodyFetchRequiresSelect() ? m_body->activeBodyRequestId() : 0;
1223
2/2
✓ Branch 10 → 11 taken 44 times.
✓ Branch 10 → 33 taken 4 times.
48 if (response.status == "OK") {
1224 // A pipelined body response is valid only after this SELECT succeeds.
1225 44 m_body->bodyFetchRequiresSelect() = false;
1226 // Bug 34: Only assign m_selectedFolder on SELECT OK
1227 44 m_selectedFolder = command.selectFolder;
1228
6/6
✓ Branch 14 → 15 taken 44 times.
✓ Branch 18 → 19 taken 44 times.
✓ Branch 19 → 20 taken 44 times.
✓ Branch 20 → 21 taken 44 times.
✓ Branch 28 → 16 taken 44 times.
✓ Branch 28 → 29 taken 44 times.
88 qCInfo(lcImap) << "SELECT complete:" << m_selectedFolder
1229
2/2
✓ Branch 21 → 22 taken 44 times.
✓ Branch 22 → 23 taken 44 times.
44 << "msgs=" << m_selectedMessageCount
1230
2/2
✓ Branch 23 → 24 taken 44 times.
✓ Branch 24 → 25 taken 44 times.
44 << "uidvalidity=" << m_selectedUidValidity;
1231 44 setState(State::Selected);
1232 44 emit folderSelected(m_selectedFolder, m_selectedMessageCount,
1233 m_selectedUidValidity, m_selectedHighestModseq);
1234
2/2
✓ Branch 31 → 32 taken 2 times.
✓ Branch 31 → 53 taken 42 times.
44 if (bodyRequestId > 0)
1235 2 emit bodyMailboxSelected(bodyRequestId, true);
1236 } else {
1237
2/2
✓ Branch 33 → 34 taken 4 times.
✓ Branch 34 → 35 taken 4 times.
4 emit errorOccurred("SELECT failed: " + response.message);
1238
2/2
✓ Branch 38 → 39 taken 2 times.
✓ Branch 38 → 52 taken 2 times.
4 if (m_body->bodyFetchRequiresSelect()) {
1239
2/2
✓ Branch 39 → 40 taken 1 time.
✓ Branch 39 → 41 taken 1 time.
2 if (bodyRequestId > 0)
1240 1 emit bodyMailboxSelected(bodyRequestId, false);
1241
1/1
✓ Branch 46 → 47 taken 2 times.
4 m_body->completeBodyRequest(BodyOutcome::Failed, {},
1242 4 QStringLiteral("Mailbox selection failed"));
1243 2 m_body->invalidateBodyFetch();
1244 }
1245 // Notify multi-folder flows so they can skip this folder instead of
1246 // stalling (real servers reject \Noselect container folders here).
1247 4 emit folderSelectFailed(command.selectFolder);
1248 }
1249 // T-401/Bug 10: Only drain deferred commands on SELECT success
1250
6/6
✓ Branch 54 → 55 taken 44 times.
✓ Branch 54 → 60 taken 4 times.
✓ Branch 58 → 59 taken 1 time.
✓ Branch 58 → 60 taken 43 times.
✓ Branch 61 → 62 taken 1 time.
✓ Branch 61 → 64 taken 47 times.
48 if (response.status == "OK" && !m_commands->deferredCommands().isEmpty()) {
1251 1 m_commands->runNextDeferredCommand();
1252
2/2
✓ Branch 65 → 66 taken 4 times.
✓ Branch 65 → 88 taken 43 times.
47 } else if (response.status != "OK") {
1253 // Discard deferred commands — they'd operate on wrong folder
1254
2/2
✓ Branch 69 → 70 taken 1 time.
✓ Branch 69 → 88 taken 3 times.
4 if (!m_commands->deferredCommands().isEmpty()) {
1255
5/5
✓ Branch 70 → 71 taken 1 time.
✓ Branch 74 → 75 taken 1 time.
✓ Branch 75 → 76 taken 1 time.
✓ Branch 84 → 72 taken 1 time.
✓ Branch 84 → 85 taken 1 time.
2 qCWarning(lcImap) << "SELECT failed — discarding"
1256
1/1
✓ Branch 79 → 80 taken 1 time.
1 << m_commands->deferredCommands().size()
1257
1/1
✓ Branch 80 → 81 taken 1 time.
1 << "deferred commands";
1258 1 m_commands->deferredCommands().clear();
1259 }
1260 }
1261 48 }
1262
1263 5 void ImapService::handleTaggedFetchHeaders(const TaggedResponse &response) {
1264
1/2
✓ Branch 3 → 4 taken 5 times.
✗ Branch 3 → 22 not taken.
5 if (response.status == "OK") {
1265
6/6
✓ Branch 4 → 5 taken 5 times.
✓ Branch 8 → 9 taken 5 times.
✓ Branch 9 → 10 taken 5 times.
✓ Branch 11 → 12 taken 5 times.
✓ Branch 16 → 6 taken 5 times.
✓ Branch 16 → 17 taken 5 times.
10 qCInfo(lcImap) << "FETCH headers complete:" << m_pendingHeaders.size()
1266
1/1
✓ Branch 12 → 13 taken 5 times.
5 << "remaining";
1267 // Emit any remaining buffered headers
1268
2/2
✓ Branch 18 → 19 taken 2 times.
✓ Branch 18 → 21 taken 3 times.
5 if (!m_pendingHeaders.isEmpty()) {
1269 2 emit headersReceived(m_pendingHeaders);
1270 2 m_pendingHeaders.clear();
1271 }
1272 // Signal that all header batches have been delivered
1273 5 emit headerFetchComplete();
1274 } else {
1275 ✗ emit errorOccurred("FETCH headers failed: " + response.message);
1276 }
1277 5 }
1278
1279 9 void ImapService::handleTaggedFetchFlags(const TaggedResponse &response) {
1280
1/2
✓ Branch 3 → 4 taken 9 times.
✗ Branch 3 → 21 not taken.
9 if (response.status == "OK") {
1281
6/6
✓ Branch 4 → 5 taken 9 times.
✓ Branch 8 → 9 taken 9 times.
✓ Branch 9 → 10 taken 9 times.
✓ Branch 11 → 12 taken 9 times.
✓ Branch 16 → 6 taken 9 times.
✓ Branch 16 → 17 taken 9 times.
18 qCInfo(lcImap) << "FETCH flags complete:" << m_pendingFlags.size()
1282
1/1
✓ Branch 12 → 13 taken 9 times.
9 << "entries";
1283 9 emit flagSnapshotsReceived(m_pendingFlagSnapshots, m_selectedFolder);
1284 9 emit flagsReceived(m_pendingFlags);
1285 9 m_pendingFlags.clear();
1286 9 m_pendingFlagSnapshots.clear();
1287 } else {
1288 ✗ emit errorOccurred("FETCH flags failed: " + response.message);
1289 ✗ m_pendingFlags.clear();
1290 ✗ m_pendingFlagSnapshots.clear();
1291 }
1292 9 }
1293
1294 3 void ImapService::handleTaggedFetchFlagsChanged(const TaggedResponse &response) {
1295 // A partial list: never emitted as a flag snapshot (that would claim
1296 // every unlisted UID is absent).
1297
1/2
✓ Branch 3 → 4 taken 3 times.
✗ Branch 3 → 18 not taken.
3 if (response.status == "OK") {
1298
7/7
✓ Branch 4 → 5 taken 3 times.
✓ Branch 8 → 9 taken 3 times.
✓ Branch 9 → 10 taken 3 times.
✓ Branch 11 → 12 taken 3 times.
✓ Branch 12 → 13 taken 3 times.
✓ Branch 16 → 6 taken 3 times.
✓ Branch 16 → 17 taken 3 times.
6 qCInfo(lcImap) << "CHANGEDSINCE complete:" << m_pendingFlags.size() << "changed";
1299 3 emit changedFlagsReceived(m_pendingFlags);
1300 } else {
1301 // F-72: a declined CHANGEDSINCE (e.g. a stale watermark pipelined
1302 // before SELECT revealed a UIDVALIDITY change) is a folder-sync
1303 // event, not a connection error — it must not arm the health
1304 // monitor's reconnect ladder for a healthy Selected connection.
1305 // Emit the empty result so the pipeline advances; the membership
1306 // query and backfill complete the flag pass.
1307 ✗ qCWarning(lcImap) << "CHANGEDSINCE declined, falling back to full sync:"
1308 ✗ << response.message;
1309 ✗ emit changedFlagsReceived({});
1310 }
1311 3 m_pendingFlags.clear();
1312 3 m_pendingFlagSnapshots.clear();
1313 3 }
1314
1315 2 void ImapService::handleTaggedSearchMembership(const TaggedResponse &response) {
1316
1/2
✓ Branch 3 → 4 taken 2 times.
✗ Branch 3 → 18 not taken.
2 if (response.status == "OK") {
1317
7/7
✓ Branch 4 → 5 taken 2 times.
✓ Branch 8 → 9 taken 2 times.
✓ Branch 9 → 10 taken 2 times.
✓ Branch 11 → 12 taken 2 times.
✓ Branch 12 → 13 taken 2 times.
✓ Branch 16 → 6 taken 2 times.
✓ Branch 16 → 17 taken 2 times.
4 qCInfo(lcImap) << "UID membership complete:" << m_pendingSearchUids.size() << "UIDs";
1318 2 emit uidMembershipReceived(m_pendingSearchUids);
1319 } else {
1320 // F-72: same family as CHANGEDSINCE above — a declined membership
1321 // SEARCH reports an empty membership, which reconciles every cached
1322 // UID as missing and triggers the full backfill (with flags) instead
1323 // of a spurious reconnect of the healthy connection.
1324 ✗ qCWarning(lcImap) << "UID SEARCH declined, reconciling via backfill:"
1325 ✗ << response.message;
1326 ✗ emit uidMembershipReceived({});
1327 }
1328 2 m_pendingSearchUids.clear();
1329 2 }
1330
1331 // T-065 fix: Single-UID flag fetch (from IDLE seqNo resolution).
1332 // Emit each result directly as idleFlagsChanged → targeted update, no purge.
1333 4 void ImapService::handleTaggedFetchSeqno(const TaggedResponse &response) {
1334
2/2
✓ Branch 3 → 4 taken 3 times.
✓ Branch 3 → 29 taken 1 time.
4 if (response.status == "OK") {
1335
6/6
✓ Branch 4 → 5 taken 3 times.
✓ Branch 8 → 9 taken 3 times.
✓ Branch 9 → 10 taken 3 times.
✓ Branch 11 → 12 taken 3 times.
✓ Branch 16 → 6 taken 3 times.
✓ Branch 16 → 17 taken 3 times.
6 qCInfo(lcImap) << "FETCH_SEQNO complete:" << m_pendingFlags.size()
1336
1/1
✓ Branch 12 → 13 taken 3 times.
3 << "entries";
1337
4/4
✓ Branch 17 → 18 taken 3 times.
✓ Branch 18 → 19 taken 3 times.
✓ Branch 26 → 20 taken 3 times.
✓ Branch 26 → 27 taken 3 times.
6 for (const auto &[uid, flags] : m_pendingFlags) {
1338
1/1
✓ Branch 23 → 24 taken 3 times.
3 emit idleFlagsChanged(uid, flags);
1339 }
1340 3 m_pendingFlags.clear();
1341 3 m_pendingFlagSnapshots.clear();
1342 } else {
1343
2/2
✓ Branch 29 → 30 taken 1 time.
✓ Branch 30 → 31 taken 1 time.
1 emit errorOccurred("FETCH seqNo failed: " + response.message);
1344 1 m_pendingFlags.clear();
1345 1 m_pendingFlagSnapshots.clear();
1346 }
1347 // Drain remaining deferred commands (e.g. more flag refetches from
1348 // multiple IDLE pushes) before restarting IDLE.
1349
2/2
✓ Branch 37 → 38 taken 1 time.
✓ Branch 37 → 40 taken 3 times.
4 if (!m_commands->deferredCommands().isEmpty()) {
1350 1 m_commands->runNextDeferredCommand();
1351 } else {
1352 3 m_push->restartPush();
1353 }
1354 4 }
1355
1356 13 void ImapService::handleTaggedStatus(const TaggedResponse &response,
1357 ImapPendingCommand &command) {
1358 13 auto snapshot = std::exchange(command.status, std::nullopt);
1359 13 const auto startedNs = std::exchange(command.statusStartedNs, 0);
1360 13 const auto folder = std::exchange(command.statusFolder, {});
1361
2/2
✓ Branch 8 → 9 taken 3 times.
✓ Branch 8 → 23 taken 10 times.
13 if (response.status != "OK") {
1362 // Non-fatal: folder may not exist (e.g. dovecot/sieve) – log, don't alarm.
1363 // F-26 (SPRINT-104): report the folder so the sequential polling round
1364 // continues with the next folder instead of stopping at this NO.
1365
6/6
✓ Branch 9 → 10 taken 3 times.
✓ Branch 13 → 14 taken 3 times.
✓ Branch 14 → 15 taken 3 times.
✓ Branch 15 → 16 taken 3 times.
✓ Branch 21 → 11 taken 3 times.
✓ Branch 21 → 22 taken 3 times.
6 qCWarning(lcImap) << "STATUS failed for" << folder
1366
2/2
✓ Branch 16 → 17 taken 3 times.
✓ Branch 17 → 18 taken 3 times.
3 << ":" << response.message;
1367
1/1
✓ Branch 22 → 28 taken 3 times.
3 emit folderStatusFailed(folder);
1368
2/2
✓ Branch 24 → 25 taken 8 times.
✓ Branch 24 → 28 taken 2 times.
10 } else if (snapshot) {
1369 8 snapshot->requestStartedNs = startedNs;
1370
1/1
✓ Branch 27 → 28 taken 8 times.
8 emit folderStatusReceived(*snapshot);
1371 }
1372 13 }
1373
1374 // T-281: CREATE folder
1375 6 void ImapService::handleTaggedCreate(const TaggedResponse &response,
1376 ImapPendingCommand &command) {
1377
2/2
✓ Branch 3 → 4 taken 5 times.
✓ Branch 3 → 16 taken 1 time.
6 if (response.status == "OK") {
1378
6/6
✓ Branch 4 → 5 taken 5 times.
✓ Branch 8 → 9 taken 5 times.
✓ Branch 9 → 10 taken 5 times.
✓ Branch 10 → 11 taken 5 times.
✓ Branch 14 → 6 taken 5 times.
✓ Branch 14 → 15 taken 5 times.
10 qCInfo(lcImap) << "CREATE complete:" << command.folderPath;
1379 5 emit folderCreated(command.folderPath);
1380 } else {
1381
6/6
✓ Branch 16 → 17 taken 1 time.
✓ Branch 20 → 21 taken 1 time.
✓ Branch 21 → 22 taken 1 time.
✓ Branch 22 → 23 taken 1 time.
✓ Branch 26 → 18 taken 1 time.
✓ Branch 26 → 27 taken 1 time.
2 qCWarning(lcImap) << "CREATE failed:" << response.message;
1382
1/1
✓ Branch 30 → 31 taken 1 time.
2 emit folderOperationError(QStringLiteral("CREATE"), response.message);
1383 }
1384 6 command.folderPath.clear();
1385
2/2
✓ Branch 38 → 39 taken 2 times.
✓ Branch 38 → 41 taken 4 times.
6 if (!m_commands->deferredCommands().isEmpty()) {
1386 2 m_commands->runNextDeferredCommand();
1387 } else {
1388 4 m_push->restartPush();
1389 }
1390 6 }
1391
1392 // T-281: DELETE folder
1393 5 void ImapService::handleTaggedDelete(const TaggedResponse &response,
1394 ImapPendingCommand &command) {
1395
2/2
✓ Branch 3 → 4 taken 3 times.
✓ Branch 3 → 16 taken 2 times.
5 if (response.status == "OK") {
1396
6/6
✓ Branch 4 → 5 taken 3 times.
✓ Branch 8 → 9 taken 3 times.
✓ Branch 9 → 10 taken 3 times.
✓ Branch 10 → 11 taken 3 times.
✓ Branch 14 → 6 taken 3 times.
✓ Branch 14 → 15 taken 3 times.
6 qCInfo(lcImap) << "DELETE complete:" << command.folderPath;
1397 3 emit folderDeleted(command.folderPath);
1398 } else {
1399
6/6
✓ Branch 16 → 17 taken 2 times.
✓ Branch 20 → 21 taken 2 times.
✓ Branch 21 → 22 taken 2 times.
✓ Branch 22 → 23 taken 2 times.
✓ Branch 26 → 18 taken 2 times.
✓ Branch 26 → 27 taken 2 times.
4 qCWarning(lcImap) << "DELETE failed:" << response.message;
1400
1/1
✓ Branch 30 → 31 taken 2 times.
4 emit folderOperationError(QStringLiteral("DELETE"), response.message);
1401 }
1402 5 command.folderPath.clear();
1403
2/2
✓ Branch 38 → 39 taken 2 times.
✓ Branch 38 → 41 taken 3 times.
5 if (!m_commands->deferredCommands().isEmpty()) {
1404 2 m_commands->runNextDeferredCommand();
1405 } else {
1406 3 m_push->restartPush();
1407 }
1408 5 }
1409
1410 // T-281: RENAME folder
1411 4 void ImapService::handleTaggedRename(const TaggedResponse &response,
1412 ImapPendingCommand &command) {
1413
2/2
✓ Branch 3 → 4 taken 3 times.
✓ Branch 3 → 18 taken 1 time.
4 if (response.status == "OK") {
1414
6/6
✓ Branch 4 → 5 taken 3 times.
✓ Branch 8 → 9 taken 3 times.
✓ Branch 9 → 10 taken 3 times.
✓ Branch 10 → 11 taken 3 times.
✓ Branch 16 → 6 taken 3 times.
✓ Branch 16 → 17 taken 3 times.
6 qCInfo(lcImap) << "RENAME complete:" << command.folderPath
1415
2/2
✓ Branch 11 → 12 taken 3 times.
✓ Branch 12 → 13 taken 3 times.
3 << "->" << command.folderNewPath;
1416 3 emit folderRenamed(command.folderPath, command.folderNewPath);
1417 } else {
1418
6/6
✓ Branch 18 → 19 taken 1 time.
✓ Branch 22 → 23 taken 1 time.
✓ Branch 23 → 24 taken 1 time.
✓ Branch 24 → 25 taken 1 time.
✓ Branch 28 → 20 taken 1 time.
✓ Branch 28 → 29 taken 1 time.
2 qCWarning(lcImap) << "RENAME failed:" << response.message;
1419
1/1
✓ Branch 32 → 33 taken 1 time.
2 emit folderOperationError(QStringLiteral("RENAME"), response.message);
1420 }
1421 4 command.folderPath.clear();
1422 4 command.folderNewPath.clear();
1423
2/2
✓ Branch 41 → 42 taken 2 times.
✓ Branch 41 → 44 taken 2 times.
4 if (!m_commands->deferredCommands().isEmpty()) {
1424 2 m_commands->runNextDeferredCommand();
1425 } else {
1426 2 m_push->restartPush();
1427 }
1428 4 }
1429
1430
1431 7 void ImapService::onSocketError(QAbstractSocket::SocketError error) {
1432 Q_UNUSED(error)
1433 // An explicit teardown has no live request to fail. A queued socket error
1434 // from the cancelled attempt must not turn an offline session into Error.
1435
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 6 times.
7 if (m_state == State::Disconnected)
1436 1 return;
1437
1/1
✓ Branch 4 → 5 taken 6 times.
6 auto errorMsg = m_socket->errorString();
1438
6/6
✓ Branch 5 → 6 taken 6 times.
✓ Branch 9 → 10 taken 6 times.
✓ Branch 10 → 11 taken 6 times.
✓ Branch 11 → 12 taken 6 times.
✓ Branch 15 → 7 taken 6 times.
✓ Branch 15 → 16 taken 6 times.
12 qCWarning(lcImap) << "Socket error:" << errorMsg;
1439
1/1
✓ Branch 16 → 17 taken 6 times.
6 failConnection(errorMsg, DisconnectReason::SocketError);
1440 6 }
1441
1442 1 void ImapService::onTimeout() {
1443
1/1
✓ Branch 5 → 6 taken 1 time.
1 failConnection(QStringLiteral("Connection timeout"),
1444 DisconnectReason::CommandTimeout);
1445 1 }
1446