Merge pull request #4611 from HabitRPG/paglias/email-notifications

feat(email-notifications): implement email notifications for events
This commit is contained in:
Matteo Pagliazzi 2015-02-02 17:02:22 +01:00
commit ea04ebac4e
11 changed files with 240 additions and 52 deletions

View file

@ -213,6 +213,10 @@ window.habitrpg = angular.module('habitrpg',
url: "/subscription",
templateUrl: "partials/options.settings.subscription.html"
})
.state('options.settings.notifications', {
url: "/notifications",
templateUrl: "partials/options.settings.notifications.html"
})
var settings = JSON.parse(localStorage.getItem(STORAGE_SETTINGS_ID));
if (settings && settings.auth) {

View file

@ -103,7 +103,7 @@ api.registerUser = function(req, res, next) {
}
user.save(cb);
if(isProd) utils.txnEmail({name:username, email:email}, 'welcome');
utils.txnEmail(user, 'welcome');
ga.event('register', 'Local').send()
}
], function(err, saved) {
@ -173,9 +173,7 @@ api.loginSocial = function(req, res, next) {
user = new User(user);
user.save(cb);
if (isProd && prof.emails && prof.emails[0] && prof.emails[0].value) {
utils.txnEmail({name: prof.displayName || prof.username, email: prof.emails[0].value}, 'welcome');
}
utils.txnEmail(user, 'welcome');
ga.event('register', network).send();
}]
}, function(err, results){

View file

@ -9,6 +9,7 @@ var Group = require('./../models/group').model;
var Challenge = require('./../models/challenge').model;
var logging = require('./../logging');
var csv = require('express-csv');
var utils = require('../utils');
var api = module.exports;
@ -335,6 +336,11 @@ api.selectWinner = function(req, res, next) {
winner.save(cb);
},
function(saved, num, cb) {
if(saved.preferences.emailNotifications.wonChallenge !== false){
utils.txnEmail(saved, 'won-challenge', [
{name: 'CHALLENGE_NAME', content: chal.name}
]);
}
closeChal(cid, {broken: 'CHALLENGE_CLOSED', winner: saved.profile.name}, cb);
}
], function(err){

View file

@ -301,13 +301,11 @@ api.flagChatMessage = function(req, res, next){
group.markModified('chat');
group.save(function(err,_saved){
if(err) return next(err);
if (isProd){
var addressesToSendTo = JSON.parse(nconf.get('FLAG_REPORT_EMAIL'));
if(Array.isArray(addressesToSendTo)){
addressesToSendTo = addressesToSendTo.map(function(email){
return {email: email}
return {email: email, canSend: true}
});
}else{
addressesToSendTo = {email: addressesToSendTo}
@ -332,7 +330,7 @@ api.flagChatMessage = function(req, res, next){
{name: "GROUP_ID", content: group._id},
{name: "GROUP_URL", content: group._id == 'habitrpg' ? (nconf.get('BASE_URL') + '/#/options/groups/tavern') : (group.type === 'guild' ? (nconf.get('BASE_URL')+ '/#/options/groups/guilds/' + group._id) : 'party')},
]);
}
return res.send(204);
});
});
@ -556,6 +554,26 @@ api.invite = function(req, res, next) {
], function(err, results){
if (err) return next(err);
if(invite.preferences.emailNotifications['invited' + (group.type == 'guild' ? 'Guild' : 'Party')] !== false){
var emailVars = [
{name: 'INVITER', content: utils.getUserInfo(res.locals.user, ['name']).name}
];
if(group.type == 'guild'){
emailVars.push(
{name: 'GUILD_NAME', content: group.name},
{name: 'GUILD_URL', content: nconf.get('BASE_URL') + '/#/options/groups/guilds/' + group._id}
);
}else{
emailVars.push(
{name: 'PARTY_NAME', content: group.name},
{name: 'PARTY_URL', content: nconf.get('BASE_URL') + '/#/options/groups/party'}
)
}
utils.txnEmail(invite, ('invited-' + group.type == 'guild' ? 'guild' : 'party'), emailVars);
}
// Have to return whole group and its members for angular to show the invited user
res.json(results[2]);
group = uuid = null;
@ -629,7 +647,7 @@ questStart = function(req, res, next) {
var group = res.locals.group;
var force = req.query.force;
// if (group.quest.active) return res.json(400,{err:'Quest already began.'});
// if (group.quest.active) return res.json(400,{err:'Quest already began.'});
// temporarily send error email, until we know more about this issue (then remove below, uncomment above).
if (group.quest.active) return next('Quest already began.');
@ -720,8 +738,9 @@ api.questAccept = function(req, res, next) {
if (m == user._id) {
group.quest.members[m] = true;
group.quest.leader = user._id;
} else
} else {
group.quest.members[m] = undefined;
}
});
// Party member accepting the invitation

