diff --git a/apps/api/src/modules/mail/__tests__/fakes.ts b/apps/api/src/modules/mail/__tests__/fakes.ts new file mode 100644 index 0000000..d67a019 --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/fakes.ts @@ -0,0 +1,337 @@ +/** + * 测试夹具 —— 内存 store(用例测试用)、假 provider(邮件通道是外界,替身允许)、EML 构造。 + * 系统内部机制在 IT 里真跑(mail-store.it.test.ts);这里的内存 store 只服务用例逻辑。 + */ +import type { MailAccountKind, MailProbeReport } from '@contracts'; +import { isDue } from '../internal/domain/due.ts'; +import { imapCursor, wecomCursor } from '../internal/domain/cursor.ts'; +import type { + AttachmentStore, + Clock, + DeliveryRecord, + IdGenerator, + InboundRecord, + InboundStore, + MailAccountRecord, + MailAccountStore, + MailCursor, + MailProvider, + OutboundMessage, + OutboundRecord, + OutboundStore, + RawMessage, + SecretBox, +} from '../port.ts'; + +const CRLF = '\r\n'; + +export function fixedClock(iso = '2026-08-25T10:00:00Z'): Clock & { tick(ms: number): void } { + let t = new Date(iso).getTime(); + return { + now: () => new Date(t), + tick(ms) { + t += ms; + }, + }; +} + +export function seqIds(prefix = 'id'): IdGenerator { + let n = 0; + return { + next: () => { + n += 1; + return `${prefix}-${n}`; + }, + }; +} + +export const plainSecrets: SecretBox = { + seal: (s) => `sealed:${s}`, + open: (c) => { + if (!c.startsWith('sealed:')) throw new Error('密文格式非法'); + return c.slice('sealed:'.length); + }, +}; + +export function account(over: Partial = {}): MailAccountRecord { + const now = new Date('2026-08-25T09:00:00Z'); + return { + id: 'acc-1', + kind: 'imap-smtp', + name: '销售信箱', + address: 'sales@we.com', + displayName: '销售部', + enabled: true, + isDefault: true, + pollIntervalSec: 30, + channelId: 'ch-1', + backfillFrom: null, + settings: { folder: 'INBOX' }, + secretCipher: `sealed:${JSON.stringify({ password: 'pw-1234' })}`, + cursor: null, + lastPolledAt: null, + lastPollError: null, + lastInboundAt: null, + pollingUntil: null, + createdAt: now, + updatedAt: now, + ...over, + }; +} + +export function memoryAccounts(clock: Clock): MailAccountStore & { rows: Map } { + const rows = new Map(); + return { + rows, + async list() { + return [...rows.values()].sort((a, b) => a.name.localeCompare(b.name)); + }, + async get(id) { + return rows.get(id); + }, + async findByName(name) { + return [...rows.values()].find((r) => r.name === name); + }, + async save(record) { + if (record.isDefault) { + for (const [k, v] of rows) if (k !== record.id && v.isDefault) rows.set(k, { ...v, isDefault: false }); + } + rows.set(record.id, record); + }, + async remove(id) { + rows.delete(id); + }, + async listDue(now) { + return [...rows.values()].filter((r) => isDue(r, now)); + }, + async claimPolling(id, leaseMs) { + const r = rows.get(id); + const now = clock.now(); + if (r === undefined || !r.enabled) return false; + if (r.pollingUntil !== null && r.pollingUntil.getTime() > now.getTime()) return false; + rows.set(id, { ...r, pollingUntil: new Date(now.getTime() + leaseMs) }); + return true; + }, + async releasePolling(id) { + const r = rows.get(id); + if (r !== undefined) rows.set(id, { ...r, pollingUntil: null }); + }, + async savePoll(id, patch) { + const r = rows.get(id); + if (r === undefined) return; + rows.set(id, { + ...r, + ...(patch.cursor === undefined ? {} : { cursor: patch.cursor }), + ...(patch.polledAt === undefined ? {} : { lastPolledAt: patch.polledAt }), + ...(patch.error === undefined ? {} : { lastPollError: patch.error }), + ...(patch.lastInboundAt === undefined ? {} : { lastInboundAt: patch.lastInboundAt }), + ...(patch.address === undefined ? {} : { address: patch.address }), + }); + }, + }; +} + +export function memoryInbound(): InboundStore & { rows: Map; deliveries: Map } { + const rows = new Map(); + const deliveries = new Map(); + // 与 DB store 同序:按订阅方名排(测试里对顺序有断言) + const of = (mailId: string): DeliveryRecord[] => + [...deliveries.values()].filter((d) => d.mailId === mailId).sort((a, b) => a.subscriber.localeCompare(b.subscriber)); + return { + rows, + deliveries, + async insert(record) { + for (const r of rows.values()) { + if (r.accountId === record.accountId && (r.messageId === record.messageId || r.providerRef === record.providerRef)) { + return { inserted: false }; + } + } + rows.set(record.id, record); + return { inserted: true }; + }, + async knownProviderRefs(accountId, refs) { + return new Set([...rows.values()].filter((r) => r.accountId === accountId && refs.includes(r.providerRef)).map((r) => r.providerRef)); + }, + async list(query) { + return [...rows.values()] + .filter((r) => query.accountId === undefined || r.accountId === query.accountId) + .sort((a, b) => b.receivedAt.getTime() - a.receivedAt.getTime()) + .slice(0, query.limit) + .map((record) => ({ record, deliveries: of(record.id) })); + }, + async get(mailId) { + const record = rows.get(mailId); + return record === undefined ? undefined : { record, deliveries: of(mailId) }; + }, + async setDelivery(delivery) { + deliveries.set(`${delivery.mailId}|${delivery.subscriber}`, delivery); + }, + }; +} + +export function memoryOutbound(): OutboundStore & { rows: Map } { + const rows = new Map(); + return { + rows, + async insert(record) { + if (record.idempotencyKey !== null && [...rows.values()].some((r) => r.idempotencyKey === record.idempotencyKey)) { + throw new Error('duplicate key value violates unique constraint "mail_outbound_idem_key"'); + } + rows.set(record.id, record); + }, + async byIdempotencyKey(key) { + return [...rows.values()].find((r) => r.idempotencyKey === key); + }, + async setState(id, state, patch) { + const r = rows.get(id); + if (r === undefined) return; + rows.set(id, { + ...r, + state, + ...(patch.error === undefined ? {} : { error: patch.error }), + ...(patch.providerRef === undefined ? {} : { providerRef: patch.providerRef }), + ...(patch.sentAt === undefined ? {} : { sentAt: patch.sentAt }), + }); + }, + async list(query) { + return [...rows.values()] + .filter((r) => query.accountId === undefined || r.accountId === query.accountId) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + .slice(0, query.limit); + }, + }; +} + +export function memoryAttachments(): AttachmentStore & { files: Map } { + const files = new Map(); + let n = 0; + return { + files, + async put(file) { + n += 1; + const ref = `up-${n}`; + files.set(ref, { ...file }); + return { ref }; + }, + async get(ref) { + const f = files.get(ref); + return f === undefined ? undefined : { name: f.name, contentType: f.contentType, bytes: f.bytes }; + }, + }; +} + +export type FakeProvider = MailProvider & { + readonly sent: OutboundMessage[]; + readonly pullCalls: Array<{ cursor: MailCursor; limit: number }>; + queue(...messages: RawMessage[]): void; + failPullWith(error: Error | undefined): void; + failSendWith(error: Error | undefined): void; + probeWith(report: MailProbeReport | undefined): void; +}; + +/** 可脚本化的假 provider:排队的信按 limit 交出去,IMAP 形逐封带 cursorAfter */ +export function fakeProvider(kind: MailAccountKind = 'imap-smtp'): FakeProvider { + const pending: RawMessage[] = []; + const sent: OutboundMessage[] = []; + const pullCalls: Array<{ cursor: MailCursor; limit: number }> = []; + let pullError: Error | undefined; + let sendError: Error | undefined; + let probeReport: MailProbeReport | undefined; + let uidSeq = 0; + return { + kind, + sent, + pullCalls, + queue(...messages) { + pending.push(...messages); + }, + failPullWith(error) { + pullError = error; + }, + failSendWith(error) { + sendError = error; + }, + probeWith(report) { + probeReport = report; + }, + async probe(accountArg) { + return ( + probeReport ?? { + kind, + address: accountArg.address, + receive: { ok: true, latencyMs: 1 }, + send: kind === 'wecom' ? { checked: false, note: '发信权限只能在真发时验证' } : { checked: true, ok: true }, + } + ); + }, + async anchor(_accountArg, _backfillFrom, now) { + return kind === 'imap-smtp' ? imapCursor('v1', 0) : wecomCursor(Math.floor(now.getTime() / 1000)); + }, + async pull(_accountArg, cursor, opts) { + pullCalls.push({ cursor, limit: opts.limit }); + if (pullError !== undefined) throw pullError; + const batch = pending.splice(0, opts.limit).map((m) => { + if (kind !== 'imap-smtp' || m.cursorAfter !== undefined) return m; + uidSeq += 1; + return { ...m, cursorAfter: imapCursor('v1', uidSeq) }; + }); + const last = batch.at(-1)?.cursorAfter; + const next = kind === 'imap-smtp' ? (last ?? cursor) : wecomCursor(Math.floor(opts.now.getTime() / 1000)); + return { messages: batch, cursor: next, more: pending.length > 0 }; + }, + async send(_accountArg, message) { + if (sendError !== undefined) throw sendError; + sent.push(message); + return { providerRef: `fake:${sent.length}` }; + }, + }; +} + +export function rawMessage(providerRef: string, eml: Uint8Array, receivedAt = new Date('2026-08-25T09:30:00Z')): RawMessage { + return { providerRef, eml, receivedAt }; +} + +/** 极简 RFC822 构造:够 postal-mime 解出地址、主题、正文、附件 */ +export function eml(parts: { + from?: string; + to?: string; + cc?: string; + subject?: string; + text?: string; + html?: string; + messageId?: string; + inReplyTo?: string; + references?: string; + date?: string; + attachment?: { name: string; contentType: string; content: string }; +}): Uint8Array { + const headers = [ + `From: ${parts.from ?? 'buyer@acme.com'}`, + `To: ${parts.to ?? 'sales@we.com'}`, + ...(parts.cc === undefined ? [] : [`Cc: ${parts.cc}`]), + `Subject: ${parts.subject ?? '询价'}`, + ...(parts.messageId === undefined ? [] : [`Message-ID: ${parts.messageId}`]), + ...(parts.inReplyTo === undefined ? [] : [`In-Reply-To: ${parts.inReplyTo}`]), + ...(parts.references === undefined ? [] : [`References: ${parts.references}`]), + `Date: ${parts.date ?? 'Tue, 25 Aug 2026 09:00:00 +0800'}`, + 'MIME-Version: 1.0', + ]; + const text = parts.text ?? '你好,请报价'; + if (parts.html === undefined && parts.attachment === undefined) { + return new TextEncoder().encode([...headers, 'Content-Type: text/plain; charset=utf-8', '', text, ''].join(CRLF)); + } + const lines = [...headers, 'Content-Type: multipart/mixed; boundary="B1"', '', '--B1', 'Content-Type: text/plain; charset=utf-8', '', text]; + if (parts.html !== undefined) lines.push('--B1', 'Content-Type: text/html; charset=utf-8', '', parts.html); + if (parts.attachment !== undefined) { + lines.push( + '--B1', + `Content-Type: ${parts.attachment.contentType}; name="${parts.attachment.name}"`, + `Content-Disposition: attachment; filename="${parts.attachment.name}"`, + 'Content-Transfer-Encoding: base64', + '', + Buffer.from(parts.attachment.content, 'utf8').toString('base64'), + ); + } + lines.push('--B1--', ''); + return new TextEncoder().encode(lines.join(CRLF)); +} diff --git a/apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts b/apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts new file mode 100644 index 0000000..0047ea2 --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts @@ -0,0 +1,81 @@ +/** MIME 解析 —— 真 postal-mime:GBK 编码字头、群组地址、References 拆串、纯 html、附件字节 */ +import { describe, expect, it } from 'vitest'; +import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts'; +import { eml } from './fakes.ts'; + +const parser = createPostalMimeParser(); +const CRLF = '\r\n'; + +describe('createPostalMimeParser', () => { + it('地址 / 主题 / 正文 / Message-ID / In-Reply-To / References / 日期', async () => { + const parsed = await parser.parse( + eml({ + from: '"张三" ', + to: 'sales@we.com, "Ops" ', + subject: '询价 RFQ-1', + messageId: '', + inReplyTo: '', + references: ' ', + text: '你好,请报价', + }), + ); + expect(parsed.from).toEqual({ address: 'buyer@acme.com', name: '张三' }); + expect(parsed.to).toEqual([ + { address: 'sales@we.com', name: '' }, + { address: 'ops@we.com', name: 'Ops' }, + ]); + expect(parsed.subject).toBe('询价 RFQ-1'); + expect(parsed.messageId).toBe(''); + expect(parsed.inReplyTo).toBe(''); + expect(parsed.references).toEqual(['', '']); + expect(parsed.text?.trim()).toBe('你好,请报价'); + expect(parsed.date?.toISOString()).toBe('2026-08-25T01:00:00.000Z'); + }); + + it('GBK 的 =?GBK?B?…?= 主题与 charset=gbk 正文解成中文(Node 自带 TextDecoder)', async () => { + const subject = Buffer.from([0xd1, 0xaf, 0xbc, 0xdb]).toString('base64'); // 询价 + const body = Buffer.from([0xc4, 0xe3, 0xba, 0xc3]).toString('base64'); // 你好 + const raw = [ + 'From: buyer@acme.com', + 'To: sales@we.com', + `Subject: =?GBK?B?${subject}?=`, + 'Content-Type: text/plain; charset=gbk', + 'Content-Transfer-Encoding: base64', + '', + body, + '', + ].join(CRLF); + const parsed = await parser.parse(new TextEncoder().encode(raw)); + expect(parsed.subject).toBe('询价'); + expect(parsed.text?.trim()).toBe('你好'); + }); + + it('群组地址摊平;没有 Cc 就是空数组', async () => { + const raw = ['From: a@x.com', 'To: team: b@x.com, "C" ;', 'Subject: s', '', 'body', ''].join(CRLF); + const parsed = await parser.parse(new TextEncoder().encode(raw)); + expect(parsed.to).toEqual([ + { address: 'b@x.com', name: '' }, + { address: 'c@x.com', name: 'C' }, + ]); + expect(parsed.cc).toEqual([]); + }); + + it('纯 html 的信 text 缺席、html 在;没有 Message-ID / From 也不抛', async () => { + const raw = ['Subject: x', 'Content-Type: text/html', '', 'only html', ''].join(CRLF); + const parsed = await parser.parse(new TextEncoder().encode(raw)); + expect(parsed.text).toBeUndefined(); + expect(parsed.html).toContain('only html'); + expect(parsed.messageId).toBeUndefined(); + expect(parsed.from).toBeUndefined(); + expect(parsed.date).toBeUndefined(); + }); + + it('附件:文件名、类型、字节(ArrayBuffer → Uint8Array)', async () => { + const parsed = await parser.parse(eml({ attachment: { name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', content: 'PKxyz' } })); + expect(parsed.attachments).toHaveLength(1); + expect(parsed.attachments[0]?.name).toBe('bom.xlsx'); + expect(parsed.attachments[0]?.contentType).toBe('application/vnd.ms-excel'); + expect(parsed.attachments[0]?.bytes).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(parsed.attachments[0]?.bytes)).toBe('PKxyz'); + }); +}); diff --git a/apps/api/src/modules/mail/index.ts b/apps/api/src/modules/mail/index.ts index 05e1669..79fadcf 100644 --- a/apps/api/src/modules/mail/index.ts +++ b/apps/api/src/modules/mail/index.ts @@ -80,3 +80,4 @@ export { cursorReset, mergeAccount, secretOf, viewOf } from './internal/domain/a export { createInboundStore, createMailAccountStore, createOutboundStore } from './internal/adapter/mail-store.ts'; export { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './internal/adapter/mail-schema.ts'; +export { createPostalMimeParser } from './internal/adapter/postal-mime-parser.ts'; diff --git a/apps/api/src/modules/mail/internal/adapter/postal-mime-parser.ts b/apps/api/src/modules/mail/internal/adapter/postal-mime-parser.ts new file mode 100644 index 0000000..46d13f0 --- /dev/null +++ b/apps/api/src/modules/mail/internal/adapter/postal-mime-parser.ts @@ -0,0 +1,63 @@ +/** + * MIME 解析 —— postal-mime(零依赖;GBK / GB18030 走 Node 自带 TextDecoder)。 + * 两条接入路共用:IMAP 拉到的 source 与 wecom `read_mail` 的 EML 都从这里过。 + */ +import PostalMime from 'postal-mime'; +import type { Address } from 'postal-mime'; +import type { MailAddress } from '@contracts'; +import type { MimeParser, ParsedMail } from '../../port.ts'; + +function mailboxesOf(list: readonly Address[] | undefined): MailAddress[] { + const out: MailAddress[] = []; + for (const entry of list ?? []) { + if (entry.group !== undefined) { + for (const member of entry.group) out.push({ address: member.address, name: member.name }); + } else if (entry.address !== undefined) { + out.push({ address: entry.address, name: entry.name }); + } + } + return out; +} + +/** References / In-Reply-To 是空白分隔的一串 ;拆开、去空 */ +function idsOf(value: string | undefined): string[] { + if (value === undefined) return []; + return value + .split(/\s+/) + .map((s) => s.trim()) + .filter((s) => s !== ''); +} + +function bytesOf(content: ArrayBuffer | Uint8Array | string): Uint8Array { + if (typeof content === 'string') return new TextEncoder().encode(content); + return content instanceof Uint8Array ? content : new Uint8Array(content); +} + +export function createPostalMimeParser(): MimeParser { + return { + async parse(eml): Promise { + const email = await PostalMime.parse(eml, { attachmentEncoding: 'arraybuffer' }); + const from = + email.from !== undefined && email.from.address !== undefined ? { address: email.from.address, name: email.from.name } : undefined; + const date = email.date === undefined ? undefined : new Date(email.date); + const inReplyTo = idsOf(email.inReplyTo)[0]; + return { + ...(email.messageId === undefined ? {} : { messageId: email.messageId }), + ...(inReplyTo === undefined ? {} : { inReplyTo }), + references: idsOf(email.references), + ...(from === undefined ? {} : { from }), + to: mailboxesOf(email.to), + cc: mailboxesOf(email.cc), + subject: email.subject ?? '', + ...(email.text === undefined ? {} : { text: email.text }), + ...(email.html === undefined ? {} : { html: email.html }), + ...(date === undefined || Number.isNaN(date.getTime()) ? {} : { date }), + attachments: email.attachments.map((a) => ({ + name: a.filename ?? '', + contentType: a.mimeType, + bytes: bytesOf(a.content), + })), + }; + }, + }; +}