mail adapter:企业微信邮件 API provider(token 缓存与刷新 / 翻页 / 已知 mail_id 不再读 / EML 编码兜底 / compose_send)

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:50:11 +08:00
co-authored by Claude Fable 5
parent 7afc40987b
commit 5bd92f2e93
3 changed files with 333 additions and 0 deletions
@@ -0,0 +1,147 @@
/** 企业微信邮件 API 适配器 —— 对假 fetch:token 缓存 / 过期刷新 / -1 重试 / 翻页 / 已知 mail_id 不再 read_mail / EML 编码兜底 / compose_send 载荷 */
import { describe, expect, it } from 'vitest';
import { createWecomProvider, WecomApiError } from '../internal/adapter/wecom-provider.ts';
import type { FetchLike } from '../internal/adapter/wecom-provider.ts';
import { wecomCursor } from '../internal/domain/cursor.ts';
type Call = { url: string; body: Record<string, unknown> };
function scripted(handlers: Record<string, (body: Record<string, unknown>, nth: number) => unknown>): { fetch: FetchLike; calls: Call[] } {
const calls: Call[] = [];
const counts = new Map<string, number>();
const fetch: FetchLike = async (url, init) => {
const body = init?.body === undefined ? {} : (JSON.parse(init.body) as Record<string, unknown>);
calls.push({ url, body });
const key = Object.keys(handlers).find((k) => url.includes(k));
if (key === undefined) return { ok: false, status: 404, json: async () => ({}) };
const nth = (counts.get(key) ?? 0) + 1;
counts.set(key, nth);
return { ok: true, status: 200, json: async () => handlers[key]!(body, nth) };
};
return { fetch, calls };
}
const account = { id: 'w', kind: 'wecom' as const, address: '', displayName: '应用', settings: { corpId: 'ww1' }, secret: { corpSecret: 'cs-1' } };
const known = async (): Promise<Set<string>> => new Set<string>();
const now = new Date('2026-08-25T10:00:00Z');
const EML = 'From: a@x.com\r\nTo: b@y.com\r\nSubject: hi\r\n\r\nbody\r\n';
describe('token', () => {
it('第一次取 token 之后缓存;过期前 5 分钟重取;42001 刷一次重试', async () => {
let clock = 0;
let issued = 0;
const { fetch, calls } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: `tok-${(issued += 1)}`, expires_in: 7200 }),
'get_email_alias': (_b, nth) => (nth === 2 ? { errcode: 42001, errmsg: 'access_token expired' } : { errcode: 0, email: 'app@corp.wecom.work' }),
});
const provider = createWecomProvider({ fetch, now: () => clock });
await provider.probe(account);
await provider.probe(account);
expect(calls.filter((c) => c.url.includes('gettoken'))).toHaveLength(2); // 第二次 alias 回 42001 → 刷一次
clock = 7200_000 - 4 * 60_000; // 离过期不到 5 分钟
await provider.probe(account);
expect(calls.filter((c) => c.url.includes('gettoken'))).toHaveLength(3);
expect(calls[0]?.url).toContain('corpid=ww1');
expect(calls[0]?.url).toContain('corpsecret=cs-1');
});
it('-1 系统繁忙重试 ≤3 次;别的错误码抛 WecomApiError 带 api 名', async () => {
let busy = 2;
const { fetch } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
'get_email_alias': () => (busy-- > 0 ? { errcode: -1, errmsg: 'system busy' } : { errcode: 48002, errmsg: 'api forbidden' }),
});
const report = await createWecomProvider({ fetch }).probe(account);
expect(report.receive.ok).toBe(false);
expect(report.receive.error).toContain('get_email_alias');
expect(report.receive.error).toContain('48002');
expect(report.send).toMatchObject({ checked: false });
await expect(
createWecomProvider({ fetch: scripted({ '/cgi-bin/gettoken': () => ({ errcode: 40001, errmsg: 'invalid credential' }) }).fetch }).pull(account, wecomCursor(1), { limit: 5, now, backfillFrom: null, known }),
).rejects.toBeInstanceOf(WecomApiError);
});
});
describe('probe / anchor', () => {
it('探到应用邮箱地址;发信一栏 checked=false', async () => {
const { fetch } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
'get_email_alias': () => ({ errcode: 0, email: 'app@corp.wecom.work', alias_list: [] }),
});
const report = await createWecomProvider({ fetch }).probe(account);
expect(report).toMatchObject({ kind: 'wecom', address: 'app@corp.wecom.work', receive: { ok: true }, send: { checked: false } });
});
it('锚定 = 当下(或回填起点)的秒', async () => {
const provider = createWecomProvider({ fetch: scripted({}).fetch });
expect(await provider.anchor(account, null, now)).toEqual(wecomCursor(Math.floor(now.getTime() / 1000)));
expect(await provider.anchor(account, new Date('2026-08-20T00:00:00Z'), now)).toEqual(wecomCursor(Math.floor(new Date('2026-08-20T00:00:00Z').getTime() / 1000)));
});
});
describe('pull', () => {
it('窗口 = lastTime−600 到当下;翻页;库里已有的 mail_id 不再 read_mail;EML 原文与 base64 都解得出;more 反映还没翻完', async () => {
const { fetch, calls } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
'get_mail_list': (body) =>
body['cursor'] === undefined
? { errcode: 0, next_cursor: 'c2', has_more: 1, mail_list: [{ mail_id: 'm1' }, { mail_id: 'm2' }] }
: { errcode: 0, next_cursor: '', has_more: 0, mail_list: [{ mail_id: 'm3' }] },
'read_mail': (body) => ({ errcode: 0, mail_data: body['mail_id'] === 'm3' ? Buffer.from(EML).toString('base64') : EML }),
});
const provider = createWecomProvider({ fetch });
const cursor = wecomCursor(Math.floor(now.getTime() / 1000) - 1000);
const result = await provider.pull(account, cursor, { limit: 10, now, backfillFrom: null, known: async (refs) => new Set(refs.filter((r) => r === 'm2')) });
const listCall = calls.find((c) => c.url.includes('get_mail_list'))?.body;
expect(listCall).toMatchObject({ begin_time: cursor.lastTime - 600, end_time: Math.floor(now.getTime() / 1000) });
expect(result.messages.map((m) => m.providerRef)).toEqual(['m1', 'm3']);
expect(new TextDecoder().decode(result.messages[1]?.eml)).toBe(EML);
expect(calls.filter((c) => c.url.includes('read_mail')).map((c) => c.body['mail_id'])).toEqual(['m1', 'm3']);
expect(result.cursor).toEqual(wecomCursor(Math.floor(now.getTime() / 1000)));
expect(result.more).toBe(false);
});
it('取满 limit 就停,more=true', async () => {
const { fetch } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
'get_mail_list': () => ({ errcode: 0, next_cursor: 'c2', has_more: 1, mail_list: [{ mail_id: 'm1' }, { mail_id: 'm2' }] }),
'read_mail': () => ({ errcode: 0, mail_data: EML }),
});
const cursor = wecomCursor(1);
const result = await createWecomProvider({ fetch }).pull(account, cursor, { limit: 2, now, backfillFrom: null, known });
expect(result.messages).toHaveLength(2);
expect(result.more).toBe(true);
// **截断时游标原样退回**:没读到的那几封不能被跳过去(重叠窗兜不住一次积压)
expect(result.cursor).toEqual(cursor);
});
});
describe('send', () => {
it('compose_send 载荷:emails 三组、html 优先、附件 base64;成功不回 providerRef', async () => {
const { fetch, calls } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
'compose_send': () => ({ errcode: 0, errmsg: 'ok' }),
});
const result = await createWecomProvider({ fetch }).send(account, {
messageId: '<x@y>',
from: { address: 'app@corp.wecom.work', name: '应用' },
to: ['a@x.com'],
cc: ['b@x.com'],
bcc: [],
subject: '报价',
text: '纯文本',
html: '<p>html</p>',
references: [],
attachments: [{ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]) }],
});
expect(result).toEqual({});
const payload = calls.find((c) => c.url.includes('compose_send'))?.body;
expect(payload).toEqual({
to: { emails: ['a@x.com'] },
cc: { emails: ['b@x.com'] },
subject: '报价',
content: '<p>html</p>',
content_type: 'html',
attachment_list: [{ file_name: 'q.pdf', content: Buffer.from([1, 2]).toString('base64') }],
});
});
});
+3
View File
@@ -86,3 +86,6 @@ 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'; export { createImapSmtpProvider } from './internal/adapter/imap-smtp-provider.ts';
export type { FetchLike } from './internal/adapter/wecom-provider.ts';
export { createWecomProvider, WecomApiError } from './internal/adapter/wecom-provider.ts';
@@ -0,0 +1,183 @@
/**
* 企业微信邮件 API 适配器(外界;设计稿 §5.5;文档核实 2026-08-25):
* gettoken(corpid, corpsecret) → access_token(7200s)
* exmail/app/get_email_alias → 应用邮箱地址
* exmail/app/get_mail_list(begin_time, end_time, cursor, limit ≤ 1000) → mail_id(只有应用邮箱自己的收件箱)
* exmail/app/read_mail(mail_id) → mail_data(原始 EML;**文档未说编码** —— 像 EML 按原文,否则当 base64)
* exmail/app/compose_send → 发件人恒为应用邮箱;**不回 Message-ID**(线程级归属对它发出的信不成立)
* token 缓存在实例闭包里按账号存(不是模块级状态);提前 5 分钟刷新;40014 / 42001 刷一次重试;-1 重试 ≤3 次。
*/
import { wecomSettingsSchema } from '@contracts';
import type { MailProbeReport } from '@contracts';
import { wecomCursor, wecomWindow } from '../domain/cursor.ts';
import type { MailCursor } from '../domain/cursor.ts';
import { errorText } from '../domain/redact.ts';
import type { MailProvider, ProviderAccount, RawMessage } from '../../port.ts';
export type FetchLike = (
url: string,
init?: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal },
) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>;
type WecomResponse = Record<string, unknown> & { errcode?: number; errmsg?: string };
export class WecomApiError extends Error {
constructor(
readonly errcode: number,
readonly errmsg: string,
readonly api: string,
) {
super(`企业微信 ${api} 失败:errcode ${errcode} ${errmsg}`);
this.name = 'WecomApiError';
}
}
const TOKEN_EARLY_MS = 5 * 60_000;
const BUSY_RETRIES = 3;
const REQUEST_TIMEOUT_MS = 30_000;
const LIST_PAGE = 100;
/** 文档只说「邮件 eml 内容」:头部行的形(`Name: value`)就按原文;否则当 base64 */
export function emlBytesOf(mailData: string): Uint8Array {
const looksLikeEml = /^[A-Za-z][A-Za-z0-9-]*:\s/m.test(mailData.slice(0, 2048));
return looksLikeEml ? new TextEncoder().encode(mailData) : new Uint8Array(Buffer.from(mailData, 'base64'));
}
export function createWecomProvider(deps: { fetch?: FetchLike; now?: () => number } = {}): MailProvider {
const doFetch: FetchLike = deps.fetch ?? ((url, init) => fetch(url, init));
const nowMs = deps.now ?? (() => Date.now());
const tokens = new Map<string, { token: string; expiresAt: number }>();
async function call(api: string, url: string, init?: Parameters<FetchLike>[1]): Promise<WecomResponse> {
for (let attempt = 0; ; attempt += 1) {
const res = await doFetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
if (!res.ok) throw new Error(`企业微信 ${api} HTTP ${res.status}`);
const body = (await res.json()) as WecomResponse;
const code = typeof body.errcode === 'number' ? body.errcode : 0;
if (code === 0) return body;
if (code === -1 && attempt < BUSY_RETRIES) continue;
throw new WecomApiError(code, String(body.errmsg ?? ''), api);
}
}
async function tokenOf(account: ProviderAccount, force = false): Promise<string> {
const cached = tokens.get(account.id);
if (!force && cached !== undefined && cached.expiresAt - TOKEN_EARLY_MS > nowMs()) return cached.token;
const settings = wecomSettingsSchema.parse(account.settings);
const secret = account.secret['corpSecret'];
if (secret === undefined || secret === '') throw new Error('缺应用 secret');
const body = await call(
'gettoken',
`${settings.apiBase}/cgi-bin/gettoken?corpid=${encodeURIComponent(settings.corpId)}&corpsecret=${encodeURIComponent(secret)}`,
{ method: 'GET' },
);
const token = typeof body['access_token'] === 'string' ? body['access_token'] : '';
if (token === '') throw new Error('企业微信 gettoken 没有返回 access_token');
const expiresIn = typeof body['expires_in'] === 'number' ? body['expires_in'] : 7200;
tokens.set(account.id, { token, expiresAt: nowMs() + expiresIn * 1000 });
return token;
}
async function withToken(account: ProviderAccount, api: string, path: string, payload?: unknown): Promise<WecomResponse> {
const settings = wecomSettingsSchema.parse(account.settings);
const run = (token: string): Promise<WecomResponse> =>
call(
api,
`${settings.apiBase}${path}?access_token=${encodeURIComponent(token)}`,
payload === undefined
? { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }
: { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) },
);
try {
return await run(await tokenOf(account));
} catch (error) {
if (error instanceof WecomApiError && (error.errcode === 40014 || error.errcode === 42001)) {
return run(await tokenOf(account, true));
}
throw error;
}
}
return {
kind: 'wecom',
async probe(account): Promise<MailProbeReport> {
const started = nowMs();
try {
const alias = await withToken(account, 'get_email_alias', '/cgi-bin/exmail/app/get_email_alias');
const address = typeof alias['email'] === 'string' ? alias['email'] : '';
return {
kind: 'wecom',
address,
receive: { ok: true, latencyMs: nowMs() - started },
send: { checked: false, note: '凭证有效;发信权限只能在真发时验证(compose_send 没有只验不发的口)' },
};
} catch (error) {
return { kind: 'wecom', receive: { ok: false, error: errorText(error) }, send: { checked: false, note: '收信凭证未通过,发信未验' } };
}
},
async anchor(_account, backfillFrom, now): Promise<MailCursor> {
return wecomCursor(Math.floor((backfillFrom ?? now).getTime() / 1000));
},
async pull(account, cursor, opts) {
const { beginTime, endTime } = wecomWindow(cursor, opts.backfillFrom, opts.now);
/*
* **先滤库里已有的,再数够不够** —— 顺序反了会卡死:一个窗口里若前 limit 条恰好都已入库,
* 这一轮就交出 0 封,而引擎见空批即收工、游标又没动,下一轮还是这 0 封,永远吃不到后面的新信。
* 滤在前,每一轮都实实在在往前吃 limit 封**新**的。
*/
const fresh: string[] = [];
let pageCursor: string | undefined;
let exhausted = false;
while (fresh.length < opts.limit) {
const page = await withToken(account, 'get_mail_list', '/cgi-bin/exmail/app/get_mail_list', {
begin_time: beginTime,
end_time: endTime,
...(pageCursor === undefined ? {} : { cursor: pageCursor }),
limit: LIST_PAGE,
});
const list = Array.isArray(page['mail_list']) ? (page['mail_list'] as Array<{ mail_id?: unknown }>) : [];
const ids = list.flatMap((item) => (typeof item.mail_id === 'string' ? [item.mail_id] : []));
const known = await opts.known(ids);
for (const id of ids) if (!known.has(id)) fresh.push(id);
const hasMore = page['has_more'] === 1 || page['has_more'] === true;
pageCursor = typeof page['next_cursor'] === 'string' && page['next_cursor'] !== '' ? page['next_cursor'] : undefined;
if (!hasMore || list.length === 0 || pageCursor === undefined) {
exhausted = true;
break;
}
}
const wanted = fresh.slice(0, opts.limit);
const messages: RawMessage[] = [];
for (const mailId of wanted) {
const mail = await withToken(account, 'read_mail', '/cgi-bin/exmail/app/read_mail', { mail_id: mailId });
const data = mail['mail_data'];
if (typeof data !== 'string' || data === '') continue;
messages.push({ providerRef: mailId, eml: emlBytesOf(data), receivedAt: opts.now });
}
/*
* 游标**只在把这个窗口翻完时**才推到窗口上沿;被 limit 截断时原样退回旧游标 ——
* 否则没读到的那几封就被跳过去了(重叠窗只兜 10 分钟,兜不住一次积压)。
* 退回不会卡住:已入库的下一轮被 `known` 滤掉,每轮照旧往前吃一批。
*/
return { messages, cursor: exhausted ? wecomCursor(endTime) : cursor, more: !exhausted || fresh.length > wanted.length };
},
async send(account, message) {
await withToken(account, 'compose_send', '/cgi-bin/exmail/app/compose_send', {
to: { emails: [...message.to] },
...(message.cc.length === 0 ? {} : { cc: { emails: [...message.cc] } }),
...(message.bcc.length === 0 ? {} : { bcc: { emails: [...message.bcc] } }),
subject: message.subject,
content: message.html ?? message.text ?? '',
content_type: message.html !== undefined ? 'html' : 'text',
...(message.attachments.length === 0
? {}
: { attachment_list: message.attachments.map((a) => ({ file_name: a.name, content: Buffer.from(a.bytes).toString('base64') })) }),
});
return {};
},
};
}