mail adapter:IMAP/SMTP provider(只读打开 / UID 游标 / n:* 过滤 / UIDVALIDITY 重锚 / nodemailer 发信)+ env 门控真信箱 IT

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
This commit is contained in:
xcs
2026-08-26 01:47:24 +08:00
co-authored by Claude Fable 5
parent f04c64926b
commit 7afc40987b
4 changed files with 331 additions and 0 deletions
@@ -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: '<o1@we.com>',
from: { address: 'sales@we.com', name: '销售部' },
to: ['b@acme.com'],
cc: [],
bcc: ['c@we.com'],
subject: '报价',
text: '正文',
inReplyTo: '<m1@acme.com>',
references: ['<m1@acme.com>'],
attachments: [{ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]) }],
});
expect(options.messageId).toBe('<o1@we.com>');
expect(options.from).toEqual({ name: '销售部', address: 'sales@we.com' });
expect(options.bcc).toEqual(['c@we.com']);
expect(options.inReplyTo).toBe('<m1@acme.com>');
expect(options.references).toEqual(['<m1@acme.com>']);
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);
});
});
@@ -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<Set<string>> => new Set<string>();
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: `<it-${Date.now()}@${account.address.split('@')[1] ?? 'local'}>`,
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);
});
+2
View File
@@ -84,3 +84,5 @@ export { createPostalMimeParser } from './internal/adapter/postal-mime-parser.ts
export type { MailServiceDeps } from './internal/usecase/mail-service.ts'; export type { MailServiceDeps } from './internal/usecase/mail-service.ts';
export { createMailService } from './internal/usecase/mail-service.ts'; export { createMailService } from './internal/usecase/mail-service.ts';
export { createImapSmtpProvider } from './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<T>(account: ProviderAccount, fn: (client: ImapFlow, folder: string) => Promise<T>): Promise<T> {
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<void>): 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<MailProbeReport> {
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<MailCursor> {
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();
}
},
};
}