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'
+ );
+ });
+ });
+ });
+});