1use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8
9use super::{
10 EmailAddress, EmailError, EmailMessage, EmailResult, EmailSender, EmailTag, RenderedEmail,
11 SentEmail, system_email_from,
12};
13
14const RESEND_API_BASE_URL: &str = "https://api.resend.com";
15const RESEND_REQUEST_TIMEOUT_MS: u64 = 30_000;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ResendEmailConfig {
19 pub api_key: String,
20 pub api_base_url: String,
21}
22
23impl ResendEmailConfig {
24 pub fn from_env() -> EmailResult<Self> {
25 let api_key = env_required("RESEND_API_KEY")?;
26 let api_base_url = env_opt("RESEND_API_BASE_URL")
27 .unwrap_or_else(|| RESEND_API_BASE_URL.trim_end_matches('/').to_string());
28
29 let config = Self {
30 api_key,
31 api_base_url: api_base_url.trim_end_matches('/').to_string(),
32 };
33 config.validate()?;
34 Ok(config)
35 }
36
37 fn validate(&self) -> EmailResult<()> {
38 if self.api_key.trim().is_empty() {
39 return Err(EmailError::config("RESEND_API_KEY is empty"));
40 }
41 let url = reqwest::Url::parse(&self.api_base_url).map_err(|error| {
42 EmailError::config(format!("RESEND_API_BASE_URL is not a valid URL: {error}"))
43 })?;
44 match url.scheme() {
45 "http" | "https" => {}
46 scheme => {
47 return Err(EmailError::config(format!(
48 "RESEND_API_BASE_URL must use http or https, got '{scheme}'"
49 )));
50 }
51 }
52 Ok(())
53 }
54}
55
56#[derive(Clone)]
57pub struct ResendEmailSender {
58 http: reqwest::Client,
59 config: ResendEmailConfig,
60}
61
62impl std::fmt::Debug for ResendEmailSender {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 f.debug_struct("ResendEmailSender")
65 .field("api_base_url", &self.config.api_base_url)
66 .finish_non_exhaustive()
67 }
68}
69
70impl ResendEmailSender {
71 pub fn new(config: ResendEmailConfig) -> Self {
72 let http = reqwest::Client::builder()
73 .timeout(std::time::Duration::from_millis(RESEND_REQUEST_TIMEOUT_MS))
74 .redirect(reqwest::redirect::Policy::none())
75 .build()
76 .expect("build Resend email HTTP client");
77 Self::with_http_client(config, http)
78 }
79
80 pub fn with_http_client(config: ResendEmailConfig, http: reqwest::Client) -> Self {
81 Self { http, config }
82 }
83
84 pub fn config(&self) -> &ResendEmailConfig {
85 &self.config
86 }
87}
88
89#[async_trait]
90impl EmailSender for ResendEmailSender {
91 async fn send_email(&self, message: EmailMessage) -> EmailResult<SentEmail> {
92 let rendered = message.validate()?;
93 let request = ResendSendEmailRequest::from_message(system_email_from(), message, rendered);
94 let body = serde_json::to_vec(&request).map_err(|error| {
95 EmailError::Transport(format!("failed to encode Resend request: {error}"))
96 })?;
97
98 let mut builder = self
99 .http
100 .post(format!("{}/emails", self.config.api_base_url))
101 .header("Authorization", format!("Bearer {}", self.config.api_key))
102 .header("Content-Type", "application/json")
103 .body(body);
104
105 if let Some(key) = &request.idempotency_key {
106 builder = builder.header("Idempotency-Key", key);
107 }
108
109 let response = builder
110 .send()
111 .await
112 .map_err(|error| EmailError::Transport(error.to_string()))?;
113 let status = response.status();
114 let body = response
115 .bytes()
116 .await
117 .map_err(|error| EmailError::Transport(error.to_string()))?
118 .to_vec();
119 if !status.is_success() {
120 return Err(EmailError::Provider {
121 provider: "resend",
122 status: status.as_u16(),
123 body: String::from_utf8_lossy(&body).into_owned(),
124 });
125 }
126
127 let body: ResendSendEmailResponse = serde_json::from_slice(&body).map_err(|error| {
128 EmailError::Transport(format!("failed to decode Resend response: {error}"))
129 })?;
130 Ok(SentEmail {
131 provider: "resend",
132 id: body.id,
133 })
134 }
135
136 fn name(&self) -> &'static str {
137 "ResendEmailSender"
138 }
139}
140
141#[derive(Debug, Serialize)]
142struct ResendSendEmailRequest {
143 from: String,
144 to: Vec<String>,
145 subject: String,
146 html: String,
147 text: String,
148 #[serde(skip_serializing_if = "Vec::is_empty")]
149 tags: Vec<EmailTag>,
150 #[serde(skip)]
151 idempotency_key: Option<String>,
152}
153
154impl ResendSendEmailRequest {
155 fn from_message(from: EmailAddress, message: EmailMessage, rendered: RenderedEmail) -> Self {
156 Self {
157 from: from.format_for_provider(),
158 to: message
159 .to
160 .into_iter()
161 .map(|address| address.format_for_provider())
162 .collect(),
163 subject: message.subject,
164 html: rendered.html,
165 text: rendered.text,
166 tags: message.tags,
167 idempotency_key: message.idempotency_key,
168 }
169 }
170}
171
172#[derive(Debug, Deserialize)]
173struct ResendSendEmailResponse {
174 id: String,
175}
176
177fn env_required(name: &str) -> EmailResult<String> {
178 env_opt(name).ok_or_else(|| EmailError::config(format!("{name} environment variable not set")))
179}
180
181fn env_opt(name: &str) -> Option<String> {
182 std::env::var(name).ok().filter(|value| !value.is_empty())
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use serde_json::json;
189 use wiremock::matchers::{header, method, path};
190 use wiremock::{Mock, MockServer, ResponseTemplate};
191
192 fn test_config(base_url: String) -> ResendEmailConfig {
193 ResendEmailConfig {
194 api_key: "re_test".to_string(),
195 api_base_url: base_url,
196 }
197 }
198
199 #[test]
200 fn resend_config_rejects_invalid_base_url() {
201 let error = ResendEmailConfig {
202 api_key: "re_test".to_string(),
203 api_base_url: "not a url".to_string(),
204 }
205 .validate()
206 .unwrap_err();
207
208 assert!(matches!(error, EmailError::Configuration(_)));
209 assert!(error.to_string().contains("valid URL"));
210 }
211
212 #[tokio::test]
213 async fn resend_sender_posts_branded_email() {
214 let server = MockServer::start().await;
215 Mock::given(method("POST"))
216 .and(path("/emails"))
217 .and(header("Authorization", "Bearer re_test"))
218 .and(header("Idempotency-Key", "welcome/user_123"))
219 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
220 "id": "email_123"
221 })))
222 .expect(1)
223 .mount(&server)
224 .await;
225
226 let sender = ResendEmailSender::new(test_config(server.uri()));
227 let sent = sender
228 .send_email(
229 EmailMessage::minimal("delivered@example.com", "Welcome", "hello", "<p>hello</p>")
230 .with_idempotency_key("welcome/user_123")
231 .with_tag("kind", "welcome"),
232 )
233 .await
234 .unwrap();
235
236 assert_eq!(sent.provider, "resend");
237 assert_eq!(sent.id, "email_123");
238
239 let requests = server.received_requests().await.unwrap();
240 let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap();
241 assert_eq!(body["from"], "Everruns <no-replay@everruns.com>");
242 assert_eq!(body["to"], json!(["delivered@example.com"]));
243 assert_eq!(body["subject"], "Welcome");
244 assert_eq!(body["text"], "hello");
245 assert!(body["html"].as_str().unwrap().contains("<p>hello</p>"));
246 assert_eq!(
247 body["tags"],
248 json!([{ "name": "kind", "value": "welcome" }])
249 );
250 assert!(body.get("cc").is_none());
251 assert!(body.get("bcc").is_none());
252 assert!(body.get("reply_to").is_none());
253 assert!(body.get("headers").is_none());
254 assert!(body.get("idempotency_key").is_none());
255 }
256
257 #[tokio::test]
258 async fn resend_sender_uses_direct_http_outside_runtime_egress_policy() {
259 let server = MockServer::start().await;
260 Mock::given(method("POST"))
261 .and(path("/emails"))
262 .and(header("Authorization", "Bearer re_test"))
263 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
264 "id": "email_allowed"
265 })))
266 .expect(1)
267 .mount(&server)
268 .await;
269
270 let allowlist = crate::SystemAllowlist::from_toml(
271 r#"
272 [groups.test]
273 allowed = ["allowed.example.com"]
274 "#,
275 )
276 .unwrap();
277 assert!(
278 !allowlist.is_url_allowed(&format!("{}/emails", server.uri())),
279 "mock Resend endpoint should be outside the runtime system allowlist"
280 );
281
282 let sender = ResendEmailSender::new(test_config(server.uri()));
283 let sent = sender
284 .send_email(EmailMessage::minimal(
285 "delivered@example.com",
286 "Welcome",
287 "hello",
288 "<p>hello</p>",
289 ))
290 .await
291 .unwrap();
292
293 assert_eq!(sent.id, "email_allowed");
294 }
295}