engine7 7.1.119 → 7.1.121
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +39870 -26290
- package/dist/engine-startup.mjs +61000 -34856
- package/dist/main.mjs +60917 -34773
- package/dist/mem7-worker.mjs +22969 -0
- package/dist/prompts/atomic-facts-memcell.md +80 -0
- package/dist/prompts/atomic-facts.md +108 -0
- package/dist/prompts/boundary.md +88 -0
- package/dist/prompts/case-compress.md +73 -0
- package/dist/prompts/case-filter.md +34 -0
- package/dist/prompts/case-rerank.md +12 -0
- package/dist/prompts/cluster-assign.md +25 -0
- package/dist/prompts/episode.md +65 -0
- package/dist/prompts/episodic-rerank.md +12 -0
- package/dist/prompts/foresight.md +148 -0
- package/dist/prompts/multi-query.md +61 -0
- package/dist/prompts/profile-compact.md +30 -0
- package/dist/prompts/profile-update.md +66 -0
- package/dist/prompts/profile.md +43 -0
- package/dist/prompts/refined-query.md +43 -0
- package/dist/prompts/skill-failure.md +84 -0
- package/dist/prompts/skill-maturity.md +49 -0
- package/dist/prompts/skill-rerank.md +23 -0
- package/dist/prompts/skill-success.md +103 -0
- package/dist/prompts/sufficiency.md +41 -0
- package/dist/prompts/tool-pre-compress.md +39 -0
- package/package.json +110 -95
- package/templates/config.template.json +25 -0
- package/templates/skills/email/LICENSE.txt +22 -0
- package/templates/skills/email/SKILL.md +115 -0
- package/templates/skills/email/assets/1-account-menu.jpg +0 -0
- package/templates/skills/email/assets/163/1-settings-menu.jpg +0 -0
- package/templates/skills/email/assets/163/2-service-closed.jpg +0 -0
- package/templates/skills/email/assets/163/3-security-warning.jpg +0 -0
- package/templates/skills/email/assets/163/5-imap-code.jpg +0 -0
- package/templates/skills/email/assets/2-security-settings-generate.jpg +0 -0
- package/templates/skills/email/assets/3-sms-verify.jpg +0 -0
- package/templates/skills/email/assets/4-wechat-send-confirm.jpg +0 -0
- package/templates/skills/email/assets/5-code-generated.jpg +0 -0
- package/templates/skills/email/package.json +12 -0
- package/templates/skills/email/scripts/email-config.js +114 -0
- package/templates/skills/email/scripts/email-config.test.js +93 -0
- package/templates/skills/email/scripts/email-gate.js +124 -0
- package/templates/skills/email/scripts/email-gate.test.js +62 -0
- package/templates/skills/email/scripts/imap.js +540 -0
- package/templates/skills/email/scripts/imap.test.js +86 -0
- package/templates/skills/email/scripts/smtp.js +238 -0
- package/templates/skills/email/scripts/smtp.test.js +44 -0
- package/templates/workspace/scripts/emotion_state_machine.mjs +13 -13
- package/src/memory/everos/python/__pycache__/agentic_search.cpython-314.pyc +0 -0
- package/templates/workspace/scripts/__pycache__/emotion_state_machine.cpython-310.pyc +0 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "engine7-email-skill",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "IMAP/SMTP email skill for engine7 agents. Reads credentials from stateDir config (tools.email), falls back to .env. Supports QQ Mail, 163/126/188 series, Gmail, Outlook.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"dotenv": "^16.6.1",
|
|
8
|
+
"imapflow": "^1.2.10",
|
|
9
|
+
"mailparser": "^3.9.3",
|
|
10
|
+
"nodemailer": "^7.0.13"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// email-config.js — 邮件凭证/配置解析(imap.js、smtp.js 共用)
|
|
2
|
+
// 优先级:stateDir config 的 tools.email 块 → skill 目录 .env(妹妹存量形态兼容)。
|
|
3
|
+
// 面板最简形态只需 address+pass,host 按邮箱域名自动映射(providerHostsFor)。
|
|
4
|
+
const fs = require('node:fs')
|
|
5
|
+
const path = require('node:path')
|
|
6
|
+
|
|
7
|
+
// 域名 → 服务器映射(设计 §3.2;QQ/163/126/188/vip 系/Gmail/Outlook)
|
|
8
|
+
const PROVIDER_MAP = {
|
|
9
|
+
'qq.com': { imap: ['imap.qq.com', 993], smtp: ['smtp.qq.com', 465, true] },
|
|
10
|
+
'foxmail.com': { imap: ['imap.qq.com', 993], smtp: ['smtp.qq.com', 465, true] },
|
|
11
|
+
'163.com': { imap: ['imap.163.com', 993], smtp: ['smtp.163.com', 465, true] },
|
|
12
|
+
'vip.163.com': { imap: ['imap.vip.163.com', 993], smtp: ['smtp.vip.163.com', 465, true] },
|
|
13
|
+
'126.com': { imap: ['imap.126.com', 993], smtp: ['smtp.126.com', 465, true] },
|
|
14
|
+
'vip.126.com': { imap: ['imap.vip.126.com', 993], smtp: ['smtp.vip.126.com', 465, true] },
|
|
15
|
+
'188.com': { imap: ['imap.188.com', 993], smtp: ['smtp.188.com', 465, true] },
|
|
16
|
+
'vip.188.com': { imap: ['imap.vip.188.com', 993], smtp: ['smtp.vip.188.com', 465, true] },
|
|
17
|
+
'yeah.net': { imap: ['imap.yeah.net', 993], smtp: ['smtp.yeah.net', 465, true] },
|
|
18
|
+
'gmail.com': { imap: ['imap.gmail.com', 993], smtp: ['smtp.gmail.com', 587, false] },
|
|
19
|
+
'outlook.com': { imap: ['outlook.office365.com', 993], smtp: ['smtp.office365.com', 587, false] },
|
|
20
|
+
'hotmail.com': { imap: ['outlook.office365.com', 993], smtp: ['smtp.office365.com', 587, false] },
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 按邮箱地址返回 {imap:{host,port}, smtp:{host,port,secure}};未知域名返回 null(不猜) */
|
|
24
|
+
function providerHostsFor(address) {
|
|
25
|
+
const domain = String(address || '').toLowerCase().split('@')[1]
|
|
26
|
+
if (!domain) return null
|
|
27
|
+
const p = PROVIDER_MAP[domain]
|
|
28
|
+
if (!p) return null
|
|
29
|
+
return {
|
|
30
|
+
imap: { host: p.imap[0], port: p.imap[1] },
|
|
31
|
+
smtp: { host: p.smtp[0], port: p.smtp[1], secure: p.smtp[2] },
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 从 stateDir config 的 tools.email 块 + .env 兜底解析出统一配置。
|
|
36
|
+
* 返回 {address?, imap:{host,port,user,pass}, smtp:{host,port,secure,user,pass}, source:'config'|'env'} 或 null */
|
|
37
|
+
function resolveEmailConfig(opts = {}) {
|
|
38
|
+
const stateDir = opts.stateDir
|
|
39
|
+
|| process.env.ENGINE_STATE_DIR
|
|
40
|
+
|| process.env.ENGINE7_STATE_DIR
|
|
41
|
+
|| null
|
|
42
|
+
|
|
43
|
+
// 1) stateDir config 的 tools.email
|
|
44
|
+
if (stateDir) {
|
|
45
|
+
for (const name of ['main.json', 'configs/main.json']) {
|
|
46
|
+
const p = path.join(stateDir, name.startsWith('configs') ? name : path.join('configs', name))
|
|
47
|
+
try {
|
|
48
|
+
if (!fs.existsSync(p)) continue
|
|
49
|
+
const cfg = JSON.parse(fs.readFileSync(p, 'utf8'))
|
|
50
|
+
const block = cfg?.tools?.email
|
|
51
|
+
if (block && block.address) {
|
|
52
|
+
const hosts = providerHostsFor(block.address)
|
|
53
|
+
const imapBlock = block.imap || {}
|
|
54
|
+
const smtpBlock = block.smtp || {}
|
|
55
|
+
const out = {
|
|
56
|
+
address: block.address,
|
|
57
|
+
imap: {
|
|
58
|
+
host: imapBlock.host || hosts?.imap.host,
|
|
59
|
+
port: imapBlock.port || hosts?.imap.port || 993,
|
|
60
|
+
user: imapBlock.user || block.address,
|
|
61
|
+
pass: imapBlock.pass || block.pass || '',
|
|
62
|
+
},
|
|
63
|
+
smtp: {
|
|
64
|
+
host: smtpBlock.host || hosts?.smtp.host,
|
|
65
|
+
port: smtpBlock.port || hosts?.smtp.port || 465,
|
|
66
|
+
secure: smtpBlock.secure !== undefined ? smtpBlock.secure : (hosts ? hosts.smtp.secure : true),
|
|
67
|
+
user: smtpBlock.user || block.address,
|
|
68
|
+
// 授权码优先级:smtp 块自带 > 顶层 smtpPass(IMAP/SMTP 两个码)> 顶层 pass(最简形态一个码)
|
|
69
|
+
pass: smtpBlock.pass || block.smtpPass || block.pass || '',
|
|
70
|
+
},
|
|
71
|
+
source: 'config',
|
|
72
|
+
}
|
|
73
|
+
if (out.imap.host && out.imap.pass && out.smtp.host) return out
|
|
74
|
+
}
|
|
75
|
+
} catch { /* config 损坏 → 落到 .env */ }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 2) .env 兜底(存量妹妹形态)
|
|
80
|
+
const envFile = opts.envFile || path.join(__dirname, '..', '.env')
|
|
81
|
+
if (fs.existsSync(envFile)) {
|
|
82
|
+
const parsed = {}
|
|
83
|
+
for (const line of fs.readFileSync(envFile, 'utf8').split(/\r?\n/)) {
|
|
84
|
+
const m = line.match(/^\s*([A-Z_]+)\s*=\s*(.*)\s*$/)
|
|
85
|
+
if (m && !process.env[m[1]]) parsed[m[1]] = m[2]
|
|
86
|
+
}
|
|
87
|
+
const g = (k) => process.env[k] || parsed[k]
|
|
88
|
+
if (g('IMAP_HOST') && g('IMAP_USER')) {
|
|
89
|
+
return {
|
|
90
|
+
address: g('IMAP_USER'),
|
|
91
|
+
imap: { host: g('IMAP_HOST'), port: parseInt(g('IMAP_PORT')) || 993, user: g('IMAP_USER'), pass: g('IMAP_PASS') || '' },
|
|
92
|
+
smtp: {
|
|
93
|
+
host: g('SMTP_HOST') || g('IMAP_HOST'), port: parseInt(g('SMTP_PORT')) || 465,
|
|
94
|
+
secure: g('SMTP_SECURE') !== 'false', user: g('SMTP_USER') || g('IMAP_USER'), pass: g('SMTP_PASS') || g('IMAP_PASS') || '',
|
|
95
|
+
},
|
|
96
|
+
source: 'env',
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 错误分类 → --json 的 reason 枚举(设计 §4.1;239 事故防线:连接失败≠邮箱空≠认证失败) */
|
|
104
|
+
function classifyError(err) {
|
|
105
|
+
const text = `${err?.message || ''} ${err?.response?.text || err?.responseText || ''}`
|
|
106
|
+
if (/缺少|Missing IMAP_USER|Missing.*PASS|no email config/i.test(text)) return 'config_missing'
|
|
107
|
+
if (err?.authenticationFailed || /AUTHENTICATIONFAILED|authentication failed|Login error|LOGIN failed|invalid login|auth.*denied/i.test(text)) return 'auth_failed'
|
|
108
|
+
if (/RATELIMITED|rate limit|too many|频繁/i.test(text)) return 'rate_limited'
|
|
109
|
+
if (/ETIMEDOUT|timed?\s*out|timeout/i.test(text)) return 'timeout'
|
|
110
|
+
if (/ENOTFOUND|ECONNREFUSED|ECONNRESET|EPIPE|EAI_AGAIN|network|socket hang up/i.test(text)) return 'network'
|
|
111
|
+
return 'unknown'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { PROVIDER_MAP, providerHostsFor, resolveEmailConfig, classifyError }
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// email-config.test.js — 配置解析/域名映射/错误分类 单测(node 直跑)
|
|
2
|
+
// 运行:node scripts/email-config.test.js
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const fs = require('node:fs')
|
|
5
|
+
const os = require('node:os')
|
|
6
|
+
const path = require('node:path')
|
|
7
|
+
const { resolveEmailConfig, providerHostsFor, classifyError } = require('./email-config.js')
|
|
8
|
+
|
|
9
|
+
// === providerHostsFor:域名 → IMAP/SMTP 映射 ===
|
|
10
|
+
{
|
|
11
|
+
const qq = providerHostsFor('user@qq.com')
|
|
12
|
+
assert.equal(qq.imap.host, 'imap.qq.com')
|
|
13
|
+
assert.equal(qq.imap.port, 993)
|
|
14
|
+
assert.equal(qq.smtp.host, 'smtp.qq.com')
|
|
15
|
+
assert.equal(qq.smtp.port, 465)
|
|
16
|
+
assert.equal(qq.smtp.secure, true)
|
|
17
|
+
|
|
18
|
+
const netease = providerHostsFor('a@163.com')
|
|
19
|
+
assert.equal(netease.imap.host, 'imap.163.com')
|
|
20
|
+
const vip = providerHostsFor('a@vip.126.com')
|
|
21
|
+
assert.equal(vip.imap.host, 'imap.vip.126.com')
|
|
22
|
+
|
|
23
|
+
const gmail = providerHostsFor('x@gmail.com')
|
|
24
|
+
assert.equal(gmail.imap.host, 'imap.gmail.com')
|
|
25
|
+
assert.equal(gmail.smtp.port, 587)
|
|
26
|
+
assert.equal(gmail.smtp.secure, false, 'gmail 走 587 STARTTLS')
|
|
27
|
+
|
|
28
|
+
const unknown = providerHostsFor('x@some-corp.cn')
|
|
29
|
+
assert.equal(unknown, null, '未知域名返回 null(面板/用户手动填),不猜')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// === resolveEmailConfig:config 块优先,.env 回退,地址自动补 host ===
|
|
33
|
+
{
|
|
34
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'e7-email-cfg-'))
|
|
35
|
+
// 1) 无 config 无 .env → null
|
|
36
|
+
assert.equal(resolveEmailConfig({ stateDir: tmp }), null)
|
|
37
|
+
|
|
38
|
+
// 2) config tools.email 完整 → 直接用
|
|
39
|
+
fs.mkdirSync(path.join(tmp, 'configs'), { recursive: true })
|
|
40
|
+
fs.writeFileSync(path.join(tmp, 'configs', 'main.json'), JSON.stringify({
|
|
41
|
+
tools: { email: { address: 'a@qq.com',
|
|
42
|
+
imap: { host: 'imap.qq.com', port: 993, user: 'a@qq.com', pass: 'k1' },
|
|
43
|
+
smtp: { host: 'smtp.qq.com', port: 465, secure: true, user: 'a@qq.com', pass: 'k1' } } },
|
|
44
|
+
}))
|
|
45
|
+
let cfg = resolveEmailConfig({ stateDir: tmp })
|
|
46
|
+
assert.equal(cfg.imap.pass, 'k1')
|
|
47
|
+
assert.equal(cfg.source, 'config')
|
|
48
|
+
|
|
49
|
+
// 3) config 只给 address+pass(面板最简形态)→ 域名映射自动补 host
|
|
50
|
+
fs.writeFileSync(path.join(tmp, 'configs', 'main.json'), JSON.stringify({
|
|
51
|
+
tools: { email: { address: 'b@163.com', pass: 'k2' } },
|
|
52
|
+
}))
|
|
53
|
+
cfg = resolveEmailConfig({ stateDir: tmp })
|
|
54
|
+
assert.equal(cfg.source, 'config')
|
|
55
|
+
assert.equal(cfg.imap.host, 'imap.163.com')
|
|
56
|
+
assert.equal(cfg.imap.user, 'b@163.com')
|
|
57
|
+
assert.equal(cfg.imap.pass, 'k2')
|
|
58
|
+
assert.equal(cfg.smtp.host, 'smtp.163.com')
|
|
59
|
+
assert.equal(cfg.smtp.secure, true)
|
|
60
|
+
// 独立授权码可覆盖(IMAP/SMTP 授权码可能是两个)
|
|
61
|
+
fs.writeFileSync(path.join(tmp, 'configs', 'main.json'), JSON.stringify({
|
|
62
|
+
tools: { email: { address: 'b@163.com', pass: 'k2', smtpPass: 'k3' } },
|
|
63
|
+
}))
|
|
64
|
+
cfg = resolveEmailConfig({ stateDir: tmp })
|
|
65
|
+
assert.equal(cfg.imap.pass, 'k2')
|
|
66
|
+
assert.equal(cfg.smtp.pass, 'k3')
|
|
67
|
+
|
|
68
|
+
// 4) 无 config → 回退 skill 目录 .env(妹妹存量形态,不破坏)
|
|
69
|
+
const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'e7-email-env-'))
|
|
70
|
+
fs.writeFileSync(path.join(tmp2, '.env'), 'IMAP_HOST=imap.qq.com\nIMAP_PORT=993\nIMAP_USER=c@qq.com\nIMAP_PASS=k4\nSMTP_HOST=smtp.qq.com\nSMTP_PORT=465\nSMTP_SECURE=true\nSMTP_USER=c@qq.com\nSMTP_PASS=k4\n')
|
|
71
|
+
const cfg2 = resolveEmailConfig({ stateDir: tmp2, envFile: path.join(tmp2, '.env') })
|
|
72
|
+
assert.equal(cfg2.source, 'env')
|
|
73
|
+
assert.equal(cfg2.imap.user, 'c@qq.com')
|
|
74
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
75
|
+
fs.rmSync(tmp2, { recursive: true, force: true })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// === classifyError:错误分类(--json 的 reason 枚举) ===
|
|
79
|
+
{
|
|
80
|
+
const auth = new Error('AUTHENTICATIONFAILED Authentication failed')
|
|
81
|
+
auth.authenticationFailed = true
|
|
82
|
+
assert.equal(classifyError(auth), 'auth_failed')
|
|
83
|
+
assert.equal(classifyError(new Error('NO Login error. (163)')), 'auth_failed')
|
|
84
|
+
assert.equal(classifyError(new Error('getaddrinfo ENOTFOUND imap.qq.com')), 'network')
|
|
85
|
+
assert.equal(classifyError(new Error('connect ECONNREFUSED 1.2.3.4:993')), 'network')
|
|
86
|
+
assert.equal(classifyError(new Error('read ECONNRESET')), 'network')
|
|
87
|
+
assert.equal(classifyError(new Error('connect ETIMEDOUT')), 'timeout')
|
|
88
|
+
assert.equal(classifyError(new Error('NO [RATELIMITED]')), 'rate_limited')
|
|
89
|
+
assert.equal(classifyError(new Error('缺少 IMAP_USER 或 IMAP_PASS 配置')), 'config_missing')
|
|
90
|
+
assert.equal(classifyError(new Error('别的什么错')), 'unknown')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log('email-config.test ALL PASS')
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// email-gate.js — 发信分级闸(设计 v2 §3.2:候选/已验证两级 + 逐收件人判定)
|
|
2
|
+
// 协议层实现:agent 发信前必须先跑本判定(CLI 或 require),凭 verdict 组装话术。
|
|
3
|
+
// node email-gate.js check --to a@x.com,b@y.com [--cc ...] [--bcc ...] [--attach] [--ext-link]
|
|
4
|
+
// → {"action":"send"|"confirm","reasons":{...}}
|
|
5
|
+
const fs = require('node:fs')
|
|
6
|
+
const path = require('node:path')
|
|
7
|
+
|
|
8
|
+
/** "Name <a@b.c>" / "a@b.c" / 大小写 → 归一地址 */
|
|
9
|
+
function normalizeAddress(addr) {
|
|
10
|
+
const m = String(addr || '').match(/<([^>]+)>/) || [null, String(addr || '')]
|
|
11
|
+
return m[1].trim().toLowerCase()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {{to:string[], cc?:string[], bcc?:string[], hasAttachment?:boolean, hasExternalLink?:boolean,
|
|
16
|
+
* verified:Set<string>, policy?:'tiered'|'confirm-all'|'auto'}} input
|
|
17
|
+
* @returns {{action:'send'|'confirm'|'invalid', reasons?:{unverified:string[], requiresReview:string[]}}}
|
|
18
|
+
*/
|
|
19
|
+
function gateCheck({ to = [], cc = [], bcc = [], hasAttachment = false, hasExternalLink = false, verified, policy = 'tiered' }) {
|
|
20
|
+
if (policy === 'auto') return { action: 'send' }
|
|
21
|
+
if (policy === 'confirm-all') return { action: 'confirm', reasons: { unverified: [], requiresReview: ['policy'] } }
|
|
22
|
+
|
|
23
|
+
const all = [...to, ...cc, ...bcc].map(normalizeAddress).filter(Boolean)
|
|
24
|
+
if (all.length === 0) return { action: 'invalid' }
|
|
25
|
+
|
|
26
|
+
const unverified = all.filter(a => !verified.has(a))
|
|
27
|
+
const requiresReview = []
|
|
28
|
+
if (hasAttachment) requiresReview.push('attachment')
|
|
29
|
+
if (hasExternalLink) requiresReview.push('external_link')
|
|
30
|
+
if (unverified.length > 0 || requiresReview.length > 0) {
|
|
31
|
+
return { action: 'confirm', reasons: { unverified, requiresReview } }
|
|
32
|
+
}
|
|
33
|
+
return { action: 'send' }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 攒批规划:direct=免确认直发;batchable=仅因陌生收件人待确认(≤3 封一批);
|
|
37
|
+
* singles=带附件/外链必须单独过(设计 §3.2) */
|
|
38
|
+
function planBatch(mails) {
|
|
39
|
+
const direct = []
|
|
40
|
+
const batchable = []
|
|
41
|
+
const singles = []
|
|
42
|
+
for (const m of mails) {
|
|
43
|
+
const v = gateCheck(m)
|
|
44
|
+
if (v.action === 'send') { direct.push(m); continue }
|
|
45
|
+
if (v.reasons?.requiresReview?.length) singles.push(m)
|
|
46
|
+
else batchable.push(m)
|
|
47
|
+
}
|
|
48
|
+
return { direct, batchable: batchable.slice(0, 3), nextBatch: batchable.slice(3), singles }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// === 联系人名单(candidates 来信自动入册 / verified 用户确认后升级)===
|
|
52
|
+
function contactsPath() {
|
|
53
|
+
const stateDir = process.env.ENGINE_STATE_DIR || process.env.ENGINE7_STATE_DIR
|
|
54
|
+
return stateDir ? path.join(stateDir, 'email-contacts.json') : path.join(__dirname, '..', 'email-contacts.json')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadContacts() {
|
|
58
|
+
try { return JSON.parse(fs.readFileSync(contactsPath(), 'utf8')) } catch { return { candidates: [], verified: [] } }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function saveContacts(c) {
|
|
62
|
+
fs.mkdirSync(path.dirname(contactsPath()), { recursive: true })
|
|
63
|
+
fs.writeFileSync(contactsPath(), JSON.stringify(c, null, 2))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 来信地址入候选(幂等;已在 verified 不降级) */
|
|
67
|
+
function addCandidate(address) {
|
|
68
|
+
const a = normalizeAddress(address)
|
|
69
|
+
if (!a) return loadContacts()
|
|
70
|
+
const c = loadContacts()
|
|
71
|
+
if (!c.verified.includes(a) && !c.candidates.includes(a)) c.candidates.push(a)
|
|
72
|
+
saveContacts(c)
|
|
73
|
+
return c
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 用户确认 → 升已验证(直发权) */
|
|
77
|
+
function verifyAddress(address) {
|
|
78
|
+
const a = normalizeAddress(address)
|
|
79
|
+
const c = loadContacts()
|
|
80
|
+
if (a && !c.verified.includes(a)) c.verified.push(a)
|
|
81
|
+
c.candidates = c.candidates.filter(x => x !== a)
|
|
82
|
+
saveContacts(c)
|
|
83
|
+
return c
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function verifiedSet() {
|
|
87
|
+
return new Set(loadContacts().verified)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// === CLI ===
|
|
91
|
+
if (require.main === module) {
|
|
92
|
+
const args = process.argv.slice(2)
|
|
93
|
+
const cmd = args[0]
|
|
94
|
+
const flag = (name) => args.includes(`--${name}`)
|
|
95
|
+
const val = (name) => {
|
|
96
|
+
const i = args.indexOf(`--${name}`)
|
|
97
|
+
return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : null
|
|
98
|
+
}
|
|
99
|
+
const list = (name) => (val(name) ? val(name).split(',').map(s => s.trim()).filter(Boolean) : [])
|
|
100
|
+
|
|
101
|
+
if (cmd === 'check') {
|
|
102
|
+
const verdict = gateCheck({
|
|
103
|
+
to: list('to'), cc: list('cc'), bcc: list('bcc'),
|
|
104
|
+
hasAttachment: flag('attach'), hasExternalLink: flag('ext-link'),
|
|
105
|
+
verified: verifiedSet(),
|
|
106
|
+
policy: (val('policy') || 'tiered'),
|
|
107
|
+
})
|
|
108
|
+
console.log(JSON.stringify(verdict))
|
|
109
|
+
} else if (cmd === 'add-candidate') {
|
|
110
|
+
addCandidate(val('addr'))
|
|
111
|
+
console.log(JSON.stringify(loadContacts()))
|
|
112
|
+
} else if (cmd === 'verify') {
|
|
113
|
+
verifyAddress(val('addr'))
|
|
114
|
+
console.log(JSON.stringify(loadContacts()))
|
|
115
|
+
} else if (cmd === 'list') {
|
|
116
|
+
console.log(JSON.stringify(loadContacts(), null, 2))
|
|
117
|
+
} else {
|
|
118
|
+
console.error('Usage: email-gate.js check --to a@x.com[,b@y.com] [--cc ...] [--bcc ...] [--attach] [--ext-link] [--policy tiered|confirm-all|auto]')
|
|
119
|
+
console.error(' email-gate.js add-candidate --addr a@x.com | verify --addr a@x.com | list')
|
|
120
|
+
process.exit(1)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { gateCheck, planBatch, normalizeAddress, addCandidate, verifyAddress, verifiedSet, loadContacts, saveContacts }
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// email-gate.test.js — 分级闸判定单测(node 直跑)
|
|
2
|
+
const assert = require('node:assert/strict')
|
|
3
|
+
const { gateCheck, normalizeAddress, planBatch } = require('./email-gate.js')
|
|
4
|
+
|
|
5
|
+
const V = new Set(['mom@qq.com', 'teacher@163.com'])
|
|
6
|
+
|
|
7
|
+
// 1. 全部已验证、无附件无外链 → 直发
|
|
8
|
+
assert.equal(gateCheck({ to: ['mom@qq.com'], verified: V }).action, 'send')
|
|
9
|
+
|
|
10
|
+
// 2. to 里有陌生 → confirm(reason 点名)
|
|
11
|
+
{
|
|
12
|
+
const r = gateCheck({ to: ['mom@qq.com', 'stranger@x.com'], verified: V })
|
|
13
|
+
assert.equal(r.action, 'confirm')
|
|
14
|
+
assert.deepEqual(r.reasons.unverified, ['stranger@x.com'])
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 3. cc/bcc 逐收件人判——夹带的陌生地址也会把整封降级
|
|
18
|
+
assert.equal(gateCheck({ to: ['mom@qq.com'], cc: ['b@x.com'], verified: V }).action, 'confirm')
|
|
19
|
+
assert.equal(gateCheck({ to: ['mom@qq.com'], bcc: ['b@x.com'], verified: V }).action, 'confirm')
|
|
20
|
+
|
|
21
|
+
// 4. 全已验证但带附件 → confirm
|
|
22
|
+
{
|
|
23
|
+
const r = gateCheck({ to: ['mom@qq.com'], verified: V, hasAttachment: true })
|
|
24
|
+
assert.equal(r.action, 'confirm')
|
|
25
|
+
assert.ok(r.reasons.requiresReview.includes('attachment'))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 5. 全已验证但正文含外链 → confirm
|
|
29
|
+
assert.equal(gateCheck({ to: ['mom@qq.com'], verified: V, hasExternalLink: true }).action, 'confirm')
|
|
30
|
+
|
|
31
|
+
// 6. sendPolicy 三档
|
|
32
|
+
assert.equal(gateCheck({ to: ['a@x.com'], verified: V, policy: 'auto' }).action, 'send', 'auto 档永远直发')
|
|
33
|
+
assert.equal(gateCheck({ to: ['mom@qq.com'], verified: V, policy: 'confirm-all' }).action, 'confirm', 'confirm-all 档永远确认')
|
|
34
|
+
|
|
35
|
+
// 7. 地址归一化:显示名、尖括号、大小写
|
|
36
|
+
assert.equal(normalizeAddress('"张老师" <Teacher@163.com>'), 'teacher@163.com')
|
|
37
|
+
assert.equal(normalizeAddress('MOM@QQ.COM'), 'mom@qq.com')
|
|
38
|
+
{
|
|
39
|
+
const r = gateCheck({ to: ['"妈妈" <MOM@qq.com>'], verified: V })
|
|
40
|
+
assert.equal(r.action, 'send', '归一化后命中已验证')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 8. 无收件人 → invalid(调用方错误,明确不猜)
|
|
44
|
+
assert.equal(gateCheck({ to: [], verified: V }).action, 'invalid')
|
|
45
|
+
|
|
46
|
+
// 9. 攒批规划:待确认件 ≤3 封一批;带附件/外链单独过;全 verified 直接发
|
|
47
|
+
{
|
|
48
|
+
const plan = planBatch([
|
|
49
|
+
{ id: 'd1', to: ['mom@qq.com'], verified: V }, // 直发
|
|
50
|
+
{ id: 'm1', to: ['c1@x.com'], verified: V }, // 陌生→可攒批
|
|
51
|
+
{ id: 'm2', to: ['c2@x.com'], verified: V },
|
|
52
|
+
{ id: 'm3', to: ['c3@x.com'], verified: V },
|
|
53
|
+
{ id: 'm4', to: ['c4@x.com'], verified: V }, // 第 4 封 → 下一批
|
|
54
|
+
{ id: 'm5', to: ['mom@qq.com'], verified: V, hasAttachment: true }, // 附件 → 单过
|
|
55
|
+
])
|
|
56
|
+
assert.deepEqual(plan.direct.map(m => m.id), ['d1'])
|
|
57
|
+
assert.deepEqual(plan.batchable.map(m => m.id), ['m1', 'm2', 'm3'])
|
|
58
|
+
assert.deepEqual(plan.nextBatch.map(m => m.id), ['m4'])
|
|
59
|
+
assert.deepEqual(plan.singles.map(m => m.id), ['m5'], '附件件不参与攒批')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log('email-gate.test ALL PASS')
|