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: 88.6 % 324 287
Test Date: 2026-07-27 17:53:44 Functions: 100.0 % 18 18
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 53.9 % 690 372

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

Generated by: LCOV version 2.0-1