1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
//! Connection lifecycle: connect, EHLO, capabilities, protocol info.
//!
//! RFC 5321 Section 3.1 (SMTP session initiation), RFC 2033 Section 4.1
//! (LMTP LHLO handshake), RFC 8314 Section 3 (implicit TLS),
//! RFC 3207 (STARTTLS upgrade).
#[allow(clippy::wildcard_imports)]
use super::*;
impl SmtpConnection {
// -----------------------------------------------------------------------
// Connection lifecycle
// -----------------------------------------------------------------------
/// Connect to an SMTP server and perform the initial EHLO handshake
/// (RFC 5321 Section 3.1).
///
/// For `TlsMode::Implicit`, connects over TLS immediately (RFC 8314 Section 3).
/// For `TlsMode::StartTls`, connects in cleartext, performs EHLO, upgrades
/// via STARTTLS (RFC 3207), then performs a second EHLO.
pub async fn connect(
host: &str,
port: u16,
tls_mode: TlsMode,
timeout: Duration,
) -> Result<Self, Error> {
let tls_config = Self::default_tls_config();
Self::connect_inner(host, port, tls_mode, timeout, tls_config, Protocol::Smtp).await
}
/// Connect to an SMTP server with a custom TLS configuration
/// (RFC 5321 Section 3.1).
pub async fn connect_with_tls_config(
host: &str,
port: u16,
tls_mode: TlsMode,
timeout: Duration,
tls_config: Arc<rustls::ClientConfig>,
) -> Result<Self, Error> {
Self::connect_inner(host, port, tls_mode, timeout, tls_config, Protocol::Smtp).await
}
/// Connect to an LMTP server and perform the initial LHLO handshake
/// (RFC 2033 Section 4.1).
///
/// LMTP uses LHLO instead of EHLO and returns per-recipient responses
/// after DATA (RFC 2033 Section 4.2).
pub async fn connect_lmtp(
host: &str,
port: u16,
tls_mode: TlsMode,
timeout: Duration,
) -> Result<Self, Error> {
let tls_config = Self::default_tls_config();
Self::connect_inner(host, port, tls_mode, timeout, tls_config, Protocol::Lmtp).await
}
/// Connect to an LMTP server with a custom TLS configuration
/// (RFC 2033 Section 4.1).
pub async fn connect_lmtp_with_tls_config(
host: &str,
port: u16,
tls_mode: TlsMode,
timeout: Duration,
tls_config: Arc<rustls::ClientConfig>,
) -> Result<Self, Error> {
Self::connect_inner(host, port, tls_mode, timeout, tls_config, Protocol::Lmtp).await
}
/// Return the connection protocol (SMTP or LMTP).
pub fn protocol(&self) -> Protocol {
self.protocol
}
/// Return a snapshot of the server's advertised capabilities from the
/// last EHLO/LHLO.
///
/// Callers should inspect capabilities before choosing protocol paths:
/// e.g., check [`ServerCapabilities::supports_chunking`] before using
/// [`send_bdat`](Self::send_bdat) (RFC 3030), or
/// [`ServerCapabilities::supports_8bitmime`] before declaring
/// `BODY=8BITMIME` (RFC 1652).
///
/// This method acquires the internal mutex; the returned value is an
/// owned clone so no borrow is held after the call returns.
pub async fn capabilities(&self) -> ServerCapabilities {
self.inner.lock().await.capabilities.clone()
}
/// Returns `true` if the server has sent a 421 response, indicating it
/// is shutting down the transmission channel (RFC 5321 Section 3.8).
///
/// Once this returns `true`, no further commands should be sent on this
/// connection — subsequent send attempts will fail immediately.
pub async fn is_shutting_down(&self) -> bool {
self.inner.lock().await.server_shutting_down
}
/// Returns `true` if authentication has been completed on this session
/// (RFC 4954 Section 3).
///
/// Useful for checking session state before attempting to send mail
/// or issue additional AUTH commands.
pub async fn is_authenticated(&self) -> bool {
self.inner.lock().await.authenticated
}
/// RFC 5321 Section 3.8: once a 421 response has been received, the
/// server will close the transmission channel and the client must not
/// attempt further commands on that connection.
pub(super) fn ensure_not_shutting_down(inner: &SmtpInner) -> Result<(), Error> {
if inner.server_shutting_down {
return Err(Error::Protocol(
"connection is shutting down after 421 (RFC 5321 Section 3.8)".into(),
));
}
Ok(())
}
/// Inner connection logic shared by SMTP and LMTP constructors.
///
/// Operates on a bare [`SmtpInner`] during the setup phase (before the
/// mutex is constructed) to avoid lock overhead during handshake.
async fn connect_inner(
host: &str,
port: u16,
tls_mode: TlsMode,
timeout: Duration,
tls_config: Arc<rustls::ClientConfig>,
protocol: Protocol,
) -> Result<Self, Error> {
tokio::time::timeout(timeout, async {
let tcp = TcpStream::connect((host, port)).await?;
let default_ehlo_domain = default_ehlo_domain()?;
let mut inner = match tls_mode {
TlsMode::Implicit => {
// RFC 8314 Section 3: connect directly over TLS.
let server_name = rustls::pki_types::ServerName::try_from(host.to_owned())
.map_err(|e| Error::Protocol(format!("invalid server name: {e}")))?;
let connector = TlsConnector::from(tls_config.clone());
let tls_stream = connector.connect(server_name, tcp).await?;
SmtpInner {
stream: SmtpStream::Tls(Box::new(tls_stream)),
read_buf: BytesMut::with_capacity(4096),
capabilities: ServerCapabilities::default(),
// RFC 5321 Section 4.1.1.1: EHLO argument is the client's
// FQDN, not the server's hostname. When no better
// identity is known, RFC 5321 Section 4.1.4 says to
// substitute an address-literal.
ehlo_domain: default_ehlo_domain.clone(),
authenticated: false,
server_shutting_down: false,
helo_mode: false,
}
}
// TlsMode is #[non_exhaustive]; StartTls and None both start
// with a plaintext TCP stream — future variants would need
// explicit handling here.
TlsMode::StartTls | TlsMode::None | _ => SmtpInner {
stream: SmtpStream::Plain(tcp),
read_buf: BytesMut::with_capacity(4096),
capabilities: ServerCapabilities::default(),
ehlo_domain: default_ehlo_domain,
authenticated: false,
server_shutting_down: false,
helo_mode: false,
},
};
// Read the server greeting (RFC 5321 Section 3.1).
let greeting = inner.read_response().await?;
// RFC 5321 Section 3.1: "the SMTP server issues a positive
// response with the 220 service ready greeting." Only 220
// is a valid greeting code; any other 2xx is a protocol
// violation (compare RFC 3207 Section 4 which also requires
// exactly 220 for STARTTLS).
if greeting.code != 220 {
// RFC 5321 Section 4.1.1.10: the client SHOULD send QUIT
// before closing the connection for any non-220 greeting,
// whether it is a 4xx/5xx rejection or a non-standard 2xx.
let mut quit_buf = BytesMut::new();
encode::encode_quit(&mut quit_buf);
let _ = inner.write_all(&quit_buf).await;
let _ = inner.read_response().await;
if !greeting.is_success() {
// RFC 5321 Section 3.1: 4xx/5xx rejection greeting.
return Err(Self::response_to_error(greeting));
}
// Non-220 2xx — protocol violation by the server.
return Err(Error::Protocol(format!(
"server greeting must be 220, got {} \
(RFC 5321 Section 3.1)",
greeting.code
)));
}
// Initial EHLO/LHLO.
// RFC 5321 Section 4.1.1.10: from this point on, the 220
// greeting has established a session. If any subsequent step
// fails, we must send QUIT before closing the connection.
if let Err(e) = Self::ehlo_on_inner(&mut inner, protocol).await {
inner.quit_best_effort().await;
return Err(e);
}
// STARTTLS upgrade (RFC 3207).
if tls_mode == TlsMode::StartTls {
if !inner.capabilities.supports_starttls() {
inner.quit_best_effort().await;
return Err(Error::StartTlsUnavailable);
}
let mut buf = BytesMut::new();
encode::encode_starttls(&mut buf);
inner.write_all(&buf).await?;
let resp = inner.read_response().await?;
// RFC 3207 Section 4: the only valid success response to
// STARTTLS is 220. Other codes (454, 501) indicate failure.
if resp.code != 220 {
// RFC 5321 Section 4.1.1.10: send QUIT before closing.
inner.quit_best_effort().await;
return Err(if resp.is_success() {
// Non-220 2xx is unexpected per RFC 3207 §4.
Error::Protocol(format!(
"STARTTLS response must be 220, got {} \
(RFC 3207 Section 4)",
resp.code
))
} else {
Self::response_to_error(resp)
});
}
// Upgrade the connection to TLS.
let plain_stream = match inner.stream {
SmtpStream::Plain(s) => s,
SmtpStream::Tls(_) => {
return Err(Error::Protocol("already TLS".into()));
}
};
let server_name = rustls::pki_types::ServerName::try_from(host.to_owned())
.map_err(|e| Error::Protocol(format!("invalid server name: {e}")))?;
let connector = TlsConnector::from(tls_config);
let tls_stream = connector.connect(server_name, plain_stream).await?;
inner.stream = SmtpStream::Tls(Box::new(tls_stream));
inner.read_buf.clear();
// RFC 3207 Section 4.2: after TLS handshake the SMTP session is
// reset to initial state. Clear the authenticated flag and
// helo_mode as defense-in-depth — the TLS-upgraded session
// may support ESMTP even if the plaintext session did not.
inner.authenticated = false;
inner.helo_mode = false;
// RFC 3207 Section 4.2: the SMTP session resets to initial
// state after TLS. Clear stale plaintext capabilities so
// that, if the re-EHLO below fails, callers never see
// capabilities that were advertised on the insecure channel.
inner.capabilities = ServerCapabilities::default();
// Re-EHLO/LHLO after TLS (RFC 3207 Section 4.2).
if let Err(e) = Self::ehlo_on_inner(&mut inner, protocol).await {
inner.quit_best_effort().await;
return Err(e);
}
}
Ok(Self {
inner: tokio::sync::Mutex::new(inner),
protocol,
})
})
.await
.map_err(|_| Error::Timeout)?
}
}