wasm-smtp 0.9.2

Environment-independent SMTP client core for WASM and other constrained runtimes.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
//! Error types returned by SMTP operations.
//!
//! All operations in this crate ultimately return [`SmtpError`], a four-arm
//! enum that classifies failures at a coarse granularity: transport (`Io`),
//! wire-format / unexpected response (`Protocol`), authentication (`Auth`),
//! and caller-supplied input that violates SMTP grammar (`InvalidInput`).
//!
//! ## Sensitivity
//!
//! Error messages must never include credentials or message body content.
//! Constructors in this module are designed so that callers cannot
//! accidentally embed such material:
//!
//! - [`InvalidInputError`] takes a static reason string only.
//! - [`AuthError::Rejected`] carries the server's reply text (which the server
//!   itself produced) but no client-side credentials.
//! - [`ProtocolError::UnexpectedCode`] carries server-produced text.
//! - The DATA-phase code in [`crate::client`] never includes body bytes in any
//!   error.

use core::fmt;
use std::error::Error as StdError;

use crate::protocol::EnhancedStatus;

/// Top-level error type for all SMTP operations.
///
/// # Logging caveat
///
/// The [`Display`](core::fmt::Display) output of `SmtpError` (and of
/// the variants `Protocol(ProtocolError)` and `Auth(AuthError)` in
/// particular) embeds the server's reply text verbatim. SMTP servers
/// commonly include the rejected envelope address in their reply
/// — e.g. `550 5.1.1 <user@example.com>: recipient does not exist`
/// — and may include other PII the application would not log of its
/// own accord. When emitting these errors to a shared log channel,
/// consider:
///
/// - Logging only the structured fields you care about
///   (`during`, `actual`, `enhanced`, `code`) and not the free-text
///   `message` field.
/// - Truncating the message to a fixed length.
/// - Filtering / redacting addresses with an application-level
///   regex if your compliance posture requires it.
///
/// `joined_text()` (the source of the `message` fields) is documented
/// to potentially contain `\n`; see [`crate::protocol::Reply::joined_text`].
#[derive(Debug)]
pub enum SmtpError {
    /// Underlying transport (socket) failure, including connection close.
    Io(IoError),
    /// Server response did not match SMTP grammar or expected code.
    Protocol(ProtocolError),
    /// Authentication exchange failed or no compatible mechanism was offered.
    Auth(AuthError),
    /// Caller-supplied input violated SMTP constraints before any byte was
    /// sent on the wire.
    InvalidInput(InvalidInputError),
}

impl fmt::Display for SmtpError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Io(e) => write!(f, "smtp transport error: {e}"),
            Self::Protocol(e) => write!(f, "smtp protocol error: {e}"),
            Self::Auth(e) => write!(f, "smtp auth error: {e}"),
            Self::InvalidInput(e) => write!(f, "smtp invalid input: {e}"),
        }
    }
}

impl StdError for SmtpError {
    fn source(&self) -> Option<&(dyn StdError + 'static)> {
        match self {
            Self::Io(e) => Some(e),
            Self::Protocol(e) => Some(e),
            Self::Auth(e) => Some(e),
            Self::InvalidInput(e) => Some(e),
        }
    }
}

impl From<IoError> for SmtpError {
    fn from(value: IoError) -> Self {
        Self::Io(value)
    }
}

impl From<ProtocolError> for SmtpError {
    fn from(value: ProtocolError) -> Self {
        Self::Protocol(value)
    }
}

impl From<AuthError> for SmtpError {
    fn from(value: AuthError) -> Self {
        Self::Auth(value)
    }
}

impl From<InvalidInputError> for SmtpError {
    fn from(value: InvalidInputError) -> Self {
        Self::InvalidInput(value)
    }
}

// -----------------------------------------------------------------------------
// IoError
// -----------------------------------------------------------------------------

/// A failure that originated below SMTP, in the transport (TCP, TLS, the
/// runtime's socket API).
///
/// Adapter crates (e.g. `wasm-smtp-cloudflare`, `wasm-smtp-tokio`) convert
/// their runtime-specific errors into this type at the transport boundary.
/// The wire-level details — the failing socket call, the underlying
/// `std::io::Error`, the rustls handshake error — can optionally be
/// preserved as the [`std::error::Error::source`] chain so callers see
/// the full diagnostic when formatting the error chain.
///
/// # Backwards compatibility
///
/// The simplest constructor [`Self::new`] continues to accept any
/// `Display`-able message and produces an `IoError` without a source.
/// Adapters wishing to preserve the original cause should use
/// [`Self::with_source`] (or the `From<std::io::Error>` impl).
///
/// # Example: preserving the `io::Error` in an adapter
///
/// ```
/// use wasm_smtp::IoError;
/// use std::io;
///
/// fn map_tcp_failure(e: io::Error) -> IoError {
///     IoError::with_source("TCP connect failed", e)
/// }
///
/// let original = io::Error::new(io::ErrorKind::ConnectionRefused, "refused");
/// let wrapped = map_tcp_failure(original);
///
/// // The high-level message is what Display shows:
/// assert_eq!(wrapped.to_string(), "TCP connect failed");
///
/// // The source chain still carries the original io::Error.
/// use std::error::Error;
/// assert!(wrapped.source().is_some());
/// ```
#[derive(Debug)]
pub struct IoError {
    message: String,
    source: Option<Box<dyn StdError + Send + Sync + 'static>>,
}

