plunk-rs 0.1.2

Async Rust client for the Plunk transactional email API
Documentation
use crate::error::{Error, Result};
use serde::Serialize;
use serde_json::{Map, Value};

pub(crate) fn serialize_data_map<T>(data: T) -> Result<Map<String, Value>>
where
    T: Serialize,
{
    match serde_json::to_value(data).map_err(Error::TemplateDataSerialization)? {
        Value::Object(map) => Ok(map),
        _ => Err(Error::TemplateDataMustBeObject),
    }
}

pub(crate) fn normalize_email(value: String) -> Result<String> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        return Err(Error::InvalidEmailAddress {
            reason: "email cannot be empty",
            value,
        });
    }
    if trimmed.contains(char::is_whitespace) {
        return Err(Error::InvalidEmailAddress {
            reason: "email cannot contain whitespace",
            value,
        });
    }

    let mut parts = trimmed.split('@');
    let local = parts.next().unwrap_or_default();
    let domain = parts.next().unwrap_or_default();
    if local.is_empty() || domain.is_empty() || parts.next().is_some() {
        return Err(Error::InvalidEmailAddress {
            reason: "email must contain exactly one @ with non-empty local and domain parts",
            value,
        });
    }
    if !domain.contains('.') {
        return Err(Error::InvalidEmailAddress {
            reason: "email domain must contain a dot",
            value,
        });
    }

    Ok(trimmed.to_string())
}

pub(crate) fn normalize_non_empty(value: String, error: Error) -> Result<String> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        return Err(error);
    }
    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() {
        return Err(Error::InvalidHeaderName);
    }
    if trimmed.contains(':') {
        return Err(Error::InvalidHeaderName);
    }
    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));
    }
}