Background job for sending emails + confirmation email template.

This commit is contained in:
baldo 2016-05-21 17:06:24 +02:00
parent a5605a0349
commit 001e7b59a3
13 changed files with 414 additions and 18 deletions

View file

@ -7,7 +7,17 @@
"peersPath": "/tmp/peers", "peersPath": "/tmp/peers",
"email": { "email": {
"from": "no-reply@musterstadt.freifunk.net" "from": "Freifunk Knotenformular <no-reply@musterstadt.freifunk.net>",
"smtp": {
"host": "mail.example.com",
"port": 465,
"secure": true,
"auth": {
"user": "user@example.com",
"pass": "pass"
}
}
} }
}, },
"client": { "client": {

View file

@ -39,7 +39,11 @@
"jshint-stylish": "~2.2.0", "jshint-stylish": "~2.2.0",
"load-grunt-tasks": "~3.5.0", "load-grunt-tasks": "~3.5.0",
"lodash": "~4.12.0", "lodash": "~4.12.0",
"moment": "~2.13.0",
"ng-di": "~0.2.1", "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", "serve-static": "~1.10.2",
"sqlite3": "~3.1.4", "sqlite3": "~3.1.4",
"time-grunt": "~1.3.0" "time-grunt": "~1.3.0"

View file

@ -12,7 +12,18 @@ var defaultConfig = {
peersPath: '/tmp/peers', peersPath: '/tmp/peers',
email: { email: {
from: 'no-reply@musterstadt.freifunk.net' from: 'Freifunk Knotenformular <no-reply@musterstadt.freifunk.net>',
// 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: { client: {

View file

@ -7,5 +7,6 @@ CREATE TABLE email_queue (
email VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL,
data TEXT 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
); );

View file

@ -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);
}
});
}
};
});

29
server/jobs/scheduler.js Normal file
View file

@ -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');
}
};
});

View file

@ -14,8 +14,10 @@
} }
lib('_', 'lodash'); lib('_', 'lodash');
lib('async');
lib('crypto'); lib('crypto');
lib('deepExtend', 'deep-extend');
lib('fs'); lib('fs');
lib('glob'); lib('glob');
lib('deepExtend', 'deep-extend'); lib('moment');
})(); })();

View file

@ -0,0 +1,106 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<style type="text/css">
body {
margin: 0;
padding: 0;
}
th {
font-weight: bold;
text-align: left;
padding-right: 10px;
}
em {
font-size: 0.9em;
}
</style>
</head>
<body>
<table border="0" cellpadding="10" cellspacing="0" bgcolor="#EDEDED">
<tr>
<td style="color: #444444; font-family: HelveticaNeue, 'Helvetica Neue', Helvetica, Arial, sans-serif;">
<p>
<strong style="font-size: 1.5em;">Hallo <%- node.nickname %>,</strong>
</p>
<p>
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.
</p>
<table width="100%" border="0" cellspacing="0" cellpadding="0" style="margin-top: 45px;">
<tr>
<td align="center">
<table class="table" style="border: 3px dashed #666666; padding: 10px; background-color: #F5F5F5; font-size: 1.3em;">
<tr>
<th>Knoten</th>
<td><%- node.hostname %></td>
</tr>
<tr>
<th>Empfänger</th>
<td><a href="mailto:<%- node.email %>" style="color: #E5287A;"><%- node.email %></a></td>
</tr>
</table>
</td>
</tr>
</table>
<table width="100%" border="0" cellspacing="0" cellpadding="0" style="margin-top: 45px; margin-bottom: 45px;">
<tr>
<td align="center" style="font-size: 1.5em;">
<strong>
<a href="<%- confirmUrl %>" style="color: #E5287A;">» E-Mail-Adresse bestätigen «</a>
</strong>
</td>
</tr>
</table>
<p></p>
<p>
Bei Fragen wende Dich gerne an
<a href="mailto:<%- community.contactEmail %>" style="color: #E5287A;"><%- community.contactEmail %></a>.
</p>
<p>
<strong>
Viele Grüße<br />
Dein <%- community.name %>
</strong>
</p>
<hr style="border-top: 1px solid #333333; border-left: 0; border-right: 0; border-bottom: 0;" />
<p>
<em>
Möchtest Du keine Status-E-Mails zu Diesem Knoten mehr erhalten, so kannst Du den Versand
jederzeit <a href="<%- disableUrl %>" style="color: #E5287A;">deaktivieren</a>.
</em>
</p>
<p>
<em>
Alternativ kannst Du die Versandeinstellungen auch jederzeit unter
<a href="<%- editNodeUrl %>" style="color: #E5287A;"><%- editNodeUrl %></a>
ändern.
</em>
</p>
<p>
<em>
Bitte habe Verständnis dafür, dass das An- und Abschalten des Versands für jeden Deiner Knoten
einzeln erfolgt.
</em>
</p>
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1 @@
Bitte bestätige Deine E-Mail-Adresse