impl IoError {
    /// Construct from any `Display`-able message, without an underlying
    /// source.
    ///
    /// This is the simplest constructor and the right choice when the
    /// adapter does not have a structured error to preserve (e.g. when
    /// surfacing a programmer-supplied invariant violation).
    pub fn new(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
            source: None,
        }
    }

    /// Construct with a high-level message and an underlying error
    /// preserved as the [`std::error::Error::source`] chain.
    ///
    /// Adapter crates use this to retain the original `io::Error`,
    /// rustls handshake error, etc. so caller-side error-chain
    /// formatters (anyhow's `{:#}`, eyre, manual walks of
    /// `.source()`) can render the full diagnostic.
    ///
    /// The `Send + Sync + 'static` bounds match the conventions of
    /// `Box<dyn Error>` carried across thread boundaries — important
    /// for tokio-based adapters where errors may surface on a
    /// different thread than the one that observed them.
    pub fn with_source<E>(message: impl Into<String>, source: E) -> Self
    where
        E: StdError + Send + Sync + 'static,
    {
        Self {
            message: message.into(),
            source: Some(Box::new(source)),
        }
    }

    /// The human-readable description of the failure.
    pub fn message(&self) -> &str {
        &self.message
    }
}

impl fmt::Display for IoError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.message)
    }
}

impl StdError for IoError {
    fn source(&self) -> Option<&(dyn StdError + 'static)> {
        self.source
            .as_deref()
            .map(|e| e as &(dyn StdError + 'static))
    }
}

impl From<std::io::Error> for IoError {
    /// Convenience conversion: every adapter that wraps a `std::io::Error`
    /// can write `io_error.into()` to produce an `IoError` whose message
    /// is the original error's `Display` and whose source chain preserves
    /// the original.
    ///
    /// Most adapters will prefer [`IoError::with_source`] directly,
    /// because it lets them attach a higher-level context message
    /// ("TCP connect failed", "TLS handshake failed", "read short")
    /// rather than relying on the often-terse `io::Error` Display.
    fn from(e: std::io::Error) -> Self {
        let message = e.to_string();
        Self::with_source(message, e)
    }
}

// -----------------------------------------------------------------------------
// ProtocolError
// -----------------------------------------------------------------------------

/// The SMTP operation that was in progress when an error was observed.
///
/// This is the granularity an operator looks for in a log message:
/// "MAIL FROM was rejected" is more useful than "the server returned
/// 550". Each variant corresponds to one user-visible step of the SMTP
/// state machine.
///
/// The enum is `non_exhaustive` so that future SMTP extensions (e.g.
/// `AUTH XOAUTH2`) can add a variant without forcing a major version
/// bump.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SmtpOp {
    /// Reading the server's initial greeting (a `2xx` line, typically
    /// `220`).
    Greeting,
    /// `EHLO` and the capability negotiation that follows.
    Ehlo,
    /// `STARTTLS` command and the `220` reply that precedes the TLS
    /// handshake (RFC 3207).
    StartTls,
    /// `AUTH PLAIN` (RFC 4616) initial-response exchange.
    AuthPlain,
    /// `AUTH LOGIN` exchange (any of its three round-trips).
    AuthLogin,
    /// `AUTH XOAUTH2` exchange (Google / Microsoft OAuth 2.0 SASL
    /// profile).
    AuthXOAuth2,
    /// `AUTH SCRAM-SHA-256` exchange (RFC 5802 / RFC 7677). Available
    /// only with the `scram-sha-256` cargo feature; the variant
    /// itself is always present for source-compatibility stability.
    AuthScramSha256,
    /// `MAIL FROM:<...>` envelope-sender announcement.
    MailFrom,
    /// `RCPT TO:<...>` recipient announcement (any of several when the
    /// message has multiple recipients).
    RcptTo,
    /// The `DATA` command and the body that follows it.
    Data,
    /// `QUIT` shutdown handshake.
    Quit,
}

impl SmtpOp {
    /// A short, on-the-wire-style label for this operation. The string
    /// matches the SMTP command keyword whenever there is one
    /// (`"MAIL FROM"`, `"DATA"`, `"AUTH PLAIN"`); for the greeting
    /// (which is server-initiated) the label is `"greeting"`.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Greeting => "greeting",
            Self::Ehlo => "EHLO",
            Self::StartTls => "STARTTLS",
            Self::AuthPlain => "AUTH PLAIN",
            Self::AuthLogin => "AUTH LOGIN",
            Self::AuthXOAuth2 => "AUTH XOAUTH2",
            Self::AuthScramSha256 => "AUTH SCRAM-SHA-256",
            Self::MailFrom => "MAIL FROM",
            Self::RcptTo => "RCPT TO",
            Self::Data => "DATA",
            Self::Quit => "QUIT",
        }
    }
}

