# Mail

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

- Package: `@db3.ai/app/mail`
- Canonical page: [https://db3.ai/docs/mail](https://db3.ai/docs/mail)
- Markdown: [https://db3.ai/docs/mail.md](https://db3.ai/docs/mail.md)
- Framework source of truth: `packages/app/src/mail/README.md`

<a id="setup"></a>

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

- [Installation and tools](https://db3.ai/docs/installation.md)

### Copy the shipped example

```bash
mkdir -p examples
cp -R node_modules/@db3.ai/app/src/mail/examples/. examples/
```

<a id="run"></a>

## 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 &amp; 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
```

<a id="template"></a>

## 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

```typescript
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;');
}
```

<a id="send"></a>

## 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

```typescript
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 <hello@example.test>', 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('ada@example.test', '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));
}
```

<a id="configuration"></a>

## 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

```dotenv
MAIL_TRANSPORT=file
MAIL_FROM="Notes <hello@example.test>"
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
```

<a id="app"></a>

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

- [App service ownership](https://db3.ai/docs/app.md)

### Application-owned service getter

```typescript
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());
	}
}
```

<a id="failures"></a>

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

- [Queue retries and failure recovery](https://db3.ai/docs/queue-overview.md#retries-and-failures)

<a id="capabilities"></a>

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

- [Message and custom transport contracts](https://db3.ai/docs/mail-api.md#messages)

<a id="privacy"></a>

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

<a id="testing"></a>

## 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

```typescript
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: ['ada@example.test'], rejectedEmptyRecipients: true, files: 1, from: 'Notes <hello@example.test>', 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('ada@example.test', '<b>Ada</b>').html).toContain('&lt;b&gt;Ada&lt;/b&gt;');
	expect(() => welcomeMessage('invalid', 'Ada')).toThrow('recipient');
	expect(() => welcomeMessage('ada@example.test', '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: 'ada@example.test', 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 <hello@example.test>', transport: new ResendTransport({ apiKey: 'test-only', baseUrl: 'https://mail-provider.example.test' }) });
	const message = welcomeMessage('ada@example.test', '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: ['ada@example.test'] });
	expect(fetch).toHaveBeenCalledTimes(2);
});
```

<a id="run-tests"></a>

## 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
```

<a id="coverage"></a>

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

