feat: 通过 Cloudflare Hyperdrive 支持外部数据库邮件存储
目标
支持通过 Cloudflare Hyperdrive 将邮件存储到部署者自己的外部数据库,降低邮件数据对 D1 容量的依赖;保持默认 D1 部署、现有 API 和客户端兼容。
Hyperdrive 是数据库连接加速/连接池服务,数据实际存放在外部数据库中。官方支持 PostgreSQL、MySQL 及兼容平台。建议首期实现 PostgreSQL(例如 Neon、Supabase 或自建 PostgreSQL),MySQL 作为后续独立适配,避免首期同时维护三种 SQL 方言。
当前实现分析
基于 main 的 cea2a8c7:
db/schema.sql:收件存于raw_mails,包含原文raw、gzipraw_blob、metadata、已读状态;发件记录存于sendbox。worker/src/email/storage.ts:storeRawMail()直接使用 D1,依赖PRAGMA table_info、D1Result;email/index.ts使用meta.last_row_id生成后续 webhook 所需邮件 ID。- 邮件读取、删除、已读状态分散在
mails_api/、user_api/、admin_api/、telegram_api/,解析接口及 webhook 附件下载也直接访问 D1。 user_api/user_mail_api.ts通过users_address → address → raw_mailsJOIN 实现账户聚合收件箱和删除鉴权;地址列表包含邮件数子查询。common.ts、admin_api/address_api.ts、scheduled.ts的地址删除、未知邮箱邮件清理、空邮箱清理均与邮件表关联。email/ai_extract.ts按message_id更新 metadata;send_mail_api.ts单独写入发件箱。
因此,仅修改收信写入或将 env.DB 换为 Hyperdrive binding 无法完成支持。
建议方案
1. 存储边界
首期将 raw_mails、sendbox 完整放在所选邮件后端;账户、地址、权限、设置等继续保留 D1。每次部署只配置一个活动邮件后端,默认 d1。
新增小范围的邮件存储接口,提供保存、按 ID/地址读取、列表/计数、已读更新、metadata 更新、删除与批量清理。实现 D1 和 PostgreSQL 两个适配器;不模拟完整 D1 API,也不做全项目 ORM 重写。
业务层返回统一的保存结果(如 { success, id }),消除 D1Result/meta.last_row_id 泄漏;适配器统一 ID、时间、二进制数据和 count 的返回类型。所有邮件入口必须走同一后端,包括内部邮件、发件箱、Telegram、webhook 测试与附件下载。
备选方案是只外置邮件正文,在 D1 保留索引和状态,能够减少 JOIN 改造,但会引入正文与索引双写、孤儿清理,且 D1 仍随邮件数量增长。如调整为该方案,应明确它仅解决正文容量问题。整库迁移不纳入本期。
2. Hyperdrive 与 PostgreSQL 接入
建议新增配置(名称待实现时确认):
[vars]
MAIL_STORAGE_BACKEND = "postgres" # 默认 d1
[[hyperdrive]]
binding = "MAIL_HYPERDRIVE"
id = "<hyperdrive-config-id>"- 在
worker/src/types.d.ts声明可选 Hyperdrive binding;使用官方支持版本的pg,从 binding 的connectionString创建客户端。模板已有nodejs_compat。 - 客户端在请求/邮件事件内创建并可靠释放,避免跨事件复用连接;明确连接超时及外部数据库连接数预算。
- 邮件连接关闭 Hyperdrive 查询缓存:官方说明缓存默认开启,写入不会使已有读缓存失效。否则收信轮询、已读更新、删除后读取可能返回旧数据。
- 选择 PostgreSQL 却缺少 binding 或 schema 不匹配时明确报错;不静默回退写 D1,避免数据分散。
- 数据库凭据交由 Hyperdrive 配置管理;本地连接字符串只放本地环境,不提交到仓库。
3. Schema 与 SQL 适配
- 单独维护 PostgreSQL 初始化和版本迁移,不能复用 SQLite 的
PRAGMA、AUTOINCREMENT、datetime()和?占位符。 - 原文使用
TEXT、压缩内容使用BYTEA;兼容现有 gzip 数据以及ENABLE_MAIL_GZIP、ENABLE_MAIL_READ_STATUS行为。 - 使用
INSERT ... RETURNING id;保留迁移前 ID,并正确推进序列。若使用 bigint,明确安全整数范围和序列化策略,避免破坏客户端、邮件链接及 webhook 签名。 - 明确 UTC 时间及 API 输出格式;至少覆盖地址+ID分页索引、清理时间索引、message_id 索引。
- metadata 更新优先使用已保存的邮件 ID,避免仅按非唯一
Message-ID更新其他收件人的邮件。 - 保留现有附件移除配置语义;外部存储不自动解除 Worker 的运行资源或入站邮件限制。
4. 跨库查询和删除一致性
- 用户收件箱:先由 D1 获取当前用户有权访问的地址,再以这些地址约束外部数据库查询;邮件详情、删除等同样保留所有权校验。
- 地址列表统计:针对当前页地址批量查询收/发件数并合并,避免 N+1;跨地址列表需在外部数据库统一排序分页,不能分别分页后拼接。大量地址的参数/批次上限需明确。
- 未知地址邮件和空邮箱清理无法继续使用跨库 JOIN/NOT IN:分页扫描候选地址,批量向另一侧查询存在性,并在删除前复核。确保 count 与列表筛选一致。
- 删除邮箱横跨 D1 和 PostgreSQL,没有统一事务。需要可恢复的删除流程:先记录待删除状态并阻止新写入,再分批幂等清理邮件、完成 D1 删除;中断后可以重试,避免部分成功、孤儿邮件或地址重用造成数据误归属。
5. 失败语义与历史数据迁移
- 当前收信存储异常分支仅记录日志并继续处理;接入外部数据库时需明确持久化失败行为,不能让邮件未保存却表现为处理成功。
- MVP 不引入运行时双写。写入失败明确失败;若增加重试,应使用稳定的投递幂等键,不把可能缺失或重复的
Message-ID当成全局唯一键。 - 提供可断点续传的 D1 → PostgreSQL 迁移工具,保留收/发件 ID、时间、原文/压缩内容、metadata、已读状态;以旧 ID 幂等导入。
- 推荐维护窗口切换:暂停邮件写入及邮件修改/清理 → 迁移并校验记录数、内容摘要和压缩解码 → 校准序列 → 切换后端 → 恢复流量。
- 保留 D1 备份;启用外部后端后产生的新邮件、修改和删除必须先回迁/对账,不能只改配置就宣称无损回滚。
实施拆分与验收
- 抽取邮件存储接口,D1 默认行为保持兼容。
- 实现 PostgreSQL schema、迁移、Hyperdrive binding 和连接生命周期管理。
- 覆盖收/发件全部读写入口、账户聚合收件箱、统计、清理及 webhook/Telegram。
- 完成跨库权限检查与可恢复的邮箱删除流程。
- 验证两个后端的 API 兼容性:收信立即可见、删除/已读立即生效、gzip、metadata、附件、分页、发件记录。
- 验证跨用户访问拒绝、未知地址/空邮箱清理、数据库不可达及超时、写入失败处理、迁移重复执行和切换回滚。
- 本地 PostgreSQL 集成验证之外,在实际 Hyperdrive 环境验证
email()和 HTTP 路径、关闭缓存效果及连接释放。 - 更新中英文 Worker 配置/部署/迁移文档,以及实现时当前
(main)中英文 CHANGELOG。
参考资料
Source: dreamhunter2333/cloudflare_temp_email