impl fmt::Display for SmtpOp {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

/// A wire-format failure or an unexpected response from the server.
///
/// This enum is `non_exhaustive` so that future SMTP extensions can add
/// new failure modes without forcing a major version bump.
#[derive(Debug)]
#[non_exhaustive]
pub enum ProtocolError {
    /// The server returned a reply whose code class did not match what the
    /// state machine required at this point.
    ///
    /// `during` records the SMTP operation that was in progress. This
    /// lets a caller surface "MAIL FROM rejected (550)" rather than the
    /// less actionable "550".
    ///
    /// `expected_class` is one of `2`, `3`, etc., representing the leading
    /// digit. `actual` is the full three-digit code as observed.
    ///
    /// `enhanced` carries the parsed RFC 3463 status code if and only
    /// if the server advertised `ENHANCEDSTATUSCODES` and the reply
    /// began with a `class.subject.detail` token. It refines `actual`
    /// significantly — for instance, the basic code `550` covers
    /// many distinct failure modes (`5.1.1` user unknown, `5.7.1`
    /// policy rejection, …) that a caller may want to distinguish
    /// programmatically.
    UnexpectedCode {
        /// The SMTP operation that was in progress.
        during: SmtpOp,
        /// The leading reply-code digit the state machine required.
        expected_class: u8,
        /// The full three-digit reply code actually returned.
        actual: u16,
        /// Optional enhanced status code (RFC 3463), present only when
        /// the session has `ENHANCEDSTATUSCODES` enabled and the
        /// server attached a parseable code to its reply.
        enhanced: Option<EnhancedStatus>,
        /// The server-supplied reply text (joined across multi-line replies
        /// with `\n`). When `enhanced` is `Some`, the prefix is left in
        /// the message so the wire form is preserved; presentation code
        /// can re-render the code separately if desired.
        message: String,
    },
    /// A reply line did not parse: wrong length, non-digit code, illegal
    /// continuation marker, or non-UTF-8 in a position where text was
    /// expected.
    Malformed(String),
    /// The server closed the connection while the state machine was waiting
    /// for more data.
    UnexpectedClose,
    /// A reply line exceeded the SMTP line-length limit (RFC 5321 §4.5.3.1.5,
    /// 1000 octets including CRLF).
    LineTooLong,
    /// A multi-line reply contained inconsistent reply codes across lines.
    /// RFC 5321 requires every line of a multi-line reply to share the same
    /// three-digit code.
    InconsistentMultiline {
        /// The code on the first line.
        first: u16,
        /// The differing code observed on a later line.
        later: u16,
    },
    /// The client requested an SMTP extension that the server did not
    /// advertise in its `EHLO` response.
    ///
    /// Today this is raised only when the caller asks for `STARTTLS` but
    /// the server's `EHLO` capability list does not include it.
    ExtensionUnavailable {
        /// The extension keyword as it would appear in `EHLO`
        /// (e.g. `"STARTTLS"`).
        name: &'static str,
    },
    /// Bytes were observed in the receive buffer between the server's
    /// `220` reply to `STARTTLS` and the start of the TLS handshake.
    ///
    /// This is the signature of a [STARTTLS injection][rfc3207-sec5]
    /// (also known as "STARTTLS command injection" or
    /// CVE-2011-1575-class) attack: an attacker pipelines additional
    /// SMTP commands on top of `STARTTLS` while the channel is still
    /// plaintext, hoping the client will treat them as commands sent
    /// over the secured channel after the upgrade. Robust clients
    /// detect any unread bytes at the moment of upgrade and abort
    /// the session rather than risk silently treating attacker-
    /// injected plaintext as authenticated post-TLS traffic.
    ///
    /// `byte_count` is the number of bytes that were already in the
    /// receive buffer when the upgrade was about to begin. Any
    /// non-zero value is suspicious; zero is the safe case and never
    /// produces this error.
    ///
    /// [rfc3207-sec5]: https://www.rfc-editor.org/rfc/rfc3207#section-5
    StartTlsBufferResidue {
        /// Number of unread bytes observed in the receive buffer
        /// after the `220` STARTTLS reply.
        byte_count: usize,
    },
}

impl fmt::Display for ProtocolError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::UnexpectedCode {
                during,
                expected_class,
                actual,
                enhanced,
                message,
            } => {
                if let Some(es) = enhanced {
                    write!(
                        f,
                        "during {during}, expected {expected_class}xx response but received {actual} [{es}]: {message}",
                    )
                } else {
                    write!(
                        f,
                        "during {during}, expected {expected_class}xx response but received {actual}: {message}",
                    )
                }
            }
            Self::Malformed(s) => write!(f, "malformed server reply: {s}"),
            Self::UnexpectedClose => f.write_str("server closed connection unexpectedly"),
            Self::LineTooLong => f.write_str("server reply line exceeded SMTP line-length limit"),
            Self::InconsistentMultiline { first, later } => {
                write!(f, "multi-line reply mixed codes {first} and {later}",)
            }
            Self::ExtensionUnavailable { name } => {
                write!(f, "server did not advertise the {name} extension")
            }
            Self::StartTlsBufferResidue { byte_count } => {
                write!(
                    f,
                    "{byte_count} unread byte(s) in receive buffer after STARTTLS reply \
                     (possible command-injection attack — aborting upgrade)"
                )
            }
        }
    }
}

