Archived
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:
@@ -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);
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user