daaki-smtp 0.2.0

An async SMTP client library
Documentation
//! 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)?
    }
}