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,