Send an email
Configure Resend, send a test message from an operator task, and test the same
task with a memory mailer. Application code calls ctx.ports.mailer; the
provider owns the connection to the email service.
Before you start
Use the Next.js and SQLite app from the quickstart. Run commands from its root with Bun. You can write and run the tests without an email account. Sending a real message requires a Resend API key and a sender on a verified domain.
This guide uses a task so you can send a message from your terminal without adding an HTTP endpoint. For mail triggered by a business operation, use a job and an outbox when delivery must survive a restart.
Add the provider and task
bun beignet provider add mail-resend
bun beignet make task mail.send-test
bun installThe provider preset adds the mailer port and registers Resend in your server.
The task generator adds lib/tasks.ts, a feature task registry, and
server/tasks.ts. Keep those registrations.
Replace the generated task with this file:
// features/mail/tasks/send-test.ts
import { z } from "zod";
import { defineTask } from "@/lib/tasks";
export const SendTestTaskInputSchema = z.object({
to: z.email(),
});
export type SendTestTaskInput = z.infer<typeof SendTestTaskInputSchema>;
export const sendTestTask = defineTask("mail.send-test", {
input: SendTestTaskInputSchema,
description: "Send a test email to a specified recipient.",
async handle({ input, ctx }) {
const receipt = await ctx.ports.mailer.send({
to: input.to,
subject: "Your Beignet mail setup works",
text: "This message was sent through your application's mail port.",
});
return { provider: receipt.provider, id: receipt.id };
},
});The task validates the recipient before calling the mailer. The fixed message keeps this first send focused on verifying your configuration.
Test without sending mail
Add a test beside the feature's other tests:
// features/mail/tests/send-test.test.ts
import { createMemoryMailer } from "@beignet/core/mail";
import { runTask } from "@beignet/core/tasks";
import { createTestContext } from "@beignet/core/testing";
import type { AppContext } from "@/app-context";
import { expect, test } from "@/lib/beignet-test";
import { sendTestTask } from "../tasks/send-test";
const makeContext = createTestContext<AppContext>();
test("sends one message and rejects invalid recipients before delivery", async () => {
const mailer = createMemoryMailer({ defaultFrom: "hello@example.com" });
const fixture = makeContext({ ports: { mailer }, ambient: false });
try {
const receipt = await runTask(sendTestTask, {
input: { to: "reader@example.com" },
ctx: fixture.ctx,
});
expect(mailer.deliveries).toHaveLength(1);
expect(receipt).toMatchObject({ id: mailer.deliveries[0].id });
expect(mailer.deliveries[0].message.to).toEqual(["reader@example.com"]);
expect(mailer.deliveries[0].message.subject).toBe("Your Beignet mail setup works");
await expect(runTask(sendTestTask, {
input: { to: "not-an-email" },
ctx: fixture.ctx,
})).rejects.toMatchObject({ name: "TaskValidationError" });
expect(mailer.deliveries).toHaveLength(1);
} finally {
await fixture.dispose();
}
});bun run format
bun run test
bun beignet checkThe test supplies a memory mailer directly, so it needs no Resend credentials and makes no network request. The same task runs against the registered Resend provider when invoked through the CLI.
Send a real message
Add these entries to your existing .env.local, replacing both placeholders:
RESEND_API_KEY=replace-with-your-resend-api-key
RESEND_FROM=Your App <hello@your-verified-domain.example>Use a domain verified in your Resend account. See the Resend setup instructions if you have not created an API key. Keep the key server-side and out of Git.
The CLI task starts your app's configured providers. Make sure the starter's database is migrated before running it:
bun beignet db migrate
bun beignet explain task mail.send-testThe explanation should identify mail.send-test and its source file. Replace you@example.com below
with a mailbox you control, then run the command once:
bun --bun run beignet task run mail.send-test --input '{"to":"you@example.com"}' --jsonExpect a successful result with a provider name and message ID. Find that ID in Resend and check your inbox. Provider acceptance means the service accepted the message; it does not prove delivery to the inbox.
--bun runs the task CLI with Bun so it can resolve the starter's TypeScript
path aliases when loading your app. See Bun's runtime flag.
Troubleshooting
| Symptom | Check |
|---|---|
| The task cannot be found | Keep the generated feature export and server/tasks.ts registration. Run bun beignet doctor --strict. |
| Startup reports a missing key or sender | Add RESEND_API_KEY and RESEND_FROM to .env.local in the app root. |
| Resend rejects the sender | Check domain verification and use an address on that domain. |
| The task succeeds but no message arrives | Inspect the message in Resend, including delivery or bounce events, and check spam filtering. |
| The request times out | Check the provider's message history before sending again; a timed-out request may already have delivered. |
The Resend mail provider does not retry a failed send automatically. Keep business email out of a request's best-effort callback when loss matters. For durable delivery, follow the background job guide and design retry-safe handlers. Use notifications when one application event may reach several communication channels.
Work with your agent
Share this page using Copy page, then adapt this prompt:
Add the send-email guide to this Beignet app. Read the app instructions and
installed package guidance first. Inspect existing providers and tasks before
generating anything, and preserve custom configuration. Add mail.send-test and
test it with the memory mailer: one valid input sends one message, and an invalid
recipient sends none. Run format, tests, and beignet check. Leave credentials
out of source control and show me the command for sending to my chosen mailbox.