邮件收发终审修复轮: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:
xcs
2026-08-26 03:47:48 +08:00
co-authored by Claude Fable 5
parent 18ca6dacfe
commit 816389a185
30 changed files with 1105 additions and 114 deletions
+41 -3
View File
@@ -20,7 +20,10 @@ export type MailAccountStore = {
list(): Promise<MailAccountRecord[]>;
get(id: string): Promise<MailAccountRecord | undefined>;
findByName(name: string): Promise<MailAccountRecord | undefined>;
/** upsert;`isDefault:true` 时先把别的置 false(同一事务) */
/**
* upsert **只写配置列**;`isDefault:true` 时先把别的置 false(同一事务)。
* 运行列(cursor / lastPolledAt / lastPollError / lastInboundAt / pollingUntil)一概不动 —— 见 `savePoll`。
*/
save(record: MailAccountRecord): Promise<void>;
remove(id: string): Promise<void>;
/** enabled 且(从没拉过 或 last_polled_at + interval <= now)且租约空 / 过期 */
@@ -28,9 +31,16 @@ export type MailAccountStore = {
/** 一条 SQL 判 + 占(设计稿 §9.1);抢到 true */
claimPolling(id: string, leaseMs: number): Promise<boolean>;
releasePolling(id: string): Promise<void>;
/** 拉取过程的进度:游标 / 时刻 / 错误 / 最近来信 / 探到的地址;行不存在 = no-op */
/**
* 拉取过程的进度:游标 / 时刻 / 错误 / 最近来信 / 探到的地址;行不存在 = no-op。
*
* **运行列只走这条路**(与 `claimPolling` / `releasePolling` 一起)——`save()` 一个字都不碰它们:
* 运营面 PUT 的同时另一台正拉着,整行 upsert 会把租约与游标一并覆盖回读那一刻的快照
* (`sys.loop_state` 整份覆盖那一课的同款指纹)。
* `cursor: null` 是**显式清游标**(改了收侧配置要重锚时用)。
*/
savePoll(id: string, patch: {
cursor?: MailCursor;
cursor?: MailCursor | null;
polledAt?: Date;
error?: string | null;
lastInboundAt?: Date;
@@ -43,6 +53,10 @@ export type InboundStore = {
inserted: boolean;
}>;
knownProviderRefs(accountId: string, refs: readonly string[]): Promise<Set<string>>;
/**
* 列表 —— **不取 `raw` 原件字节**(一封上限 16MB,50 行就是几百 MB,只为算 200 字预览);
* `rawAvailable` 由 `raw is not null` 投影出来,不靠把字节搬回来再判。
*/
list(query: {
accountId?: string | undefined;
limit: number;
@@ -50,6 +64,12 @@ export type InboundStore = {
record: InboundRecord;
deliveries: DeliveryRecord[];
}>>;
/** 只要元信息的读(分发 / 详情 / 附件 / 重投都走它)—— 同样不取 `raw` 字节 */
getMeta(mailId: string): Promise<{
record: InboundRecord;
deliveries: DeliveryRecord[];
} | undefined>;
/** 全列读 —— 只有「下载原件」那一条路要它 */
get(mailId: string): Promise<{
record: InboundRecord;
deliveries: DeliveryRecord[];
@@ -60,6 +80,18 @@ export type InboundStore = {
export type OutboundStore = {
insert(record: OutboundRecord): Promise<void>;
byIdempotencyKey(key: string): Promise<OutboundRecord | undefined>;
/**
* 复用旧行重发之前**先抢**(一条条件更新,抢到才回 true):
* `failed` 的行、或 `created_at <= staleBefore` 的 `sending` 行才抢得到;抢到即置 `sending` 并把
* `created_at` 推到 `now` —— 这一手让那一行**离开可抢的集合**,第二个人当场抢不到。
*
* 本仓是两个后端连同一个库:没有这一抢,两台同时重放同一次放行都会判「超时可复用」,
* 发出**两封真邮件**,直接破掉契约白纸黑字的「同键不发两封」。
*/
claimForResend(id: string, opts: {
readonly staleBefore: Date;
readonly now: Date;
}): Promise<boolean>;
setState(id: string, state: 'sent' | 'failed', patch: {
error?: string;
providerRef?: string;
@@ -108,6 +140,11 @@ export type MailProvider = {
probe(account: ProviderAccount): Promise<MailProbeReport>;
/** 无游标时锚定初始游标(不回填;设了 backfillFrom 才从那天起) */
anchor(account: ProviderAccount, backfillFrom: Date | null, now: Date): Promise<MailCursor>;
/**
* 拉一批。回参的 `note` 是这一轮**不算失败、但人必须知道**的事
* (当前唯一一种:IMAP 的 UIDVALIDITY 变了、已重锚在当下、期间来信不回填)——
* 用例把它落进 `last_poll_error`。丢了一段信而界面一片绿,那是界面撒谎。
*/
pull(account: ProviderAccount, cursor: MailCursor, opts: {
readonly limit: number;
readonly now: Date;
@@ -118,6 +155,7 @@ export type MailProvider = {
messages: RawMessage[];
cursor: MailCursor;
more: boolean;
note?: string | undefined;
}>;
send(account: ProviderAccount, message: OutboundMessage): Promise<{
providerRef?: string | undefined;