app 装配 mail:channel 作首个订阅方、出站经 mail.send、mail.send 工具真发信(dryRun 只校验)、轮询工作者;上传件存取抽出共用

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
This commit is contained in:
xcs
2026-08-26 02:05:50 +08:00
co-authored by Claude Fable 5
parent 58358210e2
commit 0c6e8e2c1b
10 changed files with 449 additions and 48 deletions
@@ -0,0 +1,111 @@
/** mail ↔ channel / toolbox 的接线:三段纯映射各自成立;dryRun 不发;失败翻成工具错误 */
import { describe, expect, it } from 'vitest';
import type { MailInboundDetail, MailSendInput, MailSendResult } from '@contracts';
import type { InboundMail } from '@contracts';
import { channelSubscriber, channelTransport, mailSendTool, toInboundMail } from '../mail-wiring.ts';
const detail: MailInboundDetail = {
id: 'in-1',
accountId: 'acc-1',
messageId: '<m1@acme.com>',
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: ['<root@we.com>', '<q1@we.com>'],
text: null,
html: '<p>你好</p>',
size: 100,
rawAvailable: true,
};
function fakeSend(result: Partial<MailSendResult> = {}): { send: (input: MailSendInput) => Promise<MailSendResult>; calls: MailSendInput[] } {
const calls: MailSendInput[] = [];
return {
calls,
send: async (input) => {
calls.push(input);
return { outboundId: 'out-1', accountId: 'acc-1', messageId: '<out-1@we.com>', state: 'sent', ...result };
},
};
}
describe('toInboundMail', () => {
it('线程取 References 末项(没 In-Reply-To);只带存下来的附件;纯 html 剥成正文', () => {
const mail: InboundMail = toInboundMail(detail);
expect(mail.threadId).toBe('<q1@we.com>');
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');
});
});
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('<m1@acme.com>');
});
});
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: '<m1@acme.com>', attachments: [{ name: 'q.pdf', ref: 'up-9' }] });
expect(sent).toEqual({ messageId: '<out-1@we.com>', threadId: '<m1@acme.com>' });
expect(mail.calls[0]).toMatchObject({ from: 'sales@we.com', inReplyTo: '<m1@acme.com>', references: ['<m1@acme.com>'], 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('<out-1@we.com>');
});
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: '<m1@a>', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }, { name: 'no-ref' }] } });
expect(result).toEqual({ ok: true, output: { queued: true, outboundId: 'out-1', messageId: '<out-1@we.com>', state: 'sent' } });
expect(mail.calls[0]).toMatchObject({ accountId: 'acc-2', to: ['a@x.com', 'b@x.com'], text: '正文', inReplyTo: '<m1@a>', references: ['<m1@a>'], 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('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');
});
});