Skip to main content

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}