- [Mail API reference](https://db3.ai/docs/mail-api.md)
- [Schedule recurring work](https://db3.ai/docs/scheduler.md)

## Behavioural verification
Writes and inspects one real file preview, rejects invalid messages and tests controlled provider failure/recovery without sending email.
- Behaviour test: `packages/app/src/mail/tests/examples/runMailPreview.test.ts`
- Repository test command (framework checkout only): `npm run test:service --workspace @platform/app -- mail --maxWorkers=1`
- In an installed application, use the walkthrough commands instead of this repository test.
- Expected outcome: The guide test passes against the real framework components.
- Environment: Node.js 24; local temporary files. External HTTP is simulated in tests; no real key, provider or database.

## Related documentation
- [Mail API reference](https://db3.ai/docs/mail-api.md): Current emitted signatures and options for @db3.ai/app/mail.
- [App](https://db3.ai/docs/app.md): Create one application at boot. Configure its services, use request-local state and close the resources you own.
- [Auth](https://db3.ai/docs/auth.md): Give an account one or more login methods. Issue bearer sessions, reset passwords and revoke access without mixing identity with credentials.
- [Queue](https://db3.ai/docs/queue-overview.md): Create durable background jobs, run named workers, understand every attempt, compose chains and batches, and recover failures through one complete service guide.
- [Scheduler](https://db3.ai/docs/scheduler.md): Decide when a daily task is due, claim its occurrence and hand expensive work to Queue. Inspect what happened without hiding schedule definitions in a database.

## Framework-owned source: `packages/app/src/mail/README.md`

This is the exact source document captured by the documentation build. Use it for detailed API and workflow guidance, subject to the public package exports and behavioural evidence identified above.

````markdown
# Mail

`@db3.ai/app/mail` provides a small provider-independent boundary for
outbound application email. It checks that recipients and body content are present, resolves
a default sender, and delegates delivery to an injectable `MailTransport`.

The service owns message normalization and the built-in file, Mailgun, and
Resend transports. Applications own templates, localization, recipient policy,
queueing, and deciding which business event should send a message. Sending is
immediate; use the Queue service when delivery must be durable or retried.

## Run a welcome email without sending it

Install the package using the [installation guide](https://db3.ai/docs/installation). During the unpublished preview, use matching App and Pure tarballs. Then copy the installed examples:

```sh
npm install --save-dev tsx typescript @types/node vitest
mkdir -p examples
cp -R node_modules/@db3.ai/app/src/mail/examples/. examples/
npx tsx examples/runMailPreview.ts
```

The command uses a real `FileMailTransport` in its own temporary directory. It rejects an empty recipient list, writes one welcome message for `ada@example.test`, reads it back and removes only that directory in `finally`. It makes no network requests, even if provider credentials exist in your environment.

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

[`welcomeMessage.ts`](./examples/welcomeMessage.ts) owns application input validation and HTML text escaping. `Mail` does not validate email addresses, sanitize headers, or escape your HTML. Escape values for the context where they are inserted; HTML text escaping is not URL validation or a general HTML sanitizer. Do not expose an unrestricted public send-mail endpoint.

The complete [Mail walkthrough](https://db3.ai/docs/mail) includes both source files and the copyable consumer test. Copy its test into `tests/mail/runMailPreview.test.ts`, then run:

```sh
npx vitest run tests/mail/runMailPreview.test.ts
npx tsc --noEmit --module ESNext --moduleResolution Bundler --target es2022 --types node --skipLibCheck examples/*.ts
```

Those tests use real file delivery and real Mail/Resend components with only the external `fetch` boundary replaced. They cover template validation, missing credentials, rejection before sending, provider failure and explicit retry. No live email delivery is claimed.

## Module Ownership

Mail is a service-owned module inside `packages/app`:

```text
mail/
	Mail.ts
	transports/
	tests/
	index.ts
	README.md
```

The module has no dependency on the framework `App`. Applications may construct
one `Mail` instance directly or expose it from their own `App` subclass through
the normal `service(...)` cache. The HTTP transports depend only on the runtime
`fetch` API, while the file transport uses the local filesystem.

## Public API

Import supported APIs from the package subpath:

```ts
import { Mail, createMailFromEnv, type MailDelivery, type MailMessage, type MailTransport } from '@db3.ai/app/mail';
```

The public surface includes:

- `Mail`, which resolves and sends `MailMessage` values.
- `MailAddress`, accepting either an address string or an `{ email, name }`
  object.
- `MailTransport`, the provider boundary applications can implement.
- `MailDelivery`, the normalized result returned after a successful send.
- `createMailFromEnv(...)` and `createMailTransportFromEnv(...)`, which apply
  the framework environment-variable convention.
- `FileMailTransport`, `MailgunTransport`, and `ResendTransport`.
- `formatMailAddress(...)` and `mailAddressEmail(...)` for transport authors.

`ResolvedMailMessage` is the transport-facing shape. It always contains a
resolved sender and an array of recipients, so custom transports do not need to
repeat that normalization.

## Configuration

`createMailFromEnv()` recognizes these common variables:

```env
MAIL_TRANSPORT=file
MAIL_FROM="Example App <no-reply@example.com>"
MAIL_FILE_DIRECTORY=storage/mail
```

`MAIL_TRANSPORT` supports `file`, `mailgun`, or `resend`. `MAIL_MAILER` is
accepted as a legacy fallback when `MAIL_TRANSPORT` is absent. An absent or
unrecognized value selects the file transport, keeping local development from
accidentally sending external email.

In production, validate `MAIL_TRANSPORT` against an explicit allowlist before constructing Mail. A typo otherwise selects file delivery and can look like a successful send. File messages are plaintext: keep the directory outside public storage, restrict access and set your own retention policy.

Mailgun requires:

```env
MAIL_TRANSPORT=mailgun
MAILGUN_API_KEY=key-example
MAILGUN_DOMAIN=mg.example.com
MAILGUN_BASE_URL=https://api.mailgun.net/v3
```

Resend requires:

```env
MAIL_TRANSPORT=resend
RESEND_API_KEY=re_example
RESEND_BASE_URL=https://api.resend.com
```

The base URL variables are optional and primarily useful for compatible
gateways and controlled tests. Credentials belong in the application's secret
store or deployment environment, never in committed configuration.

## Normal Workflows

Create one shared mail service during application boot:

```ts
import { createMailFromEnv } from '@db3.ai/app/mail';

const mail = createMailFromEnv(process.env, {
	from: 'Example App <no-reply@example.com>',
});
```

Send text, HTML, or both:

```ts
const delivery = await mail.send({
	to: {
		email: user.email,
		name: user.name,
	},
	subject: 'Reset your password',
	text: `Open ${resetUrl} to reset your password.`,
	html: `<p>Open <a href="${resetUrl}">this reset link</a>.</p>`,
});
```

The returned `MailDelivery` contains the provider message id, transport name,
normalized accepted and rejected address lists, and the written path for file
deliveries.

Applications can inject a transport explicitly. This is the preferred seam for
application tests and provider extensions:

```ts
const mail = new Mail({
	from: 'Example App <no-reply@example.com>',
	transport: new ApplicationMailTransport(),
});
```

The file transport writes one readable JSON record per message. It is the
default transport and is suitable for local inspection without contacting an
external provider.

## Errors And Failure Behaviour

`Mail.send(...)` rejects before invoking the transport when the recipient array
is empty or when neither `text` nor `html` content is present. A message-level
`from` value overrides the service default.

Environment construction fails immediately when the selected provider is
missing required credentials. Mailgun and Resend reject non-successful HTTP
responses with a provider-specific error message, while network failures
propagate to the caller. Invalid JSON falls back to the HTTP status text for a
rejection and an empty provider id for a successful response. A successful HTTP
response currently reports all submitted recipients as accepted because the
provider APIs used here do not return per-recipient status in this boundary.

The framework does not retry a failed send or persist an outbox. Dispatch a
dedicated queued job when the application needs retry policy, idempotency, or
delivery that survives process shutdown.

Queue retry alone does not prevent duplicate email. A timeout can follow provider acceptance. These built-in HTTP transports do not expose provider idempotency keys or an outbox transaction; a message header is not an API idempotency key. Make that business decision explicitly.

`accepted` means transport/provider acceptance, not inbox delivery. The built-ins have no attachments, CC/BCC, delivery tracking, scheduled sending or explicit request timeout. File and Resend preserve custom `headers`; Mailgun currently does not forward them. `App` has no built-in `mail` getter, and Mail has no close method. Own any resources introduced by your custom transport.

The [full API reference](https://db3.ai/docs/mail-api) is generated from the shipped declarations for Mail and all three transports.

## Testing And Verification

The service-owned behaviour tests exercise file output, message validation,
environment selection, Resend request formatting, and provider rejection:

```sh
npm run test:service --workspace packages/app -- mail
```

Run the complete framework suite and source checks before publishing a change:

```sh
npm test --workspace packages/app
npm run check --workspace packages/app
```

External mail APIs are controlled through the injectable transport or a stubbed
`fetch` boundary. Tests must not send live email. Relevant implementation lives
in [`Mail.ts`](./Mail.ts) and [`transports/`](./transports). Behavioural tests
live at `packages/app/src/mail/tests/` in the source repository and are not
included in the installed runtime package.
````

## Guidance for AI tools
Use the documented public import `@db3.ai/app/mail` and its exported types. Prefer the source-backed examples and behavioural outcomes above over invented APIs or source-relative internal imports.
