Skip to main content

everruns_core/email/
resend.rs

1// Resend-backed system email provider.
2//
3// Decision: Keep this as a concrete EmailSender implementation, not a public
4// capability. Resend credentials are process-level deployment configuration.
5
6use 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}