diff --git a/dashboard/public/translation/en.json b/dashboard/public/translation/en.json
index 842585469..0388eca5d 100644
--- a/dashboard/public/translation/en.json
+++ b/dashboard/public/translation/en.json
@@ -1136,7 +1136,18 @@
"nonePending": "All Caught Up!",
"dismissTooltip": "Dismiss",
"clearAll": "Clear All",
- "markAllAsRead": "Mark All as Read"
+ "markAllAsRead": "Mark All as Read",
+ "settings": {
+ "title": "Notification Settings",
+ "backupFailed": "Backup failed",
+ "certificateRenewalFailed": "Certificate renewal failed",
+ "appOutOfMemory": "App ran out of memory",
+ "appUp": "App is online",
+ "appDown": "App went down"
+ },
+ "settingsDialog": {
+ "description": "Manage your personal notification preferences here. Cloudron will send an email for the selected events to your primary email address."
+ }
},
"logs": {
"title": "Logs",
diff --git a/dashboard/public/translation/nl.json b/dashboard/public/translation/nl.json
index c1a0c5d71..d43bba347 100644
--- a/dashboard/public/translation/nl.json
+++ b/dashboard/public/translation/nl.json
@@ -643,6 +643,27 @@
"title": "Bewerk Backup",
"label": "Label",
"remotePath": "Extern pad"
+ },
+ "archives": {
+ "title": "Archieven",
+ "location": "Locatie",
+ "archiveDate": "Archief datum",
+ "info": "Info"
+ },
+ "archive": {
+ "description": "Verwijderde archieven worden opgeschoond op basis van het backup-beleid."
+ },
+ "deleteArchive": {
+ "deleteAction": "Verwijder"
+ },
+ "deleteArchiveDialog": {
+ "description": "Na verwijdering zal het Archief worden opgeschoond op basis van het backup-beleid.",
+ "title": "Verwijder Archief van {{appTitle}} ({{fqdn}})"
+ },
+ "restoreArchiveDialog": {
+ "title": "Herstel vanuit Archief",
+ "description": "Hiermee installeer je {{appId}} op de aangegeven locatie met de backup van {{creationTime}}.",
+ "restoreAction": "Herstel {{ dnsOverwrite ? 'and overwrite DNS' : '' }}"
}
},
"branding": {
diff --git a/dashboard/public/views/notifications.html b/dashboard/public/views/notifications.html
index 12089beaf..526c1fde0 100644
--- a/dashboard/public/views/notifications.html
+++ b/dashboard/public/views/notifications.html
@@ -7,6 +7,21 @@
diff --git a/src/mail_templates/app_down.ejs b/src/mail_templates/app_down.ejs
new file mode 100644
index 000000000..8e26935e8
--- /dev/null
+++ b/src/mail_templates/app_down.ejs
@@ -0,0 +1,23 @@
+<%if (format === 'text') { %>
+
+Dear Cloudron Admin,
+
+The application '<%= title %>' installed at <%= appFqdn %> is not responding.
+
+This is most likely a problem in the application.
+
+To resolve this, you can try the following:
+
+* Restart the app by opening the app's web terminal - https://docs.cloudron.io/apps/#web-terminal
+* Restore the app to the latest backup - https://docs.cloudron.io/backups/#restoring-an-app
+* Contact us in our Forum at https://forum.cloudron.io
+
+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/oom_event.ejs b/src/mail_templates/oom_event.ejs
new file mode 100644
index 000000000..9d99d395e
--- /dev/null
+++ b/src/mail_templates/oom_event.ejs
@@ -0,0 +1,29 @@
+<%if (format === 'text') { %>
+
+Dear <%= cloudronName %> Admin,
+
+<%if (app) { %>
+The application at <%= app.fqdn %> ran out of memory. The application has been restarted automatically. If you see this notification often,
+consider increasing the memory limit - <%= webadminUrl %>/#/app/<%= app.id %>/resources .
+<% } else { %>
+The addon <%= addon.name %> service ran out of memory. The service has been restarted automatically. If you see this notification often,
+consider increasing the memory limit - <%= webadminUrl %>/#/services .
+<% } %>
+
+Out of memory event:
+
+-------------------------------------
+
+<%- event %>
+
+-------------------------------------
+
+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 63be08aa9..25fdc642a 100644
--- a/src/mailer.js
+++ b/src/mailer.js
@@ -7,8 +7,10 @@ exports = module.exports = {
sendNewLoginLocation,
backupFailed,
-
certificateRenewalError,
+ appDown,
+ appUp,
+ oomEvent,
sendTestMail,
@@ -40,7 +42,8 @@ async function getMailConfig() {
cloudronName,
notificationFrom: `"${cloudronName}" `,
supportEmail: 'support@cloudron.io',
- dashboardFqdn
+ dashboardFqdn,
+ notificationsUrl: `https://${dashboardFqdn}/cloudron/#/notifications`
};
}
@@ -182,19 +185,78 @@ async function passwordReset(user, email, resetLink) {
await sendMail(mailOptions);
}
+async function appDown(mailTo, app) {
+ assert.strictEqual(typeof mailTo, 'string');
+ assert.strictEqual(typeof app, 'object');
+
+ const mailConfig = await getMailConfig();
+
+ const mailOptions = {
+ from: mailConfig.notificationFrom,
+ to: mailTo,
+ subject: `[${mailConfig.cloudronName}] App ${app.fqdn} is down`,
+ text: render('app_down.ejs', { title: app.manifest.title, appFqdn: app.fqdn, notificationsUrl: mailConfig.notificationsUrl })
+ };
+
+ await sendMail(mailOptions);
+}
+
+async function appUp(mailTo, app) {
+ assert.strictEqual(typeof mailTo, 'string');
+ assert.strictEqual(typeof app, 'object');
+
+ const mailConfig = await getMailConfig();
+
+ const mailOptions = {
+ from: mailConfig.notificationFrom,
+ to: mailTo,
+ subject: `[${mailConfig.cloudronName}] App ${app.fqdn} is back online`,
+ text: render('app_up.ejs', { title: app.manifest.title, appFqdn: app.fqdn, notificationsUrl: mailConfig.notificationsUrl })
+ };
+
+ await sendMail(mailOptions);
+}
+
+async function oomEvent(mailTo, app, addon, containerId, event) {
+ assert.strictEqual(typeof mailTo, 'string');
+ assert.strictEqual(typeof app, 'object');
+ assert.strictEqual(typeof addon, 'object');
+ assert.strictEqual(typeof containerId, 'string');
+ assert.strictEqual(typeof event, 'object');
+
+ const mailConfig = await getMailConfig();
+
+ const templateData = {
+ webadminUrl: `https://${mailConfig.dashboardFqdn}`,
+ cloudronName: mailConfig.cloudronName,
+ app,
+ addon,
+ event: JSON.stringify(event),
+ notificationsUrl: mailConfig.notificationsUrl
+ };
+
+ const mailOptions = {
+ from: mailConfig.notificationFrom,
+ to: mailTo,
+ subject: `[${mailConfig.cloudronName}] ${app ? app.fqdn : addon.name} was restarted (OOM)`,
+ text: render('oom_event.ejs', templateData)
+ };
+
+ await sendMail(mailOptions);
+}
+
async function backupFailed(mailTo, errorMessage, logUrl) {
assert.strictEqual(typeof mailTo, 'string');
assert.strictEqual(typeof errorMessage, 'string');
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, notificationsUrl })
+ text: render('backup_failed.ejs', { cloudronName: mailConfig.cloudronName, message: errorMessage, logUrl, notificationsUrl: mailConfig.notificationsUrl })
};
await sendMail(mailOptions);
@@ -206,13 +268,12 @@ 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, notificationsUrl })
+ text: render('certificate_renewal_error.ejs', { domain, message, notificationsUrl: mailConfig.notificationsUrl })
};
await sendMail(mailOptions);
diff --git a/src/notifications.js b/src/notifications.js
index 7148f3a0e..eccc944c3 100644
--- a/src/notifications.js
+++ b/src/notifications.js
@@ -17,6 +17,8 @@ exports = module.exports = {
TYPE_APP_OOM: 'appOutOfMemory',
TYPE_APP_UPDATED: 'appUpdated',
TYPE_BACKUP_FAILED: 'backupFailed',
+ TYPE_APP_DOWN: 'appDown',
+ TYPE_APP_UP: 'appUp',
// these are singleton types allowed in pin() and unpin()
TYPE_DISK_SPACE: 'diskSpace',
@@ -157,6 +159,37 @@ async function oomEvent(eventId, containerId, app, addonName /*, event*/) {
}
await add(exports.TYPE_APP_OOM, title, message, { eventId });
+
+ const admins = await users.getAdmins();
+ for (const admin of admins) {
+ if (admin.notificationConfig.includes(exports.TYPE_APP_OOM)) {
+ await mailer.oomEvent(admin.email, app, addonName);
+ }
+ }
+}
+
+async function appUp(eventId, app) {
+ assert.strictEqual(typeof eventId, 'string');
+ assert.strictEqual(typeof app, 'object');
+
+ const admins = await users.getAdmins();
+ for (const admin of admins) {
+ if (admin.notificationConfig.includes(exports.TYPE_APP_UP)) {
+ await mailer.appDown(admin.email, app);
+ }
+ }
+}
+
+async function appDown(eventId, app) {
+ assert.strictEqual(typeof eventId, 'string');
+ assert.strictEqual(typeof app, 'object');
+
+ const admins = await users.getAdmins();
+ for (const admin of admins) {
+ if (admin.notificationConfig.includes(exports.TYPE_APP_DOWN)) {
+ await mailer.appDown(admin.email, app);
+ }
+ }
}
async function appUpdated(eventId, app, fromManifest, toManifest) {
@@ -175,16 +208,6 @@ async function appUpdated(eventId, app, fromManifest, toManifest) {
await add(exports.TYPE_APP_UPDATED, title, `The application installed at https://${app.fqdn} was updated.\n\nChangelog:\n${toManifest.changelog}\n`, { eventId });
}
-async function boxInstalled(eventId, version) {
- assert.strictEqual(typeof eventId, 'string');
- assert.strictEqual(typeof version, 'string');
-
- const changes = changelog.getChanges(version.replace(/\.([^.]*)$/, '.0')); // last .0 release
- const changelogMarkdown = changes.map((m) => `* ${m}\n`).join('');
-
- await add(exports.TYPE_CLOUDRON_INSTALLED, `Cloudron v${version} installed`, `Cloudron v${version} was installed.\n\nPlease join our community at ${constants.FORUM_URL} .\n\nChangelog:\n${changelogMarkdown}\n`, { eventId });
-}
-
async function boxUpdated(eventId, oldVersion, newVersion) {
assert.strictEqual(typeof eventId, 'string');
assert.strictEqual(typeof oldVersion, 'string');
@@ -196,6 +219,16 @@ async function boxUpdated(eventId, oldVersion, newVersion) {
await add(exports.TYPE_CLOUDRON_UPDATED, `Cloudron updated to v${newVersion}`, `Cloudron was updated from v${oldVersion} to v${newVersion}.\n\nChangelog:\n${changelogMarkdown}\n`, { eventId });
}
+async function boxInstalled(eventId, version) {
+ assert.strictEqual(typeof eventId, 'string');
+ assert.strictEqual(typeof version, 'string');
+
+ const changes = changelog.getChanges(version.replace(/\.([^.]*)$/, '.0')); // last .0 release
+ const changelogMarkdown = changes.map((m) => `* ${m}\n`).join('');
+
+ await add(exports.TYPE_CLOUDRON_INSTALLED, `Cloudron v${version} installed`, `Cloudron v${version} was installed.\n\nPlease join our community at ${constants.FORUM_URL} .\n\nChangelog:\n${changelogMarkdown}\n`, { eventId });
+}
+
async function boxUpdateError(eventId, errorMessage) {
assert.strictEqual(typeof eventId, 'string');
assert.strictEqual(typeof errorMessage, 'string');
@@ -278,6 +311,12 @@ async function onEvent(id, action, source, data) {
case eventlog.ACTION_APP_OOM:
return await oomEvent(id, data.containerId, data.app, data.addonName, data.event);
+ case eventlog.ACTION_APP_UP:
+ return await appUp(id, data.app);
+
+ case eventlog.ACTION_APP_DOWN:
+ return await appDown(id, data.app);
+
case eventlog.ACTION_APP_UPDATE_FINISH:
if (source.username !== AuditSource.CRON.username) return; // updated by user
if (data.errorMessage) return; // the update indicator will still appear, so no need to notify user
diff --git a/src/users.js b/src/users.js
index d59c9c169..863c70bb8 100644
--- a/src/users.js
+++ b/src/users.js
@@ -90,6 +90,7 @@ const appPasswords = require('./apppasswords.js'),
mail = require('./mail.js'),
mailer = require('./mailer.js'),
mysql = require('mysql'),
+ notifications = require('./notifications'),
qrcode = require('qrcode'),
safe = require('safetydance'),
settings = require('./settings.js'),
@@ -213,6 +214,7 @@ async function add(email, data, auditSource) {
let fallbackEmail = data.fallbackEmail || '';
const source = data.source || ''; // empty is local user
const role = data.role || exports.ROLE_USER;
+ const notificationConfig = 'notificationConfig' in data ? data.notificationConfig : null;
let error;
@@ -264,11 +266,12 @@ async function add(email, data, auditSource) {
source,
role,
avatar: constants.AVATAR_NONE,
- language: ''
+ language: '',
+ notificationConfigJson: notificationConfig ? JSON.stringify(notificationConfig) : null
};
- const query = 'INSERT INTO users (id, username, password, email, fallbackEmail, salt, resetToken, inviteToken, displayName, source, role, avatar, language) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
- const args = [ user.id, user.username, user.password, user.email, user.fallbackEmail, user.salt, user.resetToken, user.inviteToken, user.displayName, user.source, user.role, user.avatar, user.language ];
+ const query = 'INSERT INTO users (id, username, password, email, fallbackEmail, salt, resetToken, inviteToken, displayName, source, role, avatar, language, notificationConfigJson) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
+ const args = [ user.id, user.username, user.password, user.email, user.fallbackEmail, user.salt, user.resetToken, user.inviteToken, user.displayName, user.source, user.role, user.avatar, user.language, user.notificationConfigJson ];
[error] = await safe(database.query(query, args));
if (error && error.code === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('users_email') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'email already exists');
@@ -631,8 +634,8 @@ async function update(user, data, auditSource) {
if (k === 'twoFactorAuthenticationEnabled' || k === 'active') {
fields.push(k + ' = ?');
args.push(data[k] ? 1 : 0);
- } else if (k === 'loginLocations') {
- fields.push('loginLocationsJson = ?');
+ } else if (k === 'loginLocations' || k === 'notificationConfig') {
+ fields.push(`${k}Json = ?`);
args.push(JSON.stringify(data[k]));
} else {
fields.push(k + ' = ?');
@@ -819,7 +822,8 @@ async function createOwner(email, username, password, displayName, auditSource)
const activated = await isActivated();
if (activated) throw new BoxError(BoxError.ALREADY_EXISTS, 'Cloudron already activated');
- return await add(email, { username, password, fallbackEmail: '', displayName, role: exports.ROLE_OWNER }, auditSource);
+ const notificationConfig = [notifications.TYPE_BACKUP_FAILED, notifications.TYPE_CERTIFICATE_RENEWAL_FAILED, notifications.TYPE_MANUAL_APP_UPDATE_NEEDED, notifications.TYPE_APP_DOWN ];
+ return await add(email, { username, password, fallbackEmail: '', displayName, role: exports.ROLE_OWNER, notificationConfig }, auditSource);
}
async function getInviteLink(user, auditSource) {
@@ -980,8 +984,7 @@ async function setNotificationConfig(user, notificationConfig, auditSource) {
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');
+ await update(user, { notificationConfig }, auditSource);
}
async function resetSources() {