This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/docs/adr/ADR-013-mail-gateway.md
T
xcsandClaude Fable 5 816389a185 邮件收发终审修复轮:token 按凭证指纹、毒信有退路、只改发信设置不丢信、重发先抢、列表不搬原件、save 不盖运行列、重锚出声、脏游标不炸页、线程口径归一
全分支终审(跨任务视角)逮出的十条 P0 + 八条 P1,一次修完。每条都配了测试。

P0-1 企业微信 token 缓存改按**凭证指纹**(sha256(corpId,corpSecret),secret 不进 Map 的键):
  按 account.id 存有两条真实串号路径 —— 草稿探测共用 'draft'(探完 A 公司再探 B 公司会命中 A 的
  token,回的是 A 的应用邮箱地址,凭证填错的 B 也显示成功);已存账号换了凭证 id 不变,
  下一轮轮询继续拿旧 token 拉旧 corp 的收件箱,分发给新账号绑的通道(窗口 ≤ 7200s)。
P0-2 一封「毒信」不许卡死整条路:①domain 的 buildInboundRecord 把落 text/jsonb 的字段一律剥 NUL
  (PG 拒收它,MIME 解出二进制垃圾时真会出现);②单封失败先落**降级骨架**(原件原样存、
  主题从原始字节扒、正文写明失败原因)→ 落成了就推进游标并把「第 N 封(providerRef)解析失败,
  已降级存档」写进 last_poll_error;骨架也落不下才停在这一封,那时 error **点名是哪一封**。
  降级行不自动分发(正文是失败原因,不是客户的话)。
P0-3 cursorReset 只比**收侧**键(imapHost/imapPort/imapTls/login/folder;corpId/apiBase):
  从前比全部 settings,把 465 改成 587 也会清游标 → IMAP 重锚 → 上次游标到重锚之间的来信永远不拉。
P0-4 复用旧行重发**先抢**(OutboundStore.claimForResend,一条条件更新;真 store 与内存件同款):
  两个后端连同一个库,两台同时重放同一次放行都判「超时可复用」→ 两封真邮件,破掉「同键不发两封」。
P0-5 列表与只要元信息的读**不取 raw**(一封上限 16MB × 200 行);rawAvailable 由 `raw is not null`
  投影,不靠取回字节再判;只有「下载原件」走全列的 get()。InboundRecord 新增 rawAvailable。
P0-6 账号 save() 只写**配置列**:运行列(cursor/last_polled_at/last_poll_error/last_inbound_at/
  polling_until)只经 savePoll/claimPolling/releasePolling 改 —— 整行 upsert 会把另一台正持着的租约
  与刚推进的游标盖回读那一刻的快照(08-24 loop_state 整份覆盖同款指纹)。清游标改为显式单写那一列。
P0-7 UIDVALIDITY 变化不再静默:provider 回 note,用例落进 last_poll_error(「已重锚在当下;
  期间来信未拉取」)—— 丢了一段信而账号页一片绿是界面撒谎。
P0-8 parseCursor 连取值范围一起查(非负安全整数 + 界内:UID ≤ 0xFFFFFFFF、秒 ≤ Date 上限)——
  1e13 正是安全整数,乘 1000 出界,一行脏游标能让 listAccounts 整页 500。debt ⑨ 划线销账。
P0-9 线程口径归一:threadIdOf 改成**取根**(references[0] ?? inReplyTo,ADR-013 决策 3),
  mail-wiring 调它、不再内联一份。原测试改断言,不删。
P0-10 文档说老实话:mail.send 工具路发出的信**不登记 outbound_ref**(recordOutbound 的唯一调用方
  channel.sendMail 在 src 里零调用方)—— API 文档 §4 与 ADR-013 决策 6 各补一段,根治记 debt。

P1:imap anchor 的 Math.min(...uids) 改 reduce(十万级 UID 会栈溢出)· wecom 翻页加页数上限
(服务端一直回 has_more:1 会死循环)· MAIL_TOOL 导出并对**真契约**断言(原 v1/v2 是手抄夹具、
自证,且 v1 版本号写错)· reset-runs 清运行数据时把邮箱账号的游标与拉取状态一并清(留着游标而信没了,
wecom 十分钟重叠窗会把最近的信当新信再投一次)· due.test 的 leaseMsFor(30) 断言改写死 300_000(原为自证)·
cursor.test 删掉指向评审报告的注释 · 设计稿稿头标注三处已被 ADR-013 取代 · ADR-013 补记 wecom
compose_send 不带 inReplyTo/references(它发出的信在对方那里不串线程)。

契约变更(§12.4):port.ts 的 InboundStore 加 getMeta、OutboundStore 加 claimForResend、
savePoll 的 cursor 允许显式 null、MailProvider.pull 回参可带 note —— 实现方只有本模块 adapter 与
__tests__/fakes.ts 的内存件(两边语义一致,已同改),CONTRACT.md 已同步,api-report 已刷新。

测试:1922 → 1942(mail 模块 102 → 113,含真 PG 的 save 不盖运行列 / 不搬原件 / 两条真连接抢同一行)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
2026-08-26 03:47:48 +08:00

