mail.send 工具契约 2.0.0(to 数组 / html / 附件 / inReplyTo / dryRun;保留 v1 的 body 与 queued);契约快照刷新,五道门禁绿

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 02:23:24 +08:00
co-authored by Claude Fable 5
parent b6ec37a231
commit e0fc16db33
10 changed files with 1340 additions and 8 deletions
+51 -8
View File
@@ -48,21 +48,60 @@ const HISTORY_TOOL = {
connectionKeys: ['endpoint'],
};
/** 出站邮件 —— **写类**:过程中登记为动作意向,放行后由系统执行落地 */
/**
* 出站邮件 —— **写类**:过程中登记为动作意向,放行后由系统执行落地;真发经 mail 模块(ADR-013)。
*
* 2.0.0(2026-08-25):`to` 改数组,加 cc / bcc / html / attachments(uploadRef)/ inReplyTo / accountId / dryRun。
* 契约演进只增不删:v1 的 `body`(纯文本正文)与出参 `queued` 保留。
* `connectionKeys: ['account']` —— 从哪个信箱发:'default' = 默认账号,或某个账号 id。
* `dryRun`:注册的试运行会**真调处理器**,样例带 dryRun 只校验不发。
*/
const MAIL_TOOL = {
...HISTORY_TOOL,
name: 'mail.send',
displayName: '发送邮件',
description: '发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界',
version: '2.0.0',
description:
'从系统配置的邮箱发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界。' +
'to 是收件人数组;正文给 body(纯文本)或 html,至少一个;附件给上传件的 uploadRef;回信带 inReplyTo(来信的 Message-ID)。',
input: {
type: 'object',
required: ['to', 'subject'],
properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' } },
properties: {
to: { type: 'array', items: { type: 'string' }, description: '收件人邮箱' },
cc: { type: 'array', items: { type: 'string' }, description: '抄送' },
bcc: { type: 'array', items: { type: 'string' }, description: '密送' },
subject: { type: 'string' },
body: { type: 'string', description: '纯文本正文(与 html 至少一个)' },
html: { type: 'string', description: 'HTML 正文' },
attachments: {
type: 'array',
items: { type: 'object', required: ['uploadRef'], properties: { uploadRef: { type: 'string' }, name: { type: 'string' } } },
description: '附件:上传件的 ref',
},
inReplyTo: { type: 'string', description: '回信时带上来信的 Message-ID,收件人那边才能串成一个线程' },
accountId: { type: 'string', description: '指定发件账号;缺省用连接配置里的账号 / 默认账号' },
dryRun: { type: 'boolean', description: '只校验不发(注册试运行用)' },
},
},
output: {
type: 'object',
properties: {
queued: { type: 'boolean', description: 'v1 遗留:真发时恒 true' },
outboundId: { type: 'string' },
messageId: { type: 'string' },
state: { type: 'string' },
dryRun: { type: 'boolean' },
},
},
output: { type: 'object', properties: { queued: { type: 'boolean' } } },
effect: 'write' as const,
errors: [
{ code: 'send-failed', retryable: true, message: '发信失败(厂商原话见 message)' },
{ code: 'no-account', retryable: false, message: '没有可用的邮箱账号' },
{ code: 'invalid', retryable: false, message: '入参不合法' },
],
idempotent: true,
connectionKeys: ['endpoint'],
connectionKeys: ['account'],
};
/**
@@ -297,8 +336,10 @@ export async function seedMvp(container: Container): Promise<void> {
// 样例集一份喂四件工具:并集覆盖各家必填
input: {
pn: 'LM358DR',
to: 'x@y.com',
to: ['x@y.com'],
subject: '询价',
body: '样例正文',
dryRun: true,
history_days: 30,
market_stable: true,
lifecycle: '在产',
@@ -306,8 +347,10 @@ export async function seedMvp(container: Container): Promise<void> {
expectShape: { type: 'object' },
},
],
// 占位工具是纯工具,没有连接目标 —— 与真工具同门过两审,但不给它端点
...(contract.connectionKeys.length === 0 ? {} : { connectionValues: { endpoint: 'replay://crm' } }),
// 占位工具是纯工具,没有连接目标 —— 与真工具同门过两审,但不给它端点;mail.send 的连接目标是「从哪个信箱发」
...(contract.connectionKeys.length === 0
? {}
: { connectionValues: Object.fromEntries(contract.connectionKeys.map((k) => [k, k === 'account' ? 'default' : 'replay://crm'])) }),
});
if (!verdict.accepted) {
log(' 工具 ' + contract.name + ' 未过两审:' + verdict.staticFindings.map((f) => f.message).join('; '));