Archived
全分支终审(跨任务视角)逮出的十条 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
91 lines
4.2 KiB
TypeScript
91 lines
4.2 KiB
TypeScript
/** 账号记录 ↔ 入参 / 视图(设计稿 §5.3);视图经 schema 过一遍,密文进不来 */
|
||
import type { MailAccountInput, MailAccountKind, MailAccountView } from '@contracts';
|
||
import { mailAccountViewSchema } from '@contracts';
|
||
import { cursorSummary } from './cursor.ts';
|
||
import { isPolling } from './due.ts';
|
||
import type { MailAccountRecord } from './records.ts';
|
||
|
||
export function viewOf(record: MailAccountRecord, now: Date): MailAccountView {
|
||
return mailAccountViewSchema.parse({
|
||
id: record.id,
|
||
kind: record.kind,
|
||
name: record.name,
|
||
address: record.address,
|
||
displayName: record.displayName,
|
||
enabled: record.enabled,
|
||
isDefault: record.isDefault,
|
||
pollIntervalSec: record.pollIntervalSec,
|
||
channelId: record.channelId,
|
||
backfillFrom: record.backfillFrom === null ? null : record.backfillFrom.toISOString(),
|
||
settings: record.settings,
|
||
secretBound: record.secretCipher !== null && record.secretCipher !== '',
|
||
status: {
|
||
lastPolledAt: record.lastPolledAt === null ? null : record.lastPolledAt.toISOString(),
|
||
lastPollError: record.lastPollError,
|
||
lastInboundAt: record.lastInboundAt === null ? null : record.lastInboundAt.toISOString(),
|
||
cursor: cursorSummary(record.cursor),
|
||
polling: isPolling(record.pollingUntil, now),
|
||
},
|
||
createdAt: record.createdAt.toISOString(),
|
||
updatedAt: record.updatedAt.toISOString(),
|
||
});
|
||
}
|
||
|
||
/** 入参里的凭证 → 待密封的 JSON;没给就是 undefined(= 不改) */
|
||
export function secretOf(input: MailAccountInput): string | undefined {
|
||
return input.secret === undefined ? undefined : JSON.stringify(input.secret);
|
||
}
|
||
|
||
/**
|
||
* **只有收侧的键动了才谈得上重锚** —— 游标说的是「这个信箱 / 这个文件夹我读到哪儿了」。
|
||
*
|
||
* 从前比的是全部 settings 键,连 `smtpHost` / `smtpPort` / `smtpTls` 都算:
|
||
* 人把 465 改成 587(纯发信的事)→ 游标清空 → IMAP 重锚 `lastUid = uidNext − 1` →
|
||
* 上次游标到重锚之间到达的信**永远不拉**(设计即「不回填」)。**改发信的设置不该丢来信**。
|
||
* `login` 两侧都用,算收侧(换了登录名就是换了一个信箱在读)。
|
||
*/
|
||
const RECEIVE_KEYS: Readonly<Record<MailAccountKind, readonly string[]>> = {
|
||
'imap-smtp': ['imapHost', 'imapPort', 'imapTls', 'login', 'folder'],
|
||
wecom: ['corpId', 'apiBase'],
|
||
};
|
||
|
||
function sameReceiveSettings(kind: MailAccountKind, a: Record<string, unknown>, b: Record<string, unknown>): boolean {
|
||
return RECEIVE_KEYS[kind].every((k) => JSON.stringify(a[k] ?? null) === JSON.stringify(b[k] ?? null));
|
||
}
|
||
|
||
/** kind 或**收侧** settings 变了,旧游标指的就不是同一个信箱 / 同一个文件夹 —— 清空重锚 */
|
||
export function cursorReset(existing: MailAccountRecord | undefined, input: MailAccountInput): boolean {
|
||
if (existing === undefined) return false;
|
||
return existing.kind !== input.kind || !sameReceiveSettings(input.kind, existing.settings, input.settings);
|
||
}
|
||
|
||
export function mergeAccount(
|
||
input: MailAccountInput,
|
||
base: { readonly id: string; readonly existing: MailAccountRecord | undefined; readonly secretCipher: string | null; readonly now: Date },
|
||
): MailAccountRecord {
|
||
const reset = cursorReset(base.existing, input);
|
||
const existing = base.existing;
|
||
return {
|
||
id: base.id,
|
||
kind: input.kind,
|
||
name: input.name,
|
||
// wecom 的地址由探测回填,人不填;换成 imap-smtp 时以填的为准
|
||
address: input.kind === 'imap-smtp' ? input.address : existing?.kind === 'wecom' ? existing.address : '',
|
||
displayName: input.displayName,
|
||
enabled: input.enabled,
|
||
isDefault: input.isDefault,
|
||
pollIntervalSec: input.pollIntervalSec,
|
||
channelId: input.channelId,
|
||
backfillFrom: input.backfillFrom === null ? null : new Date(input.backfillFrom),
|
||
settings: { ...input.settings },
|
||
secretCipher: base.secretCipher,
|
||
cursor: reset ? null : (existing?.cursor ?? null),
|
||
lastPolledAt: existing?.lastPolledAt ?? null,
|
||
lastPollError: reset ? null : (existing?.lastPollError ?? null),
|
||
lastInboundAt: existing?.lastInboundAt ?? null,
|
||
pollingUntil: existing?.pollingUntil ?? null,
|
||
createdAt: existing?.createdAt ?? base.now,
|
||
updatedAt: base.now,
|
||
};
|
||
}
|