@freecorps/ember-email

中文文档
0.7.3发布于 2026/11/12MITTypeScriptES Module构建来源已验证近 30 天下载 4298

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

原始使用文档
文档目录4 节
  1. What it does
  2. Domains: @freecorps/ember-email/cloudflare
  3. Offline replicas: @freecorps/ember-email/schema
  4. Example

@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, optionally sendAt. 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 (nested rateLimit component, same transaction). Delivery goes through the send_email binding (the deploy adds it) or the REST API, with retries. Headers the provider refuses are dropped, keeping threading and X- 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 pass html.

  • Layouts: transactional mail (stored templates with a layout, through lib:send or an HTTP API on it) gets emailLayout. Mail a person writes from the inbox (reply, compose, mailbox auto-replies) gets conversationLayout instead: 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 by eventId, the status only moves forward, and lib:get is 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 .eml and the attachments in files (R2), and parses the message with postal-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: true in 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 onInbound hook runs in the same transaction (tickets, push).
  • Inbox queries for dashboards and mobile apps: threads, thread, unread (aggregate), search (searchIndex; each hit carries its thread's status, unreadCount, assignee/assigneeName, snoozedUntil and tags), 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) and sent (the mailbox's Sent view: threads it wrote in, by its latest send, paged with before). Snooze, scheduled sends and presence are described below. reply and compose build the email like a mail client:

    • text (required) is the text/plain part, and the HTML part when nothing richer is given (paragraphs, with http(s):// and mailto: URLs linked).
    • bodyHtml?: string is the composer's rich body, an HTML fragment. It is sanitized to p br div span b strong i em u s a ul ol li blockquote code pre, no attributes but a[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 as html. html?: string (a whole document, used as is) still works; not both.
    • signature?: { text: string; html?: string }: after the body, -- + text in the text part, Gmail's gmail_signature block in the HTML part (html sanitized the same way, or text rendered).
    • reply quotes the last inbound message (its first 40 lines) under Gmail's gmail_quote structure, which Gmail and Apple Mail collapse, and > lines in the text part. quoteHeader?: string is the attribution line, already localized (Em 5 de nov. de 2026, Marina Costa escreveu:); the default is On {date}, {name} <{email}> wrote:. reply and compose take bcc?: string[] (blind copies, validated and counted like cc). 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. reply and compose take attachments: [{ filename, contentType, file }], files already stored in the shard (uploaded with ctx.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 with link: 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. assignee is an app user id, or null for unassigned. unread: true lists threads with unread messages, false the others. tag is a tag id. Each filter has an index (with or without status). 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 changes unreadCount writes it: inbound, markRead, markUnread. Offline replicas can filter on it.
    • counts({ mailbox, assignee? }) returns { all, byStatus, assignee?, tags }. Each block is { threads, unread, unreadThreads } (unread counts 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. color is one of TAG_COLORS (gray by default), at most 100 tags per mailbox. Also updateTag({ tagId, name?, color?, order? }) and deleteTag({ tagId }).
    • Threads hold tag ids in tags (at most 20). Change them with setTags({ threadId, tags, author? }), addTag({ threadId, tag, author? }) and removeTag(...); each returns the tags as written.
    • deleteTag takes the tag off every thread: 200 threads in its own transaction, the rest in lib:untag jobs.
    • A threadTags row per (thread, tag) backs the tag filter 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 the author arg as the actor (setStatus, assign, setTags, addTag, removeTag, snooze, cancelScheduled, reply and compose take 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 it waiting);
    • assigned { from, fromName?, to, toName? };
    • tag_added / tag_removed { tag, name, color };
    • delivery { emailId, status, previous, messageId?, recipient?, … }, for the thread's outbound emails reaching sent, delivery_delayed, delivered, bounced, complained, failed, suppressed or canceled.

    Messages and notes are not repeated in the timeline: clients merge them by time.

  • Snooze:

    • snooze({ threadId, until, author? }) snoozes a thread. until is a future time in ms, at most 366 days ahead; null wakes the thread now.
    • A snoozed thread has snoozed: true and snoozedUntil. It leaves the default threads lists and counts (all / byStatus), and counts gives it its own snoozed block. threads({ snoozed: true }) lists the snoozed threads, soonest to wake first. With a tag, that list is newest first.
    • At until, a scheduled lib:wake job brings the thread back as new, like Gmail. It goes to the top of the lists (lastMessageAt becomes the wake time, so lastMessageAt is "the latest message or wake-up"), becomes unread (at least one), and is set to open unless it is spam.
    • An inbound message wakes the thread at once. Replies and notes leave the snooze in place. lib:maintain also wakes any snooze whose job never ran.
    • Timeline entries: snoozed { until, previous? } and unsnoozed { reason: "time" | "inbound" | "manual", until }.
  • Scheduled sends:

    • reply and compose take sendAt. The email is scheduled, and its thread message is stored at once with scheduledFor (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 marked unsent: true and Sent leaves it out.
    • scheduled({ threadId }) lists the thread's emails still waiting, soonest first.
    • cancelScheduled({ emailId, author? }) cancels an email that is still scheduled or queued and 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 } and scheduled_canceled.
  • Presence ("Bia is replying"):

    • setPresence({ threadId, userId, name, state: "viewing" | "replying" | null, ttlMs? }) is a heartbeat; call it about every 10 s. null leaves.
    • Each (thread, user) gets a presence row. A scheduled job deletes the row ttlMs after the last heartbeat (default 30 s, 5..120 s). Queries never read the clock, so presence({ 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. The lib:maintain cron (every 10 minutes, bounded batches) fills it in once the new indexes have backfilled. Until then those threads are missing from unread: true and from unreadThreads; the other counts and lists are right.

  • Sender rules (block, spam, allow), per mailbox or for every mailbox:

    • setSenderRule({ mailbox?, pattern, action, note?, author? }). pattern is an address ([email protected]) or a domain (acme.com, also @acme.com / *.acme.com), which covers its subdomains; [email protected] also matches [email protected]. mailbox absent or null: 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 .eml or 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's allow decides when the From passed DMARC (an allow must not be spoofable); otherwise the strongest block wins (reject over spam).
    • senderRules({ mailbox?, action?, paginationOpts? }) lists them newest first (mailbox: null: the rules for every mailbox, which come with mailbox: 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.spam is { reason: "rule", rule, pattern, action } or { reason: "rate" }. removeMailbox removes 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 .eml and attachments. Without retention (or with retention: 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 it

What 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: true creates 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 without catchAll, turns it back off.
  • Events: it adds an email.sending event 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.

版本与发布记录

@freecorps/ember-email 共发布 20 个版本,首次发布于 2026/11/06;过去 12 个月发布了 20 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.7.3latest2026/11/123 天前
0.7.22026/11/114 天前
0.7.12026/11/114 天前
0.7.02026/11/114 天前
0.6.02026/11/114 天前
0.5.102026/11/114 天前

关于 @freecorps/ember-email 的常见问题

@freecorps/ember-email 是什么?

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

如何安装 @freecorps/ember-email?

在项目目录运行 npm install @freecorps/ember-email,或使用 pnpm add / yarn add / bun add。

@freecorps/ember-email 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@freecorps/ember-email 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@freecorps/ember-email 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@freecorps/ember-email 可以商用吗?

@freecorps/ember-email 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@freecorps/ember-email 的下载量有多少?

根据 npm 官方下载统计,@freecorps/ember-email 在 2026/10/16 – 2026/11/14 这 30 天内被下载 4,298 次,日均约 143 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@freecorps/ember-email 还在维护吗?

最新版本 0.7.3 发布于 2026/11/12(3 天前);至今共发布 20 个版本,其中过去 12 个月发布了 20 个;首次发布于 2026/11/06。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。