@freecorps/ember-email
v0.7.3
Published
Email component for Ember on Cloudflare: send (atomic from mutations, scheduled, idempotent) through Email Service, templates, suppressions and per-project rate limits, delivery events from the Queue, inbound mail (Email Routing) parsed into mailboxes and
Readme
@freecorps/ember-email
Email as an Ember component, on Cloudflare Email Service (sending) and Email Routing (receiving). It runs in your own Cloudflare account.
// ember/ember.config.ts
import { defineApp, createFunctionHandle } from "@freecorps/ember-server";
import { email } from "@freecorps/ember-email";
export default defineApp().use(email, {
config: {
domains: ["acme.com"], // inbound domains routed to this Worker
rateLimit: { rate: 100, period: 60_000 }, // per project
hooks: { onInbound: createFunctionHandle("support:onInbound") },
},
});// ember/users.ts
import { Email } from "@freecorps/ember-email";
import { components, mutation } from "./_generated/server";
const email = new Email(components.email);
export const signup = mutation({
args: { address: v.string(), name: v.string() },
handler: (ctx, a) => {
const id = ctx.db.insert("users", a);
// Commits (or rolls back) with the insert; delivered by an action right after.
email.send(ctx, { from: "Acme <[email protected]>", to: a.address, template: { name: "welcome", data: { name: a.name } }, idempotencyKey: `welcome:${id}` });
return id;
},
});What it does
Send.
lib:send/email.send(ctx, ...)is a mutation: atomic with the caller, optionallysendAt. It also handles idempotency keys per project, suppressions per project (hard bounces and complaints are added automatically), and a per-project token-bucket rate limit (nestedrateLimitcomponent, same transaction). Delivery goes through thesend_emailbinding (the deploy adds it) or the REST API, with retries. Headers the provider refuses are dropped, keeping threading andX-headers.Templates are stored in the shard and use a small Mustache-like engine (
{{var}},{{{raw}}},#if/#each) with a responsive layout (emailLayout: a card on a grey page). React Email works too: render it in an action and passhtml.Layouts: transactional mail (stored templates with a layout, through
lib:sendor an HTTP API on it) getsemailLayout. Mail a person writes from the inbox (reply,compose, mailbox auto-replies) getsconversationLayoutinstead: a plain document like a mail client's, with no background, card, border or forced text color (so dark-mode clients recolor it), the system font at 14px.Delivery events. The component owns a Queue (
ember-<project>-<env>-email-events). Point an Email Service event subscription for your sending domain at it. Events are deduplicated byeventId, the status only moves forward, andlib:getis reactive.Inbound. The component hooks the Worker's
email()handler:- It routes mailbox addresses (
support@,privacy@) and plus-addressed thread tokens. Unknown addresses are rejected. - It stores the raw
.emland the attachments in files (R2), and parses the message withpostal-mime. - It reads DKIM, DMARC and SPF from Cloudflare's
Authentication-Results. - It threads messages like a mail client, with no token in the Reply-To by default:
- an answer that cites an email the mailbox sent joins its thread when the sender is the thread's contact or one of that email's recipients (In-Reply-To / References against the Message-ID the provider gave it, recorded when the send succeeds; DMARC not required, since only those recipients have that id);
- a citation of an inbound message counts with DMARC pass from the contact;
- then the same contact and subject, within 30 days, if the thread is not closed or spam;
- a plus-addressed token (
support+t_…@) still threads from any sender.
replyToken: truein the install config puts that token in the Reply-To of replies, composed emails and auto-replies, as before 0.5.4.- Replies and auto-replies cite the Message-ID and References exactly as the sender wrote them (Gmail only groups a reply in the conversation on an exact match); lookups ignore case.
- It handles spam per sender per hour and auto-replies without loops.
- It applies the sender rules (below) before it stores anything.
- Your
onInboundhook runs in the same transaction (tickets, push).
- It routes mailbox addresses (
Inbox queries for dashboards and mobile apps:
threads,thread,unread(aggregate),search(searchIndex; each hit carries its thread'sstatus,unreadCount,assignee/assigneeName,snoozedUntilandtags),reply,note,markRead,markUnread,setStatus,deleteThread(the conversation and its messages, now),assign,compose(a new conversation from the mailbox, answers join it as above),recent(latest threads with their messages, for offline replicas) andsent(the mailbox's Sent view: threads it wrote in, by its latest send, paged withbefore). Snooze, scheduled sends and presence are described below.replyandcomposebuild the email like a mail client:text(required) is the text/plain part, and the HTML part when nothing richer is given (paragraphs, withhttp(s)://andmailto:URLs linked).bodyHtml?: stringis the composer's rich body, an HTML fragment. It is sanitized top br div span b strong i em u s a ul ol li blockquote code pre, no attributes buta[href](http:,https:,mailto:); script, style, iframe and the like go with their content, other tags leave their text (sanitizeEmailHtml). It is kept on the thread's message ashtml.html?: string(a whole document, used as is) still works; not both.signature?: { text: string; html?: string }: after the body,--+textin the text part, Gmail'sgmail_signatureblock in the HTML part (htmlsanitized the same way, ortextrendered).replyquotes the last inbound message (its first 40 lines) under Gmail'sgmail_quotestructure, which Gmail and Apple Mail collapse, and>lines in the text part.quoteHeader?: stringis the attribution line, already localized (Em 5 de nov. de 2026, Marina Costa escreveu:); the default isOn {date}, {name} <{email}> wrote:.replyandcomposetakebcc?: string[](blind copies, validated and counted likecc). Those addresses go to the provider as Bcc, in the envelope only, never in a header or body the recipients see. They are stored on the thread's outbound message (bcc) for the mailbox's own view, and an answer from one of them joins the thread.replyandcomposetakeattachments: [{ filename, contentType, file }], files already stored in the shard (uploaded withctx.storage.generateUploadUrl). They go with the email and stay on the thread's outbound message (with their size). Mind the provider's message size: for a large file, put a download link in the text (ctx.storage.getUrl(file, { download, expiresIn })) and pass it withlink: true. The email then has no MIME part for it, and the message still holds the file (the thread lists it, and it is not collected).
Lists and counts (exact, index-backed):
threads({ mailbox, status?, assignee?, unread?, tag?, paginationOpts? }), newest message first.assigneeis an app user id, ornullfor unassigned.unread: truelists threads with unread messages,falsethe others.tagis a tag id. Each filter has an index (with or withoutstatus). When you combine filters, the most selective one picks the index and the others filter its range, so the page is still exact.- Each thread stores
hasUnread(unreadCount > 0). Every path that changesunreadCountwrites it: inbound,markRead,markUnread. Offline replicas can filter on it. counts({ mailbox, assignee? })returns{ all, byStatus, assignee?, tags }. Each block is{ threads, unread, unreadThreads }(unreadcounts messages). They come from aggregates that the writes keep up to date, so a call reads a few group rows and never scans threads.
Tags (labels), per mailbox:
tags({ mailbox })lists them in order.createTag({ mailbox, name, color?, order? }): names are unique per mailbox, ignoring case, 1..40 characters.coloris one ofTAG_COLORS(grayby default), at most 100 tags per mailbox. AlsoupdateTag({ tagId, name?, color?, order? })anddeleteTag({ tagId }).- Threads hold tag ids in
tags(at most 20). Change them withsetTags({ threadId, tags, author? }),addTag({ threadId, tag, author? })andremoveTag(...); each returns the tags as written. deleteTagtakes the tag off every thread: 200 threads in its own transaction, the rest inlib:untagjobs.- A
threadTagsrow per (thread, tag) backs thetagfilter and the per-tag counts. The component keeps those rows in step with the thread.
Activity timeline:
activity({ threadId, limit? }), oldest first. The component writes it, with theauthorarg as the actor (setStatus,assign,setTags,addTag,removeTag,snooze,cancelScheduled,replyandcomposetake one; absent means the system). Kinds (plus the snooze and scheduled-send ones below):status{ from, to, messageId? }(messageId: the outbound message whose sending moved it), including the automatic changes (an answer reopens a thread, a reply makes itwaiting);assigned{ from, fromName?, to, toName? };tag_added/tag_removed{ tag, name, color };delivery{ emailId, status, previous, messageId?, recipient?, … }, for the thread's outbound emails reachingsent,delivery_delayed,delivered,bounced,complained,failed,suppressedorcanceled.
Messages and notes are not repeated in the timeline: clients merge them by time.
Snooze:
snooze({ threadId, until, author? })snoozes a thread.untilis a future time in ms, at most 366 days ahead;nullwakes the thread now.- A snoozed thread has
snoozed: trueandsnoozedUntil. It leaves the defaultthreadslists andcounts(all/byStatus), andcountsgives it its ownsnoozedblock.threads({ snoozed: true })lists the snoozed threads, soonest to wake first. With atag, that list is newest first. - At
until, a scheduledlib:wakejob brings the thread back as new, like Gmail. It goes to the top of the lists (lastMessageAtbecomes the wake time, solastMessageAtis "the latest message or wake-up"), becomes unread (at least one), and is set toopenunless it is spam. - An inbound message wakes the thread at once. Replies and notes leave the snooze in place.
lib:maintainalso wakes any snooze whose job never ran. - Timeline entries:
snoozed{ until, previous? }andunsnoozed{ reason: "time" | "inbound" | "manual", until }.
Scheduled sends:
replyandcomposetakesendAt. The email isscheduled, and its thread message is stored at once withscheduledFor(the send time), which clears once the email is on its way. If it never goes (lib:cancel, or every recipient suppressed by then), the message stays markedunsent: trueand Sent leaves it out.scheduled({ threadId })lists the thread's emails still waiting, soonest first.cancelScheduled({ emailId, author? })cancels an email that is stillscheduledorqueuedand removes its message from the thread. The thread's count, last time and snippet then come from the messages that are left. A composed conversation left with no message is deleted (threadDeleted: true).- Timeline entries:
scheduled{ emailId, messageId, sendAt }andscheduled_canceled.
Presence ("Bia is replying"):
setPresence({ threadId, userId, name, state: "viewing" | "replying" | null, ttlMs? })is a heartbeat; call it about every 10 s.nullleaves.- Each (thread, user) gets a
presencerow. A scheduled job deletes the rowttlMsafter the last heartbeat (default 30 s, 5..120 s). Queries never read the clock, sopresence({ threadId })subscribers see the row go the moment it expires. - A heartbeat that changes nothing writes nothing during the first half of the TTL. That keeps the cost at one small write per user every ~15 s.
Upgrading from 0.6: older threads have no
hasUnread. Thelib:maintaincron (every 10 minutes, bounded batches) fills it in once the new indexes have backfilled. Until then those threads are missing fromunread: trueand fromunreadThreads; the other counts and lists are right.Sender rules (block, spam, allow), per mailbox or for every mailbox:
setSenderRule({ mailbox?, pattern, action, note?, author? }).patternis an address ([email protected]) or a domain (acme.com, also@acme.com/*.acme.com), which covers its subdomains;[email protected]also matches[email protected].mailboxabsent ornull: every mailbox. One rule per (pattern, mailbox): setting it again changes its action. At most 5000 per mailbox.action: "reject": the email() hook refuses the message (setReject("Message rejected")) before it stores the.emlor attachments."spam": the message is filed in a spam conversation (it never joins one that is not spam, and gets no auto-reply)."allow": never spam by the per-hour limit, and it beats a broader rule (block a domain, allow one address of it).- Matching: the From, the Reply-To and the envelope sender are each checked against their most specific rule
(address, then the address without its
+tag, then each domain from the longest; the mailbox's own rule before the one for every mailbox). The From'sallowdecides when the From passed DMARC (an allow must not be spoofable); otherwise the strongest block wins (rejectoverspam). senderRules({ mailbox?, action?, paginationOpts? })lists them newest first (mailbox: null: the rules for every mailbox, which come withmailbox: null),removeSenderRule({ ruleId } | { pattern, mailbox? }),matchSender({ mailbox, email, verified? })→{ effective, rules }(what mail from that address would get, for a "Blocked" badge or an "Unblock" button). Each rule counts the messages it decided (hits,lastHitAt).- An inbound message filed as spam by a filter says why:
messages.spamis{ reason: "rule", rule, pattern, action }or{ reason: "rate" }.removeMailboxremoves its rules.
Retention:
upsertMailbox({ retention: { closedDays: 730, spamDays: 30 } })keeps closed and spam conversations that many days, counted from the latest status change or message; a daily job (lib:purge, 04:00 UTC) then deletes the conversation and its messages, and file GC deletes their.emland attachments. Withoutretention(or withretention: null) nothing is deleted.
Domains: @freecorps/ember-email/cloudflare
Puts a domain on Cloudflare for the component and removes it again. It only needs fetch, so it runs in a Worker
(with its own API token secret) or on an operator's machine (no token in the Worker). It has no storage, HTTP API
or keys: you keep the returned state.
import { destroyDomain, emailResources, resolveAccountId, syncDomain } from "@freecorps/ember-email/cloudflare";
const c = { apiToken, accountId: await resolveAccountId(apiToken, { env: process.env }) };
const { worker, eventsQueue } = emailResources("acme", "prod"); // ember-acme-prod, ember-acme-prod-email-events
let d = await syncDomain(c, { name: "mail.acme.com", enableRouting: true }, { prev: null, mailboxes: ["[email protected]"], worker, queue: eventsQueue });
// d.status: "pending" until Email Service finishes onboarding (a minute or two): sync again with { prev: d }.
// d.records: the DNS records involved; d.notes: what is missing or needs the zone owner.
await destroyDomain(c, d); // rules, event subscription, and the sending domain if this created itWhat it does:
- Sending: it onboards the domain in Email Service. Cloudflare manages its records (bounce MX/SPF, DKIM, DMARC).
- Receiving: it checks Email Routing. For the zone apex, routing must already be on, because turning it on
rewrites the zone's MX. For a subdomain,
enableRouting: truecreates the routing subdomain. No API removes a routing subdomain, so removal is a dashboard step. - Mailboxes: it adds one literal rule per mailbox address on the domain → your Worker. It never changes a rule that routes somewhere else.
- Catch-all: with
catchAll: true(a mailbox of the domain is its catch-all,upsertMailbox({ catchAll: true })), the zone's catch-all rule → your Worker, so every address no mailbox has reaches that mailbox, with the address it was sent to in its To. Only for the zone apex, since the rule belongs to the zone. It never takes over a catch-all that routes somewhere else.destroyDomain, or a sync withoutcatchAll, turns it back off. - Events: it adds an
email.sendingevent subscription → the component's queue.
Offline replicas: @freecorps/ember-email/schema
emailSchema: the component's tables only (no postal-mime), for clients composing a local replica schema, and TAG_COLORS.
Example
examples/email-lite is the smallest app on the
component (mailboxes, send, inbound). freecorps/mail is a full email service
built on it (Resend-style HTTP API, inbox, mobile app).
All the component's state lives in one shard (config.shard, default global:main).
See docs/PLAN.md §16 and docs/design/contracts.md ("Email") in the repository.
License: MIT.
