Beignet treats mail as a port. Application code calls
ctx.ports.mailer.send(...); providers decide whether delivery happens through
Resend, SMTP, a memory adapter, or an app-owned service.
This keeps email out of use-case internals and makes workflows easy to test.
Use notifications when the application intent is broader than "send one email," such as appointment reminders, message alerts, or delivery that may later fan out to SMS, push, or in-app channels.
App-facing port
bun add @beignet/coreimport type { MailerPort } from "@beignet/core/mail";
export type AppPorts = {
mailer: MailerPort;
};Use cases and jobs depend on MailerPort, not a vendor SDK:
await ctx.ports.mailer.send({
to: "user@example.com",
subject: "Welcome",
text: "Thanks for joining.",
});send(...) requires at least one to recipient and accepts text, html, or
both. Recipients can be strings or named address objects:
await ctx.ports.mailer.send({
from: { email: "support@example.com", name: "Support" },
to: [
"user@example.com",
{ email: "admin@example.com", name: "Admin" },
],
cc: "audit@example.com",
replyTo: "support@example.com",
subject: "Account updated",
text: "Your account was updated.",
html: "<p>Your account was updated.</p>",
headers: {
"X-App-Event": "account.updated",
},
});Empty optional recipient lists are ignored, so callers can safely pass filtered
cc, bcc, or replyTo arrays.
Dev-default provider
Use createMemoryMailerProvider(...) to wire the mailer port before choosing
a real mail service. It captures deliveries in memory and records mail.sent
devtools events through the mail watcher when instrumentation is installed,
so local development can see outgoing mail without sending anything.
// server/providers.ts
import { createMemoryMailerProvider } from "@beignet/core/mail";
export const providers = [
createMemoryMailerProvider({
defaultFrom: "App <noreply@example.local>",
}),
] as const;Swap it for a real provider when the app needs delivery; the mailer port
shape stays the same.
Provider setup
Use Resend when you want an HTTP email service:
bun add @beignet/provider-mail-resend resendThe Resend provider supports resend ^2, ^3, ^4, and ^6. Resend v6
inherits the SDK's Node.js 20+ runtime requirement.
import { createResendMailProvider } from "@beignet/provider-mail-resend";
export const providers = [createResendMailProvider()];Resend reads RESEND_API_KEY and RESEND_FROM.
Use SMTP when your deployment already has SMTP credentials:
bun add @beignet/provider-mail-smtp nodemailerimport { createSmtpMailProvider } from "@beignet/provider-mail-smtp";
export const providers = [createSmtpMailProvider()];createResendMailProvider(options) and createSmtpMailProvider(options)
configure either provider in code; options mirror the env fields and override
env-derived values.
SMTP reads MAIL_HOST, MAIL_PORT, MAIL_USER, MAIL_PASS, and MAIL_FROM.
Both providers install the same ctx.ports.mailer shape. Resend also exposes
ctx.ports.resend.client; SMTP exposes ctx.ports.smtp.transporter. Treat
those as escape hatches for provider-specific
features such as attachments or custom delivery APIs.
Neither provider retries a failed send. Each provider makes one vendor call per
send(...); a Resend network error or SMTP sendMail(...) failure becomes a
MailDeliveryError. Mail delivery is not idempotent — a timed-out send may
still have been delivered — so the providers fail fast instead of risking
duplicate emails.
When mail needs retries, dispatch it from jobs or the outbox and own idempotency there. The job or outbox row should choose attempts and delay, and the application should record one delivery per business event before sending.
Send mail from jobs
For production workflows, prefer dispatching a job from the use case and sending mail in the job handler. The use case stays focused on the business decision, and mail delivery can be retried independently.
import { retry } from "@beignet/core/jobs";
import { z } from "zod";
import { defineJob } from "@/lib/jobs";
export const SendWelcomeEmailJob = defineJob("mail.welcome", {
payload: z.object({
email: z.string().email(),
}),
retry: retry.exponential({
attempts: 3,
}),
async handle({ payload, ctx }) {
await ctx.ports.mailer.send({
to: payload.email,
subject: "Welcome",
text: "Thanks for joining.",
});
},
});Then dispatch the job from the workflow:
await ctx.ports.jobs.dispatch(SendWelcomeEmailJob, {
email: user.email,
});Use Jobs for dispatcher and provider worker wiring.
Testing
Use the memory adapter in tests and local examples:
import { createMemoryMailer } from "@beignet/core/mail";
const mailer = createMemoryMailer({
defaultFrom: "noreply@example.com",
});
await mailer.send({
to: "user@example.com",
subject: "Welcome",
text: "Hello",
});
expect(mailer.deliveries).toHaveLength(1);
expect(mailer.deliveries[0].message.to).toEqual(["user@example.com"]);Because the memory adapter implements MailerPort, the same use case or job can
run against production and test adapters.
Devtools and errors
Mail operations appear in the Mail view of devtools when the devtools provider is installed.
Delivery failures throw MailDeliveryError from @beignet/core/mail. Catch
that error when mail delivery is expected to fail independently from the main
workflow; otherwise let the job runner record the failure and retry.