/** mail ↔ channel / toolbox 的接线:三段纯映射各自成立;dryRun 不发;失败翻成工具错误 */ 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', accountId: 'acc-1', messageId: '', from: { address: 'buyer@acme.com', name: '张三' }, to: [{ address: 'sales@we.com', name: '' }], cc: [{ address: 'ops@we.com', name: 'Ops' }], subject: '询价 RFQ-1', sentAt: '2026-08-25T09:00:00.000Z', receivedAt: '2026-08-25T09:30:00.000Z', textPreview: '你好', hasHtml: true, attachments: [ { index: 0, name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1', note: '' }, { index: 1, name: 'big.zip', contentType: 'application/zip', size: 9_000_000, ref: null, note: '超限' }, ], deliveries: [], inReplyTo: null, references: ['', ''], text: null, html: '

你好

', size: 100, rawAvailable: true, }; function fakeSend(result: Partial = {}): { send: (input: MailSendInput) => Promise; calls: MailSendInput[] } { const calls: MailSendInput[] = []; return { calls, send: async (input) => { calls.push(input); return { outboundId: 'out-1', accountId: 'acc-1', messageId: '', state: 'sent', ...result }; }, }; } describe('toInboundMail', () => { it('线程取 References 首项(即这一串的根);只带存下来的附件;纯 html 剥成正文', () => { const mail: InboundMail = toInboundMail(detail); // 不是末项 :我方出站登记的恒是首封,①级归属是精确等值比对 —— // 取最近一跳的话,跑一个来回(我方再回一封)线程就漂到库里没记过的 Message-ID 上,①级落空退③级 expect(mail.threadId).toBe(''); expect(mail.from).toBe('buyer@acme.com'); expect(mail.to).toEqual(['sales@we.com']); expect(mail.cc).toEqual(['ops@we.com']); expect(mail.body).toBe('你好'); 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: '' }; expect(toInboundMail(onlyHop).threadId).toBe(threadIdOf(onlyHop)); expect(toInboundMail(onlyHop).threadId).toBe(''); const none: MailInboundDetail = { ...detail, references: [], inReplyTo: null }; expect(toInboundMail(none).threadId).toBeUndefined(); }); }); describe('channelSubscriber', () => { it('账号没绑通道 → skipped;绑了就 ingestMail(通道 id + 归一后的信)', async () => { const calls: Array<[string, InboundMail]> = []; const handler = channelSubscriber({ ingestMail: async (id, mail) => { calls.push([id, mail]); return undefined; }, }); expect(await handler(detail, { accountId: 'acc-1', channelId: undefined })).toEqual({ skipped: '账号未绑定通道' }); expect(await handler(detail, { accountId: 'acc-1', channelId: 'ch-1' })).toBeUndefined(); expect(calls[0]?.[0]).toBe('ch-1'); expect(calls[0]?.[1].messageId).toBe(''); }); }); describe('channelTransport', () => { it('按 from 找账号、threadId 进 inReplyTo / references、附件 ref 变 uploadRef;回 messageId 与线程', async () => { const mail = fakeSend(); const sent = await channelTransport(mail).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: '报价', body: '正文', threadId: '', attachments: [{ name: 'q.pdf', ref: 'up-9' }] }); expect(sent).toEqual({ messageId: '', threadId: '' }); expect(mail.calls[0]).toMatchObject({ from: 'sales@we.com', inReplyTo: '', references: [''], text: '正文', attachments: [{ name: 'q.pdf', uploadRef: 'up-9' }], requestedBy: { kind: 'channel' } }); const fresh = await channelTransport(fakeSend()).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: 's', body: 'b', attachments: [] }); expect(fresh.threadId).toBe(''); }); it('厂商失败抛出,让 channel.sendMail 的调用方看到', async () => { await expect(channelTransport(fakeSend({ state: 'failed', error: '535 auth failed' })).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: 's', body: 'b', attachments: [] })).rejects.toThrow('535 auth failed'); }); }); describe('mailSendTool', () => { const base = { connection: { account: 'default' }, operation: undefined, idempotencyKey: 'k-1' }; it('数组 / 逗号串都认作收件人;body → text;幂等键与连接配置的账号带过去;出参保留 queued', async () => { const mail = fakeSend(); const result = await mailSendTool(mail)({ ...base, connection: { account: 'acc-2' }, input: { to: 'a@x.com; b@x.com', subject: 's', body: '正文', inReplyTo: '', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }] } }); expect(result).toEqual({ ok: true, output: { queued: true, outboundId: 'out-1', messageId: '', state: 'sent' } }); expect(mail.calls[0]).toMatchObject({ accountId: 'acc-2', to: ['a@x.com', 'b@x.com'], text: '正文', inReplyTo: '', references: [''], idempotencyKey: 'k-1', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }], requestedBy: { kind: 'tool', tool: 'mail.send' } }); await mailSendTool(mail)({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } }); expect(mail.calls[1]?.accountId).toBeUndefined(); }); it('没给 uploadRef 的附件不静默丢掉:整次调用判 invalid,一封都不发', async () => { // 模块那一层本就是硬的(既没字节也没 ref 就抛 MailSendInvalidError);app 层不许比它松 —— // 悄悄丢掉附件再回 queued:true,等于让界面撒谎 const mail = fakeSend(); const result = await mailSendTool(mail)({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }, { name: 'no-ref' }] } }); expect(result.ok).toBe(false); expect(result.error?.code).toBe('invalid'); expect(result.error?.retryable).toBe(false); expect(result.error?.message).toContain('no-ref'); expect(mail.calls).toHaveLength(0); }); it('dryRun 只校验不发', async () => { const mail = fakeSend(); expect(await mailSendTool(mail)({ ...base, input: { to: ['x@y.com'], subject: '询价', body: 'b', dryRun: true } })).toEqual({ ok: true, output: { queued: false, dryRun: true } }); expect((await mailSendTool(mail)({ ...base, input: { to: [], subject: '', dryRun: true } })).ok).toBe(false); expect(mail.calls).toHaveLength(0); }); it('厂商失败 → send-failed 可重试;没账号 → no-account 不重试;入参问题 → invalid', async () => { const failed = await mailSendTool(fakeSend({ state: 'failed', error: '535' }))({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } }); expect(failed).toEqual({ ok: false, error: { code: 'send-failed', retryable: true, message: '535' } }); const { MailNoDefaultAccountError, MailSendInvalidError } = await import('@modules/mail'); const noAccount = await mailSendTool({ send: async () => { throw new MailNoDefaultAccountError(); } })({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } }); expect(noAccount.error?.code).toBe('no-account'); const invalid = await mailSendTool({ send: async () => { throw new MailSendInvalidError(['正文 text / html 至少一个']); } })({ ...base, input: { to: ['a@x.com'], subject: 's' } }); 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'); }); });