@btravstack/mailer
v0.50.0
Published
A Mailer port for @btravstack/core: a recording adapter a spec asserts against, an SMTP adapter, and a composition that spans, counts and logs every send
Maintainers
Readme
@btravstack/mailer
The mail port for
@btravstack/core: oneMaileran application depends on, a recording adapter a spec asserts against, an SMTP adapter, and a span, a count and a log line per send.
📖 Documentation · API Reference
pnpm add @btravstack/mailer @btravstack/core @btravstack/config @btravstack/di unthrownFour peer dependencies, plus nodemailer — optional, and needed only if you
compose the SMTP adapter. Instrumentation needs no peer of its own: the
Logger, Tracer and Meter it depends on are
the kernel's ports.
Node >=22.
A worked example
A notification, and the failure arm that makes it honest:
import { Mailer } from "@btravstack/mailer";
import { Port, Provider } from "@btravstack/di";
import type { AsyncResult } from "unthrown";
class Notifier extends Port("ReadmeNotifier")<{
readonly placed: (
to: string,
orderId: string,
) => AsyncResult<void, RetryableError>;
}> {}
export const notifier = Provider(Notifier)({
inject: { mailer: Mailer },
sync: ({ mailer }) => ({
placed: (to, orderId) =>
mailer
.send({
from: "[email protected]",
to: [to],
subject: `order ${orderId} placed`,
text: `Order ${orderId} is on its way.`,
})
// A failed send is YOUR transport's problem, which is why the port
// models it instead of retrying: leaving the delivery un-acked
// hands it to the next worker, on the broker's own budget.
.mapErrCases((matcher) =>
matcher.with(
P.tag("MailNotSent"),
(error) =>
new RetryableError(
`order ${orderId} was not notified: ${error.reason}`,
),
),
),
}),
});The composition — the adapter is the only decision, made here and nowhere else:
import { mailer } from "@btravstack/mailer";
import { smtpMailer } from "@btravstack/mailer/smtp";
export const NotifierApp = Module("NotifierApp")({
imports: [
// Instrumented by default: a span, a counter and a log line per send,
// which is why `observability()` and `otel()` are below. Pass
// Every call is reported through `Observers`. Drop the two modules
// below and this composition is unchanged — it just reports to nobody.
mailer({ adapter: smtpMailer() }),
observability(),
otel(),
],
provides: [notifier],
exports: [Notifier, Logger],
});And in a test, the adapter that sends nothing and keeps everything:
import { mailRecorder, recordingMailerProvider } from "@btravstack/mailer";
import { overridden } from "@btravstack/testing";
const recorder = mailRecorder();
const app = overridden(NotifierApp, [recordingMailerProvider(recorder)]);
// … place an order through the real graph …
expect(recorder.only()).toEqual(
expect.objectContaining({ subject: "order 42 placed" }),
);Options
| Option | Where | What it is |
| ---------- | ----------------------------------- | ------------------------------------------------------- |
| adapter | mailer({ adapter }) | the adapter module providing MailerBackend — required |
| SMTP_URL | environment, read by smtpMailer() | the transport URL — required, validated at graph build |
The full table — defaults, semantics and the reasoning — lives on the reference page, which is this list's one detailed home.
What it decides, and what it does not
It decides that every send is handed to whatever contributed to
Observers — there is no flag and no opt-out, because a graph that composed no
observability pays one inert call and reports to nobody. It decides that the
SMTP adapter contributes a health check — verify(),
a connection and an authentication, folded into the kernel's /healthz with
nothing wired, while the recording adapter contributes none because it would
report healthy for free. And it decides that a send is accepted, not
delivered, and that a failure is a modeled MailNotSent carrying the envelope
and never the body.
It does not decide what happens after a failure — retrying belongs to your
transport, and the example answers a RetryableError so the broker's budget
owns it. Mail carries the whole envelope — cc, bcc, attachments and
headers beside to, subject and the two bodies — but there is no
templating, no bulk send and no address validation — each reason is on
the reference page,
and the same reasoning is in AGENTS.md for contributors.
License
MIT
