Skip to content

Cloudflare Worker ​

For sites deployed as static assets on Cloudflare, the mail endpoint can run in a plain Worker instead of the Nitro server. Initializing Nuxt on every request costs CPU time that can exceed the 10 ms limit of the Workers Free plan; the Worker handler runs without Nitro.

The client side does not change: useMailer calls the same route with the same body and gets the same response.

Setup ​

Import the handler from the /worker entry and call it from your Worker fetch:

worker/index.ts
ts
import { createMailHandler, sesTransport } from '@vikeriait/nuxt-transport-mailer/worker'

interface Env {
  CONTACT_EMAIL: string
  MAIL_FROM: string
  SES_ACCESS_KEY_ID: string
  SES_SECRET_ACCESS_KEY: string
  TURNSTILE_SECRET_KEY: string
  MAIL_RATE_LIMITER: RateLimit
}

const mail = createMailHandler((env: Env) => ({
  to: env.CONTACT_EMAIL,
  from: env.MAIL_FROM,
  transport: sesTransport({
    clientConfig: {
      accessKeyId: env.SES_ACCESS_KEY_ID,
      secretAccessKey: env.SES_SECRET_ACCESS_KEY,
      region: 'eu-west-1',
    },
  }),
  captcha: {
    provider: 'turnstile',
    secretKey: env.TURNSTILE_SECRET_KEY,
  },
  rateLimiter: env.MAIL_RATE_LIMITER,
}))

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    return await mail(request, env, ctx) ?? new Response('Not Found', { status: 404 })
  },
} satisfies ExportedHandler<Env>

The handler returns undefined for requests outside its route, so several handlers can be chained in the same fetch.

With static assets, route only the API to the Worker:

wrangler.jsonc
jsonc
{
  "main": "worker/index.ts",
  "assets": {
    "directory": ".output/public",
    "run_worker_first": ["/api/*"]
  },
  "ratelimits": [
    { "name": "MAIL_RATE_LIMITER", "namespace_id": "1001", "simple": { "limit": 5, "period": 60 } }
  ]
}

Keep serverApi.enabled: false in nuxt.config.ts: the endpoint is served by the Worker.

Differences from the Nuxt server API ​

Nuxt server APIWorker handler
Recipients and senderFrom the request body, defaults.from as fallbackFixed in the configuration; to, from, cc, bcc in the body are ignored
Accepted body fieldsAll email optionssubject, text, html, replyTo, captchaToken, _gotcha
Transportsdriver: smtp, ses, streamtransport: sesTransport(), smtpTransport() (Edge only), or your own
Rate limitingnuxt-securityWorkers ratelimits binding, keyed by cf-connecting-ip
Success resultFull SentMessageInfo{ messageId } only
Transport failure500 with the transport message502 Failed to send email; the detail goes to the Worker logs
Hookstransport:send:before, transport:send:afteronSent

Transports ​

Only the transport you import is bundled: a Worker using sesTransport does not ship worker-mailer.

A custom transport is an object with a name (used in logs) and a send function returning the message id:

ts
const transport: MailTransport = {
  name: 'resend',
  send: async (options) => {
    const response = await fetch('https://api.resend.com/emails', { /* ... */ })
    return { messageId: (await response.json()).id }
  },
}

send receives the fixed from and to, and subject, text, html, replyTo from the request.

SMTP

SMTP on Edge uses worker-mailer, which relies on Cloudflare's connect() API. Measure the CPU time on your plan before relying on it.

Options ​

OptionTypeDefaultDescription
routestring'/api/mail/send'Path served by the handler.
tostring | string[]—Fixed recipients.
fromstring—Fixed sender.
transportMailTransport—sesTransport({ endpoint?, clientConfig }) (aws4fetch) or smtpTransport(options) (worker-mailer).
captcha{ provider, secretKey }—turnstile, recaptcha or hcaptcha. Omit to disable.
rateLimiter{ limit({ key }) }—Workers rate limiting binding. Omit to disable.
maxBodyLengthnumber65536Maximum length of the raw body, in characters (413 above it).
maxSubjectLengthnumber200Maximum length of the subject.
onSent({ messageId, request, body }) => void | Promise<void>—Runs after a successful send, before the response. Errors are logged and do not fail the response.