Skip to main content

wasm_smtp/
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    /// A [`crate::policy::SendPolicy`] rejected the send before any SMTP
60    /// command was issued.
61    Policy(PolicyError),
62}
63
64impl fmt::Display for SmtpError {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        match self {
67            Self::Io(e) => write!(f, "smtp transport error: {e}"),
68            Self::Protocol(e) => write!(f, "smtp protocol error: {e}"),
69            Self::Auth(e) => write!(f, "smtp auth error: {e}"),
70            Self::InvalidInput(e) => write!(f, "smtp invalid input: {e}"),
71            Self::Policy(e) => write!(f, "smtp policy rejected: {e}"),
72        }
73    }
74}
75
76impl StdError for SmtpError {
77    fn source(&self) -> Option<&(dyn StdError + 'static)> {
78        match self {
79            Self::Io(e) => Some(e),
80            Self::Protocol(e) => Some(e),
81            Self::Auth(e) => Some(e),
82            Self::InvalidInput(e) => Some(e),
83            Self::Policy(e) => Some(e),
84        }
85    }
86}
87
88impl From<IoError> for SmtpError {
89    fn from(value: IoError) -> Self {
90        Self::Io(value)
91    }
92}
93
94impl From<ProtocolError> for SmtpError {
95    fn from(value: ProtocolError) -> Self {
96        Self::Protocol(value)
97    }
98}
99
100impl From<AuthError> for SmtpError {
101    fn from(value: AuthError) -> Self {
102        Self::Auth(value)
103    }
104}
105
106impl From<InvalidInputError> for SmtpError {
107    fn from(value: InvalidInputError) -> Self {
108        Self::InvalidInput(value)
109    }
110}
111
112impl From<PolicyError> for SmtpError {
113    fn from(value: PolicyError) -> Self {
114        Self::Policy(value)
115    }
116}
117
118// -----------------------------------------------------------------------------
119// PolicyError
120// -----------------------------------------------------------------------------
121
122/// A [`crate::policy::SendPolicy`] rejected the send operation.
123///
124/// Unlike [`InvalidInputError`] (which enforces SMTP grammar constraints),
125/// `PolicyError` represents application-defined business-logic constraints:
126/// sender allowlists, recipient limits, message size caps, rate limits, etc.
127///
128/// The `message` field contains a human-readable explanation supplied by the
129/// policy implementation. It must not include credential or message-body data.
130#[derive(Debug)]
131pub struct PolicyError {
132    message: String,
133}
134
135impl PolicyError {
136    /// Construct from any `Display`-able message.
137    pub fn new(message: impl Into<String>) -> Self {
138        Self {
139            message: message.into(),
140        }
141    }
142
143    /// The human-readable rejection reason.
144    pub fn message(&self) -> &str {
145        &self.message
146    }
147}
148
149impl fmt::Display for PolicyError {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        f.write_str(&self.message)
152    }
153}
154
155impl StdError for PolicyError {}
156
157// -----------------------------------------------------------------------------
158// IoError
159// -----------------------------------------------------------------------------
160
161/// A failure that originated below SMTP, in the transport (TCP, TLS, the
162/// runtime's socket API).
163///
164/// Adapter crates (e.g. `wasm-smtp-cloudflare`, `wasm-smtp-tokio`) convert
165/// their runtime-specific errors into this type at the transport boundary.
166/// The wire-level details — the failing socket call, the underlying
167/// `std::io::Error`, the rustls handshake error — can optionally be
168/// preserved as the [`std::error::Error::source`] chain so callers see
169/// the full diagnostic when formatting the error chain.
170///
171/// # Backwards compatibility
172///
173/// The simplest constructor [`Self::new`] continues to accept any
174/// `Display`-able message and produces an `IoError` without a source.
175/// Adapters wishing to preserve the original cause should use
176/// [`Self::with_source`] (or the `From<std::io::Error>` impl).
177///
178/// # Example: preserving the `io::Error` in an adapter
179///
180/// ```
181/// use wasm_smtp::IoError;
182/// use std::io;
183///
184/// fn map_tcp_failure(e: io::Error) -> IoError {
185///     IoError::with_source("TCP connect failed", e)
186/// }
187///
188/// let original = io::Error::new(io::ErrorKind::ConnectionRefused, "refused");
189/// let wrapped = map_tcp_failure(original);
190///
191/// // The high-level message is what Display shows:
192/// assert_eq!(wrapped.to_string(), "TCP connect failed");
193///
194/// // The source chain still carries the original io::Error.
195/// use std::error::Error;
196/// assert!(wrapped.source().is_some());
197/// ```
198#[derive(Debug)]
199pub struct IoError {
200    message: String,
201    source: Option<Box<dyn StdError + Send + Sync + 'static>>,
202}
203
204impl IoError {
205    /// Construct from any `Display`-able message, without an underlying
206    /// source.
207    ///
208    /// This is the simplest constructor and the right choice when the
209    /// adapter does not have a structured error to preserve (e.g. when
210    /// surfacing a programmer-supplied invariant violation).
211    pub fn new(message: impl Into<String>) -> Self {
212        Self {
213            message: message.into(),
214            source: None,
215        }
216    }
217
218    /// Construct with a high-level message and an underlying error
219    /// preserved as the [`std::error::Error::source`] chain.
220    ///
221    /// Adapter crates use this to retain the original `io::Error`,
222    /// rustls handshake error, etc. so caller-side error-chain
223    /// formatters (anyhow's `{:#}`, eyre, manual walks of
224    /// `.source()`) can render the full diagnostic.
225    ///
226    /// The `Send + Sync + 'static` bounds match the conventions of
227    /// `Box<dyn Error>` carried across thread boundaries — important
228    /// for tokio-based adapters where errors may surface on a
229    /// different thread than the one that observed them.
230    pub fn with_source<E>(message: impl Into<String>, source: E) -> Self
231    where
232        E: StdError + Send + Sync + 'static,
233    {
234        Self {
235            message: message.into(),
236            source: Some(Box::new(source)),
237        }
238    }
239
240    /// The human-readable description of the failure.
241    pub fn message(&self) -> &str {
242        &self.message
243    }
244
245    /// Walk the [`std::error::Error::source`] chain looking for an
246    /// [`std::io::Error`], then expose its [`std::io::ErrorKind`].
247    ///
248    /// Returns `None` when the chain contains no `io::Error` — for
249    /// example, a TLS handshake error from rustls that did not wrap
250    /// an underlying I/O error, or an [`IoError`] constructed via
251    /// [`Self::new`] without a source.
252    ///
253    /// This is the foundation for the `is_*` helpers below; callers
254    /// who need a kind not covered by a named helper (e.g.
255    /// [`std::io::ErrorKind::NotFound`] for a missing certificate
256    /// file) can use `io_kind` directly.
257    ///
258    /// # Example
259    ///
260    /// ```
261    /// # use wasm_smtp::IoError;
262    /// # use std::io;
263    /// let io = io::Error::new(io::ErrorKind::PermissionDenied, "no");
264    /// let wrapped = IoError::with_source("connect failed", io);
265    /// assert_eq!(wrapped.io_kind(), Some(io::ErrorKind::PermissionDenied));
266    /// ```
267    #[must_use]
268    pub fn io_kind(&self) -> Option<std::io::ErrorKind> {
269        // Walk the source chain manually rather than using a fixed
270        // depth: rustls and tokio may nest the io::Error one or two
271        // levels deep depending on the operation. The chain is
272        // typically very short (1–3 nodes) so this is cheap.
273        let mut current: Option<&(dyn StdError + 'static)> = self.source();
274        while let Some(err) = current {
275            if let Some(io) = err.downcast_ref::<std::io::Error>() {
276                return Some(io.kind());
277            }
278            current = err.source();
279        }
280        None
281    }
282
283    /// `true` when the underlying I/O error is a timeout
284    /// ([`std::io::ErrorKind::TimedOut`]). Useful for retry-or-give-up
285    /// decisions in retry layers.
286    #[must_use]
287    pub fn is_timeout(&self) -> bool {
288        self.io_kind() == Some(std::io::ErrorKind::TimedOut)
289    }
290
291    /// `true` when the underlying I/O error indicates the peer
292    /// refused the connection ([`std::io::ErrorKind::ConnectionRefused`]).
293    /// Typically means the server is down, the wrong port was used,
294    /// or a firewall is blocking the connection.
295    #[must_use]
296    pub fn is_connection_refused(&self) -> bool {
297        self.io_kind() == Some(std::io::ErrorKind::ConnectionRefused)
298    }
299
300    /// `true` when the underlying I/O error indicates the connection
301    /// was reset by the peer ([`std::io::ErrorKind::ConnectionReset`]).
302    /// Typically means the server hung up unexpectedly, often after
303    /// an authentication failure or a protocol violation the server
304    /// chose not to spell out.
305    #[must_use]
306    pub fn is_connection_reset(&self) -> bool {
307        self.io_kind() == Some(std::io::ErrorKind::ConnectionReset)
308    }
309
310    /// `true` when the underlying I/O error indicates the connection
311    /// was aborted ([`std::io::ErrorKind::ConnectionAborted`]).
312    #[must_use]
313    pub fn is_connection_aborted(&self) -> bool {
314        self.io_kind() == Some(std::io::ErrorKind::ConnectionAborted)
315    }
316}
317
318impl fmt::Display for IoError {
319    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
320        f.write_str(&self.message)
321    }
322}
323
324impl StdError for IoError {
325    fn source(&self) -> Option<&(dyn StdError + 'static)> {
326        self.source
327            .as_deref()
328            .map(|e| e as &(dyn StdError + 'static))
329    }
330}
331
332impl From<std::io::Error> for IoError {
333    /// Convenience conversion: every adapter that wraps a `std::io::Error`
334    /// can write `io_error.into()` to produce an `IoError` whose message
335    /// is the original error's `Display` and whose source chain preserves
336    /// the original.
337    ///
338    /// Most adapters will prefer [`IoError::with_source`] directly,
339    /// because it lets them attach a higher-level context message
340    /// ("TCP connect failed", "TLS handshake failed", "read short")
341    /// rather than relying on the often-terse `io::Error` Display.
342    fn from(e: std::io::Error) -> Self {
343        let message = e.to_string();
344        Self::with_source(message, e)
345    }
346}
347
348// -----------------------------------------------------------------------------
349// ProtocolError
350// -----------------------------------------------------------------------------
351
352/// The SMTP operation that was in progress when an error was observed.
353///
354/// This is the granularity an operator looks for in a log message:
355/// "MAIL FROM was rejected" is more useful than "the server returned
356/// 550". Each variant corresponds to one user-visible step of the SMTP
357/// state machine.
358///
359/// The enum is `non_exhaustive` so that future SMTP extensions (e.g.
360/// `AUTH XOAUTH2`) can add a variant without forcing a major version
361/// bump.
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
363#[non_exhaustive]
364pub enum SmtpOp {
365    /// Reading the server's initial greeting (a `2xx` line, typically
366    /// `220`).
367    Greeting,
368    /// `EHLO` and the capability negotiation that follows.
369    Ehlo,
370    /// `STARTTLS` command and the `220` reply that precedes the TLS
371    /// handshake (RFC 3207).
372    StartTls,
373    /// `AUTH PLAIN` (RFC 4616) initial-response exchange.
374    AuthPlain,
375    /// `AUTH LOGIN` exchange (any of its three round-trips).
376    AuthLogin,
377    /// `AUTH XOAUTH2` exchange (Google / Microsoft OAuth 2.0 SASL
378    /// profile).
379    AuthXOAuth2,
380    /// `AUTH OAUTHBEARER` exchange (RFC 7628, IETF-standard OAuth 2.0
381    /// SASL mechanism).
382    AuthOAuthBearer,
383    /// `AUTH SCRAM-SHA-256` exchange (RFC 5802 / RFC 7677). Available
384    /// only with the `scram-sha-256` cargo feature; the variant
385    /// itself is always present for source-compatibility stability.
386    AuthScramSha256,
387    /// `MAIL FROM:<...>` envelope-sender announcement.
388    MailFrom,
389    /// `RCPT TO:<...>` recipient announcement (any of several when the
390    /// message has multiple recipients).
391    RcptTo,
392    /// The `DATA` command and the body that follows it.
393    Data,
394    /// `QUIT` shutdown handshake.
395    Quit,
396}
397
398impl SmtpOp {
399    /// A short, on-the-wire-style label for this operation. The string
400    /// matches the SMTP command keyword whenever there is one
401    /// (`"MAIL FROM"`, `"DATA"`, `"AUTH PLAIN"`); for the greeting
402    /// (which is server-initiated) the label is `"greeting"`.
403    #[must_use]
404    pub const fn as_str(self) -> &'static str {
405        match self {
406            Self::Greeting => "greeting",
407            Self::Ehlo => "EHLO",
408            Self::StartTls => "STARTTLS",
409            Self::AuthPlain => "AUTH PLAIN",
410            Self::AuthLogin => "AUTH LOGIN",
411            Self::AuthXOAuth2 => "AUTH XOAUTH2",
412            Self::AuthOAuthBearer => "AUTH OAUTHBEARER",
413            Self::AuthScramSha256 => "AUTH SCRAM-SHA-256",
414            Self::MailFrom => "MAIL FROM",
415            Self::RcptTo => "RCPT TO",
416            Self::Data => "DATA",
417            Self::Quit => "QUIT",
418        }
419    }
420}
421
422impl fmt::Display for SmtpOp {
423    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
424        f.write_str(self.as_str())
425    }
426}
427
428/// A wire-format failure or an unexpected response from the server.
429///
430/// This enum is `non_exhaustive` so that future SMTP extensions can add
431/// new failure modes without forcing a major version bump.
432#[derive(Debug)]
433#[non_exhaustive]
434pub enum ProtocolError {
435    /// The server returned a reply whose code class did not match what the
436    /// state machine required at this point.
437    ///
438    /// `during` records the SMTP operation that was in progress. This
439    /// lets a caller surface "MAIL FROM rejected (550)" rather than the
440    /// less actionable "550".
441    ///
442    /// `expected_class` is one of `2`, `3`, etc., representing the leading
443    /// digit. `actual` is the full three-digit code as observed.
444    ///
445    /// `enhanced` carries the parsed RFC 3463 status code if and only
446    /// if the server advertised `ENHANCEDSTATUSCODES` and the reply
447    /// began with a `class.subject.detail` token. It refines `actual`
448    /// significantly — for instance, the basic code `550` covers
449    /// many distinct failure modes (`5.1.1` user unknown, `5.7.1`
450    /// policy rejection, …) that a caller may want to distinguish
451    /// programmatically.
452    UnexpectedCode {
453        /// The SMTP operation that was in progress.
454        during: SmtpOp,
455        /// The leading reply-code digit the state machine required.
456        expected_class: u8,
457        /// The full three-digit reply code actually returned.
458        actual: u16,
459        /// Optional enhanced status code (RFC 3463), present only when
460        /// the session has `ENHANCEDSTATUSCODES` enabled and the
461        /// server attached a parseable code to its reply.
462        enhanced: Option<EnhancedStatus>,
463        /// The server-supplied reply text (joined across multi-line replies
464        /// with `\n`). When `enhanced` is `Some`, the prefix is left in
465        /// the message so the wire form is preserved; presentation code
466        /// can re-render the code separately if desired.
467        message: String,
468    },
469    /// A reply line did not parse: wrong length, non-digit code, illegal
470    /// continuation marker, or non-UTF-8 in a position where text was
471    /// expected.
472    Malformed(String),
473    /// The server closed the connection while the state machine was waiting
474    /// for more data.
475    UnexpectedClose,
476    /// A reply line exceeded the SMTP line-length limit (RFC 5321 §4.5.3.1.5,
477    /// 1000 octets including CRLF).
478    LineTooLong,
479    /// A multi-line reply contained inconsistent reply codes across lines.
480    /// RFC 5321 requires every line of a multi-line reply to share the same
481    /// three-digit code.
482    InconsistentMultiline {
483        /// The code on the first line.
484        first: u16,
485        /// The differing code observed on a later line.
486        later: u16,
487    },
488    /// The client requested an SMTP extension that the server did not
489    /// advertise in its `EHLO` response.
490    ///
491    /// Today this is raised only when the caller asks for `STARTTLS` but
492    /// the server's `EHLO` capability list does not include it.
493    ExtensionUnavailable {
494        /// The extension keyword as it would appear in `EHLO`
495        /// (e.g. `"STARTTLS"`).
496        name: &'static str,
497    },
498    /// Bytes were observed in the receive buffer between the server's
499    /// `220` reply to `STARTTLS` and the start of the TLS handshake.
500    ///
501    /// This is the signature of a [STARTTLS injection][rfc3207-sec5]
502    /// (also known as "STARTTLS command injection" or
503    /// CVE-2011-1575-class) attack: an attacker pipelines additional
504    /// SMTP commands on top of `STARTTLS` while the channel is still
505    /// plaintext, hoping the client will treat them as commands sent
506    /// over the secured channel after the upgrade. Robust clients
507    /// detect any unread bytes at the moment of upgrade and abort
508    /// the session rather than risk silently treating attacker-
509    /// injected plaintext as authenticated post-TLS traffic.
510    ///
511    /// `byte_count` is the number of bytes that were already in the
512    /// receive buffer when the upgrade was about to begin. Any
513    /// non-zero value is suspicious; zero is the safe case and never
514    /// produces this error.
515    ///
516    /// [rfc3207-sec5]: https://www.rfc-editor.org/rfc/rfc3207#section-5
517    StartTlsBufferResidue {
518        /// Number of unread bytes observed in the receive buffer
519        /// after the `220` STARTTLS reply.
520        byte_count: usize,
521    },
522}
523
524impl fmt::Display for ProtocolError {
525    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
526        match self {
527            Self::UnexpectedCode {
528                during,
529                expected_class,
530                actual,
531                enhanced,
532                message,
533            } => {
534                if let Some(es) = enhanced {
535                    write!(
536                        f,
537                        "during {during}, expected {expected_class}xx response but received {actual} [{es}]: {message}",
538                    )
539                } else {
540                    write!(
541                        f,
542                        "during {during}, expected {expected_class}xx response but received {actual}: {message}",
543                    )
544                }
545            }
546            Self::Malformed(s) => write!(f, "malformed server reply: {s}"),
547            Self::UnexpectedClose => f.write_str("server closed connection unexpectedly"),
548            Self::LineTooLong => f.write_str("server reply line exceeded SMTP line-length limit"),
549            Self::InconsistentMultiline { first, later } => {
550                write!(f, "multi-line reply mixed codes {first} and {later}",)
551            }
552            Self::ExtensionUnavailable { name } => {
553                write!(f, "server did not advertise the {name} extension")
554            }
555            Self::StartTlsBufferResidue { byte_count } => {
556                write!(
557                    f,
558                    "{byte_count} unread byte(s) in receive buffer after STARTTLS reply \
559                     (possible command-injection attack — aborting upgrade)"
560                )
561            }
562        }
563    }
564}
565
566impl StdError for ProtocolError {}
567
568// -----------------------------------------------------------------------------
569// AuthError
570// -----------------------------------------------------------------------------
571
572/// An authentication-specific failure.
573///
574/// This enum is `non_exhaustive` so that future SASL mechanisms can
575/// add new failure modes without forcing a major version bump.
576#[derive(Debug)]
577#[non_exhaustive]
578pub enum AuthError {
579    /// The server rejected the credentials. The reply code (typically 535) and
580    /// server message are preserved; client credentials are not.
581    Rejected {
582        /// SMTP reply code returned by the server.
583        code: u16,
584        /// Optional enhanced status code (RFC 3463), present only when
585        /// the session has `ENHANCEDSTATUSCODES` enabled. For AUTH
586        /// failures, common enhanced codes include `5.7.8`
587        /// (authentication credentials invalid) and `5.7.9`
588        /// (authentication mechanism too weak).
589        enhanced: Option<EnhancedStatus>,
590        /// Server-supplied reply text.
591        message: String,
592    },
593    /// The server's EHLO response did not advertise an `AUTH` mechanism that
594    /// this client supports, or did not advertise the specific mechanism
595    /// the caller asked for.
596    UnsupportedMechanism,
597    /// The server returned a 334 prompt that did not look like a valid
598    /// base64 challenge.
599    MalformedChallenge(String),
600    /// A mechanism-specific protocol failure that does not fit the
601    /// other variants. Currently used by SCRAM-SHA-256 to surface
602    /// malformed `server-first` / `server-final` messages, salt or
603    /// signature decode failures, server-signature mismatch, and
604    /// out-of-policy iteration counts. The `&'static str` is a
605    /// debug aid; callers should not pattern-match on its content
606    /// (it is not part of the API contract).
607    Other(&'static str),
608}
609
610impl fmt::Display for AuthError {
611    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
612        match self {
613            Self::Rejected {
614                code,
615                enhanced,
616                message,
617            } => {
618                if let Some(es) = enhanced {
619                    write!(
620                        f,
621                        "server rejected authentication ({code} [{es}]): {message}"
622                    )
623                } else {
624                    write!(f, "server rejected authentication ({code}): {message}")
625                }
626            }
627            Self::UnsupportedMechanism => {
628                #[cfg(feature = "xoauth2")]
629                {
630                    f.write_str(
631                        "server did not advertise an AUTH mechanism supported by this client \
632                         (this client knows PLAIN, LOGIN, and XOAUTH2)",
633                    )
634                }
635                #[cfg(not(feature = "xoauth2"))]
636                {
637                    f.write_str(
638                        "server did not advertise an AUTH mechanism supported by this client \
639                         (this client knows PLAIN and LOGIN; XOAUTH2 was not compiled in)",
640                    )
641                }
642            }
643            Self::MalformedChallenge(s) => {
644                write!(f, "server sent a malformed AUTH challenge: {s}")
645            }
646            Self::Other(detail) => {
647                write!(f, "authentication failed: {detail}")
648            }
649        }
650    }
651}
652
653impl StdError for AuthError {}
654
655// -----------------------------------------------------------------------------
656// InvalidInputError
657// -----------------------------------------------------------------------------
658
659/// Caller-supplied input did not satisfy SMTP grammar.
660///
661/// The error carries a static reason string and never echoes the offending
662/// input, which would risk leaking message content or credentials into logs.
663#[derive(Debug)]
664pub struct InvalidInputError {
665    reason: &'static str,
666}
667
668impl InvalidInputError {
669    /// Construct from a static reason. Reasons are static to make it
670    /// statically impossible to embed runtime-supplied user input.
671    pub const fn new(reason: &'static str) -> Self {
672        Self { reason }
673    }
674
675    /// The reason string.
676    pub const fn reason(&self) -> &'static str {
677        self.reason
678    }
679}
680
681impl fmt::Display for InvalidInputError {
682    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
683        f.write_str(self.reason)
684    }
685}
686
687impl StdError for InvalidInputError {}