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 % 483 481
Test Date: 2026-06-21 21:10:19 Functions: 100.0 % 24 24
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 62.9 % 1198 753

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

Generated by: LCOV version 2.0-1