Skip to content

Changelog

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: "AWS SES daily sending 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.