#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
mod inbound;
pub use inbound::ResendInbound;
use async_trait::async_trait;
use bytes::Bytes;
use cratefield_core::{
Clock, HttpClient, HttpError, MailError, MailProvider, Mailer, Message, SendOutcome,
retry_after,
};
use http::header::{AUTHORIZATION, CONTENT_TYPE};
use http::{Request, StatusCode};
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
const RESEND_ENDPOINT: &str = "https://api.resend.com/emails";
pub struct Resend {
http: Arc<dyn HttpClient>,
clock: Arc<dyn Clock>,
api_key: Option<String>,
from: String,
reply_to: Option<String>,
}
impl Resend {
pub fn new(
http: Arc<dyn HttpClient>,
clock: Arc<dyn Clock>,
api_key: Option<String>,
from: impl Into<String>,
reply_to: Option<String>,
) -> Self {
Self {
http,
clock,
api_key,
from: from.into(),
reply_to,
}
}
pub fn from_env(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>) -> Self {
Self::new(
http,
clock,
std::env::var("RESEND_API_KEY").ok(),
std::env::var("MAIL_FROM").unwrap_or_else(|_| String::new()),
std::env::var("MAIL_REPLY_TO").ok(),
)
}
}
#[derive(serde::Serialize)]
struct OutboundEmail<'a> {
from: &'a str,
to: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
reply_to: Option<&'a str>,
subject: &'a str,
html: &'a str,
text: &'a str,
#[serde(skip_serializing_if = "Vec::is_empty")]
tags: Vec<ResendTag>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
headers: BTreeMap<&'a str, &'a str>,
}
#[derive(serde::Serialize)]
struct ResendTag {
name: String,
value: &'static str,
}
fn resend_tags(tags: &[String]) -> Vec<ResendTag> {
tags.iter()
.map(|tag| ResendTag {
name: sanitize_tag(tag),
value: "1",
})
.collect()
}
fn sanitize_tag(tag: &str) -> String {
let cleaned: String = tag
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect();
if cleaned.is_empty() {
"tag".to_owned()
} else {
cleaned
}
}
#[derive(serde::Deserialize)]
struct SendResponse {
#[serde(default)]
id: String,
}
#[derive(serde::Deserialize)]
struct ErrorResponse {
#[serde(default)]
message: String,
}
fn extract_domain(message: &str) -> String {
message
.split_whitespace()
.find(|token| token.contains('.') && !token.starts_with('"'))
.unwrap_or(message)
.trim_matches(['"', '.', ','])
.to_string()
}
impl Resend {
fn map_status(status: StatusCode, body: &str, retry_after: Option<Duration>) -> MailError {
let detail = match serde_json::from_str::<ErrorResponse>(body) {
Ok(parsed) => parsed.message,
Err(_) => body.to_string(),
};
match status {
StatusCode::UNAUTHORIZED => MailError::Unauthorized,
StatusCode::FORBIDDEN => {
let lowered = detail.to_ascii_lowercase();
if lowered.contains("verify") || lowered.contains("domain") {
MailError::DomainNotVerified {
domain: extract_domain(&detail),
}
} else {
MailError::Unauthorized
}
}
StatusCode::UNPROCESSABLE_ENTITY => MailError::Invalid { detail },
StatusCode::TOO_MANY_REQUESTS => MailError::RateLimited { retry_after },
status if status.is_server_error() => MailError::Upstream(status.to_string()),
status => MailError::Upstream(format!("unexpected status {status}: {detail}")),
}
}
}
#[async_trait]
impl Mailer for Resend {
fn providers(&self) -> Vec<MailProvider> {
vec![MailProvider::new("resend", self.api_key.is_some())]
}
async fn send(&self, message: Message) -> Result<SendOutcome, MailError> {
let Some(api_key) = &self.api_key else {
tracing::info!(
provider = "resend",
outcome = "not_configured",
idempotency = message.idempotency_key.as_deref().unwrap_or(""),
"mailer outcome"
);
return Ok(SendOutcome::NotConfigured);
};
let from = if message.from.is_empty() {
self.from.as_str()
} else {
message.from.as_str()
};
let reply_to = message.reply_to.as_deref().or(self.reply_to.as_deref());
let payload = OutboundEmail {
from,
to: &message.to,
reply_to,
subject: &message.subject,
html: &message.html,
text: &message.text,
tags: resend_tags(&message.tags),
headers: message
.headers
.iter()
.map(|(name, value)| (name.as_str(), value.as_str()))
.collect(),
};
let body =
serde_json::to_vec(&payload).map_err(|err| MailError::Transport(err.to_string()))?;
let mut builder = Request::builder()
.method(http::Method::POST)
.uri(RESEND_ENDPOINT)
.header(CONTENT_TYPE, "application/json")
.header(AUTHORIZATION, format!("Bearer {api_key}"));
if let Some(key) = &message.idempotency_key {
builder = builder.header("Idempotency-Key", key);
}
let request = builder
.body(Bytes::from(body))
.map_err(|err| MailError::Transport(err.to_string()))?;
let response = self
.http
.send(request)
.await
.map_err(|err: HttpError| MailError::Transport(err.to_string()))?;
let status = response.status();
let retry_after = retry_after(response.headers(), self.clock.as_ref());
let text = String::from_utf8_lossy(response.body()).to_string();
if status.is_success() {
let parsed: SendResponse =
serde_json::from_str(&text).map_err(|err| MailError::Transport(err.to_string()))?;
tracing::info!(
provider = "resend",
code = status.as_u16(),
outcome = "sent",
idempotency = message.idempotency_key.as_deref().unwrap_or(""),
"mailer outcome"
);
return Ok(SendOutcome::Sent { id: parsed.id });
}
let error = Self::map_status(status, &text, retry_after);
tracing::warn!(
provider = "resend",
code = status.as_u16(),
outcome = "failed",
idempotency = message.idempotency_key.as_deref().unwrap_or(""),
error = %error,
"mailer outcome"
);
Err(error)
}
}