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/CHANGELOG.md +32 -0
- package/README.md +124 -6
- package/lib/client.js +281 -2
- package/lib/index.js +183 -1
- package/lib/mailer.js +810 -0
- package/package.json +2 -2
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
|
+
}
|