Background job for sending emails + confirmation email template.
This commit is contained in:
parent
a5605a0349
commit
001e7b59a3
13 changed files with 414 additions and 18 deletions
|
@ -7,7 +7,17 @@
|
|||
"peersPath": "/tmp/peers",
|
||||
|
||||
"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": {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -12,7 +12,18 @@ var defaultConfig = {
|
|||
peersPath: '/tmp/peers',
|
||||
|
||||
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: {
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
13
server/jobs/mailQueueJob.js
Normal file
13
server/jobs/mailQueueJob.js
Normal 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
29
server/jobs/scheduler.js
Normal 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');
|
||||
}
|
||||
};
|
||||
});
|
|
@ -14,8 +14,10 @@
|
|||
}
|
||||
|
||||
lib('_', 'lodash');
|
||||
lib('async');
|
||||
lib('crypto');
|
||||
lib('deepExtend', 'deep-extend');
|
||||
lib('fs');
|
||||
lib('glob');
|
||||
lib('deepExtend', 'deep-extend');
|
||||
lib('moment');
|
||||
})();
|
||||
|
|
106
server/mailTemplates/monitoring-confirmation.body.html
Normal file
106
server/mailTemplates/monitoring-confirmation.body.html
Normal 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>
|
1
server/mailTemplates/monitoring-confirmation.subject.txt
Normal file
1
server/mailTemplates/monitoring-confirmation.subject.txt
Normal file
|
@ -0,0 +1 @@
|
|||
Bitte bestätige Deine E-Mail-Adresse
|
|
@ -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, '::');
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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});
|
||||
}
|
||||
|
||||
|
|
39
server/utils/urlBuilder.js
Normal file
39
server/utils/urlBuilder.js
Normal 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 });
|
||||
}
|
||||
};
|
||||
});
|
Loading…
Reference in a new issue