Archived
全分支终审(跨任务视角)逮出的十条 P0 + 八条 P1,一次修完。每条都配了测试。 P0-1 企业微信 token 缓存改按**凭证指纹**(sha256(corpId,corpSecret),secret 不进 Map 的键): 按 account.id 存有两条真实串号路径 —— 草稿探测共用 'draft'(探完 A 公司再探 B 公司会命中 A 的 token,回的是 A 的应用邮箱地址,凭证填错的 B 也显示成功);已存账号换了凭证 id 不变, 下一轮轮询继续拿旧 token 拉旧 corp 的收件箱,分发给新账号绑的通道(窗口 ≤ 7200s)。 P0-2 一封「毒信」不许卡死整条路:①domain 的 buildInboundRecord 把落 text/jsonb 的字段一律剥 NUL (PG 拒收它,MIME 解出二进制垃圾时真会出现);②单封失败先落**降级骨架**(原件原样存、 主题从原始字节扒、正文写明失败原因)→ 落成了就推进游标并把「第 N 封(providerRef)解析失败, 已降级存档」写进 last_poll_error;骨架也落不下才停在这一封,那时 error **点名是哪一封**。 降级行不自动分发(正文是失败原因,不是客户的话)。 P0-3 cursorReset 只比**收侧**键(imapHost/imapPort/imapTls/login/folder;corpId/apiBase): 从前比全部 settings,把 465 改成 587 也会清游标 → IMAP 重锚 → 上次游标到重锚之间的来信永远不拉。 P0-4 复用旧行重发**先抢**(OutboundStore.claimForResend,一条条件更新;真 store 与内存件同款): 两个后端连同一个库,两台同时重放同一次放行都判「超时可复用」→ 两封真邮件,破掉「同键不发两封」。 P0-5 列表与只要元信息的读**不取 raw**(一封上限 16MB × 200 行);rawAvailable 由 `raw is not null` 投影,不靠取回字节再判;只有「下载原件」走全列的 get()。InboundRecord 新增 rawAvailable。 P0-6 账号 save() 只写**配置列**:运行列(cursor/last_polled_at/last_poll_error/last_inbound_at/ polling_until)只经 savePoll/claimPolling/releasePolling 改 —— 整行 upsert 会把另一台正持着的租约 与刚推进的游标盖回读那一刻的快照(08-24 loop_state 整份覆盖同款指纹)。清游标改为显式单写那一列。 P0-7 UIDVALIDITY 变化不再静默:provider 回 note,用例落进 last_poll_error(「已重锚在当下; 期间来信未拉取」)—— 丢了一段信而账号页一片绿是界面撒谎。 P0-8 parseCursor 连取值范围一起查(非负安全整数 + 界内:UID ≤ 0xFFFFFFFF、秒 ≤ Date 上限)—— 1e13 正是安全整数,乘 1000 出界,一行脏游标能让 listAccounts 整页 500。debt ⑨ 划线销账。 P0-9 线程口径归一:threadIdOf 改成**取根**(references[0] ?? inReplyTo,ADR-013 决策 3), mail-wiring 调它、不再内联一份。原测试改断言,不删。 P0-10 文档说老实话:mail.send 工具路发出的信**不登记 outbound_ref**(recordOutbound 的唯一调用方 channel.sendMail 在 src 里零调用方)—— API 文档 §4 与 ADR-013 决策 6 各补一段,根治记 debt。 P1:imap anchor 的 Math.min(...uids) 改 reduce(十万级 UID 会栈溢出)· wecom 翻页加页数上限 (服务端一直回 has_more:1 会死循环)· MAIL_TOOL 导出并对**真契约**断言(原 v1/v2 是手抄夹具、 自证,且 v1 版本号写错)· reset-runs 清运行数据时把邮箱账号的游标与拉取状态一并清(留着游标而信没了, wecom 十分钟重叠窗会把最近的信当新信再投一次)· due.test 的 leaseMsFor(30) 断言改写死 300_000(原为自证)· cursor.test 删掉指向评审报告的注释 · 设计稿稿头标注三处已被 ADR-013 取代 · ADR-013 补记 wecom compose_send 不带 inReplyTo/references(它发出的信在对方那里不串线程)。 契约变更(§12.4):port.ts 的 InboundStore 加 getMeta、OutboundStore 加 claimForResend、 savePoll 的 cursor 允许显式 null、MailProvider.pull 回参可带 note —— 实现方只有本模块 adapter 与 __tests__/fakes.ts 的内存件(两边语义一致,已同改),CONTRACT.md 已同步,api-report 已刷新。 测试:1922 → 1942(mail 模块 102 → 113,含真 PG 的 save 不盖运行列 / 不搬原件 / 两条真连接抢同一行)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
159 lines
7.5 KiB
TypeScript
159 lines
7.5 KiB
TypeScript
/**
|
|
* mail 与 channel / toolbox 的接线(app 层;设计稿 §8;ADR-013):
|
|
* ① channel 订阅方:入站 → channel.ingestMail(账号绑的通道)—— 活动经现有事件链收到信
|
|
* ② channel 的 MailTransport:出站 → mail.send(按 from 找账号,对不上不回落默认)
|
|
* ③ `mail.send` 工具处理器:活动侧出站,放行时经 ToolInvoker 执行;dryRun 只校验不发(注册试运行会真调处理器)
|
|
* 三段都是纯映射 + 一次调用,单测直接对函数。
|
|
*/
|
|
import type { InboundMail, MailSendInput } from '@contracts';
|
|
import type { MailTransport } from '@modules/channel';
|
|
import type { InboundHandler, MailInboundDetail, MailService } from '@modules/mail';
|
|
import {
|
|
bodyTextOf,
|
|
MailAccountDisabledError,
|
|
MailAccountNotFoundError,
|
|
MailNoDefaultAccountError,
|
|
MailSecretMissingError,
|
|
MailSendInvalidError,
|
|
threadIdOf,
|
|
} from '@modules/mail';
|
|
import type { ToolHandler } from '@modules/toolbox';
|
|
|
|
/**
|
|
* mail 的入站详情 → channel 的 InboundMail:只带存下来的附件。
|
|
*
|
|
* **线程口径只有一处**:`threadIdOf`(mail 模块的 domain,取这一串的根;缘由与反例见那里)。
|
|
* 这里曾经内联过一份同义实现,而模块里那一份当时取的是最近一跳 —— 同一个模块两套相反的语义,
|
|
* 下一个人照导出的那个接线就会接反。现在调它。
|
|
*/
|
|
export function toInboundMail(mail: MailInboundDetail): InboundMail {
|
|
const threadId = threadIdOf(mail);
|
|
return {
|
|
messageId: mail.messageId,
|
|
...(threadId === undefined ? {} : { threadId }),
|
|
from: mail.from.address,
|
|
to: mail.to.map((a) => a.address),
|
|
cc: mail.cc.map((a) => a.address),
|
|
subject: mail.subject,
|
|
body: bodyTextOf({ ...(mail.text === null ? {} : { text: mail.text }), ...(mail.html === null ? {} : { html: mail.html }) }),
|
|
receivedAt: mail.receivedAt,
|
|
attachments: mail.attachments.flatMap((a) =>
|
|
a.ref === null ? [] : [{ name: a.name, contentType: a.contentType, size: a.size, ref: a.ref }],
|
|
),
|
|
};
|
|
}
|
|
|
|
/** 只要 `ingestMail` 这一个口,出参不看(InboundOutcome 由 channel 自己留痕) */
|
|
export function channelSubscriber(channel: { ingestMail(channelId: string, mail: InboundMail): Promise<unknown> }): InboundHandler {
|
|
return async (mail, ctx) => {
|
|
if (ctx.channelId === undefined) return { skipped: '账号未绑定通道' };
|
|
await channel.ingestMail(ctx.channelId, toInboundMail(mail));
|
|
// 显式回 undefined —— 投递成功就是「没话说」;`noImplicitReturns` 不许一条路返值一条路掉出去
|
|
return undefined;
|
|
};
|
|
}
|
|
|
|
/** channel 的出站面:厂商失败抛出 —— channel.sendMail 的调用方(放行落地)要看到失败 */
|
|
export function channelTransport(mail: Pick<MailService, 'send'>): MailTransport {
|
|
return {
|
|
async send(input) {
|
|
const result = await mail.send({
|
|
from: input.from,
|
|
to: [...input.to],
|
|
cc: [...input.cc],
|
|
bcc: [],
|
|
subject: input.subject,
|
|
text: input.body,
|
|
...(input.threadId === undefined ? {} : { inReplyTo: input.threadId }),
|
|
references: input.threadId === undefined ? [] : [input.threadId],
|
|
attachments: input.attachments.map((a) => ({ name: a.name, contentType: 'application/octet-stream', uploadRef: a.ref })),
|
|
requestedBy: { kind: 'channel' },
|
|
});
|
|
if (result.state === 'failed') throw new Error(`发信失败:${result.error ?? '未知'}`);
|
|
return { messageId: result.messageId, threadId: input.threadId ?? result.messageId };
|
|
},
|
|
};
|
|
}
|
|
|
|
function recipientsOf(value: unknown): string[] {
|
|
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string').map((v) => v.trim()).filter((v) => v !== '');
|
|
if (typeof value === 'string') return value.split(/[,;]/).map((s) => s.trim()).filter((s) => s !== '');
|
|
return [];
|
|
}
|
|
|
|
function attachmentsOf(value: unknown): MailSendInput['attachments'] {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.flatMap((v) => {
|
|
if (typeof v !== 'object' || v === null) return [];
|
|
const a = v as Record<string, unknown>;
|
|
const uploadRef = typeof a['uploadRef'] === 'string' && a['uploadRef'] !== '' ? a['uploadRef'] : undefined;
|
|
// 没给 uploadRef 的附件**不静默丢掉**:活动说「带上 q.pdf」,信发了附件没了还回 queued:true,是界面撒谎
|
|
if (uploadRef === undefined) throw new MailSendInvalidError([`附件 ${String(a['name'] ?? '(无名)')} 没给 uploadRef`]);
|
|
const name = typeof a['name'] === 'string' && a['name'] !== '' ? a['name'] : uploadRef;
|
|
return [{ name, contentType: 'application/octet-stream', uploadRef }];
|
|
});
|
|
}
|
|
|
|
export function mailSendTool(mail: Pick<MailService, 'send'>): ToolHandler {
|
|
return async ({ input, connection, idempotencyKey }) => {
|
|
const to = recipientsOf(input['to']);
|
|
const cc = recipientsOf(input['cc']);
|
|
const bcc = recipientsOf(input['bcc']);
|
|
const subject = typeof input['subject'] === 'string' ? input['subject'] : '';
|
|
const body = typeof input['body'] === 'string' && input['body'] !== '' ? input['body'] : undefined;
|
|
const html = typeof input['html'] === 'string' && input['html'] !== '' ? input['html'] : undefined;
|
|
const inReplyTo = typeof input['inReplyTo'] === 'string' && input['inReplyTo'] !== '' ? input['inReplyTo'] : undefined;
|
|
// 账号:入参指定 > 连接配置('default' = 默认账号)
|
|
const configured = connection['account'];
|
|
const accountId =
|
|
typeof input['accountId'] === 'string' && input['accountId'] !== ''
|
|
? input['accountId']
|
|
: configured !== undefined && configured !== '' && configured !== 'default'
|
|
? configured
|
|
: undefined;
|
|
|
|
if (input['dryRun'] === true) {
|
|
const problems = [
|
|
...(to.length === 0 ? ['收件人至少一个'] : []),
|
|
...(subject === '' ? ['主题不能为空'] : []),
|
|
...(body === undefined && html === undefined ? ['正文 body / html 至少一个'] : []),
|
|
];
|
|
return problems.length === 0
|
|
? { ok: true, output: { queued: false, dryRun: true } }
|
|
: { ok: false, error: { code: 'invalid', retryable: false, message: problems.join('; ') } };
|
|
}
|
|
|
|
try {
|
|
const result = await mail.send({
|
|
...(accountId === undefined ? {} : { accountId }),
|
|
to,
|
|
cc,
|
|
bcc,
|
|
subject,
|
|
...(body === undefined ? {} : { text: body }),
|
|
...(html === undefined ? {} : { html }),
|
|
...(inReplyTo === undefined ? {} : { inReplyTo }),
|
|
references: inReplyTo === undefined ? [] : [inReplyTo],
|
|
attachments: attachmentsOf(input['attachments']),
|
|
...(idempotencyKey === undefined ? {} : { idempotencyKey }),
|
|
requestedBy: { kind: 'tool', tool: 'mail.send' },
|
|
});
|
|
if (result.state === 'failed') {
|
|
return { ok: false, error: { code: 'send-failed', retryable: true, message: result.error ?? '发信失败' } };
|
|
}
|
|
return { ok: true, output: { queued: true, outboundId: result.outboundId, messageId: result.messageId, state: result.state } };
|
|
} catch (error) {
|
|
if (error instanceof MailSendInvalidError) return { ok: false, error: { code: 'invalid', retryable: false, message: error.message } };
|
|
if (
|
|
error instanceof MailNoDefaultAccountError ||
|
|
error instanceof MailAccountNotFoundError ||
|
|
error instanceof MailAccountDisabledError ||
|
|
error instanceof MailSecretMissingError
|
|
) {
|
|
return { ok: false, error: { code: 'no-account', retryable: false, message: error.message } };
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
}
|