engine7 7.1.120 → 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.
Files changed (50) hide show
  1. package/dist/cli.mjs +39910 -26359
  2. package/dist/engine-startup.mjs +51118 -25041
  3. package/dist/main.mjs +61139 -35062
  4. package/dist/mem7-worker.mjs +22969 -0
  5. package/dist/prompts/atomic-facts-memcell.md +80 -0
  6. package/dist/prompts/atomic-facts.md +108 -0
  7. package/dist/prompts/boundary.md +88 -0
  8. package/dist/prompts/case-compress.md +73 -0
  9. package/dist/prompts/case-filter.md +34 -0
  10. package/dist/prompts/case-rerank.md +12 -0
  11. package/dist/prompts/cluster-assign.md +25 -0
  12. package/dist/prompts/episode.md +65 -0
  13. package/dist/prompts/episodic-rerank.md +12 -0
  14. package/dist/prompts/foresight.md +148 -0
  15. package/dist/prompts/multi-query.md +61 -0
  16. package/dist/prompts/profile-compact.md +30 -0
  17. package/dist/prompts/profile-update.md +66 -0
  18. package/dist/prompts/profile.md +43 -0
  19. package/dist/prompts/refined-query.md +43 -0
  20. package/dist/prompts/skill-failure.md +84 -0
  21. package/dist/prompts/skill-maturity.md +49 -0
  22. package/dist/prompts/skill-rerank.md +23 -0
  23. package/dist/prompts/skill-success.md +103 -0
  24. package/dist/prompts/sufficiency.md +41 -0
  25. package/dist/prompts/tool-pre-compress.md +39 -0
  26. package/package.json +110 -95
  27. package/templates/config.template.json +25 -0
  28. package/templates/skills/email/LICENSE.txt +22 -0
  29. package/templates/skills/email/SKILL.md +115 -0
  30. package/templates/skills/email/assets/1-account-menu.jpg +0 -0
  31. package/templates/skills/email/assets/163/1-settings-menu.jpg +0 -0
  32. package/templates/skills/email/assets/163/2-service-closed.jpg +0 -0
  33. package/templates/skills/email/assets/163/3-security-warning.jpg +0 -0
  34. package/templates/skills/email/assets/163/5-imap-code.jpg +0 -0
  35. package/templates/skills/email/assets/2-security-settings-generate.jpg +0 -0
  36. package/templates/skills/email/assets/3-sms-verify.jpg +0 -0
  37. package/templates/skills/email/assets/4-wechat-send-confirm.jpg +0 -0
  38. package/templates/skills/email/assets/5-code-generated.jpg +0 -0
  39. package/templates/skills/email/package.json +12 -0
  40. package/templates/skills/email/scripts/email-config.js +114 -0
  41. package/templates/skills/email/scripts/email-config.test.js +93 -0
  42. package/templates/skills/email/scripts/email-gate.js +124 -0
  43. package/templates/skills/email/scripts/email-gate.test.js +62 -0
  44. package/templates/skills/email/scripts/imap.js +540 -0
  45. package/templates/skills/email/scripts/imap.test.js +86 -0
  46. package/templates/skills/email/scripts/smtp.js +238 -0
  47. package/templates/skills/email/scripts/smtp.test.js +44 -0
  48. package/templates/workspace/scripts/emotion_state_machine.mjs +13 -13
  49. package/src/memory/everos/python/__pycache__/agentic_search.cpython-314.pyc +0 -0
  50. package/templates/workspace/scripts/__pycache__/emotion_state_machine.cpython-310.pyc +0 -0
