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