Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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."
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/blocking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,12 @@ impl Suppressions<'_> {
.block_on(self.client.inner.suppressions.create(body))
}

pub fn create_batch(&self, items: impl Serialize) -> Result<Response> {
self.client
.runtime
.block_on(self.client.inner.suppressions.create_batch(items))
}

pub fn get(&self, email: &str, topic: &str) -> Result<Response> {
self.client
.runtime
Expand Down
23 changes: 22 additions & 1 deletion src/resources/suppressions.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::sync::Arc;

use serde::Serialize;
use serde_json::json;

use crate::error::Result;
use crate::http::HttpClient;
Expand All @@ -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<Page> {
self.http.list("/suppressions", Self::query(params)).await
}
Expand All @@ -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<Response> {
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<Response> {
Expand Down
4 changes: 4 additions & 0 deletions src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,17 @@ 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<HttpResponse, TransportError>;
}

/// 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.
Expand Down
42 changes: 42 additions & 0 deletions tests/suppressions.rs
Original file line number Diff line number Diff line change
@@ -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"},
]})
);
}
Loading