Implement Email DAO for sending verification mails

This commit is contained in:
Tankred Hase 2016-05-29 16:47:45 +02:00
parent c805371f0e
commit d3cce89b06
10 changed files with 235 additions and 24 deletions

7
config/integration.js Normal file
View File

@ -0,0 +1,7 @@
'use strict';
module.exports = {
log: {
level: "warn"
}
};

View File

@ -11,7 +11,7 @@
}, },
"scripts": { "scripts": {
"start": "node index.js", "start": "node index.js",
"test": "grunt test" "test": "export NODE_ENV=integration && grunt test"
}, },
"dependencies": { "dependencies": {
"addressparser": "^1.0.1", "addressparser": "^1.0.1",

View File

@ -96,9 +96,9 @@ app.on('error', (error, ctx) => {
function injectDependencies() { function injectDependencies() {
let credentials = readCredentials(); let credentials = readCredentials();
mongo = new Mongo({ mongo = new Mongo({
uri: process.env.MONGO_URI || credentials.mongoUri, uri: process.env.MONGO_URI || credentials.mongo.uri,
user: process.env.MONGO_USER || credentials.mongoUser, user: process.env.MONGO_USER || credentials.mongo.user,
password: process.env.MONGO_PASS || credentials.mongoPass password: process.env.MONGO_PASS || credentials.mongo.pass
}); });
email = new Email(nodemailer); email = new Email(nodemailer);
userId = new UserId(mongo); userId = new UserId(mongo);
@ -123,14 +123,14 @@ if (!global.testing) { // don't automatically start server in tests
co(function *() { co(function *() {
let app = yield init(); let app = yield init();
app.listen(config.server.port); app.listen(config.server.port);
log.verbose('app', 'Ready to rock! Listening on http://localhost:' + config.server.port); log.info('app', 'Ready to rock! Listening on http://localhost:' + config.server.port);
}).catch(err => log.error('app', 'Initialization failed!', err)); }).catch(err => log.error('app', 'Initialization failed!', err));
} }
function *init() { function *init() {
log.level = config.log.level; // set log level depending on process.env.NODE_ENV log.level = config.log.level; // set log level depending on process.env.NODE_ENV
injectDependencies(); injectDependencies();
log.verbose('app', 'Connecting to MongoDB ...'); log.info('app', 'Connecting to MongoDB ...');
yield mongo.connect(); yield mongo.connect();
return app; return app;
} }

View File

@ -17,6 +17,9 @@
'use strict'; 'use strict';
const log = require('npmlog');
const util = require('../service/util');
/** /**
* A simple wrapper around Nodemailer to send verification emails * A simple wrapper around Nodemailer to send verification emails
*/ */
@ -30,21 +33,131 @@ class Email {
this._mailer = mailer; this._mailer = mailer;
} }
/**
* Create an instance of the reusable nodemailer SMTP transport.
* @param {string} host The SMTP server's hostname e.g. 'smtp.gmail.com'
* @param {Object} auth Auth credential e.g. { user:'user@gmail.com', pass:'pass' }
* @param {Object} sender The message 'FROM' field e.g. { name:'Your Support', email:'noreply@exmple.com' }
* @param {string} port (optional) The SMTP server's SMTP port. Defaults to 465.
* @param {boolean} secure (optional) If TSL should be used. Defaults to true.
* @param {boolean} requireTLS (optional) If TSL is mandatory. Defaults to true.
*/
init(options) {
this._transport = this._mailer.createTransport({
host: options.host,
port: options.port || 465,
auth: options.auth,
secure: options.secure || true,
requireTLS: options.requireTLS || true
});
this._sender = options.sender;
}
/**
* A generic method to send an email message via nodemail.
* @param {Object} from The sender user id object e.g. { name:'Jon Smith', email:'j@smith.com' }
* @param {Object} to The recipient user id object e.g. { name:'Jon Smith', email:'j@smith.com' }
* @param {string} subject The message subject
* @param {string} text The message plaintext body
* @param {string} html The message html body
* @yield {Object} The reponse object containing SMTP info
*/
*send(options) {
let mailOptions = {
from: {
name: options.from.name,
address: options.from.email
},
to: {
name: options.to.name,
address: options.to.email
},
subject: options.subject,
text: options.text,
html: options.html
};
try {
let info = yield this._transport.sendMail(mailOptions);
log.silly('email', 'Email sent.', info);
return info;
} catch(error) {
log.error('email', 'Sending email failed.', error, options);
throw error;
}
}
/** /**
* Send the verification email to the user to verify email address * Send the verification email to the user to verify email address
* ownership. If the primary email address is provided, only one email * ownership. If the primary email address is provided, only one email
* will be sent out. Otherwise all of the PGP key's user IDs will be * will be sent out. Otherwise all of the PGP key's user IDs will be
* verified, resulting in an email sent per user ID. * verified, resulting in an email sent per user ID.
* @param {Array} options.userIds The user id documents containing the nonces * @param {Array} userIds The user id documents containing the nonces
* @param {Array} options.primaryEmail (optional) The user's primary email address * @param {Array} primaryEmail (optional) The user's primary email address
* @param {Object} origin Required for links to the keyserver e.g. { protocol:'https', host:'openpgpkeys@example.com' }
* @yield {undefined} * @yield {undefined}
*/ */
sendVerification() { *sendVerification(options) {
return Promise.resolve(); let primaryEmail = options.primaryEmail, userIds = options.userIds, origin = options.origin;
let primaryUserId = userIds.find(uid => uid.email === primaryEmail);
if (primaryUserId) { // send only one email to the primary user id
return yield this._sendVerificationHelper(primaryUserId, origin);
}
for (let uid of userIds) {
yield this._sendVerificationHelper(uid, origin);
}
} }
send() { /**
* Help method to send a verification message
* @param {Object} userId The user id document
* @param {Object} origin The origin of the server
* @yield {Object} The send response from the SMTP server
*/
*_sendVerificationHelper(userId, origin) {
let message = this._createVerifyMessage(userId, origin);
try {
let info = yield this.send(message);
if (!this._checkResponse(info)) {
log.warn('email', 'Verification mail may not have been received.', info);
}
return info;
} catch(e) {
util.throw(500, 'Sending verification email failed');
}
}
/**
* Helper function to create a verification message object.
* @param {Object} userId The user id document
* @param {Object} origin The origin of the server
* @return {Object} The message object
*/
_createVerifyMessage(userId, origin) {
let verifyLink = origin.protocol + '://' + origin.host +
'/api/v1/verify/?keyid=' + encodeURIComponent(userId.keyid) +
'&nonce=' + encodeURIComponent(userId.nonce);
let text = `Hey${userId.name ? ' ' + userId.name : ''},
please click here to verify your key: ${verifyLink}
`;
return {
from: this._sender,
to: userId,
subject: 'Verify Your Key',
text: text
};
}
/**
* Check if the message was sent successfully according to SMTP
* reply codes: http://www.supermailer.de/smtp_reply_codes.htm
* @param {Object} info The info object return from nodemailer
* @return {boolean} If the message was received by the user
*/
_checkResponse(info) {
return /^2/.test(info.response);
} }
} }

View File

@ -40,10 +40,12 @@ class HKP {
*/ */
*add(ctx) { *add(ctx) {
let body = yield parse.form(ctx, { limit: '1mb' }); let body = yield parse.form(ctx, { limit: '1mb' });
if (!util.validatePublicKey(body.keytext)) { let publicKeyArmored = body.keytext;
if (!util.validatePublicKey(publicKeyArmored)) {
ctx.throw(400, 'Invalid request!'); ctx.throw(400, 'Invalid request!');
} }
yield this._publicKey.put({ publicKeyArmored:body.keytext }); let origin = util.getOrigin(ctx);
yield this._publicKey.put({ publicKeyArmored, origin });
} }
/** /**

View File

@ -38,12 +38,15 @@ class REST {
* @param {Object} ctx The koa request/response context * @param {Object} ctx The koa request/response context
*/ */
*create(ctx) { *create(ctx) {
let pk = yield parse.json(ctx, { limit: '1mb' }); let body = yield parse.json(ctx, { limit: '1mb' });
if ((pk.primaryEmail && !util.validateAddress(pk.primaryEmail)) || let primaryEmail = body.primaryEmail;
!util.validatePublicKey(pk.publicKeyArmored)) { let publicKeyArmored = body.publicKeyArmored;
if ((primaryEmail && !util.validateAddress(primaryEmail)) ||
!util.validatePublicKey(publicKeyArmored)) {
ctx.throw(400, 'Invalid request!'); ctx.throw(400, 'Invalid request!');
} }
yield this._publicKey(pk); let origin = util.getOrigin(ctx);
yield this._publicKey({ publicKeyArmored, primaryEmail, origin });
} }
*verify(ctx) { *verify(ctx) {

View File

@ -50,13 +50,14 @@ class PublicKey {
/** /**
* Persist a new public key * Persist a new public key
* @param {String} options.publicKeyArmored The ascii armored pgp key block * @param {String} publicKeyArmored The ascii armored pgp key block
* @param {String} options.primaryEmail (optional) The key's primary email address * @param {String} primaryEmail (optional) The key's primary email address
* @param {Object} origin Required for links to the keyserver e.g. { protocol:'https', host:'openpgpkeys@example.com' }
* @yield {undefined} * @yield {undefined}
*/ */
*put(options) { *put(options) {
// parse key block // parse key block
let publicKeyArmored = options.publicKeyArmored; let publicKeyArmored = options.publicKeyArmored, primaryEmail = options.primaryEmail, origin = options.origin;
let params = this.parseKey(publicKeyArmored); let params = this.parseKey(publicKeyArmored);
// check for existing verfied key by id or email addresses // check for existing verfied key by id or email addresses
let verified = yield this._userid.getVerfied(params); let verified = yield this._userid.getVerfied(params);
@ -73,7 +74,7 @@ class PublicKey {
// persist new user ids // persist new user ids
let userIds = yield this._userid.batch(params); let userIds = yield this._userid.batch(params);
// send mails to verify user ids (send only one if primary email is provided) // send mails to verify user ids (send only one if primary email is provided)
yield this._email.sendVerification({ userIds, primaryEmail:options.primaryEmail }); yield this._email.sendVerification({ userIds, primaryEmail, origin });
} }
/** /**

View File

@ -108,3 +108,18 @@ exports.throw = function(status, message) {
err.expose = true; // display message to the client err.expose = true; // display message to the client
throw err; throw err;
}; };
/**
* Get the server's own origin host and protocol. Required for sending
* verification links via email. If the PORT environmane variable
* is set, we assume the protocol to be 'https', since the AWS loadbalancer
* speaks 'https' externally but 'http' between the LB and the server.
* @param {Object} ctx The koa request/repsonse context
* @return {Object} The server origin
*/
exports.getOrigin = function(ctx) {
return {
protocol: process.env.PORT ? 'https' : ctx.protocol,
host: ctx.host
};
};

View File

@ -0,0 +1,70 @@
'use strict';
require('co-mocha')(require('mocha')); // monkey patch mocha for generators
const expect = require('chai').expect;
const log = require('npmlog');
const config = require('config');
const Email = require('../../src/dao/email');
const nodemailer = require('nodemailer');
log.level = config.log.level;
describe('Email Integration Tests', function() {
this.timeout(20000);
let email, credentials;
before(function() {
try {
credentials = require('../../credentials.json');
} catch(e) {
log.warn('email-test', 'No credentials.json found ... skipping tests.');
this.skip();
return;
}
email = new Email(nodemailer);
email.init({
host: credentials.smtp.host,
auth: {
user: credentials.smtp.user,
pass: credentials.smtp.pass
},
sender: credentials.sender
});
});
describe("send", function() {
it('should work', function *() {
let mailOptions = {
from: credentials.sender,
to: credentials.sender,
subject: 'Hello ✔', // Subject line
text: 'Hello world 🐴', // plaintext body
html: '<b>Hello world 🐴</b>' // html body
};
let info = yield email.send(mailOptions);
expect(info).to.exist;
});
});
describe("sendVerification", function() {
it('should work', function *() {
let options = {
userIds: [{
name: credentials.sender.name,
email: credentials.sender.email,
keyid: '0123456789ABCDF0',
nonce: 'qwertzuioasdfghjkqwertzuio'
}],
primaryEmail: credentials.sender.email,
origin: {
protocol: 'http',
host: 'localhost:' + config.server.port
}
};
yield email.sendVerification(options);
});
});
});

View File

@ -20,9 +20,9 @@ describe('Mongo Integration Tests', function() {
log.info('mongo-test', 'No credentials.json found ... using environment vars.'); log.info('mongo-test', 'No credentials.json found ... using environment vars.');
} }
mongo = new Mongo({ mongo = new Mongo({
uri: process.env.MONGO_URI || credentials.mongoUri, uri: process.env.MONGO_URI || credentials.mongo.uri,
user: process.env.MONGO_USER || credentials.mongoUser, user: process.env.MONGO_USER || credentials.mongo.user,
password: process.env.MONGO_PASS || credentials.mongoPass password: process.env.MONGO_PASS || credentials.mongo.pass
}); });
yield mongo.connect(); yield mongo.connect();
}); });