CloudflareEmail Service

Email Service

The EmailService layer gives every Worker handler a single, provider-agnostic way to send transactional email. You call createEmailService(env), get back an...

Email Service

The EmailService layer gives every Worker handler a single, provider-agnostic way to send transactional email. You call createEmailService(env), get back an IEmailService, and call sendEmail(payload) — the service picks the best available provider automatically.


Architecture


Provider priority

PriorityProviderTrigger conditionDurabilityNotes
1 (best)QueuedEmailServiceEMAIL_QUEUE binding presentDurable (queue + Workflow)Preferred for production
2aResendEmailServicepriority='critical' + RESEND_API_KEYBest-effortAuth critical path only
2bCfEmailServiceRestServicepriority='transactional' + CF_EMAIL_API_TOKEN + CF_ACCOUNT_IDBest-effortTransactional notifications
2cCfEmailWorkerServiceSEND_EMAIL binding presentBest-effortFallback
3NullEmailServiceNothing configuredN/ALogs a warning; no send

Configuration

1. wrangler.toml bindings (added by PR #1664)

The following bindings are already present in wrangler.toml:

[[send_email]]
name = "SEND_EMAIL"
# Optional: restrict sends to a single verified address.
# Omit to allow sending to all verified addresses.
# destination_address = "notifications@bloqr.dev"

[[queues.producers]]
queue = "bloqr-backend-email-queue"
binding = "EMAIL_QUEUE"

[[queues.consumers]]
queue = "bloqr-backend-email-queue"
max_batch_size = 5
max_batch_timeout = 5  # seconds
max_retries = 3
dead_letter_queue = "bloqr-backend-email-dlq"

[[workflows]]
name = "email-delivery-workflow"
binding = "EMAIL_DELIVERY_WORKFLOW"
class_name = "EmailDeliveryWorkflow"

2. Worker Secrets

The following Worker Secrets are used by the email system:

SecretRequired forCommand
RESEND_API_KEYAuth critical email (verification, password reset)wrangler secret put RESEND_API_KEY
RESEND_AUDIENCE_IDUser lifecycle contact sync to Resend audiencewrangler secret put RESEND_AUDIENCE_ID
CF_EMAIL_API_TOKENTransactional notifications via CF Email Service RESTwrangler secret put CF_EMAIL_API_TOKEN

SEND_EMAIL does not require a secret — it is a native Cloudflare binding configured in wrangler.toml.

Obtain RESEND_API_KEY from resend.com/api-keys — use Full Access or a domain-scoped key for bloqr.dev.

Obtain RESEND_AUDIENCE_ID from resend.com/audiences — create an audience named "Bloqr Users" and copy the UUID.

3. D1 migration (edge tracking tables)

wrangler d1 execute adblock-db --file=migrations/0011_email_tracking_edge.sql

Creates email_log_edge and email_idempotency_keys tables in D1.

4. Neon migration (primary tracking tables)

deno task db:migrate:deploy
# Applies: prisma/migrations/20260425000000_email_tracking/

Creates EmailTemplate, EmailLog, and EmailNotificationPreference tables in Neon.


How to send an email

Use a fire-and-forget pattern so email never blocks the primary response:

import { createEmailService } from '../services/email-service.ts';
import { renderCompilationComplete } from '../services/email-templates.ts';

// Inside a handler that has access to ctx (ExecutionContext):
const mailer = createEmailService(env);
const payload = renderCompilationComplete({
    configName: req.configName,
    ruleCount: result.ruleCount,
    durationMs: elapsed,
    requestId: req.id,
});

ctx.waitUntil(
    mailer.sendEmail(payload).catch((err) =>
        console.warn('[email] send error:', err)
    )
);

ctx.waitUntil ensures the Worker does not terminate before the email is enqueued, without blocking the HTTP response to the user.


Admin API

MethodPathAuthReturns
GET/admin/email/configUserTier.Admin + X-Admin-KeyCurrent provider type, binding status, env var presence
POST/admin/email/testUserTier.Admin + X-Admin-KeyDelivery result for a test email to the specified address

Idempotency

QueuedEmailService derives an idempotency key internally as email-${requestId ?? uuid}. Pass the optional requestId option to make the key deterministic:

const mailer = new QueuedEmailService(env.EMAIL_QUEUE, {
    requestId: compilationRequestId,   // derives idempotencyKey = "email-<compilationRequestId>"
    reason: 'compilation_complete',
});

Inside EmailDeliveryWorkflow, the Workflow instance ID is set to the idempotency key when the queue consumer creates the workflow (env.EMAIL_DELIVERY_WORKFLOW.create({ id: idempotencyKey })). Cloudflare's Workflow runtime rejects duplicate create() calls with the same instance ID, preventing duplicate workflow runs. After successful delivery, Step 3 writes the key to email_idempotency_keys (D1) so the queue consumer can short-circuit replays before even triggering a new workflow.


ZTA notes

  • All inbound EmailPayload objects are Zod-validated (EmailPayloadSchema) at the service boundary.
  • Admin endpoints (/admin/email/*) require UserTier.Admin + a valid X-Admin-Key header.
  • Email subject lines are RFC 2047-encoded and validated against a ^[^\r ]*$ pattern to prevent MIME header injection.
  • HTML email bodies are passed through escapeHtml() (worker/utils/escape-html.ts) before template interpolation to prevent XSS in email clients.

Troubleshooting

SymptomLikely causeFix
No emails sent, no errors loggedNullEmailService selected — no provider configuredConfigure SEND_EMAIL or EMAIL_QUEUE bindings in wrangler.toml
Queue backlog growingEmailDeliveryWorkflow failing repeatedlyCheck Workflow logs via wrangler tail; verify SEND_EMAIL binding is correctly configured
503 from POST /admin/email/testNo email provider availableConfirm bindings in wrangler.toml are deployed; check GET /admin/email/config for binding status
[ResendEmailService] Delivery failed: HTTP 401RESEND_API_KEY invalid or expiredwrangler secret put RESEND_API_KEY to rotate
[ResendContactService] syncUserCreated failedRESEND_AUDIENCE_ID missing or wrongVerify audience UUID in Resend dashboard; wrangler secret put RESEND_AUDIENCE_ID

See also