From b039a6c9384bd1be75d8df70061d420c02d7dd36 Mon Sep 17 00:00:00 2001 From: danhstevens Date: Mon, 2 Nov 2026 17:13:03 -0700 Subject: [PATCH 1/3] add suppressions.createBatch for POST /suppressions/batch; document that suppressions no longer expire --- README.md | 16 +++++++++++++ src/blocking.rs | 6 +++++ src/resources/suppressions.rs | 23 ++++++++++++++++++- tests/suppressions.rs | 42 +++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 tests/suppressions.rs diff --git a/README.md b/README.md index 586b3f9..296b3fe 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,22 @@ client client.suppressions.delete("alice@example.com", "marketing").await?; ``` +Add up to 1,000 suppressions per call with `create_batch`: + +```rust +let result = client + .suppressions + .create_batch(json!([ + { "email": "alice@example.com", "topic": "marketing" }, + { "email": "bob@example.com", "reason": "hard bounce" }, + ])) + .await?; + +println!("{} imported, {} already suppressed", result["imported"], result["already_suppressed"]); +``` + +Addresses that are already suppressed are counted in `already_suppressed` and left unchanged, so a batch is safe to retry. Invalid addresses come back in `invalid` (with their `index` and `email`) instead of failing the batch, and a batch that would pass your suppression limit raises `validation_error`. + `get`, `list` (with `email_contains`, `topic`, `reason`, and `origin` filters via `SuppressionListParams`), `list_for_email`, and `delete_for_email` round out the resource. See [Suppressions](https://anypost.com/docs/reference/suppressions) for scoping and the automatic-suppression rules for bounces and complaints. ## Webhooks diff --git a/src/blocking.rs b/src/blocking.rs index 7ada8e6..574a81d 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -327,6 +327,12 @@ impl Suppressions<'_> { .block_on(self.client.inner.suppressions.create(body)) } + pub fn create_batch(&self, items: impl Serialize) -> Result { + self.client + .runtime + .block_on(self.client.inner.suppressions.create_batch(items)) + } + pub fn get(&self, email: &str, topic: &str) -> Result { self.client .runtime diff --git a/src/resources/suppressions.rs b/src/resources/suppressions.rs index ad289bf..3095480 100644 --- a/src/resources/suppressions.rs +++ b/src/resources/suppressions.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use serde::Serialize; +use serde_json::json; use crate::error::Result; use crate::http::HttpClient; @@ -19,7 +20,7 @@ impl Suppressions { Self { http } } - /// List the team's suppressions, newest-first. Expired rows are filtered out. + /// List the team's suppressions, newest-first. pub async fn list(&self, params: SuppressionListParams) -> Result { self.http.list("/suppressions", Self::query(params)).await } @@ -45,6 +46,26 @@ impl Suppressions { .await } + /// Add up to 1,000 suppressions in one call. `items` is the list of entries + /// (each `{ "email", "topic"?, "reason"?, "note"? }`); the SDK wraps it in + /// `{ "suppressions": [...] }`. Returns `imported`, `already_suppressed`, and + /// `invalid`. Entries already suppressed are counted in `already_suppressed` + /// and left unchanged, so a batch is safe to retry. Invalid addresses come + /// back in `invalid` (with their `index` and `email`) instead of failing the + /// batch. Raises `validation_error` (422) if the batch would pass the team's + /// suppression limit; nothing is added in that case. + pub async fn create_batch(&self, items: impl Serialize) -> Result { + self.http + .request_object( + Method::Post, + "/suppressions/batch", + Some(json!({ "suppressions": to_value(items)? })), + false, + None, + ) + .await + } + /// Retrieve the suppression for an `(email, topic)` pair. Use `*` as the /// topic for the global row. pub async fn get(&self, email: &str, topic: &str) -> Result { diff --git a/tests/suppressions.rs b/tests/suppressions.rs new file mode 100644 index 0000000..3d878ea --- /dev/null +++ b/tests/suppressions.rs @@ -0,0 +1,42 @@ +mod common; + +use anypost::transport::Method; +use common::{client, json}; +use serde_json::json as j; + +#[tokio::test] +async fn create_batch_wraps_items_and_parses_the_result() { + let (client, transport) = client(vec![json( + 200, + j!({ + "imported": 1, + "already_suppressed": 0, + "invalid": [{"index": 1, "email": "not-an-address"}] + }), + )]); + + let result = client + .suppressions + .create_batch(j!([ + {"email": "alice@example.com", "topic": "marketing", "reason": "hard bounce"}, + {"email": "not-an-address"}, + ])) + .await + .unwrap(); + + assert_eq!(result["imported"], 1); + assert_eq!(result["already_suppressed"], 0); + assert_eq!(result["invalid"][0]["index"], 1); + assert_eq!(result["invalid"][0]["email"], "not-an-address"); + + let req = transport.last(); + assert_eq!(req.method, Method::Post); + assert_eq!(req.path(), "https://api.test/v1/suppressions/batch"); + assert_eq!( + req.json(), + j!({"suppressions": [ + {"email": "alice@example.com", "topic": "marketing", "reason": "hard bounce"}, + {"email": "not-an-address"}, + ]}) + ); +} From 31ce7d09c75a36b356013d4b55f007b6e59b43e7 Mon Sep 17 00:00:00 2001 From: danhstevens Date: Mon, 2 Nov 2026 17:17:16 -0700 Subject: [PATCH 2/3] allow clippy::double_must_use on the async_trait traits, which newer clippy flags --- src/transport.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/transport.rs b/src/transport.rs index 7efd5ec..8654ffd 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -47,6 +47,8 @@ pub struct TransportError { /// Performs a single HTTP round-trip. Implement this to swap in a custom HTTP /// stack; the default is [`reqwest`]. #[async_trait] +// Newer clippy flags the `#[must_use]` async_trait puts on the boxed future. +#[allow(clippy::double_must_use)] pub trait Transport: Send + Sync { async fn execute(&self, request: HttpRequest) -> Result; } @@ -54,6 +56,8 @@ pub trait Transport: Send + Sync { /// The clock the retry loop sleeps against, and the jitter source for backoff. /// The default sleeps on the Tokio timer; tests inject a no-op. #[async_trait] +// Newer clippy flags the `#[must_use]` async_trait puts on the boxed future. +#[allow(clippy::double_must_use)] pub trait Sleeper: Send + Sync { async fn sleep(&self, duration: Duration); /// A value in `[0, 1)` used as the full-jitter factor for one backoff. From a2151fd8bbbb702dbef4ffbc29e1152fd7793dcd Mon Sep 17 00:00:00 2001 From: danhstevens Date: Mon, 2 Nov 2026 18:10:16 -0700 Subject: [PATCH 3/3] bump version to v2.1.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 436cbcc..7e518fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 3 [[package]] name = "anypost" -version = "2.0.0" +version = "2.1.0" dependencies = [ "async-trait", "base64", diff --git a/Cargo.toml b/Cargo.toml index a9f91ad..eade3fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "anypost" -version = "2.0.0" +version = "2.1.0" edition = "2021" rust-version = "1.82" description = "Official Rust SDK for the Anypost email API."