Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 52 additions & 2 deletions docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<your-domain>`, plus 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 (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
Expand Down Expand Up @@ -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 |

Expand All @@ -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:
Expand Down
90 changes: 90 additions & 0 deletions src/adapters/email/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
},
}
}
7 changes: 4 additions & 3 deletions src/http/pages/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
}

/**
Expand All @@ -108,7 +108,8 @@ function emailWarningBanner(chrome: DashboardChrome): string {
return `<div role="alert" class="pu-callout" style="margin:0 0 1.25rem">
<p style="margin:0"><strong>Email is not configured — no one is receiving confirmations.</strong>
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
<code>[[send_email]]</code> binding for Cloudflare Email Service, or set
<code>RESEND_API_KEY</code> or <code>BREVO_API_KEY</code> as a secret, then redeploy
&mdash; see <a href="/docs/self-hosting">self-hosting</a>.</p>
</div>`
Expand Down
44 changes: 41 additions & 3 deletions src/http/pages/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,42 @@ ${pre(`npx wrangler secret put MICROSOFT_CLIENT_ID\nnpx wrangler secret put MICR

<h2>6. Email (optional, but you want it)</h2>
<p>Without an email provider, Punctual logs emails instead of sending them.
To send for real, set <strong>either</strong> provider's key (Resend is
tried first if both are set):</p>
There are three ways to send; on Cloudflare the first needs no API key at
all.</p>

<h3>Cloudflare Email Service</h3>
<p>The <code>send_email</code> binding is itself the credential, scoped by
<code>wrangler.toml</code> &mdash; 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 <strong>in order</strong>:</p>
<ol>
<li><strong>Onboard the sending domain</strong> &mdash;
<strong>Compute &rarr; Email Service &rarr; Email Sending &rarr; Onboard
Domain</strong>. Cloudflare adds MX, SPF and DKIM under
<code>cf-bounce.&lt;your-domain&gt;</code> and DMARC at
<code>_dmarc.&lt;your-domain&gt;</code>; allow 5&ndash;15 minutes. It does
not touch your apex MX, so an existing mailbox provider on the same domain
keeps working &mdash; Email <em>Routing</em> is the feature that conflicts
there, and this is not that.</li>
<li><strong>Set <code>FROM_EMAIL</code> to an address on the domain you
onboarded.</strong> Onboarding <code>mail.example.com</code> does not
authorise <code>you@example.com</code>: a sender outside an onboarded
domain is rejected on every send.</li>
<li><strong>Uncomment the binding</strong> in <code>wrangler.toml</code>.</li>
<li><strong>Deploy, then sign in.</strong> 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 &mdash; before a guest ever books.</li>
</ol>
${pre(`[[send_email]]\nname = "EMAIL"`)}
<p class="pu-muted">The order matters. Do steps 3 and 4 before step 1 and you
get a window where this page and <code>/health</code> both read healthy while
no mail arrives: the &ldquo;email is not configured&rdquo; warning only fires
for the console sender, and with a binding present a provider <em>is</em>
configured.</p>

<h3>Resend or Brevo</h3>
<p>Set <strong>either</strong> provider's key (Resend is tried first if both
are set, and a <code>send_email</code> binding takes precedence over both):</p>
${pre(`npx wrangler secret put RESEND_API_KEY\n# or\nnpx wrangler secret put BREVO_API_KEY`)}
<p class="pu-muted">Then set <code>FROM_EMAIL</code> and <code>FROM_NAME</code>
in <code>wrangler.toml</code>'s <code>[vars]</code> to an address on a
Expand Down Expand Up @@ -321,6 +355,7 @@ ${pre(`git pull\nnpm run migrate\nnpm run deploy`)}
<tr><td class="pu-time">SIGNING_KEY</td><td>secret</td><td>HMAC key for guest manage links</td></tr>
<tr><td class="pu-time">GOOGLE_CLIENT_ID / _SECRET</td><td>secret</td><td>Your Google OAuth app</td></tr>
<tr><td class="pu-time">MICROSOFT_CLIENT_ID / _SECRET</td><td>secret</td><td>Your Microsoft app</td></tr>
<tr><td class="pu-time">[[send_email]]</td><td>binding</td><td>Cloudflare Email Service; no key. Wins over both API keys</td></tr>
<tr><td class="pu-time">RESEND_API_KEY</td><td>secret</td><td>Omit to log emails instead of sending</td></tr>
<tr><td class="pu-time">BREVO_API_KEY</td><td>secret</td><td>Alternative to Resend; Resend wins if both are set</td></tr>
</tbody>
Expand All @@ -338,7 +373,10 @@ ${pre(`git pull\nnpm run migrate\nnpm run deploy`)}
screen.</p>
<p><strong>Emails are not arriving.</strong> With no
<code>RESEND_API_KEY</code> they are logged, not sent. Check
<code>npx wrangler tail</code>.</p>
<code>npx wrangler tail</code>. 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 <code>FROM_EMAIL</code> is on a
different domain than the one you onboarded. <code>tail</code> names which.</p>
<p><strong>Times look wrong by an hour.</strong> Almost always a host
timezone set incorrectly rather than a DST bug &mdash; the engine computes
in UTC and converts at the edges.</p>
Expand Down
2 changes: 1 addition & 1 deletion src/http/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)
}
Expand Down
32 changes: 24 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -127,21 +129,35 @@ 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
// healthy Worker), which is why /health and the dashboard carry the same
// 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.',
)
}
Expand Down
2 changes: 1 addition & 1 deletion src/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading