Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/docs/content/1.guide/12.organizations.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ AUTH_ALLOW_ORGANIZATION_CREATION=true # whether any signed-in user may create
::warning
**Mail transport required**

Enabling organizations requires a working mail transport, because invitations are delivered by email. The default `MAIL_PROVIDER=console` only logs — configure `resend` or `smtp` first. See [Mails](/guide/mails).
Enabling organizations requires a working mail transport, because invitations are delivered by email. The default `MAIL_PROVIDER=console` only logs — configure `resend`, `smtp`, or `cloudflare` first. See [Mails](/guide/mails).
::

`AUTH_ALLOW_ORGANIZATION_CREATION` is ignored while organizations are off. When creation is disallowed, only operators (through the database or a seeded flow) create organizations, and users join by invitation.
Expand Down
28 changes: 22 additions & 6 deletions apps/docs/content/1.guide/14.mails.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,32 @@ title: Mails

`MAIL_PROVIDER` selects the transport. When it is absent, a configured `RESEND_API_KEY` selects Resend so hosted integrations such as Vercel's work without a second selector variable. With neither set, the default is `console`, which logs instead of delivering — an unconfigured deployment cannot silently attempt real delivery. An explicit `MAIL_PROVIDER` always wins.

| Provider | Configuration |
| --------- | --------------------------------------------------------------------------------- |
| `console` | None. Logs the rendered message. The credential-free default; not for production. |
| `resend` | `RESEND_API_KEY` |
| `smtp` | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_SECURE` |
| Provider | Configuration |
| ------------ | ------------------------------------------------------------------------------------------ |
| `console` | None. Logs the rendered message. The credential-free default; not for production. |
| `resend` | `RESEND_API_KEY` |
| `smtp` | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_SECURE` |
| `cloudflare` | A `send_email` binding named `EMAIL` on the Worker. No variables. Cloudflare Workers only. |

`MAIL_FROM` sets the sender address for every provider.

`SMTP_SECURE` selects implicit TLS: `true` on port 465, `false` on port 587 (STARTTLS).

### Cloudflare Email Service

