1use std::fmt;
2use std::sync::Arc;
3
4use reqwest::Method;
5use types::DeleteDomainResponse;
6
7use crate::{Config, Result, domains::types::VerifyDomainResponse};
8use crate::{
9 list_opts::{ListOptions, ListResponse},
10 types::{CreateDomainClaimOptions, CreateDomainOptions, Domain, DomainChanges, DomainClaim},
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 pub async fn create(&self, domain: CreateDomainOptions) -> Result<Domain> {
25 let request = self.0.build(Method::POST, "/domains");
26 let response = self.0.send(request.json(&domain)).await?;
27 let content = response.json::<Domain>().await?;
28
29 Ok(content)
30 }
31
32 #[maybe_async::maybe_async]
36 pub async fn get(&self, domain_id: &str) -> Result<Domain> {
37 let path = format!("/domains/{domain_id}");
38
39 let request = self.0.build(Method::GET, &path);
40 let response = self.0.send(request).await?;
41 let content = response.json::<Domain>().await?;
42
43 Ok(content)
44 }
45
46 #[maybe_async::maybe_async]
50 pub async fn verify(&self, domain_id: &str) -> Result<VerifyDomainResponse> {
51 let path = format!("/domains/{domain_id}/verify");
52
53 let request = self.0.build(Method::POST, &path);
54 let response = self.0.send(request).await?;
55 let content = response.json::<VerifyDomainResponse>().await?;
56
57 Ok(content)
58 }
59
60 #[maybe_async::maybe_async]
64 pub async fn update(
65 &self,
66 domain_id: &str,
67 update: DomainChanges,
68 ) -> Result<UpdateDomainResponse> {
69 let path = format!("/domains/{domain_id}");
70
71 let request = self.0.build(Method::PATCH, &path);
72 let response = self.0.send(request.json(&update)).await?;
73 let content = response.json::<UpdateDomainResponse>().await?;
74
75 Ok(content)
76 }
77
78 #[maybe_async::maybe_async]
84 pub async fn list<T>(&self, list_opts: ListOptions<T>) -> Result<ListResponse<Domain>> {
85 let request = self.0.build(Method::GET, "/domains").query(&list_opts);
86 let response = self.0.send(request).await?;
87 let content = response.json::<ListResponse<Domain>>().await?;
88
89 Ok(content)
90 }
91
92 #[maybe_async::maybe_async]
98 pub async fn delete(&self, domain_id: &str) -> Result<DeleteDomainResponse> {
99 let path = format!("/domains/{domain_id}");
100
101 let request = self.0.build(Method::DELETE, &path);
102 let response = self.0.send(request).await?;
103 let content = response.json::<DeleteDomainResponse>().await?;
104
105 Ok(content)
106 }
107
108 #[maybe_async::maybe_async]
112 pub async fn claim(&self, domain_claim: CreateDomainClaimOptions) -> Result<DomainClaim> {
113 let request = self.0.build(Method::POST, "/domains/claim");
114 let response = self.0.send(request.json(&domain_claim)).await?;
115 let content = response.json::<DomainClaim>().await?;
116
117 Ok(content)
118 }
119
120 #[maybe_async::maybe_async]
124 pub async fn get_claim(&self, domain_id: &str) -> Result<DomainClaim> {
125 let path = format!("/domains/{domain_id}/claim");
126
127 let request = self.0.build(Method::GET, &path);
128 let response = self.0.send(request).await?;
129 let content = response.json::<DomainClaim>().await?;
130
131 Ok(content)
132 }
133
134 #[maybe_async::maybe_async]
138 pub async fn verify_claim(&self, domain_id: &str) -> Result<DomainClaim> {
139 let path = format!("/domains/{domain_id}/claim/verify");
140
141 let request = self.0.build(Method::POST, &path);
142 let response = self.0.send(request).await?;
143 let content = response.json::<DomainClaim>().await?;
144
145 Ok(content)
146 }
147}
148
149impl fmt::Debug for DomainsSvc {
150 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151 fmt::Debug::fmt(&self.0, f)
152 }
153}
154
155#[allow(unreachable_pub)]
156pub mod types {
157 use serde::{Deserialize, Serialize};
158 use serde_json::Value;
159
160 #[derive(Debug, Copy, Clone, Serialize)]
161 #[serde(rename_all = "lowercase")]
162 pub enum Tls {
163 Enforced,
166 Opportunistic,
170 }
171
172 crate::define_id_type!(DomainId);
173 crate::define_id_type!(DomainClaimId);
174
175 #[must_use]
177 #[derive(Debug, Clone, Serialize)]
178 pub struct CreateDomainOptions {
179 #[serde(rename = "name")]
181 name: String,
182 #[serde(rename = "region", skip_serializing_if = "Option::is_none")]
186 region: Option<Region>,
187 #[serde(skip_serializing_if = "Option::is_none")]
192 custom_return_path: Option<String>,
193
194 #[serde(skip_serializing_if = "Option::is_none")]
195 open_tracking: Option<bool>,
196 #[serde(skip_serializing_if = "Option::is_none")]
197 click_tracking: Option<bool>,
198 #[serde(skip_serializing_if = "Option::is_none")]
199 tracking_subdomain: Option<String>,
200 #[serde(skip_serializing_if = "Option::is_none")]
201 tls: Option<Tls>,
202 #[serde(skip_serializing_if = "Option::is_none")]
203 capabilities: Option<Value>,
204 }
205
206 impl CreateDomainOptions {
207 #[inline]
211 pub fn new(name: &str) -> Self {
212 Self {
213 name: name.to_owned(),
214 region: None,
215 custom_return_path: None,
216
217 open_tracking: None,
218 click_tracking: None,
219 tracking_subdomain: None,
220 tls: None,
221 capabilities: None,
222 }
223 }
224
225 #[inline]
227 pub fn with_region(mut self, region: impl Into<Region>) -> Self {
228 self.region = Some(region.into());
229 self
230 }
231
232 #[inline]
237 pub fn with_custom_return_path(mut self, custom_return_path: impl Into<String>) -> Self {
238 self.custom_return_path = Some(custom_return_path.into());
239 self
240 }
241
242 #[inline]
243 pub fn with_open_tracking(mut self, open_tracking: bool) -> Self {
244 self.open_tracking = Some(open_tracking);
245 self
246 }
247
248 #[inline]
249 pub fn with_click_tracking(mut self, click_tracking: bool) -> Self {
250 self.click_tracking = Some(click_tracking);
251 self
252 }
253
254 #[inline]
255 pub fn with_tracking_subdomain(mut self, tracking_subdomain: impl Into<String>) -> Self {
256 self.tracking_subdomain = Some(tracking_subdomain.into());
257 self
258 }
259
260 #[inline]
261 pub fn with_tls(mut self, tls: Tls) -> Self {
262 self.tls = Some(tls);
263 self
264 }
265
266 #[inline]
267 pub fn with_capabilities(mut self, capabilities: Value) -> Self {
268 self.capabilities = Some(capabilities);
269 self
270 }
271 }
272
273 #[non_exhaustive]
279 #[derive(Debug, Clone, Serialize, Deserialize)]
280 pub enum Region {
281 #[serde(rename = "us-east-1")]
283 UsEast1,
284 #[serde(rename = "eu-west-1")]
286 EuWest1,
287 #[serde(rename = "sa-east-1")]
289 SaEast1,
290 #[serde(rename = "ap-northeast-1")]
292 ApNorthEast1,
293 }
294
295 #[derive(Debug, Clone, Serialize, Deserialize)]
296 pub struct DomainSpfRecord {
297 pub name: String,
299 pub value: String,
301 #[serde(rename = "type")]
303 pub r#type: SpfRecordType,
304 pub ttl: String,
306 pub status: DomainRecordStatus,
308
309 pub routing_policy: Option<String>,
310 pub priority: Option<i32>,
311 pub proxy_status: Option<ProxyStatus>,
312 }
313
314 #[derive(Debug, Clone, Serialize, Deserialize)]
315 pub struct DomainDkimRecord {
316 pub name: String,
318 pub value: String,
320 #[serde(rename = "type")]
322 pub r#type: DkimRecordType,
323 pub ttl: String,
325 pub status: DomainRecordStatus,
327
328 pub routing_policy: Option<String>,
329 pub priority: Option<i32>,
330 pub proxy_status: Option<ProxyStatus>,
331 }
332
333 #[derive(Debug, Clone, Serialize, Deserialize)]
334 pub struct ReceivingRecord {
335 pub name: String,
337 pub value: String,
339 #[serde(rename = "type")]
341 pub r#type: ReceivingRecordType,
342 pub ttl: String,
344 pub status: DomainRecordStatus,
346
347 pub priority: i32,
348 }
349
350 #[derive(Debug, Clone, Serialize, Deserialize)]
351 pub struct TrackingRecord {
352 pub name: String,
354 pub value: String,
356 #[serde(rename = "type")]
358 pub r#type: TrackingRecordType,
359 pub ttl: String,
361 pub status: DomainRecordStatus,
363 }
364
365 #[derive(Debug, Clone, Serialize, Deserialize)]
366 pub struct TrackingCaaRecord {
367 pub name: String,
369 pub value: String,
371 #[serde(rename = "type")]
373 pub r#type: TrackingCaaRecordType,
374 pub ttl: String,
376 pub status: DomainRecordStatus,
378 }
379
380 #[derive(Debug, Copy, Clone, Serialize, Deserialize)]
381 pub enum ReceivingRecordType {
382 #[allow(clippy::upper_case_acronyms)]
383 MX,
384 }
385
386 #[derive(Debug, Copy, Clone, Serialize, Deserialize)]
387 pub enum TrackingRecordType {
388 #[allow(clippy::upper_case_acronyms)]
389 CNAME,
390 }
391
392 #[derive(Debug, Copy, Clone, Serialize, Deserialize)]
393 pub enum TrackingCaaRecordType {
394 #[allow(clippy::upper_case_acronyms)]
395 CAA,
396 }
397
398 #[derive(Debug, Copy, Clone, Serialize, Deserialize)]
399 pub enum ProxyStatus {
400 Enable,
401 Disable,
402 }
403
404 #[derive(Debug, Copy, Clone, Serialize, Deserialize)]
405 #[serde(rename_all = "snake_case")]
406 pub enum DomainStatus {
407 Pending,
408 Verified,
409 Failed,
410 NotStarted,
411 PartiallyVerified,
412 PartiallyFailed,
413 }
414
415 #[derive(Debug, Copy, Clone, Serialize, Deserialize)]
416 #[serde(rename_all = "snake_case")]
417 pub enum DomainRecordStatus {
418 Pending,
419 Verified,
420 Failed,
421 TemporaryFailure,
422 NotStarted,
423 }
424
425 #[derive(Debug, Copy, Clone, Serialize, Deserialize)]
426 pub enum SpfRecordType {
427 MX,
428 #[allow(clippy::upper_case_acronyms)]
429 TXT,
430 }
431
432 #[derive(Debug, Copy, Clone, Serialize, Deserialize)]
433 pub enum DkimRecordType {
434 #[allow(clippy::upper_case_acronyms)]
435 CNAME,
436 #[allow(clippy::upper_case_acronyms)]
437 TXT,
438 }
439
440 #[derive(Debug, Clone, Serialize, Deserialize)]
442 #[serde(tag = "record")]
443 pub enum DomainRecord {
444 #[serde(rename = "SPF")]
445 DomainSpfRecord(DomainSpfRecord),
446 #[serde(rename = "DKIM")]
447 DomainDkimRecord(DomainDkimRecord),
448 #[serde(rename = "Receiving MX")]
449 ReceivingRecord(ReceivingRecord),
450 #[serde(rename = "Tracking")]
451 TrackingRecord(TrackingRecord),
452 #[serde(rename = "TrackingCAA")]
453 TrackingCaaRecord(TrackingCaaRecord),
454 }
455
456 #[must_use]
458 #[derive(Debug, Clone, Serialize, Deserialize)]
459 pub struct Domain {
460 pub id: DomainId,
462 pub name: String,
464 pub status: DomainStatus,
466
467 pub created_at: String,
469 pub region: Region,
471 pub records: Option<Vec<DomainRecord>>,
473
474 pub capabilities: DomainCapabilities,
475
476 #[serde(skip_serializing_if = "Option::is_none")]
478 pub open_tracking: Option<bool>,
479 #[serde(skip_serializing_if = "Option::is_none")]
481 pub click_tracking: Option<bool>,
482 }
483
484 #[must_use]
485 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
486 pub struct DomainCapabilities {
487 pub sending: DomainCapabilityStatus,
488 pub receiving: DomainCapabilityStatus,
489 }
490
491 #[non_exhaustive]
492 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
493 #[serde(rename_all = "snake_case")]
494 pub enum DomainCapabilityStatus {
495 Enabled,
496 Disabled,
497 }
498
499 #[derive(Debug, Clone, Serialize, Deserialize)]
500 pub struct VerifyDomainResponse {
501 #[allow(dead_code)]
503 pub id: DomainId,
504 }
505
506 #[must_use]
508 #[derive(Debug, Default, Clone, Serialize)]
509 pub struct DomainChanges {
510 #[serde(skip_serializing_if = "Option::is_none")]
512 click_tracking: Option<bool>,
513 #[serde(skip_serializing_if = "Option::is_none")]
515 open_tracking: Option<bool>,
516 #[serde(skip_serializing_if = "Option::is_none")]
517 tls: Option<Tls>,
518 #[serde(skip_serializing_if = "Option::is_none")]
519 capabilities: Option<DomainCapabilities>,
520 #[serde(skip_serializing_if = "Option::is_none")]
521 tracking_subdomain: Option<String>,
522 }
523
524 impl DomainChanges {
525 #[inline]
527 pub fn new() -> Self {
528 Self::default()
529 }
530
531 #[inline]
533 pub const fn with_click_tracking(mut self, enable: bool) -> Self {
534 self.click_tracking = Some(enable);
535 self
536 }
537
538 #[inline]
540 pub const fn with_open_tracking(mut self, enable: bool) -> Self {
541 self.open_tracking = Some(enable);
542 self
543 }
544
545 #[inline]
547 pub const fn with_tls(mut self, tls: Tls) -> Self {
548 self.tls = Some(tls);
549 self
550 }
551
552 #[inline]
553 pub fn with_tracking_subdomain(mut self, tracking_subdomain: impl Into<String>) -> Self {
554 self.tracking_subdomain = Some(tracking_subdomain.into());
555 self
556 }
557 }
558
559 #[derive(Debug, Clone, Serialize, Deserialize)]
560 pub struct UpdateDomainResponse {
561 pub id: DomainId,
563 }
564
565 #[derive(Debug, Clone, Serialize, Deserialize)]
566 pub struct DeleteDomainResponse {
567 pub id: DomainId,
569 pub deleted: bool,
571 }
572
573 #[must_use]
575 #[derive(Debug, Clone, Serialize)]
576 pub struct CreateDomainClaimOptions {
577 #[serde(rename = "name")]
578 name: String,
579 #[serde(rename = "region", skip_serializing_if = "Option::is_none")]
580 region: Option<Region>,
581 #[serde(skip_serializing_if = "Option::is_none")]
582 custom_return_path: Option<String>,
583 #[serde(skip_serializing_if = "Option::is_none")]
584 open_tracking: Option<bool>,
585 #[serde(skip_serializing_if = "Option::is_none")]
586 click_tracking: Option<bool>,
587 #[serde(skip_serializing_if = "Option::is_none")]
588 tracking_subdomain: Option<String>,
589 }
590
591 impl CreateDomainClaimOptions {
592 pub fn new(name: &str) -> Self {
593 Self {
594 name: name.to_owned(),
595 region: None,
596 custom_return_path: None,
597 open_tracking: None,
598 click_tracking: None,
599 tracking_subdomain: None,
600 }
601 }
602
603 #[inline]
605 pub fn with_region(mut self, region: impl Into<Region>) -> Self {
606 self.region = Some(region.into());
607 self
608 }
609
610 #[inline]
615 pub fn with_custom_return_path(mut self, custom_return_path: impl Into<String>) -> Self {
616 self.custom_return_path = Some(custom_return_path.into());
617 self
618 }
619
620 #[inline]
621 pub fn with_open_tracking(mut self, open_tracking: bool) -> Self {
622 self.open_tracking = Some(open_tracking);
623 self
624 }
625
626 #[inline]
627 pub fn with_click_tracking(mut self, click_tracking: bool) -> Self {
628 self.click_tracking = Some(click_tracking);
629 self
630 }
631
632 #[inline]
633 pub fn with_tracking_subdomain(mut self, tracking_subdomain: impl Into<String>) -> Self {
634 self.tracking_subdomain = Some(tracking_subdomain.into());
635 self
636 }
637 }
638
639 #[derive(Debug, Copy, Clone, Serialize, Deserialize)]
640 #[serde(rename_all = "snake_case")]
641 pub enum DomainClaimStatus {
642 Pending,
643 Verified,
644 Completed,
645 Blocked,
646 Expired,
647 Superseded,
648 Canceled,
649 Failed,
650 }
651
652 #[derive(Debug, Copy, Clone, Serialize, Deserialize)]
653 #[serde(rename_all = "snake_case")]
654 pub enum DomainClaimBlockedReason {
655 GracePeriod,
656 RecentOwnerActivity,
657 PendingScheduledEmails,
658 }
659
660 #[derive(Debug, Copy, Clone, Serialize, Deserialize)]
661 #[serde(rename_all = "snake_case")]
662 pub enum DomainClaimRecordType {
663 #[allow(clippy::upper_case_acronyms)]
664 TXT,
665 }
666
667 #[must_use]
668 #[derive(Debug, Clone, Serialize, Deserialize)]
669 pub struct DomainClaimRecord {
670 #[serde(rename = "type")]
671 pub r#type: DomainClaimRecordType,
672 pub name: String,
673 pub value: String,
674 pub ttl: String,
675 }
676
677 #[must_use]
678 #[derive(Debug, Clone, Serialize, Deserialize)]
679 pub struct DomainClaim {
680 pub id: DomainClaimId,
681 pub name: String,
682 pub status: DomainClaimStatus,
683 pub domain_id: Option<String>,
684 pub region: Option<Region>,
685 pub record: DomainClaimRecord,
686 pub blocked_reason: Option<DomainClaimBlockedReason>,
687 pub failure_reason: Option<String>,
688 pub created_at: String,
689 pub expires_at: String,
690 }
691}
692
693#[cfg(test)]
694#[allow(clippy::needless_return)]
695mod test {
696 #[cfg(not(feature = "blocking"))]
697 use crate::{
698 domains::types::DeleteDomainResponse,
699 domains::types::{CreateDomainOptions, DomainChanges, Tls},
700 list_opts::ListOptions,
701 test::{CLIENT, DebugResult, retry},
702 };
703
704 #[tokio_shared_rt::test(shared = true)]
705 #[serial_test::serial]
706 #[cfg(not(feature = "blocking"))]
707 async fn all() -> DebugResult<()> {
708 let resend = &*CLIENT;
709
710 let domain = resend
712 .domains
713 .create(CreateDomainOptions::new("resend-rust.com"))
714 .await?;
715
716 std::thread::sleep(std::time::Duration::from_secs(4));
717
718 let list = resend.domains.list(ListOptions::default()).await?;
720 assert_eq!(list.len(), 1);
721
722 let domain = resend.domains.get(&domain.id).await?;
724
725 let updates = DomainChanges::new()
727 .with_open_tracking(false)
728 .with_click_tracking(true)
729 .with_tls(Tls::Enforced);
730
731 std::thread::sleep(std::time::Duration::from_secs(4));
732 let f = async || resend.domains.update(&domain.id, updates.clone()).await;
733 let domain = retry(f, 5, std::time::Duration::from_secs(2)).await?;
734 std::thread::sleep(std::time::Duration::from_secs(4));
735
736 let f = async || resend.domains.delete(&domain.id).await;
738 let resp: DeleteDomainResponse = retry(f, 5, std::time::Duration::from_secs(2)).await?;
739
740 assert!(resp.deleted);
741
742 let list = resend.domains.list(ListOptions::default()).await?;
744 assert!(list.is_empty());
745
746 Ok(())
747 }
748
749 #[test]
750 #[allow(clippy::indexing_slicing)]
751 fn deserialize_domain_with_tracking_caa_record() {
752 use crate::domains::types::{Domain, DomainRecord};
753
754 let json = r#"{
755 "object": "domain",
756 "id": "7c2a439f-d5fc-4dc1-8bab-ced17f14c972",
757 "name": "namingishard.dev",
758 "created_at": "2026-04-14 11:16:24.808219+00",
759 "status": "verified",
760 "capabilities": { "sending": "enabled", "receiving": "disabled" },
761 "records": [
762 {
763 "record": "Tracking",
764 "type": "CNAME",
765 "name": "links",
766 "value": "links1.resend-dns-staging.com",
767 "ttl": "Auto",
768 "status": "verified"
769 },
770 {
771 "record": "TrackingCAA",
772 "name": "",
773 "type": "CAA",
774 "ttl": "Auto",
775 "value": "0 issue \"amazon.com\"",
776 "status": "verified"
777 }
778 ],
779 "region": "eu-west-1"
780 }"#;
781
782 let domain: Domain = serde_json::from_str(json).expect("domain deserializes");
783 let records = domain.records.expect("records present");
784 assert_eq!(records.len(), 2);
785 assert!(matches!(records[0], DomainRecord::TrackingRecord(_)));
786 assert!(matches!(records[1], DomainRecord::TrackingCaaRecord(_)));
787 }
788
789 #[test]
790 fn deserialize_domain_with_tracking_fields() {
791 use crate::domains::types::Domain;
792
793 let json = r#"{
794 "object": "domain",
795 "id": "fd61172c-cafc-40f5-b049-b45947779a29",
796 "name": "resend.com",
797 "status": "verified",
798 "created_at": "2023-06-21 06:10:36.144+00",
799 "region": "us-east-1",
800 "capabilities": { "sending": "enabled", "receiving": "disabled" },
801 "open_tracking": true,
802 "click_tracking": false
803 }"#;
804
805 let domain: Domain = serde_json::from_str(json).expect("domain deserializes");
806 assert_eq!(domain.open_tracking, Some(true));
807 assert_eq!(domain.click_tracking, Some(false));
808 }
809
810 #[test]
811 fn deserialize_partially_verified_domain() {
812 use crate::domains::types::{Domain, DomainStatus};
813
814 let json = r#"{
815 "object": "domain",
816 "id": "fd61172c-cafc-40f5-b049-b45947779a29",
817 "name": "resend.com",
818 "status": "partially_verified",
819 "created_at": "2023-06-21 06:10:36.144+00",
820 "region": "us-east-1",
821 "capabilities": { "sending": "enabled", "receiving": "disabled" }
822 }"#;
823
824 let domain: Domain = serde_json::from_str(json).expect("domain deserializes");
825 assert!(matches!(domain.status, DomainStatus::PartiallyVerified));
826 }
827
828 #[test]
829 fn deserialize_partially_failed_domain() {
830 use crate::domains::types::{Domain, DomainStatus};
831
832 let json = r#"{
833 "object": "domain",
834 "id": "fd61172c-cafc-40f5-b049-b45947779a29",
835 "name": "resend.com",
836 "status": "partially_failed",
837 "created_at": "2023-06-21 06:10:36.144+00",
838 "region": "us-east-1",
839 "capabilities": { "sending": "enabled", "receiving": "enabled" }
840 }"#;
841
842 let domain: Domain = serde_json::from_str(json).expect("domain deserializes");
843 assert!(matches!(domain.status, DomainStatus::PartiallyFailed));
844 }
845}