wasm_smtp_core/client.rs
1//! High-level SMTP client.
2//!
3//! [`SmtpClient`] is the entry point of this crate. It owns a [`Transport`]
4//! and orchestrates the full SMTP exchange: greeting, `EHLO`, optional
5//! `AUTH LOGIN`, the mail transaction (`MAIL FROM`, `RCPT TO`, `DATA`, body,
6//! end-of-data), and `QUIT`.
7//!
8//! ## Lifecycle
9//!
10//! ```text
11//! SmtpClient::connect(transport, ehlo_domain)
12//! |
13//! v
14//! [optional] login(user, pass)
15//! |
16//! v
17//! send_mail(from, &[to], body) <-- may be called more than once
18//! |
19//! v
20//! quit() <-- consumes self
21//! ```
22//!
23//! Each method advances [`SessionState`]. Misordered calls (for example,
24//! `send_mail` before `connect`, or any operation after `quit`) return
25//! [`InvalidInputError`] without touching the wire.
26
27use crate::error::{AuthError, InvalidInputError, ProtocolError, SmtpError, SmtpOp};
28use crate::protocol::{
29 self, AuthMechanism, MAX_REPLY_LINE_LEN, MAX_REPLY_LINES, Reply,
30 build_auth_plain_initial_response, dot_stuff_and_terminate, ehlo_advertises_auth,
31 ehlo_advertises_enhanced_status_codes, ehlo_advertises_starttls, format_command,
32 format_command_arg, format_mail_from, format_rcpt_to, parse_reply_line, select_auth_mechanism,
33};
34use crate::session::SessionState;
35use crate::transport::{StartTlsCapable, Transport};
36
37const READ_CHUNK: usize = 1024;
38const RX_BUF_COMPACT_THRESHOLD: usize = 4096;
39const RX_BUF_HARD_LIMIT: usize = MAX_REPLY_LINE_LEN * 2;
40
41/// SMTP client driving a single connection.
42///
43/// See the [module-level documentation](self) for the full lifecycle.
44pub struct SmtpClient<T: Transport> {
45 transport: T,
46 state: SessionState,
47 rx_buf: Vec<u8>,
48 rx_pos: usize,
49 capabilities: Vec<String>,
50 /// The EHLO domain supplied to [`Self::connect`]. Stored so that
51 /// [`Self::starttls`] can re-issue `EHLO` after the TLS upgrade per
52 /// RFC 3207 §4.2 without forcing the caller to pass the domain again.
53 ehlo_domain: String,
54 /// Whether the most recent EHLO advertised `ENHANCEDSTATUSCODES`
55 /// (RFC 2034). When set, every reply parsed by [`Self::read_reply`]
56 /// is annotated with an [`crate::protocol::EnhancedStatus`] (when
57 /// the leading reply line carries one), and that code is propagated
58 /// into [`crate::ProtocolError::UnexpectedCode`] on failure.
59 enhanced_status_enabled: bool,
60}
61
62// Manual `Debug` implementation. We do not require `T: Debug` because typical
63// transport types (raw sockets, TLS streams) do not implement it. The
64// transport is therefore omitted from the formatted output; everything else
65// the caller might reasonably want to inspect is included.
66impl<T: Transport> core::fmt::Debug for SmtpClient<T> {
67 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
68 f.debug_struct("SmtpClient")
69 .field("state", &self.state)
70 .field("capabilities", &self.capabilities)
71 .field("ehlo_domain", &self.ehlo_domain)
72 .field("enhanced_status_enabled", &self.enhanced_status_enabled)
73 .field("rx_buf_len", &self.rx_buf.len())
74 .field("rx_pos", &self.rx_pos)
75 .finish_non_exhaustive()
76 }
77}
78
79impl<T: Transport> SmtpClient<T> {
80 /// Connect by reading the server greeting and performing the `EHLO`
81 /// handshake.
82 ///
83 /// `transport` must already be connected and, if Implicit TLS is in use,
84 /// already past the TLS handshake. `ehlo_domain` is the FQDN or address
85 /// literal that identifies the client to the server.
86 ///
87 /// On success the client is in a state where [`Self::login`] or
88 /// [`Self::send_mail`] may be called.
89 pub async fn connect(transport: T, ehlo_domain: &str) -> Result<Self, SmtpError> {
90 protocol::validate_ehlo_domain(ehlo_domain)?;
91 let mut client = Self {
92 transport,
93 state: SessionState::Greeting,
94 rx_buf: Vec::with_capacity(READ_CHUNK),
95 rx_pos: 0,
96 capabilities: Vec::new(),
97 ehlo_domain: ehlo_domain.to_owned(),
98 enhanced_status_enabled: false,
99 };
100 client.read_greeting().await?;
101 client.send_ehlo(ehlo_domain).await?;
102 Ok(client)
103 }
104
105 /// The capability lines returned by the server in its `EHLO` reply.
106 ///
107 /// The first reply line (the greeting) is excluded; each remaining entry
108 /// is one advertised extension, for example `"AUTH LOGIN PLAIN"`,
109 /// `"PIPELINING"`, or `"8BITMIME"`.
110 pub fn capabilities(&self) -> &[String] {
111 &self.capabilities
112 }
113
114 /// The current session state. Mostly useful for diagnostics and tests.
115 pub fn state(&self) -> SessionState {
116 self.state
117 }
118
119 /// Authenticate using the best `AUTH` mechanism the server advertised.
120 ///
121 /// `PLAIN` is preferred over `LOGIN` when both are advertised, because
122 /// it completes in a single round-trip and is the IETF-standard SASL
123 /// mechanism. `LOGIN` is used as a fallback for older servers that
124 /// only advertise it. Callers that need to lock in a specific
125 /// mechanism (for testing, or for known-broken servers) should call
126 /// [`Self::login_with`] instead.
127 ///
128 /// Returns [`AuthError::UnsupportedMechanism`] if the server's `EHLO`
129 /// reply did not advertise either `PLAIN` or `LOGIN`. Returns
130 /// [`AuthError::Rejected`] if the server rejects the credentials.
131 ///
132 /// May only be called immediately after [`Self::connect`]. Calling it
133 /// a second time, or after [`Self::send_mail`], returns
134 /// [`InvalidInputError`].
135 ///
136 /// # Credential lifetime and zeroization
137 ///
138 /// `wasm-smtp-core` does not retain copies of `user` or `pass` after
139 /// this call returns: the credentials are passed by reference, used
140 /// once to build a base64-encoded SASL payload, and dropped together
141 /// with that payload at the end of the call. The crate also never
142 /// includes credentials in [`Debug`](core::fmt::Debug) output, error
143 /// messages, or [`Display`](core::fmt::Display) text.
144 ///
145 /// What the crate cannot do is securely erase the bytes the caller
146 /// supplied — that storage belongs to the caller. If your threat
147 /// model includes memory disclosure (a process dump, a debugger
148 /// attached to the running Worker, etc.), wrap the password in a
149 /// type that zeroes its backing memory on drop (the `zeroize` crate
150 /// is the conventional choice) and pass `&z.expose_secret()` only at
151 /// the call site. Concretely, avoid pulling the password out of an
152 /// environment variable into a long-lived `String`.
153 pub async fn login(&mut self, user: &str, pass: &str) -> Result<(), SmtpError> {
154 if let Some(mech) = select_auth_mechanism(&self.capabilities) {
155 self.login_with(mech, user, pass).await
156 } else {
157 // Validate inputs first so the caller still gets a clean
158 // InvalidInputError on empty credentials, even if the
159 // server would have refused us anyway.
160 protocol::validate_plain_username(user)?;
161 protocol::validate_plain_password(pass)?;
162 self.assert_state_in(&[SessionState::Authentication])?;
163 self.mark_closed_on_logical_failure();
164 Err(AuthError::UnsupportedMechanism.into())
165 }
166 }
167
168 /// Authenticate using a specific `AUTH` mechanism.
169 ///
170 /// Use this when [`Self::login`]'s auto-selection is not what you
171 /// want — for example, when reproducing a production failure that
172 /// is specific to one mechanism, or when testing against a server
173 /// whose advertisement is known to be inaccurate.
174 ///
175 /// `credential` is the secret material whose meaning depends on the
176 /// mechanism: a static password for `Plain` and `Login`, or an
177 /// OAuth 2.0 access token for `XOAuth2` (the latter requires the
178 /// `xoauth2` cargo feature). The `user` parameter is validated
179 /// against rules appropriate to the mechanism (NUL bytes rejected
180 /// for SASL framing in `Plain` / `Login`, additional control bytes
181 /// rejected for `XOAuth2`).
182 ///
183 /// Returns [`AuthError::UnsupportedMechanism`] if `mechanism` was not
184 /// advertised by the server. Returns [`AuthError::Rejected`] if the
185 /// server rejects the credentials.
186 ///
187 /// When the `xoauth2` feature is disabled and the caller passes
188 /// [`AuthMechanism::XOAuth2`], this returns
189 /// [`InvalidInputError`] without performing any I/O — the variant
190 /// remains in the public enum (it is `non_exhaustive`) but the
191 /// code path is removed.
192 pub async fn login_with(
193 &mut self,
194 mechanism: AuthMechanism,
195 user: &str,
196 credential: &str,
197 ) -> Result<(), SmtpError> {
198 match mechanism {
199 AuthMechanism::Plain | AuthMechanism::Login => {
200 protocol::validate_plain_username(user)?;
201 protocol::validate_plain_password(credential)?;
202 }
203 #[cfg(feature = "xoauth2")]
204 AuthMechanism::XOAuth2 => {
205 protocol::validate_xoauth2_user(user)?;
206 protocol::validate_oauth2_token(credential)?;
207 }
208 #[cfg(not(feature = "xoauth2"))]
209 _ => {
210 return Err(InvalidInputError::new(
211 "XOAUTH2 support is not compiled in (enable the `xoauth2` feature)",
212 )
213 .into());
214 }
215 }
216 self.assert_state_in(&[SessionState::Authentication])?;
217
218 if !ehlo_advertises_auth(&self.capabilities, mechanism.name()) {
219 self.mark_closed_on_logical_failure();
220 return Err(AuthError::UnsupportedMechanism.into());
221 }
222
223 match mechanism {
224 AuthMechanism::Plain => self.run_auth_plain(user, credential).await?,
225 AuthMechanism::Login => self.run_auth_login(user, credential).await?,
226 #[cfg(feature = "xoauth2")]
227 AuthMechanism::XOAuth2 => self.run_auth_xoauth2(user, credential).await?,
228 #[cfg(not(feature = "xoauth2"))]
229 _ => unreachable!("XOAUTH2 was screened out above when the feature is disabled"),
230 }
231
232 self.transition(SessionState::MailFrom)?;
233 Ok(())
234 }
235
236 /// Authenticate with `XOAUTH2`, the Google / Microsoft OAuth 2.0
237 /// SASL profile.
238 ///
239 /// `user` is the email address of the account, `access_token` is a
240 /// short-lived OAuth 2.0 bearer token obtained via the OAuth flow
241 /// for that account. This crate does not perform the OAuth dance
242 /// itself — token acquisition, refresh, and storage are the
243 /// caller's responsibility.
244 ///
245 /// Convenience wrapper for
246 /// `login_with(AuthMechanism::XOAuth2, user, access_token)`. Note
247 /// that [`Self::login`] (the auto-selecting variant) deliberately
248 /// does not pick `XOAUTH2` even when the server advertises it,
249 /// because the credential semantics are different from a static
250 /// password.
251 ///
252 /// # Errors
253 ///
254 /// - [`AuthError::UnsupportedMechanism`] if the server did not
255 /// advertise `AUTH XOAUTH2`.
256 /// - [`AuthError::Rejected`] if the server rejected the token.
257 /// Google and Microsoft typically return a 535 with a base64-
258 /// encoded JSON `{"status":"401","schemes":"Bearer","scope":"..."}`
259 /// in the message; the parsed text is preserved in the error.
260 ///
261 /// Available only with the `xoauth2` cargo feature enabled
262 /// (default-on).
263 #[cfg(feature = "xoauth2")]
264 pub async fn login_xoauth2(&mut self, user: &str, access_token: &str) -> Result<(), SmtpError> {
265 self.login_with(AuthMechanism::XOAuth2, user, access_token)
266 .await
267 }
268
269 /// SASL `PLAIN` exchange (RFC 4616) using the initial-response form.
270 ///
271 /// One round-trip:
272 /// `C: AUTH PLAIN <b64(\0user\0pass)>` → `S: 235`.
273 async fn run_auth_plain(&mut self, user: &str, pass: &str) -> Result<(), SmtpError> {
274 let response = build_auth_plain_initial_response(user, pass);
275 let mut cmd = String::with_capacity(11 + response.len() + 2);
276 cmd.push_str("AUTH PLAIN ");
277 cmd.push_str(&response);
278 cmd.push_str("\r\n");
279 self.write_all(cmd.as_bytes()).await?;
280 self.expect_code(235, SmtpOp::AuthPlain)
281 .await
282 .map_err(convert_auth)?;
283 Ok(())
284 }
285
286 /// `AUTH LOGIN` exchange (legacy, two round-trips).
287 ///
288 /// `C: AUTH LOGIN` → `S: 334` → `C: b64(user)` → `S: 334` →
289 /// `C: b64(pass)` → `S: 235`.
290 async fn run_auth_login(&mut self, user: &str, pass: &str) -> Result<(), SmtpError> {
291 self.write_all(b"AUTH LOGIN\r\n").await?;
292 self.expect_code(334, SmtpOp::AuthLogin)
293 .await
294 .map_err(convert_auth)?;
295
296 let mut user_b64 = protocol::base64_encode(user.as_bytes());
297 user_b64.push_str("\r\n");
298 self.write_all(user_b64.as_bytes()).await?;
299 self.expect_code(334, SmtpOp::AuthLogin)
300 .await
301 .map_err(convert_auth)?;
302
303 let mut pass_b64 = protocol::base64_encode(pass.as_bytes());
304 pass_b64.push_str("\r\n");
305 self.write_all(pass_b64.as_bytes()).await?;
306 self.expect_code(235, SmtpOp::AuthLogin)
307 .await
308 .map_err(convert_auth)?;
309 Ok(())
310 }
311
312 /// `AUTH XOAUTH2` exchange (Google / Microsoft).
313 ///
314 /// Wire form:
315 /// `C: AUTH XOAUTH2 <b64("user="user SOH "auth=Bearer "token SOH SOH)>`
316 /// → `S: 235` on success.
317 ///
318 /// On failure, RFC 7628-style providers send `334 <b64(json)>` first
319 /// and expect the client to reply with an empty line; the server
320 /// then sends the final 5xx. We follow that protocol so the JSON
321 /// error detail (containing `scope`, `error`, etc.) ends up in the
322 /// final reply text and is preserved in [`AuthError::Rejected`].
323 #[cfg(feature = "xoauth2")]
324 async fn run_auth_xoauth2(&mut self, user: &str, token: &str) -> Result<(), SmtpError> {
325 let response = protocol::build_xoauth2_initial_response(user, token);
326 let mut cmd = String::with_capacity(13 + response.len() + 2);
327 cmd.push_str("AUTH XOAUTH2 ");
328 cmd.push_str(&response);
329 cmd.push_str("\r\n");
330 self.write_all(cmd.as_bytes()).await?;
331
332 // Read the first reply. 235 is direct success; 334 indicates the
333 // provider is sending JSON error details and expects an empty
334 // continuation line, after which a final 5xx arrives.
335 let reply = self.read_reply().await?;
336 match reply.code {
337 235 => Ok(()),
338 334 => {
339 // Provider-supplied error detail. Send an empty continuation
340 // line so the provider can finalize with a proper 5xx.
341 self.write_all(b"\r\n").await?;
342 let final_reply = self.read_reply().await?;
343 self.mark_closed_on_logical_failure();
344 Err(SmtpError::Auth(AuthError::Rejected {
345 code: final_reply.code,
346 enhanced: final_reply.enhanced(),
347 message: final_reply.joined_text(),
348 }))
349 }
350 other => {
351 self.mark_closed_on_logical_failure();
352 Err(if (500..600).contains(&other) {
353 SmtpError::Auth(AuthError::Rejected {
354 code: other,
355 enhanced: reply.enhanced(),
356 message: reply.joined_text(),
357 })
358 } else {
359 SmtpError::Protocol(ProtocolError::UnexpectedCode {
360 during: SmtpOp::AuthXOAuth2,
361 expected_class: 2,
362 actual: other,
363 enhanced: reply.enhanced(),
364 message: reply.joined_text(),
365 })
366 })
367 }
368 }
369 }
370
371 /// Send a single message.
372 ///
373 /// `from` is the envelope sender (RFC 5321 reverse-path), used in the
374 /// `MAIL FROM:<...>` command. `to` is a non-empty slice of envelope
375 /// recipients (forward-paths). `body` is the fully-formed message,
376 /// including all RFC 5322 headers, separated from the body proper by a
377 /// blank line, and CRLF-normalized. Any line in `body` whose first
378 /// character is `.` is automatically dot-stuffed before transmission.
379 ///
380 /// On success the client is left in a state where another `send_mail`
381 /// may be issued, or `quit` may be called to close the session.
382 ///
383 /// # Body size
384 ///
385 /// `wasm-smtp-core` does not impose an upper bound on `body.len()`;
386 /// the body is dot-stuffed into a single `Vec<u8>` and written in
387 /// one [`crate::Transport::write_all`] call.
388 /// In practice the caller (or a layer above this crate) should
389 /// enforce a sane application-specific limit, both to avoid the
390 /// allocation cost on a malicious body and to stay within the
391 /// `SIZE` limit (RFC 1870) the server may have advertised in its
392 /// `EHLO` response. A typical safe default for transactional mail
393 /// is 10 MiB; submission relays such as Gmail enforce 25-50 MiB.
394 pub async fn send_mail(
395 &mut self,
396 from: &str,
397 to: &[&str],
398 body: &str,
399 ) -> Result<(), SmtpError> {
400 protocol::validate_address(from)?;
401 if to.is_empty() {
402 return Err(InvalidInputError::new("at least one recipient is required").into());
403 }
404 for &addr in to {
405 protocol::validate_address(addr)?;
406 }
407 self.assert_state_in(&[SessionState::Authentication, SessionState::MailFrom])?;
408
409 // Issue MAIL FROM.
410 self.transition(SessionState::MailFrom)?;
411 self.write_all(&format_mail_from(from)).await?;
412 self.expect_class(2, SmtpOp::MailFrom).await?;
413
414 // Issue RCPT TO for every recipient. 250 (OK) and 251 (forwarded)
415 // are both acceptances; treat any 2xx as success.
416 self.transition(SessionState::RcptTo)?;
417 for &addr in to {
418 self.write_all(&format_rcpt_to(addr)).await?;
419 self.expect_class(2, SmtpOp::RcptTo).await?;
420 }
421
422 // Issue DATA, expect 354.
423 self.transition(SessionState::Data)?;
424 self.write_all(&format_command("DATA")).await?;
425 self.expect_code(354, SmtpOp::Data).await?;
426
427 // Send the body with dot-stuffing and terminator.
428 let payload = dot_stuff_and_terminate(body.as_bytes());
429 self.write_all(&payload).await?;
430 self.expect_class(2, SmtpOp::Data).await?;
431
432 // Ready for another transaction.
433 self.transition(SessionState::MailFrom)?;
434 Ok(())
435 }
436
437 /// Send a single message using the SMTPUTF8 extension (RFC 6531),
438 /// allowing UTF-8 characters in envelope addresses.
439 ///
440 /// Identical to [`Self::send_mail`] except:
441 ///
442 /// - Address validation uses [`protocol::validate_address_utf8`]
443 /// instead of the strict ASCII validator, so codepoints outside
444 /// the ASCII range are accepted in `from` and `to`.
445 /// - The `MAIL FROM` command is suffixed with the `SMTPUTF8`
446 /// ESMTP parameter so the server knows to expect UTF-8.
447 /// - The server must have advertised `SMTPUTF8` in its `EHLO`
448 /// response. If it did not, this method returns
449 /// [`ProtocolError::ExtensionUnavailable`] without sending any
450 /// bytes.
451 ///
452 /// The body must still be CRLF-normalized; any UTF-8 in headers
453 /// (e.g. `Subject:` containing non-ASCII characters) is the
454 /// caller's responsibility to format correctly. RFC 6531 §3.2
455 /// permits raw UTF-8 in headers when SMTPUTF8 is in effect, but
456 /// strict deployments may still expect MIME encoded-words; this
457 /// crate makes no claim either way.
458 ///
459 /// Available only with the `smtputf8` cargo feature enabled.
460 ///
461 /// # Errors
462 ///
463 /// In addition to the error categories returned by `send_mail`:
464 ///
465 /// - [`ProtocolError::ExtensionUnavailable`] with `name: "SMTPUTF8"`
466 /// if the server's `EHLO` reply did not include the keyword.
467 /// The session is moved to `Closed` to prevent silent fallback
468 /// to ASCII-only delivery.
469 #[cfg(feature = "smtputf8")]
470 pub async fn send_mail_smtputf8(
471 &mut self,
472 from: &str,
473 to: &[&str],
474 body: &str,
475 ) -> Result<(), SmtpError> {
476 protocol::validate_address_utf8(from)?;
477 if to.is_empty() {
478 return Err(InvalidInputError::new("at least one recipient is required").into());
479 }
480 for &addr in to {
481 protocol::validate_address_utf8(addr)?;
482 }
483 self.assert_state_in(&[SessionState::Authentication, SessionState::MailFrom])?;
484
485 if !protocol::ehlo_advertises_smtputf8(&self.capabilities) {
486 self.mark_closed_on_logical_failure();
487 return Err(ProtocolError::ExtensionUnavailable { name: "SMTPUTF8" }.into());
488 }
489
490 // Issue MAIL FROM:<from> SMTPUTF8.
491 self.transition(SessionState::MailFrom)?;
492 self.write_all(&protocol::format_mail_from_smtputf8(from))
493 .await?;
494 self.expect_class(2, SmtpOp::MailFrom).await?;
495
496 // RCPT TO is identical to the ASCII path: SMTPUTF8 does not
497 // add a parameter to RCPT, only to MAIL FROM. Recipients can
498 // be UTF-8 because the validator we ran above already
499 // accepted them.
500 self.transition(SessionState::RcptTo)?;
501 for &addr in to {
502 self.write_all(&format_rcpt_to(addr)).await?;
503 self.expect_class(2, SmtpOp::RcptTo).await?;
504 }
505
506 // DATA + body identical to the ASCII path.
507 self.transition(SessionState::Data)?;
508 self.write_all(&format_command("DATA")).await?;
509 self.expect_code(354, SmtpOp::Data).await?;
510
511 let payload = dot_stuff_and_terminate(body.as_bytes());
512 self.write_all(&payload).await?;
513 self.expect_class(2, SmtpOp::Data).await?;
514
515 self.transition(SessionState::MailFrom)?;
516 Ok(())
517 }
518
519 /// Send `QUIT` and close the transport.
520 ///
521 /// Consumes `self` so the client cannot be reused after a clean
522 /// shutdown. If the underlying transport's `close` fails, the SMTP
523 /// `QUIT` may still have completed cleanly; the returned error wraps
524 /// the transport-level failure.
525 pub async fn quit(mut self) -> Result<(), SmtpError> {
526 if self.state == SessionState::Closed {
527 return Ok(());
528 }
529 // Best-effort QUIT: if the server has already closed, we still want
530 // to release the transport.
531 let send_result: Result<(), SmtpError> = async {
532 self.transition(SessionState::Quit)?;
533 self.write_all(&format_command("QUIT")).await?;
534 self.expect_code(221, SmtpOp::Quit).await?;
535 Ok(())
536 }
537 .await;
538
539 let close_result = self.transport.close().await;
540 self.state = SessionState::Closed;
541
542 send_result?;
543 close_result.map_err(SmtpError::from)?;
544 Ok(())
545 }
546
547 // -------------------------------------------------------------------------
548 // Internal helpers
549 // -------------------------------------------------------------------------
550
551 async fn read_greeting(&mut self) -> Result<(), SmtpError> {
552 let reply = self.read_reply().await?;
553 if reply.class() != 2 {
554 self.mark_closed_on_logical_failure();
555 return Err(ProtocolError::UnexpectedCode {
556 during: SmtpOp::Greeting,
557 expected_class: 2,
558 actual: reply.code,
559 enhanced: reply.enhanced(),
560 message: reply.joined_text(),
561 }
562 .into());
563 }
564 self.transition(SessionState::Ehlo)?;
565 Ok(())
566 }
567
568 async fn send_ehlo(&mut self, domain: &str) -> Result<(), SmtpError> {
569 self.write_all(&format_command_arg("EHLO", domain)).await?;
570 let reply = self.read_reply().await?;
571 if reply.class() != 2 {
572 self.mark_closed_on_logical_failure();
573 return Err(ProtocolError::UnexpectedCode {
574 during: SmtpOp::Ehlo,
575 expected_class: 2,
576 actual: reply.code,
577 enhanced: reply.enhanced(),
578 message: reply.joined_text(),
579 }
580 .into());
581 }
582 // The first line of an EHLO reply is the greeting; capability lines
583 // follow. Store only the capability lines.
584 let mut lines = reply.lines;
585 if !lines.is_empty() {
586 lines.remove(0);
587 }
588 // Refresh ENHANCEDSTATUSCODES enablement from the post-EHLO
589 // capability set. Doing this BEFORE assigning self.capabilities
590 // is the cleanest order; it also keeps enabledness false if the
591 // capability is dropped on a re-EHLO (e.g. after STARTTLS).
592 self.enhanced_status_enabled = ehlo_advertises_enhanced_status_codes(&lines);
593 self.capabilities = lines;
594 self.transition(SessionState::Authentication)?;
595 Ok(())
596 }
597
598 async fn write_all(&mut self, buf: &[u8]) -> Result<(), SmtpError> {
599 match self.transport.write_all(buf).await {
600 Ok(()) => Ok(()),
601 Err(e) => {
602 self.mark_closed_on_logical_failure();
603 Err(SmtpError::Io(e))
604 }
605 }
606 }
607
608 /// Read one full reply (possibly multi-line) and require the given
609 /// exact code. Any deviation is reported as
610 /// [`ProtocolError::UnexpectedCode`] tagged with `during` so the
611 /// caller knows which SMTP step the failure refers to.
612 async fn expect_code(&mut self, expected: u16, during: SmtpOp) -> Result<Reply, SmtpError> {
613 let reply = self.read_reply().await?;
614 if reply.code == expected {
615 Ok(reply)
616 } else {
617 let class = u8::try_from(expected / 100).expect("expected code is in valid SMTP range");
618 self.mark_closed_on_logical_failure();
619 Err(ProtocolError::UnexpectedCode {
620 during,
621 expected_class: class,
622 actual: reply.code,
623 enhanced: reply.enhanced(),
624 message: reply.joined_text(),
625 }
626 .into())
627 }
628 }
629
630 /// Read one full reply (possibly multi-line) and require the given
631 /// leading-digit class. Errors are tagged with `during` for the
632 /// same reason as [`Self::expect_code`].
633 async fn expect_class(
634 &mut self,
635 expected_class: u8,
636 during: SmtpOp,
637 ) -> Result<Reply, SmtpError> {
638 let reply = self.read_reply().await?;
639 if reply.class() == expected_class {
640 Ok(reply)
641 } else {
642 self.mark_closed_on_logical_failure();
643 Err(ProtocolError::UnexpectedCode {
644 during,
645 expected_class,
646 actual: reply.code,
647 enhanced: reply.enhanced(),
648 message: reply.joined_text(),
649 }
650 .into())
651 }
652 }
653
654 async fn read_reply(&mut self) -> Result<Reply, SmtpError> {
655 let mut lines: Vec<String> = Vec::new();
656 let mut code: Option<u16> = None;
657 loop {
658 if lines.len() >= MAX_REPLY_LINES {
659 self.mark_closed_on_logical_failure();
660 return Err(ProtocolError::Malformed(format!(
661 "reply exceeded {MAX_REPLY_LINES} lines",
662 ))
663 .into());
664 }
665 let line = self.read_line().await?;
666 let parsed = match parse_reply_line(&line) {
667 Ok(p) => p,
668 Err(e) => {
669 self.mark_closed_on_logical_failure();
670 return Err(e.into());
671 }
672 };
673 match code {
674 None => code = Some(parsed.code),
675 Some(prev) if prev != parsed.code => {
676 self.mark_closed_on_logical_failure();
677 return Err(ProtocolError::InconsistentMultiline {
678 first: prev,
679 later: parsed.code,
680 }
681 .into());
682 }
683 _ => {}
684 }
685 lines.push(String::from_utf8_lossy(parsed.text).into_owned());
686 if parsed.is_last {
687 let code = code.expect("at least one line was read so code has been initialised");
688 let mut reply = Reply::new(code, lines);
689 if self.enhanced_status_enabled
690 && let Some(status) = reply.try_parse_enhanced()
691 {
692 reply.attach_enhanced_status(status);
693 }
694 return Ok(reply);
695 }
696 }
697 }
698
699 async fn read_line(&mut self) -> Result<Vec<u8>, SmtpError> {
700 loop {
701 // Search for CRLF in the unread portion of the buffer.
702 if let Some(pos) = find_crlf(&self.rx_buf[self.rx_pos..]) {
703 let abs_end = self.rx_pos + pos;
704 let line = self.rx_buf[self.rx_pos..abs_end].to_vec();
705 self.rx_pos = abs_end + 2;
706 self.compact_rx();
707 if line.len() > MAX_REPLY_LINE_LEN {
708 self.mark_closed_on_logical_failure();
709 return Err(ProtocolError::LineTooLong.into());
710 }
711 return Ok(line);
712 }
713 // No CRLF yet. Refuse to grow without bound.
714 if self.rx_buf.len() - self.rx_pos > RX_BUF_HARD_LIMIT {
715 self.mark_closed_on_logical_failure();
716 return Err(ProtocolError::LineTooLong.into());
717 }
718 let n = self.fill_buf().await?;
719 if n == 0 {
720 self.mark_closed_on_logical_failure();
721 return Err(ProtocolError::UnexpectedClose.into());
722 }
723 }
724 }
725
726 async fn fill_buf(&mut self) -> Result<usize, SmtpError> {
727 let mut tmp = [0u8; READ_CHUNK];
728 let n = self.transport.read(&mut tmp).await.map_err(|e| {
729 // I/O failure is fatal; transition to Closed.
730 self.state = SessionState::Closed;
731 SmtpError::Io(e)
732 })?;
733 self.rx_buf.extend_from_slice(&tmp[..n]);
734 Ok(n)
735 }
736
737 fn compact_rx(&mut self) {
738 if self.rx_pos >= RX_BUF_COMPACT_THRESHOLD {
739 self.rx_buf.drain(..self.rx_pos);
740 self.rx_pos = 0;
741 }
742 }
743
744 fn assert_state_in(&self, allowed: &[SessionState]) -> Result<(), InvalidInputError> {
745 if allowed.contains(&self.state) {
746 Ok(())
747 } else if self.state == SessionState::Closed {
748 Err(InvalidInputError::new(
749 "operation not allowed: SMTP session is already closed",
750 ))
751 } else {
752 Err(InvalidInputError::new(
753 "operation not allowed in the current SMTP session state",
754 ))
755 }
756 }
757
758 fn transition(&mut self, next: SessionState) -> Result<(), InvalidInputError> {
759 if self.state.can_transition_to(next) {
760 self.state = next;
761 Ok(())
762 } else {
763 Err(InvalidInputError::new(
764 "internal session-state transition rejected",
765 ))
766 }
767 }
768
769 fn mark_closed_on_logical_failure(&mut self) {
770 // After any unrecoverable error, the connection is poisoned. Move to
771 // Closed so subsequent calls fail fast with InvalidInput.
772 self.state = SessionState::Closed;
773 }
774}
775
776// -----------------------------------------------------------------------------
777// STARTTLS (RFC 3207) — only available on transports that can be upgraded
778// to TLS in-place.
779// -----------------------------------------------------------------------------
780
781impl<T: StartTlsCapable> SmtpClient<T> {
782 /// Connect, read the greeting, send `EHLO`, issue `STARTTLS`, upgrade
783 /// the transport to TLS, and re-issue `EHLO` on the secure stream.
784 ///
785 /// This is the convenience entry point for the STARTTLS submission flow
786 /// on ports 587 / 25. The returned client is in
787 /// [`SessionState::Authentication`] just like one returned by
788 /// [`Self::connect`] would be — meaning the caller proceeds with
789 /// [`Self::login`] (or skips straight to [`Self::send_mail`] for
790 /// unauthenticated submission) without observing the TLS upgrade
791 /// itself.
792 ///
793 /// Use [`Self::connect`] for Implicit TLS on port 465 instead. STARTTLS
794 /// is appropriate when the transport must remain plaintext until the
795 /// server has accepted the upgrade request.
796 ///
797 /// # Errors
798 ///
799 /// Returns the same error categories as [`Self::connect`] for the
800 /// pre-upgrade phase. Additionally:
801 ///
802 /// - [`ProtocolError::ExtensionUnavailable`] with `name: "STARTTLS"`
803 /// if the server's first `EHLO` reply did not advertise the
804 /// extension.
805 /// - [`ProtocolError::UnexpectedCode`] with `during: SmtpOp::StartTls`
806 /// if the server rejected `STARTTLS` itself.
807 /// - [`SmtpError::Io`] if the transport-level upgrade fails.
808 pub async fn connect_starttls(transport: T, ehlo_domain: &str) -> Result<Self, SmtpError> {
809 let mut client = Self::connect(transport, ehlo_domain).await?;
810 client.starttls().await?;
811 Ok(client)
812 }
813
814 /// Issue `STARTTLS` on an already-connected client, upgrade the
815 /// transport, and re-issue `EHLO` per RFC 3207 §4.2.
816 ///
817 /// May only be called immediately after [`Self::connect`]. Calling it
818 /// after [`Self::login`] or [`Self::send_mail`] returns
819 /// [`InvalidInputError`] without touching the wire.
820 ///
821 /// # Errors
822 ///
823 /// - [`ProtocolError::ExtensionUnavailable`] with `name: "STARTTLS"`
824 /// if the server did not advertise the extension. In this case the
825 /// client is moved to [`SessionState::Closed`] so subsequent calls
826 /// fail fast — accidentally falling back to plaintext authentication
827 /// would defeat the purpose of asking for STARTTLS.
828 /// - [`ProtocolError::UnexpectedCode`] with `during: SmtpOp::StartTls`
829 /// if the server rejected the command.
830 /// - [`SmtpError::Io`] if the transport-level upgrade fails.
831 pub async fn starttls(&mut self) -> Result<(), SmtpError> {
832 self.assert_state_in(&[SessionState::Authentication])?;
833
834 if !ehlo_advertises_starttls(&self.capabilities) {
835 self.mark_closed_on_logical_failure();
836 return Err(ProtocolError::ExtensionUnavailable { name: "STARTTLS" }.into());
837 }
838
839 // Send STARTTLS and require a 220 reply before touching the
840 // transport. Per RFC 3207, a 4xx/5xx reply leaves the channel
841 // plaintext and the client is free to try other things — but for
842 // simplicity, and to avoid silently falling through to plaintext
843 // AUTH, we treat any non-220 here as a fatal error.
844 self.transition(SessionState::StartTls)?;
845 self.write_all(&format_command("STARTTLS")).await?;
846 self.expect_code(220, SmtpOp::StartTls).await?;
847
848 // STARTTLS injection / pipelining defense (RFC 3207 §5):
849 //
850 // Between the `220` reply and the TLS handshake the channel is
851 // still plaintext. An attacker who is willing to corrupt the
852 // server's reply stream may try to pipeline additional SMTP
853 // commands ("EHLO ..\r\nMAIL FROM:..\r\n") onto the buffer
854 // before the TLS upgrade, hoping the client will read those
855 // bytes back AFTER the upgrade and treat them as if they had
856 // arrived over the secured channel. (See CVE-2011-1575 for the
857 // historical Postfix case; equivalent client-side bugs exist.)
858 //
859 // The defense is to refuse to start TLS when there are any
860 // unread bytes in the receive buffer after the 220. Honest
861 // servers do not pipeline data into the STARTTLS handshake
862 // window — they wait for the client to begin the TLS
863 // ClientHello. Any bytes here are therefore evidence of an
864 // injection or of a server bug that we want to surface
865 // loudly rather than silently absorb.
866 let residue = self.rx_buf.len() - self.rx_pos;
867 if residue > 0 {
868 self.mark_closed_on_logical_failure();
869 return Err(ProtocolError::StartTlsBufferResidue {
870 byte_count: residue,
871 }
872 .into());
873 }
874
875 // Upgrade the transport. Discard previously-advertised
876 // capabilities: RFC 3207 §4.2 mandates that the server may
877 // advertise a different set after the TLS upgrade.
878 self.capabilities.clear();
879 self.transport.upgrade_to_tls().await.map_err(|e| {
880 self.mark_closed_on_logical_failure();
881 SmtpError::Io(e)
882 })?;
883
884 // RFC 3207 §4.2: re-issue EHLO on the now-secure channel. We
885 // reuse send_ehlo, which writes the command, parses the reply,
886 // refreshes self.capabilities, and transitions to
887 // SessionState::Authentication.
888 self.transition(SessionState::Ehlo)?;
889 // Cloning is cheap relative to a network round-trip and avoids a
890 // borrow-checker conflict with the &mut self call.
891 let domain = self.ehlo_domain.clone();
892 self.send_ehlo(&domain).await?;
893 Ok(())
894 }
895}
896
897// -----------------------------------------------------------------------------
898// Free helpers
899// -----------------------------------------------------------------------------
900
901fn find_crlf(buf: &[u8]) -> Option<usize> {
902 buf.windows(2).position(|w| w == b"\r\n")
903}
904
905/// Convert a generic protocol error from an AUTH-phase reply into a more
906/// specific [`AuthError::Rejected`] when the server returned a 5xx code.
907fn convert_auth(err: SmtpError) -> SmtpError {
908 match err {
909 SmtpError::Protocol(ProtocolError::UnexpectedCode {
910 actual,
911 enhanced,
912 message,
913 ..
914 }) if (500..600).contains(&actual) => SmtpError::Auth(AuthError::Rejected {
915 code: actual,
916 enhanced,
917 message,
918 }),
919 other => other,
920 }
921}