Skip to main content

cratefield_adapter_resend/
lib.rs

1//! `cratefield-adapter-resend`: the [`Mailer`] port over the Resend REST API
2//! (issue #6). Uses the runtime's [`HttpClient`] port — no `reqwest`, no
3//! vendor SDK — so the same adapter runs on Workers and natively.
4//!
5//! **Degraded mode.** The Resend account has no verified domain yet; until
6//! `send.<domain>` is verified, real sends 403. When the API key is absent
7//! the adapter reports [`SendOutcome::NotConfigured`] without any network
8//! call, so forms keep working.
9
10#![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
31/// `Mailer` over `POST https://api.resend.com/emails`.
32pub struct Resend {
33    http: Arc<dyn HttpClient>,
34    /// Needed only to read the HTTP-date form of `Retry-After` (issue #278).
35    /// A constructor argument rather than a builder default so a deployment
36    /// that forgets it fails to compile instead of silently retrying a
37    /// date-form 429 immediately.
38    clock: Arc<dyn Clock>,
39    api_key: Option<String>,
40    from: String,
41    reply_to: Option<String>,
42}
43
44impl Resend {
45    /// `api_key: None` => the adapter is `NotConfigured` (no network).
46    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    /// Reads `RESEND_API_KEY`, `MAIL_FROM`, `MAIL_REPLY_TO` from the process
63    /// environment. On Workers the venture should read the secrets from its
64    /// `Env` and use [`Resend::new`] instead (`std::env` has no Workers vars).
65    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    /// Extra RFC 5322 headers. Resend takes them as a JSON object, so a
88    /// repeated header name would silently collapse — the harness sends
89    /// each name once, and `List-Unsubscribe` is one value by RFC 8058.
90    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
91    headers: BTreeMap<&'a str, &'a str>,
92}
93
94/// Resend requires each tag to be a `{ name, value }` object whose fields
95/// contain only ASCII letters, digits, `_` or `-`; a bare string array is
96/// rejected with `422 Invalid input`. A harness tag is a single label, so it
97/// becomes the `name` (sanitised) with a constant `value`.
98#[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
113/// Keeps only Resend's allowed tag characters (ASCII letters, digits, `_`,
114/// `-`), mapping anything else to `_`, and never emits an empty name.
115fn 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
145/// Pulls the sending domain out of a Resend error message like
146/// "Domain send.factory0.ventures is not verified" — the first
147/// dot-separated token, otherwise the whole sanitized message.
148fn 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    /// Resend is the only provider behind this adapter, and it has no
186    /// probe: whether the key works is only learned by sending, so health
187    /// stays unknown rather than being guessed.
188    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        // Outcome logging per issue #14: provider, code, idempotency key;
194        // the recipient is never logged.
195        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        // One parser for both `Retry-After` forms (issue #214/#278); the
249        // date form needs the clock this adapter is constructed with.
250        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}