resend_rs/
domains.rs

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/// `Resend` APIs for `/domains` endpoints.
16#[derive(Clone)]
17pub struct DomainsSvc(pub(crate) Arc<Config>);
18
19impl DomainsSvc {
20    /// Creates a domain through the Resend Email API.
21    ///
22    /// <https://resend.com/docs/api-reference/domains/create-domain>
23    #[maybe_async::maybe_async]
24    // Reasoning for allow: https://github.com/resend/resend-rust/pull/1#issuecomment-2081646115
25    #[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    /// Retrieves a single domain for the authenticated user.
35    ///
36    /// <https://resend.com/docs/api-reference/domains/get-domain>
37    #[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    /// Verifies an existing domain.
49    ///
50    /// <https://resend.com/docs/api-reference/domains/verify-domain>
51    #[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    /// Updates an existing domain.
63    ///
64    /// <https://resend.com/docs/api-reference/domains/update-domain>
65    #[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    /// Retrieves a list of domains for the authenticated user.
81    ///
82    /// - Default limit: no limit (return everything)
83    ///
84    /// <https://resend.com/docs/api-reference/domains/list-domains>
85    #[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    /// Removes an existing domain.
96    ///
97    /// Returns whether the domain was deleted successfully.
98    ///
99    /// <https://resend.com/docs/api-reference/domains/delete-domain>
100    #[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 serde::{Deserialize, Serialize};
122
123    #[derive(Debug, Copy, Clone, Serialize)]
124    #[serde(rename_all = "lowercase")]
125    pub enum Tls {
126        /// Enforced TLS on the other hand, requires that the email communication must use TLS no
127        /// matter what. If the receiving server does not support TLS, the email will not be sent.
128        Enforced,
129        /// Opportunistic TLS means that it always attempts to make a secure connection to the
130        /// receiving mail server. If it can’t establish a secure connection, it sends the message
131        /// unencrypted.
132        Opportunistic,
133    }
134
135    crate::define_id_type!(DomainId);
136
137    /// Details of a new [`Domain`].
138    #[must_use]
139    #[derive(Debug, Clone, Serialize)]
140    pub struct CreateDomainOptions {
141        /// The name of the domain you want to create.
142        #[serde(rename = "name")]
143        name: String,
144        /// The region where the email will be sent from.
145        ///
146        /// Possible values are `'us-east-1' | 'eu-west-1' | 'sa-east-1'`.
147        #[serde(rename = "region", skip_serializing_if = "Option::is_none")]
148        region: Option<Region>,
149        /// For advanced use cases, choose a subdomain for the Return-Path address.
150        /// The custom return path is used for SPF authentication, DMARC alignment, and handling
151        /// bounced emails. Defaults to `send` (i.e., `send.yourdomain.tld`). Avoid setting values
152        /// that could undermine credibility (e.g. `testing`), as they may be exposed to recipients.
153        #[serde(skip_serializing_if = "Option::is_none")]
154        custom_return_path: Option<String>,
155    }
156
157    impl CreateDomainOptions {
158        /// Creates a new [`CreateDomainOptions`].
159        ///
160        /// - `name`: The name of the domain you want to create.
161        #[inline]
162        pub fn new(name: &str) -> Self {
163            Self {
164                name: name.to_owned(),
165                region: None,
166                custom_return_path: None,
167            }
168        }
169
170        /// The region where the email will be sent from.
171        #[inline]
172        pub fn with_region(mut self, region: impl Into<Region>) -> Self {
173            self.region = Some(region.into());
174            self
175        }
176
177        /// For advanced use cases, choose a subdomain for the Return-Path address.
178        /// The custom return path is used for SPF authentication, DMARC alignment, and handling
179        /// bounced emails. Defaults to `send` (i.e., `send.yourdomain.tld`). Avoid setting values
180        /// that could undermine credibility (e.g. `testing`), as they may be exposed to recipients.
181        #[inline]
182        pub fn with_custom_return_path(mut self, custom_return_path: impl Into<String>) -> Self {
183            self.custom_return_path = Some(custom_return_path.into());
184            self
185        }
186    }
187
188    /// Region where [`CreateEmailBaseOptions`]s will be sent from.
189    ///
190    /// Possible values are 'us-east-1' | 'eu-west-1' | 'sa-east-1' | 'ap-northeast-1'.
191    ///
192    /// [`CreateEmailBaseOptions`]: crate::types::CreateEmailBaseOptions
193    #[non_exhaustive]
194    #[derive(Debug, Clone, Serialize, Deserialize)]
195    pub enum Region {
196        /// 'us-east-1'
197        #[serde(rename = "us-east-1")]
198        UsEast1,
199        /// 'eu-west-1'
200        #[serde(rename = "eu-west-1")]
201        EuWest1,
202        /// 'sa-east-1'
203        #[serde(rename = "sa-east-1")]
204        SaEast1,
205        /// 'ap-northeast-1'
206        #[serde(rename = "ap-northeast-1")]
207        ApNorthEast1,
208    }
209
210    #[derive(Debug, Clone, Deserialize)]
211    pub struct DomainSpfRecord {
212        /// The name of the record.
213        pub name: String,
214        /// The value of the record.
215        pub value: String,
216        /// The type of record.
217        #[serde(rename = "type")]
218        pub d_type: SpfRecordType,
219        /// The time to live for the record.
220        pub ttl: String,
221        /// The status of the record.
222        pub status: DomainStatus,
223
224        pub routing_policy: Option<String>,
225        pub priority: Option<i32>,
226        pub proxy_status: Option<ProxyStatus>,
227    }
228
229    #[derive(Debug, Clone, Deserialize)]
230    pub struct DomainDkimRecord {
231        /// The name of the record.
232        pub name: String,
233        /// The value of the record.
234        pub value: String,
235        /// The type of record.
236        #[serde(rename = "type")]
237        pub d_type: DkimRecordType,
238        /// The time to live for the record.
239        pub ttl: String,
240        /// The status of the record.
241        pub status: DomainStatus,
242
243        pub routing_policy: Option<String>,
244        pub priority: Option<i32>,
245        pub proxy_status: Option<ProxyStatus>,
246    }
247
248    #[derive(Debug, Clone, Deserialize)]
249    pub struct ReceivingRecord {
250        /// The name of the record.
251        pub name: String,
252        /// The value of the record.
253        pub value: String,
254        /// The type of record.
255        #[serde(rename = "type")]
256        pub d_type: ReceivingRecordType,
257        /// The time to live for the record.
258        pub ttl: String,
259        /// The status of the record.
260        pub status: DomainStatus,
261
262        pub priority: i32,
263    }
264
265    #[derive(Debug, Copy, Clone, Deserialize)]
266    pub enum ReceivingRecordType {
267        #[allow(clippy::upper_case_acronyms)]
268        MX,
269    }
270
271    #[derive(Debug, Copy, Clone, Deserialize)]
272    pub enum ProxyStatus {
273        Enable,
274        Disable,
275    }
276
277    #[derive(Debug, Copy, Clone, Deserialize)]
278    pub enum DomainStatus {
279        Pending,
280        Verified,
281        Failed,
282        #[serde(rename = "temporary_failure")]
283        TemporaryFailure,
284        #[serde(rename = "not_started")]
285        NotStarted,
286    }
287
288    #[derive(Debug, Copy, Clone, Deserialize)]
289    pub enum SpfRecordType {
290        MX,
291        #[allow(clippy::upper_case_acronyms)]
292        TXT,
293    }
294
295    #[derive(Debug, Copy, Clone, Deserialize)]
296    pub enum DkimRecordType {
297        #[allow(clippy::upper_case_acronyms)]
298        CNAME,
299        #[allow(clippy::upper_case_acronyms)]
300        TXT,
301    }
302
303    /// Individual [`Domain`] record.
304    #[derive(Debug, Clone, Deserialize)]
305    #[serde(tag = "record")]
306    pub enum DomainRecord {
307        #[serde(rename = "SPF")]
308        DomainSpfRecord(DomainSpfRecord),
309        #[serde(rename = "DKIM")]
310        DomainDkimRecord(DomainDkimRecord),
311        #[serde(rename = "Receiving")]
312        ReceivingRecord(ReceivingRecord),
313    }
314
315    /// Details of an existing domain.
316    #[must_use]
317    #[derive(Debug, Clone, Deserialize)]
318    pub struct Domain {
319        /// The ID of the domain.
320        pub id: DomainId,
321        /// The name of the domain.
322        pub name: String,
323        // TODO: Technically both this and the domainrecord could be an enum https://resend.com/docs/api-reference/domains/get-domain#path-parameters
324        /// The status of the domain.
325        pub status: String,
326
327        /// The date and time the domain was created in ISO8601 format.
328        pub created_at: String,
329        /// The region where the domain is hosted.
330        pub region: Region,
331        /// The records of the domain.
332        pub records: Option<Vec<DomainRecord>>,
333    }
334
335    #[derive(Debug, Clone, Deserialize)]
336    pub struct VerifyDomainResponse {
337        /// The ID of the domain.
338        #[allow(dead_code)]
339        pub id: DomainId,
340    }
341
342    /// List of changes to apply to a [`Domain`].
343    #[must_use]
344    #[derive(Debug, Default, Copy, Clone, Serialize)]
345    pub struct DomainChanges {
346        /// Enable or disable click tracking for the domain.
347        #[serde(skip_serializing_if = "Option::is_none")]
348        click_tracking: Option<bool>,
349        /// Enable or disable open tracking for the domain.
350        #[serde(skip_serializing_if = "Option::is_none")]
351        open_tracking: Option<bool>,
352        #[serde(skip_serializing_if = "Option::is_none")]
353        tls: Option<Tls>,
354    }
355
356    impl DomainChanges {
357        /// Creates a new [`DomainChanges`].
358        #[inline]
359        pub fn new() -> Self {
360            Self::default()
361        }
362
363        /// Toggles the click tracking to `enable`.
364        #[inline]
365        pub const fn with_click_tracking(mut self, enable: bool) -> Self {
366            self.click_tracking = Some(enable);
367            self
368        }
369
370        /// Toggles the open tracing to `enable`.
371        #[inline]
372        pub const fn with_open_tracking(mut self, enable: bool) -> Self {
373            self.open_tracking = Some(enable);
374            self
375        }
376
377        /// Changes the TLS configuration.
378        #[inline]
379        pub const fn with_tls(mut self, tls: Tls) -> Self {
380            self.tls = Some(tls);
381            self
382        }
383    }
384
385    #[derive(Debug, Clone, Deserialize)]
386    pub struct UpdateDomainResponse {
387        /// The ID of the updated domain.
388        pub id: DomainId,
389    }
390
391    #[derive(Debug, Clone, Deserialize)]
392    pub struct DeleteDomainResponse {
393        /// The ID of the domain.
394        pub id: DomainId,
395        /// Indicates whether the domain was deleted successfully.
396        pub deleted: bool,
397    }
398}
399
400#[cfg(test)]
401#[allow(clippy::needless_return)]
402mod test {
403    use crate::domains::types::DeleteDomainResponse;
404    use crate::list_opts::ListOptions;
405    use crate::{
406        domains::types::{CreateDomainOptions, DomainChanges, Tls},
407        test::{CLIENT, DebugResult, retry},
408    };
409
410    #[tokio_shared_rt::test(shared = true)]
411    #[cfg(not(feature = "blocking"))]
412    #[ignore = "Flaky backend"]
413    async fn all() -> DebugResult<()> {
414        let resend = &*CLIENT;
415
416        // Create
417        let domain = resend
418            .domains
419            .add(CreateDomainOptions::new("example.com"))
420            .await?;
421
422        std::thread::sleep(std::time::Duration::from_secs(4));
423
424        // List.
425        let list = resend.domains.list(ListOptions::default()).await?;
426        assert!(list.len() == 1);
427
428        // Get
429        let domain = resend.domains.get(&domain.id).await?;
430
431        // Update
432        let updates = DomainChanges::new()
433            .with_open_tracking(false)
434            .with_click_tracking(true)
435            .with_tls(Tls::Enforced);
436
437        std::thread::sleep(std::time::Duration::from_secs(4));
438        let f = async || resend.domains.update(&domain.id, updates).await;
439        let domain = retry(f, 5, std::time::Duration::from_secs(2)).await?;
440        std::thread::sleep(std::time::Duration::from_secs(4));
441
442        // Delete
443        let f = async || resend.domains.delete(&domain.id).await;
444        let resp: DeleteDomainResponse = retry(f, 5, std::time::Duration::from_secs(2)).await?;
445
446        assert!(resp.deleted);
447
448        // List.
449        let list = resend.domains.list(ListOptions::default()).await?;
450        assert!(list.is_empty());
451
452        Ok(())
453    }
454}