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. Email is not configured — no one is receiving confirmations.
Bookings are being saved and synced to calendars, but every confirmation, reschedule notice,
- cancellation and reminder is written to the log instead of sent. Set
+ cancellation and reminder is written to the log instead of sent. Add a
+ 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):[[send_email]] binding for Cloudflare Email Service, or set
RESEND_API_KEY or BREVO_API_KEY as a secret, then redeploy
— see self-hosting.6. Email (optional, but you want it)
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:
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.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.wrangler.toml.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.
Set either provider's key (Resend is tried first if both
+ are set, and a send_email binding takes precedence over both):
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`)}
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(['"Guestx
', 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