diff --git a/AGENTS.md b/AGENTS.md index 12fe3c8b0..400fbb87e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ This is a **TypeScript monorepo** for applications on the Tempo blockchain appli | `apps/tokenlist` | Token registry API | | `apps/contract-verification` | Smart contract verification | | `apps/og` | OpenGraph image generation | +| `apps/email` | Transactional email sending via Cloudflare Email Service | ## Commands diff --git a/apps/email/.env.example b/apps/email/.env.example new file mode 100644 index 000000000..8e349bf1a --- /dev/null +++ b/apps/email/.env.example @@ -0,0 +1,3 @@ +# No environment variables required for basic email sending. +# The SEND_EMAIL binding is configured in wrangler.json. +# To restrict senders/recipients, update the send_email binding in wrangler.json. diff --git a/apps/email/package.json b/apps/email/package.json new file mode 100644 index 000000000..509eb505e --- /dev/null +++ b/apps/email/package.json @@ -0,0 +1,25 @@ +{ + "name": "email", + "type": "module", + "private": true, + "scripts": { + "dev": "wrangler dev", + "build": "echo 'No build step needed for Cloudflare Worker'", + "deploy": "wrangler deploy", + "check": "pnpm check:biome && pnpm check:types", + "check:biome": "biome check --write --unsafe", + "check:types": "tsgo --project tsconfig.json --noEmit", + "gen:types": "wrangler types --env-interface='CloudflareBindings' --env-file='.env.example'", + "postinstall": "pnpm gen:types" + }, + "dependencies": { + "@hono/zod-validator": "catalog:", + "hono": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:", + "@types/node": "catalog:", + "wrangler": "catalog:" + } +} diff --git a/apps/email/src/index.ts b/apps/email/src/index.ts new file mode 100644 index 000000000..484a14093 --- /dev/null +++ b/apps/email/src/index.ts @@ -0,0 +1,36 @@ +import { zValidator } from '@hono/zod-validator' +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import * as z from 'zod' + +const emailAddress = z.object({ + email: z.string().email(), + name: z.string(), +}) + +const sendSchema = z.object({ + from: z.union([z.string().email(), emailAddress]), + to: z.union([z.string().email(), z.array(z.string().email()).min(1)]), + subject: z.string().min(1), + replyTo: z.union([z.string().email(), emailAddress]).optional(), + cc: z.union([z.string().email(), z.array(z.string().email())]).optional(), + bcc: z.union([z.string().email(), z.array(z.string().email())]).optional(), + text: z.string().optional(), + html: z.string().optional(), +}) + +const app = new Hono<{ Bindings: CloudflareBindings }>() + +app.use('*', cors()) + +app.get('/health', (c) => c.text('ok')) + +app.post('/send', zValidator('json', sendSchema), async (c) => { + const body = c.req.valid('json') + + await c.env.SEND_EMAIL.send(body) + + return c.json({ success: true }) +}) + +export default app diff --git a/apps/email/tsconfig.json b/apps/email/tsconfig.json new file mode 100644 index 000000000..b67577f74 --- /dev/null +++ b/apps/email/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "ESNext", + "lib": ["ES2024"], + "types": ["@cloudflare/workers-types", "node"], + "moduleResolution": "bundler", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "checkJs": false, + "allowImportingTsExtensions": true, + "resolvePackageJsonImports": true, + "resolvePackageJsonExports": true, + "useUnknownInCatchVariables": true, + "noUncheckedIndexedAccess": true, + "paths": { + "#*": ["./src/*"] + } + }, + "files": ["worker-configuration.d.ts"], + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/email/wrangler.json b/apps/email/wrangler.json new file mode 100644 index 000000000..38ce68f36 --- /dev/null +++ b/apps/email/wrangler.json @@ -0,0 +1,23 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "email", + "main": "src/index.ts", + "compatibility_date": "2025-12-17", + "compatibility_flags": ["nodejs_compat"], + "workers_dev": true, + "preview_urls": true, + "send_email": [ + { + "name": "SEND_EMAIL" + } + ], + "observability": { + "enabled": true, + "logs": { + "enabled": true, + "persist": true, + "head_sampling_rate": 1, + "invocation_logs": true + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a6bc1a96..9fee84d7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -381,6 +381,28 @@ importers: specifier: 'catalog:' version: 4.79.0(@cloudflare/workers-types@4.20260408.1)(bufferutil@4.1.0)(utf-8-validate@5.0.10) + apps/email: + dependencies: + '@hono/zod-validator': + specifier: 'catalog:' + version: 0.7.6(hono@4.12.12)(zod@4.3.6) + hono: + specifier: 'catalog:' + version: 4.12.12 + zod: + specifier: 'catalog:' + version: 4.3.6 + devDependencies: + '@cloudflare/workers-types': + specifier: 'catalog:' + version: 4.20260408.1 + '@types/node': + specifier: 'catalog:' + version: 25.5.2 + wrangler: + specifier: 'catalog:' + version: 4.79.0(@cloudflare/workers-types@4.20260408.1)(bufferutil@4.1.0)(utf-8-validate@5.0.10) + apps/explorer: dependencies: '@sentry/cloudflare':