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 {}