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