From ced1a91ba96e6a006d878544650d1f67d3c556e5 Mon Sep 17 00:00:00 2001 From: Antfroze Date: Wed, 29 Apr 2026 22:13:39 -0500 Subject: [PATCH] fix: validate template UUIDs and parse Plunk validation errors --- README.md | 2 +- src/email.rs | 28 +++++++++++++----- src/error.rs | 80 +++++++++++++++++++++++++++++++++++++++++----------- src/lib.rs | 2 +- src/util.rs | 50 ++++++++++++++++++++++++++++++++ src/wire.rs | 20 +++++++++++-- 6 files changed, 154 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index ede7568..1cbb676 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ use serde_json::json; async fn main() -> Result<(), plunk_rs::Error> { 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" }))?; client.send(&email).await?; diff --git a/src/email.rs b/src/email.rs index 00cf97c..9cd116c 100644 --- a/src/email.rs +++ b/src/email.rs @@ -1,5 +1,8 @@ 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_json::{Map, Value}; use std::collections::BTreeMap; @@ -45,6 +48,8 @@ impl Email { } /// Creates a template email for a single recipient. + /// + /// `template_id` must be the Plunk template UUID, not the template name. pub fn template(to: A, template_id: impl Into) -> Result where A: TryInto, @@ -54,6 +59,8 @@ impl Email { } /// Creates a template email for multiple recipients. + /// + /// `template_id` must be the Plunk template UUID, not the template name. pub fn template_many(to: I, template_id: impl Into) -> Result where I: IntoIterator, @@ -61,7 +68,7 @@ impl Email { Ok(Self { recipients: Recipients::many(to)?, content: EmailContent::Template { - template_id: normalize_non_empty(template_id.into(), Error::InvalidTemplateId)?, + template_id: normalize_template_id(template_id.into())?, }, from: None, reply_to: None, @@ -282,7 +289,7 @@ mod tests { EmailAddress::new("one@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(); @@ -290,14 +297,14 @@ mod tests { json, json!({ "to": ["one@example.com", "two@example.com"], - "template": "tpl_123" + "template": "550e8400-e29b-41d4-a716-446655440000" }) ); } #[test] 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() .with_data(vec!["not", "an", "object"]) .unwrap_err(); @@ -307,7 +314,7 @@ mod tests { #[test] 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() .with_data(WelcomeData { first_name: "Ada" }) .unwrap(); @@ -318,7 +325,7 @@ mod tests { json, json!({ "to": "user@example.com", - "template": "tpl_123", + "template": "550e8400-e29b-41d4-a716-446655440000", "data": { "first_name": "Ada" } @@ -347,6 +354,13 @@ mod tests { 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] fn email_accessors_are_consistent() { let email = Email::html("user@example.com", "Welcome", "

Hello

") diff --git a/src/error.rs b/src/error.rs index 836421d..37735b6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -4,12 +4,33 @@ use thiserror::Error as ThisError; pub type Result = std::result::Result; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ApiFieldError { + field: String, + message: String, + code: Option, +} + +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, - code: Option, - kind: Option, + error_code: Option, message: String, + field_errors: Vec, body: String, } @@ -18,16 +39,25 @@ impl ApiError { match serde_json::from_str::(&body) { Ok(payload) => Self { status, - code: Some(payload.code), - kind: Some(payload.error), - message: payload.message, + 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, - code: None, - kind: None, + error_code: None, message: "unparseable API error response".to_string(), + field_errors: Vec::new(), body, }, } @@ -37,18 +67,18 @@ impl ApiError { self.status } - pub fn code(&self) -> Option { - self.code - } - - pub fn kind(&self) -> Option<&str> { - self.kind.as_deref() + 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 } @@ -56,8 +86,8 @@ impl ApiError { impl fmt::Display for ApiError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self.kind() { - Some(kind) => write!(f, "{} {}: {}", self.status, kind, self.message), + match self.error_code() { + Some(code) => write!(f, "{} {}: {}", self.status, code, self.message), None => write!(f, "{}: {}", self.status, self.message), } } @@ -77,6 +107,8 @@ pub enum Error { 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")] @@ -107,7 +139,23 @@ mod tests { ApiError::from_response(StatusCode::BAD_GATEWAY, "bad gateway".into()); 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(), "bad gateway"); } + + #[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")); + } } diff --git a/src/lib.rs b/src/lib.rs index cd44e61..3b59ae9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,5 +7,5 @@ mod wire; pub use client::{Client, ClientBuilder}; 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}; diff --git a/src/util.rs b/src/util.rs index af0e48c..675b807 100644 --- a/src/util.rs +++ b/src/util.rs @@ -54,6 +54,39 @@ pub(crate) fn normalize_non_empty(value: String, error: Error) -> Result Ok(trimmed.to_string()) } +pub(crate) fn normalize_template_id(value: String) -> Result { + 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 { let trimmed = value.trim(); if trimmed.is_empty() { @@ -64,3 +97,20 @@ pub(crate) fn normalize_header_key(value: String) -> Result { } 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)); + } +} diff --git a/src/wire.rs b/src/wire.rs index 9723386..46f3a9d 100644 --- a/src/wire.rs +++ b/src/wire.rs @@ -105,7 +105,21 @@ pub(crate) struct WireContact { #[derive(Clone, Debug, Deserialize)] pub(crate) struct WireApiError { - pub(crate) code: i64, - pub(crate) error: String, - pub(crate) message: String, + pub(crate) error: WireApiErrorDetails, +} + +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct WireApiErrorDetails { + pub(crate) code: String, + pub(crate) message: String, + #[serde(default)] + pub(crate) errors: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct WireApiFieldError { + pub(crate) field: String, + pub(crate) message: String, + #[serde(default)] + pub(crate) code: Option, }