Skip to main content

wasm_smtp_core/
error.rs

1//! Error types returned by SMTP operations.
2//!
3//! All operations in this crate ultimately return [`SmtpError`], a four-arm
4//! enum that classifies failures at a coarse granularity: transport (`Io`),
5//! wire-format / unexpected response (`Protocol`), authentication (`Auth`),
6//! and caller-supplied input that violates SMTP grammar (`InvalidInput`).
7//!
8//! ## Sensitivity
9//!
10//! Error messages must never include credentials or message body content.
11//! Constructors in this module are designed so that callers cannot
12//! accidentally embed such material:
13//!
14//! - [`InvalidInputError`] takes a static reason string only.
15//! - [`AuthError::Rejected`] carries the server's reply text (which the server
16//!   itself produced) but no client-side credentials.
17//! - [`ProtocolError::UnexpectedCode`] carries server-produced text.
18//! - The DATA-phase code in [`crate::client`] never includes body bytes in any
19//!   error.
20
21use core::fmt;
22use std::error::Error as StdError;
23
24use crate::protocol::EnhancedStatus;
25
26/// Top-level error type for all SMTP operations.
27///
28/// # Logging caveat
29///
30/// The [`Display`](core::fmt::Display) output of `SmtpError` (and of
31/// the variants `Protocol(ProtocolError)` and `Auth(AuthError)` in
32/// particular) embeds the server's reply text verbatim. SMTP servers
33/// commonly include the rejected envelope address in their reply
34/// — e.g. `550 5.1.1 <user@example.com>: recipient does not exist`
35/// — and may include other PII the application would not log of its
36/// own accord. When emitting these errors to a shared log channel,
37/// consider:
38///
39/// - Logging only the structured fields you care about
40///   (`during`, `actual`, `enhanced`, `code`) and not the free-text
41///   `message` field.
42/// - Truncating the message to a fixed length.
43/// - Filtering / redacting addresses with an application-level
44///   regex if your compliance posture requires it.
45///
46/// `joined_text()` (the source of the `message` fields) is documented
47/// to potentially contain `\n`; see [`crate::protocol::Reply::joined_text`].
48#[derive(Debug)]
49pub enum SmtpError {
50    /// Underlying transport (socket) failure, including connection close.
51    Io(IoError),
52    /// Server response did not match SMTP grammar or expected code.
53    Protocol(ProtocolError),
54    /// Authentication exchange failed or no compatible mechanism was offered.
55    Auth(AuthError),
56    /// Caller-supplied input violated SMTP constraints before any byte was
57    /// sent on the wire.
58    InvalidInput(InvalidInputError),
59}
60
61impl fmt::Display for SmtpError {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            Self::Io(e) => write!(f, "smtp transport error: {e}"),
65            Self::Protocol(e) => write!(f, "smtp protocol error: {e}"),
66            Self::Auth(e) => write!(f, "smtp auth error: {e}"),
67            Self::InvalidInput(e) => write!(f, "smtp invalid input: {e}"),
68        }
69    }
70}
71
72impl StdError for SmtpError {
73    fn source(&self) -> Option<&(dyn StdError + 'static)> {
74        match self {
75            Self::Io(e) => Some(e),
76            Self::Protocol(e) => Some(e),
77            Self::Auth(e) => Some(e),
78            Self::InvalidInput(e) => Some(e),
79        }
80    }
81}
82
83impl From<IoError> for SmtpError {
84    fn from(value: IoError) -> Self {
85        Self::Io(value)
86    }
87}
88
89impl From<ProtocolError> for SmtpError {
90    fn from(value: ProtocolError) -> Self {
91        Self::Protocol(value)
92    }
93}
94
95impl From<AuthError> for SmtpError {
96    fn from(value: AuthError) -> Self {
97        Self::Auth(value)
98    }
99}
100
101impl From<InvalidInputError> for SmtpError {
102    fn from(value: InvalidInputError) -> Self {
103        Self::InvalidInput(value)
104    }
105}
106
107// -----------------------------------------------------------------------------
108// IoError
109// -----------------------------------------------------------------------------
110
111/// A failure that originated below SMTP, in the transport (TCP, TLS, the
112/// runtime's socket API).
113///
114/// Adapter crates (e.g. `wasm-smtp-cloudflare`) convert their runtime-specific
115/// errors into this type at the transport boundary. The conversion is lossy by
116/// design: it preserves a human-readable message but discards the original
117/// type, which keeps adapter-specific types out of the core public API.
118#[derive(Debug)]
119pub struct IoError {
120    message: String,
121}
122
123impl IoError {
124    /// Construct from any `Display`-able message.
125    pub fn new(message: impl Into<String>) -> Self {
126        Self {
127            message: message.into(),
128        }
129    }
130
131    /// The human-readable description of the failure.
132    pub fn message(&self) -> &str {
133        &self.message
134    }
135}
136
137impl fmt::Display for IoError {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        f.write_str(&self.message)
140    }
141}
142
143impl StdError for IoError {}
144
145// -----------------------------------------------------------------------------
146// ProtocolError
147// -----------------------------------------------------------------------------
148
149/// The SMTP operation that was in progress when an error was observed.
150///
151/// This is the granularity an operator looks for in a log message:
152/// "MAIL FROM was rejected" is more useful than "the server returned
153/// 550". Each variant corresponds to one user-visible step of the SMTP
154/// state machine.
155///
156/// The enum is `non_exhaustive` so that future SMTP extensions (e.g.
157/// `AUTH XOAUTH2`) can add a variant without forcing a major version
158/// bump.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
160#[non_exhaustive]
161pub enum SmtpOp {
162    /// Reading the server's initial greeting (a `2xx` line, typically
163    /// `220`).
164    Greeting,
165    /// `EHLO` and the capability negotiation that follows.
166    Ehlo,
167    /// `STARTTLS` command and the `220` reply that precedes the TLS
168    /// handshake (RFC 3207).
169    StartTls,
170    /// `AUTH PLAIN` (RFC 4616) initial-response exchange.
171    AuthPlain,
172    /// `AUTH LOGIN` exchange (any of its three round-trips).
173    AuthLogin,
174    /// `AUTH XOAUTH2` exchange (Google / Microsoft OAuth 2.0 SASL
175    /// profile).
176    AuthXOAuth2,
177    /// `MAIL FROM:<...>` envelope-sender announcement.
178    MailFrom,
179    /// `RCPT TO:<...>` recipient announcement (any of several when the
180    /// message has multiple recipients).
181    RcptTo,
182    /// The `DATA` command and the body that follows it.
183    Data,
184    /// `QUIT` shutdown handshake.
185    Quit,
186}
187
188impl SmtpOp {
189    /// A short, on-the-wire-style label for this operation. The string
190    /// matches the SMTP command keyword whenever there is one
191    /// (`"MAIL FROM"`, `"DATA"`, `"AUTH PLAIN"`); for the greeting
192    /// (which is server-initiated) the label is `"greeting"`.
193    #[must_use]
194    pub const fn as_str(self) -> &'static str {
195        match self {
196            Self::Greeting => "greeting",
197            Self::Ehlo => "EHLO",
198            Self::StartTls => "STARTTLS",
199            Self::AuthPlain => "AUTH PLAIN",
200            Self::AuthLogin => "AUTH LOGIN",
201            Self::AuthXOAuth2 => "AUTH XOAUTH2",
202            Self::MailFrom => "MAIL FROM",
203            Self::RcptTo => "RCPT TO",
204            Self::Data => "DATA",
205            Self::Quit => "QUIT",
206        }
207    }
208}
209
210impl fmt::Display for SmtpOp {
211    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212        f.write_str(self.as_str())
213    }
214}
215
216/// A wire-format failure or an unexpected response from the server.
217///
218/// This enum is `non_exhaustive` so that future SMTP extensions can add
219/// new failure modes without forcing a major version bump.
220#[derive(Debug)]
221#[non_exhaustive]
222pub enum ProtocolError {
223    /// The server returned a reply whose code class did not match what the
224    /// state machine required at this point.
225    ///
226    /// `during` records the SMTP operation that was in progress. This
227    /// lets a caller surface "MAIL FROM rejected (550)" rather than the
228    /// less actionable "550".
229    ///
230    /// `expected_class` is one of `2`, `3`, etc., representing the leading
231    /// digit. `actual` is the full three-digit code as observed.
232    ///
233    /// `enhanced` carries the parsed RFC 3463 status code if and only
234    /// if the server advertised `ENHANCEDSTATUSCODES` and the reply
235    /// began with a `class.subject.detail` token. It refines `actual`
236    /// significantly — for instance, the basic code `550` covers
237    /// many distinct failure modes (`5.1.1` user unknown, `5.7.1`
238    /// policy rejection, …) that a caller may want to distinguish
239    /// programmatically.
240    UnexpectedCode {
241        /// The SMTP operation that was in progress.
242        during: SmtpOp,
243        /// The leading reply-code digit the state machine required.
244        expected_class: u8,
245        /// The full three-digit reply code actually returned.
246        actual: u16,
247        /// Optional enhanced status code (RFC 3463), present only when
248        /// the session has `ENHANCEDSTATUSCODES` enabled and the
249        /// server attached a parseable code to its reply.
250        enhanced: Option<EnhancedStatus>,
251        /// The server-supplied reply text (joined across multi-line replies
252        /// with `\n`). When `enhanced` is `Some`, the prefix is left in
253        /// the message so the wire form is preserved; presentation code
254        /// can re-render the code separately if desired.
255        message: String,
256    },
257    /// A reply line did not parse: wrong length, non-digit code, illegal
258    /// continuation marker, or non-UTF-8 in a position where text was
259    /// expected.
260    Malformed(String),
261    /// The server closed the connection while the state machine was waiting
262    /// for more data.
263    UnexpectedClose,
264    /// A reply line exceeded the SMTP line-length limit (RFC 5321 §4.5.3.1.5,
265    /// 1000 octets including CRLF).
266    LineTooLong,
267    /// A multi-line reply contained inconsistent reply codes across lines.
268    /// RFC 5321 requires every line of a multi-line reply to share the same
269    /// three-digit code.
270    InconsistentMultiline {
271        /// The code on the first line.
272        first: u16,
273        /// The differing code observed on a later line.
274        later: u16,
275    },
276    /// The client requested an SMTP extension that the server did not
277    /// advertise in its `EHLO` response.
278    ///
279    /// Today this is raised only when the caller asks for `STARTTLS` but
280    /// the server's `EHLO` capability list does not include it.
281    ExtensionUnavailable {
282        /// The extension keyword as it would appear in `EHLO`
283        /// (e.g. `"STARTTLS"`).
284        name: &'static str,
285    },
286    /// Bytes were observed in the receive buffer between the server's
287    /// `220` reply to `STARTTLS` and the start of the TLS handshake.
288    ///
289    /// This is the signature of a [STARTTLS injection][rfc3207-sec5]
290    /// (also known as "STARTTLS command injection" or
291    /// CVE-2011-1575-class) attack: an attacker pipelines additional
292    /// SMTP commands on top of `STARTTLS` while the channel is still
293    /// plaintext, hoping the client will treat them as commands sent
294    /// over the secured channel after the upgrade. Robust clients
295    /// detect any unread bytes at the moment of upgrade and abort
296    /// the session rather than risk silently treating attacker-
297    /// injected plaintext as authenticated post-TLS traffic.
298    ///
299    /// `byte_count` is the number of bytes that were already in the
300    /// receive buffer when the upgrade was about to begin. Any
301    /// non-zero value is suspicious; zero is the safe case and never
302    /// produces this error.
303    ///
304    /// [rfc3207-sec5]: https://www.rfc-editor.org/rfc/rfc3207#section-5
305    StartTlsBufferResidue {
306        /// Number of unread bytes observed in the receive buffer
307        /// after the `220` STARTTLS reply.
308        byte_count: usize,
309    },
310}
311
312impl fmt::Display for ProtocolError {
313    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
314        match self {
315            Self::UnexpectedCode {
316                during,
317                expected_class,
318                actual,
319                enhanced,
320                message,
321            } => {
322                if let Some(es) = enhanced {
323                    write!(
324                        f,
325                        "during {during}, expected {expected_class}xx response but received {actual} [{es}]: {message}",
326                    )
327                } else {
328                    write!(
329                        f,
330                        "during {during}, expected {expected_class}xx response but received {actual}: {message}",
331                    )
332                }
333            }
334            Self::Malformed(s) => write!(f, "malformed server reply: {s}"),
335            Self::UnexpectedClose => f.write_str("server closed connection unexpectedly"),
336            Self::LineTooLong => f.write_str("server reply line exceeded SMTP line-length limit"),
337            Self::InconsistentMultiline { first, later } => {
338                write!(f, "multi-line reply mixed codes {first} and {later}",)
339            }
340            Self::ExtensionUnavailable { name } => {
341                write!(f, "server did not advertise the {name} extension")
342            }
343            Self::StartTlsBufferResidue { byte_count } => {
344                write!(
345                    f,
346                    "{byte_count} unread byte(s) in receive buffer after STARTTLS reply \
347                     (possible command-injection attack — aborting upgrade)"
348                )
349            }
350        }
351    }
352}
353
354impl StdError for ProtocolError {}
355
356// -----------------------------------------------------------------------------
357// AuthError
358// -----------------------------------------------------------------------------
359
360/// An authentication-specific failure.
361///
362/// This enum is `non_exhaustive` so that future SASL mechanisms can
363/// add new failure modes without forcing a major version bump.
364#[derive(Debug)]
365#[non_exhaustive]
366pub enum AuthError {
367    /// The server rejected the credentials. The reply code (typically 535) and
368    /// server message are preserved; client credentials are not.
369    Rejected {
370        /// SMTP reply code returned by the server.
371        code: u16,
372        /// Optional enhanced status code (RFC 3463), present only when
373        /// the session has `ENHANCEDSTATUSCODES` enabled. For AUTH
374        /// failures, common enhanced codes include `5.7.8`
375        /// (authentication credentials invalid) and `5.7.9`
376        /// (authentication mechanism too weak).
377        enhanced: Option<EnhancedStatus>,
378        /// Server-supplied reply text.
379        message: String,
380    },
381    /// The server's EHLO response did not advertise an `AUTH` mechanism that
382    /// this client supports, or did not advertise the specific mechanism
383    /// the caller asked for.
384    UnsupportedMechanism,
385    /// The server returned a 334 prompt that did not look like a valid
386    /// base64 challenge.
387    MalformedChallenge(String),
388}
389
390impl fmt::Display for AuthError {
391    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
392        match self {
393            Self::Rejected {
394                code,
395                enhanced,
396                message,
397            } => {
398                if let Some(es) = enhanced {
399                    write!(
400                        f,
401                        "server rejected authentication ({code} [{es}]): {message}"
402                    )
403                } else {
404                    write!(f, "server rejected authentication ({code}): {message}")
405                }
406            }
407            Self::UnsupportedMechanism => {
408                #[cfg(feature = "xoauth2")]
409                {
410                    f.write_str(
411                        "server did not advertise an AUTH mechanism supported by this client \
412                         (this client knows PLAIN, LOGIN, and XOAUTH2)",
413                    )
414                }
415                #[cfg(not(feature = "xoauth2"))]
416                {
417                    f.write_str(
418                        "server did not advertise an AUTH mechanism supported by this client \
419                         (this client knows PLAIN and LOGIN; XOAUTH2 was not compiled in)",
420                    )
421                }
422            }
423            Self::MalformedChallenge(s) => {
424                write!(f, "server sent a malformed AUTH challenge: {s}")
425            }
426        }
427    }
428}
429
430impl StdError for AuthError {}
431
432// -----------------------------------------------------------------------------
433// InvalidInputError
434// -----------------------------------------------------------------------------
435
436/// Caller-supplied input did not satisfy SMTP grammar.
437///
438/// The error carries a static reason string and never echoes the offending
439/// input, which would risk leaking message content or credentials into logs.
440#[derive(Debug)]
441pub struct InvalidInputError {
442    reason: &'static str,
443}
444
445impl InvalidInputError {
446    /// Construct from a static reason. Reasons are static to make it
447    /// statically impossible to embed runtime-supplied user input.
448    pub const fn new(reason: &'static str) -> Self {
449        Self { reason }
450    }
451
452    /// The reason string.
453    pub const fn reason(&self) -> &'static str {
454        self.reason
455    }
456}
457
458impl fmt::Display for InvalidInputError {
459    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
460        f.write_str(self.reason)
461    }
462}
463
464impl StdError for InvalidInputError {}