Archived
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:
@@ -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('; '));
|
||||
|
||||
@@ -138,3 +138,20 @@ describe('staticAudit —— 注册两审之静态审', () => {
|
||||
expect(auditPassed(findings)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mail.send 2.0.0 的演进', () => {
|
||||
it('保留 v1 的 body / queued、不新增必填 —— 演进检查零违规', () => {
|
||||
const v1 = {
|
||||
name: 'mail.send', displayName: '发送邮件', version: '1.0.0', description: '发出邮件(报价单 / 询价函 / 催办)',
|
||||
input: { type: 'object', required: ['to', 'subject'], properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' } } },
|
||||
output: { type: 'object', properties: { queued: { type: 'boolean' } } },
|
||||
effect: 'write' as const, errors: [], idempotent: true, timeoutMs: 10_000, retries: 2, connectionKeys: ['endpoint'],
|
||||
};
|
||||
const v2 = {
|
||||
...v1, version: '2.0.0', connectionKeys: ['account'],
|
||||
input: { type: 'object', required: ['to', 'subject'], properties: { ...v1.input.properties, to: { type: 'array', items: { type: 'string' } }, html: { type: 'string' }, dryRun: { type: 'boolean' } } },
|
||||
output: { type: 'object', properties: { ...v1.output.properties, outboundId: { type: 'string' }, state: { type: 'string' } } },
|
||||
};
|
||||
expect(checkEvolution(v1, v2)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user