Pass an array to mail() and each message becomes its own request, sent with bounded
concurrency. The call never throws — you get one result per message.
Concurrency and results
On a provider instance you can tune concurrency (default 5). Each result tells you whether
that message succeeded:
Because the call never throws, you handle failures by inspecting results rather than with try/catch. Each failed result carries the same normalised PostboiError on .error.
Hooks run once per message, so before.send, after.send, and on.error fire for each item in the array.
Personalized batches
When every recipient gets the same message with a few values swapped in, pass one to array plus per-recipient data instead of building the array yourself. {name} placeholders
in subject and body are filled from each recipient’s variables:
When to is a literal array, the data keys are type-checked against it — a typo’d or
missing-from-to address is a compile error. (Recipients written as { address } objects or
a non-literal string[] can’t be inferred, so their keys relax to any string.)
Providers with a native batch API send the whole thing as one request — Resend, Postmark
and MailerSend pack the rendered messages; Mailgun, SparkPost, Mandrill, SendGrid and Brevo
map your {name} tags onto their server-side merge fields. Every other provider falls back to
one request per recipient. Either way you get one BatchResult per recipient, in
order.
before.sendruns per recipient (so suppression and staging redirects still work). On the native-batch path,after.send/on.errorfire per recipient only when a provider has no batch endpoint and falls back to fan-out.