9.6 KiB
Raw Blame History

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), 重复拉到静默跳过;首次不回填(锚在当下,backfillFrom 才回填);IMAP 只读打开,不改已读标记。 两条接入路的游标推进规则不是同一种,不能一句「逐封/逐批」带过:IMAP 随每一封成功入库逐封前进 (每封给出自己的 cursorAfter = 该封 UID),more 与游标推进彼此独立,失败停在最后一封成功处; 企业微信是整窗全有或全无 —— 只有这一窗的新信全吃完(more === false)才把游标推到窗口上沿, 被本轮预算截断时游标原样退回(more = !exhausted || fresh.length > wanted.length)—— 因为它的窗口是时间区间,推早了、没读到的那些就被永久跳过去了(重叠窗只兜 10 分钟); 退回不会卡住,已入库的下一轮被 known 滤掉,每轮照旧往前吃一批。 入站的线程身份取「根」,不取最近一跳:我方出站登记的 threadId 恒是这一串的首封 (channelTransport.send 回 input.threadId ?? messageId,channel 据此 recordOutbound), 而归属解析①级是精确等值比对。若入站取 In-Reply-To(最近一跳),跑一个来回就漂: 我方 M1 → 客户回 M1(①命中)→ 我方再回(实际 M2、仍记在 M1 名下)→ 客户回 M2 → 库里从没记过 M2 ⇒ ①级落空,退到③地址+时间窗。故取 references[0] ?? inReplyTo; 客户端只给 In-Reply-To 时退回它,全新来信两者皆无则不带线程。 这条口径全仓只有一处:mail 模块 domain 的 threadIdOf,mail-wiring.ts 的 toInboundMail 调它 ——2026-08-25 终审逮到过两套相反的实现(导出的那个取最近一跳、app 里内联的取根),已并成一份。
  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 的重发分三支, 落在 mail-service.ts 的 send() 里 —— 已 sent 的短路,原样回上次的结果、不再发;failed、以及停在 sending 超过 5 分钟 (SEND_STALE_MS)的(视为上次崩在半路)复用那一行重发;停在 sending 不足 5 分钟 (视为正在飞)则不发,如实回 {state:'failed', error:'同一幂等键的上一封仍在发送中(Ns), 本次未重复发送'}。「在飞」与「崩在半路」在库里长得一模一样,只能靠这个 5 分钟时限分开: 宁可让调用方再试一次,也不能发出第二封真邮件。同键不发两封是契约写死的; 厂商失败不抛(state='failed' + 原话),调用方各自翻译 —— 工具处理器翻成 send-failed(可重试), channel 传输件抛出让放行落地看到。活动侧出站走 mail.send 工具(写效应,放行时经 ToolInvoker 执行,幂等键随调用一起带来);connectionKeys: ['account'] 说「从哪个信箱发」。 幂等保护只在这一条路上:channel.sendMail(channelTransport.send)与运营面 POST …/send-test 都不传 idempotencyKey,不受这三支逻辑保护 —— 各自的调用方式本就不重放。 复用旧行之前还要抢(2026-08-25 终审补):判「可以复用」与「真去发」之间隔着 await, 两个后端连同一个库时两台会同时判出同一个结论 —— 都发就是两封真邮件。故 OutboundStore.claimForResend 是一条条件更新(failed、或 created_at <= now − 5min 的 sending 才抢得到;抢到即置 sending 并把 created_at 推到 now,那一行当场离开可抢的集合),抢到才发,抢不到回 {state:'failed', error:'同一幂等键的上一封正被另一路重发,本次未重复发送'}。 outbound_ref 的实情:event.recordOutbound 的唯一调用方是 channel.sendMail,而 channel.sendMail 在 src 里零调用方 —— 放行落地走 deps.tools.execute → mail.send 工具,不经 channel。 于是活动经 mail.send 发出的信不登记 outbound_ref:回信归属只靠②字段提取与触发器(以及③地址 + 时间窗), ①级(outbound_ref 对表)目前只对 channel 路成立,而 channel 路暂无调用方。 根治要 toolbox 把 taskId 透进 ToolHandler 签名(跨模块契约变更,单独一件事),已记 debt。
  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,线程级归属(①级)对它不成立;③级地址窗仍可用。 还有一半要说清:wecom 的 compose_send 载荷里没有 inReplyTo / references 的位置 (适配器发出去的就是 to/cc/bcc/subject/content/attachment_list)—— 所以 wecom 账号发出的信在对方的邮件客户端里 不与来信串成一个线程;imap-smtp 那条路走 nodemailer,两个头都带得上,线程正常。
  • 附件走 sys.upload 的 8MB 上限,更大的只留注;原件 .eml 超 16MB 不存。
  • mail.send 工具契约升 2.0.0:to 由 string 变 array(演进检查不管类型;处理器仍容忍逗号串), 唯一消费方 quote-* 活动已卸载,无在途调用;createSimulatedMailTransport 与 sys.outbound_mail 暂留待退役。
  • 前端接入前:账号只能经接口配;配好账号 + 通道 + 触发器,来信才会起单 —— channel 配置仍是只读种子(无 upsert 路由),真要按业务收信得先把通道种上(记 debt)。
  • 部署:迁移 0033 先于启动;reset-runs 清三张留痕表、留账号但清它的游标与拉取状态 (留着游标而信没了,wecom 那十分钟重叠窗会把最近的信当新信再投一次)。
  • 欠账见 docs/debt.md「邮件收发」段。