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,
UpdateEmailOptions, UpdateEmailResponse,
},
};
#[derive(Clone, Debug)]
pub struct EmailsSvc(pub(crate) Arc<Config>);
impl EmailsSvc {
#[maybe_async::maybe_async]
#[allow(clippy::needless_pass_by_value)]
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]
#[allow(clippy::needless_pass_by_value)]
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]
#[allow(clippy::needless_pass_by_value)]
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]
#[allow(clippy::needless_pass_by_value)]
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)
}
}
#[allow(unreachable_pub)]
pub mod types {
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::{emails::parse_nullable_vec, idempotent::Idempotent, types::TemplateId};
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")]
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,
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_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, 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 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_variables(mut self, variables: HashMap<String, serde_json::Value>) -> Self {
self.variables = Some(variables);
self
}
}
}
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))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
#[allow(clippy::needless_return)]
mod test {
use crate::types::{
CreateAttachment, CreateEmailBaseOptions, CreateTemplateOptions, Email, EmailTemplate, Tag,
UpdateEmailOptions, Variable, VariableType,
};
use crate::{
list_opts::ListOptions,
test::{CLIENT, DebugResult},
};
use jiff::{Span, Timestamp, Zoned};
#[tokio_shared_rt::test(shared = true)]
#[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",
"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!(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",
"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]
#[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 _ = resend.emails.send(email)?;
std::thread::sleep(std::time::Duration::from_millis(1100));
Ok(())
}
#[tokio_shared_rt::test(shared = true)]
#[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(4));
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)]
#[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!(list.data.len() == 3);
Ok(())
}
#[tokio_shared_rt::test(shared = true)]
#[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!(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)]
#[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(())
}
}