Skip to content

Changelog

Unreleased

New: suppression search, bulk add and bulk remove

Three new methods on outbound.suppressions, all accepting a list of addresses as either a comma-separated string or an array:

ts
// Exact lookup of specific addresses (max 100 per call)
const found = await outbound.suppressions.search({ emails: ['a@x.com', 'b@x.com'] });

// Suppress many at once (max 1000 per call)
const added = await outbound.suppressions.bulkAdd({ emails: 'a@x.com,b@x.com', reason: 'manual' });

// Unsuppress many at once (max 1000 per call)
const removed = await outbound.suppressions.bulkRemove({ emails: ['a@x.com', 'b@x.com'] });

Every address is validated server-side against the same RFC regex the send path uses, before anything is queried or written. A malformed address does not fail the call: each response splits your input into accepted (valid, lowercased, deduplicated, acted on) and rejected ({ email, reason } where reason is invalid_format or too_long), plus a summary of counts. rejected[].email echoes the address exactly as you supplied it.

Beyond that split, each method reports what actually happened:

MethodResult fields
search()suppressions (the matching records), notFound
bulkAdd()suppressions (record for every accepted address), added, alreadySuppressed
bulkRemove()removed, notFound

bulkAdd() is idempotent: an address already on the list keeps its existing record and original reason. bulkRemove() treats an address that is not on the list as notFound rather than an error, unlike single remove() which throws NotFoundError.

New types: SearchSuppressionsParams, SearchSuppressionsResponse, BulkAddSuppressionsParams, BulkAddSuppressionsResponse, BulkRemoveSuppressionsParams, BulkRemoveSuppressionsResponse, RejectedEmail, EmailList, SuppressionReason.

suppressions.list() gains search, and it accepts multiple terms

ListSuppressionsParams now types the search param (partial, case-insensitive match on the address). A comma-separated value is treated as several terms OR'd together:

ts
// Every suppressed address containing "acme.com" OR "gmail.com"
await outbound.suppressions.list({ search: 'acme.com,gmail.com' });

Backward compatible: a comma is not legal inside an address, so any existing single-term search parses to exactly one term and behaves as before. Use list({ search }) for substring matching and search({ emails }) for exact lookups.

suppressions.add() now rejects malformed addresses

add() previously accepted any string. It now validates the address against the RFC regex and returns 400 BadRequestError for a malformed one, matching the bulk methods. An unknown reason is also a 400 now instead of a server error. Suppressing an address that could never be sent to had no effect anyway.

Webhook events: failed replaced by dropped

The failed webhook event is removed and replaced by a new dropped event. dropped fires when a recipient is not attempted (currently: an invalid recipient address caught by pre-send RFC validation); the v2 payload carries event: 'dropped', status: 'dropped', and details.failedReason with the reason. Subscribe to dropped where you previously used failed.

failed is no longer a subscribable event or a webhook status. It remains a recipient status (EmailStatus) for real SES/send errors, queryable via email.status(jobId), but it does not emit a webhook. Existing webhooks are migrated automatically: failed is removed from their subscriptions and dropped is added, so notification coverage is preserved.

WebhookEvent and WebhookEventStatus were updated accordingly ('failed' removed, 'dropped' added).

Expanded priority scale: 4 → 7 levels

Three new priority levels were added: critical (new highest, above urgent) and deferred + backlog (below low). Full scale, highest-urgency first: 'critical' | 'urgent' | 'high' | 'normal' | 'low' | 'deferred' | 'backlog' (default 'normal'). Unrecognized values are still treated as 'normal', and the existing four levels keep their meaning, so this is backward-compatible.

critical/urgent/high bypass send pacing (sent immediately); normal and below are paced. Use backlog/deferred for large backfill campaigns so transactional / time-sensitive sends jump ahead.

When priority is omitted, the send now uses the tenant's configured default priority (set by an admin), falling back to 'normal' if none is configured. Passing an explicit priority always overrides the tenant default.

New: priority on bulk sends + urgent level

email.bulk() and templates.bulkSend() now accept an optional priority, bringing them in line with email.send() and templates.send(). All four send methods now share the same priority semantics.

