service/SmtpService.h
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #pragma once | ||
| 2 | |||
| 3 | #include "SmtpReplies.h" | ||
| 4 | |||
| 5 | #include <QObject> | ||
| 6 | #include <QSslSocket> | ||
| 7 | #include <QStringList> | ||
| 8 | #include <QTimer> | ||
| 9 | |||
| 10 | #include "data/AccountConfig.h" | ||
| 11 | |||
| 12 | // SmtpService handles sending emails via SMTP with STARTTLS or SSL. | ||
| 13 | // Asynchronous: emits sendSuccess/sendFailed signals. | ||
| 14 | class SmtpService : public QObject { | ||
| 15 | 9 | Q_OBJECT | |
| 16 | #ifdef MAILJD_UNIT_TEST | ||
| 17 | friend class TestSmtpService; | ||
| 18 | friend class QuickComposeSafetyTest; | ||
| 19 | #endif | ||
| 20 | |||
| 21 | public: | ||
| 22 | // The DATA bytes for `message`: dot-stuffed lines ending in CRLF and the | ||
| 23 | // terminator line (RFC 5321 §4.5.2). | ||
| 24 | static QByteArray dataPayload(const QByteArray &message); | ||
| 25 | explicit SmtpService(QObject *parent = nullptr); | ||
| 26 | ~SmtpService() override; | ||
| 27 | |||
| 28 | // Shared structural validation for presentation and transport admission. | ||
| 29 | static bool isValidSmtpAddress(const QString &address); | ||
| 30 | |||
| 31 | // Send an email. The message should be a complete RFC 5322 message. | ||
| 32 | // from: envelope sender, recipients: all envelope recipients | ||
| 33 | void sendMail(const SmtpConfig &config, const QString &from, | ||
| 34 | const QStringList &recipients, const QByteArray &message); | ||
| 35 | |||
| 36 | // Sprint 108 S108.7.3 (D-20): with partial recipients, a permanently | ||
| 37 | // refused RCPT (5xx) is recorded and the message still goes to the | ||
| 38 | // accepted ones; only when every recipient is refused does the send fail. | ||
| 39 | // The outbox reads the refused addresses after sendSuccess(). | ||
| 40 | 9 | void setPartialRecipients(bool on) { m_partialRecipients = on; } | |
| 41 | ✗ | QStringList refusedRecipients() const { return m_refusedRecipients; } | |
| 42 | ✗ | QString refusalReply() const { return m_refusalReply; } | |
| 43 | // The reply code of the server line that ended the last attempt (0 for | ||
| 44 | // local failures: connection, TLS, timeout). | ||
| 45 | 9 | int lastReplyCode() const { return m_lastReplyCode; } | |
| 46 | |||
| 47 | #ifdef MAILJD_E2E_TESTING | ||
| 48 | quint16 localPortForE2e() const { | ||
| 49 | return m_socket ? m_socket->localPort() : 0; | ||
| 50 | } | ||
| 51 | #endif | ||
| 52 | |||
| 53 | #ifdef MAILJD_UNIT_TEST | ||
| 54 | static bool ehloResponseSupportsAuthLoginForTest(const QString &response); | ||
| 55 | static bool ehloResponseSupportsStartTlsForTest(const QString &response); | ||
| 56 | #endif | ||
| 57 | |||
| 58 | signals: | ||
| 59 | void sendSuccess(); | ||
| 60 | void sendFailed(const QString &error); | ||
| 61 | void statusMessage(const QString &message); | ||
| 62 | // Sprint 108 S108.2.5 (D-18): the server's RFC 1870 SIZE limit from the | ||
| 63 | // EHLO that precedes authentication (0 = none announced). | ||
| 64 | void sizeLimitAnnounced(const QString &host, qint64 maximumMessageBytes); | ||
| 65 | #ifdef MAILJD_E2E_TESTING | ||
| 66 | // Metadata-only boundary hook. It exposes neither SMTP commands, | ||
| 67 | // addresses, credentials, nor RFC message bytes. | ||
| 68 | void e2eTransportPhase(const QString &phase, quint64 attemptToken); | ||
| 69 | #endif | ||
| 70 | |||
| 71 | private slots: | ||
| 72 | void onConnected(); | ||
| 73 | void onEncrypted(); | ||
| 74 | void onReadyRead(); | ||
| 75 | void onError(QAbstractSocket::SocketError error); | ||
| 76 | |||
| 77 | private: | ||
| 78 | enum class State { | ||
| 79 | Disconnected, | ||
| 80 | Connecting, | ||
| 81 | WaitGreeting, | ||
| 82 | WaitEhlo, | ||
| 83 | WaitStartTls, | ||
| 84 | WaitEhloAfterTls, | ||
| 85 | WaitAuth, | ||
| 86 | WaitAuthUser, | ||
| 87 | WaitAuthPass, | ||
| 88 | WaitAuthPlain, | ||
| 89 | WaitMailFrom, | ||
| 90 | WaitRcptTo, | ||
| 91 | WaitData, | ||
| 92 | WaitDataContent, | ||
| 93 | WaitQuit, | ||
| 94 | }; | ||
| 95 | |||
| 96 | using EhloCapabilities = SmtpReplies::EhloCapabilities; | ||
| 97 | using AuthMechanism = SmtpReplies::AuthMechanism; | ||
| 98 | |||
| 99 | 22 | static EhloCapabilities parseEhloCapabilities(const QString &response) { | |
| 100 | 22 | return SmtpReplies::parseEhlo(response); | |
| 101 | } | ||
| 102 | 7 | static AuthMechanism selectAuthMechanism(const EhloCapabilities &capabilities) { | |
| 103 | 7 | return SmtpReplies::selectAuthMechanism(capabilities); | |
| 104 | } | ||
| 105 | void createSocketForAttempt(); | ||
| 106 | void resetSessionState(); | ||
| 107 | void clearCredentials(); | ||
| 108 | void failSession(const QString &error); | ||
| 109 | void completeSession(); | ||
| 110 | void failResponseTooLarge(); | ||
| 111 | bool appendResponseText(const QString &text); | ||
| 112 | void processResponse(const QString &line); | ||
| 113 | bool beginAuthentication(); | ||
| 114 | bool beginAuthLogin(); | ||
| 115 | bool beginAuthPlain(); | ||
| 116 | void sendAuthPlainPayload(bool initialResponse); | ||
| 117 | void authSucceeded(); | ||
| 118 | void sendCommand(const QString &cmd); | ||
| 119 | #ifdef MAILJD_E2E_TESTING | ||
| 120 | void reportE2eTransportPhase(const QString &phase); | ||
| 121 | #endif | ||
| 122 | void nextRcptTo(); | ||
| 123 | |||
| 124 | QSslSocket *m_socket = nullptr; | ||
| 125 | State m_state = State::Disconnected; | ||
| 126 | SmtpConfig m_config; | ||
| 127 | QString m_from; | ||
| 128 | QStringList m_recipients; | ||
| 129 | QByteArray m_message; | ||
| 130 | int m_rcptIndex = 0; | ||
| 131 | bool m_partialRecipients = false; | ||
| 132 | QStringList m_refusedRecipients; | ||
| 133 | QString m_refusalReply; | ||
| 134 | int m_lastReplyCode = 0; | ||
| 135 | QString m_responseBuffer; | ||
| 136 | EhloCapabilities m_ehloCapabilities; | ||
| 137 | bool m_attemptActive = false; | ||
| 138 | bool m_operationFinished = true; | ||
| 139 | bool m_plainChallengeAnswered = false; | ||
| 140 | quint64 m_attemptGeneration = 0; | ||
| 141 | QTimer *m_timeoutTimer = nullptr; // T-612/SEC-11: Timeout for each SMTP phase | ||
| 142 | static constexpr qint64 MaxResponseBufferSize = 64 * 1024; | ||
| 143 | static constexpr auto EhloClientName = "mailjd.invalid"; | ||
| 144 | // F-01: watchdog intervals of the DATA upload. UploadInactivityTimeoutMs | ||
| 145 | // is restarted by every flushed chunk (bytesWritten) while payload is | ||
| 146 | // still buffered; UploadFinalAcceptTimeoutMs covers the server's own | ||
| 147 | // processing (virus/spam scan) after the terminating "." left the wire. | ||
| 148 | static constexpr int ResponseTimeoutMs = 60 * 1000; | ||
| 149 | static constexpr int UploadInactivityTimeoutMs = 60 * 1000; | ||
| 150 | static constexpr int UploadFinalAcceptTimeoutMs = 300 * 1000; | ||
| 151 | }; | ||
| 152 |