plunk-rs 0.1.2

Async Rust client for the Plunk transactional email API
Documentation
use reqwest::StatusCode;
use std::fmt;
use thiserror::Error as ThisError;

pub type Result<T> = std::result::Result<T, Error>;

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ApiFieldError {
    field: String,
    message: String,
    code: Option<String>,
}

impl ApiFieldError {
    pub fn field(&self) -> &str {
        &self.field
    }

    pub fn message(&self) -> &str {
        &self.message
    }

    pub fn error_code(&self) -> Option<&str> {
        self.code.as_deref()
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ApiError {
    status: StatusCode,
    error_code: Option<String>,
    message: String,
    field_errors: Vec<ApiFieldError>,
    body: String,
}

impl ApiError {
    pub(crate) fn from_response(status: StatusCode, body: String) -> Self {
        match serde_json::from_str::<crate::wire::WireApiError>(&body) {
            Ok(payload) => Self {
                status,
                error_code: Some(payload.error.code),
                message: payload.error.message,
                field_errors: payload
                    .error
                    .errors
                    .into_iter()
                    .map(|error| ApiFieldError {
                        field: error.field,
                        message: error.message,
                        code: error.code,
                    })
                    .collect(),
                body,
            },
            Err(_) => Self {
                status,
                error_code: None,
                message: "unparseable API error response".to_string(),
                field_errors: Vec::new(),
                body,
            },
        }
    }

    pub fn status(&self) -> StatusCode {
        self.status
    }

    pub fn error_code(&self) -> Option<&str> {
        self.error_code.as_deref()
    }

    pub fn message(&self) -> &str {
        &self.message
    }

    pub fn field_errors(&self) -> &[ApiFieldError] {
        &self.field_errors
    }

    pub fn body(&self) -> &str {
        &self.body
    }
}

impl fmt::Display for ApiError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self.error_code() {
            Some(code) => write!(f, "{} {}: {}", self.status, code, self.message),
            None => write!(f, "{}: {}", self.status, self.message),
        }
    }
}

#[derive(Debug, ThisError)]
pub enum Error {
    #[error("api key cannot be empty")]
    InvalidApiKey,
    #[error("base URL is invalid: {0}")]
    InvalidBaseUrl(#[from] url::ParseError),
    #[error("at least one recipient is required")]
    MissingRecipients,
    #[error("subject cannot be empty")]
    InvalidSubject,
    #[error("body cannot be empty")]
    InvalidBody,
    #[error("template id cannot be empty")]
    InvalidTemplateId,
    #[error("template id must be a UUID")]
    InvalidTemplateIdFormat,
    #[error("display name cannot be empty")]
    InvalidDisplayName,
    #[error("header name cannot be empty")]
    InvalidHeaderName,
    #[error("header value cannot be empty")]
    InvalidHeaderValue,
    #[error("email address is invalid: {reason} ({value})")]
    InvalidEmailAddress { reason: &'static str, value: String },
    #[error("template data must serialize into a JSON object")]
    TemplateDataMustBeObject,
    #[error("failed to serialize template data: {0}")]
    TemplateDataSerialization(serde_json::Error),
    #[error("http request failed: {0}")]
    Transport(#[from] reqwest::Error),
    #[error("plunk API returned {0}")]
    Api(ApiError),
    #[error("unexpected API response: {0}")]
    UnexpectedResponse(String),
}

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

    #[test]
    fn api_error_preserves_raw_body_when_json_is_unparseable() {
        let error =
            ApiError::from_response(StatusCode::BAD_GATEWAY, "<html>bad gateway</html>".into());

        assert_eq!(error.status(), StatusCode::BAD_GATEWAY);
        assert_eq!(error.error_code(), None);
        assert!(error.field_errors().is_empty());
        assert_eq!(error.body(), "<html>bad gateway</html>");
    }

    #[test]
    fn api_error_parses_nested_plunk_error_shape() {
        let body = r#"{"success":false,"error":{"code":"VALIDATION_ERROR","message":"Request validation failed","statusCode":422,"requestId":"babb9a40-0826-4246-998d-35f6b3265612","errors":[{"field":"template","message":"Invalid uuid","code":"invalid_string"}],"suggestion":"Please check the API documentation for the correct request format."},"timestamp":"2026-04-30T02:47:06.773Z"}"#;

        let error = ApiError::from_response(StatusCode::UNPROCESSABLE_ENTITY, body.into());

        assert_eq!(error.status(), StatusCode::UNPROCESSABLE_ENTITY);
        assert_eq!(error.error_code(), Some("VALIDATION_ERROR"));
        assert_eq!(error.message(), "Request validation failed");
        assert_eq!(error.field_errors().len(), 1);
        assert_eq!(error.field_errors()[0].field(), "template");
        assert_eq!(error.field_errors()[0].message(), "Invalid uuid");
        assert_eq!(error.field_errors()[0].error_code(), Some("invalid_string"));
    }
}