diff --git a/config.json.example b/config.json.example index 6c088ea..86cc6a3 100644 --- a/config.json.example +++ b/config.json.example @@ -7,7 +7,17 @@ "peersPath": "/tmp/peers", "email": { - "from": "no-reply@musterstadt.freifunk.net" + "from": "Freifunk Knotenformular ", + + "smtp": { + "host": "mail.example.com", + "port": 465, + "secure": true, + "auth": { + "user": "user@example.com", + "pass": "pass" + } + } } }, "client": { diff --git a/package.json b/package.json index 42147e5..546532c 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,11 @@ "jshint-stylish": "~2.2.0", "load-grunt-tasks": "~3.5.0", "lodash": "~4.12.0", + "moment": "~2.13.0", "ng-di": "~0.2.1", + "node-cron": "~1.1.1", + "nodemailer": "~2.4.1", + "nodemailer-html-to-text": "~2.1.0", "serve-static": "~1.10.2", "sqlite3": "~3.1.4", "time-grunt": "~1.3.0" diff --git a/server/config.js b/server/config.js index 64ed473..b75e99b 100644 --- a/server/config.js +++ b/server/config.js @@ -12,7 +12,18 @@ var defaultConfig = { peersPath: '/tmp/peers', email: { - from: 'no-reply@musterstadt.freifunk.net' + from: 'Freifunk Knotenformular ', + + // For details see: https://nodemailer.com/2-0-0-beta/setup-smtp/ + smtp: { + host: 'mail.example.com', + port: '465', + secure: true, + auth: { + user: 'user@example.com', + pass: 'pass' + } + } } }, client: { diff --git a/server/db/patches/001_add-email-queue-table.sql b/server/db/patches/001_add-email-queue-table.sql index 54ce16a..2b63665 100644 --- a/server/db/patches/001_add-email-queue-table.sql +++ b/server/db/patches/001_add-email-queue-table.sql @@ -7,5 +7,6 @@ CREATE TABLE email_queue ( email VARCHAR(255) NOT NULL, data TEXT NOT NULL, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL + created_at DATETIME DEFAULT (strftime('%s','now')) NOT NULL, + modified_at DATETIME DEFAULT (strftime('%s','now')) NOT NULL ); diff --git a/server/jobs/mailQueueJob.js b/server/jobs/mailQueueJob.js new file mode 100644 index 0000000..39878cb --- /dev/null +++ b/server/jobs/mailQueueJob.js @@ -0,0 +1,13 @@ +'use strict'; + +angular.module('ffffng').factory('MailQueueJob', function (Database, MailService) { + return { + run: function () { + MailService.sendPendingMails(function (err) { + if (err) { + console.error(err); + } + }); + } + }; +}); diff --git a/server/jobs/scheduler.js b/server/jobs/scheduler.js new file mode 100644 index 0000000..5a72515 --- /dev/null +++ b/server/jobs/scheduler.js @@ -0,0 +1,29 @@ +'use strict'; + +var glob = require('glob'); +var _ = require('lodash'); + +var jobFiles = glob.sync(__dirname + '/*Job.js'); +_.each(jobFiles, function (jobFile) { + require(jobFile); +}); + +angular.module('ffffng').factory('Scheduler', function ($injector) { + var cron = require('node-cron'); + + function schedule(expr, jobName) { + var job = $injector.get(jobName); + + if (!_.isFunction(job.run)) { + throw new Error('The job ' + jobName + ' does not provide a "run" function.'); + } + + cron.schedule(expr, job.run); + } + + return { + init: function () { + schedule('*/5 * * * * *', 'MailQueueJob'); + } + }; +}); diff --git a/server/libs.js b/server/libs.js index 43a98ad..9c3b047 100644 --- a/server/libs.js +++ b/server/libs.js @@ -14,8 +14,10 @@ } lib('_', 'lodash'); + lib('async'); lib('crypto'); + lib('deepExtend', 'deep-extend'); lib('fs'); lib('glob'); - lib('deepExtend', 'deep-extend'); + lib('moment'); })(); diff --git a/server/mailTemplates/monitoring-confirmation.body.html b/server/mailTemplates/monitoring-confirmation.body.html new file mode 100644 index 0000000..66b56c7 --- /dev/null +++ b/server/mailTemplates/monitoring-confirmation.body.html @@ -0,0 +1,106 @@ + + + + + + + + + + + + + +
+

+ Hallo <%- node.nickname %>, +

+ +

+ für einen Deiner Knoten wurde der automatisierte Versand von Status-E-Mails aktiviert. Um sicherzustellen, dass + Du wirklich der richtige Empfänger für diese E-Mails bist, bitten wir Dich, Deine E-Mail-Adresse durch einen + Klick auf den Bestätiguns-Link unten zu bestätigen. Erst danach wird der Versand von Status-E-Mails wirklich + stattfinden. +

+ + + + + +
+ + + + + + + + + + +
Knoten<%- node.hostname %>
Empfänger<%- node.email %>
+
+ + + + + +
+ + » E-Mail-Adresse bestätigen « + +
+ +

+ +

+ Bei Fragen wende Dich gerne an + <%- community.contactEmail %>. +

+ +

+ + Viele Grüße
+ Dein <%- community.name %> +
+

+ +
+ +

+ + Möchtest Du keine Status-E-Mails zu Diesem Knoten mehr erhalten, so kannst Du den Versand + jederzeit deaktivieren. + +

+

+ + Alternativ kannst Du die Versandeinstellungen auch jederzeit unter + <%- editNodeUrl %> + ändern. + +

+

+ + Bitte habe Verständnis dafür, dass das An- und Abschalten des Versands für jeden Deiner Knoten + einzeln erfolgt. + +

+
+ + diff --git a/server/mailTemplates/monitoring-confirmation.subject.txt b/server/mailTemplates/monitoring-confirmation.subject.txt new file mode 100644 index 0000000..9ba4813 --- /dev/null +++ b/server/mailTemplates/monitoring-confirmation.subject.txt @@ -0,0 +1 @@ +Bitte bestätige Deine E-Mail-Adresse diff --git a/server/main.js b/server/main.js index 081e776..ab51fce 100644 --- a/server/main.js +++ b/server/main.js @@ -15,6 +15,7 @@ require('./libs'); require('./utils/errorTypes'); require('./utils/resources'); require('./utils/strings'); +require('./utils/urlBuilder'); require('./resources/nodeResource'); require('./resources/monitoringResource'); @@ -26,8 +27,13 @@ require('./services/monitoringService'); require('../shared/validation/constraints'); require('./validation/validator'); -require('./db/database').init(function () { - angular.injector(['ffffng']).invoke(function (config, app, Router) { +require('./jobs/scheduler'); + +var db = require('./db/database'); + +db.init(function () { + angular.injector(['ffffng']).invoke(function (config, app, Scheduler, Router) { + Scheduler.init(); Router.init(); app.listen(config.server.port, '::'); diff --git a/server/services/mailService.js b/server/services/mailService.js index 88c5209..4f7e488 100644 --- a/server/services/mailService.js +++ b/server/services/mailService.js @@ -1,20 +1,185 @@ 'use strict'; angular.module('ffffng') -.service('MailService', function (Database, _) { +.service('MailService', function (Database, UrlBuilder, config, _, async, deepExtend, fs, moment) { + var MAIL_QUEUE_DB_BATCH_SIZE = 2; + var MAIL_QUEUE_MAX_PARALLEL_SENDING = 3; + + var transporter = require('nodemailer').createTransport(deepExtend( + {}, + config.server.email.smtp, + { + transport: 'smtp', + pool: true + } + )); + + var htmlToText = require('nodemailer-html-to-text').htmlToText; + transporter.use('compile', htmlToText({ + tables: ['.table'] + })); + + function sendMail(options, callback) { + var templateBasePath = __dirname + '/../mailTemplates/' + options.email; + async.parallel({ + subject: _.partial(fs.readFile, templateBasePath + '.subject.txt'), + body: _.partial(fs.readFile, templateBasePath + '.body.html') + }, + function (err, templates) { + if (err) { + return callback(err); + } + + var data = deepExtend( + {}, + options.data, + { + community: config.client.community, + editNodeUrl: UrlBuilder.editNodeUrl() + } + ); + + console.log(data); + + function render(field) { + console.log(field); + var rendered = _.template(templates[field].toString())(data); + console.log(rendered); + return rendered; + } + + var mailOptions; + try { + mailOptions = { + from: options.sender, + to: options.recipient, + subject: _.trim(render('subject')), + html: render('body') + }; + } + catch (error) { + return callback(error); + } + + transporter.sendMail(mailOptions, function (err) { + if (err) { + return callback(err); + } + + callback(null); + }); + } + ); + } + + function findPendingMailsBefore(beforeMoment, limit, callback) { + Database.all( + 'SELECT * FROM email_queue WHERE modified_at < ? AND failures < ? ORDER BY id ASC LIMIT ?', + [beforeMoment.unix(), 5, limit], // TODO: retrycount + function (err, rows) { + if (err) { + return callback(err); + } + + var pendingMails; + try { + pendingMails = _.map(rows, function (row) { + return deepExtend( + {}, + row, + { + data: JSON.parse(row.data) + } + ); + }); + } + catch (error) { + return callback(error); + } + + callback(null, pendingMails); + } + ); + } + + function removePendingMailFromQueue(id, callback) { + Database.run('DELETE FROM email_queue WHERE id = ?', [id], callback); + } + + function incrementFailureCounterForPendingEmail(id, callback) { + var now = moment(); + Database.run( + 'UPDATE email_queue SET failures = failures + 1, modified_at = ? WHERE id = ?', + [now.unix(), id], + callback + ); + } + + function sendPendingMail(pendingMail, callback) { + console.log(pendingMail); + sendMail(pendingMail, function (err) { + if (err) { + // we only log the error and increment the failure counter as we want to continue with pending mails + console.error(err); + + return incrementFailureCounterForPendingEmail(pendingMail.id, function (err) { + if (err) { + return callback(err); + } + return callback(null); + }); + } + + removePendingMailFromQueue(pendingMail.id, callback); + }); + } + return { enqueue: function (sender, recipient, email, data, callback) { if (!_.isPlainObject(data)) { return callback(new Error('Unexpected data: ' + data)); } Database.run( - 'INSERT INTO email_queue (failures, sender, recipient, email, data) VALUES (?, ?, ?, ?, ?)', + 'INSERT INTO email_queue ' + + '(failures, sender, recipient, email, data) ' + + 'VALUES (?, ?, ?, ?, ?)', [0, sender, recipient, email, JSON.stringify(data)], function (err, res) { - debugger; callback(err, res); } ); + }, + + sendPendingMails: function (callback) { + console.info('Start sending pending mails.'); + + var startTime = moment(); + + var sendNextBatch = function (err) { + if (err) { + return callback(err); + } + + findPendingMailsBefore(startTime, MAIL_QUEUE_DB_BATCH_SIZE, function (err, pendingMails) { + if (err) { + return callback(err); + } + + if (_.isEmpty(pendingMails)) { + console.info('Done sending pending mails.'); + return callback(null); + } + + async.eachLimit( + pendingMails, + MAIL_QUEUE_MAX_PARALLEL_SENDING, + sendPendingMail, + sendNextBatch + ); + }); + }; + + sendNextBatch(null); } }; }); diff --git a/server/services/nodeService.js b/server/services/nodeService.js index b380b30..bb1beb3 100644 --- a/server/services/nodeService.js +++ b/server/services/nodeService.js @@ -1,7 +1,17 @@ 'use strict'; angular.module('ffffng') -.service('NodeService', function (config, _, crypto, fs, glob, MailService, Strings, ErrorTypes) { +.service('NodeService', function ( + config, + _, + crypto, + fs, + glob, + MailService, + Strings, + ErrorTypes, + UrlBuilder +) { var linePrefixes = { hostname: '# Knotenname: ', nickname: '# Ansprechpartner: ', @@ -107,7 +117,7 @@ angular.module('ffffng') fs.unlinkSync(file); } catch (error) { - console.log(error); + console.error(error); return callback({data: 'Could not remove old node data.', type: ErrorTypes.internalError}); } } else { @@ -121,7 +131,7 @@ angular.module('ffffng') fs.writeFileSync(filename, data, 'utf8'); } catch (error) { - console.log(error); + console.error(error); return callback({data: 'Could not write node data.', type: ErrorTypes.internalError}); } @@ -138,7 +148,7 @@ angular.module('ffffng') fs.unlinkSync(files[0]); } catch (error) { - console.log(error); + console.error(error); return callback({data: 'Could not delete node.', type: ErrorTypes.internalError}); } @@ -197,13 +207,12 @@ angular.module('ffffng') } function sendMonitoringConfirmationMail(node, nodeSecrets, callback) { - var monitoringQueryString = '?mac=' + node.mac + '&token=' + nodeSecrets.monitoringToken; - var confirmUrl = config.server.baseUrl + '/#!/monitoring/confirm' + monitoringQueryString; - var disableUrl = config.server.baseUrl + '/#!/monitoring/disable' + monitoringQueryString; + var confirmUrl = UrlBuilder.monitoringConfirmUrl(node, nodeSecrets); + var disableUrl = UrlBuilder.monitoringDisableUrl(node, nodeSecrets); MailService.enqueue( config.server.email.from, - node.email, + node.nickname + ' <' + node.email + '>', 'monitoring-confirmation', { node: node, @@ -212,7 +221,7 @@ angular.module('ffffng') }, function (err) { if (err) { - console.log(err); + console.error(err); return callback({data: 'Internal error.', type: ErrorTypes.internalError}); } diff --git a/server/utils/urlBuilder.js b/server/utils/urlBuilder.js new file mode 100644 index 0000000..1385caf --- /dev/null +++ b/server/utils/urlBuilder.js @@ -0,0 +1,39 @@ +'use strict'; + +angular.module('ffffng').factory('UrlBuilder', function (_, config) { + function formUrl(route, queryParams) { + var url = config.server.baseUrl; + if (route || queryParams) { + url += '/#!/'; + } + if (route) { + url += route; + } + if (queryParams) { + url += '?'; + url += _.join( + _.map( + queryParams, + function (value, key) { + return encodeURIComponent(key) + '=' + encodeURIComponent(value); + } + ), + '&' + ); + } + return url; + } + + return { + editNodeUrl: function () { + return formUrl('update'); + }, + + monitoringConfirmUrl: function (node, nodeSecrets) { + return formUrl('monitoring/confirm', { mac: node.mac, token: nodeSecrets.monitoringToken }); + }, + monitoringDisableUrl: function (node, nodeSecrets) { + return formUrl('monitoring/disable', { mac: node.mac, token: nodeSecrets.monitoringToken }); + } + }; +});