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:
@@ -69,7 +69,10 @@ describe('账号路由', () => {
|
||||
expect(await (await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).json()).toMatchObject({ pulled: 1, stored: 1 });
|
||||
await h.accounts.save(account({ enabled: false }));
|
||||
expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).status).toBe(412);
|
||||
await h.accounts.save(account({ pollingUntil: new Date('2027-01-01T00:00:00Z') }));
|
||||
// 租约是**运行列**:`save()` 只写配置列(整行 upsert 会盖掉另一台正持着的租约),
|
||||
// 所以按真路径占它 —— claimPolling 就是另一台机器那一下
|
||||
await h.accounts.save(account());
|
||||
expect(await h.accounts.claimPolling('acc-1', 60_000)).toBe(true);
|
||||
expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).status).toBe(409);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MailInboundDetail, MailSendInput, MailSendResult } from '@contracts';
|
||||
import type { InboundMail } from '@contracts';
|
||||
import { auditPassed, checkEvolution, staticAudit } from '@modules/toolbox';
|
||||
import type { ToolContract } from '@modules/toolbox';
|
||||
import { threadIdOf } from '@modules/mail';
|
||||
import { channelSubscriber, channelTransport, mailSendTool, toInboundMail } from '../mail-wiring.ts';
|
||||
import { MAIL_TOOL } from '../seed-mvp.ts';
|
||||
|
||||
const detail: MailInboundDetail = {
|
||||
id: 'in-1',
|
||||
@@ -53,6 +57,17 @@ describe('toInboundMail', () => {
|
||||
expect(mail.attachments).toEqual([{ name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1' }]);
|
||||
expect(mail.receivedAt).toBe('2026-08-25T09:30:00.000Z');
|
||||
});
|
||||
|
||||
it('线程口径只有一处:这里就是 mail 模块的 threadIdOf,不是另抄的一份', () => {
|
||||
// 从前这里内联了一份同义实现,而模块导出的 threadIdOf 取的是**最近一跳** ——
|
||||
// 同一个模块两套相反的语义,下一个人照导出的那个接线就会接反
|
||||
expect(toInboundMail(detail).threadId).toBe(threadIdOf(detail));
|
||||
const onlyHop: MailInboundDetail = { ...detail, references: [], inReplyTo: '<q1@we.com>' };
|
||||
expect(toInboundMail(onlyHop).threadId).toBe(threadIdOf(onlyHop));
|
||||
expect(toInboundMail(onlyHop).threadId).toBe('<q1@we.com>');
|
||||
const none: MailInboundDetail = { ...detail, references: [], inReplyTo: null };
|
||||
expect(toInboundMail(none).threadId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('channelSubscriber', () => {
|
||||
@@ -122,3 +137,36 @@ describe('mailSendTool', () => {
|
||||
expect(invalid.error?.code).toBe('invalid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mail.send 2.0.0 的演进', () => {
|
||||
/*
|
||||
* **对真契约断言,不对手抄的夹具**。上一版这里的 v1/v2 都是照着 `seed-mvp.ts` 手抄的两份 JSON:
|
||||
* 改真的那一份它不会红(自证),而且抄的 v1 版本号还写成了 `1.0.0` ——
|
||||
* 真前一版是 `1.1.0`(MAIL_TOOL 展开 HISTORY_TOOL,版本跟着它)。
|
||||
* v1 是从 git 里那一版逐字取回来的**历史快照**(只有它必须手写:那份代码已经不在仓里了)。
|
||||
*/
|
||||
const v1: ToolContract = {
|
||||
...MAIL_TOOL,
|
||||
version: '1.1.0',
|
||||
description: '发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界',
|
||||
input: { type: 'object', required: ['to', 'subject'], properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' } } },
|
||||
output: { type: 'object', properties: { queued: { type: 'boolean' } } },
|
||||
connectionKeys: ['endpoint'],
|
||||
};
|
||||
|
||||
it('真契约就是 2.0.0,且过得了静态审', () => {
|
||||
expect(MAIL_TOOL.name).toBe('mail.send');
|
||||
expect(MAIL_TOOL.version).toBe('2.0.0');
|
||||
expect(auditPassed(staticAudit(MAIL_TOOL))).toBe(true);
|
||||
});
|
||||
|
||||
it('保留 v1 的 body / queued、不新增必填 —— 演进检查零违规', () => {
|
||||
expect(checkEvolution(v1, MAIL_TOOL)).toEqual([]);
|
||||
// 反证这条断言真的在看东西:真把 v1 的 body 删掉就该报违规
|
||||
const dropped: ToolContract = {
|
||||
...MAIL_TOOL,
|
||||
input: { type: 'object', required: ['to', 'subject'], properties: { to: { type: 'array' }, subject: { type: 'string' } } },
|
||||
};
|
||||
expect(checkEvolution(v1, dropped).map((f) => f.field)).toContain('input.body');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,21 +15,19 @@ import {
|
||||
MailNoDefaultAccountError,
|
||||
MailSecretMissingError,
|
||||
MailSendInvalidError,
|
||||
threadIdOf,
|
||||
} from '@modules/mail';
|
||||
import type { ToolHandler } from '@modules/toolbox';
|
||||
|
||||
/**
|
||||
* mail 的入站详情 → channel 的 InboundMail:只带存下来的附件。
|
||||
*
|
||||
* **线程取的是根,不是最近一跳**:我方出站登记的 threadId 恒是这一串的**首封**
|
||||
* (`channelTransport` 回的是 `input.threadId ?? messageId`,channel 据此 `recordOutbound`),
|
||||
* 而归属解析①级是精确等值比对。若这里取 `inReplyTo`(最近一跳),跑一个来回就漂:
|
||||
* 我方 M1 → 客户回 M1(命中)→ 我方再回(实际 M2、仍记在 M1 名下)→ 客户回 M2 →
|
||||
* 取到 M2,而库里从没记过 M2 ⇒ ①级落空,退到③地址+时间窗。
|
||||
* `references[0]` 才稳定:客户端续链时首项恒是首封;客户端只给 In-Reply-To 时退回它。
|
||||
* **线程口径只有一处**:`threadIdOf`(mail 模块的 domain,取这一串的根;缘由与反例见那里)。
|
||||
* 这里曾经内联过一份同义实现,而模块里那一份当时取的是最近一跳 —— 同一个模块两套相反的语义,
|
||||
* 下一个人照导出的那个接线就会接反。现在调它。
|
||||
*/
|
||||
export function toInboundMail(mail: MailInboundDetail): InboundMail {
|
||||
const threadId = mail.references[0] ?? mail.inReplyTo ?? undefined;
|
||||
const threadId = threadIdOf(mail);
|
||||
return {
|
||||
messageId: mail.messageId,
|
||||
...(threadId === undefined ? {} : { threadId }),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*
|
||||
* **清的是「跑出来的」**:任务、工作流、卡、任务记忆、事件、监听、台账行、活动记忆…
|
||||
* **留的是「配置好的」**:员工与岗位授权、会话、工作组、流程包、活动库、工具、
|
||||
* 触发器、通道、模型实例、纪要。
|
||||
* 触发器、通道、模型实例、纪要、**邮箱账号**(它身上的游标与拉取状态是运行痕,跟着清)。
|
||||
*
|
||||
* 直接走 SQL 而不绕模块:模块出口里没有、也不该有「删掉所有运行痕迹」这种动作 ——
|
||||
* 留痕只增不改是系统的规矩,清库是**开发期的外科手术**,不是系统的一项能力。
|
||||
@@ -89,6 +89,17 @@ export async function resetRuns(): Promise<void> {
|
||||
);
|
||||
const all = [...RUN_TABLES, ...ledger.rows.map((r) => r.name)];
|
||||
await pool.query(`truncate table ${all.join(', ')} restart identity cascade`);
|
||||
/*
|
||||
* 邮件账号是**配置**(留着),但它身上挂着的**游标是运行痕** —— 清了 `sys.mail_inbound`
|
||||
* 却留着游标,下一轮就从游标处往后拉:wecom 那十分钟重叠窗会把最近的信当新信**再投一次**
|
||||
* (库里已经没有那一行了,唯一键挡不住),可能凭空再起一单。
|
||||
* 游标清空 = 下一轮重锚在当下、不回填,与「第一次配上账号」同一个起点。
|
||||
* 上次拉取 / 错误 / 最近来信也一并清:它们说的都是刚被清掉的那些信。
|
||||
*/
|
||||
await pool.query(
|
||||
`update sys.mail_account
|
||||
set cursor = null, last_polled_at = null, last_poll_error = null, last_inbound_at = null, polling_until = null`,
|
||||
);
|
||||
const kept = await pool.query<{ n: string }>(
|
||||
`select count(*)::text as n from ledger.__object_meta where kind = 'master'`,
|
||||
);
|
||||
|
||||
@@ -56,7 +56,7 @@ const HISTORY_TOOL = {
|
||||
* `connectionKeys: ['account']` —— 从哪个信箱发:'default' = 默认账号,或某个账号 id。
|
||||
* `dryRun`:注册的试运行会**真调处理器**,样例带 dryRun 只校验不发。
|
||||
*/
|
||||
const MAIL_TOOL = {
|
||||
export const MAIL_TOOL = {
|
||||
...HISTORY_TOOL,
|
||||
name: 'mail.send',
|
||||
displayName: '发送邮件',
|
||||
|
||||
Reference in New Issue
Block a user