Skip to content

Repository files navigation

Hex Mail

自托管临时邮箱面板,跑在 Cloudflare Workers 上

License Cloudflare Workers TypeScript React Tests

简体中文 · English

Deploy to Cloudflare

Hex Mail 收件箱

目录

这是什么

把 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 · 亮色
modern 暗色
modern · 暗色
琥珀子主题
terminal · 琥珀子主题
SPF 校验失败
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 前先绑定支付方式

四步

  1. 部署 —— Compute (Workers) → Create,选「从 git 仓库导入」那一项,指向本仓库,构建配置三栏保持默认。

    D1 / R2 / KV 在首次部署时自动开通,不需要填任何资源 ID;前端由 wrangler.jsonc 的 build.command 自动构建;会话签名密钥首次运行时自动生成,也不需要设置任何密钥。

  2. 建表 —— D1 → hex-mail → Console,依次执行 migrations/ 下的两个 .sql。

  3. 注册 —— 打开 /#/register。首个账号免邀请码并自动成为管理员。

    部署完成后请立刻完成这一步。 从部署成功到你注册之间存在一个窗口,期间任何知道地址的人都能抢先注册并拿到管理员。

  4. 配置收信 —— 域名 → Email Routing → 启用并添加 MX 记录 → 新建 catch-all 规则,动作选 Send to a Worker,目标选 hex-mail。最后在 /#/admin → 域名 里登记该域名。

Fork 之后要改的地方

仓库里的链接指向 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

About

一个跑在自己域名上的一次性/临时邮箱系统,支持全自动配置,开箱即用。

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages