Mail

Build and preview an application email locally, then select a transport when you are ready to send it.

On this pageSource-backed Markdown

Preview a welcome email

Complete Installation and copy the shipped mail example from your independent app directory. The example deliberately constructs FileMailTransport; it cannot send external email even if your shell contains real provider settings.

Mail owns message normalization and transport selection. Your application owns the template, authorized recipients, when to send and whether a retry is safe. There is no built-in app().mail getter; construct one shared Mail instance or expose it from your own App subclass.

Copy the shipped example
bash
mkdir -p examples
cp -R node_modules/@db3.ai/app/src/mail/examples/. examples/

Run and inspect the preview

Expect transport: "file", files: 1, rejectedEmptyRecipients: true and subject Welcome to your notes. The text contains Ada & team; the HTML contains Ada & team.

The lab tries an empty recipient list, repairs the message, writes a real JSON preview, reads it and prints the selected contents. It removes its temporary directory in finally. No message is delivered to an inbox.

Run the lab
bash
npx tsx examples/runMailPreview.ts

Keep the template in the application

welcomeMessage() returns the public MailMessage shape. It checks the recipient and display name, produces plain text and escapes name text before putting it in HTML. Use a suitable template renderer when templates grow.

Mail itself only checks for a nonempty recipient list and some text or HTML. It does not validate email addresses, sanitize headers or escape HTML. Never turn this example into an unrestricted endpoint that accepts arbitrary recipients or HTML.

examples/welcomeMessage.ts
ts
import type { MailMessage } from '@db3.ai/app/mail';
import { isEmail } from '@db3.ai/app/validation';

/**
 * Builds an application-owned welcome message with safe plain text and HTML.
 *
 * @param email - Recipient selected by the application, not an arbitrary send API.
 * @param name - Display name rendered as text, never trusted HTML.
 * @returns Message ready for any supported mail transport.
 */
export function welcomeMessage(email: string, name: string): MailMessage {
	if (!isEmail(email)) throw new Error('A valid recipient email is required.');
	const displayName = name.trim();
	if (!displayName || displayName.length > 80 || /[\r\n]/.test(displayName)) throw new Error('Name must be 1–80 characters without line breaks.');
	return {
		to: { email, name: displayName },
		subject: 'Welcome to your notes',
		text: `Hello ${displayName}, your notebook is ready.`,
		html: `<p>Hello ${escapeHtml(displayName)}, your notebook is ready.</p>`,
	};
}

/** Escapes user-controlled text for an HTML text node, not a URL or script. */
function escapeHtml(value: string): string {
	return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;');
}

Send once and inspect the result

mail.send() resolves the default sender and converts a single recipient into an array. A message-level from overrides the service default. Supply text, html or both.

MailDelivery contains a transport, message ID, accepted/rejected addresses and a path for file previews. Provider acceptance is not inbox delivery. Current HTTP transports report submitted recipients as accepted after a successful response; they do not process bounces or delivery webhooks.

examples/runMailPreview.ts
ts
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { FileMailTransport, Mail } from '@db3.ai/app/mail';
import { welcomeMessage } from './welcomeMessage';

/**
 * Writes, reads and removes a local welcome-email preview without sending email.
 *
 * @returns Selected message contents and the observed validation/recovery outcome.
 */
export async function runMailPreview() {
	const directory = await mkdtemp(join(tmpdir(), 'db3-mail-guide-'));
	try {
		const mail = new Mail({ from: 'Notes <[email protected]>', transport: new FileMailTransport({ directory }) });
		let rejectedEmptyRecipients = false;
		try {
			await mail.send({ to: [], subject: 'Invalid', text: 'This should not be written.' });
		} catch (error) {
			if (!(error instanceof Error) || error.message !== 'Mail requires at least one recipient.') throw error;
			rejectedEmptyRecipients = true;
		}
		const delivery = await mail.send(welcomeMessage('[email protected]', 'Ada & team'));
		if (!delivery.path) throw new Error('The file transport did not return a preview path.');
		const preview = JSON.parse(await readFile(delivery.path, 'utf8'));
		return {
			transport: delivery.transport,
			accepted: delivery.accepted,
			rejectedEmptyRecipients,
			files: (await readdir(directory)).length,
			from: preview.from,
			subject: preview.subject,
			text: preview.text,
			html: preview.html,
		};
	} finally {
		await rm(directory, { recursive: true, force: true });
	}
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
	console.log(JSON.stringify(await runMailPreview(), null, 2));
}

