文档修复:补齐入站线程取根、企业微信游标整窗语义、发信幂等第三支(未超时 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
+7
View File
@@ -14,6 +14,7 @@
| 凭证 | 视图只回 `secretBound: boolean`;`secret` 只写不读,PUT 时缺省 = 不改 | | 凭证 | 视图只回 `secretBound: boolean`;`secret` 只写不读,PUT 时缺省 = 不改 |
| 厂商失败 | 探测 / 试发 / 立即拉取的**厂商失败不是接口错误**:200 的 body 里 `ok:false` / `state:'failed'` / `error` 带原话(已脱敏) | | 厂商失败 | 探测 / 试发 / 立即拉取的**厂商失败不是接口错误**:200 的 body 里 `ok:false` / `state:'failed'` / `error` 带原话(已脱敏) |
| 两种账号 | `kind: 'imap-smtp'`(任意腾讯企业邮箱地址;凭证 = 客户端专用密码)/ `kind: 'wecom'`(企业微信自建应用的应用邮箱;凭证 = 应用 secret) | | 两种账号 | `kind: 'imap-smtp'`(任意腾讯企业邮箱地址;凭证 = 客户端专用密码)/ `kind: 'wecom'`(企业微信自建应用的应用邮箱;凭证 = 应用 secret) |
| 游标推进 | 两条路不是同一种:IMAP 随每一封成功入库**逐封前进**;企业微信是**整窗全有或全无**——一轮吃不完这一窗的新信,游标就原样不动、下一轮整窗重来(已入库的会被滤掉,不会重复处理),被截断的信不会跳过 |
状态码怎么读(通则之外本文特有的): 状态码怎么读(通则之外本文特有的):
@@ -126,6 +127,7 @@ body `{ "to": "me@corp.com", "subject": "试发", "text": "正文" }` →
``` ```
失败照样 200:`{ …, "state": "failed", "error": "Invalid login —— 535 …" }`;这一笔也进出站列表。400 入参不合法;412 凭证空 / 停用。 失败照样 200:`{ …, "state": "failed", "error": "Invalid login —— 535 …" }`;这一笔也进出站列表。400 入参不合法;412 凭证空 / 停用。
这条路**不带 `idempotencyKey`**,不受 §4「发」那三支幂等逻辑保护 —— 每次调用都是一封新信,重试会真的重发一遍。
## 2. 入站 ## 2. 入站
@@ -193,7 +195,12 @@ body `{ "subscriber": "channel" }`(可省 = 全部订阅方;body 也可整个不
## 4. 活动怎么用(不是 HTTP) ## 4. 活动怎么用(不是 HTTP)
- **收**:账号绑 `channelId` → 来信经 `channel.ingestMail` 归一进事件流 → 触发器起单 / 运行中任务的 `listen` 监听命中 → 任务拿到 `data` 事件(载荷带 subject / body / from / channelId 与提取键)。附件以 `ref` 进信封,活动经上传件读文本。 - **收**:账号绑 `channelId` → 来信经 `channel.ingestMail` 归一进事件流 → 触发器起单 / 运行中任务的 `listen` 监听命中 → 任务拿到 `data` 事件(载荷带 subject / body / from / channelId 与提取键)。附件以 `ref` 进信封,活动经上传件读文本。
线程身份取来信 `References` 的**首项**(即这一串的根),没有 `References` 时退回 `In-Reply-To`,全新来信两者皆无则不带线程 ——
归属解析①级靠它与我方出站登记的线程号(同样是首封的 messageId)对表;若认最近一跳,回信来回一趟就会对不上,退化成③级地址 + 时间窗。
- **发**:活动声明工具 `mail.send`(2.0.0)—— 入参 `to[]` `subject` `body` / `html` `cc[]` `bcc[]` `attachments[{uploadRef}]` `inReplyTo` `accountId`;写效应,放行时才真发;出参 `queued / outboundId / messageId / state`;错误 `send-failed`(可重试)/ `no-account` / `invalid`。 - **发**:活动声明工具 `mail.send`(2.0.0)—— 入参 `to[]` `subject` `body` / `html` `cc[]` `bcc[]` `attachments[{uploadRef}]` `inReplyTo` `accountId`;写效应,放行时才真发;出参 `queued / outboundId / messageId / state`;错误 `send-failed`(可重试)/ `no-account` / `invalid`。
放行落地会带 `idempotencyKey`(同一次放行重放不发两封),重试分三种局面:上一封已 `sent` → 直接短路回那次的结果,不再发;
上一封 `failed`,或停在 `sending` **超过 5 分钟**(算作上次进程崩在半路)→ 复用那一行重发;停在 `sending` **不足 5 分钟**
(算作正在飞)→ **不发**,如实回 `state:'failed'` 加一句「同一幂等键的上一封仍在发送中,本次未重复发送」,过一会再放行会重试。
## 5. Schema 附录(与 `packages/contracts/src/mail.ts` 同名) ## 5. Schema 附录(与 `packages/contracts/src/mail.ts` 同名)
+25 -6
View File
@@ -21,17 +21,36 @@
`wecom`(企业微信邮件 API,应用邮箱)各一个适配器;MIME 解析共用 postal-mime(`read_mail` 回的也是 EML)。 `wecom`(企业微信邮件 API,应用邮箱)各一个适配器;MIME 解析共用 postal-mime(`read_mail` 回的也是 EML)。
批准三个新依赖(§12.5 #3):`imapflow` `nodemailer@8` `postal-mime`。 批准三个新依赖(§12.5 #3):`imapflow` `nodemailer@8` `postal-mime`。
3. **入站先持久后分发**:`sys.mail_inbound` 两把唯一键 `(account_id, message_id)` / `(account_id, provider_ref)`, 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(原话脱敏)`,不自动重试 —— 4. **分发自动只投一次**:每个订阅方一条留痕 `dispatching → done | skipped | failed(原话脱敏)`,不自动重试 ——
channel → event 按 `cause.ref` 不幂等,重投会起两单;「重新投递」是人按的钮,那一刻起是至少一次。 channel → event 按 `cause.ref` 不幂等,重投会起两单;「重新投递」是人按的钮,那一刻起是至少一次。
订阅是代码级装配(不落库、不回放老信)。 订阅是代码级装配(不落库、不回放老信)。
5. **跨进程租约**:`sys.mail_account.polling_until` 一条 SQL 判 + 占(与 `sys.task.advancing_until` 同款), 5. **跨进程租约**:`sys.mail_account.polling_until` 一条 SQL 判 + 占(与 `sys.task.advancing_until` 同款),
过期 max(5 分钟, 3 × 间隔);两个后端连同一个库时一个信箱同一时刻只有一个在拉。 过期 max(5 分钟, 3 × 间隔);两个后端连同一个库时一个信箱同一时刻只有一个在拉。
6. **出站先落行**:`sys.mail_outbound` `sending → sent | failed`;带 `idempotencyKey` 且已 `sent` 的不发第二封, 6. **出站先落行**:`sys.mail_outbound` `sending → sent | failed`。带 `idempotencyKey` 的重发分**三支**,
失败的那一行复用重发;厂商失败不抛(`state='failed'` + 原话),调用方各自翻译 —— 工具处理器翻成 落在 `mail-service.ts` 的 `send()` 里 ——
`send-failed`(可重试),channel 传输件抛出让放行落地看到。活动侧出站走 `mail.send` 工具(写效应, 已 `sent` 的**短路**,原样回上次的结果、不再发;`failed`、以及**停在 `sending` 超过 5 分钟**
放行时经 `ToolInvoker` 执行);`connectionKeys: ['account']` 说「从哪个信箱发」。 (`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 7. **凭证**:`secret_cipher` 密封(AES-256-GCM,`JCAI_SECRET_KEY`);视图只回 `secretBound`;更新时 secret
缺省 = 不改;明文只在用例打开、交给 provider 那一段;所有落库 / 上接口的厂商原话先脱敏(明文 / base64 / URL 编码三形)。 缺省 = 不改;明文只在用例打开、交给 provider 那一段;所有落库 / 上接口的厂商原话先脱敏(明文 / base64 / URL 编码三形)。
8. **界面不撒谎**:探测收 / 发分栏各报;wecom 的发信一栏 `checked:false`(拿到 token 不等于能发, 8. **界面不撒谎**:探测收 / 发分栏各报;wecom 的发信一栏 `checked:false`(拿到 token 不等于能发,