Skip to main content

wasm_smtp/client/
mod.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::{self, MAX_REPLY_LINE_LEN, format_command};
29use crate::session::SessionState;
30use crate::tracing_helpers::{smtp_debug, smtp_trace, smtp_warn};
31use crate::transport::Transport;
32
33mod auth;
34mod io;
35mod send;
36mod starttls;
37
38pub(super) const READ_CHUNK: usize = 1024;
39pub(super) const RX_BUF_COMPACT_THRESHOLD: usize = 4096;
40pub(super) const RX_BUF_HARD_LIMIT: usize = MAX_REPLY_LINE_LEN * 2;
41
42/// SMTP client driving a single connection.
43///
44/// See the [module-level documentation](self) for the full lifecycle.
45pub struct SmtpClient<T: Transport> {
46    transport: T,
47    state: SessionState,
48    rx_buf: Vec<u8>,
49    rx_pos: usize,
50    capabilities: Vec<String>,
51    /// The EHLO domain supplied to [`Self::connect`]. Stored so that
52    /// [`Self::starttls`] can re-issue `EHLO` after the TLS upgrade per
53    /// RFC 3207 §4.2 without forcing the caller to pass the domain again.
54    ehlo_domain: String,
55    /// Whether the most recent EHLO advertised `ENHANCEDSTATUSCODES`
56    /// (RFC 2034). When set, every reply parsed by [`Self::read_reply`]\
57    /// is annotated with an [`crate::protocol::EnhancedStatus`] (when
58    /// the leading reply line carries one), and that code is propagated
59    /// into [`crate::ProtocolError::UnexpectedCode`] on failure.
60    enhanced_status_enabled: bool,
61    /// Pre-send policy hook. Checked before each `send_mail` call.
62    policy: Box<dyn crate::policy::SendPolicy>,
63    /// Audit event sink. Receives events for each session milestone.
64    audit: Box<dyn crate::audit::AuditSink>,
65}
66
67// Manual `Debug` implementation. We do not require `T: Debug` because typical
68// transport types (raw sockets, TLS streams) do not implement it. The
69// transport is therefore omitted from the formatted output; everything else
70// the caller might reasonably want to inspect is included.
71impl<T: Transport> core::fmt::Debug for SmtpClient<T> {
72    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
73        f.debug_struct("SmtpClient")
74            .field("state", &self.state)
75            .field("capabilities", &self.capabilities)
76            .field("ehlo_domain", &self.ehlo_domain)
77            .field("enhanced_status_enabled", &self.enhanced_status_enabled)
78            .field("rx_buf_len", &self.rx_buf.len())
79            .field("rx_pos", &self.rx_pos)
80            .finish_non_exhaustive()
81    }
82}
83
84impl<T: Transport> SmtpClient<T> {
85    /// Connect by reading the server greeting and performing the `EHLO`
86    /// handshake.
87    ///
88    /// `transport` must already be connected and, if Implicit TLS is in use,
89    /// already past the TLS handshake. `ehlo_domain` is the FQDN or address
90    /// literal that identifies the client to the server.
91    ///
92    /// On success the client is in a state where [`Self::login`] or
93    /// [`Self::send_mail`] may be called.
94    ///
95    /// Uses [`DefaultPolicy`][crate::policy::DefaultPolicy] (allow all) and
96    /// [`NoopAuditSink`][crate::audit::NoopAuditSink] (no events). To attach
97    /// a policy or audit sink, use [`SmtpClientOptions`] with
98    /// [`Self::connect_with`].
99    pub async fn connect(transport: T, ehlo_domain: &str) -> Result<Self, SmtpError> {
100        Self::connect_with(transport, ehlo_domain, SmtpClientOptions::default()).await
101    }
102
103    /// Connect with explicit policy and audit options.
104    ///
105    /// ```rust
106    /// # use wasm_smtp::policy::BoundedPolicy;
107    /// # use wasm_smtp::SmtpClientOptions;
108    /// let opts = SmtpClientOptions::new()
109    ///     .with_policy(Box::new(BoundedPolicy::new().max_recipients(50)));
110    /// // let client = SmtpClient::connect_with(transport, "client.example.com", opts).await?;
111    /// ```
112    pub async fn connect_with(
113        transport: T,
114        ehlo_domain: &str,
115        options: SmtpClientOptions,
116    ) -> Result<Self, SmtpError> {
117        protocol::validate_ehlo_domain(ehlo_domain)?;
118        smtp_debug!(ehlo_domain = %ehlo_domain, "SMTP session: connect");
119        options.audit.on_event(&crate::audit::SmtpAuditEvent::Connected);
120        let mut client = Self {
121            transport,
122            state: SessionState::Greeting,
123            rx_buf: Vec::with_capacity(READ_CHUNK),
124            rx_pos: 0,
125            capabilities: Vec::new(),
126            ehlo_domain: ehlo_domain.to_owned(),
127            enhanced_status_enabled: false,
128            policy: options.policy,
129            audit: options.audit,
130        };
131        client.read_greeting().await?;
132        client.send_ehlo(ehlo_domain).await?;
133        smtp_debug!(
134            capability_count = client.capabilities.len(),
135            "SMTP session: ready"
136        );
137        Ok(client)
138    }
139
140    /// The capability lines returned by the server in its `EHLO` reply.
141    ///
142    /// The first reply line (the greeting) is excluded; each remaining entry
143    /// is one advertised extension, for example `"AUTH LOGIN PLAIN"`,
144    /// `"PIPELINING"`, or `"8BITMIME"`.
145    pub fn capabilities(&self) -> &[String] {
146        &self.capabilities
147    }
148
149    /// The current session state. Mostly useful for diagnostics and tests.
150    pub fn state(&self) -> SessionState {
151        self.state
152    }
153
154    /// Authenticate using the best `AUTH` mechanism the server advertised.
155    ///
156    /// `PLAIN` is preferred over `LOGIN` when both are advertised, because
157    /// it completes in a single round-trip and is the IETF-standard SASL
158    /// mechanism. `LOGIN` is used as a fallback for older servers that
159    /// only advertise it. Callers that need to lock in a specific
160    /// mechanism (for testing, or for known-broken servers) should call
161    /// [`Self::login_with`] instead.
162    ///
163    /// Returns [`AuthError::UnsupportedMechanism`] if the server's `EHLO`
164    /// reply did not advertise either `PLAIN` or `LOGIN`. Returns
165    /// [`AuthError::Rejected`] if the server rejects the credentials.
166    ///
167    /// May only be called immediately after [`Self::connect`]. Calling it
168    /// a second time, or after [`Self::send_mail`], returns
169    /// [`InvalidInputError`].
170    ///
171    /// # Credential lifetime and zeroization
172    ///
173    /// `wasm-smtp` does not retain copies of `user` or `pass` after
174    /// this call returns: the credentials are passed by reference, used
175    /// once to build a base64-encoded SASL payload, and dropped together
176    /// with that payload at the end of the call. The crate also never
177    /// includes credentials in [`Debug`](core::fmt::Debug) output, error
178    /// messages, or [`Display`](core::fmt::Display) text.
179    ///
180    /// What the crate cannot do is securely erase the bytes the caller
181    /// supplied — that storage belongs to the caller. If your threat
182    /// model includes memory disclosure (a process dump, a debugger
183    /// attached to the running Worker, etc.), wrap the password in a
184    /// type that zeroes its backing memory on drop (the `zeroize` crate
185    /// is the conventional choice) and pass `&z.expose_secret()` only at
186    /// the call site. Concretely, avoid pulling the password out of an
187    /// environment variable into a long-lived `String`.
188
189    /// Send `QUIT` and close the transport.
190    ///
191    /// Consumes `self` so the client cannot be reused after a clean
192    /// shutdown. If the underlying transport's `close` fails, the SMTP
193    /// `QUIT` may still have completed cleanly; the returned error wraps
194    /// the transport-level failure.
195    pub async fn quit(mut self) -> Result<(), SmtpError> {
196        if self.state == SessionState::Closed {
197            smtp_trace!("quit: already closed; nothing to do");
198            return Ok(());
199        }
200        smtp_debug!("QUIT: closing session");
201        // Best-effort QUIT: if the server has already closed, we still want
202        // to release the transport.
203        let send_result: Result<(), SmtpError> = async {
204            self.transition(SessionState::Quit)?;
205            self.write_all(&format_command("QUIT")).await?;
206            self.expect_code(221, SmtpOp::Quit).await?;
207            Ok(())
208        }
209        .await;
210
211        let close_result = self.transport.close().await;
212        self.state = SessionState::Closed;
213
214        if send_result.is_ok() && close_result.is_ok() {
215            self.audit.on_event(&crate::audit::SmtpAuditEvent::QuitCompleted);
216        } else {
217            self.audit.on_event(&crate::audit::SmtpAuditEvent::SessionAborted);
218        }
219
220        send_result?;
221        close_result.map_err(SmtpError::from)?;
222        Ok(())
223    }
224
225    // -------------------------------------------------------------------------
226    // Internal helpers
227    // -------------------------------------------------------------------------
228
229    fn assert_state_in(&self, allowed: &[SessionState]) -> Result<(), InvalidInputError> {
230        if allowed.contains(&self.state) {
231            Ok(())
232        } else if self.state == SessionState::Closed {
233            Err(InvalidInputError::new(
234                "operation not allowed: SMTP session is already closed",
235            ))
236        } else {
237            Err(InvalidInputError::new(
238                "operation not allowed in the current SMTP session state",
239            ))
240        }
241    }
242
243    fn transition(&mut self, next: SessionState) -> Result<(), InvalidInputError> {
244        if self.state.can_transition_to(next) {
245            self.state = next;
246            Ok(())
247        } else {
248            Err(InvalidInputError::new(
249                "internal session-state transition rejected",
250            ))
251        }
252    }
253
254    fn mark_closed_on_logical_failure(&mut self) {
255        // After any unrecoverable error, the connection is poisoned. Move to
256        // Closed so subsequent calls fail fast with InvalidInput.
257        if self.state != SessionState::Closed {
258            smtp_warn!(
259                state = ?self.state,
260                "session closed on logical failure; further calls will fail fast"
261            );
262        }
263        self.state = SessionState::Closed;
264    }
265}
266
267
268// -----------------------------------------------------------------------------
269// SmtpClientOptions
270// -----------------------------------------------------------------------------
271
272/// Options for configuring an [`SmtpClient`] before connecting.
273///
274/// Provides a send-policy hook (RFC 011) and an audit-event sink (RFC 012).
275/// All settings are optional; the defaults are permissive and silent.
276///
277/// ## Example
278///
279/// ```rust
280/// use wasm_smtp::policy::BoundedPolicy;
281/// use wasm_smtp::audit::VecAuditSink;
282/// use wasm_smtp::SmtpClientOptions;
283/// use std::sync::Arc;
284///
285/// let sink = Arc::new(VecAuditSink::default());
286/// let opts = SmtpClientOptions::new()
287///     .with_policy(Box::new(BoundedPolicy::new().max_recipients(25)))
288///     .with_audit(Box::new(Arc::clone(&sink)));
289/// ```
290pub struct SmtpClientOptions {
291    /// Pre-send validation hook.
292    pub(crate) policy: Box<dyn crate::policy::SendPolicy>,
293    /// Audit event observer.
294    pub(crate) audit: Box<dyn crate::audit::AuditSink>,
295}
296
297impl SmtpClientOptions {
298    /// Create options with the default policy (allow all) and no-op audit sink.
299    #[must_use]
300    pub fn new() -> Self {
301        Self {
302            policy: Box::new(crate::policy::DefaultPolicy),
303            audit: Box::new(crate::audit::NoopAuditSink),
304        }
305    }
306
307    /// Attach a [`SendPolicy`][crate::policy::SendPolicy].
308    #[must_use]
309    pub fn with_policy(mut self, policy: Box<dyn crate::policy::SendPolicy>) -> Self {
310        self.policy = policy;
311        self
312    }
313
314    /// Attach an [`AuditSink`][crate::audit::AuditSink].
315    #[must_use]
316    pub fn with_audit(mut self, audit: Box<dyn crate::audit::AuditSink>) -> Self {
317        self.audit = audit;
318        self
319    }
320}
321
322impl Default for SmtpClientOptions {
323    fn default() -> Self {
324        Self::new()
325    }
326}
327
328impl core::fmt::Debug for SmtpClientOptions {
329    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
330        f.debug_struct("SmtpClientOptions")
331            .finish_non_exhaustive()
332    }
333}
334
335// -----------------------------------------------------------------------------
336// Free helpers
337// -----------------------------------------------------------------------------
338
339pub(super) fn find_crlf(buf: &[u8]) -> Option<usize> {
340    buf.windows(2).position(|w| w == b"\r\n")
341}
342
343/// Convert a generic protocol error from an AUTH-phase reply into a more
344/// specific [`AuthError::Rejected`] when the server returned a 5xx code.
345pub(super) fn convert_auth(err: SmtpError) -> SmtpError {
346    match err {
347        SmtpError::Protocol(ProtocolError::UnexpectedCode {
348            actual,
349            enhanced,
350            message,
351            ..
352        }) if (500..600).contains(&actual) => SmtpError::Auth(AuthError::Rejected {
353            code: actual,
354            enhanced,
355            message,
356        }),
357        other => other,
358    }
359}