Skip to main content

io_email/smtp/
client.rs

1//! Std-blocking SMTP client.
2//!
3//! Holds an inner [`SmtpClientStd`] (from io-smtp) wrapping the
4//! authenticated stream, plus the io-email-specific
5//! `default_reverse_path` knob (envelope-sender override used by
6//! alias accounts and DKIM-aligned bounce-address rewriting).
7//!
8//! [`SmtpClientStd::send_message`] runs the RFC 5321 mail
9//! transaction (MAIL FROM, RCPT TO, DATA) extracting reverse and
10//! forward paths from the raw RFC 5322 bytes. The inner client's
11//! command helpers (greeting, ehlo, mail, rcpt, data, ...) stay
12//! reachable through [`SmtpClientStd::inner`] for protocol-specific
13//! paths the shared API does not cover.
14//!
15//! [`SmtpClientStd`]: io_smtp::client::SmtpClientStd
16
17use alloc::{string::String, vec::Vec};
18use std::io::{self, Read, Write};
19
20#[cfg(any(
21    feature = "rustls-ring",
22    feature = "rustls-aws",
23    feature = "native-tls"
24))]
25use io_smtp::rfc5321::types::ehlo_domain::EhloDomain;
26use io_smtp::{
27    client::{SmtpClientStd as InnerSmtpClientStd, SmtpClientStdError as InnerSmtpClientStdError},
28    coroutine::*,
29};
30#[cfg(any(
31    feature = "rustls-ring",
32    feature = "rustls-aws",
33    feature = "native-tls"
34))]
35use pimalaya_stream::{sasl::Sasl, tls::Tls};
36use thiserror::Error;
37#[cfg(any(
38    feature = "rustls-ring",
39    feature = "rustls-aws",
40    feature = "native-tls"
41))]
42use url::Url;
43
44use crate::message::smtp::send::{SmtpMessageSend, SmtpMessageSendError};
45
46/// Errors surfaced by [`SmtpClientStd`] while running a coroutine.
47#[derive(Debug, Error)]
48pub enum SmtpClientError {
49    #[error(transparent)]
50    Io(#[from] io::Error),
51    #[error(transparent)]
52    MessageSend(#[from] SmtpMessageSendError),
53    #[error(transparent)]
54    Inner(#[from] InnerSmtpClientStdError),
55}
56
57const READ_BUFFER_SIZE: usize = 16 * 1024;
58
59/// Light SMTP client built on a generic blocking stream.
60///
61/// `default_reverse_path` overrides the `MAIL FROM` envelope sender
62/// for accounts whose header `From:` differs from the SMTP sender
63/// (DKIM-aligned gateways, bounce-address rewriting).
64pub struct SmtpClientStd {
65    pub inner: InnerSmtpClientStd,
66    pub default_reverse_path: Option<String>,
67}
68
69impl SmtpClientStd {
70    /// Wraps an already-authenticated SMTP stream with no envelope
71    /// override.
72    pub fn new<S: Read + Write + Send + 'static>(stream: S) -> Self {
73        Self {
74            inner: InnerSmtpClientStd::new(stream),
75            default_reverse_path: None,
76        }
77    }
78
79    /// Pumps any standard-shape SMTP coroutine
80    /// (`Yield = SmtpYield`, `Return = Result<T, E>`) against the
81    /// inner client's stream until it terminates.
82    ///
83    /// Reaches into [`Self::inner`] for raw field access rather than
84    /// delegating to [`InnerSmtpClientStd::run`] so error variants
85    /// route through [`SmtpClientError`] directly.
86    pub fn run<C, T, E>(&mut self, mut coroutine: C) -> Result<T, SmtpClientError>
87    where
88        C: SmtpCoroutine<Yield = SmtpYield, Return = Result<T, E>>,
89        SmtpClientError: From<E>,
90    {
91        let mut buf = [0u8; READ_BUFFER_SIZE];
92        let mut arg: Option<&[u8]> = None;
93
94        loop {
95            match coroutine.resume(arg.take()) {
96                SmtpCoroutineState::Complete(Ok(out)) => return Ok(out),
97                SmtpCoroutineState::Complete(Err(err)) => return Err(err.into()),
98                SmtpCoroutineState::Yielded(SmtpYield::WantsRead) => {
99                    let n = self.inner.stream.read(&mut buf)?;
100                    arg = Some(&buf[..n]);
101                }
102                SmtpCoroutineState::Yielded(SmtpYield::WantsWrite(bytes)) => {
103                    self.inner.stream.write_all(&bytes)?;
104                }
105            }
106        }
107    }
108
109    /// Sends a NOOP to keep the connection alive (RFC 5321 ยง4.1.1.9).
110    /// Sole purpose is to reset the server's inactivity timer on
111    /// long-idle TUI sessions; the response is discarded.
112    pub fn ping(&mut self) -> Result<(), SmtpClientError> {
113        Ok(self.inner.noop()?)
114    }
115
116    /// Sends the raw RFC 5322 `raw` message through the authenticated
117    /// stream. Reverse path comes from
118    /// [`Self::default_reverse_path`] when set, otherwise from the
119    /// message's `From:` header; forward paths come from
120    /// `To:` + `Cc:` + `Bcc:`.
121    pub fn send_message(&mut self, raw: Vec<u8>) -> Result<(), SmtpClientError> {
122        let coroutine = {
123            let override_reverse = self.default_reverse_path.as_deref();
124            SmtpMessageSend::new(raw, override_reverse)?
125        };
126        self.run(coroutine)
127    }
128}
129
130#[cfg(any(
131    feature = "rustls-ring",
132    feature = "rustls-aws",
133    feature = "native-tls"
134))]
135impl SmtpClientStd {
136    /// Opens a TCP / TLS connection to `url`, runs the optional
137    /// STARTTLS upgrade plus EHLO + SASL authentication, then wraps
138    /// the authenticated stream with the io-email knobs (empty
139    /// `default_reverse_path`).
140    pub fn connect(
141        url: &Url,
142        tls: &Tls,
143        starttls: bool,
144        domain: EhloDomain<'_>,
145        sasl: Option<impl Into<Sasl>>,
146    ) -> Result<Self, SmtpClientError> {
147        let inner = InnerSmtpClientStd::connect(url, tls, starttls, domain, sasl)?;
148        Ok(Self {
149            inner,
150            default_reverse_path: None,
151        })
152    }
153}