Archived
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:
@@ -0,0 +1,47 @@
|
||||
/** 轮询工作者:每 tick 调 pollDue;上一 tick 没跑完就跳过这一拍;出错的账号写 stderr;stop 后不再跑 */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { MailPollOutcome } from '@contracts';
|
||||
import { pollMailOnce, startMailPoller } from '../workers/mail-poller.ts';
|
||||
|
||||
function fakeMail(outcomes: MailPollOutcome[], holdMs = 0) {
|
||||
const calls: Date[] = [];
|
||||
return {
|
||||
calls,
|
||||
mail: {
|
||||
pollDue: async (now: Date) => {
|
||||
calls.push(now);
|
||||
if (holdMs > 0) await new Promise((r) => setTimeout(r, holdMs));
|
||||
return outcomes;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('pollMailOnce', () => {
|
||||
it('回用例的结果;出错的账号写一行 stderr', async () => {
|
||||
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const { mail } = fakeMail([{ accountId: 'a', pulled: 1, stored: 1, dispatched: 1, more: false }, { accountId: 'b', pulled: 0, stored: 0, dispatched: 0, more: false, error: '连不上' }]);
|
||||
const out = await pollMailOnce({ container: { mail: mail as never } });
|
||||
expect(out).toHaveLength(2);
|
||||
expect(write).toHaveBeenCalledWith(expect.stringContaining('[mail-poller] b:连不上'));
|
||||
write.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startMailPoller', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('按 tick 调;在跑时跳过;stop 后停', async () => {
|
||||
const { mail, calls } = fakeMail([], 1000);
|
||||
const poller = startMailPoller({ container: { mail: mail as never } }, 100);
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
expect(calls).toHaveLength(1); // 第一拍 1000ms 没跑完,后面两拍跳过
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(calls.length).toBeGreaterThanOrEqual(2);
|
||||
poller.stop();
|
||||
const before = calls.length;
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(calls).toHaveLength(before);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user