@barchart/common-node-js
Version:
Common classes, utilities, and functions for building Node.js servers
394 lines (307 loc) • 10.8 kB
JavaScript
const aws = require('aws-sdk'),
log4js = require('log4js'),
nodemailer = require('nodemailer');
const assert = require('@barchart/common-js/lang/assert'),
Disposable = require('@barchart/common-js/lang/Disposable'),
is = require('@barchart/common-js/lang/is'),
object = require('@barchart/common-js/lang/object'),
RateLimiter = require('@barchart/common-js/timing/RateLimiter');
const DelegateReadStream = require('./../stream/DelegateReadStream');
module.exports = (() => {
'use strict';
const logger = log4js.getLogger('common-node/aws/SesProvider');
/**
* A facade for Amazon's Simple Email Service (SES). The constructor
* accepts configuration options. The promise-based instance functions
* abstract knowledge of the AWS API.
*
* @public
* @extends Disposable
* @param {object} configuration
* @param {string} configuration.region - The AWS region (e.g. "us-east-1").
* @param {string=} configuration.apiVersion - The SES version (defaults to "2010-12-01").
* @param {string=} configuration.recipientOverride - If specified, all emails sent will be redirected to this email address, ignoring the specified recipient.
* @param {number=} configuration.rateLimitPerSecond - The number of emails which will be sent to the AWS SDK within one second (defaults to 10).
*/
class SesProvider extends Disposable {
constructor(configuration) {
super();
assert.argumentIsRequired(configuration, 'configuration');
assert.argumentIsRequired(configuration.region, 'configuration.region', String);
assert.argumentIsOptional(configuration.apiVersion, 'configuration.apiVersion', String);
if (is.array(configuration.recipientOverride)) {
assert.argumentIsArray(configuration.recipientOverride, 'configuration.recipientOverride', String);
} else {
assert.argumentIsOptional(configuration.recipientOverride, 'configuration.recipientOverride', String);
}
assert.argumentIsOptional(configuration.rateLimitPerSecond, 'configuration.rateLimitPerSecond', Number);
this._configuration = configuration;
this._sesv2 = null;
this._transport = null;
this._started = false;
this._rateLimiters = { };
this._rateLimiters.send = new RateLimiter(configuration.rateLimitPerSecond || 10, 1000);
this._rateLimiters.suppressed = new RateLimiter(configuration.rateLimitPerSecond || 1, 4000);
}
/**
* Initializes the Amazon SDK. Call this before invoking any other instance
* functions.
*
* @public
* @async
* @returns {Promise<void>}
*/
async start() {
if (this.getIsDisposed()) {
throw new Error('Unable to start, the SES provider has been disposed.');
}
if (!this._started) {
try {
aws.config.update({ region: this._configuration.region });
this._transport = nodemailer.createTransport({
SES: new aws.SESV2({ apiVersion: '2019-09-27' })
});
this._sesv2 = new aws.SESV2({ apiVersion: this._configuration.apiVersion || '2019-09-27' });
logger.info('The SES provider has started');
this._started = true;
} catch (e) {
logger.error('The SES provider failed to start', e);
throw e;
}
}
}
/**
* Returns a clone of the configuration object originally passed
* to the constructor.
*
* @returns {Object}
*/
getConfiguration() {
if (this.getIsDisposed()) {
throw new Error('The SES provider has been disposed.');
}
return object.clone(this._configuration);
}
async send(options) {
checkReady.call(this);
assert.argumentIsRequired(options.senderAddress, 'senderAddress', String);
assert.argumentIsRequired(options.recipientAddress, 'recipientAddress', String);
assert.argumentIsRequired(options.subject, 'subject', String);
assert.argumentIsRequired(options.recipientAddress, 'senderAddress', String);
assert.argumentIsOptional(options.headers, 'headers', Object);
await this._rateLimiters.send.enqueue(async () => {
logger.debug('Sending email to [', options.recipientAddress, ']');
try {
const result = await this._transport.sendMail({
from: options.senderAddress,
to: options.recipientAddress,
subject: options.subject,
html: options.htmlBody,
text: options.textBody,
headers: options.headers
});
logger.debug('Sent email to [', options.recipientAddress, ']');
return result;
} catch (error) {
logger.error('SES provider failed to send email message', options);
logger.error(error);
throw error;
}
});
}
/**
* Attempts to send an email.
*
* @public
* @async
* @param {string} senderAddress - The "from" email address.
* @param {string|string[]} recipientAddress - The "to" email address(es).
* @param {string=} subject - The email's subject.
* @param {string=} htmlBody - The email's HTML body.
* @param {string=} textBody - The email's text body.
* @returns {Promise<void>}
*/
async sendEmail(senderAddress, recipientAddress, subject, htmlBody, textBody) {
checkReady.call(this);
assert.argumentIsRequired(senderAddress, 'senderAddress', String);
if (is.array(recipientAddress)) {
assert.argumentIsArray(recipientAddress, 'recipientAddress', String);
} else {
assert.argumentIsRequired(recipientAddress, 'recipientAddress', String);
}
assert.argumentIsOptional(subject, 'subject', String);
assert.argumentIsOptional(htmlBody, 'htmlBody', String);
assert.argumentIsOptional(textBody, 'textBody', String);
if (this._configuration.recipientOverride) {
logger.warn('Overriding email recipient for testing purposes, using [', this._configuration.recipientOverride, ']');
recipientAddress = this._configuration.recipientOverride;
}
const recipientAddressesToUse = is.array(recipientAddress) ? recipientAddress : [recipientAddress];
const params = {
Destination: {
ToAddresses: recipientAddressesToUse
},
Content: {
Simple: {
Subject: { Data: subject || '' },
Body: {}
}
},
FromEmailAddress: senderAddress
};
if (is.string(htmlBody) && htmlBody.length > 0) {
params.Content.Simple.Body.Html = { Data: htmlBody };
}
if (is.string(textBody) && textBody.length > 0) {
params.Content.Simple.Body.Text = { Data: textBody };
}
await this._rateLimiters.send.enqueue(async () => {
try {
logger.debug('Sending email to [', recipientAddress, ']');
await this._sesv2.sendEmail(params).promise();
logger.debug('Sent email to [', recipientAddress, ']');
} catch (error) {
logger.error('SES provider failed to send email message', params);
logger.error(error);
throw error;
}
});
}
/**
* Returns a specific item on the account-level suppression list.
*
* @public
* @async
* @param {string} email
* @returns {Promise<Object|null>}
*/
async getSuppressedItem(email) {
checkReady.call(this);
assert.argumentIsRequired(email, 'email', String);
let item;
try {
const response = await this._sesv2.getSuppressedDestination({ EmailAddress: email }).promise();
item = transformSuppressionListItem(response.SuppressedDestination);
} catch (e) {
if (e && e.code === 'NotFoundException') {
item = null;
} else {
throw e;
}
}
return item;
}
/**
* Returns the list of items on the account-level suppression list.
*
* @public
* @async
* @returns {Promise<*[]>}
*/
async getSuppressedItems() {
checkReady.call(this);
const items = [];
let token = null;
while (true) {
const params = {};
if (token) {
params.NextToken = token;
}
const response = await this._sesv2.listSuppressedDestinations(params).promise();
const batch = response.SuppressedDestinationSummaries;
for (let i = 0; i < batch.length; i++) {
items.push(transformSuppressionListItem(batch[i]));
}
token = response.NextToken || null;
if (token === null) {
break;
}
}
return items;
}
/**
* Creates a readable stream for suppressed items.
*
* @public
* @param {Boolean} discrete
* @returns {Stream.Readable}
*/
getSuppressedItemStream(discrete) {
checkReady.call(this);
let done = false;
let token = null;
const delegate = async() => {
if (done) {
return null;
}
const response = await this._rateLimiters.suppressed.enqueue(async () => {
const params = { };
if (token !== null) {
params.NextToken = token;
}
return this._sesv2.listSuppressedDestinations(params).promise();
});
const items = response.SuppressedDestinationSummaries.reduce((accumulator, raw) => {
accumulator.push(transformSuppressionListItem(raw));
return accumulator;
}, [ ]);
token = response.NextToken || null;
if (token === null) {
done = true;
}
return items;
};
return new DelegateReadStream(delegate, null, discrete);
}
/**
* Adds an email address to the suppression list.
*
* @public
* @async
* @param {string} email - The email address to suppress.
* @param {string=} reason - The reason for suppression (valid values: "BOUNCE", "COMPLAINT"). Defaults to "COMPLAINT".
* @returns {Promise<Object>}
*/
async addSuppressedItem(email, reason = 'COMPLAINT') {
checkReady.call(this);
assert.argumentIsRequired(email, 'email', String);
assert.argumentIsOptional(reason, 'reason', String);
assert.argumentIsValid(reason, 'reason', r => r.toUpperCase() === 'BOUNCE' || r.toUpperCase() === 'COMPLAINT', 'must be one of [ BOUNCE, COMPLIANT ]');
await this._sesv2.putSuppressedDestination({ EmailAddress: email, Reason: reason.toUpperCase() }).promise();
return await this.getSuppressedItem(email);
}
/**
* Removes an email address from the suppression list.
*
* @public
* @async
* @param {string} email - The email address to remove from the suppression list.
* @returns {Promise<void>}
*/
async removeSuppressedItem(email) {
checkReady.call(this);
assert.argumentIsRequired(email, 'email', String);
await this._sesv2.deleteSuppressedDestination({ EmailAddress: email }).promise();
}
_onDispose() {
this._rateLimiters.send.dispose();
this._rateLimiters.suppressed.dispose();
this._rateLimiters = null;
}
toString() {
return '[SesProvider]';
}
}
function checkReady() {
if (this.getIsDisposed()) {
throw new Error('The SES provider has been disposed.');
}
if (!this._started) {
throw new Error('The SES provider has not been started.');
}
}
function transformSuppressionListItem(data) {
return { email: data.EmailAddress, reason: data.Reason, date: data.LastUpdateTime };
}
return SesProvider;
})();