Send email
Configure outbound
Section titled “Configure outbound”Create a send-only Email instance with email.send(options).
import { email } from "@layeron/modules"
const outbound = email.send({ name: "transactional", namespace: "comms", domain: "mail.example.com", email: "hello", queue: { consumer: { concurrency: 4, }, },})
app.use(outbound)domain is the Cloudflare email domain hostname. The domain must belong to a
Cloudflare zone in the connected account.
email is the optional default sender. Use a local part such as hello or a
full address on the same domain such as hello@mail.example.com.
When email is omitted, every outbound.send(input) call must set from.
namespace groups the instance in the Layeron platform namespace. It defaults to
default.
Set queue.consumer.concurrency to control the maximum number of outbound
deliveries that can run at the same time for this instance. The default is 1.
Send a message
Section titled “Send a message”Call outbound.send(input) from route handlers, jobs, or workflows.
app.post("/welcome", async () => { const result = await outbound.send({ to: "ada@example.com", subject: "Welcome!", text: "Hello Ada. Welcome to our app.", html: "<p>Hello Ada. Welcome to our app.</p>", })
return Response.json(result)})Layeron uses email.send(options).email when from is omitted. to, cc,
and bcc accept one address or an array of addresses.
Dynamic sender
Section titled “Dynamic sender”Set from for a single message when the sender must change.
await outbound.send({ from: "support", to: "ada@example.com", subject: "Need help?", text: "Reply to this message when you need us.",})from accepts a local part such as support or user_123. Layeron sends it as
support@mail.example.com or user_123@mail.example.com.
await outbound.send({ from: "billing@mail.example.com", to: "ada@example.com", subject: "Invoice", text: "Your invoice is ready.",})A full from address must use the configured domain. Layeron rejects
cross-domain senders before it creates the Job run.
Send either text, html, or both.
With a template
Section titled “With a template”Reference a registered template name and pass the payload.
const result = await outbound.send({ to: "ada@example.com", template: "welcome", payload: { user: { name: "Ada" }, },})subject, text, and html in send(input) override the matching field from
the template. data is an alias for payload.
See Templates for how to create and register templates.
Idempotency
Section titled “Idempotency”Use idempotencyKey to prevent duplicate sends from retries.
const result = await outbound.send({ to: "ada@example.com", template: "welcome", payload: { user: { name: "Ada" } }, idempotencyKey: "welcome-ada-2025-01-01",})The key is passed through product RPC to the delivery Worker.
Metadata and tags
Section titled “Metadata and tags”Attach application metadata and tags for observability.
const result = await outbound.send({ to: "ada@example.com", template: "welcome", payload: { user: { name: "Ada" } }, metadata: { flow: "signup", source: "web", }, tags: { campaign: "onboarding", tier: "premium", },})Local dev stores metadata and tags in the outbox JSON file. Values are
passed through product RPC in production.
Headers
Section titled “Headers”Set additional email headers.
const result = await outbound.send({ to: "ada@example.com", subject: "Important", text: "Read receipt requested.", headers: { "X-Custom-Header": "value", "List-Id": "<updates.example.com>", },})Reserved headers (from, to, cc, bcc, subject, reply-to, date,
message-id, mime-version, content-type,
content-transfer-encoding) cannot be overridden.
Delivery
Section titled “Delivery”Layeron renders the template before it creates the Job run. The Job run owns
delivery, retry, and final failure state. A successful outbound.send(input)
call means the send request is durably accepted for delivery.
The result includes the message ID and the Job run ID.
{ status: "queued", messageId: "<layeron-...@layeron.local>", jobRunId: "jobrun_..."}status values:
queued— the send is durably recorded and ready for delivery.sending— the delivery is in progress.sent— the message was sent through Cloudflare Email Sending.retry_scheduled— the delivery failed and will retry.failed— all delivery attempts failed.unknown— the status could not be determined.
Rate limits
Section titled “Rate limits”When Cloudflare Email Sending returns HTTP 429, Layeron treats the send as a
retryable Job attempt. If Cloudflare includes Retry-After, the next attempt
uses that delay.
Deploy
Section titled “Deploy”Run deploy after the domain is in the connected Cloudflare account.
layeron deployLayeron creates the Cloudflare Email Sending subdomain for each
email.send(options) instance during deploy, creates the internal Job resources
that deliver outbound messages, and binds Cloudflare send_email to the Job
product Worker.
The deploy fails when Cloudflare cannot find the zone for domain in the
connected account.
Local dev
Section titled “Local dev”Local dev simulates outbound email. outbound.send(input) creates a Job run,
and the Job delivery task writes the message to
.layeron/local/<environment>/email/outbox/ as .json and .eml files. Local
dev does not send email through Cloudflare.