fix: validate template UUIDs and parse Plunk validation errors

This commit is contained in:
2026-04-29 22:13:39 -05:00
parent 532ef8a18d
commit ced1a91ba9
6 changed files with 154 additions and 28 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ use serde_json::json;
async fn main() -> Result<(), plunk_rs::Error> { async fn main() -> Result<(), plunk_rs::Error> {
let client = Client::new("sk_your_plunk_api_key")?; let client = Client::new("sk_your_plunk_api_key")?;
let email = Email::template("user@example.com", "tpl_xxxxxxxx")? let email = Email::template("user@example.com", "550e8400-e29b-41d4-a716-446655440000")?
.with_data(json!({ "first_name": "Ada" }))?; .with_data(json!({ "first_name": "Ada" }))?;
client.send(&email).await?; client.send(&email).await?;
+21 -7
View File
@@ -1,5 +1,8 @@
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::util::{normalize_email, normalize_header_key, normalize_non_empty, serialize_data_map}; use crate::util::{
normalize_email, normalize_header_key, normalize_non_empty, normalize_template_id,
serialize_data_map,
};
use serde::Serialize; use serde::Serialize;
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -45,6 +48,8 @@ impl Email {
} }
/// Creates a template email for a single recipient. /// Creates a template email for a single recipient.
///
/// `template_id` must be the Plunk template UUID, not the template name.
pub fn template<A>(to: A, template_id: impl Into<String>) -> Result<Self> pub fn template<A>(to: A, template_id: impl Into<String>) -> Result<Self>
where where
A: TryInto<EmailAddress>, A: TryInto<EmailAddress>,
@@ -54,6 +59,8 @@ impl Email {
} }
/// Creates a template email for multiple recipients. /// Creates a template email for multiple recipients.
///
/// `template_id` must be the Plunk template UUID, not the template name.
pub fn template_many<I>(to: I, template_id: impl Into<String>) -> Result<Self> pub fn template_many<I>(to: I, template_id: impl Into<String>) -> Result<Self>
where where
I: IntoIterator<Item = EmailAddress>, I: IntoIterator<Item = EmailAddress>,
@@ -61,7 +68,7 @@ impl Email {
Ok(Self { Ok(Self {
recipients: Recipients::many(to)?, recipients: Recipients::many(to)?,
content: EmailContent::Template { content: EmailContent::Template {
template_id: normalize_non_empty(template_id.into(), Error::InvalidTemplateId)?, template_id: normalize_template_id(template_id.into())?,
}, },
from: None, from: None,
reply_to: None, reply_to: None,
@@ -282,7 +289,7 @@ mod tests {
EmailAddress::new("one@example.com").unwrap(), EmailAddress::new("one@example.com").unwrap(),
EmailAddress::new("two@example.com").unwrap(), EmailAddress::new("two@example.com").unwrap(),
]; ];
let email = Email::template_many(recipients, "tpl_123").unwrap(); let email = Email::template_many(recipients, "550e8400-e29b-41d4-a716-446655440000").unwrap();
let json = serde_json::to_value(WireEmail::from(&email)).unwrap(); let json = serde_json::to_value(WireEmail::from(&email)).unwrap();
@@ -290,14 +297,14 @@ mod tests {
json, json,
json!({ json!({
"to": ["one@example.com", "two@example.com"], "to": ["one@example.com", "two@example.com"],
"template": "tpl_123" "template": "550e8400-e29b-41d4-a716-446655440000"
}) })
); );
} }
#[test] #[test]
fn template_data_must_be_an_object() { fn template_data_must_be_an_object() {
let error = Email::template("user@example.com", "tpl_123") let error = Email::template("user@example.com", "550e8400-e29b-41d4-a716-446655440000")
.unwrap() .unwrap()
.with_data(vec!["not", "an", "object"]) .with_data(vec!["not", "an", "object"])
.unwrap_err(); .unwrap_err();
@@ -307,7 +314,7 @@ mod tests {
#[test] #[test]
fn typed_template_data_serializes_from_struct() { fn typed_template_data_serializes_from_struct() {
let email = Email::template("user@example.com", "tpl_123") let email = Email::template("user@example.com", "550e8400-e29b-41d4-a716-446655440000")
.unwrap() .unwrap()
.with_data(WelcomeData { first_name: "Ada" }) .with_data(WelcomeData { first_name: "Ada" })
.unwrap(); .unwrap();
@@ -318,7 +325,7 @@ mod tests {
json, json,
json!({ json!({
"to": "user@example.com", "to": "user@example.com",
"template": "tpl_123", "template": "550e8400-e29b-41d4-a716-446655440000",
"data": { "data": {
"first_name": "Ada" "first_name": "Ada"
} }
@@ -347,6 +354,13 @@ mod tests {
assert!(matches!(error, Error::InvalidTemplateId)); assert!(matches!(error, Error::InvalidTemplateId));
} }
#[test]
fn rejects_non_uuid_template_id_at_construction_time() {
let error = Email::template("user@example.com", "free-trial").unwrap_err();
assert!(matches!(error, Error::InvalidTemplateIdFormat));
}
#[test] #[test]
fn email_accessors_are_consistent() { fn email_accessors_are_consistent() {
let email = Email::html("user@example.com", "Welcome", "<p>Hello</p>") let email = Email::html("user@example.com", "Welcome", "<p>Hello</p>")
+64 -16
View File
@@ -4,12 +4,33 @@ use thiserror::Error as ThisError;
pub type Result<T> = std::result::Result<T, Error>; 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)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct ApiError { pub struct ApiError {
status: StatusCode, status: StatusCode,
code: Option<i64>, error_code: Option<String>,
kind: Option<String>,
message: String, message: String,
field_errors: Vec<ApiFieldError>,
body: String, body: String,
} }
@@ -18,16 +39,25 @@ impl ApiError {
match serde_json::from_str::<crate::wire::WireApiError>(&body) { match serde_json::from_str::<crate::wire::WireApiError>(&body) {
Ok(payload) => Self { Ok(payload) => Self {
status, status,
code: Some(payload.code), error_code: Some(payload.error.code),
kind: Some(payload.error), message: payload.error.message,
message: payload.message, field_errors: payload
.error
.errors
.into_iter()
.map(|error| ApiFieldError {
field: error.field,
message: error.message,
code: error.code,
})
.collect(),
body, body,
}, },
Err(_) => Self { Err(_) => Self {
status, status,
code: None, error_code: None,
kind: None,
message: "unparseable API error response".to_string(), message: "unparseable API error response".to_string(),
field_errors: Vec::new(),
body, body,
}, },
} }
@@ -37,18 +67,18 @@ impl ApiError {
self.status self.status
} }
pub fn code(&self) -> Option<i64> { pub fn error_code(&self) -> Option<&str> {
self.code self.error_code.as_deref()
}
pub fn kind(&self) -> Option<&str> {
self.kind.as_deref()
} }
pub fn message(&self) -> &str { pub fn message(&self) -> &str {
&self.message &self.message
} }
pub fn field_errors(&self) -> &[ApiFieldError] {
&self.field_errors
}
pub fn body(&self) -> &str { pub fn body(&self) -> &str {
&self.body &self.body
} }
@@ -56,8 +86,8 @@ impl ApiError {
impl fmt::Display for ApiError { impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.kind() { match self.error_code() {
Some(kind) => write!(f, "{} {}: {}", self.status, kind, self.message), Some(code) => write!(f, "{} {}: {}", self.status, code, self.message),
None => write!(f, "{}: {}", self.status, self.message), None => write!(f, "{}: {}", self.status, self.message),
} }
} }
@@ -77,6 +107,8 @@ pub enum Error {
InvalidBody, InvalidBody,
#[error("template id cannot be empty")] #[error("template id cannot be empty")]
InvalidTemplateId, InvalidTemplateId,
#[error("template id must be a UUID")]
InvalidTemplateIdFormat,
#[error("display name cannot be empty")] #[error("display name cannot be empty")]
InvalidDisplayName, InvalidDisplayName,
#[error("header name cannot be empty")] #[error("header name cannot be empty")]
@@ -107,7 +139,23 @@ mod tests {
ApiError::from_response(StatusCode::BAD_GATEWAY, "<html>bad gateway</html>".into()); ApiError::from_response(StatusCode::BAD_GATEWAY, "<html>bad gateway</html>".into());
assert_eq!(error.status(), StatusCode::BAD_GATEWAY); assert_eq!(error.status(), StatusCode::BAD_GATEWAY);
assert_eq!(error.code(), None); assert_eq!(error.error_code(), None);
assert!(error.field_errors().is_empty());
assert_eq!(error.body(), "<html>bad gateway</html>"); 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"));
}
} }
+1 -1
View File
@@ -7,5 +7,5 @@ mod wire;
pub use client::{Client, ClientBuilder}; pub use client::{Client, ClientBuilder};
pub use email::{Email, EmailAddress, Recipients}; pub use email::{Email, EmailAddress, Recipients};
pub use error::{ApiError, Error, Result}; pub use error::{ApiError, ApiFieldError, Error, Result};
pub use response::{Contact, Delivery, SendResponse}; pub use response::{Contact, Delivery, SendResponse};
+50
View File
@@ -54,6 +54,39 @@ pub(crate) fn normalize_non_empty(value: String, error: Error) -> Result<String>
Ok(trimmed.to_string()) Ok(trimmed.to_string())
} }
pub(crate) fn normalize_template_id(value: String) -> Result<String> {
let trimmed = normalize_non_empty(value, Error::InvalidTemplateId)?;
if is_uuid_like(&trimmed) {
Ok(trimmed)
} else {
Err(Error::InvalidTemplateIdFormat)
}
}
fn is_uuid_like(value: &str) -> bool {
let bytes = value.as_bytes();
if bytes.len() != 36 {
return false;
}
for (idx, byte) in bytes.iter().enumerate() {
match idx {
8 | 13 | 18 | 23 => {
if *byte != b'-' {
return false;
}
}
_ => {
if !byte.is_ascii_hexdigit() {
return false;
}
}
}
}
true
}
pub(crate) fn normalize_header_key(value: String) -> Result<String> { pub(crate) fn normalize_header_key(value: String) -> Result<String> {
let trimmed = value.trim(); let trimmed = value.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
@@ -64,3 +97,20 @@ pub(crate) fn normalize_header_key(value: String) -> Result<String> {
} }
Ok(trimmed.to_string()) Ok(trimmed.to_string())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_template_id_accepts_uuid() {
let id = normalize_template_id("550e8400-e29b-41d4-a716-446655440000".to_string()).unwrap();
assert_eq!(id, "550e8400-e29b-41d4-a716-446655440000");
}
#[test]
fn normalize_template_id_rejects_name() {
let error = normalize_template_id("free-trial".to_string()).unwrap_err();
assert!(matches!(error, Error::InvalidTemplateIdFormat));
}
}
+17 -3
View File
@@ -105,7 +105,21 @@ pub(crate) struct WireContact {
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub(crate) struct WireApiError { pub(crate) struct WireApiError {
pub(crate) code: i64, pub(crate) error: WireApiErrorDetails,
pub(crate) error: String, }
pub(crate) message: String,
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct WireApiErrorDetails {
pub(crate) code: String,
pub(crate) message: String,
#[serde(default)]
pub(crate) errors: Vec<WireApiFieldError>,
}
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct WireApiFieldError {
pub(crate) field: String,
pub(crate) message: String,
#[serde(default)]
pub(crate) code: Option<String>,
} }