diff --git a/src/cron.js b/src/cron.js index c4e2b50e7..e2155a921 100644 --- a/src/cron.js +++ b/src/cron.js @@ -15,6 +15,7 @@ var apps = require('./apps.js'), constants = require('./constants.js'), CronJob = require('cron').CronJob, debug = require('debug')('box:cron'), + digest = require('./digest.js'), eventlog = require('./eventlog.js'), janitor = require('./janitor.js'), scheduler = require('./scheduler.js'), @@ -22,20 +23,21 @@ var apps = require('./apps.js'), semver = require('semver'), updateChecker = require('./updatechecker.js'); -var gAutoupdaterJob = null, - gBoxUpdateCheckerJob = null, +var gAliveJob = null, // send periodic stats gAppUpdateCheckerJob = null, - gHeartbeatJob = null, // for CaaS health check - gAliveJob = null, // send periodic stats + gAutoupdaterJob = null, gBackupJob = null, - gCleanupTokensJob = null, - gCleanupBackupsJob = null, - gDockerVolumeCleanerJob = null, - gSchedulerSyncJob = null, + gBoxUpdateCheckerJob = null, gCertificateRenewJob = null, gCheckDiskSpaceJob = null, + gCleanupBackupsJob = null, gCleanupEventlogJob = null, - gDynamicDNSJob = null; + gCleanupTokensJob = null, + gDockerVolumeCleanerJob = null, + gDynamicDNSJob = null, + gHeartbeatJob = null, // for CaaS health check + gSchedulerSyncJob = null, + gDigestEmailJob = null; var NOOP_CALLBACK = function (error) { if (error) console.error(error); }; var AUDIT_SOURCE = { userId: null, username: 'cron' }; @@ -173,6 +175,14 @@ function recreateJobs(tz) { start: true, timeZone: tz }); + + if (gDigestEmailJob) gDigestEmailJob.stop(); + gDigestEmailJob = new CronJob({ + cronTime: '00 00 * * * 3', // every tuesday + onTick: digest.maybeSend, + start: true, + timeZone: tz + }); } function autoupdatePatternChanged(pattern) { @@ -272,5 +282,8 @@ function uninitialize(callback) { if (gDynamicDNSJob) gDynamicDNSJob.stop(); gDynamicDNSJob = null; + if (gDigestEmailJob) gDigestEmailJob.stop(); + gDigestEmailJob = null; + callback(); } diff --git a/src/digest.js b/src/digest.js new file mode 100644 index 000000000..912735c77 --- /dev/null +++ b/src/digest.js @@ -0,0 +1,46 @@ +'use strict'; + +var assert = require('assert'), + debug = require('debug')('box:digest'), + eventlog = require('./eventlog.js'), + updatechecker = require('./updatechecker.js'), + mailer = require('./mailer.js'), + settings = require('./settings.js'); + +exports = module.exports = { + maybeSend: maybeSend +}; + +function maybeSend() { + settings.getEmailDigest(function (error, enabled) { + if (error) return console.error(error); + if (!enabled) return debug('Email digest is disabled'); + + var updateInfo = updatechecker.getUpdateInfo(); + var pendingAppUpdates = updateInfo.apps || {}; + pendingAppUpdates = Object.keys(pendingAppUpdates).map(function (key) { return pendingAppUpdates[key]; }); + + eventlog.getByActionLastWeek(eventlog.ACTION_APP_UPDATE, function (error, appUpdates) { + if (error) return console.error(error); + + eventlog.getByActionLastWeek(eventlog.ACTION_UPDATE, function (error, boxUpdates) { + if (error) return console.error(error); + + var info = { + pendingAppUpdates: pendingAppUpdates, + pendingBoxUpdate: updateInfo.box || null, + + finishedAppUpdates: (appUpdates || []).map(function (e) { return e.data; }), + finishedBoxUpdates: (boxUpdates || []).map(function (e) { return e.data; }) + }; + + if (info.pendingAppUpdates.length || info.pendingBoxUpdate || info.finishedAppUpdates.length || info.finishedBoxUpdates.length) { + debug('maybeSend: sending digest email', info); + mailer.sendDigest(info); + } else { + debug('maybeSend: nothing happened, NOT sending digest email'); + } + }); + }); + }); +} diff --git a/src/eventlog.js b/src/eventlog.js index 7980f5401..a72441363 100644 --- a/src/eventlog.js +++ b/src/eventlog.js @@ -6,6 +6,7 @@ exports = module.exports = { add: add, get: get, getAllPaged: getAllPaged, + getByActionLastWeek: getByActionLastWeek, cleanup: cleanup, // keep in sync with webadmin index.js filter and CLI tool @@ -103,6 +104,17 @@ function getAllPaged(action, search, page, perPage, callback) { }); } +function getByActionLastWeek(action, callback) { + assert(typeof action === 'string' || action === null); + assert.strictEqual(typeof callback, 'function'); + + eventlogdb.getByActionLastWeek(action, function (error, boxes) { + if (error) return callback(new EventLogError(EventLogError.INTERNAL_ERROR, error)); + + callback(null, boxes); + }); +} + function cleanup(callback) { callback = callback || NOOP_CALLBACK; diff --git a/src/eventlogdb.js b/src/eventlogdb.js index 979d9f8c2..f08a394ef 100644 --- a/src/eventlogdb.js +++ b/src/eventlogdb.js @@ -3,6 +3,7 @@ exports = module.exports = { get: get, getAllPaged: getAllPaged, + getByActionLastWeek: getByActionLastWeek, add: add, count: count, delByCreationTime: delByCreationTime, @@ -71,6 +72,20 @@ function getAllPaged(action, search, page, perPage, callback) { }); } +function getByActionLastWeek(action, callback) { + assert(typeof action === 'string' || action === null); + assert.strictEqual(typeof callback, 'function'); + + var query = 'SELECT ' + EVENTLOGS_FIELDS + ' FROM eventlog WHERE action=? AND creationTime >= DATE_SUB(NOW(), INTERVAL 1 WEEK) ORDER BY creationTime DESC'; + database.query(query, [ action ], function (error, results) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + results.forEach(postProcess); + + callback(null, results); + }); +} + function add(id, action, source, data, callback) { assert.strictEqual(typeof id, 'string'); assert.strictEqual(typeof action, 'string'); diff --git a/src/mail_templates/digest.ejs b/src/mail_templates/digest.ejs new file mode 100644 index 000000000..a62f031dc --- /dev/null +++ b/src/mail_templates/digest.ejs @@ -0,0 +1,46 @@ +<%if (format === 'text') { %> + +Dear <%= cloudronName %> Admin, + +This is your weekly summary of activities on your Cloudron. +<% if (info.pendingBoxUpdate) { %> +Pending box update to version <%- info.pendingBoxUpdate.version %>: + <% for (var i = 0; i < info.pendingBoxUpdate.changelog.length; i++) { %> + * <%- info.pendingBoxUpdate.changelog[i] %> + <% } %> +<% } %> +<% if (info.pendingAppUpdates.length) { %> +Pending app updates: + <% for (var i = 0; i < info.pendingAppUpdates.length; i++) { %> + - <%= info.pendingAppUpdates[i].manifest.title %> (to version <%= info.pendingAppUpdates[i].manifest.version %>): + <% for (var j = 0; j < info.pendingAppUpdates[i].manifest.changelog.split('\n').length; j++) { %> + <%= info.pendingAppUpdates[i].manifest.changelog.split('\n')[j] %> + <% } %> + <% } %> +<% } %> +<% if (info.finishedBoxUpdates.length) { %> +Your Cloudron received <%= info.finishedBoxUpdates.length %> system updates. + <% for (var i = 0; i < info.finishedBoxUpdates.length; i++) { %> + - Version <%= info.finishedBoxUpdates[i].boxUpdateInfo.version %> + <% for (var j = 0; j < info.finishedBoxUpdates[i].boxUpdateInfo.changelog.length; j++) { %> + * <%= info.finishedBoxUpdates[i].boxUpdateInfo.changelog[j] %> + <% } %> + <% } %> +<% } %> + +<% if (info.finishedAppUpdates.length) { %> +Your Cloudron updated the following apps: + <% for (var i = 0; i < info.finishedAppUpdates.length; i++) { %> + - <%= info.finishedAppUpdates[i].toManifest.title %> (to version <%= info.finishedAppUpdates[i].toManifest.version %>) + <% for (var j = 0; j < info.finishedAppUpdates[i].toManifest.changelog.split('\n').length; j++) { %> + <%= info.finishedAppUpdates[i].toManifest.changelog.split('\n')[j] %> + <% } %> + <% } %> +<% } %> + +Thank you, +your Cloudron + +<% } else { %> + +<% } %> diff --git a/src/mailer.js b/src/mailer.js index 2ae25eebc..ceda8ff3c 100644 --- a/src/mailer.js +++ b/src/mailer.js @@ -10,6 +10,7 @@ exports = module.exports = { passwordReset: passwordReset, boxUpdateAvailable: boxUpdateAvailable, appUpdateAvailable: appUpdateAvailable, + sendDigest: sendDigest, sendInvite: sendInvite, unexpectedExit: unexpectedExit, @@ -418,6 +419,30 @@ function appUpdateAvailable(app, updateInfo) { }); } +function sendDigest(info) { + assert.strictEqual(typeof info, 'object'); + + getAdminEmails(function (error, adminEmails) { + if (error) return console.log('Error getting admins', error); + + settings.getCloudronName(function (error, cloudronName) { + if (error) { + debug(error); + cloudronName = 'Cloudron'; + } + + var mailOptions = { + from: mailConfig().from, + to: adminEmails.join(', '), + subject: util.format('[%s] Weekly event digest', config.fqdn()), + text: render('digest.ejs', { fqdn: config.fqdn(), webadminUrl: config.adminOrigin(), cloudronName: cloudronName, info: info, format: 'text' }) + }; + + enqueue(mailOptions); + }); + }); +} + function outOfDiskSpace(message) { assert.strictEqual(typeof message, 'string'); diff --git a/src/settings.js b/src/settings.js index 5a7ccc6fe..cb8ec2a6c 100644 --- a/src/settings.js +++ b/src/settings.js @@ -45,12 +45,16 @@ exports = module.exports = { setMailConfig: setMailConfig, getDefaultSync: getDefaultSync, + getEmailDigest: getEmailDigest, + setEmailDigest: setEmailDigest, + getAll: getAll, AUTOUPDATE_PATTERN_KEY: 'autoupdate_pattern', TIME_ZONE_KEY: 'time_zone', CLOUDRON_NAME_KEY: 'cloudron_name', DEVELOPER_MODE_KEY: 'developer_mode', + EMAIL_DIGEST: 'email_digest', DNS_CONFIG_KEY: 'dns_config', DYNAMIC_DNS_KEY: 'dynamic_dns', BACKUP_CONFIG_KEY: 'backup_config', @@ -104,6 +108,7 @@ var gDefaults = (function () { result[exports.UPDATE_CONFIG_KEY] = { prerelease: false }; result[exports.APPSTORE_CONFIG_KEY] = {}; result[exports.MAIL_CONFIG_KEY] = { enabled: false }; + result[exports.EMAIL_DIGEST] = true; return result; })(); @@ -653,6 +658,30 @@ function setMailConfig(mailConfig, callback) { }); } +function getEmailDigest(callback) { + assert.strictEqual(typeof callback, 'function'); + + settingsdb.get(exports.EMAIL_DIGEST, function (error, enabled) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, gDefaults[exports.EMAIL_DIGEST]); + if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error)); + + callback(null, !!enabled); // settingsdb holds string values only + }); +} + +function setEmailDigest(enabled, callback) { + assert.strictEqual(typeof enabled, 'boolean'); + assert.strictEqual(typeof callback, 'function'); + + settingsdb.set(exports.EMAIL_DIGEST, enabled ? 'enabled' : '', function (error) { + if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error)); + + exports.events.emit(exports.EMAIL_DIGEST, enabled); + + callback(null); + }); +} + function getAppstoreConfig(callback) { assert.strictEqual(typeof callback, 'function');