Skip to content

Send email

Create a send-only Email instance with email.send(options).

Terminal window
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.

Call outbound.send(input) from route handlers, jobs, or workflows.

Terminal window
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.

Set from for a single message when the sender must change.

Terminal window
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.

Terminal window
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.

Reference a registered template name and pass the payload.

Terminal window
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.

Use idempotencyKey to prevent duplicate sends from retries.

Terminal window
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.

Attach application metadata and tags for observability.

Terminal window
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.

Set additional email headers.

Terminal window
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.

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.

Terminal window
{
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.

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.

Run deploy after the domain is in the connected Cloudflare account.

Terminal window
layeron deploy

Layeron 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 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.