dsh-instruction-memory 1.4.0 → 1.4.1
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/CHANGELOG.md +117 -0
- package/README.md +21 -6
- package/docs/screenshot.png +0 -0
- package/lib/client.js +391 -81
- package/lib/index.js +217 -72
- package/lib/mailer.js +141 -19
- package/package.json +3 -2
package/lib/mailer.js
CHANGED
|
@@ -100,10 +100,30 @@ const MAX_FEEDBACK_COOLDOWN_MS = 10 * 60_000
|
|
|
100
100
|
/** SMTP defaults: implicit TLS on 465, STARTTLS on 587. */
|
|
101
101
|
const DEFAULT_SECURE_PORT = 465
|
|
102
102
|
const DEFAULT_PLAIN_PORT = 587
|
|
103
|
+
/** Idle timeout of one protocol step. */
|
|
103
104
|
const SMTP_TIMEOUT_MS = 20_000
|
|
105
|
+
/**
|
|
106
|
+
* Ceiling for a whole delivery. Every step may idle for SMTP_TIMEOUT_MS, so
|
|
107
|
+
* without an overall bound a slow peer keeps the request open far longer than
|
|
108
|
+
* the panel waits: the user is told "timeout" while the mail is still being
|
|
109
|
+
* delivered, and a retry duplicates it.
|
|
110
|
+
*/
|
|
111
|
+
export const SMTP_TOTAL_TIMEOUT_MS = 60_000
|
|
112
|
+
/**
|
|
113
|
+
* Upper bound for one response line. A peer that never sends CRLF must not be
|
|
114
|
+
* able to grow the read buffer without limit.
|
|
115
|
+
*/
|
|
116
|
+
const MAX_RESPONSE_BYTES = 64 * 1024
|
|
104
117
|
|
|
105
|
-
/**
|
|
106
|
-
|
|
118
|
+
/**
|
|
119
|
+
* One mailbox check. The local part stays permissive (the SMTP server is the
|
|
120
|
+
* authority) but control bytes and newlines never pass, and non-ASCII is
|
|
121
|
+
* rejected on purpose: SMTPUTF8 is not implemented, so an internationalised
|
|
122
|
+
* address would leave as a raw 8-bit header and be refused — a readable message
|
|
123
|
+
* here beats a mangled envelope.
|
|
124
|
+
*/
|
|
125
|
+
const EMAIL_RE = /^[^\s@<>()[\],;:"\u0000-\u001f\u007f-\u009f]+@[^\s@<>()[\],;:"\u0000-\u001f\u007f-\u009f]+$/
|
|
126
|
+
const ASCII_RE = /^[\x20-\x7e]+$/
|
|
107
127
|
|
|
108
128
|
const textOf = (value) => (typeof value === 'string' ? value.trim() : '')
|
|
109
129
|
const envText = (env, name) => (env !== null && typeof env === 'object' && typeof env[name] === 'string' ? env[name].trim() : '')
|
|
@@ -120,6 +140,30 @@ const scalarText = (value) => {
|
|
|
120
140
|
return ''
|
|
121
141
|
}
|
|
122
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Whether an address may be used in an envelope or a header, with the reason
|
|
145
|
+
* when it may not (a fragment so callers can name the field themselves).
|
|
146
|
+
*/
|
|
147
|
+
function mailboxVerdict(address) {
|
|
148
|
+
const text = textOf(address)
|
|
149
|
+
if (text === '' || !EMAIL_RE.test(text)) return '格式不正确'
|
|
150
|
+
if (!ASCII_RE.test(text)) return '含非 ASCII 字符(SMTPUTF8 未启用,暂不支持)'
|
|
151
|
+
return ''
|
|
152
|
+
}
|
|
153
|
+
const isMailbox = (address) => mailboxVerdict(address) === ''
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A header field and an SMTP command are single lines. Anything else is a
|
|
157
|
+
* forgery attempt: refuse it here instead of trusting the caller to have
|
|
158
|
+
* validated an address first.
|
|
159
|
+
*/
|
|
160
|
+
function assertHeaderSafe(value, field) {
|
|
161
|
+
const text = String(value === null || value === undefined ? '' : value)
|
|
162
|
+
if (/[\r\n]/.test(text)) throw new Error(field + '不能包含换行符')
|
|
163
|
+
if (/[\u0000-\u001f\u007f]/.test(text)) throw new Error(field + '不能包含控制字符')
|
|
164
|
+
return text
|
|
165
|
+
}
|
|
166
|
+
|
|
123
167
|
/**
|
|
124
168
|
* Remove control characters that could forge a new header or an SMTP command.
|
|
125
169
|
* Newlines inside the body are legitimate and survive; every other field is
|
|
@@ -336,8 +380,18 @@ export function feedbackConfigFrom(options = {}) {
|
|
|
336
380
|
const fileSmtp = file.smtp !== null && typeof file.smtp === 'object' && !Array.isArray(file.smtp) ? file.smtp : {}
|
|
337
381
|
const configPath = textOf(options.configPath)
|
|
338
382
|
|
|
339
|
-
|
|
383
|
+
const fileTo = textOf(file.to)
|
|
340
384
|
const encryptedTo = textOf(file.toEncrypted)
|
|
385
|
+
if (fileTo !== '' && encryptedTo !== '') {
|
|
386
|
+
// Silently preferring one of them would either leave the address in clear
|
|
387
|
+
// text while the operator believes the encryption is in force, or hide a
|
|
388
|
+
// broken ciphertext. Both are configuration mistakes worth refusing.
|
|
389
|
+
return {
|
|
390
|
+
ok: false,
|
|
391
|
+
message: '配置文件同时包含 to 与 toEncrypted:请删除明文 to(否则收件邮箱仍以明文保存,加密不生效)。',
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
let to = envText(env, FEEDBACK_ENV.to) || fileTo
|
|
341
395
|
const secret = envText(env, FEEDBACK_ENV.secret)
|
|
342
396
|
const explicitHost = envText(env, FEEDBACK_ENV.host)
|
|
343
397
|
|
|
@@ -366,9 +420,16 @@ export function feedbackConfigFrom(options = {}) {
|
|
|
366
420
|
const user = envText(env, FEEDBACK_ENV.user)
|
|
367
421
|
|| (urlConfig === null ? '' : urlConfig.user)
|
|
368
422
|
|| textOf(fileSmtp.user)
|
|
423
|
+
const passFromFile = scalarText(fileSmtp.pass)
|
|
424
|
+
if (fileSmtp.pass !== undefined && fileSmtp.pass !== null && fileSmtp.pass !== '' && passFromFile === '') {
|
|
425
|
+
// A JSON number is fine (scalarText stringifies it, like port/secure); an
|
|
426
|
+
// object or array would otherwise be dropped silently and surface as a
|
|
427
|
+
// wrong-password failure nobody can explain.
|
|
428
|
+
return { ok: false, message: '配置文件 smtp.pass 类型无效:请写成字符串' }
|
|
429
|
+
}
|
|
369
430
|
const pass = envText(env, FEEDBACK_ENV.pass) !== ''
|
|
370
431
|
? envText(env, FEEDBACK_ENV.pass)
|
|
371
|
-
: ((urlConfig === null ? '' : urlConfig.pass) ||
|
|
432
|
+
: ((urlConfig === null ? '' : urlConfig.pass) || passFromFile)
|
|
372
433
|
|
|
373
434
|
const setupHint = '请通过环境变量 ' + FEEDBACK_ENV.to + ' / ' + FEEDBACK_ENV.host
|
|
374
435
|
+ '(或复用 ' + FEEDBACK_ENV.smtpUrl + ')配置'
|
|
@@ -390,7 +451,8 @@ export function feedbackConfigFrom(options = {}) {
|
|
|
390
451
|
}
|
|
391
452
|
|
|
392
453
|
if (to === '') return { ok: false, message: '尚未配置反馈收件邮箱。' + setupHint + '。' }
|
|
393
|
-
|
|
454
|
+
const toVerdict = mailboxVerdict(to)
|
|
455
|
+
if (toVerdict !== '') return { ok: false, message: '反馈收件邮箱' + toVerdict + ',请检查收件人配置' }
|
|
394
456
|
if (host === '') return { ok: false, message: '尚未配置 SMTP 服务器。' + setupHint + '。' }
|
|
395
457
|
|
|
396
458
|
const secure = boolOf(secureText, false)
|
|
@@ -405,9 +467,10 @@ export function feedbackConfigFrom(options = {}) {
|
|
|
405
467
|
|
|
406
468
|
// A From is required by SMTP. It does not have to be an alias the server
|
|
407
469
|
// accepts (that is the server's policy), but it must be one mailbox.
|
|
408
|
-
const fromAddress = from !== '' ? from : (user !== '' &&
|
|
409
|
-
|
|
410
|
-
|
|
470
|
+
const fromAddress = from !== '' ? from : (user !== '' && isMailbox(user) ? user : to)
|
|
471
|
+
const fromVerdict = mailboxVerdict(fromAddress)
|
|
472
|
+
if (fromVerdict !== '') {
|
|
473
|
+
return { ok: false, message: '反馈发件邮箱' + fromVerdict + ',请检查 FROM 配置' }
|
|
411
474
|
}
|
|
412
475
|
if (user === '' && pass !== '') {
|
|
413
476
|
return { ok: false, message: '已配置 SMTP 密码但缺少 SMTP 用户名(' + FEEDBACK_ENV.user + ')' }
|
|
@@ -489,7 +552,7 @@ export function buildFeedbackMessage(options = {}) {
|
|
|
489
552
|
messageId: randomUUID() + '@' + messageDomain(options.from),
|
|
490
553
|
// Only a bare, valid address becomes Reply-To. Free-form contact text must
|
|
491
554
|
// never reach a header: a newline there would inject a header.
|
|
492
|
-
replyTo:
|
|
555
|
+
replyTo: isMailbox(contact) ? contact : '',
|
|
493
556
|
}
|
|
494
557
|
}
|
|
495
558
|
|
|
@@ -503,24 +566,51 @@ function messageDomain(from) {
|
|
|
503
566
|
}
|
|
504
567
|
|
|
505
568
|
const b64 = (value) => Buffer.from(String(value), 'utf8').toString('base64')
|
|
506
|
-
|
|
569
|
+
/**
|
|
570
|
+
* RFC 2047: one encoded word may not exceed 75 characters, so a long subject is
|
|
571
|
+
* split into several. The payload is capped at 45 bytes (60 base64 characters +
|
|
572
|
+
* the 12-character wrapper = 72) and a multi-byte character never straddles two
|
|
573
|
+
* words.
|
|
574
|
+
*/
|
|
575
|
+
const ENCODED_WORD_PAYLOAD_BYTES = 45
|
|
576
|
+
function encodeWords(value) {
|
|
577
|
+
const text = oneLine(value)
|
|
578
|
+
if (text === '') return '=?UTF-8?B??='
|
|
579
|
+
const words = []
|
|
580
|
+
let chunk = ''
|
|
581
|
+
let bytes = 0
|
|
582
|
+
for (const char of text) {
|
|
583
|
+
const size = Buffer.byteLength(char, 'utf8')
|
|
584
|
+
if (bytes + size > ENCODED_WORD_PAYLOAD_BYTES && chunk !== '') {
|
|
585
|
+
words.push('=?UTF-8?B?' + b64(chunk) + '?=')
|
|
586
|
+
chunk = ''
|
|
587
|
+
bytes = 0
|
|
588
|
+
}
|
|
589
|
+
chunk += char
|
|
590
|
+
bytes += size
|
|
591
|
+
}
|
|
592
|
+
if (chunk !== '') words.push('=?UTF-8?B?' + b64(chunk) + '?=')
|
|
593
|
+
return words.join(' ')
|
|
594
|
+
}
|
|
507
595
|
|
|
508
596
|
/** RFC 5322 headers + base64 body. Base64 never needs dot-stuffing. */
|
|
509
597
|
export function buildRawMail(options = {}) {
|
|
510
|
-
|
|
511
|
-
|
|
598
|
+
// The envelope addresses reach the headers verbatim, so this formatter refuses
|
|
599
|
+
// a newline or a control byte itself instead of trusting every caller.
|
|
600
|
+
const from = assertHeaderSafe(textOf(options.from), '发件邮箱')
|
|
601
|
+
const to = assertHeaderSafe(textOf(options.to), '收件邮箱')
|
|
512
602
|
const now = options.now instanceof Date ? options.now : new Date()
|
|
513
603
|
const messageId = textOf(options.messageId) || (randomUUID() + '@' + messageDomain(from))
|
|
514
604
|
const subject = oneLine(options.subject)
|
|
515
605
|
const replyTo = oneLine(options.replyTo)
|
|
516
606
|
const body64 = b64(options.text).replace(/(.{76})/g, '$1\r\n')
|
|
517
607
|
const headers = [
|
|
518
|
-
'From: ' +
|
|
608
|
+
'From: ' + encodeWords('DSH 指令记忆反馈') + ' <' + from + '>',
|
|
519
609
|
'To: <' + to + '>',
|
|
520
610
|
]
|
|
521
|
-
if (
|
|
611
|
+
if (isMailbox(replyTo)) headers.push('Reply-To: <' + replyTo + '>')
|
|
522
612
|
headers.push(
|
|
523
|
-
'Subject: ' +
|
|
613
|
+
'Subject: ' + encodeWords(subject),
|
|
524
614
|
'Date: ' + now.toUTCString(),
|
|
525
615
|
'Message-ID: <' + messageId + '>',
|
|
526
616
|
'MIME-Version: 1.0',
|
|
@@ -546,6 +636,13 @@ function createChannel(socket, timeoutMs) {
|
|
|
546
636
|
}
|
|
547
637
|
const onData = (chunk) => {
|
|
548
638
|
state.buffer += chunk.toString('utf8')
|
|
639
|
+
// A peer that keeps sending bytes without CRLF must not be able to grow this
|
|
640
|
+
// buffer without bound; every other read is line-delimited and small.
|
|
641
|
+
if (state.buffer.length > MAX_RESPONSE_BYTES) {
|
|
642
|
+
fail(new Error('SMTP 响应过长(超过 ' + MAX_RESPONSE_BYTES + ' 字节),已断开连接'))
|
|
643
|
+
socket.destroy()
|
|
644
|
+
return
|
|
645
|
+
}
|
|
549
646
|
for (;;) {
|
|
550
647
|
const index = state.buffer.indexOf('\r\n')
|
|
551
648
|
if (index === -1) return
|
|
@@ -617,7 +714,7 @@ function createChannel(socket, timeoutMs) {
|
|
|
617
714
|
socket.on('error', () => {})
|
|
618
715
|
}
|
|
619
716
|
|
|
620
|
-
return { readResponse, write, dispose }
|
|
717
|
+
return { readResponse, write, dispose, fail }
|
|
621
718
|
}
|
|
622
719
|
|
|
623
720
|
const smtpError = (what, response) =>
|
|
@@ -641,10 +738,23 @@ async function hello(channel, name) {
|
|
|
641
738
|
return response.lines.map((line) => line.trim())
|
|
642
739
|
}
|
|
643
740
|
|
|
741
|
+
/**
|
|
742
|
+
* The AUTH mechanisms a server actually offers. Only the AUTH capability line
|
|
743
|
+
* counts (RFC 4954); matching the bare word anywhere in the EHLO reply used to
|
|
744
|
+
* pick LOGIN whenever an unrelated extension happened to contain "LOGIN"
|
|
745
|
+
* (e.g. X-LOGIN-POLICY). Exported so the suite can pin that down.
|
|
746
|
+
*/
|
|
747
|
+
export function smtpAuthMechanisms(caps) {
|
|
748
|
+
const list = Array.isArray(caps) ? caps : []
|
|
749
|
+
const authLine = list.find((cap) => typeof cap === 'string' && /^AUTH[= ]/i.test(cap))
|
|
750
|
+
if (authLine === undefined) return []
|
|
751
|
+
return authLine.replace(/^AUTH[= ]+/i, '').toUpperCase().split(/[\s,]+/).filter((name) => name !== '')
|
|
752
|
+
}
|
|
753
|
+
|
|
644
754
|
async function authenticate(channel, smtp, caps) {
|
|
645
|
-
const
|
|
646
|
-
const canLogin =
|
|
647
|
-
const canPlain =
|
|
755
|
+
const mechanisms = smtpAuthMechanisms(caps)
|
|
756
|
+
const canLogin = mechanisms.includes('LOGIN')
|
|
757
|
+
const canPlain = mechanisms.includes('PLAIN')
|
|
648
758
|
const fail = (response) => new Error('SMTP 认证失败(' + response.code + '):'
|
|
649
759
|
+ cutText(response.lines.join(' '), 300)
|
|
650
760
|
+ '。请检查用户名 / 密码;Gmail、QQ 邮箱等服务需要「应用专用密码」,不能用登录密码。')
|
|
@@ -721,6 +831,10 @@ export async function sendSmtpMail(options = {}) {
|
|
|
721
831
|
throw new Error('SMTP 配置不完整(host / port)')
|
|
722
832
|
}
|
|
723
833
|
if (from === '' || to === '') throw new Error('SMTP 配置不完整(from / to)')
|
|
834
|
+
const fromVerdict = mailboxVerdict(from)
|
|
835
|
+
if (fromVerdict !== '') throw new Error('发件邮箱' + fromVerdict)
|
|
836
|
+
const toVerdict = mailboxVerdict(to)
|
|
837
|
+
if (toVerdict !== '') throw new Error('收件邮箱' + toVerdict)
|
|
724
838
|
|
|
725
839
|
const mail = buildRawMail({
|
|
726
840
|
from,
|
|
@@ -736,6 +850,13 @@ export async function sendSmtpMail(options = {}) {
|
|
|
736
850
|
|
|
737
851
|
let socket = await openSocket({ host, port, secure: smtp.secure === true }, timeoutMs)
|
|
738
852
|
let channel = createChannel(socket, timeoutMs)
|
|
853
|
+
// Overall ceiling: the per-step idle timeout may be paid several times over
|
|
854
|
+
// (greeting, EHLO, STARTTLS, AUTH, DATA), which would outlast the panel's own
|
|
855
|
+
// timeout and show "timed out" for a mail that is still on its way.
|
|
856
|
+
const overallTimer = setTimeout(() => {
|
|
857
|
+
channel.fail(new Error('SMTP 总耗时超过 ' + SMTP_TOTAL_TIMEOUT_MS + ' ms,已中止发送'))
|
|
858
|
+
socket.destroy()
|
|
859
|
+
}, SMTP_TOTAL_TIMEOUT_MS)
|
|
739
860
|
try {
|
|
740
861
|
await expectCode(channel, 220, '服务端欢迎')
|
|
741
862
|
let caps = await hello(channel, clientName)
|
|
@@ -770,6 +891,7 @@ export async function sendSmtpMail(options = {}) {
|
|
|
770
891
|
}
|
|
771
892
|
return { messageId: mail.messageId, accepted: to, response: accepted.lines.join(' ') }
|
|
772
893
|
} finally {
|
|
894
|
+
clearTimeout(overallTimer)
|
|
773
895
|
channel.dispose()
|
|
774
896
|
// Add an error listener before destroy: a socket destroyed mid-TLS can emit
|
|
775
897
|
// ECONNRESET synchronously, and an unhandled 'error' would take the host down.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-instruction-memory",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "User-only instruction memory for DSH: long-term instructions maintained in the settings UI and auto-injected into every conversation — the model has no write access. 指令记忆:只由用户在设置页维护的长期指令,自动注入此后每轮对话;模型没有任何写入口。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -59,7 +59,8 @@
|
|
|
59
59
|
"scripts": {
|
|
60
60
|
"lint": "node lint.mjs",
|
|
61
61
|
"test": "node lint.mjs && node host-range-test.mjs && node smoke-test.mjs && node contract-test.mjs && node verify.mjs && node feedback-test.mjs && node ui-test.mjs",
|
|
62
|
-
"prepublishOnly": "node check-metadata.mjs"
|
|
62
|
+
"prepublishOnly": "node check-metadata.mjs && npm test",
|
|
63
|
+
"smoke:hosts": "node live-host-smoke.mjs"
|
|
63
64
|
},
|
|
64
65
|
"dsh": {
|
|
65
66
|
"bundle": {
|