View file

@ -5,6 +5,8 @@ var api = module.exports;
var async = require('async');
var _ = require('lodash');
var shared = require('habitrpg-shared');
var utils = require('../utils');
var nconf = require('nconf');
var fetchMember = function(uuid, restrict){
return function(cb){
@ -48,9 +50,11 @@ api.sendMessage = function(user, member, data){
}
api.sendPrivateMessage = function(req, res, next){
var fetchedMember;
async.waterfall([
fetchMember(req.params.uuid),
function(member, cb) {
fetchedMember = member;
if (~member.inbox.blocks.indexOf(res.locals.user._id) // can't send message if that user blocked me
|| ~res.locals.user.inbox.blocks.indexOf(member._id) // or if I blocked them
|| member.inbox.optOut) { // or if they've opted out of messaging
@ -64,6 +68,14 @@ api.sendPrivateMessage = function(req, res, next){
}
], function(err){
if (err) return sendErr(err, res, next);
if(fetchedMember.preferences.emailNotifications.newPM !== false){
utils.txnEmail(fetchedMember, 'new-pm', [
{name: 'SENDER', content: utils.getUserInfo(res.locals.user, ['name']).name},
{name: 'PMS_INBOX_URL', content: nconf.get('BASE_URL') + '/#/options/groups/inbox'}
]);
}
res.send(200);
})
}
@ -84,6 +96,12 @@ api.sendGift = function(req, res, next){
member.balance += amt;
user.balance -= amt;
api.sendMessage(user, member, req.body);
if(member.preferences.emailNotifications.giftedGems !== false){
utils.txnEmail(member, 'gifted-gems', [
{name: 'GIFTER', content: utils.getUserInfo(user, ['name']).name},
{name: 'X_GEMS_GIFTED', content: amt}
]);
}
return async.parallel([
function (cb2) { member.save(cb2) },
function (cb2) { user.save(cb2) }

View file

@ -73,7 +73,15 @@ exports.createSubscription = function(data, cb) {
utils.ga.transaction(data.user._id, block.price).item(block.price, 1, data.paymentMethod.toLowerCase() + '-subscription', data.paymentMethod).send();
}
data.user.purchased.txnCount++;
if (data.gift) members.sendMessage(data.user, data.gift.member, data.gift);
if (data.gift){
members.sendMessage(data.user, data.gift.member, data.gift);
if(data.gift.member.preferences.emailNotifications.giftedSubscription !== false){
utils.txnEmail(member, 'gifted-subscription', [
{name: 'GIFTER', content: utils.getUserInfo(data.user, ['name']).name},
{name: 'X_MONTHS_SUBSCRIPTION', content: months}
]);
}
}
async.parallel([
function(cb2){data.user.save(cb2)},
function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);}
@ -96,7 +104,7 @@ exports.cancelSubscription = function(data, cb) {
p.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated
data.user.save(cb);
if(isProduction) utils.txnEmail(data.user, 'cancel-subscription');
utils.txnEmail(data.user, 'cancel-subscription');
utils.ga.event('unsubscribe', data.paymentMethod).send();
}
@ -110,7 +118,15 @@ exports.buyGems = function(data, cb) {
//TODO ga.transaction to reflect whether this is gift or self-purchase
utils.ga.transaction(data.user._id, amt).item(amt, 1, data.paymentMethod.toLowerCase() + "-checkout", "Gems > " + data.paymentMethod).send();
}
if (data.gift) members.sendMessage(data.user, data.gift.member, data.gift);
if (data.gift){
members.sendMessage(data.user, data.gift.member, data.gift);
if(data.gift.member.preferences.emailNotifications.giftedGems !== false){
utils.txnEmail(member, 'gifted-gems', [
{name: 'GIFTER', content: utils.getUserInfo(data.user, ['name']).name},
{name: 'X_GEMS_GIFTED', content: amt}
]);
}
}
async.parallel([
function(cb2){data.user.save(cb2)},
function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);}

View file

@ -437,16 +437,34 @@ api.cast = function(req, res, next) {
api.inviteFriends = function(req, res, next) {
Group.findOne({type:'party', members:{'$in': [res.locals.user._id]}}).select('_id name').exec(function(err,party){
if (err) return next(err);
var link = nconf.get('BASE_URL')+'?partyInvite='+ utils.encrypt(JSON.stringify({id:party._id, inviter:res.locals.user._id, name:party.name}));
_.each(req.body.emails, function(invite){
if (invite.email) {
var variables = [
{name: 'LINK', content: link},
{name: 'INVITER', content: req.body.inviter || res.locals.user.profile.name},
{name: 'INVITEE', content: invite.name}
];
// TODO implement "users can only be invited once"
utils.txnEmail(invite, 'invite-friend', variables);
User.findOne({$or: [
{'auth.local.email': invite.email},
{'auth.facebook.emails.value': invite.email}
]}).select({_id: true, 'preferences.emailNotifications': true})
.exec(function(err, userToContact){
if(err) return next(err);
var link = nconf.get('BASE_URL')+'?partyInvite='+ utils.encrypt(JSON.stringify({id:party._id, inviter:res.locals.user._id, name:party.name}));
var variables = [
{name: 'LINK', content: link},
{name: 'INVITER', content: req.body.inviter || utils.getUserInfo(res.locals.user, ['name']).name}
];
invite.canSend = true;
// We check for unsubscribeFromAll here because don't pass through utils.getUserInfo
if(userToContact.preferences.emailNotifications.invitedParty !== false &&
userToContact.preferences.emailNotifications.unsubscribeFromAll !== true){
// TODO implement "users can only be invited once"
utils.txnEmail(invite, 'invite-friend', variables);
}
});
}
});
res.send(200);

View file

@ -297,7 +297,20 @@ var UserSchema = new Schema({
advancedCollapsed: {type: Boolean, 'default': false},
toolbarCollapsed: {type:Boolean, 'default':false},
background: String,
webhooks: {type: Schema.Types.Mixed, 'default': {}}
webhooks: {type: Schema.Types.Mixed, 'default': {}},
// For this fields make sure to use strict comparison when searching for falsey values (=== false)
// As users who didn't login after these were introduced may have them undefined/null
emailNotifications: {
unsubscribeFromAll: {type: Boolean, 'default': false},
newPM: {type: Boolean, 'default': true},
wonChallenge: {type: Boolean, 'default': true},
giftedGems: {type: Boolean, 'default': true},
giftedSubscription: {type: Boolean, 'default': true},
invitedParty: {type: Boolean, 'default': true},
invitedGuild: {type: Boolean, 'default': true},
//remindersToLogin: {type: Boolean, 'default': true},
importantAnnouncements: {type: Boolean, 'default': true}
}
},
profile: {
blurb: String,

View file

@ -4,6 +4,9 @@ var crypto = require('crypto');
var path = require("path");
var request = require('request');
// Set when utils.setupConfig is run
var isProd, baseUrl;
module.exports.ga = undefined; // set Google Analytics on nconf init
module.exports.sendEmail = function(mailData) {
@ -22,48 +25,75 @@ module.exports.sendEmail = function(mailData) {
});
}
function getMailingInfo(user) {
var email, name;
if(user.auth.local && user.auth.local.email){
email = user.auth.local.email;
name = user.profile.name || user.auth.local.username;
}else if(user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value){
email = user.auth.facebook.emails[0].value;
name = user.auth.facebook.displayName || user.auth.facebook.username;
function getUserInfo(user, fields) {
var info = {};
if(fields.indexOf('name') != -1){
if(user.auth.local){
info.name = user.profile.name || user.auth.local.username;
}else if(user.auth.facebook){
info.name = user.auth.facebook.displayName || user.auth.facebook.username;
}
}
return {email: email, name: name};
if(fields.indexOf('email') != -1){
if(user.auth.local){
info.email = user.auth.local.email;
}else if(user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value){
info.email = user.auth.facebook.emails[0].value;
}
}
if(fields.indexOf('canSend') != -1){
info.canSend = user.preferences.emailNotifications.unsubscribeFromAll !== true;
}
return info;
}
module.exports.getUserInfo = getUserInfo;
module.exports.txnEmail = function(mailingInfoArray, emailType, variables){
var variables = [{name: 'BASE_URL', content: nconf.get('BASE_URL')}].concat(variables || []);
var mailingInfoArray = Array.isArray(mailingInfoArray) ? mailingInfoArray : [mailingInfoArray];
var variables = [
{name: 'BASE_URL', content: baseUrl},
{name: 'EMAIL_SETTINGS_URL', content: baseUrl + '/#/options/settings/notifications'}
].concat(variables || []);
// It's important to pass at least a user with its `preferences` as we need to check if he unsubscribed
mailingInfoArray = mailingInfoArray.map(function(mailingInfo){
return mailingInfo._id ? getMailingInfo(mailingInfo) : mailingInfo;
return mailingInfo._id ? getUserInfo(mailingInfo, ['email', 'name', 'canSend']) : mailingInfo;
}).filter(function(mailingInfo){
return mailingInfo.email ? true : false;
return (mailingInfo.email && mailingInfo.canSend);
});
request({
url: nconf.get('EMAIL_SERVER:url') + '/job',
method: 'POST',
auth: {
user: nconf.get('EMAIL_SERVER:authUser'),
pass: nconf.get('EMAIL_SERVER:authPassword')
},
json: {
type: 'email',
data: {
emailType: emailType,
to: mailingInfoArray,
variables: variables
// When only one recipient send his info as variables
if(mailingInfoArray.length === 1 && mailingInfoArray[0].name){
variables.push({name: 'RECIPIENT_NAME', content: mailingInfoArray[0].name});
}
if(isProd && mailingInfoArray.length > 0){
request({
url: nconf.get('EMAIL_SERVER:url') + '/job',
method: 'POST',
auth: {
user: nconf.get('EMAIL_SERVER:authUser'),
pass: nconf.get('EMAIL_SERVER:authPassword')
},
options: {
attemps: 5,
backoff: {delay: 10*60*1000, type: 'fixed'}
json: {
type: 'email',
data: {
emailType: emailType,
to: mailingInfoArray,
variables: variables
},
options: {
attemps: 5,
backoff: {delay: 10*60*1000, type: 'fixed'}
}
}
}
});
});
}
}
// Encryption using http://dailyjs.com/2010/12/06/node-tutorial-5/
@ -93,6 +123,9 @@ module.exports.setupConfig = function(){
if (nconf.get('NODE_ENV') === 'production')
require('newrelic');
isProd = nconf.get('NODE_ENV') === 'production';
baseUrl = nconf.get('BASE_URL');
module.exports.ga = require('universal-analytics')(nconf.get('GA_ID'));
};

View file

@ -14,6 +14,8 @@ script(id='partials/options.settings.html', type="text/ng-template")
=env.t('coupon')
li(ng-class="{ active: $state.includes('options.settings.subscription') }")
a(ui-sref='options.settings.subscription')=env.t('subscription')
li(ng-class="{ active: $state.includes('options.settings.notifications') }")
a(ui-sref='options.settings.notifications')=env.t('notifications')
.tab-content
.tab-pane.active
@ -84,6 +86,7 @@ script(type='text/ng-template', id='partials/options.settings.settings.html')
| 
=env.t('subWarning3')
.personal-options.col-md-6
.panel.panel-default
.panel-heading
span Registration
@ -242,6 +245,64 @@ script(id='partials/feature-matrix-check.html',type='text/ng-template')
input.focusable(type='checkbox', checked)
label
script(id='partials/options.settings.notifications.html', type="text/ng-template")
.container-fluid
.row
.personal-options.col-md-6
.panel.panel-default
.panel-heading
=env.t('emailNotifications')
.panel-body
.checkbox
label
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.newPM', ng-change='set({"preferences.emailNotifications.newPM": user.preferences.emailNotifications.newPM ? true: false})')
span=env.t('newPM')
.checkbox
label
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.wonChallenge', ng-change='set({"preferences.emailNotifications.wonChallenge": user.preferences.emailNotifications.wonChallenge ? true: false})')
span=env.t('wonChallenge')
.checkbox
label
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.giftedGems', ng-change='set({"preferences.emailNotifications.giftedGems": user.preferences.emailNotifications.giftedGems ? true: false})')
span=env.t('giftedGems')
.checkbox
label
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.giftedSubscription', ng-change='set({"preferences.emailNotifications.giftedSubscription": user.preferences.emailNotifications.giftedSubscription ? true: false})')
span=env.t('giftedSubscription')
.checkbox
label
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.invitedParty', ng-change='set({"preferences.emailNotifications.invitedParty": user.preferences.emailNotifications.invitedParty ? true: false})')
span=env.t('invitedParty')
.checkbox
label
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.invitedGuild', ng-change='set({"preferences.emailNotifications.invitedGuild": user.preferences.emailNotifications.invitedGuild ? true: false})')
span=env.t('invitedGuild')
//.checkbox
label
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.remindersToLogin', ng-change='set({"preferences.emailNotifications.remindersToLogin": user.preferences.emailNotifications.remindersToLogin ? true: false})')
span=env.t('remindersToLogin')
.checkbox
label
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.importantAnnouncements', ng-change='set({"preferences.emailNotifications.importantAnnouncements": user.preferences.emailNotifications.importantAnnouncements ? true: false})')
span=env.t('importantAnnouncements')
hr
.checkbox
label
input(type='checkbox', ng-model='user.preferences.emailNotifications.unsubscribeFromAll', ng-change='set({"preferences.emailNotifications.unsubscribeFromAll": user.preferences.emailNotifications.unsubscribeFromAll ? true: false})')
span=env.t('unsubscribeAllEmails')
small=env.t('unsubscribeAllEmailsText')
script(id='partials/options.settings.subscription.html',type='text/ng-template')
//-h2=env.t('individualSub')
.container-fluid(ng-init='_subscription={key:"basic_earned"}')

View file

@ -242,6 +242,8 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}')
a(ui-sref='options.settings.coupon') Coupon
li
a(ui-sref='options.settings.subscription')=env.t('subscription')
li
a(ui-sref='options.settings.notifications')=env.t('notifications')
ul.toolbar-submenu(ng-click='expandMenu(null)')
li
a(href="http://habitrpg.wikia.com/wiki/FAQ", target='_blank')=env.t('FAQ')