Choose a transport explicitly

For application boot, createMailFromEnv(process.env) reads MAIL_TRANSPORT, MAIL_FROM and provider settings. Load your .env before calling it. Keep credentials on the server and outside Git.

Use file with a private MAIL_FILE_DIRECTORY for local inspection. Resend needs RESEND_API_KEY. Mailgun needs both MAILGUN_API_KEY and MAILGUN_DOMAIN. Select a sender/domain verified for your provider before attempting real delivery.

Missing selected-provider credentials fail during construction. A missing or unrecognized transport name currently falls back to file; production boot should allow-list its intended transport so a typo cannot silently become local-only delivery. MAIL_MAILER is a legacy fallback.

Local application .env, not required by the isolated lab
txt
MAIL_TRANSPORT=file
MAIL_FROM="Notes <[email protected]>"
MAIL_FILE_DIRECTORY=storage/mail

# Provider alternatives: set the selected provider and its real server-side key.
# MAIL_TRANSPORT=resend
# RESEND_API_KEY=replace-with-your-key
# MAIL_TRANSPORT=mailgun
# MAILGUN_API_KEY=replace-with-your-key
# MAILGUN_DOMAIN=mg.your-domain.example

Share Mail from your App

Create the mail service once during boot or expose a typed getter through this.service(). Keep delivery calls in application services and jobs, not in the reusable framework root.

File sends and HTTP requests are awaited operations. There is no Mail close() API or background retry loop. Custom transports that own pools or handles must expose and run their own cleanup.

Application-owned service getter
ts
import { App } from '@db3.ai/app/server';
import { createMailFromEnv, type Mail } from '@db3.ai/app/mail';

export class Application extends App {
	/** Returns the application-owned mail service, configured once on first use. */
	get mail(): Mail {
		return this.service('mail', () => createMailFromEnv());
	}
}

Failures and retry policy

Await send() and handle rejection at the application boundary. Mailgun and Resend include provider rejection details; network failures propagate. Do not show raw provider errors to end users or log reset links, credentials and message bodies.

Mail makes one attempt. Queue can make delivery durable and retry failures, but an ambiguous timeout may mean the provider accepted the email. Replaying without idempotency can send twice. The current message API does not expose a provider idempotency key, and a custom message header is not a substitute for one. Treat provider-backed exactly-once delivery as a separate application/transport design.

What each transport supports

All three built-in transports handle a sender, one or more recipients, subject, plain text and HTML. File and Resend preserve message headers; the current Mailgun transport does not forward them.

There is no first-class attachment, CC/BCC, reply-to, bulk-send, cancellation, scheduling, streaming, provider timeout/AbortSignal or template API. Use a documented custom transport boundary when your product needs more, and test it; do not pass undocumented fields and assume they are delivered.

Keep previews private

The file transport writes plaintext JSON, including HTML, headers and recipients. Keep its directory outside public storage, restrict filesystem access and define retention. Password reset and invitation links are credentials too.

The lab deletes only its own temporary directory. In an app, delivery history and cleanup are product responsibilities; Mail does not prune previews or persist a delivery ledger.

Testing

Create a tests/mail directory and save the test below as runMailPreview.test.ts. It imports the example you copied into examples/. Keep the same folder layout so the relative import resolves.

Run from the application root with the development dependencies from Installation. These are consumer tests, not commands that assume a framework checkout.

