diff --git a/package.json b/package.json index 40c04bdfbb..8e0603e0db 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "babel": "^5.5.4", "babelify": "^7.2.0", "body-parser": "^1.14.1", - "compression": "^1.6.0", "bower": "~1.3.12", "browserify": "~12.0.1", "coffee-script": "1.6.x", + "compression": "^1.6.0", "connect-ratelimit": "0.0.7", "cookie-parser": "^1.4.0", "cookie-session": "^1.2.0", @@ -64,7 +64,7 @@ "nconf": "~0.8.2", "newrelic": "~1.23.0", "nib": "~1.0.1", - "nodemailer": "~0.5.2", + "nodemailer": "^1.9.0", "pageres": "^1.0.1", "passport": "~0.2.1", "passport-facebook": "2.0.0", diff --git a/tasks/gulp-console.js b/tasks/gulp-console.js index 46f4ea44ea..96af26a27a 100644 --- a/tasks/gulp-console.js +++ b/tasks/gulp-console.js @@ -2,7 +2,6 @@ import mongoose from 'mongoose'; import autoinc from 'mongoose-id-autoinc'; import logger from '../website/src/libs/api-v3/logger'; import nconf from 'nconf'; -import utils from '../website/src/libs/utils'; import repl from 'repl'; import gulp from 'gulp'; @@ -19,8 +18,6 @@ let improveRepl = (context) => { process.stdout.write('\u001B[2J\u001B[0;0f'); }}); - utils.setupConfig(); - context.Challenge = require('../website/src/models/challenge').model; context.Group = require('../website/src/models/group').model; context.User = require('../website/src/models/user').model; diff --git a/test/api/v3/unit/libs/email.test.js b/test/api/v3/unit/libs/email.test.js new file mode 100644 index 0000000000..353ac0ad09 --- /dev/null +++ b/test/api/v3/unit/libs/email.test.js @@ -0,0 +1,217 @@ +import request from 'request'; +import nconf from 'nconf'; +import nodemailer from 'nodemailer'; +import Q from 'q'; +import logger from '../../../../../website/src/libs/api-v3/logger'; + +function getUser () { + return { + _id: 'random _id', + auth: { + local: { + username: 'username', + email: 'email@email', + }, + facebook: { + emails: [{ + value: 'email@facebook' + }], + displayName: 'fb display name', + } + }, + profile: { + name: 'profile name', + }, + preferences: { + emailNotifications: { + unsubscribeFromAll: false + }, + }, + }; +}; + +describe('emails', () => { + let pathToEmailLib = '../../../../../website/src/libs/api-v3/email'; + + beforeEach(() => { + delete require.cache[require.resolve(pathToEmailLib)]; + }); + + describe('sendEmail', () => { + it('can send an email using the default transport', () => { + let sendMailSpy = sandbox.stub().returns(Q.defer().promise); + + sandbox.stub(nodemailer, 'createTransport').returns({ + sendMail: sendMailSpy, + }); + + let attachEmail = require(pathToEmailLib); + attachEmail.send(); + expect(sendMailSpy).to.be.calledOnce; + }); + + it('logs errors', (done) => { + let deferred = Q.defer(); + let sendMailSpy = sandbox.stub().returns(deferred.promise); + + sandbox.stub(nodemailer, 'createTransport').returns({ + sendMail: sendMailSpy, + }); + sandbox.stub(logger, 'error'); + + let attachEmail = require(pathToEmailLib); + attachEmail.send(); + expect(sendMailSpy).to.be.calledOnce; + deferred.reject(); + deferred.promise.catch((err) => { + expect(logger.error).to.be.calledOnce; + done(); + }); + }); + }); + + describe('getUserInfo', () => { + it('returns an empty object if no field request', () => { + let attachEmail = require(pathToEmailLib); + let getUserInfo = attachEmail.getUserInfo; + expect(getUserInfo({}, [])).to.be.empty; + }); + + it('returns correct user data', () => { + let attachEmail = require(pathToEmailLib); + let getUserInfo = attachEmail.getUserInfo; + let user = getUser(); + let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']); + + expect(data).to.have.property('name', user.profile.name); + expect(data).to.have.property('email', user.auth.local.email); + expect(data).to.have.property('_id', user._id); + expect(data).to.have.property('canSend', true); + }); + + it('returns correct user data [facebook users]', () => { + let attachEmail = require(pathToEmailLib); + let getUserInfo = attachEmail.getUserInfo; + let user = getUser(); + delete user.profile['name']; + delete user.auth['local']; + + let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']); + + expect(data).to.have.property('name', user.auth.facebook.displayName); + expect(data).to.have.property('email', user.auth.facebook.emails[0].value); + expect(data).to.have.property('_id', user._id); + expect(data).to.have.property('canSend', true); + }); + + it('has fallbacks for missing data', () => { + let attachEmail = require(pathToEmailLib); + let getUserInfo = attachEmail.getUserInfo; + let user = getUser(); + delete user.profile['name']; + delete user.auth.local['email'] + delete user.auth['facebook']; + + let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']); + + expect(data).to.have.property('name', user.auth.local.username); + expect(data).not.to.have.property('email'); + expect(data).to.have.property('_id', user._id); + expect(data).to.have.property('canSend', true); + }); + }); + + describe('sendTxnEmail', () => { + beforeEach(() => { + sandbox.stub(request, 'post'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('can send a txn email to one recipient', () => { + sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); + let attachEmail = require(pathToEmailLib); + let sendTxnEmail = attachEmail.sendTxn; + let emailType = 'an email type'; + let mailingInfo = { + name: 'my name', + email: 'my@email', + }; + + sendTxnEmail(mailingInfo, emailType); + expect(request.post).to.be.calledWith(sinon.match({ + json: { + data: { + emailType: sinon.match.same(emailType), + to: sinon.match((value) => { + return Array.isArray(value) && value[0].name === mailingInfo.name; + }, 'matches mailing info array'), + } + } + })); + }); + + it('does not send email if address is missing', () => { + sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); + let attachEmail = require(pathToEmailLib); + let sendTxnEmail = attachEmail.sendTxn; + let emailType = 'an email type'; + let mailingInfo = { + name: 'my name', + //email: 'my@email', + }; + + sendTxnEmail(mailingInfo, emailType); + expect(request.post).not.to.be.called; + }); + + it('uses getUserInfo in case of user data', () => { + sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); + let attachEmail = require(pathToEmailLib); + let sendTxnEmail = attachEmail.sendTxn; + let emailType = 'an email type'; + let mailingInfo = getUser(); + + sendTxnEmail(mailingInfo, emailType); + expect(request.post).to.be.calledWith(sinon.match({ + json: { + data: { + emailType: sinon.match.same(emailType), + to: sinon.match(val => val[0]._id === mailingInfo._id), + } + } + })); + }); + + it('sends email with some default variables', () => { + sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); + let attachEmail = require(pathToEmailLib); + let sendTxnEmail = attachEmail.sendTxn; + let emailType = 'an email type'; + let mailingInfo = { + name: 'my name', + email: 'my@email', + }; + let variables = [1,2,3]; + + sendTxnEmail(mailingInfo, emailType, variables); + expect(request.post).to.be.calledWith(sinon.match({ + json: { + data: { + variables: sinon.match((value) => { + return value[0].name === 'BASE_URL'; + }, 'matches variables'), + personalVariables: sinon.match((value) => { + return (value[0].rcpt === mailingInfo.email + && value[0].vars[0].name === 'RECIPIENT_NAME' + && value[0].vars[1].name === 'RECIPIENT_UNSUB_URL' + ); + }, 'matches personal variables'), + } + } + })); + }); + }); +}); diff --git a/test/api/v3/unit/libs/encryption.test.js b/test/api/v3/unit/libs/encryption.test.js new file mode 100644 index 0000000000..dcab9bffd3 --- /dev/null +++ b/test/api/v3/unit/libs/encryption.test.js @@ -0,0 +1,15 @@ +import { + encrypt, + decrypt, +} from '../../../../../website/src/libs/api-v3/encryption'; + +describe('encryption', () => { + it('can encrypt and decrypt', () => { + let data = 'some secret text'; + let encrypted = encrypt(data); + let decrypted = decrypt(encrypted); + + expect(encrypted).not.to.equal(data); + expect(data).to.equal(decrypted); + }); +}); diff --git a/test/server_side/controllers/groups.test.js b/test/server_side/controllers/groups.test.js index 9e970c4afc..9574ed7640 100644 --- a/test/server_side/controllers/groups.test.js +++ b/test/server_side/controllers/groups.test.js @@ -8,7 +8,7 @@ var Group = require('../../../website/src/models/group').model; var groupsController = require('../../../website/src/controllers/api-v2/groups'); describe('Groups Controller', function() { - var utils = require('../../../website/src/libs/utils'); + var utils = require('../../../website/src/libs/api-v2/utils'); describe('#invite', function() { var res, req, user, group; diff --git a/website/src/controllers/api-v2/auth.js b/website/src/controllers/api-v2/auth.js index 11cab90624..dd379cc095 100644 --- a/website/src/controllers/api-v2/auth.js +++ b/website/src/controllers/api-v2/auth.js @@ -3,7 +3,7 @@ var validator = require('validator'); var passport = require('passport'); var shared = require('../../../../common'); var async = require('async'); -var utils = require('../../libs/utils'); +var utils = require('../../libs/api-v2/utils'); var nconf = require('nconf'); var request = require('request'); var FirebaseTokenGenerator = require('firebase-token-generator'); diff --git a/website/src/controllers/api-v2/challenges.js b/website/src/controllers/api-v2/challenges.js index 21104f2885..89d68495d6 100644 --- a/website/src/controllers/api-v2/challenges.js +++ b/website/src/controllers/api-v2/challenges.js @@ -9,7 +9,7 @@ var Group = require('./../../models/group').model; var Challenge = require('./../../models/challenge').model; var logging = require('./../../libs/api-v2/logging'); var csv = require('express-csv'); -var utils = require('../../libs/utils'); +var utils = require('../../libs/api-v2/utils'); var api = module.exports; var pushNotify = require('./../pushNotifications'); diff --git a/website/src/controllers/api-v2/groups.js b/website/src/controllers/api-v2/groups.js index 62ac3804b5..0355148856 100644 --- a/website/src/controllers/api-v2/groups.js +++ b/website/src/controllers/api-v2/groups.js @@ -9,7 +9,7 @@ var _ = require('lodash'); var nconf = require('nconf'); var async = require('async'); var Q = require('q'); -var utils = require('./../../libs/utils'); +var utils = require('./../../libs/api-v2/utils'); var shared = require('../../../../common'); var User = require('./../../models/user').model; var Group = require('./../../models/group').model; diff --git a/website/src/controllers/api-v2/members.js b/website/src/controllers/api-v2/members.js index c528b92cd7..1c3bb448ac 100644 --- a/website/src/controllers/api-v2/members.js +++ b/website/src/controllers/api-v2/members.js @@ -5,7 +5,7 @@ var api = module.exports; var async = require('async'); var _ = require('lodash'); var shared = require('../../../../common'); -var utils = require('../../libs/utils'); +var utils = require('../../libs/api-v2/utils'); var nconf = require('nconf'); var pushNotify = require('./../pushNotifications'); diff --git a/website/src/controllers/api-v2/unsubscription.js b/website/src/controllers/api-v2/unsubscription.js index 772d0580e9..65f37f6298 100644 --- a/website/src/controllers/api-v2/unsubscription.js +++ b/website/src/controllers/api-v2/unsubscription.js @@ -1,6 +1,6 @@ var User = require('../../models/user').model; var EmailUnsubscription = require('../../models/emailUnsubscription').model; -var utils = require('../../libs/utils'); +var utils = require('../../libs/api-v2/utils'); var i18n = require('../../../../common').i18n; var api = module.exports = {}; diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index 5c6d256044..519ae0c557 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -5,7 +5,7 @@ var nconf = require('nconf'); var async = require('async'); var shared = require('../../../../common'); var User = require('./../../models/user').model; -var utils = require('./../../libs/utils'); +var utils = require('./../../libs/api-v2/utils'); var analytics = utils.analytics; var Group = require('./../../models/group').model; var Challenge = require('./../../models/challenge').model; diff --git a/website/src/controllers/payments/index.js b/website/src/controllers/payments/index.js index 866c33381b..074fab2369 100644 --- a/website/src/controllers/payments/index.js +++ b/website/src/controllers/payments/index.js @@ -1,7 +1,7 @@ var _ = require('lodash'); var shared = require('../../../../common'); var nconf = require('nconf'); -var utils = require('./../../libs/utils'); +var utils = require('./../../libs/api-v2/utils'); var moment = require('moment'); var isProduction = nconf.get("NODE_ENV") === "production"; var stripe = require('./stripe'); diff --git a/website/src/index.js b/website/src/index.js index 134060bf45..1de0170cd1 100644 --- a/website/src/index.js +++ b/website/src/index.js @@ -9,13 +9,13 @@ var logger = require('./libs/api-v3/logger'); // Initialize configuration var setupNconf = require('./libs/api-v3/setupNconf'); setupNconf(); -var utils = require('./libs/utils'); -utils.setupConfig(); var IS_PROD = nconf.get('IS_PROD'); var IS_DEV = nconf.get('IS_DEV'); var cores = Number(nconf.get('WEB_CONCURRENCY')) || 0; +if (IS_DEV) Error.stackTraceLimit = Infinity; + // Setup the cluster module if (cores !== 0 && cluster.isMaster && (IS_DEV || IS_PROD)) { // Fork workers. If config.json has CORES=x, use that - otherwise, use all cpus-1 (production) diff --git a/website/src/libs/utils.js b/website/src/libs/api-v2/utils.js similarity index 100% rename from website/src/libs/utils.js rename to website/src/libs/api-v2/utils.js diff --git a/website/src/libs/api-v3/email.js b/website/src/libs/api-v3/email.js new file mode 100644 index 0000000000..65cbcce02a --- /dev/null +++ b/website/src/libs/api-v3/email.js @@ -0,0 +1,158 @@ +import { createTransport } from 'nodemailer'; +import nconf from 'nconf'; +import logger from './logger'; +import { encrypt } from './encryption'; +import request from 'request'; + +const IS_PROD = nconf.get('IS_PROD'); +const EMAIL_SERVER = { + url: nconf.get('EMAIL_SERVER:url'), + auth: { + user: nconf.get('EMAIL_SERVER:authUser'), + password: nconf.get('EMAIL_SERVER:authPassword'), + }, +}; +const BASE_URL = nconf.get('BASE_URL'); + +let smtpTransporter = createTransport({ + service: nconf.get('SMTP_SERVICE'), + auth: { + user: nconf.get('SMTP_USER'), + pass: nconf.get('SMTP_PASS'), + }, +}); + +// Send email directly from the server using the smtpTransporter, +// used only to send password reset emails because users unsubscribed on Mandrill wouldn't get them +export function send (mailData) { + return smtpTransporter + .sendMail(mailData) + .catch((error) => logger.error(error)); +} + +export function getUserInfo (user, fields = []) { + let info = {}; + + if (fields.indexOf('name') !== -1) { + info.name = user.profile && user.profile.name; + + if (!info.name) { + if (user.auth.local && user.auth.local.username) { + info.name = user.auth.local.username; + } else if (user.auth.facebook) { + info.name = user.auth.facebook.displayName || user.auth.facebook.username; + } + } + } + + if (fields.indexOf('email') !== -1) { + if (user.auth.local && user.auth.local.email) { + info.email = user.auth.local.email; + } else if (user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value) { + info.email = user.auth.facebook.emails[0].value; + } + } + + if (fields.indexOf('_id') !== -1) { + info._id = user._id; + } + + if (fields.indexOf('canSend') !== -1) { + if (user.preferences && user.preferences.emailNotifications) { + info.canSend = user.preferences.emailNotifications.unsubscribeFromAll !== true; + } + } + + return info; +} + +// Send a transactional email using Mandrill through the external email server +export function sendTxn (mailingInfoArray, emailType, variables, personalVariables) { + mailingInfoArray = Array.isArray(mailingInfoArray) ? mailingInfoArray : [mailingInfoArray]; + + variables = [ + {name: 'BASE_URL', content: BASE_URL}, + ].concat(variables || []); + + // It's important to pass at least a user with its `preferences` as we need to check if he unsubscribed + mailingInfoArray = mailingInfoArray.map((mailingInfo) => { + return mailingInfo._id ? getUserInfo(mailingInfo, ['_id', 'email', 'name', 'canSend']) : mailingInfo; + }).filter((mailingInfo) => { + // Always send reset-password emails + // Don't check canSend for non registered users as already checked before + return mailingInfo.email && (!mailingInfo._id || mailingInfo.canSend || emailType === 'reset-password'); + }); + + // Personal variables are personal to each email recipient, if they are missing + // we manually create a structure for them with RECIPIENT_NAME and RECIPIENT_UNSUB_URL + // otherwise we just add RECIPIENT_NAME and RECIPIENT_UNSUB_URL to the existing personal variables + if (!personalVariables || personalVariables.length === 0) { + personalVariables = mailingInfoArray.map((mailingInfo) => { + return { + rcpt: mailingInfo.email, + vars: [ + { + name: 'RECIPIENT_NAME', + content: mailingInfo.name, + }, + { + name: 'RECIPIENT_UNSUB_URL', + content: `/unsubscribe?code=${encrypt(JSON.stringify({ + _id: mailingInfo._id, + email: mailingInfo.email, + }))}`, + }, + ], + }; + }); + } else { + let temporaryPersonalVariables = {}; + + mailingInfoArray.forEach((mailingInfo) => { + temporaryPersonalVariables[mailingInfo.email] = { + name: mailingInfo.name, + _id: mailingInfo._id, + }; + }); + + personalVariables.forEach((singlePersonalVariables) => { + singlePersonalVariables.vars.push( + { + name: 'RECIPIENT_NAME', + content: temporaryPersonalVariables[singlePersonalVariables.rcpt].name, + }, + { + name: 'RECIPIENT_UNSUB_URL', + content: `/unsubscribe?code=${encrypt(JSON.stringify({ + _id: temporaryPersonalVariables[singlePersonalVariables.rcpt]._id, + email: singlePersonalVariables.rcpt, + }))}`, + } + ); + }); + } + + if (IS_PROD && mailingInfoArray.length > 0) { + request.post({ + url: `${EMAIL_SERVER.url}/job`, + auth: { + user: EMAIL_SERVER.auth.user, + pass: EMAIL_SERVER.auth.password, + }, + json: { + type: 'email', + data: { + emailType, + to: mailingInfoArray, + variables, + personalVariables, + }, + options: { + priority: 'high', + attempts: 5, + backoff: {delay: 10 * 60 * 1000, type: 'fixed'}, + }, + }, + }, (err) => logger.error(err)); + } +} diff --git a/website/src/libs/api-v3/encryption.js b/website/src/libs/api-v3/encryption.js new file mode 100644 index 0000000000..0f5f9d83dd --- /dev/null +++ b/website/src/libs/api-v3/encryption.js @@ -0,0 +1,25 @@ +import { + createCipher, + createDecipher, +} from 'crypto'; +import nconf from 'nconf'; + +// TODO check this is secure +const algorithm = 'aes-256-ctr'; +const SESSION_SECRET = nconf.get('SESSION_SECRET'); + +export function encrypt (text) { + let cipher = createCipher(algorithm, SESSION_SECRET); + let crypted = cipher.update(text, 'utf8', 'hex'); + + crypted += cipher.final('hex'); + return crypted; +} + +export function decrypt (text) { + let decipher = createDecipher(algorithm, SESSION_SECRET); + let dec = decipher.update(text, 'hex', 'utf8'); + + dec += decipher.final('utf8'); + return dec; +} \ No newline at end of file diff --git a/website/src/libs/api-v3/logger.js b/website/src/libs/api-v3/logger.js index 4583610d25..3ab20ad685 100644 --- a/website/src/libs/api-v3/logger.js +++ b/website/src/libs/api-v3/logger.js @@ -12,7 +12,9 @@ if (IS_PROD) { // log errors to console too } else { logger - .add(winston.transports.Console); + .add(winston.transports.Console, { + colorize: true, + }); } export default logger; diff --git a/website/src/middlewares/locals.js b/website/src/middlewares/locals.js index 7bec7cc399..223186a81a 100644 --- a/website/src/middlewares/locals.js +++ b/website/src/middlewares/locals.js @@ -1,6 +1,6 @@ var nconf = require('nconf'); var _ = require('lodash'); -var utils = require('../libs/utils'); +var utils = require('../libs/api-v2/utils'); var shared = require('../../../common'); var i18n = require('../libs/api-v2/i18n'); var buildManifest = require('../libs/api-v2/buildManifest'); diff --git a/website/src/server.js b/website/src/server.js index 6573bb3c6e..1142f27539 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -2,7 +2,6 @@ import nconf from 'nconf'; import logger from './libs/api-v3/logger'; -import utils from './libs/utils'; import express from 'express'; import http from 'http'; // import path from 'path'; @@ -15,7 +14,6 @@ import mongoose from 'mongoose'; import Q from 'q'; import domainMiddleware from './middlewares/api-v3/domain'; import attachMiddlewares from './middlewares/api-v3/index'; -utils.setupConfig(); // Setup translations // let i18n = require('./libs/api-v2/i18n');