use std::fmt;
use std::sync::Arc;
use reqwest::Method;
use types::DeleteDomainResponse;
use crate::{Config, Result, domains::types::VerifyDomainResponse};
use crate::{
list_opts::{ListOptions, ListResponse},
types::{CreateDomainOptions, Domain, DomainChanges},
};
use self::types::UpdateDomainResponse;
#[derive(Clone)]
pub struct DomainsSvc(pub(crate) Arc<Config>);
impl DomainsSvc {
#[maybe_async::maybe_async]
#[allow(clippy::needless_pass_by_value)]
pub async fn create(&self, domain: CreateDomainOptions) -> Result<Domain> {
let request = self.0.build(Method::POST, "/domains");
let response = self.0.send(request.json(&domain)).await?;
let content = response.json::<Domain>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
pub async fn get(&self, domain_id: &str) -> Result<Domain> {
let path = format!("/domains/{domain_id}");
let request = self.0.build(Method::GET, &path);
let response = self.0.send(request).await?;
let content = response.json::<Domain>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
pub async fn verify(&self, domain_id: &str) -> Result<VerifyDomainResponse> {
let path = format!("/domains/{domain_id}/verify");
let request = self.0.build(Method::POST, &path);
let response = self.0.send(request).await?;
let content = response.json::<VerifyDomainResponse>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
#[allow(clippy::needless_pass_by_value)]
pub async fn update(
&self,
domain_id: &str,
update: DomainChanges,
) -> Result<UpdateDomainResponse> {
let path = format!("/domains/{domain_id}");
let request = self.0.build(Method::PATCH, &path);
let response = self.0.send(request.json(&update)).await?;
let content = response.json::<UpdateDomainResponse>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
#[allow(clippy::needless_pass_by_value)]
pub async fn list<T>(&self, list_opts: ListOptions<T>) -> Result<ListResponse<Domain>> {
let request = self.0.build(Method::GET, "/domains").query(&list_opts);
let response = self.0.send(request).await?;
let content = response.json::<ListResponse<Domain>>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
#[allow(clippy::needless_pass_by_value)]
pub async fn delete(&self, domain_id: &str) -> Result<DeleteDomainResponse> {
let path = format!("/domains/{domain_id}");
let request = self.0.build(Method::DELETE, &path);
let response = self.0.send(request).await?;
let content = response.json::<DeleteDomainResponse>().await?;
Ok(content)
}
}
impl fmt::Debug for DomainsSvc {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&self.0, f)
}
}
#[allow(unreachable_pub)]
pub mod types {
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Copy, Clone, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Tls {
Enforced,
Opportunistic,
}
crate::define_id_type!(DomainId);
#[must_use]
#[derive(Debug, Clone, Serialize)]
pub struct CreateDomainOptions {
#[serde(rename = "name")]
name: String,
#[serde(rename = "region", skip_serializing_if = "Option::is_none")]
region: Option<Region>,
#[serde(skip_serializing_if = "Option::is_none")]
custom_return_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
open_tracking: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
click_tracking: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
tracking_subdomain: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tls: Option<Tls>,
#[serde(skip_serializing_if = "Option::is_none")]
capabilities: Option<Value>,
}
impl CreateDomainOptions {
#[inline]
pub fn new(name: &str) -> Self {
Self {
name: name.to_owned(),
region: None,
custom_return_path: None,
open_tracking: None,
click_tracking: None,
tracking_subdomain: None,
tls: None,
capabilities: None,
}
}
#[inline]
pub fn with_region(mut self, region: impl Into<Region>) -> Self {
self.region = Some(region.into());
self
}
#[inline]
pub fn with_custom_return_path(mut self, custom_return_path: impl Into<String>) -> Self {
self.custom_return_path = Some(custom_return_path.into());
self
}
#[inline]
pub fn with_open_tracking(mut self, open_tracking: bool) -> Self {
self.open_tracking = Some(open_tracking);
self
}
#[inline]
pub fn with_click_tracking(mut self, click_tracking: bool) -> Self {
self.click_tracking = Some(click_tracking);
self
}
#[inline]
pub fn with_tracking_subdomain(mut self, tracking_subdomain: impl Into<String>) -> Self {
self.tracking_subdomain = Some(tracking_subdomain.into());
self
}
#[inline]
pub fn with_tls(mut self, tls: Tls) -> Self {
self.tls = Some(tls);
self
}
#[inline]
pub fn with_capabilities(mut self, capabilities: Value) -> Self {
self.capabilities = Some(capabilities);
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Region {
#[serde(rename = "us-east-1")]
UsEast1,
#[serde(rename = "eu-west-1")]
EuWest1,
#[serde(rename = "sa-east-1")]
SaEast1,
#[serde(rename = "ap-northeast-1")]
ApNorthEast1,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DomainSpfRecord {
pub name: String,
pub value: String,
#[serde(rename = "type")]
pub r#type: SpfRecordType,
pub ttl: String,
pub status: DomainRecordStatus,
pub routing_policy: Option<String>,
pub priority: Option<i32>,
pub proxy_status: Option<ProxyStatus>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DomainDkimRecord {
pub name: String,
pub value: String,
#[serde(rename = "type")]
pub r#type: DkimRecordType,
pub ttl: String,
pub status: DomainRecordStatus,
pub routing_policy: Option<String>,
pub priority: Option<i32>,
pub proxy_status: Option<ProxyStatus>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReceivingRecord {
pub name: String,
pub value: String,
#[serde(rename = "type")]
pub r#type: ReceivingRecordType,
pub ttl: String,
pub status: DomainRecordStatus,
pub priority: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrackingRecord {
pub name: String,
pub value: String,
#[serde(rename = "type")]
pub r#type: TrackingRecordType,
pub ttl: String,
pub status: DomainRecordStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrackingCaaRecord {
pub name: String,
pub value: String,
#[serde(rename = "type")]
pub r#type: TrackingCaaRecordType,
pub ttl: String,
pub status: DomainRecordStatus,
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
pub enum ReceivingRecordType {
#[allow(clippy::upper_case_acronyms)]
MX,
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
pub enum TrackingRecordType {
#[allow(clippy::upper_case_acronyms)]
CNAME,
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
pub enum TrackingCaaRecordType {
#[allow(clippy::upper_case_acronyms)]
CAA,
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
pub enum ProxyStatus {
Enable,
Disable,
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DomainStatus {
Pending,
Verified,
Failed,
NotStarted,
PartiallyVerified,
PartiallyFailed,
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DomainRecordStatus {
Pending,
Verified,
Failed,
TemporaryFailure,
NotStarted,
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
pub enum SpfRecordType {
MX,
#[allow(clippy::upper_case_acronyms)]
TXT,
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
pub enum DkimRecordType {
#[allow(clippy::upper_case_acronyms)]
CNAME,
#[allow(clippy::upper_case_acronyms)]
TXT,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "record")]
pub enum DomainRecord {
#[serde(rename = "SPF")]
DomainSpfRecord(DomainSpfRecord),
#[serde(rename = "DKIM")]
DomainDkimRecord(DomainDkimRecord),
#[serde(rename = "Receiving MX")]
ReceivingRecord(ReceivingRecord),
#[serde(rename = "Tracking")]
TrackingRecord(TrackingRecord),
#[serde(rename = "TrackingCAA")]
TrackingCaaRecord(TrackingCaaRecord),
}
#[must_use]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Domain {
pub id: DomainId,
pub name: String,
pub status: DomainStatus,
pub created_at: String,
pub region: Region,
pub records: Option<Vec<DomainRecord>>,
pub capabilities: DomainCapabilities,
}
#[must_use]
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct DomainCapabilities {
pub sending: DomainCapabilityStatus,
pub receiving: DomainCapabilityStatus,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DomainCapabilityStatus {
Enabled,
Disabled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifyDomainResponse {
#[allow(dead_code)]
pub id: DomainId,
}
#[must_use]
#[derive(Debug, Default, Clone, Serialize)]
pub struct DomainChanges {
#[serde(skip_serializing_if = "Option::is_none")]
click_tracking: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
open_tracking: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
tls: Option<Tls>,
#[serde(skip_serializing_if = "Option::is_none")]
capabilities: Option<DomainCapabilities>,
#[serde(skip_serializing_if = "Option::is_none")]
tracking_subdomain: Option<String>,
}
impl DomainChanges {
#[inline]
pub fn new() -> Self {
Self::default()
}
#[inline]
pub const fn with_click_tracking(mut self, enable: bool) -> Self {
self.click_tracking = Some(enable);
self
}
#[inline]
pub const fn with_open_tracking(mut self, enable: bool) -> Self {
self.open_tracking = Some(enable);
self
}
#[inline]
pub const fn with_tls(mut self, tls: Tls) -> Self {
self.tls = Some(tls);
self
}
#[inline]
pub fn with_tracking_subdomain(mut self, tracking_subdomain: impl Into<String>) -> Self {
self.tracking_subdomain = Some(tracking_subdomain.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateDomainResponse {
pub id: DomainId,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteDomainResponse {
pub id: DomainId,
pub deleted: bool,
}
}
#[cfg(test)]
#[allow(clippy::needless_return)]
mod test {
use crate::domains::types::DeleteDomainResponse;
use crate::list_opts::ListOptions;
use crate::{
domains::types::{CreateDomainOptions, DomainChanges, Tls},
test::{CLIENT, DebugResult, retry},
};
#[tokio_shared_rt::test(shared = true)]
#[cfg(not(feature = "blocking"))]
async fn all() -> DebugResult<()> {
let resend = &*CLIENT;
let domain = resend
.domains
.create(CreateDomainOptions::new("example.com"))
.await?;
std::thread::sleep(std::time::Duration::from_secs(4));
let list = resend.domains.list(ListOptions::default()).await?;
assert!(list.len() == 1);
let domain = resend.domains.get(&domain.id).await?;
let updates = DomainChanges::new()
.with_open_tracking(false)
.with_click_tracking(true)
.with_tls(Tls::Enforced);
std::thread::sleep(std::time::Duration::from_secs(4));
let f = async || resend.domains.update(&domain.id, updates.clone()).await;
let domain = retry(f, 5, std::time::Duration::from_secs(2)).await?;
std::thread::sleep(std::time::Duration::from_secs(4));
let f = async || resend.domains.delete(&domain.id).await;
let resp: DeleteDomainResponse = retry(f, 5, std::time::Duration::from_secs(2)).await?;
assert!(resp.deleted);
let list = resend.domains.list(ListOptions::default()).await?;
assert!(list.is_empty());
Ok(())
}
#[test]
#[allow(clippy::indexing_slicing)]
fn deserialize_domain_with_tracking_caa_record() {
use crate::domains::types::{Domain, DomainRecord};
let json = r#"{
"object": "domain",
"id": "7c2a439f-d5fc-4dc1-8bab-ced17f14c972",
"name": "namingishard.dev",
"created_at": "2026-04-14 11:16:24.808219+00",
"status": "verified",
"capabilities": { "sending": "enabled", "receiving": "disabled" },
"records": [
{
"record": "Tracking",
"type": "CNAME",
"name": "links",
"value": "links1.resend-dns-staging.com",
"ttl": "Auto",
"status": "verified"
},
{
"record": "TrackingCAA",
"name": "",
"type": "CAA",
"ttl": "Auto",
"value": "0 issue \"amazon.com\"",
"status": "verified"
}
],
"region": "eu-west-1"
}"#;
let domain: Domain = serde_json::from_str(json).expect("domain deserializes");
let records = domain.records.expect("records present");
assert_eq!(records.len(), 2);
assert!(matches!(records[0], DomainRecord::TrackingRecord(_)));
assert!(matches!(records[1], DomainRecord::TrackingCaaRecord(_)));
}
#[test]
fn deserialize_partially_verified_domain() {
use crate::domains::types::{Domain, DomainStatus};
let json = r#"{
"object": "domain",
"id": "fd61172c-cafc-40f5-b049-b45947779a29",
"name": "resend.com",
"status": "partially_verified",
"created_at": "2023-06-21T06:10:36.144Z",
"region": "us-east-1",
"capabilities": { "sending": "enabled", "receiving": "disabled" }
}"#;
let domain: Domain = serde_json::from_str(json).expect("domain deserializes");
assert!(matches!(domain.status, DomainStatus::PartiallyVerified));
}
#[test]
fn deserialize_partially_failed_domain() {
use crate::domains::types::{Domain, DomainStatus};
let json = r#"{
"object": "domain",
"id": "fd61172c-cafc-40f5-b049-b45947779a29",
"name": "resend.com",
"status": "partially_failed",
"created_at": "2023-06-21T06:10:36.144Z",
"region": "us-east-1",
"capabilities": { "sending": "enabled", "receiving": "enabled" }
}"#;
let domain: Domain = serde_json::from_str(json).expect("domain deserializes");
assert!(matches!(domain.status, DomainStatus::PartiallyFailed));
}
}