From b7f07645adc53ec186c8c284d6b79c44eb7a07bd Mon Sep 17 00:00:00 2001 From: Rayhan Noufal Arayilakath Date: Tue, 1 Sep 2026 00:27:36 -0400 Subject: [PATCH] feat: send mail through Cloudflare instead of Resend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01AHAyCUvMbU4jG9bg5EuC3C --- .env.example | 6 +- README.md | 21 +-- bun.lock | 43 ------ package.json | 1 - src/emails/cloudflareAdapter.ts | 226 ++++++++++++++++++++++++++++++++ src/environment.d.ts | 3 +- src/payload.config.ts | 12 +- 7 files changed, 251 insertions(+), 61 deletions(-) create mode 100644 src/emails/cloudflareAdapter.ts diff --git a/.env.example b/.env.example index a7f6c38..cae86ec 100644 --- a/.env.example +++ b/.env.example @@ -8,8 +8,10 @@ TURSO_AUTH_TOKEN= # Vercel Blob BLOB_READ_WRITE_TOKEN= -# Resend -RESEND_API_KEY= +# Cloudflare Email Sending. The token needs Account:Email Sending:Edit, and the +# From must sit on an onboarded sending subdomain (ours is mail.purduehackers.com). +CLOUDFLARE_API_TOKEN= +CLOUDFLARE_ACCOUNT_ID= # Sentry SENTRY_DSN= diff --git a/README.md b/README.md index 6f02294..6e6ea8b 100644 --- a/README.md +++ b/README.md @@ -34,16 +34,17 @@ For local dev against a file-backed SQLite, set `TURSO_DATABASE_URL=file:./dev.d See `.env.example`. Required for production: -| Variable | Purpose | -| ------------------------ | ---------------------------------------- | -| `PAYLOAD_SECRET` | Payload JWT/session signing | -| `TURSO_DATABASE_URL` | `libsql://…turso.io` connection URL | -| `TURSO_AUTH_TOKEN` | Turso DB auth token | -| `BLOB_READ_WRITE_TOKEN` | Vercel Blob token (auto-set on Vercel) | -| `RESEND_API_KEY` | Resend API key for transactional email | -| `SENTRY_DSN` | Server-side Sentry DSN | -| `NEXT_PUBLIC_SENTRY_DSN` | Client-side Sentry DSN | -| `SENTRY_AUTH_TOKEN` | Source-map upload token (Vercel CI only) | +| Variable | Purpose | +| ------------------------ | -------------------------------------------------- | +| `PAYLOAD_SECRET` | Payload JWT/session signing | +| `TURSO_DATABASE_URL` | `libsql://…turso.io` connection URL | +| `TURSO_AUTH_TOKEN` | Turso DB auth token | +| `BLOB_READ_WRITE_TOKEN` | Vercel Blob token (auto-set on Vercel) | +| `CLOUDFLARE_API_TOKEN` | Cloudflare token; needs Account:Email Sending:Edit | +| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account the sending domain lives in | +| `SENTRY_DSN` | Server-side Sentry DSN | +| `NEXT_PUBLIC_SENTRY_DSN` | Client-side Sentry DSN | +| `SENTRY_AUTH_TOKEN` | Source-map upload token (Vercel CI only) | ## Database diff --git a/bun.lock b/bun.lock index 06416cb..7f28f07 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,6 @@ "dependencies": { "@libsql/client": "^0.15.0", "@payloadcms/db-sqlite": "3.83.0", - "@payloadcms/email-resend": "3.83.0", "@payloadcms/next": "3.83.0", "@payloadcms/plugin-mcp": "3.83.0", "@payloadcms/plugin-redirects": "3.83.0", @@ -375,8 +374,6 @@ "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="], - "@neondatabase/serverless": ["@neondatabase/serverless@0.9.5", "", { "dependencies": { "@types/pg": "8.11.6" } }, "sha512-siFas6gItqv6wD/pZnvdu34wEqgG3nSE6zWZdq5j2DEsa+VvX8i/5HXJOo06qrw5axPXn+lGCxeR+NLaSPIXug=="], - "@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="], "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.2.3", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-nE/b9mht28XJxjTwKs/yk7w4XTaU3t40UHVAky6cjiijdP/SEy3hGsnQMPxmXPTpC7W4/97okm6fngKnvCqVaA=="], @@ -475,8 +472,6 @@ "@payloadcms/drizzle": ["@payloadcms/drizzle@3.83.0", "", { "dependencies": { "console-table-printer": "2.12.1", "dequal": "2.0.3", "drizzle-orm": "0.45.2", "prompts": "2.4.2", "to-snake-case": "1.0.0", "uuid": "11.1.0" }, "peerDependencies": { "payload": "3.83.0" } }, "sha512-gavwuiEJpFd7/+nwup2d+ZuE9D8kJs0B8W60CGI0MySEzWrz11tTMPAvfNaOzjoh81sETdVejaCG1iEep4+L/g=="], - "@payloadcms/email-resend": ["@payloadcms/email-resend@3.83.0", "", { "peerDependencies": { "payload": "3.83.0" } }, "sha512-yCp/avRhxTAXS81xVxmllwFd8MiZa7ztmczFA3J0wD4Y1FbrAt91XXl5ux0R3YyBioSKFbe9XVKBOwUU5J6wnw=="], - "@payloadcms/graphql": ["@payloadcms/graphql@3.83.0", "", { "dependencies": { "graphql-scalars": "1.22.2", "pluralize": "8.0.0", "ts-essentials": "10.0.3", "tsx": "4.21.0" }, "peerDependencies": { "graphql": "^16.8.1", "payload": "3.83.0" }, "bin": { "payload-graphql": "bin.js" } }, "sha512-HBy7OI+rDLpeN+KEXlcEk/3ohOzrCJApoS9vtWfoAnDh7N3kDr/fHSTsUlAlMrH5f5OU0fOMyx1V88J9zdBDiw=="], "@payloadcms/next": ["@payloadcms/next@3.83.0", "", { "dependencies": { "@dnd-kit/core": "6.3.1", "@dnd-kit/modifiers": "9.0.0", "@dnd-kit/sortable": "10.0.0", "@payloadcms/graphql": "3.83.0", "@payloadcms/translations": "3.83.0", "@payloadcms/ui": "3.83.0", "busboy": "^1.6.0", "dequal": "2.0.3", "file-type": "21.3.4", "graphql-http": "^1.22.0", "graphql-playground-html": "1.6.30", "http-status": "2.1.0", "path-to-regexp": "6.3.0", "qs-esm": "8.0.1", "sass": "1.77.4", "uuid": "11.1.0" }, "peerDependencies": { "graphql": "^16.8.1", "next": ">=15.2.9 <15.3.0 || >=15.3.9 <15.4.0 || >=15.4.11 <15.5.0 || >=16.2.2 <17.0.0", "payload": "3.83.0" } }, "sha512-N4pmZSfVEylajGxTXc/69EGJT4tZWgVIA4U6rV9rGqD7FkHTXVut4tz/WyR2gZuSryHsFP0OobMEACCPs5YLIA=="], @@ -503,8 +498,6 @@ "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], - "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], - "@preact/signals-core": ["@preact/signals-core@1.14.1", "", {}, "sha512-vxPpfXqrwUe9lpjqfYNjAF/0RF/eFGeLgdJzdmIIZjpOnTmGmAB4BjWone562mJGMRP4frU6iZ6ei3PDsu52Ng=="], "@prisma/instrumentation": ["@prisma/instrumentation@7.6.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ=="], @@ -797,8 +790,6 @@ "@vercel/config": ["@vercel/config@0.2.1", "", { "dependencies": { "@vercel/routing-utils": "6.1.1", "pretty-cache-header": "^1.0.0", "zod": "^3.22.0" }, "bin": { "config": "dist/cli.js" } }, "sha512-MWLB1WjVqj2z7oMBzhQez+uMr1JjK3Gq50tZ1EgcJpOua1Dyt2ITLNjh+nNyO0Z8VXdurszFeXvaDnswJRgSgQ=="], - "@vercel/postgres": ["@vercel/postgres@0.9.0", "", { "dependencies": { "@neondatabase/serverless": "^0.9.3", "bufferutil": "^4.0.8", "utf-8-validate": "^6.0.4", "ws": "^8.17.1" } }, "sha512-WiI2g3+ce2g1u1gP41MoDj2DsMuQQ+us7vHobysRixKECGaLHpfTI7DuVZmHU087ozRAGr3GocSyqmWLLo+fig=="], - "@vercel/routing-utils": ["@vercel/routing-utils@6.1.1", "", { "dependencies": { "path-to-regexp": "6.1.0", "path-to-regexp-updated": "npm:path-to-regexp@6.3.0" }, "optionalDependencies": { "ajv": "^6.12.3" } }, "sha512-kUJn6CRuLvua5xvDZTJu1rC8GLdniC8D5VIzoJKm0dUdcUxv3LEx+pi8dokxWS+YM7cIs1Z3Y8ms/BDW1iBzzA=="], "@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="], @@ -913,8 +904,6 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bufferutil": ["bufferutil@4.1.0", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw=="], - "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -1407,8 +1396,6 @@ "jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="], - "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], @@ -1591,8 +1578,6 @@ "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], - "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], - "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -1661,24 +1646,14 @@ "peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="], - "pg": ["pg@8.16.3", "", { "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw=="], - - "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="], - - "pg-connection-string": ["pg-connection-string@2.12.0", "", {}, "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ=="], - "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], "pg-numeric": ["pg-numeric@1.0.2", "", {}, "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw=="], - "pg-pool": ["pg-pool@3.13.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA=="], - "pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="], "pg-types": ["pg-types@4.1.0", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, "sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg=="], - "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], @@ -1693,10 +1668,6 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], - - "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], - "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], @@ -2005,8 +1976,6 @@ "use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA=="], - "utf-8-validate": ["utf-8-validate@6.0.6", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA=="], - "utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="], "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], @@ -2197,12 +2166,8 @@ "payload/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], - "pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], - "pino-pretty/strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], - "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "react-datepicker/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], @@ -2379,14 +2344,6 @@ "payload/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "pg/pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], - - "pg/pg-types/postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], - - "pg/pg-types/postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], - - "pg/pg-types/postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], - "schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/package.json b/package.json index 5bdaefc..82d44ab 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "dependencies": { "@libsql/client": "^0.15.0", "@payloadcms/db-sqlite": "3.83.0", - "@payloadcms/email-resend": "3.83.0", "@payloadcms/next": "3.83.0", "@payloadcms/plugin-mcp": "3.83.0", "@payloadcms/plugin-redirects": "3.83.0", diff --git a/src/emails/cloudflareAdapter.ts b/src/emails/cloudflareAdapter.ts new file mode 100644 index 0000000..c990b04 --- /dev/null +++ b/src/emails/cloudflareAdapter.ts @@ -0,0 +1,226 @@ +import { APIError } from 'payload' +import type { EmailAdapter, SendEmailOptions } from 'payload' + +/** + * Payload email adapter for Cloudflare Email Sending. + * + * Purdue Hackers already runs DNS, Email Routing, and outbound sending through + * one Cloudflare token, so this replaces the Resend adapter with the provider we + * were already paying attention to — one credential to rotate, one dashboard to + * check when a blast does not arrive. + * + * Written against `fetch` rather than the `cloudflare` SDK for the same reason + * the Resend adapter it replaces was: an email adapter uses exactly one endpoint, + * and pulling a full API client into the CMS bundle to reach it is not a trade + * worth making. + * + * Three behaviours differ from most providers and are handled deliberately below. + * + * A permanent bounce is reported inside a 2xx body rather than as an error. + * + * The sender must belong to a subdomain onboarded for Email Sending — ours is + * `mail.purduehackers.com` — and a `from` anywhere else is refused with + * `sending_disabled` rather than silently rewritten. That is a domain-level + * answer, not an account-level one: it reads like the product is switched off. + * + * Because the sender therefore lives on a subdomain nobody reads mail at, + * `defaultReplyTo` exists to point replies back at the routed inbox. Without it + * every reply to an event blast lands somewhere unattended. + */ + +const API_BASE = 'https://api.cloudflare.com/client/v4' + +interface CloudflareAdapterArgs { + accountId: string + apiToken: string + defaultFromAddress: string + defaultFromName: string + /** Applied when a message does not set its own `replyTo`. */ + defaultReplyTo?: string +} + +interface CloudflareAddress { + address: string + name?: string +} + +/** + * The attachment shape Payload passes through from nodemailer. + * + * Declared here rather than imported: the CMS does not carry `@types/nodemailer`, + * so `SendEmailOptions['attachments']` widens to `any` and the mapping below + * would silently lose its element type. + */ +interface MailAttachment { + cid?: string + content?: unknown + contentType?: string + filename?: string +} + +interface CloudflareAttachment { + content: string + disposition: 'attachment' | 'inline' + filename: string + type: string + content_id?: string +} + +export interface CloudflareSendResult { + delivered: string[] + message_id: string + permanent_bounces: string[] + queued: string[] +} + +interface CloudflareEnvelope { + errors?: { code?: number; message?: string }[] + result?: CloudflareSendResult + success?: boolean +} + +/** `Name ` and a bare address both reach us; both mean an address. */ +function toAddress(value: unknown): CloudflareAddress | null { + if (typeof value === 'string') { + const named = /^\s*(.*?)\s*<([^>]+)>\s*$/.exec(value) + if (named) return { address: named[2]!.trim(), name: named[1] || undefined } + return { address: value.trim() } + } + if (value && typeof value === 'object' && 'address' in value) { + const { address, name } = value as { address: string; name?: string } + return { address, ...(name ? { name } : {}) } + } + return null +} + +/** Recipient lists, flattened to plain addresses — Cloudflare takes no display names here. */ +function toRecipients(value: SendEmailOptions['to']): string[] { + const values = Array.isArray(value) ? value : [value] + return values.flatMap((entry) => { + const address = toAddress(entry) + return address ? [address.address] : [] + }) +} + +function toBody(value: unknown): string | undefined { + if (typeof value === 'string') return value.length > 0 ? value : undefined + if (Buffer.isBuffer(value)) return value.toString('utf-8') + return undefined +} + +/** + * Nodemailer attachments carry raw bytes; Cloudflare wants base64. + * + * A `cid` means the file is referenced from the HTML body, which is Cloudflare's + * `inline` disposition — getting that wrong shows the image as a download + * instead of in the message. + */ +function toAttachments(attachments: MailAttachment[] | undefined): CloudflareAttachment[] { + if (!attachments?.length) return [] + + return attachments.map((attachment) => { + const { cid, content, contentType, filename } = attachment + if (typeof content !== 'string' && !Buffer.isBuffer(content)) { + // Streams and `path`/`href` sources would need to be read before the + // request is built. Nothing here sends one, and failing loudly beats + // sending a mail with a silently missing attachment. + throw new APIError( + `Unsupported attachment source for "${filename ?? 'attachment'}": expected a string or Buffer`, + 400, + ) + } + + const encoded = Buffer.isBuffer(content) + ? content.toString('base64') + : Buffer.from(content, 'utf-8').toString('base64') + + return { + content: encoded, + disposition: cid ? 'inline' : 'attachment', + filename: filename ?? 'attachment', + type: contentType ?? 'application/octet-stream', + ...(cid ? { content_id: cid } : {}), + } + }) +} + +export const cloudflareAdapter = + (args: CloudflareAdapterArgs): EmailAdapter => + ({ payload }) => { + const { accountId, apiToken, defaultFromAddress, defaultFromName, defaultReplyTo } = args + + return { + name: 'cloudflare-email-sending', + defaultFromAddress, + defaultFromName, + sendEmail: async (message) => { + const text = toBody(message.text) + const html = toBody(message.html) + if (!text && !html) { + throw new APIError('Refusing to send an email with neither a text nor an HTML body', 400) + } + + const to = toRecipients(message.to) + const cc = toRecipients(message.cc) + const bcc = toRecipients(message.bcc) + if (to.length === 0 && cc.length === 0 && bcc.length === 0) { + throw new APIError('Refusing to send an email with no recipients', 400) + } + + const replyTo = toAddress(message.replyTo ?? defaultReplyTo) + const attachments = toAttachments(message.attachments) + const headers = + message.headers && !Array.isArray(message.headers) ? message.headers : undefined + + 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 } : {}), + }), + }) + + const envelope = (await response.json().catch(() => ({}))) as CloudflareEnvelope + + if (!response.ok || envelope.success === false || !envelope.result) { + const detail = (envelope.errors ?? []) + .map((error) => [error.code, error.message].filter(Boolean).join(' ')) + .filter(Boolean) + .join('; ') + throw new APIError( + `Error sending email: ${response.status}${detail ? ` ${detail}` : ''}`, + response.status, + ) + } + + const result = envelope.result + // A bounce arrives inside a 2xx body, so a caller that only watches for a + // thrown error would record a dead address as a delivered one. Logged + // rather than thrown: one bad address must not fail a blast to 200 good + // ones, and the send genuinely did happen. + if (result.permanent_bounces.length > 0) { + payload.logger.warn( + { messageId: result.message_id, bounced: result.permanent_bounces }, + 'Cloudflare reported permanent bounces while sending email', + ) + } + + return result + }, + } + } diff --git a/src/environment.d.ts b/src/environment.d.ts index f74ca21..2709786 100644 --- a/src/environment.d.ts +++ b/src/environment.d.ts @@ -5,7 +5,8 @@ declare global { TURSO_DATABASE_URL: string TURSO_AUTH_TOKEN: string BLOB_READ_WRITE_TOKEN: string - RESEND_API_KEY: string + CLOUDFLARE_API_TOKEN: string + CLOUDFLARE_ACCOUNT_ID: string SENTRY_DSN: string NEXT_PUBLIC_SENTRY_DSN: string EMAIL_ASSETS_URL?: string diff --git a/src/payload.config.ts b/src/payload.config.ts index 38f7998..a2d276e 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -1,5 +1,4 @@ import { sqliteAdapter } from '@payloadcms/db-sqlite' -import { resendAdapter } from '@payloadcms/email-resend' import { lexicalEditor } from '@payloadcms/richtext-lexical' import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob' import * as Sentry from '@sentry/nextjs' @@ -17,6 +16,7 @@ import { Rsvps } from './collections/Rsvps' import { ServiceAccounts } from './collections/ServiceAccounts' import { Shelter } from './collections/Shelter' import { Users } from './collections/Users' +import { cloudflareAdapter } from './emails/cloudflareAdapter' import { plugins } from './plugins' const filename = fileURLToPath(import.meta.url) @@ -86,10 +86,14 @@ export default buildConfig({ }), ], email: wrapAdapterWithSentry( - resendAdapter({ - defaultFromAddress: 'events@purduehackers.com', + cloudflareAdapter({ + // Cloudflare sends only from an onboarded subdomain, so the visible sender + // is on mail.; replies go back to the address people actually write to. + defaultFromAddress: 'events@mail.purduehackers.com', defaultFromName: 'Purdue Hackers', - apiKey: process.env.RESEND_API_KEY || '', + defaultReplyTo: 'events@purduehackers.com', + accountId: process.env.CLOUDFLARE_ACCOUNT_ID || '', + apiToken: process.env.CLOUDFLARE_API_TOKEN || '', }), ), cors: allowedOrigins,