diff --git a/README.md b/README.md index 42b3deb5..b93fa7c0 100644 --- a/README.md +++ b/README.md @@ -145,8 +145,7 @@ Secrets source model (CI): - `CLOUDFLARE_DEFAULT_ACCOUNT_ID` - `TINYBIRD_HOST` - `TINYBIRD_TOKEN` - - `RESEND_API_KEY` - - `RESEND_FROM_EMAIL` + - `EMAIL_FROM` (sender address on an onboarded Cloudflare Email Service domain; delivery uses the `EMAIL` worker binding, no API key) - `MAPLE_INGEST_KEY_ENCRYPTION_KEY` - `MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY` - `MAPLE_AUTH_MODE` diff --git a/apps/alerting/alchemy.run.ts b/apps/alerting/alchemy.run.ts index 013af704..590eccb7 100644 --- a/apps/alerting/alchemy.run.ts +++ b/apps/alerting/alchemy.run.ts @@ -1,6 +1,6 @@ import path from "node:path" import alchemy from "alchemy" -import { Worker, Workflow, type Hyperdrive } from "alchemy/cloudflare" +import { EmailSender, Worker, Workflow, type Hyperdrive } from "alchemy/cloudflare" import type { MapleDomains, MapleStage } from "@maple/infra/cloudflare" import { CLOUDFLARE_WORKER_PLACEMENT, @@ -59,6 +59,10 @@ export const createAlertingWorker = async ({ stage, mapleDb }: CreateAlertingWor bindings: { MAPLE_DB: mapleDb, AI_TRIAGE_WORKFLOW: aiTriageWorkflow, + EMAIL: EmailSender({ + allowedSenderAddresses: ["notifications@noreply.maple.dev"], + dev: { remote: true }, + }), TINYBIRD_HOST: requireEnv("TINYBIRD_HOST"), TINYBIRD_TOKEN: alchemy.secret(requireEnv("TINYBIRD_TOKEN")), MAPLE_AUTH_MODE: process.env.MAPLE_AUTH_MODE?.trim() || "self_hosted", @@ -68,7 +72,7 @@ export const createAlertingWorker = async ({ stage, mapleDb }: CreateAlertingWor MAPLE_INGEST_PUBLIC_URL: process.env.MAPLE_INGEST_PUBLIC_URL?.trim() || "https://ingest.maple.dev", MAPLE_APP_BASE_URL: process.env.MAPLE_APP_BASE_URL?.trim() || "https://app.maple.dev", - RESEND_FROM_EMAIL: process.env.RESEND_FROM_EMAIL?.trim() || "Maple ", + EMAIL_FROM: process.env.EMAIL_FROM?.trim() || "Maple ", ...optionalPlain("MAPLE_ENDPOINT"), ...optionalPlain("MAPLE_ENVIRONMENT", resolveDeploymentEnvironment(stage)), ...optionalPlain("COMMIT_SHA"), @@ -79,7 +83,6 @@ export const createAlertingWorker = async ({ stage, mapleDb }: CreateAlertingWor ...optionalSecret("CLERK_JWT_KEY"), ...optionalSecret("AUTUMN_SECRET_KEY"), ...optionalSecret("INTERNAL_SERVICE_TOKEN"), - ...optionalSecret("RESEND_API_KEY"), }, }) diff --git a/apps/alerting/src/worker.ts b/apps/alerting/src/worker.ts index cbdd269a..db6e1f96 100644 --- a/apps/alerting/src/worker.ts +++ b/apps/alerting/src/worker.ts @@ -107,7 +107,11 @@ const buildLayer = (_env: Record) => { ), ) - const EmailServiceLive = EmailService.layer.pipe(Layer.provide(EnvLive)) + // EmailService now resolves the Cloudflare Email Service `EMAIL` binding from + // WorkerEnvironment (delivery binding) in addition to EnvLive (EMAIL_FROM). + const EmailServiceLive = EmailService.layer.pipe( + Layer.provide(Layer.mergeAll(EnvLive, WorkerEnvironment.layer)), + ) const DigestServiceLive = DigestService.layer.pipe( Layer.provide(Layer.mergeAll(BaseLive, WarehouseQueryServiceLive, EmailServiceLive)), diff --git a/apps/alerting/wrangler.jsonc b/apps/alerting/wrangler.jsonc index 8afc4c8a..76cee40c 100644 --- a/apps/alerting/wrangler.jsonc +++ b/apps/alerting/wrangler.jsonc @@ -15,6 +15,14 @@ "localConnectionString": "postgres://maple:maple@localhost:5499/maple", }, ], + // Cloudflare Email Service (Email Sending). `remote: true` routes sends through + // the real Cloudflare sender. (Mirrored in alchemy.run.ts for deploy.) + "send_email": [ + { + "name": "EMAIL", + "remote": true, + }, + ], "triggers": { "crons": ["* * * * *", "*/5 * * * *", "*/15 * * * *", "0 * * * *", "0 9 * * *"], }, diff --git a/apps/api/alchemy.run.ts b/apps/api/alchemy.run.ts index 6976dcf4..59439d47 100644 --- a/apps/api/alchemy.run.ts +++ b/apps/api/alchemy.run.ts @@ -1,6 +1,7 @@ import path from "node:path" import alchemy from "alchemy" import { + EmailSender, Hyperdrive, HyperdriveRef, KVNamespace, @@ -162,6 +163,10 @@ export const createMapleApi = async ({ stage, domains }: CreateMapleApiOptions) CLICKHOUSE_SCHEMA_APPLY_WORKFLOW: schemaApplyWorkflow, AI_TRIAGE_WORKFLOW: aiTriageWorkflow, CHAT_FLUE: chatFlue, + EMAIL: EmailSender({ + allowedSenderAddresses: ["notifications@noreply.maple.dev"], + dev: { remote: true }, + }), TINYBIRD_HOST: requireEnv("TINYBIRD_HOST"), TINYBIRD_TOKEN: alchemy.secret(requireEnv("TINYBIRD_TOKEN")), ...optionalPlain("CLICKHOUSE_URL"), @@ -175,7 +180,7 @@ export const createMapleApi = async ({ stage, domains }: CreateMapleApiOptions) MAPLE_INGEST_PUBLIC_URL: process.env.MAPLE_INGEST_PUBLIC_URL?.trim() || "https://ingest.maple.dev", MAPLE_APP_BASE_URL: process.env.MAPLE_APP_BASE_URL?.trim() || "https://app.maple.dev", - RESEND_FROM_EMAIL: process.env.RESEND_FROM_EMAIL?.trim() || "Maple ", + EMAIL_FROM: process.env.EMAIL_FROM?.trim() || "Maple ", // Bucket-cache knobs: on by default in deployed stages. Override via // deploy-time env (e.g. `QE_BUCKET_CACHE_ENABLED=false`) if needed. QE_BUCKET_CACHE_ENABLED: process.env.QE_BUCKET_CACHE_ENABLED?.trim() || "true", @@ -192,7 +197,6 @@ export const createMapleApi = async ({ stage, domains }: CreateMapleApiOptions) ...optionalSecret("AUTUMN_SECRET_KEY"), ...optionalSecret("SD_INTERNAL_TOKEN"), ...optionalSecret("INTERNAL_SERVICE_TOKEN"), - ...optionalSecret("RESEND_API_KEY"), ...optionalPlain("HAZEL_API_BASE_URL"), ...optionalPlain("HAZEL_OAUTH_DISCOVERY_URL"), ...optionalPlain("HAZEL_OAUTH_CLIENT_ID"), diff --git a/apps/api/src/lib/EmailService.ts b/apps/api/src/lib/EmailService.ts index 11ecc61b..8890f4eb 100644 --- a/apps/api/src/lib/EmailService.ts +++ b/apps/api/src/lib/EmailService.ts @@ -1,4 +1,5 @@ -import { Duration, Effect, Layer, Option, Redacted, Schema, Context } from "effect" +import { WorkerEnvironment } from "@maple/effect-cloudflare/worker-environment" +import { Duration, Effect, Layer, Schema, Context } from "effect" import { Env } from "./Env" class EmailDeliveryError extends Schema.TaggedErrorClass()( @@ -18,6 +19,22 @@ export interface EmailServiceShape { ) => Effect.Effect } +/** + * Minimal shape of the Cloudflare Email Service Workers binding (`send_email`). + * Mirrors the builder overload of `SendEmail` from `@cloudflare/workers-types` + * — we only use the structured-object form, never the raw MIME `EmailMessage`. + */ +interface SendEmailBinding { + send: (message: { + from: string + to: string + subject: string + html?: string + text?: string + replyTo?: string + }) => Promise<{ messageId: string }> +} + const EMAIL_TIMEOUT = Duration.seconds(15) export class EmailService extends Context.Service()( @@ -25,10 +42,12 @@ export class EmailService extends Context.Service).EMAIL + + const isConfigured = binding !== undefined const send = Effect.fn("EmailService.send")(function* ( to: string, @@ -38,69 +57,53 @@ export class EmailService extends Context.Service - fetch("https://api.resend.com/emails", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${Redacted.value(apiKey.value)}`, - }, - body: JSON.stringify({ - from: fromEmail, - to: [to], - subject, - html, - ...(replyTo ? { reply_to: replyTo } : {}), - }), - }), - catch: (error) => - new EmailDeliveryError({ - message: error instanceof Error ? error.message : "Resend API request failed", + binding.send({ + from: fromEmail, + to, + subject, + html, + ...(replyTo ? { replyTo } : {}), }), + catch: (error) => { + const code = + error && typeof error === "object" && "code" in error + ? ` [${String((error as { code: unknown }).code)}]` + : "" + return new EmailDeliveryError({ + message: + error instanceof Error + ? `Cloudflare Email send failed${code}: ${error.message}` + : "Cloudflare Email send failed", + }) + }, }).pipe( Effect.timeoutOrElse({ duration: EMAIL_TIMEOUT, orElse: () => Effect.fail( new EmailDeliveryError({ - message: "Resend API request timed out after 15s", + message: "Cloudflare Email send timed out after 15s", }), ), }), ) - yield* Effect.annotateCurrentSpan("http.response.status_code", response.status) - - if (!response.ok) { - const body = yield* Effect.tryPromise({ - try: () => response.text(), - catch: () => - new EmailDeliveryError({ - message: `Resend API returned ${response.status}`, - }), - }) - yield* Effect.logError("Email delivery failed").pipe( - Effect.annotateLogs({ subject, status: response.status, body }), - ) - return yield* Effect.fail( - new EmailDeliveryError({ - message: `Resend API returned ${response.status}: ${body}`, - }), - ) - } - - yield* Effect.logInfo("Email sent successfully").pipe(Effect.annotateLogs({ subject })) + yield* Effect.annotateCurrentSpan("email.message_id", result.messageId) + yield* Effect.logInfo("Email sent successfully").pipe( + Effect.annotateLogs({ subject, messageId: result.messageId }), + ) }) return { isConfigured, send } satisfies EmailServiceShape diff --git a/apps/api/src/lib/Env.ts b/apps/api/src/lib/Env.ts index 27328b48..0ac4aab8 100644 --- a/apps/api/src/lib/Env.ts +++ b/apps/api/src/lib/Env.ts @@ -29,8 +29,7 @@ export interface EnvShape { readonly AUTUMN_SECRET_KEY: Option.Option> readonly SD_INTERNAL_TOKEN: Option.Option> readonly INTERNAL_SERVICE_TOKEN: Option.Option> - readonly RESEND_API_KEY: Option.Option> - readonly RESEND_FROM_EMAIL: string + readonly EMAIL_FROM: string readonly HAZEL_API_BASE_URL: string readonly HAZEL_OAUTH_DISCOVERY_URL: string readonly HAZEL_OAUTH_CLIENT_ID: Option.Option @@ -87,8 +86,7 @@ const envConfig = Config.all({ AUTUMN_SECRET_KEY: optionalRedacted("AUTUMN_SECRET_KEY"), SD_INTERNAL_TOKEN: optionalRedacted("SD_INTERNAL_TOKEN"), INTERNAL_SERVICE_TOKEN: optionalRedacted("INTERNAL_SERVICE_TOKEN"), - RESEND_API_KEY: optionalRedacted("RESEND_API_KEY"), - RESEND_FROM_EMAIL: stringWithDefault("RESEND_FROM_EMAIL", "Maple "), + EMAIL_FROM: stringWithDefault("EMAIL_FROM", "Maple "), HAZEL_API_BASE_URL: stringWithDefault("HAZEL_API_BASE_URL", "https://api.hazel.sh"), HAZEL_OAUTH_DISCOVERY_URL: stringWithDefault( "HAZEL_OAUTH_DISCOVERY_URL", diff --git a/apps/api/src/services/OnboardingEmailService.ts b/apps/api/src/services/OnboardingEmailService.ts index 3316ccf5..743a549a 100644 --- a/apps/api/src/services/OnboardingEmailService.ts +++ b/apps/api/src/services/OnboardingEmailService.ts @@ -16,7 +16,7 @@ const ROOT_ROLE = RoleName.make("root") /** * Reply-To for the founder-voice onboarding emails. Replies land in David's - * inbox instead of the unattended `RESEND_FROM_EMAIL` so the "I read every + * inbox instead of the unattended `EMAIL_FROM` so the "I read every * email" promise in the copy is actually true. */ const FOUNDER_REPLY_EMAIL = "david@maple.dev" diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index e7ee435a..af677dd5 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -27,6 +27,15 @@ "id": "00000000000000000000000000000000", }, ], + // Cloudflare Email Service (Email Sending). `remote: true` routes sends through + // the real Cloudflare sender even under local dev. (Mirrored in alchemy.run.ts + // for deploy.) Pure `bun dev` (non-wrangler) has no EMAIL binding → sends skip. + "send_email": [ + { + "name": "EMAIL", + "remote": true, + }, + ], "workflows": [ { "name": "clickhouse-schema-apply-workflow",