码桶

发现社区成员的开源项目

西风 / 公开

版本历史

4 次提交

提交详情

40cfb59
提交编号40cfb59eca12933576bcc14d723b224d7a1bd7a8
提交作者西风<codebucket@localhost>
提交时间2026-08-04 05:59:25 +0800
更新说明更新 README.md

文件变更 1 个文件

文件新增删除
README.md+336-3

文件差异 逐行查看本次提交的修改

README.md+336-3
@@ -1,3 +1,336 @@
1-# MailEdge
2-
3-支持多发信渠道的 Serverless Webmail,跑在 Cloudflare Workers 上。收件走 Email Routing,发件支持 Cloudflare Email Service / Sendflare / Resend 自动主备切换,大附件自动转 R2 下载链接。
1+<h1 align="center">MailEdge</h1>
2+
3+<p align="center">支持多发信渠道的 Serverless Webmail,完整跑在 Cloudflare 上,不需要任何自己的服务器。</p>
4+
5+<p align="center">
6+ <a href="https://workers.cloudflare.com/"><img alt="Cloudflare Workers" src="https://img.shields.io/badge/Cloudflare-Workers-F38020?logo=cloudflare&logoColor=white"></a>
7+ <a href="https://www.typescriptlang.org/"><img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-7.0-3178C6?logo=typescript&logoColor=white"></a>
8+ <a href="https://react.dev/"><img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black"></a>
9+ <a href="https://hono.dev/"><img alt="Hono" src="https://img.shields.io/badge/Hono-4-E36002?logo=hono&logoColor=white"></a>
10+ <a href="https://developers.cloudflare.com/d1/"><img alt="D1" src="https://img.shields.io/badge/D1-SQLite-003B57?logo=sqlite&logoColor=white"></a>
11+</p>
12+
13+<p align="center">
14+ <a href="https://github.com/gentpan/MailEdge/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/gentpan/MailEdge?color=555555"></a>
15+ <a href="https://github.com/gentpan/MailEdge/issues"><img alt="Issues" src="https://img.shields.io/github/issues/gentpan/MailEdge?color=555555"></a>
16+ <a href="https://github.com/gentpan/MailEdge/commits/main"><img alt="Last commit" src="https://img.shields.io/github/last-commit/gentpan/MailEdge?color=555555"></a>
17+</p>
18+
19+<p align="center"><strong>简体中文</strong> · <a href="README.en.md">English</a></p>
20+
21+<p align="center"><img src="docs/images/02-inbox.png" alt="MailEdge 收件箱" width="880"></p>
22+
23+> 完整的产品介绍、界面截图与图文部署教程见 [docs/blog.md](docs/blog.md)。
24+
25+```
26+收件:Email Routing → Email Worker → Durable Object (SQLite) + R2
27+发件:统一 MailProvider 接口 → Cloudflare Email Service / Sendflare / Resend
28+配置:D1(账户、渠道配置、发信状态机)
29+```
30+
31+## 它解决什么
32+
33+Cloudflare Email Routing 只能收信、转发,不能回复,也没有界面。市面上的 Cloudflare 邮箱项目大多止步于「把收到的信显示出来」。MailEdge 补上了缺的那一半:
34+
35+- **发信不绑死单一服务商**。`MailProvider` 是一层抽象,Cloudflare Email Service、Sendflare、Resend 三家开箱即用,新增 SES / Mailgun / Postmark / SMTP 只需要加一个类。
36+- **主备切换不会重复发信**。只有网络故障、429、5xx 这类临时错误才切换渠道;域名未验证、地址非法、内容被拒等永久性错误立即失败。否则一封被拒的邮件会在三个平台各发一次。
37+- **绕开 5 MiB 附件上限**。小附件正常发,大附件自动上传 R2 并在正文插入下载链接,可统计下载次数、设置过期、随时撤销。用户感觉不到区别。
38+- **邮件分片存储**。每个地址一个 Durable Object,各自带一份 SQLite,不存在单库瓶颈。
39+
40+## 功能
41+
42+- 收件箱 / 已发送 / 归档 / 回收站,搜索、分页、星标、未读计数
43+- 多信箱聚合视图;未精确登记、靠兜底兜进来的信单独归入「其他地址」
44+- 写信支持 Markdown(发送时转成邮件安全 HTML)、抄送、密送、多附件;管理员可指定发信渠道
45+- 设置页在线配置三个渠道,支持测试发送、设为默认、备用优先级
46+- 渠道密钥 AES-GCM 加密后存 D1,接口只返回脱敏值
47+- 发信记录带完整重试链路,可手动重试;`deferred` 状态由 Cron 指数退避自动重试
48+- HTML 正文在 `sandbox=""` 的 iframe 中渲染,脚本、表单、同源访问全部禁用
49+- 界面中/英双语,跟随浏览器语言自动选择,可随时切换
50+- 新信实时推送:每个信箱 Durable Object 持有前端 WebSocket(Hibernation,空闲不计费),收信秒级到达;断线自动重连,另有 60 秒轮询兜底
51+
52+### AI 助手(可选)
53+
54+统一走 OpenAI 兼容接口,可接 OpenAI、DeepSeek、Kimi、智谱、硅基流动、Ollama 等(设置页有一键预设,也可自填 baseURL + 模型名)。Key 同样 AES-GCM 加密存 D1。
55+
56+- **AI 回复**:针对来信生成回复草稿,直接填入写信框
57+- **AI 总结**:长邮件一键摘要,结果缓存进 Durable Object
58+- **AI 分类**:收信时自动打标签(重要 / 更新 / 营销 / 社交 / 其他),收件箱按分类分栏
59+- **Telegram 推送**:新信到达推送到 Telegram Bot,可只推指定分类
60+
61+分类与推送挂在收信 Worker 上,都在 `waitUntil` 里异步执行,且各自 try/catch 隔离——AI 或推送出问题绝不影响邮件入库。
62+
63+## 技术栈
64+
65+全部构建在 Cloudflare 平台上,前后端同一次部署,无需自建服务器。
66+
67+| 层 | 选型 | 说明 |
68+| --- | --- | --- |
69+| 运行时 | Cloudflare Workers | 边缘执行,`fetch` / `email` / `scheduled` 三个入口 |
70+| 收件 | Email Routing + `postal-mime` | 转发到 Email Worker,解析 MIME |
71+| 发件 | 自研 `MailProvider` 抽象 | Cloudflare Email Service / Sendflare / Resend / SMTP |
72+| SMTP | `cloudflare:sockets` | `connect()` 裸 TCP,手写 SMTP 会话(587/465) |
73+| 账户 · 配置 · 发信状态机 | D1(SQLite) | 需要跨信箱查询的数据 |
74+| 邮件正文 | Durable Objects + 内置 SQLite | 一地址一实例,天然分片 |
75+| 附件 | R2 | 出口流量免费,按信箱/年月分区 |
76+| 定时任务 | Cron Triggers | 重试 deferred 邮件、清理过期分享 |
77+| 加密 | Web Crypto(AES-GCM / PBKDF2) | 渠道密钥加密、口令哈希、会话签名 |
78+| API 框架 | Hono | 轻量路由,贴合 Workers |
79+| 前端 | React 19 + Vite 7 | 通过 Workers Assets 托管 |
80+| 样式 | 纯手写 CSS + 设计令牌 | 无 Tailwind / CSS-in-JS,全部 CSS 变量驱动 |
81+| i18n | 自研轻量方案 | 中/英,无第三方库 |
82+| AI | OpenAI 兼容接口 | 回复 / 总结 / 分类,可接任意兼容服务商 |
83+| 语言 · 工具链 | TypeScript 7 · Wrangler | 原生编译器,端到端类型安全 |
84+
85+## 架构
86+
87+| 能力 | 实现 |
88+| --- | --- |
89+| 收件 | Cloudflare Email Routing → `email()` handler → `postal-mime` 解析 |
90+| 邮件存储 | 每个地址一个 Durable Object,实例内置 SQLite |
91+| 附件 | R2;原始报文 `.eml` 一并留档 |
92+| 账户 / 渠道配置 / 发信状态机 | D1 |
93+| 发信 | `MailProvider` 抽象,三个实现 + 主备切换 |
94+| 前端 | React + Vite,通过 Workers Assets 托管 |
95+
96+### 发信渠道
97+
98+| Provider | 定位 | 说明 |
99+| --- | --- | --- |
100+| Cloudflare Email Service | 默认原生渠道 | Workers Binding,无额外 HTTP 请求;单封 ≤ 5 MiB、≤ 32 个附件;发往任意外部邮箱需要 Workers Paid |
101+| Sendflare | 备用或主渠道 | REST API,Bearer Token,可选 HMAC-SHA256 签名 |
102+| Resend | 成熟备用渠道 | REST API,需要在其后台验证域名 |
103+| SMTP | 通用代发 | 用 Workers `connect()` 走 587 STARTTLS / 465 TLS,手写 SMTP 会话;可用 Gmail 等外部邮箱(应用专用密码) |
104+
105+新增 SES / Mailgun / Postmark 只需要在 [src/mail/providers/](src/mail/providers/) 加一个类,并在 [factory.ts](src/mail/factory.ts) 加一个分支。
106+
107+> **发件人与已验证域名**:用 Resend/Sendflare 发信时,发件域名必须先在其后台验证。在渠道配置里点「拉取域名」,MailEdge 会调用服务商接口同步你已验证的域名;写信时「发件人」下拉据此约束,发出前就拦住未验证的地址,而不是被拒后才知道。
108+>
109+> **SMTP 用 Gmail 代发**:主机 `smtp.gmail.com`、端口 587、加密 STARTTLS、用户名填完整邮箱、密码填「应用专用密码」(需先开两步验证,不能用登录密码)。设置页有 Gmail 一键预设。
110+>
111+> Workers **禁止 25 端口**出站,所以 SMTP 只能走 587/465——发信本来也不需要 25。IMAP 代收同理不适合 Worker,收信请用 Email Routing 转发。
112+
113+### 状态机与切换规则
114+
115+```
116+queued → sending → sent
117+ ├── deferred → 定时任务重试(5min 起指数退避,上限 6h,最多 5 次)
118+ └── failed
119+```
120+
121+每封邮件生成固定的内部 ID(`mail_01J...`),以 `X-App-Message-ID` 头带出。切换渠道时沿用同一个 ID,便于去重与追踪。
122+
123+**只有临时性错误才切换备用渠道**:网络故障、429、5xx、408。
124+**永久性错误直接失败**:域名未验证、地址非法、内容被拒、spam complaint、账户暂停、发件人无权限。
125+否则一封被拒的邮件会在三个平台各发一次。分类规则见 [src/mail/errors.ts](src/mail/errors.ts)。
126+
127+### 智能附件
128+
129+```
130+附件 ≤ 3 MB(且整封不超上限) → 真 Email Attachment
131+附件 > 3 MB → 上传 R2 → 正文插入下载链接
132+```
133+
134+下载走 `/d/:token`,由 Worker 校验 token、有效期与撤销状态后从 R2 出流,支持统计下载次数、7 天过期、随时撤销。内嵌图片(`cid:`)始终留在邮件里,避免正文裂图。阈值由 `SMART_ATTACHMENT_THRESHOLD` 控制。
135+
136+### R2 目录结构
137+
138+```
139+inbound/{信箱ID}/{年-月}/{邮件ID}/{序号}-{文件名}
140+inbound/{信箱ID}/{年-月}/{邮件ID}/raw.eml
141+outbound/{信箱ID}/{年-月}/{内部ID}/payload.json
142+outbound/{信箱ID}/{年-月}/{内部ID}/attachments/{序号}-{文件名}
143+shares/{信箱ID}/{年-月}/{token}/{文件名}
144+```
145+
146+按信箱与年月分区不只是为了整齐:
147+
148+- **生命周期规则按前缀配置**,可以直接交给 R2 自动清理旧对象,不必在应用层写清理逻辑
149+- **`list()` 按前缀扫描**,扁平结构下列举某个月的对象要扫全量
150+- 按信箱前缀可直接统计各信箱占用的存储
151+
152+完整键落在库里(DO 的 `r2_key`、`attachment_links.r2_key`、`outbound_messages.payload_key`),因此调整键结构只影响新对象,存量对象照常可读,不需要迁移。键构造集中在 [src/lib/r2key.ts](src/lib/r2key.ts)。
153+
154+文件名保留中文——R2 键支持 UTF-8,且键从不直接进 URL(下载走 token),只剔除控制字符和影响键层级的字符。
155+
156+配置 R2 自动清理 90 天前的收件归档:
157+
158+```bash
159+npx wrangler r2 bucket lifecycle add mailedge-attachments --prefix inbound/ --expire-days 90
160+```
161+
162+## 部署
163+
164+### 一键部署
165+
166+先做一次 OAuth 授权(凭据由 wrangler 自己保管,不需要你复制粘贴任何 API Key):
167+
168+```bash
169+npx wrangler login
170+```
171+
172+然后:
173+
174+```bash
175+npm run setup
176+```
177+
178+脚本会列出计划、等你确认,再依次完成:建 D1 → 把 `database_id` 回填进 wrangler.jsonc(保留注释)→ 建 R2 → 建表 → 部署 → 生成并写入两个机密 → 把 `APP_URL` 回填成实际地址。
179+
180+全程幂等,中途失败修好后重跑即可,已完成的步骤自动跳过。**已存在的 `ENCRYPTION_KEY` 绝不会被覆盖**——它是渠道密钥的主密钥,换掉等于作废所有已保存的发信配置。
181+
182+> 首次运行时 Worker 尚未部署,机密可能写不进去,脚本会提示你再跑一次 `npm run setup` 补上。
183+
184+跑完后还剩两步必须在面板操作,见下面的「配置收件」和「初始化」。
185+
186+### 手动部署
187+
188+不想用脚本的话,等价的手工步骤:
189+
190+```bash
191+npx wrangler d1 create mailedge
192+```
193+
194+```bash
195+npx wrangler r2 bucket create mailedge-attachments
196+```
197+
198+把 `d1 create` 输出的 `database_id` 填进 [wrangler.jsonc](wrangler.jsonc),同时把 `APP_URL` 改成你的正式域名(下载链接会用它拼绝对地址)。
199+
200+```bash
201+openssl rand -base64 32
202+```
203+
204+```bash
205+npx wrangler secret put ENCRYPTION_KEY
206+```
207+
208+```bash
209+npx wrangler secret put SESSION_SECRET
210+```
211+
212+```bash
213+npx wrangler d1 migrations apply mailedge --remote
214+```
215+
216+```bash
217+npm run deploy
218+```
219+
220+### 配置收件
221+
222+**必须先完成部署**,Worker 才会出现在 Email Routing 的下拉列表里。
223+
224+Cloudflare 面板 → **Compute** → **Email Service** → **Email Routing** → 选择域名(首次进入需先启用,它会自动写入 MX 与 SPF 记录)。
225+
226+然后 **Routing Rules** → **Create routing rule**:
227+
228+| 字段 | 填写 |
229+| --- | --- |
230+| Email pattern | 地址的本地部分,如 `support` |
231+| Action | **Send to a Worker** |
232+| Worker | `mailedge` |
233+
234+想接收整个域名的邮件,改用 **Catch-all address**,action 同样设为 Send to a Worker。
235+
236+> 投递给 Worker 只在新版 Email Routing 界面提供。若面板提示需要切换到新界面,按提示切换即可。
237+
238+### 初始化
239+
240+打开部署后的域名,首次访问会进入初始化页,创建管理员并绑定第一个收件地址。**这里填写的地址必须与上一步的路由规则一致**,否则 Worker 收到邮件时找不到对应信箱,会直接退信(`550 未知收件人`)。
241+
242+之后到「设置 → 发信服务」配置渠道,先「测试发送」确认可用,再「设为默认」。
243+
244+发往任意外部邮箱需要 Workers Paid(含每月 3,000 封,超出每 1,000 封 0.35 美元);收件在免费和付费计划都可用。
245+
246+## 本地开发
247+
248+```bash
249+npm install
250+```
251+
252+```bash
253+cp .dev.vars.example .dev.vars
254+```
255+
256+填入两个 `openssl rand -base64 32` 生成的值,然后:
257+
258+```bash
259+npx wrangler d1 migrations apply mailedge --local
260+```
261+
262+```bash
263+npm run dev
264+```
265+
266+`npm run dev` 会先构建前端再启动 `wrangler dev`(http://127.0.0.1:8787)。改前端时另开一个终端跑 `npm run dev:web` 做增量构建。
267+
268+本地模拟收信(wrangler 内置入口):
269+
270+```bash
271+curl -X POST 'http://127.0.0.1:8787/cdn-cgi/handler/[email protected]&[email protected]' --data-binary @test.eml -H 'Content-Type: message/rfc822'
272+```
273+
274+本地状态都在 `.wrangler/state/`,删掉即可重置。
275+
276+### 测试与代码检查
277+
278+```bash
279+npm run verify
280+```
281+
282+一条命令跑完 lint、三套 typecheck(Worker / 前端 / 测试)和全部测试。也可以分开跑:
283+
284+| 命令 | 作用 |
285+| --- | --- |
286+| `npm test` | Vitest,跑在真实 workerd 里(`@cloudflare/vitest-pool-workers`) |
287+| `npm run test:watch` | 监听模式 |
288+| `npm run typecheck` | Worker、前端、测试三套 tsconfig |
289+| `npm run lint` | Biome,检查格式 + lint + import 顺序 |
290+| `npm run lint:fix` | 自动修可修的部分 |
291+
292+测试不是在 Node 里模拟 Workers,而是真的跑在 workerd 中:`crypto.subtle`、D1、Durable Object、`cloudflare:sockets` 的行为与线上一致。`test/dispatcher.test.ts` 用真实 D1 建表(直接读 `migrations/`),验证发信状态机不会把一封被拒的邮件在多个渠道重发。
293+
294+## 接口
295+
296+| 方法 | 路径 | 说明 |
297+| --- | --- | --- |
298+| `GET` | `/api/health` | 健康检查 |
299+| `GET/POST` | `/api/auth/setup` | 首次初始化(已有用户后自动关闭) |
300+| `POST` | `/api/auth/login` `/logout` `/password` | 会话 |
301+| `GET` | `/api/auth/me` | 当前用户与信箱 |
302+| `GET/POST/DELETE` | `/api/mailboxes` | 收件地址管理 |
303+| `GET` | `/api/messages` | 列表,支持 `folder` `q` `before` 分页 |
304+| `GET/PATCH/DELETE` | `/api/messages/:id` | 详情、已读/星标/移动、删除(先进回收站) |
305+| `GET` | `/api/messages/:id/attachments/:attachmentId` | 收件附件下载 |
306+| `GET` | `/api/stats` | 各文件夹未读数 |
307+| `POST` | `/api/mail/send` | 发信(JSON 或 multipart) |
308+| `GET` | `/api/mail/outbox` `/outbox/:id` | 发信记录 |
309+| `POST` | `/api/mail/outbox/:id/retry` | 手动重试(沿用同一内部 ID) |
310+| `GET/POST/DELETE` | `/api/providers` | 渠道管理(管理员) |
311+| `POST` | `/api/providers/:id/default` `/test` | 设为默认、测试发送 |
312+| `GET/POST` | `/api/shares` `/shares/:token/revoke` | 附件分享链接 |
313+| `GET` | `/d/:token` | 大附件公开下载 |
314+
315+### 发信示例
316+
317+JSON(附件用 base64):
318+
319+```bash
320+curl -X POST https://your-domain/api/mail/send -H 'Content-Type: application/json' -b cookie.txt -d '{"from":"[email protected]","to":"[email protected]","subject":"你好","text":"正文","html":"<p>正文</p>"}'
321+```
322+
323+multipart(前端上传用,`payload` 字段是 JSON,文件放 `attachments`):
324+
325+```bash
326+curl -X POST https://your-domain/api/mail/send -b cookie.txt -F 'payload={"from":"[email protected]","to":"[email protected]","subject":"报价单","text":"见附件"}' -F '[email protected]'
327+```
328+
329+返回里的 `smartAttachments` 会说明哪些附件真发了、哪些转成了下载链接。
330+
331+## 已知取舍
332+
333+- Cloudflare 的 Workers Binding 收的是原始 MIME,报文由 [src/mail/mime.ts](src/mail/mime.ts) 自行构建(抄送、密送、回复地址、自定义头、附件、内嵌图片都已覆盖)。绑定按信封收件人逐个投递,因此收件人多时会调用多次 `send()`;若中途失败可能出现部分投递。
334+- Sendflare 的字段名与签名头以其当前 API Reference 为准,如有调整只需要改 [src/mail/providers/sendflare.ts](src/mail/providers/sendflare.ts),不影响上层抽象。
335+- HTML 正文在前端用 `sandbox=""` 的 iframe 渲染,脚本、表单、同源访问全部禁用。
336+- 邮件按地址分片存储在各自的 Durable Object 中,跨信箱的全局搜索需要另做索引。