daaki-smtp 0.1.0

An async SMTP client library
Documentation
//! Error types for SMTP operations.
//!
//! Distinguishes permanent (5xx), transient (4xx), I/O, auth, parse, and timeout errors.
//! Reply code classes are defined in RFC 5321 Section 4.2.1.

use crate::types::SmtpResponse;

/// Error type for SMTP client operations.
#[derive(Debug, thiserror::Error)]
pub enum Error {
    /// Underlying I/O error (includes TLS transport errors).
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),

    /// Authentication was rejected by the server (RFC 4954 Section 4).
    ///
    /// The `response` field preserves the full server reply so callers can
    /// distinguish transient failures (454) from permanent ones (535).
    #[error("authentication failed: {message}")]
    Auth {
        message: String,
        response: SmtpResponse,
    },

    /// Permanent failure — 5xx response (RFC 5321 Section 4.2.1). Do not retry.
    #[error("permanent failure ({code}): {message}")]
    Permanent {
        code: u16,
        message: String,
        response: SmtpResponse,
    },

    /// Transient failure — 4xx response (RFC 5321 Section 4.2.1). May succeed on retry.
    #[error("transient failure ({code}): {message}")]
    Transient {
        code: u16,
        message: String,
        response: SmtpResponse,
    },

    /// SMTP protocol violation by the server.
    #[error("protocol error: {0}")]
    Protocol(String),

    /// Failed to parse a server response.
    #[error("parse error: {0}")]
    Parse(String),

    /// Operation exceeded the caller-supplied timeout.
    #[error("operation timed out")]
    Timeout,

    /// The connection has been closed.
    #[error("connection closed")]
    Closed,

    /// STARTTLS was requested but the server does not advertise it (RFC 3207).
    #[error("STARTTLS not supported by server")]
    StartTlsUnavailable,

    /// All recipients were rejected (RFC 5321 Section 3.3 / RFC 1854 Section 3).
    #[error("all {count} recipients were rejected")]
    AllRecipientsFailed {
        count: usize,
        responses: Vec<SmtpResponse>,
    },
}

impl Error {
    /// Returns `true` if the error is transient and the operation may succeed on retry.
    ///
    /// Transient errors include 4xx SMTP responses (RFC 5321 Section 4.2.1),
    /// I/O errors, timeouts, and transient auth failures (454, RFC 4954 Section 4).
    pub fn is_transient(&self) -> bool {
        match self {
            Self::Transient { .. } | Self::Io(_) | Self::Timeout => true,
            // RFC 4954 Section 4: 454 is a transient auth failure.
            Self::Auth { response, .. } => response.is_transient_error(),
            _ => false,
        }
    }

    /// Returns `true` if the error is permanent and the operation should not be retried.
    ///
    /// Permanent errors include 5xx SMTP responses (RFC 5321 Section 4.2.1)
    /// and permanent authentication failures (535, RFC 4954 Section 4).
    pub fn is_permanent(&self) -> bool {
        match self {
            Self::Permanent { .. } => true,
            // RFC 4954 Section 4: 535 is a permanent auth failure.
            Self::Auth { response, .. } => response.is_permanent_error(),
            _ => false,
        }
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    /// Helper to build an `SmtpResponse` with a given code.
    fn response(code: u16) -> SmtpResponse {
        SmtpResponse {
            code,
            enhanced_code: None,
            lines: vec!["test".into()],
        }
    }

    // ── is_transient ────────────────────────────────────────────────────

    #[test]
    fn transient_error_is_transient() {
        // RFC 5321 Section 4.2.1: 4xx responses are transient failures.
        let err = Error::Transient {
            code: 421,
            message: "try again".into(),
            response: response(421),
        };
        assert!(
            err.is_transient(),
            "Transient error must return true for is_transient()"
        );
    }

    #[test]
    fn io_error_is_transient() {
        // I/O errors are transient — network issues may resolve on retry.
        let err = Error::Io(std::io::Error::new(
            std::io::ErrorKind::ConnectionReset,
            "connection reset",
        ));
        assert!(
            err.is_transient(),
            "Io error must return true for is_transient()"
        );
    }

    #[test]
    fn timeout_error_is_transient() {
        // Timeouts are transient — the server may respond faster on retry.
        let err = Error::Timeout;
        assert!(
            err.is_transient(),
            "Timeout error must return true for is_transient()"
        );
    }

    #[test]
    fn permanent_error_is_not_transient() {
        // RFC 5321 Section 4.2.1: 5xx responses are permanent failures.
        let err = Error::Permanent {
            code: 550,
            message: "mailbox not found".into(),
            response: response(550),
        };
        assert!(
            !err.is_transient(),
            "Permanent error must return false for is_transient()"
        );
    }

    #[test]
    fn auth_transient_is_transient() {
        // RFC 4954 Section 4: 454 is a transient auth failure.
        let err = Error::Auth {
            message: "temporary auth failure".into(),
            response: response(454),
        };
        assert!(
            err.is_transient(),
            "Auth error with 4xx response must return true for is_transient() \
             (RFC 4954 Section 4)"
        );
    }

    #[test]
    fn auth_permanent_is_not_transient() {
        // RFC 4954 Section 4: 535 is a permanent auth failure, not transient.
        let err = Error::Auth {
            message: "bad credentials".into(),
            response: response(535),
        };
        assert!(
            !err.is_transient(),
            "Auth error with 5xx response must return false for is_transient()"
        );
    }

    #[test]
    fn parse_error_is_not_transient() {
        let err = Error::Parse("bad response".into());
        assert!(
            !err.is_transient(),
            "Parse error must return false for is_transient()"
        );
    }

    #[test]
    fn protocol_error_is_not_transient() {
        let err = Error::Protocol("violation".into());
        assert!(
            !err.is_transient(),
            "Protocol error must return false for is_transient()"
        );
    }

    #[test]
    fn closed_error_is_not_transient() {
        let err = Error::Closed;
        assert!(
            !err.is_transient(),
            "Closed error must return false for is_transient()"
        );
    }

    // ── is_permanent ────────────────────────────────────────────────────

    #[test]
    fn permanent_error_is_permanent() {
        // RFC 5321 Section 4.2.1: 5xx responses are permanent failures.
        let err = Error::Permanent {
            code: 550,
            message: "mailbox not found".into(),
            response: response(550),
        };
        assert!(
            err.is_permanent(),
            "Permanent error must return true for is_permanent()"
        );
    }

    #[test]
    fn transient_error_is_not_permanent() {
        // RFC 5321 Section 4.2.1: 4xx responses are transient, not permanent.
        let err = Error::Transient {
            code: 421,
            message: "try again".into(),
            response: response(421),
        };
        assert!(
            !err.is_permanent(),
            "Transient error must return false for is_permanent()"
        );
    }

    #[test]
    fn io_error_is_not_permanent() {
        let err = Error::Io(std::io::Error::new(
            std::io::ErrorKind::ConnectionRefused,
            "refused",
        ));
        assert!(
            !err.is_permanent(),
            "Io error must return false for is_permanent()"
        );
    }

    #[test]
    fn auth_permanent_is_permanent() {
        // RFC 4954 Section 4: 535 is a permanent auth failure.
        let err = Error::Auth {
            message: "bad credentials".into(),
            response: response(535),
        };
        assert!(
            err.is_permanent(),
            "Auth error with 5xx response must return true for is_permanent() \
             (RFC 4954 Section 4)"
        );
    }

    #[test]
    fn auth_transient_is_not_permanent() {
        // RFC 4954 Section 4: 454 is a transient auth failure, not permanent.
        let err = Error::Auth {
            message: "temporary failure".into(),
            response: response(454),
        };
        assert!(
            !err.is_permanent(),
            "Auth error with 4xx response must return false for is_permanent()"
        );
    }

    #[test]
    fn timeout_is_not_permanent() {
        let err = Error::Timeout;
        assert!(
            !err.is_permanent(),
            "Timeout error must return false for is_permanent()"
        );
    }

    #[test]
    fn parse_error_is_not_permanent() {
        let err = Error::Parse("bad response".into());
        assert!(
            !err.is_permanent(),
            "Parse error must return false for is_permanent()"
        );
    }

    #[test]
    fn starttls_unavailable_is_not_permanent() {
        // RFC 3207: STARTTLS unavailability is not inherently permanent
        // (server config may change).
        let err = Error::StartTlsUnavailable;
        assert!(
            !err.is_permanent(),
            "StartTlsUnavailable must return false for is_permanent()"
        );
    }

    #[test]
    fn all_recipients_failed_is_not_permanent() {
        // AllRecipientsFailed is an aggregate error, not classified as
        // permanent or transient at the top level.
        let err = Error::AllRecipientsFailed {
            count: 2,
            responses: vec![response(550), response(550)],
        };
        assert!(
            !err.is_permanent(),
            "AllRecipientsFailed must return false for is_permanent()"
        );
    }
}