cratefield_adapter_resend/
lib.rs1#![doc = include_str!("../README.md")]
11#![forbid(unsafe_code)]
12
13mod inbound;
14
15pub use inbound::ResendInbound;
16
17use async_trait::async_trait;
18use bytes::Bytes;
19use cratefield_core::{
20 Clock, HttpClient, HttpError, MailError, MailProvider, Mailer, Message, SendOutcome,
21 retry_after,
22};
23use http::header::{AUTHORIZATION, CONTENT_TYPE};
24use http::{Request, StatusCode};
25use std::collections::BTreeMap;
26use std::sync::Arc;
27use std::time::Duration;
28
29const RESEND_ENDPOINT: &str = "https://api.resend.com/emails";
30
31pub struct Resend {
33 http: Arc<dyn HttpClient>,
34 clock: Arc<dyn Clock>,
39 api_key: Option<String>,
40 from: String,
41 reply_to: Option<String>,
42}
43
44impl Resend {
45 pub fn new(
47 http: Arc<dyn HttpClient>,
48 clock: Arc<dyn Clock>,
49 api_key: Option<String>,
50 from: impl Into<String>,
51 reply_to: Option<String>,
52 ) -> Self {
53 Self {
54 http,
55 clock,
56 api_key,
57 from: from.into(),
58 reply_to,
59 }
60 }
61
62 pub fn from_env(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>) -> Self {
66 Self::new(
67 http,
68 clock,
69 std::env::var("RESEND_API_KEY").ok(),
70 std::env::var("MAIL_FROM").unwrap_or_else(|_| String::new()),
71 std::env::var("MAIL_REPLY_TO").ok(),
72 )
73 }
74}
75
76#[derive(serde::Serialize)]
77struct OutboundEmail<'a> {
78 from: &'a str,
79 to: &'a str,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 reply_to: Option<&'a str>,
82 subject: &'a str,
83 html: &'a str,
84 text: &'a str,
85 #[serde(skip_serializing_if = "Vec::is_empty")]
86 tags: Vec<ResendTag>,
87 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
91 headers: BTreeMap<&'a str, &'a str>,
92}
93
94#[derive(serde::Serialize)]
99struct ResendTag {
100 name: String,
101 value: &'static str,
102}
103
104fn resend_tags(tags: &[String]) -> Vec<ResendTag> {
105 tags.iter()
106 .map(|tag| ResendTag {
107 name: sanitize_tag(tag),
108 value: "1",
109 })
110 .collect()
111}
112
113fn sanitize_tag(tag: &str) -> String {
116 let cleaned: String = tag
117 .chars()
118 .map(|c| {
119 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
120 c
121 } else {
122 '_'
123 }
124 })
125 .collect();
126 if cleaned.is_empty() {
127 "tag".to_owned()
128 } else {
129 cleaned
130 }
131}
132
133#[derive(serde::Deserialize)]
134struct SendResponse {
135 #[serde(default)]
136 id: String,
137}
138
139#[derive(serde::Deserialize)]
140struct ErrorResponse {
141 #[serde(default)]
142 message: String,
143}
144
145fn extract_domain(message: &str) -> String {
149 message
150 .split_whitespace()
151 .find(|token| token.contains('.') && !token.starts_with('"'))
152 .unwrap_or(message)
153 .trim_matches(['"', '.', ','])
154 .to_string()
155}
156
157impl Resend {
158 fn map_status(status: StatusCode, body: &str, retry_after: Option<Duration>) -> MailError {
159 let detail = match serde_json::from_str::<ErrorResponse>(body) {
160 Ok(parsed) => parsed.message,
161 Err(_) => body.to_string(),
162 };
163 match status {
164 StatusCode::UNAUTHORIZED => MailError::Unauthorized,
165 StatusCode::FORBIDDEN => {
166 let lowered = detail.to_ascii_lowercase();
167 if lowered.contains("verify") || lowered.contains("domain") {
168 MailError::DomainNotVerified {
169 domain: extract_domain(&detail),
170 }
171 } else {
172 MailError::Unauthorized
173 }
174 }
175 StatusCode::UNPROCESSABLE_ENTITY => MailError::Invalid { detail },
176 StatusCode::TOO_MANY_REQUESTS => MailError::RateLimited { retry_after },
177 status if status.is_server_error() => MailError::Upstream(status.to_string()),
178 status => MailError::Upstream(format!("unexpected status {status}: {detail}")),
179 }
180 }
181}
182
183#[async_trait]
184impl Mailer for Resend {
185 fn providers(&self) -> Vec<MailProvider> {
189 vec![MailProvider::new("resend", self.api_key.is_some())]
190 }
191
192 async fn send(&self, message: Message) -> Result<SendOutcome, MailError> {
193 let Some(api_key) = &self.api_key else {
196 tracing::info!(
197 provider = "resend",
198 outcome = "not_configured",
199 idempotency = message.idempotency_key.as_deref().unwrap_or(""),
200 "mailer outcome"
201 );
202 return Ok(SendOutcome::NotConfigured);
203 };
204
205 let from = if message.from.is_empty() {
206 self.from.as_str()
207 } else {
208 message.from.as_str()
209 };
210 let reply_to = message.reply_to.as_deref().or(self.reply_to.as_deref());
211
212 let payload = OutboundEmail {
213 from,
214 to: &message.to,
215 reply_to,
216 subject: &message.subject,
217 html: &message.html,
218 text: &message.text,
219 tags: resend_tags(&message.tags),
220 headers: message
221 .headers
222 .iter()
223 .map(|(name, value)| (name.as_str(), value.as_str()))
224 .collect(),
225 };
226 let body =
227 serde_json::to_vec(&payload).map_err(|err| MailError::Transport(err.to_string()))?;
228
229 let mut builder = Request::builder()
230 .method(http::Method::POST)
231 .uri(RESEND_ENDPOINT)
232 .header(CONTENT_TYPE, "application/json")
233 .header(AUTHORIZATION, format!("Bearer {api_key}"));
234 if let Some(key) = &message.idempotency_key {
235 builder = builder.header("Idempotency-Key", key);
236 }
237 let request = builder
238 .body(Bytes::from(body))
239 .map_err(|err| MailError::Transport(err.to_string()))?;
240
241 let response = self
242 .http
243 .send(request)
244 .await
245 .map_err(|err: HttpError| MailError::Transport(err.to_string()))?;
246
247 let status = response.status();
248 let retry_after = retry_after(response.headers(), self.clock.as_ref());
251 let text = String::from_utf8_lossy(response.body()).to_string();
252
253 if status.is_success() {
254 let parsed: SendResponse =
255 serde_json::from_str(&text).map_err(|err| MailError::Transport(err.to_string()))?;
256 tracing::info!(
257 provider = "resend",
258 code = status.as_u16(),
259 outcome = "sent",
260 idempotency = message.idempotency_key.as_deref().unwrap_or(""),
261 "mailer outcome"
262 );
263 return Ok(SendOutcome::Sent { id: parsed.id });
264 }
265 let error = Self::map_status(status, &text, retry_after);
266 tracing::warn!(
267 provider = "resend",
268 code = status.as_u16(),
269 outcome = "failed",
270 idempotency = message.idempotency_key.as_deref().unwrap_or(""),
271 error = %error,
272 "mailer outcome"
273 );
274 Err(error)
275 }
276}