MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - service - MimeParser.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 89.6 % 326 292
Test Date: 2026-06-21 21:10:19 Functions: 100.0 % 19 19
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 54.8 % 682 374

             Branch data     Line data    Source code
       1                 :             : #include "MimeParser.h"
       2                 :             : 
       3                 :             : #include <QLoggingCategory>
       4                 :             : #include <QStringDecoder>
       5                 :             : #include <QUrl>
       6                 :             : 
       7                 :             : #include "service/ImapResponseParser.h"
       8                 :             : 
       9   [ +  +  +  -  :           3 : Q_LOGGING_CATEGORY(lcMime, "mailjd.mime")
             +  -  -  - ]
      10                 :             : 
      11                 :             : // --- Public API ---
      12                 :             : 
      13                 :          76 : MimeMessage MimeParser::parse(const QByteArray &rawMessage) {
      14                 :          76 :   MimeMessage result;
      15                 :             : 
      16         [ -  + ]:          76 :   if (rawMessage.isEmpty()) {
      17                 :           0 :     return result;
      18                 :             :   }
      19                 :             : 
      20                 :             :   // Split into headers and body
      21         [ +  - ]:          76 :   auto [headerBlock, bodyBlock] = splitHeaderBody(rawMessage);
      22         [ +  - ]:          76 :   auto headers = parseHeaders(headerBlock);
      23                 :             : 
      24                 :         152 :   QString contentType = headers.value(QStringLiteral("content-type"),
      25         [ +  - ]:         228 :                                       QStringLiteral("text/plain"));
      26                 :             :   QString transferEncoding = headers.value(
      27         [ +  - ]:         152 :       QStringLiteral("content-transfer-encoding"), QStringLiteral("7bit"));
      28                 :             : 
      29                 :             :   // Check if this is a multipart message
      30         [ +  - ]:          76 :   QString ctLower = contentType.toLower();
      31   [ +  -  +  + ]:          76 :   if (ctLower.contains(QStringLiteral("multipart/"))) {
      32         [ +  - ]:          40 :     QByteArray boundary = extractBoundary(contentType);
      33         [ +  + ]:          40 :     if (boundary.isEmpty()) {
      34                 :             :       // Malformed: no boundary → treat entire body as plain text
      35   [ +  -  +  -  :           2 :       qCWarning(lcMime) << "Multipart message without boundary, falling back "
                   +  + ]
      36         [ +  - ]:           1 :                            "to plain text";
      37         [ +  - ]:           1 :       result.textPlain = QString::fromUtf8(bodyBlock);
      38                 :           1 :       return result;
      39                 :             :     }
      40                 :             : 
      41                 :          39 :     ParseBudget budget;
      42                 :             :     auto parts =
      43                 :             :         splitMultipartBody(bodyBlock, boundary, remainingParts(budget),
      44   [ +  -  +  - ]:          39 :                            remainingMaterializedBytes(budget));
      45   [ +  -  +  -  :         116 :     for (const auto &part : parts) {
                   +  + ]
      46   [ +  -  -  + ]:          77 :       if (!consumePartBudget(part, budget)) {
      47                 :           0 :         break;
      48                 :             :       }
      49         [ +  - ]:          77 :       parsePart(part, result, budget);
      50                 :             :     }
      51         [ +  + ]:          40 :   } else {
      52                 :             :     // Single-part message
      53         [ +  - ]:          36 :     QByteArray decoded = decodeTransferEncoding(bodyBlock, transferEncoding);
      54         [ +  - ]:          36 :     QString charset = extractCharset(contentType);
      55                 :             : 
      56   [ +  -  +  + ]:          36 :     if (ctLower.startsWith(QStringLiteral("text/html"))) {
      57         [ +  - ]:           7 :       result.textHtml = convertCharset(decoded, charset);
      58   [ +  -  +  + ]:          29 :     } else if (ctLower.startsWith(QStringLiteral("text/"))) {
      59         [ +  - ]:          26 :       result.textPlain = convertCharset(decoded, charset);
      60                 :             :     } else {
      61                 :             :       // Non-text single part (rare but possible)
      62                 :           3 :       MimePart part;
      63   [ +  -  +  -  :           3 :       part.contentType = contentType.section(';', 0, 0).trimmed().toLower();
                   +  - ]
      64                 :           3 :       part.body = decoded;
      65                 :           3 :       part.isAttachment = true;
      66         [ +  - ]:           6 :       part.filename = decodeAndSanitizeFilename(
      67         [ +  - ]:          12 :           headers.value(QStringLiteral("content-disposition")), contentType);
      68         [ +  - ]:           3 :       result.attachments.append(part);
      69                 :           3 :     }
      70                 :          36 :   }
      71                 :             : 
      72                 :          75 :   return result;
      73                 :          76 : }
      74                 :             : 
      75                 :             : // --- Encoding utilities ---
      76                 :             : 
      77                 :          29 : QByteArray MimeParser::decodeQuotedPrintable(const QByteArray &input) {
      78                 :          29 :   QByteArray output;
      79         [ +  - ]:          29 :   output.reserve(input.size());
      80                 :             : 
      81         [ +  + ]:     7544938 :   for (int i = 0; i < input.size(); ++i) {
      82                 :     7544909 :     char c = input.at(i);
      83         [ +  + ]:     7544909 :     if (c == '=') {
      84                 :             :       // Check for soft line break: =\r\n or =\n
      85   [ +  -  -  +  :      280029 :       if (i + 1 < input.size() && input.at(i + 1) == '\n') {
                   -  + ]
      86                 :           0 :         i += 1; // skip \n
      87                 :           0 :         continue;
      88                 :             :       }
      89   [ +  -  +  +  :      280031 :       if (i + 2 < input.size() && input.at(i + 1) == '\r' &&
             +  -  +  + ]
      90                 :           2 :           input.at(i + 2) == '\n') {
      91                 :           2 :         i += 2; // skip \r\n
      92                 :           2 :         continue;
      93                 :             :       }
      94                 :             :       // Hex-encoded byte: =XX
      95         [ +  - ]:      280027 :       if (i + 2 < input.size()) {
      96                 :      280027 :         char hi = input.at(i + 1);
      97                 :      280027 :         char lo = input.at(i + 2);
      98                 :      280027 :         bool okHi = false, okLo = false;
      99   [ +  -  +  - ]:      280027 :         int hiVal = QByteArray(1, hi).toInt(&okHi, 16);
     100   [ +  -  +  - ]:      280027 :         int loVal = QByteArray(1, lo).toInt(&okLo, 16);
     101   [ +  -  +  - ]:      280027 :         if (okHi && okLo) {
     102         [ +  - ]:      280027 :           output.append(static_cast<char>((hiVal << 4) | loVal));
     103                 :      280027 :           i += 2;
     104                 :      280027 :           continue;
     105                 :             :         }
     106                 :             :       }
     107                 :             :       // Malformed =, pass through
     108         [ #  # ]:           0 :       output.append(c);
     109                 :             :     } else {
     110         [ +  - ]:     7264880 :       output.append(c);
     111                 :             :     }
     112                 :             :   }
     113                 :             : 
     114                 :          29 :   return output;
     115                 :           0 : }
     116                 :             : 
     117                 :        1116 : QByteArray MimeParser::decodeTransferEncoding(const QByteArray &data,
     118                 :             :                                               const QString &encoding) {
     119   [ +  -  +  - ]:        1116 :   QString enc = encoding.trimmed().toLower();
     120         [ +  + ]:        1116 :   if (enc == QStringLiteral("quoted-printable")) {
     121         [ +  - ]:          25 :     return decodeQuotedPrintable(data);
     122                 :             :   }
     123         [ +  + ]:        1091 :   if (enc == QStringLiteral("base64")) {
     124         [ +  - ]:          30 :     return QByteArray::fromBase64(data);
     125                 :             :   }
     126                 :             :   // 7bit, 8bit, binary → no transformation
     127                 :        1061 :   return data;
     128                 :        1116 : }
     129                 :             : 
     130                 :          83 : QString MimeParser::convertCharset(const QByteArray &data,
     131                 :             :                                    const QString &charset) {
     132         [ +  + ]:          83 :   if (data.isEmpty()) {
     133                 :           3 :     return {};
     134                 :             :   }
     135                 :             : 
     136   [ +  -  +  - ]:          80 :   QString cs = charset.trimmed().toLower();
     137   [ +  +  +  -  :         166 :   if (cs.isEmpty() || cs == QStringLiteral("utf-8") ||
             +  -  +  + ]
     138   [ +  -  -  +  :         166 :       cs == QStringLiteral("us-ascii") || cs == QStringLiteral("ascii")) {
          +  +  +  +  +  
             +  +  +  +  
                      - ]
     139         [ +  - ]:          74 :     return QString::fromUtf8(data);
     140                 :             :   }
     141                 :             : 
     142                 :             :   // Try QStringDecoder for the given charset
     143   [ +  -  +  - ]:           6 :   auto decoder = QStringDecoder(cs.toLatin1().constData());
     144         [ +  - ]:           6 :   if (decoder.isValid()) {
     145         [ +  - ]:           6 :     return decoder.decode(data);
     146                 :             :   }
     147                 :             : 
     148                 :             :   // Fallback: try Latin-1
     149   [ #  #  #  #  :           0 :   qCWarning(lcMime) << "Unknown charset" << charset
          #  #  #  #  #  
                      # ]
     150         [ #  # ]:           0 :                     << ", falling back to Latin-1";
     151         [ #  # ]:           0 :   return QString::fromLatin1(data);
     152                 :          80 : }
     153                 :             : 
     154                 :             : // --- Private helpers ---
     155                 :             : 
     156                 :             : QPair<QByteArray, QByteArray>
     157                 :        1207 : MimeParser::splitHeaderBody(const QByteArray &raw) {
     158                 :             :   // Headers and body are separated by a blank line (\r\n\r\n or \n\n)
     159                 :        1207 :   int sep = raw.indexOf("\r\n\r\n");
     160         [ +  - ]:        1207 :   if (sep >= 0) {
     161   [ +  -  +  - ]:        1207 :     return {raw.left(sep), raw.mid(sep + 4)};
     162                 :             :   }
     163                 :             : 
     164                 :           0 :   sep = raw.indexOf("\n\n");
     165         [ #  # ]:           0 :   if (sep >= 0) {
     166   [ #  #  #  # ]:           0 :     return {raw.left(sep), raw.mid(sep + 2)};
     167                 :             :   }
     168                 :             : 
     169                 :             :   // No blank line → entire message is headers (no body)
     170                 :           0 :   return {raw, {}};
     171                 :             : }
     172                 :             : 
     173                 :        1207 : QMap<QString, QString> MimeParser::parseHeaders(const QByteArray &headerBlock) {
     174                 :        1207 :   QMap<QString, QString> headers;
     175         [ -  + ]:        1207 :   if (headerBlock.isEmpty()) {
     176                 :           0 :     return headers;
     177                 :             :   }
     178                 :             : 
     179                 :        1207 :   QString currentKey;
     180                 :        1207 :   QString currentValue;
     181                 :             : 
     182                 :             :   // Split by lines, handling both \r\n and \n
     183                 :        1207 :   QByteArray normalized = headerBlock;
     184         [ +  - ]:        1207 :   normalized.replace("\r\n", "\n");
     185         [ +  - ]:        1207 :   auto lines = normalized.split('\n');
     186                 :             : 
     187   [ +  -  +  -  :        3745 :   for (const auto &lineBytes : lines) {
                   +  + ]
     188         [ +  - ]:        2538 :     QString line = QString::fromUtf8(lineBytes);
     189                 :             : 
     190                 :             :     // Continuation line: starts with space or tab
     191   [ +  -  +  -  :        2538 :     if (!line.isEmpty() && (line.at(0) == ' ' || line.at(0) == '\t')) {
             +  +  +  + ]
     192         [ +  - ]:           4 :       if (!currentKey.isEmpty()) {
     193   [ +  -  +  -  :           4 :         currentValue += ' ' + line.trimmed();
                   +  - ]
     194                 :             :       }
     195                 :           4 :       continue;
     196                 :             :     }
     197                 :             : 
     198                 :             :     // Save previous header
     199         [ +  + ]:        2534 :     if (!currentKey.isEmpty()) {
     200         [ +  - ]:        1327 :       headers.insert(currentKey, currentValue);
     201                 :             :     }
     202                 :             : 
     203                 :             :     // Parse new header line: "Key: Value"
     204                 :        2534 :     int colonPos = line.indexOf(':');
     205         [ +  - ]:        2534 :     if (colonPos > 0) {
     206   [ +  -  +  -  :        2534 :       currentKey = line.left(colonPos).trimmed().toLower();
                   +  - ]
     207   [ +  -  +  - ]:        2534 :       currentValue = line.mid(colonPos + 1).trimmed();
     208                 :             :     } else {
     209                 :           0 :       currentKey.clear();
     210                 :           0 :       currentValue.clear();
     211                 :             :     }
     212         [ +  + ]:        2538 :   }
     213                 :             : 
     214                 :             :   // Save last header
     215         [ +  - ]:        1207 :   if (!currentKey.isEmpty()) {
     216         [ +  - ]:        1207 :     headers.insert(currentKey, currentValue);
     217                 :             :   }
     218                 :             : 
     219                 :        1207 :   return headers;
     220                 :        1207 : }
     221                 :             : 
     222                 :          94 : QByteArray MimeParser::extractBoundary(const QString &contentType) {
     223         [ +  - ]:          94 :   QString boundary = extractParam(contentType, QStringLiteral("boundary"));
     224         [ +  - ]:         188 :   return boundary.toUtf8();
     225                 :          94 : }
     226                 :             : 
     227                 :        1113 : QString MimeParser::extractCharset(const QString &contentType) {
     228         [ +  - ]:        1113 :   QString charset = extractParam(contentType, QStringLiteral("charset"));
     229         [ +  + ]:        1113 :   if (charset.isEmpty()) {
     230                 :        1042 :     return QStringLiteral("utf-8"); // Default
     231                 :             :   }
     232                 :             :   // Remove surrounding quotes
     233   [ +  -  -  +  :          71 :   if (charset.startsWith('"') && charset.endsWith('"')) {
          -  -  -  -  -  
                      + ]
     234         [ #  # ]:           0 :     charset = charset.mid(1, charset.length() - 2);
     235                 :             :   }
     236                 :          71 :   return charset;
     237                 :        1113 : }
     238                 :             : 
     239                 :        2296 : QString MimeParser::extractParam(const QString &headerValue,
     240                 :             :                                  const QString &param) {
     241                 :             :   // Look for param="value" or param=value in the header
     242         [ +  - ]:        2296 :   QString lower = headerValue.toLower();
     243   [ +  -  +  - ]:        2296 :   QString search = param.toLower() + '=';
     244                 :             : 
     245         [ +  - ]:        2296 :   int pos = lower.indexOf(search);
     246                 :             : 
     247                 :             :   // Bug 30: Ensure we matched a full parameter name, not a substring
     248                 :             :   // (e.g. "xboundary=" should not match when searching for "boundary=")
     249                 :             :   // Also skip if we matched "param*=" (RFC 2231) — handled below
     250   [ +  +  +  + ]:        2296 :   if (pos >= 0 && pos > 0) {
     251                 :        1199 :     QChar before = lower.at(pos - 1);
     252   [ +  +  +  +  :        1199 :     if (before != ';' && !before.isSpace()) {
                   +  + ]
     253                 :           1 :       pos = -1; // not a real match
     254   [ +  -  +  -  :        1198 :     } else if (before == '*' || (pos > 0 && lower.at(pos - 1) == '*')) {
             -  +  -  + ]
     255                 :           0 :       pos = -1; // this is param*= (RFC 2231), not param=
     256                 :             :     }
     257                 :             :   }
     258                 :             :   // Check if the char right before '=' is '*' (param*=)
     259   [ +  +  +  -  :        2296 :   if (pos >= 0 && pos + search.length() <= lower.length()) {
                   +  + ]
     260                 :             :     // Verify we didn't match "filename" inside "filename*="
     261                 :             :     // search = "filename=", but actual text might have "filename*="
     262                 :             :     // Check: is the char at pos + param.length() actually '=' and not '*'?
     263                 :        1199 :     int eqPos = pos + param.length();
     264   [ +  -  -  +  :        1199 :     if (eqPos < lower.length() && lower.at(eqPos) != '=') {
                   -  + ]
     265                 :           0 :       pos = -1;
     266                 :             :     }
     267                 :             :   }
     268                 :             : 
     269                 :        2296 :   QString value;
     270         [ +  + ]:        2296 :   if (pos >= 0) {
     271                 :        1199 :     int valueStart = pos + search.length();
     272         [ +  - ]:        1199 :     if (valueStart < headerValue.length()) {
     273         [ +  + ]:        1199 :       if (headerValue.at(valueStart) == '"') {
     274                 :             :         // Quoted value
     275                 :        1159 :         int endQuote = headerValue.indexOf('"', valueStart + 1);
     276         [ +  - ]:        1159 :         if (endQuote > valueStart) {
     277         [ +  - ]:        1159 :           value = headerValue.mid(valueStart + 1, endQuote - valueStart - 1);
     278                 :             :         }
     279                 :             :       } else {
     280                 :             :         // Unquoted value: ends at ; or end of string
     281                 :          40 :         int end = headerValue.indexOf(';', valueStart);
     282         [ +  + ]:          40 :         if (end < 0)
     283                 :          38 :           end = headerValue.length();
     284   [ +  -  +  - ]:          40 :         value = headerValue.mid(valueStart, end - valueStart).trimmed();
     285                 :             :       }
     286                 :             :     }
     287                 :             :   }
     288                 :             : 
     289                 :             :   // Bug 31: Try RFC 2231 encoded form (param*=charset'language'value)
     290         [ +  + ]:        2296 :   if (value.isEmpty()) {
     291   [ +  -  +  - ]:        1097 :     QString rfc2231Search = param.toLower() + QStringLiteral("*=");
     292         [ +  - ]:        1097 :     int rfc2231Pos = lower.indexOf(rfc2231Search);
     293         [ +  + ]:        1097 :     if (rfc2231Pos >= 0) {
     294                 :             :       // Boundary check for RFC 2231 too
     295   [ +  -  +  -  :           6 :       if (rfc2231Pos == 0 || lower.at(rfc2231Pos - 1) == ';' ||
                   +  - ]
     296         [ +  - ]:           6 :           lower.at(rfc2231Pos - 1).isSpace()) {
     297                 :           3 :         int vStart = rfc2231Pos + rfc2231Search.length();
     298                 :           3 :         int vEnd = headerValue.indexOf(';', vStart);
     299         [ +  - ]:           3 :         if (vEnd < 0) vEnd = headerValue.length();
     300   [ +  -  +  - ]:           3 :         QString encoded = headerValue.mid(vStart, vEnd - vStart).trimmed();
     301                 :             :         // Format: charset'language'encoded_value (e.g. UTF-8''file%20name.pdf)
     302                 :           3 :         int firstTick = encoded.indexOf('\'');
     303                 :           3 :         int secondTick = encoded.indexOf('\'', firstTick + 1);
     304   [ +  -  +  - ]:           3 :         if (firstTick >= 0 && secondTick > firstTick) {
     305         [ +  - ]:           6 :           value = QUrl::fromPercentEncoding(
     306   [ +  -  +  - ]:           9 :               encoded.mid(secondTick + 1).toUtf8());
     307                 :             :         }
     308                 :           3 :       }
     309                 :             :     }
     310                 :        1097 :   }
     311                 :             : 
     312                 :        2296 :   return value;
     313                 :        2296 : }
     314                 :             : 
     315                 :        1080 : QString MimeParser::extractFilename(const QString &contentDisposition,
     316                 :             :                                     const QString &contentType) {
     317                 :        1080 :   QString fn;
     318                 :             :   // Try Content-Disposition first
     319         [ +  + ]:        1080 :   if (!contentDisposition.isEmpty()) {
     320         [ +  - ]:        1031 :     fn = extractParam(contentDisposition, QStringLiteral("filename"));
     321                 :             :   }
     322                 :             : 
     323                 :             :   // Fallback: Content-Type name= parameter
     324   [ +  +  +  -  :        1080 :   if (fn.isEmpty() && !contentType.isEmpty()) {
                   +  + ]
     325         [ +  - ]:          50 :     fn = extractParam(contentType, QStringLiteral("name"));
     326                 :             :   }
     327                 :             : 
     328                 :        1080 :   return fn;
     329                 :           0 : }
     330                 :             : 
     331                 :        1080 : QString MimeParser::decodeAndSanitizeFilename(
     332                 :             :     const QString &contentDisposition, const QString &contentType) {
     333         [ +  - ]:        2160 :   return sanitizeFilename(ImapResponseParser::decodeRfc2047(
     334   [ +  -  +  - ]:        3240 :       extractFilename(contentDisposition, contentType)));
     335                 :             : }
     336                 :             : 
     337                 :        1080 : QString MimeParser::sanitizeFilename(QString filename) {
     338                 :             :   // T-516/L3: Path traversal protection — strip directory components
     339                 :             :   // Only keep the base filename (after the last / or \)
     340                 :        1080 :   int lastSlash = filename.lastIndexOf('/');
     341                 :        1080 :   int lastBackslash = filename.lastIndexOf('\\');
     342                 :        1080 :   int lastSep = qMax(lastSlash, lastBackslash);
     343         [ +  + ]:        1080 :   if (lastSep >= 0)
     344         [ +  - ]:           3 :     filename = filename.mid(lastSep + 1);
     345                 :             : 
     346                 :             :   // Remove leading dots to prevent hidden files or .. components
     347   [ +  -  +  + ]:        1083 :   while (filename.startsWith('.'))
     348         [ +  - ]:           3 :     filename = filename.mid(1);
     349                 :             : 
     350                 :        2160 :   return filename;
     351                 :             : }
     352                 :             : 
     353                 :          93 : int MimeParser::remainingParts(const ParseBudget &budget) {
     354                 :          93 :   return qMax(0, kMaxTotalParts - budget.totalParts);
     355                 :             : }
     356                 :             : 
     357                 :          93 : qint64 MimeParser::remainingMaterializedBytes(const ParseBudget &budget) {
     358                 :         186 :   return qMax<qint64>(0,
     359                 :         186 :                       kMaxMaterializedPartBytes -
     360                 :          93 :                           budget.materializedPartBytes);
     361                 :             : }
     362                 :             : 
     363                 :        1132 : bool MimeParser::consumePartBudget(const QByteArray &partData,
     364                 :             :                                    ParseBudget &budget) {
     365         [ -  + ]:        1132 :   if (budget.totalParts >= kMaxTotalParts) {
     366   [ #  #  #  #  :           0 :     qCWarning(lcMime) << "MIME total parts limit exceeded" << kMaxTotalParts
          #  #  #  #  #  
                      # ]
     367         [ #  # ]:           0 :                       << "- skipping remaining";
     368                 :           0 :     return false;
     369                 :             :   }
     370                 :             : 
     371         [ -  + ]:        1132 :   if (budget.materializedPartBytes + partData.size() >
     372                 :             :       kMaxMaterializedPartBytes) {
     373   [ #  #  #  #  :           0 :     qCWarning(lcMime) << "MIME materialized part byte limit exceeded"
             #  #  #  # ]
     374   [ #  #  #  # ]:           0 :                       << kMaxMaterializedPartBytes << "- skipping remaining";
     375                 :           0 :     return false;
     376                 :             :   }
     377                 :             : 
     378                 :        1132 :   ++budget.totalParts;
     379                 :        1132 :   budget.materializedPartBytes += partData.size();
     380                 :        1132 :   return true;
     381                 :             : }
     382                 :             : 
     383                 :          93 : QList<QByteArray> MimeParser::splitMultipartBody(const QByteArray &body,
     384                 :             :                                                  const QByteArray &boundary,
     385                 :             :                                                  int maxParts,
     386                 :             :                                                  qint64 maxPartBytes) {
     387                 :          93 :   QList<QByteArray> parts;
     388   [ +  -  -  + ]:          93 :   if (maxParts == 0 || maxPartBytes == 0) {
     389                 :           0 :     return parts;
     390                 :             :   }
     391                 :             : 
     392                 :          93 :   qint64 materializedBytes = 0;
     393                 :             : 
     394                 :        1133 :   auto appendPart = [&](const QByteArray &part) {
     395   [ +  -  +  +  :        1133 :     if (maxParts >= 0 && parts.size() >= maxParts) {
                   +  + ]
     396   [ +  -  +  -  :           2 :       qCWarning(lcMime) << "MIME multipart split part limit reached"
             +  -  +  + ]
     397   [ +  -  +  - ]:           1 :                         << maxParts << "- skipping remaining";
     398                 :           1 :       return false;
     399                 :             :     }
     400   [ +  -  -  +  :        1132 :     if (maxPartBytes >= 0 && materializedBytes + part.size() > maxPartBytes) {
                   -  + ]
     401   [ #  #  #  #  :           0 :       qCWarning(lcMime) << "MIME multipart split byte limit reached"
             #  #  #  # ]
     402   [ #  #  #  # ]:           0 :                         << maxPartBytes << "- skipping remaining";
     403                 :           0 :       return false;
     404                 :             :     }
     405                 :             : 
     406                 :        1132 :     materializedBytes += part.size();
     407                 :        1132 :     parts.append(part);
     408                 :        1132 :     return true;
     409                 :          93 :   };
     410                 :             : 
     411                 :             :   // MIME boundaries are prefixed with "--"
     412         [ +  - ]:          93 :   QByteArray delimiter = "--" + boundary;
     413         [ +  - ]:          93 :   QByteArray finalDelimiter = delimiter + "--";
     414                 :             : 
     415                 :          93 :   int start = body.indexOf(delimiter);
     416         [ -  + ]:          93 :   if (start < 0) {
     417                 :           0 :     return parts; // No boundary found
     418                 :             :   }
     419                 :             : 
     420                 :             :   // Skip preamble: advance past the first boundary line
     421                 :          93 :   start += delimiter.size();
     422                 :             :   // Skip the rest of the boundary line (\r\n or \n)
     423   [ +  -  +  -  :          93 :   if (start < body.size() && body.at(start) == '\r')
                   +  - ]
     424                 :          93 :     ++start;
     425   [ +  -  +  -  :          93 :   if (start < body.size() && body.at(start) == '\n')
                   +  - ]
     426                 :          93 :     ++start;
     427                 :             : 
     428         [ +  - ]:        1133 :   while (start < body.size()) {
     429                 :             :     // Find the next boundary
     430                 :        1133 :     int end = body.indexOf(delimiter, start);
     431         [ -  + ]:        1133 :     if (end < 0) {
     432                 :             :       // No more boundaries → rest is part (shouldn't happen in valid MIME)
     433   [ #  #  #  # ]:           0 :       appendPart(body.mid(start));
     434                 :           0 :       break;
     435                 :             :     }
     436                 :             : 
     437                 :             :     // The part is everything between start and end
     438                 :             :     // Remove trailing \r\n before the boundary
     439                 :        1133 :     int partEnd = end;
     440   [ +  -  +  -  :        1133 :     if (partEnd > start && body.at(partEnd - 1) == '\n')
                   +  - ]
     441                 :        1133 :       --partEnd;
     442   [ +  -  +  -  :        1133 :     if (partEnd > start && body.at(partEnd - 1) == '\r')
                   +  - ]
     443                 :        1133 :       --partEnd;
     444                 :             : 
     445   [ +  -  +  -  :        1133 :     if (!appendPart(body.mid(start, partEnd - start))) {
                   +  + ]
     446                 :           1 :       break;
     447                 :             :     }
     448                 :             : 
     449                 :             :     // Check if this is the final boundary (--)
     450                 :        1132 :     int afterDelim = end + delimiter.size();
     451   [ +  -  +  +  :        1224 :     if (afterDelim + 1 < body.size() && body.at(afterDelim) == '-' &&
             +  -  +  + ]
     452                 :          92 :         body.at(afterDelim + 1) == '-') {
     453                 :          92 :       break; // Final boundary, ignore epilogue
     454                 :             :     }
     455                 :             : 
     456                 :             :     // Skip past boundary line
     457                 :        1040 :     start = afterDelim;
     458   [ +  -  +  -  :        1040 :     if (start < body.size() && body.at(start) == '\r')
                   +  - ]
     459                 :        1040 :       ++start;
     460   [ +  -  +  -  :        1040 :     if (start < body.size() && body.at(start) == '\n')
                   +  - ]
     461                 :        1040 :       ++start;
     462                 :             :   }
     463                 :             : 
     464                 :          93 :   return parts;
     465                 :          93 : }
     466                 :             : 
     467                 :        1132 : void MimeParser::parsePart(const QByteArray &partData, MimeMessage &result,
     468                 :             :                            ParseBudget &budget, int depth) {
     469         [ -  + ]:        1132 :   if (partData.isEmpty()) {
     470                 :         102 :     return;
     471                 :             :   }
     472                 :             : 
     473                 :             :   // T-405/Bug 19: Prevent stack overflow from deeply nested MIME
     474         [ +  + ]:        1132 :   if (depth >= kMaxMimeDepth) {
     475   [ +  -  +  -  :           2 :     qCWarning(lcMime) << "MIME nesting depth exceeded" << kMaxMimeDepth
          +  -  +  -  +  
                      + ]
     476         [ +  - ]:           1 :                       << "— skipping further parts";
     477                 :           1 :     return;
     478                 :             :   }
     479                 :             : 
     480         [ +  - ]:        1131 :   auto [headerBlock, bodyBlock] = splitHeaderBody(partData);
     481         [ +  - ]:        1131 :   auto headers = parseHeaders(headerBlock);
     482                 :             : 
     483                 :        2262 :   QString contentType = headers.value(QStringLiteral("content-type"),
     484         [ +  - ]:        3393 :                                       QStringLiteral("text/plain"));
     485                 :             :   QString transferEncoding = headers.value(
     486         [ +  - ]:        2262 :       QStringLiteral("content-transfer-encoding"), QStringLiteral("7bit"));
     487                 :             :   QString contentDisposition =
     488         [ +  - ]:        2262 :       headers.value(QStringLiteral("content-disposition"));
     489         [ +  - ]:        2262 :   QString contentId = headers.value(QStringLiteral("content-id"));
     490                 :             : 
     491                 :             :   // Clean up Content-ID: remove angle brackets
     492   [ +  -  +  +  :        1131 :   if (contentId.startsWith('<') && contentId.endsWith('>')) {
          +  -  +  -  +  
                      + ]
     493         [ +  - ]:           2 :     contentId = contentId.mid(1, contentId.length() - 2);
     494                 :             :   }
     495                 :             : 
     496         [ +  - ]:        1131 :   QString ctLower = contentType.toLower();
     497                 :             : 
     498                 :             :   // Recursive: this part is itself multipart
     499   [ +  -  +  + ]:        1131 :   if (ctLower.contains(QStringLiteral("multipart/"))) {
     500         [ +  - ]:          54 :     QByteArray boundary = extractBoundary(contentType);
     501         [ +  - ]:          54 :     if (!boundary.isEmpty()) {
     502                 :             :       auto subParts =
     503                 :             :           splitMultipartBody(bodyBlock, boundary, remainingParts(budget),
     504   [ +  -  +  - ]:          54 :                              remainingMaterializedBytes(budget));
     505   [ +  -  +  -  :        1109 :       for (const auto &sub : subParts) {
                   +  + ]
     506   [ +  -  -  + ]:        1055 :         if (!consumePartBudget(sub, budget)) {
     507                 :           0 :           break;
     508                 :             :         }
     509         [ +  - ]:        1055 :         parsePart(sub, result, budget, depth + 1);
     510                 :             :       }
     511                 :          54 :     }
     512                 :          54 :     return;
     513                 :          54 :   }
     514                 :             : 
     515                 :             :   // Decode the body
     516         [ +  - ]:        1077 :   QByteArray decoded = decodeTransferEncoding(bodyBlock, transferEncoding);
     517         [ +  - ]:        1077 :   QString charset = extractCharset(contentType);
     518                 :             :   QString filename =
     519         [ +  - ]:        1077 :       decodeAndSanitizeFilename(contentDisposition, contentType);
     520                 :             : 
     521                 :             :   // Determine if this is an attachment.
     522                 :             :   // Note: Content-ID alone does NOT mean attachment for text/* parts.
     523                 :             :   // Many mailers (e.g. LinkedIn) set Content-ID on body parts.
     524                 :             :   bool isExplicitAttachment =
     525   [ +  -  +  -  :        3280 :       contentDisposition.toLower().startsWith(QStringLiteral("attachment")) ||
          +  +  +  -  +  
          -  -  -  -  -  
                   -  - ]
     526   [ -  +  +  - ]:        1126 :       !filename.isEmpty();
     527                 :             :   bool isInlineByContentId =
     528   [ +  +  +  -  :        1081 :       !contentId.isEmpty() && !ctLower.startsWith(QStringLiteral("text/"));
          +  +  +  +  +  
             +  -  -  -  
                      - ]
     529                 :             : 
     530                 :             :   // Text parts (not explicitly marked as attachment)
     531   [ +  -  +  +  :        2154 :   if (ctLower.startsWith(QStringLiteral("text/")) && !isExplicitAttachment) {
          +  +  +  -  +  
          -  +  +  -  -  
                   -  - ]
     532         [ +  - ]:          47 :     QString text = convertCharset(decoded, charset);
     533   [ +  -  +  + ]:          47 :     if (ctLower.startsWith(QStringLiteral("text/html"))) {
     534                 :             :       // Keep first HTML part (or best one from multipart/alternative)
     535         [ +  - ]:          13 :       if (result.textHtml.isEmpty()) {
     536                 :          13 :         result.textHtml = text;
     537                 :             :       }
     538                 :             :     } else {
     539                 :             :       // text/plain or other text types
     540         [ +  - ]:          34 :       if (result.textPlain.isEmpty()) {
     541                 :          34 :         result.textPlain = text;
     542                 :             :       }
     543                 :             :     }
     544                 :          47 :     return;
     545                 :          47 :   }
     546                 :             : 
     547                 :             :   // Everything else is an attachment
     548                 :        1030 :   MimePart part;
     549   [ +  -  +  - ]:        1030 :   part.contentType = ctLower.section(';', 0, 0).trimmed();
     550                 :        1030 :   part.charset = charset;
     551   [ +  -  +  - ]:        1030 :   part.transferEncoding = transferEncoding.trimmed().toLower();
     552                 :        1030 :   part.body = decoded;
     553                 :        1030 :   part.filename = filename;
     554                 :        1030 :   part.contentId = contentId;
     555                 :        1030 :   part.isAttachment = true;
     556                 :             : 
     557         [ +  - ]:        1030 :   result.attachments.append(part);
     558   [ +  +  +  +  :        1878 : }
          +  +  +  +  +  
          +  +  +  +  +  
          +  +  +  +  +  
                      + ]
        

Generated by: LCOV version 2.0-1