Skip to main content
Frameworks File 017

Effect

Postboi in an Effect program. Typed errors, Mailer and Pusher services with config and mock layers, a runtime for promise code, a wire schema and a retry helper.

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.

bun add postboi effect

Set up a provider as usual (bunx postboi init sets POSTBOI_TOKEN, or pick another provider), then send:

import { Effect } from 'effect'
import { mail } from 'postboi/effect'

const program = mail({
	to: 'ada@example.com',
	subject: 'Welcome',
	body: '<p>Hello</p>'
}).pipe(
	Effect.catchTag('PostboiError', (error) =>
		Effect.logError(`${error.provider} answered ${error.status}: ${error.message}`)
	)
)

await Effect.runPromise(program)

Errors

Every send is Effect<SendResponse, PostboiError | SkipSend | Spam>:

Tag When
PostboiError The provider or the transport failed. Same fields as the class on the root: provider, channel, status, code, raw, outcome_unknown, reason and retry_after.
SkipSend A before.send hook cancelled the send. code is what the hook threw with, skipped by default.
Spam A FormData body tripped the honeypot.

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:

import { mail } from 'postboi'
import { tried } from 'postboi/effect'

const create = tried(() => mail.lists.create('Newsletter', { confirmation: true }))

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:

import { Effect } from 'effect'
import { mailbox } from 'postboi/mailbox'
import { tried } from 'postboi/effect'

const next_code = Effect.gen(function* () {
	const box = yield* tried(() => mailbox())
	const mail = yield* tried(() => box.wait({ timeout: '2m', subject: /code/i }))
	return mail.code
})

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:

import { Effect } from 'effect'
import { Mailer } from 'postboi/effect'

const welcome = Effect.fn('welcome')(function* (to: string) {
	const mailer = yield* Mailer
	return yield* mailer.send({ to, subject: 'Welcome', body: '<p>Hello</p>' })
})

Effect.runPromise(welcome('ada@example.com').pipe(Effect.provide(Mailer.layer)))

Four layers:

  • Mailer.layer sends through mail() itself, so it is the same send path as everywhere else: each send resolves the provider when it runs, from the config file’s provider and 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 for mail(), and the visitor’s IP for Turnstile is read when you call send. POSTBOI_PROVIDER and POSTBOI_TOKEN are read through Config when the layer is built, so your own ConfigProvider can supply them and a value it gives beats the environment. The layer always builds: when no provider resolves, each send fails with the PostboiError mail() would throw, with code set to no_provider, no_token or missing_env, and the rest of your graph is unaffected.
  • Mailer.layerStrict is Mailer.layer that also checks a provider resolves when it is built, and fails the layer with that same PostboiError when 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 what mail() 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 from postboi/mock, for tests. Pass your own instance to read mock.sent back. Each message there keeps its headers (the List-Unsubscribe pair unsubscribe_url adds included) and tags.
import { it, expect } from '@effect/vitest'
import { Effect } from 'effect'
import Mock from 'postboi/mock'
import { Mailer } from 'postboi/effect'

const mock = new Mock({ default: { from: 'tests@example.com' } })

// `welcome` is the function from the section above.
it.effect('welcomes a new member', () =>
	Effect.gen(function* () {
		yield* welcome('ada@example.com')
		expect(mock.last?.to[0].address).toBe('ada@example.com')
	}).pipe(Effect.provide(Mailer.layerMock(mock)))
)

The Pusher service

Pusher is the same idea for push, with one method, send(options):

  • Pusher.layer sends through push(), 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 from postboi/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:

import { Effect } from 'effect'
import Mock from 'postboi/mock'
import MockPush from 'postboi/push-mock'
import { layer_mocks } from 'postboi/effect'

const mail = new Mock({ default: { from: 'tests@example.com' } })
const push = new MockPush()

program.pipe(Effect.provide(layer_mocks({ mail, push })))

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.

import { Effect } from 'effect'
import type { WebPushSubscription } from 'postboi'
import { Pusher, push } from 'postboi/effect'

const notify = Effect.fn('notify')(function* (subscription: WebPushSubscription) {
	const pusher = yield* Pusher
	yield* pusher
		.send({ to: subscription, message: 'Your export is ready' })
		.pipe(Effect.catchIf(push.expired, () => forget_subscription(subscription)))
})

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:

import { Effect } from 'effect'
import { promise_runtime } from 'postboi/effect'

export const mailer = promise_runtime()

export function send_welcome(to: string) {
	return mailer.run(welcome(to))
}
import Mock from 'postboi/mock'
import { Mailer } from 'postboi/effect'
import { mailer, send_welcome } from './welcome'

const mock = new Mock({ default: { from: 'tests@example.com' } })
await mailer.use(Mailer.layerMock(mock))
await send_welcome('ada@example.com')

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.

import { Effect } from 'effect'
import { HttpServerRequest, HttpServerResponse } from 'effect/http'
import { SendRequest, mail, send_options } from 'postboi/effect'

const handle = Effect.gen(function* () {
	const request = yield* HttpServerRequest.schemaBodyJson(SendRequest)
	const response = yield* mail(send_options(request))
	return yield* HttpServerResponse.json(response)
})

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.

import { mail, with_retry } from 'postboi/effect'

const send = with_retry(mail({ to, subject, body, idempotency_key: `welcome-${id}` }), {
	times: 3,
	base: '1 second',
	retry_unknown: true
})

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.