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}