Archived
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
62 lines
5.6 KiB
Markdown
62 lines
5.6 KiB
Markdown
# ADR-013 邮件收发:外界适配的两条路、入站先持久后分发、出站留痕
|
|
|
|
状态:已接受(2026-08-25)
|
|
背景出处:设计稿 `docs/superpowers/specs/2026-08-25-mail-design.md`;腾讯企业邮箱 / 企业微信官方文档
|
|
(核实日期 2026-08-25,链接列在设计稿开头)。
|
|
关联:ADR-008(投递记录即队列)、ADR-009(凭证密封)、ADR-012(票据 / 权限只到端)。
|
|
|
|
## 问题
|
|
|
|
`channel` 模块(L2)早就有整条「入站归一 → 事件 → 触发器起单 / 任务监听 / 消息兜底」与出站 `MailTransport` 端口,
|
|
但两端都是替身:出站落库不真发,入站只能经 `/api/ingest` 注入;`mail.send` 工具回 `{queued:true}` 从不触达世界。
|
|
系统要接真信箱(腾讯企业邮箱),要给活动一个能发信的口,要让来信真的进入事件链;账号参数要能在前端配。
|
|
腾讯的两条接入路(IMAP/SMTP、企业微信邮件 API)都**没有带内容的推送**:收信只能轮询拉。
|
|
|
|
## 决策
|
|
|
|
1. **新模块 `mail`(L1),channel 作首个订阅方**:`mail` 管账号(配置 + 密封凭证)、两条接入路的适配器、
|
|
轮询拉取、入站 / 出站留痕、订阅注册表;app 装配把 `channel.ingestMail` 注册为订阅方、把 channel 的
|
|
`MailTransport` 注入为「转 `mail.send`」。channel / toolbox 的契约一字不动,也不 import `mail`。
|
|
2. **两条接入路一个端口**:`MailProvider { probe, anchor, pull, send }`,`imap-smtp`(imapflow + nodemailer)与
|
|
`wecom`(企业微信邮件 API,应用邮箱)各一个适配器;MIME 解析共用 postal-mime(`read_mail` 回的也是 EML)。
|
|
批准三个新依赖(§12.5 #3):`imapflow` `nodemailer@8` `postal-mime`。
|
|
3. **入站先持久后分发**:`sys.mail_inbound` 两把唯一键 `(account_id, message_id)` / `(account_id, provider_ref)`,
|
|
重复拉到静默跳过;游标逐封(IMAP)/ 逐批(wecom)推进,失败停在最后一封成功处;首次不回填(锚在当下,
|
|
`backfillFrom` 才回填);IMAP 只读打开,不改已读标记。
|
|
4. **分发自动只投一次**:每个订阅方一条留痕 `dispatching → done | skipped | failed(原话脱敏)`,不自动重试 ——
|
|
channel → event 按 `cause.ref` 不幂等,重投会起两单;「重新投递」是人按的钮,那一刻起是至少一次。
|
|
订阅是代码级装配(不落库、不回放老信)。
|
|
5. **跨进程租约**:`sys.mail_account.polling_until` 一条 SQL 判 + 占(与 `sys.task.advancing_until` 同款),
|
|
过期 max(5 分钟, 3 × 间隔);两个后端连同一个库时一个信箱同一时刻只有一个在拉。
|
|
6. **出站先落行**:`sys.mail_outbound` `sending → sent | failed`;带 `idempotencyKey` 且已 `sent` 的不发第二封,
|
|
失败的那一行复用重发;厂商失败不抛(`state='failed'` + 原话),调用方各自翻译 —— 工具处理器翻成
|
|
`send-failed`(可重试),channel 传输件抛出让放行落地看到。活动侧出站走 `mail.send` 工具(写效应,
|
|
放行时经 `ToolInvoker` 执行);`connectionKeys: ['account']` 说「从哪个信箱发」。
|
|
7. **凭证**:`secret_cipher` 密封(AES-256-GCM,`JCAI_SECRET_KEY`);视图只回 `secretBound`;更新时 secret
|
|
缺省 = 不改;明文只在用例打开、交给 provider 那一段;所有落库 / 上接口的厂商原话先脱敏(明文 / base64 / URL 编码三形)。
|
|
8. **界面不撒谎**:探测收 / 发分栏各报;wecom 的发信一栏 `checked:false`(拿到 token 不等于能发,
|
|
`compose_send` 没有只验不发的口);wecom 的地址探到之前是空串;拉取错误摆厂商原话;探测 / 试发 / 拉取的
|
|
厂商失败在 200 的 body 里说,不编状态码。
|
|
9. **§7.3「两缝」的解释**:`MailProvider` 不经 ModelGateway / ToolInvoker —— 入站是外界事实、持久化即记录
|
|
(回放读 `sys.mail_inbound`,永不重拉);活动侧出站经 `ToolInvoker`(可录可放),系统侧出站留痕
|
|
`sys.mail_outbound`;`createFakeMailProvider` 是测试替身。这是「新增 port 的验收条件」在本模块的落法。
|
|
10. **15 条路由读写都归运营中心 `ops`,不设计端两端共读**:`route-access.test.ts`(编码规范 / ADR-012)钉着
|
|
一份有意枚举、随权限模型一起审过的「两端共用读路由」名单(原 8 条)。设计稿原把 7 条 GET 标成
|
|
`design|ops`,会让这份名单长到 14 条。落地时改成路由、不改名单 —— ①邮箱那一屏按本模块自己的前端
|
|
对接说明落在运营中心,设计端没有消费方(测试台读的是通道配置,不是邮箱账号);②入站正文装的是
|
|
客户来往内容,ADR-012 已把「设计端能读客户主数据」记为一处有意承担的代价,不再无故加宽;
|
|
③要往那份名单里加,得有真实消费方撑着,现在没有。故 15 条(账号 CRUD / 探测 / 拉取 / 试发 /
|
|
入站 / 出站 / 附件 / 原件 / 重投)一律 `access: 'ops'`。
|
|
|
|
## 后果
|
|
|
|
- 收信最快延迟 = `pollIntervalSec`(下限 10 秒);没有 IDLE 推送(记 debt)。
|
|
- wecom 发出的信拿不到 Message-ID,线程级归属(①级)对它不成立;③级地址窗仍可用。
|
|
- 附件走 `sys.upload` 的 8MB 上限,更大的只留注;原件 .eml 超 16MB 不存。
|
|
- `mail.send` 工具契约升 2.0.0:`to` 由 string 变 array(演进检查不管类型;处理器仍容忍逗号串),
|
|
唯一消费方 quote-* 活动已卸载,无在途调用;`createSimulatedMailTransport` 与 `sys.outbound_mail` 暂留待退役。
|
|
- 前端接入前:账号只能经接口配;配好账号 + 通道 + 触发器,来信才会起单 ——
|
|
`channel` 配置仍是只读种子(无 upsert 路由),真要按业务收信得先把通道种上(记 debt)。
|
|
- 部署:迁移 0033 先于启动;`reset-runs` 清三张留痕表、留账号。
|
|
- 欠账见 `docs/debt.md`「邮件收发」段。
|