From 7afc40987ba483626c03d8a40ecd6bb89e267bdc Mon Sep 17 00:00:00 2001 From: xcs Date: Wed, 26 Aug 2026 01:47:24 +0800 Subject: [PATCH] =?UTF-8?q?mail=20adapter:IMAP/SMTP=20provider(=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E6=89=93=E5=BC=80=20/=20UID=20=E6=B8=B8=E6=A0=87=20/?= =?UTF-8?q?=20n:*=20=E8=BF=87=E6=BB=A4=20/=20UIDVALIDITY=20=E9=87=8D?= =?UTF-8?q?=E9=94=9A=20/=20nodemailer=20=E5=8F=91=E4=BF=A1)+=20env=20?= =?UTF-8?q?=E9=97=A8=E6=8E=A7=E7=9C=9F=E4=BF=A1=E7=AE=B1=20IT?= 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__/imap-helpers.test.ts | 68 ++++++ .../__tests__/imap-smtp-provider.it.test.ts | 63 ++++++ apps/api/src/modules/mail/index.ts | 2 + .../internal/adapter/imap-smtp-provider.ts | 198 ++++++++++++++++++ 4 files changed, 331 insertions(+) create mode 100644 apps/api/src/modules/mail/__tests__/imap-helpers.test.ts create mode 100644 apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts create mode 100644 apps/api/src/modules/mail/internal/adapter/imap-smtp-provider.ts diff --git a/apps/api/src/modules/mail/__tests__/imap-helpers.test.ts b/apps/api/src/modules/mail/__tests__/imap-helpers.test.ts new file mode 100644 index 0000000..0eb0437 --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/imap-helpers.test.ts @@ -0,0 +1,68 @@ +/** IMAP/SMTP 适配器里不碰网络的那几段:设置默认值、内部时间兜底、fetch 结果排序、nodemailer 入参映射 */ +import { describe, expect, it } from 'vitest'; +import { imapSettingsOf, internalDateOf, mailOptionsOf, orderFetched } from '../internal/adapter/imap-smtp-provider.ts'; +import { imapCursor } from '../internal/domain/cursor.ts'; + +const account = { id: 'a', kind: 'imap-smtp' as const, address: 'sales@we.com', displayName: '销售部', settings: {}, secret: { password: 'pw' } }; + +describe('imapSettingsOf', () => { + it('空 settings 取腾讯默认;登录名缺省 = 地址', () => { + const s = imapSettingsOf(account); + expect(s.settings.imapHost).toBe('imap.exmail.qq.com'); + expect(s.settings.smtpPort).toBe(465); + expect(s.login).toBe('sales@we.com'); + expect(imapSettingsOf({ ...account, settings: { login: 'other@we.com' } }).login).toBe('other@we.com'); + }); + it('缺密码当场说清', () => { + expect(() => imapSettingsOf({ ...account, secret: {} })).toThrow('缺客户端专用密码'); + }); +}); + +describe('internalDateOf', () => { + const fallback = new Date('2026-08-25T10:00:00Z'); + it('Date 原样;字符串解得出就用;解不出 / 缺席用兜底', () => { + const d = new Date('2026-08-25T09:00:00Z'); + expect(internalDateOf(d, fallback)).toBe(d); + expect(internalDateOf('2026-08-25T09:00:00Z', fallback).toISOString()).toBe('2026-08-25T09:00:00.000Z'); + expect(internalDateOf('garbage', fallback)).toBe(fallback); + expect(internalDateOf(undefined, fallback)).toBe(fallback); + }); +}); + +describe('orderFetched', () => { + it('按 UID 升序,cursorAfter 就是自己的 UID;没 source 的丢', () => { + const rows = orderFetched('7', [ + { uid: 12, source: Buffer.from('b'), internalDate: undefined }, + { uid: 10, source: Buffer.from('a'), internalDate: undefined }, + { uid: 11, source: undefined, internalDate: undefined }, + ], new Date('2026-08-25T10:00:00Z')); + expect(rows.map((r) => r.providerRef)).toEqual(['7:10', '7:12']); + expect(rows[1]?.cursorAfter).toEqual(imapCursor('7', 12)); + expect(rows[0]?.eml).toBeInstanceOf(Uint8Array); + }); +}); + +describe('mailOptionsOf', () => { + it('Message-ID / 显示名 / 附件字节全部带上;没 html 就不带 html 键', () => { + const options = mailOptionsOf({ + messageId: '', + from: { address: 'sales@we.com', name: '销售部' }, + to: ['b@acme.com'], + cc: [], + bcc: ['c@we.com'], + subject: '报价', + text: '正文', + inReplyTo: '', + references: [''], + attachments: [{ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]) }], + }); + expect(options.messageId).toBe(''); + expect(options.from).toEqual({ name: '销售部', address: 'sales@we.com' }); + expect(options.bcc).toEqual(['c@we.com']); + expect(options.inReplyTo).toBe(''); + expect(options.references).toEqual(['']); + expect('html' in options).toBe(false); + expect(options.attachments?.[0]).toMatchObject({ filename: 'q.pdf', contentType: 'application/pdf' }); + expect(Buffer.isBuffer(options.attachments?.[0]?.content)).toBe(true); + }); +}); diff --git a/apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts b/apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts new file mode 100644 index 0000000..8a560d8 --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts @@ -0,0 +1,63 @@ +/** + * 真信箱集成测试 —— 只在给了 JCAI_MAIL_IT_IMAP_USER / _PASS 时跑(缺即跳过并告警)。 + * JCAI_MAIL_IT_IMAP_HOST(缺省 imap.exmail.qq.com)· JCAI_MAIL_IT_SMTP_HOST(缺省 smtp.exmail.qq.com) + * JCAI_MAIL_IT_SEND_TO:给了才真发一封(发给自己最省事) + */ +import { describe, expect, it } from 'vitest'; +import { createImapSmtpProvider } from '../internal/adapter/imap-smtp-provider.ts'; + +const user = process.env['JCAI_MAIL_IT_IMAP_USER']; +const pass = process.env['JCAI_MAIL_IT_IMAP_PASS']; +const enabled = user !== undefined && pass !== undefined; +if (!enabled) process.stderr.write('⚠️ IMAP/SMTP 真信箱集成测试跳过:未设 JCAI_MAIL_IT_IMAP_USER / JCAI_MAIL_IT_IMAP_PASS\n'); + +const account = { + id: 'it', + kind: 'imap-smtp' as const, + address: user ?? '', + displayName: 'JCAI IT', + settings: { + imapHost: process.env['JCAI_MAIL_IT_IMAP_HOST'] ?? 'imap.exmail.qq.com', + smtpHost: process.env['JCAI_MAIL_IT_SMTP_HOST'] ?? 'smtp.exmail.qq.com', + }, + secret: { password: pass ?? '' }, +}; +const known = async (): Promise> => new Set(); + +describe.skipIf(!enabled)('IMAP/SMTP provider(真信箱)', () => { + const provider = createImapSmtpProvider(); + + it('探测:收发都 ok', async () => { + const report = await provider.probe(account); + expect(report.receive).toMatchObject({ ok: true }); + expect(report.send).toMatchObject({ checked: true, ok: true }); + }, 60_000); + + it('锚定在当下;紧接着拉一轮是空的;回填 7 天能拉到东西(或至少不抛)', async () => { + const now = new Date(); + const cursor = await provider.anchor(account, null, now); + expect(cursor.kind).toBe('imap'); + const empty = await provider.pull(account, cursor, { limit: 5, now, backfillFrom: null, known }); + expect(empty.messages).toEqual([]); + const since = new Date(now.getTime() - 7 * 86_400_000); + const back = await provider.anchor(account, since, now); + const some = await provider.pull(account, back, { limit: 3, now, backfillFrom: since, known }); + expect(some.messages.length).toBeLessThanOrEqual(3); + for (const m of some.messages) expect(m.eml.byteLength).toBeGreaterThan(0); + }, 120_000); + + it.skipIf(process.env['JCAI_MAIL_IT_SEND_TO'] === undefined)('真发一封', async () => { + const result = await provider.send(account, { + messageId: ``, + from: { address: account.address, name: 'JCAI IT' }, + to: [process.env['JCAI_MAIL_IT_SEND_TO'] ?? ''], + cc: [], + bcc: [], + subject: 'JCAI mail IT', + text: '这是一封集成测试信', + references: [], + attachments: [], + }); + expect(result.providerRef ?? '').toContain('250'); + }, 60_000); +}); diff --git a/apps/api/src/modules/mail/index.ts b/apps/api/src/modules/mail/index.ts index c5d07b3..d5fbd58 100644 --- a/apps/api/src/modules/mail/index.ts +++ b/apps/api/src/modules/mail/index.ts @@ -84,3 +84,5 @@ export { createPostalMimeParser } from './internal/adapter/postal-mime-parser.ts export type { MailServiceDeps } from './internal/usecase/mail-service.ts'; export { createMailService } from './internal/usecase/mail-service.ts'; + +export { createImapSmtpProvider } from './internal/adapter/imap-smtp-provider.ts'; diff --git a/apps/api/src/modules/mail/internal/adapter/imap-smtp-provider.ts b/apps/api/src/modules/mail/internal/adapter/imap-smtp-provider.ts new file mode 100644 index 0000000..37610c4 --- /dev/null +++ b/apps/api/src/modules/mail/internal/adapter/imap-smtp-provider.ts @@ -0,0 +1,198 @@ +/** + * 标准协议适配器 —— imapflow 收、nodemailer 发(外界;设计稿 §5.5;官方帮助 open.work.weixin.qq.com/help2/pc/19886)。 + * + * 腾讯企业邮箱:imap.exmail.qq.com:993 / smtp.exmail.qq.com:465(SSL);账号 = 完整邮箱地址; + * 密码 = 邮箱密码,开了安全登录 / 微信绑定则是**客户端专用密码**。 + * 只读打开文件夹 —— 轮询不动人的信箱(不改已读标记)。 + * IMAP 的 `n:*` 在 n 大于最大 UID 时仍回最大那一封:UID 列表再按 `newUids` 过滤一遍。 + */ +import { ImapFlow } from 'imapflow'; +import nodemailer from 'nodemailer'; +import type Mail from 'nodemailer/lib/mailer'; +import { imapSmtpSettingsSchema } from '@contracts'; +import type { ImapSmtpSettings, MailProbeReport } from '@contracts'; +import { imapCursor, newUids } from '../domain/cursor.ts'; +import type { MailCursor } from '../domain/cursor.ts'; +import { errorText } from '../domain/redact.ts'; +import type { MailProvider, OutboundMessage, ProviderAccount, RawMessage } from '../../port.ts'; + +const CONNECT_TIMEOUT_MS = 30_000; +const SOCKET_TIMEOUT_MS = 60_000; + +export function imapSettingsOf(account: ProviderAccount): { settings: ImapSmtpSettings; login: string; password: string } { + const settings = imapSmtpSettingsSchema.parse(account.settings); + const password = account.secret['password']; + if (password === undefined || password === '') throw new Error('缺客户端专用密码(邮箱密码 / 授权码)'); + return { settings, login: settings.login ?? account.address, password }; +} + +export function internalDateOf(value: Date | string | undefined, fallback: Date): Date { + if (value instanceof Date) return value; + if (typeof value === 'string') { + const parsed = new Date(value); + if (!Number.isNaN(parsed.getTime())) return parsed; + } + return fallback; +} + +/** fetch 的到达顺序不保证升序 —— 按 UID 排好,cursorAfter 就是各自的 UID(逐封推进的依据) */ +export function orderFetched( + uidValidity: string, + rows: ReadonlyArray<{ uid: number; source: Buffer | undefined; internalDate: Date | string | undefined }>, + now: Date, +): RawMessage[] { + return rows + .filter((r): r is { uid: number; source: Buffer; internalDate: Date | string | undefined } => r.source !== undefined) + .sort((a, b) => a.uid - b.uid) + .map((r) => ({ + providerRef: `${uidValidity}:${r.uid}`, + eml: Uint8Array.from(r.source), + receivedAt: internalDateOf(r.internalDate, now), + cursorAfter: imapCursor(uidValidity, r.uid), + })); +} + +export function mailOptionsOf(message: OutboundMessage): Mail.Options { + return { + messageId: message.messageId, + from: { name: message.from.name, address: message.from.address }, + to: [...message.to], + cc: [...message.cc], + bcc: [...message.bcc], + subject: message.subject, + ...(message.text === undefined ? {} : { text: message.text }), + ...(message.html === undefined ? {} : { html: message.html }), + ...(message.inReplyTo === undefined ? {} : { inReplyTo: message.inReplyTo }), + references: [...message.references], + attachments: message.attachments.map((a) => ({ filename: a.name, content: Buffer.from(a.bytes), contentType: a.contentType })), + }; +} + +function transporterOf(account: ProviderAccount) { + const { settings, login, password } = imapSettingsOf(account); + return nodemailer.createTransport({ + host: settings.smtpHost, + port: settings.smtpPort, + secure: settings.smtpTls, + requireTLS: !settings.smtpTls, + auth: { user: login, pass: password }, + connectionTimeout: CONNECT_TIMEOUT_MS, + greetingTimeout: CONNECT_TIMEOUT_MS, + socketTimeout: SOCKET_TIMEOUT_MS, + }); +} + +async function withImap(account: ProviderAccount, fn: (client: ImapFlow, folder: string) => Promise): Promise { + const { settings, login, password } = imapSettingsOf(account); + const client = new ImapFlow({ + host: settings.imapHost, + port: settings.imapPort, + secure: settings.imapTls, + auth: { user: login, pass: password }, + logger: false, + connectionTimeout: CONNECT_TIMEOUT_MS, + socketTimeout: SOCKET_TIMEOUT_MS, + }); + await client.connect(); + try { + return await fn(client, settings.folder); + } finally { + await client.logout().catch(() => client.close()); + } +} + +function mailboxOf(client: ImapFlow): { uidValidity: string; uidNext: number } { + const box = client.mailbox; + if (box === false) throw new Error('文件夹未打开'); + return { uidValidity: String(box.uidValidity), uidNext: box.uidNext }; +} + +async function timed(run: () => Promise): Promise<{ ok: true; latencyMs: number } | { ok: false; error: string }> { + const started = Date.now(); + try { + await run(); + return { ok: true, latencyMs: Date.now() - started }; + } catch (error) { + return { ok: false, error: errorText(error) }; + } +} + +export function createImapSmtpProvider(): MailProvider { + return { + kind: 'imap-smtp', + + async probe(account): Promise { + const receive = await timed(() => + withImap(account, async (client, folder) => { + const lock = await client.getMailboxLock(folder, { readOnly: true }); + lock.release(); + }), + ); + const send = await timed(async () => { + const transporter = transporterOf(account); + try { + await transporter.verify(); + } finally { + transporter.close(); + } + }); + return { + kind: 'imap-smtp', + address: account.address, + receive: receive.ok ? { ok: true, latencyMs: receive.latencyMs } : { ok: false, error: receive.error }, + send: send.ok ? { checked: true, ok: true } : { checked: true, ok: false, error: send.error }, + }; + }, + + async anchor(account, backfillFrom): Promise { + return withImap(account, async (client, folder) => { + const lock = await client.getMailboxLock(folder, { readOnly: true }); + try { + const box = mailboxOf(client); + if (backfillFrom === null) return imapCursor(box.uidValidity, box.uidNext - 1); + const uids = await client.search({ since: backfillFrom }, { uid: true }); + const first = uids === false || uids.length === 0 ? box.uidNext : Math.min(...uids); + return imapCursor(box.uidValidity, first - 1); + } finally { + lock.release(); + } + }); + }, + + async pull(account, cursor, opts) { + return withImap(account, async (client, folder) => { + const lock = await client.getMailboxLock(folder, { readOnly: true }); + try { + const box = mailboxOf(client); + if (cursor.kind !== 'imap' || cursor.uidValidity !== box.uidValidity) { + // UIDVALIDITY 变了(信箱重建 / 迁移):旧 UID 全作废,重锚在当下、不回填;万一重复由唯一键挡 + return { messages: [], cursor: imapCursor(box.uidValidity, box.uidNext - 1), more: false }; + } + const found = await client.search({ uid: `${cursor.lastUid + 1}:*` }, { uid: true }); + const fresh = newUids(found === false ? [] : found, cursor.lastUid); + const wanted = fresh.slice(0, opts.limit); + const rows: Array<{ uid: number; source: Buffer | undefined; internalDate: Date | string | undefined }> = []; + if (wanted.length > 0) { + for await (const msg of client.fetch(wanted, { uid: true, source: true, internalDate: true }, { uid: true })) { + rows.push({ uid: msg.uid, source: msg.source, internalDate: msg.internalDate }); + } + } + const messages = orderFetched(box.uidValidity, rows, opts.now); + return { messages, cursor: messages.at(-1)?.cursorAfter ?? cursor, more: fresh.length > wanted.length }; + } finally { + lock.release(); + } + }); + }, + + async send(account, message) { + const transporter = transporterOf(account); + try { + const info = await transporter.sendMail(mailOptionsOf(message)); + return { providerRef: info.response }; + } finally { + transporter.close(); + } + }, + }; +}