Skip to content

feat: send mail through Cloudflare instead of Resend - #6

Merged
rayhanadev merged 1 commit into
mainfrom
feat/cloudflare-email
Sep 1, 2026
Merged

rayhanadev merged 1 commit into
mainfrom
feat/cloudflare-email

Conversation

@rayhanadev

Copy link
Copy Markdown
Member

Purdue Hackers already runs DNS, Email Routing, and outbound sending on one Cloudflare token. The CMS was the odd one out on Resend, and its key had been invalid long enough that every RSVP confirmation and every reminder blast was failing silently — which is how the image-drop incident surfaced it.

The adapter

src/emails/cloudflareAdapter.ts — a Payload EmailAdapter over POST /accounts/{id}/email/sending/send, written against fetch rather than the cloudflare SDK for the same reason the Resend adapter it replaces was: one endpoint doesn't justify a full API client in the bundle.

Three provider behaviours shape it:

  • Bounces arrive inside a 2xx body, not as errors — so they're logged via payload.logger.warn rather than thrown. One dead address must not fail a blast to 200 good ones.
  • Attachments are base64, and a cid maps to Cloudflare's inline disposition — the difference between an embedded image and a stray download. The RSVP confirmation sends an .ics, so this path matters.
  • Mail may only leave from an onboarded sending subdomain. The visible sender is now events@mail.purduehackers.com; a from on the apex is refused with sending_disabled, which reads like the product is switched off rather than like a domain answer. Since nobody reads mail at that subdomain, defaultReplyTo points replies back at events@purduehackers.com.

Verification

Not just typechecked — exercised against the real API with the exact config this ships:

{ "message_id": "<AUrkV4yFqaf1DeGBuuCUqEmmnS22ZChiQSVt@mail.purduehackers.com>",
  "delivered": ["events@purduehackers.com"], "queued": [], "permanent_bounces": [] }

Delivered, no bounces, with the .ics attachment and the default reply-to applied. tsc --noEmit and prettier --check clean.

CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are set on this project for Production and Preview. The token is account-owned and scoped to Email Sending only. RESEND_API_KEY comes out once this is deployed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AHAyCUvMbU4jG9bg5EuC3C

Purdue Hackers already runs DNS, Email Routing, and outbound sending on one
Cloudflare token; the CMS was the odd one out on Resend, and its key had been
invalid for long enough that every RSVP confirmation and every reminder blast
was failing silently. One provider means one credential to rotate and one place
to look when a mail does not arrive.

`cloudflareAdapter` is a Payload email adapter over
`POST /accounts/{id}/email/sending/send`, written against fetch rather than the
`cloudflare` SDK for the same reason the Resend adapter it replaces was: an
email adapter uses one endpoint, and a full API client is not worth the bundle.

Three provider behaviours shape it. A permanent bounce arrives inside a 2xx body
rather than as an error, so it is logged and does not fail a blast to everyone
else. Attachments are base64, and a `cid` means Cloudflare's `inline`
disposition — the difference between an embedded image and a stray download, and
the RSVP confirmation sends an .ics. And mail may only leave from an onboarded
subdomain, so the visible sender is events@mail.purduehackers.com; because
nobody reads mail there, `defaultReplyTo` points replies back at
events@purduehackers.com.

Verified end to end against the real API with the config this ships: delivered,
no bounces, with an .ics attachment and the reply-to applied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHAyCUvMbU4jG9bg5EuC3C
Copilot AI lite review requested due to automatic review settings September 1, 2026 04:28
@vercel

vercel Bot commented Sep 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cms Building Building Preview Sep 1, 2026 4:28am UTC

Request Review

@rayhanadev
rayhanadev merged commit e1bd1ea into main Sep 1, 2026
2 of 3 checks passed
@rayhanadev
rayhanadev deleted the feat/cloudflare-email branch September 1, 2026 04:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR migrates Payload’s email sending in the CMS from the Resend adapter to a custom Cloudflare Email Sending adapter, aligning outbound email with the existing Cloudflare-managed DNS/routing setup and restoring functionality for RSVP confirmations and reminder blasts.

Changes:

  • Replace the Resend email adapter with a new cloudflareAdapter that sends via Cloudflare’s /email/sending/send API.
  • Update environment variable typings and documentation to use CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID instead of RESEND_API_KEY.
  • Remove the @payloadcms/email-resend dependency and update lockfile accordingly.

Reviewed changes

Copilot reviewed 5 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/payload.config.ts Switches Payload email adapter configuration from Resend to Cloudflare, including reply-to behavior.
src/environment.d.ts Updates required env var typings for Cloudflare credentials.
src/emails/cloudflareAdapter.ts Introduces the Cloudflare Email Sending adapter (fetch-based), including attachment/base64 and bounce handling.
README.md Documents the new required production env vars for Cloudflare sending.
package.json Removes the Resend adapter package dependency.
bun.lock Lockfile updates reflecting dependency removal/pruning.
.env.example Replaces Resend env var with Cloudflare Email Sending variables and guidance.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +175 to +196
const response = await fetch(`${API_BASE}/accounts/${accountId}/email/sending/send`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: toAddress(message.from) ?? {
address: defaultFromAddress,
name: defaultFromName,
},
subject: message.subject ?? '',
...(to.length > 0 ? { to } : {}),
...(cc.length > 0 ? { cc } : {}),
...(bcc.length > 0 ? { bcc } : {}),
...(text ? { text } : {}),
...(html ? { html } : {}),
...(replyTo ? { reply_to: replyTo } : {}),
...(attachments.length > 0 ? { attachments } : {}),
...(headers ? { headers } : {}),
}),
})
Comment on lines +217 to +220
payload.logger.warn(
{ messageId: result.message_id, bounced: result.permanent_bounces },
'Cloudflare reported permanent bounces while sending email',
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants