use reqwest::Client;
use serde::{Deserialize, Serialize};
use thiserror::Error;
const DEFAULT_BASE_URL: &str = "https://resent.one/api/v1";
#[derive(Debug, Error)]
pub enum Error {
#[error("missing API key — pass Resent::new(key) or set RESENT_API_KEY")]
MissingApiKey,
#[error("{0}")]
Validation(String),
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("Resent API error ({status}): {message}")]
Api { status: u16, message: String, body: Option<serde_json::Value> },
}
#[derive(Clone, Debug)]
pub struct Resent {
api_key: String,
base_url: String,
http: Client,
}
impl Resent {
pub fn new(api_key: impl Into<String>) -> Self {
let key = api_key.into().trim().to_string();
let key = if key.is_empty() {
std::env::var("RESENT_API_KEY").unwrap_or_default()
} else {
key
};
Self {
api_key: key,
base_url: DEFAULT_BASE_URL.to_string(),
http: Client::new(),
}
}
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
let mut url = base_url.into().trim().to_string();
while url.ends_with('/') {
url.pop();
}
self.base_url = url;
self
}
pub fn emails(&self) -> Emails<'_> {
Emails { client: self }
}
async fn send_email(&self, payload: SendEmail) -> Result<SendEmailResponse, Error> {
if self.api_key.trim().is_empty() {
return Err(Error::MissingApiKey);
}
if payload.from.trim().is_empty() {
return Err(Error::Validation("from is required".into()));
}
if payload.to.is_empty() {
return Err(Error::Validation("to is required".into()));
}
if payload.subject.trim().is_empty() {
return Err(Error::Validation("subject is required".into()));
}
if payload.html.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true)
&& payload.text.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true)
{
return Err(Error::Validation("html or text body is required".into()));
}
let url = format!("{}/email/send", self.base_url);
let response = self
.http
.post(url)
.bearer_auth(&self.api_key)
.header("Accept", "application/json")
.json(&payload)
.send()
.await?;
let status = response.status();
let body_text = response.text().await.unwrap_or_default();
let json: Option<serde_json::Value> = serde_json::from_str(&body_text).ok();
if !status.is_success() {
let message = json
.as_ref()
.and_then(|v| v.get("error"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| {
if body_text.is_empty() {
format!("Resent API error ({})", status.as_u16())
} else {
body_text.clone()
}
});
return Err(Error::Api {
status: status.as_u16(),
message,
body: json,
});
}
serde_json::from_str(&body_text).map_err(|err| Error::Validation(err.to_string()))
}
}
#[derive(Debug)]
pub struct Emails<'a> {
client: &'a Resent,
}
impl Emails<'_> {
pub async fn send(&self, payload: SendEmail) -> Result<SendEmailResponse, Error> {
self.client.send_email(payload).await
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct SendEmail {
pub from: String,
pub to: Vec<String>,
pub subject: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub html: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cc: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bcc: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reply_to: Option<String>,
}
impl SendEmail {
pub fn new(
from: impl Into<String>,
to: impl IntoIterator<Item = impl Into<String>>,
subject: impl Into<String>,
) -> Self {
Self {
from: from.into(),
to: to.into_iter().map(Into::into).collect(),
subject: subject.into(),
..Default::default()
}
}
pub fn html(mut self, html: impl Into<String>) -> Self {
self.html = Some(html.into());
self
}
pub fn text(mut self, text: impl Into<String>) -> Self {
self.text = Some(text.into());
self
}
pub fn reply_to(mut self, reply_to: impl Into<String>) -> Self {
self.reply_to = Some(reply_to.into());
self
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct SendEmailResponse {
pub id: Option<String>,
pub submission_id: Option<String>,
pub status: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_missing_body() {
let client = Resent::new("test_key");
let err = tokio::runtime::Runtime::new()
.unwrap()
.block_on(client.emails().send(SendEmail {
from: "a@b.com".into(),
to: vec!["c@d.com".into()],
subject: "hi".into(),
..Default::default()
}))
.unwrap_err();
match err {
Error::Validation(msg) => assert!(msg.contains("html or text")),
other => panic!("unexpected {other:?}"),
}
}
}