diff --git a/apps/api/src/modules/mail/__tests__/mail-service-send.test.ts b/apps/api/src/modules/mail/__tests__/mail-service-send.test.ts new file mode 100644 index 0000000..8ff25d5 --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/mail-service-send.test.ts @@ -0,0 +1,108 @@ +/** 发信:先落 sending 再发;厂商失败落 failed 不抛;幂等键不发两封、失败可重发;附件取自 upload;from 对账号 */ +import { describe, expect, it } from 'vitest'; +import { MailAccountNotFoundError, MailNoDefaultAccountError, MailSecretMissingError, MailSendInvalidError } from '../contract.ts'; +import { createMailService } from '../internal/usecase/mail-service.ts'; +import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts'; +import { account, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, seqIds } from './fakes.ts'; + +function harness() { + const clock = fixedClock(); + const accounts = memoryAccounts(clock); + const outbound = memoryOutbound(); + const attachments = memoryAttachments(); + const imap = fakeProvider('imap-smtp'); + const wecom = fakeProvider('wecom'); + const service = createMailService({ + accounts, + inbound: memoryInbound(), + outbound, + attachments, + providers: { 'imap-smtp': imap, wecom }, + mime: createPostalMimeParser(), + secrets: plainSecrets, + clock, + ids: seqIds('out'), + notify: { changed: () => undefined }, + }); + return { service, accounts, outbound, attachments, imap, clock }; +} +const base = { to: ['b@acme.com'], cc: [], bcc: [], subject: '报价', text: '见附件', references: [], attachments: [], requestedBy: { kind: 'ops' as const } }; + +describe('send', () => { + it('默认账号发出:留痕 sent、Message-ID 是 、发件人带显示名、返回 providerRef', async () => { + const h = harness(); + await h.accounts.save(account()); + const result = await h.service.send(base); + expect(result).toEqual({ outboundId: 'out-1', accountId: 'acc-1', messageId: '', state: 'sent', providerRef: 'fake:1' }); + expect(h.imap.sent[0]?.from).toEqual({ address: 'sales@we.com', name: '销售部' }); + expect(h.imap.sent[0]?.messageId).toBe(''); + const row = h.outbound.rows.get('out-1'); + expect(row?.state).toBe('sent'); + expect(row?.sentAt).toEqual(h.clock.now()); + expect((await h.service.listOutbound({}))[0]).toMatchObject({ id: 'out-1', state: 'sent', to: ['b@acme.com'] }); + }); + + it('厂商失败:落 failed(原话脱敏)、不抛、返回 state=failed', async () => { + const h = harness(); + await h.accounts.save(account()); + h.imap.failSendWith(Object.assign(new Error('Invalid login'), { response: '535 5.7.8 pw-1234 rejected' })); + const result = await h.service.send(base); + expect(result.state).toBe('failed'); + expect(result.error).toBe('Invalid login —— 535 5.7.8 *** rejected'); + expect(h.outbound.rows.get('out-1')?.state).toBe('failed'); + }); + + it('幂等键:已 sent 的直接回原结果不再发;失败的那一行复用重发', async () => { + const h = harness(); + await h.accounts.save(account()); + h.imap.failSendWith(new Error('暂时不行')); + const failed = await h.service.send({ ...base, idempotencyKey: 'k-1' }); + expect(failed.state).toBe('failed'); + h.imap.failSendWith(undefined); + const retried = await h.service.send({ ...base, idempotencyKey: 'k-1' }); + expect(retried).toMatchObject({ outboundId: failed.outboundId, state: 'sent' }); + const again = await h.service.send({ ...base, idempotencyKey: 'k-1' }); + expect(again).toMatchObject({ outboundId: failed.outboundId, state: 'sent' }); + expect(h.imap.sent).toHaveLength(1); + expect(h.outbound.rows.size).toBe(1); + }); + + it('入参问题抛而不落行:无正文 400、没默认账号 412、from 对不上 404、凭证空 412', async () => { + const h = harness(); + await expect(h.service.send(base)).rejects.toBeInstanceOf(MailNoDefaultAccountError); + await h.accounts.save(account()); + await expect(h.service.send({ ...base, text: undefined })).rejects.toBeInstanceOf(MailSendInvalidError); + await expect(h.service.send({ ...base, from: 'nobody@we.com' })).rejects.toBeInstanceOf(MailAccountNotFoundError); + await h.accounts.save(account({ secretCipher: null })); + await expect(h.service.send(base)).rejects.toBeInstanceOf(MailSecretMissingError); + expect(h.outbound.rows.size).toBe(0); + }); + + it('附件:uploadRef 取 upload 的字节与类型;dataBase64 直接用;两者都没有 400', async () => { + const h = harness(); + await h.accounts.save(account()); + const { ref } = await h.attachments.put({ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]), note: '' }); + await h.service.send({ + ...base, + attachments: [ + { name: 'q.pdf', contentType: 'application/octet-stream', uploadRef: ref }, + { name: 'n.txt', contentType: 'text/plain', dataBase64: Buffer.from('hi').toString('base64') }, + ], + }); + expect(h.imap.sent[0]?.attachments.map((a) => [a.name, a.contentType, a.bytes.byteLength])).toEqual([ + ['q.pdf', 'application/pdf', 2], + ['n.txt', 'text/plain', 2], + ]); + await expect(h.service.send({ ...base, attachments: [{ name: 'x', contentType: 'text/plain' }] })).rejects.toBeInstanceOf(MailSendInvalidError); + await expect(h.service.send({ ...base, attachments: [{ name: 'x', contentType: 'text/plain', uploadRef: 'up-404' }] })).rejects.toBeInstanceOf(MailSendInvalidError); + }); + + it('from 指定发件地址(channel 用):按地址找账号,inReplyTo / references 原样交给 provider', async () => { + const h = harness(); + await h.accounts.save(account({ isDefault: false })); + const result = await h.service.send({ ...base, from: 'SALES@we.com', inReplyTo: '', references: [''], requestedBy: { kind: 'channel', taskId: 't-1' } }); + expect(result.accountId).toBe('acc-1'); + expect(h.imap.sent[0]?.inReplyTo).toBe(''); + expect(h.outbound.rows.get('out-1')?.requestedBy).toEqual({ kind: 'channel', taskId: 't-1' }); + }); +});