From 8da78b67d8a7752bc6126de61ddbf8d443de7485 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Mon, 14 Sep 2026 16:01:25 -0700 Subject: [PATCH 1/2] Send through Cloudflare Email Service, a provider with no API key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A third `EmailSender`. On a Cloudflare-hosted deployment the `send_email` binding is capability-scoped by wrangler.toml, so unlike Resend and Brevo there is no secret to set, rotate, leak or forget — this provider removes a credential rather than adding one. ADR-0003's port exists so that is a choice, and on this platform it is the cheapest correct one. Two platform facts shape the adapter: Arbitrary recipients need an onboarded sending domain. Before that, the binding delivers only to verified destination addresses in the account — to the operator, never to a guest. A deployment in that state looks healthy: no key is missing, so the console-sender banner correctly stays silent while every guest confirmation is rejected. That is precisely the failure the banner exists to catch, so the adapter translates it into an error naming the onboarding step rather than letting a bare provider body reach the log. Attachment content is a base64 string, which is already how `EmailMessage.attachments` carries the .ics — so it crosses untouched, with no decode/re-encode that could corrupt a calendar invite. The binding takes precedence over both API keys. 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 secret inherited off another deployment. It ships commented out in wrangler.toml. Uncommenting changes where every email goes, and defaulting it on would silently move existing deployments off the provider they configured. `DashboardChrome.emailDelivery` now refers to `EmailDelivery` rather than restating its members — the duplicate did not include the new provider, which was a compile error in ten places and is the reason to name the type once. --- docs/self-hosting.md | 1 + src/adapters/email/index.ts | 90 ++++++++++++++++++++++++++++++ src/http/pages/dashboard.ts | 7 ++- src/http/pages/docs.ts | 17 +++++- src/http/router.ts | 2 +- src/index.ts | 32 ++++++++--- src/ports.ts | 2 +- test/core/crypto.test.ts | 108 ++++++++++++++++++++++++++++++++++++ test/workers/smoke.test.ts | 20 +++++++ wrangler.toml | 14 +++++ 10 files changed, 278 insertions(+), 15 deletions(-) diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 0182513..a805e57 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -241,6 +241,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 | 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..22f0fed 100644 --- a/src/http/pages/docs.ts +++ b/src/http/pages/docs.ts @@ -247,7 +247,17 @@ ${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 + On Cloudflare the simplest option has no key at all — bind Cloudflare + Email Service in wrangler.toml, which takes precedence over + both API keys when present:

+${pre(`[[send_email]]\nname = "EMAIL"`)} +

Sending to guests (rather than only to verified + destination addresses in your own account) requires onboarding your sending + domain under Compute → Email Service → Email Sending, + and a Workers Paid plan. Until the domain is onboarded, sends to guests fail + — and because a provider is configured, the banner below stays + silent, so onboard the domain before your first sign-in.

+

Otherwise set either provider's key (Resend is tried first if both are set):

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

Then set FROM_EMAIL and FROM_NAME @@ -321,6 +331,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 +349,9 @@ ${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, the likeliest cause is a sending domain that is not onboarded yet: + the send fails rather than logging, and tail names it.

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 From 401f157d2ecdc939e84db2193fb7d98dd7750f9f Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Mon, 14 Sep 2026 22:53:57 -0700 Subject: [PATCH 2/2] Document the Cloudflare Email Service setup as an ordered flow The two failures this avoids are both invisible until a send happens, so the ordering is the documentation: onboard the domain BEFORE uncommenting the binding, and put FROM_EMAIL on the domain you onboarded rather than anywhere on the zone. Names the sign-in link as the check, since it is the one email sent on the request path and so fails where someone is looking. Also notes that onboarding leaves the apex MX alone, because "will this break the mail I already receive here" is the first question anyone with a live domain asks. --- docs/self-hosting.md | 53 ++++++++++++++++++++++++++++++++++++++++-- src/http/pages/docs.ts | 51 +++++++++++++++++++++++++++++----------- 2 files changed, 89 insertions(+), 15 deletions(-) diff --git a/docs/self-hosting.md b/docs/self-hosting.md index a805e57..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 @@ -260,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/http/pages/docs.ts b/src/http/pages/docs.ts index 22f0fed..090352c 100644 --- a/src/http/pages/docs.ts +++ b/src/http/pages/docs.ts @@ -247,18 +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. - On Cloudflare the simplest option has no key at all — bind Cloudflare - Email Service in wrangler.toml, which takes precedence over - both API keys when present:

+ 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"`)} -

Sending to guests (rather than only to verified - destination addresses in your own account) requires onboarding your sending - domain under Compute → Email Service → Email Sending, - and a Workers Paid plan. Until the domain is onboarded, sends to guests fail - — and because a provider is configured, the banner below stays - silent, so onboard the domain before your first sign-in.

-

Otherwise set either provider's key (Resend is - tried first if both are set):

+

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 @@ -350,8 +374,9 @@ ${pre(`git pull\nnpm run migrate\nnpm run deploy`)}

Emails are not arriving. With no RESEND_API_KEY they are logged, not sent. Check npx wrangler tail. If you bound Cloudflare Email Service - instead, the likeliest cause is a sending domain that is not onboarded yet: - the send fails rather than logging, and tail names it.

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