diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 0182513..4090bbf 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -140,8 +140,52 @@ Without an email provider, Punctual logs emails instead of sending them — useful for local testing, not for real bookings. This is a real trap: nothing in the product looks broken, because only the recipients can tell. If you skip this step, the dashboard and `/health` will both keep saying so (see -Troubleshooting). To send for real, set **either** provider's key (Resend is -tried first if both are set): +Troubleshooting). + +There are three ways to send. On Cloudflare the first needs no API key at all. + +### Cloudflare Email Service + +The `send_email` binding is itself the credential, scoped by `wrangler.toml` — +nothing to rotate, leak, or forget to set. Your domain must be on Cloudflare +DNS, and sending to guests needs a Workers Paid plan. + +Do these in order: + +1. **Onboard the sending domain.** Cloudflare dashboard → **Compute → Email + Service → Email Sending → Onboard Domain**. Cloudflare adds MX, SPF and + DKIM records under `cf-bounce.`, plus DMARC at + `_dmarc.`; allow 5–15 minutes. + + It does not touch your apex MX, so an existing mailbox provider on the same + domain (Google Workspace, Microsoft 365) keeps working — different DKIM + selectors, and the return path lives on the `cf-bounce` subdomain. Email + *Routing* is the feature that would conflict there; this is not that. + +2. **Set `FROM_EMAIL` to an address on the domain you onboarded.** Onboarding + `mail.example.com` does not authorise `you@example.com`: the sender address + must belong to an onboarded domain, or every send is rejected. + +3. **Uncomment the binding** in `wrangler.toml`: + + ```toml + [[send_email]] + name = "EMAIL" + ``` + +4. **Deploy, then sign in.** The sign-in link is the one email Punctual sends + on the request path rather than through the queue, so a sender that is not + authorised fails immediately and visibly — before a guest ever books. + +The order matters. Do 3 and 4 before 1 and you get a window where the +dashboard and `/health` both read healthy while no mail arrives, because the +"email is not configured" warning only fires for the console sender — and with +a binding present, a provider *is* configured. + +### Resend or Brevo + +Set **either** provider's key (Resend is tried first if both are set, and a +`send_email` binding takes precedence over both): ```bash npx wrangler secret put RESEND_API_KEY @@ -241,6 +285,7 @@ Two features need a paid plan, and both degrade gracefully: | `SIGNING_KEY` | secret | HMAC key for guest manage links | | `GOOGLE_CLIENT_ID` / `_SECRET` | secret | Your Google OAuth app | | `MICROSOFT_CLIENT_ID` / `_SECRET` | secret | Your Microsoft app | +| `[[send_email]]` | binding | Cloudflare Email Service — no key. Takes precedence over both API keys. Needs the sending domain onboarded (Compute → Email Service) and Workers Paid; until then guest sends fail while `/health` still reads healthy | | `RESEND_API_KEY` | secret | Omit to log emails instead of sending — `/health` and the dashboard both warn when neither key is set | | `BREVO_API_KEY` | secret | Alternative to Resend; Resend wins if both are set | @@ -259,6 +304,11 @@ rather than take our word for it. **"unverified app" on Google sign-in.** Expected until Google finishes verification. Add yourself as a test user on the consent screen. +**Emails are not arriving, and you bound Cloudflare Email Service.** They are +not being logged — they are being rejected. Two usual causes: the sending +domain is not onboarded yet, or `FROM_EMAIL` is on a different domain than the +one you onboarded. `npx wrangler tail` names which. + **Emails are not arriving.** With no `RESEND_API_KEY` or `BREVO_API_KEY` they are logged, not sent — bookings still commit and calendars still sync, so nothing else looks wrong. Two places say so without your having to read logs: diff --git a/src/adapters/email/index.ts b/src/adapters/email/index.ts index 15ccfae..6f5b54e 100644 --- a/src/adapters/email/index.ts +++ b/src/adapters/email/index.ts @@ -170,3 +170,93 @@ export function createBrevoSender(opts: BrevoOptions): EmailSender { }, } } + +// --------------------------------------------------------------------------- +// Cloudflare Email Service +// --------------------------------------------------------------------------- + +export interface CloudflareOptions { + binding: SendEmail + from: string + fromName?: string +} + +/** + * Cloudflare Email Service, through the `send_email` Worker binding. + * + * The third provider, and the only one with no API key: the binding is + * capability-scoped by wrangler.toml, so there is no secret to rotate, leak or + * forget to set. That is the whole reason to prefer it on a Cloudflare-hosted + * deployment — `EmailSender` exists (ADR-0003) so this is a choice, and this + * one removes a credential rather than adding one. + * + * Two facts about the platform decide the shape of everything below: + * + * 1. **Arbitrary recipients require an onboarded sending domain.** Before + * the domain in `from` is onboarded to Email Service, the binding will + * only deliver to *verified destination addresses* in the account — i.e. + * to the operator, never to a guest. A deployment in that state looks + * healthy (no key missing, no warning banner) while every guest + * confirmation is rejected, which is the exact failure the console-sender + * banner exists to prevent. Hence the error wrapping below: the platform + * reports this as a specific, recognisable failure, and it must reach the + * operator's log saying what to do, not as a bare 500. + * 2. **Attachment `content` is a base64 string.** Which is already how + * `EmailMessage.attachments` carries the .ics, so it maps across + * untouched — no decode/re-encode round trip that could corrupt a + * calendar invite. + * + * Delivery failures throw, like the other two senders, so the queue consumer + * retries rather than a transient blip becoming a permanently missing + * confirmation. + */ +export function createCloudflareSender(opts: CloudflareOptions): EmailSender { + return { + async send(message) { + // sanitizeHeader for the same reason as Brevo: `to`/`toName`/`replyTo` + // are frequently guest-controlled from an unauthenticated booking form. + // The binding builds the MIME itself and rejects non-allowlisted + // headers, so this is defence in depth rather than the only guard — but + // a sender that behaves differently from its siblings on hostile input + // is a bug waiting for the one deployment that switches providers. + const to = sanitizeHeader(message.to) + const toName = message.toName ? sanitizeHeader(message.toName) : undefined + + try { + await opts.binding.send({ + from: opts.fromName + ? { email: sanitizeHeader(opts.from), name: sanitizeHeader(opts.fromName) } + : sanitizeHeader(opts.from), + to: toName ? { email: to, name: toName } : to, + subject: message.subject, + html: message.html, + text: message.text, + ...(message.replyTo ? { replyTo: sanitizeHeader(message.replyTo) } : {}), + ...(message.attachments?.length + ? { + attachments: message.attachments.map((a) => ({ + // Already base64 at the port boundary — see note 2 above. + content: a.content, + filename: a.filename, + type: a.contentType, + disposition: 'attachment' as const, + })), + } + : {}), + }) + } catch (e) { + // The platform throws an Error carrying a `code`. Both halves matter + // and neither is useful alone: the code is what you search the docs + // for, the message is what names the offending address. + const code = typeof e === 'object' && e !== null && 'code' in e ? String((e as { code: unknown }).code) : '' + const detail = e instanceof Error ? e.message : String(e) + // The one failure worth translating rather than echoing: it is + // indistinguishable from "email works" until a guest tries to book. + const hint = /verified destination|not onboarded|domain/i.test(`${code} ${detail}`) + ? ' — is the sending domain onboarded to Email Service? Until it is, the binding only delivers to verified destination addresses in your own account, never to guests.' + : '' + throw new Error(`cloudflare email: ${code} ${detail}${hint}`.trim()) + } + }, + } +} diff --git a/src/http/pages/dashboard.ts b/src/http/pages/dashboard.ts index 1ee43e0..d4b9b0a 100644 --- a/src/http/pages/dashboard.ts +++ b/src/http/pages/dashboard.ts @@ -43,7 +43,7 @@ import type { CompanyLogo, User, WeeklySchedule, } from '../../core/domain/types.js' -import type { BookingListView, CalendarProviderName } from '../../ports.js' +import type { BookingListView, CalendarProviderName, EmailDelivery } from '../../ports.js' import type { HostChangeFailure } from '../../core/domain/booking-hosts.js' import { slotStateClassName } from '../../core/slot-state.js' import { slugify } from '../../core/domain/booking-service.js' @@ -95,7 +95,7 @@ export interface DashboardChrome { * unaffected: they do not carry `DashboardChrome` and must not show an * operator's config problems to a booker. */ - emailDelivery: 'resend' | 'brevo' | 'console' + emailDelivery: EmailDelivery } /** @@ -108,7 +108,8 @@ function emailWarningBanner(chrome: DashboardChrome): string { return `` diff --git a/src/http/pages/docs.ts b/src/http/pages/docs.ts index 6dceee0..090352c 100644 --- a/src/http/pages/docs.ts +++ b/src/http/pages/docs.ts @@ -247,8 +247,42 @@ ${pre(`npx wrangler secret put MICROSOFT_CLIENT_ID\nnpx wrangler secret put MICR

6. Email (optional, but you want it)

Without an email provider, Punctual logs emails instead of sending them. - To send for real, set either provider's key (Resend is - tried first if both are set):

+ There are three ways to send; on Cloudflare the first needs no API key at + all.

+ +

Cloudflare Email Service

+

The send_email binding is itself the credential, scoped by + wrangler.toml — nothing to rotate, leak or forget to set. + Your domain must be on Cloudflare DNS, and sending to guests needs a Workers + Paid plan. Do these in order:

+
    +
  1. Onboard the sending domain — + Compute → Email Service → Email Sending → Onboard + Domain. Cloudflare adds MX, SPF and DKIM under + cf-bounce.<your-domain> and DMARC at + _dmarc.<your-domain>; allow 5–15 minutes. It does + not touch your apex MX, so an existing mailbox provider on the same domain + keeps working — Email Routing is the feature that conflicts + there, and this is not that.
  2. +
  3. Set FROM_EMAIL to an address on the domain you + onboarded. Onboarding mail.example.com does not + authorise you@example.com: a sender outside an onboarded + domain is rejected on every send.
  4. +
  5. Uncomment the binding in wrangler.toml.
  6. +
  7. Deploy, then sign in. The sign-in link is the one email + sent on the request path rather than through the queue, so an unauthorised + sender fails immediately and visibly — before a guest ever books.
  8. +
