Mail

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.

App-facing port

bun add @beignet/core
import 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.

Provider setup

Use Resend when you want an HTTP email service:

bun add @beignet/provider-mail-resend resend
import { mailResendProvider } from "@beignet/provider-mail-resend";

export const providers = [mailResendProvider];

Resend reads RESEND_API_KEY and RESEND_FROM.

Use SMTP when your deployment already has SMTP credentials:

bun add @beignet/provider-mail-smtp nodemailer
import { mailSmtpProvider } from "@beignet/provider-mail-smtp";

export const providers = [mailSmtpProvider];

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.

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 { createJobHandlers } from "@beignet/core/jobs";
import { z } from "zod";
import type { AppContext } from "@/app-context";

const jobs = createJobHandlers<AppContext>();

export const SendWelcomeEmailJob = jobs.defineJob("mail.welcome", {
  payload: z.object({
    email: z.string().email(),
  }),
  retry: {
    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 Inngest 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

The Resend and SMTP providers record mail.send, mail.sent, and mail.failed devtools events when ctx.ports.devtools 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.

Read next