Skip to content

fix(drivers): resend and postmark sendBatch do not chunk at the provider's batch cap, so an over-large batch fails wholesale #123

Closed

Description

@productdevbook

What happens

Both batch drivers pass the caller's whole list to the provider in one request, whatever its length. The providers cap it:

  • Resend /emails/batch: 100 emails per call
  • Postmark /email/batch and /email/batchWithTemplates: 500 messages per call (over the cap it answers ErrorCode 410, Too many batch messages)

Neither driver chunks or checks. A 150-message Resend batch or a 600-message Postmark batch goes out as a single oversized request, and when the provider rejects it, sendBatch maps that one error onto every message.

Reproduction

import { createEmail } from "../src/core/email.ts"
import resend from "../src/drivers/resend.ts"
import postmark from "../src/drivers/postmark.ts"

function spy(payload: unknown) {
  const calls: any[] = []
  const f = (async (_u: any, init: any) => {
    calls.push({ body: init.body ? JSON.parse(init.body) : undefined })
    return new Response(JSON.stringify(payload), { status: 200 })
  }) as unknown as typeof fetch
  return { f, calls }
}
const defaults = { from: "Acme <hi@acme.com>" }
const msgs = (n: number) => Array.from({ length: n }, (_, i) => ({ to: `u${i}@x.com`, subject: "s", text: "t" }))

const r = spy({ data: Array.from({ length: 150 }, (_, i) => ({ id: `x${i}` })) })
await createEmail({ driver: resend({ apiKey: "re_x", fetch: r.f }), defaults }).sendBatch(msgs(150))
console.log("resend   -> requests:", r.calls.length, "messages in request 1:", r.calls[0].body.length)

const p = spy(Array.from({ length: 600 }, () => ({ MessageID: "m" })))
await createEmail({ driver: postmark({ token: "t", fetch: p.f }), defaults }).sendBatch(msgs(600))
console.log("postmark -> requests:", p.calls.length, "messages in request 1:", p.calls[0].body.length)

Observed output:

resend   -> requests: 1 messages in request 1: 150
postmark -> requests: 1 messages in request 1: 600

Why it matters

email.sendBatch() is the documented way to send a list, and sendStream() defaults to a chunk size of 50 — so a caller who reaches for sendBatch with a real mailing list has no signal that 101 or 501 is a cliff. When it is hit, nothing partial gets through: sendBatch maps the single request error onto all N results, so 600 messages fail together on an opaque provider error, and a retry middleware then re-sends all 600 into the same wall.

Related, same shape, not enforced either: Resend caps to at 50 addresses per message, Postmark caps To/Cc/Bcc at 50 each, and SES caps a message at 50 recipients — ses.toPayload merges to+cc+bcc into one ToAddresses list with no check (src/drivers/ses.ts:142).

Where

  • src/drivers/resend.ts:95-96 — /emails/batch, no chunking
  • src/drivers/postmark.ts:102-107 — /email/batch, no chunking
  • src/drivers/ses.ts:142 — merged recipient list, no cap

Suggested fix

Give each batch driver a BATCH_LIMIT constant and have sendBatch slice the input into that many messages per request, issuing the requests and stitching results back into input order — the positional contract holds and one over-large chunk no longer takes the rest with it. Recipient caps are better expressed as an INVALID_OPTIONS result for the offending message, so the failure names the field instead of arriving as a provider error string.

Activity

  1. productdevbook commented on Sep 2, 2026

    @productdevbook
    OwnerAuthor

    Fixed in #130, with a regression test. Suite went from 269 tests to 293.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions