Skip to content

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

bash
bun beignet provider add mail-resend
bun beignet make task mail.send-test
bun install

The 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:

typescript
// 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:

typescript
// 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();
	}
});
bash
bun run format
bun run test
bun beignet check

The 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:

dotenv
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:

bash
bun beignet db migrate
bun beignet explain task mail.send-test

The 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:

bash
bun --bun run beignet task run mail.send-test --input '{"to":"you@example.com"}' --json

Expect 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

SymptomCheck
The task cannot be foundKeep the generated feature export and server/tasks.ts registration. Run bun beignet doctor --strict.
Startup reports a missing key or senderAdd RESEND_API_KEY and RESEND_FROM to .env.local in the app root.
Resend rejects the senderCheck domain verification and use an address on that domain.
The task succeeds but no message arrivesInspect the message in Resend, including delivery or bounce events, and check spam filtering.
The request times outCheck 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:

text
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.