diff --git a/apps/docs/content/1.guide/12.organizations.md b/apps/docs/content/1.guide/12.organizations.md index ac784acd..eb334c74 100644 --- a/apps/docs/content/1.guide/12.organizations.md +++ b/apps/docs/content/1.guide/12.organizations.md @@ -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. diff --git a/apps/docs/content/1.guide/14.mails.md b/apps/docs/content/1.guide/14.mails.md index fa9fb7f8..0534c94f 100644 --- a/apps/docs/content/1.guide/14.mails.md +++ b/apps/docs/content/1.guide/14.mails.md @@ -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: @@ -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`). :: diff --git a/apps/docs/content/1.guide/16.deployment.md b/apps/docs/content/1.guide/16.deployment.md index 8054324e..dfadb9da 100644 --- a/apps/docs/content/1.guide/16.deployment.md +++ b/apps/docs/content/1.guide/16.deployment.md @@ -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. diff --git a/apps/docs/content/1.guide/4.environment-variables.md b/apps/docs/content/1.guide/4.environment-variables.md index 1364f323..6ffc8d7f 100644 --- a/apps/docs/content/1.guide/4.environment-variables.md +++ b/apps/docs/content/1.guide/4.environment-variables.md @@ -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). diff --git a/apps/web/.env.example b/apps/web/.env.example index 9e609675..136a487d 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -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. diff --git a/apps/web/server/auth.config.ts b/apps/web/server/auth.config.ts index 1086de79..94f023be 100644 --- a/apps/web/server/auth.config.ts +++ b/apps/web/server/auth.config.ts @@ -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 @@ -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'; diff --git a/apps/web/server/utils/mail-binding.ts b/apps/web/server/utils/mail-binding.ts new file mode 100644 index 00000000..a35b9dd5 --- /dev/null +++ b/apps/web/server/utils/mail-binding.ts @@ -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; +} diff --git a/apps/web/test/unit/mail-binding.test.ts b/apps/web/test/unit/mail-binding.test.ts new file mode 100644 index 00000000..30d8a5f3 --- /dev/null +++ b/apps/web/test/unit/mail-binding.test.ts @@ -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: '

Hello

', + text: 'Hello', +}; + +describe('emailBindingFrom', () => { + it('finds the send_email binding on the Worker environment', async () => { + const send = vi.fn<(message: unknown) => Promise>().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(); + }); +}); diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc index 1c4cba73..cd4c3aa2 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -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 diff --git a/packages/mail/src/index.ts b/packages/mail/src/index.ts index 4fff57a3..7d634748 100644 --- a/packages/mail/src/index.ts +++ b/packages/mail/src/index.ts @@ -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, diff --git a/packages/mail/src/provider/cloudflare.test.ts b/packages/mail/src/provider/cloudflare.test.ts new file mode 100644 index 00000000..ebab8f62 --- /dev/null +++ b/packages/mail/src/provider/cloudflare.test.ts @@ -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: 'Reset', + 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().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().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() + .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() + .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'); + }); +}); diff --git a/packages/mail/src/provider/cloudflare.ts b/packages/mail/src/provider/cloudflare.ts new file mode 100644 index 00000000..e7b15574 --- /dev/null +++ b/packages/mail/src/provider/cloudflare.ts @@ -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; +} + +/** 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)}`); + } + }; +} diff --git a/packages/mail/src/provider/index.test.ts b/packages/mail/src/provider/index.test.ts index 8c136f36..97bc714b 100644 --- a/packages/mail/src/provider/index.test.ts +++ b/packages/mail/src/provider/index.test.ts @@ -8,6 +8,7 @@ import { const INVALID_MAIL_PROVIDER_PATTERN = /invalid MAIL_PROVIDER/; const MISSING_RESEND_API_KEY_PATTERN = /RESEND_API_KEY/; +const MISSING_CLOUDFLARE_BINDING_PATTERN = /send_email binding/; const MISSING_SMTP_HOST_PATTERN = /SMTP_HOST/; const MISSING_SMTP_PORT_PATTERN = /SMTP_PORT/; const LEAKED_SMTP_PASSWORD_PATTERN = /hunter2/; @@ -34,6 +35,20 @@ describe('mailProviderFromEnvironment', () => { ); }); + it('requires the Worker binding before selecting Cloudflare', () => { + // There is no credential in the environment to fall back on, so a missing binding is a + // deployment error, not a reason to log or to pick another transport. + expect(() => mailProviderFromEnvironment({ MAIL_PROVIDER: 'cloudflare' })).toThrow( + MISSING_CLOUDFLARE_BINDING_PATTERN, + ); + expect(() => + mailProviderFromEnvironment( + { MAIL_PROVIDER: 'cloudflare' }, + { cloudflareEmail: () => ({ send: () => Promise.resolve() }) }, + ), + ).not.toThrow(); + }); + it('requires host and port before selecting SMTP', () => { expect(() => mailProviderFromEnvironment({ MAIL_PROVIDER: 'smtp' })).toThrow( MISSING_SMTP_HOST_PATTERN, @@ -78,6 +93,7 @@ describe('mailProviderNameFromEnvironment', () => { it('reports the configured transport name', () => { expect(mailProviderNameFromEnvironment({ MAIL_PROVIDER: 'smtp' })).toBe('smtp'); + expect(mailProviderNameFromEnvironment({ MAIL_PROVIDER: 'cloudflare' })).toBe('cloudflare'); }); it('rejects an unknown provider name instead of silently defaulting', () => { diff --git a/packages/mail/src/provider/index.ts b/packages/mail/src/provider/index.ts index 71059668..14e645d4 100644 --- a/packages/mail/src/provider/index.ts +++ b/packages/mail/src/provider/index.ts @@ -1,15 +1,21 @@ +import { createCloudflareProvider, type CloudflareEmailBinding } from './cloudflare.js'; import { createConsoleProvider } from './console.js'; import { createResendProvider } from './resend.js'; import { createSmtpProvider } from './smtp.js'; import type { MailProvider } from './types.js'; +export { + createCloudflareProvider, + type CloudflareEmailBinding, + type CloudflareProviderOptions, +} from './cloudflare.js'; export { createConsoleProvider } from './console.js'; export { createResendProvider, type ResendProviderOptions } from './resend.js'; export { createSmtpProvider, type SmtpProviderOptions } from './smtp.js'; export type { MailProvider, OutgoingMail } from './types.js'; /** Transports a deployment can select through `MAIL_PROVIDER`. */ -export const MAIL_PROVIDER_NAMES = ['console', 'resend', 'smtp'] as const; +export const MAIL_PROVIDER_NAMES = ['console', 'resend', 'smtp', 'cloudflare'] as const; export type MailProviderName = (typeof MAIL_PROVIDER_NAMES)[number]; @@ -65,6 +71,15 @@ export function mailProviderNameFromEnvironment( return configured; } +/** What the environment cannot name: values only the hosting platform can hand over. */ +export interface MailProviderBindings { + /** + * The Worker's `send_email` binding, resolved per request. Required to select `cloudflare`, + * which has no credential to read from the environment: the binding is the credential. + */ + readonly cloudflareEmail?: () => CloudflareEmailBinding; +} + /** * Resolve the configured transport. * @@ -75,9 +90,16 @@ export function mailProviderNameFromEnvironment( */ export function mailProviderFromEnvironment( environment: Readonly> = process.env, + bindings: MailProviderBindings = {}, ): MailProvider { const configured = mailProviderNameFromEnvironment(environment); + if (configured === 'cloudflare') { + if (!bindings.cloudflareEmail) + throw new Error('MAIL_PROVIDER=cloudflare needs a Cloudflare Worker send_email binding'); + return createCloudflareProvider({ binding: bindings.cloudflareEmail }); + } + if (configured === 'resend') return createResendProvider({ apiKey: requireEnvironmentValue(environment, 'RESEND_API_KEY'),