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:
@@ -35,6 +35,22 @@
|
||||
不变量(ADR-013):
|
||||
- **入站先持久后分发**:`sys.mail_inbound` 两把唯一键 `(account_id, message_id)` / `(account_id, provider_ref)`,
|
||||
重复拉到一律 `inserted:false` 静默跳过、不分发;游标逐封(IMAP)/ 逐批(wecom)推进,失败停在最后一封成功处
|
||||
- **一封解析不动的信不许卡死整条路**(2026-08-25 终审):落 text/jsonb 的字段一律剥 NUL(`stripNul`);
|
||||
单封 `storeOne` 抛错时先落**降级骨架**(原件原样存、主题从原始字节扒、正文写明失败原因)——
|
||||
落成了就推进游标并把「第 N 封(providerRef)解析失败,已降级存档」写进 `last_poll_error`;
|
||||
骨架也落不下才停在这一封,那时 `last_poll_error` **必须点名是哪一封**。降级行**不自动分发**
|
||||
(正文是失败原因不是客户的话),人可按「重新投递」
|
||||
- **线程取根**:`threadIdOf` = `references[0] ?? inReplyTo`(ADR-013 决策 3),全仓唯一口径 ——
|
||||
app 的 `mail-wiring.toInboundMail` 调它,不另抄一份
|
||||
- **provider 的 `note` 要上界面**:`pull` 回的 `note`(当前唯一一种:UIDVALIDITY 变化已重锚、期间来信未拉取)
|
||||
落进 `last_poll_error`;丢了一段信而账号页一片绿是界面撒谎
|
||||
- **读取分两档**:`list` / `getMeta` **不取 `raw` 原件字节**(一封上限 16MB),`rawAvailable` 由
|
||||
`raw is not null` 投影;只有「下载原件」那一条路走全列的 `get`
|
||||
- **账号的配置列与运行列分家**:`save()` 只写配置列;运行列(cursor / last_polled_at / last_poll_error /
|
||||
last_inbound_at / polling_until)只经 `savePoll` / `claimPolling` / `releasePolling` 改 ——
|
||||
整行 upsert 会把另一台正持着的租约与刚推进的游标盖回读那一刻的快照。清游标是显式的 `savePoll({cursor:null})`
|
||||
- **重锚只看收侧**:`cursorReset` 只比 kind 与收侧 settings 键(imap-smtp:`imapHost`/`imapPort`/`imapTls`/
|
||||
`login`/`folder`;wecom:`corpId`/`apiBase`)—— 改 SMTP 端口不该把来信丢掉
|
||||
- **分发自动只投一次**:先落 `dispatching` 再调,`done / skipped / failed(原话脱敏)`;不自动重试 ——
|
||||
channel → event 按 `cause.ref` 不幂等,重投会起两单;「重新投递」是人按的钮,那一刻起是至少一次;
|
||||
一个订阅方失败不影响其它;订阅是代码级装配,不落库、不回放老信
|
||||
@@ -42,6 +58,9 @@
|
||||
- **跨进程租约**:`polling_until` 一条 SQL 判 + 占,过期 max(5min, 3×间隔);抢不到 → `MailPollBusyError`
|
||||
- **出站先落行**:`sending` → provider → `sent` / `failed(原话)`;带 `idempotencyKey` 且已 `sent` 的不发第二封;
|
||||
厂商失败**不抛**,以 `state='failed'` 返回(调用方各自翻译)
|
||||
- **复用旧行重发要先抢**:`OutboundStore.claimForResend` 一条条件更新(`failed`、或停够 5 分钟的 `sending`
|
||||
才抢得到;抢到即置 `sending` 并把 `created_at` 推到 now)—— 抢到才发,抢不到回「本次未重复发送」。
|
||||
两个后端连同一个库时,这一句是「同键不发两封」唯一的墙
|
||||
- **凭证密封**:`secret_cipher` 永不出接口,视图只回 `secretBound`;更新时 secret 缺省 = 不改;
|
||||
明文只在 usecase 打开、交给 provider 那一段;所有落库的厂商原话先过 `redactSecrets`
|
||||
- **界面不撒谎**:探测收 / 发分栏各报;wecom 的发信一栏 `checked:false`(拿到 token 不等于能发);
|
||||
@@ -50,4 +69,8 @@
|
||||
(回放读 `sys.mail_inbound`,永不重拉);活动侧出站经 `ToolInvoker`(`mail.send` 工具),系统侧留痕
|
||||
`sys.mail_outbound`;`createFakeMailProvider` 是测试替身
|
||||
|
||||
影响半径: 新模块;channel / toolbox / event 的 index / contract / port 未变
|
||||
影响半径: 新模块;channel / toolbox / event 的 index / contract / port 未变。
|
||||
2026-08-25 终审修复轮改了 `port.ts`:`InboundStore` 新增 `getMeta`(list / getMeta 不再取 raw)、
|
||||
`OutboundStore` 新增 `claimForResend`、`MailAccountStore.savePoll` 的 `cursor` 允许显式 `null`、
|
||||
`MailProvider.pull` 回参可带 `note` —— 端口的实现方只有本模块的 adapter 与 `__tests__/fakes.ts`
|
||||
的内存件(两边语义必须一致),app 侧装配未变
|
||||
|
||||
Reference in New Issue
Block a user