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}