2020-05-24 20:40:01 +00:00
|
|
|
import fs from 'fs';
|
|
|
|
import { hostname } from 'os';
|
|
|
|
import { Stream } from 'stream';
|
2020-06-19 22:47:42 +00:00
|
|
|
import { TextEncoder, TextDecoder } from 'util';
|
|
|
|
import { createHmac } from 'crypto';
|
|
|
|
import { EventEmitter } from 'events';
|
|
|
|
import { Socket } from 'net';
|
|
|
|
import { connect, TLSSocket, createSecureContext } from 'tls';
|
2020-05-24 20:40:01 +00:00
|
|
|
|
2020-06-19 22:47:42 +00:00
|
|
|
/*
|
|
|
|
* Operator tokens and which tokens are expected to end the sequence
|
2020-05-24 20:40:01 +00:00
|
|
|
*/
|
2020-06-19 22:47:42 +00:00
|
|
|
const OPERATORS = new Map([
|
|
|
|
['"', '"'],
|
|
|
|
['(', ')'],
|
|
|
|
['<', '>'],
|
|
|
|
[',', ''],
|
|
|
|
// Groups are ended by semicolons
|
|
|
|
[':', ';'],
|
|
|
|
// Semicolons are not a legal delimiter per the RFC2822 grammar other
|
|
|
|
// than for terminating a group, but they are also not valid for any
|
|
|
|
// other use in this context. Given that some mail clients have
|
|
|
|
// historically allowed the semicolon as a delimiter equivalent to the
|
|
|
|
// comma in their UI, it makes sense to treat them the same as a comma
|
|
|
|
// when used outside of a group.
|
|
|
|
[';', ''],
|
|
|
|
]);
|
2020-05-24 20:40:01 +00:00
|
|
|
/**
|
2020-06-19 22:47:42 +00:00
|
|
|
* Tokenizes the original input string
|
2020-05-24 20:40:01 +00:00
|
|
|
*
|
2020-06-19 22:47:42 +00:00
|
|
|
* @param {string | string[] | undefined} address string(s) to tokenize
|
|
|
|
* @return {AddressToken[]} An array of operator|text tokens
|
|
|
|
*/
|
|
|
|
function tokenizeAddress(address = '') {
|
|
|
|
var _a, _b;
|
|
|
|
const tokens = [];
|
|
|
|
let token = undefined;
|
|
|
|
let operator = undefined;
|
|
|
|
for (const character of address.toString()) {
|
|
|
|
if (((_a = operator === null || operator === void 0 ? void 0 : operator.length) !== null && _a !== void 0 ? _a : 0) > 0 && character === operator) {
|
|
|
|
tokens.push({ type: 'operator', value: character });
|
|
|
|
token = undefined;
|
|
|
|
operator = undefined;
|
|
|
|
}
|
|
|
|
else if (((_b = operator === null || operator === void 0 ? void 0 : operator.length) !== null && _b !== void 0 ? _b : 0) === 0 && OPERATORS.has(character)) {
|
|
|
|
tokens.push({ type: 'operator', value: character });
|
|
|
|
token = undefined;
|
|
|
|
operator = OPERATORS.get(character);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
if (token == null) {
|
|
|
|
token = { type: 'text', value: character };
|
|
|
|
tokens.push(token);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
token.value += character;
|
|
|
|
}
|
|
|
|
}
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
2020-06-19 22:47:42 +00:00
|
|
|
return tokens
|
|
|
|
.map((x) => {
|
|
|
|
x.value = x.value.trim();
|
|
|
|
return x;
|
|
|
|
})
|
|
|
|
.filter((x) => x.value.length > 0);
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
/**
|
2020-06-19 22:47:42 +00:00
|
|
|
* Converts tokens for a single address into an address object
|
2020-05-24 20:40:01 +00:00
|
|
|
*
|
2020-06-19 22:47:42 +00:00
|
|
|
* @param {AddressToken[]} tokens Tokens object
|
|
|
|
* @return {AddressObject[]} addresses object array
|
|
|
|
*/
|
|
|
|
function convertAddressTokens(tokens) {
|
|
|
|
const addressObjects = [];
|
|
|
|
const groups = [];
|
|
|
|
let addresses = [];
|
|
|
|
let comments = [];
|
|
|
|
let texts = [];
|
|
|
|
let state = 'text';
|
|
|
|
let isGroup = false;
|
|
|
|
function handleToken(token) {
|
|
|
|
if (token.type === 'operator') {
|
|
|
|
switch (token.value) {
|
|
|
|
case '<':
|
|
|
|
state = 'address';
|
|
|
|
break;
|
|
|
|
case '(':
|
|
|
|
state = 'comment';
|
|
|
|
break;
|
|
|
|
case ':':
|
|
|
|
state = 'group';
|
|
|
|
isGroup = true;
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
state = 'text';
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
else if (token.value.length > 0) {
|
|
|
|
switch (state) {
|
|
|
|
case 'address':
|
|
|
|
addresses.push(token.value);
|
|
|
|
break;
|
|
|
|
case 'comment':
|
|
|
|
comments.push(token.value);
|
|
|
|
break;
|
|
|
|
case 'group':
|
|
|
|
groups.push(token.value);
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
texts.push(token.value);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
2020-06-19 22:47:42 +00:00
|
|
|
// Filter out <addresses>, (comments) and regular text
|
|
|
|
for (const token of tokens) {
|
|
|
|
handleToken(token);
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
2020-06-19 22:47:42 +00:00
|
|
|
// If there is no text but a comment, replace the two
|
|
|
|
if (texts.length === 0 && comments.length > 0) {
|
|
|
|
texts = [...comments];
|
|
|
|
comments = [];
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
2020-06-19 22:47:42 +00:00
|
|
|
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
|
|
|
|
if (isGroup) {
|
|
|
|
addressObjects.push({
|
|
|
|
name: texts.length === 0 ? undefined : texts.join(' '),
|
|
|
|
group: groups.length > 0 ? addressparser(groups.join(',')) : [],
|
|
|
|
});
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
2020-06-19 22:47:42 +00:00
|
|
|
else {
|
|
|
|
// If no address was found, try to detect one from regular text
|
|
|
|
if (addresses.length === 0 && texts.length > 0) {
|
|
|
|
for (let i = texts.length - 1; i >= 0; i--) {
|
|
|
|
if (texts[i].match(/^[^@\s]+@[^@\s]+$/)) {
|
|
|
|
addresses = texts.splice(i, 1);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// still no address
|
|
|
|
if (addresses.length === 0) {
|
|
|
|
for (let i = texts.length - 1; i >= 0; i--) {
|
|
|
|
texts[i] = texts[i]
|
|
|
|
.replace(/\s*\b[^@\s]+@[^@\s]+\b\s*/, (address) => {
|
|
|
|
if (addresses.length === 0) {
|
|
|
|
addresses = [address.trim()];
|
|
|
|
return ' ';
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
return address;
|
|
|
|
}
|
|
|
|
})
|
|
|
|
.trim();
|
|
|
|
if (addresses.length > 0) {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// If there's still is no text but a comment exixts, replace the two
|
|
|
|
if (texts.length === 0 && comments.length > 0) {
|
|
|
|
texts = [...comments];
|
|
|
|
comments = [];
|
|
|
|
}
|
|
|
|
// Keep only the first address occurence, push others to regular text
|
|
|
|
if (addresses.length > 1) {
|
|
|
|
texts = [...texts, ...addresses.splice(1)];
|
|
|
|
}
|
|
|
|
if (addresses.length === 0 && isGroup) {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
// Join values with spaces
|
|
|
|
let address = addresses.join(' ');
|
|
|
|
let name = texts.length === 0 ? address : texts.join(' ');
|
|
|
|
if (address === name) {
|
|
|
|
if (address.match(/@/)) {
|
|
|
|
name = '';
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
address = '';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
addressObjects.push({ address, name });
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
}
|
2020-06-19 22:47:42 +00:00
|
|
|
return addressObjects;
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
/**
|
2020-06-19 22:47:42 +00:00
|
|
|
* Parses structured e-mail addresses from an address field
|
2020-05-24 20:40:01 +00:00
|
|
|
*
|
2020-06-19 22:47:42 +00:00
|
|
|
* Example:
|
2020-05-24 20:40:01 +00:00
|
|
|
*
|
2020-06-19 22:47:42 +00:00
|
|
|
* "Name <address@domain>"
|
2020-05-24 20:40:01 +00:00
|
|
|
*
|
2020-06-19 22:47:42 +00:00
|
|
|
* will be converted to
|
|
|
|
*
|
|
|
|
* [{name: "Name", address: "address@domain"}]
|
2020-05-24 20:40:01 +00:00
|
|
|
*
|
2020-06-19 22:47:42 +00:00
|
|
|
* @param {string | string[] | undefined} address Address field
|
|
|
|
* @return {AddressObject[]} An array of address objects
|
2020-05-24 20:40:01 +00:00
|
|
|
*/
|
2020-06-19 22:47:42 +00:00
|
|
|
function addressparser(address) {
|
|
|
|
const addresses = [];
|
|
|
|
let tokens = [];
|
|
|
|
for (const token of tokenizeAddress(address)) {
|
|
|
|
if (token.type === 'operator' &&
|
|
|
|
(token.value === ',' || token.value === ';')) {
|
|
|
|
if (tokens.length > 0) {
|
|
|
|
addresses.push(...convertAddressTokens(tokens));
|
|
|
|
}
|
|
|
|
tokens = [];
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
2020-06-19 22:47:42 +00:00
|
|
|
else {
|
|
|
|
tokens.push(token);
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
}
|
2020-06-19 22:47:42 +00:00
|
|
|
if (tokens.length > 0) {
|
|
|
|
addresses.push(...convertAddressTokens(tokens));
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
2020-06-19 22:47:42 +00:00
|
|
|
return addresses;
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {Date} [date] an optional date to convert to RFC2822 format
|
|
|
|
* @param {boolean} [useUtc] whether to parse the date as UTC (default: false)
|
|
|
|
* @returns {string} the converted date
|
|
|
|
*/
|
|
|
|
function getRFC2822Date(date = new Date(), useUtc = false) {
|
|
|
|
if (useUtc) {
|
|
|
|
return getRFC2822DateUTC(date);
|
|
|
|
}
|
|
|
|
const dates = date
|
|
|
|
.toString()
|
|
|
|
.replace('GMT', '')
|
|
|
|
.replace(/\s\(.*\)$/, '')
|
|
|
|
.split(' ');
|
|
|
|
dates[0] = dates[0] + ',';
|
|
|
|
const day = dates[1];
|
|
|
|
dates[1] = dates[2];
|
|
|
|
dates[2] = day;
|
|
|
|
return dates.join(' ');
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @param {Date} [date] an optional date to convert to RFC2822 format (UTC)
|
|
|
|
* @returns {string} the converted date
|
|
|
|
*/
|
|
|
|
function getRFC2822DateUTC(date = new Date()) {
|
|
|
|
const dates = date.toUTCString().split(' ');
|
|
|
|
dates.pop(); // remove timezone
|
|
|
|
dates.push('+0000');
|
|
|
|
return dates.join(' ');
|
|
|
|
}
|
2020-11-30 05:07:00 +00:00
|
|
|
/**
|
|
|
|
* RFC 2822 regex
|
|
|
|
* @see https://tools.ietf.org/html/rfc2822#section-3.3
|
|
|
|
* @see https://github.com/moment/moment/blob/a831fc7e2694281ce31e4f090bbcf90a690f0277/src/lib/create/from-string.js#L101
|
|
|
|
*/
|
|
|
|
const rfc2822re = /^(?:(Mon|Tue|Wed|Thu|Fri|Sat|Sun),?\s)?(\d{1,2})\s(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s(\d{2,4})\s(\d\d):(\d\d)(?::(\d\d))?\s(?:(UT|GMT|[ECMP][SD]T)|([Zz])|([+-]\d{4}))$/.compile();
|
|
|
|
/**
|
|
|
|
* @param {string} [date] a string to check for conformance to the [rfc2822](https://tools.ietf.org/html/rfc2822#section-3.3) standard
|
|
|
|
* @returns {boolean} the result of the conformance check
|
|
|
|
*/
|
|
|
|
function isRFC2822Date(date) {
|
|
|
|
return rfc2822re.test(date);
|
|
|
|
}
|
2020-05-24 20:40:01 +00:00
|
|
|
|
2020-06-19 22:47:42 +00:00
|
|
|
// adapted from https://github.com/emailjs/emailjs-mime-codec/blob/6909c706b9f09bc0e5c3faf48f723cca53e5b352/src/mimecodec.js
|
|
|
|
const encoder = new TextEncoder();
|
|
|
|
/**
|
|
|
|
* @see https://tools.ietf.org/html/rfc2045#section-6.7
|
|
|
|
*/
|
|
|
|
const RANGES = [
|
|
|
|
[0x09],
|
|
|
|
[0x0a],
|
|
|
|
[0x0d],
|
|
|
|
[0x20, 0x3c],
|
2021-06-26 19:46:30 +00:00
|
|
|
[0x3e, 0x7e], // >?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}
|
2020-06-19 22:47:42 +00:00
|
|
|
];
|
|
|
|
const LOOKUP = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.split('');
|
|
|
|
const MAX_CHUNK_LENGTH = 16383; // must be multiple of 3
|
|
|
|
const MAX_MIME_WORD_LENGTH = 52;
|
|
|
|
const MAX_B64_MIME_WORD_BYTE_LENGTH = 39;
|
|
|
|
function tripletToBase64(num) {
|
|
|
|
return (LOOKUP[(num >> 18) & 0x3f] +
|
|
|
|
LOOKUP[(num >> 12) & 0x3f] +
|
|
|
|
LOOKUP[(num >> 6) & 0x3f] +
|
|
|
|
LOOKUP[num & 0x3f]);
|
|
|
|
}
|
|
|
|
function encodeChunk(uint8, start, end) {
|
|
|
|
let output = '';
|
|
|
|
for (let i = start; i < end; i += 3) {
|
|
|
|
output += tripletToBase64((uint8[i] << 16) + (uint8[i + 1] << 8) + uint8[i + 2]);
|
|
|
|
}
|
|
|
|
return output;
|
|
|
|
}
|
|
|
|
function encodeBase64(data) {
|
|
|
|
const len = data.length;
|
|
|
|
const extraBytes = len % 3; // if we have 1 byte left, pad 2 bytes
|
|
|
|
let output = '';
|
|
|
|
// go through the array every three bytes, we'll deal with trailing stuff later
|
|
|
|
for (let i = 0, len2 = len - extraBytes; i < len2; i += MAX_CHUNK_LENGTH) {
|
|
|
|
output += encodeChunk(data, i, i + MAX_CHUNK_LENGTH > len2 ? len2 : i + MAX_CHUNK_LENGTH);
|
|
|
|
}
|
|
|
|
// pad the end with zeros, but make sure to not forget the extra bytes
|
|
|
|
if (extraBytes === 1) {
|
|
|
|
const tmp = data[len - 1];
|
|
|
|
output += LOOKUP[tmp >> 2];
|
|
|
|
output += LOOKUP[(tmp << 4) & 0x3f];
|
|
|
|
output += '==';
|
|
|
|
}
|
|
|
|
else if (extraBytes === 2) {
|
|
|
|
const tmp = (data[len - 2] << 8) + data[len - 1];
|
|
|
|
output += LOOKUP[tmp >> 10];
|
|
|
|
output += LOOKUP[(tmp >> 4) & 0x3f];
|
|
|
|
output += LOOKUP[(tmp << 2) & 0x3f];
|
|
|
|
output += '=';
|
|
|
|
}
|
|
|
|
return output;
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Splits a mime encoded string. Needed for dividing mime words into smaller chunks
|
|
|
|
*
|
|
|
|
* @param {string} str Mime encoded string to be split up
|
|
|
|
* @param {number} maxlen Maximum length of characters for one part (minimum 12)
|
|
|
|
* @return {string[]} lines
|
|
|
|
*/
|
|
|
|
function splitMimeEncodedString(str, maxlen = 12) {
|
|
|
|
const minWordLength = 12; // require at least 12 symbols to fit possible 4 octet UTF-8 sequences
|
|
|
|
const maxWordLength = Math.max(maxlen, minWordLength);
|
|
|
|
const lines = [];
|
|
|
|
while (str.length) {
|
|
|
|
let curLine = str.substr(0, maxWordLength);
|
|
|
|
const match = curLine.match(/=[0-9A-F]?$/i); // skip incomplete escaped char
|
|
|
|
if (match) {
|
|
|
|
curLine = curLine.substr(0, match.index);
|
|
|
|
}
|
|
|
|
let done = false;
|
|
|
|
while (!done) {
|
|
|
|
let chr;
|
|
|
|
done = true;
|
|
|
|
const match = str.substr(curLine.length).match(/^=([0-9A-F]{2})/i); // check if not middle of a unicode char sequence
|
|
|
|
if (match) {
|
|
|
|
chr = parseInt(match[1], 16);
|
|
|
|
// invalid sequence, move one char back anc recheck
|
|
|
|
if (chr < 0xc2 && chr > 0x7f) {
|
|
|
|
curLine = curLine.substr(0, curLine.length - 3);
|
|
|
|
done = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (curLine.length) {
|
|
|
|
lines.push(curLine);
|
|
|
|
}
|
|
|
|
str = str.substr(curLine.length);
|
|
|
|
}
|
|
|
|
return lines;
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @param {number} nr number
|
|
|
|
* @returns {boolean} if number is in range
|
|
|
|
*/
|
|
|
|
function checkRanges(nr) {
|
|
|
|
return RANGES.reduce((val, range) => val ||
|
|
|
|
(range.length === 1 && nr === range[0]) ||
|
|
|
|
(range.length === 2 && nr >= range[0] && nr <= range[1]), false);
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Encodes all non printable and non ascii bytes to =XX form, where XX is the
|
|
|
|
* byte value in hex. This function does not convert linebreaks etc. it
|
|
|
|
* only escapes character sequences
|
|
|
|
*
|
|
|
|
* NOTE: Encoding support depends on util.TextDecoder, which is severely limited
|
|
|
|
* prior to Node.js 13.
|
|
|
|
*
|
|
|
|
* @see https://nodejs.org/api/util.html#util_whatwg_supported_encodings
|
|
|
|
* @see https://github.com/nodejs/node/issues/19214
|
|
|
|
*
|
|
|
|
* @param {string|Uint8Array} data Either a string or an Uint8Array
|
|
|
|
* @param {string} encoding WHATWG supported encoding
|
|
|
|
* @return {string} Mime encoded string
|
|
|
|
*/
|
|
|
|
function mimeEncode(data = '', encoding = 'utf-8') {
|
|
|
|
const decoder = new TextDecoder(encoding);
|
|
|
|
const buffer = typeof data === 'string'
|
|
|
|
? encoder.encode(data)
|
|
|
|
: encoder.encode(decoder.decode(data));
|
|
|
|
return buffer.reduce((aggregate, ord, index) => checkRanges(ord) &&
|
|
|
|
!((ord === 0x20 || ord === 0x09) &&
|
|
|
|
(index === buffer.length - 1 ||
|
|
|
|
buffer[index + 1] === 0x0a ||
|
|
|
|
buffer[index + 1] === 0x0d))
|
|
|
|
? // if the char is in allowed range, then keep as is, unless it is a ws in the end of a line
|
|
|
|
aggregate + String.fromCharCode(ord)
|
|
|
|
: `${aggregate}=${ord < 0x10 ? '0' : ''}${ord
|
|
|
|
.toString(16)
|
|
|
|
.toUpperCase()}`, '');
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Encodes a string or an Uint8Array to an UTF-8 MIME Word
|
|
|
|
*
|
|
|
|
* NOTE: Encoding support depends on util.TextDecoder, which is severely limited
|
|
|
|
* prior to Node.js 13.
|
|
|
|
*
|
|
|
|
* @see https://tools.ietf.org/html/rfc2047
|
|
|
|
* @see https://nodejs.org/api/util.html#util_whatwg_supported_encodings
|
|
|
|
* @see https://github.com/nodejs/node/issues/19214
|
|
|
|
*
|
|
|
|
* @param {string|Uint8Array} data String to be encoded
|
|
|
|
* @param {'Q' | 'B'} mimeWordEncoding='Q' Encoding for the mime word, either Q or B
|
|
|
|
* @param {string} encoding WHATWG supported encoding
|
|
|
|
* @return {string} Single or several mime words joined together
|
|
|
|
*/
|
|
|
|
function mimeWordEncode(data, mimeWordEncoding = 'Q', encoding = 'utf-8') {
|
|
|
|
let parts = [];
|
|
|
|
const decoder = new TextDecoder(encoding);
|
|
|
|
const str = typeof data === 'string' ? data : decoder.decode(data);
|
|
|
|
if (mimeWordEncoding === 'Q') {
|
|
|
|
const encodedStr = mimeEncode(str, encoding).replace(/[^a-z0-9!*+\-/=]/gi, (chr) => chr === ' '
|
|
|
|
? '_'
|
|
|
|
: '=' +
|
|
|
|
(chr.charCodeAt(0) < 0x10 ? '0' : '') +
|
|
|
|
chr.charCodeAt(0).toString(16).toUpperCase());
|
|
|
|
parts =
|
|
|
|
encodedStr.length < MAX_MIME_WORD_LENGTH
|
|
|
|
? [encodedStr]
|
|
|
|
: splitMimeEncodedString(encodedStr, MAX_MIME_WORD_LENGTH);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
// Fits as much as possible into every line without breaking utf-8 multibyte characters' octets up across lines
|
|
|
|
let j = 0;
|
|
|
|
let i = 0;
|
|
|
|
while (i < str.length) {
|
|
|
|
if (encoder.encode(str.substring(j, i)).length >
|
|
|
|
MAX_B64_MIME_WORD_BYTE_LENGTH) {
|
|
|
|
// we went one character too far, substring at the char before
|
|
|
|
parts.push(str.substring(j, i - 1));
|
|
|
|
j = i - 1;
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
i++;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// add the remainder of the string
|
|
|
|
str.substring(j) && parts.push(str.substring(j));
|
|
|
|
parts = parts.map((x) => encoder.encode(x)).map((x) => encodeBase64(x));
|
|
|
|
}
|
|
|
|
return parts
|
|
|
|
.map((p) => `=?UTF-8?${mimeWordEncoding}?${p}?= `)
|
|
|
|
.join('')
|
|
|
|
.trim();
|
|
|
|
}
|
|
|
|
|
2021-06-26 19:46:30 +00:00
|
|
|
const CRLF$1 = '\r\n';
|
2020-05-24 20:40:01 +00:00
|
|
|
/**
|
|
|
|
* MIME standard wants 76 char chunks when sending out.
|
|
|
|
*/
|
|
|
|
const MIMECHUNK = 76;
|
|
|
|
/**
|
|
|
|
* meets both base64 and mime divisibility
|
|
|
|
*/
|
|
|
|
const MIME64CHUNK = (MIMECHUNK * 6);
|
|
|
|
/**
|
|
|
|
* size of the message stream buffer
|
|
|
|
*/
|
|
|
|
const BUFFERSIZE = (MIMECHUNK * 24 * 7);
|
|
|
|
let counter = 0;
|
2020-11-30 05:07:00 +00:00
|
|
|
function generateBoundary() {
|
2020-05-24 20:40:01 +00:00
|
|
|
let text = '';
|
|
|
|
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'()+_,-./:=?";
|
|
|
|
for (let i = 0; i < 69; i++) {
|
|
|
|
text += possible.charAt(Math.floor(Math.random() * possible.length));
|
|
|
|
}
|
|
|
|
return text;
|
|
|
|
}
|
|
|
|
function convertPersonToAddress(person) {
|
2020-06-19 22:47:42 +00:00
|
|
|
return addressparser(person)
|
2020-05-24 20:40:01 +00:00
|
|
|
.map(({ name, address }) => {
|
|
|
|
return name
|
2020-06-19 22:47:42 +00:00
|
|
|
? `${mimeWordEncode(name).replace(/,/g, '=2C')} <${address}>`
|
2020-05-24 20:40:01 +00:00
|
|
|
: address;
|
|
|
|
})
|
|
|
|
.join(', ');
|
|
|
|
}
|
|
|
|
function convertDashDelimitedTextToSnakeCase(text) {
|
|
|
|
return text
|
|
|
|
.toLowerCase()
|
|
|
|
.replace(/^(.)|-(.)/g, (match) => match.toUpperCase());
|
|
|
|
}
|
|
|
|
class Message {
|
2020-05-28 21:36:36 +00:00
|
|
|
/**
|
|
|
|
* Construct an rfc2822-compliant message object.
|
|
|
|
*
|
|
|
|
* Special notes:
|
|
|
|
* - The `from` field is required.
|
|
|
|
* - At least one `to`, `cc`, or `bcc` header is also required.
|
|
|
|
* - You can also add whatever other headers you want.
|
|
|
|
*
|
|
|
|
* @see https://tools.ietf.org/html/rfc2822
|
|
|
|
* @param {Partial<MessageHeaders>} headers Message headers
|
|
|
|
*/
|
2020-05-24 20:40:01 +00:00
|
|
|
constructor(headers) {
|
|
|
|
this.attachments = [];
|
|
|
|
this.header = {
|
|
|
|
'message-id': `<${new Date().getTime()}.${counter++}.${process.pid}@${hostname()}>`,
|
|
|
|
date: getRFC2822Date(),
|
|
|
|
};
|
|
|
|
this.content = 'text/plain; charset=utf-8';
|
|
|
|
this.alternative = null;
|
|
|
|
for (const header in headers) {
|
|
|
|
// allow user to override default content-type to override charset or send a single non-text message
|
|
|
|
if (/^content-type$/i.test(header)) {
|
|
|
|
this.content = headers[header];
|
|
|
|
}
|
|
|
|
else if (header === 'text') {
|
|
|
|
this.text = headers[header];
|
|
|
|
}
|
|
|
|
else if (header === 'attachment' &&
|
|
|
|
typeof headers[header] === 'object') {
|
|
|
|
const attachment = headers[header];
|
|
|
|
if (Array.isArray(attachment)) {
|
|
|
|
for (let i = 0; i < attachment.length; i++) {
|
|
|
|
this.attach(attachment[i]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
else if (attachment != null) {
|
|
|
|
this.attach(attachment);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
else if (header === 'subject') {
|
2020-06-19 22:47:42 +00:00
|
|
|
this.header.subject = mimeWordEncode(headers.subject);
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
else if (/^(cc|bcc|to|from)/i.test(header)) {
|
|
|
|
this.header[header.toLowerCase()] = convertPersonToAddress(headers[header]);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
// allow any headers the user wants to set??
|
|
|
|
this.header[header.toLowerCase()] = headers[header];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
2020-05-28 21:36:36 +00:00
|
|
|
* Attach a file to the message.
|
|
|
|
*
|
|
|
|
* Can be called multiple times, each adding a new attachment.
|
|
|
|
*
|
2020-05-24 20:40:01 +00:00
|
|
|
* @public
|
|
|
|
* @param {MessageAttachment} options attachment options
|
|
|
|
* @returns {Message} the current instance for chaining
|
|
|
|
*/
|
|
|
|
attach(options) {
|
|
|
|
// sender can specify an attachment as an alternative
|
|
|
|
if (options.alternative) {
|
|
|
|
this.alternative = options;
|
|
|
|
this.alternative.charset = options.charset || 'utf-8';
|
|
|
|
this.alternative.type = options.type || 'text/html';
|
|
|
|
this.alternative.inline = true;
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
this.attachments.push(options);
|
|
|
|
}
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
2020-05-28 21:36:36 +00:00
|
|
|
* @param {function(isValid: boolean, invalidReason: string): void} callback .
|
2020-05-24 20:40:01 +00:00
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
valid(callback) {
|
2020-06-19 22:47:42 +00:00
|
|
|
if (typeof this.header.from !== 'string' &&
|
|
|
|
Array.isArray(this.header.from) === false) {
|
2020-05-28 21:36:36 +00:00
|
|
|
callback(false, 'Message must have a `from` header');
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
2020-05-28 21:36:36 +00:00
|
|
|
else if (typeof this.header.to !== 'string' &&
|
2020-06-19 22:47:42 +00:00
|
|
|
Array.isArray(this.header.to) === false &&
|
2020-05-28 21:36:36 +00:00
|
|
|
typeof this.header.cc !== 'string' &&
|
2020-06-19 22:47:42 +00:00
|
|
|
Array.isArray(this.header.cc) === false &&
|
|
|
|
typeof this.header.bcc !== 'string' &&
|
|
|
|
Array.isArray(this.header.bcc) === false) {
|
2020-05-28 21:36:36 +00:00
|
|
|
callback(false, 'Message must have at least one `to`, `cc`, or `bcc` header');
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
else if (this.attachments.length === 0) {
|
|
|
|
callback(true, undefined);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
const failed = [];
|
|
|
|
this.attachments.forEach((attachment) => {
|
|
|
|
if (attachment.path) {
|
|
|
|
if (fs.existsSync(attachment.path) == false) {
|
|
|
|
failed.push(`${attachment.path} does not exist`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
else if (attachment.stream) {
|
|
|
|
if (!attachment.stream.readable) {
|
|
|
|
failed.push('attachment stream is not readable');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
else if (!attachment.data) {
|
|
|
|
failed.push('attachment has no data associated with it');
|
|
|
|
}
|
|
|
|
});
|
|
|
|
callback(failed.length === 0, failed.join(', '));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
2020-11-30 05:07:00 +00:00
|
|
|
* @returns {MessageStream} a stream of the current message
|
2020-05-24 20:40:01 +00:00
|
|
|
*/
|
|
|
|
stream() {
|
|
|
|
return new MessageStream(this);
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* @param {function(Error, string): void} callback the function to call with the error and buffer
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
read(callback) {
|
|
|
|
let buffer = '';
|
|
|
|
const str = this.stream();
|
|
|
|
str.on('data', (data) => (buffer += data));
|
|
|
|
str.on('end', (err) => callback(err, buffer));
|
|
|
|
str.on('error', (err) => callback(err, buffer));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
class MessageStream extends Stream {
|
|
|
|
/**
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {Message} message the message to stream
|
2020-05-24 20:40:01 +00:00
|
|
|
*/
|
|
|
|
constructor(message) {
|
|
|
|
super();
|
|
|
|
this.message = message;
|
|
|
|
this.readable = true;
|
|
|
|
this.paused = false;
|
|
|
|
this.buffer = Buffer.alloc(MIMECHUNK * 24 * 7);
|
|
|
|
this.bufferIndex = 0;
|
|
|
|
/**
|
|
|
|
* @param {string} [data] the data to output
|
|
|
|
* @param {Function} [callback] the function
|
|
|
|
* @param {any[]} [args] array of arguments to pass to the callback
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
const output = (data) => {
|
|
|
|
// can we buffer the data?
|
|
|
|
if (this.buffer != null) {
|
|
|
|
const bytes = Buffer.byteLength(data);
|
|
|
|
if (bytes + this.bufferIndex < this.buffer.length) {
|
|
|
|
this.buffer.write(data, this.bufferIndex);
|
|
|
|
this.bufferIndex += bytes;
|
|
|
|
}
|
|
|
|
// we can't buffer the data, so ship it out!
|
|
|
|
else if (bytes > this.buffer.length) {
|
|
|
|
if (this.bufferIndex) {
|
|
|
|
this.emit('data', this.buffer.toString('utf-8', 0, this.bufferIndex));
|
|
|
|
this.bufferIndex = 0;
|
|
|
|
}
|
|
|
|
const loops = Math.ceil(data.length / this.buffer.length);
|
|
|
|
let loop = 0;
|
|
|
|
while (loop < loops) {
|
|
|
|
this.emit('data', data.substring(this.buffer.length * loop, this.buffer.length * (loop + 1)));
|
|
|
|
loop++;
|
|
|
|
}
|
|
|
|
} // we need to clean out the buffer, it is getting full
|
|
|
|
else {
|
|
|
|
if (!this.paused) {
|
|
|
|
this.emit('data', this.buffer.toString('utf-8', 0, this.bufferIndex));
|
|
|
|
this.buffer.write(data, 0);
|
|
|
|
this.bufferIndex = bytes;
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
// we can't empty out the buffer, so let's wait till we resume before adding to it
|
|
|
|
this.once('resume', () => output(data));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
|
|
|
/**
|
2020-06-19 22:47:42 +00:00
|
|
|
* @param {MessageAttachment} [attachment] the attachment whose headers you would like to output
|
2020-05-24 20:40:01 +00:00
|
|
|
* @returns {void}
|
|
|
|
*/
|
2020-11-30 05:07:00 +00:00
|
|
|
const outputAttachmentHeaders = (attachment) => {
|
2020-05-24 20:40:01 +00:00
|
|
|
let data = [];
|
|
|
|
const headers = {
|
|
|
|
'content-type': attachment.type +
|
|
|
|
(attachment.charset ? `; charset=${attachment.charset}` : '') +
|
|
|
|
(attachment.method ? `; method=${attachment.method}` : ''),
|
|
|
|
'content-transfer-encoding': 'base64',
|
|
|
|
'content-disposition': attachment.inline
|
|
|
|
? 'inline'
|
2020-06-19 22:47:42 +00:00
|
|
|
: `attachment; filename="${mimeWordEncode(attachment.name)}"`,
|
2020-05-24 20:40:01 +00:00
|
|
|
};
|
|
|
|
// allow sender to override default headers
|
|
|
|
if (attachment.headers != null) {
|
|
|
|
for (const header in attachment.headers) {
|
|
|
|
headers[header.toLowerCase()] = attachment.headers[header];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
for (const header in headers) {
|
|
|
|
data = data.concat([
|
|
|
|
convertDashDelimitedTextToSnakeCase(header),
|
|
|
|
': ',
|
|
|
|
headers[header],
|
2021-06-26 19:46:30 +00:00
|
|
|
CRLF$1,
|
2020-05-24 20:40:01 +00:00
|
|
|
]);
|
|
|
|
}
|
2021-06-26 19:46:30 +00:00
|
|
|
output(data.concat([CRLF$1]).join(''));
|
2020-05-24 20:40:01 +00:00
|
|
|
};
|
|
|
|
/**
|
|
|
|
* @param {string} data the data to output as base64
|
|
|
|
* @param {function(): void} [callback] the function to call after output is finished
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
2020-11-30 05:07:00 +00:00
|
|
|
const outputBase64 = (data, callback) => {
|
2020-05-24 20:40:01 +00:00
|
|
|
const loops = Math.ceil(data.length / MIMECHUNK);
|
|
|
|
let loop = 0;
|
|
|
|
while (loop < loops) {
|
2021-06-26 19:46:30 +00:00
|
|
|
output(data.substring(MIMECHUNK * loop, MIMECHUNK * (loop + 1)) + CRLF$1);
|
2020-05-24 20:40:01 +00:00
|
|
|
loop++;
|
|
|
|
}
|
|
|
|
if (callback) {
|
|
|
|
callback();
|
|
|
|
}
|
|
|
|
};
|
2020-11-30 05:07:00 +00:00
|
|
|
const outputFile = (attachment, next) => {
|
2020-05-24 20:40:01 +00:00
|
|
|
const chunk = MIME64CHUNK * 16;
|
|
|
|
const buffer = Buffer.alloc(chunk);
|
|
|
|
const closed = (fd) => fs.closeSync(fd);
|
|
|
|
/**
|
|
|
|
* @param {Error} err the error to emit
|
|
|
|
* @param {number} fd the file descriptor
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
const opened = (err, fd) => {
|
|
|
|
if (!err) {
|
|
|
|
const read = (err, bytes) => {
|
|
|
|
if (!err && this.readable) {
|
|
|
|
let encoding = attachment && attachment.headers
|
|
|
|
? attachment.headers['content-transfer-encoding'] || 'base64'
|
|
|
|
: 'base64';
|
|
|
|
if (encoding === 'ascii' || encoding === '7bit') {
|
|
|
|
encoding = 'ascii';
|
|
|
|
}
|
|
|
|
else if (encoding === 'binary' || encoding === '8bit') {
|
|
|
|
encoding = 'binary';
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
encoding = 'base64';
|
|
|
|
}
|
|
|
|
// guaranteed to be encoded without padding unless it is our last read
|
2020-11-30 05:07:00 +00:00
|
|
|
outputBase64(buffer.toString(encoding, 0, bytes), () => {
|
2020-05-24 20:40:01 +00:00
|
|
|
if (bytes == chunk) {
|
|
|
|
// we read a full chunk, there might be more
|
|
|
|
fs.read(fd, buffer, 0, chunk, null, read);
|
|
|
|
} // that was the last chunk, we are done reading the file
|
|
|
|
else {
|
|
|
|
this.removeListener('error', closed);
|
|
|
|
fs.close(fd, next);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
this.emit('error', err || { message: 'message stream was interrupted somehow!' });
|
|
|
|
}
|
|
|
|
};
|
|
|
|
fs.read(fd, buffer, 0, chunk, null, read);
|
|
|
|
this.once('error', closed);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
this.emit('error', err);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
fs.open(attachment.path, 'r', opened);
|
|
|
|
};
|
|
|
|
/**
|
|
|
|
* @param {MessageAttachment} attachment the metadata to use as headers
|
|
|
|
* @param {function(): void} callback the function to call after output is finished
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
2020-11-30 05:07:00 +00:00
|
|
|
const outputStream = (attachment, callback) => {
|
2020-08-02 02:24:33 +00:00
|
|
|
const { stream } = attachment;
|
|
|
|
if (stream === null || stream === void 0 ? void 0 : stream.readable) {
|
2020-05-24 20:40:01 +00:00
|
|
|
let previous = Buffer.alloc(0);
|
2020-08-02 02:24:33 +00:00
|
|
|
stream.resume();
|
|
|
|
stream.on('end', () => {
|
2020-11-30 05:07:00 +00:00
|
|
|
outputBase64(previous.toString('base64'), callback);
|
2020-08-02 02:24:33 +00:00
|
|
|
this.removeListener('pause', stream.pause);
|
|
|
|
this.removeListener('resume', stream.resume);
|
|
|
|
this.removeListener('error', stream.resume);
|
2020-05-24 20:40:01 +00:00
|
|
|
});
|
2020-08-02 02:24:33 +00:00
|
|
|
stream.on('data', (buff) => {
|
2020-05-24 20:40:01 +00:00
|
|
|
// do we have bytes from a previous stream data event?
|
|
|
|
let buffer = Buffer.isBuffer(buff) ? buff : Buffer.from(buff);
|
|
|
|
if (previous.byteLength > 0) {
|
|
|
|
buffer = Buffer.concat([previous, buffer]);
|
|
|
|
}
|
|
|
|
const padded = buffer.length % MIME64CHUNK;
|
|
|
|
previous = Buffer.alloc(padded);
|
|
|
|
// encode as much of the buffer to base64 without empty bytes
|
|
|
|
if (padded > 0) {
|
|
|
|
// copy dangling bytes into previous buffer
|
|
|
|
buffer.copy(previous, 0, buffer.length - padded);
|
|
|
|
}
|
2020-11-30 05:07:00 +00:00
|
|
|
outputBase64(buffer.toString('base64', 0, buffer.length - padded));
|
2020-05-24 20:40:01 +00:00
|
|
|
});
|
2020-08-02 02:24:33 +00:00
|
|
|
this.on('pause', stream.pause);
|
|
|
|
this.on('resume', stream.resume);
|
|
|
|
this.on('error', stream.resume);
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
else {
|
|
|
|
this.emit('error', { message: 'stream not readable' });
|
|
|
|
}
|
|
|
|
};
|
2020-11-30 05:07:00 +00:00
|
|
|
const outputAttachment = (attachment, callback) => {
|
2020-05-24 20:40:01 +00:00
|
|
|
const build = attachment.path
|
2020-11-30 05:07:00 +00:00
|
|
|
? outputFile
|
2020-05-24 20:40:01 +00:00
|
|
|
: attachment.stream
|
2020-11-30 05:07:00 +00:00
|
|
|
? outputStream
|
|
|
|
: outputData;
|
|
|
|
outputAttachmentHeaders(attachment);
|
2020-05-24 20:40:01 +00:00
|
|
|
build(attachment, callback);
|
|
|
|
};
|
|
|
|
/**
|
|
|
|
* @param {string} boundary the boundary text between outputs
|
|
|
|
* @param {MessageAttachment[]} list the list of potential messages to output
|
|
|
|
* @param {number} index the index of the list item to output
|
|
|
|
* @param {function(): void} callback the function to call if index is greater than upper bound
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
2020-11-30 05:07:00 +00:00
|
|
|
const outputMessage = (boundary, list, index, callback) => {
|
2020-05-24 20:40:01 +00:00
|
|
|
if (index < list.length) {
|
2021-06-26 19:46:30 +00:00
|
|
|
output(`--${boundary}${CRLF$1}`);
|
2020-05-24 20:40:01 +00:00
|
|
|
if (list[index].related) {
|
2020-11-30 05:07:00 +00:00
|
|
|
outputRelated(list[index], () => outputMessage(boundary, list, index + 1, callback));
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
else {
|
2020-11-30 05:07:00 +00:00
|
|
|
outputAttachment(list[index], () => outputMessage(boundary, list, index + 1, callback));
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
else {
|
2021-06-26 19:46:30 +00:00
|
|
|
output(`${CRLF$1}--${boundary}--${CRLF$1}${CRLF$1}`);
|
2020-05-24 20:40:01 +00:00
|
|
|
callback();
|
|
|
|
}
|
|
|
|
};
|
2020-11-30 05:07:00 +00:00
|
|
|
const outputMixed = () => {
|
|
|
|
const boundary = generateBoundary();
|
2021-06-26 19:46:30 +00:00
|
|
|
output(`Content-Type: multipart/mixed; boundary="${boundary}"${CRLF$1}${CRLF$1}--${boundary}${CRLF$1}`);
|
2020-05-24 20:40:01 +00:00
|
|
|
if (this.message.alternative == null) {
|
2020-11-30 05:07:00 +00:00
|
|
|
outputText(this.message);
|
|
|
|
outputMessage(boundary, this.message.attachments, 0, close);
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
else {
|
2020-11-30 05:07:00 +00:00
|
|
|
outputAlternative(
|
2020-06-19 22:47:42 +00:00
|
|
|
// typescript bug; should narrow to { alternative: MessageAttachment }
|
2020-11-30 05:07:00 +00:00
|
|
|
this.message, () => outputMessage(boundary, this.message.attachments, 0, close));
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
};
|
|
|
|
/**
|
|
|
|
* @param {MessageAttachment} attachment the metadata to use as headers
|
|
|
|
* @param {function(): void} callback the function to call after output is finished
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
2020-11-30 05:07:00 +00:00
|
|
|
const outputData = (attachment, callback) => {
|
2020-06-19 22:47:42 +00:00
|
|
|
var _a, _b;
|
2020-11-30 05:07:00 +00:00
|
|
|
outputBase64(attachment.encoded
|
2021-06-26 19:46:30 +00:00
|
|
|
? (_a = attachment.data) !== null && _a !== void 0 ? _a : ''
|
|
|
|
: Buffer.from((_b = attachment.data) !== null && _b !== void 0 ? _b : '').toString('base64'), callback);
|
2020-05-24 20:40:01 +00:00
|
|
|
};
|
|
|
|
/**
|
|
|
|
* @param {Message} message the message to output
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
2020-11-30 05:07:00 +00:00
|
|
|
const outputText = (message) => {
|
2020-05-24 20:40:01 +00:00
|
|
|
let data = [];
|
|
|
|
data = data.concat([
|
|
|
|
'Content-Type:',
|
|
|
|
message.content,
|
2021-06-26 19:46:30 +00:00
|
|
|
CRLF$1,
|
2020-05-24 20:40:01 +00:00
|
|
|
'Content-Transfer-Encoding: 7bit',
|
2021-06-26 19:46:30 +00:00
|
|
|
CRLF$1,
|
2020-05-24 20:40:01 +00:00
|
|
|
]);
|
2021-06-26 19:46:30 +00:00
|
|
|
data = data.concat(['Content-Disposition: inline', CRLF$1, CRLF$1]);
|
|
|
|
data = data.concat([message.text || '', CRLF$1, CRLF$1]);
|
2020-05-24 20:40:01 +00:00
|
|
|
output(data.join(''));
|
|
|
|
};
|
|
|
|
/**
|
|
|
|
* @param {MessageAttachment} message the message to output
|
|
|
|
* @param {function(): void} callback the function to call after output is finished
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
2020-11-30 05:07:00 +00:00
|
|
|
const outputRelated = (message, callback) => {
|
|
|
|
const boundary = generateBoundary();
|
2021-06-26 19:46:30 +00:00
|
|
|
output(`Content-Type: multipart/related; boundary="${boundary}"${CRLF$1}${CRLF$1}--${boundary}${CRLF$1}`);
|
2020-11-30 05:07:00 +00:00
|
|
|
outputAttachment(message, () => {
|
2020-05-24 20:40:01 +00:00
|
|
|
var _a;
|
2020-11-30 05:07:00 +00:00
|
|
|
outputMessage(boundary, (_a = message.related) !== null && _a !== void 0 ? _a : [], 0, () => {
|
2021-06-26 19:46:30 +00:00
|
|
|
output(`${CRLF$1}--${boundary}--${CRLF$1}${CRLF$1}`);
|
2020-05-24 20:40:01 +00:00
|
|
|
callback();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
};
|
|
|
|
/**
|
|
|
|
* @param {Message} message the message to output
|
|
|
|
* @param {function(): void} callback the function to call after output is finished
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
2020-11-30 05:07:00 +00:00
|
|
|
const outputAlternative = (message, callback) => {
|
|
|
|
const boundary = generateBoundary();
|
2021-06-26 19:46:30 +00:00
|
|
|
output(`Content-Type: multipart/alternative; boundary="${boundary}"${CRLF$1}${CRLF$1}--${boundary}${CRLF$1}`);
|
2020-11-30 05:07:00 +00:00
|
|
|
outputText(message);
|
2021-06-26 19:46:30 +00:00
|
|
|
output(`--${boundary}${CRLF$1}`);
|
2020-05-24 20:40:01 +00:00
|
|
|
/**
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
const finish = () => {
|
2021-06-26 19:46:30 +00:00
|
|
|
output([CRLF$1, '--', boundary, '--', CRLF$1, CRLF$1].join(''));
|
2020-05-24 20:40:01 +00:00
|
|
|
callback();
|
|
|
|
};
|
|
|
|
if (message.alternative.related) {
|
2020-11-30 05:07:00 +00:00
|
|
|
outputRelated(message.alternative, finish);
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
else {
|
2020-11-30 05:07:00 +00:00
|
|
|
outputAttachment(message.alternative, finish);
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
};
|
|
|
|
const close = (err) => {
|
|
|
|
var _a, _b;
|
|
|
|
if (err) {
|
|
|
|
this.emit('error', err);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
this.emit('data', (_b = (_a = this.buffer) === null || _a === void 0 ? void 0 : _a.toString('utf-8', 0, this.bufferIndex)) !== null && _b !== void 0 ? _b : '');
|
|
|
|
this.emit('end');
|
|
|
|
}
|
|
|
|
this.buffer = null;
|
|
|
|
this.bufferIndex = 0;
|
|
|
|
this.readable = false;
|
|
|
|
this.removeAllListeners('resume');
|
|
|
|
this.removeAllListeners('pause');
|
|
|
|
this.removeAllListeners('error');
|
|
|
|
this.removeAllListeners('data');
|
|
|
|
this.removeAllListeners('end');
|
|
|
|
};
|
|
|
|
/**
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
2020-11-30 05:07:00 +00:00
|
|
|
const outputHeaderData = () => {
|
2020-05-24 20:40:01 +00:00
|
|
|
if (this.message.attachments.length || this.message.alternative) {
|
2021-06-26 19:46:30 +00:00
|
|
|
output(`MIME-Version: 1.0${CRLF$1}`);
|
2020-11-30 05:07:00 +00:00
|
|
|
outputMixed();
|
2020-05-24 20:40:01 +00:00
|
|
|
} // you only have a text message!
|
|
|
|
else {
|
2020-11-30 05:07:00 +00:00
|
|
|
outputText(this.message);
|
2020-05-24 20:40:01 +00:00
|
|
|
close();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
/**
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
2020-11-30 05:07:00 +00:00
|
|
|
const outputHeader = () => {
|
2020-05-24 20:40:01 +00:00
|
|
|
let data = [];
|
|
|
|
for (const header in this.message.header) {
|
|
|
|
// do not output BCC in the headers (regex) nor custom Object.prototype functions...
|
|
|
|
if (!/bcc/i.test(header) &&
|
|
|
|
Object.prototype.hasOwnProperty.call(this.message.header, header)) {
|
|
|
|
data = data.concat([
|
|
|
|
convertDashDelimitedTextToSnakeCase(header),
|
|
|
|
': ',
|
|
|
|
this.message.header[header],
|
2021-06-26 19:46:30 +00:00
|
|
|
CRLF$1,
|
2020-05-24 20:40:01 +00:00
|
|
|
]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
output(data.join(''));
|
2020-11-30 05:07:00 +00:00
|
|
|
outputHeaderData();
|
2020-05-24 20:40:01 +00:00
|
|
|
};
|
|
|
|
this.once('destroy', close);
|
2020-11-30 05:07:00 +00:00
|
|
|
process.nextTick(outputHeader);
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* pause the stream
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
pause() {
|
|
|
|
this.paused = true;
|
|
|
|
this.emit('pause');
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* resume the stream
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
resume() {
|
|
|
|
this.paused = false;
|
|
|
|
this.emit('resume');
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* destroy the stream
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
destroy() {
|
|
|
|
this.emit('destroy', this.bufferIndex > 0 ? { message: 'message stream destroyed' } : null);
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* destroy the stream at first opportunity
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
destroySoon() {
|
|
|
|
this.emit('destroy');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @readonly
|
|
|
|
* @enum
|
|
|
|
*/
|
|
|
|
const SMTPErrorStates = {
|
|
|
|
COULDNOTCONNECT: 1,
|
|
|
|
BADRESPONSE: 2,
|
|
|
|
AUTHFAILED: 3,
|
|
|
|
TIMEDOUT: 4,
|
|
|
|
ERROR: 5,
|
|
|
|
NOCONNECTION: 6,
|
|
|
|
AUTHNOTSUPPORTED: 7,
|
|
|
|
CONNECTIONCLOSED: 8,
|
|
|
|
CONNECTIONENDED: 9,
|
|
|
|
CONNECTIONAUTH: 10,
|
|
|
|
};
|
|
|
|
class SMTPError extends Error {
|
2020-05-28 21:36:36 +00:00
|
|
|
/**
|
|
|
|
* @protected
|
|
|
|
* @param {string} message error message
|
|
|
|
*/
|
2020-05-24 20:40:01 +00:00
|
|
|
constructor(message) {
|
|
|
|
super(message);
|
|
|
|
this.code = null;
|
|
|
|
this.smtp = null;
|
|
|
|
this.previous = null;
|
|
|
|
}
|
2020-05-28 21:36:36 +00:00
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @param {string} message error message
|
|
|
|
* @param {number} code smtp error state
|
|
|
|
* @param {Error | null} error previous error
|
|
|
|
* @param {unknown} smtp arbitrary data
|
|
|
|
* @returns {SMTPError} error
|
|
|
|
*/
|
|
|
|
static create(message, code, error, smtp) {
|
|
|
|
const msg = (error === null || error === void 0 ? void 0 : error.message) ? `${message} (${error.message})` : message;
|
|
|
|
const err = new SMTPError(msg);
|
|
|
|
err.code = code;
|
|
|
|
err.smtp = smtp;
|
|
|
|
if (error) {
|
|
|
|
err.previous = error;
|
|
|
|
}
|
|
|
|
return err;
|
|
|
|
}
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
|
2020-05-28 21:36:36 +00:00
|
|
|
class SMTPResponseMonitor {
|
2020-05-24 20:40:01 +00:00
|
|
|
constructor(stream, timeout, onerror) {
|
|
|
|
let buffer = '';
|
|
|
|
const notify = () => {
|
|
|
|
var _a, _b;
|
|
|
|
if (buffer.length) {
|
|
|
|
// parse buffer for response codes
|
|
|
|
const line = buffer.replace('\r', '');
|
|
|
|
if (!((_b = (_a = line
|
|
|
|
.trim()
|
|
|
|
.split(/\n/)
|
|
|
|
.pop()) === null || _a === void 0 ? void 0 : _a.match(/^(\d{3})\s/)) !== null && _b !== void 0 ? _b : false)) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const match = line ? line.match(/(\d+)\s?(.*)/) : null;
|
|
|
|
const data = match !== null
|
|
|
|
? { code: match[1], message: match[2], data: line }
|
|
|
|
: { code: -1, data: line };
|
|
|
|
stream.emit('response', null, data);
|
|
|
|
buffer = '';
|
|
|
|
}
|
|
|
|
};
|
|
|
|
const error = (err) => {
|
2020-05-28 21:36:36 +00:00
|
|
|
stream.emit('response', SMTPError.create('connection encountered an error', SMTPErrorStates.ERROR, err));
|
2020-05-24 20:40:01 +00:00
|
|
|
};
|
|
|
|
const timedout = (err) => {
|
|
|
|
stream.end();
|
2020-05-28 21:36:36 +00:00
|
|
|
stream.emit('response', SMTPError.create('timedout while connecting to smtp server', SMTPErrorStates.TIMEDOUT, err));
|
2020-05-24 20:40:01 +00:00
|
|
|
};
|
|
|
|
const watch = (data) => {
|
|
|
|
if (data !== null) {
|
|
|
|
buffer += data.toString();
|
|
|
|
notify();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
const close = (err) => {
|
2020-05-28 21:36:36 +00:00
|
|
|
stream.emit('response', SMTPError.create('connection has closed', SMTPErrorStates.CONNECTIONCLOSED, err));
|
2020-05-24 20:40:01 +00:00
|
|
|
};
|
|
|
|
const end = (err) => {
|
2020-05-28 21:36:36 +00:00
|
|
|
stream.emit('response', SMTPError.create('connection has ended', SMTPErrorStates.CONNECTIONENDED, err));
|
2020-05-24 20:40:01 +00:00
|
|
|
};
|
|
|
|
this.stop = (err) => {
|
|
|
|
stream.removeAllListeners('response');
|
|
|
|
stream.removeListener('data', watch);
|
|
|
|
stream.removeListener('end', end);
|
|
|
|
stream.removeListener('close', close);
|
|
|
|
stream.removeListener('error', error);
|
|
|
|
if (err != null && typeof onerror === 'function') {
|
|
|
|
onerror(err);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
stream.on('data', watch);
|
|
|
|
stream.on('end', end);
|
|
|
|
stream.on('close', close);
|
|
|
|
stream.on('error', error);
|
|
|
|
stream.setTimeout(timeout, timedout);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @readonly
|
|
|
|
* @enum
|
|
|
|
*/
|
|
|
|
const AUTH_METHODS = {
|
|
|
|
PLAIN: 'PLAIN',
|
|
|
|
'CRAM-MD5': 'CRAM-MD5',
|
|
|
|
LOGIN: 'LOGIN',
|
|
|
|
XOAUTH2: 'XOAUTH2',
|
|
|
|
};
|
|
|
|
/**
|
|
|
|
* @readonly
|
|
|
|
* @enum
|
|
|
|
*/
|
|
|
|
const SMTPState = {
|
|
|
|
NOTCONNECTED: 0,
|
|
|
|
CONNECTING: 1,
|
|
|
|
CONNECTED: 2,
|
|
|
|
};
|
|
|
|
const DEFAULT_TIMEOUT = 5000;
|
|
|
|
const SMTP_PORT = 25;
|
|
|
|
const SMTP_SSL_PORT = 465;
|
|
|
|
const SMTP_TLS_PORT = 587;
|
2021-06-26 19:46:30 +00:00
|
|
|
const CRLF = '\r\n';
|
2020-08-02 02:24:33 +00:00
|
|
|
const GREYLIST_DELAY = 300;
|
2020-05-24 20:40:01 +00:00
|
|
|
let DEBUG = 0;
|
|
|
|
/**
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {...any[]} args the message(s) to log
|
2020-05-24 20:40:01 +00:00
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
const log = (...args) => {
|
|
|
|
if (DEBUG === 1) {
|
|
|
|
args.forEach((d) => console.log(typeof d === 'object'
|
|
|
|
? d instanceof Error
|
|
|
|
? d.message
|
|
|
|
: JSON.stringify(d)
|
|
|
|
: d));
|
|
|
|
}
|
|
|
|
};
|
|
|
|
/**
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {function(...any[]): void} callback the function to call
|
|
|
|
* @param {...any[]} args the arguments to apply to the function
|
2020-05-24 20:40:01 +00:00
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
const caller = (callback, ...args) => {
|
|
|
|
if (typeof callback === 'function') {
|
|
|
|
callback(...args);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
class SMTPConnection extends EventEmitter {
|
|
|
|
/**
|
2020-05-28 21:36:36 +00:00
|
|
|
* SMTP class written using python's (2.7) smtplib.py as a base.
|
|
|
|
*
|
|
|
|
* To target a Message Transfer Agent (MTA), omit all options.
|
|
|
|
*
|
|
|
|
* NOTE: `host` is trimmed before being used to establish a connection; however, the original untrimmed value will still be visible in configuration.
|
2020-05-24 20:40:01 +00:00
|
|
|
*/
|
|
|
|
constructor({ timeout, host, user, password, domain, port, ssl, tls, logger, authentication, } = {}) {
|
2020-05-28 21:36:36 +00:00
|
|
|
var _a;
|
2020-05-24 20:40:01 +00:00
|
|
|
super();
|
|
|
|
this.timeout = DEFAULT_TIMEOUT;
|
|
|
|
this.log = log;
|
|
|
|
this.authentication = [
|
|
|
|
AUTH_METHODS['CRAM-MD5'],
|
|
|
|
AUTH_METHODS.LOGIN,
|
|
|
|
AUTH_METHODS.PLAIN,
|
|
|
|
AUTH_METHODS.XOAUTH2,
|
|
|
|
];
|
|
|
|
this._state = SMTPState.NOTCONNECTED;
|
|
|
|
this._secure = false;
|
|
|
|
this.loggedin = false;
|
|
|
|
this.sock = null;
|
|
|
|
this.features = null;
|
|
|
|
this.monitor = null;
|
|
|
|
this.domain = hostname();
|
|
|
|
this.host = 'localhost';
|
|
|
|
this.ssl = false;
|
|
|
|
this.tls = false;
|
2020-11-30 05:07:00 +00:00
|
|
|
this.greylistResponseTracker = new WeakSet();
|
2020-05-24 20:40:01 +00:00
|
|
|
if (Array.isArray(authentication)) {
|
|
|
|
this.authentication = authentication;
|
|
|
|
}
|
|
|
|
if (typeof timeout === 'number') {
|
|
|
|
this.timeout = timeout;
|
|
|
|
}
|
|
|
|
if (typeof domain === 'string') {
|
|
|
|
this.domain = domain;
|
|
|
|
}
|
|
|
|
if (typeof host === 'string') {
|
|
|
|
this.host = host;
|
|
|
|
}
|
|
|
|
if (ssl != null &&
|
|
|
|
(typeof ssl === 'boolean' ||
|
|
|
|
(typeof ssl === 'object' && Array.isArray(ssl) === false))) {
|
|
|
|
this.ssl = ssl;
|
|
|
|
}
|
|
|
|
if (tls != null &&
|
|
|
|
(typeof tls === 'boolean' ||
|
|
|
|
(typeof tls === 'object' && Array.isArray(tls) === false))) {
|
|
|
|
this.tls = tls;
|
|
|
|
}
|
|
|
|
this.port = port || (ssl ? SMTP_SSL_PORT : tls ? SMTP_TLS_PORT : SMTP_PORT);
|
|
|
|
this.loggedin = user && password ? false : true;
|
2020-05-28 21:36:36 +00:00
|
|
|
if (!user && ((_a = password === null || password === void 0 ? void 0 : password.length) !== null && _a !== void 0 ? _a : 0) > 0) {
|
|
|
|
throw new Error('`password` cannot be set without `user`');
|
|
|
|
}
|
2020-05-24 20:40:01 +00:00
|
|
|
// keep these strings hidden when quicky debugging/logging
|
|
|
|
this.user = () => user;
|
|
|
|
this.password = () => password;
|
|
|
|
if (typeof logger === 'function') {
|
|
|
|
this.log = log;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* @param {0 | 1} level -
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
debug(level) {
|
|
|
|
DEBUG = level;
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* @returns {SMTPState} the current state
|
|
|
|
*/
|
|
|
|
state() {
|
|
|
|
return this._state;
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* @returns {boolean} whether or not the instance is authorized
|
|
|
|
*/
|
|
|
|
authorized() {
|
|
|
|
return this.loggedin;
|
|
|
|
}
|
|
|
|
/**
|
2020-05-28 21:36:36 +00:00
|
|
|
* Establish an SMTP connection.
|
|
|
|
*
|
|
|
|
* NOTE: `host` is trimmed before being used to establish a connection; however, the original untrimmed value will still be visible in configuration.
|
|
|
|
*
|
2020-05-24 20:40:01 +00:00
|
|
|
* @public
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {function(...any[]): void} callback function to call after response
|
2020-05-24 20:40:01 +00:00
|
|
|
* @param {number} [port] the port to use for the connection
|
|
|
|
* @param {string} [host] the hostname to use for the connection
|
|
|
|
* @param {ConnectOptions} [options={}] the options
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
connect(callback, port = this.port, host = this.host, options = {}) {
|
|
|
|
this.port = port;
|
|
|
|
this.host = host;
|
|
|
|
this.ssl = options.ssl || this.ssl;
|
|
|
|
if (this._state !== SMTPState.NOTCONNECTED) {
|
|
|
|
this.quit(() => this.connect(callback, port, host, options));
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
const connected = () => {
|
|
|
|
this.log(`connected: ${this.host}:${this.port}`);
|
|
|
|
if (this.ssl && !this.tls) {
|
|
|
|
// if key/ca/cert was passed in, check if connection is authorized
|
|
|
|
if (typeof this.ssl !== 'boolean' &&
|
|
|
|
this.sock instanceof TLSSocket &&
|
|
|
|
!this.sock.authorized) {
|
|
|
|
this.close(true);
|
2020-05-28 21:36:36 +00:00
|
|
|
caller(callback, SMTPError.create('could not establish an ssl connection', SMTPErrorStates.CONNECTIONAUTH));
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
else {
|
|
|
|
this._secure = true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
|
|
|
/**
|
|
|
|
* @param {Error} err err
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
const connectedErrBack = (err) => {
|
|
|
|
if (!err) {
|
|
|
|
connected();
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
this.close(true);
|
|
|
|
this.log(err);
|
2020-05-28 21:36:36 +00:00
|
|
|
caller(callback, SMTPError.create('could not connect', SMTPErrorStates.COULDNOTCONNECT, err));
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
};
|
|
|
|
const response = (err, msg) => {
|
|
|
|
if (err) {
|
|
|
|
if (this._state === SMTPState.NOTCONNECTED && !this.sock) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this.close(true);
|
|
|
|
caller(callback, err);
|
|
|
|
}
|
|
|
|
else if (msg.code == '220') {
|
|
|
|
this.log(msg.data);
|
|
|
|
// might happen first, so no need to wait on connected()
|
|
|
|
this._state = SMTPState.CONNECTED;
|
|
|
|
caller(callback, null, msg.data);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
this.log(`response (data): ${msg.data}`);
|
|
|
|
this.quit(() => {
|
2020-05-28 21:36:36 +00:00
|
|
|
caller(callback, SMTPError.create('bad response on connection', SMTPErrorStates.BADRESPONSE, err, msg.data));
|
2020-05-24 20:40:01 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
};
|
|
|
|
this._state = SMTPState.CONNECTING;
|
|
|
|
this.log(`connecting: ${this.host}:${this.port}`);
|
|
|
|
if (this.ssl) {
|
2020-05-28 21:36:36 +00:00
|
|
|
this.sock = connect(this.port, this.host.trim(), typeof this.ssl === 'object' ? this.ssl : {}, connected);
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
else {
|
|
|
|
this.sock = new Socket();
|
2020-05-28 21:36:36 +00:00
|
|
|
this.sock.connect(this.port, this.host.trim(), connectedErrBack);
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
2020-05-28 21:36:36 +00:00
|
|
|
this.monitor = new SMTPResponseMonitor(this.sock, this.timeout, () => this.close(true));
|
2020-05-24 20:40:01 +00:00
|
|
|
this.sock.once('response', response);
|
|
|
|
this.sock.once('error', response); // the socket could reset or throw, so let's handle it and let the user know
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* @param {string} str the string to send
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {function(...any[]): void} callback function to call after response
|
2020-05-24 20:40:01 +00:00
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
send(str, callback) {
|
2020-08-02 02:24:33 +00:00
|
|
|
if (this.sock != null && this._state === SMTPState.CONNECTED) {
|
2020-05-24 20:40:01 +00:00
|
|
|
this.log(str);
|
|
|
|
this.sock.once('response', (err, msg) => {
|
|
|
|
if (err) {
|
|
|
|
caller(callback, err);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
this.log(msg.data);
|
|
|
|
caller(callback, null, msg);
|
|
|
|
}
|
|
|
|
});
|
2020-08-02 02:24:33 +00:00
|
|
|
if (this.sock.writable) {
|
|
|
|
this.sock.write(str);
|
|
|
|
}
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
else {
|
|
|
|
this.close(true);
|
2020-05-28 21:36:36 +00:00
|
|
|
caller(callback, SMTPError.create('no connection has been established', SMTPErrorStates.NOCONNECTION));
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* @param {string} cmd command to issue
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {function(...any[]): void} callback function to call after response
|
2020-05-24 20:40:01 +00:00
|
|
|
* @param {(number[] | number)} [codes=[250]] array codes
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
command(cmd, callback, codes = [250]) {
|
|
|
|
const codesArray = Array.isArray(codes)
|
|
|
|
? codes
|
|
|
|
: typeof codes === 'number'
|
|
|
|
? [codes]
|
|
|
|
: [250];
|
|
|
|
const response = (err, msg) => {
|
|
|
|
if (err) {
|
|
|
|
caller(callback, err);
|
|
|
|
}
|
|
|
|
else {
|
2020-08-02 02:24:33 +00:00
|
|
|
const code = Number(msg.code);
|
|
|
|
if (codesArray.indexOf(code) !== -1) {
|
2020-05-24 20:40:01 +00:00
|
|
|
caller(callback, err, msg.data, msg.message);
|
|
|
|
}
|
2020-08-02 02:24:33 +00:00
|
|
|
else if ((code === 450 || code === 451) &&
|
|
|
|
msg.message.toLowerCase().includes('greylist') &&
|
2020-11-30 05:07:00 +00:00
|
|
|
this.greylistResponseTracker.has(response) === false) {
|
|
|
|
this.greylistResponseTracker.add(response);
|
2020-08-02 02:24:33 +00:00
|
|
|
setTimeout(() => {
|
2021-06-26 19:46:30 +00:00
|
|
|
this.send(cmd + CRLF, response);
|
2020-08-02 02:24:33 +00:00
|
|
|
}, GREYLIST_DELAY);
|
|
|
|
}
|
2020-05-24 20:40:01 +00:00
|
|
|
else {
|
|
|
|
const suffix = msg.message ? `: ${msg.message}` : '';
|
|
|
|
const errorMessage = `bad response on command '${cmd.split(' ')[0]}'${suffix}`;
|
2020-05-28 21:36:36 +00:00
|
|
|
caller(callback, SMTPError.create(errorMessage, SMTPErrorStates.BADRESPONSE, null, msg.data));
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
2020-11-30 05:07:00 +00:00
|
|
|
this.greylistResponseTracker.delete(response);
|
2021-06-26 19:46:30 +00:00
|
|
|
this.send(cmd + CRLF, response);
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* @description SMTP 'helo' command.
|
|
|
|
*
|
|
|
|
* Hostname to send for self command defaults to the FQDN of the local
|
|
|
|
* host.
|
|
|
|
*
|
2020-05-28 21:36:36 +00:00
|
|
|
* As this command was deprecated by rfc2821, it should only be used for compatibility with non-compliant servers.
|
|
|
|
* @see https://tools.ietf.org/html/rfc2821#appendix-F.3
|
|
|
|
*
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {function(...any[]): void} callback function to call after response
|
2020-05-24 20:40:01 +00:00
|
|
|
* @param {string} domain the domain to associate with the 'helo' request
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
helo(callback, domain) {
|
|
|
|
this.command(`helo ${domain || this.domain}`, (err, data) => {
|
|
|
|
if (err) {
|
|
|
|
caller(callback, err);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
this.parse_smtp_features(data);
|
|
|
|
caller(callback, err, data);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {function(...any[]): void} callback function to call after response
|
2020-05-24 20:40:01 +00:00
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
starttls(callback) {
|
|
|
|
const response = (err, msg) => {
|
|
|
|
if (this.sock == null) {
|
|
|
|
throw new Error('null socket');
|
|
|
|
}
|
|
|
|
if (err) {
|
|
|
|
err.message += ' while establishing a starttls session';
|
|
|
|
caller(callback, err);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
const secureContext = createSecureContext(typeof this.tls === 'object' ? this.tls : {});
|
|
|
|
const secureSocket = new TLSSocket(this.sock, { secureContext });
|
|
|
|
secureSocket.on('error', (err) => {
|
|
|
|
this.close(true);
|
|
|
|
caller(callback, err);
|
|
|
|
});
|
|
|
|
this._secure = true;
|
|
|
|
this.sock = secureSocket;
|
2020-05-28 21:36:36 +00:00
|
|
|
new SMTPResponseMonitor(this.sock, this.timeout, () => this.close(true));
|
2020-05-24 20:40:01 +00:00
|
|
|
caller(callback, msg.data);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
this.command('starttls', response, [220]);
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* @param {string} data the string to parse for features
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
parse_smtp_features(data) {
|
|
|
|
// According to RFC1869 some (badly written)
|
|
|
|
// MTA's will disconnect on an ehlo. Toss an exception if
|
|
|
|
// that happens -ddm
|
|
|
|
data.split('\n').forEach((ext) => {
|
|
|
|
const parse = ext.match(/^(?:\d+[-=]?)\s*?([^\s]+)(?:\s+(.*)\s*?)?$/);
|
|
|
|
// To be able to communicate with as many SMTP servers as possible,
|
|
|
|
// we have to take the old-style auth advertisement into account,
|
|
|
|
// because:
|
|
|
|
// 1) Else our SMTP feature parser gets confused.
|
|
|
|
// 2) There are some servers that only advertise the auth methods we
|
|
|
|
// support using the old style.
|
|
|
|
if (parse != null && this.features != null) {
|
|
|
|
// RFC 1869 requires a space between ehlo keyword and parameters.
|
|
|
|
// It's actually stricter, in that only spaces are allowed between
|
|
|
|
// parameters, but were not going to check for that here. Note
|
|
|
|
// that the space isn't present if there are no parameters.
|
|
|
|
this.features[parse[1].toLowerCase()] = parse[2] || true;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {function(...any[]): void} callback function to call after response
|
2020-05-24 20:40:01 +00:00
|
|
|
* @param {string} domain the domain to associate with the 'ehlo' request
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
ehlo(callback, domain) {
|
|
|
|
this.features = {};
|
|
|
|
this.command(`ehlo ${domain || this.domain}`, (err, data) => {
|
|
|
|
if (err) {
|
|
|
|
caller(callback, err);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
this.parse_smtp_features(data);
|
|
|
|
if (this.tls && !this._secure) {
|
|
|
|
this.starttls(() => this.ehlo(callback, domain));
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
caller(callback, err, data);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* @param {string} opt the features keyname to check
|
|
|
|
* @returns {boolean} whether the extension exists
|
|
|
|
*/
|
|
|
|
has_extn(opt) {
|
|
|
|
var _a;
|
|
|
|
return ((_a = this.features) !== null && _a !== void 0 ? _a : {})[opt.toLowerCase()] === undefined;
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* @description SMTP 'help' command, returns text from the server
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {function(...any[]): void} callback function to call after response
|
2020-05-24 20:40:01 +00:00
|
|
|
* @param {string} domain the domain to associate with the 'help' request
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
help(callback, domain) {
|
|
|
|
this.command(domain ? `help ${domain}` : 'help', callback, [211, 214]);
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {function(...any[]): void} callback function to call after response
|
2020-05-24 20:40:01 +00:00
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
rset(callback) {
|
|
|
|
this.command('rset', callback);
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {function(...any[]): void} callback function to call after response
|
2020-05-24 20:40:01 +00:00
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
noop(callback) {
|
|
|
|
this.send('noop', callback);
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {function(...any[]): void} callback function to call after response
|
2020-05-24 20:40:01 +00:00
|
|
|
* @param {string} from the sender
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
mail(callback, from) {
|
|
|
|
this.command(`mail FROM:${from}`, callback);
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {function(...any[]): void} callback function to call after response
|
2020-05-24 20:40:01 +00:00
|
|
|
* @param {string} to the receiver
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
rcpt(callback, to) {
|
|
|
|
this.command(`RCPT TO:${to}`, callback, [250, 251]);
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {function(...any[]): void} callback function to call after response
|
2020-05-24 20:40:01 +00:00
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
data(callback) {
|
|
|
|
this.command('data', callback, [354]);
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {function(...any[]): void} callback function to call after response
|
2020-05-24 20:40:01 +00:00
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
data_end(callback) {
|
2021-06-26 19:46:30 +00:00
|
|
|
this.command(`${CRLF}.`, callback);
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* @param {string} data the message to send
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
message(data) {
|
|
|
|
var _a, _b;
|
|
|
|
this.log(data);
|
|
|
|
(_b = (_a = this.sock) === null || _a === void 0 ? void 0 : _a.write(data)) !== null && _b !== void 0 ? _b : this.log('no socket to write to');
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* @description SMTP 'verify' command -- checks for address validity.
|
|
|
|
* @param {string} address the address to validate
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {function(...any[]): void} callback function to call after response
|
2020-05-24 20:40:01 +00:00
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
verify(address, callback) {
|
|
|
|
this.command(`vrfy ${address}`, callback, [250, 251, 252]);
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* @description SMTP 'expn' command -- expands a mailing list.
|
|
|
|
* @param {string} address the mailing list to expand
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {function(...any[]): void} callback function to call after response
|
2020-05-24 20:40:01 +00:00
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
expn(address, callback) {
|
|
|
|
this.command(`expn ${address}`, callback);
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* @description Calls this.ehlo() and, if an error occurs, this.helo().
|
|
|
|
*
|
|
|
|
* If there has been no previous EHLO or HELO command self session, self
|
|
|
|
* method tries ESMTP EHLO first.
|
|
|
|
*
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {function(...any[]): void} callback function to call after response
|
2020-05-24 20:40:01 +00:00
|
|
|
* @param {string} [domain] the domain to associate with the command
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
ehlo_or_helo_if_needed(callback, domain) {
|
|
|
|
// is this code callable...?
|
|
|
|
if (!this.features) {
|
|
|
|
const response = (err, data) => caller(callback, err, data);
|
|
|
|
this.ehlo((err, data) => {
|
|
|
|
if (err) {
|
|
|
|
this.helo(response, domain);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
caller(callback, err, data);
|
|
|
|
}
|
|
|
|
}, domain);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
*
|
|
|
|
* Log in on an SMTP server that requires authentication.
|
|
|
|
*
|
|
|
|
* If there has been no previous EHLO or HELO command self session, self
|
|
|
|
* method tries ESMTP EHLO first.
|
|
|
|
*
|
|
|
|
* This method will return normally if the authentication was successful.
|
|
|
|
*
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {function(...any[]): void} callback function to call after response
|
2020-05-24 20:40:01 +00:00
|
|
|
* @param {string} [user] the username to authenticate with
|
|
|
|
* @param {string} [password] the password for the authentication
|
|
|
|
* @param {{ method: string, domain: string }} [options] login options
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
login(callback, user, password, options = {}) {
|
|
|
|
var _a, _b;
|
|
|
|
const login = {
|
|
|
|
user: user ? () => user : this.user,
|
|
|
|
password: password ? () => password : this.password,
|
|
|
|
method: (_b = (_a = options === null || options === void 0 ? void 0 : options.method) === null || _a === void 0 ? void 0 : _a.toUpperCase()) !== null && _b !== void 0 ? _b : '',
|
|
|
|
};
|
|
|
|
const domain = (options === null || options === void 0 ? void 0 : options.domain) || this.domain;
|
|
|
|
const initiate = (err, data) => {
|
|
|
|
var _a;
|
|
|
|
if (err) {
|
|
|
|
caller(callback, err);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
let method = null;
|
|
|
|
/**
|
|
|
|
* @param {string} challenge challenge
|
|
|
|
* @returns {string} base64 cram hash
|
|
|
|
*/
|
2020-11-30 05:07:00 +00:00
|
|
|
const encodeCramMd5 = (challenge) => {
|
2020-05-24 20:40:01 +00:00
|
|
|
const hmac = createHmac('md5', login.password());
|
|
|
|
hmac.update(Buffer.from(challenge, 'base64').toString('ascii'));
|
|
|
|
return Buffer.from(`${login.user()} ${hmac.digest('hex')}`).toString('base64');
|
|
|
|
};
|
|
|
|
/**
|
|
|
|
* @returns {string} base64 login/password
|
|
|
|
*/
|
2020-11-30 05:07:00 +00:00
|
|
|
const encodePlain = () => Buffer.from(`\u0000${login.user()}\u0000${login.password()}`).toString('base64');
|
2020-05-24 20:40:01 +00:00
|
|
|
/**
|
|
|
|
* @see https://developers.google.com/gmail/xoauth2_protocol
|
|
|
|
* @returns {string} base64 xoauth2 auth token
|
|
|
|
*/
|
2020-11-30 05:07:00 +00:00
|
|
|
const encodeXoauth2 = () => Buffer.from(`user=${login.user()}\u0001auth=Bearer ${login.password()}\u0001\u0001`).toString('base64');
|
2020-05-24 20:40:01 +00:00
|
|
|
// List of authentication methods we support: from preferred to
|
|
|
|
// less preferred methods.
|
|
|
|
if (!method) {
|
|
|
|
const preferred = this.authentication;
|
|
|
|
let auth = '';
|
2021-06-26 19:46:30 +00:00
|
|
|
if (typeof ((_a = this.features) === null || _a === void 0 ? void 0 : _a['auth']) === 'string') {
|
|
|
|
auth = this.features['auth'];
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
for (let i = 0; i < preferred.length; i++) {
|
|
|
|
if (auth.includes(preferred[i])) {
|
|
|
|
method = preferred[i];
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* handle bad responses from command differently
|
|
|
|
* @param {Error} err err
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {unknown} data data
|
2020-05-24 20:40:01 +00:00
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
const failed = (err, data) => {
|
|
|
|
this.loggedin = false;
|
|
|
|
this.close(); // if auth is bad, close the connection, it won't get better by itself
|
2020-05-28 21:36:36 +00:00
|
|
|
caller(callback, SMTPError.create('authorization.failed', SMTPErrorStates.AUTHFAILED, err, data));
|
2020-05-24 20:40:01 +00:00
|
|
|
};
|
|
|
|
/**
|
|
|
|
* @param {Error} err err
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {unknown} data data
|
2020-05-24 20:40:01 +00:00
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
const response = (err, data) => {
|
|
|
|
if (err) {
|
|
|
|
failed(err, data);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
this.loggedin = true;
|
|
|
|
caller(callback, err, data);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
/**
|
|
|
|
* @param {Error} err err
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {unknown} data data
|
2020-05-24 20:40:01 +00:00
|
|
|
* @param {string} msg msg
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
const attempt = (err, data, msg) => {
|
|
|
|
if (err) {
|
|
|
|
failed(err, data);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
if (method === AUTH_METHODS['CRAM-MD5']) {
|
2020-11-30 05:07:00 +00:00
|
|
|
this.command(encodeCramMd5(msg), response, [235, 503]);
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
else if (method === AUTH_METHODS.LOGIN) {
|
|
|
|
this.command(Buffer.from(login.password()).toString('base64'), response, [235, 503]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
|
|
|
/**
|
|
|
|
* @param {Error} err err
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {unknown} data data
|
2020-05-24 20:40:01 +00:00
|
|
|
* @param {string} msg msg
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
2020-11-30 05:07:00 +00:00
|
|
|
const attemptUser = (err, data) => {
|
2020-05-24 20:40:01 +00:00
|
|
|
if (err) {
|
|
|
|
failed(err, data);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
if (method === AUTH_METHODS.LOGIN) {
|
|
|
|
this.command(Buffer.from(login.user()).toString('base64'), attempt, [334]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
|
|
|
switch (method) {
|
|
|
|
case AUTH_METHODS['CRAM-MD5']:
|
|
|
|
this.command(`AUTH ${AUTH_METHODS['CRAM-MD5']}`, attempt, [334]);
|
|
|
|
break;
|
|
|
|
case AUTH_METHODS.LOGIN:
|
2020-11-30 05:07:00 +00:00
|
|
|
this.command(`AUTH ${AUTH_METHODS.LOGIN}`, attemptUser, [334]);
|
2020-05-24 20:40:01 +00:00
|
|
|
break;
|
|
|
|
case AUTH_METHODS.PLAIN:
|
2020-11-30 05:07:00 +00:00
|
|
|
this.command(`AUTH ${AUTH_METHODS.PLAIN} ${encodePlain()}`, response, [235, 503]);
|
2020-05-24 20:40:01 +00:00
|
|
|
break;
|
|
|
|
case AUTH_METHODS.XOAUTH2:
|
2020-11-30 05:07:00 +00:00
|
|
|
this.command(`AUTH ${AUTH_METHODS.XOAUTH2} ${encodeXoauth2()}`, response, [235, 503]);
|
2020-05-24 20:40:01 +00:00
|
|
|
break;
|
|
|
|
default:
|
2020-11-30 05:07:00 +00:00
|
|
|
caller(callback, SMTPError.create('no form of authorization supported', SMTPErrorStates.AUTHNOTSUPPORTED, null, data));
|
2020-05-24 20:40:01 +00:00
|
|
|
break;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
this.ehlo_or_helo_if_needed(initiate, domain);
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* @param {boolean} [force=false] whether or not to force destroy the connection
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
close(force = false) {
|
|
|
|
if (this.sock) {
|
|
|
|
if (force) {
|
|
|
|
this.log('smtp connection destroyed!');
|
|
|
|
this.sock.destroy();
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
this.log('smtp connection closed.');
|
|
|
|
this.sock.end();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (this.monitor) {
|
|
|
|
this.monitor.stop();
|
|
|
|
this.monitor = null;
|
|
|
|
}
|
|
|
|
this._state = SMTPState.NOTCONNECTED;
|
|
|
|
this._secure = false;
|
|
|
|
this.sock = null;
|
|
|
|
this.features = null;
|
|
|
|
this.loggedin = !(this.user() && this.password());
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {function(...any[]): void} [callback] function to call after response
|
2020-05-24 20:40:01 +00:00
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
quit(callback) {
|
|
|
|
this.command('quit', (err, data) => {
|
|
|
|
caller(callback, err, data);
|
|
|
|
this.close();
|
|
|
|
}, [221, 250]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-05-28 21:36:36 +00:00
|
|
|
class SMTPClient {
|
2020-05-24 20:40:01 +00:00
|
|
|
/**
|
2020-05-28 21:36:36 +00:00
|
|
|
* Create a standard SMTP client backed by a self-managed SMTP connection.
|
|
|
|
*
|
|
|
|
* NOTE: `host` is trimmed before being used to establish a connection; however, the original untrimmed value will still be visible in configuration.
|
|
|
|
*
|
2020-05-24 20:40:01 +00:00
|
|
|
* @param {SMTPConnectionOptions} server smtp options
|
|
|
|
*/
|
|
|
|
constructor(server) {
|
|
|
|
this.queue = [];
|
|
|
|
this.sending = false;
|
|
|
|
this.ready = false;
|
|
|
|
this.timer = null;
|
|
|
|
this.smtp = new SMTPConnection(server);
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* @param {Message} msg the message to send
|
2020-05-28 21:36:36 +00:00
|
|
|
* @param {function(err: Error, msg: Message): void} callback .
|
2020-05-24 20:40:01 +00:00
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
send(msg, callback) {
|
|
|
|
const message = msg instanceof Message
|
|
|
|
? msg
|
|
|
|
: this._canMakeMessage(msg)
|
|
|
|
? new Message(msg)
|
|
|
|
: null;
|
|
|
|
if (message == null) {
|
|
|
|
callback(new Error('message is not a valid Message instance'), msg);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
message.valid((valid, why) => {
|
|
|
|
if (valid) {
|
2020-05-28 21:36:36 +00:00
|
|
|
const stack = this.createMessageStack(message, callback);
|
|
|
|
if (stack.to.length === 0) {
|
|
|
|
return callback(new Error('No recipients found in message'), msg);
|
2020-05-24 20:40:01 +00:00
|
|
|
}
|
|
|
|
this.queue.push(stack);
|
|
|
|
this._poll();
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
callback(new Error(why), msg);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
2020-11-30 05:07:00 +00:00
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* @param {Message} msg the message to send
|
|
|
|
* @returns {Promise<Message>} a promise that resolves to the fully processed message
|
|
|
|
*/
|
|
|
|
sendAsync(msg) {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
this.send(msg, (err, msg) => {
|
|
|
|
if (err != null) {
|
|
|
|
reject(err);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
resolve(msg);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
2020-05-28 21:36:36 +00:00
|
|
|
/**
|
|
|
|
* @public
|
|
|
|
* @description Converts a message to the raw object used by the internal stack.
|
|
|
|
* @param {Message} message message to convert
|
|
|
|
* @param {function(err: Error, msg: Message): void} callback errback
|
|
|
|
* @returns {MessageStack} raw message object
|
|
|
|
*/
|
|
|
|
createMessageStack(message, callback = function () {
|
|
|
|
/* ø */
|
|
|
|
}) {
|
2020-06-19 22:47:42 +00:00
|
|
|
const [{ address: from }] = addressparser(message.header.from);
|
2020-05-28 21:36:36 +00:00
|
|
|
const stack = {
|
|
|
|
message,
|
|
|
|
to: [],
|
|
|
|
from,
|
|
|
|
callback: callback.bind(this),
|
|
|
|
};
|
2020-06-19 22:47:42 +00:00
|
|
|
const { header: { to, cc, bcc, 'return-path': returnPath }, } = message;
|
|
|
|
if ((typeof to === 'string' || Array.isArray(to)) && to.length > 0) {
|
|
|
|
stack.to = addressparser(to);
|
2020-05-28 21:36:36 +00:00
|
|
|
}
|
2020-06-19 22:47:42 +00:00
|
|
|
if ((typeof cc === 'string' || Array.isArray(cc)) && cc.length > 0) {
|
|
|
|
stack.to = stack.to.concat(addressparser(cc).filter((x) => stack.to.some((y) => y.address === x.address) === false));
|
2020-05-28 21:36:36 +00:00
|
|
|
}
|
2020-06-19 22:47:42 +00:00
|
|
|
if ((typeof bcc === 'string' || Array.isArray(bcc)) && bcc.length > 0) {
|
|
|
|
stack.to = stack.to.concat(addressparser(bcc).filter((x) => stack.to.some((y) => y.address === x.address) === false));
|
2020-05-28 21:36:36 +00:00
|
|
|
}
|
2020-06-19 22:47:42 +00:00
|
|
|
if (typeof returnPath === 'string' && returnPath.length > 0) {
|
|
|
|
const parsedReturnPath = addressparser(returnPath);
|
2020-05-28 21:36:36 +00:00
|
|
|
if (parsedReturnPath.length > 0) {
|
|
|
|
const [{ address: returnPathAddress }] = parsedReturnPath;
|
|
|
|
stack.returnPath = returnPathAddress;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return stack;
|
|
|
|
}
|
2020-05-24 20:40:01 +00:00
|
|
|
/**
|
|
|
|
* @protected
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
_poll() {
|
|
|
|
if (this.timer != null) {
|
|
|
|
clearTimeout(this.timer);
|
|
|
|
}
|
|
|
|
if (this.queue.length) {
|
|
|
|
if (this.smtp.state() == SMTPState.NOTCONNECTED) {
|
|
|
|
this._connect(this.queue[0]);
|
|
|
|
}
|
|
|
|
else if (this.smtp.state() == SMTPState.CONNECTED &&
|
|
|
|
!this.sending &&
|
|
|
|
this.ready) {
|
|
|
|
this._sendmail(this.queue.shift());
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// wait around 1 seconds in case something does come in,
|
|
|
|
// otherwise close out SMTP connection if still open
|
|
|
|
else if (this.smtp.state() == SMTPState.CONNECTED) {
|
|
|
|
this.timer = setTimeout(() => this.smtp.quit(), 1000);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @protected
|
|
|
|
* @param {MessageStack} stack stack
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
_connect(stack) {
|
|
|
|
/**
|
|
|
|
* @param {Error} err callback error
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
const connect = (err) => {
|
|
|
|
if (!err) {
|
|
|
|
const begin = (err) => {
|
|
|
|
if (!err) {
|
|
|
|
this.ready = true;
|
|
|
|
this._poll();
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
stack.callback(err, stack.message);
|
|
|
|
// clear out the queue so all callbacks can be called with the same error message
|
|
|
|
this.queue.shift();
|
|
|
|
this._poll();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
if (!this.smtp.authorized()) {
|
|
|
|
this.smtp.login(begin);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
this.smtp.ehlo_or_helo_if_needed(begin);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
stack.callback(err, stack.message);
|
|
|
|
// clear out the queue so all callbacks can be called with the same error message
|
|
|
|
this.queue.shift();
|
|
|
|
this._poll();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
this.ready = false;
|
|
|
|
this.smtp.connect(connect);
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @protected
|
|
|
|
* @param {MessageStack} msg message stack
|
|
|
|
* @returns {boolean} can make message
|
|
|
|
*/
|
|
|
|
_canMakeMessage(msg) {
|
|
|
|
return (msg.from &&
|
|
|
|
(msg.to || msg.cc || msg.bcc) &&
|
|
|
|
(msg.text !== undefined || this._containsInlinedHtml(msg.attachment)));
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @protected
|
2020-11-30 05:07:00 +00:00
|
|
|
* @param {MessageAttachment | MessageAttachment[]} attachment attachment
|
|
|
|
* @returns {boolean} whether the attachment contains inlined html
|
2020-05-24 20:40:01 +00:00
|
|
|
*/
|
|
|
|
_containsInlinedHtml(attachment) {
|
|
|
|
if (Array.isArray(attachment)) {
|
|
|
|
return attachment.some((att) => {
|
|
|
|
return this._isAttachmentInlinedHtml(att);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
return this._isAttachmentInlinedHtml(attachment);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @protected
|
|
|
|
* @param {MessageAttachment} attachment attachment
|
|
|
|
* @returns {boolean} whether the attachment is inlined html
|
|
|
|
*/
|
|
|
|
_isAttachmentInlinedHtml(attachment) {
|
|
|
|
return (attachment &&
|
|
|
|
(attachment.data || attachment.path) &&
|
|
|
|
attachment.alternative === true);
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @protected
|
|
|
|
* @param {MessageStack} stack stack
|
|
|
|
* @param {function(MessageStack): void} next next
|
|
|
|
* @returns {function(Error): void} callback
|
|
|
|
*/
|
|
|
|
_sendsmtp(stack, next) {
|
|
|
|
/**
|
|
|
|
* @param {Error} [err] error
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
return (err) => {
|
|
|
|
if (!err && next) {
|
|
|
|
next.apply(this, [stack]);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
// if we snag on SMTP commands, call done, passing the error
|
|
|
|
// but first reset SMTP state so queue can continue polling
|
|
|
|
this.smtp.rset(() => this._senddone(err, stack));
|
|
|
|
}
|
|
|
|
};
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @protected
|
|
|
|
* @param {MessageStack} stack stack
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
_sendmail(stack) {
|
|
|
|
const from = stack.returnPath || stack.from;
|
|
|
|
this.sending = true;
|
|
|
|
this.smtp.mail(this._sendsmtp(stack, this._sendrcpt), '<' + from + '>');
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @protected
|
|
|
|
* @param {MessageStack} stack stack
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
_sendrcpt(stack) {
|
|
|
|
var _a;
|
|
|
|
if (stack.to == null || typeof stack.to === 'string') {
|
|
|
|
throw new TypeError('stack.to must be array');
|
|
|
|
}
|
|
|
|
const to = (_a = stack.to.shift()) === null || _a === void 0 ? void 0 : _a.address;
|
|
|
|
this.smtp.rcpt(this._sendsmtp(stack, stack.to.length ? this._sendrcpt : this._senddata), `<${to}>`);
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @protected
|
|
|
|
* @param {MessageStack} stack stack
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
_senddata(stack) {
|
|
|
|
this.smtp.data(this._sendsmtp(stack, this._sendmessage));
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @protected
|
|
|
|
* @param {MessageStack} stack stack
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
_sendmessage(stack) {
|
|
|
|
const stream = stack.message.stream();
|
|
|
|
stream.on('data', (data) => this.smtp.message(data));
|
|
|
|
stream.on('end', () => {
|
|
|
|
this.smtp.data_end(this._sendsmtp(stack, () => this._senddone(null, stack)));
|
|
|
|
});
|
|
|
|
// there is no way to cancel a message while in the DATA portion,
|
|
|
|
// so we have to close the socket to prevent a bad email from going out
|
|
|
|
stream.on('error', (err) => {
|
|
|
|
this.smtp.close();
|
|
|
|
this._senddone(err, stack);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @protected
|
|
|
|
* @param {Error} err err
|
|
|
|
* @param {MessageStack} stack stack
|
|
|
|
* @returns {void}
|
|
|
|
*/
|
|
|
|
_senddone(err, stack) {
|
|
|
|
this.sending = false;
|
|
|
|
stack.callback(err, stack.message);
|
|
|
|
this._poll();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-11-30 05:07:00 +00:00
|
|
|
export { AUTH_METHODS, BUFFERSIZE, DEFAULT_TIMEOUT, MIME64CHUNK, MIMECHUNK, Message, SMTPClient, SMTPConnection, SMTPError, SMTPErrorStates, SMTPResponseMonitor, SMTPState, addressparser, getRFC2822Date, getRFC2822DateUTC, isRFC2822Date, mimeEncode, mimeWordEncode };
|
2020-05-24 20:40:01 +00:00
|
|
|
//# sourceMappingURL=email.mjs.map
|