w-email 1.1.2 → 1.1.4

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.
@@ -0,0 +1,310 @@
1
+ import net from 'net'
2
+ import tls from 'tls'
3
+
4
+
5
+ /**
6
+ * 啟動本機假SMTP伺服器,記錄客戶端送出之指令、AUTH、信封與信件原文,供測試斷言
7
+ *
8
+ * 連接埠自portStart起循序嘗試,遇占用則試下一號至portEnd為止(自動配發,不隨機),僅綁定127.0.0.1
9
+ *
10
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
11
+ * @param {Number} [opt.portStart=8025] 輸入起始連接埠,預設8025
12
+ * @param {Number} [opt.portEnd=8999] 輸入結束連接埠,預設8999
13
+ * @param {String} [opt.auth='accept'] 輸入AUTH行為,'accept'為接受,'reject'為回覆535,'none'為不宣告AUTH,'xoauth2only'為僅宣告XOAUTH2,未宣告之機制一律回覆504,預設'accept'
14
+ * @param {Object} [opt.tls=null] 輸入STARTTLS用之憑證物件{key, cert},給予時宣告STARTTLS且於升級TLS前不宣告AUTH(同Gmail),預設null
15
+ * @param {Array} [opt.rcptReject=[]] 輸入以550拒收之收件地址(小寫)陣列,預設[]
16
+ * @param {Boolean} [opt.dataReject=false] 輸入DATA結束後是否回覆554,預設false
17
+ * @param {String} [opt.dropAt=''] 輸入伺服器端強制斷線時機,''為不斷線,'connect'為連線即斷,'mailfrom'為收到MAIL FROM即斷,'middata'為收到部分信件內容即斷,預設''
18
+ * @returns {Promise} 回傳Promise,resolve回傳物件,含port(實際連接埠)、sessions(各連線紀錄陣列)、close(關閉函數)
19
+ */
20
+ async function startFakeSmtp(opt = {}) {
21
+ let portStart = opt.portStart || 8025
22
+ let portEnd = opt.portEnd || 8999
23
+ let auth = opt.auth || 'accept'
24
+ let optTls = opt.tls || null
25
+ let rcptReject = opt.rcptReject || []
26
+ let dataReject = opt.dataReject === true
27
+ let dropAt = opt.dropAt || ''
28
+
29
+ let sessions = []
30
+ let sockets = new Set()
31
+
32
+ let onConnection = (socket) => {
33
+ sockets.add(socket)
34
+ socket.on('close', () => {
35
+ sockets.delete(socket)
36
+ })
37
+ socket.on('error', () => {})
38
+
39
+ //sess, tls為是否已升級TLS, cmds為指令(AUTH之初始回應已遮蔽), auth為解出之帳密, mailFrom與rcptTo為信封地址, data為信件原文Buffer
40
+ let sess = {
41
+ tls: false,
42
+ cmds: [],
43
+ auth: null,
44
+ mailFrom: null,
45
+ rcptTo: [],
46
+ data: null,
47
+ }
48
+ sessions.push(sess)
49
+
50
+ if (dropAt === 'connect') {
51
+ socket.destroy()
52
+ return
53
+ }
54
+
55
+ let sock = socket //升級TLS後改為TLSSocket
56
+ let buf = '' //以latin1保留原始位元組
57
+ let inData = false
58
+ let dataLines = []
59
+ let authStep = ''
60
+ let loginUser = ''
61
+
62
+ let send = (s) => {
63
+ if (!sock.destroyed) {
64
+ sock.write(s + '\r\n')
65
+ }
66
+ }
67
+
68
+ let b64 = (s) => {
69
+ return Buffer.from(s, 'base64').toString('utf8')
70
+ }
71
+
72
+ let addrOf = (s) => {
73
+ let m = s.match(/<([^>]*)>/)
74
+ return m ? m[1] : s
75
+ }
76
+
77
+ //advertised, 目前宣告之AUTH機制, 有STARTTLS時須升級TLS後才宣告
78
+ let advertised = () => {
79
+ if (optTls && !sess.tls) {
80
+ return []
81
+ }
82
+ if (auth === 'accept' || auth === 'reject') {
83
+ return ['PLAIN', 'LOGIN']
84
+ }
85
+ if (auth === 'xoauth2only') {
86
+ return ['XOAUTH2']
87
+ }
88
+ return []
89
+ }
90
+
91
+ let setAuth = (mech, user, pass) => {
92
+ sess.auth = { mech, user, pass }
93
+ if (auth === 'reject') {
94
+ send('535 5.7.8 Username and Password not accepted')
95
+ }
96
+ else {
97
+ send('235 2.7.0 Accepted')
98
+ }
99
+ }
100
+
101
+ let onLine = (line) => {
102
+
103
+ //信件內容
104
+ if (inData) {
105
+ if (line === '.') {
106
+ inData = false
107
+ sess.data = Buffer.from(dataLines.join('\r\n'), 'latin1')
108
+ if (dataReject) {
109
+ send('554 5.6.0 Message rejected')
110
+ }
111
+ else {
112
+ send('250 2.0.0 OK queued')
113
+ }
114
+ return
115
+ }
116
+ dataLines.push(line.startsWith('.') ? line.slice(1) : line)
117
+ if (dropAt === 'middata' && dataLines.length >= 3) {
118
+ sock.destroy()
119
+ }
120
+ return
121
+ }
122
+
123
+ //AUTH後續回應
124
+ if (authStep !== '') {
125
+ let step = authStep
126
+ authStep = ''
127
+ if (step === 'login-user') {
128
+ loginUser = b64(line)
129
+ authStep = 'login-pass'
130
+ send('334 UGFzc3dvcmQ6')
131
+ }
132
+ else if (step === 'login-pass') {
133
+ setAuth('LOGIN', loginUser, b64(line))
134
+ }
135
+ else if (step === 'plain') {
136
+ let p = b64(line).split('\0')
137
+ setAuth('PLAIN', p[1], p[2])
138
+ }
139
+ return
140
+ }
141
+
142
+ let text = Buffer.from(line, 'latin1').toString('utf8')
143
+ sess.cmds.push(text.replace(/^(AUTH\s+\S+)\s+.*$/i, '$1 <initial-response>'))
144
+ let cmd = text.split(' ')[0].toUpperCase()
145
+ let arg = text.slice(cmd.length).trim()
146
+
147
+ if (cmd === 'EHLO') {
148
+ let ext = ['8BITMIME', 'SMTPUTF8']
149
+ if (optTls && !sess.tls) {
150
+ ext.push('STARTTLS')
151
+ }
152
+ let mechs = advertised()
153
+ if (mechs.length > 0) {
154
+ ext.push(`AUTH ${mechs.join(' ')}`)
155
+ }
156
+ let lines = ['localhost', ...ext]
157
+ lines.forEach((l, i) => {
158
+ send(`250${i === lines.length - 1 ? ' ' : '-'}${l}`)
159
+ })
160
+ }
161
+ else if (cmd === 'HELO') {
162
+ send('250 localhost')
163
+ }
164
+ else if (cmd === 'STARTTLS') {
165
+ if (!optTls || sess.tls) {
166
+ send('503 5.5.1 STARTTLS not available')
167
+ return
168
+ }
169
+ send('220 2.0.0 Ready to start TLS')
170
+ socket.removeAllListeners('data')
171
+ let ts = new tls.TLSSocket(socket, { isServer: true, key: optTls.key, cert: optTls.cert })
172
+ ts.on('error', () => {})
173
+ ts.on('secure', () => {
174
+ sess.tls = true
175
+ })
176
+ ts.on('data', onData)
177
+ sock = ts
178
+ buf = ''
179
+ }
180
+ else if (cmd === 'AUTH') {
181
+ let [mech, init] = arg.split(/\s+/)
182
+ mech = (mech || '').toUpperCase()
183
+ if (!advertised().includes(mech)) {
184
+ //未宣告之機制, 同真實伺服器回覆504
185
+ sess.auth = { mech }
186
+ send('504 5.5.4 Unrecognized authentication type')
187
+ }
188
+ else if (mech === 'PLAIN') {
189
+ if (init) {
190
+ let p = b64(init).split('\0')
191
+ setAuth('PLAIN', p[1], p[2])
192
+ }
193
+ else {
194
+ authStep = 'plain'
195
+ send('334 ')
196
+ }
197
+ }
198
+ else if (mech === 'LOGIN') {
199
+ if (init) {
200
+ loginUser = b64(init)
201
+ authStep = 'login-pass'
202
+ send('334 UGFzc3dvcmQ6')
203
+ }
204
+ else {
205
+ authStep = 'login-user'
206
+ send('334 VXNlcm5hbWU6')
207
+ }
208
+ }
209
+ else {
210
+ sess.auth = { mech }
211
+ send('535 5.7.8 Authentication failed')
212
+ }
213
+ }
214
+ else if (cmd === 'MAIL') {
215
+ if (dropAt === 'mailfrom') {
216
+ sock.destroy()
217
+ return
218
+ }
219
+ sess.mailFrom = addrOf(arg)
220
+ send('250 2.1.0 OK')
221
+ }
222
+ else if (cmd === 'RCPT') {
223
+ let a = addrOf(arg)
224
+ sess.rcptTo.push(a)
225
+ if (rcptReject.includes(a.toLowerCase())) {
226
+ send('550 5.1.1 User unknown')
227
+ }
228
+ else {
229
+ send('250 2.1.5 OK')
230
+ }
231
+ }
232
+ else if (cmd === 'DATA') {
233
+ inData = true
234
+ dataLines = []
235
+ send('354 Go ahead')
236
+ }
237
+ else if (cmd === 'RSET' || cmd === 'NOOP') {
238
+ send('250 2.0.0 OK')
239
+ }
240
+ else if (cmd === 'QUIT') {
241
+ send('221 2.0.0 Bye')
242
+ sock.end()
243
+ }
244
+ else {
245
+ send('502 5.5.2 Command not recognized')
246
+ }
247
+ }
248
+
249
+ let onData = (chunk) => {
250
+ buf += chunk.toString('latin1')
251
+ let i = buf.indexOf('\r\n')
252
+ while (i >= 0) {
253
+ let line = buf.slice(0, i)
254
+ buf = buf.slice(i + 2)
255
+ onLine(line)
256
+ if (sock.destroyed) {
257
+ return
258
+ }
259
+ i = buf.indexOf('\r\n')
260
+ }
261
+ }
262
+
263
+ socket.on('data', onData)
264
+ send('220 localhost ESMTP fakeSmtp')
265
+ }
266
+
267
+ for (let port = portStart; port <= portEnd; port++) {
268
+ let server = net.createServer(onConnection)
269
+ let r = await new Promise((resolve) => {
270
+ server.once('error', (err) => {
271
+ resolve(err)
272
+ })
273
+ server.listen(port, '127.0.0.1', () => {
274
+ resolve(null)
275
+ })
276
+ })
277
+ if (r === null) {
278
+ let close = () => {
279
+ return new Promise((resolve) => {
280
+ for (let s of sockets) {
281
+ s.destroy()
282
+ }
283
+ server.close(() => {
284
+ resolve()
285
+ })
286
+ })
287
+ }
288
+ return { port, sessions, close }
289
+ }
290
+ if (r.code !== 'EADDRINUSE' && r.code !== 'EACCES') {
291
+ throw r
292
+ }
293
+ }
294
+ throw new Error(`no free port in ${portStart}-${portEnd}`)
295
+ }
296
+
297
+
298
+ /**
299
+ * 取得一個當下無服務監聽之連接埠(自8025起循序配發後立即關閉),供測試連線被拒之情境
300
+ *
301
+ * @returns {Promise} 回傳Promise,resolve回傳連接埠數字
302
+ */
303
+ async function getClosedPort() {
304
+ let srv = await startFakeSmtp()
305
+ await srv.close()
306
+ return srv.port
307
+ }
308
+
309
+
310
+ export { startFakeSmtp, getClosedPort }
@@ -0,0 +1,101 @@
1
+ import crypto from 'crypto'
2
+
3
+
4
+ //DER編碼之最小輔助, 僅涵蓋產生自簽憑證所需之型別
5
+ function derLen(n) {
6
+ if (n < 0x80) {
7
+ return Buffer.from([n])
8
+ }
9
+ if (n < 0x100) {
10
+ return Buffer.from([0x81, n])
11
+ }
12
+ return Buffer.from([0x82, n >> 8, n & 0xff])
13
+ }
14
+
15
+
16
+ function tlv(tag, buf) {
17
+ return Buffer.concat([Buffer.from([tag]), derLen(buf.length), buf])
18
+ }
19
+
20
+
21
+ function seq(...items) {
22
+ return tlv(0x30, Buffer.concat(items))
23
+ }
24
+
25
+
26
+ function oid(s) {
27
+ let ns = s.split('.').map(Number)
28
+ let bytes = [ns[0] * 40 + ns[1]]
29
+ for (let n of ns.slice(2)) {
30
+ let enc = [n & 0x7f]
31
+ n = Math.floor(n / 128)
32
+ while (n > 0) {
33
+ enc.unshift((n & 0x7f) | 0x80)
34
+ n = Math.floor(n / 128)
35
+ }
36
+ bytes.push(...enc)
37
+ }
38
+ return tlv(0x06, Buffer.from(bytes))
39
+ }
40
+
41
+
42
+ function int(buf) {
43
+ //正整數, 最高位元為1時補0
44
+ if (buf[0] & 0x80) {
45
+ buf = Buffer.concat([Buffer.from([0]), buf])
46
+ }
47
+ return tlv(0x02, buf)
48
+ }
49
+
50
+
51
+ function time(d) {
52
+ //2050年(含)以後須用GeneralizedTime
53
+ let p = (v) => String(v).padStart(2, '0')
54
+ let s = `${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}Z`
55
+ if (d.getUTCFullYear() >= 2050) {
56
+ return tlv(0x18, Buffer.from(`${d.getUTCFullYear()}${s}`, 'ascii'))
57
+ }
58
+ return tlv(0x17, Buffer.from(`${p(d.getUTCFullYear() % 100)}${s}`, 'ascii'))
59
+ }
60
+
61
+
62
+ /**
63
+ * 以Node內建crypto產生localhost自簽憑證(EC P-256, SAN含localhost與127.0.0.1), 供測試之TLS伺服器使用
64
+ *
65
+ * 於測試執行時產生, 不需openssl與第三方套件, 亦不必將私鑰納入版控或發布之套件
66
+ *
67
+ * @returns {Object} 回傳物件,含key(私鑰PEM)與cert(憑證PEM)
68
+ */
69
+ function genCert() {
70
+ let { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' })
71
+ let spki = publicKey.export({ type: 'spki', format: 'der' })
72
+ let sigAlg = seq(oid('1.2.840.10045.4.3.2')) //ecdsa-with-SHA256
73
+ let name = seq(tlv(0x31, seq(oid('2.5.4.3'), tlv(0x0c, Buffer.from('localhost', 'utf8'))))) //CN=localhost
74
+ let now = Date.now()
75
+ let validity = seq(time(new Date(now - 60 * 60 * 1000)), time(new Date(now + 24 * 60 * 60 * 1000)))
76
+ let san = seq(tlv(0x82, Buffer.from('localhost', 'ascii')), tlv(0x87, Buffer.from([127, 0, 0, 1]))) //dNSName, iPAddress
77
+ let extensions = tlv(0xa3, seq(
78
+ seq(oid('2.5.29.19'), tlv(0x01, Buffer.from([0xff])), tlv(0x04, seq(tlv(0x01, Buffer.from([0xff]))))), //basicConstraints critical CA:TRUE
79
+ seq(oid('2.5.29.17'), tlv(0x04, san)), //subjectAltName
80
+ ))
81
+ let tbs = seq(
82
+ tlv(0xa0, int(Buffer.from([2]))), //v3
83
+ int(Buffer.from([1])), //序號固定為1, 每次執行皆為新金鑰對故無衝突
84
+ sigAlg,
85
+ name,
86
+ validity,
87
+ name,
88
+ spki,
89
+ extensions,
90
+ )
91
+ let sig = crypto.sign('sha256', tbs, { key: privateKey, dsaEncoding: 'der' })
92
+ let certDer = seq(tbs, sigAlg, tlv(0x03, Buffer.concat([Buffer.from([0]), sig])))
93
+ let b64 = certDer.toString('base64').match(/.{1,64}/g).join('\n')
94
+ return {
95
+ key: privateKey.export({ type: 'pkcs8', format: 'pem' }),
96
+ cert: `-----BEGIN CERTIFICATE-----\n${b64}\n-----END CERTIFICATE-----\n`,
97
+ }
98
+ }
99
+
100
+
101
+ export default genCert