把 Cloudflare Email Routing 的 catch-all 规则接到一个 Worker 上,整个域名的来信由它接管、解析、入库,再通过网页读取。用来接各类注册验证码,地址随用随建。
只收信。 Cloudflare 的 Email Sending 仍是 Beta 且需付费计划,本项目不做发信。这一取舍带来两个连带结果:注册不依赖邮件验证(改用邀请码),忘记口令只能由管理员在后台重置。
静态资源、HTTP API、email() 收信、scheduled() 清理都在同一个 Worker 里,npm run deploy 一次全量更新,前后端不会版本漂移。
| 领域 | 能力 |
|---|---|
| 收信 | catch-all 直投 Worker;postal-mime 解析 multipart、传输编码、非 UTF-8 字符集 |
| 容错 | 原始 .eml 先落 R2 再解析,解析失败降级为可下载的占位记录,不丢邮件 |
| 认证 | 从 Authentication-Results 解析 SPF / DKIM / DMARC,任一失败提升为横幅级提示 |
| 地址 | 8 位随机十六进制或自定义前缀,可加备注、可单独停用;67 个内置保留前缀 |
| 域名 | 数量不限,后台添加,可单独停用 |
| 验证码 | 自动识别 4–8 位数字并高亮,一键复制 |
| 追踪拦截 | 远程图片、CSS url()、@import 默认阻断并计数,可手动放行 |
| 附件 | 存 R2,强制下载;超过 10 MiB 的在点击前标出「见原文」 |
| 检索 | 主题 / 发件人搜索、未读筛选、游标分页 |
| 实时性 | 轮询 + 退避,页面隐藏时完全暂停 |
| 账号 | 首个用户免邀请码并自动成为管理员;之后凭邀请码注册。双层 KDF,明文口令不离开浏览器 |
| 后台 | 概览、邀请码、用户、域名、设置、手动清理、孤儿对象回收 |
| 保留策略 | 保留天数 + 单邮箱条数上限,每日 cron 清理 |
| 界面 | 双皮肤 + 琥珀子主题 + 亮暗切换,14 个快捷键,Ctrl/⌘ K 命令面板 |
| 页面 | 路由 | 说明 |
|---|---|---|
| 首页 | / |
伪终端自动演示,固定终端皮肤 |
| 登录 / 注册 | /#/login /#/register |
注册需邀请码 |
| 收件箱 | /#/inbox |
三栏:邮箱列表 / 邮件列表 / 详情 |
| 管理后台 | /#/admin |
概览、邀请码、用户、域名、设置 |
| 组件总览 | /#/gallery |
双皮肤逐项比对,开发自检用 |
两套皮肤共享完全相同的信息架构与栏宽,差异只在 token 取值与少量结构参数:
![]() modern · 亮色 |
![]() modern · 暗色 |
![]() terminal · 琥珀子主题 |
![]() SPF 校验失败横幅 |
![]() 命令面板 |
![]() 管理后台 |
| 维度 | terminal |
modern |
|---|---|---|
| 基调 | 磷光终端绿,仅深色 | 中性灰 + 蓝,亮 / 暗 / 跟随系统 |
| 圆角 | 0 | 4 / 6 / 10 px |
| 深度 | 仅 1px 边框,无投影 | 边框 + 轻投影 |
| 装饰层 | 扫描线 / 噪点 / 暗角,可关 | 无 |
| 底部状态栏 | tmux 风常驻 | 移除,信息上移顶栏 |
| 层 | 选型 | 理由 |
|---|---|---|
| 运行时 | Cloudflare Workers | 官方推荐新项目用 Workers + Static Assets 而非 Pages |
| API 框架 | Hono | Workers 生态标准,体积小,TS 类型友好 |
| 数据库 | Cloudflare D1 | SQLite 语义,单库免费额度 500 MB |
| 对象存储 | Cloudflare R2 | 存原始 .eml 与附件,出站免费 |
| 计数器 | Cloudflare KV | 仅用于限流等易失数据 |
| MIME 解析 | postal-mime | Cloudflare 官方文档推荐 |
| 前端 | React 19 · Vite 8 · TypeScript 7 | — |
| HTML 净化 | DOMPurify | 邮件正文渲染前净化 |
规模
| 指标 | 数值 |
|---|---|
| 自测断言 | 160 |
| API 端点 | 43 |
| Worker 代码 | 2,192 行 |
| 前端代码 | 4,526 行 TS/TSX + 2,326 行 CSS |
| 产物体积 | JS 304.8 kB(gzip 97.8 kB)· CSS 45.2 kB(gzip 9.1 kB) |
全程在 Cloudflare 网页面板完成,不需要命令行,本机也不需要装 Node。 逐步说明与排错见 面板部署教程。
前置条件
| 需要 | 说明 |
|---|---|
| Cloudflare 账号 | 免费计划即可 |
| 一个域名 | NS 已指向 Cloudflare,面板里状态为 Active |
| 开通 R2 | 免费额度 10 GB 存储 / 100 万次写,本项目用量远低于此。但 Cloudflare 要求开通 R2 前先绑定支付方式 |
四步
-
部署 —— Compute (Workers) → Create,选「从 git 仓库导入」那一项,指向本仓库,构建配置三栏保持默认。
D1 / R2 / KV 在首次部署时自动开通,不需要填任何资源 ID;前端由
wrangler.jsonc的build.command自动构建;会话签名密钥首次运行时自动生成,也不需要设置任何密钥。 -
建表 —— D1 →
hex-mail→ Console,依次执行migrations/下的两个.sql。 -
注册 —— 打开
/#/register。首个账号免邀请码并自动成为管理员。部署完成后请立刻完成这一步。 从部署成功到你注册之间存在一个窗口,期间任何知道地址的人都能抢先注册并拿到管理员。
-
配置收信 —— 域名 → Email Routing → 启用并添加 MX 记录 → 新建 catch-all 规则,动作选 Send to a Worker,目标选
hex-mail。最后在/#/admin→ 域名 里登记该域名。
仓库里的链接指向 github.com/jsongmax/hex-mail。fork 后把这四处换成你自己的:
| 文件 | 内容 |
|---|---|
README.md / README.en.md |
头部 Deploy 按钮 URL 里的用户名 |
web/src/pages/Landing.tsx |
REPO_URL,首页 clone 命令与页脚链接都取它 |
package.json |
repository.url |
web/public/fonts/ |
自托管字体(可选),见下 |
三个最常见的部署错误
一、改动了构建配置的三栏
保持默认。静态资源在 ./dist/web,而 dist/ 不进仓库 —— 构建由 wrangler.jsonc
的 build.command 承担。把「构建命令」填成别的、或把「部署命令」改掉,容易得到一个
只有 API、打开是空白的 Worker,而且不报错。
同理别给 wrangler.jsonc 加 secrets.required:那个字段会让部署在密钥未设置时失败,
而首次部署时 Worker 还不存在、没有任何地方能提前设密钥,直接死锁。密钥现在会自动生成,
/api/health 的 jwtSecret 字段报 env(显式设置)还是 auto(自动生成)。
二、catch-all 规则动作选错
Email Routing 的规则动作里,Send to an email address 与 Send to a Worker 是两回事。选前者邮件会被转发到你的私人邮箱,Worker 收不到任何东西,页面永远是空的。
三、字体未自托管
base.css 刻意不引 Google Fonts —— 首页写着「无第三方中转」,字体走 CDN 会自相矛盾,也会把访问者 IP 暴露给字体 CDN。
自托管字体是可选的。 仓库里 web/public/fonts/ 是空的,上面所有截图就是在这个状态下渲染的:中文回落到系统无衬线,拉丁字符走系统等宽栈,界面不会错位(框线都是 CSS 边框,需要对齐的内容全是 ASCII)。唯一的代价是中文与拉丁字形风格不统一。想要完全一致的观感,再补字体。
刻意不引 Google Fonts:首页写着「无第三方中转」,字体走 CDN 会自相矛盾,也会把访问者 IP 暴露给字体服务方。
把两个文件放进 web/public/fonts/:
| 文件 | 用途 | 说明 |
|---|---|---|
jetbrains-mono-*.woff2 |
界面等宽主体 | 400 / 700 两个字重 |
sarasa-mono-sc-subset.woff2 |
中英等宽对齐 | 必须做子集。界面中文是可穷举的,数百字量级,目标 ≤ 100 KB |
不需要域名,也不需要 Cloudflare 账号。
npm install创建 .dev.vars:
.dev.vars 可以完全不建 —— 密钥会自动生成,ENVIRONMENT 由 npm run dev 显式传入。
想固定密钥的话再建:
JWT_SECRET=用 openssl rand -base64 32 生成npm run db:migrate
npm run dev # http://localhost:8787打开 http://localhost:8787/#/register 注册第一个账号。库里没有用户时,首个注册者免邀请码并自动成为管理员,之后的注册都需要管理员在后台生成邀请码。
本地没有真实收信。用 dev 专用接口灌样本,它与线上 email() handler 复用同一个 ingestMessage,所以本地验证的就是线上行为:
curl -X POST http://127.0.0.1:8787/api/_dev/simulate-email \
-H 'Origin: http://127.0.0.1:8787' \
-H 'Content-Type: application/json' \
-d '{"to":"你的地址","eml":"From: a@b.c\r\nSubject: test\r\n\r\ncode 418902"}'其他命令
npm run dev:web # 仅前端 HMR,API 代理到 8787
npm run typecheck # Worker 与前端两套 tsconfig
npm run verify:readme # 核对本文档里的数字与源码是否一致Windows:不要在
wrangler dev运行时另起wrangler d1 execute --local。两者争同一个 SQLite 文件锁,命令会永久挂起且无超时,严重时把 workerd 拖崩。自测脚本因此一律走POST /api/_dev/query(只读,拒绝非 SELECT)。终止wrangler dev时须连进程树一起杀,否则孤儿workerd.exe会继续持锁。
需先跑起 npm run dev。
npm test # 全部 160 项
npm run test:bootstrap # 首个用户引导 15 项
npm run test:jwt # 会话密钥自动生成 12 项
npm run test:auth # 认证链路 21 项
npm run test:ingest # 收信链路 38 项
npm run test:inbox # 收件箱与管理接口 74 项
npm run bench:pbkdf2 # PBKDF2 各迭代数的成本表覆盖重点:越权隔离、保留前缀拦截、附件下载响应头、CSRF 闸门、限流语义、分页游标边界、MIME 解析降级、字符集解码、文件名净化,以及两处并发正确性 —— 并发注册到空库只能产生一个管理员,并发首次访问只能产生一个会话密钥。
系统设置存于 settings 表,在后台「设置」页修改。
| 键 | 默认 | 说明 |
|---|---|---|
open_registration |
0 |
开放注册。关闭时必须凭邀请码 |
default_mailbox_quota |
20 |
新用户默认邮箱数上限 |
retention_days |
7 |
邮件保留天数,超期由 cron 清理 |
max_messages_per_mailbox |
0 |
单邮箱条数上限,0 表示不限 |
unknown_address_policy |
reject |
未知地址策略:reject 退信 / drop 静默丢弃 |
reserved_extra |
空 | 额外保留前缀,每行一个 |
default_skin |
terminal |
新用户默认皮肤 |
default_theme |
system |
新用户默认主题 |
代码内常量
| 常量 | 取值 | 位置 |
|---|---|---|
| 入站单封上限 | 25 MiB | src/email/ingest.ts |
| 正文入 D1 上限 | 256 KiB,超出截断(完整副本仍在 R2) | src/email/parse.ts |
| 附件单独存储上限 | 10 MiB,超出标记 storedInRaw |
src/lib/storage.ts |
| 列表页大小 | 50 | src/api/routes/messages.ts |
| 轮询间隔 | 10 秒基础 → 30 秒 → 60 秒退避;新建邮箱后 120 秒内加速到 5 秒 | web/src/hooks/usePoll.ts |
| 登录限流 | 每「用户名 + IP」15 分钟 5 次失败 | src/api/routes/auth.ts |
| 邀请码限流 | 每 IP 每小时 10 次失败,成功不计数 | src/api/routes/auth.ts |
| 会话有效期 | 7 天 | src/api/middleware/auth.ts |
| 定时任务 | 0 3 * * * |
wrangler.jsonc |
┌──────────────────────────────────────────┐
发件方 SMTP ───► │ Cloudflare Email Routing (catch-all) │
└───────────────────┬──────────────────────┘
│ email()
浏览器 ─── HTTPS ───────────────────►│
▼
┌──────────────────────────────────────────┐
│ 单个 Worker (hex-mail) │
│ fetch() → Hono API + 静态资源 │
│ email() → 收信 / 解析 / 落库 │
│ scheduled() → 过期邮件与孤儿对象清理 │
└───┬───────────────┬──────────────┬───────┘
▼ ▼ ▼
D1 (元数据) R2 (原始eml/附件) KV (限流计数)
hex-mail/
├── src/ Worker
│ ├── api/ 路由与中间件
│ ├── email/ 收信处理与 MIME 解析
│ ├── cron/ 定时清理
│ └── lib/ 口令、JWT、存储、保留前缀
├── web/ React 前端
│ └── src/
│ ├── skin/ 双皮肤状态与文案表
│ ├── styles/ 设计 token,唯一存放色值的地方
│ ├── components/ 组件库与收件箱
│ └── pages/ Landing / Auth / Inbox / Admin / Gallery
├── shared/kdf.ts 客户端 KDF,前端与后端共用
├── migrations/ D1 迁移
├── scripts/ 自测、基准、截图
└── docs/ 面板部署教程与界面截图
数据库:7 张业务表 users invite_codes domains mailboxes messages attachments settings。
为什么时间戳是毫秒
messages.received_at 与 mailboxes.last_message_at 用毫秒而非秒。整秒精度会静默丢数据:
- 轮询用
received_at > since,同一秒内到达的邮件永远追不上since,会被永久漏掉 - 游标分页用
received_at < cursor,同一秒的两封邮件会在翻页边界丢一封
其余表的 created_at 仍是秒 —— 它们不参与排序与游标。见 migrations/0002_millisecond_timestamps.sql。
为什么收信要先落 R2 再解析
message.raw 是 ReadableStream 且只能消费一次。原始字节既要上传 R2 又要喂给 postal-mime,必须先整体读进 ArrayBuffer 再分发。
顺序上原始 .eml 先落 R2,之后才解析。这样解析抛异常时不会让 handler 崩掉——降级为插入一条带 parse_error 的占位记录,用户至少能下载原文。反过来做的话,一封结构畸形的邮件会直接丢失。
API:42 个端点,全部在 /api 下,JSON,会话走 httpOnly cookie。/_dev/* 按 ENVIRONMENT === 'development' 门控,生产环境返回 404。
| 分组 | 端点 |
|---|---|
| 认证 | POST /auth/register /login /logout /password · GET /auth/me · PATCH /auth/preferences |
| 邮箱 | GET /mailboxes /mailboxes/domains /mailboxes/check · POST /mailboxes · PATCH DELETE /mailboxes/:id |
| 邮件 | GET /messages/poll /messages/mailbox/:id /messages/:id /messages/:id/raw · PATCH DELETE /messages/:id |
| 附件 | GET /attachments/:id |
| 管理 | GET /admin/stats · 邀请码 / 用户 / 域名 / 设置 CRUD · POST /admin/cleanup /admin/gc-orphans |
| 仅 dev | POST /_dev/simulate-email /_dev/query /_dev/reset-test-data /_dev/reset-ratelimits |
| 限制 | 原因 |
|---|---|
| 不能发信 | Cloudflare Email Sending 仍是 Beta 且需 Workers Paid |
| 忘记口令只能管理员重置 | 上一条的必然连带,发不出验证邮件 |
| 最坏 10 秒送达延迟 | 轮询机制。Durable Objects + WebSocket 需 Workers Paid |
| 开箱中文不等宽 | Sarasa Mono SC 不在 Google Fonts 上,须自托管 |
| 每日 10 万请求上限 | Workers Free。轮询是主要消耗方,退避与隐藏暂停可降约一个数量级 |
| 附件超过 10 MiB | 无独立对象,需通过完整 .eml 取回。界面在点击前标出 |
| 单封上限 25 MiB | Cloudflare Email Routing 在 SMTP 阶段即拒绝更大的邮件 |
| 开通 R2 需绑定支付方式 | Cloudflare 账号层面的准入要求。免费额度 10 GB 存储 / 100 万次写,本项目用量远低于此,但卡必须先绑 |
本项目仅供自托管学习与个人使用。
- 使用者需自行遵守所在司法辖区的法律法规,以及 Cloudflare 的服务条款与可接受使用政策
- 临时邮箱容易被用于规避服务商的注册限制。是否构成违约由你与对方服务商之间的协议决定,与本项目无关
- 邮件内容明文存储于你自己的 D1 与 R2,本项目不提供端到端加密。不要用它接收敏感信息
- 作者不对因使用本项目导致的域名封禁、账号冻结、数据丢失或其他损失承担责任
MIT © Hex Mail contributors








