1use reqwest::StatusCode;
2use std::fmt;
3use thiserror::Error as ThisError;
4
5pub type Result<T> = std::result::Result<T, Error>;
6
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct ApiFieldError {
9 field: String,
10 message: String,
11 code: Option<String>,
12}
13
14impl ApiFieldError {
15 pub fn field(&self) -> &str {
16 &self.field
17 }
18
19 pub fn message(&self) -> &str {
20 &self.message
21 }
22
23 pub fn error_code(&self) -> Option<&str> {
24 self.code.as_deref()
25 }
26}
27
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct ApiError {
30 status: StatusCode,
31 error_code: Option<String>,
32 message: String,
33 field_errors: Vec<ApiFieldError>,
34 body: String,
35}
36
37impl ApiError {
38 pub(crate) fn from_response(status: StatusCode, body: String) -> Self {
39 match serde_json::from_str::<crate::wire::WireApiError>(&body) {
40 Ok(payload) => Self {
41 status,
42 error_code: Some(payload.error.code),
43 message: payload.error.message,
44 field_errors: payload
45 .error
46 .errors
47 .into_iter()
48 .map(|error| ApiFieldError {
49 field: error.field,
50 message: error.message,
51 code: error.code,
52 })
53 .collect(),
54 body,
55 },
56 Err(_) => Self {
57 status,
58 error_code: None,
59 message: "unparseable API error response".to_string(),
60 field_errors: Vec::new(),
61 body,
62 },
63 }
64 }
65
66 pub fn status(&self) -> StatusCode {
67 self.status
68 }
69
70 pub fn error_code(&self) -> Option<&str> {
71 self.error_code.as_deref()
72 }
73
74 pub fn message(&self) -> &str {
75 &self.message
76 }
77
78 pub fn field_errors(&self) -> &[ApiFieldError] {
79 &self.field_errors
80 }
81
82 pub fn body(&self) -> &str {
83 &self.body
84 }
85}
86
87impl fmt::Display for ApiError {
88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89 match self.error_code() {
90 Some(code) => write!(f, "{} {}: {}", self.status, code, self.message),
91 None => write!(f, "{}: {}", self.status, self.message),
92 }
93 }
94}
95
96#[derive(Debug, ThisError)]
97pub enum Error {
98 #[error("api key cannot be empty")]
99 InvalidApiKey,
100 #[error("base URL is invalid: {0}")]
101 InvalidBaseUrl(#[from] url::ParseError),
102 #[error("at least one recipient is required")]
103 MissingRecipients,
104 #[error("subject cannot be empty")]
105 InvalidSubject,
106 #[error("body cannot be empty")]
107 InvalidBody,
108 #[error("template id cannot be empty")]
109 InvalidTemplateId,
110 #[error("template id must be a UUID")]
111 InvalidTemplateIdFormat,
112 #[error("display name cannot be empty")]
113 InvalidDisplayName,
114 #[error("header name cannot be empty")]
115 InvalidHeaderName,
116 #[error("header value cannot be empty")]
117 InvalidHeaderValue,
118 #[error("email address is invalid: {reason} ({value})")]
119 InvalidEmailAddress { reason: &'static str, value: String },
120 #[error("template data must serialize into a JSON object")]
121 TemplateDataMustBeObject,
122 #[error("failed to serialize template data: {0}")]
123 TemplateDataSerialization(serde_json::Error),
124 #[error("http request failed: {0}")]
125 Transport(#[from] reqwest::Error),
126 #[error("plunk API returned {0}")]
127 Api(ApiError),
128 #[error("unexpected API response: {0}")]
129 UnexpectedResponse(String),
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn api_error_preserves_raw_body_when_json_is_unparseable() {
138 let error =
139 ApiError::from_response(StatusCode::BAD_GATEWAY, "<html>bad gateway</html>".into());
140
141 assert_eq!(error.status(), StatusCode::BAD_GATEWAY);
142 assert_eq!(error.error_code(), None);
143 assert!(error.field_errors().is_empty());
144 assert_eq!(error.body(), "<html>bad gateway</html>");
145 }
146
147 #[test]
148 fn api_error_parses_nested_plunk_error_shape() {
149 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"}"#;
150
151 let error = ApiError::from_response(StatusCode::UNPROCESSABLE_ENTITY, body.into());
152
153 assert_eq!(error.status(), StatusCode::UNPROCESSABLE_ENTITY);
154 assert_eq!(error.error_code(), Some("VALIDATION_ERROR"));
155 assert_eq!(error.message(), "Request validation failed");
156 assert_eq!(error.field_errors().len(), 1);
157 assert_eq!(error.field_errors()[0].field(), "template");
158 assert_eq!(error.field_errors()[0].message(), "Invalid uuid");
159 assert_eq!(error.field_errors()[0].error_code(), Some("invalid_string"));
160 }
161}