The managed cloud sends through [Cloudflare Email Service](https://developers.cloudflare.com/email-service/) instead of an API key. `apps/web/wrangler.jsonc` binds it as `EMAIL` and sets `MAIL_PROVIDER=cloudflare`, and the provider calls `env.EMAIL.send()` with the same rendered HTML and plaintext every other provider receives.

Two things sit outside the repository:

- the sender domain in `MAIL_FROM` must be onboarded and verified in the Cloudflare dashboard under Email Service, otherwise every send fails with `E_SENDER_DOMAIN_NOT_AVAILABLE` or `E_SENDER_NOT_VERIFIED`;
- the binding exists only inside a Worker, so selecting `cloudflare` anywhere else, or on a Worker deployed without the binding, fails at the first message rather than falling back to another transport.

A refused message rejects with the service's error code (`cloudflare email rejected the message: E_RECIPIENT_NOT_ALLOWED`), never with the recipient or the body.

### Receiving mail on the managed cloud

The Worker only sends. Mail for the cloud's own addresses (`support@`, `security@`, and so on) needs no code: enable [Email Routing](https://developers.cloudflare.com/email-routing/) on the domain, add a custom address whose action is **Send to an email**, and verify the destination under Email Routing's destination addresses.

## Templates

Templates are authored with Maizzle and declared in the package's template registry:
Expand Down Expand Up @@ -53,5 +69,5 @@ This serves every template at `http://localhost:3005` with hot reload, rendered
::tip
**Organizations depend on mail**

Organization invitations are delivered by email, so enabling [organizations](/guide/organizations) requires a real transport (`resend` or `smtp`).
Organization invitations are delivered by email, so enabling [organizations](/guide/organizations) requires a real transport (`resend`, `smtp`, or `cloudflare`).
::
1 change: 1 addition & 0 deletions apps/docs/content/1.guide/16.deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ npx wrangler deploy --config apps/web/.output/server/wrangler.json

- **`HYPERDRIVE`** — Cloudflare's pool in front of Postgres. Create it with `wrangler hyperdrive create --connection-string=...` and turn query caching off (`--caching-disabled=true`): sessions and permissions must never be read from a cache. A Worker opens a new database client for every request, because a connection cannot outlive the request that opened it.
- **`KV`** — the task-history namespace.
- **`EMAIL`** — the `send_email` binding for transactional mail, selected by `MAIL_PROVIDER=cloudflare`. Onboard the sender domain in Cloudflare Email Service first. See [Mails](/guide/mails).
- **Secrets** — `wrangler secret put NUXT_BETTER_AUTH_SECRET`, plus the model provider's key and the mail credentials. They are read as environment variables.

Two things differ from a self-hosted deployment. The poller does not run, because a Worker is frozen between requests; webhooks are the way work arrives until a scheduled trigger replaces it. Subscription model providers (`claude-code`, `codex-cli`) are not available, because they drive a CLI on the host.
Expand Down
14 changes: 7 additions & 7 deletions apps/docs/content/1.guide/4.environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,13 +134,13 @@ Sign-in methods are read at runtime from `/api/capabilities`, so `AUTH_ENABLE_SI

`console` logs instead of delivering and is the default when neither `MAIL_PROVIDER` nor `RESEND_API_KEY` is configured, so an unconfigured deployment cannot silently attempt real delivery. If `MAIL_PROVIDER` is absent, a Resend credential selects Resend automatically; an explicit provider always wins.

| Variable | Purpose |
| --------------------------------------------------------- | ------------------------------------------------------------------- |
| `MAIL_PROVIDER` | Optional explicit `console`, `resend`, or `smtp` selector |
| `MAIL_FROM` | Sender address |
| `RESEND_API_KEY` | Required for Resend; also selects it when `MAIL_PROVIDER` is absent |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` | Required when `MAIL_PROVIDER=smtp` |
| `SMTP_SECURE` | Implicit TLS: `true` on 465, `false` on 587 |
| Variable | Purpose |
| --------------------------------------------------------- | ----------------------------------------------------------------------- |
| `MAIL_PROVIDER` | Optional explicit `console`, `resend`, `smtp`, or `cloudflare` selector |
| `MAIL_FROM` | Sender address |
| `RESEND_API_KEY` | Required for Resend; also selects it when `MAIL_PROVIDER` is absent |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` | Required when `MAIL_PROVIDER=smtp` |
| `SMTP_SECURE` | Implicit TLS: `true` on 465, `false` on 587 |

See [Mails](/guide/mails).

Expand Down
2 changes: 2 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ BETTER_AUTH_API_KEY=
# automatically when MAIL_PROVIDER is absent; an explicit provider always wins.
MAIL_PROVIDER=console
MAIL_FROM=noreply@example.com
# MAIL_PROVIDER=cloudflare sends through a Workers send_email binding named EMAIL, so it has no
# variables of its own; it only works on the managed cloud's Worker (see wrangler.jsonc).
# Required when MAIL_PROVIDER=resend.
RESEND_API_KEY=
# Required when MAIL_PROVIDER=smtp. SMTP_SECURE is implicit TLS: true on 465, false on 587.
Expand Down
16 changes: 14 additions & 2 deletions apps/web/server/auth.config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { authBetterAuthOptions, authDatabaseOptionsFromEnvironment } from '@code-zero/auth';
import { optionalDatabaseUrlFromEnvironment } from '@code-zero/database';
import { createMailer, mailProviderNameFromEnvironment } from '@code-zero/mail';
import {
createMailer,
mailProviderFromEnvironment,
mailProviderNameFromEnvironment,
} from '@code-zero/mail';
import { defineServerAuth } from '@onmax/nuxt-better-auth/config';
import { memoryAdapter } from 'better-auth/adapters/memory';
import { testUtils } from 'better-auth/plugins';

import { database } from './utils/database.js';
import { emailBinding } from './utils/mail-binding.js';

// This module is the composition root for authentication, so it is where the mail transport is
// bound and injected. `packages/auth` declares the delivery contract structurally and never
Expand All @@ -15,7 +20,14 @@ import { database } from './utils/database.js';
// provider is useful for exercising invitation flows locally. Production still withholds the
// callback for `console`, so `authBetterAuthOptions` fails startup instead of accepting invitations
// that will never reach their recipients.
const sendMail = createMailer();
//
// The transport is still resolved per message, as `createMailer()` does by default: only the
// `cloudflare` provider needs something from outside the environment, a Worker's `send_email`
// binding, and it is looked up from the request that sends.
const sendMail = createMailer({
provider: (mail) =>
mailProviderFromEnvironment(process.env, { cloudflareEmail: emailBinding })(mail),
});
const deliversMail =
mailProviderNameFromEnvironment() !== 'console' || process.env.NODE_ENV === 'development';

Expand Down
34 changes: 34 additions & 0 deletions apps/web/server/utils/mail-binding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { CloudflareEmailBinding } from '@code-zero/mail';

/** Narrows what Nitro leaves untyped on `event.context.cloudflare` to the `EMAIL` binding. */
export function emailBindingFrom(cloudflare: unknown): CloudflareEmailBinding | undefined {
if (typeof cloudflare !== 'object' || cloudflare === null || !('env' in cloudflare)) {
return undefined;
}
const { env } = cloudflare;
if (typeof env !== 'object' || env === null || !('EMAIL' in env)) return undefined;
const { EMAIL: binding } = env;
return isEmailBinding(binding) ? binding : undefined;
}

function isEmailBinding(value: unknown): value is CloudflareEmailBinding {
return (
typeof value === 'object' &&
value !== null &&
'send' in value &&
typeof value.send === 'function'
);
}

/**
* The Worker's `send_email` binding for the request being served.
*
* Read from the request rather than held at startup, because a Worker receives its bindings with
* each request. Sending outside one, or from a Worker deployed without the binding, is a
* configuration error and says so.
*/
export function emailBinding(): CloudflareEmailBinding {
const binding = emailBindingFrom(useEvent().context.cloudflare);
if (!binding) throw new Error('the EMAIL send_email binding is not configured on this Worker');
return binding;
}
33 changes: 33 additions & 0 deletions apps/web/test/unit/mail-binding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from 'vitest';

import { emailBindingFrom } from '../../server/utils/mail-binding.js';

const MESSAGE = {
from: 'noreply@example.com',
to: 'operator@example.com',
subject: 'Hello',
html: '<p>Hello</p>',
text: 'Hello',
};

describe('emailBindingFrom', () => {
it('finds the send_email binding on the Worker environment', async () => {
const send = vi.fn<(message: unknown) => Promise<unknown>>().mockResolvedValue(undefined);

const binding = emailBindingFrom({ env: { EMAIL: { send } } });
await binding?.send(MESSAGE);

expect(send).toHaveBeenCalledExactlyOnceWith(MESSAGE);
});

it('answers nothing for a request that did not arrive through a Worker', () => {
expect(emailBindingFrom(undefined)).toBeUndefined();
expect(emailBindingFrom({})).toBeUndefined();
expect(emailBindingFrom({ env: {} })).toBeUndefined();
});

it('ignores a binding that cannot send', () => {
expect(emailBindingFrom({ env: { EMAIL: 'not-a-binding' } })).toBeUndefined();
expect(emailBindingFrom({ env: { EMAIL: {} } })).toBeUndefined();
});
});
8 changes: 8 additions & 0 deletions apps/web/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,15 @@
// Stated, never inferred from the host: see `CODE_ZERO_EDITION` in the deployment guide.
"CODE_ZERO_EDITION": "cloud",
"NUXT_PUBLIC_SITE_URL": "https://app.code-zero.dev",
// Mail goes out through the `EMAIL` binding below; the sender domain must be onboarded to
// Cloudflare Email Service first.
"MAIL_PROVIDER": "cloudflare",
"MAIL_FROM": "noreply@code-zero.dev",
},
"send_email": [
// Transactional mail (verification, password reset, invitations) with no API key to manage.
{ "name": "EMAIL" },
],
"hyperdrive": [
{
// Postgres, through Cloudflare's pool. Query caching is off on this configuration: sessions
Expand Down
4 changes: 4 additions & 0 deletions packages/mail/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ export {
type SendEmailOptions,
} from './mail.js';
export {
createCloudflareProvider,
createConsoleProvider,
createResendProvider,
createSmtpProvider,
MAIL_PROVIDER_NAMES,
mailProviderFromEnvironment,
mailProviderNameFromEnvironment,
type CloudflareEmailBinding,
type CloudflareProviderOptions,
type MailProvider,
type MailProviderBindings,
type MailProviderName,
type OutgoingMail,
type ResendProviderOptions,
Expand Down
85 changes: 85 additions & 0 deletions packages/mail/src/provider/cloudflare.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest';

import { createCloudflareProvider, type CloudflareEmailBinding } from './cloudflare.js';
import type { OutgoingMail } from './types.js';

const REFUSED_PATTERN = /cloudflare email rejected the message: E_SENDER_NOT_VERIFIED/;

const mail: OutgoingMail = {
to: 'operator@example.com',
subject: 'Reset your password',
html: '<a href="https://example.com/reset?token=secret-token">Reset</a>',
text: 'https://example.com/reset?token=secret-token',
from: 'noreply@example.com',
};

/** Email Service refuses a message by throwing an `Error` that carries a stable `code`. */
function refusal(code: string, message: string): Error {
return Object.assign(new Error(message), { code });
}

describe('createCloudflareProvider', () => {
it('hands the rendered message to the binding', async () => {
const send = vi.fn<CloudflareEmailBinding['send']>().mockResolvedValue({ messageId: 'm1' });

await createCloudflareProvider({ binding: () => ({ send }) })(mail);

expect(send).toHaveBeenCalledExactlyOnceWith({
from: mail.from,
to: mail.to,
subject: mail.subject,
html: mail.html,
text: mail.text,
});
});

it('asks for the binding on every message, not once at startup', async () => {
const send = vi.fn<CloudflareEmailBinding['send']>().mockResolvedValue(undefined);
const binding = vi.fn<() => CloudflareEmailBinding>(() => ({ send }));
const provider = createCloudflareProvider({ binding });

await provider(mail);
await provider(mail);

// A Worker's bindings arrive with the request being served.
expect(binding).toHaveBeenCalledTimes(2);
});

it('rejects with the refusal code instead of resolving quietly', async () => {
const send = vi
.fn<CloudflareEmailBinding['send']>()
.mockRejectedValue(refusal('E_SENDER_NOT_VERIFIED', 'operator@example.com is not allowed'));

await expect(createCloudflareProvider({ binding: () => ({ send }) })(mail)).rejects.toThrow(
REFUSED_PATTERN,
);
});

it('keeps the recipient and the message body out of the error, nested causes included', async () => {
const send = vi
.fn<CloudflareEmailBinding['send']>()
.mockRejectedValue(refusal('E_RECIPIENT_NOT_ALLOWED', 'operator@example.com is not allowed'));

const error = await createCloudflareProvider({ binding: () => ({ send }) })(mail).catch(
(caught: unknown) => caught,
);

expect(error).toBeInstanceOf(Error);
if (!(error instanceof Error)) return;
expect(error.message).not.toContain('operator@example.com');
expect(error.message).not.toContain('secret-token');
// A logger prints a nested `cause`, which would bring the refused recipient back.
expect(error.cause).toBeUndefined();
});

it('rejects when the binding is missing from the request', async () => {
const provider = createCloudflareProvider({
binding: () => {
throw new Error('the EMAIL binding is not configured on this Worker');
},
});

// Resolving the binding is part of delivering; a Worker without one must not drop the mail.
await expect(provider(mail)).rejects.toThrow('EMAIL binding');
});
});
67 changes: 67 additions & 0 deletions packages/mail/src/provider/cloudflare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { MailProvider, OutgoingMail } from './types.js';

/**
* The part of a Workers `send_email` binding (Cloudflare Email Service) this transport calls.
*
* Declared here rather than imported from `@cloudflare/workers-types` so the package stays free of
* a platform dependency: the binding only exists inside a Worker, and the composition root hands
* it over.
*/
export interface CloudflareEmailBinding {
send(message: {
readonly from: string;
readonly to: string;
readonly subject: string;
readonly html: string;
readonly text: string;
}): Promise<unknown>;
}

/** Options the Cloudflare transport needs. */
export interface CloudflareProviderOptions {
/**
* Resolves the binding for the work in progress.
*
* A function rather than a value because a Worker receives its bindings with each request, not
* when the module loads, so the transport cannot hold one from startup.
*/
readonly binding: () => CloudflareEmailBinding;
}

/** Email Service rejects a message by throwing an `Error` with a stable `code`. */
function failureCode(error: unknown): string {
if (typeof error === 'object' && error !== null && 'code' in error) {
const { code } = error;
if (typeof code === 'string') return code;
}
return 'unknown error';
}

/**
* Cloudflare Email Service transport, for the managed cloud's Worker.
*
* Sending goes through the Worker's `send_email` binding, so there is no API key to provision or
* leak: the binding is the credential, and the sender domain must be onboarded to Email Service.
*/
export function createCloudflareProvider(options: CloudflareProviderOptions): MailProvider {
return async (mail: OutgoingMail) => {
// Resolved outside the `try`: a Worker without the binding fails with its own explanation, not
// as a refusal from the service.
const binding = options.binding();
try {
await binding.send({
from: mail.from,
to: mail.to,
subject: mail.subject,
html: mail.html,
text: mail.text,
});
} catch (error) {
// Only the code survives: the service's own text can quote the recipient, and a caller needs
// to know why the message was refused, not who it was for. The original error is not kept as
// `cause` either, because loggers print a nested cause and would put the address back.
// oxlint-disable-next-line preserve-caught-error -- the original's text can quote the recipient
throw new Error(`cloudflare email rejected the message: ${failureCode(error)}`);
}
};
}
Loading
Loading