+${pre(`[[send_email]]\nname = "EMAIL"`)} +

The order matters. Do steps 3 and 4 before step 1 and you + get a window where this page and /health both read healthy while + no mail arrives: the “email is not configured” warning only fires + for the console sender, and with a binding present a provider is + configured.

+ +

Resend or Brevo

+

Set either provider's key (Resend is tried first if both + are set, and a send_email binding takes precedence over both):

${pre(`npx wrangler secret put RESEND_API_KEY\n# or\nnpx wrangler secret put BREVO_API_KEY`)}

Then set FROM_EMAIL and FROM_NAME in wrangler.toml's [vars] to an address on a @@ -321,6 +355,7 @@ ${pre(`git pull\nnpm run migrate\nnpm run deploy`)} SIGNING_KEYsecretHMAC key for guest manage links GOOGLE_CLIENT_ID / _SECRETsecretYour Google OAuth app MICROSOFT_CLIENT_ID / _SECRETsecretYour Microsoft app +[[send_email]]bindingCloudflare Email Service; no key. Wins over both API keys RESEND_API_KEYsecretOmit to log emails instead of sending BREVO_API_KEYsecretAlternative to Resend; Resend wins if both are set @@ -338,7 +373,10 @@ ${pre(`git pull\nnpm run migrate\nnpm run deploy`)} screen.

Emails are not arriving. With no RESEND_API_KEY they are logged, not sent. Check - npx wrangler tail.

+ npx wrangler tail. If you bound Cloudflare Email Service + instead they are not logged but rejected, and there are two usual causes: + the sending domain is not onboarded yet, or FROM_EMAIL is on a + different domain than the one you onboarded. tail names which.

Times look wrong by an hour. Almost always a host timezone set incorrectly rather than a DST bug — the engine computes in UTC and converts at the edges.

