Send an email
How-to. Get transactional mail out of an application, with the failure on a channel your transport can act on. For the port's surface, see
@btravstack/mailer.
1. Send from a use case
send answers when the transport ACCEPTED the message, which is not the same as delivered. Nothing this side of a mailbox can promise delivery; an application that needs those semantics reads its provider's webhooks.
class Notifier extends Port("Notifier")<{
// Both arms of the transport's own vocabulary: what it should retry, and
// what it should dead-letter on the first attempt.
readonly placed: (order: Order) => AsyncResult<void, RetryableError | NonRetryableError>;
}> {}
// SMTP separates them for you: 4xx is transient (busy relay, locked mailbox),
// 5xx is permanent (no such recipient, message rejected). `MailNotSent.reason`
// carries the transport's own words, which is where the code is.
const permanent = (reason: string) => /\b5\d\d\b/.test(reason);
export const notifier = Provider(Notifier)({
inject: { mailer: Mailer },
sync: ({ mailer }) => ({
placed: (order) =>
mailer
.send({
from: "orders@example.test",
to: [order.customerEmail],
subject: `Order ${order.id} placed`,
// `text` is required: a mail with only HTML is a mail some clients
// cannot read.
text: `Order ${order.id} is on its way.`,
html: `<p>Order <strong>${order.id}</strong> is on its way.</p>`,
})
.mapErrCases((matcher) =>
matcher.with(P.tag("MailNotSent"), (error) =>
permanent(error.reason)
? // Dead-lettered on the first attempt: a bad address will be
// bad on every one, and retrying it spends a delivery budget
// and, at volume, a sender reputation.
new NonRetryableError(`order ${order.id} cannot be notified: ${error.reason}`)
: new RetryableError(`order ${order.id} was not notified: ${error.reason}`),
),
),
}),
});2. Compose the starter
export const NotifierApp = Module("NotifierApp")({
imports: [AppModule, mailer({ adapter: smtpMailer() }), observability()],
provides: [notifier],
exports: [Notifier, Logger],
});smtpMailer() binds SMTP_URL through Config — one URL rather than a host/port/user/password quartet, because that is what the transport takes and what a deployment already stores as one secret — and holds one pooled transport as a resource of the graph, closed when the scope closes. It also contributes a health check (verify(), a connection and an authentication) that the kernel folds into /healthz.
nodemailer is an optional peer, reached only through the @btravstack/mailer/smtp subpath: an application that composes the recording adapter never installs it.
3. Decide what a failure means — the port will not
MailNotSent carries { to, subject, reason } and never the body, since a body is the one part of a message that is reliably somebody's personal data. There is no retry inside the port, deliberately: what to do about a failed send belongs to the caller's transport, whose retry budget already exists.
Not every failure deserves a retry, and the port cannot tell you which — which is why the mapping above splits them rather than calling everything retryable. Retrying a permanent failure costs a delivery budget and, at volume, a sender reputation; a 5xx will be a 5xx on every attempt.
The mapping above is the AMQP answer — a RetryableError leaves the delivery un-acked, so the broker hands it to the next worker on its own budget. Under Temporal it would be an ordinary activity failure and the platform's retry policy; under HTTP it is a decision about the response, and usually a sign the send should not have been in the request path at all.
The whole envelope, when you need it
export const invoiceMail = (order: Order, pdf: Uint8Array) => ({
from: "billing@example.test",
to: [order.customerEmail],
cc: ["accounts@example.test"],
bcc: ["archive@example.test"],
subject: `Invoice for order ${order.id}`,
text: "Your invoice is attached.",
attachments: [
{ filename: `invoice-${order.id}.pdf`, content: pdf, contentType: "application/pdf" },
],
headers: { "X-Order-Id": order.id },
});An attachment is bytes or a string, never a path or a stream: a caller that has a file can read it, and neither the port nor an adapter should have to own a filesystem and a lifetime. Templating is not here — text and html are strings, and what rendered them is a library you chose.
4. Assert on what would have been sent
The recording adapter sends nothing and keeps everything, which is what a test usually wants: the question is rarely "does SMTP work" but "would this code have sent the right message".
export const recorder = mailRecorder();
export const TestNotifier = overridden(NotifierApp, [recordingMailerProvider(recorder)]);
// … run the use case through the real graph, then:
// expect(recorder.only()).toEqual(expect.objectContaining({ subject: "Order 42 placed" }));recordingMailer(recorder) is the same adapter as a module, for a root that composes it directly rather than overriding one. It cannot fail: a spec that needs the failure arm composes an adapter of its own, since a configurable failure mode would put a policy in a fixture whose whole value is having none.
Where to go next
- The port's surface:
@btravstack/mailer. - Where a notification usually belongs — reacting to a committed fact: Consume AMQP messages.
- Swapping adapters under the real root: Swap an adapter for tests.