first commit 🍻

This commit is contained in:
2026-04-29 18:31:37 -05:00
commit 394794b0be
11 changed files with 2327 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
use crate::email::Email;
use crate::error::{ApiError, Error, Result};
use crate::response::SendResponse;
use crate::util::normalize_non_empty;
use crate::wire::{WireEmail, WireSendResponse};
use reqwest::{Client as HttpClient, Url};
const DEFAULT_BASE_URL: &str = "https://api.useplunk.com/";
/// Async client for Plunk's transactional email API.
///
/// ```no_run
/// use plunk_rs::{Client, Email, EmailAddress};
///
/// # async fn demo() -> Result<(), Box<dyn std::error::Error>> {
/// let client = Client::new("sk_your_secret_key")?;
///
/// let email = Email::html("user@example.com", "Welcome", "<h1>Hello</h1>")?
/// .from(EmailAddress::named("My App", "hello@example.com")?)?
/// .reply_to("support@example.com")?;
///
/// let response = client.send(&email).await?;
/// assert_eq!(response.deliveries().len(), 1);
/// # Ok(())
/// # }
/// ```
#[derive(Clone, Debug)]
pub struct Client {
http: HttpClient,
api_key: String,
base_url: Url,
}
impl Client {
/// Creates a client that targets Plunk's hosted API.
pub fn new(api_key: impl Into<String>) -> Result<Self> {
Self::builder(api_key).build()
}
/// Creates a builder for custom HTTP clients or self-hosted Plunk URLs.
pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
ClientBuilder::new(api_key)
}
/// Sends a validated email via `POST /v1/send`.
pub async fn send(&self, email: &Email) -> Result<SendResponse> {
let response = self
.http
.post(self.base_url.join("v1/send").expect("valid send endpoint"))
.bearer_auth(&self.api_key)
.json(&WireEmail::from(email))
.send()
.await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await?;
return Err(Error::Api(ApiError::from_response(status, body)));
}
let payload = response.json::<WireSendResponse>().await?;
if !payload.success {
return Err(Error::UnexpectedResponse(
"success=false in a successful API response".to_string(),
));
}
Ok(SendResponse::from(payload.data))
}
pub fn base_url(&self) -> &Url {
&self.base_url
}
}
#[derive(Clone, Debug)]
pub struct ClientBuilder {
api_key: String,
base_url: Url,
http: HttpClient,
}
impl ClientBuilder {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
base_url: Url::parse(DEFAULT_BASE_URL).expect("default base URL must be valid"),
http: HttpClient::new(),
}
}
pub fn base_url(mut self, base_url: impl AsRef<str>) -> Result<Self> {
let mut parsed = Url::parse(base_url.as_ref())?;
if !parsed.path().ends_with('/') {
parsed.set_path(&format!("{}/", parsed.path()));
}
self.base_url = parsed;
Ok(self)
}
pub fn http_client(mut self, http: HttpClient) -> Self {
self.http = http;
self
}
pub fn build(self) -> Result<Client> {
let api_key = normalize_non_empty(self.api_key, Error::InvalidApiKey)?;
Ok(Client {
http: self.http,
api_key,
base_url: self.base_url,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_normalizes_base_url_for_self_hosting() {
let client = Client::builder("sk_test")
.base_url("https://plunk.example.com/api")
.unwrap()
.build()
.unwrap();
assert_eq!(client.base_url().as_str(), "https://plunk.example.com/api/");
}
}
+381
View File
@@ -0,0 +1,381 @@
use crate::error::{Error, Result};
use crate::util::{normalize_email, normalize_header_key, normalize_non_empty, serialize_data_map};
use serde::Serialize;
use serde_json::{Map, Value};
use std::collections::BTreeMap;
use std::convert::Infallible;
/// A validated email ready to send.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Email {
recipients: Recipients,
pub(crate) content: EmailContent,
pub(crate) from: Option<EmailAddress>,
pub(crate) reply_to: Option<EmailAddress>,
pub(crate) headers: BTreeMap<String, String>,
pub(crate) data: Map<String, Value>,
}
impl Email {
/// Creates an HTML email for a single recipient.
pub fn html<A>(to: A, subject: impl Into<String>, body: impl Into<String>) -> Result<Self>
where
A: TryInto<EmailAddress>,
A::Error: Into<Error>,
{
Self::html_many([to.try_into().map_err(Into::into)?], subject, body)
}
/// Creates an HTML email for multiple recipients.
pub fn html_many<I>(to: I, subject: impl Into<String>, body: impl Into<String>) -> Result<Self>
where
I: IntoIterator<Item = EmailAddress>,
{
Ok(Self {
recipients: Recipients::many(to)?,
content: EmailContent::Html {
subject: normalize_non_empty(subject.into(), Error::InvalidSubject)?,
body: normalize_non_empty(body.into(), Error::InvalidBody)?,
},
from: None,
reply_to: None,
headers: BTreeMap::new(),
data: Map::new(),
})
}
/// Creates a template email for a single recipient.
pub fn template<A>(to: A, template_id: impl Into<String>) -> Result<Self>
where
A: TryInto<EmailAddress>,
A::Error: Into<Error>,
{
Self::template_many([to.try_into().map_err(Into::into)?], template_id)
}
/// Creates a template email for multiple recipients.
pub fn template_many<I>(to: I, template_id: impl Into<String>) -> Result<Self>
where
I: IntoIterator<Item = EmailAddress>,
{
Ok(Self {
recipients: Recipients::many(to)?,
content: EmailContent::Template {
template_id: normalize_non_empty(template_id.into(), Error::InvalidTemplateId)?,
},
from: None,
reply_to: None,
headers: BTreeMap::new(),
data: Map::new(),
})
}
pub fn from<A>(mut self, from: A) -> Result<Self>
where
A: TryInto<EmailAddress>,
A::Error: Into<Error>,
{
self.from = Some(from.try_into().map_err(Into::into)?);
Ok(self)
}
pub fn reply_to<A>(mut self, reply_to: A) -> Result<Self>
where
A: TryInto<EmailAddress>,
A::Error: Into<Error>,
{
self.reply_to = Some(reply_to.try_into().map_err(Into::into)?);
Ok(self)
}
pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Result<Self> {
let key = normalize_header_key(key.into())?;
let value = normalize_non_empty(value.into(), Error::InvalidHeaderValue)?;
self.headers.insert(key, value);
Ok(self)
}
pub fn with_data<T>(mut self, data: T) -> Result<Self>
where
T: Serialize,
{
self.data = serialize_data_map(data)?;
Ok(self)
}
pub fn recipients(&self) -> &[EmailAddress] {
self.recipients.as_slice()
}
pub fn from_address(&self) -> Option<&EmailAddress> {
self.from.as_ref()
}
pub fn reply_to_address(&self) -> Option<&EmailAddress> {
self.reply_to.as_ref()
}
pub fn subject(&self) -> Option<&str> {
match &self.content {
EmailContent::Html { subject, .. } => Some(subject),
EmailContent::Template { .. } => None,
}
}
pub fn body(&self) -> Option<&str> {
match &self.content {
EmailContent::Html { body, .. } => Some(body),
EmailContent::Template { .. } => None,
}
}
pub fn template_id(&self) -> Option<&str> {
match &self.content {
EmailContent::Html { .. } => None,
EmailContent::Template { template_id } => Some(template_id),
}
}
pub fn headers(&self) -> &BTreeMap<String, String> {
&self.headers
}
pub fn data(&self) -> &Map<String, Value> {
&self.data
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum EmailContent {
Html { subject: String, body: String },
Template { template_id: String },
}
/// A validated recipient list.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Recipients(Vec<EmailAddress>);
impl Recipients {
pub fn one<A>(recipient: A) -> Result<Self>
where
A: TryInto<EmailAddress>,
A::Error: Into<Error>,
{
Ok(Self(vec![recipient.try_into().map_err(Into::into)?]))
}
pub fn many<I>(recipients: I) -> Result<Self>
where
I: IntoIterator<Item = EmailAddress>,
{
let recipients: Vec<_> = recipients.into_iter().collect();
if recipients.is_empty() {
return Err(Error::MissingRecipients);
}
Ok(Self(recipients))
}
pub fn as_slice(&self) -> &[EmailAddress] {
&self.0
}
}
/// A validated email address with an optional display name.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct EmailAddress {
pub(crate) email: String,
pub(crate) name: Option<String>,
}
impl EmailAddress {
pub fn new(email: impl Into<String>) -> Result<Self> {
let email = normalize_email(email.into())?;
Ok(Self { email, name: None })
}
pub fn named(name: impl Into<String>, email: impl Into<String>) -> Result<Self> {
let name = normalize_non_empty(name.into(), Error::InvalidDisplayName)?;
let email = normalize_email(email.into())?;
Ok(Self {
email,
name: Some(name),
})
}
pub fn email(&self) -> &str {
&self.email
}
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
}
impl TryFrom<&str> for EmailAddress {
type Error = Error;
fn try_from(value: &str) -> Result<Self> {
Self::new(value)
}
}
impl TryFrom<String> for EmailAddress {
type Error = Error;
fn try_from(value: String) -> Result<Self> {
Self::new(value)
}
}
impl From<Infallible> for Error {
fn from(value: Infallible) -> Self {
match value {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wire::WireEmail;
use serde::Serialize;
use serde_json::json;
#[derive(Serialize)]
struct WelcomeData<'a> {
first_name: &'a str,
}
#[test]
fn html_email_validates_up_front() {
let email = Email::html("user@example.com", "Welcome", "<p>Hello from Plunk</p>")
.unwrap()
.from(EmailAddress::named("My App", "hello@example.com").unwrap())
.unwrap()
.reply_to("reply@example.com")
.unwrap()
.with_header("X-Test", "true")
.unwrap();
let json = serde_json::to_value(WireEmail::from(&email)).unwrap();
assert_eq!(
json,
json!({
"to": "user@example.com",
"subject": "Welcome",
"body": "<p>Hello from Plunk</p>",
"from": {
"name": "My App",
"email": "hello@example.com"
},
"headers": {
"X-Test": "true"
},
"reply": "reply@example.com"
})
);
}
#[test]
fn template_email_serializes_cleanly() {
let recipients = vec![
EmailAddress::new("one@example.com").unwrap(),
EmailAddress::new("two@example.com").unwrap(),
];
let email = Email::template_many(recipients, "tpl_123").unwrap();
let json = serde_json::to_value(WireEmail::from(&email)).unwrap();
assert_eq!(
json,
json!({
"to": ["one@example.com", "two@example.com"],
"template": "tpl_123"
})
);
}
#[test]
fn template_data_must_be_an_object() {
let error = Email::template("user@example.com", "tpl_123")
.unwrap()
.with_data(vec!["not", "an", "object"])
.unwrap_err();
assert!(matches!(error, Error::TemplateDataMustBeObject));
}
#[test]
fn typed_template_data_serializes_from_struct() {
let email = Email::template("user@example.com", "tpl_123")
.unwrap()
.with_data(WelcomeData { first_name: "Ada" })
.unwrap();
let json = serde_json::to_value(WireEmail::from(&email)).unwrap();
assert_eq!(
json,
json!({
"to": "user@example.com",
"template": "tpl_123",
"data": {
"first_name": "Ada"
}
})
);
}
#[test]
fn rejects_invalid_email_at_construction_time() {
let error = Email::html("not-an-email", "Welcome", "<p>Hello</p>").unwrap_err();
assert!(matches!(error, Error::InvalidEmailAddress { .. }));
}
#[test]
fn rejects_empty_subject_at_construction_time() {
let error = Email::html("user@example.com", " ", "<p>Hello</p>").unwrap_err();
assert!(matches!(error, Error::InvalidSubject));
}
#[test]
fn rejects_empty_template_id_at_construction_time() {
let error = Email::template("user@example.com", " ").unwrap_err();
assert!(matches!(error, Error::InvalidTemplateId));
}
#[test]
fn email_accessors_are_consistent() {
let email = Email::html("user@example.com", "Welcome", "<p>Hello</p>")
.unwrap()
.from("hello@example.com")
.unwrap()
.reply_to(EmailAddress::named("Support", "reply@example.com").unwrap())
.unwrap()
.with_header("X-Test", "true")
.unwrap()
.with_data(serde_json::json!({ "first_name": "Ada" }))
.unwrap();
assert_eq!(
email.recipients(),
&[EmailAddress::new("user@example.com").unwrap()]
);
assert_eq!(
email.from_address(),
Some(&EmailAddress::new("hello@example.com").unwrap())
);
assert_eq!(
email.reply_to_address(),
Some(&EmailAddress::named("Support", "reply@example.com").unwrap())
);
assert_eq!(email.subject(), Some("Welcome"));
assert_eq!(email.body(), Some("<p>Hello</p>"));
assert_eq!(email.template_id(), None);
assert_eq!(email.headers().get("X-Test"), Some(&"true".to_string()));
assert_eq!(email.data().get("first_name"), Some(&json!("Ada")));
}
}
+113
View File
@@ -0,0 +1,113 @@
use reqwest::StatusCode;
use std::fmt;
use thiserror::Error as ThisError;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ApiError {
status: StatusCode,
code: Option<i64>,
kind: Option<String>,
message: String,
body: String,
}
impl ApiError {
pub(crate) fn from_response(status: StatusCode, body: String) -> Self {
match serde_json::from_str::<crate::wire::WireApiError>(&body) {
Ok(payload) => Self {
status,
code: Some(payload.code),
kind: Some(payload.error),
message: payload.message,
body,
},
Err(_) => Self {
status,
code: None,
kind: None,
message: "unparseable API error response".to_string(),
body,
},
}
}
pub fn status(&self) -> StatusCode {
self.status
}
pub fn code(&self) -> Option<i64> {
self.code
}
pub fn kind(&self) -> Option<&str> {
self.kind.as_deref()
}
pub fn message(&self) -> &str {
&self.message
}
pub fn body(&self) -> &str {
&self.body
}
}
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),
None => write!(f, "{}: {}", self.status, self.message),
}
}
}
#[derive(Debug, ThisError)]
pub enum Error {
#[error("api key cannot be empty")]
InvalidApiKey,
#[error("base URL is invalid: {0}")]
InvalidBaseUrl(#[from] url::ParseError),
#[error("at least one recipient is required")]
MissingRecipients,
#[error("subject cannot be empty")]
InvalidSubject,
#[error("body cannot be empty")]
InvalidBody,
#[error("template id cannot be empty")]
InvalidTemplateId,
#[error("display name cannot be empty")]
InvalidDisplayName,
#[error("header name cannot be empty")]
InvalidHeaderName,
#[error("header value cannot be empty")]
InvalidHeaderValue,
#[error("email address is invalid: {reason} ({value})")]
InvalidEmailAddress { reason: &'static str, value: String },
#[error("template data must serialize into a JSON object")]
TemplateDataMustBeObject,
#[error("failed to serialize template data: {0}")]
TemplateDataSerialization(serde_json::Error),
#[error("http request failed: {0}")]
Transport(#[from] reqwest::Error),
#[error("plunk API returned {0}")]
Api(ApiError),
#[error("unexpected API response: {0}")]
UnexpectedResponse(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn api_error_preserves_raw_body_when_json_is_unparseable() {
let error =
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.body(), "<html>bad gateway</html>");
}
}
+11
View File
@@ -0,0 +1,11 @@
mod client;
mod email;
mod error;
mod response;
mod util;
mod wire;
pub use client::{Client, ClientBuilder};
pub use email::{Email, EmailAddress, Recipients};
pub use error::{ApiError, Error, Result};
pub use response::{Contact, Delivery, SendResponse};
+116
View File
@@ -0,0 +1,116 @@
use crate::wire::{WireContact, WireDeliveredEmail, WireSendResponseData};
/// Successful send result.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SendResponse {
deliveries: Vec<Delivery>,
timestamp: String,
}
impl SendResponse {
pub fn deliveries(&self) -> &[Delivery] {
&self.deliveries
}
pub fn timestamp(&self) -> &str {
&self.timestamp
}
}
impl From<WireSendResponseData> for SendResponse {
fn from(value: WireSendResponseData) -> Self {
Self {
deliveries: value.emails.into_iter().map(Delivery::from).collect(),
timestamp: value.timestamp,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Delivery {
contact: Contact,
id: String,
}
impl Delivery {
pub fn contact(&self) -> &Contact {
&self.contact
}
pub fn id(&self) -> &str {
&self.id
}
}
impl From<WireDeliveredEmail> for Delivery {
fn from(value: WireDeliveredEmail) -> Self {
Self {
contact: Contact::from(value.contact),
id: value.email,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Contact {
id: String,
email: String,
}
impl Contact {
pub fn id(&self) -> &str {
&self.id
}
pub fn email(&self) -> &str {
&self.email
}
}
impl From<WireContact> for Contact {
fn from(value: WireContact) -> Self {
Self {
id: value.id,
email: value.email,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wire::WireSendResponse;
use serde_json::json;
#[test]
fn deserializes_success_response_into_domain_types() {
let response = serde_json::from_value::<WireSendResponse>(json!({
"success": true,
"data": {
"emails": [
{
"contact": {
"id": "cnt_abc123",
"email": "user@example.com"
},
"email": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf"
}
],
"timestamp": "2025-01-15T10:30:00.000Z"
}
}))
.unwrap();
let response = SendResponse::from(response.data);
assert_eq!(response.deliveries().len(), 1);
assert_eq!(
response.deliveries()[0].contact().email(),
"user@example.com"
);
assert_eq!(
response.deliveries()[0].id(),
"ac32f08e-c6b9-45d3-9824-a73dff1e3bbf"
);
}
}
+66
View File
@@ -0,0 +1,66 @@
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_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())
}
+111
View File
@@ -0,0 +1,111 @@
use crate::email::{Email, EmailAddress, EmailContent};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::BTreeMap;
#[derive(Clone, Debug, Serialize)]
pub(crate) struct WireEmail {
to: WireRecipients,
#[serde(skip_serializing_if = "Option::is_none")]
subject: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
template: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
from: Option<WireEmailAddress>,
#[serde(skip_serializing_if = "Map::is_empty", default)]
data: Map<String, Value>,
#[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
headers: BTreeMap<String, String>,
#[serde(rename = "reply", skip_serializing_if = "Option::is_none")]
reply: Option<String>,
}
impl From<&Email> for WireEmail {
fn from(value: &Email) -> Self {
let (subject, body, template) = match &value.content {
EmailContent::Html { subject, body } => {
(Some(subject.clone()), Some(body.clone()), None)
}
EmailContent::Template { template_id } => (None, None, Some(template_id.clone())),
};
Self {
to: WireRecipients::from(value.recipients()),
subject,
body,
template,
from: value.from.as_ref().map(WireEmailAddress::from),
data: value.data.clone(),
headers: value.headers.clone(),
reply: value.reply_to.as_ref().map(|a| a.email.clone()),
}
}
}
#[derive(Clone, Debug, Serialize)]
#[serde(untagged)]
enum WireRecipients {
One(WireEmailAddress),
Many(Vec<WireEmailAddress>),
}
impl From<&[EmailAddress]> for WireRecipients {
fn from(value: &[EmailAddress]) -> Self {
match value {
[only] => Self::One(WireEmailAddress::from(only)),
many => Self::Many(many.iter().map(WireEmailAddress::from).collect()),
}
}
}
#[derive(Clone, Debug, Serialize)]
#[serde(untagged)]
enum WireEmailAddress {
Plain(String),
Named { name: String, email: String },
}
impl From<&EmailAddress> for WireEmailAddress {
fn from(value: &EmailAddress) -> Self {
match &value.name {
Some(name) => Self::Named {
name: name.clone(),
email: value.email.clone(),
},
None => Self::Plain(value.email.clone()),
}
}
}
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct WireSendResponse {
pub(crate) success: bool,
pub(crate) data: WireSendResponseData,
}
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct WireSendResponseData {
pub(crate) emails: Vec<WireDeliveredEmail>,
pub(crate) timestamp: String,
}
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct WireDeliveredEmail {
pub(crate) contact: WireContact,
pub(crate) email: String,
}
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct WireContact {
pub(crate) id: String,
pub(crate) email: String,
}
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct WireApiError {
pub(crate) code: i64,
pub(crate) error: String,
pub(crate) message: String,
}