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 ¶m) {
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 : }
+ + + + +
+ + + + +
+ + + + +
+ ]
|