View file

@ -15,6 +15,7 @@ require('./libs');
require('./utils/errorTypes'); require('./utils/errorTypes');
require('./utils/resources'); require('./utils/resources');
require('./utils/strings'); require('./utils/strings');
require('./utils/urlBuilder');
require('./resources/nodeResource'); require('./resources/nodeResource');
require('./resources/monitoringResource'); require('./resources/monitoringResource');
@ -26,8 +27,13 @@ require('./services/monitoringService');
require('../shared/validation/constraints'); require('../shared/validation/constraints');
require('./validation/validator'); require('./validation/validator');
require('./db/database').init(function () { require('./jobs/scheduler');
angular.injector(['ffffng']).invoke(function (config, app, Router) {
var db = require('./db/database');
db.init(function () {
angular.injector(['ffffng']).invoke(function (config, app, Scheduler, Router) {
Scheduler.init();
Router.init(); Router.init();
app.listen(config.server.port, '::'); app.listen(config.server.port, '::');

View file

@ -1,20 +1,185 @@
'use strict'; 'use strict';
angular.module('ffffng') 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 { return {
enqueue: function (sender, recipient, email, data, callback) { enqueue: function (sender, recipient, email, data, callback) {
if (!_.isPlainObject(data)) { if (!_.isPlainObject(data)) {
return callback(new Error('Unexpected data: ' + data)); return callback(new Error('Unexpected data: ' + data));
} }
Database.run( 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)], [0, sender, recipient, email, JSON.stringify(data)],
function (err, res) { function (err, res) {
debugger;
callback(err, res); 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);
} }
}; };
}); });

View file

@ -1,7 +1,17 @@
'use strict'; 'use strict';
angular.module('ffffng') 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 = { var linePrefixes = {
hostname: '# Knotenname: ', hostname: '# Knotenname: ',
nickname: '# Ansprechpartner: ', nickname: '# Ansprechpartner: ',
@ -107,7 +117,7 @@ angular.module('ffffng')
fs.unlinkSync(file); fs.unlinkSync(file);
} }
catch (error) { catch (error) {
console.log(error); console.error(error);
return callback({data: 'Could not remove old node data.', type: ErrorTypes.internalError}); return callback({data: 'Could not remove old node data.', type: ErrorTypes.internalError});
} }
} else { } else {
@ -121,7 +131,7 @@ angular.module('ffffng')
fs.writeFileSync(filename, data, 'utf8'); fs.writeFileSync(filename, data, 'utf8');
} }
catch (error) { catch (error) {
console.log(error); console.error(error);
return callback({data: 'Could not write node data.', type: ErrorTypes.internalError}); return callback({data: 'Could not write node data.', type: ErrorTypes.internalError});
} }
@ -138,7 +148,7 @@ angular.module('ffffng')
fs.unlinkSync(files[0]); fs.unlinkSync(files[0]);
} }
catch (error) { catch (error) {
console.log(error); console.error(error);
return callback({data: 'Could not delete node.', type: ErrorTypes.internalError}); return callback({data: 'Could not delete node.', type: ErrorTypes.internalError});
} }
@ -197,13 +207,12 @@ angular.module('ffffng')
} }
function sendMonitoringConfirmationMail(node, nodeSecrets, callback) { function sendMonitoringConfirmationMail(node, nodeSecrets, callback) {
var monitoringQueryString = '?mac=' + node.mac + '&token=' + nodeSecrets.monitoringToken; var confirmUrl = UrlBuilder.monitoringConfirmUrl(node, nodeSecrets);
var confirmUrl = config.server.baseUrl + '/#!/monitoring/confirm' + monitoringQueryString; var disableUrl = UrlBuilder.monitoringDisableUrl(node, nodeSecrets);
var disableUrl = config.server.baseUrl + '/#!/monitoring/disable' + monitoringQueryString;
MailService.enqueue( MailService.enqueue(
config.server.email.from, config.server.email.from,
node.email, node.nickname + ' <' + node.email + '>',
'monitoring-confirmation', 'monitoring-confirmation',
{ {
node: node, node: node,
@ -212,7 +221,7 @@ angular.module('ffffng')
}, },
function (err) { function (err) {
if (err) { if (err) {
console.log(err); console.error(err);
return callback({data: 'Internal error.', type: ErrorTypes.internalError}); return callback({data: 'Internal error.', type: ErrorTypes.internalError});
} }

View file

@ -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 });
}
};
});