emailjs/rollup/email.mjs

2102 lines
74 KiB
JavaScript

import fs from 'fs';
import { hostname } from 'os';
import { Stream } from 'stream';
import { TextEncoder, TextDecoder } from 'util';
import { createHmac } from 'crypto';
import { EventEmitter } from 'events';
import { Socket } from 'net';
import { connect, TLSSocket, createSecureContext } from 'tls';
/*
* Operator tokens and which tokens are expected to end the sequence
*/
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.
[';', ''],
]);
/**
* Tokenizes the original input string
*
* @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;
}
}
}
return tokens
.map((x) => {
x.value = x.value.trim();
return x;
})
.filter((x) => x.value.length > 0);
}
/**
* Converts tokens for a single address into an address object
*
* @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;
}
}
}
// Filter out <addresses>, (comments) and regular text
for (const token of tokens) {
handleToken(token);
}
// If there is no text but a comment, replace the two
if (texts.length === 0 && comments.length > 0) {
texts = [...comments];
comments = [];
}
// 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(',')) : [],
});
}
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 });
}
}
return addressObjects;
}
/**
* Parses structured e-mail addresses from an address field
*
* Example:
*
* "Name <address@domain>"
*
* will be converted to
*
* [{name: "Name", address: "address@domain"}]
*
* @param {string | string[] | undefined} address Address field
* @return {AddressObject[]} An array of address objects
*/
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 = [];
}
else {
tokens.push(token);
}
}
if (tokens.length > 0) {
addresses.push(...convertAddressTokens(tokens));
}
return addresses;
}
/**
* @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(' ');
}
/**
* 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);
}
// 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],
[0x3e, 0x7e],
];
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();
}
const CRLF = '\r\n';
/**
* 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;
function generateBoundary() {
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) {
return addressparser(person)
.map(({ name, address }) => {
return name
? `${mimeWordEncode(name).replace(/,/g, '=2C')} <${address}>`
: address;
})
.join(', ');
}
function convertDashDelimitedTextToSnakeCase(text) {
return text
.toLowerCase()
.replace(/^(.)|-(.)/g, (match) => match.toUpperCase());
}
class Message {
/**
* 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
*/
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') {
this.header.subject = mimeWordEncode(headers.subject);
}
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];
}
}
}
/**
* Attach a file to the message.
*
* Can be called multiple times, each adding a new attachment.
*
* @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
* @param {function(isValid: boolean, invalidReason: string): void} callback .
* @returns {void}
*/
valid(callback) {
if (typeof this.header.from !== 'string' &&
Array.isArray(this.header.from) === false) {
callback(false, 'Message must have a `from` header');
}
else if (typeof this.header.to !== 'string' &&
Array.isArray(this.header.to) === false &&
typeof this.header.cc !== 'string' &&
Array.isArray(this.header.cc) === false &&
typeof this.header.bcc !== 'string' &&
Array.isArray(this.header.bcc) === false) {
callback(false, 'Message must have at least one `to`, `cc`, or `bcc` header');
}
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
* @returns {MessageStream} a stream of the current message
*/
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 {
/**
* @param {Message} message the message to stream
*/
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));
}
}
}
};
/**
* @param {MessageAttachment} [attachment] the attachment whose headers you would like to output
* @returns {void}
*/
const outputAttachmentHeaders = (attachment) => {
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'
: `attachment; filename="${mimeWordEncode(attachment.name)}"`,
};
// 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],
CRLF,
]);
}
output(data.concat([CRLF]).join(''));
};
/**
* @param {string} data the data to output as base64
* @param {function(): void} [callback] the function to call after output is finished
* @returns {void}
*/
const outputBase64 = (data, callback) => {
const loops = Math.ceil(data.length / MIMECHUNK);
let loop = 0;
while (loop < loops) {
output(data.substring(MIMECHUNK * loop, MIMECHUNK * (loop + 1)) + CRLF);
loop++;
}
if (callback) {
callback();
}
};
const outputFile = (attachment, next) => {
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
outputBase64(buffer.toString(encoding, 0, bytes), () => {
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}
*/
const outputStream = (attachment, callback) => {
const { stream } = attachment;
if (stream === null || stream === void 0 ? void 0 : stream.readable) {
let previous = Buffer.alloc(0);
stream.resume();
stream.on('end', () => {
outputBase64(previous.toString('base64'), callback);
this.removeListener('pause', stream.pause);
this.removeListener('resume', stream.resume);
this.removeListener('error', stream.resume);
});
stream.on('data', (buff) => {
// 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);
}
outputBase64(buffer.toString('base64', 0, buffer.length - padded));
});
this.on('pause', stream.pause);
this.on('resume', stream.resume);
this.on('error', stream.resume);
}
else {
this.emit('error', { message: 'stream not readable' });
}
};
const outputAttachment = (attachment, callback) => {
const build = attachment.path
? outputFile
: attachment.stream
? outputStream
: outputData;
outputAttachmentHeaders(attachment);
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}
*/
const outputMessage = (boundary, list, index, callback) => {
if (index < list.length) {
output(`--${boundary}${CRLF}`);
if (list[index].related) {
outputRelated(list[index], () => outputMessage(boundary, list, index + 1, callback));
}
else {
outputAttachment(list[index], () => outputMessage(boundary, list, index + 1, callback));
}
}
else {
output(`${CRLF}--${boundary}--${CRLF}${CRLF}`);
callback();
}
};
const outputMixed = () => {
const boundary = generateBoundary();
output(`Content-Type: multipart/mixed; boundary="${boundary}"${CRLF}${CRLF}--${boundary}${CRLF}`);
if (this.message.alternative == null) {
outputText(this.message);
outputMessage(boundary, this.message.attachments, 0, close);
}
else {
outputAlternative(
// typescript bug; should narrow to { alternative: MessageAttachment }
this.message, () => outputMessage(boundary, this.message.attachments, 0, close));
}
};
/**
* @param {MessageAttachment} attachment the metadata to use as headers
* @param {function(): void} callback the function to call after output is finished
* @returns {void}
*/
const outputData = (attachment, callback) => {
var _a, _b;
outputBase64(attachment.encoded
? (_a = attachment.data) !== null && _a !== void 0 ? _a : '' : Buffer.from((_b = attachment.data) !== null && _b !== void 0 ? _b : '').toString('base64'), callback);
};
/**
* @param {Message} message the message to output
* @returns {void}
*/
const outputText = (message) => {
let data = [];
data = data.concat([
'Content-Type:',
message.content,
CRLF,
'Content-Transfer-Encoding: 7bit',
CRLF,
]);
data = data.concat(['Content-Disposition: inline', CRLF, CRLF]);
data = data.concat([message.text || '', CRLF, CRLF]);
output(data.join(''));
};
/**
* @param {MessageAttachment} message the message to output
* @param {function(): void} callback the function to call after output is finished
* @returns {void}
*/
const outputRelated = (message, callback) => {
const boundary = generateBoundary();
output(`Content-Type: multipart/related; boundary="${boundary}"${CRLF}${CRLF}--${boundary}${CRLF}`);
outputAttachment(message, () => {
var _a;
outputMessage(boundary, (_a = message.related) !== null && _a !== void 0 ? _a : [], 0, () => {
output(`${CRLF}--${boundary}--${CRLF}${CRLF}`);
callback();
});
});
};
/**
* @param {Message} message the message to output
* @param {function(): void} callback the function to call after output is finished
* @returns {void}
*/
const outputAlternative = (message, callback) => {
const boundary = generateBoundary();
output(`Content-Type: multipart/alternative; boundary="${boundary}"${CRLF}${CRLF}--${boundary}${CRLF}`);
outputText(message);
output(`--${boundary}${CRLF}`);
/**
* @returns {void}
*/
const finish = () => {
output([CRLF, '--', boundary, '--', CRLF, CRLF].join(''));
callback();
};
if (message.alternative.related) {
outputRelated(message.alternative, finish);
}
else {
outputAttachment(message.alternative, finish);
}
};
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}
*/
const outputHeaderData = () => {
if (this.message.attachments.length || this.message.alternative) {
output(`MIME-Version: 1.0${CRLF}`);
outputMixed();
} // you only have a text message!
else {
outputText(this.message);
close();
}
};
/**
* @returns {void}
*/
const outputHeader = () => {
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],
CRLF,
]);
}
}
output(data.join(''));
outputHeaderData();
};
this.once('destroy', close);
process.nextTick(outputHeader);
}
/**
* @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 {
/**
* @protected
* @param {string} message error message
*/
constructor(message) {
super(message);
this.code = null;
this.smtp = null;
this.previous = null;
}
/**
*
* @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;
}
}
class SMTPResponseMonitor {
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) => {
stream.emit('response', SMTPError.create('connection encountered an error', SMTPErrorStates.ERROR, err));
};
const timedout = (err) => {
stream.end();
stream.emit('response', SMTPError.create('timedout while connecting to smtp server', SMTPErrorStates.TIMEDOUT, err));
};
const watch = (data) => {
if (data !== null) {
buffer += data.toString();
notify();
}
};
const close = (err) => {
stream.emit('response', SMTPError.create('connection has closed', SMTPErrorStates.CONNECTIONCLOSED, err));
};
const end = (err) => {
stream.emit('response', SMTPError.create('connection has ended', SMTPErrorStates.CONNECTIONENDED, err));
};
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;
const CRLF$1 = '\r\n';
const GREYLIST_DELAY = 300;
let DEBUG = 0;
/**
* @param {...any[]} args the message(s) to log
* @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));
}
};
/**
* @param {function(...any[]): void} callback the function to call
* @param {...any[]} args the arguments to apply to the function
* @returns {void}
*/
const caller = (callback, ...args) => {
if (typeof callback === 'function') {
callback(...args);
}
};
class SMTPConnection extends EventEmitter {
/**
* 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.
*/
constructor({ timeout, host, user, password, domain, port, ssl, tls, logger, authentication, } = {}) {
var _a;
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;
this.greylistResponseTracker = new WeakSet();
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;
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`');
}
// 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;
}
/**
* 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.
*
* @public
* @param {function(...any[]): void} callback function to call after response
* @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);
caller(callback, SMTPError.create('could not establish an ssl connection', SMTPErrorStates.CONNECTIONAUTH));
}
else {
this._secure = true;
}
}
};
/**
* @param {Error} err err
* @returns {void}
*/
const connectedErrBack = (err) => {
if (!err) {
connected();
}
else {
this.close(true);
this.log(err);
caller(callback, SMTPError.create('could not connect', SMTPErrorStates.COULDNOTCONNECT, err));
}
};
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(() => {
caller(callback, SMTPError.create('bad response on connection', SMTPErrorStates.BADRESPONSE, err, msg.data));
});
}
};
this._state = SMTPState.CONNECTING;
this.log(`connecting: ${this.host}:${this.port}`);
if (this.ssl) {
this.sock = connect(this.port, this.host.trim(), typeof this.ssl === 'object' ? this.ssl : {}, connected);
}
else {
this.sock = new Socket();
this.sock.connect(this.port, this.host.trim(), connectedErrBack);
}
this.monitor = new SMTPResponseMonitor(this.sock, this.timeout, () => this.close(true));
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
* @param {function(...any[]): void} callback function to call after response
* @returns {void}
*/
send(str, callback) {
if (this.sock != null && this._state === SMTPState.CONNECTED) {
this.log(str);
this.sock.once('response', (err, msg) => {
if (err) {
caller(callback, err);
}
else {
this.log(msg.data);
caller(callback, null, msg);
}
});
if (this.sock.writable) {
this.sock.write(str);
}
}
else {
this.close(true);
caller(callback, SMTPError.create('no connection has been established', SMTPErrorStates.NOCONNECTION));
}
}
/**
* @public
* @param {string} cmd command to issue
* @param {function(...any[]): void} callback function to call after response
* @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 {
const code = Number(msg.code);
if (codesArray.indexOf(code) !== -1) {
caller(callback, err, msg.data, msg.message);
}
else if ((code === 450 || code === 451) &&
msg.message.toLowerCase().includes('greylist') &&
this.greylistResponseTracker.has(response) === false) {
this.greylistResponseTracker.add(response);
setTimeout(() => {
this.send(cmd + CRLF$1, response);
}, GREYLIST_DELAY);
}
else {
const suffix = msg.message ? `: ${msg.message}` : '';
const errorMessage = `bad response on command '${cmd.split(' ')[0]}'${suffix}`;
caller(callback, SMTPError.create(errorMessage, SMTPErrorStates.BADRESPONSE, null, msg.data));
}
}
};
this.greylistResponseTracker.delete(response);
this.send(cmd + CRLF$1, response);
}
/**
* @public
* @description SMTP 'helo' command.
*
* Hostname to send for self command defaults to the FQDN of the local
* host.
*
* 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
*
* @param {function(...any[]): void} callback function to call after response
* @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
* @param {function(...any[]): void} callback function to call after response
* @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;
new SMTPResponseMonitor(this.sock, this.timeout, () => this.close(true));
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
* @param {function(...any[]): void} callback function to call after response
* @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
* @param {function(...any[]): void} callback function to call after response
* @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
* @param {function(...any[]): void} callback function to call after response
* @returns {void}
*/
rset(callback) {
this.command('rset', callback);
}
/**
* @public
* @param {function(...any[]): void} callback function to call after response
* @returns {void}
*/
noop(callback) {
this.send('noop', callback);
}
/**
* @public
* @param {function(...any[]): void} callback function to call after response
* @param {string} from the sender
* @returns {void}
*/
mail(callback, from) {
this.command(`mail FROM:${from}`, callback);
}
/**
* @public
* @param {function(...any[]): void} callback function to call after response
* @param {string} to the receiver
* @returns {void}
*/
rcpt(callback, to) {
this.command(`RCPT TO:${to}`, callback, [250, 251]);
}
/**
* @public
* @param {function(...any[]): void} callback function to call after response
* @returns {void}
*/
data(callback) {
this.command('data', callback, [354]);
}
/**
* @public
* @param {function(...any[]): void} callback function to call after response
* @returns {void}
*/
data_end(callback) {
this.command(`${CRLF$1}.`, callback);
}
/**
* @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
* @param {function(...any[]): void} callback function to call after response
* @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
* @param {function(...any[]): void} callback function to call after response
* @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.
*
* @param {function(...any[]): void} callback function to call after response
* @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.
*
* @param {function(...any[]): void} callback function to call after response
* @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
*/
const encodeCramMd5 = (challenge) => {
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
*/
const encodePlain = () => Buffer.from(`\u0000${login.user()}\u0000${login.password()}`).toString('base64');
/**
* @see https://developers.google.com/gmail/xoauth2_protocol
* @returns {string} base64 xoauth2 auth token
*/
const encodeXoauth2 = () => Buffer.from(`user=${login.user()}\u0001auth=Bearer ${login.password()}\u0001\u0001`).toString('base64');
// List of authentication methods we support: from preferred to
// less preferred methods.
if (!method) {
const preferred = this.authentication;
let auth = '';
if (typeof ((_a = this.features) === null || _a === void 0 ? void 0 : _a.auth) === 'string') {
auth = this.features.auth;
}
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
* @param {unknown} data data
* @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
caller(callback, SMTPError.create('authorization.failed', SMTPErrorStates.AUTHFAILED, err, data));
};
/**
* @param {Error} err err
* @param {unknown} data data
* @returns {void}
*/
const response = (err, data) => {
if (err) {
failed(err, data);
}
else {
this.loggedin = true;
caller(callback, err, data);
}
};
/**
* @param {Error} err err
* @param {unknown} data data
* @param {string} msg msg
* @returns {void}
*/
const attempt = (err, data, msg) => {
if (err) {
failed(err, data);
}
else {
if (method === AUTH_METHODS['CRAM-MD5']) {
this.command(encodeCramMd5(msg), response, [235, 503]);
}
else if (method === AUTH_METHODS.LOGIN) {
this.command(Buffer.from(login.password()).toString('base64'), response, [235, 503]);
}
}
};
/**
* @param {Error} err err
* @param {unknown} data data
* @param {string} msg msg
* @returns {void}
*/
const attemptUser = (err, data) => {
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:
this.command(`AUTH ${AUTH_METHODS.LOGIN}`, attemptUser, [334]);
break;
case AUTH_METHODS.PLAIN:
this.command(`AUTH ${AUTH_METHODS.PLAIN} ${encodePlain()}`, response, [235, 503]);
break;
case AUTH_METHODS.XOAUTH2:
this.command(`AUTH ${AUTH_METHODS.XOAUTH2} ${encodeXoauth2()}`, response, [235, 503]);
break;
default:
caller(callback, SMTPError.create('no form of authorization supported', SMTPErrorStates.AUTHNOTSUPPORTED, null, data));
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
* @param {function(...any[]): void} [callback] function to call after response
* @returns {void}
*/
quit(callback) {
this.command('quit', (err, data) => {
caller(callback, err, data);
this.close();
}, [221, 250]);
}
}
class SMTPClient {
/**
* 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.
*
* @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
* @param {function(err: Error, msg: Message): void} callback .
* @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) {
const stack = this.createMessageStack(message, callback);
if (stack.to.length === 0) {
return callback(new Error('No recipients found in message'), msg);
}
this.queue.push(stack);
this._poll();
}
else {
callback(new Error(why), msg);
}
});
}
/**
* @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);
}
});
});
}
/**
* @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 () {
/* ø */
}) {
const [{ address: from }] = addressparser(message.header.from);
const stack = {
message,
to: [],
from,
callback: callback.bind(this),
};
const { header: { to, cc, bcc, 'return-path': returnPath }, } = message;
if ((typeof to === 'string' || Array.isArray(to)) && to.length > 0) {
stack.to = addressparser(to);
}
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));
}
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));
}
if (typeof returnPath === 'string' && returnPath.length > 0) {
const parsedReturnPath = addressparser(returnPath);
if (parsedReturnPath.length > 0) {
const [{ address: returnPathAddress }] = parsedReturnPath;
stack.returnPath = returnPathAddress;
}
}
return stack;
}
/**
* @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
* @param {MessageAttachment | MessageAttachment[]} attachment attachment
* @returns {boolean} whether the attachment contains inlined html
*/
_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();
}
}
export { AUTH_METHODS, BUFFERSIZE, DEFAULT_TIMEOUT, MIME64CHUNK, MIMECHUNK, Message, SMTPClient, SMTPConnection, SMTPError, SMTPErrorStates, SMTPResponseMonitor, SMTPState, addressparser, getRFC2822Date, getRFC2822DateUTC, isRFC2822Date, mimeEncode, mimeWordEncode };
//# sourceMappingURL=email.mjs.map