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.6% 916 / 0 / 948
Functions: 100.0% 36 / 0 / 36
Branches: 86.0% 1207 / 0 / 1404

service/ImapResponseParser.cpp
Line Branch Exec Source
1 #include "ImapResponseParser.h"
2
3 #include "util/MessageId.h"
4 #include "util/SpamHeaders.h"
5
6 #include <QLoggingCategory>
7 #include <QRegularExpression>
8 #include <QStringDecoder>
9
10 #include <cstring>
11 #include <limits>
12
13
4/7
✓ Branch 2 → 3 taken 4 times.
✓ Branch 2 → 8 taken 65 times.
✓ Branch 4 → 5 taken 4 times.
✗ Branch 4 → 8 not taken.
✓ Branch 5 → 6 taken 4 times.
✗ Branch 10 → 11 not taken.
✗ Branch 10 → 12 not taken.
69 Q_LOGGING_CATEGORY(lcImapParser, "mailjd.imap.parser")
14
15 namespace {
16
17 // One character of FETCH data as ASCII (non-ASCII never matches an item).
18 25653 inline char asciiAt(const QString &data, qsizetype i) {
19 25653 const ushort u = data.at(i).unicode();
20
1/2
✓ Branch 4 → 5 taken 25653 times.
✗ Branch 4 → 6 not taken.
25653 return u < 128 ? char(u) : char(0x7f);
21 }
22 1054 inline char asciiAt(const QByteArray &data, qsizetype i) { return data.at(i); }
23
24 // S106-019/021: the position right after the FETCH attribute `name` on the
25 // top level of the attribute list, or -1. Quoted strings (with \-escapes)
26 // and nested lists are skipped, so an ENVELOPE subject "UUID 42" or a
27 // MYFLAGS item can never be taken for the UID or FLAGS item. The name must
28 // start a token (after '(' or a blank) and be followed by one of `follow`.
29 // Works on QString and QByteArray data alike.
30 template <typename Data>
31 667 qsizetype topLevelItem(const Data &data, const char *name, const char *follow = " (") {
32 667 const qsizetype length = qsizetype(qstrlen(name));
33 // The attribute list is the first parenthesized level ("(UID 1 …)",
34 // "* 1 FETCH (UID 1 …)") or the bare text ("UID 1 …"): depth 0 or 1.
35 667 int depth = 0;
36 667 bool quoted = false;
37 667 bool escaped = false;
38
4/4
long long (anonymous namespace)::topLevelItem<QByteArray>(QByteArray const&, char const*, char const*):
✓ Branch 55 → 4 taken 556 times.
✓ Branch 55 → 56 taken 5 times.
long long (anonymous namespace)::topLevelItem<QString>(QString const&, char const*, char const*):
✓ Branch 61 → 4 taken 20918 times.
✓ Branch 61 → 62 taken 185 times.
21664 for (qsizetype i = 0; i < data.size(); ++i) {
39 21474 const char c = asciiAt(data, i);
40
4/4
long long (anonymous namespace)::topLevelItem<QByteArray>(QByteArray const&, char const*, char const*):
✓ Branch 5 → 6 taken 56 times.
✓ Branch 5 → 13 taken 500 times.
long long (anonymous namespace)::topLevelItem<QString>(QString const&, char const*, char const*):
✓ Branch 5 → 6 taken 6176 times.
✓ Branch 5 → 13 taken 14742 times.
21474 if (quoted) {
41
4/4
long long (anonymous namespace)::topLevelItem<QByteArray>(QByteArray const&, char const*, char const*):
✓ Branch 6 → 7 taken 4 times.
✓ Branch 6 → 8 taken 52 times.
long long (anonymous namespace)::topLevelItem<QString>(QString const&, char const*, char const*):
✓ Branch 6 → 7 taken 86 times.
✓ Branch 6 → 8 taken 6090 times.
6232 if (escaped)
42 90 escaped = false;
43
4/4
long long (anonymous namespace)::topLevelItem<QByteArray>(QByteArray const&, char const*, char const*):
✓ Branch 8 → 9 taken 4 times.
✓ Branch 8 → 10 taken 48 times.
long long (anonymous namespace)::topLevelItem<QString>(QString const&, char const*, char const*):
✓ Branch 8 → 9 taken 89 times.
✓ Branch 8 → 10 taken 6001 times.
6142 else if (c == '\\')
44 93 escaped = true;
45
4/4
long long (anonymous namespace)::topLevelItem<QByteArray>(QByteArray const&, char const*, char const*):
✓ Branch 10 → 11 taken 5 times.
✓ Branch 10 → 12 taken 43 times.
long long (anonymous namespace)::topLevelItem<QString>(QString const&, char const*, char const*):
✓ Branch 10 → 11 taken 487 times.
✓ Branch 10 → 12 taken 5514 times.
6049 else if (c == '"')
46 492 quoted = false;
47 6232 continue;
48 }
49
4/4
long long (anonymous namespace)::topLevelItem<QByteArray>(QByteArray const&, char const*, char const*):
✓ Branch 13 → 14 taken 5 times.
✓ Branch 13 → 15 taken 495 times.
long long (anonymous namespace)::topLevelItem<QString>(QString const&, char const*, char const*):
✓ Branch 13 → 14 taken 493 times.
✓ Branch 13 → 15 taken 14249 times.
15242 if (c == '"') {
50 498 quoted = true;
51 498 continue;
52 }
53
4/4
long long (anonymous namespace)::topLevelItem<QByteArray>(QByteArray const&, char const*, char const*):
✓ Branch 15 → 16 taken 71 times.
✓ Branch 15 → 17 taken 424 times.
long long (anonymous namespace)::topLevelItem<QString>(QString const&, char const*, char const*):
✓ Branch 15 → 16 taken 1053 times.
✓ Branch 15 → 17 taken 13196 times.
14744 if (c == '(') {
54 1124 ++depth;
55 1124 continue;
56 }
57
4/4
long long (anonymous namespace)::topLevelItem<QByteArray>(QByteArray const&, char const*, char const*):
✓ Branch 17 → 18 taken 8 times.
✓ Branch 17 → 19 taken 416 times.
long long (anonymous namespace)::topLevelItem<QString>(QString const&, char const*, char const*):
✓ Branch 17 → 18 taken 626 times.
✓ Branch 17 → 19 taken 12570 times.
13620 if (c == ')') {
58 634 --depth;
59 634 continue;
60 }
61
22/24
long long (anonymous namespace)::topLevelItem<QByteArray>(QByteArray const&, char const*, char const*):
✓ Branch 19 → 20 taken 403 times.
✓ Branch 19 → 27 taken 13 times.
✓ Branch 20 → 21 taken 395 times.
✓ Branch 20 → 28 taken 8 times.
✓ Branch 22 → 23 taken 327 times.
✓ Branch 22 → 28 taken 68 times.
✓ Branch 24 → 25 taken 248 times.
✓ Branch 24 → 28 taken 79 times.
✓ Branch 26 → 27 taken 247 times.
✓ Branch 26 → 28 taken 1 time.
✓ Branch 29 → 30 taken 260 times.
✓ Branch 29 → 31 taken 156 times.
long long (anonymous namespace)::topLevelItem<QString>(QString const&, char const*, char const*):
✓ Branch 19 → 20 taken 6422 times.
✓ Branch 19 → 33 taken 6148 times.
✓ Branch 20 → 21 taken 6422 times.
✗ Branch 20 → 34 not taken.
✓ Branch 24 → 25 taken 5825 times.
✓ Branch 24 → 34 taken 597 times.
✓ Branch 28 → 29 taken 4898 times.
✓ Branch 28 → 34 taken 927 times.
✓ Branch 32 → 33 taken 4898 times.
✗ Branch 32 → 34 not taken.
✓ Branch 35 → 36 taken 11046 times.
✓ Branch 35 → 37 taken 1524 times.
12986 if (depth > 1 || (i > 0 && data.at(i - 1) != '(' && data.at(i - 1) != ' ' && data.at(i - 1) != '\t'))
62 11306 continue;
63
4/4
long long (anonymous namespace)::topLevelItem<QByteArray>(QByteArray const&, char const*, char const*):
✓ Branch 32 → 33 taken 2 times.
✓ Branch 32 → 34 taken 154 times.
long long (anonymous namespace)::topLevelItem<QString>(QString const&, char const*, char const*):
✓ Branch 38 → 39 taken 19 times.
✓ Branch 38 → 40 taken 1505 times.
1680 if (length > data.size() - i)
64 21 return -1;
65 1659 bool same = true;
66
8/8
long long (anonymous namespace)::topLevelItem<QByteArray>(QByteArray const&, char const*, char const*):
✓ Branch 40 → 41 taken 457 times.
✓ Branch 40 → 42 taken 65 times.
✓ Branch 41 → 35 taken 368 times.
✓ Branch 41 → 42 taken 89 times.
long long (anonymous namespace)::topLevelItem<QString>(QString const&, char const*, char const*):
✓ Branch 46 → 47 taken 4372 times.
✓ Branch 46 → 48 taken 393 times.
✓ Branch 47 → 41 taken 3260 times.
✓ Branch 47 → 48 taken 1112 times.
5287 for (qsizetype k = 0; k < length && same; ++k) {
67 3628 char actual = asciiAt(data, i + k);
68
6/8
long long (anonymous namespace)::topLevelItem<QByteArray>(QByteArray const&, char const*, char const*):
✓ Branch 36 → 37 taken 8 times.
✓ Branch 36 → 39 taken 360 times.
✓ Branch 37 → 38 taken 8 times.
✗ Branch 37 → 39 not taken.
long long (anonymous namespace)::topLevelItem<QString>(QString const&, char const*, char const*):
✓ Branch 42 → 43 taken 1 time.
✓ Branch 42 → 45 taken 3259 times.
✓ Branch 43 → 44 taken 1 time.
✗ Branch 43 → 45 not taken.
3628 if (actual >= 'a' && actual <= 'z') actual = char(actual - ('a' - 'A'));
69 3628 same = actual == name[k];
70 }
71 1659 const qsizetype end = i + length;
72
15/20
long long (anonymous namespace)::topLevelItem<QByteArray>(QByteArray const&, char const*, char const*):
✓ Branch 42 → 43 taken 65 times.
✓ Branch 42 → 50 taken 89 times.
✓ Branch 44 → 45 taken 65 times.
✗ Branch 44 → 50 not taken.
✓ Branch 46 → 47 taken 65 times.
✗ Branch 46 → 50 not taken.
✓ Branch 48 → 49 taken 63 times.
✓ Branch 48 → 50 taken 2 times.
✓ Branch 51 → 52 taken 63 times.
✓ Branch 51 → 53 taken 91 times.
long long (anonymous namespace)::topLevelItem<QString>(QString const&, char const*, char const*):
✓ Branch 48 → 49 taken 393 times.
✓ Branch 48 → 56 taken 1112 times.
✓ Branch 50 → 51 taken 393 times.
✗ Branch 50 → 56 not taken.
✓ Branch 52 → 53 taken 393 times.
✗ Branch 52 → 56 not taken.
✓ Branch 54 → 55 taken 393 times.
✗ Branch 54 → 56 not taken.
✓ Branch 57 → 58 taken 393 times.
✓ Branch 57 → 59 taken 1112 times.
1659 if (same && end < data.size() && asciiAt(data, end) != 0 && std::strchr(follow, asciiAt(data, end)))
73 456 return end;
74 }
75 190 return -1;
76 }
77
78 // A positive 63-bit number right after `pos` (blanks skipped); 0 when the
79 // digits are missing, overflow (S106-018) or are not positive.
80 template <typename Data>
81 285 qint64 numberAfter(const Data &data, qsizetype pos) {
82
7/8
✓ Branch 4 → 5 taken 550 times.
✓ Branch 4 → 12 taken 10 times.
✓ Branch 6 → 7 taken 550 times.
✗ Branch 6 → 12 not taken.
✓ Branch 10 → 11 taken 275 times.
✓ Branch 10 → 12 taken 275 times.
✓ Branch 13 → 3 taken 275 times.
✓ Branch 13 → 14 taken 285 times.
560 while (pos >= 0 && pos < data.size() && data.at(pos) == ' ')
83 275 ++pos;
84
2/2
✓ Branch 14 → 15 taken 10 times.
✓ Branch 14 → 16 taken 275 times.
285 if (pos < 0)
85 10 return 0;
86 275 qint64 value = 0;
87 275 const qsizetype digitsStart = pos;
88
6/8
✓ Branch 23 → 24 taken 962 times.
✗ Branch 23 → 35 not taken.
✓ Branch 28 → 29 taken 689 times.
✓ Branch 28 → 35 taken 273 times.
✓ Branch 33 → 34 taken 689 times.
✗ Branch 33 → 35 not taken.
✓ Branch 36 → 17 taken 689 times.
✓ Branch 36 → 37 taken 273 times.
962 while (pos < data.size() && data.at(pos) >= '0' && data.at(pos) <= '9') {
89 689 const int digit = int(asciiAt(data, pos) - '0');
90
2/2
✓ Branch 19 → 20 taken 2 times.
✓ Branch 19 → 21 taken 687 times.
689 if (value > (std::numeric_limits<qint64>::max() - digit) / 10)
91 2 return 0;
92 687 value = value * 10 + digit;
93 687 ++pos;
94 }
95
1/2
✓ Branch 37 → 38 taken 273 times.
✗ Branch 37 → 39 not taken.
273 return pos == digitsStart ? 0 : value;
96 }
97
98 // The parenthesized FLAGS list after `pos` ("" with ok=false when absent).
99 185 QString flagListAfter(const QString &data, qsizetype pos, bool *ok) {
100 185 *ok = false;
101
7/8
✓ Branch 4 → 5 taken 228 times.
✓ Branch 4 → 13 taken 71 times.
✓ Branch 6 → 7 taken 228 times.
✗ Branch 6 → 13 not taken.
✓ Branch 11 → 12 taken 114 times.
✓ Branch 11 → 13 taken 114 times.
✓ Branch 14 → 3 taken 114 times.
✓ Branch 14 → 15 taken 185 times.
299 while (pos >= 0 && pos < data.size() && data.at(pos) == QLatin1Char(' '))
102 114 ++pos;
103
6/8
✓ Branch 15 → 16 taken 114 times.
✓ Branch 15 → 23 taken 71 times.
✓ Branch 17 → 18 taken 114 times.
✗ Branch 17 → 23 not taken.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 24 taken 114 times.
✓ Branch 25 → 26 taken 71 times.
✓ Branch 25 → 27 taken 114 times.
185 if (pos < 0 || pos >= data.size() || data.at(pos) != QLatin1Char('('))
104 71 return {};
105 114 const qsizetype close = data.indexOf(QLatin1Char(')'), pos);
106
1/2
✗ Branch 30 → 31 not taken.
✓ Branch 30 → 32 taken 114 times.
114 if (close < 0)
107 ✗ return {};
108 114 *ok = true;
109 114 return data.mid(pos + 1, close - pos - 1);
110 }
111
112 // F-12 (SPRINT-104): the value of the last BODY[HEADER.FIELDS (…)] item
113 // of a FETCH response — the only part of the data that carries header
114 // fields. Returns an empty string when the item is absent (the ENVELOPE
115 // remains the source then); a subject or display name quoting
116 // "References:" can never leak into the header scan again.
117 158 QString headerFieldsBlock(const QString &data) {
118 static QRegularExpression item(
119 18 QStringLiteral("BODY\\[HEADER\\.FIELDS"),
120
4/7
✓ Branch 2 → 3 taken 9 times.
✓ Branch 2 → 15 taken 149 times.
✓ Branch 4 → 5 taken 9 times.
✗ Branch 4 → 15 not taken.
✓ Branch 9 → 10 taken 9 times.
✗ Branch 95 → 96 not taken.
✗ Branch 95 → 97 not taken.
176 QRegularExpression::CaseInsensitiveOption);
121 158 qsizetype at = -1;
122
1/1
✓ Branch 15 → 16 taken 158 times.
158 QRegularExpressionMatch match;
123 158 qsizetype search = 0;
124
4/4
✓ Branch 21 → 22 taken 187 times.
✓ Branch 23 → 24 taken 187 times.
✓ Branch 25 → 17 taken 29 times.
✓ Branch 25 → 26 taken 158 times.
345 while ((match = item.match(data, search)).hasMatch()) {
125
1/1
✓ Branch 17 → 18 taken 29 times.
29 at = match.capturedStart();
126
1/1
✓ Branch 18 → 19 taken 29 times.
29 search = match.capturedEnd();
127 }
128 // S106-012: no BODY[HEADER.FIELDS item → no header fields. Every header
129 // sync requests the item (kHeaderFieldsItem in ImapService.cpp); the former
130 // whole-line fallback scanned quoted ENVELOPE/BODYSTRUCTURE strings of a
131 // response without it (unsolicited FETCH, another item set) as headers.
132
2/2
✓ Branch 26 → 27 taken 129 times.
✓ Branch 26 → 28 taken 29 times.
158 if (at < 0) return {};
133 29 const qsizetype close = data.indexOf(QLatin1Char(']'), search);
134
1/2
✗ Branch 31 → 32 not taken.
✓ Branch 31 → 33 taken 29 times.
29 if (close < 0) return {};
135 29 qsizetype quote = close + 1;
136
5/6
✓ Branch 36 → 37 taken 58 times.
✗ Branch 36 → 41 not taken.
✓ Branch 39 → 40 taken 29 times.
✓ Branch 39 → 41 taken 29 times.
✓ Branch 42 → 34 taken 29 times.
✓ Branch 42 → 43 taken 29 times.
58 while (quote < data.size() && data.at(quote).isSpace()) ++quote;
137
3/6
✓ Branch 44 → 45 taken 29 times.
✗ Branch 44 → 50 not taken.
✗ Branch 49 → 50 not taken.
✓ Branch 49 → 51 taken 29 times.
✗ Branch 52 → 53 not taken.
✓ Branch 52 → 54 taken 29 times.
29 if (quote >= data.size() || data.at(quote) != QLatin1Char('"')) return {};
138 29 ++quote;
139 29 QString value;
140 29 bool escaped = false;
141
1/2
✓ Branch 82 → 55 taken 2394 times.
✗ Branch 82 → 83 not taken.
2394 for (qsizetype i = quote; i < data.size(); ++i) {
142 2394 const QChar c = data.at(i);
143
2/2
✓ Branch 56 → 57 taken 46 times.
✓ Branch 56 → 67 taken 2348 times.
2394 if (escaped) {
144 // Keep \r and \n as their escape pairs (the normalization below
145 // turns them into breaks); an escaped quote becomes the character.
146
2/3
✓ Branch 60 → 61 taken 46 times.
✗ Branch 60 → 65 not taken.
✓ Branch 63 → 64 taken 46 times.
46 if (c != QLatin1Char('"')) value.append(QLatin1Char('\\'));
147
1/1
✓ Branch 65 → 66 taken 46 times.
46 value.append(c);
148 46 escaped = false;
149 92 continue;
150 }
151
2/2
✓ Branch 70 → 71 taken 46 times.
✓ Branch 70 → 72 taken 2302 times.
2348 if (c == QLatin1Char('\\')) { escaped = true; continue; }
152
2/2
✓ Branch 75 → 76 taken 29 times.
✓ Branch 75 → 77 taken 2273 times.
2302 if (c == QLatin1Char('"')) break;
153
1/1
✓ Branch 77 → 78 taken 2273 times.
2273 value.append(c);
154 }
155 29 return value;
156 158 }
157 } // namespace
158
159 357 bool ImapResponseParser::isTagged(const QString &line) {
160 // Tagged responses start with a tag like "A001 "
161
5/8
✓ Branch 2 → 3 taken 10 times.
✓ Branch 2 → 12 taken 347 times.
✓ Branch 4 → 5 taken 10 times.
✗ Branch 4 → 12 not taken.
✓ Branch 6 → 7 taken 10 times.
✓ Branch 7 → 8 taken 10 times.
✗ Branch 23 → 24 not taken.
✗ Branch 23 → 25 not taken.
357 static QRegularExpression rx(R"(^[A-Za-z]\d+\s)");
162
2/2
✓ Branch 13 → 14 taken 357 times.
✓ Branch 14 → 15 taken 357 times.
357 return rx.match(line).hasMatch();
163 }
164
165 100744 bool ImapResponseParser::isUntagged(const QString &line) {
166
2/2
✓ Branch 2 → 3 taken 100744 times.
✓ Branch 3 → 4 taken 100744 times.
100744 return line.startsWith("* ");
167 }
168
169 44 bool ImapResponseParser::isContinuation(const QString &line) {
170
2/2
✓ Branch 2 → 3 taken 44 times.
✓ Branch 3 → 4 taken 44 times.
44 return line.startsWith("+");
171 }
172
173 std::optional<TaggedResponse>
174 487 ImapResponseParser::parseTaggedResponse(const QString &line) {
175 // Format: "A001 OK message text" or "A001 NO [CODE] message"
176
5/8
✓ Branch 2 → 3 taken 13 times.
✓ Branch 2 → 12 taken 474 times.
✓ Branch 4 → 5 taken 13 times.
✗ Branch 4 → 12 not taken.
✓ Branch 6 → 7 taken 13 times.
✓ Branch 7 → 8 taken 13 times.
✗ Branch 32 → 33 not taken.
✗ Branch 32 → 34 not taken.
487 static QRegularExpression rx(R"(^([A-Za-z]\d+)\s+(OK|NO|BAD)\s*(.*)?$)");
177
1/1
✓ Branch 13 → 14 taken 487 times.
487 auto match = rx.match(line);
178
3/3
✓ Branch 14 → 15 taken 487 times.
✓ Branch 15 → 16 taken 4 times.
✓ Branch 15 → 17 taken 483 times.
487 if (!match.hasMatch()) {
179 4 return std::nullopt;
180 }
181
182
3/7
✓ Branch 17 → 18 taken 483 times.
✓ Branch 18 → 19 taken 483 times.
✓ Branch 20 → 21 taken 483 times.
✗ Branch 39 → 40 not taken.
✗ Branch 39 → 41 not taken.
✗ Branch 42 → 43 not taken.
✗ Branch 42 → 44 not taken.
1449 return TaggedResponse{
183 match.captured(1), // tag
184 match.captured(2), // status
185
1/1
✓ Branch 19 → 20 taken 483 times.
966 match.captured(3).trimmed() // message
186 483 };
187 487 }
188
189 std::optional<UntaggedResponse>
190 100454 ImapResponseParser::parseUntaggedResponse(const QString &line) {
191
4/4
✓ Branch 2 → 3 taken 100454 times.
✓ Branch 3 → 4 taken 100454 times.
✓ Branch 5 → 6 taken 4 times.
✓ Branch 5 → 7 taken 100450 times.
100454 if (!line.startsWith("* ")) {
192 4 return std::nullopt;
193 }
194
195 // After "* ", the next token is the type
196 100450 const auto rest = QStringView(line).mid(2);
197 100450 const auto spaceIdx = rest.indexOf(' ');
198
199
2/2
✓ Branch 11 → 12 taken 3 times.
✓ Branch 11 → 16 taken 100447 times.
100450 if (spaceIdx < 0) {
200 // Single-word untagged response (rare)
201
1/1
✓ Branch 12 → 13 taken 3 times.
3 return UntaggedResponse{rest.toString(), {}};
202 }
203
204
2/4
✓ Branch 17 → 18 taken 100447 times.
✓ Branch 19 → 20 taken 100447 times.
✗ Branch 30 → 31 not taken.
✗ Branch 30 → 32 not taken.
301341 return UntaggedResponse{rest.left(spaceIdx).toString(),
205 301341 rest.mid(spaceIdx + 1).toString()};
206 }
207
208 std::optional<FolderInfo>
209 43 ImapResponseParser::parseListResponse(const QString &data) {
210 // Format: "(\Flag1 \Flag2) "delimiter" "mailbox-name"" — the mailbox
211 // name is a quoted string with escapes or a bare astring. F-16: a
212 // "\\" delimiter (escaped backslash) and quoted names containing \"
213 // need a quote-aware scan; the former one-character regex dropped
214 // those folders from the list.
215 43 const int n = data.size();
216 43 int i = 0;
217
4/6
✓ Branch 5 → 6 taken 42 times.
✓ Branch 5 → 10 taken 1 time.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 42 times.
✗ Branch 11 → 4 not taken.
✓ Branch 11 → 12 taken 43 times.
43 while (i < n && data.at(i).isSpace())
218 ✗ ++i;
219
6/6
✓ Branch 12 → 13 taken 42 times.
✓ Branch 12 → 17 taken 1 time.
✓ Branch 16 → 17 taken 2 times.
✓ Branch 16 → 18 taken 40 times.
✓ Branch 19 → 20 taken 3 times.
✓ Branch 19 → 32 taken 40 times.
43 if (i >= n || data.at(i) != '(') {
220
6/6
✓ Branch 20 → 21 taken 3 times.
✓ Branch 24 → 25 taken 3 times.
✓ Branch 25 → 26 taken 3 times.
✓ Branch 26 → 27 taken 3 times.
✓ Branch 30 → 22 taken 3 times.
✓ Branch 30 → 31 taken 3 times.
6 qCWarning(lcImapParser) << "Failed to parse LIST response:" << data;
221 3 return std::nullopt;
222 }
223 40 const int flagsEnd = data.indexOf(')', i);
224
1/2
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 47 taken 40 times.
40 if (flagsEnd < 0) {
225 ✗ qCWarning(lcImapParser) << "Failed to parse LIST response:" << data;
226 ✗ return std::nullopt;
227 }
228 40 FolderInfo info;
229
2/2
✓ Branch 47 → 48 taken 40 times.
✓ Branch 48 → 49 taken 40 times.
40 info.flags = parseFlags(data.mid(i, flagsEnd - i + 1));
230 40 i = flagsEnd + 1;
231
5/6
✓ Branch 54 → 55 taken 80 times.
✗ Branch 54 → 59 not taken.
✓ Branch 57 → 58 taken 40 times.
✓ Branch 57 → 59 taken 40 times.
✓ Branch 60 → 53 taken 40 times.
✓ Branch 60 → 61 taken 40 times.
80 while (i < n && data.at(i).isSpace())
232 40 ++i;
233
234 // Delimiter: NIL, a quoted (escape-aware) string, or a bare character.
235
3/3
✓ Branch 61 → 62 taken 40 times.
✓ Branch 65 → 66 taken 3 times.
✓ Branch 65 → 67 taken 37 times.
40 if (data.mid(i, 3).compare(QLatin1String("NIL"), Qt::CaseInsensitive) == 0) {
236 3 i += 3;
237
3/6
✓ Branch 67 → 68 taken 37 times.
✗ Branch 67 → 73 not taken.
✓ Branch 71 → 72 taken 37 times.
✗ Branch 71 → 73 not taken.
✓ Branch 74 → 75 taken 37 times.
✗ Branch 74 → 96 not taken.
37 } else if (i < n && data.at(i) == '"') {
238 37 ++i;
239 37 bool escaped = false;
240 37 QString delimiter;
241
1/2
✓ Branch 92 → 76 taken 75 times.
✗ Branch 92 → 93 not taken.
75 for (; i < n; ++i) {
242 75 const QChar c = data.at(i);
243
2/2
✓ Branch 77 → 78 taken 1 time.
✓ Branch 77 → 80 taken 74 times.
75 if (escaped) {
244
1/1
✓ Branch 78 → 79 taken 1 time.
1 delimiter.append(c);
245 1 escaped = false;
246
2/2
✓ Branch 83 → 84 taken 1 time.
✓ Branch 83 → 85 taken 73 times.
74 } else if (c == QLatin1Char('\\')) {
247 1 escaped = true;
248
2/2
✓ Branch 88 → 89 taken 37 times.
✓ Branch 88 → 90 taken 36 times.
73 } else if (c == QLatin1Char('"')) {
249 37 ++i;
250 37 break;
251 } else {
252
1/1
✓ Branch 90 → 91 taken 36 times.
36 delimiter.append(c);
253 }
254 }
255 37 info.delimiter = delimiter;
256
0/6
✗ Branch 96 → 97 not taken.
✗ Branch 96 → 101 not taken.
✗ Branch 99 → 100 not taken.
✗ Branch 99 → 101 not taken.
✗ Branch 102 → 103 not taken.
✗ Branch 102 → 108 not taken.
37 } else if (i < n && !data.at(i).isSpace()) {
257 ✗ info.delimiter = QString(data.at(i));
258 ✗ ++i;
259 }
260
5/6
✓ Branch 110 → 111 taken 80 times.
✗ Branch 110 → 115 not taken.
✓ Branch 113 → 114 taken 40 times.
✓ Branch 113 → 115 taken 40 times.
✓ Branch 116 → 109 taken 40 times.
✓ Branch 116 → 117 taken 40 times.
80 while (i < n && data.at(i).isSpace())
261 40 ++i;
262
263 // Mailbox name: quoted (escape-aware) or astring to the end.
264 40 QString path;
265
5/6
✓ Branch 117 → 118 taken 40 times.
✗ Branch 117 → 123 not taken.
✓ Branch 121 → 122 taken 39 times.
✓ Branch 121 → 123 taken 1 time.
✓ Branch 124 → 125 taken 39 times.
✓ Branch 124 → 143 taken 1 time.
40 if (i < n && data.at(i) == '"') {
266 39 ++i;
267 39 bool escaped = false;
268
1/2
✓ Branch 142 → 126 taken 440 times.
✗ Branch 142 → 149 not taken.
440 for (; i < n; ++i) {
269 440 const QChar c = data.at(i);
270
2/2
✓ Branch 127 → 128 taken 1 time.
✓ Branch 127 → 130 taken 439 times.
440 if (escaped) {
271
1/1
✓ Branch 128 → 129 taken 1 time.
1 path.append(c);
272 1 escaped = false;
273
2/2
✓ Branch 133 → 134 taken 1 time.
✓ Branch 133 → 135 taken 438 times.
439 } else if (c == QLatin1Char('\\')) {
274 1 escaped = true;
275
2/2
✓ Branch 138 → 139 taken 39 times.
✓ Branch 138 → 140 taken 399 times.
438 } else if (c == QLatin1Char('"')) {
276 39 ++i;
277 39 break;
278 } else {
279
1/1
✓ Branch 140 → 141 taken 399 times.
399 path.append(c);
280 }
281 }
282 } else {
283
2/2
✓ Branch 143 → 144 taken 1 time.
✓ Branch 144 → 145 taken 1 time.
1 path = data.mid(i).trimmed();
284 }
285
1/2
✗ Branch 150 → 151 not taken.
✓ Branch 150 → 163 taken 40 times.
40 if (path.isEmpty()) {
286 ✗ qCWarning(lcImapParser) << "Failed to parse LIST response:" << data;
287 ✗ return std::nullopt;
288 }
289
290 40 info.path = path;
291
1/1
✓ Branch 164 → 165 taken 40 times.
40 info.name = decodeMailboxName(info.path);
292
293 // Extract display name (last component after delimiter)
294
7/7
✓ Branch 168 → 169 taken 37 times.
✓ Branch 168 → 172 taken 3 times.
✓ Branch 169 → 170 taken 37 times.
✓ Branch 170 → 171 taken 5 times.
✓ Branch 170 → 172 taken 32 times.
✓ Branch 173 → 174 taken 5 times.
✓ Branch 173 → 179 taken 35 times.
40 if (!info.delimiter.isEmpty() && info.name.contains(info.delimiter)) {
295
1/1
✓ Branch 175 → 176 taken 5 times.
5 info.name = info.name.section(info.delimiter, -1);
296 }
297
298 40 return info;
299 40 }
300
301 78 QStringList ImapResponseParser::parseCapabilities(const QString &data) {
302 // CAPABILITY data is space-separated tokens
303
1/1
✓ Branch 4 → 5 taken 78 times.
78 return data.split(' ', Qt::SkipEmptyParts);
304 }
305
306 40 QStringList ImapResponseParser::parseFlags(const QString &flagStr) {
307 // Input: "(\HasChildren \Sent)" → ["\\HasChildren", "\\Sent"]
308 40 auto inner = flagStr;
309
1/1
✓ Branch 4 → 5 taken 40 times.
40 inner.remove('(');
310
1/1
✓ Branch 6 → 7 taken 40 times.
40 inner.remove(')');
311
1/1
✓ Branch 7 → 8 taken 40 times.
40 inner = inner.trimmed();
312
313
2/2
✓ Branch 11 → 12 taken 15 times.
✓ Branch 11 → 13 taken 25 times.
40 if (inner.isEmpty()) {
314 15 return {};
315 }
316
317
1/1
✓ Branch 15 → 16 taken 25 times.
25 return inner.split(' ', Qt::SkipEmptyParts);
318 40 }
319
320 14 quint32 ImapResponseParser::flagsToBitmask(const QStringList &flagStrings) {
321
1/1
✓ Branch 2 → 3 taken 14 times.
14 return flagsAndKeywords(flagStrings).first;
322 }
323
324 QPair<quint32, QStringList>
325 144 ImapResponseParser::flagsAndKeywords(const QStringList &flagStrings) {
326 144 quint32 result = MailFlag::None;
327 144 QStringList keywords;
328
2/2
✓ Branch 41 → 4 taken 147 times.
✓ Branch 41 → 42 taken 144 times.
291 for (const auto &flag : flagStrings) {
329
3/3
✓ Branch 5 → 6 taken 147 times.
✓ Branch 8 → 9 taken 101 times.
✓ Branch 8 → 10 taken 46 times.
147 if (flag.compare("\\Seen", Qt::CaseInsensitive) == 0)
330 101 result |= MailFlag::Seen;
331
3/3
✓ Branch 10 → 11 taken 46 times.
✓ Branch 13 → 14 taken 7 times.
✓ Branch 13 → 15 taken 39 times.
46 else if (flag.compare("\\Answered", Qt::CaseInsensitive) == 0)
332 7 result |= MailFlag::Answered;
333
3/3
✓ Branch 15 → 16 taken 39 times.
✓ Branch 18 → 19 taken 12 times.
✓ Branch 18 → 20 taken 27 times.
39 else if (flag.compare("\\Flagged", Qt::CaseInsensitive) == 0)
334 12 result |= MailFlag::Flagged;
335
3/3
✓ Branch 20 → 21 taken 27 times.
✓ Branch 23 → 24 taken 3 times.
✓ Branch 23 → 25 taken 24 times.
27 else if (flag.compare("\\Deleted", Qt::CaseInsensitive) == 0)
336 3 result |= MailFlag::Deleted;
337
3/3
✓ Branch 25 → 26 taken 24 times.
✓ Branch 28 → 29 taken 4 times.
✓ Branch 28 → 30 taken 20 times.
24 else if (flag.compare("\\Draft", Qt::CaseInsensitive) == 0)
338 4 result |= MailFlag::Draft;
339
7/7
✓ Branch 31 → 32 taken 20 times.
✓ Branch 32 → 33 taken 16 times.
✓ Branch 32 → 36 taken 4 times.
✓ Branch 34 → 35 taken 14 times.
✓ Branch 34 → 36 taken 2 times.
✓ Branch 37 → 38 taken 14 times.
✓ Branch 37 → 39 taken 6 times.
20 else if (!flag.startsWith('\\') && !flag.isEmpty()) {
340 // Non-system flag = keyword (label) — store all, UI filters internal ones
341
1/1
✓ Branch 38 → 39 taken 14 times.
14 keywords.append(flag);
342 }
343 }
344 288 return {result, keywords};
345 144 }
346
347 2979 bool ImapResponseParser::isInternalKeyword(const QString &keyword) {
348 static const QStringList internalKeywords = {
349 "NonJunk", "NotJunk", "$NotJunk",
350 "Junk", "$Junk", "$MDNSent",
351 "$Forwarded", "$SubmitPending", "$Submitted",
352
5/10
✓ Branch 2 → 3 taken 7 times.
✓ Branch 2 → 21 taken 2972 times.
✓ Branch 4 → 5 taken 7 times.
✗ Branch 4 → 21 not taken.
✓ Branch 18 → 19 taken 63 times.
✓ Branch 18 → 20 taken 7 times.
✗ Branch 38 → 39 not taken.
✗ Branch 38 → 40 not taken.
✗ Branch 47 → 48 not taken.
✗ Branch 47 → 49 not taken.
3049 };
353
2/2
✓ Branch 29 → 23 taken 26706 times.
✓ Branch 29 → 30 taken 2957 times.
29663 for (const auto &kw : internalKeywords) {
354
2/2
✓ Branch 25 → 26 taken 22 times.
✓ Branch 25 → 27 taken 26684 times.
26706 if (keyword.compare(kw, Qt::CaseInsensitive) == 0) {
355 22 return true;
356 }
357 }
358 // SOGo/Mailcow internal annotations: x-me-annot-1, x-me-annot-2, etc.
359
4/4
✓ Branch 30 → 31 taken 2957 times.
✓ Branch 31 → 32 taken 2957 times.
✓ Branch 33 → 34 taken 3 times.
✓ Branch 33 → 35 taken 2954 times.
2957 if (keyword.startsWith("x-me-annot", Qt::CaseInsensitive))
360 3 return true;
361 2954 return false;
362 7 }
363
364 std::optional<MailHeader>
365 161 ImapResponseParser::parseFetchHeaderResponse(const QString &data) {
366 // Expected format (after "* N FETCH "):
367 // (UID 42 FLAGS (\Seen) RFC822.SIZE 1234 ENVELOPE ("date" "subject"
368 // (("from-name" NIL "user" "host")) ...))
369 //
370 // We extract key fields using targeted regex patterns rather than
371 // implementing a full IMAP grammar parser.
372
373 161 MailHeader header;
374
375 // Extract UID (top-level item only, S106-019; a positive 63-bit value,
376 // S106-018 — an overflowing UID used to become 0 and was accepted)
377 161 header.uid = numberAfter(data, topLevelItem(data, "UID", " "));
378
2/2
✓ Branch 5 → 6 taken 3 times.
✓ Branch 5 → 20 taken 158 times.
161 if (header.uid <= 0) {
379
7/7
✓ Branch 6 → 7 taken 3 times.
✓ Branch 10 → 11 taken 3 times.
✓ Branch 11 → 12 taken 3 times.
✓ Branch 12 → 13 taken 3 times.
✓ Branch 13 → 14 taken 3 times.
✓ Branch 18 → 8 taken 3 times.
✓ Branch 18 → 19 taken 3 times.
6 qCWarning(lcImapParser) << "No valid UID in FETCH response:" << data.left(100);
380 3 return std::nullopt;
381 }
382
383 // Extract FLAGS
384 158 bool hasFlags = false;
385
1/1
✓ Branch 21 → 22 taken 158 times.
158 const QString flagText = flagListAfter(data, topLevelItem(data, "FLAGS"), &hasFlags);
386
2/2
✓ Branch 22 → 23 taken 89 times.
✓ Branch 22 → 33 taken 69 times.
158 if (hasFlags) {
387
1/1
✓ Branch 25 → 26 taken 89 times.
89 auto flagList = flagText.split(' ', Qt::SkipEmptyParts);
388
1/1
✓ Branch 26 → 27 taken 89 times.
89 auto [flags, keywords] = flagsAndKeywords(flagList);
389 89 header.flags = flags;
390 89 header.labels = keywords;
391 89 }
392
393 // Extract RFC822.SIZE
394 158 const qsizetype sizeItem = topLevelItem(data, "RFC822.SIZE", " ");
395
2/2
✓ Branch 34 → 35 taken 88 times.
✓ Branch 34 → 37 taken 70 times.
158 if (sizeItem >= 0)
396 88 header.size = numberAfter(data, sizeItem);
397
398 // Extract ENVELOPE fields
399 // Envelope format: ("date" "subject" ((from)) ((sender)) ((reply-to))
400 // ((to)) ((cc)) ((bcc)) "in-reply-to" "message-id")
401
5/8
✓ Branch 37 → 38 taken 9 times.
✓ Branch 37 → 47 taken 149 times.
✓ Branch 39 → 40 taken 9 times.
✗ Branch 39 → 47 not taken.
✓ Branch 41 → 42 taken 9 times.
✓ Branch 42 → 43 taken 9 times.
✗ Branch 701 → 702 not taken.
✗ Branch 701 → 703 not taken.
158 static QRegularExpression envRx(R"(ENVELOPE\s*\()");
402
1/1
✓ Branch 48 → 49 taken 158 times.
158 auto envMatch = envRx.match(data);
403
3/3
✓ Branch 49 → 50 taken 158 times.
✓ Branch 50 → 51 taken 140 times.
✓ Branch 50 → 243 taken 18 times.
158 if (envMatch.hasMatch()) {
404
1/1
✓ Branch 51 → 52 taken 140 times.
140 int envStart = envMatch.capturedEnd();
405 // Parse quoted fields from envelope
406 // Field 1: date string
407 // Field 2: subject
408 1328 auto extractQuoted = [&](int &pos) -> QString {
409 // Skip whitespace
410
6/6
✓ Branch 5 → 6 taken 2286 times.
✓ Branch 5 → 10 taken 9 times.
✓ Branch 8 → 9 taken 967 times.
✓ Branch 8 → 10 taken 1319 times.
✓ Branch 11 → 3 taken 967 times.
✓ Branch 11 → 12 taken 1328 times.
2295 while (pos < data.length() && data[pos].isSpace())
411 967 pos++;
412
2/2
✓ Branch 13 → 14 taken 9 times.
✓ Branch 13 → 15 taken 1319 times.
1328 if (pos >= data.length())
413 9 return {};
414
415
3/3
✓ Branch 15 → 16 taken 1319 times.
✓ Branch 18 → 19 taken 382 times.
✓ Branch 18 → 20 taken 937 times.
1319 if (data.mid(pos, 3) == "NIL") {
416 382 pos += 3;
417 382 return {};
418 }
419
420 // Handle IMAP literal syntax {N} — the transport layer normally converts
421 // these to quoted strings, but handle the case where they survive inline.
422 // SEC-2026-07-21-01: use qsizetype to avoid int overflow on unrealistic
423 // literal lengths; the transport budgets already reject >64 MiB literals,
424 // but this makes the parser self-defensive.
425
2/2
✓ Branch 23 → 24 taken 9 times.
✓ Branch 23 → 42 taken 928 times.
937 if (data[pos] == '{') {
426 9 qsizetype braceEnd = data.indexOf('}', pos);
427
2/2
✓ Branch 26 → 27 taken 8 times.
✓ Branch 26 → 42 taken 1 time.
9 if (braceEnd > pos) {
428 bool ok;
429 qsizetype len =
430
2/2
✓ Branch 27 → 28 taken 8 times.
✓ Branch 28 → 29 taken 8 times.
8 data.mid(pos + 1, braceEnd - pos - 1).toLongLong(&ok);
431 // S106-017: compared without the sum, which overflowed for a
432 // server length near INT64_MAX and wrapped the check true.
433
8/8
✓ Branch 30 → 31 taken 7 times.
✓ Branch 30 → 35 taken 1 time.
✓ Branch 31 → 32 taken 5 times.
✓ Branch 31 → 35 taken 2 times.
✓ Branch 33 → 34 taken 3 times.
✓ Branch 33 → 35 taken 2 times.
✓ Branch 36 → 37 taken 3 times.
✓ Branch 36 → 41 taken 5 times.
8 if (ok && len >= 0 && len <= data.length() - (braceEnd + 1)) {
434
1/1
✓ Branch 37 → 38 taken 3 times.
3 QString result = data.mid(braceEnd + 1, len);
435 3 pos = braceEnd + 1 + len;
436 3 return result;
437 3 }
438 }
439 }
440
441
2/2
✓ Branch 45 → 46 taken 31 times.
✓ Branch 45 → 65 taken 903 times.
934 if (data[pos] != '"') {
442
4/4
✓ Branch 46 → 47 taken 31 times.
✓ Branch 50 → 51 taken 31 times.
✓ Branch 63 → 48 taken 31 times.
✓ Branch 63 → 64 taken 31 times.
62 qCWarning(lcImapParser)
443
2/2
✓ Branch 51 → 52 taken 31 times.
✓ Branch 53 → 54 taken 31 times.
31 << "extractQuoted: unexpected char" << data[pos]
444
5/5
✓ Branch 54 → 55 taken 31 times.
✓ Branch 55 → 56 taken 31 times.
✓ Branch 56 → 57 taken 31 times.
✓ Branch 57 → 58 taken 31 times.
✓ Branch 58 → 59 taken 31 times.
31 << "at pos" << pos << "context:" << data.mid(pos, 30);
445 // T-401/Bug 11: Advance pos to avoid infinite loop
446 31 pos++;
447 31 return {};
448 }
449
450 903 pos++; // skip opening quote
451 903 QString result;
452
6/6
✓ Branch 80 → 81 taken 10110 times.
✓ Branch 80 → 86 taken 2 times.
✓ Branch 84 → 85 taken 9209 times.
✓ Branch 84 → 86 taken 901 times.
✓ Branch 87 → 66 taken 9209 times.
✓ Branch 87 → 88 taken 903 times.
10112 while (pos < data.length() && data[pos] != '"') {
453
6/6
✓ Branch 69 → 70 taken 7 times.
✓ Branch 69 → 73 taken 9202 times.
✓ Branch 71 → 72 taken 6 times.
✓ Branch 71 → 73 taken 1 time.
✓ Branch 74 → 75 taken 6 times.
✓ Branch 74 → 76 taken 9203 times.
9209 if (data[pos] == '\\' && pos + 1 < data.length()) {
454 6 pos++;
455 }
456
1/1
✓ Branch 77 → 78 taken 9209 times.
9209 result.append(data[pos]);
457 9209 pos++;
458 }
459
2/2
✓ Branch 89 → 90 taken 901 times.
✓ Branch 89 → 91 taken 2 times.
903 if (pos < data.length())
460 901 pos++; // skip closing quote
461 903 return result;
462 903 };
463
464 140 int pos = envStart;
465
1/1
✓ Branch 52 → 53 taken 140 times.
140 QString dateStr = extractQuoted(pos);
466
2/2
✓ Branch 53 → 54 taken 140 times.
✓ Branch 54 → 55 taken 140 times.
140 header.subject = decodeRfc2047(extractQuoted(pos));
467
6/6
✓ Branch 59 → 60 taken 16 times.
✓ Branch 59 → 63 taken 124 times.
✓ Branch 61 → 62 taken 4 times.
✓ Branch 61 → 63 taken 12 times.
✓ Branch 64 → 65 taken 4 times.
✓ Branch 64 → 83 taken 136 times.
140 if (header.subject.isEmpty() && !dateStr.isEmpty()) {
468
4/4
✓ Branch 65 → 66 taken 4 times.
✓ Branch 69 → 70 taken 4 times.
✓ Branch 81 → 67 taken 4 times.
✓ Branch 81 → 82 taken 4 times.
8 qCWarning(lcImapParser)
469
2/2
✓ Branch 70 → 71 taken 4 times.
✓ Branch 71 → 72 taken 4 times.
4 << "Empty subject for UID" << header.uid
470
1/1
✓ Branch 72 → 73 taken 4 times.
4 << "— ENVELOPE near pos" << pos
471
4/4
✓ Branch 73 → 74 taken 4 times.
✓ Branch 74 → 75 taken 4 times.
✓ Branch 75 → 76 taken 4 times.
✓ Branch 76 → 77 taken 4 times.
4 << "context:" << data.mid(envStart, 120);
472 }
473
474 // Parse date (RFC 2822 format)
475
2/2
✓ Branch 84 → 85 taken 110 times.
✓ Branch 84 → 194 taken 30 times.
140 if (!dateStr.isEmpty()) {
476 // Strip RFC 2822 comments like "(UTC)" or "(CET)" before parsing
477
5/8
✓ Branch 85 → 86 taken 8 times.
✓ Branch 85 → 95 taken 102 times.
✓ Branch 87 → 88 taken 8 times.
✗ Branch 87 → 95 not taken.
✓ Branch 89 → 90 taken 8 times.
✓ Branch 90 → 91 taken 8 times.
✗ Branch 721 → 722 not taken.
✗ Branch 721 → 723 not taken.
110 static QRegularExpression commentRx(R"(\([^)]*\))");
478 110 QString cleanDate = dateStr;
479
1/1
✓ Branch 96 → 97 taken 110 times.
110 cleanDate.remove(commentRx);
480
1/1
✓ Branch 97 → 98 taken 110 times.
110 cleanDate = cleanDate.simplified(); // collapse whitespace
481
482 // Replace named timezones that Qt may not recognize.
483 // T-79.A3/M2: token-exact (trailing token only) — a plain substring
484 // replace of " UT" turned " UTC" into " +0000C", breaking every
485 // subsequent parse attempt.
486 static QRegularExpression namedZoneRx(
487
4/7
✓ Branch 100 → 101 taken 8 times.
✓ Branch 100 → 113 taken 102 times.
✓ Branch 102 → 103 taken 8 times.
✗ Branch 102 → 113 not taken.
✓ Branch 107 → 108 taken 8 times.
✗ Branch 732 → 733 not taken.
✗ Branch 732 → 734 not taken.
118 QStringLiteral(" (?:UTC|GMT|UT)$"));
488
1/1
✓ Branch 116 → 117 taken 110 times.
110 cleanDate.replace(namedZoneRx, QStringLiteral(" +0000"));
489
490 // Try common date formats (use QLocale::c() for English month names)
491
1/1
✓ Branch 119 → 120 taken 110 times.
110 QLocale cLocale = QLocale::c();
492
493
1/1
✓ Branch 123 → 124 taken 110 times.
220 header.date = cLocale.toDateTime(cleanDate,
494 330 QStringLiteral("ddd, d MMM yyyy H:mm:ss t"));
495
3/3
✓ Branch 128 → 129 taken 110 times.
✓ Branch 129 → 130 taken 26 times.
✓ Branch 129 → 134 taken 84 times.
110 if (!header.date.isValid()) {
496
1/1
✓ Branch 130 → 131 taken 26 times.
26 header.date = QDateTime::fromString(cleanDate, Qt::RFC2822Date);
497 }
498
3/3
✓ Branch 134 → 135 taken 110 times.
✓ Branch 135 → 136 taken 10 times.
✓ Branch 135 → 140 taken 100 times.
110 if (!header.date.isValid()) {
499
1/1
✓ Branch 136 → 137 taken 10 times.
10 header.date = QDateTime::fromString(cleanDate, Qt::ISODate);
500 }
501
3/3
✓ Branch 140 → 141 taken 110 times.
✓ Branch 141 → 142 taken 9 times.
✓ Branch 141 → 151 taken 101 times.
110 if (!header.date.isValid()) {
502 // Without timezone: "Mon, 16 Feb 2026 10:58:01"
503
1/1
✓ Branch 145 → 146 taken 9 times.
18 header.date = cLocale.toDateTime(cleanDate,
504 27 QStringLiteral("ddd, d MMM yyyy H:mm:ss"));
505 }
506
3/3
✓ Branch 151 → 152 taken 110 times.
✓ Branch 152 → 153 taken 9 times.
✓ Branch 152 → 162 taken 101 times.
110 if (!header.date.isValid()) {
507 // Without weekday: "16 Feb 2026 10:58:01 +0000"
508
1/1
✓ Branch 156 → 157 taken 9 times.
18 header.date = cLocale.toDateTime(cleanDate,
509 27 QStringLiteral("d MMM yyyy H:mm:ss t"));
510 }
511
3/3
✓ Branch 162 → 163 taken 110 times.
✓ Branch 163 → 164 taken 9 times.
✓ Branch 163 → 173 taken 101 times.
110 if (!header.date.isValid()) {
512 // Without weekday or timezone: "16 Feb 2026 10:58:01"
513
1/1
✓ Branch 167 → 168 taken 9 times.
18 header.date = cLocale.toDateTime(cleanDate,
514 27 QStringLiteral("d MMM yyyy H:mm:ss"));
515 }
516
3/3
✓ Branch 173 → 174 taken 110 times.
✓ Branch 174 → 175 taken 9 times.
✓ Branch 174 → 191 taken 101 times.
110 if (!header.date.isValid()) {
517
6/6
✓ Branch 175 → 176 taken 9 times.
✓ Branch 179 → 180 taken 9 times.
✓ Branch 180 → 181 taken 9 times.
✓ Branch 181 → 182 taken 9 times.
✓ Branch 189 → 177 taken 9 times.
✓ Branch 189 → 190 taken 9 times.
18 qCWarning(lcImapParser) << "Date parse failed for UID" << header.uid
518
2/2
✓ Branch 182 → 183 taken 9 times.
✓ Branch 183 → 184 taken 9 times.
9 << "dateStr:" << dateStr
519
2/2
✓ Branch 184 → 185 taken 9 times.
✓ Branch 185 → 186 taken 9 times.
9 << "cleanDate:" << cleanDate;
520 }
521 110 }
522
523 // Helper: skip a parenthesized group (NIL or (...)) entirely
524 794 auto skipGroup = [&](int &pos) {
525
6/6
✓ Branch 5 → 6 taken 1154 times.
✓ Branch 5 → 10 taken 15 times.
✓ Branch 8 → 9 taken 375 times.
✓ Branch 8 → 10 taken 779 times.
✓ Branch 11 → 3 taken 375 times.
✓ Branch 11 → 12 taken 794 times.
1169 while (pos < data.length() && data[pos].isSpace())
526 375 pos++;
527
2/2
✓ Branch 13 → 14 taken 15 times.
✓ Branch 13 → 15 taken 779 times.
794 if (pos >= data.length())
528 15 return;
529
3/3
✓ Branch 15 → 16 taken 779 times.
✓ Branch 18 → 19 taken 530 times.
✓ Branch 18 → 20 taken 249 times.
779 if (data.mid(pos, 3) == "NIL") {
530 530 pos += 3;
531 530 return;
532 }
533
2/2
✓ Branch 23 → 24 taken 189 times.
✓ Branch 23 → 68 taken 60 times.
249 if (data[pos] == '(') {
534 189 int depth = 0;
535
1/2
✓ Branch 67 → 25 taken 2365 times.
✗ Branch 67 → 68 not taken.
2365 while (pos < data.length()) {
536 // T-401/Bug 12: Skip quoted strings to avoid counting
537 // parentheses inside them (e.g. "Smith (CTO)")
538
2/2
✓ Branch 28 → 29 taken 571 times.
✓ Branch 28 → 54 taken 1794 times.
2365 if (data[pos] == '"') {
539 571 pos++; // skip opening quote
540
5/6
✓ Branch 42 → 43 taken 3672 times.
✗ Branch 42 → 48 not taken.
✓ Branch 46 → 47 taken 3101 times.
✓ Branch 46 → 48 taken 571 times.
✓ Branch 49 → 30 taken 3101 times.
✓ Branch 49 → 50 taken 571 times.
3672 while (pos < data.length() && data[pos] != '"') {
541
5/6
✓ Branch 33 → 34 taken 6 times.
✓ Branch 33 → 37 taken 3095 times.
✓ Branch 35 → 36 taken 6 times.
✗ Branch 35 → 37 not taken.
✓ Branch 38 → 39 taken 6 times.
✓ Branch 38 → 40 taken 3095 times.
3101 if (data[pos] == '\\' && pos + 1 < data.length())
542 6 pos++;
543 3101 pos++;
544 }
545
1/2
✓ Branch 51 → 52 taken 571 times.
✗ Branch 51 → 53 not taken.
571 if (pos < data.length())
546 571 pos++; // skip closing quote
547 571 continue;
548 }
549
2/2
✓ Branch 57 → 58 taken 291 times.
✓ Branch 57 → 59 taken 1503 times.
1794 if (data[pos] == '(')
550 291 depth++;
551
2/2
✓ Branch 62 → 63 taken 291 times.
✓ Branch 62 → 65 taken 1212 times.
1503 else if (data[pos] == ')') {
552 291 depth--;
553
2/2
✓ Branch 63 → 64 taken 189 times.
✓ Branch 63 → 65 taken 102 times.
291 if (depth == 0) {
554 189 pos++;
555 189 return;
556 }
557 }
558 1605 pos++;
559 }
560 }
561 140 };
562
563 // Helper: extract first address from an address list.
564 // IMAP address list formats:
565 // NIL → no addresses
566 // ((name NIL user host)) → single address
567 // ((name NIL user host)(name NIL user host)) → multiple addresses
568 // ((NIL NIL group NIL)(name NIL u h)(NIL NIL NIL NIL)) → group syntax
569 140 auto extractAddress = [&](int &pos) -> std::pair<QString, QString> {
570
6/6
✓ Branch 5 → 6 taken 267 times.
✓ Branch 5 → 10 taken 3 times.
✓ Branch 8 → 9 taken 130 times.
✓ Branch 8 → 10 taken 137 times.
✓ Branch 11 → 3 taken 130 times.
✓ Branch 11 → 12 taken 140 times.
270 while (pos < data.length() && data[pos].isSpace())
571 130 pos++;
572
2/2
✓ Branch 13 → 14 taken 3 times.
✓ Branch 13 → 15 taken 137 times.
140 if (pos >= data.length())
573 3 return {};
574
575 // NIL = no address list
576
3/3
✓ Branch 15 → 16 taken 137 times.
✓ Branch 18 → 19 taken 26 times.
✓ Branch 18 → 20 taken 111 times.
137 if (data.mid(pos, 3) == "NIL") {
577 26 pos += 3;
578 26 return {};
579 }
580
581 // Must start with '(' (outer list)
582
2/2
✓ Branch 23 → 24 taken 9 times.
✓ Branch 23 → 25 taken 102 times.
111 if (data[pos] != '(') {
583 9 return {};
584 }
585
586 // Save start position for fallback
587 102 int listStart = pos;
588
589 // Check if we have "((" (normal case) or just "("
590
5/6
✓ Branch 26 → 27 taken 102 times.
✗ Branch 26 → 32 not taken.
✓ Branch 30 → 31 taken 100 times.
✓ Branch 30 → 32 taken 2 times.
✓ Branch 33 → 34 taken 100 times.
✓ Branch 33 → 110 taken 2 times.
102 if (pos + 1 < data.length() && data[pos + 1] == '(') {
591 // Standard format: ((name NIL user host) ...)
592 100 pos += 2; // skip ((
593
2/2
✓ Branch 34 → 35 taken 100 times.
✓ Branch 35 → 36 taken 100 times.
100 QString name = decodeRfc2047(extractQuoted(pos));
594
1/1
✓ Branch 37 → 38 taken 100 times.
100 extractQuoted(pos); // skip at-domain-list (NIL)
595
1/1
✓ Branch 39 → 40 taken 100 times.
100 QString user = extractQuoted(pos);
596
1/1
✓ Branch 40 → 41 taken 100 times.
100 QString host = extractQuoted(pos);
597
598 // Skip to end of address list (balance all parens from listStart)
599 100 int depth = 2; // we opened ((
600
5/6
✓ Branch 83 → 84 taken 300 times.
✗ Branch 83 → 86 not taken.
✓ Branch 84 → 85 taken 200 times.
✓ Branch 84 → 86 taken 100 times.
✓ Branch 87 → 42 taken 200 times.
✓ Branch 87 → 88 taken 100 times.
300 while (pos < data.length() && depth > 0) {
601 // T-79.A4/M1: skip quoted strings so parentheses inside display
602 // names (e.g. "John (Work)") don't desynchronize the balance —
603 // same walk as skipGroup() above (T-401/Bug 12).
604
1/2
✗ Branch 45 → 46 not taken.
✓ Branch 45 → 71 taken 200 times.
200 if (data[pos] == '"') {
605 ✗ pos++; // skip opening quote
606 ✗ while (pos < data.length() && data[pos] != '"') {
607 ✗ if (data[pos] == '\\' && pos + 1 < data.length())
608 ✗ pos++;
609 ✗ pos++;
610 }
611 ✗ if (pos < data.length())
612 ✗ pos++; // skip closing quote
613 ✗ continue;
614 }
615
1/2
✗ Branch 74 → 75 not taken.
✓ Branch 74 → 76 taken 200 times.
200 if (data[pos] == '(')
616 ✗ depth++;
617
1/2
✓ Branch 79 → 80 taken 200 times.
✗ Branch 79 → 81 not taken.
200 else if (data[pos] == ')')
618 200 depth--;
619 200 pos++;
620 }
621
622 100 QString email;
623
6/6
✓ Branch 89 → 90 taken 99 times.
✓ Branch 89 → 93 taken 1 time.
✓ Branch 91 → 92 taken 97 times.
✓ Branch 91 → 93 taken 2 times.
✓ Branch 94 → 95 taken 97 times.
✓ Branch 94 → 101 taken 3 times.
100 if (!user.isEmpty() && !host.isEmpty()) {
624
2/2
✓ Branch 95 → 96 taken 97 times.
✓ Branch 96 → 97 taken 97 times.
97 email = user + "@" + host;
625
2/2
✓ Branch 102 → 103 taken 2 times.
✓ Branch 102 → 104 taken 1 time.
3 } else if (!user.isEmpty()) {
626 2 email = user; // partial address
627 }
628 100 return {name, email};
629 100 } else {
630 // Unexpected format – skip the whole group safely
631 2 skipGroup(pos);
632 2 return {};
633 }
634 140 };
635
636 // from address
637
1/1
✓ Branch 194 → 195 taken 140 times.
140 auto [fromName, fromEmail] = extractAddress(pos);
638
2/2
✓ Branch 198 → 199 taken 99 times.
✓ Branch 198 → 212 taken 41 times.
140 if (!fromEmail.isEmpty()) {
639 header.from =
640
9/13
✓ Branch 200 → 201 taken 3 times.
✓ Branch 200 → 202 taken 96 times.
✓ Branch 202 → 203 taken 96 times.
✓ Branch 203 → 204 taken 96 times.
✓ Branch 204 → 205 taken 96 times.
✓ Branch 207 → 208 taken 96 times.
✓ Branch 207 → 209 taken 3 times.
✓ Branch 209 → 210 taken 96 times.
✓ Branch 209 → 211 taken 3 times.
✗ Branch 781 → 782 not taken.
✗ Branch 781 → 783 not taken.
✗ Branch 785 → 786 not taken.
✗ Branch 785 → 787 not taken.
99 fromName.isEmpty() ? fromEmail : fromName + " <" + fromEmail + ">";
641 }
642
643 // Skip sender (usually same as from)
644
1/1
✓ Branch 212 → 213 taken 140 times.
140 skipGroup(pos);
645
646 // Skip reply-to
647
1/1
✓ Branch 213 → 214 taken 140 times.
140 skipGroup(pos);
648
649 // Sprint 95 S95.1.11: every To and Cc address ("Name <a@b>, c@d"), so
650 // server rules can be previewed and applied on cached mail. Each entry
651 // is bounded by the balanced-group skip, so a malformed entry cannot
652 // desynchronize the rest of the envelope.
653 280 auto extractAddressList = [&](int &pos) -> QString {
654
6/6
✓ Branch 5 → 6 taken 524 times.
✓ Branch 5 → 10 taken 6 times.
✓ Branch 8 → 9 taken 250 times.
✓ Branch 8 → 10 taken 274 times.
✓ Branch 11 → 3 taken 250 times.
✓ Branch 11 → 12 taken 280 times.
530 while (pos < data.length() && data[pos].isSpace())
655 250 pos++;
656 280 QStringList addresses;
657
6/6
✓ Branch 13 → 14 taken 274 times.
✓ Branch 13 → 18 taken 6 times.
✓ Branch 17 → 18 taken 189 times.
✓ Branch 17 → 19 taken 85 times.
✓ Branch 20 → 21 taken 195 times.
✓ Branch 20 → 23 taken 85 times.
280 if (pos >= data.length() || data[pos] != '(') {
658
1/1
✓ Branch 21 → 22 taken 195 times.
195 skipGroup(pos); // NIL or unexpected
659 195 return {};
660 }
661 85 int listEnd = pos;
662
1/1
✓ Branch 23 → 24 taken 85 times.
85 skipGroup(listEnd);
663 85 int entry = pos + 1;
664
1/2
✓ Branch 79 → 25 taken 177 times.
✗ Branch 79 → 80 not taken.
177 while (entry < listEnd) {
665
3/6
✓ Branch 27 → 28 taken 177 times.
✗ Branch 27 → 32 not taken.
✗ Branch 30 → 31 not taken.
✓ Branch 30 → 32 taken 177 times.
✗ Branch 33 → 26 not taken.
✓ Branch 33 → 34 taken 177 times.
177 while (entry < listEnd && data[entry].isSpace())
666 ✗ entry++;
667
5/6
✓ Branch 34 → 35 taken 177 times.
✗ Branch 34 → 39 not taken.
✓ Branch 38 → 39 taken 85 times.
✓ Branch 38 → 40 taken 92 times.
✓ Branch 41 → 42 taken 85 times.
✓ Branch 41 → 43 taken 92 times.
177 if (entry >= listEnd || data[entry] != '(')
668 85 break;
669 92 int entryEnd = entry;
670
1/1
✓ Branch 43 → 44 taken 92 times.
92 skipGroup(entryEnd);
671 92 int field = entry + 1;
672
2/2
✓ Branch 44 → 45 taken 92 times.
✓ Branch 45 → 46 taken 92 times.
92 const QString name = decodeRfc2047(extractQuoted(field));
673
1/1
✓ Branch 47 → 48 taken 92 times.
92 extractQuoted(field); // at-domain-list
674
1/1
✓ Branch 49 → 50 taken 92 times.
92 const QString user = extractQuoted(field);
675
1/1
✓ Branch 50 → 51 taken 92 times.
92 const QString host = extractQuoted(field);
676 // Group markers have no host (RFC 3501 §7.4.2); they are skipped.
677
6/6
✓ Branch 52 → 53 taken 91 times.
✓ Branch 52 → 56 taken 1 time.
✓ Branch 54 → 55 taken 90 times.
✓ Branch 54 → 56 taken 1 time.
✓ Branch 57 → 58 taken 90 times.
✓ Branch 57 → 75 taken 2 times.
92 if (!user.isEmpty() && !host.isEmpty()) {
678
2/2
✓ Branch 58 → 59 taken 90 times.
✓ Branch 59 → 60 taken 90 times.
90 const QString email = user + "@" + host;
679
10/14
✓ Branch 62 → 63 taken 1 time.
✓ Branch 62 → 64 taken 89 times.
✓ Branch 64 → 65 taken 89 times.
✓ Branch 65 → 66 taken 89 times.
✓ Branch 66 → 67 taken 89 times.
✓ Branch 67 → 68 taken 90 times.
✓ Branch 69 → 70 taken 89 times.
✓ Branch 69 → 71 taken 1 time.
✓ Branch 71 → 72 taken 89 times.
✓ Branch 71 → 73 taken 1 time.
✗ Branch 100 → 101 not taken.
✗ Branch 100 → 102 not taken.
✗ Branch 104 → 105 not taken.
✗ Branch 104 → 106 not taken.
90 addresses.append(name.isEmpty() ? email : name + " <" + email + ">");
680 90 }
681 92 entry = entryEnd;
682 92 }
683 85 pos = listEnd;
684
1/1
✓ Branch 83 → 84 taken 85 times.
85 return addresses.join(QStringLiteral(", "));
685 280 };
686
687
1/1
✓ Branch 214 → 215 taken 140 times.
140 header.to = extractAddressList(pos);
688
1/1
✓ Branch 217 → 218 taken 140 times.
140 header.cc = extractAddressList(pos);
689 140 header.recipientsKnown = true;
690
691 // Skip bcc (address list)
692
1/1
✓ Branch 220 → 221 taken 140 times.
140 skipGroup(pos);
693
694 // in-reply-to (quoted string or NIL)
695
1/1
✓ Branch 221 → 222 taken 140 times.
140 QString rawInReplyTo = extractQuoted(pos);
696
2/2
✓ Branch 223 → 224 taken 7 times.
✓ Branch 223 → 231 taken 133 times.
140 if (!rawInReplyTo.isEmpty()) {
697
1/1
✓ Branch 224 → 225 taken 7 times.
7 header.inReplyToIds = MessageId::parseTokens(rawInReplyTo);
698
2/2
✓ Branch 228 → 229 taken 6 times.
✓ Branch 228 → 231 taken 1 time.
7 if (!header.inReplyToIds.isEmpty())
699
1/1
✓ Branch 229 → 230 taken 6 times.
6 header.inReplyTo = header.inReplyToIds.first();
700 }
701
702 // message-id (quoted string or NIL)
703
1/1
✓ Branch 231 → 232 taken 140 times.
140 QString rawMessageId = extractQuoted(pos);
704
2/2
✓ Branch 233 → 234 taken 97 times.
✓ Branch 233 → 238 taken 43 times.
140 if (!rawMessageId.isEmpty()) {
705
1/1
✓ Branch 234 → 235 taken 97 times.
97 header.messageId = MessageId::canonicalFirst(rawMessageId);
706 }
707 140 }
708
709 // Fallback: if ENVELOPE date is missing/unparseable, use INTERNALDATE
710
3/3
✓ Branch 243 → 244 taken 158 times.
✓ Branch 244 → 245 taken 57 times.
✓ Branch 244 → 323 taken 101 times.
158 if (!header.date.isValid()) {
711
4/7
✓ Branch 245 → 246 taken 5 times.
✓ Branch 245 → 258 taken 52 times.
✓ Branch 247 → 248 taken 5 times.
✗ Branch 247 → 258 not taken.
✓ Branch 252 → 253 taken 5 times.
✗ Branch 810 → 811 not taken.
✗ Branch 810 → 812 not taken.
62 static QRegularExpression idateRx(QStringLiteral(R"~~(^\s*"([^"]+)")~~"));
712 57 const qsizetype idateItem = topLevelItem(data, "INTERNALDATE", " ");
713
4/4
✓ Branch 260 → 261 taken 53 times.
✓ Branch 260 → 262 taken 4 times.
✓ Branch 262 → 263 taken 4 times.
✓ Branch 263 → 264 taken 57 times.
57 auto idateMatch = idateRx.match(idateItem < 0 ? QString() : data.mid(idateItem));
714
3/3
✓ Branch 265 → 266 taken 57 times.
✓ Branch 266 → 267 taken 4 times.
✓ Branch 266 → 321 taken 53 times.
57 if (idateMatch.hasMatch()) {
715
1/1
✓ Branch 267 → 268 taken 4 times.
4 QString idateStr = idateMatch.captured(1);
716 // INTERNALDATE format: "16-Feb-2026 21:44:53 +0100"
717
1/1
✓ Branch 268 → 269 taken 4 times.
4 QLocale cLocale = QLocale::c();
718
1/1
✓ Branch 272 → 273 taken 4 times.
8 header.date = cLocale.toDateTime(
719 12 idateStr, QStringLiteral("d-MMM-yyyy H:mm:ss t"));
720
3/3
✓ Branch 277 → 278 taken 4 times.
✓ Branch 278 → 279 taken 1 time.
✓ Branch 278 → 288 taken 3 times.
4 if (!header.date.isValid()) {
721
1/1
✓ Branch 282 → 283 taken 1 time.
2 header.date = cLocale.toDateTime(
722 3 idateStr, QStringLiteral("dd-MMM-yyyy HH:mm:ss t"));
723 }
724
3/3
✓ Branch 288 → 289 taken 4 times.
✓ Branch 289 → 290 taken 3 times.
✓ Branch 289 → 304 taken 1 time.
4 if (header.date.isValid()) {
725
5/5
✓ Branch 290 → 291 taken 3 times.
✓ Branch 294 → 295 taken 3 times.
✓ Branch 295 → 296 taken 3 times.
✓ Branch 302 → 292 taken 3 times.
✓ Branch 302 → 303 taken 3 times.
6 qCInfo(lcImapParser) << "Used INTERNALDATE fallback for UID"
726
3/3
✓ Branch 296 → 297 taken 3 times.
✓ Branch 297 → 298 taken 3 times.
✓ Branch 298 → 299 taken 3 times.
3 << header.uid << ":" << idateStr;
727 } else {
728
5/5
✓ Branch 304 → 305 taken 1 time.
✓ Branch 308 → 309 taken 1 time.
✓ Branch 309 → 310 taken 1 time.
✓ Branch 316 → 306 taken 1 time.
✓ Branch 316 → 317 taken 1 time.
2 qCWarning(lcImapParser) << "INTERNALDATE parse also failed for UID"
729
3/3
✓ Branch 310 → 311 taken 1 time.
✓ Branch 311 → 312 taken 1 time.
✓ Branch 312 → 313 taken 1 time.
1 << header.uid << ":" << idateStr;
730 }
731 4 }
732 57 }
733
734 // Parse the requested header block as logical fields. IMAP test fixtures and
735 // quoted transports may contain visible "\\r\\n" escapes, while literal
736 // responses contain real line breaks; normalize both without treating an
737 // arbitrary later header as part of References.
738
1/1
✓ Branch 323 → 324 taken 158 times.
158 QString headerBlock = headerFieldsBlock(data);
739
1/1
✓ Branch 330 → 331 taken 158 times.
316 headerBlock.replace(QStringLiteral("\\r\\n"), QStringLiteral("\n"));
740
1/1
✓ Branch 341 → 342 taken 158 times.
316 headerBlock.replace(QStringLiteral("\\n"), QStringLiteral("\n"));
741
1/1
✓ Branch 352 → 353 taken 158 times.
316 headerBlock.replace(QStringLiteral("\r\n"), QStringLiteral("\n"));
742
1/1
✓ Branch 361 → 362 taken 158 times.
158 headerBlock.replace(QLatin1Char('\r'), QLatin1Char('\n'));
743
744 // ENVELOPE permits only a single in-reply-to string and some servers omit
745 // or simplify it. Prefer the explicitly requested raw header so folded and
746 // multi-ID values survive incremental header synchronization.
747 static QRegularExpression inReplyToStart(
748 18 QStringLiteral(R"(In-Reply-To\s*:)"),
749
4/7
✓ Branch 362 → 363 taken 9 times.
✓ Branch 362 → 375 taken 149 times.
✓ Branch 364 → 365 taken 9 times.
✗ Branch 364 → 375 not taken.
✓ Branch 369 → 370 taken 9 times.
✗ Branch 896 → 897 not taken.
✗ Branch 896 → 898 not taken.
176 QRegularExpression::CaseInsensitiveOption);
750
1/1
✓ Branch 375 → 376 taken 158 times.
158 QRegularExpressionMatch inReplyToMatch;
751 158 qsizetype inReplyToOffset = 0;
752 158 while ((inReplyToMatch =
753
4/4
✓ Branch 394 → 395 taken 160 times.
✓ Branch 396 → 397 taken 160 times.
✓ Branch 398 → 377 taken 6 times.
✓ Branch 398 → 399 taken 154 times.
160 inReplyToStart.match(headerBlock, inReplyToOffset)).hasMatch()) {
754
1/1
✓ Branch 377 → 378 taken 6 times.
6 const qsizetype start = inReplyToMatch.capturedStart();
755
2/2
✓ Branch 378 → 379 taken 3 times.
✓ Branch 378 → 387 taken 3 times.
9 if (start == 0 ||
756
2/2
✓ Branch 381 → 382 taken 2 times.
✓ Branch 381 → 388 taken 1 time.
3 (!headerBlock.at(start - 1).isLetterOrNumber() &&
757
4/4
✓ Branch 386 → 387 taken 1 time.
✓ Branch 386 → 388 taken 1 time.
✓ Branch 389 → 390 taken 4 times.
✓ Branch 389 → 391 taken 2 times.
8 headerBlock.at(start - 1) != QLatin1Char('-'))) {
758 4 break;
759 }
760
1/1
✓ Branch 391 → 392 taken 2 times.
2 inReplyToOffset = inReplyToMatch.capturedEnd();
761 }
762
3/3
✓ Branch 399 → 400 taken 158 times.
✓ Branch 400 → 401 taken 4 times.
✓ Branch 400 → 454 taken 154 times.
158 if (inReplyToMatch.hasMatch()) {
763
1/1
✓ Branch 401 → 402 taken 4 times.
4 const qsizetype valueStart = inReplyToMatch.capturedEnd();
764 4 qsizetype lineEnd = headerBlock.indexOf(QLatin1Char('\n'), valueStart);
765
2/2
✓ Branch 405 → 406 taken 1 time.
✓ Branch 405 → 407 taken 3 times.
4 if (lineEnd < 0)
766 1 lineEnd = headerBlock.size();
767
1/1
✓ Branch 407 → 408 taken 4 times.
4 QString value = headerBlock.mid(valueStart, lineEnd - valueStart);
768
2/2
✓ Branch 441 → 409 taken 4 times.
✓ Branch 441 → 442 taken 1 time.
5 while (lineEnd < headerBlock.size()) {
769 4 const qsizetype continuationStart = lineEnd + 1;
770
2/2
✓ Branch 410 → 411 taken 3 times.
✓ Branch 410 → 421 taken 1 time.
7 if (continuationStart >= headerBlock.size() ||
771
1/2
✓ Branch 415 → 416 taken 3 times.
✗ Branch 415 → 422 not taken.
3 (headerBlock.at(continuationStart) != QLatin1Char(' ') &&
772
4/4
✓ Branch 420 → 421 taken 2 times.
✓ Branch 420 → 422 taken 1 time.
✓ Branch 423 → 424 taken 3 times.
✓ Branch 423 → 425 taken 1 time.
7 headerBlock.at(continuationStart) != QLatin1Char('\t'))) {
773 3 break;
774 }
775 qsizetype continuationEnd =
776 1 headerBlock.indexOf(QLatin1Char('\n'), continuationStart);
777
1/2
✗ Branch 428 → 429 not taken.
✓ Branch 428 → 430 taken 1 time.
1 if (continuationEnd < 0)
778 ✗ continuationEnd = headerBlock.size();
779 2 value += QLatin1Char(' ') +
780 headerBlock
781
1/1
✓ Branch 430 → 431 taken 1 time.
2 .mid(continuationStart,
782 continuationEnd - continuationStart)
783
3/3
✓ Branch 431 → 432 taken 1 time.
✓ Branch 434 → 435 taken 1 time.
✓ Branch 435 → 436 taken 1 time.
3 .trimmed();
784 1 lineEnd = continuationEnd;
785 }
786
1/1
✓ Branch 442 → 443 taken 4 times.
4 header.inReplyToIds = MessageId::parseTokens(value);
787 4 header.inReplyTo = header.inReplyToIds.isEmpty()
788
2/2
✓ Branch 446 → 447 taken 1 time.
✓ Branch 446 → 448 taken 3 times.
8 ? QString()
789
1/1
✓ Branch 448 → 449 taken 3 times.
4 : header.inReplyToIds.first();
790 4 }
791
792 static QRegularExpression refsStart(
793 18 QStringLiteral(R"(References\s*:)"),
794
4/7
✓ Branch 454 → 455 taken 9 times.
✓ Branch 454 → 467 taken 149 times.
✓ Branch 456 → 457 taken 9 times.
✗ Branch 456 → 467 not taken.
✓ Branch 461 → 462 taken 9 times.
✗ Branch 926 → 927 not taken.
✗ Branch 926 → 928 not taken.
176 QRegularExpression::CaseInsensitiveOption);
795
1/1
✓ Branch 467 → 468 taken 158 times.
158 QRegularExpressionMatch refsMatch;
796 158 qsizetype searchOffset = 0;
797
4/4
✓ Branch 486 → 487 taken 160 times.
✓ Branch 488 → 489 taken 160 times.
✓ Branch 490 → 469 taken 11 times.
✓ Branch 490 → 491 taken 149 times.
318 while ((refsMatch = refsStart.match(headerBlock, searchOffset)).hasMatch()) {
798
1/1
✓ Branch 469 → 470 taken 11 times.
11 const qsizetype start = refsMatch.capturedStart();
799
2/2
✓ Branch 470 → 471 taken 4 times.
✓ Branch 470 → 479 taken 7 times.
15 if (start == 0 ||
800
2/2
✓ Branch 473 → 474 taken 3 times.
✓ Branch 473 → 480 taken 1 time.
4 (!headerBlock.at(start - 1).isLetterOrNumber() &&
801
4/4
✓ Branch 478 → 479 taken 2 times.
✓ Branch 478 → 480 taken 1 time.
✓ Branch 481 → 482 taken 9 times.
✓ Branch 481 → 483 taken 2 times.
14 headerBlock.at(start - 1) != QLatin1Char('-'))) {
802 9 break;
803 }
804
1/1
✓ Branch 483 → 484 taken 2 times.
2 searchOffset = refsMatch.capturedEnd();
805 }
806
3/3
✓ Branch 491 → 492 taken 158 times.
✓ Branch 492 → 493 taken 9 times.
✓ Branch 492 → 539 taken 149 times.
158 if (refsMatch.hasMatch()) {
807
1/1
✓ Branch 493 → 494 taken 9 times.
9 qsizetype valueStart = refsMatch.capturedEnd();
808 9 qsizetype lineEnd = headerBlock.indexOf(QLatin1Char('\n'), valueStart);
809
2/2
✓ Branch 497 → 498 taken 2 times.
✓ Branch 497 → 499 taken 7 times.
9 if (lineEnd < 0)
810 2 lineEnd = headerBlock.size();
811
1/1
✓ Branch 499 → 500 taken 9 times.
9 QString refsValue = headerBlock.mid(valueStart, lineEnd - valueStart);
812
813
2/2
✓ Branch 533 → 501 taken 9 times.
✓ Branch 533 → 534 taken 3 times.
12 while (lineEnd < headerBlock.size()) {
814 9 const qsizetype continuationStart = lineEnd + 1;
815
2/2
✓ Branch 502 → 503 taken 6 times.
✓ Branch 502 → 513 taken 3 times.
15 if (continuationStart >= headerBlock.size() ||
816
1/2
✓ Branch 507 → 508 taken 6 times.
✗ Branch 507 → 514 not taken.
6 (headerBlock.at(continuationStart) != QLatin1Char(' ') &&
817
4/4
✓ Branch 512 → 513 taken 3 times.
✓ Branch 512 → 514 taken 3 times.
✓ Branch 515 → 516 taken 6 times.
✓ Branch 515 → 517 taken 3 times.
15 headerBlock.at(continuationStart) != QLatin1Char('\t'))) {
818 6 break;
819 }
820 qsizetype continuationEnd =
821 3 headerBlock.indexOf(QLatin1Char('\n'), continuationStart);
822
2/2
✓ Branch 520 → 521 taken 1 time.
✓ Branch 520 → 522 taken 2 times.
3 if (continuationEnd < 0)
823 1 continuationEnd = headerBlock.size();
824 6 refsValue += QLatin1Char(' ') +
825 headerBlock
826
1/1
✓ Branch 522 → 523 taken 3 times.
6 .mid(continuationStart,
827 continuationEnd - continuationStart)
828
3/3
✓ Branch 523 → 524 taken 3 times.
✓ Branch 526 → 527 taken 3 times.
✓ Branch 527 → 528 taken 3 times.
9 .trimmed();
829 3 lineEnd = continuationEnd;
830 }
831
832
1/1
✓ Branch 534 → 535 taken 9 times.
9 header.references = MessageId::parseTokens(refsValue);
833 9 }
834
835 // Sprint 95 S95.1.11: List-Id (RFC 2919), requested with the header
836 // fields above; stored as the bare id ("dev.lists.example.org").
837 18 static QRegularExpression listIdStart(QStringLiteral(R"((?:^|\n|")List-Id[ \t]*:)"),
838
4/7
✓ Branch 539 → 540 taken 9 times.
✓ Branch 539 → 552 taken 149 times.
✓ Branch 541 → 542 taken 9 times.
✗ Branch 541 → 552 not taken.
✓ Branch 546 → 547 taken 9 times.
✗ Branch 955 → 956 not taken.
✗ Branch 955 → 957 not taken.
176 QRegularExpression::CaseInsensitiveOption);
839
1/1
✓ Branch 553 → 554 taken 158 times.
158 const QRegularExpressionMatch listIdMatch = listIdStart.match(headerBlock);
840
3/3
✓ Branch 554 → 555 taken 158 times.
✓ Branch 555 → 556 taken 1 time.
✓ Branch 555 → 618 taken 157 times.
158 if (listIdMatch.hasMatch()) {
841
1/1
✓ Branch 556 → 557 taken 1 time.
1 qsizetype lineEnd = headerBlock.indexOf(QLatin1Char('\n'), listIdMatch.capturedEnd());
842
1/2
✗ Branch 560 → 561 not taken.
✓ Branch 560 → 562 taken 1 time.
1 if (lineEnd < 0)
843 ✗ lineEnd = headerBlock.size();
844
3/3
✓ Branch 562 → 563 taken 1 time.
✓ Branch 563 → 564 taken 1 time.
✓ Branch 564 → 565 taken 1 time.
1 QString value = headerBlock.mid(listIdMatch.capturedEnd(), lineEnd - listIdMatch.capturedEnd());
845
3/4
✓ Branch 582 → 583 taken 2 times.
✗ Branch 582 → 594 not taken.
✓ Branch 587 → 588 taken 1 time.
✓ Branch 587 → 593 taken 1 time.
5 while (lineEnd + 1 < headerBlock.size() &&
846
3/4
✗ Branch 592 → 593 not taken.
✓ Branch 592 → 594 taken 1 time.
✓ Branch 595 → 566 taken 1 time.
✓ Branch 595 → 596 taken 1 time.
5 (headerBlock.at(lineEnd + 1) == QLatin1Char(' ') || headerBlock.at(lineEnd + 1) == QLatin1Char('\t'))) {
847 1 qsizetype next = headerBlock.indexOf(QLatin1Char('\n'), lineEnd + 1);
848
1/2
✗ Branch 569 → 570 not taken.
✓ Branch 569 → 571 taken 1 time.
1 if (next < 0)
849 ✗ next = headerBlock.size();
850
4/4
✓ Branch 571 → 572 taken 1 time.
✓ Branch 572 → 573 taken 1 time.
✓ Branch 575 → 576 taken 1 time.
✓ Branch 576 → 577 taken 1 time.
1 value += QLatin1Char(' ') + headerBlock.mid(lineEnd + 1, next - lineEnd - 1).trimmed();
851 1 lineEnd = next;
852 }
853 1 const qsizetype open = value.lastIndexOf(QLatin1Char('<'));
854 1 const qsizetype close = value.indexOf(QLatin1Char('>'), open + 1);
855
3/5
✓ Branch 602 → 603 taken 1 time.
✗ Branch 602 → 606 not taken.
✓ Branch 603 → 604 taken 1 time.
✗ Branch 603 → 606 not taken.
✓ Branch 604 → 605 taken 1 time.
2 header.listId = (open >= 0 && close > open ? value.mid(open + 1, close - open - 1) : value)
856
3/3
✓ Branch 607 → 608 taken 1 time.
✓ Branch 610 → 611 taken 1 time.
✓ Branch 611 → 612 taken 1 time.
1 .trimmed().remove(QLatin1Char('"')).toLower();
857 1 }
858
859 // Sprint 105 S105.2.2: one parser for the sync and the reader
860 // (SpamHeaders, fuzzed by fuzz_spam_headers) — the verdict, score,
861 // threshold and tool of the requested filter fields. Only a response
862 // with the HEADER.FIELDS item knows them; others keep the stored verdict.
863 18 static const QRegularExpression fieldsItem(QStringLiteral("BODY\\[HEADER\\.FIELDS"),
864
4/7
✓ Branch 618 → 619 taken 9 times.
✓ Branch 618 → 631 taken 149 times.
✓ Branch 620 → 621 taken 9 times.
✗ Branch 620 → 631 not taken.
✓ Branch 625 → 626 taken 9 times.
✗ Branch 989 → 990 not taken.
✗ Branch 989 → 991 not taken.
176 QRegularExpression::CaseInsensitiveOption);
865
2/2
✓ Branch 632 → 633 taken 158 times.
✓ Branch 633 → 634 taken 158 times.
158 header.spamKnown = fieldsItem.match(data).hasMatch();
866
2/2
✓ Branch 635 → 636 taken 129 times.
✓ Branch 635 → 654 taken 29 times.
158 if (!header.spamKnown) {
867 // The header-only transport shape without the item (see
868 // headerFieldsBlock): the former tolerant yes/no scan, no score.
869 16 static const QRegularExpression spamYes(QStringLiteral(R"(X-Spam(?:-Flag|-Status)?:\s*Yes)"),
870
4/7
✓ Branch 636 → 637 taken 8 times.
✓ Branch 636 → 649 taken 121 times.
✓ Branch 638 → 639 taken 8 times.
✗ Branch 638 → 649 not taken.
✓ Branch 643 → 644 taken 8 times.
✗ Branch 1003 → 1004 not taken.
✗ Branch 1003 → 1005 not taken.
145 QRegularExpression::CaseInsensitiveOption);
871
2/2
✓ Branch 650 → 651 taken 129 times.
✓ Branch 651 → 652 taken 129 times.
129 header.isSpam = spamYes.match(headerBlock).hasMatch();
872 129 return header;
873 }
874
4/4
✓ Branch 654 → 655 taken 29 times.
✓ Branch 655 → 656 taken 29 times.
✓ Branch 656 → 657 taken 29 times.
✓ Branch 657 → 658 taken 29 times.
29 const SpamHeaders::Verdict spam = SpamHeaders::parse(SpamHeaders::extract(headerBlock.toUtf8() + "\n\n"));
875 29 header.isSpam = spam.spam;
876 29 header.spamScore = spam.score;
877 29 header.spamThreshold = spam.threshold;
878
6/6
✓ Branch 661 → 662 taken 13 times.
✓ Branch 661 → 668 taken 16 times.
✓ Branch 663 → 664 taken 7 times.
✓ Branch 663 → 667 taken 6 times.
✓ Branch 671 → 672 taken 7 times.
✓ Branch 671 → 673 taken 22 times.
36 header.spamTool = spam.known ? (spam.tool.isEmpty() ? QStringLiteral("other") : spam.tool) : QString();
879
880 29 return header;
881 161 }
882
883 std::optional<QPair<qint64, QByteArray>>
884 40 ImapResponseParser::parseFetchBodyResponse(const QByteArray &data,
885 qint64 maxBodyBytes) {
886
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 39 times.
40 if (maxBodyBytes < 0)
887 1 return std::nullopt;
888
889 // S106-019: top-level items only — the former byte scan matched "UID"
890 // inside a quoted ENVELOPE subject or another item's name.
891 39 const qsizetype uidItem = topLevelItem(data, "UID", " \t");
892
2/2
✓ Branch 5 → 6 taken 4 times.
✓ Branch 5 → 7 taken 35 times.
39 if (uidItem < 0)
893 4 return std::nullopt;
894 35 qsizetype pos = uidItem;
895
5/6
✓ Branch 10 → 11 taken 70 times.
✗ Branch 10 → 16 not taken.
✓ Branch 12 → 13 taken 36 times.
✓ Branch 12 → 15 taken 34 times.
✓ Branch 17 → 8 taken 35 times.
✓ Branch 17 → 18 taken 35 times.
176 while (pos < data.size() &&
896
2/2
✓ Branch 14 → 15 taken 1 time.
✓ Branch 14 → 16 taken 35 times.
106 (data.at(pos) == ' ' || data.at(pos) == '\t'))
897 35 ++pos;
898
899 35 qint64 uid = 0;
900 35 const qsizetype uidStart = pos;
901
7/8
✓ Branch 25 → 26 taken 114 times.
✗ Branch 25 → 31 not taken.
✓ Branch 27 → 28 taken 82 times.
✓ Branch 27 → 31 taken 32 times.
✓ Branch 29 → 30 taken 81 times.
✓ Branch 29 → 31 taken 1 time.
✓ Branch 32 → 19 taken 81 times.
✓ Branch 32 → 33 taken 33 times.
114 while (pos < data.size() && data.at(pos) >= '0' && data.at(pos) <= '9') {
902 81 const int digit = data.at(pos) - '0';
903
2/2
✓ Branch 21 → 22 taken 2 times.
✓ Branch 21 → 23 taken 79 times.
81 if (uid > (std::numeric_limits<qint64>::max() - digit) / 10)
904 2 return std::nullopt;
905 79 uid = uid * 10 + digit;
906 79 ++pos;
907 }
908
4/4
✓ Branch 33 → 34 taken 32 times.
✓ Branch 33 → 35 taken 1 time.
✓ Branch 34 → 35 taken 1 time.
✓ Branch 34 → 36 taken 31 times.
33 if (pos == uidStart || uid <= 0)
909 2 return std::nullopt;
910
911 31 const qsizetype bodyItem = topLevelItem(data, "BODY[]", " \t<\"{");
912
2/2
✓ Branch 37 → 38 taken 3 times.
✓ Branch 37 → 39 taken 28 times.
31 if (bodyItem < 0)
913 3 return std::nullopt;
914 28 pos = bodyItem;
915
916 // A partial fetch response may echo BODY[]<0>. The requested offset must be
917 // zero; non-zero origins cannot represent the complete raw message.
918
5/6
✓ Branch 40 → 41 taken 28 times.
✗ Branch 40 → 44 not taken.
✓ Branch 42 → 43 taken 7 times.
✓ Branch 42 → 44 taken 21 times.
✓ Branch 45 → 46 taken 7 times.
✓ Branch 45 → 78 taken 21 times.
28 if (pos < data.size() && data.at(pos) == '<') {
919 7 ++pos;
920 7 const qsizetype offsetStart = pos;
921
7/8
✓ Branch 49 → 50 taken 13 times.
✗ Branch 49 → 55 not taken.
✓ Branch 51 → 52 taken 11 times.
✓ Branch 51 → 55 taken 2 times.
✓ Branch 53 → 54 taken 6 times.
✓ Branch 53 → 55 taken 5 times.
✓ Branch 56 → 47 taken 6 times.
✓ Branch 56 → 57 taken 7 times.
13 while (pos < data.size() && data.at(pos) >= '0' && data.at(pos) <= '9')
922 6 ++pos;
923
5/6
✓ Branch 57 → 58 taken 5 times.
✓ Branch 57 → 67 taken 2 times.
✓ Branch 59 → 60 taken 5 times.
✗ Branch 59 → 67 not taken.
✓ Branch 61 → 62 taken 4 times.
✓ Branch 61 → 67 taken 1 time.
11 if (pos == offsetStart || pos >= data.size() || data.at(pos) != '>' ||
924
11/17
✓ Branch 64 → 65 taken 4 times.
✓ Branch 66 → 67 taken 2 times.
✓ Branch 66 → 68 taken 2 times.
✓ Branch 69 → 70 taken 4 times.
✓ Branch 69 → 71 taken 3 times.
✓ Branch 71 → 72 taken 4 times.
✓ Branch 71 → 73 taken 3 times.
✓ Branch 73 → 74 taken 4 times.
✓ Branch 73 → 75 taken 3 times.
✓ Branch 75 → 76 taken 5 times.
✓ Branch 75 → 77 taken 2 times.
✗ Branch 127 → 128 not taken.
✗ Branch 127 → 129 not taken.
✗ Branch 131 → 132 not taken.
✗ Branch 131 → 133 not taken.
✗ Branch 135 → 136 not taken.
✗ Branch 135 → 137 not taken.
11 data.mid(offsetStart, pos - offsetStart) != QByteArrayLiteral("0")) {
925 5 return std::nullopt;
926 }
927 2 ++pos;
928 }
929
930
5/6
✓ Branch 81 → 82 taken 46 times.
✗ Branch 81 → 87 not taken.
✓ Branch 83 → 84 taken 24 times.
✓ Branch 83 → 86 taken 22 times.
✓ Branch 88 → 79 taken 23 times.
✓ Branch 88 → 89 taken 23 times.
116 while (pos < data.size() &&
931
2/2
✓ Branch 85 → 86 taken 1 time.
✓ Branch 85 → 87 taken 23 times.
70 (data.at(pos) == ' ' || data.at(pos) == '\t'))
932 23 ++pos;
933
5/6
✓ Branch 90 → 91 taken 23 times.
✗ Branch 90 → 93 not taken.
✓ Branch 92 → 93 taken 3 times.
✓ Branch 92 → 94 taken 20 times.
✓ Branch 95 → 96 taken 3 times.
✓ Branch 95 → 97 taken 20 times.
23 if (pos >= data.size() || data.at(pos) != '"')
934 3 return std::nullopt;
935 20 ++pos;
936
937 20 QByteArray body;
938
1/1
✓ Branch 99 → 100 taken 20 times.
20 body.reserve(static_cast<qsizetype>(
939 20 qMin<qint64>(maxBodyBytes, data.size() - pos)));
940 20 bool closed = false;
941
2/2
✓ Branch 117 → 101 taken 150 times.
✓ Branch 117 → 118 taken 3 times.
153 while (pos < data.size()) {
942 150 uchar byte = static_cast<uchar>(data.at(pos++));
943
2/2
✓ Branch 102 → 103 taken 13 times.
✓ Branch 102 → 104 taken 137 times.
150 if (byte == '"') {
944 13 closed = true;
945 13 break;
946 }
947
2/2
✓ Branch 104 → 105 taken 9 times.
✓ Branch 104 → 112 taken 128 times.
137 if (byte == '\\') {
948
1/2
✗ Branch 106 → 107 not taken.
✓ Branch 106 → 108 taken 9 times.
9 if (pos >= data.size())
949 ✗ return std::nullopt;
950 9 byte = static_cast<uchar>(data.at(pos++));
951
4/4
✓ Branch 109 → 110 taken 6 times.
✓ Branch 109 → 112 taken 3 times.
✓ Branch 110 → 111 taken 2 times.
✓ Branch 110 → 112 taken 4 times.
9 if (byte != '"' && byte != '\\')
952 2 return std::nullopt;
953 }
954
2/2
✓ Branch 113 → 114 taken 2 times.
✓ Branch 113 → 115 taken 133 times.
135 if (body.size() >= maxBodyBytes)
955 2 return std::nullopt;
956
1/1
✓ Branch 115 → 116 taken 133 times.
133 body.append(static_cast<char>(byte));
957 }
958
959
2/2
✓ Branch 118 → 119 taken 3 times.
✓ Branch 118 → 120 taken 13 times.
16 if (!closed)
960 3 return std::nullopt;
961 13 return QPair<qint64, QByteArray>{uid, body};
962 20 }
963
964 200777 bool ImapResponseParser::endsWithBodyFetchItem(const QString &line) {
965 // F-02: only the empty-section body item — BODY[] or BODY[]<partial> —
966 // carries the full raw message and qualifies as the reader's body
967 // literal/quoted body. Section literals (BODY[HEADER], BODY[1.TEXT], …)
968 // are ordinary folded literals. The token must sit outside quoted
969 // strings as the line's trailing item, so a "BODY[]" inside an ENVELOPE
970 // subject or display name — always quoted — can never match, and only a
971 // partial <a.b>, whitespace, closing parens or one quoted value may
972 // follow it.
973 200777 const int n = line.size();
974
2/2
✓ Branch 3 → 4 taken 100001 times.
✓ Branch 3 → 5 taken 100776 times.
200777 if (n < 6)
975 100001 return false;
976
977 // Last "BODY[]" outside quotes.
978 100776 int bracket = -1;
979 {
980 100776 const QChar body[4] = {QLatin1Char('B'), QLatin1Char('O'),
981 QLatin1Char('D'), QLatin1Char('Y')};
982 100776 bool inQuote = false;
983 100776 bool escaped = false;
984
2/2
✓ Branch 58 → 6 taken 828850 times.
✓ Branch 58 → 59 taken 100776 times.
929626 for (int i = 0; i + 6 <= n; ++i) {
985 828850 const QChar c = line.at(i);
986
2/2
✓ Branch 7 → 8 taken 8655 times.
✓ Branch 7 → 21 taken 820195 times.
828850 if (inQuote) {
987
2/2
✓ Branch 8 → 9 taken 2 times.
✓ Branch 8 → 10 taken 8653 times.
8655 if (escaped)
988 2 escaped = false;
989
2/2
✓ Branch 13 → 14 taken 2 times.
✓ Branch 13 → 15 taken 8651 times.
8653 else if (c == QLatin1Char('\\'))
990 2 escaped = true;
991
2/2
✓ Branch 18 → 19 taken 614 times.
✓ Branch 18 → 20 taken 8037 times.
8651 else if (c == QLatin1Char('"'))
992 614 inQuote = false;
993 9358 continue;
994 }
995
2/2
✓ Branch 24 → 25 taken 703 times.
✓ Branch 24 → 26 taken 819492 times.
820195 if (c == QLatin1Char('"')) {
996 703 inQuote = true;
997 703 continue;
998 }
999
4/4
✓ Branch 32 → 33 taken 56 times.
✓ Branch 32 → 52 taken 104 times.
✓ Branch 36 → 37 taken 54 times.
✓ Branch 36 → 52 taken 2 times.
819708 if (c.toUpper() == body[0] && line.at(i + 1).toUpper() == body[1] &&
1000
1/2
✓ Branch 40 → 41 taken 54 times.
✗ Branch 40 → 52 not taken.
110 line.at(i + 2).toUpper() == body[2] &&
1001
1/2
✓ Branch 45 → 46 taken 54 times.
✗ Branch 45 → 52 not taken.
108 line.at(i + 3).toUpper() == body[3] &&
1002
4/4
✓ Branch 28 → 29 taken 160 times.
✓ Branch 28 → 52 taken 819332 times.
✓ Branch 50 → 51 taken 51 times.
✓ Branch 50 → 52 taken 3 times.
819706 line.at(i + 4) == QLatin1Char('[') &&
1003
2/2
✓ Branch 53 → 54 taken 51 times.
✓ Branch 53 → 55 taken 819441 times.
819546 line.at(i + 5) == QLatin1Char(']')) {
1004 51 bracket = i + 5; // index of ']'
1005 51 i += 5;
1006 }
1007 }
1008 }
1009
2/2
✓ Branch 59 → 60 taken 100725 times.
✓ Branch 59 → 61 taken 51 times.
100776 if (bracket < 0)
1010 100725 return false;
1011
1012 51 int i = bracket + 1;
1013
1014 // Optional <partial> echo (BODY[]<0>).
1015
5/6
✓ Branch 61 → 62 taken 51 times.
✗ Branch 61 → 68 not taken.
✓ Branch 66 → 67 taken 8 times.
✓ Branch 66 → 68 taken 43 times.
✓ Branch 69 → 70 taken 8 times.
✓ Branch 69 → 76 taken 43 times.
51 if (i < n && line.at(i) == QLatin1Char('<')) {
1016 8 const int close = line.indexOf(QLatin1Char('>'), i);
1017
1/2
✗ Branch 73 → 74 not taken.
✓ Branch 73 → 75 taken 8 times.
8 if (close < 0)
1018 ✗ return false;
1019 8 i = close + 1;
1020 }
1021
1022 // Optional quoted value of the item (the quoted-body form).
1023 51 int j = i;
1024
6/6
✓ Branch 78 → 79 taken 56 times.
✓ Branch 78 → 83 taken 46 times.
✓ Branch 81 → 82 taken 51 times.
✓ Branch 81 → 83 taken 5 times.
✓ Branch 84 → 77 taken 51 times.
✓ Branch 84 → 85 taken 51 times.
102 while (j < n && line.at(j).isSpace())
1025 51 ++j;
1026
5/6
✓ Branch 85 → 86 taken 5 times.
✓ Branch 85 → 92 taken 46 times.
✓ Branch 90 → 91 taken 5 times.
✗ Branch 90 → 92 not taken.
✓ Branch 93 → 94 taken 5 times.
✓ Branch 93 → 130 taken 46 times.
51 if (j < n && line.at(j) == QLatin1Char('"')) {
1027 5 bool q = false;
1028 5 bool esc = false;
1029
1/2
✓ Branch 126 → 95 taken 60 times.
✗ Branch 126 → 127 not taken.
60 for (; j < n; ++j) {
1030 60 const QChar c = line.at(j);
1031
2/2
✓ Branch 96 → 97 taken 50 times.
✓ Branch 96 → 110 taken 10 times.
60 if (q) {
1032
2/2
✓ Branch 97 → 98 taken 2 times.
✓ Branch 97 → 99 taken 48 times.
50 if (esc)
1033 2 esc = false;
1034
2/2
✓ Branch 102 → 103 taken 2 times.
✓ Branch 102 → 104 taken 46 times.
48 else if (c == QLatin1Char('\\'))
1035 2 esc = true;
1036
2/2
✓ Branch 107 → 108 taken 5 times.
✓ Branch 107 → 109 taken 41 times.
46 else if (c == QLatin1Char('"'))
1037 5 q = false;
1038 55 continue;
1039 }
1040
2/2
✓ Branch 113 → 114 taken 5 times.
✓ Branch 113 → 115 taken 5 times.
10 if (c == QLatin1Char('"')) {
1041 5 q = true;
1042 5 continue;
1043 }
1044
1/2
✓ Branch 118 → 119 taken 5 times.
✗ Branch 118 → 120 not taken.
5 if (c == QLatin1Char(')'))
1045 5 break; // closing FETCH paren after the quoted value
1046 ✗ if (!c.isSpace())
1047 ✗ return false;
1048 }
1049
1/2
✗ Branch 127 → 128 not taken.
✓ Branch 127 → 129 taken 5 times.
5 if (q)
1050 ✗ return false; // unterminated value
1051 5 i = j;
1052 }
1053
1054 // Only whitespace and ')' may remain.
1055
2/2
✓ Branch 143 → 131 taken 51 times.
✓ Branch 143 → 144 taken 51 times.
102 for (; i < n; ++i) {
1056 51 const QChar c = line.at(i);
1057
4/6
✓ Branch 133 → 134 taken 5 times.
✓ Branch 133 → 139 taken 46 times.
✗ Branch 137 → 138 not taken.
✓ Branch 137 → 139 taken 5 times.
✗ Branch 140 → 141 not taken.
✓ Branch 140 → 142 taken 51 times.
51 if (!c.isSpace() && c != QLatin1Char(')'))
1058 ✗ return false;
1059 }
1060 51 return true;
1061 }
1062
1063 std::optional<int>
1064 7 ImapResponseParser::parseExistsResponse(const QString &data) {
1065 // Untagged data format: "N EXISTS" (the "* " prefix is already stripped)
1066 // After parseUntaggedResponse, type="N" and data="EXISTS"
1067 // But we also handle full "42 EXISTS" format
1068
5/8
✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 12 taken 5 times.
✓ Branch 4 → 5 taken 2 times.
✗ Branch 4 → 12 not taken.
✓ Branch 6 → 7 taken 2 times.
✓ Branch 7 → 8 taken 2 times.
✗ Branch 32 → 33 not taken.
✗ Branch 32 → 34 not taken.
7 static QRegularExpression rx(R"(^(\d+)\s+EXISTS)");
1069
1/1
✓ Branch 13 → 14 taken 7 times.
7 auto match = rx.match(data);
1070
3/3
✓ Branch 14 → 15 taken 7 times.
✓ Branch 15 → 16 taken 3 times.
✓ Branch 15 → 22 taken 4 times.
7 if (match.hasMatch()) {
1071
2/2
✓ Branch 16 → 17 taken 3 times.
✓ Branch 17 → 18 taken 3 times.
3 return match.captured(1).toInt();
1072 }
1073 4 return std::nullopt;
1074 7 }
1075
1076 std::optional<quint32>
1077 72 ImapResponseParser::parseUidValidity(const QString &data) {
1078 // Format: "OK [UIDVALIDITY 12345] ..." or just "[UIDVALIDITY 12345]"
1079
5/8
✓ Branch 2 → 3 taken 6 times.
✓ Branch 2 → 12 taken 66 times.
✓ Branch 4 → 5 taken 6 times.
✗ Branch 4 → 12 not taken.
✓ Branch 6 → 7 taken 6 times.
✓ Branch 7 → 8 taken 6 times.
✗ Branch 32 → 33 not taken.
✗ Branch 32 → 34 not taken.
72 static QRegularExpression rx(R"(\[UIDVALIDITY\s+(\d+)\])");
1080
1/1
✓ Branch 13 → 14 taken 72 times.
72 auto match = rx.match(data);
1081
3/3
✓ Branch 14 → 15 taken 72 times.
✓ Branch 15 → 16 taken 42 times.
✓ Branch 15 → 22 taken 30 times.
72 if (match.hasMatch()) {
1082
2/2
✓ Branch 16 → 17 taken 42 times.
✓ Branch 17 → 18 taken 42 times.
42 return match.captured(1).toUInt();
1083 }
1084 30 return std::nullopt;
1085 72 }
1086
1087 // Modified UTF-7 decoding (RFC 3501 Section 5.1.3)
1088 // In Modified UTF-7:
1089 // - '&' starts a shifted section (Base64-encoded UTF-16BE)
1090 // - '-' ends the shifted section
1091 // - '&-' represents a literal '&'
1092 // - All other printable ASCII is literal
1093 18168 QString ImapResponseParser::decodeMailboxName(const QString &encoded) {
1094 18168 QString result;
1095 18168 int i = 0;
1096
1097
2/2
✓ Branch 48 → 3 taken 164948 times.
✓ Branch 48 → 49 taken 17939 times.
182887 while (i < encoded.length()) {
1098
2/2
✓ Branch 6 → 7 taken 256 times.
✓ Branch 6 → 44 taken 164692 times.
164948 if (encoded[i] == '&') {
1099 // Find the closing '-'
1100 256 int end = encoded.indexOf('-', i + 1);
1101
2/2
✓ Branch 9 → 10 taken 229 times.
✓ Branch 9 → 14 taken 27 times.
256 if (end < 0) {
1102 // Malformed: no closing '-', just append the rest literally
1103
2/2
✓ Branch 10 → 11 taken 229 times.
✓ Branch 11 → 12 taken 229 times.
229 result.append(encoded.mid(i));
1104 229 break;
1105 }
1106
1107
2/2
✓ Branch 14 → 15 taken 7 times.
✓ Branch 14 → 18 taken 20 times.
27 if (end == i + 1) {
1108 // "&-" represents a literal '&'
1109
1/1
✓ Branch 16 → 17 taken 7 times.
7 result.append('&');
1110 } else {
1111 // Base64-encoded UTF-16BE between '&' and '-'
1112
1/1
✓ Branch 18 → 19 taken 20 times.
20 auto base64Str = encoded.mid(i + 1, end - i - 1);
1113 // Modified UTF-7 uses ',' instead of '/' in Base64
1114
1/1
✓ Branch 21 → 22 taken 20 times.
20 base64Str.replace(',', '/');
1115 // Add padding if needed
1116
2/2
✓ Branch 27 → 23 taken 19 times.
✓ Branch 27 → 28 taken 20 times.
39 while (base64Str.length() % 4 != 0) {
1117
1/1
✓ Branch 24 → 25 taken 19 times.
19 base64Str.append('=');
1118 }
1119
1120
2/2
✓ Branch 29 → 30 taken 20 times.
✓ Branch 30 → 31 taken 20 times.
20 QByteArray decoded = QByteArray::fromBase64(base64Str.toLatin1());
1121 // Decode UTF-16BE
1122
2/2
✓ Branch 39 → 33 taken 27 times.
✓ Branch 39 → 40 taken 20 times.
47 for (int j = 0; j + 1 < decoded.size(); j += 2) {
1123
1/1
✓ Branch 33 → 34 taken 27 times.
27 ushort ch = (static_cast<uchar>(decoded[j]) << 8) |
1124
1/1
✓ Branch 34 → 35 taken 27 times.
27 static_cast<uchar>(decoded[j + 1]);
1125
1/1
✓ Branch 36 → 37 taken 27 times.
27 result.append(QChar(ch));
1126 }
1127 20 }
1128 27 i = end + 1;
1129 } else {
1130
1/1
✓ Branch 45 → 46 taken 164692 times.
164692 result.append(encoded[i]);
1131 164692 i++;
1132 }
1133 }
1134
1135 18168 return result;
1136 ✗ }
1137
1138 // A mailbox-name argument: the path in its wire form (modified UTF-7, RFC
1139 // 3501 §5.1.3 — the form LIST returns and the cache and UI keep) inside an
1140 // escaped quoted string. S106-968: the path is NOT encoded again — F-03
1141 // encoded every path, so a listed "Entw&APw-rfe" went out as
1142 // "Entw&-APw-rfe". Names typed by the user are encoded once where they
1143 // become a path (encodeMailboxName, QuickFolderActions).
1144 170 QString ImapResponseParser::quoteMailboxName(const QString &path) {
1145 170 QString escaped = path;
1146
1/1
✓ Branch 4 → 5 taken 170 times.
170 escaped.remove('\r');
1147
1/1
✓ Branch 6 → 7 taken 170 times.
170 escaped.remove('\n');
1148
1/1
✓ Branch 8 → 9 taken 170 times.
170 escaped.remove(QChar(0));
1149
2/2
✓ Branch 9 → 10 taken 170 times.
✓ Branch 11 → 12 taken 170 times.
170 escaped.replace('\\', "\\\\");
1150
2/2
✓ Branch 13 → 14 taken 170 times.
✓ Branch 15 → 16 taken 170 times.
170 escaped.replace('"', "\\\"");
1151
2/2
✓ Branch 19 → 20 taken 170 times.
✓ Branch 20 → 21 taken 170 times.
510 return '"' + escaped + '"';
1152 170 }
1153
1154 26 QString ImapResponseParser::encodeMailboxName(const QString &decoded) {
1155 26 QString result;
1156
1157 26 int i = 0;
1158
2/2
✓ Branch 55 → 3 taken 142 times.
✓ Branch 55 → 56 taken 26 times.
168 while (i < decoded.length()) {
1159 142 QChar ch = decoded[i];
1160
1161
2/2
✓ Branch 6 → 7 taken 5 times.
✓ Branch 6 → 9 taken 137 times.
142 if (ch == '&') {
1162
1/1
✓ Branch 7 → 8 taken 5 times.
5 result.append("&-");
1163 5 i++;
1164
6/6
✓ Branch 10 → 11 taken 135 times.
✓ Branch 10 → 14 taken 2 times.
✓ Branch 12 → 13 taken 121 times.
✓ Branch 12 → 14 taken 14 times.
✓ Branch 15 → 16 taken 121 times.
✓ Branch 15 → 18 taken 16 times.
137 } else if (ch.unicode() >= 0x20 && ch.unicode() <= 0x7E) {
1165 // Printable ASCII – literal
1166
1/1
✓ Branch 16 → 17 taken 121 times.
121 result.append(ch);
1167 121 i++;
1168 } else {
1169 // Non-ASCII: collect consecutive non-ASCII chars and encode as UTF-16BE
1170 16 QByteArray utf16be;
1171
2/2
✓ Branch 37 → 19 taken 33 times.
✓ Branch 37 → 38 taken 7 times.
40 while (i < decoded.length()) {
1172 33 QChar c = decoded[i];
1173
8/8
✓ Branch 22 → 23 taken 32 times.
✓ Branch 22 → 27 taken 1 time.
✓ Branch 24 → 25 taken 30 times.
✓ Branch 24 → 28 taken 2 times.
✓ Branch 26 → 27 taken 8 times.
✓ Branch 26 → 28 taken 22 times.
✓ Branch 29 → 30 taken 9 times.
✓ Branch 29 → 31 taken 24 times.
33 if (c == '&' || (c.unicode() >= 0x20 && c.unicode() <= 0x7E)) {
1174 9 break;
1175 }
1176
1/1
✓ Branch 32 → 33 taken 24 times.
24 utf16be.append(static_cast<char>(c.unicode() >> 8));
1177
1/1
✓ Branch 34 → 35 taken 24 times.
24 utf16be.append(static_cast<char>(c.unicode() & 0xFF));
1178 24 i++;
1179 }
1180
1181 auto base64 = utf16be.toBase64(QByteArray::Base64Encoding |
1182
1/1
✓ Branch 39 → 40 taken 16 times.
16 QByteArray::OmitTrailingEquals);
1183
1/1
✓ Branch 40 → 41 taken 16 times.
16 QString b64str = QString::fromLatin1(base64);
1184
1/1
✓ Branch 43 → 44 taken 16 times.
16 b64str.replace('/', ',');
1185
1/1
✓ Branch 45 → 46 taken 16 times.
16 result.append('&');
1186
1/1
✓ Branch 46 → 47 taken 16 times.
16 result.append(b64str);
1187
1/1
✓ Branch 48 → 49 taken 16 times.
16 result.append('-');
1188 16 }
1189 }
1190
1191 26 return result;
1192 ✗ }
1193
1194 std::optional<QPair<qint64, quint32>>
1195 10 ImapResponseParser::parseFetchFlagsResponse(const QString &data) {
1196
1/1
✓ Branch 2 → 3 taken 10 times.
10 const auto snapshot = parseFetchFlagSnapshot(data);
1197
2/2
✓ Branch 4 → 5 taken 5 times.
✓ Branch 4 → 6 taken 5 times.
10 if (!snapshot)
1198 5 return std::nullopt;
1199 5 return QPair<qint64, quint32>{snapshot->uid, snapshot->flags};
1200 10 }
1201
1202 std::optional<ImapFlagSnapshot>
1203 36 ImapResponseParser::parseFetchFlagSnapshot(const QString &data) {
1204 // Expected input (after "* N FETCH "): "(UID 123 FLAGS (\Seen \Flagged))"
1205 // or "(FLAGS (\Seen) UID 123)" – order may vary.
1206
1207 // Extract UID — S106-021: anchored on the top-level item (not inside
1208 // X-UID or MYFLAGS) and refused when it overflows instead of becoming 0.
1209 36 const qint64 uid = numberAfter(data, topLevelItem(data, "UID", " "));
1210
2/2
✓ Branch 4 → 5 taken 9 times.
✓ Branch 4 → 19 taken 27 times.
36 if (uid <= 0) {
1211 // Unsolicited FETCH without UID is valid IMAP (another client changed
1212 // flags); callers resolve it through its sequence number. One per mail.
1213
4/4
✓ Branch 5 → 6 taken 9 times.
✓ Branch 9 → 10 taken 9 times.
✓ Branch 17 → 7 taken 9 times.
✓ Branch 17 → 18 taken 9 times.
18 qCDebug(lcImapParser)
1214
3/3
✓ Branch 10 → 11 taken 9 times.
✓ Branch 11 → 12 taken 9 times.
✓ Branch 12 → 13 taken 9 times.
9 << "No UID in FETCH FLAGS response:" << data.left(100);
1215 9 return std::nullopt;
1216 }
1217
1218 // Extract FLAGS
1219 27 bool hasFlags = false;
1220
1/1
✓ Branch 20 → 21 taken 27 times.
27 const QString flagText = flagListAfter(data, topLevelItem(data, "FLAGS"), &hasFlags);
1221
2/2
✓ Branch 21 → 22 taken 2 times.
✓ Branch 21 → 36 taken 25 times.
27 if (!hasFlags) {
1222
4/4
✓ Branch 22 → 23 taken 2 times.
✓ Branch 26 → 27 taken 2 times.
✓ Branch 34 → 24 taken 2 times.
✓ Branch 34 → 35 taken 2 times.
4 qCWarning(lcImapParser)
1223
3/3
✓ Branch 27 → 28 taken 2 times.
✓ Branch 28 → 29 taken 2 times.
✓ Branch 29 → 30 taken 2 times.
2 << "No FLAGS in FETCH FLAGS response:" << data.left(100);
1224 2 return std::nullopt;
1225 }
1226
1/1
✓ Branch 38 → 39 taken 25 times.
25 auto flagList = flagText.split(' ', Qt::SkipEmptyParts);
1227
1/1
✓ Branch 39 → 40 taken 25 times.
25 const auto [flags, keywords] = flagsAndKeywords(flagList);
1228 25 return ImapFlagSnapshot{uid, flags, keywords};
1229 27 }
1230
1231 9 std::optional<QList<qint64>> ImapResponseParser::expandUidSet(const QString &set,
1232 qint64 maxItems) {
1233 9 QList<qint64> uids;
1234
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 8 times.
9 if (set.isEmpty()) return uids;
1235
5/5
✓ Branch 8 → 9 taken 8 times.
✓ Branch 9 → 10 taken 8 times.
✓ Branch 10 → 11 taken 8 times.
✓ Branch 57 → 12 taken 11 times.
✓ Branch 57 → 58 taken 3 times.
14 for (const auto &part : set.split(QLatin1Char(','))) {
1236
1/1
✓ Branch 16 → 17 taken 11 times.
11 const auto bounds = part.split(QLatin1Char(':'));
1237
2/2
✓ Branch 18 → 19 taken 1 time.
✓ Branch 18 → 21 taken 10 times.
11 if (bounds.size() > 2) return std::nullopt;
1238 10 bool okFirst = false, okLast = false;
1239
1/1
✓ Branch 22 → 23 taken 10 times.
10 const qint64 first = bounds.first().toLongLong(&okFirst);
1240
3/3
✓ Branch 24 → 25 taken 8 times.
✓ Branch 24 → 28 taken 2 times.
✓ Branch 26 → 27 taken 8 times.
10 const qint64 last = bounds.size() == 2 ? bounds.last().toLongLong(&okLast) : first;
1241
10/12
✓ Branch 29 → 30 taken 10 times.
✗ Branch 29 → 35 not taken.
✓ Branch 31 → 32 taken 8 times.
✓ Branch 31 → 33 taken 2 times.
✓ Branch 32 → 33 taken 6 times.
✓ Branch 32 → 35 taken 2 times.
✓ Branch 33 → 34 taken 7 times.
✓ Branch 33 → 35 taken 1 time.
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 36 taken 7 times.
✓ Branch 37 → 38 taken 3 times.
✓ Branch 37 → 40 taken 7 times.
10 if (!okFirst || (bounds.size() == 2 && !okLast) || first <= 0 || last <= 0)
1242 3 return std::nullopt;
1243 7 const qint64 low = qMin(first, last), high = qMax(first, last);
1244
2/2
✓ Branch 43 → 44 taken 1 time.
✓ Branch 43 → 46 taken 6 times.
7 if (high - low + 1 > maxItems - uids.size()) return std::nullopt;
1245
3/3
✓ Branch 47 → 48 taken 17 times.
✓ Branch 49 → 47 taken 17 times.
✓ Branch 49 → 50 taken 6 times.
23 for (qint64 uid = low; uid <= high; ++uid) uids.append(uid);
1246 19 }
1247 3 return uids;
1248 9 }
1249
1250 std::optional<StatusResult>
1251 34 ImapResponseParser::parseStatusResponse(const QString &data) {
1252 // Expected input (after "* STATUS "): '"INBOX" (MESSAGES 42 UNSEEN 3 RECENT
1253 // 0)' or: 'INBOX (MESSAGES 42 UNSEEN 3 RECENT 0)'
1254
1255 34 StatusResult result;
1256 34 result.hasMessages = false;
1257 34 result.hasUnseen = false;
1258
1259 // Extract folder name (quoted or unquoted). F-08: a quoted name may
1260 // itself contain '(' ("Projekte (alt)") — the status parens start after
1261 // the closing quote, not at the first parenthesis.
1262 34 int parenIdx = -1;
1263 34 QString folderPart;
1264
1/1
✓ Branch 2 → 3 taken 34 times.
34 const QString trimmed = data.trimmed();
1265
3/3
✓ Branch 4 → 5 taken 34 times.
✓ Branch 5 → 6 taken 29 times.
✓ Branch 5 → 27 taken 5 times.
34 if (trimmed.startsWith('"')) {
1266 29 bool escaped = false;
1267
2/2
✓ Branch 26 → 7 taken 193 times.
✓ Branch 26 → 38 taken 1 time.
194 for (int i = 1; i < trimmed.size(); ++i) {
1268 193 const QChar c = trimmed.at(i);
1269
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 11 taken 193 times.
193 if (escaped) {
1270 ✗ folderPart.append(c);
1271 ✗ escaped = false;
1272
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 193 times.
193 } else if (c == QLatin1Char('\\')) {
1273 ✗ escaped = true;
1274
2/2
✓ Branch 19 → 20 taken 28 times.
✓ Branch 19 → 23 taken 165 times.
193 } else if (c == QLatin1Char('"')) {
1275 28 parenIdx = trimmed.indexOf('(', i);
1276 28 break;
1277 } else {
1278
1/1
✓ Branch 23 → 24 taken 165 times.
165 folderPart.append(c);
1279 }
1280 }
1281 } else {
1282 5 parenIdx = trimmed.indexOf('(');
1283
4/4
✓ Branch 29 → 30 taken 3 times.
✓ Branch 29 → 31 taken 2 times.
✓ Branch 32 → 33 taken 5 times.
✓ Branch 33 → 34 taken 5 times.
5 folderPart = trimmed.left(parenIdx < 0 ? trimmed.size() : parenIdx).trimmed();
1284 }
1285
2/2
✓ Branch 38 → 39 taken 4 times.
✓ Branch 38 → 51 taken 30 times.
34 if (parenIdx < 0) {
1286
4/4
✓ Branch 39 → 40 taken 4 times.
✓ Branch 43 → 44 taken 4 times.
✓ Branch 49 → 41 taken 4 times.
✓ Branch 49 → 50 taken 4 times.
8 qCWarning(lcImapParser)
1287
2/2
✓ Branch 44 → 45 taken 4 times.
✓ Branch 45 → 46 taken 4 times.
4 << "No parenthesized data in STATUS response:" << data;
1288 4 return std::nullopt;
1289 }
1290
1291 30 result.folderPath = folderPart;
1292
1293 // Extract the parenthesized status values
1294
1/1
✓ Branch 52 → 53 taken 30 times.
30 QString statusPart = trimmed.mid(parenIdx);
1295 // Remove outer parens
1296
1/1
✓ Branch 54 → 55 taken 30 times.
30 statusPart.remove('(');
1297
1/1
✓ Branch 56 → 57 taken 30 times.
30 statusPart.remove(')');
1298
1/1
✓ Branch 57 → 58 taken 30 times.
30 statusPart = statusPart.trimmed();
1299
1300 // Parse key-value pairs: "MESSAGES 42 UNSEEN 3 RECENT 0"
1301
1/1
✓ Branch 62 → 63 taken 30 times.
30 auto tokens = statusPart.split(' ', Qt::SkipEmptyParts);
1302
2/2
✓ Branch 88 → 64 taken 59 times.
✓ Branch 88 → 89 taken 30 times.
89 for (int i = 0; i + 1 < tokens.size(); i += 2) {
1303
1/1
✓ Branch 64 → 65 taken 59 times.
59 const auto &key = tokens[i];
1304 59 bool ok = false;
1305
2/2
✓ Branch 65 → 66 taken 59 times.
✓ Branch 66 → 67 taken 59 times.
59 int value = tokens[i + 1].toInt(&ok);
1306
4/4
✓ Branch 67 → 68 taken 58 times.
✓ Branch 67 → 69 taken 1 time.
✓ Branch 68 → 69 taken 2 times.
✓ Branch 68 → 70 taken 56 times.
59 if (!ok || value < 0)
1307 3 continue;
1308
1309
3/3
✓ Branch 70 → 71 taken 56 times.
✓ Branch 73 → 74 taken 24 times.
✓ Branch 73 → 75 taken 32 times.
56 if (key.compare("MESSAGES", Qt::CaseInsensitive) == 0) {
1310 24 result.messages = value;
1311 24 result.hasMessages = true;
1312
3/3
✓ Branch 75 → 76 taken 32 times.
✓ Branch 78 → 79 taken 21 times.
✓ Branch 78 → 80 taken 11 times.
32 } else if (key.compare("UNSEEN", Qt::CaseInsensitive) == 0) {
1313 21 result.unseen = value;
1314 21 result.hasUnseen = true;
1315
3/3
✓ Branch 80 → 81 taken 11 times.
✓ Branch 83 → 84 taken 5 times.
✓ Branch 83 → 85 taken 6 times.
11 } else if (key.compare("RECENT", Qt::CaseInsensitive) == 0) {
1316 5 result.recent = value;
1317 }
1318 }
1319
1320 30 return result;
1321 34 }
1322
1323 8252 QString ImapResponseParser::decodeRfc2047(const QString &input) {
1324
4/4
✓ Branch 2 → 3 taken 8252 times.
✓ Branch 3 → 4 taken 8252 times.
✓ Branch 5 → 6 taken 8191 times.
✓ Branch 5 → 7 taken 61 times.
8252 if (!input.contains("=?"))
1325 8191 return input;
1326
1327 // RFC 2047 pattern: =?charset?encoding?encoded_text?=
1328 static QRegularExpression rx(R"(=\?([^?]+)\?([BbQq])\?([^?]*)\?=)",
1329
5/8
✓ Branch 7 → 8 taken 7 times.
✓ Branch 7 → 17 taken 54 times.
✓ Branch 9 → 10 taken 7 times.
✗ Branch 9 → 17 not taken.
✓ Branch 11 → 12 taken 7 times.
✓ Branch 12 → 13 taken 7 times.
✗ Branch 175 → 176 not taken.
✗ Branch 175 → 177 not taken.
61 QRegularExpression::CaseInsensitiveOption);
1330
1331 61 QString result;
1332 61 int lastEnd = 0;
1333 61 bool lastWasEncoded = false;
1334
1335
1/1
✓ Branch 18 → 19 taken 61 times.
61 auto it = rx.globalMatch(input);
1336
3/3
✓ Branch 129 → 130 taken 125 times.
✓ Branch 130 → 20 taken 64 times.
✓ Branch 130 → 131 taken 61 times.
125 while (it.hasNext()) {
1337
1/1
✓ Branch 20 → 21 taken 64 times.
64 auto match = it.next();
1338
1/1
✓ Branch 21 → 22 taken 64 times.
64 int matchStart = match.capturedStart();
1339
1340 // Text between encoded words
1341
1/1
✓ Branch 22 → 23 taken 64 times.
64 QString between = input.mid(lastEnd, matchStart - lastEnd);
1342
1343 // RFC 2047 §6.2: whitespace between adjacent encoded words is ignored
1344
9/11
✓ Branch 23 → 24 taken 4 times.
✓ Branch 23 → 28 taken 60 times.
✓ Branch 24 → 25 taken 4 times.
✓ Branch 26 → 27 taken 3 times.
✓ Branch 26 → 28 taken 1 time.
✓ Branch 29 → 30 taken 4 times.
✓ Branch 29 → 31 taken 60 times.
✓ Branch 31 → 32 taken 61 times.
✓ Branch 31 → 33 taken 3 times.
✗ Branch 179 → 180 not taken.
✗ Branch 179 → 181 not taken.
64 if (lastWasEncoded && between.trimmed().isEmpty()) {
1345 // Skip whitespace between consecutive encoded words
1346 } else {
1347
1/1
✓ Branch 32 → 33 taken 61 times.
61 result.append(between);
1348 }
1349
1350
2/2
✓ Branch 33 → 34 taken 64 times.
✓ Branch 34 → 35 taken 64 times.
64 QString charset = match.captured(1).toLower();
1351
2/2
✓ Branch 36 → 37 taken 64 times.
✓ Branch 37 → 38 taken 64 times.
64 QChar encoding = match.captured(2).toUpper().at(0);
1352
1/1
✓ Branch 41 → 42 taken 64 times.
64 QString encodedText = match.captured(3);
1353
1354 // T-272: WHATWG Encoding Standard mapping — ISO-8859-1 → Windows-1252.
1355 // Senders routinely mislabel CP1252 as ISO-8859-1. Bytes 0x80-0x9F are
1356 // C1 control chars in ISO-8859-1 (rendering as rectangles) but printable
1357 // glyphs (smart quotes, dashes, etc.) in Windows-1252.
1358
2/2
✓ Branch 47 → 48 taken 56 times.
✓ Branch 47 → 51 taken 3 times.
123 if (charset == QLatin1String("iso-8859-1") ||
1359
4/4
✓ Branch 44 → 45 taken 59 times.
✓ Branch 44 → 51 taken 5 times.
✓ Branch 50 → 51 taken 2 times.
✓ Branch 50 → 52 taken 54 times.
123 charset == QLatin1String("latin1") ||
1360
2/2
✓ Branch 53 → 54 taken 10 times.
✓ Branch 53 → 61 taken 54 times.
120 charset == QLatin1String("latin-1")) {
1361 10 charset = QStringLiteral("windows-1252");
1362 }
1363
1364 64 QByteArray decoded;
1365
2/2
✓ Branch 63 → 64 taken 26 times.
✓ Branch 63 → 71 taken 38 times.
64 if (encoding == 'B') {
1366 // Base64
1367
2/2
✓ Branch 65 → 66 taken 26 times.
✓ Branch 66 → 67 taken 26 times.
26 decoded = QByteArray::fromBase64(encodedText.toLatin1());
1368
1/2
✓ Branch 73 → 74 taken 38 times.
✗ Branch 73 → 102 not taken.
38 } else if (encoding == 'Q') {
1369 // Quoted-Printable (RFC 2047 variant: _ = space, =XX = hex)
1370
2/2
✓ Branch 101 → 75 taken 322 times.
✓ Branch 101 → 102 taken 38 times.
360 for (int i = 0; i < encodedText.length(); i++) {
1371
1/1
✓ Branch 75 → 76 taken 322 times.
322 QChar c = encodedText[i];
1372
2/2
✓ Branch 78 → 79 taken 26 times.
✓ Branch 78 → 80 taken 296 times.
322 if (c == '_') {
1373
1/1
✓ Branch 79 → 99 taken 26 times.
26 decoded.append(' ');
1374
6/6
✓ Branch 82 → 83 taken 55 times.
✓ Branch 82 → 86 taken 241 times.
✓ Branch 84 → 85 taken 53 times.
✓ Branch 84 → 86 taken 2 times.
✓ Branch 87 → 88 taken 53 times.
✓ Branch 87 → 97 taken 243 times.
296 } else if (c == '=' && i + 2 < encodedText.length()) {
1375 bool ok;
1376
2/2
✓ Branch 88 → 89 taken 53 times.
✓ Branch 89 → 90 taken 53 times.
53 int byte = encodedText.mid(i + 1, 2).toInt(&ok, 16);
1377
2/2
✓ Branch 91 → 92 taken 51 times.
✓ Branch 91 → 94 taken 2 times.
53 if (ok) {
1378
1/1
✓ Branch 92 → 93 taken 51 times.
51 decoded.append(static_cast<char>(byte));
1379 51 i += 2;
1380 } else {
1381
1/1
✓ Branch 95 → 96 taken 2 times.
2 decoded.append(c.toLatin1());
1382 }
1383 } else {
1384
1/1
✓ Branch 98 → 99 taken 243 times.
243 decoded.append(c.toLatin1());
1385 }
1386 }
1387 }
1388
1389 // RFC 2047 §5: CR and LF are forbidden inside encoded text and MUST be
1390 // removed. Some senders (notably Google Play) smuggle =0D=0A into the
1391 // Q-encoded payload, which otherwise surfaces as a stray leading blank
1392 // line in subjects and sender names. Replace the bytes with a space so
1393 // adjacent words cannot merge; runs of whitespace are collapsed later.
1394
1/1
✓ Branch 102 → 103 taken 64 times.
64 decoded.replace('\r', ' ');
1395
1/1
✓ Branch 103 → 104 taken 64 times.
64 decoded.replace('\n', ' ');
1396
1397 // Convert from charset to QString
1398
2/2
✓ Branch 105 → 106 taken 64 times.
✓ Branch 108 → 109 taken 64 times.
64 auto toUtf16 = QStringDecoder(charset.toLatin1().constData());
1399
2/2
✓ Branch 111 → 112 taken 62 times.
✓ Branch 111 → 117 taken 2 times.
64 if (toUtf16.isValid()) {
1400
2/2
✓ Branch 113 → 114 taken 62 times.
✓ Branch 114 → 115 taken 62 times.
62 result.append(toUtf16(decoded));
1401 } else {
1402 // Fallback: assume UTF-8
1403
2/2
✓ Branch 117 → 118 taken 2 times.
✓ Branch 118 → 119 taken 2 times.
2 result.append(QString::fromUtf8(decoded));
1404 }
1405
1406
1/1
✓ Branch 121 → 122 taken 64 times.
64 lastEnd = match.capturedEnd();
1407 64 lastWasEncoded = true;
1408 64 }
1409
1410 // Append any trailing non-encoded text
1411
2/2
✓ Branch 132 → 133 taken 4 times.
✓ Branch 132 → 137 taken 57 times.
61 if (lastEnd < input.length()) {
1412
2/2
✓ Branch 133 → 134 taken 4 times.
✓ Branch 134 → 135 taken 4 times.
4 result.append(input.mid(lastEnd));
1413 }
1414
1415 // Robustness: collapse any residual CR/LF that leaked through unencoded
1416 // portions (replace, do not drop, to keep adjacent words separated), squash
1417 // runs of internal whitespace to a single space, and trim the result.
1418 // Header values such as Subject / display name / filename should never
1419 // carry wrapping whitespace.
1420
4/7
✓ Branch 137 → 138 taken 7 times.
✓ Branch 137 → 150 taken 54 times.
✓ Branch 139 → 140 taken 7 times.
✗ Branch 139 → 150 not taken.
✓ Branch 144 → 145 taken 7 times.
✗ Branch 236 → 237 not taken.
✗ Branch 236 → 238 not taken.
68 static const QRegularExpression kWhitespaceRun(QStringLiteral("\\s+"));
1421
1/1
✓ Branch 152 → 153 taken 61 times.
61 result.replace('\r', ' ');
1422
1/1
✓ Branch 155 → 156 taken 61 times.
61 result.replace('\n', ' ');
1423
1/1
✓ Branch 159 → 160 taken 61 times.
61 result.replace(kWhitespaceRun, QStringLiteral(" "));
1424
1/1
✓ Branch 162 → 163 taken 61 times.
61 return result.trimmed();
1425 61 }
1426