commit 3c1a783bca85fd2542eb60fb047af60b7a07400a Author: kts of kettek (muzukashi) Date: Sat Dec 19 03:19:23 2015 -0800 Added working Polymail app and default example newsletter. You can send newsletters in markdown with HTML generation with this! diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e7b84c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +.DS_Store +*.swp diff --git a/app.js b/app.js new file mode 100644 index 0000000..af49ea0 --- /dev/null +++ b/app.js @@ -0,0 +1,222 @@ +/* app.js - Polymail +```````````````````````````````````````````````````````````````````````````````` +Polymail is a console Node.js application for sending newsletters. + +Intent: + * store newsletters as Markdown + * use git as an optional backend for storing sent newsletters + * create HTML from Markdown + * send both Markdown and HTML as separate body parts + +Newsletter: + * archive (git repo?) + * template file + * from, title + * recipients + * transport (smtp, etc.) + +Usage: + ./app.js polymathic_letters send 'my_markdown' --title="My Fine Time" + ./app.js NEWSLETTER COMMAND FILE --VAR=VALUE +*/ + +/* ==== REQUIRES ============================================================ */ +var fs = require('fs'); +var path = require('path'); +var nodemailer = require('nodemailer'); + +var argv = require('minimist')(process.argv.slice(2)); +/* ==== CLASS EXPORTS ======================================================= */ +/* Class: Newsletter +```````````````````````````````` +This class is the core class that custom newsletters are based upon. + +IMPORTANT NOTE: + This object can be extended to create additional functionality in a Newsletter + This can include, but is not limited to, alternative transport(s), different + build steps for using formats such as Markdown or otherwise, different send + steps for using alternative sending logic, and different archiving methods + such as using git or subversion repositories. + + In general, newsletters are composed of a directory in the newsletters/ + directory with index.js populated with an extended Newsletter module + inheriting from this Newsletter object. + +The Newsletter object has the following properties: + * subscribers + - array of subscriber emails + * repo + - optional object containing information pertaining to a repo archive + * options + - options object of key=>value pairs for template-based generation of the + newsletter text and/or HTML output. + * transport_options + - All options used for the transport - basically what should be passed to + nodemailer.createTransport or similar. + * transport + - nodemailer Transport object used during the `send` step + * temp + - object containing text and html properties for the processed newsletter - + this is what is sent during the `send` step + +There are three exposed method(s) that may be replaced by user implementation. + +These are: + build(file, options, cb) + * Step for compiling the newsletter from the given file applying the + appropriate options during creation. This populates the temp property of the + Newsletter object. + + send() + * Used to send the given newsletter email to all recipients. The newsletter + is compiled by the `build` step as the temp property of the Newsletter. + + archive(letter, file) + * Optional step used to archive the newsletter to an archive location. This + can just be a location on the filesystem or a git repository if desired. + +*/ +function Newsletter(data) { + this.temp = {}; + this.repo = {}; + this.subscribers = []; + this.options = {}; + this.transport_options = { + host: 'localhost', + port: 25 + }; + this.transport = null; + // merge passed Object into Newsletter + for (attr in data) { + if (this[attr]) this[attr] = mergeObjects(this[attr], data[attr]); + else this[attr] = data[attr]; + } +} +Newsletter.prototype.build = function(file, options, cb) { + // clear potential old newsletter + this.temp = {}; + // merge passed options with this defaults + var opts = mergeObjects(options, this.options); + // read our provided content file + var content = fs.readFileSync(file); + // create the data to be sent + this.temp.subject = opts.heading + (opts.title ? ' '+opts.title : ''); + this.temp.text = content; + this.temp.html = content; +}; +Newsletter.prototype.send = function() { + var i = 0; + var len = this.subscribers.length; + + var sendMail = function(newsletter, i, len, data) { + data.to = newsletter.subscribers[i]; + if (i++ < len) { + process.stdout.clearLine(); + process.stdout.cursorTo(0); + process.stdout.write(i+'/'+len+': '+data.to+'... '); + newsletter.transport.sendMail(data, function (err, info) { + if (err) { + console.log('Error\n'+err); + } else { + if (info.accepted && info.accepted.length > 0) { + console.log('Sent'); + } else if (info.rejected && info.rejected.length > 0) { + console.log('Rejected'); + } else if (info.pending && info.pending.length > 0) { + console.log('Pending'); + } + sendMail(newsletter, i, len, data); + } + }); + } else { + console.log('Finished!'); + newsletter.transport.close(); + } + }; + + sendMail(this, i, len, { + from: this.options.from, + subject: this.temp.subject, + text: (this.temp.text ? this.temp.text : ''), + html: (this.temp.html ? this.temp.html : '') + }); +}; +Newsletter.prototype.archive = function(letter, file) { + fs.writeFileSync('./newsletters/'+letter+'/archive/'+path.basename(file), fs.readFileSync(file)); +}; +exports.Newsletter = Newsletter; +/* Function: mergeObjects(obj1, obj2) +```````````````````````````````` +This function returns a new object that is the result of merging object 2 into +object 1. +*/ +var mergeObjects = function(obj1, obj2) { + if (obj1 instanceof Array) obj3 = []; + else obj3 = {}; + + for (var attr in obj1) { obj3[attr] = obj1[attr]; } + for (var attr in obj2) { obj3[attr] = obj2[attr]; } + + return obj3; +}; +exports.mergeObjects = mergeObjects; +/* Function: sendNewsletter(newsletter object, options, letter, file) +```````````````````````````````` +General function that iteratively calls: + build + send + archive +*/ +var sendNewsletter = function(newsletter, options, letter, file) { + newsletter.build(file, options, function(err) {}); + newsletter.send(); + newsletter.archive(letter, file); +}; +exports.sendNewsletter = sendNewsletter; +/* ==== MAIN ================================================================ */ +if (require.main == module) { // run as an app only if this is the main module + if (argv._.length < 3) { + console.log('err, 3 args required'); + } else if (argv._.length > 3) { + console.log('err, too many args'); + } else { + var letter = argv._[0]; + var cmd = argv._[1]; + var file = argv._[2]; + + // remove letter, cmd, and file from argv so we just pass argv as the options + delete argv._; + // attempt to load the given letter + try { + var nl = require('./newsletters/'+letter+'/index.js'); + if (nl.build == Newsletter.prototype.build) { + console.log('I: Newsletter is using default `build` step'); + } + if (nl.archive == Newsletter.prototype.archive) { + console.log('I: Newsletter is using default `archive` step'); + } + if (nl.transport == null) { + // create default transport + console.log('I: Newsletter has no custom transport, creating default'); + nl.transport = nodemailer.createTransport(nl.transport_options); + } + } catch(ex) { + console.log("E: Failure while loading newsletter:\n\t"+ex.stack); + process.exit(1); + } + // run the provided command + switch(cmd) { + case 'send': + try { + sendNewsletter(nl, argv, letter, file); + } catch (ex) { + console.log("E: Failure while sending newsletter:\n\t"+ex.stack); + process.exit(100); + } + break; + default: + console.log('err, unhandled cmd: '+cmd); + break; + } + } +} diff --git a/newsletters/default/config.json b/newsletters/default/config.json new file mode 100644 index 0000000..13d862f --- /dev/null +++ b/newsletters/default/config.json @@ -0,0 +1,16 @@ +{ + "options": { + "from": "Polymail ", + "heading": "Polymail" + }, + "subscribers": [ + "user@domain" + ], + "transport_options": { + "host": "", + "port": 25, + "maxConnections": 5, + "maxMessages": 10, + "rateLimit": 5 + } +} diff --git a/newsletters/default/index.js b/newsletters/default/index.js new file mode 100644 index 0000000..2d4114a --- /dev/null +++ b/newsletters/default/index.js @@ -0,0 +1,43 @@ +/* ==== MODULE ============================================================== */ +var polymail = require.main.exports; +var newsletter = new polymail.Newsletter(require('./config.json')); +/* ==== REQUIRES ============================================================ */ +var fs = require('fs'); +var nodemailer = require('nodemailer'); +var smtpPool = require('nodemailer-smtp-pool'); + +var doT = require('dot'); +var md = require('markdown-it')({ + typographer: true +}); + +/* ==== NEWSLETTER ========================================================== */ +// our custom build step +newsletter.build = function(file, options, cb) { + // clear potential old newsletter + newsletter.temp = {}; + // merge passed options with newsletter defaults + var opts = polymail.mergeObjects(newsletter.options, options); + // read our provided content file + var content = fs.readFileSync(file); + opts.content = content; + // read our markdown template and build it + var md_source = fs.readFileSync(__dirname+'/templates/md.dot'); + var md_template = doT.template(md_source); + var md_result = md_template(opts); + // convert our content md into html + opts.content = md.render(opts.content.toString()); + // read our html template and build it + var html_source = fs.readFileSync(__dirname+'/templates/html.dot'); + var html_template = doT.template(html_source); + var html_result = html_template(opts); + // set our temporary mail objects + newsletter.temp.subject = opts.heading + (opts.title ? ' '+opts.title : ''); + newsletter.temp.text = md_result; + newsletter.temp.html = html_result; +}; + +// create our transport +newsletter.transport = nodemailer.createTransport(smtpPool(newsletter.transport_options)); + +module.exports = newsletter; diff --git a/newsletters/default/package.json b/newsletters/default/package.json new file mode 100644 index 0000000..53dce8e --- /dev/null +++ b/newsletters/default/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "dot": "^1.0.3", + "markdown-it": "^5.0.2" + } +} diff --git a/newsletters/default/templates/html.dot b/newsletters/default/templates/html.dot new file mode 100644 index 0000000..9a60415 --- /dev/null +++ b/newsletters/default/templates/html.dot @@ -0,0 +1,15 @@ + + + {{=it.heading || ''}} {{=it.title || ''}} + + + +

{{=it.heading || ''}} {{=it.title || ''}}

+ {{=it.timestamp}} + {{=it.content || ''}} + + diff --git a/newsletters/default/templates/md.dot b/newsletters/default/templates/md.dot new file mode 100644 index 0000000..82b73e3 --- /dev/null +++ b/newsletters/default/templates/md.dot @@ -0,0 +1,3 @@ +# {{=it.heading || ''}} {{=it.title || ''}} {{=it.timestamp || ''}} + +{{=it.content || ''}} diff --git a/package.json b/package.json new file mode 100644 index 0000000..63b8f01 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "polymail", + "version": "0.0.1", + "description": "Fairly basic newsletter service", + "main": "app.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "http://git.kettek.net/G/polymail.git" + }, + "keywords": [ + "newsletter", + "service", + "email" + ], + "author": "kts of kettek", + "license": "GPLv3", + "dependencies": { + "minimist": "^1.2.0", + "nodemailer": "^1.10.0", + "nodemailer-smtp-pool": "^1.1.5" + } +}