first commit 🍻
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+1363
File diff suppressed because it is too large
Load Diff
+12
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "plunk-rs"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
authors = ["Antfroze"]
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
url = "2"
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Antfroze
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+132
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user