Branch data Line data Source code
1 : : #include "SmtpService.h"
2 : : #include "util/SecureUtil.h"
3 : :
4 : : #include <QHostInfo>
5 : : #include <QLoggingCategory>
6 : : #include <QTimer>
7 : :
8 : : #include "data/AccountConfig.h"
9 : :
10 [ + + + - : 89 : Q_LOGGING_CATEGORY(lcSmtp, "mailjd.smtp")
+ - - - ]
11 : :
12 [ + - ]: 100 : SmtpService::SmtpService(QObject *parent) : QObject(parent) {
13 [ + - + - : 100 : m_socket = new QSslSocket(this);
- + - - ]
14 [ + - ]: 100 : m_socket->setReadBufferSize(MaxResponseBufferSize);
15 [ + - ]: 100 : connect(m_socket, &QSslSocket::connected, this, &SmtpService::onConnected);
16 [ + - ]: 100 : connect(m_socket, &QSslSocket::encrypted, this, &SmtpService::onEncrypted);
17 [ + - ]: 100 : connect(m_socket, &QSslSocket::readyRead, this, &SmtpService::onReadyRead);
18 [ + - ]: 100 : connect(m_socket, &QSslSocket::errorOccurred, this, &SmtpService::onError);
19 : :
20 : : // T-506: Handle SSL certificate errors (were previously silently ignored)
21 : 100 : connect(m_socket, &QSslSocket::sslErrors, this,
22 [ + - ]: 100 : [this](const QList<QSslError> &errors) {
23 [ + + ]: 2 : for (const auto &err : errors)
24 [ + - + - : 2 : qCWarning(lcSmtp) << "SSL error:" << err.errorString();
+ - + - +
- + + ]
25 [ + - ]: 2 : emit sendFailed(QStringLiteral("SSL certificate error"));
26 : 1 : });
27 : :
28 : : // T-612/SEC-11: Timeout timer to prevent hanging on slow/malicious servers
29 [ + - + - : 100 : m_timeoutTimer = new QTimer(this);
- + - - ]
30 [ + - ]: 100 : m_timeoutTimer->setSingleShot(true);
31 [ + - ]: 100 : connect(m_timeoutTimer, &QTimer::timeout, this, [this]() {
32 [ + - + - : 2 : qCWarning(lcSmtp) << "SMTP timeout in state" << static_cast<int>(m_state);
+ - + - +
+ ]
33 : 1 : m_socket->abort();
34 : 1 : m_state = State::Disconnected;
35 [ + - ]: 2 : emit sendFailed(QStringLiteral("SMTP timeout"));
36 : 1 : });
37 : 100 : }
38 : :
39 : 188 : SmtpService::~SmtpService() = default;
40 : :
41 : : #ifdef MAILJD_UNIT_TEST
42 : 6 : bool SmtpService::ehloResponseSupportsAuthLoginForTest(const QString &response) {
43 [ + - ]: 18 : return parseEhloCapabilities(response).authMechanisms.contains(
44 : 18 : QStringLiteral("LOGIN"));
45 : : }
46 : :
47 : 3 : bool SmtpService::ehloResponseSupportsStartTlsForTest(const QString &response) {
48 [ + - ]: 3 : return parseEhloCapabilities(response).supportsStartTls;
49 : : }
50 : : #endif
51 : :
52 : : SmtpService::EhloCapabilities
53 : 15 : SmtpService::parseEhloCapabilities(const QString &response) {
54 : 15 : EhloCapabilities capabilities;
55 [ + - ]: 15 : const auto lines = response.split('\n', Qt::SkipEmptyParts);
56 : :
57 [ + + ]: 47 : for (QString line : lines) {
58 [ + - ]: 32 : line = line.trimmed();
59 [ + - ]: 32 : if (line.length() >= 4) {
60 : 32 : bool hasCode = false;
61 [ + - + - ]: 32 : line.left(3).toInt(&hasCode);
62 [ + - + - : 42 : if (hasCode && (line[3] == QLatin1Char('-') ||
+ + + - ]
63 [ + - + - ]: 42 : line[3] == QLatin1Char(' '))) {
64 [ + - + - ]: 32 : line = line.mid(4).trimmed();
65 : : }
66 : : }
67 : :
68 [ - + ]: 32 : if (line.isEmpty())
69 : 0 : continue;
70 : :
71 : 32 : const int spaceIndex = line.indexOf(QLatin1Char(' '));
72 : 32 : const int equalsIndex = line.indexOf(QLatin1Char('='));
73 : 32 : int delimiterIndex = -1;
74 [ + + + + ]: 32 : if (spaceIndex >= 0 && equalsIndex >= 0) {
75 : 1 : delimiterIndex = qMin(spaceIndex, equalsIndex);
76 : : } else {
77 : 31 : delimiterIndex = qMax(spaceIndex, equalsIndex);
78 : : }
79 : :
80 [ + + + - ]: 64 : const QString key = (delimiterIndex >= 0 ? line.left(delimiterIndex) : line)
81 [ + - ]: 64 : .trimmed()
82 [ + - ]: 32 : .toUpper();
83 : : const QString value =
84 [ + + + - ]: 64 : (delimiterIndex >= 0 ? line.mid(delimiterIndex + 1) : QString())
85 [ + - ]: 64 : .trimmed()
86 [ + - ]: 64 : .toUpper()
87 [ + - ]: 32 : .simplified();
88 : :
89 [ + + ]: 32 : if (key == QLatin1String("STARTTLS")) {
90 : 2 : capabilities.supportsStartTls = true;
91 : 2 : continue;
92 : : }
93 : :
94 [ + + ]: 30 : if (key == QLatin1String("AUTH")) {
95 [ + - ]: 9 : const auto mechanisms = value.split(QLatin1Char(' '), Qt::SkipEmptyParts);
96 [ + + ]: 25 : for (const QString &mechanism : mechanisms) {
97 [ + - ]: 16 : if (!capabilities.authMechanisms.contains(mechanism))
98 [ + - ]: 16 : capabilities.authMechanisms.append(mechanism);
99 : : }
100 : 9 : }
101 [ + + + + : 36 : }
+ + ]
102 : :
103 : 15 : return capabilities;
104 : 15 : }
105 : :
106 : 8 : void SmtpService::resetSessionState() {
107 [ + - ]: 8 : if (m_timeoutTimer)
108 : 8 : m_timeoutTimer->stop();
109 : 8 : m_state = State::Disconnected;
110 : 8 : m_from.clear();
111 : 8 : m_recipients.clear();
112 : 8 : m_message.clear();
113 : 8 : m_rcptIndex = 0;
114 : 8 : m_responseBuffer.clear();
115 : 8 : m_ehloCapabilities = {};
116 : 8 : }
117 : :
118 : 8 : void SmtpService::sendMail(const SmtpConfig &config, const QString &from,
119 : : const QStringList &recipients,
120 : : const QByteArray &message) {
121 : : // Bug 35: Reset socket state from any previous send
122 [ + - - + ]: 16 : if (m_state != State::Disconnected ||
123 [ - + ]: 8 : m_socket->state() != QAbstractSocket::UnconnectedState) {
124 : 0 : m_socket->abort();
125 : : }
126 : 8 : resetSessionState();
127 : :
128 : 8 : m_config = config;
129 : 8 : m_from = from;
130 : 8 : m_recipients = recipients;
131 : 8 : m_message = message;
132 : :
133 [ + - + - ]: 8 : emit statusMessage(tr("Connecting to SMTP server..."));
134 : :
135 : : // T-612/SEC-11: Start connection timeout (30 seconds)
136 : 8 : m_timeoutTimer->start(30 * 1000);
137 : :
138 [ + + ]: 8 : if (config.security == QLatin1String("ssl")) {
139 : 2 : m_state = State::Connecting;
140 [ + - ]: 2 : m_socket->connectToHostEncrypted(config.host, config.port);
141 : : } else {
142 : 6 : m_state = State::WaitGreeting;
143 [ + - ]: 6 : m_socket->connectToHost(config.host, config.port);
144 : : }
145 : 8 : }
146 : :
147 : 5 : void SmtpService::onConnected() {
148 [ + - + - : 10 : qCInfo(lcSmtp) << "Connected to" << m_config.host << ":" << m_config.port;
+ - + - +
- + - +
+ ]
149 : : // For plain/STARTTLS, wait for the server greeting
150 : 5 : }
151 : :
152 : 0 : void SmtpService::onEncrypted() {
153 [ # # # # : 0 : qCInfo(lcSmtp) << "TLS encrypted";
# # # # ]
154 [ # # ]: 0 : if (m_state == State::Connecting) {
155 : : // Direct SSL: wait for greeting after encryption
156 : 0 : m_state = State::WaitGreeting;
157 [ # # ]: 0 : } else if (m_state == State::WaitStartTls) {
158 : : // STARTTLS upgrade done, send EHLO again
159 : 0 : m_state = State::WaitEhloAfterTls;
160 : 0 : m_ehloCapabilities = {};
161 [ # # # # : 0 : sendCommand(QStringLiteral("EHLO ") + QHostInfo::localHostName());
# # ]
162 : : }
163 : 0 : }
164 : :
165 : 34 : void SmtpService::onReadyRead() {
166 [ - + - + ]: 34 : if (!m_socket->canReadLine() &&
167 [ # # ]: 0 : m_socket->bytesAvailable() >= MaxResponseBufferSize) {
168 : 0 : failResponseTooLarge();
169 : 0 : return;
170 : : }
171 : :
172 [ + + ]: 77 : while (m_socket->canReadLine()) {
173 [ + - + - : 43 : QString line = QString::fromUtf8(m_socket->readLine()).trimmed();
+ - ]
174 [ + - + - : 86 : qCDebug(lcSmtp) << "S:" << line;
+ - + - +
+ ]
175 : :
176 : : // Multi-line responses: 250-... continues, 250 ... is final
177 [ + - + - : 43 : if (line.length() >= 4 && line[3] == '-') {
+ + + + ]
178 [ + - + - : 9 : if (!appendResponseText(line + QLatin1Char('\n')))
- + ]
179 : 0 : return;
180 : 9 : continue;
181 : : }
182 [ + - - + ]: 34 : if (!appendResponseText(line))
183 : 0 : return;
184 : 34 : const QString response = m_responseBuffer;
185 : 34 : m_responseBuffer.clear();
186 [ + - ]: 34 : processResponse(response);
187 [ + - + ]: 43 : }
188 : : }
189 : :
190 : 44 : bool SmtpService::appendResponseText(const QString &text) {
191 [ + + ]: 44 : if (m_responseBuffer.size() + text.size() > MaxResponseBufferSize) {
192 : 1 : failResponseTooLarge();
193 : 1 : return false;
194 : : }
195 : 43 : m_responseBuffer += text;
196 : 43 : return true;
197 : : }
198 : :
199 : 1 : void SmtpService::failResponseTooLarge() {
200 [ + - + - : 2 : qCWarning(lcSmtp) << "SMTP response exceeded buffer limit";
+ - + + ]
201 : 1 : m_responseBuffer.clear();
202 : 1 : m_timeoutTimer->stop();
203 : 1 : m_socket->abort();
204 : 1 : m_state = State::Disconnected;
205 [ + - ]: 1 : emit sendFailed(QStringLiteral("SMTP response too large"));
206 : 1 : }
207 : :
208 : 3 : void SmtpService::onError(QAbstractSocket::SocketError error) {
209 : : Q_UNUSED(error)
210 [ + - ]: 3 : m_timeoutTimer->stop(); // T-612: Cancel timeout on socket error
211 [ + - ]: 3 : QString errMsg = m_socket->errorString();
212 [ + - + - : 6 : qCWarning(lcSmtp) << "Socket error:" << errMsg;
+ - + - +
+ ]
213 : 3 : m_state = State::Disconnected;
214 [ + - ]: 3 : emit sendFailed(errMsg);
215 : 3 : }
216 : :
217 : 51 : void SmtpService::processResponse(const QString &line) {
218 : : // T-612/SEC-11: Restart timeout on each server response.
219 : : // Use longer timeout for DATA phase (uploading message body).
220 [ + + ]: 51 : int timeoutMs = (m_state == State::WaitDataContent) ? 120 * 1000 : 60 * 1000;
221 : 51 : m_timeoutTimer->start(timeoutMs);
222 : :
223 [ + - + - ]: 51 : int code = line.left(3).toInt();
224 : :
225 [ + + + + : 51 : switch (m_state) {
+ + + + +
+ + + + ]
226 : 5 : case State::WaitGreeting:
227 [ + + ]: 5 : if (code == 220) {
228 : 4 : m_state = State::WaitEhlo;
229 : : // T-516/L1: Use actual hostname instead of generic 'localhost'
230 [ + - + - : 8 : sendCommand(QStringLiteral("EHLO ") + QHostInfo::localHostName());
+ - ]
231 : : } else {
232 [ + - + - ]: 1 : emit sendFailed(QStringLiteral("Unexpected greeting: ") + line);
233 : 1 : m_socket->close();
234 : 1 : m_state = State::Disconnected;
235 : : }
236 : 5 : break;
237 : :
238 : 6 : case State::WaitEhlo:
239 [ + + ]: 6 : if (code == 250) {
240 [ + - ]: 5 : m_ehloCapabilities = parseEhloCapabilities(line);
241 [ + + ]: 5 : if (m_config.security == QLatin1String("starttls")) {
242 [ + - ]: 1 : if (!m_ehloCapabilities.supportsStartTls) {
243 [ + - ]: 1 : emit sendFailed(QStringLiteral("Server does not support STARTTLS"));
244 : 1 : m_socket->close();
245 : 1 : m_state = State::Disconnected;
246 : 1 : return;
247 : : }
248 : 0 : m_state = State::WaitStartTls;
249 [ # # ]: 0 : sendCommand(QStringLiteral("STARTTLS"));
250 : : } else {
251 : 4 : beginAuthLogin();
252 : : }
253 : : } else {
254 [ + - + - ]: 1 : emit sendFailed(QStringLiteral("EHLO failed: ") + line);
255 : 1 : m_socket->close();
256 : 1 : m_state = State::Disconnected;
257 : : }
258 : 5 : break;
259 : :
260 : 2 : case State::WaitStartTls:
261 [ + + ]: 2 : if (code == 220) {
262 [ - + - - : 1 : if (!m_responseBuffer.isEmpty() || m_socket->bytesAvailable() > 0) {
+ - ]
263 [ + - + - : 2 : qCWarning(lcSmtp) << "Unexpected data before TLS handshake";
+ - + + ]
264 [ + - ]: 1 : emit sendFailed(
265 : 2 : QStringLiteral("Unexpected data before TLS handshake"));
266 : 1 : m_socket->abort();
267 : 1 : m_state = State::Disconnected;
268 : 1 : return;
269 : : }
270 : : // Server accepted STARTTLS, start TLS handshake
271 : 0 : m_socket->startClientEncryption();
272 : : // onEncrypted() will fire
273 : : } else {
274 [ + - + - ]: 1 : emit sendFailed(QStringLiteral("STARTTLS failed: ") + line);
275 : 1 : m_socket->close();
276 : 1 : m_state = State::Disconnected;
277 : : }
278 : 1 : break;
279 : :
280 : 1 : case State::WaitEhloAfterTls:
281 [ + - ]: 1 : if (code == 250) {
282 [ + - ]: 1 : m_ehloCapabilities = parseEhloCapabilities(line);
283 : : // T-506: Verify encryption after STARTTLS before AUTH
284 [ + - ]: 1 : if (!m_socket->isEncrypted()) {
285 [ + - + - : 2 : qCWarning(lcSmtp) << "STARTTLS succeeded but connection not encrypted";
+ - + + ]
286 [ + - ]: 1 : emit sendFailed(QStringLiteral("TLS negotiation failed"));
287 : 1 : m_socket->close();
288 : 1 : m_state = State::Disconnected;
289 : 1 : return;
290 : : }
291 : 0 : beginAuthLogin();
292 : : } else {
293 [ # # # # ]: 0 : emit sendFailed(QStringLiteral("EHLO after TLS failed: ") + line);
294 : 0 : m_socket->close();
295 : 0 : m_state = State::Disconnected;
296 : : }
297 : 0 : break;
298 : :
299 : 3 : case State::WaitAuth:
300 [ + - ]: 3 : if (code == 334) {
301 : : // Server requests username (Base64 encoded "Username:")
302 : 3 : m_state = State::WaitAuthUser;
303 [ + - + - : 6 : qCDebug(lcSmtp) << "C: <base64-username>";
+ - + + ]
304 [ + - ]: 6 : m_socket->write(
305 [ + - + - : 9 : (QString::fromUtf8(m_config.username.toUtf8().toBase64()) +
+ - ]
306 [ + - ]: 9 : QStringLiteral("\r\n"))
307 [ + - ]: 9 : .toUtf8());
308 : : } else {
309 [ # # # # ]: 0 : emit sendFailed(QStringLiteral("AUTH LOGIN failed: ") + line);
310 : 0 : m_socket->close();
311 : 0 : m_state = State::Disconnected;
312 : : }
313 : 3 : break;
314 : :
315 : 4 : case State::WaitAuthUser:
316 [ + - ]: 4 : if (code == 334) {
317 : : // Server requests password (Base64 encoded "Password:")
318 : 4 : m_state = State::WaitAuthPass;
319 [ + - + - : 8 : qCDebug(lcSmtp) << "C: <base64-password>";
+ - + + ]
320 [ + - ]: 8 : m_socket->write(
321 [ + - + - ]: 8 : (QString::fromUtf8(m_config.password.toBase64()) +
322 [ + - ]: 12 : QStringLiteral("\r\n"))
323 [ + - ]: 12 : .toUtf8());
324 : : } else {
325 [ # # # # ]: 0 : emit sendFailed(QStringLiteral("AUTH username rejected: ") + line);
326 : 0 : m_socket->close();
327 : 0 : m_state = State::Disconnected;
328 : : }
329 : 4 : break;
330 : :
331 : 4 : case State::WaitAuthPass:
332 : : // SEC-22: Zero password from memory after AUTH (success or failure)
333 : 4 : SecureUtil::zeroMemory(m_config.password);
334 [ + + ]: 4 : if (code == 235) {
335 : : // Auth successful, start MAIL FROM
336 [ + - + - ]: 3 : emit statusMessage(tr("Authenticated, sending mail..."));
337 : 3 : m_state = State::WaitMailFrom;
338 : : // T-400/Bug 26: Sanitize address to prevent SMTP command injection
339 : 3 : QString safeFrom = m_from;
340 [ + - ]: 3 : safeFrom.remove('\r');
341 [ + - ]: 3 : safeFrom.remove('\n');
342 [ + - ]: 3 : sendCommand(
343 [ + - ]: 9 : QStringLiteral("MAIL FROM:<%1>").arg(safeFrom));
344 : 3 : } else {
345 [ + - ]: 2 : emit sendFailed(QStringLiteral("Authentifizierung fehlgeschlagen: ") +
346 [ + - ]: 1 : line);
347 : 1 : m_socket->close();
348 : 1 : m_state = State::Disconnected;
349 : : }
350 : 4 : break;
351 : :
352 : 4 : case State::WaitMailFrom:
353 [ + + ]: 4 : if (code == 250) {
354 : 3 : m_rcptIndex = 0;
355 : 3 : nextRcptTo();
356 : : } else {
357 [ + - + - ]: 1 : emit sendFailed(QStringLiteral("MAIL FROM rejected: ") + line);
358 : 1 : m_socket->close();
359 : 1 : m_state = State::Disconnected;
360 : : }
361 : 4 : break;
362 : :
363 : 8 : case State::WaitRcptTo:
364 [ + + ]: 8 : if (code == 250) {
365 : 7 : m_rcptIndex++;
366 : 7 : nextRcptTo();
367 : : } else {
368 [ + - + - ]: 1 : emit sendFailed(QStringLiteral("RCPT TO rejected: ") + line);
369 : 1 : m_socket->close();
370 : 1 : m_state = State::Disconnected;
371 : : }
372 : 8 : break;
373 : :
374 : 4 : case State::WaitData:
375 [ + + ]: 4 : if (code == 354) {
376 : : // T-400/Bug 3: RFC 5321 §4.5.2 dot-stuffing
377 : : // Lines starting with '.' must be prefixed with an extra '.'
378 [ + - ]: 3 : QList<QByteArray> lines = m_message.split('\n');
379 [ + - + - : 21 : for (const QByteArray &line : lines) {
+ + ]
380 : 18 : QByteArray trimmed = line;
381 : : // Remove trailing \r if present (we re-add CRLF)
382 [ + - + + ]: 18 : if (trimmed.endsWith('\r'))
383 [ + - ]: 15 : trimmed.chop(1);
384 [ + - - + ]: 18 : if (trimmed.startsWith('.')) {
385 [ # # # # : 0 : m_socket->write("." + trimmed + "\r\n");
# # ]
386 : : } else {
387 [ + - + - ]: 18 : m_socket->write(trimmed + "\r\n");
388 : : }
389 : 18 : }
390 [ + - ]: 3 : m_socket->write(".\r\n");
391 : 3 : m_state = State::WaitDataContent;
392 : 3 : } else {
393 [ + - + - ]: 1 : emit sendFailed(QStringLiteral("DATA rejected: ") + line);
394 : 1 : m_socket->close();
395 : 1 : m_state = State::Disconnected;
396 : : }
397 : 4 : break;
398 : :
399 : 5 : case State::WaitDataContent:
400 [ + + ]: 5 : if (code == 250) {
401 [ + - + - ]: 4 : emit statusMessage(tr("Mail sent successfully!"));
402 : 4 : m_state = State::WaitQuit;
403 [ + - ]: 4 : sendCommand(QStringLiteral("QUIT"));
404 : : } else {
405 [ + - + - ]: 1 : emit sendFailed(QStringLiteral("Message rejected: ") + line);
406 : 1 : m_socket->close();
407 : 1 : m_state = State::Disconnected;
408 : : }
409 : 5 : break;
410 : :
411 : 4 : case State::WaitQuit:
412 : : // 221 or whatever, we're done
413 : 4 : m_timeoutTimer->stop(); // T-612: Stop timeout on success
414 : 4 : m_socket->close();
415 : 4 : m_state = State::Disconnected;
416 : 4 : emit sendSuccess();
417 : 4 : break;
418 : :
419 : 1 : default:
420 : 1 : break;
421 : : }
422 : : }
423 : :
424 : 6 : bool SmtpService::beginAuthLogin() {
425 [ + + ]: 12 : if (!m_ehloCapabilities.authMechanisms.contains(QStringLiteral("LOGIN"))) {
426 [ + - ]: 1 : emit sendFailed(QStringLiteral("Server does not support AUTH LOGIN"));
427 : 1 : m_socket->close();
428 : 1 : m_state = State::Disconnected;
429 : 1 : return false;
430 : : }
431 : :
432 : : // T-506: Verify encryption before sending credentials
433 : : // (skip check for security=none — plaintext AUTH is intentional)
434 [ + + + - : 5 : if (m_config.security != QLatin1String("none") && !m_socket->isEncrypted()) {
+ - + + ]
435 [ + - + - : 2 : qCWarning(lcSmtp) << "Refusing AUTH LOGIN over unencrypted connection";
+ - + + ]
436 [ + - ]: 1 : emit sendFailed(QStringLiteral("Connection is not encrypted"));
437 : 1 : m_socket->close();
438 : 1 : m_state = State::Disconnected;
439 : 1 : return false;
440 : : }
441 : :
442 : 4 : m_state = State::WaitAuth;
443 [ + - ]: 4 : sendCommand(QStringLiteral("AUTH LOGIN"));
444 : 4 : return true;
445 : : }
446 : :
447 : 25 : void SmtpService::sendCommand(const QString &cmd) {
448 [ + - + - : 50 : qCDebug(lcSmtp) << "C:" << (cmd.startsWith("AUTH") ? "AUTH ***" : cmd);
+ - + - +
- + + + -
+ - + + ]
449 [ + - + - : 25 : m_socket->write((cmd + "\r\n").toUtf8());
+ - ]
450 : 25 : }
451 : :
452 : 10 : void SmtpService::nextRcptTo() {
453 [ + + ]: 10 : if (m_rcptIndex < m_recipients.size()) {
454 : 6 : m_state = State::WaitRcptTo;
455 : : // T-400/Bug 26: Sanitize recipient to prevent SMTP command injection
456 [ + - ]: 6 : QString safeRcpt = m_recipients[m_rcptIndex];
457 [ + - ]: 6 : safeRcpt.remove('\r');
458 [ + - ]: 6 : safeRcpt.remove('\n');
459 [ + - ]: 6 : sendCommand(
460 [ + - ]: 18 : QStringLiteral("RCPT TO:<%1>").arg(safeRcpt));
461 : 6 : } else {
462 : : // All recipients sent, start DATA
463 : 4 : m_state = State::WaitData;
464 [ + - ]: 4 : sendCommand(QStringLiteral("DATA"));
465 : : }
466 : 10 : }
|