diff --git a/NEWS.md b/NEWS.md index 64181df6..039c99b3 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,20 @@ +2017-09-26 +========== + +**Breaking change:** the `nodemailer` dependency has been upgraded to version +4.x. I also took this opportunity to make some modifications to the email +configuration and move it out of `config.yaml` to `conf/email.toml`. + +To upgrade: + + * Run `npm upgrade` (or `rm -rf node_modules; npm install`) + * Copy `conf/example/email.toml` to `conf/email.toml` + * Edit `conf/email.toml` to your liking + * Remove the `mail:` block from `config.yaml` + +This feature only supports sending via SMTP for now. If there is demand for +other transports, feel free to open an issue or submit a pull request. + 2017-09-19 ========== diff --git a/conf/example/email.toml b/conf/example/email.toml new file mode 100644 index 00000000..b6ea66dc --- /dev/null +++ b/conf/example/email.toml @@ -0,0 +1,41 @@ +# SMTP configuration for sending mail +[smtp] +host = 'smtp.gmail.com' +port = 465 +secure = true +user = 'some-user@example.com' +password = 'secretpassword' + +# Email configuration for password reset emails +# Be sure to update both html-template AND text-template +# nodemailer will send both and the email client will render whichever one is supported +[password-reset] +enabled = true + +# Template to use for HTML-formatted emails +# $user$ will be replaced by the username for which the reset was requested +# $url$ will be replaced by the password reset confirmation link +html-template = """ +Hi $user$,
+
+A password reset was requested for your account on CHANGE ME. You can complete the reset by opening the following link in your browser: $url$
+
+This link will expire in 24 hours.
+
+This email address is not monitored for replies. For assistance with password resets, please contact an administrator. +""" + +# Template to use for plaintext emails +# Same substitutions as the HTML template +text-template = """ +Hi $user$, + +A password reset was requested for your account on CHANGE ME. You can complete the reset by opening the following link in your browser: $url$ + +This link will expire in 24 hours. + +This email address is not monitored for replies. For assistance with password resets, please contact an administrator. See http://example.com/contact for contact information. +""" + +from = "Example Website " +subject = "Password reset request" diff --git a/config.template.yaml b/config.template.yaml index 75c6010f..db8dc37d 100644 --- a/config.template.yaml +++ b/config.template.yaml @@ -114,18 +114,6 @@ io: # more CPU and will bottleneck pretty quickly under heavy load. per-message-deflate: false -# Mailer details (used for sending password reset links) -# see https://github.com/andris9/Nodemailer -mail: - enabled: false - config: - service: 'Gmail' - auth: - user: 'some.user@gmail.com' - pass: 'supersecretpassword' - from-address: 'some.user@gmail.com' - from-name: 'CyTube Services' - # YouTube v3 API key # See https://developers.google.com/youtube/registering_an_application # YouTube links will not work without this! diff --git a/package.json b/package.json index 44e0d44b..71a42356 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Calvin Montgomery", "name": "CyTube", "description": "Online media synchronizer and chat", - "version": "3.49.0", + "version": "3.50.0", "repository": { "url": "http://github.com/calzoneman/sync" }, @@ -30,7 +30,7 @@ "lodash": "^4.13.1", "morgan": "^1.6.1", "mysql": "^2.9.0", - "nodemailer": "^1.4.0", + "nodemailer": "^4.1.1", "prom-client": "^10.0.2", "proxy-addr": "^1.1.4", "pug": "^2.0.0-beta3", diff --git a/src/config.js b/src/config.js index ee6c2674..0112e71f 100644 --- a/src/config.js +++ b/src/config.js @@ -1,12 +1,12 @@ var fs = require("fs"); var path = require("path"); -var nodemailer = require("nodemailer"); var net = require("net"); var YAML = require("yamljs"); import { loadFromToml } from './configuration/configloader'; import { CamoConfig } from './configuration/camoconfig'; import { PrometheusConfig } from './configuration/prometheusconfig'; +import { EmailConfig } from './configuration/emailconfig'; const LOGGER = require('@calzoneman/jsli')('config'); @@ -64,13 +64,6 @@ var defaults = { "ip-connection-limit": 10, "per-message-deflate": false }, - mail: { - enabled: false, - /* the key "config" is omitted because the format depends on the - service the owner is configuring for nodemailer */ - "from-address": "some.user@gmail.com", - "from-name": "CyTube Services" - }, "youtube-v3-key": "", "channel-blacklist": [], "channel-path": "r", @@ -141,6 +134,7 @@ function merge(obj, def, path) { var cfg = defaults; let camoConfig = new CamoConfig(); let prometheusConfig = new PrometheusConfig(); +let emailConfig = new EmailConfig(); /** * Initializes the configuration from the given YAML file @@ -171,61 +165,82 @@ exports.load = function (file) { return; } - var mailconfig = {}; - if (cfg.mail && cfg.mail.config) { - mailconfig = cfg.mail.config; - delete cfg.mail.config; + if (cfg.mail) { + LOGGER.error( + 'Old style mail configuration found in config.yaml. ' + + 'Email will not be delivered unless you copy conf/example/email.toml ' + + 'to conf/email.toml and edit it to your liking. ' + + 'To remove this warning, delete the "mail:" block in config.yaml.' + ); } + merge(cfg, defaults, "config"); - cfg.mail.config = mailconfig; preprocessConfig(cfg); LOGGER.info("Loaded configuration from " + file); loadCamoConfig(); loadPrometheusConfig(); + loadEmailConfig(); }; -function loadCamoConfig() { +function checkLoadConfig(configClass, filename) { try { - camoConfig = loadFromToml(CamoConfig, - path.resolve(__dirname, '..', 'conf', 'camo.toml')); - const enabled = camoConfig.isEnabled() ? 'ENABLED' : 'DISABLED'; - LOGGER.info(`Loaded camo configuration from conf/camo.toml. Camo is ${enabled}`); + return loadFromToml( + configClass, + path.resolve(__dirname, '..', 'conf', filename) + ); } catch (error) { if (error.code === 'ENOENT') { - LOGGER.info('No camo configuration found, chat images will not be proxied.'); - camoConfig = new CamoConfig(); - return; + return null; } if (typeof error.line !== 'undefined') { - LOGGER.error(`Error in conf/camo.toml: ${error} (line ${error.line})`); + LOGGER.error(`Error in conf/${fileanme}: ${error} (line ${error.line})`); } else { - LOGGER.error(`Error loading conf/camo.toml: ${error.stack}`); + LOGGER.error(`Error loading conf/${filename}: ${error.stack}`); } } } -function loadPrometheusConfig() { - try { - prometheusConfig = loadFromToml(PrometheusConfig, - path.resolve(__dirname, '..', 'conf', 'prometheus.toml')); - const enabled = prometheusConfig.isEnabled() ? 'ENABLED' : 'DISABLED'; - LOGGER.info('Loaded prometheus configuration from conf/prometheus.toml. ' - + `Prometheus listener is ${enabled}`); - } catch (error) { - if (error.code === 'ENOENT') { - LOGGER.info('No prometheus configuration found, defaulting to disabled'); - prometheusConfig = new PrometheusConfig(); - return; - } +function loadCamoConfig() { + const conf = checkLoadConfig(CamoConfig, 'camo.toml'); - if (typeof error.line !== 'undefined') { - LOGGER.error(`Error in conf/prometheus.toml: ${error} (line ${error.line})`); - } else { - LOGGER.error(`Error loading conf/prometheus.toml: ${error.stack}`); - } + if (conf === null) { + LOGGER.info('No camo configuration found, chat images will not be proxied.'); + camoConfig = new CamoConfig(); + } else { + camoConfig = conf; + const enabled = camoConfig.isEnabled() ? 'ENABLED' : 'DISABLED'; + LOGGER.info(`Loaded camo configuration from conf/camo.toml. Camo is ${enabled}`); + } +} + +function loadPrometheusConfig() { + const conf = checkLoadConfig(PrometheusConfig, 'prometheus.toml'); + + if (conf === null) { + LOGGER.info('No prometheus configuration found, defaulting to disabled'); + prometheusConfig = new PrometheusConfig(); + } else { + prometheusConfig = conf; + const enabled = prometheusConfig.isEnabled() ? 'ENABLED' : 'DISABLED'; + LOGGER.info( + 'Loaded prometheus configuration from conf/prometheus.toml. ' + + `Prometheus listener is ${enabled}` + ); + } +} + +function loadEmailConfig() { + const conf = checkLoadConfig(EmailConfig, 'email.toml'); + + if (conf === null) { + LOGGER.info('No email configuration found, defaulting to disabled'); + emailConfig = new EmailConfig(); + } else { + emailConfig = conf; + LOGGER.info('Loaded email configuration from conf/email.toml.'); } } @@ -240,11 +255,6 @@ function preprocessConfig(cfg) { } cfg.http["root-domain-dotted"] = root; - // Setup nodemailer - cfg.mail.nodemailer = nodemailer.createTransport( - cfg.mail.config - ); - // Debug if (process.env.DEBUG === "1" || process.env.DEBUG === "true") { cfg.debug = true; @@ -450,3 +460,7 @@ exports.getCamoConfig = function getCamoConfig() { exports.getPrometheusConfig = function getPrometheusConfig() { return prometheusConfig; }; + +exports.getEmailConfig = function getEmailConfig() { + return emailConfig; +}; diff --git a/src/configuration/emailconfig.js b/src/configuration/emailconfig.js new file mode 100644 index 00000000..23d2e4f7 --- /dev/null +++ b/src/configuration/emailconfig.js @@ -0,0 +1,61 @@ +class EmailConfig { + constructor(config = { 'password-reset': { enabled: false }, smtp: {} }) { + this.config = config; + + const smtp = config.smtp; + this._smtp = { + getHost() { + return smtp.host; + }, + + getPort() { + return smtp.port; + }, + + isSecure() { + return smtp.secure; + }, + + getUser() { + return smtp.user; + }, + + getPassword() { + return smtp.password; + } + } + + const reset = config['password-reset']; + this._reset = { + isEnabled() { + return reset.enabled; + }, + + getHTML() { + return reset['html-template']; + }, + + getText() { + return reset['text-template']; + }, + + getFrom() { + return reset.from; + }, + + getSubject() { + return reset.subject; + } + }; + } + + getSmtp() { + return this._smtp; + } + + getPasswordReset() { + return this._reset; + } +} + +export { EmailConfig }; diff --git a/src/controller/email.js b/src/controller/email.js new file mode 100644 index 00000000..b3506174 --- /dev/null +++ b/src/controller/email.js @@ -0,0 +1,31 @@ +class EmailController { + constructor(mailer, config) { + this.mailer = mailer; + this.config = config; + } + + async sendPasswordReset(params = {}) { + const { address, username, url } = params; + + const resetConfig = this.config.getPasswordReset(); + + const html = resetConfig.getHTML() + .replace(/\$user\$/g, username) + .replace(/\$url\$/g, url); + const text = resetConfig.getText() + .replace(/\$user\$/g, username) + .replace(/\$url\$/g, url); + + const result = await this.mailer.sendMail({ + from: resetConfig.getFrom(), + to: `${username} <${address}>`, + subject: resetConfig.getSubject(), + html, + text + }); + + return result; + } +} + +export { EmailController }; diff --git a/src/server.js b/src/server.js index c4c26532..bb17648a 100644 --- a/src/server.js +++ b/src/server.js @@ -55,6 +55,7 @@ import { Gauge } from 'prom-client'; import { AccountDB } from './db/account'; import { ChannelDB } from './db/channel'; import { AccountController } from './controller/account'; +import { EmailController } from './controller/email'; var Server = function () { var self = this; @@ -89,8 +90,34 @@ var Server = function () { const accountDB = new AccountDB(db.getDB()); const channelDB = new ChannelDB(db.getDB()); + // controllers const accountController = new AccountController(accountDB, globalMessageBus); + let emailTransport; + if (Config.getEmailConfig().getPasswordReset().isEnabled()) { + const smtpConfig = Config.getEmailConfig().getSmtp(); + emailTransport = require("nodemailer").createTransport({ + host: smtpConfig.getHost(), + port: smtpConfig.getPort(), + secure: smtpConfig.isSecure(), + auth: { + user: smtpConfig.getUser(), + pass: smtpConfig.getPassword() + } + }); + } else { + emailTransport = { + sendMail() { + throw new Error('Email is not enabled on this server') + } + }; + } + + const emailController = new EmailController( + emailTransport, + Config.getEmailConfig() + ); + // webserver init ----------------------------------------------------- const ioConfig = IOConfiguration.fromOldConfig(Config); const webConfig = WebConfiguration.fromOldConfig(Config); @@ -104,7 +131,8 @@ var Server = function () { channelIndex = new LocalChannelIndex(); } self.express = express(); - require("./web/webserver").init(self.express, + require("./web/webserver").init( + self.express, webConfig, ioConfig, clusterClient, @@ -112,7 +140,10 @@ var Server = function () { session, globalMessageBus, accountController, - channelDB); + channelDB, + Config.getEmailConfig(), + emailController + ); // http/https/sio server init ----------------------------------------- var key = "", cert = "", ca = undefined; diff --git a/src/web/account.js b/src/web/account.js index 6b18f375..fb241123 100644 --- a/src/web/account.js +++ b/src/web/account.js @@ -18,6 +18,8 @@ const url = require("url"); const LOGGER = require('@calzoneman/jsli')('database/accounts'); let globalMessageBus; +let emailConfig; +let emailController; /** * Handles a GET request for /account/edit @@ -531,7 +533,7 @@ function handlePasswordReset(req, res) { Logger.eventlog.log("[account] " + ip + " requested password recovery for " + name + " <" + email + ">"); - if (!Config.get("mail.enabled")) { + if (!emailConfig.getPasswordReset().isEnabled()) { sendPug(res, "account-passwordreset", { reset: false, resetEmail: email, @@ -541,37 +543,26 @@ function handlePasswordReset(req, res) { return; } - var msg = "A password reset request was issued for your " + - "account `"+ name + "` on " + Config.get("http.domain") + - ". This request is valid for 24 hours. If you did "+ - "not initiate this, there is no need to take action."+ - " To reset your password, copy and paste the " + - "following link into your browser: " + - Config.get("http.domain") + "/account/passwordrecover/"+hash; + const baseUrl = `${req.realProtocol}://${req.header("host")}`; - var mail = { - from: Config.get("mail.from-name") + " <" + Config.get("mail.from-address") + ">", - to: email, - subject: "Password reset request", - text: msg - }; - - Config.get("mail.nodemailer").sendMail(mail, function (err, response) { - if (err) { - LOGGER.error("mail fail: " + err); - sendPug(res, "account-passwordreset", { - reset: false, - resetEmail: email, - resetErr: "Sending reset email failed. Please contact an " + - "administrator for assistance." - }); - } else { - sendPug(res, "account-passwordreset", { - reset: true, - resetEmail: email, - resetErr: false - }); - } + emailController.sendPasswordReset({ + username: name, + address: email, + url: `${baseUrl}/account/passwordrecover/${hash}` + }).then(result => { + sendPug(res, "account-passwordreset", { + reset: true, + resetEmail: email, + resetErr: false + }); + }).catch(error => { + LOGGER.error("Sending password reset email failed: %s", error); + sendPug(res, "account-passwordreset", { + reset: false, + resetEmail: email, + resetErr: "Sending reset email failed. Please contact an " + + "administrator for assistance." + }); }); }); }); @@ -639,8 +630,10 @@ module.exports = { /** * Initialize the module */ - init: function (app, _globalMessageBus) { + init: function (app, _globalMessageBus, _emailConfig, _emailController) { globalMessageBus = _globalMessageBus; + emailConfig = _emailConfig; + emailController = _emailController; app.get("/account/edit", handleAccountEditPage); app.post("/account/edit", handleAccountEdit); diff --git a/src/web/webserver.js b/src/web/webserver.js index a70a0a80..25b0af1b 100644 --- a/src/web/webserver.js +++ b/src/web/webserver.js @@ -171,7 +171,9 @@ module.exports = { session, globalMessageBus, accountController, - channelDB + channelDB, + emailConfig, + emailController ) { patchExpressToHandleAsync(); const chanPath = Config.get('channel-path'); @@ -230,7 +232,7 @@ module.exports = { require('./routes/socketconfig')(app, clusterClient); require('./routes/contact')(app, webConfig); require('./auth').init(app); - require('./account').init(app, globalMessageBus); + require('./account').init(app, globalMessageBus, emailConfig, emailController); require('./acp').init(app); require('../google2vtt').attach(app); require('./routes/google_drive_userscript')(app); diff --git a/test/controller/email.js b/test/controller/email.js new file mode 100644 index 00000000..1f9ad494 --- /dev/null +++ b/test/controller/email.js @@ -0,0 +1,50 @@ +const assert = require('assert'); +const { createTransport } = require('nodemailer'); +const { EmailController } = require('../../lib/controller/email'); +const { EmailConfig } = require('../../lib/configuration/emailconfig'); + +describe('EmailController', () => { + describe('sendPasswordReset', () => { + it('sends a password reset email', () => { + const mailer = createTransport({ + jsonTransport: true + }); + const config = new EmailConfig({ + 'password-reset': { + from: 'Test ', + subject: 'Password Reset', + 'html-template': 'Reset here $user$', + 'text-template': 'Text is better than HTML $user$ $url$' + } + }); + + const controller = new EmailController(mailer, config); + + return controller.sendPasswordReset({ + address: 'some-user@example.com', + username: 'SomeUser', + url: 'http://localhost/password-reset/blah' + }).then(info => { + const sentMessage = JSON.parse(info.message); + + assert.strictEqual(sentMessage.subject, 'Password Reset'); + assert.deepStrictEqual( + sentMessage.from, + { name: 'Test', address: 'test@example.com' } + ); + assert.deepStrictEqual( + sentMessage.to, + [{ name: 'SomeUser', address: 'some-user@example.com' }] + ); + assert.strictEqual( + sentMessage.html, + 'Reset here SomeUser' + ); + assert.strictEqual( + sentMessage.text, + 'Text is better than HTML SomeUser http://localhost/password-reset/blah' + ); + }); + }); + }); +});