UNPKG

ht-mailer

Version:

A hudson-taylor mailer service that keeps blacklists and a mail queue in mongoDB.

316 lines (270 loc) 11.9 kB
var fs = require('fs'); var async = require('async'); var ht = require('hudson-taylor'); var handlebars = require('handlebars'); var attrition = require('attrition'); var nodemailer = require('nodemailer'); var smtpTransport = require('nodemailer-smtp-transport'); var sesTransport = require('nodemailer-ses-transport'); var stubTransport = require('nodemailer-stub-transport'); var markdown = require('nodemailer-markdown').markdown; var mailparser = require('mailparser'); exports.setup = function(s, ready, config, db, testBucket) { //XXX testBucket is only used by the unitTest code to confirm mail delivery config = exports.configSchema.validate(config)['ht-mailer']; var subscription = db.collection('ht-mailer-subscription'); var queue = db.collection('ht-mailer-mailqueue'); var upsert = {w:1, new:true, upsert: true}; var sendSchema = s.Object({ to : s.Array([s.Email()]), cc : s.Array({opt:true, default : []}, [s.Email()]), bcc : s.Array({opt:true, default : []}, [s.Email()]), from : s.Email(), subject : s.String(), template : s.String({opt:true}), text : s.String({opt:true}), html : s.String({opt:true}), markdown : s.String({opt:true}), headers : s.Object({opt:true, strict: false}), data : s.Object({opt:true, strict : false}) }); /* Define the service APIs */ s.on("queue", sendSchema, enqueue); s.on("send", sendSchema, send); s.on("blockEmail", s.Object({email : s.Email()}), blockEmail); s.on("blockToken", s.Object({token : s.String()}), blockToken); s.on("unblockToken", s.Object({token : s.String()}), unblockToken); s.on("unblockEmail", s.Object({email : s.Email()}), unblockEmail); /* Start processing emails in the queue */ attrition.start(queue, {}, deliverEmail); /* Configure the mail transport */ var transport; switch(config.transport.type) { case 'SMTP': transport = smtpTransport(config.transport['SMTP-arguments']); break; case 'SES': transport = sesTransport(config.transport['SES-arguments']); break; case 'STUB': transport = stubTransport(); break; } var transporter = nodemailer.createTransport(transport); transporter.use('compile', markdown()); function deliverEmail(task, callback) { transporter.sendMail(task, function(err, info) { if(err) return callback(err); if(testBucket) { //assume testbucket is an array, add the item to the array, testBucket.push(info); //then add the item again to a list keyed by either header //x-test-id or subject. var parser = new mailparser.MailParser(); parser.on('headers', function(headers) { if(headers['x-test-id'] || headers['subject']) { var key = headers['x-test-id'] || headers['subject']; if(!testBucket[key]) testBucket[key] = []; testBucket[key].push(info); return callback(null, false); } }); parser.write(info.response.toString()); parser.end(); } else { callback(null, false); //delivered, remove form the queue } }); } function blockToken(data, callback) { return subscription.findAndModify( {_id : data.token}, null, {$set : {blocked : true}}, {w:1}, callback); } function unblockToken(data, callback) { return subscription.findAndModify( {_id : data.token}, null, {$set : {blocked : false}}, {w:1}, callback); } function blockEmail(data, callback) { return subscription.findAndModify( {email : data.email}, null, {$set : {blocked : true}}, upsert, callback); } function unblockEmail(data, callback) { return subscription.findAndModify( {email : data.email}, null, {$set : {blocked : false}}, upsert, callback); } function enqueue(data, done) { /* This function queues a message up for sending */ var begin = function(cb) { return cb(null, data, done); } return async.waterfall([ begin, filterBlacklistedEmails, createSubs, makeTask, finish], queueTask); } function send(data, done) { /* This function sends a message directly */ var begin = function(cb) { return cb(null, data, done); } return async.waterfall([ begin, filterBlacklistedEmails, createSubs, makeTask, finish], sendTask); } // find or create this email in the subscriptions table to get their // unsubscribe token and check if they are blocked. function filterBlacklistedEmails(data, done, callback) { /* Concat to, cc and bcc lists, search for existing subscriptions * split into 3 arrays: * - allowed (array of subscriptions), * - blocked (array of subscriptions), * - notFound (array of email addresses not found) */ var emails = data.to.concat(data.cc, data.bcc); var notFound = emails.concat([]); //copy var allowed = []; return subscription.find({ email : { $in : emails }}).toArray( function(err, res) { if(err) return callback(err); var blocked = (res||[]).filter(function(sub) { delete notFound[notFound.indexOf(sub.email)]; if(!sub.blocked) allowed.push(sub); return sub.blocked; }); return callback(null, data, done, allowed, blocked, notFound.filter(function(x){return x})); }); }; function createSubs(data, done, allowed, blocked, notFound, callback) { /* Create subscriptions for any notFound emails and add them * to the allowed list. */ var inserts = notFound.map(function(e){return {email:e};}); if(inserts.length > 0) { return subscription.insert(inserts, {w:1}, function(err, results) { if(err) { return callback(new Error("could not create subs" + err)); } return callback(null, data, done, allowed.concat(results), blocked); }); } else { return callback(null, data, done, allowed, blocked); } } function makeTask(data, done, allowed, blocked, callback) { /* Create a task and queue it for sending an email */ var task = {}; // This is the task that will get queued. //filter out blocked users task.to = filter(data.to, blocked); task.cc = filter(data.cc, blocked); task.bcc = filter(data.bcc, blocked); if(task.to.length==0 && task.cc.length==0 && task.bcc.length==0) { //We no longer have anyone to send an email to, return return done(null, []); } if(data.headers) task.headers = data.headers; //Find out which email template we're using if(data.template) { //Using a file-based template, find it in config.templates. var tpl = config.templates[data.template]; if(!tpl) return done({err: "No template '"+data.template+"' registered in ht-mailer config"}); var toLoad = []; //templates that need loading if(tpl.markdown) toLoad.push(['markdown', tpl.markdown]); if(!tpl.markdown && tpl.html) toLoad.push(['html', tpl.html]); if(!tpl.markdown && tpl.text) toLoad.push(['text', tpl.text]); async.map(toLoad, function(item, cb) { fs.readFile(item[1],{encoding:'utf8'}, function(err, file) { if(err) return cb(err); return cb(null, [item[0], file]); }); }, function(err, templates) { if(err) return done(err); //We have our templates loaded, call finish return callback(null, data, done, task, templates, allowed); }); } else { var templates = []; if(data.markdown) templates.push(['markdown', data.markdown]); if(!data.markdown && data.html) templates.push(['html', data.html]); if(!data.markdown && data.text) templates.push(['text', data.text]); if(templates.length == 0) { //we have been provided no way to make a msg body, abort. return done({err : "no message body found"}); } //We have our templates, call finish return callback(null, data, done, task, templates, allowed); } } function finish(data, done, task, templates, allowed, callback) { // Populate our template with data var extras = {}; if(task.to[0]) { // find the first 'to' recipient to provide the unsub token allowed.every(function(sub) { if(sub.email == task.to[0]) { extras.unsubscribeToken = sub._id; return false; } return true; }); } var values = ht.utils.merge(data.data, extras); // render templates and add to the task templates.forEach(function(t) { task[t[0]] = bakeTemplate(t[1], values); }); //finish populating the task task.to = task.to.join(', '); task.cc = task.cc.join(', '); task.bcc = task.bcc.join(', '); task.from = data.from; task.subject = bakeTemplate(data.subject, values); return callback(null, task, done); } function queueTask(err, task, done) { if(err) return done(err); return attrition.queue(queue, task, done); } function sendTask(err, task, done) { if(err) return done(err); return deliverEmail(task, done); } }; /******************************** Config Schema *******************************/ var s = ht.validators; exports.htConfigSchema = s.Object({strict : false}, { mongoURI : s.String({opt:true}), // Only if using server.js port : s.Number({opt:true}), // Only if using server.js templates : s.Object({opt:true, strict:false}, { "*" : s.Object({ markdown : s.String({opt:true}), html : s.String({opt:true}), text : s.String({opt:true}) }) }), transport : s.Object({ type : s.String({enum : ['SES', 'SMTP', 'STUB']}), 'SES-arguments' : s.Object({opt:true, strict:false}, { accessKeyID : s.String({opt : true}), secretAccessKey : s.String({opt : true}), sessionToken : s.String({opt : true}), }), 'SMTP-arguments' : s.Object({opt:true, strict:false}) }) }); exports.configSchema = s.Object({strict : false}, { 'ht-mailer' : exports.htConfigSchema }); /********************************** Helpers ***********************************/ function filter(emails, blocked) { /* Takes array of email addresses, and array of blocked subs, * and returns a filtered emails list. */ return emails.filter(function(e) { return blocked.every(function(sub) {return e != sub.email;}); }); } function bakeTemplate(template, data) { /* Process a handlebars template and data */ return handlebars.compile(template)(data); }