opencloudportDocs

Email

Send sign-ups, receipts and notifications from your app's own address.

Add Email to an app and it can send email from its own address. You don't set up a mail server or a domain. The address is shown on the Email panel in the dashboard. When you want email to come from your own domain (orders@bakery.com), set one up.

Good for: welcome emails, password resets, receipts. Billed: per email sent.

Send a test

Open Email in the dashboard and choose Send a test email. It goes to you. Send an email on the Sent tab writes one to anyone, from the app's address.

See what was sent

The Sent tab lists every email the app sent, or tried to, in the last 30 days: who it went to, the subject, when, and whether it came from your code, the API or the dashboard. Open one to see it as its recipient did, or as plain text. Images from the internet stay hidden until you choose Show images. Emails that didn't send say why. The list checks for new emails every 15 seconds while it's open.

The dashboard keeps each email's content for those 30 days (up to 100 KB of it), then deletes it. Removing the Email service deletes its list straight away.

"Sent" means the email provider accepted it. When the provider reports back, the email shows what happened next: Delivered (the person's email server received it), Retrying (that server asked to try later), Bounced (the address doesn't exist or won't take mail), Rejected, Not delivered, or Marked as spam, with the receiving server's reason. Problems lists every email that didn't send or didn't arrive. Addresses that bounced or reported spam before are refused straight away, and the email says so.

From your app's code

Your code reaches the service as env.EMAIL, and sends with one call. Nothing to install, no key to handle: it can only send email for this app.

const { id } = await env.EMAIL.send({
  to: order.email,
  subject: 'Thanks for your order',
  html: '<p>Your sourdough will be ready at 9am.</p>',
  end_user: order.customerId, // optional: counts toward that customer's spending
})

It takes what the API's send takes: to, subject, text and/or html, and optionally from (on your own domain), from_name, reply_to and end_user. It returns the email's id, and throws with a plain reason if the email can't go: the app is paused, an end user's limit is reached, or too many are sent at once (the same limits as the API). Emails sent this way show as From your code in the Sent tab.

The connection address

The same service is also at env.EMAIL_URL: an address carrying a key that can only send for it, for code that calls the API itself (or runs somewhere without the binding):

const url = new URL(env.EMAIL_URL)
const res = await fetch(`${url.origin}${url.pathname}/send`, {
  method: 'POST',
  headers: { authorization: `Bearer ${decodeURIComponent(url.username)}`, 'content-type': 'application/json' },
  body: JSON.stringify({ to, subject, html }),
})

Changed on 22 October 2026

env.EMAIL used to be that address. Code written for it (new URL(env.EMAIL)) should switch to env.EMAIL_URL, or to env.EMAIL.send().

From anywhere else

await ocp.app(appId).email('EMAIL').send({ to: 'ann@example.com', subject: 'Welcome', text: 'Hi Ann!' })

What you can send

FieldWhat it’s for
toOne address.
subjectUp to 500 characters.
text, htmlAt least one.
from_nameThe name people see.
fromWith a verified domain: the address to send from, as the part before the @ (orders) or the whole address. Without one, email comes from the app's own address.
reply_toWhere replies go, like your support inbox.
end_userYour own id for the person this is for, to see usage per customer.

Email isn't sent while the app is paused for lack of credit (app_paused). If the provider refuses a message, nothing is billed.

Your own domain

Email can come from your own domain instead of the app's address, so receipts say orders@bakery.com.

  1. Open Email, then Settings, and add your domain under Send from your own domain. Optionally give a usual address (hello) to send from when an email doesn't say.
  2. Add the DNS records it shows where you manage the domain: SPF and an MX for bounces (on a cf-bounce subdomain), a DKIM key that signs each email, and DMARC (it starts at p=none, which only reports). Each has a copy button. If the domain already has an SPF record on the same name, merge them: a domain may only have one.
  3. It's checked again on its own until it's Verified. That can take from a few minutes to a day.

Then send with from:

await sendEmail(env, { to: order.email, from: 'orders', from_name: 'Holt’s Bakery', subject: 'Your receipt', html: '…' })

Until it's verified, email keeps coming from the app's own address, and asking for from is refused (sender_domain_not_verified). Each domain can be used by one email service. From the API: PUT /api/v1/apps/:id/services/:serviceId/sender-domain with { "domain": "bakery.com", "default_from": "hello" }, GET to see its records and status, DELETE to go back to the app's address. With the SDK: app.email('EMAIL').senderDomain.set('bakery.com').

On Cloudflare Email Service

Cloudflare sends from domains that are on our Cloudflare account. If yours isn't, setting it up says so; contact us and we'll add it.

On this page