use async_trait::async_trait;
use chrono::{DateTime, Utc};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::email::{Email, PreparedEmail};
use crate::error::MailError;
use crate::mailer::{DeliveryResult, Mailer};
const RESEND_API_URL: &str = "https://api.resend.com";
#[must_use = "ResendMailer configuration methods return a modified mailer; chain or assign the returned value"]
pub struct ResendMailer {
api_key: String,
client: Client,
base_url: String,
}
pub trait ResendEmailExt: Sized {
#[must_use = "resend_tag returns a modified email; chain or assign the returned value"]
fn resend_tag<N, V>(self, name: N, value: V) -> Self
where
N: Into<String>,
V: Into<String>;
#[must_use = "resend_scheduled_at returns a modified email; chain or assign the returned value"]
fn resend_scheduled_at(self, scheduled_at: DateTime<Utc>) -> Self;
#[must_use = "resend_idempotency_key returns a modified email; chain or assign the returned value"]
fn resend_idempotency_key<K>(self, key: K) -> Self
where
K: Into<String>;
#[must_use = "resend_template returns a modified email; chain or assign the returned value"]
fn resend_template<T>(self, template: T) -> Result<Self, MailError>
where
T: Serialize;
}
impl ResendEmailExt for Email {
fn resend_tag<N, V>(mut self, name: N, value: V) -> Self
where
N: Into<String>,
V: Into<String>,
{
let tag = serde_json::json!({
"name": name.into(),
"value": value.into(),
});
match self.provider_options.get_mut("tags") {
Some(Value::Array(tags)) => tags.push(tag),
_ => {
self.provider_options
.insert("tags".to_string(), Value::Array(vec![tag]));
}
}
self
}
fn resend_scheduled_at(mut self, scheduled_at: DateTime<Utc>) -> Self {
self.provider_options.insert(
"scheduled_at".to_string(),
Value::String(scheduled_at.to_rfc3339()),
);
self
}
fn resend_idempotency_key<K>(mut self, key: K) -> Self
where
K: Into<String>,
{
self.provider_options
.insert("idempotency_key".to_string(), Value::String(key.into()));
self
}
fn resend_template<T>(mut self, template: T) -> Result<Self, MailError>
where
T: Serialize,
{
self.provider_options
.insert("template".to_string(), serde_json::to_value(template)?);
Ok(self)
}
}
impl ResendMailer {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
client: Client::new(),
base_url: RESEND_API_URL.to_string(),
}
}
pub fn with_client(api_key: impl Into<String>, client: Client) -> Self {
Self {
api_key: api_key.into(),
client,
base_url: RESEND_API_URL.to_string(),
}
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
async fn build_request(&self, email: &Email) -> Result<ResendRequest, MailError> {
let from = email.from.as_ref().ok_or(MailError::MissingField("from"))?;
if email.to.is_empty() {
return Err(MailError::MissingField("to"));
}
let mut request = ResendRequest {
from: from.formatted_rfc5322_ascii()?,
to: email
.to
.iter()
.map(|a| a.formatted_rfc5322_ascii())
.collect::<Result<_, _>>()?,
subject: if email.subject.is_empty() {
None
} else {
Some(email.subject.clone())
},
html: email.html_body.clone(),
text: email.text_body.clone(),
cc: if email.cc.is_empty() {
None
} else {
Some(
email
.cc
.iter()
.map(|a| a.formatted_rfc5322_ascii())
.collect::<Result<_, _>>()?,
)
},
bcc: if email.bcc.is_empty() {
None
} else {
Some(
email
.bcc
.iter()
.map(|a| a.formatted_rfc5322_ascii())
.collect::<Result<_, _>>()?,
)
},
reply_to: email
.reply_to
.first()
.map(|a| a.formatted_rfc5322_ascii())
.transpose()?,
headers: if email.headers.is_empty() {
None
} else {
Some(
email
.headers
.iter()
.map(|(k, v)| ResendHeader {
name: k.clone(),
value: v.clone(),
})
.collect(),
)
},
attachments: None,
tags: None,
scheduled_at: None,
template: None,
};
if !email.attachments.is_empty() {
let mut attachments = Vec::with_capacity(email.attachments.len());
for a in &email.attachments {
let content_id = if a.is_inline() {
a.content_id.clone()
} else {
None
};
attachments.push(ResendAttachment {
filename: a.filename.clone(),
content: a.base64_data_async().await?,
content_type: Some(a.content_type.clone()),
content_id,
});
}
request.attachments = Some(attachments);
}
if let Some(tags) = email.provider_options.get("tags") {
request.tags = serde_json::from_value(tags.clone()).ok();
}
if let Some(scheduled_at) = email.provider_options.get("scheduled_at") {
request.scheduled_at = scheduled_at.as_str().map(|s| s.to_string());
}
if let Some(template) = email.provider_options.get("template") {
request.template = Some(template.clone());
}
Ok(request)
}
}
#[cfg_attr(
all(target_family = "wasm", target_os = "unknown"),
async_trait(?Send)
)]
#[cfg_attr(not(all(target_family = "wasm", target_os = "unknown")), async_trait)]
impl Mailer for ResendMailer {
async fn deliver_prepared(&self, email: &PreparedEmail) -> Result<DeliveryResult, MailError> {
let request = self.build_request(email).await?;
let url = format!("{}/emails", self.base_url);
let mut req = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.header("User-Agent", format!("missive/{}", crate::VERSION));
if let Some(idempotency_key) = email.provider_options.get("idempotency_key") {
if let Some(key) = idempotency_key.as_str() {
req = req.header("Idempotency-Key", key);
}
}
let response = req.json(&request).send().await?;
let status = response.status();
if status.is_success() {
let result: ResendResponse = response.json().await?;
Ok(DeliveryResult::with_response(
result.id,
serde_json::json!({ "provider": "resend" }),
))
} else {
let error: ResendError = response.json().await.unwrap_or_else(|_| ResendError {
message: "Unknown error".to_string(),
name: None,
});
Err(MailError::provider_with_status(
"resend",
error.message,
status.as_u16(),
))
}
}
fn validate_batch(&self, emails: &[PreparedEmail]) -> Result<(), MailError> {
for (i, email) in emails.iter().enumerate() {
if email.provider_options.contains_key("scheduled_at") {
return Err(MailError::UnsupportedFeature(format!(
"scheduled_at is not supported in batch sends (email {})",
i + 1
)));
}
if !email.attachments.is_empty() {
return Err(MailError::UnsupportedFeature(format!(
"attachments are not supported in Resend batch sends (email {})",
i + 1
)));
}
}
Ok(())
}
async fn deliver_many_prepared(
&self,
emails: &[PreparedEmail],
) -> Result<Vec<DeliveryResult>, MailError> {
if emails.is_empty() {
return Ok(vec![]);
}
self.validate_batch(emails)?;
let mut requests = Vec::with_capacity(emails.len());
for email in emails {
requests.push(self.build_request(email).await?);
}
let url = format!("{}/emails/batch", self.base_url);
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.header("User-Agent", format!("missive/{}", crate::VERSION))
.json(&requests)
.send()
.await?;
let status = response.status();
if status.is_success() {
let result: ResendBatchResponse = response.json().await?;
Ok(result
.data
.into_iter()
.map(|r| {
DeliveryResult::with_response(r.id, serde_json::json!({ "provider": "resend" }))
})
.collect())
} else {
let error: ResendError = response.json().await.unwrap_or_else(|_| ResendError {
message: "Unknown error".to_string(),
name: None,
});
Err(MailError::provider_with_status(
"resend",
error.message,
status.as_u16(),
))
}
}
fn provider_name(&self) -> &'static str {
"resend"
}
}
#[derive(Debug, Serialize)]
struct ResendRequest {
from: String,
to: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
subject: Option<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")]
cc: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
bcc: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
reply_to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
headers: Option<Vec<ResendHeader>>,
#[serde(skip_serializing_if = "Option::is_none")]
attachments: Option<Vec<ResendAttachment>>,
#[serde(skip_serializing_if = "Option::is_none")]
tags: Option<Vec<ResendTag>>,
#[serde(skip_serializing_if = "Option::is_none")]
scheduled_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
template: Option<Value>,
}
#[derive(Debug, Serialize)]
struct ResendHeader {
name: String,
value: String,
}
#[derive(Debug, Serialize)]
struct ResendAttachment {
filename: String,
content: String, #[serde(skip_serializing_if = "Option::is_none")]
content_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
content_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResendTag {
pub name: String,
pub value: String,
}
#[derive(Debug, Deserialize)]
struct ResendResponse {
id: String,
}
#[derive(Debug, Deserialize)]
struct ResendBatchResponse {
data: Vec<ResendResponse>,
}
#[derive(Debug, Deserialize)]
struct ResendError {
message: String,
#[serde(default)]
#[allow(dead_code)]
name: Option<String>,
}