diff --git a/README.md b/README.md
index d632502..84703d7 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,8 @@
# Description
-This plugin allows users to subscribe to pads and receive email updates when a pad is being modified. You can modify the frequency. This plugin is very much in alpha stage and has a lot of things TODO (See TODO).
+This plugin allows users to subscribe to pads and receive email updates when a pad is being modified. You can modify the frequency. This plugin is still in early stage and has things TODO (See TODO).
+
+# Source code
+On Github : https://github.com/JohnMcLear/ep_email_notifications
# Installation
Make sure an SMTP gateway is installed IE postfix
@@ -11,6 +14,10 @@ NOTE: You will NOT receive an email if you(the author that registered their emai
```
"ep_email_notifications" : {
+ panelDisplayLocation: { // Where you want to have the subscription panel
+ mysettings: true, // In the "mysettings" menu
+ popup: true // A popup that pop in the bottom right corner of the pad after 10 seconds
+ },
checkFrequency: 6000, // checkFrequency = How frequently(milliseconds) to check for pad updates -- Move me to the settings file
staleTime: 30000, // staleTime = How stale(milliseconds) does a pad need to be before notifying subscribers? Move me to settings
fromName: "Etherpad SETTINGS FILE!",
@@ -22,10 +29,15 @@ NOTE: You will NOT receive an email if you(the author that registered their emai
}
```
+# Translation
+This plugin has for now an english and french translation.
+In case you would like to have it in another language, you can easily translate the few sentences and then contact us on irc (#etherpad-lite-dev on irc.freenode.net) or create a Pull-Request on the GitHub repository.
+You can find the sentences to translate in the ep_email_notifications/locales/ directory.
+Specials chars written in unicode (See https://fr.wikipedia.org/wiki/Table_des_caract%C3%A8res_Unicode_%280000-0FFF%29)
+
# TODO
* Clean up all code
# FUTURE VERSIONS TODO
* v2 - Get the modified contents from the API HTML diff and append that to the Email and make the email from the server HTML not plain text
-* v2 - a point to unsubscribe and validate/verify email https://github.com/alfredwesterveld/node-email-verification
* v2 - Keep a record of when a user was last on a pad
diff --git a/client.js b/client.js
index 4f465ba..1b360be 100644
--- a/client.js
+++ b/client.js
@@ -1,11 +1,23 @@
var eejs = require("ep_etherpad-lite/node/eejs");
+var settings = require('../../src/node/utils/Settings');
exports.eejsBlock_scripts = function (hook_name, args, cb) {
args.content = args.content + eejs.require("ep_email_notifications/templates/scripts.html", {}, module);
return cb();
};
-exports.eejsBlock_embedPopup = function (hook_name, args, cb) {
- args.content = args.content + eejs.require("ep_email_notifications/templates/embedFrame.html", {}, module);
+exports.eejsBlock_mySettings = function (hook_name, args, cb) {
+ args.content = args.content + eejs.require('ep_email_notifications/templates/email_notifications_settings.ejs');
return cb();
};
+
+exports.eejsBlock_styles = function (hook_name, args, cb) {
+ args.content = args.content + '';
+};
+
+exports.clientVars = function(hook, context, callback) {
+ var pluginSettings = settings.ep_email_notifications;
+ var panelDisplayLocation = (pluginSettings && pluginSettings.panelDisplayLocation)?pluginSettings.panelDisplayLocation:"undefiend";
+ // return the setting to the clientVars, sending the value
+ return callback({ "panelDisplayLocation": panelDisplayLocation });
+};
diff --git a/ep.json b/ep.json
index b9471cf..13e3687 100644
--- a/ep.json
+++ b/ep.json
@@ -6,11 +6,17 @@
"padUpdate": "ep_email_notifications/update",
"handleMessage": "ep_email_notifications/handleMessage",
"eejsBlock_scripts": "ep_email_notifications/client",
- "eejsBlock_embedPopup": "ep_email_notifications/client:eejsBlock_embedPopup"
+ "eejsBlock_mySettings": "ep_email_notifications/client:eejsBlock_mySettings",
+ "eejsBlock_styles": "ep_email_notifications/client:eejsBlock_styles",
+ "clientVars": "ep_email_notifications/client:clientVars",
+ "expressCreateServer" : "ep_email_notifications/index:registerRoute"
},
"client_hooks": {
"postAceInit":"ep_email_notifications/static/js/ep_email:postAceInit",
- "handleClientMessage_emailSubscriptionSuccess":"ep_email_notifications/static/js/ep_email"
+ "handleClientMessage_emailSubscriptionSuccess":"ep_email_notifications/static/js/ep_email",
+ "handleClientMessage_emailUnsubscriptionSuccess":"ep_email_notifications/static/js/ep_email",
+ "handleClientMessage_emailNotificationGetUserInfo":"ep_email_notifications/static/js/ep_email",
+ "handleClientMessage_emailNotificationMissingParams":"ep_email_notifications/static/js/ep_email"
}
}
]
diff --git a/handleMessage.js b/handleMessage.js
index e48d044..358c5e7 100644
--- a/handleMessage.js
+++ b/handleMessage.js
@@ -1,38 +1,72 @@
var db = require('../../src/node/db/DB').db,
API = require('../../src/node/db/API.js'),
async = require('../../src/node_modules/async'),
- check = require('validator').check,
+ email = require('emailjs'),
+randomString = require('../../src/static/js/pad_utils').randomString;
settings = require('../../src/node/utils/Settings');
var pluginSettings = settings.ep_email_notifications;
+var areParamsOk = (pluginSettings)?true:false;
+var fromName = (pluginSettings && pluginSettings.fromName)?pluginSettings.fromName:"Etherpad";
+var fromEmail = (pluginSettings && pluginSettings.fromEmail)?pluginSettings.fromEmail:"pad@etherpad.org";
+var urlToPads = (pluginSettings && pluginSettings.urlToPads)?pluginSettings.urlToPads:"http://beta.etherpad.org/p/";
+var emailServer = (pluginSettings && pluginSettings.emailServer)?pluginSettings.emailServer:{host:"127.0.0.1"};
+
+if (areParamsOk == false) console.warn("Settings for ep_email_notifications plugin are missing in settings.json file");
+
+// Connect to the email server -- This might not be the ideal place to connect but it stops us having lots of connections
+var server = email.server.connect(emailServer);
// When a new message comes in from the client - FML this is ugly
exports.handleMessage = function(hook_name, context, callback){
if (context.message && context.message.data){
if (context.message.data.type == 'USERINFO_UPDATE' ) { // if it's a request to update an authors email
- if (context.message.data.userInfo){
+ if (areParamsOk == false) {
+ context.client.json.send({ type: "COLLABROOM",
+ data:{
+ type: "emailNotificationMissingParams",
+ payload: true
+ }
+ });
+ console.warn("Settings for ep_email_notifications plugin are missing in settings.json file");
+
+ callback([null]); // don't run onto passing colorId or anything else to the message handler
+
+ } else if (context.message.data.userInfo){
if(context.message.data.userInfo.email){ // it contains email
console.debug(context.message);
- // does email Subscription already exist for this email address?
+ // does email (Un)Subscription already exist for this email address?
db.get("emailSubscription:"+context.message.data.padId, function(err, userIds){
var alreadyExists = false;
+
if(userIds){
async.forEach(Object.keys(userIds), function(user, cb){
console.debug("UserIds subscribed by email to this pad:", userIds);
- if(user == context.message.data.userInfo.email){ // If we already have this email registered for this pad
-
+ if(user == context.message.data.userInfo.email){ // If we already have this email registered for this pad
// This user ID is already assigned to this padId so don't do anything except tell the user they are already subscribed somehow..
alreadyExists = true;
- console.debug("email ", user, "already subscribed to ", context.message.data.padId, " so sending message to client");
- context.client.json.send({ type: "COLLABROOM",
- data:{
- type: "emailSubscriptionSuccess",
- payload: false
- }
- });
+ if(context.message.data.userInfo.email_option == 'subscribe') {
+ // Subscription process
+ exports.subscriptionEmail(
+ context,
+ context.message.data.userInfo.email,
+ alreadyExists,
+ context.message.data.userInfo,
+ context.message.data.padId,
+ callback
+ );
+ } else if (context.message.data.userInfo.email_option == 'unsubscribe') {
+ // Unsubscription process
+ exports.unsubscriptionEmail(
+ context,
+ alreadyExists,
+ context.message.data.userInfo,
+ context.message.data.padId
+ );
+ }
}
cb();
},
@@ -41,68 +75,321 @@ exports.handleMessage = function(hook_name, context, callback){
// There should be something in here!
}); // end async for each
}
- var validatesAsEmail = check(context.message.data.userInfo.email).isEmail();
- if(!validatesAsEmail){ // send validation failed if it's malformed.. y'know in general fuck em!
- console.warn("Dropped email subscription due to malformed email address");
- context.client.json.send({ type: "COLLABROOM",
- data:{
- type: "emailSubscriptionSuccess",
- payload: false
- }
- });
+
+ // In case we didn't find it in the Db
+ if (alreadyExists == false) {
+ if(context.message.data.userInfo.email_option == 'subscribe') {
+ // Subscription process
+ exports.subscriptionEmail(
+ context,
+ context.message.data.userInfo.email,
+ alreadyExists,
+ context.message.data.userInfo,
+ context.message.data.padId,
+ callback
+ );
+ } else if (context.message.data.userInfo.email_option == 'unsubscribe') {
+ // Unsubscription process
+ exports.unsubscriptionEmail(
+ context,
+ alreadyExists,
+ context.message.data.userInfo,
+ context.message.data.padId
+ );
+ }
}
- if(alreadyExists == false && validatesAsEmail){
- console.debug ("Wrote to the database and sent client a positive response ",context.message.data.userInfo.email);
- exports.setAuthorEmail(
- context.message.data.userInfo.userId,
- context.message.data.userInfo.email, callback
- );
-
- exports.setAuthorEmailRegistered(
- context.message.data.userInfo.email,
- context.message.data.userInfo.userId,
- context.message.data.padId
- );
-
- context.client.json.send({ type: "COLLABROOM",
- data:{
- type: "emailSubscriptionSuccess",
- payload: true
- }
- });
- }
}); // close db get
callback([null]); // don't run onto passing colorId or anything else to the message handler
}
}
+
+ } else if (context.message.data.type == 'USERINFO_GET' ) { // A request to find datas for a userId
+ if (context.message.data.userInfo){
+ if(context.message.data.userInfo.userId){ // it contains the userId
+ console.debug(context.message);
+
+ // does email Subscription already exist for this UserId?
+ db.get("emailSubscription:"+context.message.data.padId, function(err, userIds){
+ var userIdFound = false;
+
+ if(userIds){
+ async.forEach(Object.keys(userIds), function(user, cb){
+ if(userIds[user].authorId == context.message.data.userInfo.userId){ // if we find the same Id in the Db as the one used by the user
+ console.debug("Options for this pad ", userIds[user].authorId, " found in the Db");
+ userIdFound = true;
+
+ // Request user subscription info process
+ exports.sendUserInfo (
+ context,
+ userIdFound,
+ user,
+ userIds[user]
+ );
+ }
+ cb();
+ },
+
+ function(err){
+ // There should be something in here!
+ }); // end async for each
+ }
+
+ if (userIdFound == false) {
+ // Request user subscription info process
+ exports.sendUserInfo (
+ context,
+ userIdFound,
+ "",
+ ""
+ );
+ }
+ });
+
+ callback([null]);
+ }
+ }
}
}
callback();
}
-// Updates the database with the email record
-exports.setAuthorEmail = function (author, email, callback){
- db.setSub("globalAuthor:" + author, ["email"], email, callback);
+/**
+ * Subscription process
+ */
+exports.subscriptionEmail = function (context, email, emailFound, userInfo, padId, callback) {
+ var validatesAsEmail = exports.checkEmailValidation(email);
+ var subscribeId = randomString(25);
+
+ if(emailFound == false && validatesAsEmail){
+ // Subscription -> Go for it
+ console.debug ("Subscription: Wrote to the database and sent client a positive response ",context.message.data.userInfo.email);
+
+ exports.setAuthorEmailRegistered(
+ userInfo,
+ userInfo.userId,
+ subscribeId,
+ padId
+ );
+
+ context.client.json.send({ type: "COLLABROOM",
+ data:{
+ type: "emailSubscriptionSuccess",
+ payload: {
+ formName: userInfo.formName,
+ success: true
+ }
+ }
+ });
+
+ // Send mail to user with the link for validation
+ server.send(
+ {
+ text: "Please click on this link in order to validate your subscription to the pad " + padId + "\n" + urlToPads+padId + "/subscribe=" + subscribeId,
+ from: fromName+ "<"+fromEmail+">",
+ to: userInfo.email,
+ subject: "Email subscription confirmation for pad "+padId
+ },
+ function(err, message) {
+ console.log(err || message);
+ }
+ );
+
+ } else if (!validatesAsEmail) {
+ // Subscription -> failed coz mail malformed.. y'know in general fuck em!
+ console.warn("Dropped email subscription due to malformed email address");
+ context.client.json.send({ type: "COLLABROOM",
+ data:{
+ type: "emailSubscriptionSuccess",
+ payload: {
+ type: "malformedEmail",
+ formName: userInfo.formName,
+ success: false
+ }
+ }
+ });
+ } else {
+ // Subscription -> failed coz email already subscribed for this pad
+ console.debug("email ", context.message.data.userInfo.email, "already subscribed to ", context.message.data.padId, " so sending message to client");
+
+ context.client.json.send({ type: "COLLABROOM",
+ data:{
+ type: "emailSubscriptionSuccess",
+ payload: {
+ type: "alreadyRegistered",
+ formName: userInfo.formName,
+ success: false
+ }
+ }
+ });
+ }
}
-// Write email and padId to the database
-exports.setAuthorEmailRegistered = function(email, authorId, padId){
+/**
+ * UnsUbscription process
+ */
+exports.unsubscriptionEmail = function (context, emailFound, userInfo, padId) {
+ var unsubscribeId = randomString(25);
+
+ if(emailFound == true) {
+ // Unsubscription -> Go for it
+ console.debug ("Unsubscription: Remove from the database and sent client a positive response ",context.message.data.userInfo.email);
+
+ exports.unsetAuthorEmailRegistered(
+ userInfo,
+ userInfo.userId,
+ unsubscribeId,
+ padId
+ );
+
+ context.client.json.send({ type: "COLLABROOM",
+ data:{
+ type: "emailUnsubscriptionSuccess",
+ payload: {
+ formName: userInfo.formName,
+ success: true
+ }
+ }
+ });
+
+ // Send mail to user with the link for validation
+ server.send(
+ {
+ text: "Please click on this link in order to validate your unsubscription to the pad " + padId + "\n" + urlToPads+padId + "/unsubscribe=" + unsubscribeId,
+ from: fromName+ "<"+fromEmail+">",
+ to: userInfo.email,
+ subject: "Email unsubscription confirmation for pad "+padId
+ },
+ function(err, message) {
+ console.log(err || message);
+ }
+ );
+ } else {
+ // Unsubscription -> Send failed as email not found
+ console.debug ("Unsubscription: Send client a negative response ",context.message.data.userInfo.email);
+
+ context.client.json.send({ type: "COLLABROOM",
+ data:{
+ type: "emailUnsubscriptionSuccess",
+ payload: {
+ formName: userInfo.formName,
+ success: false
+ }
+ }
+ });
+ }
+}
+
+/**
+ * Request user subscription info process
+ */
+exports.sendUserInfo = function (context, emailFound, email, userInfo) {
+ var defaultOnStartOption = true;
+ var defaultOnEndOption = false;
+
+ if (typeof userInfo.onStart == 'boolean' && typeof userInfo.onEnd == 'boolean') {
+ var onStart = userInfo.onStart;
+ var onEnd = userInfo.onEnd;
+ } else { // In case these options are not yet defined for this userId
+ var onStart = defaultOnStartOption;
+ var onEnd = defaultOnEndOption;
+ }
+
+ if (emailFound == true) {
+ // We send back the options associated to this userId
+ context.client.json.send({ type: "COLLABROOM",
+ data:{
+ type: "emailNotificationGetUserInfo",
+ payload: {
+ email: email,
+ onStart: onStart,
+ onEnd: onEnd,
+ formName: context.message.data.userInfo.formName,
+ success:true
+ }
+ }
+ });
+ } else {
+ // No options set for this userId
+ context.client.json.send({ type: "COLLABROOM",
+ data:{
+ type: "emailNotificationGetUserInfo",
+ payload: {
+ formName: context.message.data.userInfo.formName,
+ success:false
+ }
+ }
+ });
+ }
+}
+
+/**
+ * Function to check if an email is valid
+ */
+exports.checkEmailValidation = function (email) {
+ var Validator = require('validator').Validator;
+ var validator = new Validator();
+ validator.error = function() {
+ return false;
+ };
+ return validator.check(email).isEmail();
+}
+
+/**
+ * Database manipulation
+ */
+
+// Write email, options, authorId and pendingId to the database
+exports.setAuthorEmailRegistered = function(userInfo, authorId, subscribeId, padId){
var timestamp = new Date().getTime();
var registered = {
authorId: authorId,
+ onStart: userInfo.email_onStart,
+ onEnd: userInfo.email_onEnd,
+ subscribeId: subscribeId,
timestamp: timestamp
};
console.debug("registered", registered, " to ", padId);
+
// Here we have to basically hack a new value into the database, this isn't clean or polite.
db.get("emailSubscription:" + padId, function(err, value){ // get the current value
- if(!value){value = {};} // if an emailSubscription doesnt exist yet for this padId don't panic
- value[email] = registered; // add the registered values to the object
- console.warn("written to database");
+ if(!value){
+ // if an emailSubscription doesnt exist yet for this padId don't panic
+ value = {"pending":{}};
+ } else if (!value['pending']) {
+ // if the pending section doesn't exist yet for this padId, we create it
+ value['pending'] = {};
+ }
+
+ // add the registered values to the pending section of the object
+ value['pending'][userInfo.email] = registered;
+
+ // Write the modified datas back in the Db
db.set("emailSubscription:" + padId, value); // stick it in the database
});
}
+// Write email, authorId and pendingId to the database
+exports.unsetAuthorEmailRegistered = function(userInfo, authorId, unsubscribeId, padId){
+ var timestamp = new Date().getTime();
+ var registered = {
+ authorId: authorId,
+ unsubscribeId: unsubscribeId,
+ timestamp: timestamp
+ };
+ console.debug("unregistered", userInfo.email, " to ", padId);
+
+ db.get("emailSubscription:" + padId, function(err, value){ // get the current value
+ // if the pending section doesn't exist yet for this padId, we create it (this shouldn't happen)
+ if (!value['pending']) {value['pending'] = {};}
+
+ // add the registered values to the pending section of the object
+ value['pending'][userInfo.email] = registered;
+
+ // Write the modified datas back in the Db
+ db.set("emailSubscription:" + padId, value);
+ });
+}
diff --git a/index.js b/index.js
new file mode 100644
index 0000000..6bc0c60
--- /dev/null
+++ b/index.js
@@ -0,0 +1,254 @@
+ var db = require('ep_etherpad-lite/node/db/DB').db,
+ fs = require("fs"),
+ async = require('../../src/node_modules/async'),
+settings = require('../../src/node/utils/Settings');
+
+// Remove cache for this procedure
+db['dbSettings'].cache = 0;
+
+exports.registerRoute = function (hook_name, args, callback) {
+ // Catching (un)subscribe addresses
+ args.app.get('/p/*/(un){0,1}subscribe=*', function(req, res) {
+ var fullURL = req.protocol + "://" + req.get('host') + req.url;
+ var path=req.url.split("/");
+ var padId=path[2];
+ var param = path[3].split("=");
+ var action = param[0];
+ var actionId = param[1];
+ var padURL = req.protocol + "://" + req.get('host') + "/p/" +padId;
+
+ async.waterfall(
+ [
+ function(cb) {
+ // Is the (un)subscription valid (exists & not older than 24h)
+ db.get("emailSubscription:"+padId, function(err, userIds){
+ var foundInDb = false;
+ var timeDiffGood = false;
+ var email = "your email";
+ var resultDb = {
+ "foundInDb": foundInDb,
+ "timeDiffGood": timeDiffGood,
+ "email": email
+ }
+
+ if(userIds && userIds['pending']){
+ async.forEach(Object.keys(userIds['pending']), function(user){
+ var userInfo = userIds['pending'][user];
+
+ // If we have Id int the Db, then we are good ot really unsubscribe the user
+ if(userInfo[action + 'Id'] == actionId){
+ console.debug("emailSubscription:", user, "found in DB:", userInfo);
+
+ foundInDb = true;
+ email = user;
+
+ // Checking if the demand is not older than 24h
+ var timeDiff = new Date().getTime() - userInfo.timestamp;
+ timeDiffGood = timeDiff < 1000 * 60 * 60 * 24;
+
+ if(action == 'subscribe' && timeDiffGood == true) {
+ // Subscription process
+ setAuthorEmail(
+ userInfo,
+ user
+ );
+
+ setAuthorEmailRegistered(
+ userIds,
+ userInfo,
+ user,
+ padId
+ );
+ } else if (action == 'unsubscribe' && timeDiffGood == true) {
+ // Unsubscription process
+ unsetAuthorEmail(
+ userInfo,
+ user
+ );
+
+ unsetAuthorEmailRegistered(
+ userIds,
+ user,
+ padId
+ );
+ }
+
+ resultDb = {
+ "foundInDb": foundInDb,
+ "timeDiffGood": timeDiffGood,
+ "email": user
+ }
+ }
+ },
+
+ function(err, msg){
+ if (err != null) {
+ console.error("Error in async.forEach", err, " -> ", msg);
+ }
+ }); // end async for each
+ }
+ cb(null, resultDb);
+ });
+ },
+
+ function(resultDb, cb) {
+ // Create and send the output message
+ sendContent(res, args, action, padId, padURL, resultDb);
+ cb(null, resultDb);
+ },
+
+ function(resultDb, cb) {
+ // Take a moment to clean all obsolete pending data
+ cleanPendingData(padId);
+ cb(null, resultDb);
+ }
+ ],
+ function(err, results){
+ if (err != null) {
+ console.error("Callback async.series: Err -> ", err, " / results -> ", results);
+ }
+ }
+ );
+ });
+
+ callback(); // Am I even called?
+}
+
+/**
+ * Database manipulation
+ */
+
+// Updates the database with the email record
+setAuthorEmail = function (userInfo, email){
+ db.setSub("globalAuthor:" + userInfo.authorId, ["email"], email);
+}
+
+// Write email and padId to the database
+setAuthorEmailRegistered = function(userIds, userInfo, email, padId){
+ console.debug("setAuthorEmailRegistered: Initial userIds:", userIds);
+ var timestamp = new Date().getTime();
+ var registered = {
+ authorId: userInfo.authorId,
+ onStart: userInfo.onStart,
+ onEnd: userInfo.onEnd,
+ timestamp: timestamp
+ };
+
+ // add the registered values to the object
+ userIds[email] = registered;
+
+ // remove the pending data
+ delete userIds['pending'][email];
+
+ // Write the modified datas back in the Db
+ console.warn("written to database");
+ db.set("emailSubscription:" + padId, userIds); // stick it in the database
+
+ console.debug("setAuthorEmailRegistered: Modified userIds:", userIds);
+}
+
+// Updates the database by removing the email record for that AuthorId
+unsetAuthorEmail = function (userInfo, email){
+ db.get("globalAuthor:" + userInfo.authorId, function(err, value){ // get the current value
+ if (value['email'] == email) {
+ // Remove the email option from the datas
+ delete value['email'];
+
+ // Write the modified datas back in the Db
+ db.set("globalAuthor:" + userInfo.authorId, value);
+ }
+ });
+}
+
+// Remove email, options and padId from the database
+unsetAuthorEmailRegistered = function(userIds, email, padId){
+ console.debug("unsetAuthorEmailRegistered: initial userIds:", userIds);
+ // remove the registered options from the object
+ delete userIds[email];
+
+ // remove the pending data
+ delete userIds['pending'][email];
+
+ // Write the modified datas back in the Db
+ console.warn("written to database");
+ db.set("emailSubscription:" + padId, userIds);
+
+ console.debug("unsetAuthorEmailRegistered: modified userIds:", userIds);
+}
+
+/**
+ * We take a moment to remove too old pending (un)subscription
+ */
+cleanPendingData = function (padId) {
+ var modifiedData, areDataModified = false;
+
+ db.get("emailSubscription:" + padId, function(err, userIds){ // get the current value
+ console.debug("cleanPendingData: Initial userIds:", userIds);
+ modifiedData = userIds;
+ if(userIds && userIds['pending']){
+ async.forEach(Object.keys(userIds['pending']), function(user){
+ var timeDiff = new Date().getTime() - userIds['pending'][user].timestamp;
+ var timeDiffGood = timeDiff < 1000 * 60 * 60 * 24;
+
+ if(timeDiffGood == false) {
+ delete modifiedData['pending'][user];
+
+ areDataModified = true;
+ }
+ });
+ }
+
+ if (areDataModified == true) {
+ // Write the modified datas back in the Db
+ db.set("emailSubscription:" + padId, modifiedData);
+ }
+
+ console.debug("cleanPendingData: Modified userIds:", modifiedData, " / areDataModified:", areDataModified);
+ });
+}
+
+/**
+ * Create html output with the status of the process
+ */
+function sendContent(res, args, action, padId, padURL, resultDb) {
+ console.debug("starting sendContent: args ->", action, " / ", padId, " / ", padURL, " / ", resultDb);
+
+ if (action == 'subscribe') {
+ var actionMsg = "Subscribing '" + resultDb.email + "' to pad " + padId;
+ } else {
+ var actionMsg = "Unsubscribing '" + resultDb.email + "' from pad " + padId;
+ }
+ var msgCause, resultMsg, classResult;
+
+ if (resultDb.foundInDb == true && resultDb.timeDiffGood == true) {
+ // Pending data were found un Db and updated -> good
+ resultMsg = "Success";
+ classResult = "validationGood";
+ if (action == 'subscribe') {
+ msgCause = "You will receive email when someone changes this pad.";
+ } else {
+ msgCause = "You won't receive anymore email when someone changes this pad.";
+ }
+ } else if (resultDb.foundInDb == true) {
+ // Pending data were found but older than a day -> fail
+ resultMsg = "Too late!";
+ classResult = "validationBad";
+ msgCause = "You have max 24h to click the link in your confirmation email.";
+ } else {
+ // Pending data weren't found in Db -> fail
+ resultMsg = "Fail";
+ classResult = "validationBad";
+ msgCause = "We couldn't find any pending " + (action == 'subscribe'?'subscription':'unsubscription') + "
in our system with this Id.
Maybe you wait more than 24h before validating";
+ }
+
+ args.content = fs.readFileSync(__dirname + "/templates/response.ejs", 'utf-8');
+ args.content = args.content
+ .replace(/\<%action%\>/, actionMsg)
+ .replace(/\<%classResult%\>/, classResult)
+ .replace(/\<%result%\>/, resultMsg)
+ .replace(/\<%explanation%\>/, msgCause)
+ .replace(/\<%padUrl%\>/g, padURL);
+
+ res.contentType("text/html; charset=utf-8");
+ res.send(args.content); // Send it to the requester*/
+}
diff --git a/locales/en.json b/locales/en.json
new file mode 100644
index 0000000..2884b90
--- /dev/null
+++ b/locales/en.json
@@ -0,0 +1,19 @@
+{ "ep_email_notifications.titleGritterError": "Email subscription error"
+, "ep_email_notifications.titleGritterSubscr": "Email subscription"
+, "ep_email_notifications.titleGritterUnsubscr": "Email unsubscription"
+, "ep_email_notifications.headerGritterSubscr": "(Receive an email when someone modifies this pad)"
+, "ep_email_notifications.msgOptionsNotChecked": "You need to check at least one of the two options from 'Send a mail when someone..'"
+, "ep_email_notifications.msgParamsMissing": "Settings for the 'email_Notifications' plugin are missing.
Please contact your administrator."
+, "ep_email_notifications.msgEmailMalformed": "The email address is malformed"
+, "ep_email_notifications.msgAlreadySubscr": "You are already registered for emails for this pad"
+, "ep_email_notifications.msgUnsubscrNotExisting": "This email address is not registered for this pad"
+, "ep_email_notifications.msgUnknownErr": "Unknown error"
+, "ep_email_notifications.msgSubscrSuccess": "An email was sent to your address.
Click on the link in order to validate your subscription."
+, "ep_email_notifications.msgUnsubscrSuccess": "An email was sent to your address.
Click on the link in order to validate your unsubscription"
+, "ep_email_notifications.menuLabel": "Email Notifications"
+, "ep_email_notifications.formOptionsTitle": "Send a mail when someone.."
+, "ep_email_notifications.formOptionOnStart": "starts editing the pad"
+, "ep_email_notifications.formOptionOnEnd": "finish editing the pad"
+, "ep_email_notifications.formBtnSubscr": "subscribe"
+, "ep_email_notifications.formBtnUnsubscr": "unsubscribe"
+}
diff --git a/locales/fr.json b/locales/fr.json
new file mode 100644
index 0000000..a301ed5
--- /dev/null
+++ b/locales/fr.json
@@ -0,0 +1,19 @@
+{ "ep_email_notifications.titleGritterError": "Notification par email - Erreur"
+, "ep_email_notifications.titleGritterSubscr": "Notification par email"
+, "ep_email_notifications.titleGritterUnsubscr": "D\u00e9sinscription d\u2019email"
+, "ep_email_notifications.headerGritterSubscr": "(\u00catre pr\u00e9venu lorsque quelqu\u2019un modifie ce pad)"
+, "ep_email_notifications.msgOptionsNotChecked": "Il faut cocher au moins une des 2 options pour l\u2019inscription de l\u2019email..\u2019"
+, "ep_email_notifications.msgParamsMissing": "Les param\u00e8tres de configurations du plugin \u2019email_Notifications\u2019 sont manquants.
Veuillez contacter votre administrateur."
+, "ep_email_notifications.msgEmailMalformed": "L\u2019adresse email n\u2019est pas valide"
+, "ep_email_notifications.msgAlreadySubscr": "Vous avez d\u00e9j\u00e0 enregistr\u00e9 cette adresse email pour ce pad"
+, "ep_email_notifications.msgUnsubscrNotExisting": "Cette adresse email n\u2019est pas enregistr\u00e9e pour ce pad"
+, "ep_email_notifications.msgUnknownErr": "Erreur inconnue"
+, "ep_email_notifications.msgSubscrSuccess": "Un email a \u00e9t\u00e9 envoy\u00e9 \u00e0 votre adresse.
Cliquez sur le lien afin de valider votre inscription."
+, "ep_email_notifications.msgUnsubscrSuccess": "Un email a \u00e9t\u00e9 envoy\u00e9 \u00e0 votre adresse.
Cliquez sur le lien afin de valider votre d\u00e9sinscription."
+, "ep_email_notifications.menuLabel": "Notification par email"
+, "ep_email_notifications.formOptionsTitle": "Envoyer un email quand qqu\u2019un.."
+, "ep_email_notifications.formOptionOnStart": "commence l\u2019\u00e9dition du pad"
+, "ep_email_notifications.formOptionOnEnd": "a fini d\u2019\u00e9diter le pad"
+, "ep_email_notifications.formBtnSubscr": "inscription"
+, "ep_email_notifications.formBtnUnsubscr": "d\u00e9sinscription"
+}
diff --git a/package.json b/package.json
index b744782..10c90e2 100644
--- a/package.json
+++ b/package.json
@@ -1,13 +1,17 @@
{
"name": "ep_email_notifications",
"description": "Subscribe to a pad and receive an email when someone edits your pad",
- "version": "0.0.6",
+ "version": "0.0.7",
"author": {
"name": "johnyma22",
"email": "john@mclear.co.uk",
"url": "John McLear"
},
- "contributors": [],
+ "contributors": [{
+ "name": "quenenni",
+ "email": "quenenni@bruxxel.org",
+ "url": "https://github.com/quenenni"
+ }],
"dependencies": {
"emailjs": ">= 0.2.7",
"buffertools": ">= 1.0.8",
@@ -15,5 +19,12 @@
},
"engines": {
"node": ">= 0.4.1"
- }
+ },
+ "readme": "# Description\nThis plugin allows users to subscribe to pads and receive email updates when a pad is being modified. You can modify the frequency. This plugin is very much in alpha stage and has a lot of things TODO (See TODO).\n\n# Installation\nMake sure an SMTP gateway is installed IE postfix\nConfigure SPF and RDNS records to ensure proper mail flow <-- Search online\nCopy/Edit the below to your settings.json\nConnect to a pad, Click on the Share/Embed link and enter in your email address.\nOpen that pad in ANOTHER BROWSER then begin modifying, you should receive an email when the pad has begun editing and once the pad has gone stale (when everyone stops editing it and a time period passes).\nNOTE: You will NOT receive an email if you(the author that registered their email) are currently on or editing that pad!\n\n```\n \"ep_email_notifications\" : {\n checkFrequency: 6000, // checkFrequency = How frequently(milliseconds) to check for pad updates -- Move me to the settings file\n staleTime: 30000, // staleTime = How stale(milliseconds) does a pad need to be before notifying subscribers? Move me to settings\n fromName: \"Etherpad SETTINGS FILE!\",\n fromEmail: \"pad@etherpad.org\",\n urlToPads: \"http://beta.etherpad.org/p/\", // urlToPads = The URL to your pads note the trailing /\n emailServer: { // See https://github.com/eleith/emailjs for settings\n host: \"127.0.0.1\"\n }\n }\n```\n\n# TODO\n* Clean up all code\n\n# FUTURE VERSIONS TODO\n* v2 - Get the modified contents from the API HTML diff and append that to the Email and make the email from the server HTML not plain text\n* v2 - a point to unsubscribe and validate/verify email https://github.com/alfredwesterveld/node-email-verification\n* v2 - Keep a record of when a user was last on a pad\n",
+ "readmeFilename": "README.md",
+ "_id": "ep_email_notifications@0.0.7",
+ "dist": {
+ "shasum": "1f32eee4c8d5f3903c549b5a7985afc0053ed451"
+ },
+ "_from": "ep_email_notifications"
}
diff --git a/static/css/email_notifications.css b/static/css/email_notifications.css
new file mode 100644
index 0000000..a5e5ee0
--- /dev/null
+++ b/static/css/email_notifications.css
@@ -0,0 +1,64 @@
+.ep_email_settings {
+ display: none;
+ padding: 0.2em 0.2em 0.2em 0.5em;
+}
+
+.ep_email_buttons {
+ padding:5px;
+}
+
+.ep_email_checkbox {
+ margin-left:0.3em;
+}
+
+.ep_email_input {
+ padding:.2em;
+ width:177px;
+}
+
+.ep_email_form_popup_header {
+ font-size:x-small;
+}
+
+#ep_email_form_popup .ep_email_buttons {
+ margin-top: .5em;
+}
+
+#ep_email_form_popup .ep_email_checkbox {
+ margin-top: .2em;
+}
+
+#ep_email_form_popup .ep_email_input {
+ margin: .3em 0;
+ width:95%;
+}
+/* (un)subscription validation page */
+.validationTitle {
+ margin: 0 auto;
+ width: 600px;
+ text-align: center;
+}
+.validationEmailSubscription {
+ width: 600px;
+ margin: 0 auto;
+ text-align: center;
+ font-size: bigger;
+ font-weight: bold;
+ font-color: green;
+}
+.validationEmailSubscription > div {
+ border: solid 2px #333;
+ padding: .3em;
+ margin: .5em 0;
+}
+.validationEmailSubscription > div > div {
+ margin:0;
+ padding:.2em;
+ font-weight:normal;
+}
+.validationGood {
+ background-color: #CCFF66;
+}
+.validationBad {
+ background-color: #FF3300;
+}
diff --git a/static/js/ep_email.js b/static/js/ep_email.js
index 9f70476..4be591a 100644
--- a/static/js/ep_email.js
+++ b/static/js/ep_email.js
@@ -1,62 +1,156 @@
var cookie = require('ep_etherpad-lite/static/js/pad_cookie').padcookie;
+var optionsAlreadyRecovered = false;
if(typeof exports == 'undefined'){
var exports = this['mymodule'] = {};
}
exports.postAceInit = function(hook, context){
- // after 10 seconds if we dont already have an email for this author then prompt them
- setTimeout(function(){init()},10000);
+ // If panelDisplayLocation setting is missing, set default value
+ if (typeof clientVars.panelDisplayLocation != "object") {
+ clientVars.panelDisplayLocation = {
+ mysettings: true, // In the "mysettings" menu
+ popup: true
+ }
+ }
- // subscribe by email can be active..
- $('.ep_email_form').submit(function(){
- sendEmailToServer();
- return false;
- });
+ // If plugin settings set panel form in mysettings menu
+ if (clientVars.panelDisplayLocation.mysettings == true) {
+ // Uncheck the checkbox incase of reminiscence
+ $('#options-emailNotifications').prop('checked', false);
-}
+ $('#options-emailNotifications').on('click', function() {
+ if (!optionsAlreadyRecovered) {
+ getDataForUserId('ep_email_form_mysettings');
+ optionsAlreadyRecovered = true;
+ } else {
+ $('.ep_email_settings').slideToggle();
+ }
+ });
-exports.handleClientMessage_emailSubscriptionSuccess = function(hook, context){ // was subscribing to the email a big win or fail?
- if(context.payload == false){
- showAlreadyRegistered();
- }else{
- showRegistrationSuccess();
+ // Prepare subscription before submit form
+ $('[name=ep_email_subscribe]').on('click', function(e) {
+ $('[name=ep_email_option]').val('subscribe');
+ checkAndSend(e);
+ });
+
+ // Prepare unsubscription before submit form
+ $('[name=ep_email_unsubscribe]').on('click', function(e) {
+ $('[name=ep_email_option]').val('unsubscribe');
+ checkAndSend(e);
+ });
+
+ // subscribe by email can be active..
+ $('#ep_email_form_mysettings').submit(function(){
+ sendEmailToServer('ep_email_form_mysettings');
+ return false;
+ });
+ } else {
+ // Hide the notification menu in mysettings
+ $('#options-emailNotifications').parent().hide();
+ }
+
+ // If settings set popup panel form to true, show it
+ if (clientVars.panelDisplayLocation.popup == true) {
+ // after 10 seconds if we dont already have an email for this author then prompt them
+ setTimeout(function(){initPopupForm()},10000);
}
}
-function init(){
- var popUpIsAlreadyVisible = $('.ep_email_form').is(":visible");
- if(!popUpIsAlreadyVisible){ // if the popup isn't already visible
- if(clientHasAlreadyRegistered()){ // if the client has already registered for emails on this pad.
- // showAlreadyRegistered(); // client has already registered, let em know..
- }else{
- var cookieVal = pad.getPadId() + "email";
- if(cookie.getPref(cookieVal) !== "true"){ // if this user hasn't already subscribed
- askClientToEnterEmail(); // ask the client to register TODO uncomment me for a pop up
- }
+exports.handleClientMessage_emailSubscriptionSuccess = function(hook, context){ // was subscribing to the email a big win or fail?
+ if(context.payload.success == false) {
+ showAlreadyRegistered(context.payload.type);
+ $('#' + context.payload.formName + ' [name=ep_email]').select();
+ } else {
+ showRegistrationSuccess();
+
+ // Add cookie to say an email is registered for this pad
+ cookie.setPref(pad.getPadId() + "email", "true");
+
+ if (clientVars.panelDisplayLocation.mysettings == true && $('.ep_email_settings').is(":visible")) {
+ $('.ep_email_settings').slideToggle();
+ $('#options-emailNotifications').prop('checked', false);
+ }
+
+ if (clientVars.panelDisplayLocation.popup == true && $('#ep_email_form_popup').is(":visible")) {
+ $('#ep_email_form_popup').parent().parent().parent().hide();
}
}
}
-function showRegistrationSuccess(){ // show a successful registration message
- $.gritter.add({
- // (string | mandatory) the heading of the notification
- title: "Email subscribed",
- // (string | mandatory) the text inside the notification
- text: "You will receive email when someone changes this pad. If this is the first time you have requested emails you may need to confirm your email address"
- });
+exports.handleClientMessage_emailUnsubscriptionSuccess = function(hook, context){ // was subscribing to the email a big win or fail?
+ if(context.payload.success == false) {
+ showWasNotRegistered();
+ $('#' + context.payload.formName + ' [name=ep_email]').select();
+ } else {
+ showUnregistrationSuccess();
+
+ // Set cookie to say no email is registered for this pad
+ cookie.setPref(pad.getPadId() + "email", "false");
+
+ if (clientVars.panelDisplayLocation.mysettings == true && $('.ep_email_settings').is(":visible")) {
+ $('.ep_email_settings').slideToggle();
+ $('#options-emailNotifications').prop('checked', false);
+ }
+
+ if (clientVars.panelDisplayLocation.popup == true && $('#ep_email_form_popup').is(":visible")) {
+ $('#ep_email_form_popup').parent().parent().parent().hide();
+ }
+ }
}
-function showAlreadyRegistered(){ // the client already registered for emails on this pad so notify the UI
- $.gritter.add({
- // (string | mandatory) the heading of the notification
- title: "Email subscription",
- // (string | mandatory) the text inside the notification
- text: "You are already registered for emails for this pad",
- // (bool | optional) if you want it to fade out on its own or just sit there
- sticky: false
- });
+exports.handleClientMessage_emailNotificationGetUserInfo = function (hook, context) { // return the existing options for this userId
+ var result = context.payload;
+ if(result.success == true){ // If data found, set the options with them
+ $('[name=ep_email]').val(result.email);
+ $('[name=ep_email_onStart]').prop('checked', result.onStart);
+ $('[name=ep_email_onEnd]').prop('checked', result.onEnd);
+ } else { // No data found, set the options to default values
+ $('[name=ep_email_onStart]').prop('checked', true);
+ $('[name=ep_email_onEnd]').prop('checked', false);
+ }
+ if (result.formName == 'ep_email_form_mysettings') {
+ $('.ep_email_settings').slideToggle();
+ }
+}
+
+exports.handleClientMessage_emailNotificationMissingParams = function (hook, context) { // Settings are missing in settings.json file
+ if (context.payload == true) {
+ $.gritter.add({
+ // (string | mandatory) the heading of the notification
+ title: window._('ep_email_notifications.titleGritterError'),
+ // (string | mandatory) the text inside the notification
+ text: window._('ep_email_notifications.msgParamsMissing'),
+ // (bool | optional) if you want it to fade out on its own or just sit there
+ sticky: true
+ });
+
+ // Hide the notification menu in mysettings
+ if (clientVars.panelDisplayLocation.mysettings == true && $('.ep_email_settings').is(":visible")) {
+ $('.ep_email_settings').slideToggle();
+ $('#options-emailNotifications').prop('checked', false);
+ $('#options-emailNotifications').parent().hide();
+ }
+
+ // Hide the popup if it is visible
+ if (clientVars.panelDisplayLocation.popup == true && $('#ep_email_form_popup').is(":visible")) {
+ $('#ep_email_form_popup').parent().parent().parent().hide();
+ }
+ }
+}
+
+/**
+ * Initialize the popup panel form for subscription
+ */
+function initPopupForm(){
+ var popUpIsAlreadyVisible = $('#ep_email_form_popup').is(":visible");
+ if(!popUpIsAlreadyVisible){ // if the popup isn't already visible
+ var cookieVal = pad.getPadId() + "email";
+ if(cookie.getPref(cookieVal) !== "true"){ // if this user hasn't already subscribed
+ askClientToEnterEmail(); // ask the client to register TODO uncomment me for a pop up
+ }
+ }
}
function clientHasAlreadyRegistered(){ // Has the client already registered for emails on this?
@@ -72,41 +166,169 @@ function clientHasAlreadyRegistered(){ // Has the client already registered for
}
function askClientToEnterEmail(){
+ var formContent = $('.ep_email_settings')
+ .html()
+ .replace('ep_email_form_mysettings', 'ep_email_form_popup');
+
$.gritter.add({
// (string | mandatory) the heading of the notification
- title: "Enter your email to receive an email when someone modifies this pad",
+ title: window._('ep_email_notifications.titleGritterSubscr'),
// (string | mandatory) the text inside the notification
- text: "
" + window._('ep_email_notifications.headerGritterSubscr') + "
" + formContent, // (bool | optional) if you want it to fade out on its own or just sit there sticky: true, // (int | optional) the time you want it to be alive for before fading out - time: '2000', + time: 2000, // the function to bind to the form after_open: function(e){ - $('.ep_email_form').submit(function(){ - $(e).hide(); - sendEmailToServer(); + $('#ep_email_form_popup').submit(function(){ + sendEmailToServer('ep_email_form_popup'); return false; }); + + // Prepare subscription before submit form + $('#ep_email_form_popup [name=ep_email_subscribe]').on('click', function(e) { + $('#ep_email_form_popup [name=ep_email_option]').val('subscribe'); + checkAndSend(e); + }); + + // Prepare unsubscription before submit form + $('#ep_email_form_popup [name=ep_email_unsubscribe]').on('click', function(e) { + $('#ep_email_form_popup [name=ep_email_option]').val('unsubscribe'); + checkAndSend(e); + }); + + getDataForUserId('ep_email_form_popup'); + optionsAlreadyRecovered = true; } }); } -function sendEmailToServer(){ - var email = $('#ep_email').val(); - if(!email){ // if we're not using the top value use the notification value - email = $('#ep_email_notification').val(); +/** + * Control options before submitting the form + */ +function checkAndSend(e) { + var formName = $(e.currentTarget.parentNode).attr('id'); + + var email = $('#' + formName + ' [name=ep_email]').val(); + + if (email && $('#' + formName + ' [name=ep_email_option]').val() == 'subscribe' + && !$('#' + formName + ' [name=ep_email_onStart]').is(':checked') + && !$('#' + formName + ' [name=ep_email_onEnd]').is(':checked')) { + $.gritter.add({ + // (string | mandatory) the heading of the notification + title: window._('ep_email_notifications.titleGritterError'), + // (string | mandatory) the text inside the notification + text: window._('ep_email_notifications.msgOptionsNotChecked') + }); + } else if (email) { + $('#' + formName).submit(); } + return false; +} + +/** + * Ask the server to register the email + */ +function sendEmailToServer(formName){ + var email = $('#' + formName + ' [name=ep_email]').val(); var userId = pad.getUserId(); var message = {}; message.type = 'USERINFO_UPDATE'; message.userInfo = {}; message.padId = pad.getPadId(); message.userInfo.email = email; + message.userInfo.email_option = $('#' + formName + ' [name=ep_email_option]').val(); + message.userInfo.email_onStart = $('#' + formName + ' [name=ep_email_onStart]').is(':checked'); + message.userInfo.email_onEnd = $('#' + formName + ' [name=ep_email_onEnd]').is(':checked'); + message.userInfo.formName = formName; message.userInfo.userId = userId; if(email){ pad.collabClient.sendMessage(message); - cookie.setPref(message.padId+"email", "true"); } } +/** + * Thanks to the userId, we can get back from the Db the options set for this user + * and fill the fields with them + */ +function getDataForUserId(formName) { + var userId = pad.getUserId(); + var message = {}; + message.type = 'USERINFO_GET'; + message.padId = pad.getPadId(); + message.userInfo = {}; + message.userInfo.userId = userId; + message.userInfo.formName = formName; + + pad.collabClient.sendMessage(message); +} + +/************************************* +Manage return msgs from server +*************************************/ + +/** + * Show a successful registration message + */ +function showRegistrationSuccess(){ + $.gritter.add({ + // (string | mandatory) the heading of the notification + title: window._('ep_email_notifications.titleGritterSubscr'), + // (string | mandatory) the text inside the notification + text: window._('ep_email_notifications.msgSubscrSuccess'), + // (int | optional) the time you want it to be alive for before fading out + time: 10000 + }); +} + +/** + * The client already registered for emails on this pad so notify the UI + */ +function showAlreadyRegistered(type){ + if (type == "malformedEmail") { + var msg = window._('ep_email_notifications.msgEmailMalformed'); + } else if (type == "alreadyRegistered") { + var msg = window._('ep_email_notifications.msgAlreadySubscr'); + } else { + var msg = window._('ep_email_notifications.msgUnknownErr'); + } + $.gritter.add({ + // (string | mandatory) the heading of the notification + title: window._('ep_email_notifications.titleGritterSubscr'), + // (string | mandatory) the text inside the notification + text: msg, + // (int | optional) the time you want it to be alive for before fading out + time: 7000 + }); + +} + +/** + * Show a successful unregistration message + */ +function showUnregistrationSuccess(){ + $.gritter.add({ + // (string | mandatory) the heading of the notification + title: window._('ep_email_notifications.titleGritterUnsubscr'), + // (string | mandatory) the text inside the notification + text: window._('ep_email_notifications.msgUnsubscrSuccess'), + // (int | optional) the time you want it to be alive for before fading out + time: 10000 + }); +} + +/** + * The client wasn't registered for emails + */ +function showWasNotRegistered(){ + $.gritter.add({ + // (string | mandatory) the heading of the notification + title: window._('ep_email_notifications.titleGritterUnsubscr'), + // (string | mandatory) the text inside the notification + text: window._('ep_email_notifications.msgUnsubscrNotExisting'), + // (int | optional) the time you want it to be alive for before fading out + time: 7000 + }); + +} diff --git a/templates/button.html b/templates/button.html deleted file mode 100644 index e69de29..0000000 diff --git a/templates/email_notifications_settings.ejs b/templates/email_notifications_settings.ejs new file mode 100644 index 0000000..a26aef1 --- /dev/null +++ b/templates/email_notifications_settings.ejs @@ -0,0 +1,21 @@ ++ + +