dsh-instruction-memory 1.3.3 → 1.4.0

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 ADDED
@@ -0,0 +1,810 @@
1
+ /**
2
+ * dsh-instruction-memory — feedback mailer (Host side, zero dependencies).
3
+ *
4
+ * The settings page must be able to send a problem report / suggestion to the
5
+ * maintainer. There is no email dependency in this package and deliberately no
6
+ * new one: this module speaks just enough SMTP (RFC 5321) over `node:net` /
7
+ * `node:tls` to deliver one plain-text message to one fixed recipient.
8
+ *
9
+ * Design constraints that shaped the code below:
10
+ *
11
+ * - The recipient and the credentials NEVER come from the browser. They are
12
+ * read from the environment or from a config file next to the store (see
13
+ * `feedbackConfigFrom`), so the page can only send to a destination the
14
+ * operator chose. A user-typed "to" field would turn the settings route into
15
+ * an open relay for anyone who can reach it.
16
+ * - The message body is our own composed text, never raw HTML: no header
17
+ * injection is possible from the feedback fields (they land in the body and
18
+ * are sanitised), and the SMTP DATA phase is clean ASCII/base64.
19
+ * - Credentials are refused on an unencrypted connection. If the server
20
+ * advertises STARTTLS we upgrade; if it does not and a username is set, the
21
+ * send fails instead of leaking the password. A local smart-host without
22
+ * authentication (the usual dev setup) still works over plain TCP.
23
+ * - Every failure throws an Error with a message written for the user; the
24
+ * caller decides whether to show it and how much of it to log.
25
+ */
26
+
27
+ import { createCipheriv, createDecipheriv, randomBytes, randomUUID, scryptSync } from 'node:crypto'
28
+ import { connect as netConnect } from 'node:net'
29
+ import { connect as tlsConnect } from 'node:tls'
30
+ import { hostname } from 'node:os'
31
+ import { pathToFileURL } from 'node:url'
32
+
33
+ /** The question kinds the form offers. Kept in one place for both halves. */
34
+ export const FEEDBACK_TYPES = Object.freeze(['bug', 'suggestion', 'other'])
35
+
36
+ /** Human labels used in the mail subject and body. */
37
+ export const FEEDBACK_TYPE_LABELS = Object.freeze({
38
+ bug: '问题反馈(Bug)',
39
+ suggestion: '功能建议',
40
+ other: '其他',
41
+ })
42
+
43
+ /**
44
+ * Every ceiling the feedback form enforces. Published on the snapshot so the
45
+ * Client can enforce the same numbers while typing; the Host still re-validates
46
+ * because a request can be forged past any UI.
47
+ */
48
+ export const FEEDBACK_LIMITS = Object.freeze({
49
+ maxTitle: 120,
50
+ maxBody: 4000,
51
+ maxContact: 200,
52
+ maxLocale: 40,
53
+ maxTimezone: 64,
54
+ maxUserAgent: 300,
55
+ maxVersion: 64,
56
+ maxSubject: 180,
57
+ })
58
+
59
+ /** Environment variables the operator can use instead of the config file. */
60
+ export const FEEDBACK_ENV = Object.freeze({
61
+ to: 'DSH_INSTRUCTION_MEMORY_FEEDBACK_TO',
62
+ from: 'DSH_INSTRUCTION_MEMORY_FEEDBACK_FROM',
63
+ host: 'DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_HOST',
64
+ port: 'DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_PORT',
65
+ secure: 'DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_SECURE',
66
+ user: 'DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_USER',
67
+ pass: 'DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_PASS',
68
+ /**
69
+ * A URL-shaped SMTP credential that other tools on this machine already keep
70
+ * (`smtps://user:pass@host:port`). Supported as a fallback source so the
71
+ * operator does not have to duplicate a working credential into the plugin's
72
+ * own variables; explicit DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_* still win.
73
+ */
74
+ smtpUrl: 'DSH_CRB_SMTP_URL',
75
+ config: 'DSH_INSTRUCTION_MEMORY_FEEDBACK_CONFIG',
76
+ cooldown: 'DSH_INSTRUCTION_MEMORY_FEEDBACK_COOLDOWN_MS',
77
+ secret: 'DSH_INSTRUCTION_MEMORY_FEEDBACK_SECRET',
78
+ })
79
+
80
+ /**
81
+ * At-rest protection for the recipient address. The config file may store
82
+ * `toEncrypted` instead of `to`; the key comes from the environment, never from
83
+ * the file, so a copy of the JSON alone does not reveal the mailbox.
84
+ *
85
+ * Honest scope: this protects the config file at rest (screenshots, accidental
86
+ * commits, backups). It cannot hide the address from the SMTP server or from
87
+ * anyone who can read the same user's environment — a mail envelope has to
88
+ * carry the recipient in clear text. TLS protects the wire, not the file.
89
+ */
90
+ const ENCRYPTED_PREFIX = 'im1:'
91
+ const MIN_SECRET_LENGTH = 16
92
+ const SALT_BYTES = 16
93
+ const IV_BYTES = 12
94
+ const TAG_BYTES = 16
95
+
96
+ /** How long a successful send blocks the next one (anti double-click / spam). */
97
+ export const DEFAULT_FEEDBACK_COOLDOWN_MS = 30_000
98
+ const MAX_FEEDBACK_COOLDOWN_MS = 10 * 60_000
99
+
100
+ /** SMTP defaults: implicit TLS on 465, STARTTLS on 587. */
101
+ const DEFAULT_SECURE_PORT = 465
102
+ const DEFAULT_PLAIN_PORT = 587
103
+ const SMTP_TIMEOUT_MS = 20_000
104
+
105
+ /** One mailbox check. Deliberately permissive: the SMTP server is the authority. */
106
+ const EMAIL_RE = /^[^\s@<>()[\],;:"]+@[^\s@<>()[\],;:"]+$/
107
+
108
+ const textOf = (value) => (typeof value === 'string' ? value.trim() : '')
109
+ const envText = (env, name) => (env !== null && typeof env === 'object' && typeof env[name] === 'string' ? env[name].trim() : '')
110
+ /**
111
+ * A config-file scalar. JSON files naturally carry `"port": 587` and
112
+ * `"secure": true`; treating those as absent (only strings accepted) silently
113
+ * fell back to the default port and to plaintext, so numbers and booleans are
114
+ * stringified here and validated by the same code path as environment strings.
115
+ */
116
+ const scalarText = (value) => {
117
+ if (typeof value === 'string') return value.trim()
118
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value)
119
+ if (typeof value === 'boolean') return String(value)
120
+ return ''
121
+ }
122
+
123
+ /**
124
+ * Remove control characters that could forge a new header or an SMTP command.
125
+ * Newlines inside the body are legitimate and survive; every other field is
126
+ * collapsed to one line.
127
+ */
128
+ function oneLine(value) {
129
+ return String(value === null || value === undefined ? '' : value)
130
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]+/g, ' ')
131
+ .replace(/[\r\n\t]+/g, ' ')
132
+ .replace(/\s{2,}/g, ' ')
133
+ .trim()
134
+ }
135
+
136
+ function cutText(value, max) {
137
+ const text = String(value === null || value === undefined ? '' : value)
138
+ return text.length > max ? text.slice(0, max) : text
139
+ }
140
+
141
+ /** Body keeps its paragraph breaks, but no NUL / stray control bytes. */
142
+ function bodyText(value) {
143
+ return String(value === null || value === undefined ? '' : value)
144
+ .replace(/\r\n?/g, '\n')
145
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
146
+ .trim()
147
+ }
148
+
149
+ /**
150
+ * Validate one submitted feedback form.
151
+ *
152
+ * Returns either `{ ok: true, value }` (trimmed, control characters removed) or
153
+ * `{ ok: false, message }` with a user-facing Chinese reason. The Client mirrors
154
+ * these rules; this copy is the authoritative one.
155
+ */
156
+ export function normalizeFeedback(input, limits = FEEDBACK_LIMITS) {
157
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
158
+ return { ok: false, message: '反馈内容格式不正确' }
159
+ }
160
+ const type = oneLine(input.type).toLowerCase()
161
+ if (!FEEDBACK_TYPES.includes(type)) {
162
+ return { ok: false, message: '请选择有效的问题类型' }
163
+ }
164
+ const title = oneLine(input.title)
165
+ if (title === '') return { ok: false, message: '请填写反馈标题' }
166
+ if (title.length > limits.maxTitle) {
167
+ return { ok: false, message: '反馈标题不能超过 ' + limits.maxTitle + ' 个字符' }
168
+ }
169
+ const body = bodyText(input.body)
170
+ if (body.length < 5) return { ok: false, message: '请补充详细描述(至少 5 个字符)' }
171
+ if (body.length > limits.maxBody) {
172
+ return { ok: false, message: '详细描述不能超过 ' + limits.maxBody + ' 个字符' }
173
+ }
174
+ const contact = oneLine(input.contact)
175
+ if (contact.length > limits.maxContact) {
176
+ return { ok: false, message: '联系方式不能超过 ' + limits.maxContact + ' 个字符' }
177
+ }
178
+ return { ok: true, value: { type, title, body, contact } }
179
+ }
180
+
181
+ /** Parse a boolean-ish config value without JS's surprising `Boolean('0')`. */
182
+ function boolOf(value, fallback) {
183
+ if (typeof value === 'boolean') return value
184
+ const text = textOf(value).toLowerCase()
185
+ if (text === '') return fallback
186
+ if (['1', 'true', 'yes', 'on', 'ssl', 'tls'].includes(text)) return true
187
+ if (['0', 'false', 'no', 'off', 'plain', 'none'].includes(text)) return false
188
+ return fallback
189
+ }
190
+
191
+ function deriveKey(secret, salt) {
192
+ // scrypt is deliberately slow: a short operator paraphrase is not a
193
+ // high-entropy key, so a plain hash would make guessing cheap.
194
+ return scryptSync(secret, salt, 32)
195
+ }
196
+
197
+ function assertSecret(secret) {
198
+ if (secret.length < MIN_SECRET_LENGTH) {
199
+ throw new Error('反馈加密密钥至少需要 ' + MIN_SECRET_LENGTH + ' 个字符(' + FEEDBACK_ENV.secret + ')')
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Encrypt one recipient for `toEncrypted` in feedback.json.
205
+ *
206
+ * Format: `im1:` + base64(salt[16] | iv[12] | tag[16] | ciphertext). AES-256-GCM
207
+ * authenticates the payload, so a truncated or edited file fails to decrypt
208
+ * instead of silently yielding a different address.
209
+ */
210
+ export function encryptRecipient(address, secret) {
211
+ const clean = textOf(address)
212
+ if (!EMAIL_RE.test(clean)) throw new Error('收件邮箱格式不正确,无法加密')
213
+ const phrase = textOf(secret)
214
+ assertSecret(phrase)
215
+ const salt = randomBytes(SALT_BYTES)
216
+ const iv = randomBytes(IV_BYTES)
217
+ const cipher = createCipheriv('aes-256-gcm', deriveKey(phrase, salt), iv)
218
+ const ciphertext = Buffer.concat([cipher.update(clean, 'utf8'), cipher.final()])
219
+ const tag = cipher.getAuthTag()
220
+ return ENCRYPTED_PREFIX + Buffer.concat([salt, iv, tag, ciphertext]).toString('base64')
221
+ }
222
+
223
+ /** Decrypt a `toEncrypted` value. Any tampering or a wrong secret is an error. */
224
+ export function decryptRecipient(payload, secret) {
225
+ const raw = textOf(payload)
226
+ if (!raw.startsWith(ENCRYPTED_PREFIX)) {
227
+ throw new Error('反馈收件邮箱密文格式不正确,应为 ' + ENCRYPTED_PREFIX + '…')
228
+ }
229
+ const phrase = textOf(secret)
230
+ assertSecret(phrase)
231
+ const packed = Buffer.from(raw.slice(ENCRYPTED_PREFIX.length), 'base64')
232
+ if (packed.length <= SALT_BYTES + IV_BYTES + TAG_BYTES) {
233
+ throw new Error('反馈收件邮箱密文格式不正确,应为 ' + ENCRYPTED_PREFIX + '…')
234
+ }
235
+ const salt = packed.subarray(0, SALT_BYTES)
236
+ const iv = packed.subarray(SALT_BYTES, SALT_BYTES + IV_BYTES)
237
+ const tag = packed.subarray(SALT_BYTES + IV_BYTES, SALT_BYTES + IV_BYTES + TAG_BYTES)
238
+ const ciphertext = packed.subarray(SALT_BYTES + IV_BYTES + TAG_BYTES)
239
+ let clear
240
+ try {
241
+ const decipher = createDecipheriv('aes-256-gcm', deriveKey(phrase, salt), iv)
242
+ decipher.setAuthTag(tag)
243
+ clear = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')
244
+ } catch {
245
+ throw new Error('无法解密反馈收件邮箱:密钥不匹配或密文已损坏')
246
+ }
247
+ if (!EMAIL_RE.test(clear)) throw new Error('解密后的反馈收件邮箱格式不正确')
248
+ return clear
249
+ }
250
+
251
+ const URL_TRUE = new Set(['1', 'true', 'yes', 'on', 'ssl', 'tls'])
252
+ const URL_FALSE = new Set(['0', 'false', 'no', 'off', 'plain', 'none'])
253
+
254
+ const safeDecode = (text) => {
255
+ try {
256
+ return decodeURIComponent(text)
257
+ } catch {
258
+ return null
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Parse the shared URL-shaped SMTP credential (`DSH_CRB_SMTP_URL`), e.g.
264
+ * `smtps://user%40qq.com:authcode@smtp.qq.com:465?from=user%40qq.com`.
265
+ *
266
+ * Rules: the scheme is smtp or smtps; smtps always means implicit TLS, while
267
+ * smtp:// means STARTTLS unless `?secure=1`/`?secure=0` overrides it (a bare
268
+ * smtp:// URL on port 465 is treated as implicit TLS, the common provider
269
+ * convention). Username and password are percent-decoded, so a mailbox `@` and
270
+ * punctuation in an app password must be URL-encoded by whoever writes the URL.
271
+ * Errors never echo the URL: it carries a password.
272
+ */
273
+ export function parseSmtpUrl(value) {
274
+ const raw = textOf(value)
275
+ if (raw === '') return { ok: false, message: 'SMTP URL 为空' }
276
+ let url
277
+ try {
278
+ url = new URL(raw)
279
+ } catch {
280
+ return { ok: false, message: 'SMTP URL 无法解析(应为 smtp://user:pass@host:port,特殊字符需 URL 编码)' }
281
+ }
282
+ const protocol = url.protocol.toLowerCase()
283
+ if (protocol !== 'smtp:' && protocol !== 'smtps:') {
284
+ return { ok: false, message: 'SMTP URL 必须以 smtp:// 或 smtps:// 开头' }
285
+ }
286
+ if (url.pathname !== '' && url.pathname !== '/') {
287
+ return { ok: false, message: 'SMTP URL 不应包含路径部分' }
288
+ }
289
+ const host = url.hostname.trim()
290
+ if (host === '') return { ok: false, message: 'SMTP URL 缺少主机名' }
291
+
292
+ const secureText = textOf(url.searchParams.get('secure') || '').toLowerCase()
293
+ let secure
294
+ if (protocol === 'smtps:') {
295
+ secure = true
296
+ } else if (secureText !== '') {
297
+ if (URL_TRUE.has(secureText)) secure = true
298
+ else if (URL_FALSE.has(secureText)) secure = false
299
+ else return { ok: false, message: 'SMTP URL 的 secure 参数无法识别(用 1 或 0)' }
300
+ } else {
301
+ secure = url.port === String(DEFAULT_SECURE_PORT)
302
+ }
303
+
304
+ const port = url.port === '' ? (secure ? DEFAULT_SECURE_PORT : DEFAULT_PLAIN_PORT) : Number(url.port)
305
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
306
+ return { ok: false, message: 'SMTP URL 的端口无效' }
307
+ }
308
+
309
+ const user = safeDecode(url.username)
310
+ if (user === null) return { ok: false, message: 'SMTP URL 的用户名 URL 编码无效' }
311
+ const pass = safeDecode(url.password)
312
+ if (pass === null) return { ok: false, message: 'SMTP URL 的密码 URL 编码无效' }
313
+ if (user === '' && pass !== '') {
314
+ return { ok: false, message: 'SMTP URL 含密码但缺少用户名' }
315
+ }
316
+ const from = safeDecode(textOf(url.searchParams.get('from') || ''))
317
+ if (from === null) return { ok: false, message: 'SMTP URL 的 from 参数 URL 编码无效' }
318
+
319
+ return { ok: true, config: { host, port, secure, user, pass, from } }
320
+ }
321
+
322
+ /**
323
+ * Merge the environment and the optional `feedback.json` into one validated
324
+ * config. Environment variables win field by field, so an operator can keep the
325
+ * destination in a file and inject only the password from the environment (or
326
+ * the other way round). The shared `DSH_CRB_SMTP_URL` sits between the plugin's
327
+ * explicit SMTP variables and the file: one working credential already on the
328
+ * machine can be reused without copying it anywhere.
329
+ *
330
+ * `configPath` is used only in error messages, never read here — the caller owns
331
+ * the filesystem, this module stays pure and testable.
332
+ */
333
+ export function feedbackConfigFrom(options = {}) {
334
+ const env = options.env !== null && typeof options.env === 'object' ? options.env : {}
335
+ const file = options.file !== null && typeof options.file === 'object' && !Array.isArray(options.file) ? options.file : {}
336
+ const fileSmtp = file.smtp !== null && typeof file.smtp === 'object' && !Array.isArray(file.smtp) ? file.smtp : {}
337
+ const configPath = textOf(options.configPath)
338
+
339
+ let to = envText(env, FEEDBACK_ENV.to) || textOf(file.to)
340
+ const encryptedTo = textOf(file.toEncrypted)
341
+ const secret = envText(env, FEEDBACK_ENV.secret)
342
+ const explicitHost = envText(env, FEEDBACK_ENV.host)
343
+
344
+ let urlConfig = null
345
+ const urlText = envText(env, FEEDBACK_ENV.smtpUrl)
346
+ if (urlText !== '') {
347
+ const parsed = parseSmtpUrl(urlText)
348
+ if (parsed.ok) urlConfig = parsed.config
349
+ // An invalid shared URL is only fatal when it is the only server source;
350
+ // an explicit DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_HOST wins over it.
351
+ else if (explicitHost === '') return { ok: false, message: parsed.message + '(环境变量 ' + FEEDBACK_ENV.smtpUrl + ')' }
352
+ }
353
+
354
+ const from = envText(env, FEEDBACK_ENV.from)
355
+ || (urlConfig === null ? '' : urlConfig.from)
356
+ || textOf(file.from)
357
+ const host = explicitHost
358
+ || (urlConfig === null ? '' : urlConfig.host)
359
+ || textOf(fileSmtp.host)
360
+ const portText = envText(env, FEEDBACK_ENV.port)
361
+ || (urlConfig === null ? '' : String(urlConfig.port))
362
+ || scalarText(fileSmtp.port)
363
+ const secureText = envText(env, FEEDBACK_ENV.secure)
364
+ || (urlConfig === null ? '' : (urlConfig.secure ? '1' : '0'))
365
+ || scalarText(fileSmtp.secure)
366
+ const user = envText(env, FEEDBACK_ENV.user)
367
+ || (urlConfig === null ? '' : urlConfig.user)
368
+ || textOf(fileSmtp.user)
369
+ const pass = envText(env, FEEDBACK_ENV.pass) !== ''
370
+ ? envText(env, FEEDBACK_ENV.pass)
371
+ : ((urlConfig === null ? '' : urlConfig.pass) || textOf(fileSmtp.pass))
372
+
373
+ const setupHint = '请通过环境变量 ' + FEEDBACK_ENV.to + ' / ' + FEEDBACK_ENV.host
374
+ + '(或复用 ' + FEEDBACK_ENV.smtpUrl + ')配置'
375
+ + (configPath === '' ? '' : ',或写入 ' + configPath)
376
+
377
+ if (to === '' && encryptedTo !== '') {
378
+ if (secret === '') {
379
+ return {
380
+ ok: false,
381
+ message: '反馈收件邮箱已加密,但未设置 ' + FEEDBACK_ENV.secret
382
+ + '(至少 ' + MIN_SECRET_LENGTH + ' 个字符),无法解密。',
383
+ }
384
+ }
385
+ try {
386
+ to = decryptRecipient(encryptedTo, secret)
387
+ } catch (err) {
388
+ return { ok: false, message: err !== null && typeof err === 'object' && typeof err.message === 'string' ? err.message : String(err) }
389
+ }
390
+ }
391
+
392
+ if (to === '') return { ok: false, message: '尚未配置反馈收件邮箱。' + setupHint + '。' }
393
+ if (!EMAIL_RE.test(to)) return { ok: false, message: '反馈收件邮箱格式不正确,请检查收件人配置' }
394
+ if (host === '') return { ok: false, message: '尚未配置 SMTP 服务器。' + setupHint + '。' }
395
+
396
+ const secure = boolOf(secureText, false)
397
+ let port = secure ? DEFAULT_SECURE_PORT : DEFAULT_PLAIN_PORT
398
+ if (portText !== '') {
399
+ const parsed = Number(portText)
400
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
401
+ return { ok: false, message: 'SMTP 端口无效:' + portText }
402
+ }
403
+ port = parsed
404
+ }
405
+
406
+ // A From is required by SMTP. It does not have to be an alias the server
407
+ // 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 配置' }
411
+ }
412
+ if (user === '' && pass !== '') {
413
+ return { ok: false, message: '已配置 SMTP 密码但缺少 SMTP 用户名(' + FEEDBACK_ENV.user + ')' }
414
+ }
415
+
416
+ return {
417
+ ok: true,
418
+ config: {
419
+ to,
420
+ from: fromAddress,
421
+ smtp: { host, port, secure, user, pass },
422
+ },
423
+ }
424
+ }
425
+
426
+ /** The cooldown between two accepted sends, clamped to a sane range. */
427
+ export function feedbackCooldownMs(env = process.env) {
428
+ const raw = envText(env, FEEDBACK_ENV.cooldown)
429
+ if (raw === '') return DEFAULT_FEEDBACK_COOLDOWN_MS
430
+ const parsed = Number(raw)
431
+ if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_FEEDBACK_COOLDOWN_MS
432
+ return Math.min(Math.round(parsed), MAX_FEEDBACK_COOLDOWN_MS)
433
+ }
434
+
435
+ const UNKNOWN = '未知'
436
+
437
+ function envField(value, max) {
438
+ const text = oneLine(value)
439
+ return text === '' ? UNKNOWN : cutText(text, max)
440
+ }
441
+
442
+ /**
443
+ * Compose the plain-text mail. The environment block is built on the Host from
444
+ * what the Host itself can see (plugin version, Node, platform) plus the few
445
+ * presentation facts the panel reports (locale, time zone, user agent). It NEVER
446
+ * carries instruction content: the form text is the only user text included.
447
+ */
448
+ export function buildFeedbackMessage(options = {}) {
449
+ const feedback = options.feedback !== null && typeof options.feedback === 'object' ? options.feedback : {}
450
+ const environment = options.environment !== null && typeof options.environment === 'object' ? options.environment : {}
451
+ const limits = FEEDBACK_LIMITS
452
+ const type = FEEDBACK_TYPES.includes(feedback.type) ? feedback.type : 'other'
453
+ const label = FEEDBACK_TYPE_LABELS[type]
454
+ const title = oneLine(feedback.title)
455
+ const contact = oneLine(feedback.contact)
456
+ const body = bodyText(feedback.body)
457
+
458
+ const subjectFull = '[指令记忆反馈] ' + label + ' · ' + title
459
+ const subject = subjectFull.length > limits.maxSubject
460
+ ? subjectFull.slice(0, limits.maxSubject - 1) + '…'
461
+ : subjectFull
462
+
463
+ const envLines = [
464
+ '插件版本:' + envField(environment.pluginVersion, limits.maxVersion),
465
+ 'DSH 版本:' + envField(environment.dshVersion, limits.maxVersion),
466
+ 'Node:' + envField(environment.node, limits.maxVersion),
467
+ '平台:' + envField(environment.platform, 32) + ' / ' + envField(environment.arch, 32),
468
+ '界面语言:' + envField(environment.locale, limits.maxLocale),
469
+ '时区:' + envField(environment.timezone, limits.maxTimezone),
470
+ '浏览器:' + envField(environment.userAgent, limits.maxUserAgent),
471
+ '提交时间:' + (options.now instanceof Date ? options.now : new Date()).toISOString(),
472
+ ]
473
+
474
+ const lines = [
475
+ '反馈类型:' + label,
476
+ '标题:' + title,
477
+ '联系方式:' + (contact === '' ? '(未填写)' : contact),
478
+ '',
479
+ '--- 反馈正文 ---',
480
+ body,
481
+ '',
482
+ '--- 运行环境(自动附带)---',
483
+ ...envLines,
484
+ ]
485
+
486
+ return {
487
+ subject,
488
+ text: lines.join('\n'),
489
+ messageId: randomUUID() + '@' + messageDomain(options.from),
490
+ // Only a bare, valid address becomes Reply-To. Free-form contact text must
491
+ // never reach a header: a newline there would inject a header.
492
+ replyTo: EMAIL_RE.test(contact) ? contact : '',
493
+ }
494
+ }
495
+
496
+ /** The Message-ID domain, derived from the From address (never user input). */
497
+ function messageDomain(from) {
498
+ const address = textOf(from)
499
+ const at = address.lastIndexOf('@')
500
+ if (at === -1) return 'dsh.local'
501
+ const domain = address.slice(at + 1).replace(/[^A-Za-z0-9.-]/g, '')
502
+ return domain === '' ? 'dsh.local' : domain
503
+ }
504
+
505
+ const b64 = (value) => Buffer.from(String(value), 'utf8').toString('base64')
506
+ const encodeWord = (value) => '=?UTF-8?B?' + b64(oneLine(value)) + '?='
507
+
508
+ /** RFC 5322 headers + base64 body. Base64 never needs dot-stuffing. */
509
+ export function buildRawMail(options = {}) {
510
+ const from = textOf(options.from)
511
+ const to = textOf(options.to)
512
+ const now = options.now instanceof Date ? options.now : new Date()
513
+ const messageId = textOf(options.messageId) || (randomUUID() + '@' + messageDomain(from))
514
+ const subject = oneLine(options.subject)
515
+ const replyTo = oneLine(options.replyTo)
516
+ const body64 = b64(options.text).replace(/(.{76})/g, '$1\r\n')
517
+ const headers = [
518
+ 'From: ' + encodeWord('DSH 指令记忆反馈') + ' <' + from + '>',
519
+ 'To: <' + to + '>',
520
+ ]
521
+ if (EMAIL_RE.test(replyTo)) headers.push('Reply-To: <' + replyTo + '>')
522
+ headers.push(
523
+ 'Subject: ' + encodeWord(subject),
524
+ 'Date: ' + now.toUTCString(),
525
+ 'Message-ID: <' + messageId + '>',
526
+ 'MIME-Version: 1.0',
527
+ 'Content-Type: text/plain; charset=UTF-8',
528
+ 'Content-Transfer-Encoding: base64',
529
+ 'X-Mailer: dsh-instruction-memory',
530
+ )
531
+ return { raw: headers.join('\r\n') + '\r\n\r\n' + body64 + '\r\n', messageId }
532
+ }
533
+
534
+ /**
535
+ * One SMTP connection, framed around two operations the protocol needs:
536
+ * a line reader (responses are CRLF-delimited and may be multi-line) and a
537
+ * writer that resolves when the bytes are queued.
538
+ */
539
+ function createChannel(socket, timeoutMs) {
540
+ const state = { buffer: '', lines: [], waiters: [], error: null }
541
+
542
+ const fail = (err) => {
543
+ if (state.error === null) state.error = err
544
+ const waiters = state.waiters.splice(0)
545
+ for (const waiter of waiters) waiter.reject(state.error)
546
+ }
547
+ const onData = (chunk) => {
548
+ state.buffer += chunk.toString('utf8')
549
+ for (;;) {
550
+ const index = state.buffer.indexOf('\r\n')
551
+ if (index === -1) return
552
+ const line = state.buffer.slice(0, index)
553
+ state.buffer = state.buffer.slice(index + 2)
554
+ const waiter = state.waiters.shift()
555
+ if (waiter !== undefined) waiter.resolve(line)
556
+ else state.lines.push(line)
557
+ }
558
+ }
559
+ const onError = (err) => fail(err instanceof Error ? err : new Error(String(err)))
560
+ const onClose = () => fail(new Error('SMTP 连接已关闭'))
561
+ const onTimeout = () => {
562
+ fail(new Error('SMTP 操作超时(' + timeoutMs + ' ms)'))
563
+ socket.destroy()
564
+ }
565
+
566
+ socket.on('data', onData)
567
+ socket.on('error', onError)
568
+ socket.on('close', onClose)
569
+ if (typeof socket.setTimeout === 'function') socket.setTimeout(timeoutMs, onTimeout)
570
+ socket.resume()
571
+
572
+ const readLine = () => new Promise((resolve, reject) => {
573
+ if (state.lines.length > 0) {
574
+ resolve(state.lines.shift())
575
+ return
576
+ }
577
+ if (state.error !== null) {
578
+ reject(state.error)
579
+ return
580
+ }
581
+ state.waiters.push({ resolve, reject })
582
+ })
583
+
584
+ const write = (text) => new Promise((resolve, reject) => {
585
+ if (state.error !== null) {
586
+ reject(state.error)
587
+ return
588
+ }
589
+ socket.write(text, 'utf8', (err) => (err ? reject(err) : resolve()))
590
+ })
591
+
592
+ const readResponse = async () => {
593
+ const first = await readLine()
594
+ const match = /^(\d{3})([ -])(.*)$/.exec(first)
595
+ if (match === null) throw new Error('SMTP 响应无法解析:' + cutText(first, 120))
596
+ const code = Number(match[1])
597
+ const lines = [match[3]]
598
+ if (match[2] === '-') {
599
+ for (;;) {
600
+ const next = await readLine()
601
+ const nextMatch = /^(\d{3})([ -])(.*)$/.exec(next)
602
+ if (nextMatch === null) throw new Error('SMTP 多行响应无法解析:' + cutText(next, 120))
603
+ lines.push(nextMatch[3])
604
+ if (nextMatch[2] === ' ') break
605
+ }
606
+ }
607
+ return { code, lines }
608
+ }
609
+
610
+ const dispose = () => {
611
+ socket.removeListener('data', onData)
612
+ socket.removeListener('close', onClose)
613
+ socket.removeListener('error', onError)
614
+ if (typeof socket.setTimeout === 'function') socket.setTimeout(0)
615
+ // Keep the socket's own error path quiet: callers destroy it after dispose,
616
+ // and a late ECONNRESET with no listener would crash the host process.
617
+ socket.on('error', () => {})
618
+ }
619
+
620
+ return { readResponse, write, dispose }
621
+ }
622
+
623
+ const smtpError = (what, response) =>
624
+ new Error('SMTP ' + what + ' 失败(' + response.code + '):' + cutText(response.lines.join(' '), 300))
625
+
626
+ async function expectCode(channel, codes, what) {
627
+ const response = await channel.readResponse()
628
+ const accepted = Array.isArray(codes) ? codes : [codes]
629
+ if (!accepted.includes(response.code)) throw smtpError(what, response)
630
+ return response
631
+ }
632
+
633
+ async function command(channel, line, codes, what) {
634
+ await channel.write(line + '\r\n')
635
+ return expectCode(channel, codes, what)
636
+ }
637
+
638
+ /** EHLO; returns the capability lines the server advertised. */
639
+ async function hello(channel, name) {
640
+ const response = await command(channel, 'EHLO ' + name, 250, 'EHLO')
641
+ return response.lines.map((line) => line.trim())
642
+ }
643
+
644
+ 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)
648
+ const fail = (response) => new Error('SMTP 认证失败(' + response.code + '):'
649
+ + cutText(response.lines.join(' '), 300)
650
+ + '。请检查用户名 / 密码;Gmail、QQ 邮箱等服务需要「应用专用密码」,不能用登录密码。')
651
+
652
+ if (canLogin || !canPlain) {
653
+ await command(channel, 'AUTH LOGIN', 334, 'AUTH LOGIN')
654
+ await command(channel, b64(smtp.user), 334, 'AUTH LOGIN 用户名')
655
+ const done = await (async () => {
656
+ await channel.write(b64(smtp.pass) + '\r\n')
657
+ return channel.readResponse()
658
+ })()
659
+ if (done.code !== 235) throw fail(done)
660
+ return
661
+ }
662
+ await channel.write('AUTH PLAIN ' + b64('\u0000' + smtp.user + '\u0000' + smtp.pass) + '\r\n')
663
+ const done = await channel.readResponse()
664
+ if (done.code !== 235) throw fail(done)
665
+ }
666
+
667
+ /** Open the transport socket; resolves once it is connectable (and, for TLS, verified). */
668
+ function openSocket(smtp, timeoutMs) {
669
+ return new Promise((resolve, reject) => {
670
+ const socket = smtp.secure
671
+ ? tlsConnect({ host: smtp.host, port: smtp.port, servername: smtp.host })
672
+ : netConnect({ host: smtp.host, port: smtp.port })
673
+ const event = smtp.secure ? 'secureConnect' : 'connect'
674
+ const timer = setTimeout(() => {
675
+ socket.destroy()
676
+ reject(new Error('SMTP 连接超时(' + timeoutMs + ' ms):' + smtp.host + ':' + smtp.port))
677
+ }, timeoutMs)
678
+ socket.once(event, () => {
679
+ clearTimeout(timer)
680
+ resolve(socket)
681
+ })
682
+ socket.once('error', (err) => {
683
+ clearTimeout(timer)
684
+ reject(new Error('无法连接 SMTP 服务器 ' + smtp.host + ':' + smtp.port + ':' + (err && err.message ? err.message : String(err))))
685
+ })
686
+ })
687
+ }
688
+
689
+ /** Upgrade an established plain socket with STARTTLS (certificate checked). */
690
+ function upgradeSocket(socket, host, timeoutMs) {
691
+ return new Promise((resolve, reject) => {
692
+ const secureSocket = tlsConnect({ socket, servername: host })
693
+ const timer = setTimeout(() => {
694
+ secureSocket.destroy()
695
+ reject(new Error('SMTP TLS 握手超时(' + timeoutMs + ' ms)'))
696
+ }, timeoutMs)
697
+ secureSocket.once('secureConnect', () => {
698
+ clearTimeout(timer)
699
+ resolve(secureSocket)
700
+ })
701
+ secureSocket.once('error', (err) => {
702
+ clearTimeout(timer)
703
+ reject(new Error('SMTP TLS 握手失败:' + (err && err.message ? err.message : String(err))))
704
+ })
705
+ })
706
+ }
707
+
708
+ /**
709
+ * Deliver one already-composed message.
710
+ *
711
+ * Returns `{ messageId, accepted, response }` on success. Throws an Error whose
712
+ * message is safe to show to the user (it never contains the password).
713
+ */
714
+ export async function sendSmtpMail(options = {}) {
715
+ const smtp = options.smtp !== null && typeof options.smtp === 'object' ? options.smtp : {}
716
+ const host = textOf(smtp.host)
717
+ const port = Number(smtp.port)
718
+ const from = textOf(options.from)
719
+ const to = textOf(options.to)
720
+ if (host === '' || !Number.isInteger(port) || port < 1 || port > 65535) {
721
+ throw new Error('SMTP 配置不完整(host / port)')
722
+ }
723
+ if (from === '' || to === '') throw new Error('SMTP 配置不完整(from / to)')
724
+
725
+ const mail = buildRawMail({
726
+ from,
727
+ to,
728
+ subject: options.subject,
729
+ text: options.text,
730
+ messageId: options.messageId,
731
+ replyTo: options.replyTo,
732
+ now: options.now,
733
+ })
734
+ const timeoutMs = SMTP_TIMEOUT_MS
735
+ const clientName = (hostname() || 'dsh.local').replace(/[^A-Za-z0-9.-]/g, '').slice(0, 63) || 'dsh.local'
736
+
737
+ let socket = await openSocket({ host, port, secure: smtp.secure === true }, timeoutMs)
738
+ let channel = createChannel(socket, timeoutMs)
739
+ try {
740
+ await expectCode(channel, 220, '服务端欢迎')
741
+ let caps = await hello(channel, clientName)
742
+
743
+ let encrypted = smtp.secure === true
744
+ if (!encrypted && caps.some((line) => /^STARTTLS\b/i.test(line))) {
745
+ await command(channel, 'STARTTLS', 220, 'STARTTLS')
746
+ channel.dispose()
747
+ socket = await upgradeSocket(socket, host, timeoutMs)
748
+ channel = createChannel(socket, timeoutMs)
749
+ caps = await hello(channel, clientName)
750
+ encrypted = true
751
+ }
752
+
753
+ if (textOf(smtp.user) !== '') {
754
+ if (!encrypted) {
755
+ throw new Error('SMTP 服务器未提供 STARTTLS,已拒绝在明文连接上发送账号密码。请把 '
756
+ + 'DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_SECURE 设为 1(465 端口)或改用支持 STARTTLS 的服务。')
757
+ }
758
+ await authenticate(channel, { user: textOf(smtp.user), pass: typeof smtp.pass === 'string' ? smtp.pass : '' }, caps)
759
+ }
760
+
761
+ await command(channel, 'MAIL FROM:<' + from + '>', 250, 'MAIL FROM')
762
+ await command(channel, 'RCPT TO:<' + to + '>', [250, 251], 'RCPT TO')
763
+ await command(channel, 'DATA', 354, 'DATA')
764
+ await channel.write(mail.raw + '.\r\n')
765
+ const accepted = await expectCode(channel, 250, '邮件投递')
766
+ try {
767
+ await command(channel, 'QUIT', 221, 'QUIT')
768
+ } catch {
769
+ // The message is already accepted; a missing QUIT reply is not a failure.
770
+ }
771
+ return { messageId: mail.messageId, accepted: to, response: accepted.lines.join(' ') }
772
+ } finally {
773
+ channel.dispose()
774
+ // Add an error listener before destroy: a socket destroyed mid-TLS can emit
775
+ // ECONNRESET synchronously, and an unhandled 'error' would take the host down.
776
+ socket.on('error', () => {})
777
+ socket.destroy()
778
+ }
779
+ }
780
+
781
+ /**
782
+ * Operator helper: `node lib/mailer.js encrypt-to <email>` prints the
783
+ * `toEncrypted` value to paste into feedback.json. The secret is read from the
784
+ * environment and never printed, so it does not end up in shell history.
785
+ */
786
+ const invokedDirectly = (() => {
787
+ const entry = process.argv[1]
788
+ if (entry === undefined) return false
789
+ try {
790
+ return import.meta.url === pathToFileURL(entry).href
791
+ } catch {
792
+ return false
793
+ }
794
+ })()
795
+
796
+ if (invokedDirectly) {
797
+ const [command, ...rest] = process.argv.slice(2)
798
+ if (command === 'encrypt-to') {
799
+ try {
800
+ console.log(encryptRecipient(rest[0] === undefined ? '' : rest[0], process.env[FEEDBACK_ENV.secret] || ''))
801
+ } catch (err) {
802
+ console.error(err !== null && typeof err === 'object' && typeof err.message === 'string' ? err.message : String(err))
803
+ process.exitCode = 1
804
+ }
805
+ } else {
806
+ console.error('用法:node lib/mailer.js encrypt-to <email>')
807
+ console.error('密钥从环境变量 ' + FEEDBACK_ENV.secret + ' 读取(至少 ' + MIN_SECRET_LENGTH + ' 个字符)。')
808
+ process.exitCode = 2
809
+ }
810
+ }