A new highest level, urgent, was added. Accepted values, highest-urgency first: 'urgent' | 'high' | 'normal' | 'low' (default 'normal'). Unrecognized values are treated as 'normal'. Priority sets the queue drain order for the whole batch — use 'low' for large backfill campaigns so transactional / time-sensitive sends jump ahead of the backlog.

2026-04-17 — Backend behavior updates

Documentation updates for four backend changes rolled out today. No SDK API changes.

New dropped recipient status

EmailStatus and recipient rows can now be 'dropped'. A dropped recipient was on the suppression list at submit time, so no send was attempted. The row is persisted with status='dropped' and error_message='Suppressed: <reason>', where <reason> is one of bounce | complaint | manual | unsubscribe. Update any exhaustive status checks.

Full status set: queued, processing, sent, delivered, bounced, complained, opened, clicked, failed, cancelled, dropped.

Bulk endpoints no longer 422 when all recipients are suppressed

POST /v1/email/bulk and POST /v1/templates/bulk previously returned 422 Unprocessable Entity with "All recipients are suppressed". They now always return 202 Accepted with a jobId. Suppressed addresses are persisted as dropped recipient rows so you can audit them via email.status(jobId). The response body is unchanged (still includes recipientCount, suppressedCount, suppressedEmails).

Template endpoints now return 429 on global SES exhaustion

POST /v1/templates/send and POST /v1/templates/bulk now check the AWS SES account 24-hour sending quota up-front (previously only /v1/email/* did). Message format: "Daily Quota Exhausted (X/Y). Try again later."

Tenant daily/monthly quotas no longer block sends

This is now a post-paid policy. Tenant daily_quota and monthly_quota are tracked for billing and reporting (and remain visible via outbound.dashboard.quota()) but exceeding them does not return 429 or block sends. The only request-time quota gate is the global AWS SES account quota.


v0.2.0

New: Campaign support and job cancellation

campaignId parameter — all four send methods now accept an optional campaignId string. Jobs tagged with the same ID are grouped into one campaign, enabling a single cancel() call to stop all of them:

ts
// Tag sends across multiple batches/calls
await outbound.email.bulk({ campaignId: 'spring-sale-2026', ... });
await outbound.templates.bulkSend({ campaignId: 'spring-sale-2026', ... });

// Cancel everything in the campaign instantly
await outbound.email.cancel({ campaignId: 'spring-sale-2026' });

email.cancel() — new method to abort queued/processing emails before they are sent. Accepts either campaignId (cancels all jobs in a campaign) or jobId (cancels one job). Already-sent emails cannot be recalled.

New types exported: CancelEmailParams, CancelEmailResponse, EmailJob

cancelled statusEmailStatus and job status now include 'cancelled'. Update any exhaustive status checks.

Breaking change: suppressedEmails is now optional on bulk responses

When an idempotencyKey is replayed (duplicate: true), the suppressedEmails array is absent from the response — only suppressedCount is returned. This was necessary to prevent unbounded Redis memory growth on high-volume bulk sends.

Before:

ts
const { suppressedEmails } = await outbound.email.bulk({ ... });
suppressedEmails.forEach(...); // would throw on a duplicate response

After:

ts
const { suppressedEmails = [] } = await outbound.email.bulk({ ... });
suppressedEmails.forEach(...); // safe

Affects BulkEmailResponse and TemplateBulkSendResponsesuppressedEmails and duplicatesRemoved are now typed as optional (string[] | undefined).

Idempotency TTL reduced: 48h → 24h

Idempotency keys now expire after 24 hours instead of 48. The underlying system now uses Postgres as the durable store with Redis as a short-lived hot cache, eliminating the risk of Redis memory exhaustion from accumulated keys.


v0.1.0

Initial release.

  • Email sending (single and bulk)
  • Template management (CRUD, preview, duplicate)
  • Template-based sending (single and bulk with variables)
  • Suppression list management
  • Webhook management with signature verification
  • Dashboard and quota endpoints
  • Auto-retry with exponential backoff on 429/5xx
  • Full TypeScript support
  • Zero runtime dependencies

Released under the MIT License.