Branch data Line data Source code
1 : : #include "Rfc2822Builder.h"
2 : :
3 : : #include <QFile>
4 : : #include <QFileInfo>
5 : : #include <QHostInfo>
6 : : #include <QLocale>
7 : : #include <QUuid>
8 : :
9 : : // ═══════════════════════════════════════════════════════
10 : : // Header setters
11 : : // ═══════════════════════════════════════════════════════
12 : :
13 : 69 : void Rfc2822Builder::setFrom(const QString &name, const QString &email) {
14 : 69 : m_fromName = name;
15 : 69 : m_fromEmail = email;
16 : 69 : }
17 : :
18 : 69 : void Rfc2822Builder::setTo(const QStringList &recipients) {
19 : 69 : m_to = recipients;
20 : 69 : }
21 : :
22 : 9 : void Rfc2822Builder::setCc(const QStringList &cc) { m_cc = cc; }
23 : :
24 : 12 : void Rfc2822Builder::setBcc(const QStringList &bcc) { m_bcc = bcc; }
25 : :
26 : 69 : void Rfc2822Builder::setSubject(const QString &subject) {
27 : 69 : m_subject = subject;
28 : 69 : }
29 : :
30 : 5 : void Rfc2822Builder::setDate(const QDateTime &date) { m_date = date; }
31 : :
32 : 2 : void Rfc2822Builder::setMessageId(const QString &messageId) {
33 : 2 : m_messageId = messageId;
34 : 2 : }
35 : :
36 : 9 : void Rfc2822Builder::setInReplyTo(const QString &messageId) {
37 : 9 : m_inReplyTo = messageId;
38 : 9 : }
39 : :
40 : 8 : void Rfc2822Builder::setReferences(const QStringList &messageIds) {
41 : 8 : m_references = messageIds;
42 : 8 : }
43 : :
44 : 69 : void Rfc2822Builder::setBodyText(const QString &plainText) {
45 : 69 : m_bodyText = plainText;
46 : 69 : }
47 : :
48 : 8 : void Rfc2822Builder::addAttachment(const Attachment &attachment) {
49 : 8 : m_attachments.append(attachment);
50 : 8 : }
51 : :
52 : 10 : bool Rfc2822Builder::addAttachmentFromFile(const QString &filePath) {
53 [ + - ]: 10 : QFile file(filePath);
54 : : // T-622/FUNC-07: Return false if file cannot be opened
55 [ + - + + ]: 10 : if (!file.open(QIODevice::ReadOnly))
56 : 4 : return false;
57 : :
58 : 6 : Attachment att;
59 [ + - + - ]: 6 : att.filename = QFileInfo(filePath).fileName();
60 [ + - ]: 6 : att.data = file.readAll();
61 [ + - ]: 6 : att.mimeType = "application/octet-stream";
62 [ + - ]: 6 : m_attachments.append(att);
63 : 6 : return true;
64 : 10 : }
65 : :
66 : : // ═══════════════════════════════════════════════════════
67 : : // RFC 2047: Header encoding for non-ASCII
68 : : // ═══════════════════════════════════════════════════════
69 : :
70 : 138 : QByteArray Rfc2822Builder::encodeHeader(const QString &value) {
71 [ + - ]: 138 : QByteArray utf8 = value.toUtf8();
72 : :
73 : : // T-400/Bug 4: Always strip CR/LF to prevent header injection
74 [ + - ]: 138 : utf8.replace('\r', "");
75 [ + - ]: 138 : utf8.replace('\n', "");
76 : :
77 : : // Check if encoding is needed (any non-ASCII?)
78 : 138 : bool needsEncoding = false;
79 [ + - + - : 1314 : for (char c : utf8) {
+ + ]
80 [ + + ]: 1184 : if (static_cast<unsigned char>(c) > 127) {
81 : 8 : needsEncoding = true;
82 : 8 : break;
83 : : }
84 : : }
85 : :
86 [ + + ]: 138 : if (!needsEncoding)
87 : 130 : return utf8;
88 : :
89 : : // RFC 2047 Base64 encoded-word: =?UTF-8?B?<base64>?=
90 : : // Split into chunks if needed (max 75 chars per encoded-word)
91 : 8 : QByteArray encoded;
92 [ + - ]: 8 : const QByteArray prefix = "=?UTF-8?B?";
93 [ + - ]: 8 : const QByteArray suffix = "?=";
94 : : // Max base64 payload to fit in 75 chars: 75 - prefix.size() - suffix.size()
95 : 8 : const int maxPayloadBytes = ((75 - prefix.size() - suffix.size()) / 4) * 3;
96 : :
97 : 8 : int pos = 0;
98 [ + + ]: 16 : while (pos < utf8.size()) {
99 : : // Find a chunk boundary that doesn't split a UTF-8 multibyte sequence
100 : 8 : int chunkEnd = qMin(pos + maxPayloadBytes, utf8.size());
101 : : // Don't split in the middle of a UTF-8 multibyte char
102 [ - + - - : 8 : while (chunkEnd < utf8.size() && chunkEnd > pos &&
- + ]
103 [ # # # # ]: 0 : (static_cast<unsigned char>(utf8[chunkEnd]) & 0xC0) == 0x80) {
104 : 0 : --chunkEnd;
105 : : }
106 [ - + ]: 8 : if (chunkEnd <= pos)
107 : 0 : chunkEnd = qMin(pos + maxPayloadBytes, utf8.size());
108 : :
109 [ + - ]: 8 : QByteArray chunk = utf8.mid(pos, chunkEnd - pos);
110 : :
111 [ - + ]: 8 : if (!encoded.isEmpty())
112 [ # # ]: 0 : encoded += "\r\n "; // Folding whitespace between encoded-words
113 : :
114 [ + - + - : 8 : encoded += prefix + chunk.toBase64() + suffix;
+ - + - ]
115 : 8 : pos = chunkEnd;
116 : 8 : }
117 : :
118 : 8 : return encoded;
119 : 138 : }
120 : :
121 : 76 : QByteArray Rfc2822Builder::encodeAddress(const QString &name,
122 : : const QString &email) {
123 : : // T-400/Bug 4: Sanitize CRLF from email to prevent header injection
124 : 76 : QString safeEmail = email;
125 [ + - ]: 76 : safeEmail.remove('\r');
126 [ + - ]: 76 : safeEmail.remove('\n');
127 : :
128 [ + + ]: 76 : if (name.isEmpty())
129 [ + - ]: 41 : return safeEmail.toUtf8();
130 : :
131 [ + - + - : 35 : return encodeHeader(name) + " <" + safeEmail.toUtf8() + ">";
+ - + - +
- ]
132 : 76 : }
133 : :
134 : 87 : QByteArray Rfc2822Builder::encodeAddressList(const QStringList &addresses) {
135 : : // Each address can be "name <email>" or just "email"
136 : : // Parse and re-encode each
137 : 87 : QByteArray result;
138 [ + + ]: 180 : for (const QString &addr : addresses) {
139 [ + + ]: 93 : if (!result.isEmpty())
140 [ + - ]: 7 : result += ",\r\n ";
141 : :
142 [ + - ]: 93 : QString trimmed = addr.trimmed();
143 : 93 : int ltIdx = trimmed.indexOf('<');
144 : 93 : int gtIdx = trimmed.indexOf('>');
145 : :
146 [ + + + - ]: 93 : if (ltIdx > 0 && gtIdx > ltIdx) {
147 : : // "Display Name <email>"
148 [ + - + - ]: 6 : QString name = trimmed.left(ltIdx).trimmed();
149 : : // Remove surrounding quotes if present
150 [ + - - + : 6 : if (name.startsWith('"') && name.endsWith('"'))
- - - - -
+ ]
151 [ # # ]: 0 : name = name.mid(1, name.size() - 2);
152 [ + - + - ]: 6 : QString email = trimmed.mid(ltIdx + 1, gtIdx - ltIdx - 1).trimmed();
153 [ + - + - ]: 6 : result += encodeAddress(name, email);
154 : 6 : } else {
155 : : // Plain email address — T-509: sanitize CRLF to prevent header injection
156 : 87 : QString safeAddr = trimmed;
157 [ + - ]: 87 : safeAddr.remove('\r');
158 [ + - ]: 87 : safeAddr.remove('\n');
159 [ + - + - ]: 87 : result += safeAddr.toUtf8();
160 : 87 : }
161 : 93 : }
162 : 87 : return result;
163 : 0 : }
164 : :
165 : : // ═══════════════════════════════════════════════════════
166 : : // Quoted-Printable encoding (RFC 2045)
167 : : // ═══════════════════════════════════════════════════════
168 : :
169 : 74 : QByteArray Rfc2822Builder::encodeQuotedPrintable(const QByteArray &data) {
170 : 74 : QByteArray result;
171 : 74 : int lineLen = 0;
172 : :
173 [ + + ]: 1277 : for (int i = 0; i < data.size(); ++i) {
174 : 1203 : unsigned char c = static_cast<unsigned char>(data[i]);
175 : :
176 : : // Handle CRLF pass-through
177 [ + + + - : 1203 : if (c == '\r' && i + 1 < data.size() && data[i + 1] == '\n') {
+ - + + ]
178 [ + - ]: 1 : result += "\r\n";
179 : 1 : lineLen = 0;
180 : 1 : ++i; // Skip the \n
181 : 33 : continue;
182 : : }
183 : :
184 : : // Handle bare LF → CRLF
185 [ + + ]: 1202 : if (c == '\n') {
186 [ + - ]: 32 : result += "\r\n";
187 : 32 : lineLen = 0;
188 : 32 : continue;
189 : : }
190 : :
191 : 1170 : QByteArray encoded;
192 : : // Printable ASCII (33-126) except '=' can be literal
193 [ + + + + : 1170 : if ((c >= 33 && c <= 126 && c != '=') || c == '\t' || c == ' ') {
+ + + - +
+ ]
194 : : // Space/tab at end of line must be encoded
195 : : bool atEol =
196 [ + - ]: 2244 : (i + 1 >= data.size()) ||
197 [ + + + + : 4424 : (i + 1 < data.size() && data[i + 1] == '\r') ||
+ - ]
198 [ + + ]: 2180 : (i + 1 < data.size() && data[i + 1] == '\n');
199 [ + + - + : 1153 : if ((c == ' ' || c == '\t') && atEol) {
- + ]
200 [ # # # # : 0 : encoded = "=" + QByteArray::number(c, 16).toUpper().rightJustified(2, '0');
# # # # ]
201 : : } else {
202 [ + - ]: 1153 : encoded += static_cast<char>(c);
203 : : }
204 : 1153 : } else {
205 [ + - + - : 17 : encoded = "=" + QByteArray::number(c, 16).toUpper().rightJustified(2, '0');
+ - + - ]
206 : : }
207 : :
208 : : // Soft line break if line would exceed 76 chars
209 [ - + ]: 1170 : if (lineLen + encoded.size() > 75) {
210 [ # # ]: 0 : result += "=\r\n";
211 : 0 : lineLen = 0;
212 : : }
213 : :
214 [ + - ]: 1170 : result += encoded;
215 : 1170 : lineLen += encoded.size();
216 : 1170 : }
217 : :
218 : 74 : return result;
219 : 0 : }
220 : :
221 : : // ═══════════════════════════════════════════════════════
222 : : // Message-ID and boundary generation
223 : : // ═══════════════════════════════════════════════════════
224 : :
225 : 64 : QByteArray Rfc2822Builder::generateMessageId() {
226 : : QString uuid =
227 [ + - + - : 64 : QUuid::createUuid().toString(QUuid::WithoutBraces).remove('-');
+ - ]
228 [ + - ]: 64 : QString host = QHostInfo::localHostName();
229 [ - + ]: 64 : if (host.isEmpty())
230 : 0 : host = QStringLiteral("localhost");
231 [ + - + - : 128 : return ('<' + uuid + '@' + host + '>').toUtf8();
+ - + - +
- ]
232 : 64 : }
233 : :
234 : 12 : QByteArray Rfc2822Builder::generateBoundary() const {
235 : : return "----=_Part_" +
236 [ + - ]: 12 : QUuid::createUuid()
237 [ + - ]: 12 : .toString(QUuid::WithoutBraces)
238 [ + - ]: 24 : .remove('-')
239 [ + - + - ]: 24 : .toLatin1();
240 : : }
241 : :
242 : : // ═══════════════════════════════════════════════════════
243 : : // Build the complete RFC-2822 message
244 : : // ═══════════════════════════════════════════════════════
245 : :
246 : 67 : QByteArray Rfc2822Builder::build() const {
247 : 67 : QByteArray msg;
248 : :
249 : : // Date header — required
250 [ + - + + : 67 : QDateTime date = m_date.isValid() ? m_date : QDateTime::currentDateTimeUtc();
+ - ]
251 : : // T-400/Bug 15: Use C locale for RFC 5322 compliant English date names
252 : 67 : msg += "Date: " +
253 [ + - + - : 201 : QLocale(QLocale::C).toString(date, QStringLiteral("ddd, dd MMM yyyy HH:mm:ss")).toUtf8();
+ - + - +
- ]
254 [ + - ]: 67 : int utcOffset = date.offsetFromUtc();
255 [ + - ]: 67 : if (utcOffset == 0) {
256 [ + - ]: 67 : msg += " +0000";
257 : : } else {
258 : 0 : int hours = qAbs(utcOffset) / 3600;
259 : 0 : int mins = (qAbs(utcOffset) % 3600) / 60;
260 [ # # # # ]: 0 : msg += (utcOffset > 0 ? " +" : " -");
261 [ # # # # : 0 : msg += QByteArray::number(hours).rightJustified(2, '0');
# # ]
262 [ # # # # : 0 : msg += QByteArray::number(mins).rightJustified(2, '0');
# # ]
263 : : }
264 [ + - ]: 67 : msg += "\r\n";
265 : :
266 : : // From
267 [ + + ]: 67 : if (!m_fromEmail.isEmpty()) {
268 [ + - + - : 66 : msg += "From: " + encodeAddress(m_fromName, m_fromEmail) + "\r\n";
+ - + - ]
269 : : }
270 : :
271 : : // To
272 [ + + ]: 67 : if (!m_to.isEmpty()) {
273 [ + - + - : 65 : msg += "To: " + encodeAddressList(m_to) + "\r\n";
+ - + - ]
274 : : }
275 : :
276 : : // Cc
277 [ + + ]: 67 : if (!m_cc.isEmpty()) {
278 [ + - + - : 9 : msg += "Cc: " + encodeAddressList(m_cc) + "\r\n";
+ - + - ]
279 : : }
280 : :
281 : : // Bcc — T-502: only include if m_includeBcc is set (IMAP APPEND yes, SMTP no)
282 [ + + + + : 67 : if (!m_bcc.isEmpty() && m_includeBcc) {
+ + ]
283 [ + - + - : 8 : msg += "Bcc: " + encodeAddressList(m_bcc) + "\r\n";
+ - + - ]
284 : : }
285 : :
286 : : // Subject — RFC 2047 encoded if non-ASCII
287 [ + - + - : 67 : msg += "Subject: " + encodeHeader(m_subject) + "\r\n";
+ - + - ]
288 : :
289 : : // Message-ID — T-509: sanitize CRLF to prevent header injection
290 [ + + ]: 67 : if (m_messageId.isEmpty())
291 [ + - + - ]: 64 : m_messageId = QString::fromUtf8(generateMessageId());
292 : 67 : QString safeMessageId = m_messageId;
293 [ + - ]: 67 : safeMessageId.remove('\r');
294 [ + - ]: 67 : safeMessageId.remove('\n');
295 [ + - + - : 67 : msg += "Message-ID: " + safeMessageId.toUtf8() + "\r\n";
+ - + - ]
296 : :
297 : : // In-Reply-To
298 [ + + ]: 67 : if (!m_inReplyTo.isEmpty()) {
299 : : // T-400/Bug 4: Sanitize CRLF from In-Reply-To
300 : 9 : QString safeIrt = m_inReplyTo;
301 [ + - ]: 9 : safeIrt.remove('\r');
302 [ + - ]: 9 : safeIrt.remove('\n');
303 [ + - + - : 9 : msg += "In-Reply-To: " + safeIrt.toUtf8() + "\r\n";
+ - + - ]
304 : 9 : }
305 : :
306 : : // References
307 [ + + ]: 67 : if (!m_references.isEmpty()) {
308 : : // T-400/Bug 4: Sanitize CRLF from References
309 : 8 : QStringList safeRefs;
310 [ + + ]: 21 : for (const QString &ref : m_references) {
311 : 13 : QString safe = ref;
312 [ + - ]: 13 : safe.remove('\r');
313 [ + - ]: 13 : safe.remove('\n');
314 [ + - ]: 13 : safeRefs.append(safe);
315 : 13 : }
316 [ + - + - : 8 : msg += "References: " + safeRefs.join(' ').toUtf8() + "\r\n";
+ - + - +
- ]
317 : 8 : }
318 : :
319 : : // MIME-Version
320 [ + - ]: 67 : msg += "MIME-Version: 1.0\r\n";
321 : :
322 : : // Body + optional attachments
323 [ + + ]: 67 : if (m_attachments.isEmpty()) {
324 : : // Simple text message
325 [ + - ]: 55 : msg += "Content-Type: text/plain; charset=UTF-8\r\n";
326 [ + - ]: 55 : msg += "Content-Transfer-Encoding: quoted-printable\r\n";
327 [ + - ]: 55 : msg += "\r\n";
328 [ + - + - : 55 : msg += encodeQuotedPrintable(m_bodyText.toUtf8());
+ - ]
329 : : } else {
330 : : // Multipart/mixed
331 [ + - ]: 12 : QByteArray boundary = generateBoundary();
332 [ + - ]: 24 : msg += "Content-Type: multipart/mixed;\r\n boundary=\"" + boundary +
333 [ + - + - ]: 12 : "\"\r\n";
334 [ + - ]: 12 : msg += "\r\n";
335 : :
336 : : // Text body part
337 [ + - + - : 12 : msg += "--" + boundary + "\r\n";
+ - ]
338 [ + - ]: 12 : msg += "Content-Type: text/plain; charset=UTF-8\r\n";
339 [ + - ]: 12 : msg += "Content-Transfer-Encoding: quoted-printable\r\n";
340 [ + - ]: 12 : msg += "\r\n";
341 [ + - + - : 12 : msg += encodeQuotedPrintable(m_bodyText.toUtf8()) + "\r\n";
+ - + - ]
342 : :
343 : : // Attachment parts
344 [ + + ]: 26 : for (const auto &att : m_attachments) {
345 [ + - + - : 14 : msg += "--" + boundary + "\r\n";
+ - ]
346 : : // Bug 32: Sanitize filename — quotes corrupt Content-Type/Disposition
347 : 14 : QString safeFilename = att.filename;
348 [ + - ]: 14 : safeFilename.replace('"', '\'');
349 : : // T-614/SEC-13: Sanitize mimeType to prevent CRLF header injection
350 : : QByteArray mime =
351 [ - + - - ]: 14 : att.mimeType.isEmpty() ? "application/octet-stream" : att.mimeType;
352 [ + - ]: 14 : mime.replace('\r', "");
353 [ + - ]: 14 : mime.replace('\n', "");
354 [ + - + - ]: 28 : msg += "Content-Type: " + mime + ";\r\n name=\"" +
355 [ + - + - : 42 : encodeHeader(safeFilename) + "\"\r\n";
+ - + - ]
356 [ + - ]: 14 : msg += "Content-Transfer-Encoding: base64\r\n";
357 : 14 : msg += "Content-Disposition: attachment;\r\n filename=\"" +
358 [ + - + - : 28 : encodeHeader(safeFilename) + "\"\r\n";
+ - + - ]
359 [ + - ]: 14 : msg += "\r\n";
360 : :
361 : : // Base64 with 76-char line wrapping
362 [ + - ]: 14 : QByteArray b64 = att.data.toBase64();
363 [ + + ]: 31 : for (int i = 0; i < b64.size(); i += 76) {
364 [ + - + - : 17 : msg += b64.mid(i, 76) + "\r\n";
+ - ]
365 : : }
366 : 14 : }
367 : :
368 [ + - + - : 12 : msg += "--" + boundary + "--\r\n";
+ - ]
369 : 12 : }
370 : :
371 : 67 : return msg;
372 : 67 : }
|