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.8% 183 / 0 / 189
Functions: 100.0% 12 / 0 / 12
Branches: 78.4% 337 / 0 / 430

service/ImapConnectionSetup.cpp
Line Branch Exec Source
1 #include "ImapConnectionSetup.h"
2
3 #include "ImapCommandTable.h"
4 #include "util/SecureUtil.h"
5
6 #include <QLoggingCategory>
7 #include <QRegularExpression>
8 #include <QSslSocket>
9 #include <QTimer>
10
11 #include <utility>
12
13 // T-720: TCP keepalive platform headers.
14 #ifdef Q_OS_LINUX
15 #include <sys/socket.h>
16 #include <netinet/in.h>
17 #include <netinet/tcp.h>
18 #endif
19 #ifdef Q_OS_WIN
20 #include <winsock2.h>
21 #include <ws2tcpip.h>
22 #include <mstcpip.h>
23 #endif
24 #ifdef Q_OS_MACOS
25 #include <sys/socket.h>
26 #include <netinet/in.h>
27 #include <netinet/tcp.h>
28 #endif
29
30 Q_DECLARE_LOGGING_CATEGORY(lcImap)
31
32 1401 ImapConnectionSetup::ImapConnectionSetup(QObject *owner, QSslSocket *socket,
33 ImapCommandTable &commands,
34 1401 QStringList &capabilities, Deps deps)
35 1401 : m_socket(socket), m_commands(commands), m_capabilities(capabilities),
36
4/7
✓ Branch 4 → 5 taken 1401 times.
✓ Branch 5 → 6 taken 1401 times.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 1401 times.
✓ Branch 8 → 9 taken 1401 times.
✗ Branch 11 → 12 not taken.
✗ Branch 11 → 13 not taken.
1401 m_deps(std::move(deps)), m_timeoutTimer(new QTimer(owner)) {}
37
38 111 void ImapConnectionSetup::tuneKeepAlive() {
39
2/2
✓ Branch 2 → 3 taken 51 times.
✓ Branch 2 → 4 taken 60 times.
111 if (m_keepAliveTuned)
40 53 return;
41
42 // The Qt-level option (set in the constructor) is assertable on an
43 // unconnected socket; here we tune the native intervals once the
44 // socket descriptor is valid.
45
1/1
✓ Branch 4 → 5 taken 60 times.
60 qintptr fd = m_socket->socketDescriptor();
46
2/2
✓ Branch 5 → 6 taken 2 times.
✓ Branch 5 → 17 taken 58 times.
60 if (fd < 0) {
47
5/5
✓ Branch 6 → 7 taken 2 times.
✓ Branch 10 → 11 taken 2 times.
✓ Branch 11 → 12 taken 2 times.
✓ Branch 15 → 8 taken 2 times.
✓ Branch 15 → 16 taken 2 times.
4 qCDebug(lcImap) << "tuneKeepAlive: no valid socket descriptor yet";
48 2 return;
49 }
50
51 58 m_keepAliveTuned = true;
52
53 #ifdef Q_OS_LINUX
54 // Linux: TCP_KEEPIDLE=60, TCP_KEEPINTVL=30, TCP_KEEPCNT=3.
55 // Worst-case detection ~ 60 + 3*30 = 150 s with zero app traffic.
56 58 const int idle = 60;
57 58 const int intvl = 30;
58 58 const int cnt = 3;
59 58 ::setsockopt(static_cast<int>(fd), IPPROTO_TCP, TCP_KEEPIDLE, &idle,
60 sizeof(idle));
61 58 ::setsockopt(static_cast<int>(fd), IPPROTO_TCP, TCP_KEEPINTVL, &intvl,
62 sizeof(intvl));
63 58 ::setsockopt(static_cast<int>(fd), IPPROTO_TCP, TCP_KEEPCNT, &cnt,
64 sizeof(cnt));
65
5/5
✓ Branch 20 → 21 taken 58 times.
✓ Branch 24 → 25 taken 58 times.
✓ Branch 25 → 26 taken 58 times.
✓ Branch 29 → 22 taken 58 times.
✓ Branch 29 → 30 taken 58 times.
116 qCInfo(lcImap) << "TCP keepalive tuned (Linux): idle=60s intvl=30s cnt=3";
66 #elif defined(Q_OS_MACOS)
67 // macOS: single TCP_KEEPALIVE interval (seconds). Mirror the Linux
68 // worst-case budget of ~150 s by setting an idle probe interval.
69 const int macIdle = 60;
70 ::setsockopt(static_cast<int>(fd), IPPROTO_TCP, TCP_KEEPALIVE, &macIdle,
71 sizeof(macIdle));
72 qCInfo(lcImap) << "TCP keepalive tuned (macOS): interval=60s";
73 #elif defined(Q_OS_WIN)
74 // Windows: SIO_KEEPALIVE_VALS ioctl. onoff=1, idle=60000ms, intvl=30000ms.
75 tcp_keepalive ka;
76 ka.onoff = 1;
77 ka.keepalivetime = 60000;
78 ka.keepaliveinterval = 30000;
79 DWORD bytesReturned = 0;
80 ::WSAIoctl(static_cast<SOCKET>(fd), SIO_KEEPALIVE_VALS, &ka, sizeof(ka),
81 nullptr, 0, &bytesReturned, nullptr, nullptr);
82 qCInfo(lcImap) << "TCP keepalive tuned (Windows): idle=60000ms intvl=30000ms";
83 #else
84 qCInfo(lcImap) << "TCP keepalive tuning not implemented on this platform;"
85 << "relying on OS defaults";
86 #endif
87 }
88
89 2637 void ImapConnectionSetup::clearCredentials() {
90 2637 SecureUtil::zeroMemory(m_config.password);
91 2637 m_config.username.clear();
92 2637 }
93
94 51 bool ImapConnectionSetup::beginLogin() {
95
3/3
✓ Branch 2 → 3 taken 51 times.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 21 taken 50 times.
51 if (!m_socket->isEncrypted()) {
96
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 qCWarning(lcImap) << "Refusing IMAP LOGIN over unencrypted connection";
97
1/1
✓ Branch 17 → 18 taken 1 time.
1 failConnection(QStringLiteral("Connection is not encrypted"));
98 1 return false;
99 }
100 // F-37 (SPRINT-104): LOGINDISABLED says the server refuses the LOGIN
101 // command on this connection — sending it would leak the credentials
102 // to a rejection (and some servers drop the connection).
103
2/3
✓ Branch 21 → 22 taken 50 times.
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 42 taken 50 times.
50 if (m_capabilities.contains("LOGINDISABLED", Qt::CaseInsensitive)) {
104 ✗ qCWarning(lcImap) << "Server advertises LOGINDISABLED — refusing LOGIN";
105 ✗ failConnection(QStringLiteral(
106 "The server refuses login on this connection (LOGINDISABLED); "
107 "it requires an encrypted connection"));
108 ✗ return false;
109 }
110
111 // S106-007: LOGIN takes quoted strings — 7-bit text without CR, LF or
112 // NUL (RFC 3501 §4.3). quoteImapString() strips CR/LF/NUL, so such a
113 // password could never sign in, and non-ASCII depends on the server.
114 // Credentials outside plain ASCII go through AUTHENTICATE PLAIN (RFC 4616,
115 // UTF-8 by definition) with an initial response (SASL-IR) when offered;
116 // a password with CR/LF/NUL that no mechanism can carry is reported
117 // instead of being silently changed.
118
1/1
✓ Branch 42 → 43 taken 50 times.
50 const QString password = QString::fromUtf8(m_config.password);
119 99 const auto plainAscii = [](const QString &text) {
120
2/2
✓ Branch 13 → 4 taken 982 times.
✓ Branch 13 → 14 taken 97 times.
1079 for (const QChar c : text)
121
6/6
✓ Branch 5 → 6 taken 981 times.
✓ Branch 5 → 8 taken 1 time.
✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 9 taken 980 times.
✓ Branch 10 → 11 taken 2 times.
✓ Branch 10 → 12 taken 980 times.
982 if (c.unicode() < 0x20 || c.unicode() > 0x7e) return false;
122 97 return true;
123 };
124
4/4
✓ Branch 44 → 45 taken 49 times.
✓ Branch 44 → 48 taken 1 time.
✓ Branch 46 → 47 taken 48 times.
✓ Branch 46 → 48 taken 1 time.
50 const bool loginCarries = plainAscii(m_config.username) && plainAscii(password);
125
4/6
✓ Branch 53 → 54 taken 1 time.
✓ Branch 53 → 60 taken 49 times.
✓ Branch 58 → 59 taken 1 time.
✗ Branch 58 → 60 not taken.
✓ Branch 67 → 68 taken 50 times.
✗ Branch 67 → 69 not taken.
151 const bool saslPlain = m_capabilities.contains(QStringLiteral("AUTH=PLAIN"), Qt::CaseInsensitive) &&
126
5/6
✓ Branch 61 → 62 taken 1 time.
✓ Branch 61 → 63 taken 49 times.
✓ Branch 63 → 64 taken 1 time.
✓ Branch 63 → 65 taken 49 times.
✓ Branch 65 → 66 taken 50 times.
✗ Branch 65 → 67 not taken.
52 m_capabilities.contains(QStringLiteral("SASL-IR"), Qt::CaseInsensitive);
127
4/6
✓ Branch 70 → 71 taken 50 times.
✓ Branch 71 → 72 taken 50 times.
✗ Branch 71 → 74 not taken.
✓ Branch 72 → 73 taken 50 times.
✗ Branch 73 → 74 not taken.
✓ Branch 73 → 75 taken 50 times.
50 const bool hasNul = m_config.username.contains(QChar(0)) || m_config.password.contains('\0');
128
1/1
✓ Branch 78 → 79 taken 50 times.
50 const bool hasLineBreak = m_config.username.contains(QLatin1Char('\r')) ||
129
2/3
✓ Branch 82 → 83 taken 50 times.
✓ Branch 83 → 84 taken 50 times.
✗ Branch 83 → 88 not taken.
50 m_config.username.contains(QLatin1Char('\n')) ||
130
7/8
✓ Branch 79 → 80 taken 50 times.
✗ Branch 79 → 88 not taken.
✓ Branch 84 → 85 taken 50 times.
✓ Branch 85 → 86 taken 49 times.
✓ Branch 85 → 88 taken 1 time.
✓ Branch 86 → 87 taken 49 times.
✓ Branch 87 → 88 taken 1 time.
✓ Branch 87 → 89 taken 48 times.
100 m_config.password.contains('\r') || m_config.password.contains('\n');
131
6/8
✓ Branch 90 → 91 taken 2 times.
✓ Branch 90 → 101 taken 48 times.
✓ Branch 91 → 92 taken 2 times.
✗ Branch 91 → 94 not taken.
✓ Branch 92 → 93 taken 2 times.
✗ Branch 92 → 101 not taken.
✓ Branch 93 → 94 taken 1 time.
✓ Branch 93 → 101 taken 1 time.
50 if (!loginCarries && (hasNul || (hasLineBreak && !saslPlain))) {
132
1/1
✓ Branch 97 → 98 taken 1 time.
1 failConnection(QStringLiteral(
133 "The user name or password contains a character the server's login cannot carry"));
134 1 return false;
135 }
136
137
1/1
✓ Branch 101 → 102 taken 49 times.
49 setState(State::Authenticating);
138 // SEC-2026-07-21-12: Re-arm for the LOGIN response wait.
139
1/1
✓ Branch 102 → 103 taken 49 times.
49 m_timeoutTimer->start(SESSION_SETUP_TIMEOUT_MS);
140
3/4
✓ Branch 103 → 104 taken 1 time.
✓ Branch 103 → 130 taken 48 times.
✓ Branch 104 → 105 taken 1 time.
✗ Branch 104 → 130 not taken.
49 if (!loginCarries && saslPlain) {
141 1 QByteArray initial;
142
1/1
✓ Branch 105 → 106 taken 1 time.
1 initial.append('\0');
143
2/2
✓ Branch 106 → 107 taken 1 time.
✓ Branch 107 → 108 taken 1 time.
1 initial.append(m_config.username.toUtf8());
144
1/1
✓ Branch 109 → 110 taken 1 time.
1 initial.append('\0');
145
1/1
✓ Branch 110 → 111 taken 1 time.
1 initial.append(m_config.password);
146 // The tagged answer is handled like LOGIN's (same command record type).
147
2/2
✓ Branch 118 → 119 taken 1 time.
✓ Branch 119 → 120 taken 1 time.
3 m_commands.sendCommand("LOGIN", QStringLiteral("AUTHENTICATE PLAIN ") +
148
3/3
✓ Branch 112 → 113 taken 1 time.
✓ Branch 113 → 114 taken 1 time.
✓ Branch 117 → 118 taken 1 time.
3 QString::fromLatin1(initial.toBase64()));
149
1/1
✓ Branch 127 → 128 taken 1 time.
1 SecureUtil::zeroMemory(initial);
150 1 } else {
151
3/3
✓ Branch 130 → 131 taken 48 times.
✓ Branch 134 → 135 taken 48 times.
✓ Branch 135 → 136 taken 48 times.
144 m_commands.sendCommand("LOGIN", QString("LOGIN %1 %2")
152
2/2
✓ Branch 132 → 133 taken 48 times.
✓ Branch 133 → 134 taken 48 times.
144 .arg(ImapService::quoteImapString(m_config.username),
153
1/1
✓ Branch 131 → 132 taken 48 times.
96 ImapService::quoteImapString(password)));
154 }
155 // The socket owns any bytes still queued for transmission. The service does
156 // not need another plaintext credential copy while awaiting the response.
157
1/1
✓ Branch 143 → 144 taken 49 times.
49 clearCredentials();
158 49 return true;
159 50 }
160
161 58 void ImapConnectionSetup::onConnected() {
162
5/5
✓ Branch 2 → 3 taken 58 times.
✓ Branch 6 → 7 taken 58 times.
✓ Branch 7 → 8 taken 58 times.
✓ Branch 11 → 4 taken 58 times.
✓ Branch 11 → 12 taken 58 times.
116 qCInfo(lcImap) << "TCP connected";
163 #ifdef MAILJD_E2E_TESTING
164
1/1
✓ Branch 15 → 16 taken 47 times.
47 reportE2eTransportPhase(QStringLiteral("tcp-connected"));
165 #endif
166 // SEC-2026-07-21-12: Re-arm the timer for the greeting wait phase instead
167 // of stopping it. A silent server that accepts the TCP connection but
168 // never sends a greeting would otherwise stall indefinitely.
169 58 m_timeoutTimer->start(SESSION_SETUP_TIMEOUT_MS);
170 // T-720/T-72.1: Now that the socket descriptor is valid, tune the
171 // native TCP keepalive intervals. Idempotent — sets m_keepAliveTuned.
172 58 tuneKeepAlive();
173
174 // For STARTTLS — and S106-006: for any other configured value — wait for
175 // the greeting, then upgrade (F-37 prefers STARTTLS; LOGIN refuses an
176 // unencrypted connection). Only implicit SSL waits for onEncrypted. An
177 // unknown value used to never leave Connecting and timed out after 30 s.
178
3/4
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 11 times.
✓ Branch 21 → 22 taken 3 times.
✓ Branch 21 → 23 taken 44 times.
58 if (m_config.security != "ssl")
179 3 setState(State::Connected);
180 58 }
181
182 52 void ImapConnectionSetup::onEncrypted() {
183
5/5
✓ Branch 2 → 3 taken 52 times.
✓ Branch 6 → 7 taken 52 times.
✓ Branch 7 → 8 taken 52 times.
✓ Branch 11 → 4 taken 52 times.
✓ Branch 11 → 12 taken 52 times.
104 qCInfo(lcImap) << "TLS established";
184 #ifdef MAILJD_E2E_TESTING
185
1/1
✓ Branch 15 → 16 taken 42 times.
42 reportE2eTransportPhase(QStringLiteral("tls-established"));
186 #endif
187 // SEC-2026-07-21-12: Re-arm for greeting wait (implicit-SSL path) or
188 // post-STARTTLS handshake — the latter is covered by this re-arm because
189 // onEncrypted fires for both implicit-SSL and STARTTLS upgrades.
190 52 m_timeoutTimer->start(SESSION_SETUP_TIMEOUT_MS);
191 // T-720/T-72.1: For implicit-SSL connections onConnected() may not have
192 // run (Qt emits encrypted directly). Tune here too — tuneKeepAlive() is
193 // idempotent via the m_keepAliveTuned flag.
194 52 tuneKeepAlive();
195
196
3/4
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 31 taken 10 times.
✓ Branch 20 → 21 taken 3 times.
✓ Branch 20 → 37 taken 39 times.
52 if (m_setupPhase == SetupPhase::StartingTls) {
197 // RFC 3501 §6.2.1: discard every feature learned on the unauthenticated
198 // plaintext channel. Only a complete post-handshake CAPABILITY exchange
199 // may influence authentication and the resulting session.
200 3 m_capabilities.clear();
201 3 m_pendingCapabilities.clear();
202 3 m_setupPhase = SetupPhase::PostTlsCapability;
203 3 setState(State::Capability);
204
1/2
✗ Branch 24 → 25 not taken.
✓ Branch 30 → 31 taken 3 times.
9 m_commands.sendCommand(QStringLiteral("CAPABILITY"), QStringLiteral("CAPABILITY"));
205 3 return;
206 }
207
208 49 setState(State::Connected);
209 }
210 54 void ImapConnectionSetup::handleGreeting(const QString &data, bool preAuthenticated) {
211 // Server greeting
212
9/9
✓ Branch 2 → 3 taken 54 times.
✓ Branch 6 → 7 taken 54 times.
✓ Branch 7 → 8 taken 54 times.
✓ Branch 8 → 9 taken 54 times.
✓ Branch 9 → 10 taken 2 times.
✓ Branch 9 → 11 taken 52 times.
✓ Branch 12 → 13 taken 54 times.
✓ Branch 16 → 4 taken 54 times.
✓ Branch 16 → 17 taken 54 times.
108 qCInfo(lcImap) << "Server greeting:" << data << (preAuthenticated ? "(PREAUTH)" : "");
213 #ifdef MAILJD_E2E_TESTING
214
1/1
✓ Branch 20 → 21 taken 44 times.
44 reportE2eTransportPhase(QStringLiteral("greeting-received"));
215 #endif
216 // S106-005: PREAUTH starts the session already authenticated (RFC 3501
217 // §7.1.4) — LOGIN is invalid there, and its NO was misread as a rejected
218 // password. STARTTLS cannot follow (authenticated state), so a PREAUTH on
219 // an unencrypted connection is refused: it is also the STARTTLS-stripping
220 // pattern of a man in the middle.
221
8/12
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 21 taken 10 times.
✗ Branch 19 → 20 not taken.
✗ Branch 19 → 21 not taken.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 30 taken 10 times.
✓ Branch 23 → 24 taken 2 times.
✓ Branch 23 → 27 taken 42 times.
✓ Branch 25 → 26 taken 1 time.
✓ Branch 25 → 27 taken 1 time.
✓ Branch 28 → 29 taken 1 time.
✓ Branch 28 → 36 taken 43 times.
54 if (preAuthenticated && !m_socket->isEncrypted()) {
222
1/2
✗ Branch 26 → 27 not taken.
✓ Branch 32 → 33 taken 1 time.
1 failConnection(QStringLiteral(
223 "The server pre-authenticated an unencrypted connection — refused"));
224 1 return;
225 }
226 53 setState(State::Greeting);
227 // SEC-2026-07-21-12: Re-arm for the CAPABILITY/STARTTLS phase.
228 53 m_timeoutTimer->start(SESSION_SETUP_TIMEOUT_MS);
229
230 // Request capabilities
231
3/4
✗ Branch 32 → 33 not taken.
✓ Branch 32 → 34 taken 10 times.
✓ Branch 38 → 39 taken 1 time.
✓ Branch 38 → 40 taken 42 times.
53 m_setupPhase = preAuthenticated ? SetupPhase::PreAuthCapability : SetupPhase::InitialCapability;
232 53 m_pendingCapabilities.clear();
233
6/6
✓ Branch 36 → 37 taken 10 times.
✓ Branch 37 → 38 taken 10 times.
✓ Branch 38 → 39 taken 10 times.
✓ Branch 42 → 43 taken 43 times.
✓ Branch 43 → 44 taken 43 times.
✓ Branch 44 → 45 taken 43 times.
53 m_commands.sendCommand("CAPABILITY", "CAPABILITY");
234 }
235
236 58 void ImapConnectionSetup::collectCapabilities(const QString &data) {
237 // Also collected while LOGIN runs: Dovecot answers a successful LOGIN
238 // with an untagged post-login CAPABILITY instead of a response code.
239
6/11
✓ Branch 5 → 6 taken 58 times.
✓ Branch 6 → 7 taken 2 times.
✓ Branch 6 → 12 taken 56 times.
✓ Branch 20 → 21 taken 58 times.
✗ Branch 20 → 22 not taken.
✓ Branch 22 → 23 taken 57 times.
✓ Branch 22 → 49 taken 1 time.
✗ Branch 69 → 70 not taken.
✗ Branch 69 → 71 not taken.
✗ Branch 73 → 74 not taken.
✗ Branch 73 → 75 not taken.
176 if (m_commands.hasCommandType(QStringLiteral("CAPABILITY")) ||
240
8/13
✓ Branch 10 → 11 taken 2 times.
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 1 time.
✓ Branch 14 → 15 taken 2 times.
✓ Branch 14 → 16 taken 56 times.
✓ Branch 16 → 17 taken 2 times.
✓ Branch 16 → 18 taken 56 times.
✓ Branch 18 → 19 taken 58 times.
✗ Branch 18 → 20 not taken.
✗ Branch 61 → 62 not taken.
✗ Branch 61 → 63 not taken.
✗ Branch 65 → 66 not taken.
✗ Branch 65 → 67 not taken.
62 m_commands.hasCommandType(QStringLiteral("LOGIN"))) {
241
1/1
✓ Branch 23 → 24 taken 57 times.
57 const QStringList parsed = ImapResponseParser::parseCapabilities(data);
242
2/2
✓ Branch 32 → 26 taken 109 times.
✓ Branch 32 → 33 taken 57 times.
166 for (const QString &capability : parsed) {
243
2/2
✓ Branch 28 → 29 taken 108 times.
✓ Branch 28 → 30 taken 1 time.
109 if (!m_pendingCapabilities.contains(capability, Qt::CaseInsensitive))
244
1/1
✓ Branch 29 → 30 taken 108 times.
108 m_pendingCapabilities.append(capability);
245 }
246
6/6
✓ Branch 33 → 34 taken 57 times.
✓ Branch 37 → 38 taken 57 times.
✓ Branch 38 → 39 taken 57 times.
✓ Branch 40 → 41 taken 57 times.
✓ Branch 46 → 35 taken 57 times.
✓ Branch 46 → 47 taken 57 times.
114 qCInfo(lcImap) << "Pending capabilities:" << m_pendingCapabilities;
247 57 } else {
248
5/5
✓ Branch 49 → 50 taken 1 time.
✓ Branch 53 → 54 taken 1 time.
✓ Branch 54 → 55 taken 1 time.
✓ Branch 58 → 51 taken 1 time.
✓ Branch 58 → 59 taken 1 time.
2 qCWarning(lcImap) << "Ignoring unsolicited CAPABILITY outside negotiation";
249 }
250 58 }
251
252 63 void ImapConnectionSetup::handleTaggedCapability(const TaggedResponse &response) {
253
2/2
✓ Branch 3 → 4 taken 60 times.
✓ Branch 3 → 113 taken 3 times.
63 if (response.status == "OK") {
254
2/2
✓ Branch 4 → 5 taken 7 times.
✓ Branch 4 → 14 taken 53 times.
60 if (m_setupPhase != SetupPhase::InitialCapability &&
255
2/2
✓ Branch 5 → 6 taken 2 times.
✓ Branch 5 → 14 taken 5 times.
7 m_setupPhase != SetupPhase::PostTlsCapability &&
256
2/2
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 14 taken 1 time.
2 m_setupPhase != SetupPhase::PreAuthCapability) {
257
1/1
✓ Branch 10 → 11 taken 1 time.
1 failConnection(QStringLiteral(
258 "CAPABILITY completed outside the connection setup phase"));
259 1 return;
260 }
261
3/4
✓ Branch 32 → 33 taken 59 times.
✗ Branch 32 → 34 not taken.
✓ Branch 34 → 35 taken 2 times.
✓ Branch 34 → 43 taken 57 times.
118 if (!m_pendingCapabilities.contains(QStringLiteral("IMAP4rev1"),
262
2/2
✓ Branch 18 → 19 taken 3 times.
✓ Branch 18 → 25 taken 56 times.
121 Qt::CaseInsensitive) &&
263
7/8
✓ Branch 23 → 24 taken 2 times.
✓ Branch 23 → 25 taken 1 time.
✓ Branch 26 → 27 taken 3 times.
✓ Branch 26 → 28 taken 56 times.
✓ Branch 28 → 29 taken 3 times.
✓ Branch 28 → 30 taken 56 times.
✓ Branch 30 → 31 taken 59 times.
✗ Branch 30 → 32 not taken.
65 !m_pendingCapabilities.contains(QStringLiteral("IMAP4rev2"),
264 Qt::CaseInsensitive)) {
265 2 m_pendingCapabilities.clear();
266
1/1
✓ Branch 39 → 40 taken 2 times.
2 failConnection(QStringLiteral("Malformed IMAP CAPABILITY response"));
267 2 return;
268 }
269
270 57 const SetupPhase completedPhase = m_setupPhase;
271 57 m_capabilities = std::exchange(m_pendingCapabilities, {});
272 57 setState(State::Capability);
273
2/2
✓ Branch 49 → 50 taken 1 time.
✓ Branch 49 → 54 taken 56 times.
57 if (completedPhase == SetupPhase::PreAuthCapability) {
274 // S106-005: pre-authenticated on an encrypted connection — the
275 // session is complete without LOGIN; no credential is sent.
276 1 clearCredentials();
277 1 m_setupPhase = SetupPhase::Complete;
278 1 setState(State::Authenticated);
279 1 m_timeoutTimer->stop();
280 1 return;
281 }
282
283 // F-37 (SPRINT-104, D-10): STARTTLS is preferred whenever the server
284 // offers it — including configurations set to no explicit encryption
285 // (the historical default path) — and a server that advertises
286 // LOGINDISABLED never sees a plaintext LOGIN attempt.
287
7/7
✓ Branch 54 → 55 taken 52 times.
✓ Branch 54 → 65 taken 4 times.
✓ Branch 55 → 56 taken 52 times.
✓ Branch 56 → 57 taken 4 times.
✓ Branch 56 → 65 taken 48 times.
✓ Branch 58 → 59 taken 1 time.
✓ Branch 58 → 64 taken 3 times.
61 if (completedPhase == SetupPhase::InitialCapability && !m_socket->isEncrypted() &&
288 4 (m_config.security == "starttls" ||
289
7/11
✓ Branch 60 → 61 taken 1 time.
✗ Branch 60 → 65 not taken.
✓ Branch 61 → 62 taken 1 time.
✓ Branch 63 → 64 taken 1 time.
✗ Branch 63 → 65 not taken.
✓ Branch 66 → 67 taken 1 time.
✓ Branch 66 → 68 taken 55 times.
✓ Branch 68 → 69 taken 4 times.
✓ Branch 68 → 99 taken 52 times.
✗ Branch 130 → 131 not taken.
✗ Branch 130 → 132 not taken.
57 (m_config.security != "ssl" && m_capabilities.contains("STARTTLS", Qt::CaseInsensitive)))) {
290
3/3
✓ Branch 69 → 70 taken 4 times.
✓ Branch 72 → 73 taken 2 times.
✓ Branch 72 → 81 taken 2 times.
4 if (m_capabilities.contains("STARTTLS", Qt::CaseInsensitive)) {
291 // Need to upgrade to TLS
292 2 m_setupPhase = SetupPhase::StartingTls;
293 2 setState(State::StartingTLS);
294
3/3
✓ Branch 74 → 75 taken 2 times.
✓ Branch 75 → 76 taken 2 times.
✓ Branch 76 → 77 taken 2 times.
2 m_commands.sendCommand("STARTTLS", "STARTTLS");
295 } else {
296 // T-400/Bug 2: Refuse plaintext login when STARTTLS was requested
297
5/5
✓ Branch 81 → 82 taken 2 times.
✓ Branch 85 → 86 taken 2 times.
✓ Branch 86 → 87 taken 2 times.
✓ Branch 91 → 83 taken 2 times.
✓ Branch 91 → 92 taken 2 times.
4 qCWarning(lcImap) << "STARTTLS requested but not offered by server"
298
1/1
✓ Branch 87 → 88 taken 2 times.
2 << "— refusing to send credentials in plaintext";
299
1/1
✓ Branch 95 → 96 taken 2 times.
2 failConnection(QStringLiteral(
300 "Server does not support STARTTLS — login refused for security"));
301 }
302
4/4
✓ Branch 99 → 100 taken 4 times.
✓ Branch 99 → 103 taken 48 times.
✓ Branch 104 → 105 taken 2 times.
✓ Branch 104 → 112 taken 50 times.
56 } else if (completedPhase == SetupPhase::PostTlsCapability &&
303
2/2
✓ Branch 101 → 102 taken 2 times.
✓ Branch 101 → 103 taken 2 times.
4 !m_socket->isEncrypted()) {
304
1/1
✓ Branch 108 → 109 taken 2 times.
2 failConnection(QStringLiteral(
305 "Post-TLS CAPABILITY completed on an unencrypted connection"));
306 } else {
307 50 m_setupPhase = SetupPhase::Login;
308 50 beginLogin();
309 }
310 } else {
311
2/2
✓ Branch 113 → 114 taken 3 times.
✓ Branch 114 → 115 taken 3 times.
3 failConnection("CAPABILITY failed: " + response.message);
312 }
313 }
314
315 3 void ImapConnectionSetup::handleTaggedStartTls(const TaggedResponse &response) {
316
1/2
✓ Branch 3 → 4 taken 3 times.
✗ Branch 3 → 29 not taken.
3 if (response.status == "OK") {
317
5/6
✓ Branch 5 → 6 taken 2 times.
✓ Branch 5 → 8 taken 1 time.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 2 times.
✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 18 taken 2 times.
3 if (!m_deps.readBufferEmpty() || m_socket->bytesAvailable() > 0) {
318
1/1
✓ Branch 14 → 15 taken 1 time.
1 failConnection(
319 2 QStringLiteral("Unexpected data before TLS handshake"));
320 1 return;
321 }
322
5/5
✓ Branch 18 → 19 taken 2 times.
✓ Branch 22 → 23 taken 2 times.
✓ Branch 23 → 24 taken 2 times.
✓ Branch 27 → 20 taken 2 times.
✓ Branch 27 → 28 taken 2 times.
4 qCInfo(lcImap) << "STARTTLS accepted, starting TLS handshake...";
323 2 m_socket->startClientEncryption();
324 } else {
325 ✗ failConnection("STARTTLS failed: " + response.message);
326 }
327 }
328
329 55 void ImapConnectionSetup::handleTaggedLogin(const TaggedResponse &response) {
330
1/1
✓ Branch 2 → 3 taken 55 times.
55 clearCredentials();
331
2/2
✓ Branch 4 → 5 taken 53 times.
✓ Branch 4 → 85 taken 2 times.
55 if (response.status == "OK") {
332
5/5
✓ Branch 5 → 6 taken 53 times.
✓ Branch 9 → 10 taken 53 times.
✓ Branch 10 → 11 taken 53 times.
✓ Branch 14 → 7 taken 53 times.
✓ Branch 14 → 15 taken 53 times.
106 qCInfo(lcImap) << "Login successful";
333 // RFC 3501 7.1: the post-authentication capabilities come as a
334 // response code of the tagged OK or as an untagged CAPABILITY during
335 // LOGIN (Dovecot). Servers list CONDSTORE, MOVE, UIDPLUS or ESEARCH
336 // only there, not in the pre-login CAPABILITY response.
337 static const QRegularExpression capabilityCode(
338
4/7
✓ Branch 15 → 16 taken 5 times.
✓ Branch 15 → 28 taken 48 times.
✓ Branch 17 → 18 taken 5 times.
✗ Branch 17 → 28 not taken.
✓ Branch 22 → 23 taken 5 times.
✗ Branch 185 → 186 not taken.
✗ Branch 185 → 187 not taken.
58 QStringLiteral(R"(^\[CAPABILITY\s+([^\]]*)\])"), QRegularExpression::CaseInsensitiveOption);
339
2/2
✓ Branch 29 → 30 taken 53 times.
✓ Branch 30 → 31 taken 53 times.
53 const auto match = capabilityCode.match(response.message.trimmed());
340
1/1
✓ Branch 32 → 33 taken 53 times.
53 const QStringList authenticated = match.hasMatch()
341
1/3
✓ Branch 34 → 35 taken 18 times.
✗ Branch 196 → 197 not taken.
✗ Branch 196 → 198 not taken.
36 ? ImapResponseParser::parseCapabilities(match.captured(1))
342
7/9
✓ Branch 33 → 34 taken 18 times.
✓ Branch 33 → 36 taken 35 times.
✓ Branch 35 → 38 taken 18 times.
✓ Branch 38 → 39 taken 35 times.
✓ Branch 38 → 40 taken 18 times.
✓ Branch 40 → 41 taken 18 times.
✓ Branch 40 → 42 taken 35 times.
✗ Branch 192 → 193 not taken.
✗ Branch 192 → 194 not taken.
71 : std::exchange(m_pendingCapabilities, {});
343
6/8
✓ Branch 46 → 47 taken 34 times.
✓ Branch 46 → 52 taken 19 times.
✗ Branch 51 → 52 not taken.
✓ Branch 51 → 53 taken 34 times.
✓ Branch 60 → 61 taken 53 times.
✗ Branch 60 → 62 not taken.
✓ Branch 62 → 63 taken 19 times.
✓ Branch 62 → 79 taken 34 times.
193 if (authenticated.contains(QStringLiteral("IMAP4rev1"), Qt::CaseInsensitive) ||
344
5/6
✓ Branch 54 → 55 taken 34 times.
✓ Branch 54 → 56 taken 19 times.
✓ Branch 56 → 57 taken 34 times.
✓ Branch 56 → 58 taken 19 times.
✓ Branch 58 → 59 taken 53 times.
✗ Branch 58 → 60 not taken.
87 authenticated.contains(QStringLiteral("IMAP4rev2"), Qt::CaseInsensitive)) {
345 19 m_capabilities = authenticated;
346
6/6
✓ Branch 64 → 65 taken 19 times.
✓ Branch 68 → 69 taken 19 times.
✓ Branch 69 → 70 taken 19 times.
✓ Branch 71 → 72 taken 19 times.
✓ Branch 77 → 66 taken 19 times.
✓ Branch 77 → 78 taken 19 times.
38 qCInfo(lcImap) << "Post-login capabilities:" << m_capabilities;
347 }
348
1/1
✓ Branch 79 → 80 taken 53 times.
53 m_pendingCapabilities.clear();
349 53 m_setupPhase = SetupPhase::Complete;
350
1/1
✓ Branch 80 → 81 taken 53 times.
53 setState(State::Authenticated);
351 // SEC-2026-07-21-12: Session setup complete — stop the setup timer.
352 // Command timeout/deadline timers take over from here.
353
1/1
✓ Branch 81 → 82 taken 53 times.
53 m_timeoutTimer->stop();
354 53 return;
355 53 }
356 // F-33 (SPRINT-104): a temporary refusal is not a rejected password.
357 // UNAVAILABLE (user database briefly down), LIMIT/TOOMANY (too many
358 // connections), SERVERBUG and CONTACTADMIN answer NO without the login
359 // being wrong — classifying them as AuthenticationRejected ended
360 // reconnecting and left MailJD offline until the user acted. These keep
361 // the ordinary disconnect path (the health monitor retries with
362 // backoff); only a real authentication failure stops the ladder.
363 // S106-007: BAD is a command the server could not parse (a credential
364 // form it does not accept) — not a rejected password; the connection is
365 // retried like any other failure instead of stopping as "wrong password".
366
1/2
✗ Branch 87 → 88 not taken.
✓ Branch 87 → 92 taken 2 times.
2 if (response.status == QLatin1String("BAD")) {
367 ✗ failConnection("Login command not accepted by the server: " + response.message);
368 ✗ return;
369 }
370 2 const QString message = response.message;
371
1/6
✓ Branch 147 → 148 taken 2 times.
✗ Branch 147 → 149 not taken.
✗ Branch 257 → 258 not taken.
✗ Branch 257 → 259 not taken.
✗ Branch 261 → 262 not taken.
✗ Branch 261 → 263 not taken.
4 const bool temporary = message.contains(QStringLiteral("[UNAVAILABLE]"), Qt::CaseInsensitive) ||
372
5/11
✓ Branch 101 → 102 taken 1 time.
✓ Branch 102 → 103 taken 1 time.
✗ Branch 102 → 123 not taken.
✓ Branch 143 → 144 taken 1 time.
✓ Branch 143 → 145 taken 1 time.
✓ Branch 145 → 146 taken 2 times.
✗ Branch 145 → 147 not taken.
✗ Branch 249 → 250 not taken.
✗ Branch 249 → 251 not taken.
✗ Branch 253 → 254 not taken.
✗ Branch 253 → 255 not taken.
3 message.contains(QStringLiteral("[LIMIT]"), Qt::CaseInsensitive) ||
373
6/11
✓ Branch 106 → 107 taken 1 time.
✓ Branch 107 → 108 taken 1 time.
✗ Branch 107 → 123 not taken.
✓ Branch 139 → 140 taken 1 time.
✓ Branch 139 → 141 taken 1 time.
✓ Branch 141 → 142 taken 1 time.
✓ Branch 141 → 143 taken 1 time.
✗ Branch 241 → 242 not taken.
✗ Branch 241 → 243 not taken.
✗ Branch 245 → 246 not taken.
✗ Branch 245 → 247 not taken.
3 message.contains(QStringLiteral("[TOOMANY]"), Qt::CaseInsensitive) ||
374
6/11
✓ Branch 111 → 112 taken 1 time.
✓ Branch 112 → 113 taken 1 time.
✗ Branch 112 → 123 not taken.
✓ Branch 135 → 136 taken 1 time.
✓ Branch 135 → 137 taken 1 time.
✓ Branch 137 → 138 taken 1 time.
✓ Branch 137 → 139 taken 1 time.
✗ Branch 233 → 234 not taken.
✗ Branch 233 → 235 not taken.
✗ Branch 237 → 238 not taken.
✗ Branch 237 → 239 not taken.
3 message.contains(QStringLiteral("[SERVERBUG]"), Qt::CaseInsensitive) ||
375
9/14
✓ Branch 96 → 97 taken 2 times.
✓ Branch 97 → 98 taken 1 time.
✓ Branch 97 → 123 taken 1 time.
✓ Branch 116 → 117 taken 1 time.
✓ Branch 117 → 118 taken 1 time.
✗ Branch 117 → 123 not taken.
✓ Branch 131 → 132 taken 1 time.
✓ Branch 131 → 133 taken 1 time.
✓ Branch 133 → 134 taken 1 time.
✓ Branch 133 → 135 taken 1 time.
✗ Branch 225 → 226 not taken.
✗ Branch 225 → 227 not taken.
✗ Branch 229 → 230 not taken.
✗ Branch 229 → 231 not taken.
8 message.contains(QStringLiteral("[CONTACTADMIN]"), Qt::CaseInsensitive) ||
376
8/13
✓ Branch 121 → 122 taken 1 time.
✗ Branch 122 → 123 not taken.
✓ Branch 122 → 124 taken 1 time.
✓ Branch 125 → 126 taken 1 time.
✓ Branch 125 → 127 taken 1 time.
✓ Branch 127 → 128 taken 1 time.
✓ Branch 127 → 129 taken 1 time.
✓ Branch 129 → 130 taken 1 time.
✓ Branch 129 → 131 taken 1 time.
✗ Branch 217 → 218 not taken.
✗ Branch 217 → 219 not taken.
✗ Branch 221 → 222 not taken.
✗ Branch 221 → 223 not taken.
3 message.contains(QStringLiteral("try again"), Qt::CaseInsensitive);
377
2/2
✓ Branch 149 → 150 taken 1 time.
✓ Branch 149 → 165 taken 1 time.
2 if (temporary) {
378
6/6
✓ Branch 150 → 151 taken 1 time.
✓ Branch 154 → 155 taken 1 time.
✓ Branch 155 → 156 taken 1 time.
✓ Branch 156 → 157 taken 1 time.
✓ Branch 160 → 152 taken 1 time.
✓ Branch 160 → 161 taken 1 time.
2 qCWarning(lcImap) << "Login temporarily refused:" << message;
379
2/2
✓ Branch 161 → 162 taken 1 time.
✓ Branch 162 → 163 taken 1 time.
1 failConnection("Login temporarily refused: " + message);
380 } else {
381
2/2
✓ Branch 165 → 166 taken 1 time.
✓ Branch 166 → 167 taken 1 time.
1 failConnection("Authentication failed: " + message,
382 DisconnectReason::AuthenticationRejected);
383 }
384 2 }
385