Send mail from your worker, and let your worker receive mail sent to the project address. runlot manages sender reputation and operates the receiving mail server.
Every project has one mail address. For project shop in org acme:
| Address | |
|---|---|
| Sending | [email protected] |
| Receiving | *@shop.acme.runlot.email — any local part works |
See the address in the dashboard's Email tab or with runlot email.
Sending
export default {
async fetch(request, env) {
await env.email.send({
to: "[email protected]", // string or array (up to 10)
subject: "Order received",
text: "Thanks. Shipping soon.",
html: "<p>Thanks. Shipping soon.</p>", // text or html is required
replyTo: "[email protected]", // optional
});
return new Response("ok");
},
};By default there is no from — the sender is the project address. To send from a domain you own, see the next section. The call returns { id } and throws on failure — e.status is the HTTP status (429 means the hourly limit).
Sending from a domain you own
Add the domain, publish three records, and from starts working:
runlot email domain add acme.comThat prints the records to publish. Three of them are required — without them nothing verifies:
| Type | Name | Value |
|---|---|---|
| CNAME | <token1>._domainkey.acme.com | <token1>.dkim.amazonses.com |
| CNAME | <token2>._domainkey.acme.com | <token2>.dkim.amazonses.com |
| CNAME | <token3>._domainkey.acme.com | <token3>.dkim.amazonses.com |
Two more are recommended and nothing waits on them:
| Type | Name | Value |
|---|---|---|
| TXT | acme.com | v=spf1 include:amazonses.com ~all |
| TXT | _dmarc.acme.com | v=DMARC1; p=none; adkim=r; aspf=r |
The SPF record is a soft fail (~all) on purpose: your domain probably already sends mail from somewhere else, and a hard fail we added would break it. Tightening it is yours to do.
runlot email domain verify acme.comWe also re-check on our own once a minute while a domain is pending, so you can add the records and walk away. DNS takes anywhere from a few minutes to a few hours; after 72 hours without them the domain is marked failed and you add it again.
If we host the zone for that domain (runlot dns), add --publish and we write the records for you. Records you wrote yourself are never overwritten.
Once it reads verified:
await env.email.send({
from: "[email protected]", // any local part on a verified domain
to: "[email protected]",
subject: "Order received",
text: "Thanks. Shipping soon.",
});Subdomains of a verified domain work too — [email protected] is covered by acme.com. A from on any other domain is refused with from_not_verified, and so is one on a domain that is still pending.
runlot email domain list # domains and their state
runlot email domain rm acme.com # remove (admin)Receiving still happens at *@<project>.<org>.runlot.email. Pointing your own domain's MX at us is not available yet.
Receiving
export default {
async fetch(request, env) { /* … */ },
async email(message, env, ctx) {
// message.from, message.to, message.headers (Headers), message.raw (ReadableStream), message.rawSize
const raw = await new Response(message.raw).text();
await env.db.query("insert into inbox (from_addr, to_addr, raw) values ($1, $2, $3)",
[message.from, message.to, raw]);
// To refuse — the sender sees this text
if (message.headers.get("authentication-results")?.includes("spf=fail")) {
message.setReject("SPF check failed");
}
},
};The email handler is called once per received message. message.raw is the full message including headers — use a parser such as postal-mime for attachments.
Mail to a project that does not export a handler bounces. If the handler throws, the sender retries later (treated as a temporary failure).
Limits
| Value | |
|---|---|
| Sends per hour | Free 30 · Pro 300 — shared with sign-in mail (env.auth) |
| Recipients per message | 10 |
| Received per hour | 600 |
| Received message size | 25 MiB |
| Handler runtime | 30 s |
Over the send limit you get 429; over the receive limit the sender is told to try again later.
Seeing what was sent and received
Every message, sent or received, gets one line — in the dashboard under the Email tab: time, direction, from, to, subject, outcome, size. Click a line for headers and body; the raw message downloads as .eml. HTML bodies are shown as source, not rendered.
| Plan | Kept for |
|---|---|
| Free | 30 days |
| Pro | 365 days |
runlot email log # latest 50
runlot email log --direction in # received only
runlot email log --cursor <cursor> # next page
runlot email raw <mailId> > mail.eml # raw messageFor received mail the outcome is what the handler did — handled, rejected, no handler, handler error. For sent mail it is whether the relay accepted it, and if not, why.
What is not here
- Receiving on your own domain. Sending from one works (above); MX for a domain you own does not.
- Attachments on send.
textandhtmlonly. - Newsletters. Ten recipients per message is the hard limit.
forward()/reply(). Both can be done withenv.email.send.
CLI
runlot email # address and sent/received counts for the last hour and 24 hours
runlot email --json
runlot email delete # turn off (admin). Mail to this address will bounce
runlot email domain add acme.com [--publish] # send from your own domain
runlot email domain list # domains and their state
runlot email domain verify acme.com # re-check now
runlot email domain rm acme.com # remove (admin)