├── .gitignore ├── .travis.yml ├── README.md ├── bin ├── handler.js └── index.js ├── package.json ├── server ├── api │ ├── index.js │ ├── poll │ │ ├── index.js │ │ └── router.js │ └── send │ │ ├── index.js │ │ └── router.js ├── configure.js ├── email │ ├── bounce-list.js │ ├── handle-undeliverables.js │ ├── index.js │ ├── normalize-payload.js │ ├── process-queue.js │ └── send.js ├── engines │ ├── combyne.js │ ├── handlebars.js │ ├── index.js │ ├── jade.js │ └── mustache.js ├── handler │ ├── actions │ │ ├── fetch-resource.js │ │ ├── fetch-resources.js │ │ ├── get-payload.js │ │ ├── process-data.js │ │ └── queue-email.js │ └── index.js ├── index.js ├── parsers │ ├── html.js │ ├── index.js │ ├── json.js │ └── jsonapi.js ├── queue │ └── index.js └── resources │ ├── index.js │ ├── json.js │ ├── text.js │ └── url.js └── test ├── fixtures ├── html-and-url-poll-post.js ├── secrets.json └── typical-poll-post.js ├── mocks ├── aws-sdk.js ├── crontab.js ├── kue.js └── request.js ├── setup.js └── tests ├── api.js ├── email.js └── handler.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 0.12 5 | 6 | branches: 7 | only: 8 | - master 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pigeon Post 2 | 3 | An Amazon SES E-Mail Scheduler & Delivery API. 4 | 5 | [![Build 6 | Status](https://travis-ci.org/tbranyen/pigeonpost.svg)](https://travis-ci.org/tbranyen/pigeonpost) 7 | 8 | 9 | ## Prerequisites 10 | 11 | You will need the following dependencies properly installed on your computer. 12 | 13 | * [Git](http://git-scm.com/) 14 | * [Node.js](http://nodejs.org/) (with NPM) 15 | * [Redis](http://redis.io/) 16 | * [Crontab](http://crontab.org/) (Installed by default in OS X & Linux) 17 | 18 | Note: Due to the dependency on Crontab, this application will [not run easily on 19 | Windows](http://stackoverflow.com/questions/132971/what-is-the-windows-version-of-cron). 20 | 21 | ## Installation 22 | 23 | ``` sh 24 | npm install pigeonpost 25 | ``` 26 | 27 | Set your Amazon SES credentials in a JSON file as [outlined in their official 28 | docs](http://docs.aws.amazon.com/AWSJavaScriptSDK/guide/node-configuring.html#Credentials_from_Disk): 29 | 30 | 31 | ``` json 32 | { 33 | "accessKeyId": "test_key", 34 | "secretAccessKey": "test_secret", 35 | "region": "us-east-1" 36 | } 37 | ``` 38 | 39 | ### Bounce & Complaint handling 40 | 41 | Amazon requires handling of bounce and complaint notices to mitigate potential 42 | account suspension. PigeonPost requires that you hook up SES to emit bounce 43 | and complaint notices through Amazon SNS. Once you have created a topic and 44 | marked your verified email user to forward complaints to SNS, you will need to 45 | hook up SNS to Amazon SQS. Create a queue and have it subscribe to your SNS 46 | topic. On the SQS configuration page you will find a url that points to your 47 | queue. Add this to the configuration file discussed above like so: 48 | 49 | ``` json 50 | { 51 | "accessKeyId": "test_key", 52 | "secretAccessKey": "test_secret", 53 | "region": "us-east-1", 54 | "sqsUrl": "https://sqs.us-east-1.amazonaws.com/12345/SES_UNDELIVERABLE" 55 | } 56 | ``` 57 | 58 | ## Usage 59 | 60 | By design this application is meant to be standalone on a server and act as a 61 | server daemon. If you would like to directly integrate with your pre-existing 62 | server, this application also acts as express middleware: 63 | 64 | ``` javascript 65 | app.use('/email', require('pigeonpost')); 66 | ``` 67 | 68 | #### Environment variable configuration 69 | 70 | To inform this module where the secrets file lives, set an environment 71 | variable `AWS_SES_SECRETS` to the absolute fully qualified path on disk. It 72 | must be absolute as it may be run from various locations. 73 | 74 | For example your variable may look like: 75 | 76 | ``` sh 77 | export AWS_SES_SECRETS=/mnt/secrets/ses.json 78 | ``` 79 | 80 | ## API 81 | 82 | The API should follow [JSON-API](http://jsonapi.org/) for errors and data. You 83 | must set the content type request header to JSON. 84 | 85 | *Note: jQuery and other popular request libraries may add this automatically.* 86 | 87 | ``` http 88 | Content-Type: application/json 89 | ``` 90 | 91 | ### Documentation 92 | 93 | #### Sending 94 | 95 | This is the endpoint you'll hit when you want to immediately send an email. 96 | Useful for one off emails, such as new account registration or forgot password. 97 | 98 | Description | Method | Endpoint 99 | :------------------------------ | :------- | :-------- 100 | [Send an email](#send-an-email) | `POST` | `/send` 101 | 102 | 103 | ##### Send an email: 104 | 105 | Request: 106 | 107 | Param | Type | Required 108 | :------ | :----- | :-------- 109 | to | Array | yes 110 | cc | Array | no 111 | bcc | Array | no 112 | from | String | yes 113 | subject | String | yes 114 | body | String | yes 115 | 116 | Example: 117 | 118 | ``` sh 119 | curl \ 120 | -H "Content-Type: application/json" \ 121 | -X POST \ 122 | -d \ 123 | '{ 124 | "to": ["tim@tabdeveloper.com"], 125 | "from": "tim@bocoup.com", 126 | "subject": "A test subject!", 127 | "body": "Test message" 128 | }' \ 129 | http://localhost:8000/send 130 | ``` 131 | 132 | Response: 133 | 134 | ``` json 135 | { 136 | "data": { 137 | "ResponseMetadata": { 138 | "RequestId":"2263f1cc-fccf-21e4-a378-4350dc3a1cea" 139 | }, 140 | 141 | "MessageId": "1120014e6373fd1e-2bdf239f-21bf-4c1f-b299-36eacb54dbc6-000000" 142 | } 143 | } 144 | ``` 145 | 146 | #### Pollers 147 | 148 | These are jobs that are executed on a specific schedule. They are assigned using 149 | Crontab, and allow any valid schedule expression within that format. You would 150 | use this endpoint to schedule emails to be sent every day/month/year, schedule 151 | an email to be sent 5 minutes from now, etc. 152 | 153 | 154 | Description | Method | Endpoint 155 | :------------------------------------------------------------- | :------- | :-------- 156 | [Create a new poller](#create-a-new-poller) | `POST` | `/poll` 157 | [Create or update new poller](#create-or-update-new-poller) | `PUT` | `/poll` 158 | [Get all pollers](#get-all-pollers) | `GET` | `/poll` 159 | [Get specific poller](#get-specific-poller) | `GET` | `/poll/:id` 160 | [Delete specific poller](#delete-specific-poller) | `DELETE` | `/poll/:id` 161 | 162 | ##### Create a new poller: 163 | 164 | Request: 165 | 166 | Param | Type | Required 167 | :------- | :------------- | :-------- 168 | id | String | no 169 | template | Object | yes 170 | data | Object | yes 171 | schedule | String/Number | yes 172 | handler | String | no 173 | 174 | Example: 175 | 176 | ``` sh 177 | curl \ 178 | -H "Content-Type: application/json" \ 179 | -X POST \ 180 | -d \ 181 | '{ 182 | "id": "test-uuid", 183 | "template": { 184 | "test-uuid", 185 | } 186 | }' \ 187 | http://localhost:8000/poll 188 | ``` 189 | 190 | Response: 191 | 192 | ``` json 193 | { 194 | "data": {} 195 | } 196 | ``` 197 | 198 | ##### Create or update new poller: 199 | 200 | ``` sh 201 | curl \ 202 | -H "Content-Type: application/json" \ 203 | -X PUT \ 204 | -d \ 205 | '{ 206 | "uuid": "test-uuid", 207 | }' \ 208 | http://localhost:8000/poll 209 | ``` 210 | 211 | Response: 212 | 213 | ``` json 214 | { 215 | "data": {} 216 | } 217 | ``` 218 | 219 | ##### Get all pollers: 220 | 221 | Request: 222 | 223 | Param | Type | Required 224 | :------- | :------------- | :-------- 225 | No parameters 226 | 227 | Example: 228 | 229 | ``` sh 230 | curl \ 231 | -X GET \ 232 | http://localhost:8000/poll 233 | ``` 234 | 235 | Response: 236 | 237 | ``` json 238 | { 239 | "data": [] 240 | } 241 | ``` 242 | 243 | ##### Get specific poller: 244 | 245 | Request: 246 | 247 | Param | Type | Required 248 | :------- | :------------- | :-------- 249 | id | Number | yes 250 | 251 | Example: 252 | 253 | ``` sh 254 | curl \ 255 | -X GET \ 256 | http://localhost:8000/poll/ 257 | ``` 258 | 259 | Response: 260 | 261 | ``` json 262 | { 263 | "data": {} 264 | } 265 | ``` 266 | 267 | ##### Delete specific poller: 268 | 269 | Request: 270 | 271 | Param | Type | Required 272 | :------- | :------------- | :-------- 273 | id | Number | yes 274 | 275 | Example: 276 | 277 | ``` sh 278 | curl \ 279 | -X DELETE \ 280 | http://localhost:8000/poll/ 281 | ``` 282 | 283 | Response: 284 | 285 | ``` json 286 | { 287 | "data": {} 288 | } 289 | ``` 290 | 291 | ## Running / Development 292 | 293 | ``` sh 294 | npm start 295 | ``` 296 | 297 | Visit the server at [http://localhost:8000](http://localhost:8000). 298 | 299 | ### Running Tests 300 | 301 | ``` sh 302 | npm test 303 | ``` 304 | -------------------------------------------------------------------------------- /bin/handler.js: -------------------------------------------------------------------------------- 1 | require('../server/handler')(); 2 | -------------------------------------------------------------------------------- /bin/index.js: -------------------------------------------------------------------------------- 1 | const app = require('../server'); 2 | 3 | var port = process.env.PORT || 8000; 4 | var host = process.env.HOST || '0.0.0.0'; 5 | 6 | if (!process.env.AWS_SES_SECRETS) { 7 | console.error('>> Missing AWS_SES_SECRETS environment variable\n'); 8 | } 9 | 10 | app.listen(port, host, function() { 11 | console.log('http://localhost:' + this.address().port); 12 | }); 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pigeonpost", 3 | "version": "0.0.0", 4 | "description": "Amazon SES E-Mail scheduling and delivery API", 5 | "main": "server", 6 | "bin": "bin", 7 | "scripts": { 8 | "test": "mocha test/setup test/tests", 9 | "start": "node bin" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/tbranyen/pigeonpost.git" 14 | }, 15 | "author": "Tim Branyen via Bocoup", 16 | "license": "MIT", 17 | "bugs": { 18 | "url": "https://github.com/tbranyen/pigeonpost/issues" 19 | }, 20 | "homepage": "https://github.com/tbranyen/pigeonpost", 21 | "dependencies": { 22 | "aws-sdk": "^2.1.34", 23 | "bluebird": "^2.9.30", 24 | "body-parser": "^1.13.1", 25 | "cheerio": "^0.19.0", 26 | "combyne": "^0.8.0", 27 | "crontab": "^1.0.2", 28 | "express": "^4.12.4", 29 | "handlebars": "^3.0.3", 30 | "jade": "^1.11.0", 31 | "kue": "^0.9.3", 32 | "lodash": "^3.9.3", 33 | "moment": "^2.10.3", 34 | "mustache": "^2.1.2", 35 | "node-uuid": "^1.4.3" 36 | }, 37 | "devDependencies": { 38 | "mocha": "^2.2.5", 39 | "moment": "^2.10.3", 40 | "request": "^2.58.0", 41 | "supertest": "^1.0.1" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /server/api/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const poll = require('./poll'); 3 | const send = require('./send'); 4 | 5 | var api = express.Router(); 6 | 7 | // Attach API components. 8 | api.use('/poll', poll.router); 9 | api.use('/send', send.router); 10 | //api.use('/jobs', jobs.router); // TODO 11 | 12 | module.exports = api; 13 | -------------------------------------------------------------------------------- /server/api/poll/index.js: -------------------------------------------------------------------------------- 1 | exports.router = require('./router'); 2 | -------------------------------------------------------------------------------- /server/api/poll/router.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const path = require('path'); 3 | const express = require('express'); 4 | const bodyParser = require('body-parser'); 5 | const crontab = require('crontab'); 6 | const uuid = require('node-uuid'); 7 | const Bluebird = require('bluebird'); 8 | 9 | var handlerPath = 'node ' + path.join(__dirname, '../../../bin/handler'); 10 | var router = express.Router(); 11 | 12 | router.use(bodyParser.json()); 13 | 14 | function createJob(crontab, body) { 15 | var schedule = body.schedule; 16 | 17 | // Save the secrets location. 18 | body.env = { AWS_SES_SECRETS: process.env.AWS_SES_SECRETS }; 19 | 20 | if (typeof schedule === 'number') { 21 | schedule = new Date(schedule); 22 | 23 | // Setting a date directly will cause it to be a one time only event. 24 | body.expires = true; 25 | } 26 | 27 | // Convert body.handler to base64 to avoid quoting issues on CLI. 28 | if (body.handler) { 29 | body.handler = new Buffer(body.handler).toString('base64'); 30 | } 31 | 32 | return crontab.create([ 33 | handlerPath, 34 | '\'' + JSON.stringify(body) + '\'' 35 | ].join(' '), schedule, body.id || uuid()); 36 | } 37 | 38 | function formatJob(job) { 39 | return job ? { id: job.comment() } : null; 40 | } 41 | 42 | function sendResponse(res, job) { 43 | res.json({ data: formatJob(job) }); 44 | } 45 | 46 | function sendError(res, ex) { 47 | res.status(500).json({ error: ex }); 48 | } 49 | 50 | function validateBody(body) { 51 | assert(body, 'Body payload is defined'); 52 | assert(body.template, 'Template is missing'); 53 | assert(body.data, 'Data is missing'); 54 | assert(body.schedule, 'Schedule is missing'); 55 | } 56 | 57 | var getCronTab = new Bluebird(function(resolve, reject) { 58 | crontab.load(function(err, crontab) { 59 | if (err) { reject(err); } 60 | else { resolve(crontab); } 61 | }); 62 | }).catch(function(ex) { 63 | console.error(ex); 64 | process.exit(1); 65 | }); 66 | 67 | router.get('/', function(req, res) { 68 | getCronTab.then(function(crontab) { 69 | res.json({ data: crontab.find().map(formatJob) }); 70 | }).catch(sendError.bind(null, res)); 71 | }); 72 | 73 | router.get('/:id', function(req, res) { 74 | getCronTab.then(function(crontab) { 75 | var job = crontab.find({ comment: req.params.id }); 76 | sendResponse(res, job); 77 | }).catch(sendError.bind(null, res)); 78 | }); 79 | 80 | router.post('/', function(req, res) { 81 | getCronTab.then(function(crontab) { 82 | validateBody(req.body); 83 | 84 | var id = req.body.id || uuid(); 85 | var prior = crontab.find({ comment: id }); 86 | 87 | assert(!prior || !prior.length, 'This job already exists'); 88 | 89 | var job = createJob(crontab, req.body); 90 | 91 | crontab.save(function() { 92 | sendResponse(res, job); 93 | }); 94 | }).catch(function(ex) { 95 | console.error(ex.stack); 96 | sendError(res); 97 | }); 98 | }); 99 | 100 | router.put('/', function(req, res) { 101 | getCronTab.then(function(crontab) { 102 | var prior = crontab.find({ comment: req.body.id }); 103 | 104 | if (prior) { 105 | var job = crontab.find({ comment: req.body.id }); 106 | return sendResponse(res, job); 107 | } 108 | 109 | var job = createJob(crontab, req.body); 110 | 111 | crontab.save(function() { 112 | sendResponse(res, job); 113 | }); 114 | }).catch(sendError.bind(null, res)); 115 | }); 116 | 117 | router.delete('/:id', function(req, res) { 118 | getCronTab.then(function(crontab) { 119 | var prior = crontab.find({ comment: req.params.id }); 120 | 121 | assert(prior, 'Job doesn\'t exist'); 122 | 123 | var job = crontab.remove({ comment: req.params.id }); 124 | 125 | crontab.save(function() { 126 | sendResponse(res, { comment: function() { return req.params.id; } }); 127 | }); 128 | }).catch(sendError.bind(null, res)); 129 | }); 130 | 131 | module.exports = router; 132 | -------------------------------------------------------------------------------- /server/api/send/index.js: -------------------------------------------------------------------------------- 1 | exports.router = require('./router'); 2 | -------------------------------------------------------------------------------- /server/api/send/router.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const bodyParser = require('body-parser'); 3 | const email = require('../../email'); 4 | 5 | var router = express.Router(); 6 | 7 | router.post('/', [bodyParser.json()], function(req, res) { 8 | var validPayload = true; 9 | var payload = req.body; 10 | 11 | // Ensure the payload contains valid required arguments. 12 | if (!payload.to || !payload.from || !payload.subject || !payload.body) { 13 | validPayload = false; 14 | } 15 | 16 | if (!validPayload) { 17 | return res.status(400).json({ 18 | message: 'Invalid POST body' 19 | }); 20 | } 21 | 22 | email.send(email.normalizePayload(payload), function(err, resp) { 23 | if (err) { 24 | return res.status(500).json({ 25 | error: err 26 | }); 27 | } 28 | 29 | res.json({ data: resp }); 30 | }); 31 | }); 32 | 33 | module.exports = router; 34 | -------------------------------------------------------------------------------- /server/configure.js: -------------------------------------------------------------------------------- 1 | module.exports = function(app) { 2 | var env = process.env; 3 | 4 | if (env.NODE_ENV === 'production') { 5 | env.PORT = './socket'; 6 | } 7 | 8 | return app; 9 | }; 10 | -------------------------------------------------------------------------------- /server/email/bounce-list.js: -------------------------------------------------------------------------------- 1 | exports.list = []; 2 | 3 | exports.load = function(list) { 4 | exports.list = list; 5 | }; 6 | 7 | exports.add = function(email) { 8 | exports.list.push(email); 9 | 10 | // TODO Trigger some kind of event. 11 | }; 12 | 13 | exports.lookup = function(email) { 14 | var found = exports.list.indexOf(email) > -1; 15 | 16 | // Return not found to work better with `Array#filter`. 17 | return !found; 18 | }; 19 | -------------------------------------------------------------------------------- /server/email/handle-undeliverables.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | const config = require(process.env.AWS_SES_SECRETS); 3 | const bounceList = require('./bounce-list'); 4 | 5 | AWS.config.loadFromPath(process.env.AWS_SES_SECRETS); 6 | 7 | var sqs = new AWS.SQS(); 8 | 9 | /** 10 | * Handles Amazon SES failures. 11 | * 12 | * @return 13 | */ 14 | function receiveMessage() { 15 | if (!config.sqsUrl) { 16 | console.warn('>> Missing SQS Resource URL, cannot monitor SES failures'); 17 | } 18 | 19 | // Receive 1 or many messages from Amazons's queue system. This is a polling 20 | // operation. 21 | sqs.receiveMessage({ 22 | QueueUrl: config.sqsUrl, 23 | MaxNumberOfMessages: 10, 24 | WaitTimeSeconds: 20 25 | }, function (err, data) { 26 | if (data && data.Messages && data.Messages.length) { 27 | data.Messages.forEach(function(message) { 28 | if (!message.Body) { return; } 29 | 30 | var MessageBody = JSON.parse(JSON.parse(message.Body).Message); 31 | 32 | // Is a complain'd email. 33 | if (MessageBody.notificationType === 'Complaint') { 34 | MessageBody.complaint.complainedRecipients.forEach(function(recipient) { 35 | bounceList.add(recipient.emailAddress); 36 | }); 37 | } 38 | // Is a Bounce'd email. 39 | else if (MessageBody.notificationType === 'Bounce') { 40 | MessageBody.bounce.bouncedRecipients.forEach(function(recipient) { 41 | bounceList.add(recipient.emailAddress); 42 | }); 43 | } 44 | 45 | // If we don't remove the message, it will remain in the queue. 46 | sqs.deleteMessage({ 47 | QueueUrl: config.sqsUrl, 48 | ReceiptHandle: message.ReceiptHandle 49 | }, function nop() {}); 50 | }); 51 | 52 | // If there were messages and no errors, retry immediately. 53 | receiveMessage(); 54 | } 55 | // If there were no messages and no failures, wait 5 seconds before retrying. 56 | else if (!err) { 57 | setTimeout(receiveMessage, 5000); 58 | } 59 | // If there was a failure, wait a minute before retrying. 60 | else { 61 | setTimeout(receiveMessage, 60000); 62 | } 63 | }); 64 | }; 65 | 66 | module.exports = receiveMessage; 67 | -------------------------------------------------------------------------------- /server/email/index.js: -------------------------------------------------------------------------------- 1 | exports.send = require('./send'); 2 | exports.normalizePayload = require('./normalize-payload'); 3 | exports.processQueue = require('./process-queue'); 4 | exports.handleUndeliverables = require('./handle-undeliverables'); 5 | exports.bounceList = require('./bounce-list'); 6 | -------------------------------------------------------------------------------- /server/email/normalize-payload.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Normalizes a simplified payload into an object that is suitable for Amazon 3 | * SES. 4 | * 5 | * @param {Object} payload 6 | * @return {Object} 7 | */ 8 | module.exports = function normalizePayload(payload) { 9 | return { 10 | Source: Array.isArray(payload.from) ? payload.from[0] : payload.from, 11 | Destination: { 12 | ToAddresses: payload.to, 13 | CcAddresses: payload.cc || [], 14 | BccAddresses: payload.bcc || [] 15 | }, 16 | Message: { 17 | Body: { 18 | Html: { 19 | Data: String(payload.body) 20 | } 21 | }, 22 | Subject: { 23 | Data: String(payload.subject) 24 | } 25 | } 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /server/email/process-queue.js: -------------------------------------------------------------------------------- 1 | const queue = require('../queue'); 2 | const send = require('./send'); 3 | 4 | module.exports = function() { 5 | // Set a maximum of 20 concurrent jobs to process at a time, since we are 6 | // only firing off to Amazon, this is unlikely to cause a problem. 7 | queue.process('email', 20, function(job, done) { 8 | send(job.data, done); 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /server/email/send.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const AWS = require('aws-sdk'); 3 | const bounceList = require('./bounce-list'); 4 | 5 | module.exports = function send(payload, done) { 6 | AWS.config.loadFromPath(process.env.AWS_SES_SECRETS); 7 | 8 | var dest = payload.Destination; 9 | 10 | // Look for blocked emails (bounces/complaints). 11 | dest.ToAddresses = dest.ToAddresses.filter(bounceList.lookup); 12 | dest.CcAddresses = dest.CcAddresses.filter(bounceList.lookup); 13 | dest.BccAddresses = dest.BccAddresses.filter(bounceList.lookup); 14 | 15 | // Must have atleast one recipient. 16 | if (dest.ToAddresses.length) { 17 | new AWS.SES({ apiVersion: '2010-12-01' }).sendEmail(payload, done); 18 | } 19 | else { 20 | done({ message: 'No addresses to send to, are they all blocked?' }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /server/engines/combyne.js: -------------------------------------------------------------------------------- 1 | const combyne = require('combyne'); 2 | 3 | exports.render = function(template, data) { 4 | return combyne(template).render(data); 5 | }; 6 | -------------------------------------------------------------------------------- /server/engines/handlebars.js: -------------------------------------------------------------------------------- 1 | const handlebars = require('handlebars'); 2 | 3 | exports.render = function(template, data) { 4 | return Handlebars.compile(template)(data); 5 | }; 6 | -------------------------------------------------------------------------------- /server/engines/index.js: -------------------------------------------------------------------------------- 1 | exports.handlebars = require('./handlebars'); 2 | exports.jade = require('./jade'); 3 | exports.mustache = require('./mustache'); 4 | exports.combyne = require('./combyne'); 5 | -------------------------------------------------------------------------------- /server/engines/jade.js: -------------------------------------------------------------------------------- 1 | var jade = require('jade'); 2 | 3 | exports.render = function(template, data) { 4 | return jade.render(template, data); 5 | }; 6 | -------------------------------------------------------------------------------- /server/engines/mustache.js: -------------------------------------------------------------------------------- 1 | const mustache = require('mustache'); 2 | 3 | exports.render = function(template, data) { 4 | return mustache.render(template, data); 5 | }; 6 | -------------------------------------------------------------------------------- /server/handler/actions/fetch-resource.js: -------------------------------------------------------------------------------- 1 | const resources = require('../../resources'); 2 | 3 | /** 4 | * Using the type as a guide, fetch the specified resource. 5 | * 6 | * @param resource 7 | * @return {String} contents 8 | */ 9 | module.exports = function fetchResource(resource) { 10 | var resourceType = resources[resource.type]; 11 | 12 | if (!resourceType) { 13 | throw new Error('Invalid resource type: ' + resource.type); 14 | } 15 | 16 | return resources[resource.type](resource.value); 17 | }; 18 | -------------------------------------------------------------------------------- /server/handler/actions/fetch-resources.js: -------------------------------------------------------------------------------- 1 | const Bluebird = require('bluebird'); 2 | const fetchResource = require('./fetch-resource'); 3 | 4 | /** 5 | * Fetch and save the template and data resources to the state object. 6 | * 7 | * @param state 8 | * @return state 9 | */ 10 | module.exports = function fetchResources(state) { 11 | // If the `body` property is already attached, skip this step. 12 | if (state.payload.body) { 13 | return Promise.resolve(state); 14 | } 15 | 16 | var getTemplate = fetchResource(state.payload.template); 17 | var getData = fetchResource(state.payload.data); 18 | 19 | return Bluebird.all([getTemplate, getData]).then(function(resources) { 20 | state.template = resources[0]; 21 | state.data = resources[1]; 22 | 23 | return state; 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /server/handler/actions/get-payload.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Attaches the JSON parsed payload to the state object. 3 | * 4 | * @param state 5 | * @return state 6 | */ 7 | module.exports = function getPayload(state) { 8 | var payload = state.argv[0]; 9 | 10 | state.payload = JSON.parse(payload); 11 | 12 | // Inject the `SECRETS` location into this environment. 13 | process.env.AWS_SES_SECRETS = state.payload.env.AWS_SES_SECRETS; 14 | 15 | return state; 16 | }; 17 | -------------------------------------------------------------------------------- /server/handler/actions/process-data.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const Bluebird = require('bluebird'); 3 | const parsers = require('../../parsers'); 4 | 5 | module.exports = function processData(state) { 6 | return new Bluebird(function(resolve, reject) { 7 | var handler = state.payload.handler; 8 | 9 | if (handler) { 10 | handler = new Buffer(handler, 'base64').toString('utf-8'); 11 | } 12 | else { 13 | handler = 'function(data) { return data; }'; 14 | } 15 | 16 | var fn = new Function('return ' + handler)(); 17 | var parsed = parsers[state.payload.data.parser](state.data); 18 | 19 | // Get the extracted response. 20 | var extracted = fn(parsed); 21 | 22 | // If the handler returns an Array we need to break out the 23 | if (Array.isArray(extracted)) { 24 | state.payload._extracted = extracted; 25 | } 26 | else { 27 | _.extend(state.payload, extracted); 28 | } 29 | 30 | resolve(state); 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /server/handler/actions/queue-email.js: -------------------------------------------------------------------------------- 1 | const Bluebird = require('bluebird'); 2 | const _ = require('lodash'); 3 | const queue = require('../../queue'); 4 | const email = require('../../email'); 5 | const engines = require('../../engines'); 6 | 7 | function createQueue(payload) { 8 | queue.create('email', payload) 9 | .priority('normal') 10 | .backoff(true) 11 | .ttl(1000) 12 | .attempts(3) 13 | .save(); 14 | } 15 | 16 | module.exports = function queueEmail(state) { 17 | var engine = engines[state.payload.template.engine]; 18 | var payloads = []; 19 | 20 | // Working with an Array of payloads. 21 | if (state.payload._extended) { 22 | payloads = state.payload._extended.map(function(payload, i) { 23 | var merged = _.extend({}, state.payload, payload); 24 | 25 | // Set the specific `to` (not all the recipients). 26 | merged.to = [merged.to[i]]; 27 | 28 | return merged; 29 | }); 30 | } 31 | // A single payload. 32 | else { 33 | payloads.push(state.payload); 34 | } 35 | 36 | // Render the template and attach the payload body. 37 | payloads.forEach(function(payload) { 38 | payload.body = engine.render(state.template, payload); 39 | }); 40 | 41 | // Normalize and queue all payloads. 42 | payloads.map(email.normalizePayload).forEach(createQueue); 43 | 44 | return Bluebird.resolve(state); 45 | }; 46 | -------------------------------------------------------------------------------- /server/handler/index.js: -------------------------------------------------------------------------------- 1 | const Bluebird = require('bluebird'); 2 | const getPayload = require('./actions/get-payload'); 3 | const fetchResources = require('./actions/fetch-resources'); 4 | const processData = require('./actions/process-data'); 5 | const queueEmail = require('./actions/queue-email'); 6 | const crontab = require('crontab'); 7 | 8 | /** 9 | * Exports a Promise chain that processes all the handler actions. 10 | * 11 | * @param argv 12 | * @return {Promise} chain 13 | */ 14 | module.exports = function(argv) { 15 | argv = (argv || process.argv).slice(2); 16 | 17 | if (!argv.length) { 18 | console.error('Insufficient arguments to handler see --help'); 19 | return process.exit(1); 20 | } 21 | 22 | // Pass state through the Promise chain. 23 | return Bluebird.resolve({ argv: argv }) 24 | .then(getPayload) 25 | .then(fetchResources) 26 | .then(processData) 27 | .then(queueEmail) 28 | .then(function(state) { 29 | if (state.payload.expires) { 30 | return new Bluebird(function(resolve, reject) { 31 | crontab.load(function(err, crontab) { 32 | crontab.remove({ comment: state.payload.id }); 33 | crontab.save(function() { 34 | resolve(state); 35 | }); 36 | }); 37 | }); 38 | } 39 | 40 | return state; 41 | }) 42 | .catch(function(ex) { 43 | require('fs').writeFileSync('/home/tim/Desktop/error_log', ex.stack); 44 | }); 45 | }; 46 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const configure = require('./configure'); 3 | const api = require('./api'); 4 | const email = require('./email'); 5 | 6 | var app = configure(express()).use(api); 7 | 8 | // Expose bounce list. 9 | app.bounceList = email.bounceList; 10 | 11 | // Process sending failures. 12 | email.handleUndeliverables(); 13 | 14 | // This processes the queue as necessary. 15 | email.processQueue(); 16 | 17 | module.exports = app; 18 | -------------------------------------------------------------------------------- /server/parsers/html.js: -------------------------------------------------------------------------------- 1 | const cheerio = require('cheerio'); 2 | 3 | module.exports = function(value) { 4 | return cheerio.load(value); 5 | }; 6 | -------------------------------------------------------------------------------- /server/parsers/index.js: -------------------------------------------------------------------------------- 1 | exports.json = require('./json'); 2 | exports.jsonapi = require('./jsonapi'); 3 | exports.html = require('./html'); 4 | -------------------------------------------------------------------------------- /server/parsers/json.js: -------------------------------------------------------------------------------- 1 | module.exports = function(value) { 2 | return JSON.parse(value); 3 | }; 4 | -------------------------------------------------------------------------------- /server/parsers/jsonapi.js: -------------------------------------------------------------------------------- 1 | // this is a naive json-api parser. the emailing service should support a 2 | // variety of "parsers" as dependencies. parsers can be used to do any pre 3 | // processing of data before they hit the context function. 4 | const _ = require('lodash'); 5 | 6 | function link (included, entry) { 7 | var links = entry.links; 8 | if (links) { 9 | Object.keys(links).forEach(function (relationName) { 10 | var link = links[relationName].linkage; 11 | entry[relationName] = included[link.type][link.id]; 12 | }); 13 | } 14 | return entry; 15 | } 16 | 17 | function parse(input) { 18 | input = JSON.parse(input); 19 | 20 | // key all included by type 21 | var included = _.reduce(input.included, function (result, entry) { 22 | var type = result[entry.type]; 23 | if (!type) { 24 | result[entry.type] = type = {}; 25 | } 26 | type[entry.id] = entry; 27 | return result; 28 | }, {}); 29 | 30 | // build a linker from the included records 31 | var linker = link.bind(null, included); 32 | 33 | // interlink included records 34 | var embedded = _.reduce(included, function (result, entries, type) { 35 | result[type] = _.reduce(entries, function (typeResult, entry) { 36 | typeResult[entry.id] = linker(entry); 37 | return typeResult; 38 | }, {}); 39 | return result; 40 | }, {}); 41 | 42 | // return primary data with references to included data 43 | return input.data.map(linker); 44 | }; 45 | 46 | module.exports = parse; 47 | -------------------------------------------------------------------------------- /server/queue/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('kue').createQueue(); 2 | -------------------------------------------------------------------------------- /server/resources/index.js: -------------------------------------------------------------------------------- 1 | exports.json = require('./json'); 2 | exports.url = require('./url'); 3 | exports.text = require('./text'); 4 | -------------------------------------------------------------------------------- /server/resources/json.js: -------------------------------------------------------------------------------- 1 | const Bluebird = require('bluebird'); 2 | 3 | module.exports = function(value) { 4 | return new Bluebird(function(resolve, reject) { 5 | try { 6 | resolve(JSON.parse(value)); 7 | } 8 | catch(ex) { 9 | reject(ex); 10 | } 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /server/resources/text.js: -------------------------------------------------------------------------------- 1 | const Bluebird = require('bluebird'); 2 | 3 | module.exports = function(value) { 4 | return Bluebird.resolve(String(value)); 5 | }; 6 | -------------------------------------------------------------------------------- /server/resources/url.js: -------------------------------------------------------------------------------- 1 | const Bluebird = require('bluebird'); 2 | const request = require('request'); 3 | 4 | module.exports = function(value) { 5 | return new Bluebird(function(resolve, reject) { 6 | request(value, function(err, resp, body) { 7 | if (err) { return reject(err); } 8 | else { resolve(body); } 9 | }); 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /test/fixtures/html-and-url-poll-post.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment'); 2 | 3 | module.exports = { 4 | id: 'test-uuid', 5 | to: ['tim@tabdeveloper.com'], 6 | from: 'tim@bocoup.com', 7 | template: { 8 | type: 'text', 9 | engine: 'combyne', 10 | value: 'Hello {{ name }}' 11 | }, 12 | data: { 13 | type: 'url', 14 | parser: 'html', 15 | value: 'http://tbranyen.com/' 16 | }, 17 | schedule: Number(moment().add(1, 'seconds')), 18 | handler: function($) { 19 | return { 20 | name: $('title').text() 21 | }; 22 | }.toString() 23 | }; 24 | -------------------------------------------------------------------------------- /test/fixtures/secrets.json: -------------------------------------------------------------------------------- 1 | { 2 | "accessKeyId": "test_key", 3 | "secretAccessKey": "test_secret", 4 | "region": "us-east-1" 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/typical-poll-post.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | template: { 3 | engine: 'handlebars', 4 | type: 'string', 5 | value: 'On {{requestDay}}, {{requestor}} submitted a time off ' + 6 | 'request for {{firstDay}} to {{lastDay}}. Please respond to ' + 7 | 'this request!' 8 | }, 9 | 10 | data: { 11 | type: 'url', 12 | value: '/leave-requests/pending-review', 13 | parser: 'jsonapi' 14 | }, 15 | 16 | schedule: '@daily', 17 | 18 | handler: function(resp) { 19 | return resp.map(function(entry) { 20 | return { 21 | requestor: entry.employee.first + ' ' + entry.employee.last, 22 | requestDay: entry.request_day, 23 | firstDay: entry.first_day, 24 | lastDay: entry.last_day, 25 | 26 | to: [entry.employee.supporter.email], 27 | from: entry.employee.email, 28 | cc: 'hr@bocoup.com', 29 | subject: 'Upcoming Time off Request (' + entry.first_day + 30 | ' - ' + entry.last_day + ')' 31 | }; 32 | }); 33 | }.toString() 34 | }; 35 | -------------------------------------------------------------------------------- /test/mocks/aws-sdk.js: -------------------------------------------------------------------------------- 1 | var env = process.env; 2 | 3 | exports.env = { 4 | AWS_ACCESS_KEY_ID: env.AWS_ACCESS_KEY_ID, 5 | AWS_SECRET_ACCESS_KEY: env.AWS_SECRET_ACCESS_KEY 6 | }; 7 | 8 | exports.config = {}; 9 | exports.config.loadFromPath = function(path) { 10 | require(path); 11 | }; 12 | 13 | exports.SES = function() {}; 14 | exports.SES.prototype = { 15 | sendEmail: function(config, cb) { 16 | this.config = config; 17 | this.cb = cb; 18 | 19 | this.cb(null, { 20 | status: "success", 21 | 22 | data: { 23 | ResponseMetadata: { 24 | RequestId: "454fcee7-f293-11e4-8ce7-3f483707c2e3" 25 | }, 26 | 27 | MessageId: "0000014d20639464-dd5a5eb4-5205-48fe-96c3-efc6fd5e883a-000000" 28 | } 29 | }); 30 | } 31 | }; 32 | 33 | exports.SQS = function() {}; 34 | -------------------------------------------------------------------------------- /test/mocks/crontab.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment'); 2 | const exec = require('child_process').exec; 3 | const handler = require('../../server/handler'); 4 | 5 | module.exports = { 6 | result: { 7 | comment: function() { 8 | return this.comment; 9 | } 10 | }, 11 | 12 | load: function(callback) { 13 | var result = this.result; 14 | 15 | callback(null, { 16 | create: function(command, when, uuid) { 17 | result._command = command; 18 | result._when = when; 19 | result._comment = uuid; 20 | 21 | command = command.split(' ').slice(2).join(' ').slice(1, -1); 22 | command = new Array(2).concat(command); 23 | 24 | if (typeof when !== 'object' && when.indexOf('@') === -1) { 25 | setTimeout(function() { 26 | handler(command).then(function(state) { 27 | result._state = state; 28 | }).catch(function(ex) { 29 | console.log(ex.stack); 30 | }); 31 | }, -1 * moment().diff(new Date(when))); 32 | } 33 | else { 34 | setTimeout(function() { 35 | handler(command).then(function(state) { 36 | result._state = state; 37 | }); 38 | }, 10); 39 | } 40 | 41 | return result; 42 | }, 43 | 44 | find: function(obj) { 45 | return obj ? this.result : [this.result]; 46 | }, 47 | 48 | remove: function(jobs) {}, 49 | save: function(fn) { fn(); } 50 | }); 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /test/mocks/kue.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | queue: { 3 | _created: { 4 | save: function() {}, 5 | 6 | priority: function(priority) { 7 | this.priority = priority; 8 | return this; 9 | }, 10 | 11 | ttl: function(ttl) { 12 | this.ttl = ttl; 13 | return this; 14 | }, 15 | 16 | attempts: function(attempts) { 17 | this.attempts = attempts; 18 | return this; 19 | }, 20 | 21 | backoff: function(backoff) { 22 | this.backoff = backoff; 23 | return this; 24 | } 25 | }, 26 | 27 | process: function(name, concurrency, fn) { 28 | this.name = name; 29 | this.concurrency = concurrency; 30 | this.fn = fn; 31 | }, 32 | 33 | create: function(name, payload) { 34 | this.name = name; 35 | this.payload = payload; 36 | 37 | return this._created; 38 | } 39 | }, 40 | 41 | createQueue: function() { 42 | return this.queue; 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /test/mocks/request.js: -------------------------------------------------------------------------------- 1 | module.exports = function(url, callback) { 2 | switch (url) { 3 | case 'http://tbranyen.com/': { 4 | setTimeout(function() { 5 | callback(null, null, 'Tim'); 6 | }, 0); 7 | } 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | // Configure test environment. 4 | process.env.AWS_SES_SECRETS = path.join(__dirname, 'fixtures/secrets.json'); 5 | 6 | // Install mocks. 7 | [ 8 | 'request', 9 | 'aws-sdk', 10 | 'kue', 11 | 'crontab' 12 | ].forEach(function(name) { 13 | require.cache[require.resolve(name)] = { 14 | exports: require('./mocks/' + name) 15 | }; 16 | }); 17 | -------------------------------------------------------------------------------- /test/tests/api.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const path = require('path'); 3 | const express = require('express'); 4 | const supertest = require('supertest'); 5 | const _ = require('lodash'); 6 | const crontab = require('../mocks/crontab'); 7 | const typicalPollPost = require('../fixtures/typical-poll-post'); 8 | const htmlAndUrlPollPost = require('../fixtures/html-and-url-poll-post'); 9 | 10 | var handlerPath = 'node ' + path.join(__dirname, '../../bin/handler'); 11 | var server = express(); 12 | 13 | describe('API', function() { 14 | describe('Poll', function() { 15 | before(function() { 16 | this.router = require('../../server/api/poll/router'); 17 | server.use('/poll', this.router); 18 | this.request = supertest(server); 19 | this.typicalPollPost = _.extend({}, typicalPollPost); 20 | this.htmlAndUrlPollPost = _.extend({}, htmlAndUrlPollPost); 21 | }); 22 | 23 | it('can GET all poller jobs', function(done) { 24 | this.request 25 | .get('/poll') 26 | .expect('Content-Type', /json/) 27 | .expect(200) 28 | .end(function(err, res) { 29 | done(err); 30 | }); 31 | }); 32 | 33 | it('can GET a specific poller job', function(done) { 34 | this.request 35 | .get('/poll/test-uuid') 36 | .expect('Content-Type', /json/) 37 | .expect(200) 38 | .end(function(err, res) { 39 | done(err); 40 | }); 41 | }); 42 | 43 | it('can POST to register poller', function(done) { 44 | var test = this; 45 | 46 | this.request 47 | .post('/poll') 48 | .send(test.typicalPollPost) 49 | .expect('Content-Type', /json/) 50 | .expect(200) 51 | .end(function(err, res) { 52 | var handlerAsBase64 = new Buffer(test.typicalPollPost.handler) 53 | .toString('base64'); 54 | 55 | test.typicalPollPost.handler = handlerAsBase64; 56 | test.typicalPollPost.env = { 57 | AWS_SES_SECRETS: process.env.AWS_SES_SECRETS 58 | }; 59 | 60 | var serialized = '\'' + JSON.stringify(test.typicalPollPost) + '\''; 61 | assert.equal(crontab.result._command, handlerPath + ' ' + serialized); 62 | done(err); 63 | }); 64 | }); 65 | 66 | // TODO Put example. 67 | 68 | it('can schedule with html data and url fetched data', function(done) { 69 | var test = this; 70 | 71 | this.request 72 | .post('/poll') 73 | .send(htmlAndUrlPollPost) 74 | .expect('Content-Type', /json/) 75 | .expect(200) 76 | .end(function(err, res) { 77 | var handlerAsBase64 = new Buffer(test.htmlAndUrlPollPost.handler) 78 | .toString('base64'); 79 | 80 | test.htmlAndUrlPollPost.handler = handlerAsBase64; 81 | test.htmlAndUrlPollPost.env = { 82 | AWS_SES_SECRETS: process.env.AWS_SES_SECRETS 83 | }; 84 | test.htmlAndUrlPollPost.expires = true; 85 | 86 | var serialized = '\'' + JSON.stringify(test.htmlAndUrlPollPost) + '\''; 87 | 88 | assert.equal(crontab.result._command, handlerPath + ' ' + serialized); 89 | 90 | // Test out the handler. 91 | setTimeout(function() { 92 | assert.equal(crontab.result._state.payload.body, 'Hello Tim'); 93 | done(err); 94 | }, 150); 95 | }); 96 | }); 97 | 98 | it('can DELETE a specific poller job', function() { 99 | this.request 100 | .post('/poll') 101 | .send(htmlAndUrlPollPost); 102 | 103 | this.request 104 | .delete('/poll/test-uuid') 105 | .expect('Content-Type', /json/) 106 | .expect(200) 107 | .end(function(err, res) { 108 | done(err); 109 | }); 110 | }); 111 | }); 112 | 113 | describe('Sending', function() { 114 | before(function() { 115 | this.router = require('../../server/api/send/router'); 116 | server.use('/send', this.router); 117 | this.request = supertest(server); 118 | }); 119 | 120 | it('can POST an e-mail immediately', function(done) { 121 | this.request 122 | .post('/send') 123 | .send({ 124 | from: 'test@bocoup.com', 125 | to: ['someone@email.com'], 126 | subject: 'Testing', 127 | body: 'Testing HTML' 128 | }) 129 | .expect('Content-Type', /json/) 130 | .expect(200) 131 | .end(function(err, res) { 132 | done(err); 133 | }); 134 | }); 135 | 136 | it('will reject missing required fields', function(done) { 137 | this.request 138 | .post('/send') 139 | .send({ 140 | to: '' 141 | }) 142 | .expect('Content-Type', /json/) 143 | .expect(400, done); 144 | }); 145 | 146 | it('will reject invalid POST', function(done) { 147 | this.request 148 | .post('/send') 149 | .send('{ )') 150 | .expect('Content-Type', /json/) 151 | .expect(400, done); 152 | }); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /test/tests/email.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const email = require('../../server/email'); 3 | 4 | describe('Email', function() { 5 | it('exports the public interface', function() { 6 | assert.ok(email.send); 7 | assert.ok(email.processQueue); 8 | assert.ok(email.normalizePayload); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /test/tests/handler.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const exec = require('child_process').exec; 3 | 4 | describe('Handler', function() { 5 | it('can be initialized from the command line', function(done) { 6 | exec('node bin/handler', function(err, output) { 7 | done(); 8 | }); 9 | }); 10 | 11 | it('will error if incorrect arguments are passed', function(done) { 12 | exec('node bin/handler', function(err, output) { 13 | assert.equal(err.message, 'Command failed: /bin/sh -c node bin/handler\nInsufficient arguments to handler see --help\n'); 14 | done(); 15 | }); 16 | }); 17 | }); 18 | --------------------------------------------------------------------------------