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"));
}
}