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
209 lines
8.5 KiB
TypeScript
209 lines
8.5 KiB
TypeScript
/**
|
|
* 这一份钉的是 Task 9 评审逮出的三条 Important 与一条设计律 ——
|
|
* 它们都在拉取 / 发信 / 分发那几条**当时还没有测试**的路上。
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import { mailSendInputSchema } from '@contracts';
|
|
import { createMailService } from '../internal/usecase/mail-service.ts';
|
|
import { buildInboundRecord } from '../internal/domain/inbound-record.ts';
|
|
import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts';
|
|
import type { OutboundRecord, ParsedMail } from '../port.ts';
|
|
import { account, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, seqIds } from './fakes.ts';
|
|
|
|
function harness() {
|
|
const clock = fixedClock();
|
|
const accounts = memoryAccounts(clock);
|
|
const inbound = memoryInbound();
|
|
const outbound = memoryOutbound();
|
|
const imap = fakeProvider('imap-smtp');
|
|
const wecom = fakeProvider('wecom');
|
|
const changes: string[] = [];
|
|
const service = createMailService({
|
|
accounts,
|
|
inbound,
|
|
outbound,
|
|
attachments: memoryAttachments(),
|
|
providers: { 'imap-smtp': imap, wecom },
|
|
mime: createPostalMimeParser(),
|
|
secrets: plainSecrets,
|
|
clock,
|
|
ids: seqIds('out'),
|
|
notify: { changed: (r) => void changes.push(r) },
|
|
});
|
|
return { service, accounts, inbound, outbound, imap, wecom, clock, changes };
|
|
}
|
|
|
|
const sendInput = mailSendInputSchema.parse({ to: ['buyer@acme.com'], subject: '报价', text: '见附', requestedBy: { kind: 'ops' } });
|
|
|
|
function outboundRow(over: Partial<OutboundRecord> = {}): OutboundRecord {
|
|
return {
|
|
id: 'out-9',
|
|
accountId: 'acc-1',
|
|
messageId: '<out-9@we.com>',
|
|
idempotencyKey: 'k-1',
|
|
to: ['buyer@acme.com'],
|
|
cc: [],
|
|
bcc: [],
|
|
subject: '报价',
|
|
text: '见附',
|
|
html: null,
|
|
inReplyTo: null,
|
|
references: [],
|
|
attachments: [],
|
|
state: 'sending',
|
|
error: null,
|
|
providerRef: null,
|
|
requestedBy: { kind: 'ops' },
|
|
createdAt: new Date('2026-08-25T10:00:00Z'),
|
|
sentAt: null,
|
|
...over,
|
|
};
|
|
}
|
|
|
|
describe('凭证解不开', () => {
|
|
it('密文解得开但不是 JSON:错误话里没有明文,只说「未配置凭证」', async () => {
|
|
const { service, accounts, imap } = harness();
|
|
// 解得开(plainSecrets 会回出 `pw-1234`),但那不是 JSON —— JSON.parse 的 SyntaxError 把原文嵌在 message 里
|
|
await accounts.save(account({ secretCipher: 'sealed:pw-1234' }));
|
|
const outcome = await service.pollNow('acc-1');
|
|
expect(outcome.error).not.toContain('pw-1234');
|
|
expect(outcome.error).toContain('未配置凭证');
|
|
expect(accounts.rows.get('acc-1')?.lastPollError).not.toContain('pw-1234');
|
|
expect(accounts.rows.get('acc-1')?.lastPollError).toContain('未配置凭证');
|
|
// 凭证都没打开,一次都不该去够厂商
|
|
expect(imap.pullCalls).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('幂等键', () => {
|
|
it('同键的上一封还在飞:不发第二封,如实回没发出去', async () => {
|
|
const { service, accounts, outbound, imap, clock } = harness();
|
|
await accounts.save(account());
|
|
await outbound.insert(outboundRow({ createdAt: clock.now() }));
|
|
const result = await service.send({ ...sendInput, idempotencyKey: 'k-1' });
|
|
expect(result.state).toBe('failed');
|
|
expect(result.error).toContain('仍在发送中');
|
|
expect(imap.sent).toHaveLength(0);
|
|
expect(outbound.rows.size).toBe(1);
|
|
expect(outbound.rows.get('out-9')?.state).toBe('sending');
|
|
});
|
|
|
|
it('同键的 sending 停太久:当上次崩在半路,复用那一行重发', async () => {
|
|
const { service, accounts, outbound, imap, clock } = harness();
|
|
await accounts.save(account());
|
|
await outbound.insert(outboundRow({ createdAt: new Date(clock.now().getTime() - 6 * 60_000) }));
|
|
const result = await service.send({ ...sendInput, idempotencyKey: 'k-1' });
|
|
expect(result.state).toBe('sent');
|
|
expect(imap.sent).toHaveLength(1);
|
|
expect(outbound.rows.get('out-9')?.state).toBe('sent');
|
|
// 复用,不是新开一行
|
|
expect(outbound.rows.size).toBe(1);
|
|
expect(result.outboundId).toBe('out-9');
|
|
});
|
|
|
|
it('**并发复用要抢**:同键两路同时重发,真信只出一封,另一路如实回没发出去', async () => {
|
|
/*
|
|
* 本仓是两个后端连同一个库:两台同时重放同一次放行,都会读到同一行 `failed`、都判「可以复用」。
|
|
* 没有那一句条件更新的话,两台都发 —— **两封真邮件**,直接破掉契约白纸黑字的「同键不发两封」。
|
|
*/
|
|
const { service, accounts, outbound, imap } = harness();
|
|
await accounts.save(account());
|
|
await outbound.insert(outboundRow({ state: 'failed', error: '上一次没发出去' }));
|
|
|
|
const [a, b] = await Promise.all([
|
|
service.send({ ...sendInput, idempotencyKey: 'k-1' }),
|
|
service.send({ ...sendInput, idempotencyKey: 'k-1' }),
|
|
]);
|
|
expect(imap.sent).toHaveLength(1);
|
|
const states = [a.state, b.state].sort();
|
|
expect(states).toEqual(['failed', 'sent']);
|
|
const loser = a.state === 'failed' ? a : b;
|
|
expect(loser.error).toContain('本次未重复发送');
|
|
expect(loser.outboundId).toBe('out-9');
|
|
// 抢输的那一路一个字都没往外发,也没新开一行
|
|
expect(outbound.rows.size).toBe(1);
|
|
expect(outbound.rows.get('out-9')?.state).toBe('sent');
|
|
});
|
|
|
|
it('**超时 sending 的复用同样要抢**:抢不到就走「未重复发送」那一支', async () => {
|
|
const { service, accounts, outbound, imap, clock } = harness();
|
|
await accounts.save(account());
|
|
await outbound.insert(outboundRow({ createdAt: new Date(clock.now().getTime() - 6 * 60_000) }));
|
|
const [a, b] = await Promise.all([
|
|
service.send({ ...sendInput, idempotencyKey: 'k-1' }),
|
|
service.send({ ...sendInput, idempotencyKey: 'k-1' }),
|
|
]);
|
|
expect(imap.sent).toHaveLength(1);
|
|
expect([a.state, b.state].sort()).toEqual(['failed', 'sent']);
|
|
});
|
|
|
|
it('失败重发成功之后,那一行不留上一次的错误话', async () => {
|
|
const { service, accounts, outbound, imap } = harness();
|
|
await accounts.save(account());
|
|
imap.failSendWith(new Error('暂时不行'));
|
|
const first = await service.send({ ...sendInput, idempotencyKey: 'k-1' });
|
|
expect(first.state).toBe('failed');
|
|
expect(outbound.rows.get(first.outboundId)?.error).toContain('暂时不行');
|
|
|
|
imap.failSendWith(undefined);
|
|
const second = await service.send({ ...sendInput, idempotencyKey: 'k-1' });
|
|
expect(second.state).toBe('sent');
|
|
expect(outbound.rows.get(first.outboundId)?.error).toBeNull();
|
|
expect((await service.listOutbound({}))[0]?.error).toBeNull();
|
|
|
|
// 第三次同键走 resultOf(prior):既说成功又带错误的结果会让调用方按 error 分支判反
|
|
const third = await service.send({ ...sendInput, idempotencyKey: 'k-1' });
|
|
expect(third.state).toBe('sent');
|
|
expect(third.error).toBeUndefined();
|
|
expect(imap.sent).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('卡死的 dispatching', () => {
|
|
const parsed: ParsedMail = {
|
|
messageId: '<m-1@acme.com>',
|
|
references: [],
|
|
from: { address: 'buyer@acme.com', name: '买家' },
|
|
to: [{ address: 'sales@we.com', name: '销售' }],
|
|
cc: [],
|
|
subject: '询价',
|
|
text: '请报价',
|
|
attachments: [],
|
|
};
|
|
|
|
it('自动路径不进(墙),人按的重新投递能接管(路)', async () => {
|
|
const { service, accounts, inbound, clock } = harness();
|
|
await accounts.save(account());
|
|
await inbound.insert(
|
|
buildInboundRecord({
|
|
id: 'in-1',
|
|
accountId: 'acc-1',
|
|
address: 'sales@we.com',
|
|
providerRef: 'uid-1',
|
|
parsed,
|
|
eml: new Uint8Array(),
|
|
receivedAt: clock.now(),
|
|
attachments: [],
|
|
now: clock.now(),
|
|
}),
|
|
);
|
|
let calls = 0;
|
|
service.subscribe('channel', async () => {
|
|
calls += 1;
|
|
});
|
|
|
|
// 进程崩在写 dispatching 与写结果之间:这条留痕永远停在 dispatching
|
|
const stuck = { mailId: 'in-1', subscriber: 'channel', state: 'dispatching' as const, error: null, finishedAt: null };
|
|
await inbound.setDelivery({ ...stuck, startedAt: new Date(clock.now().getTime() - 11 * 60_000) });
|
|
const reclaimed = await service.redeliver('in-1');
|
|
expect(reclaimed[0]?.state).toBe('done');
|
|
expect(calls).toBe(1);
|
|
|
|
// 新鲜的那条仍是墙:人按也不抢,免得两条线同时改一件事
|
|
await inbound.setDelivery({ ...stuck, startedAt: clock.now() });
|
|
const skipped = await service.redeliver('in-1');
|
|
expect(skipped[0]?.state).toBe('dispatching');
|
|
expect(calls).toBe(1);
|
|
});
|
|
});
|