dsh-instruction-memory 1.3.3 → 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/lib/mailer.js ADDED
@@ -0,0 +1,932 @@
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
+ /** Idle timeout of one protocol step. */
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
117
+
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]+$/
127
+
128
+ const textOf = (value) => (typeof value === 'string' ? value.trim() : '')
129
+ const envText = (env, name) => (env !== null && typeof env === 'object' && typeof env[name] === 'string' ? env[name].trim() : '')
130
+ /**
131
+ * A config-file scalar. JSON files naturally carry `"port": 587` and
132
+ * `"secure": true`; treating those as absent (only strings accepted) silently
133
+ * fell back to the default port and to plaintext, so numbers and booleans are
134
+ * stringified here and validated by the same code path as environment strings.
135
+ */
136
+ const scalarText = (value) => {
137
+ if (typeof value === 'string') return value.trim()
138
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value)
139
+ if (typeof value === 'boolean') return String(value)
140
+ return ''
141
+ }
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
+
167
+ /**
168
+ * Remove control characters that could forge a new header or an SMTP command.
169
+ * Newlines inside the body are legitimate and survive; every other field is
170
+ * collapsed to one line.
171
+ */
172
+ function oneLine(value) {
173
+ return String(value === null || value === undefined ? '' : value)
174
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]+/g, ' ')
175
+ .replace(/[\r\n\t]+/g, ' ')
176
+ .replace(/\s{2,}/g, ' ')
177
+ .trim()
178
+ }
179
+
180
+ function cutText(value, max) {
181
+ const text = String(value === null || value === undefined ? '' : value)
182
+ return text.length > max ? text.slice(0, max) : text
183
+ }
184
+
185
+ /** Body keeps its paragraph breaks, but no NUL / stray control bytes. */
186
+ function bodyText(value) {
187
+ return String(value === null || value === undefined ? '' : value)
188
+ .replace(/\r\n?/g, '\n')
189
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
190
+ .trim()
191
+ }
192
+
193
+ /**
194
+ * Validate one submitted feedback form.
195
+ *
196
+ * Returns either `{ ok: true, value }` (trimmed, control characters removed) or
197
+ * `{ ok: false, message }` with a user-facing Chinese reason. The Client mirrors
198
+ * these rules; this copy is the authoritative one.
199
+ */
200
+ export function normalizeFeedback(input, limits = FEEDBACK_LIMITS) {
201
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
202
+ return { ok: false, message: '反馈内容格式不正确' }
203
+ }
204
+ const type = oneLine(input.type).toLowerCase()
205
+ if (!FEEDBACK_TYPES.includes(type)) {
206
+ return { ok: false, message: '请选择有效的问题类型' }
207
+ }
208
+ const title = oneLine(input.title)
209
+ if (title === '') return { ok: false, message: '请填写反馈标题' }
210
+ if (title.length > limits.maxTitle) {
211
+ return { ok: false, message: '反馈标题不能超过 ' + limits.maxTitle + ' 个字符' }
212
+ }
213
+ const body = bodyText(input.body)
214
+ if (body.length < 5) return { ok: false, message: '请补充详细描述(至少 5 个字符)' }
215
+ if (body.length > limits.maxBody) {
216
+ return { ok: false, message: '详细描述不能超过 ' + limits.maxBody + ' 个字符' }
217
+ }
218
+ const contact = oneLine(input.contact)
219
+ if (contact.length > limits.maxContact) {
220
+ return { ok: false, message: '联系方式不能超过 ' + limits.maxContact + ' 个字符' }
221
+ }
222
+ return { ok: true, value: { type, title, body, contact } }
223
+ }
224
+
225
+ /** Parse a boolean-ish config value without JS's surprising `Boolean('0')`. */
226
+ function boolOf(value, fallback) {
227
+ if (typeof value === 'boolean') return value
228
+ const text = textOf(value).toLowerCase()
229
+ if (text === '') return fallback
230
+ if (['1', 'true', 'yes', 'on', 'ssl', 'tls'].includes(text)) return true
231
+ if (['0', 'false', 'no', 'off', 'plain', 'none'].includes(text)) return false
232
+ return fallback
233
+ }
234
+
235
+ function deriveKey(secret, salt) {
236
+ // scrypt is deliberately slow: a short operator paraphrase is not a
237
+ // high-entropy key, so a plain hash would make guessing cheap.
238
+ return scryptSync(secret, salt, 32)
239
+ }
240
+
241
+ function assertSecret(secret) {
242
+ if (secret.length < MIN_SECRET_LENGTH) {
243
+ throw new Error('反馈加密密钥至少需要 ' + MIN_SECRET_LENGTH + ' 个字符(' + FEEDBACK_ENV.secret + ')')
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Encrypt one recipient for `toEncrypted` in feedback.json.
249
+ *
250
+ * Format: `im1:` + base64(salt[16] | iv[12] | tag[16] | ciphertext). AES-256-GCM
251
+ * authenticates the payload, so a truncated or edited file fails to decrypt
252
+ * instead of silently yielding a different address.
253
+ */
254
+ export function encryptRecipient(address, secret) {
255
+ const clean = textOf(address)
256
+ if (!EMAIL_RE.test(clean)) throw new Error('收件邮箱格式不正确,无法加密')
257
+ const phrase = textOf(secret)
258
+ assertSecret(phrase)
259
+ const salt = randomBytes(SALT_BYTES)
260
+ const iv = randomBytes(IV_BYTES)
261
+ const cipher = createCipheriv('aes-256-gcm', deriveKey(phrase, salt), iv)
262
+ const ciphertext = Buffer.concat([cipher.update(clean, 'utf8'), cipher.final()])
263
+ const tag = cipher.getAuthTag()
264
+ return ENCRYPTED_PREFIX + Buffer.concat([salt, iv, tag, ciphertext]).toString('base64')
265
+ }
266
+
267
+ /** Decrypt a `toEncrypted` value. Any tampering or a wrong secret is an error. */
268
+ export function decryptRecipient(payload, secret) {
269
+ const raw = textOf(payload)
270
+ if (!raw.startsWith(ENCRYPTED_PREFIX)) {
271
+ throw new Error('反馈收件邮箱密文格式不正确,应为 ' + ENCRYPTED_PREFIX + '…')
272
+ }
273
+ const phrase = textOf(secret)
274
+ assertSecret(phrase)
275
+ const packed = Buffer.from(raw.slice(ENCRYPTED_PREFIX.length), 'base64')
276
+ if (packed.length <= SALT_BYTES + IV_BYTES + TAG_BYTES) {
277
+ throw new Error('反馈收件邮箱密文格式不正确,应为 ' + ENCRYPTED_PREFIX + '…')
278
+ }
279
+ const salt = packed.subarray(0, SALT_BYTES)
280
+ const iv = packed.subarray(SALT_BYTES, SALT_BYTES + IV_BYTES)
281
+ const tag = packed.subarray(SALT_BYTES + IV_BYTES, SALT_BYTES + IV_BYTES + TAG_BYTES)
282
+ const ciphertext = packed.subarray(SALT_BYTES + IV_BYTES + TAG_BYTES)
283
+ let clear
284
+ try {
285
+ const decipher = createDecipheriv('aes-256-gcm', deriveKey(phrase, salt), iv)
286
+ decipher.setAuthTag(tag)
287
+ clear = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')
288
+ } catch {
289
+ throw new Error('无法解密反馈收件邮箱:密钥不匹配或密文已损坏')
290
+ }
291
+ if (!EMAIL_RE.test(clear)) throw new Error('解密后的反馈收件邮箱格式不正确')
292
+ return clear
293
+ }
294
+
295
+ const URL_TRUE = new Set(['1', 'true', 'yes', 'on', 'ssl', 'tls'])
296
+ const URL_FALSE = new Set(['0', 'false', 'no', 'off', 'plain', 'none'])
297
+
298
+ const safeDecode = (text) => {
299
+ try {
300
+ return decodeURIComponent(text)
301
+ } catch {
302
+ return null
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Parse the shared URL-shaped SMTP credential (`DSH_CRB_SMTP_URL`), e.g.
308
+ * `smtps://user%40qq.com:authcode@smtp.qq.com:465?from=user%40qq.com`.
309
+ *
310
+ * Rules: the scheme is smtp or smtps; smtps always means implicit TLS, while
311
+ * smtp:// means STARTTLS unless `?secure=1`/`?secure=0` overrides it (a bare
312
+ * smtp:// URL on port 465 is treated as implicit TLS, the common provider
313
+ * convention). Username and password are percent-decoded, so a mailbox `@` and
314
+ * punctuation in an app password must be URL-encoded by whoever writes the URL.
315
+ * Errors never echo the URL: it carries a password.
316
+ */
317
+ export function parseSmtpUrl(value) {
318
+ const raw = textOf(value)
319
+ if (raw === '') return { ok: false, message: 'SMTP URL 为空' }
320
+ let url
321
+ try {
322
+ url = new URL(raw)
323
+ } catch {
324
+ return { ok: false, message: 'SMTP URL 无法解析(应为 smtp://user:pass@host:port,特殊字符需 URL 编码)' }
325
+ }
326
+ const protocol = url.protocol.toLowerCase()
327
+ if (protocol !== 'smtp:' && protocol !== 'smtps:') {
328
+ return { ok: false, message: 'SMTP URL 必须以 smtp:// 或 smtps:// 开头' }
329
+ }
330
+ if (url.pathname !== '' && url.pathname !== '/') {
331
+ return { ok: false, message: 'SMTP URL 不应包含路径部分' }
332
+ }
333
+ const host = url.hostname.trim()
334
+ if (host === '') return { ok: false, message: 'SMTP URL 缺少主机名' }
335
+
336
+ const secureText = textOf(url.searchParams.get('secure') || '').toLowerCase()
337
+ let secure
338
+ if (protocol === 'smtps:') {
339
+ secure = true
340
+ } else if (secureText !== '') {
341
+ if (URL_TRUE.has(secureText)) secure = true
342
+ else if (URL_FALSE.has(secureText)) secure = false
343
+ else return { ok: false, message: 'SMTP URL 的 secure 参数无法识别(用 1 或 0)' }
344
+ } else {
345
+ secure = url.port === String(DEFAULT_SECURE_PORT)
346
+ }
347
+
348
+ const port = url.port === '' ? (secure ? DEFAULT_SECURE_PORT : DEFAULT_PLAIN_PORT) : Number(url.port)
349
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
350
+ return { ok: false, message: 'SMTP URL 的端口无效' }
351
+ }
352
+
353
+ const user = safeDecode(url.username)
354
+ if (user === null) return { ok: false, message: 'SMTP URL 的用户名 URL 编码无效' }
355
+ const pass = safeDecode(url.password)
356
+ if (pass === null) return { ok: false, message: 'SMTP URL 的密码 URL 编码无效' }
357
+ if (user === '' && pass !== '') {
358
+ return { ok: false, message: 'SMTP URL 含密码但缺少用户名' }
359
+ }
360
+ const from = safeDecode(textOf(url.searchParams.get('from') || ''))
361
+ if (from === null) return { ok: false, message: 'SMTP URL 的 from 参数 URL 编码无效' }
362
+
363
+ return { ok: true, config: { host, port, secure, user, pass, from } }
364
+ }
365
+
366
+ /**
367
+ * Merge the environment and the optional `feedback.json` into one validated
368
+ * config. Environment variables win field by field, so an operator can keep the
369
+ * destination in a file and inject only the password from the environment (or
370
+ * the other way round). The shared `DSH_CRB_SMTP_URL` sits between the plugin's
371
+ * explicit SMTP variables and the file: one working credential already on the
372
+ * machine can be reused without copying it anywhere.
373
+ *
374
+ * `configPath` is used only in error messages, never read here — the caller owns
375
+ * the filesystem, this module stays pure and testable.
376
+ */
377
+ export function feedbackConfigFrom(options = {}) {
378
+ const env = options.env !== null && typeof options.env === 'object' ? options.env : {}
379
+ const file = options.file !== null && typeof options.file === 'object' && !Array.isArray(options.file) ? options.file : {}
380
+ const fileSmtp = file.smtp !== null && typeof file.smtp === 'object' && !Array.isArray(file.smtp) ? file.smtp : {}
381
+ const configPath = textOf(options.configPath)
382
+
383
+ const fileTo = textOf(file.to)
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
395
+ const secret = envText(env, FEEDBACK_ENV.secret)
396
+ const explicitHost = envText(env, FEEDBACK_ENV.host)
397
+
398
+ let urlConfig = null
399
+ const urlText = envText(env, FEEDBACK_ENV.smtpUrl)
400
+ if (urlText !== '') {
401
+ const parsed = parseSmtpUrl(urlText)
402
+ if (parsed.ok) urlConfig = parsed.config
403
+ // An invalid shared URL is only fatal when it is the only server source;
404
+ // an explicit DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_HOST wins over it.
405
+ else if (explicitHost === '') return { ok: false, message: parsed.message + '(环境变量 ' + FEEDBACK_ENV.smtpUrl + ')' }
406
+ }
407
+
408
+ const from = envText(env, FEEDBACK_ENV.from)
409
+ || (urlConfig === null ? '' : urlConfig.from)
410
+ || textOf(file.from)
411
+ const host = explicitHost
412
+ || (urlConfig === null ? '' : urlConfig.host)
413
+ || textOf(fileSmtp.host)
414
+ const portText = envText(env, FEEDBACK_ENV.port)
415
+ || (urlConfig === null ? '' : String(urlConfig.port))
416
+ || scalarText(fileSmtp.port)
417
+ const secureText = envText(env, FEEDBACK_ENV.secure)
418
+ || (urlConfig === null ? '' : (urlConfig.secure ? '1' : '0'))
419
+ || scalarText(fileSmtp.secure)
420
+ const user = envText(env, FEEDBACK_ENV.user)
421
+ || (urlConfig === null ? '' : urlConfig.user)
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
+ }
430
+ const pass = envText(env, FEEDBACK_ENV.pass) !== ''
431
+ ? envText(env, FEEDBACK_ENV.pass)
432
+ : ((urlConfig === null ? '' : urlConfig.pass) || passFromFile)
433
+
434
+ const setupHint = '请通过环境变量 ' + FEEDBACK_ENV.to + ' / ' + FEEDBACK_ENV.host
435
+ + '(或复用 ' + FEEDBACK_ENV.smtpUrl + ')配置'
436
+ + (configPath === '' ? '' : ',或写入 ' + configPath)
437
+
438
+ if (to === '' && encryptedTo !== '') {
439
+ if (secret === '') {
440
+ return {
441
+ ok: false,
442
+ message: '反馈收件邮箱已加密,但未设置 ' + FEEDBACK_ENV.secret
443
+ + '(至少 ' + MIN_SECRET_LENGTH + ' 个字符),无法解密。',
444
+ }
445
+ }
446
+ try {
447
+ to = decryptRecipient(encryptedTo, secret)
448
+ } catch (err) {
449
+ return { ok: false, message: err !== null && typeof err === 'object' && typeof err.message === 'string' ? err.message : String(err) }
450
+ }
451
+ }
452
+
453
+ if (to === '') return { ok: false, message: '尚未配置反馈收件邮箱。' + setupHint + '。' }
454
+ const toVerdict = mailboxVerdict(to)
455
+ if (toVerdict !== '') return { ok: false, message: '反馈收件邮箱' + toVerdict + ',请检查收件人配置' }
456
+ if (host === '') return { ok: false, message: '尚未配置 SMTP 服务器。' + setupHint + '。' }
457
+
458
+ const secure = boolOf(secureText, false)
459
+ let port = secure ? DEFAULT_SECURE_PORT : DEFAULT_PLAIN_PORT
460
+ if (portText !== '') {
461
+ const parsed = Number(portText)
462
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
463
+ return { ok: false, message: 'SMTP 端口无效:' + portText }
464
+ }
465
+ port = parsed
466
+ }
467
+
468
+ // A From is required by SMTP. It does not have to be an alias the server
469
+ // accepts (that is the server's policy), but it must be one mailbox.
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 配置' }
474
+ }
475
+ if (user === '' && pass !== '') {
476
+ return { ok: false, message: '已配置 SMTP 密码但缺少 SMTP 用户名(' + FEEDBACK_ENV.user + ')' }
477
+ }
478
+
479
+ return {
480
+ ok: true,
481
+ config: {
482
+ to,
483
+ from: fromAddress,
484
+ smtp: { host, port, secure, user, pass },
485
+ },
486
+ }
487
+ }
488
+
489
+ /** The cooldown between two accepted sends, clamped to a sane range. */
490
+ export function feedbackCooldownMs(env = process.env) {
491
+ const raw = envText(env, FEEDBACK_ENV.cooldown)
492
+ if (raw === '') return DEFAULT_FEEDBACK_COOLDOWN_MS
493
+ const parsed = Number(raw)
494
+ if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_FEEDBACK_COOLDOWN_MS
495
+ return Math.min(Math.round(parsed), MAX_FEEDBACK_COOLDOWN_MS)
496
+ }
497
+
498
+ const UNKNOWN = '未知'
499
+
500
+ function envField(value, max) {
501
+ const text = oneLine(value)
502
+ return text === '' ? UNKNOWN : cutText(text, max)
503
+ }
504
+
505
+ /**
506
+ * Compose the plain-text mail. The environment block is built on the Host from
507
+ * what the Host itself can see (plugin version, Node, platform) plus the few
508
+ * presentation facts the panel reports (locale, time zone, user agent). It NEVER
509
+ * carries instruction content: the form text is the only user text included.
510
+ */
511
+ export function buildFeedbackMessage(options = {}) {
512
+ const feedback = options.feedback !== null && typeof options.feedback === 'object' ? options.feedback : {}
513
+ const environment = options.environment !== null && typeof options.environment === 'object' ? options.environment : {}
514
+ const limits = FEEDBACK_LIMITS
515
+ const type = FEEDBACK_TYPES.includes(feedback.type) ? feedback.type : 'other'
516
+ const label = FEEDBACK_TYPE_LABELS[type]
517
+ const title = oneLine(feedback.title)
518
+ const contact = oneLine(feedback.contact)
519
+ const body = bodyText(feedback.body)
520
+
521
+ const subjectFull = '[指令记忆反馈] ' + label + ' · ' + title
522
+ const subject = subjectFull.length > limits.maxSubject
523
+ ? subjectFull.slice(0, limits.maxSubject - 1) + '…'
524
+ : subjectFull
525
+
526
+ const envLines = [
527
+ '插件版本:' + envField(environment.pluginVersion, limits.maxVersion),
528
+ 'DSH 版本:' + envField(environment.dshVersion, limits.maxVersion),
529
+ 'Node:' + envField(environment.node, limits.maxVersion),
530
+ '平台:' + envField(environment.platform, 32) + ' / ' + envField(environment.arch, 32),
531
+ '界面语言:' + envField(environment.locale, limits.maxLocale),
532
+ '时区:' + envField(environment.timezone, limits.maxTimezone),
533
+ '浏览器:' + envField(environment.userAgent, limits.maxUserAgent),
534
+ '提交时间:' + (options.now instanceof Date ? options.now : new Date()).toISOString(),
535
+ ]
536
+
537
+ const lines = [
538
+ '反馈类型:' + label,
539
+ '标题:' + title,
540
+ '联系方式:' + (contact === '' ? '(未填写)' : contact),
541
+ '',
542
+ '--- 反馈正文 ---',
543
+ body,
544
+ '',
545
+ '--- 运行环境(自动附带)---',
546
+ ...envLines,
547
+ ]
548
+
549
+ return {
550
+ subject,
551
+ text: lines.join('\n'),
552
+ messageId: randomUUID() + '@' + messageDomain(options.from),
553
+ // Only a bare, valid address becomes Reply-To. Free-form contact text must
554
+ // never reach a header: a newline there would inject a header.
555
+ replyTo: isMailbox(contact) ? contact : '',
556
+ }
557
+ }
558
+
559
+ /** The Message-ID domain, derived from the From address (never user input). */
560
+ function messageDomain(from) {
561
+ const address = textOf(from)
562
+ const at = address.lastIndexOf('@')
563
+ if (at === -1) return 'dsh.local'
564
+ const domain = address.slice(at + 1).replace(/[^A-Za-z0-9.-]/g, '')
565
+ return domain === '' ? 'dsh.local' : domain
566
+ }
567
+
568
+ const b64 = (value) => Buffer.from(String(value), 'utf8').toString('base64')
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
+ }
595
+
596
+ /** RFC 5322 headers + base64 body. Base64 never needs dot-stuffing. */
597
+ export function buildRawMail(options = {}) {
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), '收件邮箱')
602
+ const now = options.now instanceof Date ? options.now : new Date()
603
+ const messageId = textOf(options.messageId) || (randomUUID() + '@' + messageDomain(from))
604
+ const subject = oneLine(options.subject)
605
+ const replyTo = oneLine(options.replyTo)
606
+ const body64 = b64(options.text).replace(/(.{76})/g, '$1\r\n')
607
+ const headers = [
608
+ 'From: ' + encodeWords('DSH 指令记忆反馈') + ' <' + from + '>',
609
+ 'To: <' + to + '>',
610
+ ]
611
+ if (isMailbox(replyTo)) headers.push('Reply-To: <' + replyTo + '>')
612
+ headers.push(
613
+ 'Subject: ' + encodeWords(subject),
614
+ 'Date: ' + now.toUTCString(),
615
+ 'Message-ID: <' + messageId + '>',
616
+ 'MIME-Version: 1.0',
617
+ 'Content-Type: text/plain; charset=UTF-8',
618
+ 'Content-Transfer-Encoding: base64',
619
+ 'X-Mailer: dsh-instruction-memory',
620
+ )
621
+ return { raw: headers.join('\r\n') + '\r\n\r\n' + body64 + '\r\n', messageId }
622
+ }
623
+
624
+ /**
625
+ * One SMTP connection, framed around two operations the protocol needs:
626
+ * a line reader (responses are CRLF-delimited and may be multi-line) and a
627
+ * writer that resolves when the bytes are queued.
628
+ */
629
+ function createChannel(socket, timeoutMs) {
630
+ const state = { buffer: '', lines: [], waiters: [], error: null }
631
+
632
+ const fail = (err) => {
633
+ if (state.error === null) state.error = err
634
+ const waiters = state.waiters.splice(0)
635
+ for (const waiter of waiters) waiter.reject(state.error)
636
+ }
637
+ const onData = (chunk) => {
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
+ }
646
+ for (;;) {
647
+ const index = state.buffer.indexOf('\r\n')
648
+ if (index === -1) return
649
+ const line = state.buffer.slice(0, index)
650
+ state.buffer = state.buffer.slice(index + 2)
651
+ const waiter = state.waiters.shift()
652
+ if (waiter !== undefined) waiter.resolve(line)
653
+ else state.lines.push(line)
654
+ }
655
+ }
656
+ const onError = (err) => fail(err instanceof Error ? err : new Error(String(err)))
657
+ const onClose = () => fail(new Error('SMTP 连接已关闭'))
658
+ const onTimeout = () => {
659
+ fail(new Error('SMTP 操作超时(' + timeoutMs + ' ms)'))
660
+ socket.destroy()
661
+ }
662
+
663
+ socket.on('data', onData)
664
+ socket.on('error', onError)
665
+ socket.on('close', onClose)
666
+ if (typeof socket.setTimeout === 'function') socket.setTimeout(timeoutMs, onTimeout)
667
+ socket.resume()
668
+
669
+ const readLine = () => new Promise((resolve, reject) => {
670
+ if (state.lines.length > 0) {
671
+ resolve(state.lines.shift())
672
+ return
673
+ }
674
+ if (state.error !== null) {
675
+ reject(state.error)
676
+ return
677
+ }
678
+ state.waiters.push({ resolve, reject })
679
+ })
680
+
681
+ const write = (text) => new Promise((resolve, reject) => {
682
+ if (state.error !== null) {
683
+ reject(state.error)
684
+ return
685
+ }
686
+ socket.write(text, 'utf8', (err) => (err ? reject(err) : resolve()))
687
+ })
688
+
689
+ const readResponse = async () => {
690
+ const first = await readLine()
691
+ const match = /^(\d{3})([ -])(.*)$/.exec(first)
692
+ if (match === null) throw new Error('SMTP 响应无法解析:' + cutText(first, 120))
693
+ const code = Number(match[1])
694
+ const lines = [match[3]]
695
+ if (match[2] === '-') {
696
+ for (;;) {
697
+ const next = await readLine()
698
+ const nextMatch = /^(\d{3})([ -])(.*)$/.exec(next)
699
+ if (nextMatch === null) throw new Error('SMTP 多行响应无法解析:' + cutText(next, 120))
700
+ lines.push(nextMatch[3])
701
+ if (nextMatch[2] === ' ') break
702
+ }
703
+ }
704
+ return { code, lines }
705
+ }
706
+
707
+ const dispose = () => {
708
+ socket.removeListener('data', onData)
709
+ socket.removeListener('close', onClose)
710
+ socket.removeListener('error', onError)
711
+ if (typeof socket.setTimeout === 'function') socket.setTimeout(0)
712
+ // Keep the socket's own error path quiet: callers destroy it after dispose,
713
+ // and a late ECONNRESET with no listener would crash the host process.
714
+ socket.on('error', () => {})
715
+ }
716
+
717
+ return { readResponse, write, dispose, fail }
718
+ }
719
+
720
+ const smtpError = (what, response) =>
721
+ new Error('SMTP ' + what + ' 失败(' + response.code + '):' + cutText(response.lines.join(' '), 300))
722
+
723
+ async function expectCode(channel, codes, what) {
724
+ const response = await channel.readResponse()
725
+ const accepted = Array.isArray(codes) ? codes : [codes]
726
+ if (!accepted.includes(response.code)) throw smtpError(what, response)
727
+ return response
728
+ }
729
+
730
+ async function command(channel, line, codes, what) {
731
+ await channel.write(line + '\r\n')
732
+ return expectCode(channel, codes, what)
733
+ }
734
+
735
+ /** EHLO; returns the capability lines the server advertised. */
736
+ async function hello(channel, name) {
737
+ const response = await command(channel, 'EHLO ' + name, 250, 'EHLO')
738
+ return response.lines.map((line) => line.trim())
739
+ }
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
+
754
+ async function authenticate(channel, smtp, caps) {
755
+ const mechanisms = smtpAuthMechanisms(caps)
756
+ const canLogin = mechanisms.includes('LOGIN')
757
+ const canPlain = mechanisms.includes('PLAIN')
758
+ const fail = (response) => new Error('SMTP 认证失败(' + response.code + '):'
759
+ + cutText(response.lines.join(' '), 300)
760
+ + '。请检查用户名 / 密码;Gmail、QQ 邮箱等服务需要「应用专用密码」,不能用登录密码。')
761
+
762
+ if (canLogin || !canPlain) {
763
+ await command(channel, 'AUTH LOGIN', 334, 'AUTH LOGIN')
764
+ await command(channel, b64(smtp.user), 334, 'AUTH LOGIN 用户名')
765
+ const done = await (async () => {
766
+ await channel.write(b64(smtp.pass) + '\r\n')
767
+ return channel.readResponse()
768
+ })()
769
+ if (done.code !== 235) throw fail(done)
770
+ return
771
+ }
772
+ await channel.write('AUTH PLAIN ' + b64('\u0000' + smtp.user + '\u0000' + smtp.pass) + '\r\n')
773
+ const done = await channel.readResponse()
774
+ if (done.code !== 235) throw fail(done)
775
+ }
776
+
777
+ /** Open the transport socket; resolves once it is connectable (and, for TLS, verified). */
778
+ function openSocket(smtp, timeoutMs) {
779
+ return new Promise((resolve, reject) => {
780
+ const socket = smtp.secure
781
+ ? tlsConnect({ host: smtp.host, port: smtp.port, servername: smtp.host })
782
+ : netConnect({ host: smtp.host, port: smtp.port })
783
+ const event = smtp.secure ? 'secureConnect' : 'connect'
784
+ const timer = setTimeout(() => {
785
+ socket.destroy()
786
+ reject(new Error('SMTP 连接超时(' + timeoutMs + ' ms):' + smtp.host + ':' + smtp.port))
787
+ }, timeoutMs)
788
+ socket.once(event, () => {
789
+ clearTimeout(timer)
790
+ resolve(socket)
791
+ })
792
+ socket.once('error', (err) => {
793
+ clearTimeout(timer)
794
+ reject(new Error('无法连接 SMTP 服务器 ' + smtp.host + ':' + smtp.port + ':' + (err && err.message ? err.message : String(err))))
795
+ })
796
+ })
797
+ }
798
+
799
+ /** Upgrade an established plain socket with STARTTLS (certificate checked). */
800
+ function upgradeSocket(socket, host, timeoutMs) {
801
+ return new Promise((resolve, reject) => {
802
+ const secureSocket = tlsConnect({ socket, servername: host })
803
+ const timer = setTimeout(() => {
804
+ secureSocket.destroy()
805
+ reject(new Error('SMTP TLS 握手超时(' + timeoutMs + ' ms)'))
806
+ }, timeoutMs)
807
+ secureSocket.once('secureConnect', () => {
808
+ clearTimeout(timer)
809
+ resolve(secureSocket)
810
+ })
811
+ secureSocket.once('error', (err) => {
812
+ clearTimeout(timer)
813
+ reject(new Error('SMTP TLS 握手失败:' + (err && err.message ? err.message : String(err))))
814
+ })
815
+ })
816
+ }
817
+
818
+ /**
819
+ * Deliver one already-composed message.
820
+ *
821
+ * Returns `{ messageId, accepted, response }` on success. Throws an Error whose
822
+ * message is safe to show to the user (it never contains the password).
823
+ */
824
+ export async function sendSmtpMail(options = {}) {
825
+ const smtp = options.smtp !== null && typeof options.smtp === 'object' ? options.smtp : {}
826
+ const host = textOf(smtp.host)
827
+ const port = Number(smtp.port)
828
+ const from = textOf(options.from)
829
+ const to = textOf(options.to)
830
+ if (host === '' || !Number.isInteger(port) || port < 1 || port > 65535) {
831
+ throw new Error('SMTP 配置不完整(host / port)')
832
+ }
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)
838
+
839
+ const mail = buildRawMail({
840
+ from,
841
+ to,
842
+ subject: options.subject,
843
+ text: options.text,
844
+ messageId: options.messageId,
845
+ replyTo: options.replyTo,
846
+ now: options.now,
847
+ })
848
+ const timeoutMs = SMTP_TIMEOUT_MS
849
+ const clientName = (hostname() || 'dsh.local').replace(/[^A-Za-z0-9.-]/g, '').slice(0, 63) || 'dsh.local'
850
+
851
+ let socket = await openSocket({ host, port, secure: smtp.secure === true }, timeoutMs)
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)
860
+ try {
861
+ await expectCode(channel, 220, '服务端欢迎')
862
+ let caps = await hello(channel, clientName)
863
+
864
+ let encrypted = smtp.secure === true
865
+ if (!encrypted && caps.some((line) => /^STARTTLS\b/i.test(line))) {
866
+ await command(channel, 'STARTTLS', 220, 'STARTTLS')
867
+ channel.dispose()
868
+ socket = await upgradeSocket(socket, host, timeoutMs)
869
+ channel = createChannel(socket, timeoutMs)
870
+ caps = await hello(channel, clientName)
871
+ encrypted = true
872
+ }
873
+
874
+ if (textOf(smtp.user) !== '') {
875
+ if (!encrypted) {
876
+ throw new Error('SMTP 服务器未提供 STARTTLS,已拒绝在明文连接上发送账号密码。请把 '
877
+ + 'DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_SECURE 设为 1(465 端口)或改用支持 STARTTLS 的服务。')
878
+ }
879
+ await authenticate(channel, { user: textOf(smtp.user), pass: typeof smtp.pass === 'string' ? smtp.pass : '' }, caps)
880
+ }
881
+
882
+ await command(channel, 'MAIL FROM:<' + from + '>', 250, 'MAIL FROM')
883
+ await command(channel, 'RCPT TO:<' + to + '>', [250, 251], 'RCPT TO')
884
+ await command(channel, 'DATA', 354, 'DATA')
885
+ await channel.write(mail.raw + '.\r\n')
886
+ const accepted = await expectCode(channel, 250, '邮件投递')
887
+ try {
888
+ await command(channel, 'QUIT', 221, 'QUIT')
889
+ } catch {
890
+ // The message is already accepted; a missing QUIT reply is not a failure.
891
+ }
892
+ return { messageId: mail.messageId, accepted: to, response: accepted.lines.join(' ') }
893
+ } finally {
894
+ clearTimeout(overallTimer)
895
+ channel.dispose()
896
+ // Add an error listener before destroy: a socket destroyed mid-TLS can emit
897
+ // ECONNRESET synchronously, and an unhandled 'error' would take the host down.
898
+ socket.on('error', () => {})
899
+ socket.destroy()
900
+ }
901
+ }
902
+
903
+ /**
904
+ * Operator helper: `node lib/mailer.js encrypt-to <email>` prints the
905
+ * `toEncrypted` value to paste into feedback.json. The secret is read from the
906
+ * environment and never printed, so it does not end up in shell history.
907
+ */
908
+ const invokedDirectly = (() => {
909
+ const entry = process.argv[1]
910
+ if (entry === undefined) return false
911
+ try {
912
+ return import.meta.url === pathToFileURL(entry).href
913
+ } catch {
914
+ return false
915
+ }
916
+ })()
917
+
918
+ if (invokedDirectly) {
919
+ const [command, ...rest] = process.argv.slice(2)
920
+ if (command === 'encrypt-to') {
921
+ try {
922
+ console.log(encryptRecipient(rest[0] === undefined ? '' : rest[0], process.env[FEEDBACK_ENV.secret] || ''))
923
+ } catch (err) {
924
+ console.error(err !== null && typeof err === 'object' && typeof err.message === 'string' ? err.message : String(err))
925
+ process.exitCode = 1
926
+ }
927
+ } else {
928
+ console.error('用法:node lib/mailer.js encrypt-to <email>')
929
+ console.error('密钥从环境变量 ' + FEEDBACK_ENV.secret + ' 读取(至少 ' + MIN_SECRET_LENGTH + ' 个字符)。')
930
+ process.exitCode = 2
931
+ }
932
+ }