use std::sync::Arc;
use reqwest::Method;
use serde::{Deserialize, Deserializer};
use crate::{
Config, Result,
list_opts::{ListOptions, ListResponse},
types::Attachment,
};
use crate::{
idempotent::Idempotent,
types::{
CancelScheduleResponse, CreateEmailBaseOptions, CreateEmailResponse, Email, EmailMetrics,
GetEmailMetricsOptions, ShareEmailOptions, ShareEmailResponse, UpdateEmailOptions,
UpdateEmailResponse,
},
};
#[derive(Clone, Debug)]
pub struct EmailsSvc(pub(crate) Arc<Config>);
impl EmailsSvc {
#[maybe_async::maybe_async]
pub async fn send(
&self,
email: impl Into<Idempotent<CreateEmailBaseOptions>>,
) -> Result<CreateEmailResponse> {
let email: Idempotent<CreateEmailBaseOptions> = email.into();
let mut request = self.0.build(Method::POST, "/emails");
if let Some(ref idempotency_key) = email.idempotency_key {
request = request.header("Idempotency-Key", idempotency_key);
}
let response = self.0.send(request.json(&email)).await?;
let content = response.json::<CreateEmailResponse>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
pub async fn get(&self, email_id: &str) -> Result<Email> {
let path = format!("/emails/{email_id}");
let request = self.0.build(Method::GET, &path);
let response = self.0.send(request).await?;
let content = response.json::<Email>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
pub async fn update(
&self,
email_id: &str,
update: UpdateEmailOptions,
) -> Result<UpdateEmailResponse> {
let path = format!("/emails/{email_id}");
let request = self.0.build(Method::PATCH, &path);
let response = self.0.send(request.json(&update)).await?;
let content = response.json::<UpdateEmailResponse>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
pub async fn cancel(&self, email_id: &str) -> Result<CancelScheduleResponse> {
let path = format!("/emails/{email_id}/cancel");
let request = self.0.build(Method::POST, &path);
let response = self.0.send(request).await?;
let content = response.json::<CancelScheduleResponse>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
pub async fn share(
&self,
email_id: &str,
options: ShareEmailOptions,
) -> Result<ShareEmailResponse> {
let path = format!("/emails/{email_id}/share");
let request = self.0.build(Method::POST, &path);
let response = self.0.send(request.json(&options)).await?;
let content = response.json::<ShareEmailResponse>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
pub async fn list<T>(&self, list_opts: ListOptions<T>) -> Result<ListResponse<Email>> {
let request = self.0.build(Method::GET, "/emails").query(&list_opts);
let response = self.0.send(request).await?;
let content = response.json::<ListResponse<Email>>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
pub async fn get_attachment(&self, email_id: &str, attachment_id: &str) -> Result<Attachment> {
let path = format!("/emails/{email_id}/attachments/{attachment_id}");
let request = self.0.build(Method::GET, &path);
let response = self.0.send(request).await?;
let content = response.json::<Attachment>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
pub async fn list_attachments<T>(
&self,
email_id: &str,
list_opts: ListOptions<T>,
) -> Result<ListResponse<Attachment>> {
let path = format!("/emails/{email_id}/attachments");
let request = self.0.build(Method::GET, &path).query(&list_opts);
let response = self.0.send(request).await?;
let content = response.json::<ListResponse<Attachment>>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
pub async fn metrics<T>(&self, options: GetEmailMetricsOptions<T>) -> Result<EmailMetrics> {
let request = self.0.build(Method::GET, "/emails/metrics").query(&options);
let response = self.0.send(request).await?;
let content = response.json::<EmailMetrics>().await?;
Ok(content)
}
}
#[allow(unreachable_pub)]
pub mod types {
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::{
emails::{join_comma, parse_nullable_vec},
idempotent::Idempotent,
types::{BroadcastId, DomainId, TemplateId, TopicId},
};
crate::define_id_type!(EmailId);
crate::define_id_type!(AttachmentId);
#[must_use]
#[derive(Debug, Clone, Serialize)]
pub struct CreateEmailBaseOptions {
from: String,
to: Vec<String>,
subject: String,
#[serde(skip_serializing_if = "Option::is_none")]
html: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
bcc: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
cc: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
reply_to: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
headers: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
attachments: Option<Vec<CreateAttachment>>,
#[serde(skip_serializing_if = "Option::is_none")]
tags: Option<Vec<Tag>>,
#[serde(skip_serializing_if = "Option::is_none")]
template: Option<EmailTemplate>,
#[serde(skip_serializing_if = "Option::is_none")]
topic_id: Option<TopicId>,
#[serde(skip_serializing_if = "Option::is_none")]
scheduled_at: Option<String>,
}
impl CreateEmailBaseOptions {
pub fn new<T, A>(from: impl Into<String>, to: T, subject: impl Into<String>) -> Self
where
T: IntoIterator<Item = A>,
A: Into<String>,
{
Self {
from: from.into(),
to: to.into_iter().map(Into::into).collect(),
subject: subject.into(),
html: None,
text: None,
bcc: None,
cc: None,
reply_to: None,
headers: None,
attachments: None,
tags: None,
template: None,
topic_id: None,
scheduled_at: None,
}
}
#[inline]
pub fn with_html(mut self, html: &str) -> Self {
self.html = Some(html.to_owned());
self
}
#[inline]
pub fn with_text(mut self, text: &str) -> Self {
self.text = Some(text.to_owned());
self
}
#[inline]
pub fn with_bcc(mut self, address: &str) -> Self {
let bcc = self.bcc.get_or_insert_with(Vec::new);
bcc.push(address.to_owned());
self
}
#[inline]
pub fn with_cc(mut self, address: &str) -> Self {
let cc = self.cc.get_or_insert_with(Vec::new);
cc.push(address.to_owned());
self
}
#[inline]
pub fn with_reply(mut self, to: &str) -> Self {
let reply_to = self.reply_to.get_or_insert_with(Vec::new);
reply_to.push(to.to_owned());
self
}
#[inline]
pub fn with_reply_multiple(mut self, to: &[String]) -> Self {
let reply_to = self.reply_to.get_or_insert_with(Vec::new);
reply_to.extend_from_slice(to);
self
}
#[inline]
pub fn with_header(mut self, name: &str, value: &str) -> Self {
let headers = self.headers.get_or_insert_with(HashMap::new);
let _unused = headers.insert(name.to_owned(), value.to_owned());
self
}
#[inline]
pub fn with_attachment(mut self, file: impl Into<CreateAttachment>) -> Self {
let attachments = self.attachments.get_or_insert_with(Vec::new);
attachments.push(file.into());
self
}
#[inline]
pub fn with_attachments(
mut self,
new_attachments: impl IntoIterator<Item = impl Into<CreateAttachment>>,
) -> Self {
let attachments = self.attachments.get_or_insert_with(Vec::new);
attachments.extend(new_attachments.into_iter().map(Into::into));
self
}
#[inline]
pub fn with_tag(mut self, tag: impl Into<Tag>) -> Self {
let tags = self.tags.get_or_insert_with(Vec::new);
tags.push(tag.into());
self
}
#[inline]
pub fn with_template(mut self, template: impl Into<EmailTemplate>) -> Self {
self.template = Some(template.into());
self
}
#[inline]
pub fn with_topic(mut self, topic_id: &str) -> Self {
self.topic_id = Some(TopicId::new(topic_id));
self
}
#[inline]
pub fn with_scheduled_at(mut self, scheduled_at: &str) -> Self {
self.scheduled_at = Some(scheduled_at.to_owned());
self
}
#[inline]
pub fn with_idempotency_key(self, idempotency_key: &str) -> Idempotent<Self> {
Idempotent {
idempotency_key: Some(idempotency_key.to_owned()),
data: self,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateEmailResponse {
pub id: EmailId,
}
#[must_use]
#[derive(Debug, Default, Clone, Serialize)]
pub struct UpdateEmailOptions {
#[serde(skip_serializing_if = "Option::is_none")]
scheduled_at: Option<String>,
}
impl UpdateEmailOptions {
#[inline]
pub fn new() -> Self {
Self::default()
}
#[inline]
pub fn with_scheduled_at(mut self, scheduled_at: &str) -> Self {
self.scheduled_at = Some(scheduled_at.to_owned());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateEmailResponse {
pub id: EmailId,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CancelScheduleResponse {
pub id: EmailId,
}
#[must_use]
#[derive(Debug, Default, Clone, Serialize)]
pub struct ShareEmailOptions {
#[serde(skip_serializing_if = "Option::is_none")]
expires_in: Option<String>,
}
impl ShareEmailOptions {
#[inline]
pub fn new() -> Self {
Self::default()
}
#[inline]
pub fn with_expires_in(mut self, expires_in: &str) -> Self {
self.expires_in = Some(expires_in.to_owned());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShareEmailResponse {
pub id: EmailId,
pub url: String,
}
#[must_use]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tag {
name: String,
value: String,
}
impl Tag {
#[inline]
pub fn new(name: &str, value: &str) -> Self {
Self {
name: name.to_owned(),
value: value.to_owned(),
}
}
}
#[must_use]
#[derive(Debug, Clone, Serialize)]
pub struct CreateAttachment {
#[serde(flatten)]
content_or_path: ContentOrPath,
#[serde(skip_serializing_if = "Option::is_none")]
filename: Option<String>,
#[serde(rename = "contentType", skip_serializing_if = "Option::is_none")]
content_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
content_id: Option<String>,
}
#[must_use]
#[derive(Debug, Clone, Serialize)]
pub enum ContentOrPath {
#[serde(rename = "content")]
Content(Vec<u8>),
#[serde(rename = "path")]
Path(String),
}
impl CreateAttachment {
#[inline]
pub const fn from_content(content: Vec<u8>) -> Self {
Self {
content_or_path: ContentOrPath::Content(content),
filename: None,
content_type: None,
content_id: None,
}
}
#[inline]
pub fn from_path(path: &str) -> Self {
Self {
content_or_path: ContentOrPath::Path(path.to_owned()),
filename: None,
content_type: None,
content_id: None,
}
}
#[inline]
pub fn with_filename(mut self, filename: &str) -> Self {
self.filename = Some(filename.to_owned());
self
}
#[inline]
pub fn with_content_type(mut self, content_type: &str) -> Self {
self.content_type = Some(content_type.to_owned());
self
}
#[deprecated(
since = "0.16.1",
note = "Parameter got internally renamed to just `content_id`. Use `with_content_id` instead."
)]
#[inline]
pub fn with_inline_content_id(mut self, inline_content_id: &str) -> Self {
self.content_id = Some(inline_content_id.to_owned());
self
}
#[inline]
pub fn with_content_id(mut self, content_id: &str) -> Self {
self.content_id = Some(content_id.to_owned());
self
}
}
impl From<Vec<u8>> for CreateAttachment {
#[inline]
fn from(value: Vec<u8>) -> Self {
Self::from_content(value)
}
}
impl From<&[u8]> for CreateAttachment {
#[inline]
fn from(value: &[u8]) -> Self {
value.to_vec().into()
}
}
#[must_use]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Email {
pub id: EmailId,
pub message_id: Option<String>,
pub from: String,
pub to: Vec<String>,
pub subject: String,
pub created_at: String,
pub html: Option<String>,
pub text: Option<String>,
#[serde(deserialize_with = "parse_nullable_vec")]
pub bcc: Vec<String>,
#[serde(deserialize_with = "parse_nullable_vec")]
pub cc: Vec<String>,
pub reply_to: Option<Vec<String>>,
pub last_event: EmailEvent,
#[serde(skip_serializing_if = "Option::is_none")]
pub scheduled_at: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum EmailEvent {
Bounced,
Canceled,
Clicked,
Complained,
Delivered,
DeliveryDelayed,
Failed,
Opened,
Queued,
Scheduled,
Sent,
}
#[must_use]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attachment {
pub id: AttachmentId,
pub filename: Option<String>,
pub size: u32,
pub content_type: String,
pub content_disposition: ContentDisposition,
pub content_id: Option<String>,
pub download_url: String,
pub expires_at: String,
}
#[must_use]
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContentDisposition {
Inline,
Attachment,
}
#[must_use]
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct EmailTemplate {
pub id: TemplateId,
pub variables: Option<HashMap<String, serde_json::Value>>,
}
impl EmailTemplate {
pub fn new(id: &str) -> Self {
Self {
id: TemplateId::new(id),
variables: None,
}
}
pub fn with_variable(mut self, key: &str, value: serde_json::Value) -> Self {
let variables = self.variables.get_or_insert_with(HashMap::new);
let _old = variables.insert(key.to_owned(), value);
self
}
pub fn with_variables(mut self, variables: HashMap<String, serde_json::Value>) -> Self {
let self_variables = self.variables.get_or_insert_with(HashMap::new);
self_variables.extend(variables);
self
}
}
#[derive(Debug, Clone, Copy)]
pub struct NoFilter;
#[derive(Debug, Clone, Copy)]
pub struct EmailFilter;
#[derive(Debug, Clone, Copy)]
pub struct BroadcastFilter;
#[must_use]
#[derive(Debug, Clone, Serialize)]
pub struct GetEmailMetricsOptions<State = NoFilter> {
#[serde(skip)]
_state: std::marker::PhantomData<State>,
#[serde(skip_serializing_if = "Option::is_none")]
start_date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
end_date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
timezone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
granularity: Option<MetricsGranularity>,
#[serde(skip_serializing_if = "Vec::is_empty", serialize_with = "join_comma")]
metrics: Vec<Metric>,
#[serde(skip_serializing_if = "Vec::is_empty", serialize_with = "join_comma")]
dimensions: Vec<Dimension>,
#[serde(
rename = "domain_id",
skip_serializing_if = "Vec::is_empty",
serialize_with = "join_comma"
)]
domain_ids: Vec<DomainId>,
#[serde(
rename = "email_id",
skip_serializing_if = "Vec::is_empty",
serialize_with = "join_comma"
)]
email_ids: Vec<EmailId>,
#[serde(
rename = "broadcast_id",
skip_serializing_if = "Vec::is_empty",
serialize_with = "join_comma"
)]
broadcast_ids: Vec<BroadcastId>,
}
impl Default for GetEmailMetricsOptions {
fn default() -> Self {
Self {
_state: std::marker::PhantomData::<NoFilter>,
start_date: Option::default(),
end_date: Option::default(),
timezone: Option::default(),
granularity: Option::default(),
metrics: Vec::default(),
dimensions: Vec::default(),
domain_ids: Vec::default(),
email_ids: Vec::default(),
broadcast_ids: Vec::default(),
}
}
}
impl GetEmailMetricsOptions<NoFilter> {
#[inline]
pub fn with_email_dimension(mut self) -> GetEmailMetricsOptions<EmailFilter> {
self.dimensions.push(Dimension::Email);
GetEmailMetricsOptions::<EmailFilter> {
_state: std::marker::PhantomData,
start_date: self.start_date,
end_date: self.end_date,
timezone: self.timezone,
granularity: self.granularity,
metrics: self.metrics,
dimensions: self.dimensions,
domain_ids: self.domain_ids,
email_ids: self.email_ids,
broadcast_ids: self.broadcast_ids,
}
}
#[inline]
pub fn with_email_id(mut self, email_id: &str) -> GetEmailMetricsOptions<EmailFilter> {
self.email_ids.push(EmailId::new(email_id));
GetEmailMetricsOptions::<EmailFilter> {
_state: std::marker::PhantomData,
start_date: self.start_date,
end_date: self.end_date,
timezone: self.timezone,
granularity: self.granularity,
metrics: self.metrics,
dimensions: self.dimensions,
domain_ids: self.domain_ids,
email_ids: self.email_ids,
broadcast_ids: self.broadcast_ids,
}
}
#[inline]
pub fn with_email_ids<T: AsRef<str>>(
mut self,
email_ids: impl IntoIterator<Item = T>,
) -> GetEmailMetricsOptions<EmailFilter> {
self.email_ids
.extend(email_ids.into_iter().map(|id| EmailId::new(id.as_ref())));
GetEmailMetricsOptions::<EmailFilter> {
_state: std::marker::PhantomData,
start_date: self.start_date,
end_date: self.end_date,
timezone: self.timezone,
granularity: self.granularity,
metrics: self.metrics,
dimensions: self.dimensions,
domain_ids: self.domain_ids,
email_ids: self.email_ids,
broadcast_ids: self.broadcast_ids,
}
}
#[inline]
pub fn with_broadcast_dimension(mut self) -> GetEmailMetricsOptions<BroadcastFilter> {
self.dimensions.push(Dimension::Broadcast);
GetEmailMetricsOptions::<BroadcastFilter> {
_state: std::marker::PhantomData,
start_date: self.start_date,
end_date: self.end_date,
timezone: self.timezone,
granularity: self.granularity,
metrics: self.metrics,
dimensions: self.dimensions,
domain_ids: self.domain_ids,
email_ids: self.email_ids,
broadcast_ids: self.broadcast_ids,
}
}
#[inline]
pub fn with_broadcast_id(
mut self,
broadcast_id: &str,
) -> GetEmailMetricsOptions<BroadcastFilter> {
self.broadcast_ids.push(BroadcastId::new(broadcast_id));
GetEmailMetricsOptions::<BroadcastFilter> {
_state: std::marker::PhantomData,
start_date: self.start_date,
end_date: self.end_date,
timezone: self.timezone,
granularity: self.granularity,
metrics: self.metrics,
dimensions: self.dimensions,
domain_ids: self.domain_ids,
email_ids: self.email_ids,
broadcast_ids: self.broadcast_ids,
}
}
#[inline]
pub fn with_broadcast_ids<T: AsRef<str>>(
mut self,
broadcast_ids: impl IntoIterator<Item = T>,
) -> GetEmailMetricsOptions<BroadcastFilter> {
self.broadcast_ids.extend(
broadcast_ids
.into_iter()
.map(|id| BroadcastId::new(id.as_ref())),
);
GetEmailMetricsOptions::<BroadcastFilter> {
_state: std::marker::PhantomData,
start_date: self.start_date,
end_date: self.end_date,
timezone: self.timezone,
granularity: self.granularity,
metrics: self.metrics,
dimensions: self.dimensions,
domain_ids: self.domain_ids,
email_ids: self.email_ids,
broadcast_ids: self.broadcast_ids,
}
}
}
impl<T> GetEmailMetricsOptions<T> {
#[inline]
pub fn with_start_date(mut self, start_date: &str) -> Self {
self.start_date = Some(start_date.to_owned());
self
}
#[inline]
pub fn with_end_date(mut self, end_date: &str) -> Self {
self.end_date = Some(end_date.to_owned());
self
}
#[inline]
pub fn with_timezone(mut self, timezone: &str) -> Self {
self.timezone = Some(timezone.to_owned());
self
}
#[inline]
pub fn with_granularity(mut self, granularity: MetricsGranularity) -> Self {
self.granularity = Some(granularity);
self
}
#[inline]
pub fn with_metric(mut self, metric: Metric) -> Self {
self.metrics.push(metric);
self
}
#[inline]
pub fn with_metrics(mut self, metrics: impl IntoIterator<Item = Metric>) -> Self {
self.metrics.extend(metrics);
self
}
#[inline]
pub fn with_domain_id(mut self, domain_id: &str) -> Self {
self.domain_ids.push(DomainId::new(domain_id));
self
}
#[inline]
pub fn with_domain_ids<K: AsRef<str>>(
mut self,
domain_ids: impl IntoIterator<Item = K>,
) -> Self {
self.domain_ids
.extend(domain_ids.into_iter().map(|id| DomainId::new(id.as_ref())));
self
}
#[inline]
pub fn with_period_dimension(mut self) -> Self {
self.dimensions.push(Dimension::Period);
self
}
#[inline]
pub fn with_domain_dimension(mut self) -> Self {
self.dimensions.push(Dimension::Domain);
self
}
}
impl GetEmailMetricsOptions<EmailFilter> {
#[inline]
pub fn with_email_dimension(mut self) -> Self {
self.dimensions.push(Dimension::Email);
self
}
#[inline]
pub fn with_email_id(mut self, email_id: &str) -> Self {
self.email_ids.push(EmailId::new(email_id));
self
}
#[inline]
pub fn with_email_ids<T: AsRef<str>>(
mut self,
email_ids: impl IntoIterator<Item = T>,
) -> Self {
self.email_ids
.extend(email_ids.into_iter().map(|id| EmailId::new(id.as_ref())));
self
}
}
impl GetEmailMetricsOptions<BroadcastFilter> {
#[inline]
pub fn with_broadcast_dimension(mut self) -> Self {
self.dimensions.push(Dimension::Broadcast);
self
}
#[inline]
pub fn with_broadcast_id(mut self, broadcast_id: &str) -> Self {
self.broadcast_ids.push(BroadcastId::new(broadcast_id));
self
}
#[inline]
pub fn with_broadcast_ids<T: AsRef<str>>(
mut self,
broadcast_ids: impl IntoIterator<Item = T>,
) -> Self {
self.broadcast_ids.extend(
broadcast_ids
.into_iter()
.map(|id| BroadcastId::new(id.as_ref())),
);
self
}
}
#[must_use]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Metric {
Received,
Delivered,
Complained,
Suppressed,
Bounced,
BouncedTransient,
BouncedPermanent,
BouncedUndetermined,
Opened,
Clicked,
Unsubscribed,
DeliveryDelayed,
Failed,
Sent,
UniqueOpened,
UniqueClicked,
DeliveryRate,
OpenRate,
ClickRate,
BounceRate,
ComplaintRate,
UnsubscribeRate,
}
impl Metric {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Received => "received",
Self::Delivered => "delivered",
Self::Complained => "complained",
Self::Suppressed => "suppressed",
Self::Bounced => "bounced",
Self::BouncedTransient => "bounced_transient",
Self::BouncedPermanent => "bounced_permanent",
Self::BouncedUndetermined => "bounced_undetermined",
Self::Opened => "opened",
Self::Clicked => "clicked",
Self::Unsubscribed => "unsubscribed",
Self::DeliveryDelayed => "delivery_delayed",
Self::Failed => "failed",
Self::Sent => "sent",
Self::UniqueOpened => "unique_opened",
Self::UniqueClicked => "unique_clicked",
Self::DeliveryRate => "delivery_rate",
Self::OpenRate => "open_rate",
Self::ClickRate => "click_rate",
Self::BounceRate => "bounce_rate",
Self::ComplaintRate => "complaint_rate",
Self::UnsubscribeRate => "unsubscribe_rate",
}
}
}
impl AsRef<str> for Metric {
#[inline]
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl Serialize for Metric {
fn serialize<S>(&self, serializer: S) -> crate::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
#[must_use]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Dimension {
Period,
Domain,
Email,
Broadcast,
}
impl Dimension {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Period => "period",
Self::Domain => "domain",
Self::Email => "email",
Self::Broadcast => "broadcast",
}
}
}
impl AsRef<str> for Dimension {
#[inline]
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl Serialize for Dimension {
fn serialize<S>(&self, serializer: S) -> crate::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
#[must_use]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MetricsGranularity {
Hourly,
Daily,
Weekly,
Monthly,
}
#[must_use]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailMetrics {
pub start_date: String,
pub end_date: String,
pub metrics: Vec<Metric>,
pub dimensions: Vec<Dimension>,
pub granularity: MetricsGranularity,
pub totals: HashMap<String, f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Vec<EmailMetricsDataPoint>>,
}
#[must_use]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailMetricsDataPoint {
#[serde(skip_serializing_if = "Option::is_none")]
pub period: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain_id: Option<DomainId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email_id: Option<EmailId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub broadcast_id: Option<BroadcastId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub broadcast_name: Option<String>,
#[serde(flatten)]
pub metrics: HashMap<String, f64>,
}
}
fn parse_nullable_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
let opt = Option::deserialize(deserializer)?;
Ok(opt.unwrap_or_else(Vec::new))
}
fn join_comma<S, T>(items: &[T], serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
T: AsRef<str>,
{
let joined = items
.iter()
.map(AsRef::as_ref)
.collect::<Vec<_>>()
.join(",");
serializer.serialize_str(&joined)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
#[allow(clippy::needless_return)]
mod test {
#[cfg(not(feature = "blocking"))]
use crate::{
list_opts::ListOptions,
types::{
CreateAttachment, CreateTemplateOptions, EmailTemplate, ShareEmailOptions,
UpdateEmailOptions, Variable, VariableType,
},
};
use crate::{
test::{CLIENT, DebugResult},
types::{CreateEmailBaseOptions, Email, ShareEmailResponse, Tag},
};
#[cfg(not(feature = "blocking"))]
use jiff::{Span, Timestamp, Zoned};
use std::collections::HashMap;
use crate::{
Config,
types::{Dimension, EmailMetrics, GetEmailMetricsOptions, Metric, MetricsGranularity},
};
fn built_query<T>(opts: &GetEmailMetricsOptions<T>) -> HashMap<String, String> {
let config = Config::builder("re_test_key").build();
let request = config
.build(reqwest::Method::GET, "/emails/metrics")
.query(opts)
.build()
.unwrap();
request
.url()
.query_pairs()
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect()
}
#[tokio_shared_rt::test(shared = true)]
#[serial_test::serial]
#[cfg(not(feature = "blocking"))]
async fn all() -> DebugResult<()> {
let from = "Acme <onboarding@resend.dev>";
let to = ["delivered@resend.dev"];
let subject = "Hello World!";
let resend = &*CLIENT;
#[allow(clippy::string_lit_as_bytes)] let email = CreateEmailBaseOptions::new(from, to, subject)
.with_text("Hello World!")
.with_attachment("Hello World as file.".as_bytes())
.with_tag(Tag::new("category", "confirm_email"));
let email = resend.emails.send(email).await?;
std::thread::sleep(std::time::Duration::from_secs(1));
let _email = resend.emails.get(&email.id).await?;
Ok(())
}
#[test]
fn deserialize_test() {
let email = r#"{
"object": "email",
"id": "6757a66c-3a5b-49ee-98cc-fca7a5f423c0",
"message_id": "<111-222-333@email.example.com>",
"to": [
"email@gmail.com"
],
"from": "email@gmail.com>",
"created_at": "2024-07-11 07:49:53.682607+00",
"subject": "Subject",
"bcc": null,
"cc": null,
"reply_to": null,
"last_event": "delivery_delayed",
"html": "<div></div>",
"text": null,
"scheduled_at": null
}"#;
let res = serde_json::from_str::<Email>(email);
assert!(res.is_ok());
let res = res.unwrap();
assert_eq!(res.message_id.unwrap(), "<111-222-333@email.example.com>");
assert!(res.cc.is_empty());
assert!(res.bcc.is_empty());
assert!(res.text.is_none());
let email = r#"{
"object": "email",
"id": "6757a66c-3a5b-49ee-98cc-fca7a5f423c0",
"message_id": "<111-222-333@email.example.com>",
"to": [
"email@gmail.com"
],
"from": "email@gmail.com>",
"created_at": "2024-07-11 07:49:53.682607+00",
"subject": "Subject",
"bcc": ["hello", "world"],
"cc": ["!"],
"reply_to": null,
"last_event": "delivered",
"html": "<div></div>",
"text": "Not null",
"scheduled_at": "2024-08-07 15:15:37+00"
}"#;
let res = serde_json::from_str::<Email>(email);
assert!(res.is_ok());
let res = res.unwrap();
assert!(!res.cc.is_empty());
assert!(!res.bcc.is_empty());
assert!(res.text.is_some());
}
#[test]
fn parse_share_email_response_test() {
let data = r#"{
"object": "email",
"id": "6757a66c-3a5b-49ee-98cc-fca7a5f423c0",
"url": "https://resend.com/share/6757a66c-3a5b-49ee-98cc-fca7a5f423c0"
}"#;
let _parsed = serde_json::from_str::<ShareEmailResponse>(data).expect("Parsing failed");
}
#[test]
#[cfg(feature = "blocking")]
fn all_blocking() -> DebugResult<()> {
let from = "Acme <onboarding@resend.dev>";
let to = ["delivered@resend.dev"];
let subject = "Hello World!";
let resend = &*CLIENT;
let email = CreateEmailBaseOptions::new(from, to, subject)
.with_text("Hello World!")
.with_tag(Tag::new("category", "confirm_email"));
let _email = resend.emails.send(email)?;
std::thread::sleep(std::time::Duration::from_millis(1100));
Ok(())
}
#[tokio_shared_rt::test(shared = true)]
#[serial_test::serial]
#[cfg(not(feature = "blocking"))]
async fn schedule_email() -> DebugResult<()> {
use crate::emails::types::EmailEvent;
let now_plus_1h = Zoned::now()
.checked_add(Span::new().hours(1))
.expect("Valid date")
.timestamp()
.to_string();
let now_plus_2h = Zoned::now()
.checked_add(Span::new().hours(2))
.expect("Valid date")
.timestamp()
.to_string();
let from = "Acme <onboarding@resend.dev>";
let to = ["delivered@resend.dev"];
let subject = "Hello World!";
let resend = &*CLIENT;
let email = CreateEmailBaseOptions::new(from, to, subject)
.with_text("Hello World!")
.with_scheduled_at(&now_plus_1h);
let email = resend.emails.send(email).await?;
std::thread::sleep(std::time::Duration::from_secs(5));
let email = resend.emails.get(&email.id).await?;
assert_eq!(email.last_event, EmailEvent::Scheduled);
assert!(email.scheduled_at.is_some());
let time = email
.scheduled_at
.unwrap()
.parse::<Timestamp>()
.expect("Valid timestamp");
let time_delta = (time - Timestamp::now()).round(jiff::Unit::Hour).unwrap();
assert_eq!(
time_delta.compare(Span::new().hours(1)).unwrap(),
std::cmp::Ordering::Equal
);
let changes = UpdateEmailOptions::new().with_scheduled_at(&now_plus_2h);
let email = resend.emails.update(&email.id, changes).await?;
std::thread::sleep(std::time::Duration::from_secs(1));
let email = resend.emails.get(&email.id).await?;
assert_eq!(email.last_event, EmailEvent::Scheduled);
assert!(email.scheduled_at.is_some());
let time = email
.scheduled_at
.unwrap()
.parse::<Timestamp>()
.expect("Valid timestamp");
let time_delta = (time - Timestamp::now()).round(jiff::Unit::Hour).unwrap();
assert_eq!(
time_delta.compare(Span::new().hours(2)).unwrap(),
std::cmp::Ordering::Equal
);
let _cancelled = resend.emails.cancel(&email.id).await?;
std::thread::sleep(std::time::Duration::from_secs(1));
let email = resend.emails.get(&email.id).await?;
assert_eq!(email.last_event, EmailEvent::Canceled);
Ok(())
}
#[tokio_shared_rt::test(shared = true)]
#[serial_test::serial]
#[cfg(not(feature = "blocking"))]
async fn share_email() -> DebugResult<()> {
let from = "Acme <onboarding@resend.dev>";
let to = ["delivered@resend.dev"];
let subject = "Hello World!";
let resend = &*CLIENT;
let email = CreateEmailBaseOptions::new(from, to, subject).with_text("Hello World!");
let email = resend.emails.send(email).await?;
std::thread::sleep(std::time::Duration::from_secs(1));
let shared = resend
.emails
.share(&email.id, ShareEmailOptions::new())
.await?;
assert_eq!(shared.id, email.id);
assert!(!shared.url.is_empty());
std::thread::sleep(std::time::Duration::from_secs(1));
let shared = resend
.emails
.share(&email.id, ShareEmailOptions::new().with_expires_in("10m"))
.await?;
assert_eq!(shared.id, email.id);
assert!(!shared.url.is_empty());
std::thread::sleep(std::time::Duration::from_secs(1));
let shared = resend
.emails
.share(&email.id, ShareEmailOptions::new().with_expires_in("72h"))
.await;
assert!(shared.is_err());
std::thread::sleep(std::time::Duration::from_secs(1));
let shared = resend
.emails
.share(
&email.id,
ShareEmailOptions::new().with_expires_in("not-a-duration"),
)
.await;
assert!(matches!(shared, Err(crate::Error::Resend(_))));
std::thread::sleep(std::time::Duration::from_secs(1));
let shared = resend
.emails
.share(
"00000000-0000-0000-0000-000000000000",
ShareEmailOptions::new(),
)
.await;
assert!(matches!(shared, Err(crate::Error::Resend(_))));
Ok(())
}
#[tokio_shared_rt::test(shared = true)]
#[serial_test::serial]
#[cfg(not(feature = "blocking"))]
async fn list_emails() -> DebugResult<()> {
let resend = &*CLIENT;
std::thread::sleep(std::time::Duration::from_secs(1));
let list_opts = ListOptions::default()
.with_limit(3)
.list_before("71f170f3-826e-47e3-9128-a5958e3b375e");
let list = resend.emails.list(list_opts).await?;
assert!(list.has_more);
assert_eq!(list.data.len(), 3);
Ok(())
}
#[tokio_shared_rt::test(shared = true)]
#[serial_test::serial]
#[cfg(not(feature = "blocking"))]
async fn attachments() -> DebugResult<()> {
let resend = &*CLIENT;
std::thread::sleep(std::time::Duration::from_secs(1));
let attachment = CreateAttachment::from_content(include_bytes!("../README.md").to_vec())
.with_filename("README.md");
let from = "Acme <onboarding@resend.dev>";
let to = ["delivered@resend.dev"];
let subject = "Hello World!";
let email = CreateEmailBaseOptions::new(from, to, subject)
.with_attachment(attachment)
.with_text("Hello World!");
let email = resend.emails.send(email).await?;
let email_id = &email.id;
std::thread::sleep(std::time::Duration::from_secs(1));
let attachments = resend
.emails
.list_attachments(email_id, ListOptions::default())
.await?;
assert_eq!(attachments.data.len(), 1);
let attachment_id = &attachments.data.first().unwrap().id;
let _attachment = resend
.emails
.get_attachment(email_id, attachment_id)
.await?;
Ok(())
}
#[tokio_shared_rt::test(shared = true)]
#[serial_test::serial]
#[cfg(not(feature = "blocking"))]
async fn template() -> DebugResult<()> {
use std::collections::HashMap;
let resend = &*CLIENT;
std::thread::sleep(std::time::Duration::from_secs(1));
let name = "welcome-email";
let html = "<strong>Hey, {{{NAME}}}, you are {{{AGE}}} years old.</strong>";
let variables = [
Variable::new("NAME", VariableType::String).with_fallback("user"),
Variable::new("AGE", VariableType::Number).with_fallback(25),
Variable::new("OPTIONAL_VARIABLE", VariableType::String).with_fallback(None::<String>),
];
let opts = CreateTemplateOptions::new(name, html).with_variables(&variables);
let template = resend.templates.create(opts).await?;
std::thread::sleep(std::time::Duration::from_secs(2));
let template = resend.templates.publish(&template.id).await?;
std::thread::sleep(std::time::Duration::from_secs(2));
let mut variables = HashMap::<String, serde_json::Value>::new();
let _added = variables.insert("NAME".to_string(), serde_json::json!("Tony"));
let _added = variables.insert("AGE".to_string(), serde_json::json!(25));
let template = EmailTemplate::new(&template.id).with_variables(variables);
let template_id = &template.id.clone();
let from = "Acme <onboarding@resend.dev>";
let to = ["delivered@resend.dev"];
let subject = "hello world";
let email = CreateEmailBaseOptions::new(from, to, subject).with_template(template);
let _email = resend.emails.send(email).await?;
std::thread::sleep(std::time::Duration::from_secs(2));
let deleted = resend.templates.delete(template_id).await?;
assert!(deleted.deleted);
Ok(())
}
#[test]
fn metrics_query_no_options() {
let query = built_query(&GetEmailMetricsOptions::default());
assert!(query.is_empty());
}
#[test]
fn metrics_query_multiple_dimensions() {
let opts = GetEmailMetricsOptions::default()
.with_period_dimension()
.with_broadcast_dimension();
let query = built_query(&opts);
assert_eq!(
query.get("dimensions").map(String::as_str),
Some("period,broadcast")
);
}
#[test]
fn metrics_query_domain_id_filter() {
let single = GetEmailMetricsOptions::default().with_domain_id("d1");
let query = built_query(&single);
assert_eq!(query.get("domain_id").map(String::as_str), Some("d1"));
let multiple = GetEmailMetricsOptions::default().with_domain_ids(["d1", "d2", "d3"]);
let query = built_query(&multiple);
assert_eq!(query.get("domain_id").map(String::as_str), Some("d1,d2,d3"));
}
#[test]
fn metrics_query_email_id_filter() {
let single = GetEmailMetricsOptions::default().with_email_id("e1");
let query = built_query(&single);
assert_eq!(query.get("email_id").map(String::as_str), Some("e1"));
let multiple = GetEmailMetricsOptions::default().with_email_ids(["e1", "e2"]);
let query = built_query(&multiple);
assert_eq!(query.get("email_id").map(String::as_str), Some("e1,e2"));
}
#[test]
fn metrics_query_broadcast_id_filter() {
let single = GetEmailMetricsOptions::default().with_broadcast_id("b1");
let query = built_query(&single);
assert_eq!(query.get("broadcast_id").map(String::as_str), Some("b1"));
let multiple = GetEmailMetricsOptions::default().with_broadcast_ids(["b1", "b2"]);
let query = built_query(&multiple);
assert_eq!(query.get("broadcast_id").map(String::as_str), Some("b1,b2"));
}
#[test]
fn metrics_query_metrics_passed_through() {
let single = GetEmailMetricsOptions::default().with_metric(Metric::Delivered);
let query = built_query(&single);
assert_eq!(query.get("metrics").map(String::as_str), Some("delivered"));
let multiple =
GetEmailMetricsOptions::default().with_metrics([Metric::Delivered, Metric::Opened]);
let query = built_query(&multiple);
assert_eq!(
query.get("metrics").map(String::as_str),
Some("delivered,opened")
);
}
#[test]
fn metrics_query_granularity_and_timezone_passed_through() {
let opts = GetEmailMetricsOptions::default()
.with_start_date("2026-07-01")
.with_end_date("2026-07-08")
.with_timezone("America/New_York")
.with_granularity(MetricsGranularity::Weekly);
let query = built_query(&opts);
assert_eq!(
query.get("start_date").map(String::as_str),
Some("2026-07-01")
);
assert_eq!(
query.get("end_date").map(String::as_str),
Some("2026-07-08")
);
assert_eq!(
query.get("timezone").map(String::as_str),
Some("America/New_York")
);
assert_eq!(query.get("granularity").map(String::as_str), Some("weekly"));
}
#[test]
fn deserialize_metrics_response_with_data() {
let json = r#"{
"object": "metrics",
"start_date": "2026-07-01T00:00:00.000Z",
"end_date": "2026-07-08T00:00:00.000Z",
"metrics": ["delivered", "opened"],
"dimensions": ["period", "broadcast"],
"granularity": "daily",
"totals": { "delivered": 100, "opened": 40 },
"data": [
{
"period": "2026-07-01",
"broadcast_id": "5c9c5f21-3b3a-4f0a-8f6b-3f2d1e6f6c9a",
"broadcast_name": "July Newsletter",
"delivered": 10,
"opened": 4
}
]
}"#;
let metrics: EmailMetrics = serde_json::from_str(json).unwrap();
assert_eq!(metrics.start_date, "2026-07-01T00:00:00.000Z");
assert_eq!(metrics.end_date, "2026-07-08T00:00:00.000Z");
assert_eq!(metrics.metrics, vec![Metric::Delivered, Metric::Opened]);
assert_eq!(
metrics.dimensions,
vec![Dimension::Period, Dimension::Broadcast]
);
assert_eq!(metrics.granularity, MetricsGranularity::Daily);
assert_eq!(metrics.totals.get("delivered"), Some(&100.0));
assert_eq!(metrics.totals.get("opened"), Some(&40.0));
let data = metrics
.data
.expect("data present when dimensions requested");
assert_eq!(data.len(), 1);
let row = data.first().expect("one data row");
assert_eq!(row.period.as_deref(), Some("2026-07-01"));
assert!(row.domain_id.is_none());
assert!(row.email_id.is_none());
assert_eq!(
row.broadcast_id.as_deref(),
Some("5c9c5f21-3b3a-4f0a-8f6b-3f2d1e6f6c9a")
);
assert_eq!(row.broadcast_name.as_deref(), Some("July Newsletter"));
assert_eq!(row.metrics.get("delivered"), Some(&10.0));
assert_eq!(row.metrics.get("opened"), Some(&4.0));
}
#[test]
fn deserialize_metrics_response_without_dimensions() {
let json = r#"{
"object": "metrics",
"start_date": "2026-07-01T00:00:00.000Z",
"end_date": "2026-07-08T00:00:00.000Z",
"metrics": ["delivered"],
"dimensions": [],
"granularity": "daily",
"totals": { "delivered": 100 }
}"#;
let metrics: EmailMetrics = serde_json::from_str(json).unwrap();
assert!(metrics.dimensions.is_empty());
assert!(metrics.data.is_none());
}
}