1use std::fmt;
2use std::sync::Arc;
3
4use reqwest::Method;
5use types::DeleteDomainResponse;
6
7use crate::{Config, Result};
8use crate::{
9 list_opts::{ListOptions, ListResponse},
10 types::{CreateDomainOptions, Domain, DomainChanges},
11};
12
13use self::types::UpdateDomainResponse;
14
15#[derive(Clone)]
17pub struct DomainsSvc(pub(crate) Arc<Config>);
18
19impl DomainsSvc {
20 #[maybe_async::maybe_async]
24 #[allow(clippy::needless_pass_by_value)]
26 pub async fn add(&self, domain: CreateDomainOptions) -> Result<Domain> {
27 let request = self.0.build(Method::POST, "/domains");
28 let response = self.0.send(request.json(&domain)).await?;
29 let content = response.json::<Domain>().await?;
30
31 Ok(content)
32 }
33
34 #[maybe_async::maybe_async]
38 pub async fn get(&self, domain_id: &str) -> Result<Domain> {
39 let path = format!("/domains/{domain_id}");
40
41 let request = self.0.build(Method::GET, &path);
42 let response = self.0.send(request).await?;
43 let content = response.json::<Domain>().await?;
44
45 Ok(content)
46 }
47
48 #[maybe_async::maybe_async]
52 pub async fn verify(&self, domain_id: &str) -> Result<()> {
53 let path = format!("/domains/{domain_id}/verify");
54
55 let request = self.0.build(Method::POST, &path);
56 let response = self.0.send(request).await?;
57 let _content = response.json::<types::VerifyDomainResponse>().await?;
58
59 Ok(())
60 }
61
62 #[maybe_async::maybe_async]
66 pub async fn update(
67 &self,
68 domain_id: &str,
69 update: DomainChanges,
70 ) -> Result<UpdateDomainResponse> {
71 let path = format!("/domains/{domain_id}");
72
73 let request = self.0.build(Method::PATCH, &path);
74 let response = self.0.send(request.json(&update)).await?;
75 let content = response.json::<UpdateDomainResponse>().await?;
76
77 Ok(content)
78 }
79
80 #[maybe_async::maybe_async]
86 #[allow(clippy::needless_pass_by_value)]
87 pub async fn list<T>(&self, list_opts: ListOptions<T>) -> Result<ListResponse<Domain>> {
88 let request = self.0.build(Method::GET, "/domains").query(&list_opts);
89 let response = self.0.send(request).await?;
90 let content = response.json::<ListResponse<Domain>>().await?;
91
92 Ok(content)
93 }
94
95 #[maybe_async::maybe_async]
101 #[allow(clippy::needless_pass_by_value)]
102 pub async fn delete(&self, domain_id: &str) -> Result<DeleteDomainResponse> {
103 let path = format!("/domains/{domain_id}");
104
105 let request = self.0.build(Method::DELETE, &path);
106 let response = self.0.send(request).await?;
107 let content = response.json::<DeleteDomainResponse>().await?;
108
109 Ok(content)
110 }
111}
112
113impl fmt::Debug for DomainsSvc {
114 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115 fmt::Debug::fmt(&self.0, f)
116 }
117}
118
119#[allow(unreachable_pub)]
120pub mod types {
121 use std::{fmt, ops::Deref};
122
123 use ecow::EcoString;
124 use serde::{Deserialize, Serialize};
125
126 #[derive(Debug, Copy, Clone, Serialize)]
127 #[serde(rename_all = "lowercase")]
128 pub enum Tls {
129 Enforced,
132 Opportunistic,
136 }
137
138 #[derive(Debug, Clone, Deserialize, Serialize)]
140 pub struct DomainId(EcoString);
141
142 impl DomainId {
143 #[inline]
145 #[must_use]
146 pub fn new(id: &str) -> Self {
147 Self(EcoString::from(id))
148 }
149 }
150
151 impl Deref for DomainId {
152 type Target = str;
153
154 #[inline]
155 fn deref(&self) -> &Self::Target {
156 self.as_ref()
157 }
158 }
159
160 impl AsRef<str> for DomainId {
161 #[inline]
162 fn as_ref(&self) -> &str {
163 self.0.as_str()
164 }
165 }
166
167 impl fmt::Display for DomainId {
168 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169 fmt::Display::fmt(&self.0, f)
170 }
171 }
172
173 #[must_use]
175 #[derive(Debug, Clone, Serialize)]
176 pub struct CreateDomainOptions {
177 #[serde(rename = "name")]
179 name: String,
180 #[serde(rename = "region", skip_serializing_if = "Option::is_none")]
184 region: Option<Region>,
185 #[serde(skip_serializing_if = "Option::is_none")]
190 custom_return_path: Option<String>,
191 }
192
193 impl CreateDomainOptions {
194 #[inline]
198 pub fn new(name: &str) -> Self {
199 Self {
200 name: name.to_owned(),
201 region: None,
202 custom_return_path: None,
203 }
204 }
205
206 #[inline]
208 pub fn with_region(mut self, region: impl Into<Region>) -> Self {
209 self.region = Some(region.into());
210 self
211 }
212
213 #[inline]
218 pub fn with_custom_return_path(mut self, custom_return_path: impl Into<String>) -> Self {
219 self.custom_return_path = Some(custom_return_path.into());
220 self
221 }
222 }
223
224 #[non_exhaustive]
230 #[derive(Debug, Clone, Serialize, Deserialize)]
231 pub enum Region {
232 #[serde(rename = "us-east-1")]
234 UsEast1,
235 #[serde(rename = "eu-west-1")]
237 EuWest1,
238 #[serde(rename = "sa-east-1")]
240 SaEast1,
241 #[serde(rename = "ap-northeast-1")]
243 ApNorthEast1,
244 }
245
246 #[derive(Debug, Clone, Deserialize)]
247 pub struct DomainSpfRecord {
248 pub name: String,
250 pub value: String,
252 #[serde(rename = "type")]
254 pub d_type: SpfRecordType,
255 pub ttl: String,
257 pub status: DomainStatus,
259
260 pub routing_policy: Option<String>,
261 pub priority: Option<i32>,
262 pub proxy_status: Option<ProxyStatus>,
263 }
264
265 #[derive(Debug, Clone, Deserialize)]
266 pub struct DomainDkimRecord {
267 pub name: String,
269 pub value: String,
271 #[serde(rename = "type")]
273 pub d_type: DkimRecordType,
274 pub ttl: String,
276 pub status: DomainStatus,
278
279 pub routing_policy: Option<String>,
280 pub priority: Option<i32>,
281 pub proxy_status: Option<ProxyStatus>,
282 }
283
284 #[derive(Debug, Copy, Clone, Deserialize)]
285 pub enum ProxyStatus {
286 Enable,
287 Disable,
288 }
289
290 #[derive(Debug, Copy, Clone, Deserialize)]
291 pub enum DomainStatus {
292 Pending,
293 Verified,
294 Failed,
295 #[serde(rename = "temporary_failure")]
296 TemporaryFailure,
297 #[serde(rename = "not_started")]
298 NotStarted,
299 }
300
301 #[derive(Debug, Copy, Clone, Deserialize)]
302 pub enum SpfRecordType {
303 MX,
304 #[allow(clippy::upper_case_acronyms)]
305 TXT,
306 }
307
308 #[derive(Debug, Copy, Clone, Deserialize)]
309 pub enum DkimRecordType {
310 #[allow(clippy::upper_case_acronyms)]
311 CNAME,
312 #[allow(clippy::upper_case_acronyms)]
313 TXT,
314 }
315
316 #[derive(Debug, Clone, Deserialize)]
318 #[serde(tag = "record")]
319 pub enum DomainRecord {
320 #[serde(rename = "SPF")]
321 DomainSpfRecord(DomainSpfRecord),
322 #[serde(rename = "DKIM")]
323 DomainDkimRecord(DomainDkimRecord),
324 }
325
326 #[must_use]
328 #[derive(Debug, Clone, Deserialize)]
329 pub struct Domain {
330 pub id: DomainId,
332 pub name: String,
334 pub status: String,
337
338 pub created_at: String,
340 pub region: Region,
342 pub records: Option<Vec<DomainRecord>>,
344 }
345
346 #[derive(Debug, Clone, Deserialize)]
347 pub struct VerifyDomainResponse {
348 #[allow(dead_code)]
350 pub id: DomainId,
351 }
352
353 #[must_use]
355 #[derive(Debug, Default, Copy, Clone, Serialize)]
356 pub struct DomainChanges {
357 #[serde(skip_serializing_if = "Option::is_none")]
359 click_tracking: Option<bool>,
360 #[serde(skip_serializing_if = "Option::is_none")]
362 open_tracking: Option<bool>,
363 #[serde(skip_serializing_if = "Option::is_none")]
364 tls: Option<Tls>,
365 }
366
367 impl DomainChanges {
368 #[inline]
370 pub fn new() -> Self {
371 Self::default()
372 }
373
374 #[inline]
376 pub const fn with_click_tracking(mut self, enable: bool) -> Self {
377 self.click_tracking = Some(enable);
378 self
379 }
380
381 #[inline]
383 pub const fn with_open_tracking(mut self, enable: bool) -> Self {
384 self.open_tracking = Some(enable);
385 self
386 }
387
388 #[inline]
390 pub const fn with_tls(mut self, tls: Tls) -> Self {
391 self.tls = Some(tls);
392 self
393 }
394 }
395
396 #[derive(Debug, Clone, Deserialize)]
397 pub struct UpdateDomainResponse {
398 pub id: DomainId,
400 }
401
402 #[derive(Debug, Clone, Deserialize)]
403 pub struct DeleteDomainResponse {
404 pub id: DomainId,
406 pub deleted: bool,
408 }
409}
410
411#[cfg(test)]
412#[allow(clippy::needless_return)]
413mod test {
414 use crate::domains::types::DeleteDomainResponse;
415 use crate::list_opts::ListOptions;
416 use crate::{
417 domains::types::{CreateDomainOptions, DomainChanges, Tls},
418 test::DebugResult,
419 tests::CLIENT,
420 };
421
422 async fn retry<O, E, F>(mut f: F, retries: i32, interval: std::time::Duration) -> Result<O, E>
424 where
425 F: AsyncFnMut() -> Result<O, E>,
426 {
427 let mut count = 0;
428 loop {
429 match f().await {
430 Ok(output) => break Ok(output),
431 Err(e) => {
432 println!("try {count} failed");
433 count += 1;
434 if count == retries {
435 return Err(e);
436 }
437 tokio::time::sleep(interval).await;
438 }
439 }
440 }
441 }
442
443 #[tokio_shared_rt::test(shared = true)]
444 #[cfg(not(feature = "blocking"))]
445 #[ignore = "Flaky backend"]
446 async fn all() -> DebugResult<()> {
447 let resend = &*CLIENT;
448
449 let domain = resend
451 .domains
452 .add(CreateDomainOptions::new("example.com"))
453 .await?;
454
455 std::thread::sleep(std::time::Duration::from_secs(4));
456
457 let list = resend.domains.list(ListOptions::default()).await?;
459 assert!(list.len() == 1);
460
461 let domain = resend.domains.get(&domain.id).await?;
463
464 let updates = DomainChanges::new()
466 .with_open_tracking(false)
467 .with_click_tracking(true)
468 .with_tls(Tls::Enforced);
469
470 std::thread::sleep(std::time::Duration::from_secs(4));
471 let domain = resend.domains.update(&domain.id, updates).await?;
472 std::thread::sleep(std::time::Duration::from_secs(4));
473
474 let f = async || resend.domains.delete(&domain.id).await;
476 let resp: DeleteDomainResponse = retry(f, 5, std::time::Duration::from_secs(2)).await?;
477
478 assert!(resp.deleted);
479
480 let list = resend.domains.list(ListOptions::default()).await?;
482 assert!(list.is_empty());
483
484 Ok(())
485 }
486}