Send mail from your worker, and let your worker receive mail sent to the project address. runlot runs the sender reputation and the receiving server.
{ "email": true }runlot deployEvery project gets one mail address on deploy. 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");
},
};There is no from. The sender is the project address and cannot be changed. The call returns { id } and throws on failure — e.status is the HTTP status (429 means the hourly limit).
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 has the same shape as Cloudflare Email Workers; ported code usually runs as is. 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
- Custom sending domains. Only
*.runlot.emailfor now. - Attachments on send.
textandhtmlonly. - Newsletters. Ten recipients is the line.
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