Skip to main content

flaremail_rs/
lib.rs

1use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
2use serde::{Deserialize, Serialize, de::DeserializeOwned};
3use serde_json::Value;
4use std::collections::HashMap;
5
6const DEFAULT_BASE_URL: &str = "https://api.cloudflare.com/client/v4";
7
8#[derive(Clone, Debug)]
9pub struct Email {
10    api_key: String,
11    account_id: Option<String>,
12    base_url: String,
13    client: reqwest::Client,
14}
15
16impl Email {
17    pub fn new(api_key: impl Into<String>) -> Self {
18        Self {
19            api_key: api_key.into(),
20            account_id: std::env::var("CLOUDFLARE_ACCOUNT_ID")
21                .or_else(|_| std::env::var("CF_ACCOUNT_ID"))
22                .ok(),
23            base_url: DEFAULT_BASE_URL.to_string(),
24            client: reqwest::Client::new(),
25        }
26    }
27
28    pub fn from_env() -> Result<Self, Error> {
29        let api_key = std::env::var("CLOUDFLARE_API_TOKEN")
30            .or_else(|_| std::env::var("CF_API_TOKEN"))
31            .map_err(|_| Error::MissingApiToken)?;
32
33        Ok(Self::new(api_key))
34    }
35
36    pub fn with_account_id(mut self, account_id: impl Into<String>) -> Self {
37        self.account_id = Some(account_id.into());
38        self
39    }
40
41    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
42        self.base_url = base_url.into().trim_end_matches('/').to_string();
43        self
44    }
45
46    pub fn with_client(mut self, client: reqwest::Client) -> Self {
47        self.client = client;
48        self
49    }
50
51    pub fn emails(&self) -> Emails<'_> {
52        Emails { email: self }
53    }
54
55    async fn account_id(&self) -> Result<String, Error> {
56        if let Some(account_id) = &self.account_id {
57            return Ok(account_id.clone());
58        }
59
60        let accounts: Vec<Account> = self
61            .request(reqwest::Method::GET, "/accounts", None)
62            .await?;
63
64        match accounts.as_slice() {
65            [] => Err(Error::NoAccounts),
66            [account] => Ok(account.id.clone()),
67            _ => Err(Error::MultipleAccounts),
68        }
69    }
70
71    async fn request<T: DeserializeOwned>(
72        &self,
73        method: reqwest::Method,
74        path: &str,
75        body: Option<Value>,
76    ) -> Result<T, Error> {
77        if self.api_key.is_empty() {
78            return Err(Error::MissingApiToken);
79        }
80
81        let url = format!("{}{}", self.base_url, path);
82        let mut request = self
83            .client
84            .request(method, url)
85            .header(AUTHORIZATION, format!("Bearer {}", self.api_key));
86
87        if let Some(body) = body {
88            request = request.header(CONTENT_TYPE, "application/json").json(&body);
89        }
90
91        let response = request.send().await?;
92        let status = response.status();
93        let payload = response.json::<CloudflareResponse<T>>().await?;
94
95        if !status.is_success() || !payload.success {
96            let api_error = payload.errors.first();
97
98            return Err(Error::Api {
99                status: status.as_u16(),
100                code: api_error.map(ApiError::code),
101                message: api_error
102                    .map(|error| error.message.clone())
103                    .unwrap_or_else(|| status.to_string()),
104                details: Value::Array(payload.errors.into_iter().map(Value::from).collect()),
105            });
106        }
107
108        payload.result.ok_or(Error::MissingResult)
109    }
110}
111
112pub struct Emails<'a> {
113    email: &'a Email,
114}
115
116impl Emails<'_> {
117    pub async fn send(&self, message: SendEmail) -> Result<SendEmailResponse, Error> {
118        self.send_with_options(message, SendOptions::default())
119            .await
120    }
121
122    pub async fn create(&self, message: SendEmail) -> Result<SendEmailResponse, Error> {
123        self.send(message).await
124    }
125
126    pub async fn send_with_options(
127        &self,
128        message: SendEmail,
129        options: SendOptions,
130    ) -> Result<SendEmailResponse, Error> {
131        message.validate()?;
132
133        let account_id = self.email.account_id().await?;
134        let path = format!("/accounts/{account_id}/email/sending/send");
135        let mut request = self
136            .email
137            .client
138            .post(format!("{}{}", self.email.base_url, path))
139            .header(AUTHORIZATION, format!("Bearer {}", self.email.api_key))
140            .header(CONTENT_TYPE, "application/json");
141
142        if let Some(idempotency_key) = options.idempotency_key {
143            request = request.header("Idempotency-Key", idempotency_key);
144        }
145
146        let response = request.json(&message).send().await?;
147        let status = response.status();
148        let payload = response
149            .json::<CloudflareResponse<CloudflareSendResult>>()
150            .await?;
151
152        if !status.is_success() || !payload.success {
153            let api_error = payload.errors.first();
154
155            return Err(Error::Api {
156                status: status.as_u16(),
157                code: api_error.map(ApiError::code),
158                message: api_error
159                    .map(|error| error.message.clone())
160                    .unwrap_or_else(|| status.to_string()),
161                details: Value::Array(payload.errors.into_iter().map(Value::from).collect()),
162            });
163        }
164
165        let result = payload.result.ok_or(Error::MissingResult)?;
166
167        Ok(SendEmailResponse {
168            id: result.id(),
169            delivered: result.delivered,
170            queued: result.queued,
171            permanent_bounces: result.permanent_bounces,
172        })
173    }
174}
175
176#[derive(Clone, Debug, Default)]
177pub struct SendOptions {
178    pub idempotency_key: Option<String>,
179}
180
181impl SendOptions {
182    pub fn new() -> Self {
183        Self::default()
184    }
185
186    pub fn idempotency_key(mut self, key: impl Into<String>) -> Self {
187        self.idempotency_key = Some(key.into());
188        self
189    }
190}
191
192#[derive(Clone, Debug, Serialize)]
193pub struct SendEmail {
194    pub from: Address,
195    pub to: Recipients,
196    pub subject: String,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub html: Option<String>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub text: Option<String>,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub cc: Option<Recipients>,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub bcc: Option<Recipients>,
205    #[serde(rename = "reply_to", skip_serializing_if = "Option::is_none")]
206    pub reply_to: Option<String>,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub headers: Option<HashMap<String, String>>,
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub attachments: Option<Vec<Attachment>>,
211}
212
213impl SendEmail {
214    pub fn new(
215        from: impl Into<Address>,
216        to: impl Into<Recipients>,
217        subject: impl Into<String>,
218    ) -> Self {
219        Self {
220            from: from.into(),
221            to: to.into(),
222            subject: subject.into(),
223            html: None,
224            text: None,
225            cc: None,
226            bcc: None,
227            reply_to: None,
228            headers: None,
229            attachments: None,
230        }
231    }
232
233    pub fn html(mut self, html: impl Into<String>) -> Self {
234        self.html = Some(html.into());
235        self
236    }
237
238    pub fn text(mut self, text: impl Into<String>) -> Self {
239        self.text = Some(text.into());
240        self
241    }
242
243    pub fn cc(mut self, cc: impl Into<Recipients>) -> Self {
244        self.cc = Some(cc.into());
245        self
246    }
247
248    pub fn bcc(mut self, bcc: impl Into<Recipients>) -> Self {
249        self.bcc = Some(bcc.into());
250        self
251    }
252
253    pub fn reply_to(mut self, reply_to: impl Into<String>) -> Self {
254        self.reply_to = Some(reply_to.into());
255        self
256    }
257
258    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
259        self.headers
260            .get_or_insert_with(HashMap::new)
261            .insert(name.into(), value.into());
262        self
263    }
264
265    pub fn attachment(mut self, attachment: Attachment) -> Self {
266        self.attachments
267            .get_or_insert_with(Vec::new)
268            .push(attachment);
269        self
270    }
271
272    fn validate(&self) -> Result<(), Error> {
273        if self.subject.is_empty() {
274            return Err(Error::Validation("subject is required".to_string()));
275        }
276
277        if self.html.is_none() && self.text.is_none() {
278            return Err(Error::Validation(
279                "Either html or text is required".to_string(),
280            ));
281        }
282
283        Ok(())
284    }
285}
286
287#[derive(Clone, Debug, Serialize)]
288#[serde(untagged)]
289pub enum Address {
290    Email(String),
291    Named { address: String, name: String },
292}
293
294impl Address {
295    pub fn email(email: impl Into<String>) -> Self {
296        Self::Email(email.into())
297    }
298
299    pub fn named(name: impl Into<String>, email: impl Into<String>) -> Self {
300        Self::Named {
301            name: name.into(),
302            address: email.into(),
303        }
304    }
305}
306
307impl From<&str> for Address {
308    fn from(value: &str) -> Self {
309        Self::Email(value.to_string())
310    }
311}
312
313impl From<String> for Address {
314    fn from(value: String) -> Self {
315        Self::Email(value)
316    }
317}
318
319#[derive(Clone, Debug, Serialize)]
320#[serde(untagged)]
321pub enum Recipients {
322    One(String),
323    Many(Vec<String>),
324}
325
326impl From<&str> for Recipients {
327    fn from(value: &str) -> Self {
328        Self::One(value.to_string())
329    }
330}
331
332impl From<String> for Recipients {
333    fn from(value: String) -> Self {
334        Self::One(value)
335    }
336}
337
338impl From<Vec<String>> for Recipients {
339    fn from(value: Vec<String>) -> Self {
340        Self::Many(value)
341    }
342}
343
344impl<const N: usize> From<[&str; N]> for Recipients {
345    fn from(value: [&str; N]) -> Self {
346        Self::Many(value.into_iter().map(str::to_string).collect())
347    }
348}
349
350#[derive(Clone, Debug, Serialize)]
351pub struct Attachment {
352    pub content: String,
353    pub filename: String,
354    #[serde(rename = "type")]
355    pub mime_type: String,
356    pub disposition: AttachmentDisposition,
357    #[serde(rename = "content_id", skip_serializing_if = "Option::is_none")]
358    pub content_id: Option<String>,
359}
360
361impl Attachment {
362    pub fn new(
363        content: impl Into<String>,
364        filename: impl Into<String>,
365        mime_type: impl Into<String>,
366    ) -> Self {
367        Self {
368            content: content.into(),
369            filename: filename.into(),
370            mime_type: mime_type.into(),
371            disposition: AttachmentDisposition::Attachment,
372            content_id: None,
373        }
374    }
375
376    pub fn inline(mut self, content_id: impl Into<String>) -> Self {
377        self.disposition = AttachmentDisposition::Inline;
378        self.content_id = Some(content_id.into());
379        self
380    }
381}
382
383#[derive(Clone, Debug, Serialize)]
384#[serde(rename_all = "lowercase")]
385pub enum AttachmentDisposition {
386    Attachment,
387    Inline,
388}
389
390#[derive(Clone, Debug, PartialEq, Eq)]
391pub struct SendEmailResponse {
392    pub id: String,
393    pub delivered: Vec<String>,
394    pub queued: Vec<String>,
395    pub permanent_bounces: Vec<String>,
396}
397
398#[derive(Debug, thiserror::Error)]
399pub enum Error {
400    #[error("Missing Cloudflare API token")]
401    MissingApiToken,
402    #[error("No Cloudflare accounts found for this API token")]
403    NoAccounts,
404    #[error("Multiple Cloudflare accounts found; pass account_id explicitly")]
405    MultipleAccounts,
406    #[error("Cloudflare API error ({status}): {message}")]
407    Api {
408        status: u16,
409        code: Option<String>,
410        message: String,
411        details: Value,
412    },
413    #[error("Cloudflare API response did not include a result")]
414    MissingResult,
415    #[error("Invalid email request: {0}")]
416    Validation(String),
417    #[error(transparent)]
418    Http(#[from] reqwest::Error),
419}
420
421#[derive(Debug, Deserialize)]
422struct Account {
423    id: String,
424}
425
426#[derive(Debug, Deserialize)]
427struct CloudflareResponse<T> {
428    success: bool,
429    errors: Vec<ApiError>,
430    #[allow(dead_code)]
431    messages: Vec<Value>,
432    result: Option<T>,
433}
434
435#[derive(Clone, Debug, Deserialize, Serialize)]
436struct ApiError {
437    code: Value,
438    message: String,
439}
440
441impl ApiError {
442    fn code(&self) -> String {
443        match &self.code {
444            Value::String(code) => code.clone(),
445            code => code.to_string(),
446        }
447    }
448}
449
450impl From<ApiError> for Value {
451    fn from(error: ApiError) -> Self {
452        serde_json::json!({
453            "code": error.code,
454            "message": error.message,
455        })
456    }
457}
458
459#[derive(Debug, Deserialize)]
460struct CloudflareSendResult {
461    #[serde(default, alias = "messageId", alias = "message_id")]
462    id: Option<String>,
463    #[serde(default)]
464    delivered: Vec<String>,
465    #[serde(default)]
466    queued: Vec<String>,
467    #[serde(default)]
468    permanent_bounces: Vec<String>,
469}
470
471impl CloudflareSendResult {
472    fn id(&self) -> String {
473        self.id.clone().unwrap_or_else(|| {
474            self.delivered
475                .iter()
476                .chain(self.queued.iter())
477                .cloned()
478                .collect::<Vec<_>>()
479                .join(",")
480        })
481    }
482}