文档修复:补齐入站线程取根、企业微信游标整窗语义、发信幂等第三支(未超时 sending 不发)—— controller 评审逐条核实后落笔

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
This commit is contained in:
xcs
2026-08-26 02:57:44 +08:00
co-authored by Claude Fable 5
parent e862a5279b
commit 18ca6dacfe
2 changed files with 32 additions and 6 deletions
+25 -6
View File
@@ -21,17 +21,36 @@
`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 只读打开,不改已读标记。
重复拉到静默跳过;首次不回填(锚在当下,`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 ⇒ ①级落空,退到③地址+时间窗。故 `mail-wiring.ts` 的 `toInboundMail` 取
`references[0] ?? inReplyTo`;客户端只给 `In-Reply-To` 时退回它,全新来信两者皆无则不带线程。
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']` 说「从哪个信箱发」。
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`,不受这三支逻辑保护 —— 各自的调用方式本就不重放。
7. **凭证**:`secret_cipher` 密封(AES-256-GCM,`JCAI_SECRET_KEY`);视图只回 `secretBound`;更新时 secret
缺省 = 不改;明文只在用例打开、交给 provider 那一段;所有落库 / 上接口的厂商原话先脱敏(明文 / base64 / URL 编码三形)。
8. **界面不撒谎**:探测收 / 发分栏各报;wecom 的发信一栏 `checked:false`(拿到 token 不等于能发,