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.

Use cases can send mail through the same interface in production and tests.

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.

Setup

To send through Resend from the Quickstart app, add the preset:

bun beignet provider add mail-resend
bun install

The preset adds mailer to AppPorts, defers it in infra/port-wiring.ts, and registers the provider alongside your existing providers. Set RESEND_API_KEY and RESEND_FROM in .env.local; use a sender approved for your Resend account. Then run:

bun beignet provider audit
bun beignet doctor --strict
bun run dev

Send a message through the port shown below and check its delivery status in Resend. For local work without a mail account, replace the Resend entry with the memory provider before starting the app.

App-facing port

bun add @beignet/core
// ports/index.ts (excerpt)
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.

Beignet's shared formatter rejects carriage returns and line feeds in address strings, email fields, and display names before the Resend or SMTP provider builds its recipient headers. Named-address backslashes and quotes are escaped; full email-syntax validation remains the provider's responsibility.

Dev-default provider

After the setup above, replace only createResendMailProvider() in server/providers.ts with createMemoryMailerProvider(...). Remove the unused Resend import and keep the remaining provider entries and deferred mailer port. It captures deliveries in memory and records mail.sent devtools events through the mail watcher when instrumentation is installed, including the recipient count, delivery ID, and duration, so local development can see outgoing mail without sending anything. The event excludes addresses, subjects, and message bodies; inspect the memory mailer's captured deliveries only in trusted development and test code when message content is needed.

--- a/server/providers.ts
+++ b/server/providers.ts
@@ -1,1 +1,1 @@
-import { createResendMailProvider } from "@beignet/provider-mail-resend";
+import { createMemoryMailerProvider } from "@beignet/core/mail";
@@ -1,1 +1,3 @@
-  createResendMailProvider(),
+  createMemoryMailerProvider({
+    defaultFrom: "App <noreply@example.local>",
+  }),

Remove the unused provider package so doctor no longer expects its registration:

bun remove @beignet/provider-mail-resend
bun run typecheck
bun beignet doctor --strict

Keep the resend SDK only if other application code uses it. To restore real delivery later, remove the memory entry and import, then rerun bun beignet provider add mail-resend and bun install.

Provider setup

The setup above configures Resend for HTTP delivery. Keep that setup when using Resend; the following excerpt shows only its registry entry.

The Resend provider supports resend ^2, ^3, ^4, and ^6. Resend v6 uses Beignet's Node.js 22.12-or-newer runtime baseline, which also satisfies the SDK's requirement.

// server/providers.ts (excerpt)
import { createResendMailProvider } from "@beignet/provider-mail-resend";

export const providers = [createResendMailProvider()];

Resend reads RESEND_API_KEY and RESEND_FROM.

Use SMTP instead when your deployment already has SMTP credentials. If you configured Resend or the memory provider above, remove that mail provider entry and its import from server/providers.ts first; keep the mailer port declaration and deferred key. Remove @beignet/provider-mail-resend from your dependencies if it is still installed, so doctor does not expect that provider. Then add SMTP:

bun beignet provider add mail-smtp
bun install
// server/providers.ts (excerpt)
import { 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, MAIL_FROM, and optional MAIL_REQUIRE_TLS. Encrypted transport is required by default: port 465 uses implicit TLS, while other ports must negotiate STARTTLS. Set MAIL_REQUIRE_TLS=false only for a trusted local mail-capture service that does not support TLS.

Nodemailer 9.x starting at 9.1.1 is required because earlier releases are affected by address-parsing and content-access vulnerabilities.

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. The raw SMTP transporter bypasses Beignet's typed mail validation, so construct raw messages, attachment paths, URLs, and recipients only from trusted application data.

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 provider instrumentation is installed, both providers emit mail.send followed by mail.sent or mail.failed. Terminal events include durationMs in their details so mail latency is comparable with storage and other provider operations in devtools. Mail instrumentation records the provider, recipient count, delivery ID when available, and duration. It does not record addresses, subjects, message bodies, or raw provider errors. Delivery errors retain the provider error as cause for app-owned handling, so redact or restrict access when logging that cause.

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.