feat: send mail through Cloudflare instead of Resend - #6
Merged
Merged
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
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
cloudflareAdapterthat sends via Cloudflare’s/email/sending/sendAPI. - Update environment variable typings and documentation to use
CLOUDFLARE_API_TOKEN+CLOUDFLARE_ACCOUNT_IDinstead ofRESEND_API_KEY. - Remove the
@payloadcms/email-resenddependency 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', | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 PayloadEmailAdapteroverPOST /accounts/{id}/email/sending/send, written againstfetchrather than thecloudflareSDK 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:
payload.logger.warnrather than thrown. One dead address must not fail a blast to 200 good ones.cidmaps to Cloudflare'sinlinedisposition — the difference between an embedded image and a stray download. The RSVP confirmation sends an.ics, so this path matters.events@mail.purduehackers.com; afromon the apex is refused withsending_disabled, which reads like the product is switched off rather than like a domain answer. Since nobody reads mail at that subdomain,defaultReplyTopoints replies back atevents@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
.icsattachment and the default reply-to applied.tsc --noEmitandprettier --checkclean.CLOUDFLARE_API_TOKENandCLOUDFLARE_ACCOUNT_IDare set on this project for Production and Preview. The token is account-owned and scoped to Email Sending only.RESEND_API_KEYcomes out once this is deployed.🤖 Generated with Claude Code
https://claude.ai/code/session_01AHAyCUvMbU4jG9bg5EuC3C