MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - service - ImapResponseParser.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 99.6 % 492 490
Test Date: 2026-07-27 17:53:44 Functions: 100.0 % 24 24
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 63.0 % 1220 769

             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 : }
        

Generated by: LCOV version 2.0-1