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> {
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?;
+21 -7
View File
@@ -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<A>(to: A, template_id: impl Into<String>) -> Result<Self>
where
A: TryInto<EmailAddress>,
@@ -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<I>(to: I, template_id: impl Into<String>) -> Result<Self>
where
I: IntoIterator<Item = EmailAddress>,
@@ -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", "<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>;
#[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,
code: Option<i64>,
kind: Option<String>,
error_code: Option<String>,
message: String,
field_errors: Vec<ApiFieldError>,
body: String,
}
@@ -18,16 +39,25 @@ impl ApiError {
match serde_json::from_str::<crate::wire::WireApiError>(&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<i64> {
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, "<html>bad gateway</html>".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(), "<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 email::{Email, EmailAddress, Recipients};
pub use error::{ApiError, Error, Result};
pub use error::{ApiError, ApiFieldError, Error, Result};
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())
}
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> {
let trimmed = value.trim();
if trimmed.is_empty() {
@@ -64,3 +97,20 @@ pub(crate) fn normalize_header_key(value: String) -> Result<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)]
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<WireApiFieldError>,
}
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct WireApiFieldError {
pub(crate) field: String,
pub(crate) message: String,
#[serde(default)]
pub(crate) code: Option<String>,
}