dsh-instruction-memory 1.4.0 → 1.4.2

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/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
- /** One mailbox check. Deliberately permissive: the SMTP server is the authority. */
106
- const EMAIL_RE = /^[^\s@<>()[\],;:"]+@[^\s@<>()[\],;:"]+$/
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
- let to = envText(env, FEEDBACK_ENV.to) || textOf(file.to)
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) || textOf(fileSmtp.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
- if (!EMAIL_RE.test(to)) return { ok: false, message: '反馈收件邮箱格式不正确,请检查收件人配置' }
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 !== '' && EMAIL_RE.test(user) ? user : to)
409
- if (!EMAIL_RE.test(fromAddress)) {
410
- return { ok: false, message: '反馈发件邮箱格式不正确,请检查 FROM 配置' }
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: EMAIL_RE.test(contact) ? contact : '',
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
- const encodeWord = (value) => '=?UTF-8?B?' + b64(oneLine(value)) + '?='
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
- const from = textOf(options.from)
511
- const to = textOf(options.to)
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: ' + encodeWord('DSH 指令记忆反馈') + ' <' + from + '>',
608
+ 'From: ' + encodeWords('DSH 指令记忆反馈') + ' <' + from + '>',
519
609
  'To: <' + to + '>',
520
610
  ]
521
- if (EMAIL_RE.test(replyTo)) headers.push('Reply-To: <' + replyTo + '>')
611
+ if (isMailbox(replyTo)) headers.push('Reply-To: <' + replyTo + '>')
522
612
  headers.push(
523
- 'Subject: ' + encodeWord(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 capabilityText = caps.join(' ').toUpperCase()
646
- const canLogin = /\bLOGIN\b/.test(capabilityText)
647
- const canPlain = /\bPLAIN\b/.test(capabilityText)
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.0",
3
+ "version": "1.4.2",
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": {