diff --git a/CHANGELOG.md b/CHANGELOG.md index e021dd2..a20f11e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to ### Added - add `message_id` to `Email` and webhook `EmailBody` types (https://github.com/resend/resend-rust/pull/77) +- `suppressions` endpoints ## [0.28.0] - 2026-07-05 diff --git a/Cargo.toml b/Cargo.toml index ac14459..ac6b611 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ rand = "0.10.2" getrandom = { version = "0.4.3", features = ["wasm_js"] } serde_json = "1.0.150" mailparse = "0.16.1" +urlencoding = "2.1.3" [dev-dependencies] jiff = { version = "0.2.31", features = ["serde"] } diff --git a/src/client.rs b/src/client.rs index 4a9382b..595cab0 100644 --- a/src/client.rs +++ b/src/client.rs @@ -12,7 +12,7 @@ use crate::{ events::EventsSvc, logs::LogsSvc, oauth::OAuthSvc, - services::{AutomationsSvc, ReceivingSvc}, + services::{AutomationsSvc, ReceivingSvc, SuppressionsSvc}, webhooks::WebhookSvc, }; use crate::{ @@ -59,6 +59,8 @@ pub struct Resend { pub events: EventsSvc, /// `Resend` APIs for `/oauth` endpoints. pub oauth: OAuthSvc, + /// `Resend` APIs for `/suppressions` endpoints. + pub suppressions: SuppressionsSvc, } impl Resend { @@ -114,7 +116,8 @@ impl Resend { logs: LogsSvc(Arc::clone(&inner)), automations: AutomationsSvc(Arc::clone(&inner)), events: EventsSvc(Arc::clone(&inner)), - oauth: OAuthSvc(inner), + oauth: OAuthSvc(Arc::clone(&inner)), + suppressions: SuppressionsSvc(inner), } } diff --git a/src/contacts.rs b/src/contacts.rs index 84605b9..9edf8ab 100644 --- a/src/contacts.rs +++ b/src/contacts.rs @@ -55,6 +55,7 @@ impl ContactsSvc { /// #[maybe_async::maybe_async] pub async fn get(&self, contact_id_or_email: &str) -> Result { + let contact_id_or_email = urlencoding::encode(contact_id_or_email); let path = format!("/contacts/{contact_id_or_email}"); let request = self.0.build(Method::GET, &path); @@ -75,6 +76,7 @@ impl ContactsSvc { contact_id_or_email: &str, update: ContactChanges, ) -> Result { + let contact_id_or_email = urlencoding::encode(contact_id_or_email); let path = format!("/contacts/{contact_id_or_email}"); let request = self.0.build(Method::PATCH, &path); @@ -89,6 +91,7 @@ impl ContactsSvc { /// #[maybe_async::maybe_async] pub async fn delete(&self, contact_id_or_email: &str) -> Result { + let contact_id_or_email = urlencoding::encode(contact_id_or_email); let path = format!("/contacts/{contact_id_or_email}"); let request = self.0.build(Method::DELETE, &path); @@ -129,6 +132,7 @@ impl ContactsSvc { contact_id_or_email: &str, list_opts: ListOptions, ) -> Result> { + let contact_id_or_email = urlencoding::encode(contact_id_or_email); let path = format!("/contacts/{contact_id_or_email}/topics"); let request = self.0.build(Method::GET, &path).query(&list_opts); @@ -147,6 +151,7 @@ impl ContactsSvc { contact_id_or_email: &str, topics: impl Into>, ) -> Result { + let contact_id_or_email = urlencoding::encode(contact_id_or_email); let path = format!("/contacts/{contact_id_or_email}/topics"); let request = self.0.build(Method::PATCH, &path); @@ -165,6 +170,7 @@ impl ContactsSvc { contact_id_or_email: &str, segment_id: &str, ) -> Result { + let contact_id_or_email = urlencoding::encode(contact_id_or_email); let path = format!("/contacts/{contact_id_or_email}/segments/{segment_id}"); let request = self.0.build(Method::POST, &path); @@ -183,6 +189,7 @@ impl ContactsSvc { contact_id_or_email: &str, segment_id: &str, ) -> Result { + let contact_id_or_email = urlencoding::encode(contact_id_or_email); let path = format!("/contacts/{contact_id_or_email}/segments/{segment_id}"); let request = self.0.build(Method::DELETE, &path); @@ -202,6 +209,7 @@ impl ContactsSvc { contact_id_or_email: &str, list_opts: ListOptions, ) -> Result> { + let contact_id_or_email = urlencoding::encode(contact_id_or_email); let path = format!("/contacts/{contact_id_or_email}/segments/"); let request = self.0.build(Method::GET, &path).query(&list_opts); diff --git a/src/lib.rs b/src/lib.rs index ba652c6..a93eb51 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -72,6 +72,7 @@ mod oauth; pub mod rate_limit; mod receiving; mod segments; +mod suppressions; mod templates; mod topics; mod webhooks; @@ -90,6 +91,7 @@ pub mod services { pub use super::oauth::OAuthSvc; pub use super::receiving::ReceivingSvc; pub use super::segments::SegmentsSvc; + pub use super::suppressions::SuppressionsSvc; pub use super::templates::TemplateSvc; pub use super::topics::TopicsSvc; } @@ -157,6 +159,12 @@ pub mod types { InboundEmailHtmlFormat, InboundEmailId, }; pub use super::segments::types::{CreateSegmentResponse, Segment, SegmentId}; + pub use super::suppressions::types::{ + AddSuppressionOptions, AddSuppressionResponse, BatchAddSuppressionOptions, + BatchAddSuppressionResponse, BatchRemoveSuppressionOptions, + BatchRemoveSuppressionsResponse, EmailsSpecified, IdsSpecified, NotSpecified, + RemoveSuppressionResponse, Suppression, SuppressionId, SuppressionOrigin, + }; pub use super::templates::types::{ CreateTemplateOptions, CreateTemplateResponse, DeleteTemplateResponse, DuplicateTemplateResponse, PublishTemplateResponse, Template, TemplateEvent, TemplateId, diff --git a/src/suppressions.rs b/src/suppressions.rs new file mode 100644 index 0000000..9b46a7f --- /dev/null +++ b/src/suppressions.rs @@ -0,0 +1,452 @@ +use std::sync::Arc; + +use reqwest::Method; + +use crate::{ + Config, Result, + list_opts::{ListOptions, ListResponse}, + suppressions::types::SpecifiedMarker, + types::{ + AddSuppressionOptions, AddSuppressionResponse, BatchAddSuppressionOptions, + BatchAddSuppressionResponse, BatchRemoveSuppressionOptions, + BatchRemoveSuppressionsResponse, RemoveSuppressionResponse, Suppression, + }, +}; + +/// `Resend` APIs for `/suppressions` endpoints. +#[derive(Clone, Debug)] +pub struct SuppressionsSvc(pub(crate) Arc); + +impl SuppressionsSvc { + /// Add an email address to the suppression list. + /// + /// + #[maybe_async::maybe_async] + #[allow(clippy::needless_pass_by_value)] + pub async fn add(&self, opts: AddSuppressionOptions) -> Result { + let request = self.0.build(Method::POST, "/suppressions"); + let response = self.0.send(request.json(&opts)).await?; + let content = response.json::().await?; + + Ok(content) + } + + /// Retrieve a single suppression by ID or email. + /// + /// + #[maybe_async::maybe_async] + pub async fn get(&self, id_or_email: &str) -> Result { + let id_or_email = urlencoding::encode(id_or_email); + let path = format!("/suppressions/{id_or_email}"); + + let request = self.0.build(Method::GET, &path); + let response = self.0.send(request).await?; + let content = response.json::().await?; + + Ok(content) + } + + /// Show all suppressions. + /// + /// - Default limit: 20 + /// + /// + #[maybe_async::maybe_async] + #[allow(clippy::needless_pass_by_value)] + pub async fn list(&self, list_opts: ListOptions) -> Result> { + let request = self.0.build(Method::GET, "/suppressions").query(&list_opts); + let response = self.0.send(request).await?; + let content = response.json::>().await?; + + Ok(content) + } + + /// Remove a single suppression by ID or email. + /// + /// + #[maybe_async::maybe_async] + pub async fn remove(&self, id_or_email: &str) -> Result { + let id_or_email = urlencoding::encode(id_or_email); + let path = format!("/suppressions/{id_or_email}"); + + let request = self.0.build(Method::DELETE, &path); + let response = self.0.send(request).await?; + let content = response.json::().await?; + + Ok(content) + } + + /// Add up to 100 email addresses to the suppression list at once. + /// + /// + #[maybe_async::maybe_async] + #[allow(clippy::needless_pass_by_value)] + pub async fn batch_add( + &self, + opts: BatchAddSuppressionOptions, + ) -> Result { + let request = self.0.build(Method::POST, "/suppressions/batch/add"); + let response = self.0.send(request.json(&opts)).await?; + let content = response.json::().await?; + + Ok(content) + } + + /// Remove up to 100 suppressions from the suppression list at once. + /// + /// This endpoint requires that you have specified either ids or emails but not both, + /// hence you need [`BatchRemoveSuppressionOptions`] or + /// [`BatchRemoveSuppressionOptions`]. + /// + /// You can create those by doing: + /// + /// ```rust + /// # use resend_rs::types::BatchRemoveSuppressionOptions; + /// let _tmp = BatchRemoveSuppressionOptions::new().add_emails(vec!["emails"]); + /// let _tmp = BatchRemoveSuppressionOptions::new().add_ids(vec!["ids"]); + /// ``` + /// + /// + #[maybe_async::maybe_async] + #[allow(clippy::needless_pass_by_value, private_bounds)] + pub async fn batch_remove( + &self, + ids_or_emails: BatchRemoveSuppressionOptions, + ) -> Result { + let request = self.0.build(Method::POST, "/suppressions/batch/remove"); + let response = self.0.send(request.json(&ids_or_emails)).await?; + let content = response.json::().await?; + + Ok(content) + } +} + +#[allow(unreachable_pub)] +pub mod types { + use std::borrow::ToOwned; + + use serde::{Deserialize, Serialize}; + + use crate::types::EmailId; + + crate::define_id_type!(SuppressionId); + + #[must_use] + #[derive(Debug, Default, Clone, Serialize)] + pub struct AddSuppressionOptions { + email: String, + } + + impl AddSuppressionOptions { + #[inline] + pub fn new() -> Self { + Self::default() + } + + #[inline] + pub fn with_email(mut self, email: &str) -> Self { + email.clone_into(&mut self.email); + self + } + } + + #[must_use] + #[derive(Debug, Clone, Deserialize)] + pub struct AddSuppressionResponse { + pub id: SuppressionId, + } + + #[must_use] + #[derive(Debug, Clone, Deserialize)] + pub struct Suppression { + pub id: SuppressionId, + pub email: String, + pub created_at: String, + pub origin: SuppressionOrigin, + pub source_id: Option, + } + + #[must_use] + #[derive(Debug, Clone, Copy, Deserialize)] + #[serde(rename_all = "snake_case")] + pub enum SuppressionOrigin { + Bounce, + Complaint, + Manual, + } + + #[must_use] + #[derive(Debug, Clone, Deserialize)] + pub struct RemoveSuppressionResponse { + pub id: SuppressionId, + pub deleted: bool, + } + + #[must_use] + #[derive(Debug, Default, Clone, Serialize)] + pub struct BatchAddSuppressionOptions { + emails: Vec, + } + + impl BatchAddSuppressionOptions { + #[inline] + pub fn new() -> Self { + Self::default() + } + + #[inline] + pub fn add_email(mut self, email: &str) -> Self { + self.emails.push(email.to_owned()); + self + } + + #[inline] + pub fn add_emails(mut self, emails: impl IntoIterator>) -> Self { + self.emails.extend(emails.into_iter().map(Into::into)); + self + } + } + + impl From> for BatchAddSuppressionOptions { + fn from(value: Vec) -> Self { + Self::new().add_emails(value) + } + } + impl From> for BatchAddSuppressionOptions { + fn from(value: Vec<&str>) -> Self { + Self::new().add_emails(value.into_iter().map(ToOwned::to_owned)) + } + } + + #[must_use] + #[derive(Debug, Clone, Deserialize)] + pub struct BatchAddSuppressionResponse { + pub data: Vec, + } + + #[allow(clippy::redundant_pub_crate)] + pub(crate) trait SpecifiedMarker {} + impl SpecifiedMarker for EmailsSpecified {} + impl SpecifiedMarker for IdsSpecified {} + + #[derive(Debug, Clone, Copy, Default)] + pub struct NotSpecified {} + + #[derive(Debug, Clone, Copy)] + pub struct EmailsSpecified {} + + #[derive(Debug, Clone, Copy)] + pub struct IdsSpecified {} + + #[must_use] + #[derive(Debug, Default, Clone, Serialize)] + pub struct BatchRemoveSuppressionOptions { + #[serde(skip)] + marker: std::marker::PhantomData, + + #[serde(skip_serializing_if = "Vec::is_empty")] + emails: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + ids: Vec, + } + + impl BatchRemoveSuppressionOptions { + #[inline] + pub fn new() -> Self { + Self { + marker: std::marker::PhantomData::, + emails: vec![], + ids: vec![], + } + } + } + + impl BatchRemoveSuppressionOptions { + #[inline] + pub fn add_emails( + self, + emails: impl IntoIterator>, + ) -> BatchRemoveSuppressionOptions { + BatchRemoveSuppressionOptions:: { + marker: std::marker::PhantomData, + emails: emails.into_iter().map(Into::into).collect(), + ids: self.ids, + } + } + + #[inline] + pub fn add_ids( + self, + ids: impl IntoIterator>, + ) -> BatchRemoveSuppressionOptions { + BatchRemoveSuppressionOptions:: { + marker: std::marker::PhantomData, + emails: self.emails, + ids: ids.into_iter().map(Into::into).collect(), + } + } + } + + #[must_use] + #[derive(Debug, Clone, Deserialize)] + pub struct BatchRemoveSuppressionsResponse { + pub data: Vec, + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod test { + use crate::{ + list_opts::ListResponse, + test::{CLIENT, DebugResult}, + types::{ + AddSuppressionResponse, BatchAddSuppressionResponse, BatchRemoveSuppressionsResponse, + RemoveSuppressionResponse, Suppression, + }, + }; + + #[test] + fn deserialize() { + let add_suppression = r#"{ + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3" + }"#; + let get_suppression = r#"{ + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + "email": "steve.wozniak@example.com", + "origin": "bounce", + "source_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c", + "created_at": "2026-10-06T23:47:56.678Z" + }"#; + let list_suppressions = r#"{ + "object": "list", + "has_more": false, + "data": [ + { + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + "email": "steve.wozniak@example.com", + "origin": "manual", + "source_id": null, + "created_at": "2026-10-06T23:47:56.678Z" + }, + { + "object": "suppression", + "id": "520784e2-887d-4c25-b53c-4ad46ad38100", + "email": "susan.kare@example.com", + "origin": "bounce", + "source_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c", + "created_at": "2026-10-07T08:12:03.412Z" + } + ] + }"#; + let remove_suppression = r#"{ + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + "deleted": true + }"#; + let add_suppressions = r#"{ + "data": [ + { + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3" + }, + { + "object": "suppression", + "id": "520784e2-887d-4c25-b53c-4ad46ad38100" + } + ] + }"#; + let remove_suppressions = r#"{ + "data": [ + { + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + "deleted": true + } + ] + }"#; + + let res = serde_json::from_str::(add_suppression); + assert!(res.is_ok()); + let res = serde_json::from_str::(get_suppression); + assert!(res.is_ok()); + let res = serde_json::from_str::>(list_suppressions); + assert!(res.is_ok()); + let res = serde_json::from_str::(remove_suppression); + assert!(res.is_ok()); + let res = serde_json::from_str::(add_suppressions); + assert!(res.is_ok()); + let res = serde_json::from_str::(remove_suppressions); + assert!(res.is_ok()); + } + + #[tokio_shared_rt::test(shared = true)] + #[cfg(not(feature = "blocking"))] + async fn all() -> DebugResult<()> { + use crate::{ + list_opts::ListOptions, + types::{ + AddSuppressionOptions, BatchAddSuppressionOptions, BatchRemoveSuppressionOptions, + }, + }; + + let resend = &*CLIENT; + std::thread::sleep(std::time::Duration::from_secs(1)); + + // Add + let opts = AddSuppressionOptions::new().with_email("steve.wozniak@example.com"); + let suppression = resend.suppressions.add(opts).await?; + std::thread::sleep(std::time::Duration::from_secs(2)); + + // Get + let suppression = resend.suppressions.get(&suppression.id).await?; + + // List + let suppressions = resend.suppressions.list(ListOptions::default()).await?; + assert_eq!(suppressions.len(), 1); + + // Remove + let removed = resend.suppressions.remove(&suppression.id).await?; + assert!(removed.deleted); + + std::thread::sleep(std::time::Duration::from_secs(2)); + + let suppressions = resend.suppressions.list(ListOptions::default()).await?; + assert_eq!(suppressions.len(), 0); + + // Batch add + let opts = vec!["steve.wozniak@example.com", "susan.kare@example.com"]; + let batch_add = resend + .suppressions + .batch_add(BatchAddSuppressionOptions::from(opts)) + .await?; + assert_eq!(batch_add.data.len(), 2); + + std::thread::sleep(std::time::Duration::from_secs(2)); + + let suppressions = resend.suppressions.list(ListOptions::default()).await?; + assert_eq!(suppressions.len(), 2); + + // Batch remove + let opts = batch_add + .data + .into_iter() + .map(|el| el.id.to_string()) + .collect::>(); + let batch_remove = resend + .suppressions + .batch_remove(BatchRemoveSuppressionOptions::new().add_ids(opts)) + .await?; + assert_eq!(batch_remove.data.len(), 2); + + std::thread::sleep(std::time::Duration::from_secs(2)); + + let suppressions = resend.suppressions.list(ListOptions::default()).await?; + assert_eq!(suppressions.len(), 0); + + Ok(()) + } +}