Postboi is an email provider, and postboi/effect is its SDK for an Effect program: the same mail() as everywhere else, as an
Effect, with the failures in the error channel as tagged errors you can catchTag. It needs effect 4 in your app, and
only this entry imports it: postboi doesn’t list effect as a dependency, so an app on
Effect 3, or none, installs and uses the rest of the package as before.
Set up a provider as usual (bunx postboi init sets POSTBOI_TOKEN, or pick another provider), then send:
Errors
Every send is Effect<SendResponse, PostboiError | SkipSend | Spam>:
A failover chain’s FailoverError is a PostboiError here too, as it is on the
root: the fields are the last provider’s, and errors holds every provider’s error in
the order they were tried. reason says why a provider refused, and outcome_unknown: true says nothing proves the message wasn’t sent, so sending it again
can deliver it twice. retry_after is the seconds a provider’s Retry-After asked for.
A skip and a spam verdict have their own tags rather than a code on PostboiError,
because a program usually lets them through where it would retry or report a failure.
Anything that is not one of the package’s own errors is a defect: a bug, not an outcome
to handle.
from_error(error) is the mapping on its own, and tried(() => promise) runs any
promise-returning call from the package through it, so mail.lists.create(…) or a
provider you constructed yourself gets the same error channel:
The same goes for a mailbox, which is an object you keep rather than a send
to wrap: open it once, then run its calls through tried. A wait() that runs out is a PostboiError with code: "timeout" and the cursor the mailbox stood at, to wait on from:
The Mailer service
Mailer is the provider as a service, for a program that wants to say what it needs
rather than reach for the environment:
Four layers:
Mailer.layersends throughmail()itself, so it is the same send path as everywhere else: each send resolves the provider when it runs, from the config file’sproviderand each provider’s credential, and in development the dev inbox (even one started after the layer was built) or the console. Hooks and the captcha check run as they do formail(), and the visitor’s IP for Turnstile is read when you callsend.POSTBOI_PROVIDERandPOSTBOI_TOKENare read throughConfigwhen the layer is built, so your ownConfigProvidercan supply them and a value it gives beats the environment. The layer always builds: when no provider resolves, each send fails with thePostboiErrormail()would throw, withcodeset tono_provider,no_tokenormissing_env, and the rest of your graph is unaffected.Mailer.layerStrictisMailer.layerthat also checks a provider resolves when it is built, and fails the layer with that samePostboiErrorwhen none does. Use it in a script or a worker that should stop at start rather than at its first send.Mailer.layerProvider(provider)wraps one you constructed:Mailer.layerProvider(new Resend({ api_key })). Every send goes to that provider, so it skips whatmail()decides per send: the dev inbox,mail({ test }), and a provider or credential that changed since. The provider’s own hooks and captcha check still run.Mailer.layerMock(mock)wraps the recording mock frompostboi/mock, for tests. Pass your own instance to readmock.sentback. Each message there keeps itsheaders(theList-Unsubscribepairunsubscribe_urladds included) andtags.
The Pusher service
Pusher is the same idea for push, with one method, send(options):
Pusher.layersends throughpush(), which resolves the push provider on every send.Pusher.layerProvider(provider)wraps one you constructed:Pusher.layerProvider(new WebPush({ … })).Pusher.layerMock(mock)wraps the recording mock frompostboi/push-mock. Make it with{ expired: true }to test what your code does with a dead subscription.
A program that needs both services in a test takes layer_mocks({ mail, push }), one layer
providing Mailer.layerMock(mail) and Pusher.layerMock(push). Leave either mock out and a
silent one is built, as layerMock() builds it:
push.expired(error) is the root’s check, on the Effect push too: did the push service
say this target is dead? The right answer to one is to delete your stored copy.
From promise code
An app that keeps promise exports beside its Effect forms needs somewhere to run them. promise_runtime(layer) is a ManagedRuntime over layer (Mailer.layer when you pass
none), built on the first run. use(layer) swaps it, so a test can point the promise
exports at a mock, and use() puts the original back:
A failure rejects with the root’s error class, so promise code catches it the way it
catches mail(): instanceof PostboiError, SkipSendError or SpamError from postboi. root_error(error) is that conversion on its own, for a runtime of your own. Pass Layer.mergeAll(Mailer.layer, Pusher.layer) to run push from the same runtime, or layer_mocks() in a test.
A layer that can fail to build works too: promise_runtime(Mailer.layerStrict) rejects a run with the build’s error, through the same conversion, when no provider resolves. The
runtime doesn’t keep that failure, so once the credential is set the next run builds the
layer again.
use(layer) resolves once the runtime it replaced is released, and rejects if releasing it
failed (a finalizer in the layer that failed). A test can await it; left unawaited, the
failure is logged with console.error rather than lost. dispose() releases what the layer
built, waits on any release use started, and rejects if one of them failed.
The other channels
One function per channel, each wrapping the zero-config function of the same name on
the root: sms (which answers the SmsReceipt every SMS provider answers), push (with push.expired), whatsapp, and slack, discord, teams, telegram and bluesky for chat (the
platform is the name, as on the root). send(options) is the multi-channel send(); its per-channel outcomes stay in the result, and only a
send that could not be attempted at all fails. cancel(id) cancels a scheduled email, and sms.validate(options) checks a text against the
configured provider without sending it, as the root’s does.
A request to send, over the wire
SendRequest is a Schema for the subset of SendOptions a server can accept from a
form post or a JSON body: to, from, reply_to, cc, bcc, subject, text, html, headers, tags, idempotency_key and in_reply_to, with at least one of html and text. It is deliberately
not the whole of SendOptions, which carries functions, FormData and promises that have
no wire form.
Whoever can send this request picks who the mail goes to, who it says it is from and what
it says, so put the handler behind your own authentication. For a public form, post FormData to mail instead: it ignores a posted _to, _cc, _bcc or _from unless the send sets form_addressing.
decode_send_request(value) decodes an unknown value; send_options(request) turns the
decoded request into what mail takes. A text-only request keeps its text as the
plain-text part and sends it as the body too, escaped with its line breaks kept, since a
string body is HTML to every provider.
Retrying
with_retry(effect, { times, base }) retries the failures the transport’s own retry rule would: a 429 always, and a 5xx only when it proves nothing
was sent (outcome_unknown: false). Backoff is exponential from base (500 milliseconds
by default), with at most times more attempts (2 by default), and when the provider
answered with a Retry-After the next attempt waits that long instead, up to a minute, as
the transport does. A 4xx, a skip and a spam
verdict fail at once, and so does an error with no status: a refusal before the request
arrives that way.
A failure with outcome_unknown: true (a 5xx, a timeout or a dropped connection on a send)
is retried only with retry_unknown: true, because the provider may already have the
message. Pass it when the send carries an idempotency_key the provider deduplicates on.
This overlaps the provider’s retries option, so pick one. That option retries the HTTP
request inside the send, before hooks see an error; with_retry retries the whole Effect,
hooks included. Setting both stacks them: a retries: 2 provider inside a with_retry of
2 makes up to nine requests. A form send with a captcha needs the provider’s retries,
because a retry here checks its Turnstile token again after the first check used it up.
Runnable example: examples/effect-provider-postboi.