tests/mail/runMailPreview.test.ts
ts
import { afterEach, expect, it, vi } from 'vitest';
import { createMailFromEnv, Mail, ResendTransport } from '@db3.ai/app/mail';
import { runMailPreview } from '../../examples/runMailPreview';
import { welcomeMessage } from '../../examples/welcomeMessage';

afterEach(() => { vi.unstubAllGlobals(); });

it('rejects invalid recipients before writing, then creates one escaped local preview', async () => {
	expect(await runMailPreview()).toEqual({ transport: 'file', accepted: ['[email protected]'], rejectedEmptyRecipients: true, files: 1, from: 'Notes <[email protected]>', subject: 'Welcome to your notes', text: 'Hello Ada & team, your notebook is ready.', html: '<p>Hello Ada &amp; team, your notebook is ready.</p>' });
	expect(welcomeMessage('[email protected]', '<b>Ada</b>').html).toContain('&lt;b&gt;Ada&lt;/b&gt;');
	expect(() => welcomeMessage('invalid', 'Ada')).toThrow('recipient');
	expect(() => welcomeMessage('[email protected]', 'Ada\nBcc: someone')).toThrow('line breaks');
});

it('reports missing provider configuration without sending, and validates content before HTTP', async () => {
	expect(() => createMailFromEnv({ MAIL_TRANSPORT: 'resend' })).toThrow('RESEND_API_KEY');
	const fetch = vi.fn();
	vi.stubGlobal('fetch', fetch);
	const mail = new Mail({ transport: new ResendTransport({ apiKey: 'test-only' }) });
	await expect(mail.send({ to: '[email protected]', subject: 'Empty' })).rejects.toThrow('text or html');
	expect(fetch).not.toHaveBeenCalled();
});

it('surfaces a provider rejection and allows an explicit successful retry with no automatic retry', async () => {
	const fetch = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Temporarily unavailable' }), { status: 503 })).mockResolvedValueOnce(new Response(JSON.stringify({ id: 'preview-message' }), { status: 200 }));
	vi.stubGlobal('fetch', fetch);
	const mail = new Mail({ from: 'Notes <[email protected]>', transport: new ResendTransport({ apiKey: 'test-only', baseUrl: 'https://mail-provider.example.test' }) });
	const message = welcomeMessage('[email protected]', 'Ada');
	await expect(mail.send(message)).rejects.toThrow('Temporarily unavailable');
	expect(fetch).toHaveBeenCalledTimes(1);
	expect(await mail.send(message)).toMatchObject({ transport: 'resend', id: 'preview-message', accepted: ['[email protected]'] });
	expect(fetch).toHaveBeenCalledTimes(2);
});

Run and extend the tests

The three tests run the real file transport, validate and escape a message, reject missing provider configuration and simulate an HTTP failure followed by an explicit successful retry. Only external fetch is controlled; Mail is real.

Change the welcome text and expected preview together. Add your own recipient-policy, safe-link and provider-error tests before exposing mail through a route.

Run your copied test and check types
bash
npx vitest run tests/mail/runMailPreview.test.ts
npx tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --types node --skipLibCheck examples/*.ts

Coverage and next steps

Taught/tested: local preview, sender/recipient/message construction, HTML text escaping, presence validation, missing key, provider failure and explicit retry. Existing service tests also exercise Mailgun request formatting and rejection.

Live provider delivery, domain verification, deliverability, attachments, idempotency and webhooks are not proved by this lab. The reference renders every current Mail and built-in transport declaration. A scheduled email report remains a follow-on recipe until its delivery policy is tested.

Behaviour tested Executed by the documentation maintenance gate
What this does

Writes and inspects one real file preview, rejects invalid messages and tests controlled provider failure/recovery without sending email.

Expected outputThe guide test passes against the real framework components.
Behaviour testpackages/app/src/mail/tests/examples/runMailPreview.test.ts

This test command requires the framework repository. Use the walkthrough commands in an installed application.

Environment: Node.js 24; local temporary files. External HTTP is simulated in tests; no real key, provider or database.