From 7f1c698aee571000d9a7f9a8daa2b7df009f5127 Mon Sep 17 00:00:00 2001 From: danhstevens Date: Mon, 2 Nov 2026 17:12:41 -0700 Subject: [PATCH 1/2] add suppressions.createBatch for POST /suppressions/batch; document that suppressions no longer expire --- README.md | 12 ++++++++++++ src/index.ts | 3 +++ src/resources/suppressions.ts | 23 ++++++++++++++++++++++- src/types/suppression.ts | 33 ++++++++++++++++++++++++++++++++- test/suppressions.test.ts | 27 +++++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0c19240..0fc05f2 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,18 @@ await anypost.suppressions.create({ await anypost.suppressions.delete("alice@example.com", "marketing"); ``` +Add up to 1,000 at a time with `createBatch`, for example when moving a list over from another provider: + +```ts +const result = await anypost.suppressions.createBatch([ + { email: "alice@example.com", reason: "hard bounce" }, + { email: "bob@example.com", topic: "marketing", reason: "unsubscribed" }, +]); +// { imported: 2, already_suppressed: 0, invalid: [] } +``` + +Addresses already suppressed for that topic are counted in `already_suppressed` and left unchanged, so a batch is safe to retry. Invalid addresses come back in `invalid` with their position instead of failing the batch. + `get`, `list` (with `email_contains`, `topic`, `reason`, and `origin` filters), `listForEmail`, and `deleteForEmail` 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/index.ts b/src/index.ts index 2c04325..e04926a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -100,6 +100,9 @@ export type { export type { Suppression, + SuppressionBatchInvalid, + SuppressionBatchItem, + SuppressionBatchResult, SuppressionCreateParams, SuppressionListParams, SuppressionOrigin, diff --git a/src/resources/suppressions.ts b/src/resources/suppressions.ts index eb0af56..4376100 100644 --- a/src/resources/suppressions.ts +++ b/src/resources/suppressions.ts @@ -3,6 +3,8 @@ import { Page, type PageResponse } from "../pagination.js"; import type { RequestOptions } from "../types/common.js"; import type { Suppression, + SuppressionBatchItem, + SuppressionBatchResult, SuppressionCreateParams, SuppressionListParams, } from "../types/suppression.js"; @@ -12,7 +14,7 @@ export class Suppressions { constructor(private readonly http: HttpClient) {} /** - * List the team's suppressions, newest-first. Expired rows are filtered out. + * List the team's suppressions, newest-first. * Returns one {@link Page}; iterate it with `for await` to walk every page. * Filter with `email_contains`, `topic`, `reason`, and `origin`. */ @@ -39,6 +41,25 @@ export class Suppressions { }); } + /** + * Add up to 1,000 suppressions in one call. An address already suppressed for + * the same topic is counted in `already_suppressed` and left unchanged, so a + * batch is safe to retry; an invalid address comes back in `invalid` instead + * of failing the batch. Throws `validation_error` (and adds nothing) if the + * batch would take the team past its suppression limit. + */ + createBatch( + suppressions: SuppressionBatchItem[], + options?: RequestOptions, + ): Promise { + return this.http.request({ + method: "POST", + path: "/suppressions/batch", + body: { suppressions }, + ...(options ? { options } : {}), + }); + } + /** * Retrieve the suppression for an `(email, topic)` pair. Use `*` as the topic * for the global row. Throws `not_found` if the pair isn't suppressed. diff --git a/src/types/suppression.ts b/src/types/suppression.ts index 7ba19a0..4c021ed 100644 --- a/src/types/suppression.ts +++ b/src/types/suppression.ts @@ -28,7 +28,7 @@ export interface Suppression { note: string | null; /** When the suppression was first observed. */ suppressed_at: string; - /** When it stops applying. `null` means never. Permanent bounces roll off after 90 days. */ + /** Always `null`: suppressions do not expire. Kept for compatibility; removed in a future major version. */ expires_at: string | null; created_at: string; } @@ -51,3 +51,34 @@ export interface SuppressionCreateParams { /** Optional internal annotation, preserved across automatic re-suppressions. */ note?: string | null; } + +/** One entry for `suppressions.createBatch`. */ +export interface SuppressionBatchItem { + email: string; + /** Topic to scope the suppression. Omit or `*` to block every topic. */ + topic?: string; + /** + * `permanent_bounce`, `complaint`, `unsubscribed` or `manual`. Common + * equivalents (`hard bounce`, `spam report`, `opt-out`) are accepted; + * anything else, or no value, records `manual`. + */ + reason?: string | null; + /** Optional internal annotation, preserved across automatic re-suppressions. */ + note?: string | null; +} + +/** An entry `suppressions.createBatch` rejected because its `email` is not a valid address. */ +export interface SuppressionBatchInvalid { + /** Position of the entry in the array sent. */ + index: number; + email: string | null; +} + +/** Response from `suppressions.createBatch`. The three outcomes add up to the entries sent. */ +export interface SuppressionBatchResult { + /** Suppressions this call created. */ + imported: number; + /** Entries already suppressed for that topic, including repeats within the batch. */ + already_suppressed: number; + invalid: SuppressionBatchInvalid[]; +} diff --git a/test/suppressions.test.ts b/test/suppressions.test.ts index 71a1e52..7b51370 100644 --- a/test/suppressions.test.ts +++ b/test/suppressions.test.ts @@ -37,6 +37,33 @@ describe("suppressions", () => { expect(req.body).toEqual({ email: "alice@example.com", topic: "marketing" }); }); + test("createBatch wraps the entries and returns the outcome counts", async () => { + const m = mockFetch([ + { + status: 200, + body: { imported: 1, already_suppressed: 0, invalid: [{ index: 1, email: "not-an-address" }] }, + }, + ]); + const anypost = new Anypost("ap_test", { fetch: m.fetch }); + + const result = await anypost.suppressions.createBatch([ + { email: "alice@example.com", topic: "marketing", reason: "hard bounce" }, + { email: "not-an-address" }, + ]); + + expect(result.imported).toBe(1); + expect(result.invalid).toEqual([{ index: 1, email: "not-an-address" }]); + const req = m.calls[0]!; + expect(req.method).toBe("POST"); + expect(req.url).toBe("https://api.anypost.com/v1/suppressions/batch"); + expect(req.body).toEqual({ + suppressions: [ + { email: "alice@example.com", topic: "marketing", reason: "hard bounce" }, + { email: "not-an-address" }, + ], + }); + }); + test("list passes filters as query params", async () => { const m = mockFetch([ { status: 200, body: { data: [suppression()], has_more: false, next_cursor: null } }, From 308566a864333d6dfe56381c509482d7d94574c7 Mon Sep 17 00:00:00 2001 From: danhstevens Date: Mon, 2 Nov 2026 18:10:08 -0700 Subject: [PATCH 2/2] bump version to v1.5.0 --- package.json | 2 +- src/version.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 6e3137e..2f158e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "anypost", - "version": "1.4.0", + "version": "1.5.0", "description": "Official TypeScript SDK for the Anypost email API.", "license": "MIT", "homepage": "https://anypost.com", diff --git a/src/version.ts b/src/version.ts index 5564bbd..013977a 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,3 +1,3 @@ // Generated by scripts/sync-version.mjs — do not edit by hand. // The version is single-sourced from package.json; run `bun run sync-version`. -export const SDK_VERSION = "1.4.0"; +export const SDK_VERSION = "1.5.0";