@itentialopensource/adapter-email
Version:
Email notification adapter
684 lines (619 loc) • 20.9 kB
JavaScript
/* @copyright Itential, LLC 2019 (pre-modifications) */
// Set globals
/* global log */
/* global eventSystem */
/* eslint no-underscore-dangle: warn */
/* eslint no-loop-func: warn */
/* eslint no-cond-assign: warn */
/* eslint no-unused-vars: warn */
/* eslint consistent-return: warn */
const fs = require('fs');
const path = require('path');
const { EventEmitter } = require('events');
const { Buffer } = require('buffer');
const util = require('util');
const { ImapFlow } = require('imapflow');
const nodemailer = require('nodemailer');
// Instantiate the schema validator
const { validate } = require('jsonschema');
/**
* Schemas
*/
const targetSchema = {
$schema: 'http://json-schema.org/draft-06/schema#',
title: 'Target Schema',
description: 'The object for a notification target',
type: 'object',
properties: {
to: {
description: 'An array of recipient email addresses',
type: ['array', 'string']
},
cc: {
description:
'An array of recipient email addresses that will appear on the Cc: field',
type: 'array'
},
bcc: {
description:
'An array of recipient email addresses that will appear on the Bcc: field',
type: 'array'
},
displayName: {
description: 'The display name of the sender',
type: 'string',
default: 'IAP'
},
from: {
description: 'The email address of the sender',
type: 'string'
},
attachments: {
description: 'An array of attachments to include with the email sent',
type: 'array',
items: {
$ref: '#/definitions/attachment'
}
}
},
required: ['to'],
definitions: {
attachment: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'The name of the attachment file'
},
content: {
type: 'string',
description: 'The contents of the attachment file'
}
}
}
},
additonalProperties: false
};
const messageSchema = {
$schema: 'http://json-schema.org/draft-06/schema#',
title: 'Message Schema',
description: 'The object for a notification message',
type: 'object',
properties: {
subject: {
description: 'The subject of the message',
type: 'string'
},
text: {
description: 'The text body of the message',
type: 'string'
}
},
required: ['text'],
additonalProperties: false
};
/**
* Email Class
*/
class Email extends EventEmitter {
constructor(id, props) {
// Instantiate the EventEmitter super class
super();
// Capture the adapter id
this.id = id;
// Read the properties schema from the file system
const propertiesSchema = JSON.parse(
fs.readFileSync(path.join(__dirname, 'propertiesSchema.json'), 'utf-8')
);
// Validate the given properties against the schema
const result = validate(props, propertiesSchema, { nestedErrors: true });
// If invalid properties throw an error
if (!result.valid) {
log.error(`Error on validation of properties: ${result}`);
throw new Error(result.errors);
}
// Set the properties
this.props = props;
this.smtpOK = false;
this.imapOK = false;
this.mailset = new Set();
// Create smtp transport to email server with properties
this.transport = nodemailer.createTransport(this.props);
if (this.props.subscriptions && this.props.subscriptions.length > 0) {
this.listen();
}
}
updateAuthOptions(token) {
const { tokenRequest, ...options } = this.props;
if (options.auth && token.accessToken) {
options.auth.accessToken = token.accessToken;
}
if (options.auth && token.refreshToken && options.auth.accessUrl && options.auth.clientId && options.auth.clientSecret) {
options.auth.refreshToken = token.refreshToken;
}
if (options.auth && token.expires && options.auth.refreshToken && options.auth.accessUrl) {
options.auth.expires = token.expires;
}
return options;
}
async getNewToken(callback) {
if (!this.props.tokenRequest) {
return callback(null, 'Token request info is missing');
}
const { request, response: responseFields } = this.props.tokenRequest;
// Check if request and response fields exist and have values
if (!request || Object.keys(request).length === 0) {
return callback(null, 'Request field is missing');
}
if (!responseFields || Object.keys(responseFields).length === 0) {
return callback(null, 'Response field is missing');
}
const {
url, body, headers, method
} = request;
if (url === '' || url === null || url === undefined) {
return callback(null, 'Token request URL is missing');
}
if (method === '' || method === null || method === undefined) {
return callback(null, 'Token request method is missing');
}
let data;
if (method.toUpperCase() === 'POST') {
if (!body) {
return callback(null, 'Missing body for POST token request');
}
if (headers['Content-Type'] === 'application/x-www-form-urlencoded') {
data = new URLSearchParams(body).toString();
} else {
data = JSON.stringify(body);
}
}
try {
const response = await fetch(url, {
method,
headers: {
...headers,
'Content-Length': Buffer.byteLength(data)
},
body: data
});
if (!response.ok) {
return callback(null, `Failed to get token - ${response.status} ${response.statusText}`);
}
const responseData = await response.json();
const returnedToken = {};
// eslint-disable-next-line no-restricted-syntax
for (const [key, value] of Object.entries(responseFields)) {
returnedToken[key] = responseData[value];
}
return callback(returnedToken, null);
} catch (err) {
return callback(null, err);
}
}
/**
* Checks if current transport can connect to server
*
* @param {function} callback
*/
connect(callback) {
let retryCount = 0;
const attemptVerify = () => {
// Verify connectivity to server
this.transport.verify(async (err) => {
if (err) {
if ((err.message.includes('Authentication unsuccessful') || err.message.includes('Can\'t create new access token for user')) && this.props.auth.type === 'OAuth2' && retryCount < 1) {
log.info('Failed to verify connectivity, trying to get new tokens');
this.getNewToken((returnedToken, tokenErr) => {
if (tokenErr) {
this.emit('OFFLINE', { id: this.id });
log.error(err);
log.error(`Got connectivity issue and not able to get new token due to error: ${tokenErr}`);
return callback(null, err);
}
const options = this.updateAuthOptions(returnedToken);
log.info('Updating transport options');
this.transport = nodemailer.createTransport(options);
retryCount += 1;
log.info('Trying to verify connectivity again after updating transport options');
attemptVerify();
});
} else {
// Emit offline if connection produces error
this.emit('OFFLINE', { id: this.id });
log.error(err);
return callback(null, err);
}
} else {
// Emit online if connection succeeds
this.smtpOK = true;
this.smtpOK = true;
if (!this.props.subscriptions || this.props.subscriptions.length === 0) {
log.debug('emit online');
this.emit('ONLINE', { id: this.id });
} else if (this.imapOK) {
log.debug('emit online');
this.emit('ONLINE', { id: this.id });
}
return callback('success');
}
});
};
attemptVerify();
}
/**
* Checks to see if connection succeeded
*
* @param {function} callback
*/
healthCheck(callback) {
this.connect((data, err) => {
if (err) return callback('fail');
return callback('success');
});
}
/**
* Sends email notification to specific email address
*
* @param {object} targets
* @param {array} targets.to - List of email addresses (recipients)
* @param {string} [targets.from] - The name and email address of the sender
* @param {array} [targets.cc] - List of email addresses (recipients) for the Cc: field
* @param {array} [targets.bcc] - List of email addresses (recipients) for the Bcc: field
* @param {array} [targets.attachments] - List of attachments
* @param {object} message
* @param {string} [message.subject] - Optional subject of email
* @param {string} message.text - Body of email
* @param {function} callback
*/
notify(targets, message, callback) {
let retryCount = 0;
const attemptSendMail = () => {
// Check if targets object is valid
const targetValidation = validate(targets, targetSchema);
// Check if message object is valid
const messageValidation = validate(message, messageSchema);
// Set up and fill errors array
const errors = [];
if (!targetValidation.valid) {
targetValidation.errors.map((error) => errors.push(`target ${error.message}`));
}
if (!messageValidation.valid) {
messageValidation.errors.map((error) => errors.push(`message ${error.message}`));
}
// Return an error if invalid
if (errors.length > 0) {
log.error(`notify parameters are not valid: ${JSON.stringify(errors)}`);
return callback(null, errors);
}
// Get the subject for the email
const { subject } = message;
const mailOptions = {
subject
};
// Get the email body
// If incoming text contains html tags, assign to html property and
// if not, assign to text property
if (/<[a-zA-Z/][\s\S]*>/.test(message.text)) {
mailOptions.html = message.text;
} else {
mailOptions.text = message.text;
}
// If targets.to is an array, make it a comma separated string
mailOptions.to = Array.isArray(targets.to)
? targets.to.join(', ')
: targets.to;
// If there is a targets.cc, since it is an array, make it a comma separated string
if (targets.cc) {
mailOptions.cc = targets.cc.join(', ');
}
// If there is a targets.bcc, since it is an array, make it a comma separated string
if (targets.bcc) {
mailOptions.bcc = targets.bcc.join(', ');
}
// If there is a targets.attachments, maps properties to those expected by nodemailer
if (targets.attachments) {
mailOptions.attachments = targets.attachments.map((attachment) => ({
filename: attachment.name,
content: attachment.content
}));
}
if (message.additionalFields) {
Object.assign(mailOptions, message.additionalFields);
}
if (message.headers) {
mailOptions.headers = message.headers;
}
// If there is a targets.from, sets that as from email address
const displayName = targets.displayName || this.props.displayName || 'IAP';
const fromAddress = targets.from || this.props.auth.user || 'admin@pronghorn';
mailOptions.from = `${displayName} <${fromAddress}>`;
log.info(`Notification options: ${JSON.stringify(mailOptions)}`);
// Sends the email through smtp transport
return this.transport.sendMail(mailOptions, (error, info) => {
if (error) {
log.error(`Received error on sending email: ${error}`);
if (error.message && error.message.includes('Authentication unsuccessful') && this.props.auth.type === 'OAuth2' && retryCount < 1) {
log.info('Trying to get new tokens');
this.getNewToken((returnedToken, tokenErr) => {
if (tokenErr) {
log.error(`Received error on getting new token: ${tokenErr}`);
return callback(null, error);
}
const options = this.updateAuthOptions(returnedToken);
log.debug('Updating transport with options', JSON.stringify(options));
this.transport = nodemailer.createTransport(options);
retryCount += 1;
log.info('Trying to send mail again');
attemptSendMail();
});
} else {
return callback(null, error);
}
} else {
// Build the response
const response = {
accepted: info.accepted,
rejected: info.rejected
};
return callback(response);
}
});
};
attemptSendMail();
}
/**
* Sends email to specific email address
*
* @param {string} from - sending email address
* @param {array} to - List of email addresses (recipients)
* @param {string} subject - Optional subject of email
* @param {string} body - Body of email
* @param {function} callback
*/
mail(from, to, subject, body, callback) {
const deprecationCall = util.deprecate(
() => { },
'mail in adapter-email is deprecated. Use API call Email.mailWithOptions instead.'
);
deprecationCall();
const targets = {
from,
to
};
const message = {
subject,
text: body
};
// make sure the data format is proper
if (!Array.isArray) {
targets.to = [to];
}
if (typeof body !== 'string') {
message.text = JSON.stringify(body, null, 4);
}
this.notify(targets, message, callback);
}
/**
* Send email with cc, bcc, and attachment options.
*
* @param {string} from - The email address of the sender
* @param {array} to - List of email addresses (recipients)
* @param {string} subject - Optional subject of email
* @param {string} body - Body of email
* @param {string} [displayName] - The display name of the sender, the default value is 'IAP'
* @param {array} [cc] - List of email addresses (recipients) for the Cc: field
* @param {array} [bcc] - List of email addresses (recipients) for the Bcc: field
* @param {array} [attachments] - List of attachments
* @param {function} callback
*/
mailWithOptions(
from,
to,
subject,
body,
displayName,
cc,
bcc,
attachments,
callback
) {
this.mailWithAdvancedFields(from, to, subject, body, displayName, cc, bcc, attachments, null, null, callback);
}
/**
* Send email with advanced fields and headers.
*
* @param {string} from - The email address of the sender
* @param {array} to - List of email addresses (recipients)
* @param {string} subject - Optional subject of email
* @param {string} body - Body of email
* @param {string} [displayName] - The display name of the sender, the default value is 'IAP'
* @param {array} [cc] - List of email addresses (recipients) for the Cc: field
* @param {array} [bcc] - List of email addresses (recipients) for the Bcc: field
* @param {array} [attachments] - List of attachments
* @param {object} additionalFields - Advanced fields such as priority, date, etc.
* @param {object} headers - Additional header fields, such as X-MSMail-Priority, Importance, etc.
* @param {function} callback
*/
mailWithAdvancedFields(
from,
to,
subject,
body,
displayName,
cc,
bcc,
attachments,
additionalFields,
headers,
callback
) {
const sanitizedAttachments = attachments || [];
const sanitizedCc = cc || [];
const sanitizedBcc = bcc || [];
const targets = {
from,
to,
cc: sanitizedCc,
bcc: sanitizedBcc,
attachments: sanitizedAttachments,
displayName
};
const message = {
subject,
text: body
};
if (additionalFields) {
message.additionalFields = additionalFields;
}
if (headers) {
message.headers = headers;
}
if (typeof body !== 'string') {
message.text = JSON.stringify(body, null, 4);
}
this.notify(targets, message, callback);
}
/**
* Re-establish connection upon error or disconnect
*/
reconnect() {
const reconnectTime = 5;
log.info(`Attempting to re-establish connection in ${reconnectTime}s`);
// start the mailListener
setTimeout(() => this.listen(), reconnectTime * 1000);
}
/**
* Listens to email events
*/
listen() {
const logger = {
warn: log.warn,
info: log.info,
error: log.error,
fatal: log.error,
debug: log.debug,
trace: log.trace
};
const mailListenerProps = {
host: this.props.host,
port: this.props.port,
secure: this.props.secure,
tls: this.props.tls,
logger,
auth: {}
};
log.debug(
`going to listen for mail with: ${JSON.stringify(mailListenerProps)}`
);
mailListenerProps.auth.user = this.props.auth.user;
mailListenerProps.auth.pass = this.props.auth.pass;
this.client = new ImapFlow(mailListenerProps);
this.client.on('mailboxOpen', (mb) => {
log.debug(`IMAP connected. Mailbox ${mb.path} was opened`);
this.imapOK = true;
if (this.smtpOK) {
log.debug('emit online');
this.emit('ONLINE', { id: this.id });
}
this.getUnread();
});
this.client.on('error', (err) => {
log.error('received an error');
log.error(err);
if (!this.imapOK) return;
this.imapOK = false;
this.emit('OFFLINE', { id: this.id });
this.reconnect();
});
this.client.on('close', () => {
log.warn('IMAP Connection Closed');
if (!this.imapOK) return;
this.imapOK = false;
this.emit('OFFLINE', { id: this.id });
this.reconnect();
});
this.client.on('exists', () => {
log.debug('Mail received');
this.getUnread();
});
// // start listening
this.client
.connect()
.then(() => {
this.client.mailboxOpen('INBOX');
})
.catch((err) => {
log.error('Unable to create IMAP connection');
log.error(err);
this.reconnect();
});
}
async getUnread() {
const uidList = await this.client.search({ seen: false }, { uid: true });
log.debug('uidList of unread');
log.debug(uidList);
const range = `${uidList[0]}:${uidList[uidList.length - 1]}`;
await this.client.messageFlagsAdd(range, ['\\Seen'], {
uid: true
});
try {
const query = {
envelope: true,
bodyStructure: true,
bodyParts: ['text']
};
const mailPromises = [];
uidList.forEach((uid) => {
if (!this.mailset.has(uid)) {
const emailPromise = this.client.fetchOne(uid, query, { uid: true });
mailPromises.push(emailPromise);
this.mailset.add(uid);
}
});
if (mailPromises.length === 0) return;
const emails = await Promise.all(mailPromises);
emails.forEach((mail) => {
const buffer1 = mail.bodyParts.get('text');
const text = buffer1.toString('utf-8', 0, 12);
const cleanMail = {
from: mail.envelope.from,
to: mail.envelope.to,
date: mail.envelope.date,
subject: mail.envelope.subject,
text,
messageId: mail.envelope.messageId
};
const incomingEmailSizeLimit = this.props.incomingEmailSizeLimit || 10000;
const mailAsBuffer = Buffer.from(
JSON.stringify(mail, (key, val) => (typeof val === 'bigint' ? val.toString() : val))
);
const mailBufferSize = Buffer.byteLength(mailAsBuffer, 'utf-8');
log.debug(JSON.stringify(cleanMail));
if (mailBufferSize <= incomingEmailSizeLimit) {
log.debug('publishing cleanMail to email-event-unseen');
eventSystem.publish('email-event-unseen', cleanMail);
} else if (
mailBufferSize > incomingEmailSizeLimit
&& mailBufferSize <= 1000000
) {
log.error(
`email size exceeded the limit and can be updated in adapter props up to 1MB. Could not publish ${cleanMail} to email-event-unseen.`
);
} else if (
mailBufferSize > incomingEmailSizeLimit
&& mailBufferSize > 1000000
) {
log.error(
`email size exceeded the maximum limit which is 1MB. Could not publish ${cleanMail} to email-event-unseen.`
);
}
this.mailset.delete(mail.uid); // remove mail from mailset
});
log.debug('Finished fetching emails');
} catch (err) {
log.error(`unable to publish message ${err}`);
}
}
}
module.exports = Email;