Branch data Line data Source code
1 : : #include "ImapResponseParser.h"
2 : :
3 : : #include <QLoggingCategory>
4 : : #include <QRegularExpression>
5 : : #include <QStringDecoder>
6 : :
7 [ + + + - : 41 : Q_LOGGING_CATEGORY(lcImapParser, "mailjd.imap.parser")
+ - - - ]
8 : :
9 : 659 : bool ImapResponseParser::isTagged(const QString &line) {
10 : : // Tagged responses start with a tag like "A001 "
11 [ + + + - : 659 : static QRegularExpression rx(R"(^[A-Za-z]\d+\s)");
+ - + - -
- ]
12 [ + - + - ]: 659 : return rx.match(line).hasMatch();
13 : : }
14 : :
15 : 102211 : bool ImapResponseParser::isUntagged(const QString &line) {
16 [ + - + - ]: 102211 : return line.startsWith("* ");
17 : : }
18 : :
19 : 113 : bool ImapResponseParser::isContinuation(const QString &line) {
20 [ + - + - ]: 113 : return line.startsWith("+");
21 : : }
22 : :
23 : : std::optional<TaggedResponse>
24 : 634 : ImapResponseParser::parseTaggedResponse(const QString &line) {
25 : : // Format: "A001 OK message text" or "A001 NO [CODE] message"
26 [ + + + - : 634 : static QRegularExpression rx(R"(^([A-Za-z]\d+)\s+(OK|NO|BAD)\s*(.*)?$)");
+ - + - -
- ]
27 [ + - ]: 634 : auto match = rx.match(line);
28 [ + - + + ]: 634 : if (!match.hasMatch()) {
29 : 7 : return std::nullopt;
30 : : }
31 : :
32 [ + - + - : 1881 : return TaggedResponse{
+ - - - -
- ]
33 : : match.captured(1), // tag
34 : : match.captured(2), // status
35 [ + - ]: 1254 : match.captured(3).trimmed() // message
36 : 627 : };
37 : 634 : }
38 : :
39 : : std::optional<UntaggedResponse>
40 : 101566 : ImapResponseParser::parseUntaggedResponse(const QString &line) {
41 [ + - + - : 101566 : if (!line.startsWith("* ")) {
+ + ]
42 : 4 : return std::nullopt;
43 : : }
44 : :
45 : : // After "* ", the next token is the type
46 : 101562 : const auto rest = QStringView(line).mid(2);
47 : 101562 : const auto spaceIdx = rest.indexOf(' ');
48 : :
49 [ + + ]: 101562 : if (spaceIdx < 0) {
50 : : // Single-word untagged response (rare)
51 [ + - ]: 59 : return UntaggedResponse{rest.toString(), {}};
52 : : }
53 : :
54 [ + - + - : 304509 : return UntaggedResponse{rest.left(spaceIdx).toString(),
- - ]
55 : 304509 : rest.mid(spaceIdx + 1).toString()};
56 : : }
57 : :
58 : : std::optional<FolderInfo>
59 : 85 : ImapResponseParser::parseListResponse(const QString &data) {
60 : : // Format: "(\Flag1 \Flag2) "delimiter" "mailbox-name""
61 : : // or: "(\Flag1 \Flag2) "delimiter" mailbox-name"
62 : : // Bug 29: Handle NIL delimiter (no quotes) alongside quoted delimiters
63 [ + + + - : 85 : static QRegularExpression rx(R"re(\(([^)]*)\)\s+(?:NIL|"(.)")\s+"?([^"]+)"?)re");
+ - + - -
- ]
64 : :
65 [ + - ]: 85 : auto match = rx.match(data);
66 [ + - + + ]: 85 : if (!match.hasMatch()) {
67 [ + - + - : 4 : qCWarning(lcImapParser) << "Failed to parse LIST response:" << data;
+ - + - +
+ ]
68 : 2 : return std::nullopt;
69 : : }
70 : :
71 : 83 : FolderInfo info;
72 [ + - + - : 83 : info.flags = parseFlags("(" + match.captured(1) + ")");
+ - + - ]
73 [ + - ]: 83 : info.delimiter = match.captured(2);
74 [ + - ]: 83 : info.path = match.captured(3);
75 [ + - ]: 83 : info.name = decodeMailboxName(info.path);
76 : :
77 : : // Extract display name (last component after delimiter)
78 [ + + + - : 83 : if (!info.delimiter.isEmpty() && info.name.contains(info.delimiter)) {
+ + + + ]
79 [ + - ]: 20 : info.name = info.name.section(info.delimiter, -1);
80 : : }
81 : :
82 : 83 : return info;
83 : 85 : }
84 : :
85 : 15 : QStringList ImapResponseParser::parseCapabilities(const QString &data) {
86 : : // CAPABILITY data is space-separated tokens
87 [ + - ]: 15 : return data.split(' ', Qt::SkipEmptyParts);
88 : : }
89 : :
90 : 83 : QStringList ImapResponseParser::parseFlags(const QString &flagStr) {
91 : : // Input: "(\HasChildren \Sent)" → ["\\HasChildren", "\\Sent"]
92 : 83 : auto inner = flagStr;
93 [ + - ]: 83 : inner.remove('(');
94 [ + - ]: 83 : inner.remove(')');
95 [ + - ]: 83 : inner = inner.trimmed();
96 : :
97 [ + + ]: 83 : if (inner.isEmpty()) {
98 : 60 : return {};
99 : : }
100 : :
101 [ + - ]: 23 : return inner.split(' ', Qt::SkipEmptyParts);
102 : 83 : }
103 : :
104 : 478 : quint32 ImapResponseParser::flagsToBitmask(const QStringList &flagStrings) {
105 [ + - ]: 478 : return flagsAndKeywords(flagStrings).first;
106 : : }
107 : :
108 : : QPair<quint32, QStringList>
109 : 570 : ImapResponseParser::flagsAndKeywords(const QStringList &flagStrings) {
110 : 570 : quint32 result = MailFlag::None;
111 : 570 : QStringList keywords;
112 [ + + ]: 968 : for (const auto &flag : flagStrings) {
113 [ + - + + ]: 398 : if (flag.compare("\\Seen", Qt::CaseInsensitive) == 0)
114 : 333 : result |= MailFlag::Seen;
115 [ + - + + ]: 65 : else if (flag.compare("\\Answered", Qt::CaseInsensitive) == 0)
116 : 10 : result |= MailFlag::Answered;
117 [ + - + + ]: 55 : else if (flag.compare("\\Flagged", Qt::CaseInsensitive) == 0)
118 : 18 : result |= MailFlag::Flagged;
119 [ + - + + ]: 37 : else if (flag.compare("\\Deleted", Qt::CaseInsensitive) == 0)
120 : 4 : result |= MailFlag::Deleted;
121 [ + - + + ]: 33 : else if (flag.compare("\\Draft", Qt::CaseInsensitive) == 0)
122 : 8 : result |= MailFlag::Draft;
123 [ + - + + : 25 : else if (!flag.startsWith('\\') && !flag.isEmpty()) {
+ + + + ]
124 : : // Non-system flag = keyword (label) — store all, UI filters internal ones
125 [ + - ]: 19 : keywords.append(flag);
126 : : }
127 : : }
128 : 1140 : return {result, keywords};
129 : 570 : }
130 : :
131 : 87 : bool ImapResponseParser::isInternalKeyword(const QString &keyword) {
132 : : static const QStringList internalKeywords = {
133 : : "NonJunk", "NotJunk", "$NotJunk",
134 : : "Junk", "$Junk", "$MDNSent",
135 : : "$Forwarded", "$SubmitPending", "$Submitted",
136 [ + + + - : 207 : };
+ + - - -
- ]
137 [ + + ]: 750 : for (const auto &kw : internalKeywords) {
138 [ + + ]: 684 : if (keyword.compare(kw, Qt::CaseInsensitive) == 0) {
139 : 21 : return true;
140 : : }
141 : : }
142 : : // SOGo/Mailcow internal annotations: x-me-annot-1, x-me-annot-2, etc.
143 [ + - + - : 66 : if (keyword.startsWith("x-me-annot", Qt::CaseInsensitive))
+ + ]
144 : 4 : return true;
145 : 62 : return false;
146 [ + - + - : 12 : }
+ - + - +
- + - + -
+ - + - +
- - - -
- ]
147 : :
148 : : std::optional<MailHeader>
149 : 129 : ImapResponseParser::parseFetchHeaderResponse(const QString &data) {
150 : : // Expected format (after "* N FETCH "):
151 : : // (UID 42 FLAGS (\Seen) RFC822.SIZE 1234 ENVELOPE ("date" "subject"
152 : : // (("from-name" NIL "user" "host")) ...))
153 : : //
154 : : // We extract key fields using targeted regex patterns rather than
155 : : // implementing a full IMAP grammar parser.
156 : :
157 : 129 : MailHeader header;
158 : :
159 : : // Extract UID
160 [ + + + - : 129 : static QRegularExpression uidRx(R"(UID\s+(\d+))");
+ - + - -
- ]
161 [ + - ]: 129 : auto uidMatch = uidRx.match(data);
162 [ + - + + ]: 129 : if (!uidMatch.hasMatch()) {
163 [ + - + - : 4 : qCWarning(lcImapParser) << "No UID in FETCH response:" << data.left(100);
+ - + - +
- + + ]
164 : 2 : return std::nullopt;
165 : : }
166 [ + - + - ]: 127 : header.uid = uidMatch.captured(1).toLongLong();
167 : :
168 : : // Extract FLAGS
169 [ + + + - : 127 : static QRegularExpression flagsRx(R"(FLAGS\s*\(([^)]*)\))");
+ - + - -
- ]
170 [ + - ]: 127 : auto flagsMatch = flagsRx.match(data);
171 [ + - + + ]: 127 : if (flagsMatch.hasMatch()) {
172 [ + - + - ]: 75 : auto flagList = flagsMatch.captured(1).split(' ', Qt::SkipEmptyParts);
173 [ + - ]: 75 : auto [flags, keywords] = flagsAndKeywords(flagList);
174 : 75 : header.flags = flags;
175 : 75 : header.labels = keywords;
176 : 75 : }
177 : :
178 : : // Extract RFC822.SIZE
179 [ + + + - : 127 : static QRegularExpression sizeRx(R"(RFC822\.SIZE\s+(\d+))");
+ - + - -
- ]
180 [ + - ]: 127 : auto sizeMatch = sizeRx.match(data);
181 [ + - + + ]: 127 : if (sizeMatch.hasMatch()) {
182 [ + - + - ]: 74 : header.size = sizeMatch.captured(1).toLongLong();
183 : : }
184 : :
185 : : // Extract ENVELOPE fields
186 : : // Envelope format: ("date" "subject" ((from)) ((sender)) ((reply-to))
187 : : // ((to)) ((cc)) ((bcc)) "in-reply-to" "message-id")
188 [ + + + - : 127 : static QRegularExpression envRx(R"(ENVELOPE\s*\()");
+ - + - -
- ]
189 [ + - ]: 127 : auto envMatch = envRx.match(data);
190 [ + - + + ]: 127 : if (envMatch.hasMatch()) {
191 [ + - ]: 115 : int envStart = envMatch.capturedEnd();
192 : : // Parse quoted fields from envelope
193 : : // Field 1: date string
194 : : // Field 2: subject
195 : 1092 : auto extractQuoted = [&](int &pos) -> QString {
196 : : // Skip whitespace
197 [ + + + + : 1893 : while (pos < data.length() && data[pos].isSpace())
+ + ]
198 : 801 : pos++;
199 [ + + ]: 1092 : if (pos >= data.length())
200 : 6 : return {};
201 : :
202 [ + - + + ]: 1086 : if (data.mid(pos, 3) == "NIL") {
203 : 390 : pos += 3;
204 : 390 : return {};
205 : : }
206 : :
207 : : // Handle IMAP literal syntax {N} — the transport layer normally converts
208 : : // these to quoted strings, but handle the case where they survive inline.
209 : : // SEC-2026-07-21-01: use qsizetype to avoid int overflow on unrealistic
210 : : // literal lengths; the transport budgets already reject >64 MiB literals,
211 : : // but this makes the parser self-defensive.
212 [ + + ]: 696 : if (data[pos] == '{') {
213 : 8 : qsizetype braceEnd = data.indexOf('}', pos);
214 [ + + ]: 8 : if (braceEnd > pos) {
215 : : bool ok;
216 : : qsizetype len =
217 [ + - + - ]: 7 : data.mid(pos + 1, braceEnd - pos - 1).toLongLong(&ok);
218 [ + + + + : 7 : if (ok && len >= 0 && braceEnd + 1 + len <= data.length()) {
+ + + + ]
219 [ + - ]: 3 : QString result = data.mid(braceEnd + 1, len);
220 : 3 : pos = braceEnd + 1 + len;
221 : 3 : return result;
222 : 3 : }
223 : : }
224 : : }
225 : :
226 [ + + ]: 693 : if (data[pos] != '"') {
227 [ + - + - : 38 : qCWarning(lcImapParser)
+ + ]
228 [ + - + - ]: 19 : << "extractQuoted: unexpected char" << data[pos]
229 [ + - + - : 19 : << "at pos" << pos << "context:" << data.mid(pos, 30);
+ - + - +
- ]
230 : : // T-401/Bug 11: Advance pos to avoid infinite loop
231 : 19 : pos++;
232 : 19 : return {};
233 : : }
234 : :
235 : 674 : pos++; // skip opening quote
236 : 674 : QString result;
237 [ + + + + : 9506 : while (pos < data.length() && data[pos] != '"') {
+ + ]
238 [ + + + + : 8832 : if (data[pos] == '\\' && pos + 1 < data.length()) {
+ + ]
239 : 2 : pos++;
240 : : }
241 [ + - ]: 8832 : result.append(data[pos]);
242 : 8832 : pos++;
243 : : }
244 [ + + ]: 674 : if (pos < data.length())
245 : 673 : pos++; // skip closing quote
246 : 674 : return result;
247 : 674 : };
248 : :
249 : 115 : int pos = envStart;
250 [ + - ]: 115 : QString dateStr = extractQuoted(pos);
251 [ + - + - ]: 115 : header.subject = decodeRfc2047(extractQuoted(pos));
252 [ + + + + : 115 : if (header.subject.isEmpty() && !dateStr.isEmpty()) {
+ + ]
253 [ + - + - : 6 : qCWarning(lcImapParser)
+ + ]
254 [ + - + - ]: 3 : << "Empty subject for UID" << header.uid
255 [ + - ]: 3 : << "— ENVELOPE near pos" << pos
256 [ + - + - : 3 : << "context:" << data.mid(envStart, 120);
+ - + - ]
257 : : }
258 : :
259 : : // Parse date (RFC 2822 format)
260 [ + + ]: 115 : if (!dateStr.isEmpty()) {
261 : : // Strip RFC 2822 comments like "(UTC)" or "(CET)" before parsing
262 [ + + + - : 92 : static QRegularExpression commentRx(R"(\([^)]*\))");
+ - + - -
- ]
263 : 92 : QString cleanDate = dateStr;
264 [ + - ]: 92 : cleanDate.remove(commentRx);
265 [ + - ]: 92 : cleanDate = cleanDate.simplified(); // collapse whitespace
266 : :
267 : : // Replace named timezones that Qt may not recognize.
268 : : // T-79.A3/M2: token-exact (trailing token only) — a plain substring
269 : : // replace of " UT" turned " UTC" into " +0000C", breaking every
270 : : // subsequent parse attempt.
271 : : static QRegularExpression namedZoneRx(
272 [ + + + - : 102 : QStringLiteral(" (?:UTC|GMT|UT)$"));
+ - - - ]
273 [ + - ]: 92 : cleanDate.replace(namedZoneRx, QStringLiteral(" +0000"));
274 : :
275 : : // Try common date formats (use QLocale::c() for English month names)
276 [ + - ]: 92 : QLocale cLocale = QLocale::c();
277 : :
278 [ + - ]: 184 : header.date = cLocale.toDateTime(cleanDate,
279 : 276 : QStringLiteral("ddd, d MMM yyyy H:mm:ss t"));
280 [ + - + + ]: 92 : if (!header.date.isValid()) {
281 [ + - ]: 16 : header.date = QDateTime::fromString(cleanDate, Qt::RFC2822Date);
282 : : }
283 [ + - + + ]: 92 : if (!header.date.isValid()) {
284 [ + - ]: 5 : header.date = QDateTime::fromString(cleanDate, Qt::ISODate);
285 : : }
286 [ + - + + ]: 92 : if (!header.date.isValid()) {
287 : : // Without timezone: "Mon, 16 Feb 2026 10:58:01"
288 [ + - ]: 8 : header.date = cLocale.toDateTime(cleanDate,
289 : 12 : QStringLiteral("ddd, d MMM yyyy H:mm:ss"));
290 : : }
291 [ + - + + ]: 92 : if (!header.date.isValid()) {
292 : : // Without weekday: "16 Feb 2026 10:58:01 +0000"
293 [ + - ]: 8 : header.date = cLocale.toDateTime(cleanDate,
294 : 12 : QStringLiteral("d MMM yyyy H:mm:ss t"));
295 : : }
296 [ + - + + ]: 92 : if (!header.date.isValid()) {
297 : : // Without weekday or timezone: "16 Feb 2026 10:58:01"
298 [ + - ]: 8 : header.date = cLocale.toDateTime(cleanDate,
299 : 12 : QStringLiteral("d MMM yyyy H:mm:ss"));
300 : : }
301 [ + - + + ]: 92 : if (!header.date.isValid()) {
302 [ + - + - : 8 : qCWarning(lcImapParser) << "Date parse failed for UID" << header.uid
+ - + - +
+ ]
303 [ + - + - ]: 4 : << "dateStr:" << dateStr
304 [ + - + - ]: 4 : << "cleanDate:" << cleanDate;
305 : : }
306 : 92 : }
307 : :
308 : : // Helper: skip a parenthesized group (NIL or (...)) entirely
309 : 461 : auto skipGroup = [&](int &pos) {
310 [ + + + + : 885 : while (pos < data.length() && data[pos].isSpace())
+ + ]
311 : 424 : pos++;
312 [ + + ]: 461 : if (pos >= data.length())
313 : 8 : return;
314 [ + - + + ]: 453 : if (data.mid(pos, 3) == "NIL") {
315 : 304 : pos += 3;
316 : 304 : return;
317 : : }
318 [ + + ]: 149 : if (data[pos] == '(') {
319 : 121 : int depth = 0;
320 [ + - ]: 1623 : while (pos < data.length()) {
321 : : // T-401/Bug 12: Skip quoted strings to avoid counting
322 : : // parentheses inside them (e.g. "Smith (CTO)")
323 [ + + ]: 1623 : if (data[pos] == '"') {
324 : 337 : pos++; // skip opening quote
325 [ + - + + : 3254 : while (pos < data.length() && data[pos] != '"') {
+ + ]
326 [ + + + - : 2917 : if (data[pos] == '\\' && pos + 1 < data.length())
+ + ]
327 : 2 : pos++;
328 : 2917 : pos++;
329 : : }
330 [ + - ]: 337 : if (pos < data.length())
331 : 337 : pos++; // skip closing quote
332 : 337 : continue;
333 : : }
334 [ + + ]: 1286 : if (data[pos] == '(')
335 : 241 : depth++;
336 [ + + ]: 1045 : else if (data[pos] == ')') {
337 : 241 : depth--;
338 [ + + ]: 241 : if (depth == 0) {
339 : 121 : pos++;
340 : 121 : return;
341 : : }
342 : : }
343 : 1165 : pos++;
344 : : }
345 : : }
346 : 115 : };
347 : :
348 : : // Helper: extract first address from an address list.
349 : : // IMAP address list formats:
350 : : // NIL → no addresses
351 : : // ((name NIL user host)) → single address
352 : : // ((name NIL user host)(name NIL user host)) → multiple addresses
353 : : // ((NIL NIL group NIL)(name NIL u h)(NIL NIL NIL NIL)) → group syntax
354 : 230 : auto extractAddress = [&](int &pos) -> std::pair<QString, QString> {
355 [ + + + + : 443 : while (pos < data.length() && data[pos].isSpace())
+ + ]
356 : 213 : pos++;
357 [ + + ]: 230 : if (pos >= data.length())
358 : 4 : return {};
359 : :
360 : : // NIL = no address list
361 [ + - + + ]: 226 : if (data.mid(pos, 3) == "NIL") {
362 : 53 : pos += 3;
363 : 53 : return {};
364 : : }
365 : :
366 : : // Must start with '(' (outer list)
367 [ + + ]: 173 : if (data[pos] != '(') {
368 : 14 : return {};
369 : : }
370 : :
371 : : // Save start position for fallback
372 : 159 : int listStart = pos;
373 : :
374 : : // Check if we have "((" (normal case) or just "("
375 [ + - + + : 159 : if (pos + 1 < data.length() && data[pos + 1] == '(') {
+ + ]
376 : : // Standard format: ((name NIL user host) ...)
377 : 158 : pos += 2; // skip ((
378 [ + - + - ]: 158 : QString name = decodeRfc2047(extractQuoted(pos));
379 [ + - ]: 158 : extractQuoted(pos); // skip at-domain-list (NIL)
380 [ + - ]: 158 : QString user = extractQuoted(pos);
381 [ + - ]: 158 : QString host = extractQuoted(pos);
382 : :
383 : : // Skip to end of address list (balance all parens from listStart)
384 : 158 : int depth = 2; // we opened ((
385 [ + - + + : 518 : while (pos < data.length() && depth > 0) {
+ + ]
386 : : // T-79.A4/M1: skip quoted strings so parentheses inside display
387 : : // names (e.g. "John (Work)") don't desynchronize the balance —
388 : : // same walk as skipGroup() above (T-401/Bug 12).
389 [ + + ]: 360 : if (data[pos] == '"') {
390 : 12 : pos++; // skip opening quote
391 [ + - + + : 73 : while (pos < data.length() && data[pos] != '"') {
+ + ]
392 [ + + + - : 61 : if (data[pos] == '\\' && pos + 1 < data.length())
+ + ]
393 : 2 : pos++;
394 : 61 : pos++;
395 : : }
396 [ + - ]: 12 : if (pos < data.length())
397 : 12 : pos++; // skip closing quote
398 : 12 : continue;
399 : : }
400 [ + + ]: 348 : if (data[pos] == '(')
401 : 4 : depth++;
402 [ + + ]: 344 : else if (data[pos] == ')')
403 : 320 : depth--;
404 : 348 : pos++;
405 : : }
406 : :
407 : 158 : QString email;
408 [ + + + + : 158 : if (!user.isEmpty() && !host.isEmpty()) {
+ + ]
409 [ + - + - ]: 155 : email = user + "@" + host;
410 [ + + ]: 3 : } else if (!user.isEmpty()) {
411 : 2 : email = user; // partial address
412 : : }
413 : 158 : return {name, email};
414 : 158 : } else {
415 : : // Unexpected format – skip the whole group safely
416 : 1 : skipGroup(pos);
417 : 1 : return {};
418 : : }
419 : 115 : };
420 : :
421 : : // from address
422 [ + - ]: 115 : auto [fromName, fromEmail] = extractAddress(pos);
423 [ + + ]: 115 : if (!fromEmail.isEmpty()) {
424 : : header.from =
425 [ + + + - : 84 : fromName.isEmpty() ? fromEmail : fromName + " <" + fromEmail + ">";
+ - + - +
+ + + - -
- - ]
426 : : }
427 : :
428 : : // Skip sender (usually same as from)
429 [ + - ]: 115 : skipGroup(pos);
430 : :
431 : : // Skip reply-to
432 [ + - ]: 115 : skipGroup(pos);
433 : :
434 : : // to address
435 [ + - ]: 115 : auto [toName, toEmail] = extractAddress(pos);
436 [ + + ]: 115 : if (!toEmail.isEmpty()) {
437 [ + + + - : 73 : header.to = toName.isEmpty() ? toEmail : toName + " <" + toEmail + ">";
+ - + - +
+ + + - -
- - ]
438 : : }
439 : :
440 : : // Skip cc (address list)
441 [ + - ]: 115 : skipGroup(pos);
442 : :
443 : : // Skip bcc (address list)
444 [ + - ]: 115 : skipGroup(pos);
445 : :
446 : : // in-reply-to (quoted string or NIL)
447 [ + - ]: 115 : QString rawInReplyTo = extractQuoted(pos);
448 [ + + ]: 115 : if (!rawInReplyTo.isEmpty()) {
449 : 6 : header.inReplyTo = rawInReplyTo;
450 [ + - ]: 6 : header.inReplyTo.remove('<');
451 [ + - ]: 6 : header.inReplyTo.remove('>');
452 [ + - ]: 6 : header.inReplyTo = header.inReplyTo.trimmed();
453 : : }
454 : :
455 : : // message-id (quoted string or NIL)
456 [ + - ]: 115 : QString rawMessageId = extractQuoted(pos);
457 [ + + ]: 115 : if (!rawMessageId.isEmpty()) {
458 : 70 : header.messageId = rawMessageId;
459 [ + - ]: 70 : header.messageId.remove('<');
460 [ + - ]: 70 : header.messageId.remove('>');
461 [ + - ]: 70 : header.messageId = header.messageId.trimmed();
462 : : }
463 : 115 : }
464 : :
465 : : // Fallback: if ENVELOPE date is missing/unparseable, use INTERNALDATE
466 [ + - + + ]: 127 : if (!header.date.isValid()) {
467 : : static QRegularExpression idateRx(
468 [ + + + - : 39 : R"~~(INTERNALDATE\s+"([^"]+)")~~");
+ - + - -
- ]
469 [ + - ]: 39 : auto idateMatch = idateRx.match(data);
470 [ + - + + ]: 39 : if (idateMatch.hasMatch()) {
471 [ + - ]: 4 : QString idateStr = idateMatch.captured(1);
472 : : // INTERNALDATE format: "16-Feb-2026 21:44:53 +0100"
473 [ + - ]: 4 : QLocale cLocale = QLocale::c();
474 [ + - ]: 8 : header.date = cLocale.toDateTime(
475 : 12 : idateStr, QStringLiteral("d-MMM-yyyy H:mm:ss t"));
476 [ + - + + ]: 4 : if (!header.date.isValid()) {
477 [ + - ]: 2 : header.date = cLocale.toDateTime(
478 : 3 : idateStr, QStringLiteral("dd-MMM-yyyy HH:mm:ss t"));
479 : : }
480 [ + - + + ]: 4 : if (header.date.isValid()) {
481 [ + - + - : 6 : qCInfo(lcImapParser) << "Used INTERNALDATE fallback for UID"
+ - + + ]
482 [ + - + - : 3 : << header.uid << ":" << idateStr;
+ - ]
483 : : } else {
484 [ + - + - : 2 : qCWarning(lcImapParser) << "INTERNALDATE parse also failed for UID"
+ - + + ]
485 [ + - + - : 1 : << header.uid << ":" << idateStr;
+ - ]
486 : : }
487 : 4 : }
488 : 39 : }
489 : :
490 : : // T-432: Parse References header from BODY[HEADER.FIELDS ...] block.
491 : : // The header value contains space-separated message-IDs in angle brackets.
492 : : // e.g. "References: <id1@host> <id2@host> <id3@host>"
493 : : static QRegularExpression refsHeaderRx(
494 : : R"(References:\s*(.+?)(?:\\r\\n\S|$))",
495 : : QRegularExpression::CaseInsensitiveOption |
496 [ + + + - : 127 : QRegularExpression::DotMatchesEverythingOption);
+ - + - -
- ]
497 [ + - ]: 127 : auto refsMatch = refsHeaderRx.match(data);
498 [ + - + + ]: 127 : if (refsMatch.hasMatch()) {
499 [ + - ]: 15 : QString refsValue = refsMatch.captured(1);
500 : : // Extract individual message-IDs from angle brackets
501 [ + + + - : 15 : static QRegularExpression msgIdRx(R"(<([^>]+)>)");
+ - + - -
- ]
502 [ + - ]: 15 : auto it = msgIdRx.globalMatch(refsValue);
503 [ + - + + ]: 37 : while (it.hasNext()) {
504 [ + - ]: 22 : auto m = it.next();
505 [ + - + - ]: 22 : header.references.append(m.captured(1));
506 : 22 : }
507 : 15 : }
508 : :
509 : : // T-231: Check for X-Spam headers in BODY[HEADER.FIELDS ...] block.
510 : : // The transport layer converts the literal to a quoted string, so the
511 : : // FETCH data contains something like:
512 : : // BODY[HEADER.FIELDS (X-Spam ...)] "X-Spam: Yes\r\nX-Spam-Status: ..."
513 : : // We search for "X-Spam: Yes", "X-Spam-Flag: YES", or
514 : : // "X-Spam-Status: Yes" (case-insensitive) anywhere in the FETCH data.
515 : : static QRegularExpression spamHeaderRx(
516 : : R"(X-Spam(?:-Flag|-Status)?:\s*Yes)",
517 [ + + + - : 127 : QRegularExpression::CaseInsensitiveOption);
+ - + - -
- ]
518 [ + - + - : 127 : if (spamHeaderRx.match(data).hasMatch()) {
+ + ]
519 : 6 : header.isSpam = true;
520 : : }
521 : :
522 : 127 : return header;
523 : 129 : }
524 : :
525 : : std::optional<QPair<qint64, QByteArray>>
526 : 10 : ImapResponseParser::parseFetchBodyResponse(const QString &data) {
527 : : // Expected: (UID 42 BODY[] {1234}\r\n...raw bytes...)
528 : : // Or inline: (UID 42 BODY[] "body text here")
529 : : // We extract UID and raw content for MimeParser to process.
530 : :
531 : : // Extract UID
532 [ + + + - : 10 : static QRegularExpression uidRx(R"(UID\s+(\d+))");
+ - + - -
- ]
533 [ + - ]: 10 : auto uidMatch = uidRx.match(data);
534 [ + - + + ]: 10 : if (!uidMatch.hasMatch()) {
535 : 2 : return std::nullopt;
536 : : }
537 [ + - + - ]: 8 : qint64 uid = uidMatch.captured(1).toLongLong();
538 : :
539 : : // Look for BODY[] followed by content
540 [ + + + - : 8 : static QRegularExpression bodyRx(R"(BODY\[\]\s+)");
+ - + - -
- ]
541 [ + - ]: 8 : auto bodyMatch = bodyRx.match(data);
542 [ + - + + ]: 8 : if (bodyMatch.hasMatch()) {
543 [ + - ]: 6 : int contentStart = bodyMatch.capturedEnd();
544 : : // The rest after BODY[] is the content (minus trailing paren)
545 [ + - ]: 6 : QString content = data.mid(contentStart);
546 : : // Remove trailing ) if present
547 [ + - + + ]: 6 : if (content.endsWith(')')) {
548 [ + - ]: 5 : content.chop(1);
549 : : }
550 : : // Remove quotes if present
551 [ + - + + : 6 : if (content.startsWith('"') && content.endsWith('"')) {
+ - + + +
+ ]
552 [ + - ]: 2 : content = content.mid(1, content.length() - 2);
553 : : }
554 [ + - ]: 6 : return QPair<qint64, QByteArray>{uid, content.toUtf8()};
555 : 6 : }
556 : :
557 : 2 : return std::nullopt;
558 : 10 : }
559 : :
560 : : std::optional<int>
561 : 11 : ImapResponseParser::parseExistsResponse(const QString &data) {
562 : : // Untagged data format: "N EXISTS" (the "* " prefix is already stripped)
563 : : // After parseUntaggedResponse, type="N" and data="EXISTS"
564 : : // But we also handle full "42 EXISTS" format
565 [ + + + - : 11 : static QRegularExpression rx(R"(^(\d+)\s+EXISTS)");
+ - + - -
- ]
566 [ + - ]: 11 : auto match = rx.match(data);
567 [ + - + + ]: 11 : if (match.hasMatch()) {
568 [ + - + - ]: 6 : return match.captured(1).toInt();
569 : : }
570 : 5 : return std::nullopt;
571 : 11 : }
572 : :
573 : : std::optional<quint32>
574 : 100482 : ImapResponseParser::parseUidValidity(const QString &data) {
575 : : // Format: "OK [UIDVALIDITY 12345] ..." or just "[UIDVALIDITY 12345]"
576 [ + + + - : 100482 : static QRegularExpression rx(R"(\[UIDVALIDITY\s+(\d+)\])");
+ - + - -
- ]
577 [ + - ]: 100482 : auto match = rx.match(data);
578 [ + - + + ]: 100482 : if (match.hasMatch()) {
579 [ + - + - ]: 122 : return match.captured(1).toUInt();
580 : : }
581 : 100360 : return std::nullopt;
582 : 100482 : }
583 : :
584 : : // Modified UTF-7 decoding (RFC 3501 Section 5.1.3)
585 : : // In Modified UTF-7:
586 : : // - '&' starts a shifted section (Base64-encoded UTF-16BE)
587 : : // - '-' ends the shifted section
588 : : // - '&-' represents a literal '&'
589 : : // - All other printable ASCII is literal
590 : 279 : QString ImapResponseParser::decodeMailboxName(const QString &encoded) {
591 : 279 : QString result;
592 : 279 : int i = 0;
593 : :
594 [ + + ]: 2458 : while (i < encoded.length()) {
595 [ + + ]: 2181 : if (encoded[i] == '&') {
596 : : // Find the closing '-'
597 : 23 : int end = encoded.indexOf('-', i + 1);
598 [ + + ]: 23 : if (end < 0) {
599 : : // Malformed: no closing '-', just append the rest literally
600 [ + - + - ]: 2 : result.append(encoded.mid(i));
601 : 2 : break;
602 : : }
603 : :
604 [ + + ]: 21 : if (end == i + 1) {
605 : : // "&-" represents a literal '&'
606 [ + - ]: 5 : result.append('&');
607 : : } else {
608 : : // Base64-encoded UTF-16BE between '&' and '-'
609 [ + - ]: 16 : auto base64Str = encoded.mid(i + 1, end - i - 1);
610 : : // Modified UTF-7 uses ',' instead of '/' in Base64
611 [ + - ]: 16 : base64Str.replace(',', '/');
612 : : // Add padding if needed
613 [ + + ]: 31 : while (base64Str.length() % 4 != 0) {
614 [ + - ]: 15 : base64Str.append('=');
615 : : }
616 : :
617 [ + - + - ]: 16 : QByteArray decoded = QByteArray::fromBase64(base64Str.toLatin1());
618 : : // Decode UTF-16BE
619 [ + + ]: 35 : for (int j = 0; j + 1 < decoded.size(); j += 2) {
620 [ + - ]: 19 : ushort ch = (static_cast<uchar>(decoded[j]) << 8) |
621 [ + - ]: 19 : static_cast<uchar>(decoded[j + 1]);
622 [ + - ]: 19 : result.append(QChar(ch));
623 : : }
624 : 16 : }
625 : 21 : i = end + 1;
626 : : } else {
627 [ + - ]: 2158 : result.append(encoded[i]);
628 : 2158 : i++;
629 : : }
630 : : }
631 : :
632 : 279 : return result;
633 : 0 : }
634 : :
635 : 18 : QString ImapResponseParser::encodeMailboxName(const QString &decoded) {
636 : 18 : QString result;
637 : :
638 : 18 : int i = 0;
639 [ + + ]: 107 : while (i < decoded.length()) {
640 : 89 : QChar ch = decoded[i];
641 : :
642 [ + + ]: 89 : if (ch == '&') {
643 [ + - ]: 3 : result.append("&-");
644 : 3 : i++;
645 [ + + + + : 86 : } else if (ch.unicode() >= 0x20 && ch.unicode() <= 0x7E) {
+ + ]
646 : : // Printable ASCII – literal
647 [ + - ]: 73 : result.append(ch);
648 : 73 : i++;
649 : : } else {
650 : : // Non-ASCII: collect consecutive non-ASCII chars and encode as UTF-16BE
651 : 13 : QByteArray utf16be;
652 [ + + ]: 29 : while (i < decoded.length()) {
653 : 24 : QChar c = decoded[i];
654 [ + + + + : 24 : if (c == '&' || (c.unicode() >= 0x20 && c.unicode() <= 0x7E)) {
+ + + + ]
655 : 8 : break;
656 : : }
657 [ + - ]: 16 : utf16be.append(static_cast<char>(c.unicode() >> 8));
658 [ + - ]: 16 : utf16be.append(static_cast<char>(c.unicode() & 0xFF));
659 : 16 : i++;
660 : : }
661 : :
662 : : auto base64 = utf16be.toBase64(QByteArray::Base64Encoding |
663 [ + - ]: 13 : QByteArray::OmitTrailingEquals);
664 [ + - ]: 13 : QString b64str = QString::fromLatin1(base64);
665 [ + - ]: 13 : b64str.replace('/', ',');
666 [ + - ]: 13 : result.append('&');
667 [ + - ]: 13 : result.append(b64str);
668 [ + - ]: 13 : result.append('-');
669 : 13 : }
670 : : }
671 : :
672 : 18 : return result;
673 : 0 : }
674 : :
675 : : std::optional<QPair<qint64, quint32>>
676 : 464 : ImapResponseParser::parseFetchFlagsResponse(const QString &data) {
677 : : // Expected input (after "* N FETCH "): "(UID 123 FLAGS (\Seen \Flagged))"
678 : : // or "(FLAGS (\Seen) UID 123)" – order may vary.
679 : :
680 : : // Extract UID
681 [ + + + - : 464 : static QRegularExpression uidRx(R"(UID\s+(\d+))");
+ - + - -
- ]
682 [ + - ]: 464 : auto uidMatch = uidRx.match(data);
683 [ + - + + ]: 464 : if (!uidMatch.hasMatch()) {
684 [ + - + - : 8 : qCWarning(lcImapParser)
+ + ]
685 [ + - + - : 4 : << "No UID in FETCH FLAGS response:" << data.left(100);
+ - ]
686 : 4 : return std::nullopt;
687 : : }
688 [ + - + - ]: 460 : qint64 uid = uidMatch.captured(1).toLongLong();
689 : :
690 : : // Extract FLAGS
691 [ + + + - : 460 : static QRegularExpression flagsRx(R"(FLAGS\s*\(([^)]*)\))");
+ - + - -
- ]
692 [ + - ]: 460 : auto flagsMatch = flagsRx.match(data);
693 [ + - + + ]: 460 : if (!flagsMatch.hasMatch()) {
694 [ + - + - : 4 : qCWarning(lcImapParser)
+ + ]
695 [ + - + - : 2 : << "No FLAGS in FETCH FLAGS response:" << data.left(100);
+ - ]
696 : 2 : return std::nullopt;
697 : : }
698 [ + - + - ]: 458 : auto flagList = flagsMatch.captured(1).split(' ', Qt::SkipEmptyParts);
699 [ + - ]: 458 : quint32 flags = flagsToBitmask(flagList);
700 : :
701 : 458 : return QPair<qint64, quint32>{uid, flags};
702 : 464 : }
703 : :
704 : : std::optional<StatusResult>
705 : 22 : ImapResponseParser::parseStatusResponse(const QString &data) {
706 : : // Expected input (after "* STATUS "): '"INBOX" (MESSAGES 42 UNSEEN 3 RECENT
707 : : // 0)' or: 'INBOX (MESSAGES 42 UNSEEN 3 RECENT 0)'
708 : :
709 : 22 : StatusResult result;
710 : :
711 : : // Extract folder name (quoted or unquoted)
712 : 22 : int parenIdx = data.indexOf('(');
713 [ + + ]: 22 : if (parenIdx < 0) {
714 [ + - + - : 2 : qCWarning(lcImapParser)
+ + ]
715 [ + - + - ]: 1 : << "No parenthesized data in STATUS response:" << data;
716 : 1 : return std::nullopt;
717 : : }
718 : :
719 [ + - + - ]: 21 : QString folderPart = data.left(parenIdx).trimmed();
720 : : // Remove quotes if present
721 [ + - + + : 21 : if (folderPart.startsWith('"') && folderPart.endsWith('"')) {
+ - + + +
+ ]
722 [ + - ]: 19 : folderPart = folderPart.mid(1, folderPart.length() - 2);
723 : : }
724 : 21 : result.folderPath = folderPart;
725 : :
726 : : // Extract the parenthesized status values
727 [ + - ]: 21 : QString statusPart = data.mid(parenIdx);
728 : : // Remove outer parens
729 [ + - ]: 21 : statusPart.remove('(');
730 [ + - ]: 21 : statusPart.remove(')');
731 [ + - ]: 21 : statusPart = statusPart.trimmed();
732 : :
733 : : // Parse key-value pairs: "MESSAGES 42 UNSEEN 3 RECENT 0"
734 [ + - ]: 21 : auto tokens = statusPart.split(' ', Qt::SkipEmptyParts);
735 [ + + ]: 73 : for (int i = 0; i + 1 < tokens.size(); i += 2) {
736 [ + - ]: 52 : const auto &key = tokens[i];
737 : 52 : bool ok = false;
738 [ + - + - ]: 52 : int value = tokens[i + 1].toInt(&ok);
739 [ + + ]: 52 : if (!ok)
740 : 1 : continue;
741 : :
742 [ + - + + ]: 51 : if (key.compare("MESSAGES", Qt::CaseInsensitive) == 0) {
743 : 19 : result.messages = value;
744 [ + - + + ]: 32 : } else if (key.compare("UNSEEN", Qt::CaseInsensitive) == 0) {
745 : 17 : result.unseen = value;
746 [ + - + + ]: 15 : } else if (key.compare("RECENT", Qt::CaseInsensitive) == 0) {
747 : 14 : result.recent = value;
748 : : }
749 : : }
750 : :
751 : 21 : return result;
752 : 22 : }
753 : :
754 : 1394 : QString ImapResponseParser::decodeRfc2047(const QString &input) {
755 [ + - + - : 1394 : if (!input.contains("=?"))
+ + ]
756 : 1343 : return input;
757 : :
758 : : // RFC 2047 pattern: =?charset?encoding?encoded_text?=
759 : : static QRegularExpression rx(R"(=\?([^?]+)\?([BbQq])\?([^?]*)\?=)",
760 [ + + + - : 51 : QRegularExpression::CaseInsensitiveOption);
+ - + - -
- ]
761 : :
762 : 51 : QString result;
763 : 51 : int lastEnd = 0;
764 : 51 : bool lastWasEncoded = false;
765 : :
766 [ + - ]: 51 : auto it = rx.globalMatch(input);
767 [ + - + + ]: 105 : while (it.hasNext()) {
768 [ + - ]: 54 : auto match = it.next();
769 [ + - ]: 54 : int matchStart = match.capturedStart();
770 : :
771 : : // Text between encoded words
772 [ + - ]: 54 : QString between = input.mid(lastEnd, matchStart - lastEnd);
773 : :
774 : : // RFC 2047 §6.2: whitespace between adjacent encoded words is ignored
775 [ + + + - : 54 : if (lastWasEncoded && between.trimmed().isEmpty()) {
+ + + + +
+ - - ]
776 : : // Skip whitespace between consecutive encoded words
777 : : } else {
778 [ + - ]: 51 : result.append(between);
779 : : }
780 : :
781 [ + - + - ]: 54 : QString charset = match.captured(1).toLower();
782 [ + - + - ]: 54 : QChar encoding = match.captured(2).toUpper().at(0);
783 [ + - ]: 54 : QString encodedText = match.captured(3);
784 : :
785 : : // T-272: WHATWG Encoding Standard mapping — ISO-8859-1 → Windows-1252.
786 : : // Senders routinely mislabel CP1252 as ISO-8859-1. Bytes 0x80-0x9F are
787 : : // C1 control chars in ISO-8859-1 (rendering as rectangles) but printable
788 : : // glyphs (smart quotes, dashes, etc.) in Windows-1252.
789 [ + + ]: 103 : if (charset == QLatin1String("iso-8859-1") ||
790 [ + + + + ]: 103 : charset == QLatin1String("latin1") ||
791 [ + + ]: 101 : charset == QLatin1String("latin-1")) {
792 : 8 : charset = QStringLiteral("windows-1252");
793 : : }
794 : :
795 : 54 : QByteArray decoded;
796 [ + + ]: 54 : if (encoding == 'B') {
797 : : // Base64
798 [ + - + - ]: 17 : decoded = QByteArray::fromBase64(encodedText.toLatin1());
799 [ + - ]: 37 : } else if (encoding == 'Q') {
800 : : // Quoted-Printable (RFC 2047 variant: _ = space, =XX = hex)
801 [ + + ]: 462 : for (int i = 0; i < encodedText.length(); i++) {
802 [ + - ]: 425 : QChar c = encodedText[i];
803 [ + + ]: 425 : if (c == '_') {
804 [ + - ]: 37 : decoded.append(' ');
805 [ + + + + : 388 : } else if (c == '=' && i + 2 < encodedText.length()) {
+ + ]
806 : : bool ok;
807 [ + - + - ]: 79 : int byte = encodedText.mid(i + 1, 2).toInt(&ok, 16);
808 [ + + ]: 79 : if (ok) {
809 [ + - ]: 77 : decoded.append(static_cast<char>(byte));
810 : 77 : i += 2;
811 : : } else {
812 [ + - ]: 2 : decoded.append(c.toLatin1());
813 : : }
814 : : } else {
815 [ + - ]: 309 : decoded.append(c.toLatin1());
816 : : }
817 : : }
818 : : }
819 : :
820 : : // RFC 2047 §5: CR and LF are forbidden inside encoded text and MUST be
821 : : // removed. Some senders (notably Google Play) smuggle =0D=0A into the
822 : : // Q-encoded payload, which otherwise surfaces as a stray leading blank
823 : : // line in subjects and sender names. Replace the bytes with a space so
824 : : // adjacent words cannot merge; runs of whitespace are collapsed later.
825 [ + - ]: 54 : decoded.replace('\r', ' ');
826 [ + - ]: 54 : decoded.replace('\n', ' ');
827 : :
828 : : // Convert from charset to QString
829 [ + - + - ]: 54 : auto toUtf16 = QStringDecoder(charset.toLatin1().constData());
830 [ + + ]: 54 : if (toUtf16.isValid()) {
831 [ + - + - ]: 53 : result.append(toUtf16(decoded));
832 : : } else {
833 : : // Fallback: assume UTF-8
834 [ + - + - ]: 1 : result.append(QString::fromUtf8(decoded));
835 : : }
836 : :
837 [ + - ]: 54 : lastEnd = match.capturedEnd();
838 : 54 : lastWasEncoded = true;
839 : 54 : }
840 : :
841 : : // Append any trailing non-encoded text
842 [ + + ]: 51 : if (lastEnd < input.length()) {
843 [ + - + - ]: 4 : result.append(input.mid(lastEnd));
844 : : }
845 : :
846 : : // Robustness: collapse any residual CR/LF that leaked through unencoded
847 : : // portions (replace, do not drop, to keep adjacent words separated), squash
848 : : // runs of internal whitespace to a single space, and trim the result.
849 : : // Header values such as Subject / display name / filename should never
850 : : // carry wrapping whitespace.
851 [ + + + - : 60 : static const QRegularExpression kWhitespaceRun(QStringLiteral("\\s+"));
+ - - - ]
852 [ + - ]: 51 : result.replace('\r', ' ');
853 [ + - ]: 51 : result.replace('\n', ' ');
854 [ + - ]: 51 : result.replace(kWhitespaceRun, QStringLiteral(" "));
855 [ + - ]: 51 : return result.trimmed();
856 : 51 : }
|