| 1 | /* eslint no-undefined: 0, prefer-spread: 0, no-control-regex: 0 */
|
|---|
| 2 |
|
|---|
| 3 | 'use strict';
|
|---|
| 4 |
|
|---|
| 5 | const crypto = require('crypto');
|
|---|
| 6 | const fs = require('fs');
|
|---|
| 7 | const punycode = require('../punycode');
|
|---|
| 8 | const PassThrough = require('stream').PassThrough;
|
|---|
| 9 | const shared = require('../shared');
|
|---|
| 10 |
|
|---|
| 11 | const mimeFuncs = require('../mime-funcs');
|
|---|
| 12 | const qp = require('../qp');
|
|---|
| 13 | const base64 = require('../base64');
|
|---|
| 14 | const addressparser = require('../addressparser');
|
|---|
| 15 | const nmfetch = require('../fetch');
|
|---|
| 16 | const LastNewline = require('./last-newline');
|
|---|
| 17 |
|
|---|
| 18 | const LeWindows = require('./le-windows');
|
|---|
| 19 | const 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 | */
|
|---|
| 36 | class 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 |
|
|---|
| 1314 | module.exports = MimeNode;
|
|---|