diff --git a/dashboard/public/js/client.js b/dashboard/public/js/client.js index 672b4ddbf..dee880ec8 100644 --- a/dashboard/public/js/client.js +++ b/dashboard/public/js/client.js @@ -681,7 +681,8 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout source: null, avatarUrl: null, avatarType: null, - hasBackgroundImage: false + hasBackgroundImage: false, + notificationConfig: [] }; this._config = { consoleServerOrigin: null, @@ -813,6 +814,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout this._userInfo.avatarUrl = userInfo.avatarUrl + '?ts=' + Date.now(); // we add the timestamp to avoid caching this._userInfo.avatarType = userInfo.avatarType; this._userInfo.hasBackgroundImage = userInfo.hasBackgroundImage; + this._userInfo.notificationConfig = userInfo.notificationConfig; this._userInfo.isAtLeastOwner = [ ROLES.OWNER ].indexOf(userInfo.role) !== -1; this._userInfo.isAtLeastAdmin = [ ROLES.OWNER, ROLES.ADMIN ].indexOf(userInfo.role) !== -1; this._userInfo.isAtLeastMailManager = [ ROLES.OWNER, ROLES.ADMIN, ROLES.MAIL_MANAGER ].indexOf(userInfo.role) !== -1; @@ -2457,6 +2459,15 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout }); }; + Client.prototype.setNotificationConfig = function (notificationConfig, callback) { + post('/api/v1/profile/notification_config', { notificationConfig }, null, function (error, data, status) { + if (error) return callback(error); + if (status !== 204) return callback(new ClientError(status, data)); + + callback(null, data); + }); + }; + Client.prototype.setProfileEmail = function (email, password, callback) { post('/api/v1/profile/email', { email: email, password: password }, null, function (error, data, status) { if (error) return callback(error); diff --git a/dashboard/public/views/notifications.html b/dashboard/public/views/notifications.html index bf2ffb05b..7cc50b14c 100644 --- a/dashboard/public/views/notifications.html +++ b/dashboard/public/views/notifications.html @@ -1,8 +1,37 @@ + + +

{{ 'notifications.title' | tr }}
+ diff --git a/dashboard/public/views/notifications.js b/dashboard/public/views/notifications.js index 0010db659..806fc47c3 100644 --- a/dashboard/public/views/notifications.js +++ b/dashboard/public/views/notifications.js @@ -1,7 +1,7 @@ 'use strict'; /* global async */ -/* global angular */ +/* global angular, $ */ angular.module('Application').controller('NotificationsController', ['$scope', '$location', '$timeout', '$translate', '$interval', 'Client', function ($scope, $location, $timeout, $translate, $interval, Client) { Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); }); @@ -80,6 +80,29 @@ angular.module('Application').controller('NotificationsController', ['$scope', ' }); }; + $scope.settings = { + busy: false, + config: {}, + + show: function () { + for (const s of Client.getUserInfo().notificationConfig) { + $scope.settings.config[s] = true; + } + + $('#notificationsSettingsModal').modal('show'); + }, + + submit: function () { + const config = Object.keys($scope.settings.config).filter(c => $scope.settings.config[c] === true); + Client.setNotificationConfig(config, function (error) { + if (error) return Client.error(error); + + Client.refreshConfig(); + $('#notificationsSettingsModal').modal('hide'); + }); + }, + }; + Client.onReady(function () { var refreshTimer = $interval($scope.refresh, 60 * 1000); // keep this interval in sync with the notification count indicator in main.js $scope.$on('$destroy', function () { diff --git a/migrations/20241211175838-users-add-notificationConfig.js b/migrations/20241211175838-users-add-notificationConfig.js new file mode 100644 index 000000000..34137b480 --- /dev/null +++ b/migrations/20241211175838-users-add-notificationConfig.js @@ -0,0 +1,9 @@ +'use strict'; + +exports.up = async function (db) { + await db.runSql('ALTER TABLE users ADD COLUMN notificationConfigJson TEXT'); +}; + +exports.down = async function (db) { + await db.runSql('ALTER TABLE users DROP COLUMN notificationConfigJson'); +}; diff --git a/migrations/schema.sql b/migrations/schema.sql index 52d2ef062..9f38bca2b 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -35,6 +35,7 @@ CREATE TABLE IF NOT EXISTS users( avatar MEDIUMBLOB NOT NULL, backgroundImage MEDIUMBLOB, loginLocationsJson MEDIUMTEXT, // { locations: [{ ip, userAgent, city, country, ts }] } + notificationConfigJson TEXT, INDEX creationTime_index (creationTime), PRIMARY KEY(id)); diff --git a/src/mail_templates/backup_failed.ejs b/src/mail_templates/backup_failed.ejs index d4467d54d..f698998b4 100644 --- a/src/mail_templates/backup_failed.ejs +++ b/src/mail_templates/backup_failed.ejs @@ -13,8 +13,11 @@ Cloudron failed to create a complete backup. Please see the logs at <%= logUrl % Powered by https://cloudron.io +Don't want such mails? Change your notification preferences at <%= notificationsUrl %> + Sent at: <%= new Date().toUTCString() %> + <% } else { %> <% } %> diff --git a/src/mail_templates/certificate_renewal_error.ejs b/src/mail_templates/certificate_renewal_error.ejs index fd9eaf6b1..2a8b3cb0f 100644 --- a/src/mail_templates/certificate_renewal_error.ejs +++ b/src/mail_templates/certificate_renewal_error.ejs @@ -23,6 +23,8 @@ The error was: Powered by https://cloudron.io +Don't want such mails? Change your notification preferences at <%= notificationsUrl %> + Sent at: <%= new Date().toUTCString() %> <% } else { %> diff --git a/src/mailer.js b/src/mailer.js index beba84cea..63be08aa9 100644 --- a/src/mailer.js +++ b/src/mailer.js @@ -34,12 +34,13 @@ const MAIL_TEMPLATES_DIR = path.join(__dirname, 'mail_templates'); // This will collect the most common details required for notification emails async function getMailConfig() { const cloudronName = await branding.getCloudronName(); - const { domain:dashboardDomain } = await dashboard.getLocation(); + const { fqdn:dashboardFqdn, domain:dashboardDomain } = await dashboard.getLocation(); return { cloudronName, notificationFrom: `"${cloudronName}" `, - supportEmail: 'support@cloudron.io' + supportEmail: 'support@cloudron.io', + dashboardFqdn }; } @@ -99,15 +100,14 @@ async function sendInvite(user, invitor, email, inviteLink) { const mailConfig = await getMailConfig(); const translationAssets = await translations.getTranslations(); - const { fqdn:dashboardFqdn } = await dashboard.getLocation(); const templateData = { user: user.displayName || user.username || user.email, - webadminUrl: `https://${dashboardFqdn}`, + webadminUrl: `https://${mailConfig.dashboardFqdn}`, inviteLink: inviteLink, invitor: invitor ? invitor.email : null, cloudronName: mailConfig.cloudronName, - cloudronAvatarUrl: `https://${dashboardFqdn}/api/v1/cloudron/avatar` + cloudronAvatarUrl: `https://${mailConfig.dashboardFqdn}/api/v1/cloudron/avatar` }; const mailOptions = { @@ -134,7 +134,6 @@ async function sendNewLoginLocation(user, loginLocation) { const mailConfig = await getMailConfig(); const translationAssets = await translations.getTranslations(); - const { fqdn:dashboardFqdn } = await dashboard.getLocation(); const templateData = { user: user.displayName || user.username || user.email, @@ -143,7 +142,7 @@ async function sendNewLoginLocation(user, loginLocation) { country, city, cloudronName: mailConfig.cloudronName, - cloudronAvatarUrl: `https://${dashboardFqdn}/api/v1/cloudron/avatar` + cloudronAvatarUrl: `https://${mailConfig.dashboardFqdn}/api/v1/cloudron/avatar` }; const mailOptions = { @@ -164,13 +163,12 @@ async function passwordReset(user, email, resetLink) { const mailConfig = await getMailConfig(); const translationAssets = await translations.getTranslations(); - const { fqdn:dashboardFqdn } = await dashboard.getLocation(); const templateData = { user: user.displayName || user.username || user.email, resetLink: resetLink, cloudronName: mailConfig.cloudronName, - cloudronAvatarUrl: `https://${dashboardFqdn}/api/v1/cloudron/avatar` + cloudronAvatarUrl: `https://${mailConfig.dashboardFqdn}/api/v1/cloudron/avatar` }; const mailOptions = { @@ -190,12 +188,13 @@ async function backupFailed(mailTo, errorMessage, logUrl) { assert.strictEqual(typeof logUrl, 'string'); const mailConfig = await getMailConfig(); + const notificationsUrl = `https://${mailConfig.dashboardFqdn}/cloudron/#/notifications`; const mailOptions = { from: mailConfig.notificationFrom, to: mailTo, subject: `[${mailConfig.cloudronName}] Failed to backup`, - text: render('backup_failed.ejs', { cloudronName: mailConfig.cloudronName, message: errorMessage, logUrl }) + text: render('backup_failed.ejs', { cloudronName: mailConfig.cloudronName, message: errorMessage, logUrl, notificationsUrl }) }; await sendMail(mailOptions); @@ -207,12 +206,13 @@ async function certificateRenewalError(mailTo, domain, message) { assert.strictEqual(typeof message, 'string'); const mailConfig = await getMailConfig(); + const notificationsUrl = `https://${mailConfig.dashboardFqdn}/cloudron/#/notifications`; const mailOptions = { from: mailConfig.notificationFrom, to: mailTo, subject: `[${mailConfig.cloudronName}] Certificate renewal error`, - text: render('certificate_renewal_error.ejs', { domain, message }) + text: render('certificate_renewal_error.ejs', { domain, message, notificationsUrl }) }; await sendMail(mailOptions); diff --git a/src/notifications.js b/src/notifications.js index a9a4d8a23..7148f3a0e 100644 --- a/src/notifications.js +++ b/src/notifications.js @@ -24,7 +24,7 @@ exports = module.exports = { TYPE_REBOOT: 'reboot', TYPE_UPDATE_UBUNTU: 'ubuntuUpdate', TYPE_BOX_UPDATE: 'boxUpdate', - TYPE_MANUAL_APP_UPDATE_NEEDED: 'manualAppUpdate-', + TYPE_MANUAL_APP_UPDATE_NEEDED: 'manualAppUpdate', // these work off singleton types pin, @@ -212,7 +212,9 @@ async function certificateRenewalError(eventId, fqdn, errorMessage) { const admins = await users.getAdmins(); for (const admin of admins) { - await mailer.certificateRenewalError(admin.email, fqdn, errorMessage); + if (admin.notificationConfig.includes(exports.TYPE_CERTIFICATE_RENEWAL_FAILED)) { + await mailer.certificateRenewalError(admin.email, fqdn, errorMessage); + } } } @@ -223,19 +225,12 @@ async function backupFailed(eventId, taskId, errorMessage) { await add(exports.TYPE_BACKUP_FAILED, 'Backup failed', `Backup failed: ${errorMessage}. Logs are available [here](/logs.html?taskId=${taskId}).`, { eventId }); - // only send mail if the past 3 automated backups failed - const backupEvents = await eventlog.listPaged([eventlog.ACTION_BACKUP_FINISH], null /* search */, 1, 20); - let count = 0; - for (const event of backupEvents) { - if (!event.data.errorMessage) return; // successful backup (manual or cron) - if (event.source.username === AuditSource.CRON.username && ++count === 3) break; // last 3 consecutive crons have failed - } - if (count !== 3) return; // less than 3 failures - const { fqdn:dashboardFqdn } = await dashboard.getLocation(); const superadmins = await users.getSuperadmins(); for (const superadmin of superadmins) { - await mailer.backupFailed(superadmin.email, errorMessage, `https://${dashboardFqdn}/logs.html?taskId=${taskId}`); + if (superadmin.notificationConfig.includes(exports.TYPE_BACKUP_FAILED)) { + await mailer.backupFailed(superadmin.email, errorMessage, `https://${dashboardFqdn}/logs.html?taskId=${taskId}`); + } } } diff --git a/src/routes/profile.js b/src/routes/profile.js index 284e12f9f..eaa95a23a 100644 --- a/src/routes/profile.js +++ b/src/routes/profile.js @@ -15,6 +15,7 @@ exports = module.exports = { setTwoFactorAuthenticationSecret, enableTwoFactorAuthentication, disableTwoFactorAuthentication, + setNotificationConfig }; const assert = require('assert'), @@ -70,6 +71,7 @@ async function get(req, res, next) { source: req.user.source, hasBackgroundImage: !!backgroundImage, language: req.user.language, + notificationConfig: req.user.notificationConfig, avatarUrl: `https://${dashboardFqdn}/api/v1/profile/avatar/${req.user.id}`, avatarType: avatarType.toString() // this is a Buffer })); @@ -245,3 +247,16 @@ async function disableTwoFactorAuthentication(req, res, next) { next(new HttpSuccess(204, {})); } + +async function setNotificationConfig(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.user, 'object'); + + if (!Array.isArray(req.body.notificationConfig)) return next(new HttpError(400, 'notificationConfig must be an array of strings')); + if (req.body.notificationConfig.some(k => typeof k !== 'string')) return next(new HttpError(400, 'notificationConfig must be an array of strings')); + + const [error] = await safe(users.setNotificationConfig(req.user, req.body.notificationConfig, AuditSource.fromRequest(req))); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(204, {})); +} diff --git a/src/server.js b/src/server.js index 32c7fe346..12fb7859b 100644 --- a/src/server.js +++ b/src/server.js @@ -182,6 +182,7 @@ async function initializeExpressSync() { router.post('/api/v1/profile/twofactorauthentication_secret', json, token, authorizeUser, routes.profile.setTwoFactorAuthenticationSecret); router.post('/api/v1/profile/twofactorauthentication_enable', json, token, authorizeUser, routes.profile.enableTwoFactorAuthentication); router.post('/api/v1/profile/twofactorauthentication_disable', json, token, authorizeUser, routes.users.verifyPassword, routes.profile.disableTwoFactorAuthentication); + router.post('/api/v1/profile/notification_config', json, token, authorizeUser, routes.profile.setNotificationConfig); // non-admins cannot get notifications anyway // app password routes router.get ('/api/v1/app_passwords', token, authorizeUser, routes.appPasswords.list); diff --git a/src/users.js b/src/users.js index 7309f4dce..d59c9c169 100644 --- a/src/users.js +++ b/src/users.js @@ -51,6 +51,8 @@ exports = module.exports = { getBackgroundImage, setBackgroundImage, + setNotificationConfig, + resetSources, parseDisplayName, @@ -70,7 +72,7 @@ const ORDERED_ROLES = [ exports.ROLE_USER, exports.ROLE_USER_MANAGER, exports.RO // the avatar and backgroundImage fields are special and not added here to reduce response sizes const USERS_FIELDS = [ 'id', 'username', 'email', 'fallbackEmail', 'password', 'salt', 'creationTime', 'inviteToken', 'resetToken', 'displayName', 'language', - 'twoFactorAuthenticationEnabled', 'twoFactorAuthenticationSecret', 'active', 'source', 'role', 'resetTokenCreationTime', 'loginLocationsJson' ].join(','); + 'twoFactorAuthenticationEnabled', 'twoFactorAuthenticationSecret', 'active', 'source', 'role', 'resetTokenCreationTime', 'loginLocationsJson', 'notificationConfigJson' ].join(','); const DEFAULT_GHOST_LIFETIME = 6 * 60 * 60 * 1000; // 6 hours @@ -120,6 +122,9 @@ function postProcess(result) { if (!Array.isArray(result.loginLocations)) result.loginLocations = []; delete result.loginLocationsJson; + result.notificationConfig = safe.JSON.parse(result.notificationConfigJson) || []; + delete result.notificationConfigJson; + return result; } @@ -185,7 +190,7 @@ function validatePassword(password) { // remove all fields that should never be sent out via REST API function removePrivateFields(user) { - const result = _.pick(user, 'id', 'username', 'email', 'fallbackEmail', 'displayName', 'groupIds', 'active', 'source', 'role', 'createdAt', 'twoFactorAuthenticationEnabled'); + const result = _.pick(user, 'id', 'username', 'email', 'fallbackEmail', 'displayName', 'groupIds', 'active', 'source', 'role', 'createdAt', 'twoFactorAuthenticationEnabled', 'notificationConfig'); // invite status indicator result.inviteAccepted = !user.inviteToken; @@ -970,6 +975,15 @@ async function setBackgroundImage(id, backgroundImage) { if (result.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'User not found'); } +async function setNotificationConfig(user, notificationConfig, auditSource) { + assert.strictEqual(typeof user, 'object'); + assert(Array.isArray(notificationConfig)); + assert(auditSource && typeof auditSource === 'object'); + + const result = await database.query('UPDATE users SET notificationConfigJson=? WHERE id = ?', [ JSON.stringify(notificationConfig), user.id ]); + if (result.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'User not found'); +} + async function resetSources() { await database.query('UPDATE users SET source = ?', [ '' ]); }