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