diff --git a/README.md b/README.md index cbf8b9a..249601f 100644 --- a/README.md +++ b/README.md @@ -174,8 +174,15 @@ A suppression blocks sends to an address, scoped to a `topic`. The wildcard `*` client.suppressions.create(email: "alice@example.com", topic: "marketing", note: "Customer requested removal") client.suppressions.delete("alice@example.com", "marketing") + +client.suppressions.create_batch([ + {email: "bob@example.com"}, + {email: "carol@example.com", topic: "marketing", reason: "unsubscribed"} +]) ``` +`create_batch` takes up to 1,000 items per call and returns `imported`, `already_suppressed`, and `invalid`. Existing entries are left unchanged, so retrying a batch is safe, and invalid addresses are reported in `invalid` instead of failing the call. A batch that would pass your team's suppression limit raises a validation error and adds nothing. Suppressions do not expire. + `get`, `list` (with `email_contains`, `topic`, `reason`, and `origin` filters), `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/lib/anypost/resources/suppressions.rb b/lib/anypost/resources/suppressions.rb index 807f0fb..1265586 100644 --- a/lib/anypost/resources/suppressions.rb +++ b/lib/anypost/resources/suppressions.rb @@ -4,7 +4,7 @@ module Anypost module Resources # Operations on the `/suppressions` endpoints. Entries key on `(email, topic)`. class Suppressions < Base - # List the team's suppressions, newest-first. Expired rows are filtered out. + # List the team's suppressions, newest-first. # Filter with `email_contains`, `topic`, `reason`, and `origin`. def list(params = {}) paginate("/suppressions", { @@ -23,6 +23,20 @@ def create(params) request_object(:post, "/suppressions", body: params) end + # Add up to 1,000 suppressions in one call. Pass the list of items, each a + # hash with `email` and optionally `topic` (default `*`), `reason` (a free-form + # string; common aliases like `bounce` or `opt-out` are mapped, anything else + # records `manual`), and `note`. Entries that already exist 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, adding nothing, if + # the batch would pass the team's suppression limit. + # + # @return [Response] with `imported`, `already_suppressed`, and `invalid` + def create_batch(suppressions) + request_object(:post, "/suppressions/batch", body: {suppressions: suppressions}) + end + # Retrieve the suppression for an `(email, topic)` pair. Use `*` as the # topic for the global row. Raises not_found if the pair isn't suppressed. def get(email, topic) diff --git a/lib/anypost/version.rb b/lib/anypost/version.rb index 420d241..ca59086 100644 --- a/lib/anypost/version.rb +++ b/lib/anypost/version.rb @@ -3,5 +3,5 @@ module Anypost # The single source of truth for the gem version. Bump this, tag the commit # `vX.Y.Z`, and push — the release workflow builds and pushes to RubyGems. - VERSION = "1.2.0" + VERSION = "1.3.0" end diff --git a/spec/resources_spec.rb b/spec/resources_spec.rb index c113baa..a99ea10 100644 --- a/spec/resources_spec.rb +++ b/spec/resources_spec.rb @@ -100,6 +100,27 @@ expect(server.last_request.method).to eq(:delete) end + it "adds suppressions in a batch and parses invalid entries" do + server.stub(:post, "/v1/suppressions/batch", body: { + imported: 1, already_suppressed: 0, invalid: [{index: 1, email: "not-an-address"}] + }) + + result = client.suppressions.create_batch([ + {email: "a@b.com", reason: "hard bounce"}, + {email: "not-an-address"} + ]) + + expect(server.last_request.method).to eq(:post) + expect(server.last_request.path).to eq("/v1/suppressions/batch") + expect(server.last_request.json_body).to eq( + "suppressions" => [{"email" => "a@b.com", "reason" => "hard bounce"}, {"email" => "not-an-address"}] + ) + expect(result.imported).to eq(1) + expect(result.already_suppressed).to eq(0) + expect(result.invalid.first.index).to eq(1) + expect(result.invalid.first.email).to eq("not-an-address") + end + it "lists every suppression for an email" do server.stub(:get, "/v1/suppressions/a%40b.com", body: { data: [{email: "a@b.com", topic: "*"}, {email: "a@b.com", topic: "news"}]