-
-
Notifications
You must be signed in to change notification settings - Fork 10
/
index.js
606 lines (563 loc) · 17.6 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
/*
* NodeJS library to interact with the MailHog API.
* https://github.com/blueimp/mailhog-node
*
* Copyright 2016, Sebastian Tschan
* https://blueimp.net
*
* Licensed under the MIT license:
* https://opensource.org/licenses/MIT
*/
'use strict'
/**
* @typedef {object} Attachment
* @property {string} name Filename
* @property {string} type Content-Type
* @property {string} encoding Content-Transfer-Encoding
* @property {string} Body Encoded content
* @property {Array<string>} Headers Encoded headers
*/
/**
* @typedef {object} MIME
* @property {Array<Attachment>} Parts Attachment parts
*/
/**
* @typedef {object} Message
* @property {string} ID Message ID
* @property {string} text Decoded mail text content
* @property {string} html Decoded mail HTML content
* @property {string} subject Decoded mail Subject header
* @property {string} from Decoded mail From header
* @property {string} to Decoded mail To header
* @property {string} cc Decoded mail Cc header
* @property {string} bcc Decoded mail Bcc header
* @property {string} replyTo Decoded mail Reply-To header
* @property {Date} date Mail Date header
* @property {Date} deliveryDate Mail Delivery-Date header
* @property {Array<Attachment>} attachments List of mail attachments
* @property {string} Created Mail Created property
* @property {MIME} MIME Mail Mime property
*/
/**
* @typedef {object} Messages
* @property {number} total Number of results available
* @property {number} count Number of results returned
* @property {number} start Offset for the range of results returned
* @property {Array<Message>} items List of mail object items
*/
/**
* @typedef {object} Options API options
* @property {string} [protocol="http:"] API protocol
* @property {string} [host=localhost] API host
* @property {number} [port=8025] API port
* @property {string} [auth] API basic authentication
* @property {string} [basePath="/api"] API base path
*/
/* eslint-disable jsdoc/valid-types */
/**
* @typedef {object} API
* @property {Options} options API options
* @property {typeof messages} messages Gets all messages
* @property {typeof search} search Gets messages matching a query
* @property {typeof latestFrom} latestFrom Gets latest message from sender
* @property {typeof latestTo} latestTo Gets latest message to recipient
* @property {typeof latestContaining} latestContaining Gets latest with content
* @property {typeof releaseMessage} releaseMessage Releases given message
* @property {typeof deleteMessage} deleteMessage Deletes given message
* @property {typeof deleteAll} deleteAll Deletes all messages
* @property {typeof encode} encode Encodes given content
* @property {typeof decode} decode Decodes given content
*/
/* eslint-enable jsdoc/valid-types */
/**
* @typedef {object} SMTPConfig
* @property {string} host SMTP host
* @property {string} port SMTP port
* @property {string} email recipient email
* @property {string} [username] SMTP username
* @property {string} [password] SMTP password
* @property {string} [mechanism] SMTP auth mechanism (PLAIN or CRAM-MD5)
*/
/* global BufferEncoding */
const http = require('http')
const https = require('https')
const libqp = require('./libqp')
/**
* Adds soft line breaks to a given String
*
* @param {string} str String to wrap
* @param {number} [lineLength=76] Maximum allowed length for a line
* @returns {string} Soft-wrapped String using `\r\n` as line breaks
*/
function wrap(str, lineLength) {
const maxLength = lineLength || 76
const lines = Math.ceil(str.length / maxLength)
let output = ''
for (let i = 0, offset = 0; i < lines; ++i, offset += maxLength) {
output += str.substr(offset, maxLength) + '\r\n'
}
return output.trim()
}
/**
* Encodes a String in the given charset to base64 or quoted-printable encoding.
*
* @param {string} str String to encode
* @param {string} [encoding] base64|quoted-printable
* @param {string} [charset=utf8] Charset of the input string
* @param {number} [lineLength=76] Soft line break limit
* @returns {string} Encoded String
*/
function encode(str, encoding, charset, lineLength) {
const maxLength = lineLength === undefined ? 76 : lineLength
const outputEncoding = encoding && encoding.toLowerCase()
let output = str
if (outputEncoding === 'quoted-printable' || outputEncoding === 'base64') {
const isUTF8Input = !charset || /^utf-?8$/.test(charset.toLowerCase())
let buffer
if (isUTF8Input) {
buffer = Buffer.from(str)
} else {
buffer = require('iconv-lite').encode(str, charset)
}
if (outputEncoding === 'quoted-printable') {
const output = libqp.encode(buffer)
return maxLength ? libqp.wrap(output, maxLength) : output
}
output = buffer.toString('base64')
}
return maxLength ? wrap(output, maxLength) : output
}
/**
* Decodes a String from the given encoding and outputs it in the given charset.
*
* @param {string} str String to decode
* @param {string} [encoding=utf8] input encoding, e.g. base64|quoted-printable
* @param {string} [charset=utf8] Charset to use for the output
* @returns {string} Decoded String
*/
function decode(str, encoding, charset) {
const inputEncoding = encoding && encoding.toLowerCase()
const utf8Regexp = /^utf-?8$/
const isUTF8Input = !inputEncoding || utf8Regexp.test(inputEncoding)
const isUTF8Output = !charset || utf8Regexp.test(charset.toLowerCase())
if (isUTF8Input && isUTF8Output) return str
// 7bit|8bit|binary are not encoded, x-token has an unknown encoding, see:
// https://www.w3.org/Protocols/rfc1341/5_Content-Transfer-Encoding.html
if (/^(7|8)bit|binary|x-.+$/.test(inputEncoding)) return str
let buffer
if (inputEncoding === 'quoted-printable') {
buffer = libqp.decode(str)
} else {
buffer = Buffer.from(
str,
/** @type {BufferEncoding} */
(inputEncoding)
)
}
if (isUTF8Output) return buffer.toString()
return require('iconv-lite').decode(buffer, charset)
}
/**
* Returns the content part matching the given content-type regular expression.
*
* @param {object} mail MailHog mail object
* @param {RegExp} typeRegExp Regular expression matching the content-type
* @returns {string} Decoded content with a type matching the content-type
*/
function getContent(mail, typeRegExp) {
let parts = [mail.Content]
if (mail.MIME) parts = parts.concat(mail.MIME.Parts)
for (const part of parts) {
const type = (part.Headers['Content-Type'] || '').toString()
if (typeRegExp.test(type)) {
const match = /\bcharset=([\w_-]+)(?:;|$)/.exec(type)
const charset = match ? match[1] : undefined
return decode(
part.Body,
(part.Headers['Content-Transfer-Encoding'] || '').toString(),
charset
)
}
}
}
/**
* Matches encoded Strings in mail headers and returns decoded content.
*
* @param {string} _ Matched substring (unused)
* @param {string} charset Charset to use for the output
* @param {string} encoding B|Q, which stands for base64 or quoted-printable
* @param {string} data Encoded String data
* @returns {string} Decoded header content
*/
function headerDecoder(_, charset, encoding, data) {
switch (encoding) {
case 'b':
case 'B':
return decode(data, 'base64', charset)
case 'q':
case 'Q':
return decode(data, 'quoted-printable', charset)
}
}
/**
* Returns header content for the given mail object and header key.
*
* @param {object} mail MailHog mail object
* @param {string} key Header key
* @returns {string} Header content
*/
function getHeader(mail, key) {
const header = (mail.Content || mail).Headers[key]
if (!header || !header.length) return
// Encoded header parts have the following form:
// =?charset?encoding?data?=
return header[0].replace(/=\?([^?]+)\?([BbQq])\?([^?]+)\?=/g, headerDecoder)
}
/**
* Memoized getter for mail text content.
*
* @this Message
* @returns {string} Decoded mail text content
*/
function getText() {
delete this.text
return (this.text = getContent(this, /^text\/plain($|;)/i))
}
/**
* Memoized getter for mail HTML content.
*
* @this Message
* @returns {string} Decoded mail HTML content
*/
function getHTML() {
delete this.html
return (this.html = getContent(this, /^text\/html($|;)/i))
}
/**
* Memoized getter for mail Subject header.
*
* @this Message
* @returns {string} Decoded mail Subject header
*/
function getSubject() {
delete this.subject
return (this.subject = getHeader(this, 'Subject'))
}
/**
* Memoized getter for mail From header.
*
* @this Message
* @returns {string} Decoded mail From header
*/
function getFrom() {
delete this.from
return (this.from = getHeader(this, 'From'))
}
/**
* Memoized getter for mail To header.
*
* @this Message
* @returns {string} Decoded mail To header
*/
function getTo() {
delete this.to
return (this.to = getHeader(this, 'To'))
}
/**
* Memoized getter for mail Cc header.
*
* @this Message
* @returns {string} Decoded mail Cc header
*/
function getCc() {
delete this.cc
return (this.cc = getHeader(this, 'Cc'))
}
/**
* Memoized getter for mail Bcc header.
*
* @this Message
* @returns {string} Decoded mail Bcc header
*/
function getBcc() {
delete this.bcc
return (this.bcc = getHeader(this, 'Bcc'))
}
/**
* Memoized getter for mail Reply-To header.
*
* @this Message
* @returns {string} Decoded mail Reply-To header
*/
function getReplyTo() {
delete this.replyTo
return (this.replyTo = getHeader(this, 'Reply-To'))
}
/**
* Memoized getter for mail Date header.
*
* @this Message
* @returns {Date} Mail Date header
*/
function getDate() {
delete this.date
const dateString = getHeader(this, 'Date')
if (dateString) this.date = new Date(Date.parse(dateString))
return this.date
}
/**
* Memoized getter for mail Delivery-Date header.
*
* @this Message
* @returns {Date} Mail Delivery-Date header
*/
function getDeliveryDate() {
delete this.deliveryDate
// MailHog does not set the Delivery-Date header, but it sets a Created
// property that serves the same purpose (delivery date to application):
return (this.deliveryDate = new Date(Date.parse(this.Created)))
}
/**
* Memoized getter for mail Content-Type header.
*
* @this Attachment
* @returns {string} Decoded mail Content-Type header
*/
function getContentType() {
delete this.type
return (this.type = getHeader(this, 'Content-Type'))
}
/**
* Memoized getter for mail Content-Transfer-Encoding header.
*
* @this Attachment
* @returns {string} Decoded mail Content-Transfer-Encoding header
*/
function getContentTransferEncoding() {
delete this.encoding
return (this.encoding = getHeader(this, 'Content-Transfer-Encoding'))
}
/**
* Memoized getter for mail attachments.
*
* @this Message
* @returns {Array<Attachment>} List of mail attachments
*/
function getAttachments() {
delete this.attachments
const attachments = []
if (this.MIME && this.MIME.Parts) {
for (const part of this.MIME.Parts) {
const match = /^attachment;\s*filename="?([^"]+)"?$/.exec(
part.Headers['Content-Disposition']
)
if (!match) continue
part.name = match[1]
Object.defineProperty(part, 'type', {
get: getContentType,
configurable: true
})
Object.defineProperty(part, 'encoding', {
get: getContentTransferEncoding,
configurable: true
})
attachments.push(part)
}
}
return (this.attachments = attachments)
}
/**
* Injects convenience properties for each mail item in the given result.
*
* @param {object} result Result object for a MailHog API search/messages query
* @returns {object} Result object with injected properties for each mail item
*/
function injectProperties(result) {
if (!result.count) return result
for (const item of result.items) {
// Define memoized getter for contents and headers:
Object.defineProperty(item, 'text', { get: getText, configurable: true })
Object.defineProperty(item, 'html', { get: getHTML, configurable: true })
Object.defineProperty(item, 'subject', {
get: getSubject,
configurable: true
})
Object.defineProperty(item, 'from', { get: getFrom, configurable: true })
Object.defineProperty(item, 'to', { get: getTo, configurable: true })
Object.defineProperty(item, 'cc', { get: getCc, configurable: true })
Object.defineProperty(item, 'bcc', { get: getBcc, configurable: true })
Object.defineProperty(item, 'replyTo', {
get: getReplyTo,
configurable: true
})
Object.defineProperty(item, 'date', { get: getDate, configurable: true })
Object.defineProperty(item, 'deliveryDate', {
get: getDeliveryDate,
configurable: true
})
Object.defineProperty(item, 'attachments', {
get: getAttachments,
configurable: true
})
}
return result
}
/**
* Sends a http.request and resolves with the parsed JSON response.
*
* @param {object} options http.request options
* @param {string} [data] POST data
* @returns {Promise} resolves with JSON or http.IncomingMessage if no body
*/
function request(options, data) {
const client = options.protocol === 'https:' ? https : http
return new Promise((resolve, reject) => {
const req = client
.request(options, response => {
let body = ''
response
.on('data', chunk => (body += chunk))
.on('end', () => {
if (!body) return resolve(response)
try {
resolve(JSON.parse(body))
} catch (error) {
reject(error)
}
})
})
.on('error', reject)
if (data) req.write(data)
req.end()
})
}
/**
* Requests mail objects from the MailHog API.
*
* @param {number} [start=0] defines the offset for the messages query
* @param {number} [limit=50] defines the max number of results
* @returns {Promise<Messages?>} resolves with object listing the mail items
*/
function messages(start, limit) {
let path = `${this.options.basePath}/v2/messages`
if (start) path += `?start=${start}`
if (limit) path += `${start ? '&' : '?'}limit=${limit}`
const options = Object.assign({}, this.options, { path })
return request(options).then(result => injectProperties(result))
}
/**
* Sends a search request to the MailHog API.
*
* @param {string} query search query
* @param {string} [kind=containing] query kind, can be from|to|containing
* @param {number} [start=0] defines the offset for the search query
* @param {number} [limit=50] defines the max number of results
* @returns {Promise<Messages?>} resolves with object listing the mail items
*/
function search(query, kind, start, limit) {
const basePath = this.options.basePath
const kindParam = kind || 'containing'
const encodedQuery = encodeURIComponent(query)
let path = `${basePath}/v2/search?kind=${kindParam}&query=${encodedQuery}`
if (start) path += `&start=${start}`
if (limit) path += `&limit=${limit}`
const options = Object.assign({}, this.options, { path })
return request(options).then(result => injectProperties(result))
}
/**
* Sends a search request for the latest mail matching the "from" query.
*
* @param {string} query from address
* @returns {Promise<Message?>} resolves latest mail object for the "from" query
*/
function latestFrom(query) {
return this.search(query, 'from', 0, 1).then(
result => result.count && result.items[0]
)
}
/**
* Sends a search request for the latest mail matching the "to" query.
*
* @param {string} query to address
* @returns {Promise<Message?>} resolves latest mail object for the "to" query
*/
function latestTo(query) {
return this.search(query, 'to', 0, 1).then(
result => result.count && result.items[0]
)
}
/**
* Sends a search request for the latest mail matching the "containing" query.
*
* @param {string} query search query
* @returns {Promise<Message?>} resolves latest mail object "containing" query
*/
function latestContaining(query) {
return this.search(query, 'containing', 0, 1).then(
result => result.count && result.items[0]
)
}
/**
* Releases the mail with the given ID using the provided SMTP config.
*
* @param {string} id message ID
* @param {SMTPConfig} config SMTP configuration
* @returns {Promise<http.IncomingMessage>} resolves with http.IncomingMessage
*/
function releaseMessage(id, config) {
const basePath = this.options.basePath
const options = Object.assign({}, this.options, {
method: 'POST',
path: `${basePath}/v1/messages/${encodeURIComponent(id)}/release`
})
return request(options, JSON.stringify(config))
}
/**
* Deletes the mail with the given ID from MailHog.
*
* @param {string} id message ID
* @returns {Promise<http.IncomingMessage>} resolves with http.IncomingMessage
*/
function deleteMessage(id) {
const options = Object.assign({}, this.options, {
method: 'DELETE',
path: `${this.options.basePath}/v1/messages/${encodeURIComponent(id)}`
})
return request(options)
}
/**
* Deletes all mails stored in MailHog.
*
* @returns {Promise<http.IncomingMessage>} resolves with http.IncomingMessage
*/
function deleteAll() {
const options = Object.assign({}, this.options, {
method: 'DELETE',
path: `${this.options.basePath}/v1/messages`
})
return request(options)
}
/**
* Returns the mailhog API interface.
*
* @param {Options} [options] API options
* @returns {API} API object
*/
function mailhog(options) {
const api = {
options: Object.assign({ port: 8025, basePath: '/api' }, options),
encode,
decode
}
return Object.assign(api, {
messages: messages.bind(api),
search: search.bind(api),
latestFrom: latestFrom.bind(api),
latestTo: latestTo.bind(api),
latestContaining: latestContaining.bind(api),
releaseMessage: releaseMessage.bind(api),
deleteMessage: deleteMessage.bind(api),
deleteAll: deleteAll.bind(api)
})
}
module.exports = mailhog