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
120 lines
7.3 KiB
TypeScript
120 lines
7.3 KiB
TypeScript
/**
|
|
* 邮件路由:zod 校验 → 用例 → 领域错误翻状态码;厂商失败在 200 的 body 里如实说。
|
|
* 不挂鉴权中间件(归属由路由覆盖测试与注册表测试兜);用例是真的,store 是内存的,provider 是假的。
|
|
*/
|
|
import { Hono } from 'hono';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { createMailService, createPostalMimeParser } from '@modules/mail';
|
|
import type { AppEnv } from '../http-env.ts';
|
|
import { registerMailRoutes } from '../routes/mail.ts';
|
|
import { account, eml, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, rawMessage, seqIds } from '../../modules/mail/__tests__/fakes.ts';
|
|
|
|
function harness() {
|
|
const clock = fixedClock();
|
|
const accounts = memoryAccounts(clock);
|
|
const imap = fakeProvider('imap-smtp');
|
|
const mail = createMailService({
|
|
accounts,
|
|
inbound: memoryInbound(),
|
|
outbound: memoryOutbound(),
|
|
attachments: memoryAttachments(),
|
|
providers: { 'imap-smtp': imap, wecom: fakeProvider('wecom') },
|
|
mime: createPostalMimeParser(),
|
|
secrets: plainSecrets,
|
|
clock,
|
|
ids: seqIds('x'),
|
|
notify: { changed: () => undefined },
|
|
});
|
|
const app = new Hono<AppEnv>();
|
|
registerMailRoutes(app, { mail });
|
|
const json = (method: string, path: string, body?: unknown) =>
|
|
app.request(path, { method, headers: { 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
return { app, mail, accounts, imap, json };
|
|
}
|
|
const input = { kind: 'imap-smtp', name: '销售', address: 'sales@we.com', isDefault: true, secret: { password: 'pw-1234' } };
|
|
|
|
describe('账号路由', () => {
|
|
it('列表 / 建 / 取 / 改 / 删;400 / 404 / 409', async () => {
|
|
const h = harness();
|
|
expect(await (await h.json('GET', '/api/gov/mail/accounts')).json()).toEqual({ accounts: [] });
|
|
expect((await h.json('POST', '/api/gov/mail/accounts', { kind: 'imap-smtp' })).status).toBe(400);
|
|
const created = await h.json('POST', '/api/gov/mail/accounts', input);
|
|
expect(created.status).toBe(200);
|
|
const view = (await created.json()) as { id: string; secretBound: boolean };
|
|
expect(view.secretBound).toBe(true);
|
|
expect(JSON.stringify(view)).not.toContain('pw-1234');
|
|
expect((await h.json('POST', '/api/gov/mail/accounts', input)).status).toBe(409);
|
|
expect((await h.json('GET', `/api/gov/mail/accounts/${view.id}`)).status).toBe(200);
|
|
expect((await h.json('GET', '/api/gov/mail/accounts/nope')).status).toBe(404);
|
|
expect((await h.json('PUT', `/api/gov/mail/accounts/${view.id}`, { ...input, displayName: '销售部' })).status).toBe(200);
|
|
expect(await (await h.json('DELETE', `/api/gov/mail/accounts/${view.id}`)).json()).toEqual({ ok: true });
|
|
expect((await h.json('DELETE', `/api/gov/mail/accounts/${view.id}`)).status).toBe(404);
|
|
});
|
|
|
|
it('探测:草稿没密码 412;已存账号 200 且厂商失败在 body 里', async () => {
|
|
const h = harness();
|
|
expect((await h.json('POST', '/api/gov/mail/accounts/probe', { ...input, secret: undefined })).status).toBe(412);
|
|
const view = (await (await h.json('POST', '/api/gov/mail/accounts', input)).json()) as { id: string };
|
|
expect((await h.json('POST', '/api/gov/mail/accounts/probe?accountId=' + view.id, { ...input, secret: undefined })).status).toBe(200);
|
|
h.imap.probeWith({ kind: 'imap-smtp', receive: { ok: false, error: '535 pw-1234 bad' }, send: { checked: true, ok: true } });
|
|
const res = await h.json('POST', `/api/gov/mail/accounts/${view.id}/probe`);
|
|
expect(res.status).toBe(200);
|
|
expect(await res.json()).toMatchObject({ receive: { ok: false, error: '535 *** bad' } });
|
|
});
|
|
|
|
it('立即拉取:200 outcome;停用 412;租约被占 409', async () => {
|
|
const h = harness();
|
|
await h.accounts.save(account());
|
|
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>' })));
|
|
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);
|
|
// 租约是**运行列**:`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);
|
|
});
|
|
|
|
it('试发:成功与厂商失败都是 200,state 说话;入参错 400', async () => {
|
|
const h = harness();
|
|
await h.accounts.save(account());
|
|
const ok = await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'me@we.com', subject: '试发', text: '正文' });
|
|
expect(await ok.json()).toMatchObject({ state: 'sent', messageId: '<x-1@we.com>' });
|
|
h.imap.failSendWith(new Error('535 nope'));
|
|
expect(await (await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'me@we.com', subject: '试发', text: '正文' })).json()).toMatchObject({ state: 'failed', error: '535 nope' });
|
|
expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'not-mail', subject: '', text: '' })).status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('入站 / 出站路由', () => {
|
|
it('列表(limit)/ 详情 / 附件 / 原件 / 重新投递;404 各就各位', async () => {
|
|
const h = harness();
|
|
await h.accounts.save(account());
|
|
h.mail.subscribe('spy', async () => undefined);
|
|
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'hi' } })));
|
|
await h.json('POST', '/api/gov/mail/accounts/acc-1/poll');
|
|
const list = (await (await h.json('GET', '/api/gov/mail/inbound?accountId=acc-1&limit=10')).json()) as { mails: Array<{ id: string; deliveries: unknown[] }> };
|
|
expect(list.mails).toHaveLength(1);
|
|
expect(list.mails[0]?.deliveries).toHaveLength(1);
|
|
const id = list.mails[0]!.id;
|
|
expect((await h.json('GET', `/api/gov/mail/inbound/${id}`)).status).toBe(200);
|
|
expect((await h.json('GET', '/api/gov/mail/inbound/nope')).status).toBe(404);
|
|
const att = (await (await h.json('GET', `/api/gov/mail/inbound/${id}/attachments/0`)).json()) as { name: string; dataBase64: string };
|
|
expect(att.name).toBe('a.txt');
|
|
expect(Buffer.from(att.dataBase64, 'base64').toString()).toBe('hi');
|
|
expect((await h.json('GET', `/api/gov/mail/inbound/${id}/attachments/7`)).status).toBe(404);
|
|
const raw = (await (await h.json('GET', `/api/gov/mail/inbound/${id}/raw`)).json()) as { contentType: string };
|
|
expect(raw.contentType).toBe('message/rfc822');
|
|
expect(await (await app_request_redeliver(h, id)).json()).toMatchObject({ deliveries: [{ subscriber: 'spy', state: 'done' }] });
|
|
expect((await h.json('POST', `/api/gov/mail/inbound/${id}/redeliver`, { subscriber: 'nobody' })).status).toBe(404);
|
|
expect((await h.json('GET', '/api/gov/mail/inbound?limit=0')).status).toBe(400);
|
|
expect(await (await h.json('GET', '/api/gov/mail/outbound')).json()).toEqual({ mails: [] });
|
|
});
|
|
});
|
|
|
|
/** 空 body 的 POST:redeliver 的 body 是可省的 */
|
|
function app_request_redeliver(h: ReturnType<typeof harness>, id: string): Promise<Response> {
|
|
return Promise.resolve(h.app.request(`/api/gov/mail/inbound/${id}/redeliver`, { method: 'POST' }));
|
|
}
|