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 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 TLS on the other hand, requires that the email communication must use TLS no
130        /// matter what. If the receiving server does not support TLS, the email will not be sent.
131        Enforced,
132        /// Opportunistic TLS means that it always attempts to make a secure connection to the
133        /// receiving mail server. If it can’t establish a secure connection, it sends the message
134        /// unencrypted.
135        Opportunistic,
136    }
137
138    /// Unique [`Domain`] identifier.
139    #[derive(Debug, Clone, Deserialize, Serialize)]
140    pub struct DomainId(EcoString);
141
142    impl DomainId {
143        /// Creates a new [`DomainId`].
144        #[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    /// Details of a new [`Domain`].
174    #[must_use]
175    #[derive(Debug, Clone, Serialize)]
176    pub struct CreateDomainOptions {
177        /// The name of the domain you want to create.
178        #[serde(rename = "name")]
179        name: String,
180        /// The region where the email will be sent from.
181        ///
182        /// Possible values are `'us-east-1' | 'eu-west-1' | 'sa-east-1'`.
183        #[serde(rename = "region", skip_serializing_if = "Option::is_none")]
184        region: Option<Region>,
185        /// For advanced use cases, choose a subdomain for the Return-Path address.
186        /// The custom return path is used for SPF authentication, DMARC alignment, and handling
187        /// bounced emails. Defaults to `send` (i.e., `send.yourdomain.tld`). Avoid setting values
188        /// that could undermine credibility (e.g. `testing`), as they may be exposed to recipients.
189        #[serde(skip_serializing_if = "Option::is_none")]
190        custom_return_path: Option<String>,
191    }
192
193    impl CreateDomainOptions {
194        /// Creates a new [`CreateDomainOptions`].
195        ///
196        /// - `name`: The name of the domain you want to create.
197        #[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        /// The region where the email will be sent from.
207        #[inline]
208        pub fn with_region(mut self, region: impl Into<Region>) -> Self {
209            self.region = Some(region.into());
210            self
211        }
212
213        /// For advanced use cases, choose a subdomain for the Return-Path address.
214        /// The custom return path is used for SPF authentication, DMARC alignment, and handling
215        /// bounced emails. Defaults to `send` (i.e., `send.yourdomain.tld`). Avoid setting values
216        /// that could undermine credibility (e.g. `testing`), as they may be exposed to recipients.
217        #[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    /// Region where [`CreateEmailBaseOptions`]s will be sent from.
225    ///
226    /// Possible values are 'us-east-1' | 'eu-west-1' | 'sa-east-1' | 'ap-northeast-1'.
227    ///
228    /// [`CreateEmailBaseOptions`]: crate::types::CreateEmailBaseOptions
229    #[non_exhaustive]
230    #[derive(Debug, Clone, Serialize, Deserialize)]
231    pub enum Region {
232        /// 'us-east-1'
233        #[serde(rename = "us-east-1")]
234        UsEast1,
235        /// 'eu-west-1'
236        #[serde(rename = "eu-west-1")]
237        EuWest1,
238        /// 'sa-east-1'
239        #[serde(rename = "sa-east-1")]
240        SaEast1,
241        /// 'ap-northeast-1'
242        #[serde(rename = "ap-northeast-1")]
243        ApNorthEast1,
244    }
245
246    #[derive(Debug, Clone, Deserialize)]
247    pub struct DomainSpfRecord {
248        /// The name of the record.
249        pub name: String,
250        /// The value of the record.
251        pub value: String,
252        /// The type of record.
253        #[serde(rename = "type")]
254        pub d_type: SpfRecordType,
255        /// The time to live for the record.
256        pub ttl: String,
257        /// The status of the record.
258        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        /// The name of the record.
268        pub name: String,
269        /// The value of the record.
270        pub value: String,
271        /// The type of record.
272        #[serde(rename = "type")]
273        pub d_type: DkimRecordType,
274        /// The time to live for the record.
275        pub ttl: String,
276        /// The status of the record.
277        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    /// Individual [`Domain`] record.
317    #[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    /// Details of an existing domain.
327    #[must_use]
328    #[derive(Debug, Clone, Deserialize)]
329    pub struct Domain {
330        /// The ID of the domain.
331        pub id: DomainId,
332        /// The name of the domain.
333        pub name: String,
334        // TODO: Technically both this and the domainrecord could be an enum https://resend.com/docs/api-reference/domains/get-domain#path-parameters
335        /// The status of the domain.
336        pub status: String,
337
338        /// The date and time the domain was created in ISO8601 format.
339        pub created_at: String,
340        /// The region where the domain is hosted.
341        pub region: Region,
342        /// The records of the domain.
343        pub records: Option<Vec<DomainRecord>>,
344    }
345
346    #[derive(Debug, Clone, Deserialize)]
347    pub struct VerifyDomainResponse {
348        /// The ID of the domain.
349        #[allow(dead_code)]
350        pub id: DomainId,
351    }
352
353    /// List of changes to apply to a [`Domain`].
354    #[must_use]
355    #[derive(Debug, Default, Copy, Clone, Serialize)]
356    pub struct DomainChanges {
357        /// Enable or disable click tracking for the domain.
358        #[serde(skip_serializing_if = "Option::is_none")]
359        click_tracking: Option<bool>,
360        /// Enable or disable open tracking for the domain.
361        #[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        /// Creates a new [`DomainChanges`].
369        #[inline]
370        pub fn new() -> Self {
371            Self::default()
372        }
373
374        /// Toggles the click tracking to `enable`.
375        #[inline]
376        pub const fn with_click_tracking(mut self, enable: bool) -> Self {
377            self.click_tracking = Some(enable);
378            self
379        }
380
381        /// Toggles the open tracing to `enable`.
382        #[inline]
383        pub const fn with_open_tracking(mut self, enable: bool) -> Self {
384            self.open_tracking = Some(enable);
385            self
386        }
387
388        /// Changes the TLS configuration.
389        #[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        /// The ID of the updated domain.
399        pub id: DomainId,
400    }
401
402    #[derive(Debug, Clone, Deserialize)]
403    pub struct DeleteDomainResponse {
404        /// The ID of the domain.
405        pub id: DomainId,
406        /// Indicates whether the domain was deleted successfully.
407        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    // <https://stackoverflow.com/a/77859502/12756474>
423    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        // Create
450        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        // List.
458        let list = resend.domains.list(ListOptions::default()).await?;
459        assert!(list.len() == 1);
460
461        // Get
462        let domain = resend.domains.get(&domain.id).await?;
463
464        // Update
465        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        // Delete
475        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        // List.
481        let list = resend.domains.list(ListOptions::default()).await?;
482        assert!(list.is_empty());
483
484        Ok(())
485    }
486}