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
+1
View File
@@ -0,0 +1 @@
/target
Generated
+1363
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -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"
+21
View File
@@ -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
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,
}