Archived
邮件收发终审修复轮: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
This commit is contained in:
@@ -201,6 +201,14 @@ body `{ "subscriber": "channel" }`(可省 = 全部订阅方;body 也可整个不
|
||||
放行落地会带 `idempotencyKey`(同一次放行重放不发两封),重试分三种局面:上一封已 `sent` → 直接短路回那次的结果,不再发;
|
||||
上一封 `failed`,或停在 `sending` **超过 5 分钟**(算作上次进程崩在半路)→ 复用那一行重发;停在 `sending` **不足 5 分钟**
|
||||
(算作正在飞)→ **不发**,如实回 `state:'failed'` 加一句「同一幂等键的上一封仍在发送中,本次未重复发送」,过一会再放行会重试。
|
||||
复用那一行之前还要**抢**(一条条件更新):两个后端连同一个库时两台会同时判出「可以复用」,抢不到的那一路回
|
||||
「同一幂等键的上一封正被另一路重发,本次未重复发送」,一封真信只出一次。
|
||||
- **发出去的信不登记 `outbound_ref`**(实情,2026-08-25 终审核实):`event.recordOutbound` 的唯一调用方是
|
||||
`channel.sendMail`,而 `channel.sendMail` 在 src 里**零调用方** —— 放行落地走的是 `deps.tools.execute` → `mail.send` 工具这条路,
|
||||
它不经 channel,也就不写 `sys.outbound_ref`。所以:**活动经 `mail.send` 发出的信,回信归属只靠②字段提取与触发器**
|
||||
(以及③地址 + 时间窗);①级(`outbound_ref` 对表)目前只对 channel 路成立,而 channel 路暂无调用方。
|
||||
下面 §5 里 `sys.mail_outbound.message_id`「线程级归属的对表来源」那句话,对生产唯一的发信路径**尚不成立**。
|
||||
根治要 toolbox 把 `taskId` 透进 `ToolHandler` 签名(跨模块契约变更,单独一件事),已记 `docs/debt.md`。
|
||||
|
||||
## 5. Schema 附录(与 `packages/contracts/src/mail.ts` 同名)
|
||||
|
||||
|
||||
@@ -32,8 +32,10 @@
|
||||
(`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` 时退回它,全新来信两者皆无则不带线程。
|
||||
库里从没记过 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` 不幂等,重投会起两单;「重新投递」是人按的钮,那一刻起是至少一次。
|
||||
订阅是代码级装配(不落库、不回放老信)。
|
||||
@@ -51,6 +53,16 @@
|
||||
执行,幂等键随调用一起带来);`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 不等于能发,
|
||||
@@ -71,10 +83,14 @@
|
||||
|
||||
- 收信最快延迟 = `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` 清三张留痕表、留账号。
|
||||
- 部署:迁移 0033 先于启动;`reset-runs` 清三张留痕表、**留账号但清它的游标与拉取状态**
|
||||
(留着游标而信没了,wecom 那十分钟重叠窗会把最近的信当新信再投一次)。
|
||||
- 欠账见 `docs/debt.md`「邮件收发」段。
|
||||
|
||||
+23
-3
@@ -1131,9 +1131,12 @@ pnpm 硬链到全局 store,真实占盘远小于这个数,但 `du` 看着吓人
|
||||
`{ ...(state==='sent'?{error:null}:{}), ...(patch.error===undefined?{}:{error:patch.error}) }` 里 `patch.error` 排在后面,
|
||||
`setState(id,'sent',{error:'x'})` 在类型上合法且会让它赢,静默破掉「sent 的行没有错误话」这条 store 侧不变量。
|
||||
当下没有调用点传得到这个组合,只是没人踩雷;把 `patch.error` 那条挪到 `state==='sent'` 判断前面即可让它成为真不变量。
|
||||
⑨ **`parseCursor` 只查类型不查取值范围**(`cursor.ts`):`{kind:'wecom', lastTime: 1e13}` 这类脏游标能让 `cursorSummary`
|
||||
的 `new Date(lastTime*1000).toISOString()` 抛 `RangeError`,而它渲的正是账号视图 `status.cursor` 那一格 ——
|
||||
一行脏游标能让 `listAccounts` 整个 500。补一句 `Number.isSafeInteger` 校验即可封死。
|
||||
⑨ ~~**`parseCursor` 只查类型不查取值范围**(`cursor.ts`)~~(2026-08-25 终审修复轮销账):
|
||||
`{kind:'wecom', lastTime: 1e13}` 这类脏游标能让 `cursorSummary` 的 `new Date(lastTime*1000).toISOString()`
|
||||
抛 `RangeError`,而它渲的正是账号视图 `status.cursor` 那一格 —— 一行脏游标能让 `listAccounts` 整个 500。
|
||||
已修:两形的数值都要**非负安全整数且在界内**(imap 的 `lastUid` ≤ 0xFFFFFFFF,RFC 3501 的 32 位;
|
||||
wecom 的 `lastTime` ≤ 8.64e12 秒 = JS Date 的上限)—— 只写「安全整数」还不够,`1e13` 正是安全整数,
|
||||
乘 1000 才出界。不合格的当没有游标(照原注释「形状不对当没有游标,不抛」)。`cursor.test.ts` 钉住。
|
||||
⑩ **Message-ID 撞车(providerRef 是新的)留孤儿附件**:`storeOne` 先按 `provider_ref` 查重、再解析存附件、最后才 `insert`;
|
||||
若真正撞车的是 `(account_id, message_id)` 那把键(provider_ref 没撞),附件已经落进 `sys.upload` 而 `insert` 报 `inserted:false`,
|
||||
没人再引用这批附件。根治要给 `InboundStore` 加一个按 `messageId` 查重的口,插入前先挡。
|
||||
@@ -1148,3 +1151,20 @@ pnpm 硬链到全局 store,真实占盘远小于这个数,但 `du` 看着吓人
|
||||
与已经修过的「缺 `uploadRef` 当场拒」是同一族问题的更窄一种,还没堵上。
|
||||
⑮ **`JCAI_MAIL_POLL_TICK_MS` 给垃圾值会得 `NaN`**:`Number(env[...] ?? 5000)` 不校验,`setInterval(fn, NaN)` 按最小间隔跑;
|
||||
与既有的 `dispatchIntervalMs` 是同一款约定(未做输入校验),但这一条每拍要真打一次库,代价更大。
|
||||
|
||||
### 2026-08-25 终审修复轮之后新增 / 变更的几笔
|
||||
|
||||
⑯ **工具路发出的信不登记 `outbound_ref`**:`event.recordOutbound` 的唯一调用方是 `channel.sendMail`,
|
||||
而 `channel.sendMail` 在 src 里**零调用方** —— 放行落地走 `deps.tools.execute` → `mail.send` 工具,不经 channel。
|
||||
于是生产唯一的发信路径**不写** `sys.outbound_ref`:回信归属只剩②字段提取与③地址窗,①级(线程号对表)落空。
|
||||
根治要 toolbox 把 `taskId` 透进 `ToolHandler` 签名(处理器现在只拿得到 input / connection / idempotencyKey),
|
||||
再由 `mailSendTool` 在发成功后调 `event.recordOutbound` —— 那是跨模块契约变更,单独一件事。
|
||||
本轮只把文档改老实(ADR-013 决策 6、`docs/API文档-邮件收发.md` §4)。
|
||||
⑰ **`imapNeedsReanchor` 导出而无人调用**:UIDVALIDITY 变没变的判断在 `imap-smtp-provider.pull` 里内联了一份
|
||||
(`cursor.kind !== 'imap' || cursor.uidValidity !== box.uidValidity`)。两处同义、只有一处在跑 ——
|
||||
与本轮修掉的 `threadIdOf` 是同一族(导出的那个没人用,内联的那个才是实情),下一轮并成一处。
|
||||
⑱ **降级存档行不自动分发,而「重新投递」会把降级正文投给通道**:解析不动的那一封落的是骨架行
|
||||
(正文是失败原因,不是客户说的话),自动路径跳过它是对的;但人按「重新投递」时基座不拦 ——
|
||||
下游会按一段「【降级存档】…」的正文起单。要么在重投前提醒,要么给这类行一个标(库里现在只能靠正文认)。
|
||||
⑲ **`SEND_STALE_MS` 与 `claimForResend` 的 5 分钟是两处各写一份**:用例算 `staleBefore = now − SEND_STALE_MS` 传进去,
|
||||
store 只按传进来的时刻判 —— 目前只有一个调用方所以对得上,但这个数没有一处共同的定义点。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 邮件收发模块(`mail`)—— 后端设计
|
||||
|
||||
- 日期:2026-08-25
|
||||
- 状态:设计已通过(用户 2026-08-25 裁决),待实施(实施计划另出)
|
||||
- 状态:设计已通过(用户 2026-08-25 裁决),**已实施** —— 落地以 `docs/adr/ADR-013-mail-gateway.md` 与代码为准
|
||||
- 范围:**只做后端**(apps/api + packages/contracts + db + 文档);web 一个文件不动,
|
||||
前端按本轮产出的《前端对接说明-邮件收发》另做
|
||||
- 依据(官方文档,核实日期 2026-08-25):
|
||||
@@ -17,6 +17,14 @@
|
||||
- 全局错误码 / 访问频率限制:<https://developer.work.weixin.qq.com/document/path/90313> / <https://developer.work.weixin.qq.com/document/path/90312>
|
||||
- 老版企业邮箱开放接口(`api.exmail.qq.com`):<https://exmail.qq.com/qy_mng_logic/doc>
|
||||
|
||||
> **这份稿子里有三处已被 ADR-013 取代,别照它接线**(2026-08-25 终审补记):
|
||||
> ① **§7.2 的读路由 `access`** —— 稿子把 7 条 GET 标成 `design|ops`,落地时 15 条**一律 `ops`**(ADR-013 决策 10:
|
||||
> 邮箱那一屏没有设计端消费方,入站正文是客户来往内容);
|
||||
> ② **§5.3 的 `threadId`** —— 稿子取最近一跳,落地取**这一串的根** `references[0] ?? inReplyTo`(ADR-013 决策 3);
|
||||
> ③ **§7.3 的「`body` 改名 `text`」与 `connectionKeys: []`** —— 都没有那么做:契约演进只增不破坏,
|
||||
> 2.0.0 **保留** v1 的 `body`(纯文本正文,与 `html` 二选一或并存),连接键是 **`['account']`**
|
||||
> (「从哪个信箱发」:`'default'` 或某个账号 id),不是空数组(ADR-013 决策 6)。
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
现状:`channel` 模块(L2)已有整条「入站归一 → 事件(归属解析 / 触发器起单 / 任务 `listen` 监听 /
|
||||
|
||||
Reference in New Issue
Block a user