UNPKG

@itentialopensource/adapter-email

Version:

Email notification adapter

684 lines (619 loc) 20.9 kB
/* @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;