diff --git a/src/http/router.ts b/src/http/router.ts index c234b1f..0afca3a 100644 --- a/src/http/router.ts +++ b/src/http/router.ts @@ -60,7 +60,7 @@ export function buildRouter(ports: EnginePorts, slots: SlotService): Hono<{ Bind const warnings: string[] = [] if (ports.config.emailDelivery === 'console') { warnings.push( - 'email_not_configured: no RESEND_API_KEY or BREVO_API_KEY — booking confirmations, ' + + 'email_not_configured: no [[send_email]] binding, RESEND_API_KEY or BREVO_API_KEY — booking confirmations, ' + 'reschedule and cancellation notices and reminders are logged, not delivered', ) } diff --git a/src/index.ts b/src/index.ts index d21730d..17ba54e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,7 @@ import { createWebCrypto } from './adapters/crypto/webcrypto.js' import { createKvCache } from './adapters/cache/kv.js' import { createKvBlobCache } from './adapters/cache/kv-blob.js' import { createR2BlobStorage } from './adapters/storage/r2-blob.js' -import { createBrevoSender, createConsoleSender, createResendSender } from './adapters/email/index.js' +import { createBrevoSender, createCloudflareSender, createConsoleSender, createResendSender } from './adapters/email/index.js' import { createEnvOAuthCredentials } from './adapters/oauth.js' import { createCalendarProviders } from './adapters/providers.js' import { createCoordinator } from './adapters/coordinator.js' @@ -52,6 +52,8 @@ export interface Env { ENCRYPTION_KEY_V1?: string ENCRYPTION_KEY_V2?: string SIGNING_KEY?: string + /** Cloudflare Email Service. Bound by `[[send_email]]` in wrangler.toml — no key, no secret. */ + EMAIL?: SendEmail RESEND_API_KEY?: string BREVO_API_KEY?: string GOOGLE_CLIENT_ID?: string @@ -127,13 +129,27 @@ export function buildPorts(env: Env): EnginePorts { // Resolved ONCE, next to the sender it describes, so the two cannot drift: // a mode that claimed 'brevo' while the console sender was actually wired // would be worse than no signal at all. - const emailDelivery: EmailDelivery = env.RESEND_API_KEY ? 'resend' : env.BREVO_API_KEY ? 'brevo' : 'console' + // The binding outranks both keys deliberately. A `[[send_email]]` block is + // an edit to wrangler.toml — the most explicit configuration act available, + // and the only one of the three that cannot arrive by accident from a stray + // secret inherited off another deployment. Swapping to Resend or Brevo is + // therefore "remove the binding", not "set a key and hope the precedence + // falls your way". + const emailDelivery: EmailDelivery = env.EMAIL + ? 'cloudflare' + : env.RESEND_API_KEY + ? 'resend' + : env.BREVO_API_KEY + ? 'brevo' + : 'console' const email = - emailDelivery === 'resend' - ? createResendSender({ apiKey: env.RESEND_API_KEY!, from: emailFrom, fromName: emailFromName }) - : emailDelivery === 'brevo' - ? createBrevoSender({ apiKey: env.BREVO_API_KEY!, from: emailFrom, fromName: emailFromName }) - : createConsoleSender() + emailDelivery === 'cloudflare' + ? createCloudflareSender({ binding: env.EMAIL!, from: emailFrom, fromName: emailFromName }) + : emailDelivery === 'resend' + ? createResendSender({ apiKey: env.RESEND_API_KEY!, from: emailFrom, fromName: emailFromName }) + : emailDelivery === 'brevo' + ? createBrevoSender({ apiKey: env.BREVO_API_KEY!, from: emailFrom, fromName: emailFromName }) + : createConsoleSender() if (emailDelivery === 'console') { // Loud, once, at boot. On its own this catches nothing (nobody tails a @@ -141,7 +157,7 @@ export function buildPorts(env: Env): EnginePorts { // signal — but it costs nothing and it is the first place someone // debugging "where did my confirmation go" will look. console.warn( - '[punctual] No RESEND_API_KEY or BREVO_API_KEY is set. Emails are being LOGGED, NOT SENT — ' + + '[punctual] No email provider is configured (no [[send_email]] binding, RESEND_API_KEY or BREVO_API_KEY). Emails are being LOGGED, NOT SENT — ' + 'guests will receive no booking confirmations. See /health and docs/self-hosting.md.', ) } diff --git a/src/ports.ts b/src/ports.ts index a37121c..f9343bc 100644 --- a/src/ports.ts +++ b/src/ports.ts @@ -818,7 +818,7 @@ export interface RateLimitResult { * The email path in effect. Provider names are not secrets (the KEYS are), so * this is safe to expose on `/health` for external monitoring. */ -export type EmailDelivery = 'resend' | 'brevo' | 'console' +export type EmailDelivery = 'cloudflare' | 'resend' | 'brevo' | 'console' export interface EngineConfig { /** Public origin, e.g. https://punctual.sh — used in links and .ics URLs. */ diff --git a/test/core/crypto.test.ts b/test/core/crypto.test.ts index 0444c03..ed22224 100644 --- a/test/core/crypto.test.ts +++ b/test/core/crypto.test.ts @@ -340,3 +340,111 @@ describe('Resend sender', () => { expect(seen.body['to']).toEqual(['"Guest , Innocent" ']) }) }) + +describe('Cloudflare Email Service sender', () => { + type Builder = Parameters[0] + + async function capture( + message: Parameters[0], + // Not a defaulted parameter: `capture(msg, undefined)` would silently take + // the default and never exercise the no-display-name branch at all. + fromName: string | null = 'Punctual', + ) { + const { createCloudflareSender } = await import('../../src/adapters/email/index.js') + let seen: Builder | null = null + const binding = { + send: async (m: Builder) => { + seen = m + return { messageId: 'msg_1' } + }, + } as unknown as SendEmail + const sender = createCloudflareSender({ binding, from: 'hello@punctual.sh', ...(fromName === null ? {} : { fromName }) }) + await sender.send(message) + return seen! as Builder & Record + } + + it('sends through the binding, with no API key anywhere', async () => { + const seen = await capture({ to: 'g@example.com', toName: 'Guest', subject: 'Booked', html: '

x

', text: 'x' }) + expect(seen.from).toEqual({ email: 'hello@punctual.sh', name: 'Punctual' }) + expect(seen.to).toEqual({ email: 'g@example.com', name: 'Guest' }) + expect(seen.subject).toBe('Booked') + expect(seen.html).toBe('

x

') + expect(seen.text).toBe('x') + }) + + it('sends a bare address when no display name is configured', async () => { + const seen = await capture({ to: 'g@example.com', subject: 's', html: 'h', text: 't' }, null) + expect(seen.from).toBe('hello@punctual.sh') + expect(seen.to).toBe('g@example.com') + }) + + it('uses {content, filename, type, disposition} for attachments', async () => { + // Same stakes as the Brevo case: every booking email carries the .ics, and + // `type`/`filename` here are named differently from both other providers. + // The base64 string crosses untouched — no decode/re-encode round trip. + const seen = await capture({ + to: 'g@example.com', + subject: 'Booked', + html: '

x

', + text: 'x', + attachments: [{ filename: 'invite.ics', content: 'QkVHSU46VkNBTEVOREFS', contentType: 'text/calendar' }], + }) + expect(seen.attachments).toEqual([ + { content: 'QkVHSU46VkNBTEVOREFS', filename: 'invite.ics', type: 'text/calendar', disposition: 'attachment' }, + ]) + }) + + it('omits attachments and replyTo entirely when absent', async () => { + // `attachments: undefined` is not the same as the key being absent for a + // builder the platform validates field-by-field. + const seen = await capture({ to: 'g@example.com', subject: 's', html: 'h', text: 't' }) + expect('attachments' in seen).toBe(false) + expect('replyTo' in seen).toBe(false) + }) + + it('sanitizes a guest name/email carrying header-injection characters', async () => { + const seen = await capture({ + to: 'a@b.c\r\nBcc: victim@evil.com', + toName: 'Ada\r\nX-Evil: 1', + subject: 's', + html: 'h', + text: 't', + replyTo: 'host@x.y\r\nBcc: victim@evil.com', + }) + // sanitizeHeader collapses CR/LF to a space rather than dropping the rest + // of the value, so `victim@evil.com` survives as inert text inside a single + // mangled address. That is the correct outcome — what must never survive is + // the line break that would make it a header of its own. + expect(JSON.stringify(seen)).not.toMatch(/\\r|\\n/) + expect(seen.to).toEqual({ email: 'a@b.c Bcc: victim@evil.com', name: 'Ada X-Evil: 1' }) + expect(seen.replyTo).toBe('host@x.y Bcc: victim@evil.com') + }) + + it('explains the un-onboarded-domain failure rather than echoing it', async () => { + // The failure this translation exists for: until the sending domain is + // onboarded, the binding delivers only to verified destination addresses + // in the account — so the instance looks healthy and no guest ever hears + // from it. The operator must not have to go read the platform docs. + const { createCloudflareSender } = await import('../../src/adapters/email/index.js') + const binding = { + send: async () => { + const e = new Error('recipient is not a verified destination address') + ;(e as Error & { code: string }).code = 'E_UNVERIFIED_RECIPIENT' + throw e + }, + } as unknown as SendEmail + const sender = createCloudflareSender({ binding, from: 'x@y.z', fromName: 'P' }) + const err = await sender + .send({ to: 'a@b.c', subject: 's', html: 'h', text: 't' }) + .then(() => null, (e: Error) => e) + expect(err?.message).toContain('E_UNVERIFIED_RECIPIENT') + expect(err?.message).toMatch(/onboarded to Email Service/) + }) + + it('still throws on an unrelated failure, so the queue retries', async () => { + const { createCloudflareSender } = await import('../../src/adapters/email/index.js') + const binding = { send: async () => { throw new Error('upstream 503') } } as unknown as SendEmail + const sender = createCloudflareSender({ binding, from: 'x@y.z', fromName: 'P' }) + await expect(sender.send({ to: 'a@b.c', subject: 's', html: 'h', text: 't' })).rejects.toThrow(/upstream 503/) + }) +}) diff --git a/test/workers/smoke.test.ts b/test/workers/smoke.test.ts index c66b14c..0d46fde 100644 --- a/test/workers/smoke.test.ts +++ b/test/workers/smoke.test.ts @@ -32,6 +32,26 @@ describe('/health surfaces silent degradation', () => { expect(body.warnings.join(' ')).toContain('email_not_configured') }) + it('prefers the Cloudflare Email Service binding over a provider key', async () => { + // The binding is opt-in (`[[send_email]]` is commented out in + // wrangler.toml), so this is the only place the resolved mode can be + // exercised — and precedence is the part worth pinning: a deployment that + // binds Email Service AND carries an inherited key must not quietly keep + // sending through the key. + const { default: worker } = await import('../../src/index.js') + const binding = { send: async () => ({ messageId: 'msg_test' }) } + const res = await worker.fetch( + new Request('https://punctual.sh/health'), + { ...env, EMAIL: binding, RESEND_API_KEY: 're_inherited' }, + createExecutionContext(), + ) + const body = (await res.json()) as { emailDelivery: string; warnings: string[] } + expect(body.emailDelivery).toBe('cloudflare') + // A configured deployment must not warn: a banner that cries wolf is one + // operators learn to scroll past. + expect(body.warnings).toEqual([]) + }) + it('never leaks the provider key itself, only the mode', async () => { const { default: worker } = await import('../../src/index.js') const res = await worker.fetch( diff --git a/wrangler.toml b/wrangler.toml index 63b7a16..ed16a66 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -16,6 +16,20 @@ compatibility_flags = ["nodejs_compat"] [assets] directory = "./assets" +# Cloudflare Email Service — the option with no API key at all, since the +# binding itself is the credential and this file scopes it. Commented out +# because it is opt-in: uncommenting changes where every email goes, and it +# takes precedence over RESEND_API_KEY and BREVO_API_KEY when bound. +# +# Sending to guests — rather than only to verified destination addresses in +# your own account — needs your sending domain onboarded under +# Compute > Email Service > Email Sending, and a Workers Paid plan. FROM_EMAIL +# must be an address on that onboarded domain. Until it is, guest sends fail +# while /health still reads healthy, because a provider IS configured and the +# "email is not configured" banner only fires for the console sender. +#[[send_email]] +#name = "EMAIL" + [observability] enabled = true