source: node_modules/nodemailer/lib/mime-node/index.js@ 2d1ec46

main
Last change on this file since 2d1ec46 was 81bc7da, checked in by Klimentina Efremova <klimentina08642@…>, 3 months ago

Initial commit

  • Property mode set to 100644
File size: 43.8 KB
Line 
1/* eslint no-undefined: 0, prefer-spread: 0, no-control-regex: 0 */
2
3'use strict';
4
5const crypto = require('crypto');
6const fs = require('fs');
7const punycode = require('../punycode');
8const PassThrough = require('stream').PassThrough;
9const shared = require('../shared');
10
11const mimeFuncs = require('../mime-funcs');
12const qp = require('../qp');
13const base64 = require('../base64');
14const addressparser = require('../addressparser');
15const nmfetch = require('../fetch');
16const LastNewline = require('./last-newline');
17
18const LeWindows = require('./le-windows');
19const LeUnix = require('./le-unix');
20
21/**
22 * Creates a new mime tree node. Assumes 'multipart/*' as the content type
23 * if it is a branch, anything else counts as leaf. If rootNode is missing from
24 * the options, assumes this is the root.
25 *
26 * @param {String} contentType Define the content type for the node. Can be left blank for attachments (derived from filename)
27 * @param {Object} [options] optional options
28 * @param {Object} [options.rootNode] root node for this tree
29 * @param {Object} [options.parentNode] immediate parent for this node
30 * @param {Object} [options.filename] filename for an attachment node
31 * @param {String} [options.baseBoundary] shared part of the unique multipart boundary
32 * @param {Boolean} [options.keepBcc] If true, do not exclude Bcc from the generated headers
33 * @param {Function} [options.normalizeHeaderKey] method to normalize header keys for custom caseing
34 * @param {String} [options.textEncoding] either 'Q' (the default) or 'B'
35 */
36class MimeNode {
37 constructor(contentType, options) {
38 this.nodeCounter = 0;
39
40 options = options || {};
41
42 /**
43 * shared part of the unique multipart boundary
44 */
45 this.baseBoundary = options.baseBoundary || crypto.randomBytes(8).toString('hex');
46 this.boundaryPrefix = options.boundaryPrefix || '--_NmP';
47
48 this.disableFileAccess = !!options.disableFileAccess;
49 this.disableUrlAccess = !!options.disableUrlAccess;
50
51 this.normalizeHeaderKey = options.normalizeHeaderKey;
52
53 /**
54 * If date headers is missing and current node is the root, this value is used instead
55 */
56 this.date = new Date();
57
58 /**
59 * Root node for current mime tree
60 */
61 this.rootNode = options.rootNode || this;
62
63 /**
64 * If true include Bcc in generated headers (if available)
65 */
66 this.keepBcc = !!options.keepBcc;
67
68 /**
69 * If filename is specified but contentType is not (probably an attachment)
70 * detect the content type from filename extension
71 */
72 if (options.filename) {
73 /**
74 * Filename for this node. Useful with attachments
75 */
76 this.filename = options.filename;
77 if (!contentType) {
78 contentType = mimeFuncs.detectMimeType(this.filename.split('.').pop());
79 }
80 }
81
82 /**
83 * Indicates which encoding should be used for header strings: "Q" or "B"
84 */
85 this.textEncoding = (options.textEncoding || '').toString().trim().charAt(0).toUpperCase();
86
87 /**
88 * Immediate parent for this node (or undefined if not set)
89 */
90 this.parentNode = options.parentNode;
91
92 /**
93 * Hostname for default message-id values
94 */
95 this.hostname = options.hostname;
96
97 /**
98 * If set to 'win' then uses \r\n, if 'linux' then \n. If not set (or `raw` is used) then newlines are kept as is.
99 */
100 this.newline = options.newline;
101
102 /**
103 * An array for possible child nodes
104 */
105 this.childNodes = [];
106
107 /**
108 * Used for generating unique boundaries (prepended to the shared base)
109 */
110 this._nodeId = ++this.rootNode.nodeCounter;
111
112 /**
113 * A list of header values for this node in the form of [{key:'', value:''}]
114 */
115 this._headers = [];
116
117 /**
118 * True if the content only uses ASCII printable characters
119 * @type {Boolean}
120 */
121 this._isPlainText = false;
122
123 /**
124 * True if the content is plain text but has longer lines than allowed
125 * @type {Boolean}
126 */
127 this._hasLongLines = false;
128
129 /**
130 * If set, use instead this value for envelopes instead of generating one
131 * @type {Boolean}
132 */
133 this._envelope = false;
134
135 /**
136 * If set then use this value as the stream content instead of building it
137 * @type {String|Buffer|Stream}
138 */
139 this._raw = false;
140
141 /**
142 * Additional transform streams that the message will be piped before
143 * exposing by createReadStream
144 * @type {Array}
145 */
146 this._transforms = [];
147
148 /**
149 * Additional process functions that the message will be piped through before
150 * exposing by createReadStream. These functions are run after transforms
151 * @type {Array}
152 */
153 this._processFuncs = [];
154
155 /**
156 * If content type is set (or derived from the filename) add it to headers
157 */
158 if (contentType) {
159 this.setHeader('Content-Type', contentType);
160 }
161 }
162
163 /////// PUBLIC METHODS
164
165 /**
166 * Creates and appends a child node.Arguments provided are passed to MimeNode constructor
167 *
168 * @param {String} [contentType] Optional content type
169 * @param {Object} [options] Optional options object
170 * @return {Object} Created node object
171 */
172 createChild(contentType, options) {
173 if (!options && typeof contentType === 'object') {
174 options = contentType;
175 contentType = undefined;
176 }
177 let node = new MimeNode(contentType, options);
178 this.appendChild(node);
179 return node;
180 }
181
182 /**
183 * Appends an existing node to the mime tree. Removes the node from an existing
184 * tree if needed
185 *
186 * @param {Object} childNode node to be appended
187 * @return {Object} Appended node object
188 */
189 appendChild(childNode) {
190 if (childNode.rootNode !== this.rootNode) {
191 childNode.rootNode = this.rootNode;
192 childNode._nodeId = ++this.rootNode.nodeCounter;
193 }
194
195 childNode.parentNode = this;
196
197 this.childNodes.push(childNode);
198 return childNode;
199 }
200
201 /**
202 * Replaces current node with another node
203 *
204 * @param {Object} node Replacement node
205 * @return {Object} Replacement node
206 */
207 replace(node) {
208 if (node === this) {
209 return this;
210 }
211
212 this.parentNode.childNodes.forEach((childNode, i) => {
213 if (childNode === this) {
214 node.rootNode = this.rootNode;
215 node.parentNode = this.parentNode;
216 node._nodeId = this._nodeId;
217
218 this.rootNode = this;
219 this.parentNode = undefined;
220
221 node.parentNode.childNodes[i] = node;
222 }
223 });
224
225 return node;
226 }
227
228 /**
229 * Removes current node from the mime tree
230 *
231 * @return {Object} removed node
232 */
233 remove() {
234 if (!this.parentNode) {
235 return this;
236 }
237
238 for (let i = this.parentNode.childNodes.length - 1; i >= 0; i--) {
239 if (this.parentNode.childNodes[i] === this) {
240 this.parentNode.childNodes.splice(i, 1);
241 this.parentNode = undefined;
242 this.rootNode = this;
243 return this;
244 }
245 }
246 }
247
248 /**
249 * Sets a header value. If the value for selected key exists, it is overwritten.
250 * You can set multiple values as well by using [{key:'', value:''}] or
251 * {key: 'value'} as the first argument.
252 *
253 * @param {String|Array|Object} key Header key or a list of key value pairs
254 * @param {String} value Header value
255 * @return {Object} current node
256 */
257 setHeader(key, value) {
258 let added = false,
259 headerValue;
260
261 // Allow setting multiple headers at once
262 if (!value && key && typeof key === 'object') {
263 // allow {key:'content-type', value: 'text/plain'}
264 if (key.key && 'value' in key) {
265 this.setHeader(key.key, key.value);
266 } else if (Array.isArray(key)) {
267 // allow [{key:'content-type', value: 'text/plain'}]
268 key.forEach(i => {
269 this.setHeader(i.key, i.value);
270 });
271 } else {
272 // allow {'content-type': 'text/plain'}
273 Object.keys(key).forEach(i => {
274 this.setHeader(i, key[i]);
275 });
276 }
277 return this;
278 }
279
280 key = this._normalizeHeaderKey(key);
281
282 headerValue = {
283 key,
284 value
285 };
286
287 // Check if the value exists and overwrite
288 for (let i = 0, len = this._headers.length; i < len; i++) {
289 if (this._headers[i].key === key) {
290 if (!added) {
291 // replace the first match
292 this._headers[i] = headerValue;
293 added = true;
294 } else {
295 // remove following matches
296 this._headers.splice(i, 1);
297 i--;
298 len--;
299 }
300 }
301 }
302
303 // match not found, append the value
304 if (!added) {
305 this._headers.push(headerValue);
306 }
307
308 return this;
309 }
310
311 /**
312 * Adds a header value. If the value for selected key exists, the value is appended
313 * as a new field and old one is not touched.
314 * You can set multiple values as well by using [{key:'', value:''}] or
315 * {key: 'value'} as the first argument.
316 *
317 * @param {String|Array|Object} key Header key or a list of key value pairs
318 * @param {String} value Header value
319 * @return {Object} current node
320 */
321 addHeader(key, value) {
322 // Allow setting multiple headers at once
323 if (!value && key && typeof key === 'object') {
324 // allow {key:'content-type', value: 'text/plain'}
325 if (key.key && key.value) {
326 this.addHeader(key.key, key.value);
327 } else if (Array.isArray(key)) {
328 // allow [{key:'content-type', value: 'text/plain'}]
329 key.forEach(i => {
330 this.addHeader(i.key, i.value);
331 });
332 } else {
333 // allow {'content-type': 'text/plain'}
334 Object.keys(key).forEach(i => {
335 this.addHeader(i, key[i]);
336 });
337 }
338 return this;
339 } else if (Array.isArray(value)) {
340 value.forEach(val => {
341 this.addHeader(key, val);
342 });
343 return this;
344 }
345
346 this._headers.push({
347 key: this._normalizeHeaderKey(key),
348 value
349 });
350
351 return this;
352 }
353
354 /**
355 * Retrieves the first mathcing value of a selected key
356 *
357 * @param {String} key Key to search for
358 * @retun {String} Value for the key
359 */
360 getHeader(key) {
361 key = this._normalizeHeaderKey(key);
362 for (let i = 0, len = this._headers.length; i < len; i++) {
363 if (this._headers[i].key === key) {
364 return this._headers[i].value;
365 }
366 }
367 }
368
369 /**
370 * Sets body content for current node. If the value is a string, charset is added automatically
371 * to Content-Type (if it is text/*). If the value is a Buffer, you need to specify
372 * the charset yourself
373 *
374 * @param (String|Buffer) content Body content
375 * @return {Object} current node
376 */
377 setContent(content) {
378 this.content = content;
379 if (typeof this.content.pipe === 'function') {
380 // pre-stream handler. might be triggered if a stream is set as content
381 // and 'error' fires before anything is done with this stream
382 this._contentErrorHandler = err => {
383 this.content.removeListener('error', this._contentErrorHandler);
384 this.content = err;
385 };
386 this.content.once('error', this._contentErrorHandler);
387 } else if (typeof this.content === 'string') {
388 this._isPlainText = mimeFuncs.isPlainText(this.content);
389 if (this._isPlainText && mimeFuncs.hasLongerLines(this.content, 76)) {
390 // If there are lines longer than 76 symbols/bytes do not use 7bit
391 this._hasLongLines = true;
392 }
393 }
394 return this;
395 }
396
397 build(callback) {
398 let promise;
399
400 if (!callback) {
401 promise = new Promise((resolve, reject) => {
402 callback = shared.callbackPromise(resolve, reject);
403 });
404 }
405
406 let stream = this.createReadStream();
407 let buf = [];
408 let buflen = 0;
409 let returned = false;
410
411 stream.on('readable', () => {
412 let chunk;
413
414 while ((chunk = stream.read()) !== null) {
415 buf.push(chunk);
416 buflen += chunk.length;
417 }
418 });
419
420 stream.once('error', err => {
421 if (returned) {
422 return;
423 }
424 returned = true;
425
426 return callback(err);
427 });
428
429 stream.once('end', chunk => {
430 if (returned) {
431 return;
432 }
433 returned = true;
434
435 if (chunk && chunk.length) {
436 buf.push(chunk);
437 buflen += chunk.length;
438 }
439 return callback(null, Buffer.concat(buf, buflen));
440 });
441
442 return promise;
443 }
444
445 getTransferEncoding() {
446 let transferEncoding = false;
447 let contentType = (this.getHeader('Content-Type') || '').toString().toLowerCase().trim();
448
449 if (this.content) {
450 transferEncoding = (this.getHeader('Content-Transfer-Encoding') || '').toString().toLowerCase().trim();
451 if (!transferEncoding || !['base64', 'quoted-printable'].includes(transferEncoding)) {
452 if (/^text\//i.test(contentType)) {
453 // If there are no special symbols, no need to modify the text
454 if (this._isPlainText && !this._hasLongLines) {
455 transferEncoding = '7bit';
456 } else if (typeof this.content === 'string' || this.content instanceof Buffer) {
457 // detect preferred encoding for string value
458 transferEncoding = this._getTextEncoding(this.content) === 'Q' ? 'quoted-printable' : 'base64';
459 } else {
460 // we can not check content for a stream, so either use preferred encoding or fallback to QP
461 transferEncoding = this.textEncoding === 'B' ? 'base64' : 'quoted-printable';
462 }
463 } else if (!/^(multipart|message)\//i.test(contentType)) {
464 transferEncoding = transferEncoding || 'base64';
465 }
466 }
467 }
468 return transferEncoding;
469 }
470
471 /**
472 * Builds the header block for the mime node. Append \r\n\r\n before writing the content
473 *
474 * @returns {String} Headers
475 */
476 buildHeaders() {
477 let transferEncoding = this.getTransferEncoding();
478 let headers = [];
479
480 if (transferEncoding) {
481 this.setHeader('Content-Transfer-Encoding', transferEncoding);
482 }
483
484 if (this.filename && !this.getHeader('Content-Disposition')) {
485 this.setHeader('Content-Disposition', 'attachment');
486 }
487
488 // Ensure mandatory header fields
489 if (this.rootNode === this) {
490 if (!this.getHeader('Date')) {
491 this.setHeader('Date', this.date.toUTCString().replace(/GMT/, '+0000'));
492 }
493
494 // ensure that Message-Id is present
495 this.messageId();
496
497 if (!this.getHeader('MIME-Version')) {
498 this.setHeader('MIME-Version', '1.0');
499 }
500
501 // Ensure that Content-Type is the last header for the root node
502 for (let i = this._headers.length - 2; i >= 0; i--) {
503 let header = this._headers[i];
504 if (header.key === 'Content-Type') {
505 this._headers.splice(i, 1);
506 this._headers.push(header);
507 }
508 }
509 }
510
511 this._headers.forEach(header => {
512 let key = header.key;
513 let value = header.value;
514 let structured;
515 let param;
516 let options = {};
517 let formattedHeaders = ['From', 'Sender', 'To', 'Cc', 'Bcc', 'Reply-To', 'Date', 'References'];
518
519 if (value && typeof value === 'object' && !formattedHeaders.includes(key)) {
520 Object.keys(value).forEach(key => {
521 if (key !== 'value') {
522 options[key] = value[key];
523 }
524 });
525 value = (value.value || '').toString();
526 if (!value.trim()) {
527 return;
528 }
529 }
530
531 if (options.prepared) {
532 // header value is
533 if (options.foldLines) {
534 headers.push(mimeFuncs.foldLines(key + ': ' + value));
535 } else {
536 headers.push(key + ': ' + value);
537 }
538 return;
539 }
540
541 switch (header.key) {
542 case 'Content-Disposition':
543 structured = mimeFuncs.parseHeaderValue(value);
544 if (this.filename) {
545 structured.params.filename = this.filename;
546 }
547 value = mimeFuncs.buildHeaderValue(structured);
548 break;
549
550 case 'Content-Type':
551 structured = mimeFuncs.parseHeaderValue(value);
552
553 this._handleContentType(structured);
554
555 if (structured.value.match(/^text\/plain\b/) && typeof this.content === 'string' && /[\u0080-\uFFFF]/.test(this.content)) {
556 structured.params.charset = 'utf-8';
557 }
558
559 value = mimeFuncs.buildHeaderValue(structured);
560
561 if (this.filename) {
562 // add support for non-compliant clients like QQ webmail
563 // we can't build the value with buildHeaderValue as the value is non standard and
564 // would be converted to parameter continuation encoding that we do not want
565 param = this._encodeWords(this.filename);
566
567 if (param !== this.filename || /[\s'"\\;:/=(),<>@[\]?]|^-/.test(param)) {
568 // include value in quotes if needed
569 param = '"' + param + '"';
570 }
571 value += '; name=' + param;
572 }
573 break;
574
575 case 'Bcc':
576 if (!this.keepBcc) {
577 // skip BCC values
578 return;
579 }
580 break;
581 }
582
583 value = this._encodeHeaderValue(key, value);
584
585 // skip empty lines
586 if (!(value || '').toString().trim()) {
587 return;
588 }
589
590 if (typeof this.normalizeHeaderKey === 'function') {
591 let normalized = this.normalizeHeaderKey(key, value);
592 if (normalized && typeof normalized === 'string' && normalized.length) {
593 key = normalized;
594 }
595 }
596
597 headers.push(mimeFuncs.foldLines(key + ': ' + value, 76));
598 });
599
600 return headers.join('\r\n');
601 }
602
603 /**
604 * Streams the rfc2822 message from the current node. If this is a root node,
605 * mandatory header fields are set if missing (Date, Message-Id, MIME-Version)
606 *
607 * @return {String} Compiled message
608 */
609 createReadStream(options) {
610 options = options || {};
611
612 let stream = new PassThrough(options);
613 let outputStream = stream;
614 let transform;
615
616 this.stream(stream, options, err => {
617 if (err) {
618 outputStream.emit('error', err);
619 return;
620 }
621 stream.end();
622 });
623
624 for (let i = 0, len = this._transforms.length; i < len; i++) {
625 transform = typeof this._transforms[i] === 'function' ? this._transforms[i]() : this._transforms[i];
626 outputStream.once('error', err => {
627 transform.emit('error', err);
628 });
629 outputStream = outputStream.pipe(transform);
630 }
631
632 // ensure terminating newline after possible user transforms
633 transform = new LastNewline();
634 outputStream.once('error', err => {
635 transform.emit('error', err);
636 });
637 outputStream = outputStream.pipe(transform);
638
639 // dkim and stuff
640 for (let i = 0, len = this._processFuncs.length; i < len; i++) {
641 transform = this._processFuncs[i];
642 outputStream = transform(outputStream);
643 }
644
645 if (this.newline) {
646 const winbreak = ['win', 'windows', 'dos', '\r\n'].includes(this.newline.toString().toLowerCase());
647 const newlineTransform = winbreak ? new LeWindows() : new LeUnix();
648
649 const stream = outputStream.pipe(newlineTransform);
650 outputStream.on('error', err => stream.emit('error', err));
651 return stream;
652 }
653
654 return outputStream;
655 }
656
657 /**
658 * Appends a transform stream object to the transforms list. Final output
659 * is passed through this stream before exposing
660 *
661 * @param {Object} transform Read-Write stream
662 */
663 transform(transform) {
664 this._transforms.push(transform);
665 }
666
667 /**
668 * Appends a post process function. The functon is run after transforms and
669 * uses the following syntax
670 *
671 * processFunc(input) -> outputStream
672 *
673 * @param {Object} processFunc Read-Write stream
674 */
675 processFunc(processFunc) {
676 this._processFuncs.push(processFunc);
677 }
678
679 stream(outputStream, options, done) {
680 let transferEncoding = this.getTransferEncoding();
681 let contentStream;
682 let localStream;
683
684 // protect actual callback against multiple triggering
685 let returned = false;
686 let callback = err => {
687 if (returned) {
688 return;
689 }
690 returned = true;
691 done(err);
692 };
693
694 // for multipart nodes, push child nodes
695 // for content nodes end the stream
696 let finalize = () => {
697 let childId = 0;
698 let processChildNode = () => {
699 if (childId >= this.childNodes.length) {
700 outputStream.write('\r\n--' + this.boundary + '--\r\n');
701 return callback();
702 }
703 let child = this.childNodes[childId++];
704 outputStream.write((childId > 1 ? '\r\n' : '') + '--' + this.boundary + '\r\n');
705 child.stream(outputStream, options, err => {
706 if (err) {
707 return callback(err);
708 }
709 setImmediate(processChildNode);
710 });
711 };
712
713 if (this.multipart) {
714 setImmediate(processChildNode);
715 } else {
716 return callback();
717 }
718 };
719
720 // pushes node content
721 let sendContent = () => {
722 if (this.content) {
723 if (Object.prototype.toString.call(this.content) === '[object Error]') {
724 // content is already errored
725 return callback(this.content);
726 }
727
728 if (typeof this.content.pipe === 'function') {
729 this.content.removeListener('error', this._contentErrorHandler);
730 this._contentErrorHandler = err => callback(err);
731 this.content.once('error', this._contentErrorHandler);
732 }
733
734 let createStream = () => {
735 if (['quoted-printable', 'base64'].includes(transferEncoding)) {
736 contentStream = new (transferEncoding === 'base64' ? base64 : qp).Encoder(options);
737
738 contentStream.pipe(outputStream, {
739 end: false
740 });
741 contentStream.once('end', finalize);
742 contentStream.once('error', err => callback(err));
743
744 localStream = this._getStream(this.content);
745 localStream.pipe(contentStream);
746 } else {
747 // anything that is not QP or Base54 passes as-is
748 localStream = this._getStream(this.content);
749 localStream.pipe(outputStream, {
750 end: false
751 });
752 localStream.once('end', finalize);
753 }
754
755 localStream.once('error', err => callback(err));
756 };
757
758 if (this.content._resolve) {
759 let chunks = [];
760 let chunklen = 0;
761 let returned = false;
762 let sourceStream = this._getStream(this.content);
763 sourceStream.on('error', err => {
764 if (returned) {
765 return;
766 }
767 returned = true;
768 callback(err);
769 });
770 sourceStream.on('readable', () => {
771 let chunk;
772 while ((chunk = sourceStream.read()) !== null) {
773 chunks.push(chunk);
774 chunklen += chunk.length;
775 }
776 });
777 sourceStream.on('end', () => {
778 if (returned) {
779 return;
780 }
781 returned = true;
782 this.content._resolve = false;
783 this.content._resolvedValue = Buffer.concat(chunks, chunklen);
784 setImmediate(createStream);
785 });
786 } else {
787 setImmediate(createStream);
788 }
789 return;
790 } else {
791 return setImmediate(finalize);
792 }
793 };
794
795 if (this._raw) {
796 setImmediate(() => {
797 if (Object.prototype.toString.call(this._raw) === '[object Error]') {
798 // content is already errored
799 return callback(this._raw);
800 }
801
802 // remove default error handler (if set)
803 if (typeof this._raw.pipe === 'function') {
804 this._raw.removeListener('error', this._contentErrorHandler);
805 }
806
807 let raw = this._getStream(this._raw);
808 raw.pipe(outputStream, {
809 end: false
810 });
811 raw.on('error', err => outputStream.emit('error', err));
812 raw.on('end', finalize);
813 });
814 } else {
815 outputStream.write(this.buildHeaders() + '\r\n\r\n');
816 setImmediate(sendContent);
817 }
818 }
819
820 /**
821 * Sets envelope to be used instead of the generated one
822 *
823 * @return {Object} SMTP envelope in the form of {from: 'from@example.com', to: ['to@example.com']}
824 */
825 setEnvelope(envelope) {
826 let list;
827
828 this._envelope = {
829 from: false,
830 to: []
831 };
832
833 if (envelope.from) {
834 list = [];
835 this._convertAddresses(this._parseAddresses(envelope.from), list);
836 list = list.filter(address => address && address.address);
837 if (list.length && list[0]) {
838 this._envelope.from = list[0].address;
839 }
840 }
841 ['to', 'cc', 'bcc'].forEach(key => {
842 if (envelope[key]) {
843 this._convertAddresses(this._parseAddresses(envelope[key]), this._envelope.to);
844 }
845 });
846
847 this._envelope.to = this._envelope.to.map(to => to.address).filter(address => address);
848
849 let standardFields = ['to', 'cc', 'bcc', 'from'];
850 Object.keys(envelope).forEach(key => {
851 if (!standardFields.includes(key)) {
852 this._envelope[key] = envelope[key];
853 }
854 });
855
856 return this;
857 }
858
859 /**
860 * Generates and returns an object with parsed address fields
861 *
862 * @return {Object} Address object
863 */
864 getAddresses() {
865 let addresses = {};
866
867 this._headers.forEach(header => {
868 let key = header.key.toLowerCase();
869 if (['from', 'sender', 'reply-to', 'to', 'cc', 'bcc'].includes(key)) {
870 if (!Array.isArray(addresses[key])) {
871 addresses[key] = [];
872 }
873
874 this._convertAddresses(this._parseAddresses(header.value), addresses[key]);
875 }
876 });
877
878 return addresses;
879 }
880
881 /**
882 * Generates and returns SMTP envelope with the sender address and a list of recipients addresses
883 *
884 * @return {Object} SMTP envelope in the form of {from: 'from@example.com', to: ['to@example.com']}
885 */
886 getEnvelope() {
887 if (this._envelope) {
888 return this._envelope;
889 }
890
891 let envelope = {
892 from: false,
893 to: []
894 };
895 this._headers.forEach(header => {
896 let list = [];
897 if (header.key === 'From' || (!envelope.from && ['Reply-To', 'Sender'].includes(header.key))) {
898 this._convertAddresses(this._parseAddresses(header.value), list);
899 if (list.length && list[0]) {
900 envelope.from = list[0].address;
901 }
902 } else if (['To', 'Cc', 'Bcc'].includes(header.key)) {
903 this._convertAddresses(this._parseAddresses(header.value), envelope.to);
904 }
905 });
906
907 envelope.to = envelope.to.map(to => to.address);
908
909 return envelope;
910 }
911
912 /**
913 * Returns Message-Id value. If it does not exist, then creates one
914 *
915 * @return {String} Message-Id value
916 */
917 messageId() {
918 let messageId = this.getHeader('Message-ID');
919 // You really should define your own Message-Id field!
920 if (!messageId) {
921 messageId = this._generateMessageId();
922 this.setHeader('Message-ID', messageId);
923 }
924 return messageId;
925 }
926
927 /**
928 * Sets pregenerated content that will be used as the output of this node
929 *
930 * @param {String|Buffer|Stream} Raw MIME contents
931 */
932 setRaw(raw) {
933 this._raw = raw;
934
935 if (this._raw && typeof this._raw.pipe === 'function') {
936 // pre-stream handler. might be triggered if a stream is set as content
937 // and 'error' fires before anything is done with this stream
938 this._contentErrorHandler = err => {
939 this._raw.removeListener('error', this._contentErrorHandler);
940 this._raw = err;
941 };
942 this._raw.once('error', this._contentErrorHandler);
943 }
944
945 return this;
946 }
947
948 /////// PRIVATE METHODS
949
950 /**
951 * Detects and returns handle to a stream related with the content.
952 *
953 * @param {Mixed} content Node content
954 * @returns {Object} Stream object
955 */
956 _getStream(content) {
957 let contentStream;
958
959 if (content._resolvedValue) {
960 // pass string or buffer content as a stream
961 contentStream = new PassThrough();
962
963 setImmediate(() => {
964 try {
965 contentStream.end(content._resolvedValue);
966 } catch (err) {
967 contentStream.emit('error', err);
968 }
969 });
970
971 return contentStream;
972 } else if (typeof content.pipe === 'function') {
973 // assume as stream
974 return content;
975 } else if (content && typeof content.path === 'string' && !content.href) {
976 if (this.disableFileAccess) {
977 contentStream = new PassThrough();
978 setImmediate(() => contentStream.emit('error', new Error('File access rejected for ' + content.path)));
979 return contentStream;
980 }
981 // read file
982 return fs.createReadStream(content.path);
983 } else if (content && typeof content.href === 'string') {
984 if (this.disableUrlAccess) {
985 contentStream = new PassThrough();
986 setImmediate(() => contentStream.emit('error', new Error('Url access rejected for ' + content.href)));
987 return contentStream;
988 }
989 // fetch URL
990 return nmfetch(content.href, { headers: content.httpHeaders });
991 } else {
992 // pass string or buffer content as a stream
993 contentStream = new PassThrough();
994
995 setImmediate(() => {
996 try {
997 contentStream.end(content || '');
998 } catch (err) {
999 contentStream.emit('error', err);
1000 }
1001 });
1002 return contentStream;
1003 }
1004 }
1005
1006 /**
1007 * Parses addresses. Takes in a single address or an array or an
1008 * array of address arrays (eg. To: [[first group], [second group],...])
1009 *
1010 * @param {Mixed} addresses Addresses to be parsed
1011 * @return {Array} An array of address objects
1012 */
1013 _parseAddresses(addresses) {
1014 return [].concat.apply(
1015 [],
1016 [].concat(addresses).map(address => {
1017 // eslint-disable-line prefer-spread
1018 if (address && address.address) {
1019 address.address = this._normalizeAddress(address.address);
1020 address.name = address.name || '';
1021 return [address];
1022 }
1023 return addressparser(address);
1024 })
1025 );
1026 }
1027
1028 /**
1029 * Normalizes a header key, uses Camel-Case form, except for uppercase MIME-
1030 *
1031 * @param {String} key Key to be normalized
1032 * @return {String} key in Camel-Case form
1033 */
1034 _normalizeHeaderKey(key) {
1035 key = (key || '')
1036 .toString()
1037 // no newlines in keys
1038 .replace(/\r?\n|\r/g, ' ')
1039 .trim()
1040 .toLowerCase()
1041 // use uppercase words, except MIME
1042 .replace(/^X-SMTPAPI$|^(MIME|DKIM|ARC|BIMI)\b|^[a-z]|-(SPF|FBL|ID|MD5)$|-[a-z]/gi, c => c.toUpperCase())
1043 // special case
1044 .replace(/^Content-Features$/i, 'Content-features');
1045
1046 return key;
1047 }
1048
1049 /**
1050 * Checks if the content type is multipart and defines boundary if needed.
1051 * Doesn't return anything, modifies object argument instead.
1052 *
1053 * @param {Object} structured Parsed header value for 'Content-Type' key
1054 */
1055 _handleContentType(structured) {
1056 this.contentType = structured.value.trim().toLowerCase();
1057
1058 this.multipart = /^multipart\//i.test(this.contentType) ? this.contentType.substr(this.contentType.indexOf('/') + 1) : false;
1059
1060 if (this.multipart) {
1061 this.boundary = structured.params.boundary = structured.params.boundary || this.boundary || this._generateBoundary();
1062 } else {
1063 this.boundary = false;
1064 }
1065 }
1066
1067 /**
1068 * Generates a multipart boundary value
1069 *
1070 * @return {String} boundary value
1071 */
1072 _generateBoundary() {
1073 return this.rootNode.boundaryPrefix + '-' + this.rootNode.baseBoundary + '-Part_' + this._nodeId;
1074 }
1075
1076 /**
1077 * Encodes a header value for use in the generated rfc2822 email.
1078 *
1079 * @param {String} key Header key
1080 * @param {String} value Header value
1081 */
1082 _encodeHeaderValue(key, value) {
1083 key = this._normalizeHeaderKey(key);
1084
1085 switch (key) {
1086 // Structured headers
1087 case 'From':
1088 case 'Sender':
1089 case 'To':
1090 case 'Cc':
1091 case 'Bcc':
1092 case 'Reply-To':
1093 return this._convertAddresses(this._parseAddresses(value));
1094
1095 // values enclosed in <>
1096 case 'Message-ID':
1097 case 'In-Reply-To':
1098 case 'Content-Id':
1099 value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
1100
1101 if (value.charAt(0) !== '<') {
1102 value = '<' + value;
1103 }
1104
1105 if (value.charAt(value.length - 1) !== '>') {
1106 value = value + '>';
1107 }
1108 return value;
1109
1110 // space separated list of values enclosed in <>
1111 case 'References':
1112 value = [].concat
1113 .apply(
1114 [],
1115 [].concat(value || '').map(elm => {
1116 // eslint-disable-line prefer-spread
1117 elm = (elm || '')
1118 .toString()
1119 .replace(/\r?\n|\r/g, ' ')
1120 .trim();
1121 return elm.replace(/<[^>]*>/g, str => str.replace(/\s/g, '')).split(/\s+/);
1122 })
1123 )
1124 .map(elm => {
1125 if (elm.charAt(0) !== '<') {
1126 elm = '<' + elm;
1127 }
1128 if (elm.charAt(elm.length - 1) !== '>') {
1129 elm = elm + '>';
1130 }
1131 return elm;
1132 });
1133
1134 return value.join(' ').trim();
1135
1136 case 'Date':
1137 if (Object.prototype.toString.call(value) === '[object Date]') {
1138 return value.toUTCString().replace(/GMT/, '+0000');
1139 }
1140
1141 value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
1142 return this._encodeWords(value);
1143
1144 case 'Content-Type':
1145 case 'Content-Disposition':
1146 // if it includes a filename then it is already encoded
1147 return (value || '').toString().replace(/\r?\n|\r/g, ' ');
1148
1149 default:
1150 value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
1151 // encodeWords only encodes if needed, otherwise the original string is returned
1152 return this._encodeWords(value);
1153 }
1154 }
1155
1156 /**
1157 * Rebuilds address object using punycode and other adjustments
1158 *
1159 * @param {Array} addresses An array of address objects
1160 * @param {Array} [uniqueList] An array to be populated with addresses
1161 * @return {String} address string
1162 */
1163 _convertAddresses(addresses, uniqueList) {
1164 let values = [];
1165
1166 uniqueList = uniqueList || [];
1167
1168 [].concat(addresses || []).forEach(address => {
1169 if (address.address) {
1170 address.address = this._normalizeAddress(address.address);
1171
1172 if (!address.name) {
1173 values.push(address.address.indexOf(' ') >= 0 ? `<${address.address}>` : `${address.address}`);
1174 } else if (address.name) {
1175 values.push(`${this._encodeAddressName(address.name)} <${address.address}>`);
1176 }
1177
1178 if (address.address) {
1179 if (!uniqueList.filter(a => a.address === address.address).length) {
1180 uniqueList.push(address);
1181 }
1182 }
1183 } else if (address.group) {
1184 let groupListAddresses = (address.group.length ? this._convertAddresses(address.group, uniqueList) : '').trim();
1185 values.push(`${this._encodeAddressName(address.name)}:${groupListAddresses};`);
1186 }
1187 });
1188
1189 return values.join(', ');
1190 }
1191
1192 /**
1193 * Normalizes an email address
1194 *
1195 * @param {Array} address An array of address objects
1196 * @return {String} address string
1197 */
1198 _normalizeAddress(address) {
1199 address = (address || '')
1200 .toString()
1201 .replace(/[\x00-\x1F<>]+/g, ' ') // remove unallowed characters
1202 .trim();
1203
1204 let lastAt = address.lastIndexOf('@');
1205 if (lastAt < 0) {
1206 // Bare username
1207 return address;
1208 }
1209
1210 let user = address.substr(0, lastAt);
1211 let domain = address.substr(lastAt + 1);
1212
1213 // Usernames are not touched and are kept as is even if these include unicode
1214 // Domains are punycoded by default
1215 // 'jõgeva.ee' will be converted to 'xn--jgeva-dua.ee'
1216 // non-unicode domains are left as is
1217
1218 let encodedDomain;
1219
1220 try {
1221 encodedDomain = punycode.toASCII(domain.toLowerCase());
1222 } catch (err) {
1223 // keep as is?
1224 }
1225
1226 if (user.indexOf(' ') >= 0) {
1227 if (user.charAt(0) !== '"') {
1228 user = '"' + user;
1229 }
1230 if (user.substr(-1) !== '"') {
1231 user = user + '"';
1232 }
1233 }
1234
1235 return `${user}@${encodedDomain}`;
1236 }
1237
1238 /**
1239 * If needed, mime encodes the name part
1240 *
1241 * @param {String} name Name part of an address
1242 * @returns {String} Mime word encoded string if needed
1243 */
1244 _encodeAddressName(name) {
1245 if (!/^[\w ]*$/.test(name)) {
1246 if (/^[\x20-\x7e]*$/.test(name)) {
1247 return '"' + name.replace(/([\\"])/g, '\\$1') + '"';
1248 } else {
1249 return mimeFuncs.encodeWord(name, this._getTextEncoding(name), 52);
1250 }
1251 }
1252 return name;
1253 }
1254
1255 /**
1256 * If needed, mime encodes the name part
1257 *
1258 * @param {String} name Name part of an address
1259 * @returns {String} Mime word encoded string if needed
1260 */
1261 _encodeWords(value) {
1262 // set encodeAll parameter to true even though it is against the recommendation of RFC2047,
1263 // by default only words that include non-ascii should be converted into encoded words
1264 // but some clients (eg. Zimbra) do not handle it properly and remove surrounding whitespace
1265 return mimeFuncs.encodeWords(value, this._getTextEncoding(value), 52, true);
1266 }
1267
1268 /**
1269 * Detects best mime encoding for a text value
1270 *
1271 * @param {String} value Value to check for
1272 * @return {String} either 'Q' or 'B'
1273 */
1274 _getTextEncoding(value) {
1275 value = (value || '').toString();
1276
1277 let encoding = this.textEncoding;
1278 let latinLen;
1279 let nonLatinLen;
1280
1281 if (!encoding) {
1282 // count latin alphabet symbols and 8-bit range symbols + control symbols
1283 // if there are more latin characters, then use quoted-printable
1284 // encoding, otherwise use base64
1285 nonLatinLen = (value.match(/[\x00-\x08\x0B\x0C\x0E-\x1F\u0080-\uFFFF]/g) || []).length; // eslint-disable-line no-control-regex
1286 latinLen = (value.match(/[a-z]/gi) || []).length;
1287 // if there are more latin symbols than binary/unicode, then prefer Q, otherwise B
1288 encoding = nonLatinLen < latinLen ? 'Q' : 'B';
1289 }
1290 return encoding;
1291 }
1292
1293 /**
1294 * Generates a message id
1295 *
1296 * @return {String} Random Message-ID value
1297 */
1298 _generateMessageId() {
1299 return (
1300 '<' +
1301 [2, 2, 2, 6].reduce(
1302 // crux to generate UUID-like random strings
1303 (prev, len) => prev + '-' + crypto.randomBytes(len).toString('hex'),
1304 crypto.randomBytes(4).toString('hex')
1305 ) +
1306 '@' +
1307 // try to use the domain of the FROM address or fallback to server hostname
1308 (this.getEnvelope().from || this.hostname || 'localhost').split('@').pop() +
1309 '>'
1310 );
1311 }
1312}
1313
1314module.exports = MimeNode;
Note: See TracBrowser for help on using the repository browser.