impl StdError for ProtocolError {}

// -----------------------------------------------------------------------------
// AuthError
// -----------------------------------------------------------------------------

/// An authentication-specific failure.
///
/// This enum is `non_exhaustive` so that future SASL mechanisms can
/// add new failure modes without forcing a major version bump.
#[derive(Debug)]
#[non_exhaustive]
pub enum AuthError {
    /// The server rejected the credentials. The reply code (typically 535) and
    /// server message are preserved; client credentials are not.
    Rejected {
        /// SMTP reply code returned by the server.
        code: u16,
        /// Optional enhanced status code (RFC 3463), present only when
        /// the session has `ENHANCEDSTATUSCODES` enabled. For AUTH
        /// failures, common enhanced codes include `5.7.8`
        /// (authentication credentials invalid) and `5.7.9`
        /// (authentication mechanism too weak).
        enhanced: Option<EnhancedStatus>,
        /// Server-supplied reply text.
        message: String,
    },
    /// The server's EHLO response did not advertise an `AUTH` mechanism that
    /// this client supports, or did not advertise the specific mechanism
    /// the caller asked for.
    UnsupportedMechanism,
    /// The server returned a 334 prompt that did not look like a valid
    /// base64 challenge.
    MalformedChallenge(String),
    /// A mechanism-specific protocol failure that does not fit the
    /// other variants. Currently used by SCRAM-SHA-256 to surface
    /// malformed `server-first` / `server-final` messages, salt or
    /// signature decode failures, server-signature mismatch, and
    /// out-of-policy iteration counts. The `&'static str` is a
    /// debug aid; callers should not pattern-match on its content
    /// (it is not part of the API contract).
    Other(&'static str),
}

impl fmt::Display for AuthError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Rejected {
                code,
                enhanced,
                message,
            } => {
                if let Some(es) = enhanced {
                    write!(
                        f,
                        "server rejected authentication ({code} [{es}]): {message}"
                    )
                } else {
                    write!(f, "server rejected authentication ({code}): {message}")
                }
            }
            Self::UnsupportedMechanism => {
                #[cfg(feature = "xoauth2")]
                {
                    f.write_str(
                        "server did not advertise an AUTH mechanism supported by this client \
                         (this client knows PLAIN, LOGIN, and XOAUTH2)",
                    )
                }
                #[cfg(not(feature = "xoauth2"))]
                {
                    f.write_str(
                        "server did not advertise an AUTH mechanism supported by this client \
                         (this client knows PLAIN and LOGIN; XOAUTH2 was not compiled in)",
                    )
                }
            }
            Self::MalformedChallenge(s) => {
                write!(f, "server sent a malformed AUTH challenge: {s}")
            }
            Self::Other(detail) => {
                write!(f, "authentication failed: {detail}")
            }
        }
    }
}

impl StdError for AuthError {}

// -----------------------------------------------------------------------------
// InvalidInputError
// -----------------------------------------------------------------------------

/// Caller-supplied input did not satisfy SMTP grammar.
///
/// The error carries a static reason string and never echoes the offending
/// input, which would risk leaking message content or credentials into logs.
#[derive(Debug)]
pub struct InvalidInputError {
    reason: &'static str,
}

impl InvalidInputError {
    /// Construct from a static reason. Reasons are static to make it
    /// statically impossible to embed runtime-supplied user input.
    pub const fn new(reason: &'static str) -> Self {
        Self { reason }
    }

    /// The reason string.
    pub const fn reason(&self) -> &'static str {
        self.reason
    }
}

impl fmt::Display for InvalidInputError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.reason)
    }
}

impl StdError for InvalidInputError {}