@@ -0,0 +1,540 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * IMAP Email CLI — engine7 email skill v2(imapflow)
5
+ *
6
+ * v2 改造(设计 docs/superpowers/specs/2026-10-10-installer-email-skill-design.md):
7
+ * - --json 统一错误分类:{ok:false, reason:'auth_failed'|'network'|'timeout'|'rate_limited'|'config_missing'|'unknown'}
8
+ * 「邮箱里没有」是成功(ok:true, reason:'no_new_mail')——绝不能和连接失败混报(239 事故防线)
9
+ * - 新命令 list-subjects / list-senders(envelope-only,不拉正文)
10
+ * - 凭证:stateDir config tools.email 优先,.env 回退(见 email-config.js)
11
+ * - 连接重试:network/timeout 指数退避 ×3;auth_failed 不重试快败
12
+ */
13
+
14
+ const { ImapFlow } = require('imapflow')
15
+ const { simpleParser } = require('mailparser')
16
+ const path = require('path')
17
+ const fs = require('fs')
18
+ const { resolveEmailConfig, classifyError } = require('./email-config.js')
19
+
20
+ // IMAP ID information for server compatibility (163.com, QQ, etc.)
21
+ const IMAP_ID = {
22
+ name: 'moltbot',
23
+ version: '0.0.1',
24
+ vendor: 'netease',
25
+ 'support-email': 'kefu@188.com'
26
+ }
27
+
28
+ const DEFAULT_MAILBOX = process.env.IMAP_MAILBOX || 'INBOX'
29
+ const RETRYABLE = new Set(['network', 'timeout'])
30
+
31
+ // Parse command-line arguments
32
+ function parseArgs() {
33
+ const args = process.argv.slice(2)
34
+ const command = args[0]
35
+ const options = {}
36
+ const positional = []
37
+
38
+ for (let i = 1; i < args.length; i++) {
39
+ const arg = args[i]
40
+ if (arg.startsWith('--')) {
41
+ const key = arg.slice(2)
42
+ const value = args[i + 1]
43
+ options[key] = value || true
44
+ if (value && !value.startsWith('--')) i++
45
+ } else {
46
+ positional.push(arg)
47
+ }
48
+ }
49
+
50
+ return { command, options, positional }
51
+ }
52
+
53
+ // Create imapflow connection config(凭证来自 email-config:config 块优先,.env 回退)
54
+ function createImapConfig() {
55
+ const cfg = resolveEmailConfig()
56
+ if (!cfg) {
57
+ const err = new Error('缺少邮件配置:先在 stateDir config 配 tools.email,或提供 .env')
58
+ err.reason = 'config_missing'
59
+ throw err
60
+ }
61
+ return {
62
+ host: cfg.imap.host,
63
+ port: cfg.imap.port,
64
+ secure: true,
65
+ auth: { user: cfg.imap.user, pass: cfg.imap.pass },
66
+ enableUTF8Accept: true,
67
+ connectionTimeout: 10000,
68
+ authTimeout: 10000,
69
+ tls: { rejectUnauthorized: process.env.IMAP_REJECT_UNAUTHORIZED !== 'false' },
70
+ id: IMAP_ID,
71
+ logger: false,
72
+ }
73
+ }
74
+
75
+ // Connect to IMAP server(network/timeout 指数退避重试 ×3;auth/配置错误快败)
76
+ async function connect() {
77
+ const config = createImapConfig()
78
+ let lastErr
79
+ for (let attempt = 0; attempt <= 3; attempt++) {
80
+ const client = new ImapFlow(config)
81
+ try {
82
+ await client.connect()
83
+ return client
84
+ } catch (err) {
85
+ lastErr = err
86
+ const reason = err.reason || classifyError(err)
87
+ if (!RETRYABLE.has(reason)) throw err
88
+ if (attempt < 3) {
89
+ const delay = 1000 * Math.pow(2, attempt)
90
+ console.error(`[imap] connect ${reason}, retry ${attempt + 1}/3 in ${delay}ms`)
91
+ await new Promise(r => setTimeout(r, delay))
92
+ }
93
+ }
94
+ }
95
+ throw lastErr
96
+ }
97
+
98
+ // Open mailbox (folder)
99
+ async function openMailbox(client, mailbox, readOnly = true) {
100
+ return await client.getMailboxLock(mailbox, { readOnly })
101
+ }
102
+
103
+ // Parse email from raw source or structured data
104
+ async function parseEmail(source, includeAttachments = false) {
105
+ const parsed = await simpleParser(source)
106
+
107
+ return {
108
+ from: parsed.from?.text || 'Unknown',
109
+ to: parsed.to?.text,
110
+ subject: parsed.subject || '(no subject)',
111
+ date: parsed.date,
112
+ text: parsed.text,
113
+ html: parsed.html,
114
+ snippet: parsed.text
115
+ ? parsed.text.slice(0, 200)
116
+ : (parsed.html ? parsed.html.slice(0, 200).replace(/<[^>]*>/g, '') : ''),
117
+ attachments: parsed.attachments?.map((a) => ({
118
+ filename: a.filename,
119
+ contentType: a.contentType,
120
+ size: a.size,
121
+ content: includeAttachments ? a.content : undefined,
122
+ cid: a.cid,
123
+ })),
124
+ }
125
+ }
126
+
127
+ // Build search criteria for imapflow (SearchObject format)
128
+ function buildSearchCriteria(options) {
129
+ const criteria = {}
130
+
131
+ if (options.unseen) criteria.seen = false
132
+ if (options.seen) criteria.seen = true
133
+ if (options.flagged) criteria.flagged = true
134
+ if (options.answered) criteria.answered = true
135
+
136
+ if (options.from) criteria.from = options.from
137
+ if (options.subject) criteria.subject = options.subject
138
+ if (options.to) criteria.to = options.to
139
+
140
+ if (options.recent) {
141
+ criteria.since = parseRelativeTime(options.recent)
142
+ } else {
143
+ if (options.since) criteria.since = formatImapDate(new Date(options.since))
144
+ if (options.before) criteria.before = formatImapDate(new Date(options.before))
145
+ }
146
+
147
+ if (options.uid) criteria.uid = options.uid
148
+
149
+ if (Object.keys(criteria).length === 0) return { all: true }
150
+ return criteria
151
+ }
152
+
153
+ // Fetch and parse messages from UIDs
154
+ // ⚠️ 1010 冒烟实测:QQ 大邮箱 INBOX 上万封,all-search 返回全量 UID——不截断就逐封拉全文
155
+ // (每封 fetchOne source=true),表现为「挂死」。UID 升序 ⇒ slice(-limit) = 最新 N 封,
156
+ // 先截再拉(妹妹验收 P1「check --recent 会拉正文,大邮箱几百封就卡死」的活体版,这邮箱 10721 封)。
157
+ async function fetchAndParseMessages(client, uids, limit = 25) {
158
+ const results = []
159
+ const toFetch = uids.slice(-Math.max(1, limit))
160
+
161
+ for (const uid of toFetch) {
162
+ try {
163
+ const message = await client.fetchOne(uid, {
164
+ source: true,
165
+ envelope: true,
166
+ flags: true,
167
+ uid: true,
168
+ }, { uid: true })
169
+
170
+ if (!message || !message.source) continue
171
+
172
+ const parsed = await parseEmail(message.source)
173
+
174
+ results.push({
175
+ uid: message.uid,
176
+ seq: message.seq,
177
+ flags: message.flags,
178
+ ...parsed,
179
+ })
180
+ } catch (err) {
181
+ console.warn(`Failed to fetch UID ${uid}: ${err.message}`)
182
+ }
183
+ }
184
+ results.sort((a, b) => new Date(b.date) - new Date(a.date))
185
+ return results.slice(0, limit)
186
+ }
187
+
188
+ // === envelope-only 快路径(不下载正文;list-subjects/list-senders 的基础) ===
189
+ async function fetchEnvelopes(client, uids, limit = 50) {
190
+ const out = []
191
+ const capped = uids.slice(-Math.max(limit, 1) * 5) // 从最新往回取的范围保护
192
+ for await (const message of client.fetch(capped.join(','), { envelope: true, uid: true }, { uid: true })) {
193
+ out.push({
194
+ uid: message.uid,
195
+ from: message.envelope?.from?.[0]?.address || message.envelope?.from?.[0]?.name || '',
196
+ fromName: message.envelope?.from?.[0]?.name || '',
197
+ subject: message.envelope?.subject || '(no subject)',
198
+ date: message.envelope?.date || null,
199
+ })
200
+ }
201
+ out.sort((a, b) => new Date(b.date) - new Date(a.date))
202
+ return out.slice(0, limit)
203
+ }
204
+
205
+ /** 聚合发件人分布(纯函数,可测):[{from,subject,date}] → [{address,count,lastDate,lastSubject}] */
206
+ function aggregateSenders(envelopes) {
207
+ const map = new Map()
208
+ for (const e of envelopes) {
209
+ if (!e.from) continue
210
+ const addr = String(e.from).toLowerCase()
211
+ const cur = map.get(addr) || { address: addr, count: 0, lastDate: null, lastSubject: '' }
212
+ cur.count++
213
+ if (!cur.lastDate || new Date(e.date) > new Date(cur.lastDate)) {
214
+ cur.lastDate = e.date
215
+ cur.lastSubject = e.subject
216
+ }
217
+ map.set(addr, cur)
218
+ }
219
+ return [...map.values()].sort((a, b) => b.count - a.count || new Date(b.lastDate) - new Date(a.lastDate))
220
+ }
221
+
222
+ function formatImapDate(date) {
223
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
224
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
225
+ const d = new Date(date)
226
+ const day = String(d.getUTCDate()).padStart(2, '0')
227
+ const month = months[d.getUTCMonth()]
228
+ const year = d.getUTCFullYear()
229
+ return `${day}-${month}-${year}`
230
+ }
231
+
232
+ // Parse relative time (e.g., "2h", "30m", "7d") to IMAP date
233
+ function parseRelativeTime(timeStr) {
234
+ const match = timeStr.match(/^(\d+)(m|h|d)$/)
235
+ if (!match) {
236
+ throw new Error('Invalid time format. Use: 30m, 2h, 7d')
237
+ }
238
+ const value = parseInt(match[1])
239
+ const unit = match[2]
240
+ const now = new Date()
241
+ let past
242
+ switch (unit) {
243
+ case 'm': past = new Date(now.getTime() - value * 60 * 1000); break
244
+ case 'h': past = new Date(now.getTime() - value * 60 * 60 * 1000); break
245
+ case 'd': past = new Date(now.getTime() - value * 24 * 60 * 60 * 1000); break
246
+ default: throw new Error('Unknown time unit')
247
+ }
248
+ return formatImapDate(past)
249
+ }
250
+
251
+ // search UIDs inside an already-opened client(多个命令共用)
252
+ async function searchUids(client, criteria) {
253
+ const searchResult = await client.search(criteria, { uid: true })
254
+ return Array.isArray(searchResult) ? searchResult : []
255
+ }
256
+
257
+ // Check for new/unread emails
258
+ async function checkEmails(mailbox = DEFAULT_MAILBOX, limit = 10, recentTime = null, unreadOnly = false) {
259
+ const client = await connect()
260
+ try {
261
+ const lock = await openMailbox(client, mailbox, true)
262
+
263
+ const criteria = {}
264
+ if (unreadOnly) criteria.seen = false // 修复:imapflow 键是 seen:false(原写成 unseen:true 被忽略)
265
+ if (recentTime) criteria.since = parseRelativeTime(recentTime)
266
+ if (Object.keys(criteria).length === 0) criteria.all = true
267
+
268
+ const uids = await searchUids(client, criteria)
269
+ if (uids.length === 0) return []
270
+
271
+ return await fetchAndParseMessages(client, uids, limit)
272
+ } finally {
273
+ await client.logout()
274
+ }
275
+ }
276
+
277
+ // List subjects only(不下载正文——监控「有没有新邮件」的落地命令)
278
+ async function listSubjects(limit = 20, recentTime = null, mailbox = DEFAULT_MAILBOX) {
279
+ const client = await connect()
280
+ try {
281
+ const lock = await openMailbox(client, mailbox, true)
282
+ const criteria = {}
283
+ if (recentTime) criteria.since = parseRelativeTime(recentTime)
284
+ if (Object.keys(criteria).length === 0) criteria.all = true
285
+ const uids = await searchUids(client, criteria)
286
+ if (uids.length === 0) return []
287
+ return await fetchEnvelopes(client, uids, limit)
288
+ } finally {
289
+ await client.logout()
290
+ }
291
+ }
292
+
293
+ // List sender distribution(监控选址:先看发件人分布再决定搜什么)
294
+ async function listSenders(limit = 50, recentTime = null, mailbox = DEFAULT_MAILBOX) {
295
+ const envelopes = await listSubjects(limit, recentTime, mailbox)
296
+ return aggregateSenders(envelopes)
297
+ }
298
+
299
+ // Fetch full email by UID
300
+ async function fetchEmail(uid, mailbox = DEFAULT_MAILBOX) {
301
+ const client = await connect()
302
+ const results = []
303
+ try {
304
+ const lock = await openMailbox(client, mailbox)
305
+
306
+ const message = await client.fetchOne(uid, {
307
+ source: true,
308
+ envelope: true,
309
+ flags: true,
310
+ uid: true,
311
+ }, { uid: true })
312
+ if (!message) {
313
+ const err = new Error(`Message UID ${uid} not found`)
314
+ err.reason = 'not_found'
315
+ throw err
316
+ }
317
+ const parsed = await parseEmail(message.source)
318
+
319
+ results.push({
320
+ uid: message.uid,
321
+ seq: message.seq,
322
+ flags: message.flags,
323
+ ...parsed,
324
+ })
325
+
326
+ return results
327
+ } finally {
328
+ await client.logout()
329
+ }
330
+ }
331
+
332
+ // Download attachments from email
333
+ async function downloadAttachments(uid, mailbox = DEFAULT_MAILBOX, outputDir = '.', specificFilename = null) {
334
+ const client = await connect()
335
+
336
+ try {
337
+ await openMailbox(client, mailbox)
338
+
339
+ const message = await client.fetchOne(uid, {
340
+ source: true,
341
+ attachments: true,
342
+ envelope: true,
343
+ }, { uid: true })
344
+
345
+ if (!message) {
346
+ const err = new Error(`Message UID ${uid} not found`)
347
+ err.reason = 'not_found'
348
+ throw err
349
+ }
350
+
351
+ const parsed = await parseEmail(message.source, true)
352
+
353
+ if (!parsed.attachments || parsed.attachments.length === 0) {
354
+ return { uid, downloaded: [], message: 'No attachments found' }
355
+ }
356
+
357
+ if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true })
358
+
359
+ const downloaded = []
360
+ for (const attachment of parsed.attachments) {
361
+ if (specificFilename && attachment.filename !== specificFilename) continue
362
+ if (attachment.content) {
363
+ const filePath = path.join(outputDir, attachment.filename)
364
+ fs.writeFileSync(filePath, attachment.content)
365
+ downloaded.push({ filename: attachment.filename, path: filePath, size: attachment.size })
366
+ }
367
+ }
368
+
369
+ if (specificFilename && downloaded.length === 0) {
370
+ const availableFiles = parsed.attachments.map(a => a.filename).join(', ')
371
+ return { uid, downloaded: [], message: `File "${specificFilename}" not found. Available: ${availableFiles}` }
372
+ }
373
+
374
+ return { uid, downloaded, message: `Downloaded ${downloaded.length} attachment(s)` }
375
+ } finally {
376
+ await client.logout()
377
+ }
378
+ }
379
+
380
+ // Search emails with criteria
381
+ async function searchEmails(options) {
382
+ const client = await connect()
383
+
384
+ try {
385
+ const mailbox = options.mailbox || DEFAULT_MAILBOX
386
+ const lock = await openMailbox(client, mailbox, true)
387
+
388
+ const criteria = buildSearchCriteria(options)
389
+ const uids = await searchUids(client, criteria)
390
+ if (uids.length === 0) return []
391
+
392
+ const limit = parseInt(options.limit) || 100
393
+ return await fetchAndParseMessages(client, uids, limit)
394
+ } finally {
395
+ await client.logout()
396
+ }
397
+ }
398
+
399
+ // Mark message(s) as read/unread
400
+ async function updateFlags(uids, flags, action, mailbox = DEFAULT_MAILBOX) {
401
+ const client = await connect()
402
+
403
+ try {
404
+ const lock = await client.getMailboxLock(mailbox, { readOnly: false })
405
+
406
+ try {
407
+ const uidArray = Array.isArray(uids) ? uids.map(Number) : [Number(uids)]
408
+ const range = uidArray.join(',')
409
+ const flagArray = Array.isArray(flags) ? flags : [flags]
410
+
411
+ if (action === 'add') await client.messageFlagsAdd(range, flagArray, { uid: true })
412
+ else if (action === 'remove') await client.messageFlagsRemove(range, flagArray, { uid: true })
413
+ else if (action === 'set') await client.messageFlagsSet(range, flagArray, { uid: true })
414
+
415
+ return { success: true, uids: uidArray, action: `${action} ${flags}`, count: uidArray.length }
416
+ } finally {
417
+ lock.release()
418
+ }
419
+ } finally {
420
+ await client.logout()
421
+ }
422
+ }
423
+
424
+ async function markAsRead(uids, mailbox = DEFAULT_MAILBOX) {
425
+ return updateFlags(uids, '\\Seen', 'add', mailbox)
426
+ }
427
+
428
+ async function markAsUnread(uids, mailbox = DEFAULT_MAILBOX) {
429
+ return updateFlags(uids, '\\Seen', 'remove', mailbox)
430
+ }
431
+
432
+ // List all mailboxes
433
+ async function listMailboxes() {
434
+ const client = await connect()
435
+
436
+ try {
437
+ const mailboxes = await client.list()
438
+ return mailboxes.map(box => ({
439
+ name: box.path,
440
+ delimiter: box.delimiter,
441
+ attributes: Array.from(box.flags),
442
+ specialUse: box.specialUse || null,
443
+ }))
444
+ } finally {
445
+ await client.logout()
446
+ }
447
+ }
448
+
449
+ // Main CLI handler
450
+ async function main() {
451
+ const { command, options, positional } = parseArgs()
452
+ const jsonMode = options.json === true
453
+
454
+ try {
455
+ let result
456
+ let emptyReason = 'success'
457
+
458
+ switch (command) {
459
+ case 'check':
460
+ result = await checkEmails(
461
+ options.mailbox || DEFAULT_MAILBOX,
462
+ parseInt(options.limit) || 10,
463
+ options.recent || null,
464
+ options.unseen === 'true' || options.unseen === true
465
+ )
466
+ if (result.length === 0) emptyReason = 'no_new_mail' // 邮箱空 = 成功
467
+ break
468
+
469
+ case 'list-subjects':
470
+ result = await listSubjects(
471
+ parseInt(options.limit) || 20,
472
+ options.recent || null,
473
+ options.mailbox || DEFAULT_MAILBOX
474
+ )
475
+ if (result.length === 0) emptyReason = 'no_new_mail'
476
+ break
477
+
478
+ case 'list-senders':
479
+ result = await listSenders(
480
+ parseInt(options.limit) || 50,
481
+ options.recent || null,
482
+ options.mailbox || DEFAULT_MAILBOX
483
+ )
484
+ if (result.length === 0) emptyReason = 'no_new_mail'
485
+ break
486
+
487
+ case 'fetch':
488
+ if (!positional[0]) throw new Error('UID required: node imap.js fetch <uid>')
489
+ result = await fetchEmail(positional[0], options.mailbox)
490
+ break
491
+
492
+ case 'download':
493
+ if (!positional[0]) throw new Error('UID required: node imap.js download <uid>')
494
+ result = await downloadAttachments(positional[0], options.mailbox, options.dir || '.', options.file || null)
495
+ break
496
+
497
+ case 'search':
498
+ result = await searchEmails(options)
499
+ if (result.length === 0) emptyReason = 'no_new_mail'
500
+ break
501
+
502
+ case 'mark-read':
503
+ if (positional.length === 0) throw new Error('UID(s) required: node imap.js mark-read <uid> [uid2...]')
504
+ result = await markAsRead(positional, options.mailbox)
505
+ break
506
+
507
+ case 'mark-unread':
508
+ if (positional.length === 0) throw new Error('UID(s) required: node imap.js mark-unread <uid> [uid2...]')
509
+ result = await markAsUnread(positional, options.mailbox)
510
+ break
511
+
512
+ case 'list-mailboxes':
513
+ result = await listMailboxes()
514
+ break
515
+
516
+ default:
517
+ console.error('Unknown command:', command)
518
+ console.error('Available: check, list-subjects, list-senders, fetch, download, search, mark-read, mark-unread, list-mailboxes')
519
+ process.exit(1)
520
+ }
521
+
522
+ if (jsonMode) {
523
+ console.log(JSON.stringify({ ok: true, reason: emptyReason, command, result }, null, 2))
524
+ } else {
525
+ console.log(JSON.stringify(result, null, 2))
526
+ }
527
+ } catch (err) {
528
+ const reason = err.reason || classifyError(err)
529
+ if (jsonMode) {
530
+ console.log(JSON.stringify({ ok: false, reason, command: process.argv[2], error: String(err.message).slice(0, 300) }, null, 2))
531
+ } else {
532
+ console.error(`Error [${reason}]:`, err.message)
533
+ }
534
+ process.exit(reason === 'auth_failed' || reason === 'config_missing' || reason === 'not_found' ? 2 : 1)
535
+ }
536
+ }
537
+
538
+ if (require.main === module) main()
539
+
540
+ module.exports = { parseRelativeTime, formatImapDate, buildSearchCriteria, aggregateSenders, parseEmail }
@@ -0,0 +1,86 @@
1
+ // imap.test.js — 聚合/判定链单测 + Certum fixture 回归(node 直跑)
2
+ // Certum 三封是 239 事故的真邮件标题(妹妹验收 P1:唯一防复发的测试,必含 SimplySign)
3
+ const assert = require('node:assert/strict')
4
+ const { aggregateSenders, parseRelativeTime, buildSearchCriteria, parseEmail } = require('./imap.js')
5
+
6
+ // === Certum fixture:三封真实邮件(2026-09-30,QQ 邮箱实收) ===
7
+ const CERTUM_SUBJECTS = [
8
+ 'Code Signing Certificate Configuration Required',
9
+ 'Certum 欢迎信',
10
+ 'SimplySign 激活码和激活指引',
11
+ ]
12
+ const CERTUM_SENDERS = ['noreply@certum.pl', 'support@certum.pl', 'noreply@simplysign.io']
13
+
14
+ function mimeMail({ from, subject, body }) {
15
+ return [
16
+ 'From: Certum <' + from + '>',
17
+ 'To: user@qq.com',
18
+ 'Subject: =?UTF-8?B?' + Buffer.from(subject).toString('base64') + '?=',
19
+ 'MIME-Version: 1.0',
20
+ 'Content-Type: text/plain; charset=UTF-8',
21
+ '',
22
+ body,
23
+ ].join('\r\n')
24
+ }
25
+
26
+ // 1. 中文主题(Base64 UTF-8 编码头)能正确解码——妹妹只收英文没踩过,装机用户中文为主
27
+ // 2. SimplySign 激活码在正文——fetch 必须能拿到(当年只看标题判"不是它",激活码从头到尾没被找到)
28
+ const asyncTests = (async () => {
29
+ const parsed = await parseEmail(mimeMail({
30
+ from: CERTUM_SENDERS[1], subject: CERTUM_SUBJECTS[1], body: '欢迎使用 Certum。',
31
+ }))
32
+ assert.equal(parsed.subject, 'Certum 欢迎信', 'Base64 UTF-8 中文主题解码')
33
+ assert.ok(parsed.from.includes('support@certum.pl'), `from 含地址(mailparser 显示名带引号): ${parsed.from}`)
34
+ assert.ok(parsed.text.includes('欢迎使用'))
35
+
36
+ const ACT_CODE = 'ACT-7F3K-92QX-11ZM'
37
+ const parsed2 = await parseEmail(mimeMail({
38
+ from: CERTUM_SENDERS[2], subject: CERTUM_SUBJECTS[2],
39
+ body: `您的 SimplySign 激活码:${ACT_CODE}\n请于 14 天内完成激活。`,
40
+ }))
41
+ assert.equal(parsed2.subject, CERTUM_SUBJECTS[2])
42
+ assert.ok(parsed2.text.includes(ACT_CODE), '激活码必须能从正文拿到')
43
+ assert.ok(parsed2.snippet.includes('激活码'), 'snippet 也要可见(监控摘要层)')
44
+ })()
45
+
46
+ // 3. 发件人分布聚合:三封两个域——「先看分布再决定搜什么」的数据基础
47
+ {
48
+ const envelopes = [
49
+ { from: CERTUM_SENDERS[0], subject: CERTUM_SUBJECTS[0], date: '2026-09-30T03:21:00Z' },
50
+ { from: CERTUM_SENDERS[1], subject: CERTUM_SUBJECTS[1], date: '2026-09-30T03:31:00Z' },
51
+ { from: CERTUM_SENDERS[2], subject: CERTUM_SUBJECTS[2], date: '2026-09-30T09:21:00Z' },
52
+ ]
53
+ const agg = aggregateSenders(envelopes)
54
+ assert.equal(agg.length, 3, '三个不同发件人地址')
55
+ assert.ok(agg.some(s => s.address === 'noreply@certum.pl' && s.lastSubject === CERTUM_SUBJECTS[0]))
56
+ // 若同域多发(真实 Certum 场景重演:support@certum.pl 再来一封)→ count 体现分布
57
+ const agg2 = aggregateSenders([...envelopes, { from: 'support@certum.pl', subject: 're: 证书', date: '2026-10-02T10:00:00Z' }])
58
+ assert.equal(agg2.find(s => s.address === 'support@certum.pl').count, 2, '同地址聚合计数')
59
+ }
60
+
61
+ // 4. 监控规则②的判定链:标题列表里泛化词命中(不预设单一域名/事件名)
62
+ {
63
+ const hit = CERTUM_SUBJECTS.some(s => /certum|激活|certificate|signing/i.test(s))
64
+ assert.ok(hit, '三封中至少一封被泛化词命中(防 239:别搜事件名「签发」——三封标题一个都不含)')
65
+ assert.ok(!CERTUM_SUBJECTS.includes('签发') && !CERTUM_SUBJECTS.some(s => s.includes('签发')),
66
+ '回归锚点:真实标题里没有「签发」二字——监控规则若只搜事件名必然全漏')
67
+ }
68
+
69
+ // 5. parseRelativeTime
70
+ {
71
+ const d = parseRelativeTime('2h')
72
+ assert.match(d, /^\d{2}-[A-Z][a-z]{2}-\d{4}$/, 'IMAP 日期格式 DD-Mmm-YYYY')
73
+ assert.throws(() => parseRelativeTime('2x'), /Invalid time format/)
74
+ }
75
+
76
+ // 6. buildSearchCriteria:unseen 键修复后的正确形态
77
+ {
78
+ const c = buildSearchCriteria({ unseen: true, recent: '30m' })
79
+ assert.equal(c.seen, false, 'unseen → seen:false(原 bug:写成 unseen:true 被服务器忽略)')
80
+ assert.ok(c.since, 'recent 转 since')
81
+ }
82
+
83
+ asyncTests.then(
84
+ () => console.log('imap.test ALL PASS — Certum fixture 回归绿(239 防线)'),
85
+ (err) => { console.error(String(err)); process.exit(1) }
86
+ )