diff --git a/jobs/mailer.js b/jobs/mailer.js index 41861ccbb..015404d6c 100644 --- a/jobs/mailer.js +++ b/jobs/mailer.js @@ -1,63 +1,147 @@ -const mailer = require('../services/mailer'); +const { task } = require('../services/mailer'); +const nodemailer = require('nodemailer'); const debug = require('debug')('talk:jobs:mailer'); const Context = require('../graph/context'); const { get } = require('lodash'); +const { + SMTP_HOST, + SMTP_USERNAME, + SMTP_PORT, + SMTP_PASSWORD, + SMTP_FROM_ADDRESS, +} = require('../../config'); + +// parseSMTPPort will return the port for SMTP. +const parseSMTPPort = () => { + if (!SMTP_PORT) { + return 25; + } + + try { + return parseInt(SMTP_PORT); + } catch (e) { + throw new Error('TALK_SMTP_PORT is not an integer'); + } +}; + +// createTransport will create a new transport. +const createTransport = () => { + const options = { + host: SMTP_HOST, + }; + + if (SMTP_USERNAME && SMTP_PASSWORD) { + options.auth = { + user: SMTP_USERNAME, + pass: SMTP_PASSWORD, + }; + } + + // Get the SMTP port. + options.port = parseSMTPPort(); + + return nodemailer.createTransport(options); +}; + +// sharedTransport is the transport singleton. +let sharedTransport; + +// getTransport will retrieve the mailer transport singleton. +const getTransport = () => { + if (sharedTransport) { + return sharedTransport; + } + + // enabled is true when the required configuration is available. When testing + // is enabled, we will be simulating that emails are being sent, because in a + // production system, emails should and would be sent. + + // If the transport details aren't available, we will return null as the + // transport. + if (!SMTP_HOST || !SMTP_FROM_ADDRESS) { + return null; + } + + // Create the transport. + sharedTransport = createTransport(); + + return sharedTransport; +}; + +// getEmailAddress will retrieve the email address to send the message to from +// the job data. +const getEmailAddress = async ({ email, user }) => { + // If the message has a specific email already to sent it to, just assign + // that to the message. If the email does not have an email, and instead has + // a user id, then we should lookup the user with the graph and get their + // email. + if (email) { + return email; + } else { + // Get the user to send the message to. + const ctx = Context.forSystem(); + + const { data, errors } = await ctx.graphql( + ` + query GetUserEmail($user: ID!) { + user(id: $user) { + email + } + } + `, + { user } + ); + if (errors) { + throw errors; + } + + const email = get(data, 'user.email'); + if (!email) { + throw errors.ErrMissingEmail; + } + + return email; + } +}; + +// processJob will handle new jobs sent via this queue. +const processJob = transport => async ({ id, data }, done) => { + const { message } = data; + + // Get the email address from the job data. + message.to = await getEmailAddress(data); + + debug(`Starting to send mail for Job[${id}]`); + + // Actually send the email. + transport.sendMail(message, err => { + if (err) { + debug(`Failed to send mail for Job[${id}]:`, err); + return done(err); + } + + debug(`Finished sending mail for Job[${id}]`); + return done(); + }); +}; + /** * Start the queue processor for the mailer job. */ module.exports = () => { - debug(`Now processing ${mailer.task.name} jobs`); + // Get a transport. + const transport = getTransport(); + if (transport === null) { + console.warn( + new Error( + "sending email is not enabled because required configuration is not available so we can't create" + ) + ); + return; + } - return mailer.task.process(async ({ id, data }, done) => { - const { email, user, message } = data; + debug(`Now processing ${task.name} jobs`); - debug(`Starting to send mail for Job[${id}]`); - - // If the message has a specific email already to sent it to, just assign - // that to the message. If the email does not have an email, and instead has - // a user id, then we should lookup the user with the graph and get their - // email. - if (email) { - message.to = email; - } else { - // Get the user to send the message to. - const ctx = Context.forSystem(); - - try { - const { data, errors } = await ctx.graphql( - ` - query GetUserEmail($user: ID!) { - user(id: $user) { - email - } - } - `, - { user } - ); - if (errors) { - return done(errors); - } - const email = get(data, 'user.email'); - if (!email) { - return done(errors.ErrMissingEmail); - } - - message.to = email; - } catch (err) { - return done(err); - } - } - - // Actually send the email. - mailer.transport.sendMail(message, err => { - if (err) { - debug(`Failed to send mail for Job[${id}]:`, err); - return done(err); - } - - debug(`Finished sending mail for Job[${id}]`); - return done(); - }); - }); + return task.process(processJob(transport)); }; diff --git a/services/mailer.js b/services/mailer.js deleted file mode 100644 index 805cdee09..000000000 --- a/services/mailer.js +++ /dev/null @@ -1,208 +0,0 @@ -const nodemailer = require('nodemailer'); -const kue = require('./kue'); -const i18n = require('./i18n'); -const path = require('path'); -const fs = require('fs-extra'); -const { get, set, merge, template, isObject } = require('lodash'); -const { TEMPLATE_LOCALS } = require('../middleware/staticTemplate'); -const debug = require('debug')('talk:services:mailer'); - -const { - SMTP_HOST, - SMTP_USERNAME, - SMTP_PORT, - SMTP_PASSWORD, - SMTP_FROM_ADDRESS, - EMAIL_SUBJECT_PREFIX, -} = require('../config'); - -// load all the templates as strings -const templates = { - cache: {}, - registered: {}, -}; - -// Registers a template with the given filename and format. -templates.register = async (filename, name, format) => { - // Check to see if this template was already registered. - if (get(templates.registered, [name, format], null) !== null) { - return; - } - - const file = await fs.readFile(filename, 'utf8'); - const view = template(file); - - set(templates.registered, [name, format], view); -}; - -// load the templates per request during development -templates.render = async (name, format = 'txt', context) => { - // Check to see if the template is a registered template (provided by a plugin - // ) and prefer that first. - let view = get(templates.registered, [name, format], null); - if (view !== null) { - return view(context); - } - - if (process.env.NODE_ENV === 'production') { - // If we are in production mode, check the view cache. - const view = get(templates.cache, [name, format], null); - if (view !== null) { - return view(context); - } - } - - // Template was not registered and was not cached. Let's try and find it! - const filename = path.join( - __dirname, - 'email', - [name, format, 'ejs'].join('.') - ); - const file = await fs.readFile(filename, 'utf8'); - view = template(file); - - if (process.env.NODE_ENV === 'production') { - // If we are in production mode, fill the view cache. - set(templates.cache, [name, format], view); - } - - return view(context); -}; - -const mailer = { templates, helpers: {} }; - -// enabled is true when the required configuration is available. When testing -// is enabled, we will be simulating that emails are being sent, because in a -// production system, emails should and would be sent. -mailer.enabled = - Boolean(SMTP_HOST && SMTP_FROM_ADDRESS) || process.env.NODE_ENV === 'test'; - -if (mailer.enabled) { - const options = { - host: SMTP_HOST, - }; - - if (SMTP_USERNAME && SMTP_PASSWORD) { - options.auth = { - user: SMTP_USERNAME, - pass: SMTP_PASSWORD, - }; - } - - if (SMTP_PORT) { - try { - options.port = parseInt(SMTP_PORT); - } catch (e) { - throw new Error('TALK_SMTP_PORT is not an integer'); - } - } else { - options.port = 25; - } - - mailer.transport = nodemailer.createTransport(options); -} - -/** - * Create the new Task kue. - */ -mailer.task = new kue.Task({ - name: 'mailer', -}); - -/** - * registerHelpers will register the helpers on the mailer. - * - * @param {Object} helpers the helpers in object form that should be used by the - * mailer. - */ -mailer.registerHelpers = helpers => { - mailer.helpers = merge(mailer.helpers, helpers); -}; - -// createMessage creates a message payload to send a email to a user. -const createMessage = async options => { - // Create the new locals object and attach the static locals and the i18n - // framework. - const locals = merge({}, mailer.helpers, options.locals, TEMPLATE_LOCALS, { - t: i18n.t, - }); - - // Render the templates. - const [html, text] = await Promise.all( - ['html', 'txt'].map(fmt => { - return mailer.templates.render(template, fmt, locals); - }) - ); - - const subject = EMAIL_SUBJECT_PREFIX - ? `${EMAIL_SUBJECT_PREFIX} ${options.subject}` - : options.subject; - - return { - html, - text, - subject, - from: SMTP_FROM_ADDRESS, - }; -}; - -mailer.queue = async (message, recipient) => { - debug('Creating Job'); - - // Create the job to send the email later. - const job = await mailer.task.create( - merge( - { - title: 'Mail', - message, - }, - recipient - ) - ); - - debug(`Created Job[${job.id}]`); - - return job; -}; - -// createRecipient will extract the recipient details from the options. -const createRecipient = options => { - // Try to get the email if it was explicitly provided. - const email = get(options, 'email'); - if (email) { - return { email }; - } - - // Try to get the user if the email wasn't explicitly provided. - const user = isObject(options.user) ? get(options.user, 'id') : options.user; - if (user) { - return { user }; - } - - // If we don't have a user or a email, we can't send an email. - throw new Error('user/email not provided'); -}; - -/** - * send will prepare the message and queue the message to be sent. - */ -mailer.send = async options => { - if (!mailer.enabled) { - const err = new Error( - 'sending email is not enabled because required configuration is not available' - ); - console.warn(err); - return; - } - - // Create the recipient to sent the message to. - const recipient = createRecipient(options); - - // Create the message to send. - const message = await createMessage(options); - - // Create the job to send the message. - return mailer.queue(message, recipient); -}; - -module.exports = mailer; diff --git a/services/mailer/index.js b/services/mailer/index.js new file mode 100644 index 000000000..5f6adedef --- /dev/null +++ b/services/mailer/index.js @@ -0,0 +1,112 @@ +const kue = require('../kue'); +const i18n = require('../i18n'); +const { get, merge, isObject } = require('lodash'); +const { TEMPLATE_LOCALS } = require('../../middleware/staticTemplate'); +const debug = require('debug')('talk:services:mailer'); +const { SMTP_FROM_ADDRESS, EMAIL_SUBJECT_PREFIX } = require('../../config'); +const templates = require('./templates'); + +// createRecipient will extract the recipient details from the options. +const createRecipient = options => { + // Try to get the email if it was explicitly provided. + const email = get(options, 'email'); + if (email) { + return { email }; + } + + // Try to get the user if the email wasn't explicitly provided. + const user = isObject(options.user) ? get(options.user, 'id') : options.user; + if (user) { + return { user }; + } + + // If we don't have a user or a email, we can't send an email. + throw new Error('user/email not provided'); +}; + +// createMessage creates a message payload to send a email to a user. +const createMessage = async options => { + // Create the new locals object and attach the static locals and the i18n + // framework. + const locals = merge({}, mailer.helpers, options.locals, TEMPLATE_LOCALS, { + t: i18n.t, + }); + + // Render the templates. + const [html, text] = await Promise.all( + ['html', 'txt'].map(fmt => { + return mailer.templates.render(options.template, fmt, locals); + }) + ); + + const subject = EMAIL_SUBJECT_PREFIX + ? `${EMAIL_SUBJECT_PREFIX} ${options.subject}` + : options.subject; + + return { + html, + text, + subject, + from: SMTP_FROM_ADDRESS, + }; +}; + +const mailer = { templates, helpers: {} }; + +/** + * Create the new Task kue. + */ +mailer.task = new kue.Task({ + name: 'mailer', +}); + +/** + * registerHelpers will register the helpers on the mailer. + * + * @param {Object} helpers the helpers in object form that should be used by the + * mailer. + */ +mailer.registerHelpers = helpers => { + mailer.helpers = merge(mailer.helpers, helpers); +}; + +/** + * queue will add the message to the sending queue. + * + * @param {Object} message the message to be sent + * @param {Object} recipient the recipient to send it to + */ +mailer.queue = async (message, recipient) => { + debug('Creating Job'); + + // Create the job to send the email later. + const job = await mailer.task.create( + merge( + { + title: 'Mail', + message, + }, + recipient + ) + ); + + debug(`Created Job[${job.id}]`); + + return job; +}; + +/** + * send will prepare the message and queue the message to be sent. + */ +mailer.send = async options => { + // Create the recipient to sent the message to. + const recipient = createRecipient(options); + + // Create the message to send. + const message = await createMessage(options); + + // Create the job to send the message. + return mailer.queue(message, recipient); +}; + +module.exports = mailer; diff --git a/services/mailer/templates.js b/services/mailer/templates.js new file mode 100644 index 000000000..59bcdda5a --- /dev/null +++ b/services/mailer/templates.js @@ -0,0 +1,58 @@ +const path = require('path'); +const fs = require('fs-extra'); +const { get, set, template } = require('lodash'); + +// load all the templates as strings +const templates = { + cache: {}, + registered: {}, +}; + +// Registers a template with the given filename and format. +templates.register = async (filename, name, format) => { + // Check to see if this template was already registered. + if (get(templates.registered, [name, format], null) !== null) { + return; + } + + const file = await fs.readFile(filename, 'utf8'); + const view = template(file); + + set(templates.registered, [name, format], view); +}; + +// load the templates per request during development +templates.render = async (name, format = 'txt', context) => { + // Check to see if the template is a registered template (provided by a plugin + // ) and prefer that first. + let view = get(templates.registered, [name, format], null); + if (view !== null) { + return view(context); + } + + if (process.env.NODE_ENV === 'production') { + // If we are in production mode, check the view cache. + const view = get(templates.cache, [name, format], null); + if (view !== null) { + return view(context); + } + } + + // Template was not registered and was not cached. Let's try and find it! + const filename = path.join( + __dirname, + 'templates', + [name, format, 'ejs'].join('.') + ); + const file = await fs.readFile(filename, 'utf8'); + view = template(file); + + if (process.env.NODE_ENV === 'production') { + // If we are in production mode, fill the view cache. + set(templates.cache, [name, format], view); + } + + return view(context); +}; + +module.exports = templates; diff --git a/services/email/email-confirm.html.ejs b/services/mailer/templates/email-confirm.html.ejs similarity index 100% rename from services/email/email-confirm.html.ejs rename to services/mailer/templates/email-confirm.html.ejs diff --git a/services/email/email-confirm.txt.ejs b/services/mailer/templates/email-confirm.txt.ejs similarity index 100% rename from services/email/email-confirm.txt.ejs rename to services/mailer/templates/email-confirm.txt.ejs diff --git a/services/email/password-reset.html.ejs b/services/mailer/templates/password-reset.html.ejs similarity index 100% rename from services/email/password-reset.html.ejs rename to services/mailer/templates/password-reset.html.ejs diff --git a/services/email/password-reset.txt.ejs b/services/mailer/templates/password-reset.txt.ejs similarity index 100% rename from services/email/password-reset.txt.ejs rename to services/mailer/templates/password-reset.txt.ejs diff --git a/services/email/plain.html.ejs b/services/mailer/templates/plain.html.ejs similarity index 100% rename from services/email/plain.html.ejs rename to services/mailer/templates/plain.html.ejs diff --git a/services/email/plain.txt.ejs b/services/mailer/templates/plain.txt.ejs similarity index 100% rename from services/email/plain.txt.ejs rename to services/mailer/templates/plain.txt.ejs