@freecorps/ember-电子邮件
作为电子邮件 琥珀 cloudflare电子邮件服务(发送)和电子邮件 跑步 (接收). 它运行在你自己的Cloudflare账户.
// 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;
},
});
它会做什么
-
发送.
lib:send编号 :email.send(ctx, ...)是变种: 原子与调用者, 可选sendAt。它也会 处理每个项目的一能键,每个项目的压制(增加了硬弹跳和投诉) 自动)和每个项目的符号-桶利率限制(已取消)rateLimit组件,同一交易). 送货经过send_email绑定( 部署后添加) 或 REST API, 有重试 。 标题 拒绝提供者被丢弃,保持线程和X-头. -
模板 储存在硬体中,并使用一个小型的类似芥末的引擎(
{{var}}, (中文(简体) ),{{{raw}}}, (中文(简体) ),#if编号 :#each(中文(简体) ) 适应性布局(emailLayout:一张灰色页面上的卡. 反应电子邮件也起作用:将其变成动作和 通过html。 。 。 . -
布局 : 交易邮件(有版式的存储模板,通过
lib:send或一个HTTP API在上面)得到emailLayout。从收件箱中邮寄一个人(reply, (中文(简体) ),compose邮箱自动复制)conversationLayout相反:像邮件客户端那样的简单文档,没有背景、卡片、边框或强制 文本颜色(如此暗-mode客户端重新取色),系统字体为14px. -
送货活动. 组件拥有一个队列( Q)
ember-<project>-<env>-email-events。指向电子邮件服务 用于您发送域的事件订阅 。 事件由eventId,只移动状态 前进,以及lib:get是反应性的. -
进入. 部件勾住工人的
email()处理器 :- 它传递邮箱地址(
support@, (中文(简体) ),privacy@)和外接的线状符号. 未知地址为 拒绝. - 它储存生的
.eml和文件(R2)中的附件,并用postal-mime。 。 。 . - 它读DKIM,DMARC和SPF 从Cloudflare's
Authentication-Results。 。 。 . - 它像邮件客户端一样线入信件, 默认情况下在 Reform-To 中没有符号 :
- 当发送者是线程的联系人或一个联系人时, 信箱发送的电子邮件会加入其线程 该电子邮件的收件者(In-Reply-To / Reference for the Message-ID the supposed it, recorded when (a) 发送成功;
- (一) 使用联系人的DMARC出入证引用入境电文;
- 如果线程没有被关闭或垃圾邮件,则在30天内进行同样的接触和接触;
- 附加地址的代号
support+t_…@仍然来自任何发送者的线程.
replyToken: true在安装配置中,将这个符号放入答复的回复中,包括电子邮件和 自动复制机,如前0.5.4.- 答复和自动复制人按发件人写的那样引用信件ID和参考资料(只发送邮件组 a) 在谈话中回答一个准确的匹配; 查看忽略了案件.
- 它处理每小时每个发送者的垃圾邮件和没有回路的自动复制.
- 它在存储任何东西之前应用发件人规则(以下).
-
onInbound钩子在同一个交易中运行(ticket, push).
- 它传递邮箱地址(
-
收件箱查询 用于仪表板和移动应用程序:
threads, (中文(简体) ),thread, (中文(简体) ),unread(总数),search(单位:千美元)searchIndex;每个命中都带有线条status, (中文(简体) ),unreadCount, (中文(简体) ),assignee编号 :assigneeName, (中文(简体) ),snoozedUntil财务报告和审定财务报表tags, (中文(简体) ),reply, (中文(简体) ),note, (中文(简体) ),markRead, (中文(简体) ),markUnread, (中文(简体) ),setStatus, (中文(简体) ),deleteThread(谈话及其信息,现在),assign, (中文(简体) ),compose(一个新的谈话) 从邮箱,答案 加入到上面,recent(最新的线条与他们的留言, 对于离线复制品)和sent(邮箱的Sent视图:它用最近发送的线条写入,用before) (中文(简体) ). 斯努兹,预定 以下介绍发送和出席情况.reply财务报告和审定财务报表compose将电子邮件构建为邮件客户端 :text(要求)是文本/平面部分,在未给出更丰富的内容时是HTML部分(段落,与http(s)://财务报告和审定财务报表mailto:URL链接).bodyHtml?: string是作曲家丰富的身体, HTML 碎片。 它被消毒为p br div span b strong i em u s a ul ol li blockquote code pre,没有属性,但a[href](单位:千美元)http:, (中文(简体) ),https:, (中文(简体) ),mailto:);脚本,风格, iframe 等随其内容而去,其他标签会留下文本(sanitizeEmailHtml) (中文(简体) ). 它被保留在 线程信息为html。 。 。 .html?: string(一整份文件,按原样使用)仍然有效;而不是两者兼而有之.signature?: { text: string; html?: string }: 在尸体之后,--+ 键text在文本部分,Gmail的gmail_signaturehTML 部分中的块(html以同样的方式消毒,或text(已执行).reply在 Gmail 下引用最后一条入境信件( 它的前40行)gmail_quotegmail 结构 和苹果邮件崩溃,以及>文本部分中的行.quoteHeader?: string已经是归属线 本地化( e)Em 5 de nov. de 2026, Marina Costa escreveu:; 默认为On {date}, {name} <{email}> wrote:。 。 。 .reply财务报告和审定财务报表compose带bcc?: string[](失明副本,验证并计算如:cc) (中文(简体) ). 这些地址是 提供者作为Bcc,仅在信封中,从不出现在信头或收件人看到的体内. 它们被储存在 线程输出消息( r)bcc)为邮箱自己取景,其中一人的回答会加入线程.reply财务报告和审定财务报表compose带attachments: [{ filename, contentType, file }],已存储在硬盘中的文件 (装入ctx.storage.generateUploadUrl) (中文(简体) ). 他们带着电子邮件 留在线条外的信息 (有其大小. 注意提供者的消息大小: 对于一个大文件, 在文本中设置下载链接 (单位:千美元)ctx.storage.getUrl(file, { download, expiresIn })并传递给link: true。电子邮件中没有 MIME 部分 对于它,信件仍然持有文件(线程列出它,没有收集它).
-
清单和计数 (准确,指数支持):
threads({ mailbox, status?, assignee?, unread?, tag?, paginationOpts? })先说最新消息.assignee是应用程序用户代号,或null给未分配的.unread: true列出未读信件的线索,false其他人,他们...tag是一个标记 ID。 每个过滤器都有索引( 有无索引)status) (中文(简体) ). 当你结合 过滤器,最有选择的选取索引而其他选取过滤其范围,所以页面仍然准确.- 每个线程存储
hasUnread(单位:千美元)unreadCount > 0) (中文(简体) ). 每一个改变的路径unreadCount写道:,markRead, (中文(简体) ),markUnread。离线复制品可以在上面过滤. counts({ mailbox, assignee? })返回时{ all, byStatus, assignee?, tags }。每个块是{ threads, unread, unreadThreads }(单位:千美元)unread计数消息). 他们来自汇总 写最新,所以一个电话 读取数行的分组,并永远不扫描线程.
-
标记( 标签),每个邮箱:
tags({ mailbox })列出顺序.createTag({ mailbox, name, color?, order? }): 名称是每个 邮箱 忽略案件 1.40个字符.color这是其中之一TAG_COLORS(单位:千美元)gray),最多100个标记 每个邮箱。 还有updateTag({ tagId, name?, color?, order? })财务报告和审定财务报表deleteTag({ tagId })。 。 。 .- 线索中包含标签标识符
tags(最多20个)。 换个位置setTags({ threadId, tags, author? }), (中文(简体) ),addTag({ threadId, tag, author? })财务报告和审定财务报表removeTag(...); 每一个返回已写入的标签. deleteTag从每个线索中取出标记: 在自己的交易中取出200个线索, 其余在lib:untag任务.- A 类
threadTags每行( 线索、 标记) 背起tag过滤器和每盘计数。 部件保存这些 与线相接而行.
-
活动时间表 :
activity({ threadId, limit? }),最古老的第一。 该组件写它,与author担任演员( r)setStatus, (中文(简体) ),assign, (中文(简体) ),setTags, (中文(简体) ),addTag, (中文(简体) ),removeTag, (中文(简体) ),snooze, (中文(简体) ),cancelScheduled, (中文(简体) ),reply财务报告和审定财务报表compose使用一个;缺席意味着系统)。 Kinds(加上下面的snooze和预定发送的) :status{ from, to, messageId? }(单位:千美元)messageId:发送的外出消息移动了它),包括自动更改(一个回答重开一个线程,一个回答使它变成waiting(三);assigned{ from, fromName?, to, toName? }· ;tag_added编号 :tag_removed{ tag, name, color }· ;delivery{ emailId, status, previous, messageId?, recipient?, … },因为线程的外部电子邮件到达sent, (中文(简体) ),delivery_delayed, (中文(简体) ),delivered, (中文(简体) ),bounced, (中文(简体) ),complained, (中文(简体) ),failed, (中文(简体) ),suppressed或者说canceled。 。 。 .
消息和笔记在时间表中不重复:客户端按时间合并.
-
斯诺兹:
snooze({ threadId, until, author? })接通了一条线.until未来时间(ms),最多366天;null唤起线了.- 一条被打耳光的线条
snoozed: true财务报告和审定财务报表snoozedUntil。它留下默认值threads清单和清单counts(单位:千美元)all编号 :byStatus),以及counts给它自己的snoozed块.threads({ snoozed: true })列出 接通了线 很快就醒了 与一个tag这个名单是最新的. - 时
until,计划lib:wake任务将线条带回新的,就像Gmail。 它排在榜首 (单位:千美元)lastMessageAt成为唤醒时间,所以lastMessageAt是"最新消息或唤醒",变成未读 (至少一个),设定为open除非是垃圾邮件. - 传入的信息立刻唤醒了线条. 答复和笔记都放好了.
lib:maintain(单位:千美元) 唤醒了那些从未工作过的人. - 时间线条目 :
snoozed{ until, previous? }财务报告和审定财务报表unsnoozed{ reason: "time" | "inbound" | "manual", until }。 。 。 .
-
计划发送 :
reply财务报告和审定财务报表compose带sendAt电子邮件是scheduled,其线程消息立即被存储scheduledFor电子邮件一到,就会被清除。 如果它从来没有去(lib:cancel,或 到那时每个被压制的收件人,信件会保持标记unsent: true并发送它离开.scheduled({ threadId })列出线索的邮件仍在等待, 最快的第一.cancelScheduled({ emailId, author? })取消一个仍在使用的电子邮件scheduled或者说queued并删除其 来自线条的消息。 线程的计数, 上次和片段, 然后来自留下的消息. 删除未留言的由编成的对话(threadDeleted: true) (中文(简体) ).- 时间线条目 :
scheduled{ emailId, messageId, sendAt }财务报告和审定财务报表scheduled_canceled。 。 。 .
-
出席情况 ("Bia正在回答"):
setPresence({ threadId, userId, name, state: "viewing" | "replying" | null, ttlMs? })是一个心跳,叫它 大约每10秒.null叶子.- 每个( 线索, 用户) 都得到
presence排队 计划任务删除行ttlMs最后的心跳过后 (违约30 s, 5.120 s.). 查询从不读钟,所以presence({ threadId })订阅者看到行走 当它过期的时候. - 在TTL的上半期, 心跳改变什么也没有写。 这样成本就维持在一线 每个用户每~15 s 有小写.
-
从0.6升级: 老线条没有
hasUnread编辑lib:maintain(每10分钟,限时) 批次),在新索引被回填后填入. 在那之前,这些线条都不见了unread: true从unreadThreads;其他的计数和列表是正确的. -
发送规则 每个邮箱或每个邮箱:
setSenderRule({ mailbox?, pattern, action, note?, author? })。 。 。 .pattern是一个地址( a)[email protected]或一个 域( 选项)acme.com,还有@acme.com编号 :*.acme.com),涵盖其子域;[email protected]也匹配[email protected]。 。 。 .mailbox缺席或null每个邮箱。 每(平面,邮箱)一个规则:设置 重新改变它的动作。 每个邮箱最多5000个.action: "reject": 电子邮件( ) 钩子拒绝信件(setReject("Message rejected")在它存储之前.eml或附件."spam":消息在垃圾邮件对话中存档(它从来没有加入一个不是垃圾邮件, 没有自动复制."allow":从不以每小时的限值来垃圾邮件,它比一个更广泛的规则(阻断一个域, 允许一个地址.- 匹配: From, Reform-To 和信封发送器都会按照它们最具体的规则检查
(地址,然后地址不包含
+tag,然后每个域从最长; 邮箱自己的规则之前 每一个邮箱。 "从"allow决定何时通过DMARC(不得允许) ;否则最强块获胜(reject结束spam) (中文(简体) ). senderRules({ mailbox?, action?, paginationOpts? })首先列出最新的mailbox: null: 规则 每个信箱, 随同mailbox: null, (中文(简体) ),removeSenderRule({ ruleId } | { pattern, mailbox? }), (中文(简体) ),matchSender({ mailbox, email, verified? })→{ effective, rules }(该地址的邮件会收到什么) a"被锁"徽章或"解锁"按钮. 每项规则都计算其决定的信息(hits, (中文(简体) ),lastHitAt) (中文(简体) ).- 由过滤器作为垃圾邮件存档的入境信件说:
messages.spam实值{ reason: "rule", rule, pattern, action }或者说{ reason: "rate" }。 。 。 .removeMailbox取消规则.
-
保留:
upsertMailbox({ retention: { closedDays: 730, spamDays: 30 } })保持闭合和垃圾邮件对话 从最新状况变化或消息中计算出来的多天; 日常工作(lib:purge, 04: 00 (英语) UTC)之后删除对话及其信件,并提交 GC 删除其信件.eml还有附件 无retention(或与retention: null无所删除.
域数 : @freecorps/ember-email/cloudflare
在 Cloudflare 为组件设置一个域并再次去除。 它只需要这样 fetch因此,它运行在一个工人
(带有自己的API令牌秘密)或者在操作员的机器上(在"工人"里没有令牌). 它没有存储, HTTP API
或密钥:您保持返回状态.
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
它会做什么:
- 发送中 : 它在电子邮件服务中的域名上。 Cloudflare管理其记录(bounce MX/SPF,DKIM,DMARC).
- 接收: 它检查电子邮件运行。 对于区上层, 线路必须已经开通了, 因为打开它
重写区MX. 对于一个子域,
enableRouting: true创建路由子域。 没有 API 删除一个 路由子域,所以去除是一个仪表板步骤. - 邮箱 : 它在域名上每个邮箱地址 都增加了一条字面规则 \ 您的工人 。 它永远不会改变规则 那条路通往别的地方.
- 抓起来: 与
catchAll: true(这个域名的邮箱就是它的包罗万象,upsertMailbox({ catchAll: true }), (中文(简体) ), 所以每个地址都找不到邮箱 也没有地址 被送到了它的To。 仅为区顶,因为规则属于区. 它从不接管一切 那条路通往别的地方.destroyDomain,或同步无catchAll关掉它. - 活动 : 它增加了一个
email.sending事件订阅 ~ 组件队列 .
离线复制: @freecorps/ember-email/schema
emailSchema: 仅用于组成本地复制图案的客户的组件表(没有邮政缩写),以及 TAG_COLORS。 。 。 .
示例
examples/email-lite 这是最小的应用程序
组件(邮箱、发送、输入). 自由公司/邮件 是一个完整的电子邮件服务
在它上建(Resend-style HTTP API,收件箱,移动应用).
所有组件的状态 生活在一个硬(config.shard默认值 global:main) (中文(简体) ).
见 docs/PLAN.md 第16条和 docs/design/contracts.md ("电子邮件")入库.
驾照:麻省理工.