| 1 | /** |
| 2 | * @description MeshCentral e-mail server communication modules |
| 3 | * @author Ylian Saint-Hilaire |
| 4 | * @copyright Intel Corporation 2018-2022 |
| 5 | * @license Apache-2.0 |
| 6 | * @version v0.0.1 |
| 7 | */ |
| 8 | |
| 9 | /*xjslint node: true */ |
| 10 | /*xjslint plusplus: true */ |
| 11 | /*xjslint maxlen: 256 */ |
| 12 | /*jshint node: true */ |
| 13 | /*jshint strict: false */ |
| 14 | /*jshint esversion: 6 */ |
| 15 | "use strict"; |
| 16 | |
| 17 | // TODO: Add NTML support with "nodemailer-ntlm-auth" https://github.com/nodemailer/nodemailer-ntlm-auth |
| 18 | |
| 19 | // Construct a MeshAgent object, called upon connection |
| 20 | module.exports.CreateMeshMail = function (parent, domain) { |
| 21 | var obj = {}; |
| 22 | obj.pendingMails = []; |
| 23 | obj.parent = parent; |
| 24 | obj.retry = 0; |
| 25 | obj.sendingMail = false; |
| 26 | obj.mailCookieEncryptionKey = null; |
| 27 | obj.verifyemail = false; |
| 28 | obj.domain = domain; |
| 29 | obj.emailDelay = 5 * 60 * 1000; // Default of 5 minute email delay. |
| 30 | //obj.mailTemplates = {}; |
| 31 | const sortCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }) |
| 32 | const constants = (obj.parent.crypto.constants ? obj.parent.crypto.constants : require('constants')); // require('constants') is deprecated in Node 11.10, use require('crypto').constants instead. |
| 33 | |
| 34 | function EscapeHtml(x) { if (typeof x == 'string') return x.replace(/&/g, '&').replace(/>/g, '>').replace(/</g, '<').replace(/"/g, '"').replace(/'/g, '''); if (typeof x == "boolean") return x; if (typeof x == "number") return x; } |
| 35 | //function EscapeHtmlBreaks(x) { if (typeof x == "string") return x.replace(/&/g, '&').replace(/>/g, '>').replace(/</g, '<').replace(/"/g, '"').replace(/'/g, ''').replace(/\r/g, '<br />').replace(/\n/g, '').replace(/\t/g, ' '); if (typeof x == "boolean") return x; if (typeof x == "number") return x; } |
| 36 | |
| 37 | // Setup where we read our configuration from |
| 38 | if (obj.domain == null) { obj.config = parent.config; } else { obj.config = domain; } |
| 39 | |
| 40 | if (obj.config.sendgrid != null) { |
| 41 | // Setup SendGrid mail server |
| 42 | obj.sendGridServer = require('@sendgrid/mail'); |
| 43 | obj.sendGridServer.setApiKey(obj.config.sendgrid.apikey); |
| 44 | if (obj.config.sendgrid.verifyemail == true) { obj.verifyemail = true; } |
| 45 | if ((typeof obj.config.sendgrid.emaildelayseconds == 'number') && (obj.config.sendgrid.emaildelayseconds > 0)) { obj.emailDelay = obj.config.sendgrid.emaildelayseconds * 1000; } |
| 46 | } else if (obj.config.smtp != null) { |
| 47 | // Setup SMTP mail server |
| 48 | if ((typeof obj.config.smtp.emaildelayseconds == 'number') && (obj.config.smtp.emaildelayseconds > 0)) { obj.emailDelay = obj.config.smtp.emaildelayseconds * 1000; } |
| 49 | if (obj.config.smtp.name == 'console') { |
| 50 | // This is for debugging, the mails will be displayed on the console |
| 51 | obj.smtpServer = 'console'; |
| 52 | } else { |
| 53 | const nodemailer = require('nodemailer'); |
| 54 | var options = { name: obj.config.smtp.name, host: obj.config.smtp.host, secure: (obj.config.smtp.tls == true), tls: {} }; |
| 55 | //var options = { host: obj.config.smtp.host, secure: (obj.config.smtp.tls == true), tls: { secureProtocol: 'SSLv23_method', ciphers: 'RSA+AES:!aNULL:!MD5:!DSS', secureOptions: constants.SSL_OP_NO_SSLv2 | constants.SSL_OP_NO_SSLv3 | constants.SSL_OP_NO_COMPRESSION | constants.SSL_OP_CIPHER_SERVER_PREFERENCE, rejectUnauthorized: false } }; |
| 56 | if (obj.config.smtp.port != null) { options.port = obj.config.smtp.port; } |
| 57 | if (obj.config.smtp.tlscertcheck === false) { options.tls.rejectUnauthorized = false; } |
| 58 | if (obj.config.smtp.tlsstrict === true) { options.tls.secureProtocol = 'SSLv23_method'; options.tls.ciphers = 'RSA+AES:!aNULL:!MD5:!DSS'; options.tls.secureOptions = constants.SSL_OP_NO_SSLv2 | constants.SSL_OP_NO_SSLv3 | constants.SSL_OP_NO_COMPRESSION | constants.SSL_OP_CIPHER_SERVER_PREFERENCE; } |
| 59 | if ((obj.config.smtp.auth != null) && (typeof obj.config.smtp.auth == 'object')) { |
| 60 | var user = obj.config.smtp.from; |
| 61 | if ((user == null) && (obj.config.smtp.user != null)) { user = obj.config.smtp.user; } |
| 62 | if ((obj.config.smtp.auth.user != null) && (typeof obj.config.smtp.auth.user == 'string')) { user = obj.config.smtp.auth.user; } |
| 63 | if (user.toLowerCase().endsWith('@gmail.com')) { options = { service: 'gmail', auth: { user: user } }; obj.config.smtp.host = 'gmail'; } else { options.auth = { user: user } } |
| 64 | if (obj.config.smtp.auth.type) { options.auth.type = obj.config.smtp.auth.type; } |
| 65 | if (obj.config.smtp.auth.clientid) { options.auth.clientId = obj.config.smtp.auth.clientid; options.auth.type = 'OAuth2'; } |
| 66 | if (obj.config.smtp.auth.clientsecret) { options.auth.clientSecret = obj.config.smtp.auth.clientsecret; } |
| 67 | if (obj.config.smtp.auth.refreshtoken) { options.auth.refreshToken = obj.config.smtp.auth.refreshtoken; } |
| 68 | } |
| 69 | else if ((obj.config.smtp.user != null) && (obj.config.smtp.pass != null)) { options.auth = { user: obj.config.smtp.user, pass: obj.config.smtp.pass }; } |
| 70 | if (obj.config.smtp.verifyemail == true) { obj.verifyemail = true; } |
| 71 | |
| 72 | obj.smtpServer = nodemailer.createTransport(options); |
| 73 | } |
| 74 | } else if (obj.config.sendmail != null) { |
| 75 | // Setup Sendmail |
| 76 | if ((typeof obj.config.sendmail.emaildelayseconds == 'number') && (obj.config.sendmail.emaildelayseconds > 0)) { obj.emailDelay = obj.config.sendmail.emaildelayseconds * 1000; } |
| 77 | const nodemailer = require('nodemailer'); |
| 78 | var options = { sendmail: true }; |
| 79 | if (typeof obj.config.sendmail.newline == 'string') { options.newline = obj.config.sendmail.newline; } |
| 80 | if (typeof obj.config.sendmail.path == 'string') { options.path = obj.config.sendmail.path; } |
| 81 | if (Array.isArray(obj.config.sendmail.args)) { options.args = obj.config.sendmail.args; } |
| 82 | obj.smtpServer = nodemailer.createTransport(options); |
| 83 | } |
| 84 | |
| 85 | // Get the correct mail template object |
| 86 | function getTemplate(name, domain, lang) { |
| 87 | parent.debug('email', 'Getting mail template for: ' + name + ', lang: ' + lang); |
| 88 | if (Array.isArray(lang)) { lang = lang[0]; } // TODO: For now, we only use the first language given. |
| 89 | if (lang != null) { lang = lang.split('-')[0]; } // Take the first part of the language, "xx-xx" |
| 90 | |
| 91 | var r = {}, emailsPath = null; |
| 92 | if ((domain != null) && (domain.webemailspath != null)) { emailsPath = domain.webemailspath; } |
| 93 | else if (obj.parent.webEmailsOverridePath != null) { emailsPath = obj.parent.webEmailsOverridePath; } |
| 94 | else if (obj.parent.webEmailsPath != null) { emailsPath = obj.parent.webEmailsPath; } |
| 95 | if ((emailsPath == null) || (obj.parent.fs.existsSync(emailsPath) == false)) { return null } |
| 96 | |
| 97 | // Get the non-english email if needed |
| 98 | var htmlfile = null, txtfile = null; |
| 99 | if ((lang != null) && (lang != 'en')) { |
| 100 | var translationsPath = obj.parent.path.join(emailsPath, 'translations'); |
| 101 | var translationsPathHtml = obj.parent.path.join(emailsPath, 'translations', name + '_' + lang + '.html'); |
| 102 | var translationsPathTxt = obj.parent.path.join(emailsPath, 'translations', name + '_' + lang + '.txt'); |
| 103 | if (obj.parent.fs.existsSync(translationsPath) && obj.parent.fs.existsSync(translationsPathHtml) && obj.parent.fs.existsSync(translationsPathTxt)) { |
| 104 | htmlfile = obj.parent.fs.readFileSync(translationsPathHtml).toString(); |
| 105 | txtfile = obj.parent.fs.readFileSync(translationsPathTxt).toString(); |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | // Get the english email |
| 110 | if ((htmlfile == null) || (txtfile == null)) { |
| 111 | var pathHtml = obj.parent.path.join(emailsPath, name + '.html'); |
| 112 | var pathTxt = obj.parent.path.join(emailsPath, name + '.txt'); |
| 113 | if (obj.parent.fs.existsSync(pathHtml) && obj.parent.fs.existsSync(pathTxt)) { |
| 114 | htmlfile = obj.parent.fs.readFileSync(pathHtml).toString(); |
| 115 | txtfile = obj.parent.fs.readFileSync(pathTxt).toString(); |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // If no email template found, use the default translated email template |
| 120 | if (((htmlfile == null) || (txtfile == null)) && (lang != null) && (lang != 'en')) { |
| 121 | var translationsPath = obj.parent.path.join(obj.parent.webEmailsPath, 'translations'); |
| 122 | var translationsPathHtml = obj.parent.path.join(obj.parent.webEmailsPath, 'translations', name + '_' + lang + '.html'); |
| 123 | var translationsPathTxt = obj.parent.path.join(obj.parent.webEmailsPath, 'translations', name + '_' + lang + '.txt'); |
| 124 | if (obj.parent.fs.existsSync(translationsPath) && obj.parent.fs.existsSync(translationsPathHtml) && obj.parent.fs.existsSync(translationsPathTxt)) { |
| 125 | htmlfile = obj.parent.fs.readFileSync(translationsPathHtml).toString(); |
| 126 | txtfile = obj.parent.fs.readFileSync(translationsPathTxt).toString(); |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | // If no translated email template found, use the default email template |
| 131 | if ((htmlfile == null) || (txtfile == null)) { |
| 132 | var pathHtml = obj.parent.path.join(obj.parent.webEmailsPath, name + '.html'); |
| 133 | var pathTxt = obj.parent.path.join(obj.parent.webEmailsPath, name + '.txt'); |
| 134 | if (obj.parent.fs.existsSync(pathHtml) && obj.parent.fs.existsSync(pathTxt)) { |
| 135 | htmlfile = obj.parent.fs.readFileSync(pathHtml).toString(); |
| 136 | txtfile = obj.parent.fs.readFileSync(pathTxt).toString(); |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | // No email templates |
| 141 | if ((htmlfile == null) || (txtfile == null)) { return null; } |
| 142 | |
| 143 | // Decode the HTML file |
| 144 | htmlfile = htmlfile.split('<html>').join('').split('</html>').join('').split('<head>').join('').split('</head>').join('').split('<body>').join('').split('</body>').join('').split(' notrans="1"').join(''); |
| 145 | var lines = htmlfile.split('\r\n').join('\n').split('\n'); |
| 146 | r.htmlSubject = lines.shift(); |
| 147 | if (r.htmlSubject.startsWith('<div>')) { r.htmlSubject = r.htmlSubject.substring(5); } |
| 148 | if (r.htmlSubject.endsWith('</div>')) { r.htmlSubject = r.htmlSubject.substring(0, r.htmlSubject.length - 6); } |
| 149 | r.html = lines.join('\r\n'); |
| 150 | |
| 151 | // Decode the TXT file |
| 152 | lines = txtfile.split('\r\n').join('\n').split('\n'); |
| 153 | r.txtSubject = lines.shift(); |
| 154 | var txtbody = []; |
| 155 | for (var i in lines) { var line = lines[i]; if ((line.length > 0) && (line[0] == '~')) { txtbody.push(line.substring(1)); } else { txtbody.push(line); } } |
| 156 | r.txt = txtbody.join('\r\n'); |
| 157 | |
| 158 | return r; |
| 159 | } |
| 160 | |
| 161 | // Get the string between two markers |
| 162 | function getStrBetween(str, start, end) { |
| 163 | var si = str.indexOf(start), ei = str.indexOf(end); |
| 164 | if ((si == -1) || (ei == -1) || (si > ei)) return null; |
| 165 | return str.substring(si + start.length, ei); |
| 166 | } |
| 167 | |
| 168 | // Remove the string between two markers |
| 169 | function removeStrBetween(str, start, end) { |
| 170 | var si = str.indexOf(start), ei = str.indexOf(end); |
| 171 | if ((si == -1) || (ei == -1) || (si > ei)) return str; |
| 172 | return str.substring(0, si) + str.substring(ei + end.length); |
| 173 | } |
| 174 | |
| 175 | // Keep or remove all lines between two lines with markers |
| 176 | function strZone(str, marker, keep) { |
| 177 | var lines = str.split('\r\n'), linesEx = [], removing = false; |
| 178 | const startMarker = '<area-' + marker + '>', endMarker = '</area-' + marker + '>'; |
| 179 | for (var i in lines) { |
| 180 | var line = lines[i]; |
| 181 | if (removing) { |
| 182 | if (line.indexOf(endMarker) >= 0) { removing = false; } else { if (keep) { linesEx.push(line); } } |
| 183 | } else { |
| 184 | if (line.indexOf(startMarker) >= 0) { removing = true; } else { linesEx.push(line); } |
| 185 | } |
| 186 | } |
| 187 | return linesEx.join('\r\n'); |
| 188 | } |
| 189 | |
| 190 | // Perform all e-mail substitution |
| 191 | function mailReplacements(text, domain, options) { |
| 192 | var httpsport = (typeof obj.parent.args.aliasport == 'number') ? obj.parent.args.aliasport : obj.parent.args.port; |
| 193 | if (domain.dns == null) { |
| 194 | // Default domain or subdomain of the default. |
| 195 | options.serverurl = 'https://' + obj.parent.certificates.CommonName + ':' + httpsport + domain.url; |
| 196 | } else { |
| 197 | // Domain with a DNS name. |
| 198 | options.serverurl = 'https://' + domain.dns + ':' + httpsport + domain.url; |
| 199 | } |
| 200 | if (options.serverurl.endsWith('/')) { options.serverurl = options.serverurl.substring(0, options.serverurl.length - 1); } // Remove the ending / if present |
| 201 | for (var i in options) { |
| 202 | text = strZone(text, i.toLowerCase(), options[i]); // Adjust this text area |
| 203 | text = text.split('[[[' + i.toUpperCase() + ']]]').join(options[i]); // Replace this value |
| 204 | } |
| 205 | return text; |
| 206 | } |
| 207 | |
| 208 | // Send a generic email |
| 209 | obj.sendMail = function (to, subject, text, html) { |
| 210 | if (obj.config.sendgrid != null) { |
| 211 | obj.pendingMails.push({ to: to, from: obj.config.sendgrid.from, subject: subject, text: text, html: html }); |
| 212 | } else if (obj.config.smtp != null) { |
| 213 | obj.pendingMails.push({ to: to, from: obj.config.smtp.from, subject: subject, text: text, html: html }); |
| 214 | } |
| 215 | sendNextMail(); |
| 216 | }; |
| 217 | |
| 218 | // Send account login mail / 2 factor token |
| 219 | obj.sendAccountLoginMail = function (domain, email, token, language, loginkey) { |
| 220 | obj.checkEmail(email, function (checked) { |
| 221 | if (checked) { |
| 222 | parent.debug('email', "Sending login token to " + email); |
| 223 | |
| 224 | if ((parent.certificates == null) || (parent.certificates.CommonName == null) || (parent.certificates.CommonName.indexOf('.') == -1)) { |
| 225 | parent.debug('email', "Error: Server name not set."); // If the server name is not set, email not possible. |
| 226 | return; |
| 227 | } |
| 228 | |
| 229 | var template = getTemplate('account-login', domain, language); |
| 230 | if ((template == null) || (template.htmlSubject == null) || (template.txtSubject == null)) { |
| 231 | parent.debug('email', "Error: Failed to get mail template."); // No email template found |
| 232 | return; |
| 233 | } |
| 234 | |
| 235 | // Set all the options. |
| 236 | var options = { email: email, servername: domain.title ? domain.title : 'MeshCentral', token: token }; |
| 237 | if (loginkey != null) { options.urlargs1 = '?key=' + loginkey; options.urlargs2 = '&key=' + loginkey; } else { options.urlargs1 = ''; options.urlargs2 = ''; } |
| 238 | |
| 239 | // Get from field |
| 240 | var from = null; |
| 241 | if (obj.config.sendgrid && (typeof obj.config.sendgrid.from == 'string')) { from = obj.config.sendgrid.from; } |
| 242 | else if (obj.config.smtp && (typeof obj.config.smtp.from == 'string')) { from = obj.config.smtp.from; } |
| 243 | |
| 244 | // Send the email |
| 245 | obj.pendingMails.push({ to: email, from: from, subject: mailReplacements(template.htmlSubject, domain, options), text: mailReplacements(template.txt, domain, options), html: mailReplacements(template.html, domain, options) }); |
| 246 | sendNextMail(); |
| 247 | } |
| 248 | }); |
| 249 | }; |
| 250 | |
| 251 | // Send account invitation mail |
| 252 | obj.sendAccountInviteMail = function (domain, username, accountname, email, password, language, loginkey) { |
| 253 | obj.checkEmail(email, function (checked) { |
| 254 | if (checked) { |
| 255 | parent.debug('email', "Sending account invitation to " + email); |
| 256 | |
| 257 | if ((parent.certificates == null) || (parent.certificates.CommonName == null) || (parent.certificates.CommonName.indexOf('.') == -1)) { |
| 258 | parent.debug('email', "Error: Server name not set."); // If the server name is not set, email not possible. |
| 259 | return; |
| 260 | } |
| 261 | |
| 262 | var template = getTemplate('account-invite', domain, language); |
| 263 | if ((template == null) || (template.htmlSubject == null) || (template.txtSubject == null)) { |
| 264 | parent.debug('email', "Error: Failed to get mail template."); // No email template found |
| 265 | return; |
| 266 | } |
| 267 | |
| 268 | // Set all the options. |
| 269 | var options = { username: username, accountname: accountname, email: email, servername: domain.title ? domain.title : 'MeshCentral', password: password }; |
| 270 | if (loginkey != null) { options.urlargs1 = '?key=' + loginkey; options.urlargs2 = '&key=' + loginkey; } else { options.urlargs1 = ''; options.urlargs2 = ''; } |
| 271 | |
| 272 | // Get from field |
| 273 | var from = null; |
| 274 | if (obj.config.sendgrid && (typeof obj.config.sendgrid.from == 'string')) { from = obj.config.sendgrid.from; } |
| 275 | else if (obj.config.smtp && (typeof obj.config.smtp.from == 'string')) { from = obj.config.smtp.from; } |
| 276 | |
| 277 | // Send the email |
| 278 | obj.pendingMails.push({ to: email, from: from, subject: mailReplacements(template.htmlSubject, domain, options), text: mailReplacements(template.txt, domain, options), html: mailReplacements(template.html, domain, options) }); |
| 279 | sendNextMail(); |
| 280 | } |
| 281 | }); |
| 282 | }; |
| 283 | |
| 284 | // Send account check mail |
| 285 | obj.sendAccountCheckMail = function (domain, username, userid, email, language, loginkey) { |
| 286 | obj.checkEmail(email, function (checked) { |
| 287 | if (checked) { |
| 288 | parent.debug('email', "Sending email verification to " + email); |
| 289 | |
| 290 | if ((parent.certificates == null) || (parent.certificates.CommonName == null) || (parent.certificates.CommonName.indexOf('.') == -1)) { |
| 291 | parent.debug('email', "Error: Server name not set."); // If the server name is not set, email not possible. |
| 292 | return; |
| 293 | } |
| 294 | |
| 295 | var template = getTemplate('account-check', domain, language); |
| 296 | if ((template == null) || (template.htmlSubject == null) || (template.txtSubject == null)) { |
| 297 | parent.debug('email', "Error: Failed to get mail template."); // No email template found |
| 298 | return; |
| 299 | } |
| 300 | |
| 301 | // Set all the options. |
| 302 | var options = { username: username, email: email, servername: domain.title ? domain.title : 'MeshCentral' }; |
| 303 | if (loginkey != null) { options.urlargs1 = '?key=' + loginkey; options.urlargs2 = '&key=' + loginkey; } else { options.urlargs1 = ''; options.urlargs2 = ''; } |
| 304 | options.cookie = obj.parent.encodeCookie({ u: userid, e: email, a: 1 }, obj.mailCookieEncryptionKey); |
| 305 | |
| 306 | // Get from field |
| 307 | var from = null; |
| 308 | if (obj.config.sendgrid && (typeof obj.config.sendgrid.from == 'string')) { from = obj.config.sendgrid.from; } |
| 309 | else if (obj.config.smtp && (typeof obj.config.smtp.from == 'string')) { from = obj.config.smtp.from; } |
| 310 | |
| 311 | // Send the email |
| 312 | obj.pendingMails.push({ to: email, from: from, subject: mailReplacements(template.htmlSubject, domain, options), text: mailReplacements(template.txt, domain, options), html: mailReplacements(template.html, domain, options) }); |
| 313 | sendNextMail(); |
| 314 | } |
| 315 | }); |
| 316 | }; |
| 317 | |
| 318 | // Send account reset mail |
| 319 | obj.sendAccountResetMail = function (domain, username, userid, email, language, loginkey) { |
| 320 | obj.checkEmail(email, function (checked) { |
| 321 | if (checked) { |
| 322 | parent.debug('email', "Sending account password reset to " + email); |
| 323 | |
| 324 | if ((parent.certificates == null) || (parent.certificates.CommonName == null) || (parent.certificates.CommonName.indexOf('.') == -1)) { |
| 325 | parent.debug('email', "Error: Server name not set."); // If the server name is not set, email not possible. |
| 326 | return; |
| 327 | } |
| 328 | |
| 329 | var template = getTemplate('account-reset', domain, language); |
| 330 | if ((template == null) || (template.htmlSubject == null) || (template.txtSubject == null)) { |
| 331 | parent.debug('email', "Error: Failed to get mail template."); // No email template found |
| 332 | return; |
| 333 | } |
| 334 | |
| 335 | // Set all the options. |
| 336 | var options = { username: username, email: email, servername: domain.title ? domain.title : 'MeshCentral' }; |
| 337 | if (loginkey != null) { options.urlargs1 = '?key=' + loginkey; options.urlargs2 = '&key=' + loginkey; } else { options.urlargs1 = ''; options.urlargs2 = ''; } |
| 338 | options.cookie = obj.parent.encodeCookie({ u: userid, e: email, a: 2 }, obj.mailCookieEncryptionKey); |
| 339 | |
| 340 | // Get from field |
| 341 | var from = null; |
| 342 | if (obj.config.sendgrid && (typeof obj.config.sendgrid.from == 'string')) { from = obj.config.sendgrid.from; } |
| 343 | else if (obj.config.smtp && (typeof obj.config.smtp.from == 'string')) { from = obj.config.smtp.from; } |
| 344 | |
| 345 | // Send the email |
| 346 | obj.pendingMails.push({ to: email, from: from, subject: mailReplacements(template.htmlSubject, domain, options), text: mailReplacements(template.txt, domain, options), html: mailReplacements(template.html, domain, options) }); |
| 347 | sendNextMail(); |
| 348 | } |
| 349 | }); |
| 350 | }; |
| 351 | |
| 352 | // Send agent invite mail |
| 353 | obj.sendAgentInviteMail = function (domain, username, email, meshid, name, os, msg, flags, expirehours, language, loginkey) { |
| 354 | obj.checkEmail(email, function (checked) { |
| 355 | if (checked) { |
| 356 | parent.debug('email', "Sending agent install invitation to " + email); |
| 357 | |
| 358 | if ((parent.certificates == null) || (parent.certificates.CommonName == null) || (parent.certificates.CommonName.indexOf('.') == -1)) { |
| 359 | parent.debug('email', "Error: Server name not set."); // If the server name is not set, email not possible. |
| 360 | return; |
| 361 | } |
| 362 | |
| 363 | var template = getTemplate('mesh-invite', domain, language); |
| 364 | if ((template == null) || (template.htmlSubject == null) || (template.txtSubject == null)) { |
| 365 | parent.debug('email', "Error: Failed to get mail template."); // No email template found |
| 366 | return; |
| 367 | } |
| 368 | |
| 369 | // Set all the template replacement options and generate the final email text (both in txt and html formats). |
| 370 | var options = { username: username, name: name, email: email, installflags: flags, msg: msg, meshid: meshid, meshidhex: meshid.split('/')[2], servername: domain.title ? domain.title : 'MeshCentral', assistanttype: (domain.assistanttypeagentinvite ? domain.assistanttypeagentinvite : 0)}; |
| 371 | if (loginkey != null) { options.urlargs1 = '?key=' + loginkey; options.urlargs2 = '&key=' + loginkey; } else { options.urlargs1 = ''; options.urlargs2 = ''; } |
| 372 | options.windows = ((os == 0) || (os == 1)) ? 1 : 0; |
| 373 | options.linux = ((os == 0) || (os == 2)) ? 1 : 0; |
| 374 | options.assistant = ((os == 0) || (os == 5)) ? 1 : 0; |
| 375 | options.osx = ((os == 0) || (os == 3)) ? 1 : 0; |
| 376 | options.link = (os == 4) ? 1 : 0; |
| 377 | options.linkurl = createInviteLink(domain, meshid, flags, expirehours); |
| 378 | |
| 379 | // Get from field |
| 380 | var from = null; |
| 381 | if (obj.config.sendgrid && (typeof obj.config.sendgrid.from == 'string')) { from = obj.config.sendgrid.from; } |
| 382 | else if (obj.config.smtp && (typeof obj.config.smtp.from == 'string')) { from = obj.config.smtp.from; } |
| 383 | |
| 384 | // Send the email |
| 385 | obj.pendingMails.push({ to: email, from: from, subject: mailReplacements(template.htmlSubject, domain, options), text: mailReplacements(template.txt, domain, options), html: mailReplacements(template.html, domain, options) }); |
| 386 | sendNextMail(); |
| 387 | } |
| 388 | }); |
| 389 | }; |
| 390 | |
| 391 | // Send device connect/disconnect notification mail |
| 392 | obj.sendDeviceNotifyMail = function (domain, username, email, connections, disconnections, language, loginkey) { |
| 393 | obj.checkEmail(email, function (checked) { |
| 394 | if (checked) { |
| 395 | parent.debug('email', "Sending device notification to " + email); |
| 396 | |
| 397 | if ((parent.certificates == null) || (parent.certificates.CommonName == null) || (parent.certificates.CommonName.indexOf('.') == -1)) { |
| 398 | parent.debug('email', "Error: Server name not set."); // If the server name is not set, email not possible. |
| 399 | return; |
| 400 | } |
| 401 | |
| 402 | var template = getTemplate('device-notify', domain, language); |
| 403 | if ((template == null) || (template.htmlSubject == null) || (template.txtSubject == null)) { |
| 404 | parent.debug('email', "Error: Failed to get mail template."); // No email template found |
| 405 | return; |
| 406 | } |
| 407 | |
| 408 | // Set all the template replacement options and generate the final email text (both in txt and html formats). |
| 409 | const optionsHtml = { username: EscapeHtml(username), email: EscapeHtml(email), servername: EscapeHtml(domain.title ? domain.title : 'MeshCentral'), header: true, footer: false }; |
| 410 | const optionsTxt = { username: username, email: email, servername: domain.title ? domain.title : 'MeshCentral', header: true, footer: false }; |
| 411 | if ((connections == null) || (connections.length == 0)) { |
| 412 | optionsHtml.connections = false; |
| 413 | optionsTxt.connections = false; |
| 414 | } else { |
| 415 | optionsHtml.connections = connections.join('<br />\r\n'); |
| 416 | optionsTxt.connections = connections.join('\r\n'); |
| 417 | } |
| 418 | if ((disconnections == null) || (disconnections.length == 0)) { |
| 419 | optionsHtml.disconnections = false; |
| 420 | optionsTxt.disconnections = false; |
| 421 | } else { |
| 422 | optionsHtml.disconnections = disconnections.join('<br />\r\n'); |
| 423 | optionsTxt.disconnections = disconnections.join('\r\n'); |
| 424 | } |
| 425 | |
| 426 | // Get from field |
| 427 | var from = null; |
| 428 | if (obj.config.sendgrid && (typeof obj.config.sendgrid.from == 'string')) { from = obj.config.sendgrid.from; } |
| 429 | else if (obj.config.smtp && (typeof obj.config.smtp.from == 'string')) { from = obj.config.smtp.from; } |
| 430 | |
| 431 | // Send the email |
| 432 | obj.pendingMails.push({ to: email, from: from, subject: mailReplacements(template.htmlSubject, domain, optionsTxt), text: mailReplacements(template.txt, domain, optionsTxt), html: mailReplacements(template.html, domain, optionsHtml) }); |
| 433 | sendNextMail(); |
| 434 | } |
| 435 | }); |
| 436 | }; |
| 437 | |
| 438 | // Send device help request notification mail |
| 439 | obj.sendDeviceHelpMail = function (domain, username, email, devicename, nodeid, helpusername, helprequest, language) { |
| 440 | obj.checkEmail(email, function (checked) { |
| 441 | if (checked) { |
| 442 | parent.debug('email', "Sending device help notification to " + email); |
| 443 | |
| 444 | if ((parent.certificates == null) || (parent.certificates.CommonName == null) || (parent.certificates.CommonName.indexOf('.') == -1)) { |
| 445 | parent.debug('email', "Error: Server name not set."); // If the server name is not set, email not possible. |
| 446 | return; |
| 447 | } |
| 448 | |
| 449 | var template = getTemplate('device-help', domain, language); |
| 450 | if ((template == null) || (template.htmlSubject == null) || (template.txtSubject == null)) { |
| 451 | parent.debug('email', "Error: Failed to get mail template."); // No email template found |
| 452 | return; |
| 453 | } |
| 454 | |
| 455 | // Set all the template replacement options and generate the final email text (both in txt and html formats). |
| 456 | const optionsHtml = { devicename: EscapeHtml(devicename), helpusername: EscapeHtml(helpusername), helprequest: EscapeHtml(helprequest), nodeid: nodeid.split('/')[2], servername: EscapeHtml(domain.title ? domain.title : 'MeshCentral') }; |
| 457 | const optionsTxt = { devicename: devicename, helpusername: helpusername, helprequest: helprequest, nodeid: nodeid.split('/')[2], servername: domain.title ? domain.title : 'MeshCentral' }; |
| 458 | |
| 459 | // Get from field |
| 460 | var from = null; |
| 461 | if (obj.config.sendgrid && (typeof obj.config.sendgrid.from == 'string')) { from = obj.config.sendgrid.from; } |
| 462 | else if (obj.config.smtp && (typeof obj.config.smtp.from == 'string')) { from = obj.config.smtp.from; } |
| 463 | |
| 464 | // Send the email |
| 465 | obj.pendingMails.push({ to: email, from: from, subject: mailReplacements(template.htmlSubject, domain, optionsTxt), text: mailReplacements(template.txt, domain, optionsTxt), html: mailReplacements(template.html, domain, optionsHtml) }); |
| 466 | sendNextMail(); |
| 467 | } |
| 468 | }); |
| 469 | }; |
| 470 | |
| 471 | // Send out the next mail in the pending list |
| 472 | function sendNextMail() { |
| 473 | if ((obj.sendingMail == true) || (obj.pendingMails.length == 0)) { return; } |
| 474 | |
| 475 | var mailToSend = obj.pendingMails[0]; |
| 476 | obj.sendingMail = true; |
| 477 | |
| 478 | if (obj.sendGridServer != null) { |
| 479 | // SendGrid send |
| 480 | parent.debug('email', 'SendGrid sending mail to ' + mailToSend.to + '.'); |
| 481 | obj.sendGridServer |
| 482 | .send(mailToSend) |
| 483 | .then(function () { |
| 484 | obj.sendingMail = false; |
| 485 | parent.debug('email', 'SendGrid sending success.'); |
| 486 | obj.pendingMails.shift(); |
| 487 | obj.retry = 0; |
| 488 | sendNextMail(); |
| 489 | }, function (error) { |
| 490 | obj.sendingMail = false; |
| 491 | parent.debug('email', 'SendGrid sending error: ' + JSON.stringify(error)); |
| 492 | obj.retry++; |
| 493 | // Wait and try again |
| 494 | if (obj.retry < 3) { |
| 495 | setTimeout(sendNextMail, 10000); |
| 496 | } else { |
| 497 | // Failed, send the next mail |
| 498 | parent.debug('email', 'SendGrid server failed (Skipping): ' + JSON.stringify(err)); |
| 499 | console.log('SendGrid server failed (Skipping): ' + JSON.stringify(err)); |
| 500 | obj.pendingMails.shift(); |
| 501 | obj.retry = 0; |
| 502 | sendNextMail(); |
| 503 | } |
| 504 | }); |
| 505 | } else if (obj.smtpServer != null) { |
| 506 | parent.debug('email', 'SMTP sending mail to ' + mailToSend.to + '.'); |
| 507 | if (obj.smtpServer == 'console') { |
| 508 | // Display the email on the console, this is for easy debugging |
| 509 | if (mailToSend.from == null) { delete mailToSend.from; } |
| 510 | if (mailToSend.html == null) { delete mailToSend.html; } |
| 511 | console.log('Email', mailToSend); |
| 512 | obj.sendingMail = false; |
| 513 | obj.pendingMails.shift(); |
| 514 | obj.retry = 0; |
| 515 | sendNextMail(); |
| 516 | } else { |
| 517 | // SMTP send |
| 518 | obj.smtpServer.sendMail(mailToSend, function (err, info) { |
| 519 | parent.debug('email', 'SMTP response: ' + JSON.stringify(err) + ', ' + JSON.stringify(info)); |
| 520 | obj.sendingMail = false; |
| 521 | if (err == null) { |
| 522 | // Send the next mail |
| 523 | obj.pendingMails.shift(); |
| 524 | obj.retry = 0; |
| 525 | sendNextMail(); |
| 526 | } else { |
| 527 | obj.retry++; |
| 528 | parent.debug('email', 'SMTP server failed (Retry:' + obj.retry + '): ' + JSON.stringify(err)); |
| 529 | console.log('SMTP server failed (Retry:' + obj.retry + '/3): ' + JSON.stringify(err)); |
| 530 | // Wait and try again |
| 531 | if (obj.retry < 3) { |
| 532 | setTimeout(sendNextMail, 10000); |
| 533 | } else { |
| 534 | // Failed, send the next mail |
| 535 | parent.debug('email', 'SMTP server failed (Skipping): ' + JSON.stringify(err)); |
| 536 | console.log('SMTP server failed (Skipping): ' + JSON.stringify(err)); |
| 537 | obj.pendingMails.shift(); |
| 538 | obj.retry = 0; |
| 539 | sendNextMail(); |
| 540 | } |
| 541 | } |
| 542 | }); |
| 543 | } |
| 544 | } |
| 545 | } |
| 546 | |
| 547 | // Send out the next mail in the pending list |
| 548 | obj.verify = function () { |
| 549 | if ((obj.smtpServer == null) || (obj.smtpServer == 'console')) return; |
| 550 | obj.smtpServer.verify(function (err, info) { |
| 551 | if (err == null) { |
| 552 | if (obj.config.smtp.host == 'gmail') { |
| 553 | console.log('Gmail server with OAuth working as expected.'); |
| 554 | } else { |
| 555 | console.log('SMTP mail server ' + obj.config.smtp.host + ' working as expected.'); |
| 556 | } |
| 557 | } else { |
| 558 | // Remove all non-object types from error to avoid a JSON stringify error. |
| 559 | var err2 = {}; |
| 560 | for (var i in err) { if (typeof (err[i]) != 'object') { err2[i] = err[i]; } } |
| 561 | parent.debug('email', 'SMTP mail server ' + obj.config.smtp.host + ' failed: ' + JSON.stringify(err2)); |
| 562 | console.log('SMTP mail server ' + obj.config.smtp.host + ' failed: ' + JSON.stringify(err2)); |
| 563 | } |
| 564 | }); |
| 565 | }; |
| 566 | |
| 567 | // Load the cookie encryption key from the database |
| 568 | obj.parent.db.Get('MailCookieEncryptionKey', function (err, docs) { |
| 569 | if ((docs.length > 0) && (docs[0].key != null) && (obj.parent.mailtokengen == null)) { |
| 570 | // Key is present, use it. |
| 571 | obj.mailCookieEncryptionKey = Buffer.from(docs[0].key, 'hex'); |
| 572 | } else { |
| 573 | // Key is not present, generate one. |
| 574 | obj.mailCookieEncryptionKey = obj.parent.generateCookieKey(); |
| 575 | obj.parent.db.Set({ _id: 'MailCookieEncryptionKey', key: obj.mailCookieEncryptionKey.toString('hex'), time: Date.now() }); |
| 576 | } |
| 577 | }); |
| 578 | |
| 579 | // Create a agent invitation link |
| 580 | function createInviteLink(domain, meshid, flags, expirehours) { |
| 581 | return '/agentinvite?c=' + parent.encodeCookie({ a: 4, mid: meshid, f: flags, expire: expirehours * 60 }, parent.invitationLinkEncryptionKey); |
| 582 | } |
| 583 | |
| 584 | // Check the email domain DNS MX record. |
| 585 | obj.approvedEmailDomains = {}; |
| 586 | obj.checkEmail = function (email, func) { |
| 587 | if (obj.verifyemail == false) { func(true); return; } |
| 588 | var emailSplit = email.split('@'); |
| 589 | if (emailSplit.length != 2) { func(false); return; } |
| 590 | if (obj.approvedEmailDomains[emailSplit[1]] === true) { func(true); return; } |
| 591 | require('dns').resolveMx(emailSplit[1], function (err, addresses) { |
| 592 | parent.debug('email', "checkEmail: " + email + ", " + (err == null)); |
| 593 | if (err == null) { obj.approvedEmailDomains[emailSplit[1]] = true; } |
| 594 | func(err == null); |
| 595 | }); |
| 596 | } |
| 597 | |
| 598 | // |
| 599 | // Device connection and disconnection notifications |
| 600 | // |
| 601 | |
| 602 | obj.deviceNotifications = {}; // UserId --> { timer, nodes: nodeid --> connectType } |
| 603 | |
| 604 | // A device connected and a user needs to be notified about it. |
| 605 | obj.notifyDeviceConnect = function (user, meshid, nodeid, connectTime, connectType, powerState, serverid, extraInfo) { |
| 606 | const mesh = parent.webserver.meshes[meshid]; |
| 607 | if (mesh == null) return; |
| 608 | |
| 609 | // Add the user and start a timer |
| 610 | if (obj.deviceNotifications[user._id] == null) { |
| 611 | obj.deviceNotifications[user._id] = { nodes: {} }; |
| 612 | obj.deviceNotifications[user._id].timer = setTimeout(function () { sendDeviceNotifications(user._id); }, obj.emailDelay); |
| 613 | } |
| 614 | |
| 615 | // Add the device |
| 616 | if (obj.deviceNotifications[user._id].nodes[nodeid] == null) { |
| 617 | obj.deviceNotifications[user._id].nodes[nodeid] = { c: connectType }; // This device connection need to be added |
| 618 | } else { |
| 619 | const info = obj.deviceNotifications[user._id].nodes[nodeid]; |
| 620 | if ((info.d != null) && ((info.d & connectType) != 0)) { |
| 621 | info.d -= connectType; // This device disconnect cancels out a device connection |
| 622 | if (((info.c == null) || (info.c == 0)) && ((info.d == null) || (info.d == 0))) { |
| 623 | // This device no longer needs a notification |
| 624 | delete obj.deviceNotifications[user._id].nodes[nodeid]; |
| 625 | if (Object.keys(obj.deviceNotifications[user._id].nodes).length == 0) { |
| 626 | // This user no longer needs a notification |
| 627 | clearTimeout(obj.deviceNotifications[user._id].timer); |
| 628 | delete obj.deviceNotifications[user._id]; |
| 629 | } |
| 630 | return; |
| 631 | } |
| 632 | } else { |
| 633 | if (info.c != null) { |
| 634 | info.c |= connectType; // This device disconnect needs to be added |
| 635 | } else { |
| 636 | info.c = connectType; // This device disconnect needs to be added |
| 637 | } |
| 638 | } |
| 639 | } |
| 640 | |
| 641 | // Set the device group name |
| 642 | if ((extraInfo != null) && (extraInfo.name != null)) { obj.deviceNotifications[user._id].nodes[nodeid].nn = extraInfo.name; } |
| 643 | obj.deviceNotifications[user._id].nodes[nodeid].mn = mesh.name; |
| 644 | } |
| 645 | |
| 646 | // Cancel a device disconnect notification |
| 647 | obj.cancelNotifyDeviceDisconnect = function (user, meshid, nodeid, connectTime, connectType, powerState, serverid, extraInfo) { |
| 648 | const mesh = parent.webserver.meshes[meshid]; |
| 649 | if (mesh == null) return; |
| 650 | |
| 651 | if ((obj.deviceNotifications[user._id] != null) && (obj.deviceNotifications[user._id].nodes[nodeid] != null)) { |
| 652 | const info = obj.deviceNotifications[user._id].nodes[nodeid]; |
| 653 | if ((info.d != null) && ((info.d & connectType) != 0)) { |
| 654 | info.d -= connectType; // This device disconnect cancels out a device connection |
| 655 | if (((info.c == null) || (info.c == 0)) && ((info.d == null) || (info.d == 0))) { |
| 656 | // This device no longer needs a notification |
| 657 | delete obj.deviceNotifications[user._id].nodes[nodeid]; |
| 658 | if (Object.keys(obj.deviceNotifications[user._id].nodes).length == 0) { |
| 659 | // This user no longer needs a notification |
| 660 | clearTimeout(obj.deviceNotifications[user._id].timer); |
| 661 | delete obj.deviceNotifications[user._id]; |
| 662 | } |
| 663 | } |
| 664 | } |
| 665 | } |
| 666 | } |
| 667 | |
| 668 | // A device disconnected and a user needs to be notified about it. |
| 669 | obj.notifyDeviceDisconnect = function (user, meshid, nodeid, connectTime, connectType, powerState, serverid, extraInfo) { |
| 670 | const mesh = parent.webserver.meshes[meshid]; |
| 671 | if (mesh == null) return; |
| 672 | |
| 673 | // Add the user and start a timer |
| 674 | if (obj.deviceNotifications[user._id] == null) { |
| 675 | obj.deviceNotifications[user._id] = { nodes: {} }; |
| 676 | obj.deviceNotifications[user._id].timer = setTimeout(function () { sendDeviceNotifications(user._id); }, obj.emailDelay); |
| 677 | } |
| 678 | |
| 679 | // Add the device |
| 680 | if (obj.deviceNotifications[user._id].nodes[nodeid] == null) { |
| 681 | obj.deviceNotifications[user._id].nodes[nodeid] = { d: connectType }; // This device disconnect need to be added |
| 682 | } else { |
| 683 | const info = obj.deviceNotifications[user._id].nodes[nodeid]; |
| 684 | if ((info.c != null) && ((info.c & connectType) != 0)) { |
| 685 | info.c -= connectType; // This device disconnect cancels out a device connection |
| 686 | if (((info.d == null) || (info.d == 0)) && ((info.c == null) || (info.c == 0))) { |
| 687 | // This device no longer needs a notification |
| 688 | delete obj.deviceNotifications[user._id].nodes[nodeid]; |
| 689 | if (Object.keys(obj.deviceNotifications[user._id].nodes).length == 0) { |
| 690 | // This user no longer needs a notification |
| 691 | clearTimeout(obj.deviceNotifications[user._id].timer); |
| 692 | delete obj.deviceNotifications[user._id]; |
| 693 | } |
| 694 | return; |
| 695 | } |
| 696 | } else { |
| 697 | if (info.d != null) { |
| 698 | info.d |= connectType; // This device disconnect needs to be added |
| 699 | } else { |
| 700 | info.d = connectType; // This device disconnect needs to be added |
| 701 | } |
| 702 | } |
| 703 | } |
| 704 | |
| 705 | // Set the device group name |
| 706 | if ((extraInfo != null) && (extraInfo.name != null)) { obj.deviceNotifications[user._id].nodes[nodeid].nn = extraInfo.name; } |
| 707 | obj.deviceNotifications[user._id].nodes[nodeid].mn = mesh.name; |
| 708 | } |
| 709 | |
| 710 | // Cancel a device connect notification |
| 711 | obj.cancelNotifyDeviceConnect = function (user, meshid, nodeid, connectTime, connectType, powerState, serverid, extraInfo) { |
| 712 | const mesh = parent.webserver.meshes[meshid]; |
| 713 | if (mesh == null) return; |
| 714 | |
| 715 | if ((obj.deviceNotifications[user._id] != null) && (obj.deviceNotifications[user._id].nodes[nodeid] != null)) { |
| 716 | const info = obj.deviceNotifications[user._id].nodes[nodeid]; |
| 717 | if ((info.c != null) && ((info.c & connectType) != 0)) { |
| 718 | info.c -= connectType; // This device disconnect cancels out a device connection |
| 719 | if (((info.d == null) || (info.d == 0)) && ((info.c == null) || (info.c == 0))) { |
| 720 | // This device no longer needs a notification |
| 721 | delete obj.deviceNotifications[user._id].nodes[nodeid]; |
| 722 | if (Object.keys(obj.deviceNotifications[user._id].nodes).length == 0) { |
| 723 | // This user no longer needs a notification |
| 724 | clearTimeout(obj.deviceNotifications[user._id].timer); |
| 725 | delete obj.deviceNotifications[user._id]; |
| 726 | } |
| 727 | } |
| 728 | } |
| 729 | } |
| 730 | } |
| 731 | |
| 732 | // Send a notification about device connections and disconnections to a user |
| 733 | function sendDeviceNotifications(userid) { |
| 734 | if (obj.deviceNotifications[userid] == null) return; |
| 735 | clearTimeout(obj.deviceNotifications[userid].timer); |
| 736 | |
| 737 | var connections = []; |
| 738 | var disconnections = []; |
| 739 | |
| 740 | for (var nodeid in obj.deviceNotifications[userid].nodes) { |
| 741 | var info = obj.deviceNotifications[userid].nodes[nodeid]; |
| 742 | if ((info.c != null) && (info.c > 0) && (info.nn != null) && (info.mn != null)) { |
| 743 | var c = []; |
| 744 | if (info.c & 1) { c.push("Agent"); } |
| 745 | if (info.c & 2) { c.push("CIRA"); } |
| 746 | if (info.c & 4) { c.push("AMT"); } |
| 747 | if (info.c & 8) { c.push("AMT-Relay"); } |
| 748 | if (info.c & 16) { c.push("MQTT"); } |
| 749 | connections.push(info.mn + ', ' + info.nn + ': ' + c.join(', ')); |
| 750 | } |
| 751 | if ((info.d != null) && (info.d > 0) && (info.nn != null) && (info.mn != null)) { |
| 752 | var d = []; |
| 753 | if (info.d & 1) { d.push("Agent"); } |
| 754 | if (info.d & 2) { d.push("CIRA"); } |
| 755 | if (info.d & 4) { d.push("AMT"); } |
| 756 | if (info.d & 8) { d.push("AMT-Relay"); } |
| 757 | if (info.d & 16) { d.push("MQTT"); } |
| 758 | disconnections.push(info.mn + ', ' + info.nn + ': ' + d.join(', ')); |
| 759 | } |
| 760 | } |
| 761 | |
| 762 | // Sort the notifications |
| 763 | connections.sort(sortCollator.compare); |
| 764 | disconnections.sort(sortCollator.compare); |
| 765 | |
| 766 | // Get the user and domain |
| 767 | const user = parent.webserver.users[userid]; |
| 768 | if ((user == null) || (user.email == null) || (user.emailVerified !== true)) return; |
| 769 | const domain = obj.parent.config.domains[user.domain]; |
| 770 | if (domain == null) return; |
| 771 | |
| 772 | // Send the email |
| 773 | obj.sendDeviceNotifyMail(domain, user.name, user.email, connections, disconnections, user.llang, null); |
| 774 | |
| 775 | // Clean up |
| 776 | delete obj.deviceNotifications[userid]; |
| 777 | } |
| 778 | |
| 779 | return obj; |
| 780 | }; |