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