From 40e592ae94460ee4e0e1ab1395c18fafb751209f Mon Sep 17 00:00:00 2001 From: xcs Date: Wed, 26 Aug 2026 01:39:06 +0800 Subject: [PATCH] =?UTF-8?q?mail=20=E7=94=A8=E4=BE=8B=E6=B5=8B=E8=AF=95:?= =?UTF-8?q?=E5=8F=91=E4=BF=A1=E7=95=99=E7=97=95=20/=20=E5=8E=82=E5=95=86?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E4=B8=8D=E6=8A=9B=20/=20=E5=B9=82=E7=AD=89?= =?UTF-8?q?=E9=94=AE=20/=20=E9=99=84=E4=BB=B6=E6=9D=A5=E6=BA=90=20/=20from?= =?UTF-8?q?=20=E5=AF=B9=E8=B4=A6=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr --- .../mail/__tests__/mail-service-send.test.ts | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 apps/api/src/modules/mail/__tests__/mail-service-send.test.ts 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' }); + }); +});