Merge pull request from codecombat/feature/mail-system

Feature/mail system
This commit is contained in:
Michael Schmatz 2014-07-17 07:18:39 -07:00
commit ccb2ad67ac
9 changed files with 415 additions and 6 deletions

View file

@ -0,0 +1,17 @@
c = require './../schemas'
#This will represent transactional emails which have been sent
MailSentSchema = c.object {
title: 'Sent mail'
description: 'Emails which have been sent through the system'
}
_.extend MailSentSchema.properties,
mailTask: c.objectId {}
user: c.objectId links: [{rel: 'extra', href: '/db/user/{($)}'}]
sent: c.date title: 'Sent', readOnly: true
metadata: c.object {}, {}
c.extendBasicProperties MailSentSchema, 'mail.sent'
module.exports = MailSentSchema

View file

@ -59,7 +59,7 @@
"express-useragent": "~0.0.9",
"gridfs-stream": "0.4.x",
"stream-buffers": "0.2.x",
"sendwithus": "2.0.x",
"sendwithus": "2.1.x",
"aws-sdk": "~2.0.0",
"bayesian-battle": "0.0.x",
"redis": "",

View file

@ -0,0 +1,43 @@
config = require '../../server_config'
redis = require 'redis'
log = require 'winston'
class LockManager
constructor: ->
unless config.isProduction
throw "You shouldn't be instantiating distributed locks unless in production."
@redisNotAvailable = true
@redisClient = redis.createClient config.redis.port, config.redis.host
@redisClient.on "ready", =>
log.info "Redis ready!"
@redisNotAvailable = false
@redisClient.on "error", (err) =>
@redisNotAvailable = true
log.error "Redis connection error! Err: #{err}"
@redisClient.on "end", =>
@redisNotAvailable = true
log.error "Redis connection ended!"
@lockValues = {}
@unlockScript = "if redis.call(\"get\",KEYS[1]) == ARGV[1] then return redis.call(\"del\",KEYS[1]) else return 0 end"
setLock: (lockName, timeoutMs, cb) =>
if @redisNotAvailable is true then return cb "Redis not available!"
randomNumber = Math.floor(Math.random() * 1000000000)
@redisClient.set [lockName,randomNumber, "NX", "PX", timeoutMs], (err, res) =>
if err? then return cb err, null
if res is "OK"
@lockValues[lockName] = randomNumber
return cb null, "Lock set!"
unless res
return cb "Lock already set!", null
releaseLock: (lockName, cb) =>
if @redisNotAvailable is true then return cb "Redis not available!"
@redisClient.eval [@unlockScript, 1, lockName, @lockValues[lockName]], (err, res) ->
if err? then return cb err, null
if res
cb null, "The lock was released!"
else
cb "The lock was not released.", null
module.exports = new LockManager()

View file

@ -9,6 +9,7 @@ module.exports.handlers =
'thang_type': 'levels/thangs/thang_type_handler'
'user': 'users/user_handler'
'user_remark': 'users/remarks/user_remark_handler'
'mail_sent': 'mail/sent/mail_sent_handler'
'achievement': 'achievements/achievement_handler'
'earned_achievement': 'achievements/earned_achievement_handler'

View file

@ -0,0 +1,11 @@
mongoose = require 'mongoose'
plugins = require '../../plugins/plugins'
jsonschema = require '../../../app/schemas/models/mail_sent'
MailSent = new mongoose.Schema({
sent:
type: Date
'default': Date.now
}, {strict: false})
module.exports = MailSent = mongoose.model('mail.sent', MailSent)

View file

@ -0,0 +1,12 @@
MailSent = require './MailSent'
Handler = require '../../commons/Handler'
class MailSentHandler extends Handler
modelClass: MailSent
editableProperties: ['mailTask','user','sent']
jsonSchema: require '../../../app/schemas/models/mail_sent'
hasAccess: (req) ->
req.user?.isAdmin()
module.exports = new MailSentHandler()

View file

@ -117,7 +117,8 @@ module.exports.setup = (app) ->
)
)
app.get '/auth/unsubscribe', (req, res) ->
app.get '/auth/unsubscribe', (req, res) ->
req.query.email = decodeURIComponent(req.query.email)
email = req.query.email
unless req.query.email
return errors.badInput res, 'No email provided to unsubscribe.'
@ -131,7 +132,7 @@ module.exports.setup = (app) ->
return errors.serverError res, 'Database failure.' if err
res.send "Unsubscribed #{req.query.email} from CodeCombat emails for #{session.levelName} #{session.team} ladder updates. Sorry to see you go! <p><a href='/play/ladder/#{session.levelID}#my-matches'>Ladder preferences</a></p>"
res.end()
User.findOne({emailLower: req.query.email.toLowerCase()}).exec (err, user) ->
if not user
return errors.notFound res, "No user found with email '#{req.query.email}'"
@ -143,7 +144,11 @@ module.exports.setup = (app) ->
emails.recruitNotes ?= {}
emails.recruitNotes.enabled = false
msg = "Unsubscribed #{req.query.email} from recruiting emails."
else if req.query.employerNotes
emails.employerNotes ?= {}
emails.employerNotes.enabled = false
msg = "Unsubscribed #{req.query.email} from employer emails."
else
msg = "Unsubscribed #{req.query.email} from all CodeCombat emails. Sorry to see you go!"
emailSettings.enabled = false for emailSettings in _.values(emails)

View file

@ -1,17 +1,330 @@
mail = require '../commons/mail'
MailSent = require '../mail/sent/MailSent'
User = require '../users/User'
async = require 'async'
errors = require '../commons/errors'
config = require '../../server_config'
LevelSession = require '../levels/sessions/LevelSession'
Level = require '../levels/Level'
log = require 'winston'
sendwithus = require '../sendwithus'
if config.isProduction
lockManager = require '../commons/LockManager'
module.exports.setup = (app) ->
app.all config.mail.mailchimpWebhook, handleMailchimpWebHook
app.get '/mail/cron/ladder-update', handleLadderUpdate
if lockManager
setupScheduledEmails()
setupScheduledEmails = ->
testForLockManager()
mailTasks = [
taskFunction: candidateUpdateProfileTask
frequencyMs: 10 * 60 * 1000 #10 minutes
,
taskFunction: internalCandidateUpdateTask
frequencyMs: 10 * 60 * 1000 #10 minutes
,
taskFunction: employerNewCandidatesAvailableTask
frequencyMs: 10 * 60 * 1000 #10 minutes
]
for mailTask in mailTasks
setInterval mailTask.taskFunction, mailTask.frequencyMs
testForLockManager = -> unless lockManager then throw "The system isn't configured to do distributed locking!"
### Candidate Update Reminder Task ###
candidateUpdateProfileTask = ->
mailTaskName = "candidateUpdateProfileTask"
lockDurationMs = 2 * 60 * 1000
currentDate = new Date()
timeRanges = []
for weekPair in [[4, 2,'two weeks'], [8, 4, 'four weeks'], [8, 52, 'eight weeks']]
timeRanges.push
start: generateWeekOffset currentDate, weekPair[0]
end: generateWeekOffset currentDate, weekPair[1]
name: weekPair[2]
lockManager.setLock mailTaskName, lockDurationMs, (err) ->
if err? then return log.error "Error getting a distributed lock for task #{mailTaskName}: #{err}"
async.each timeRanges, emailTimeRange.bind({mailTaskName: mailTaskName}), (err) ->
if err
log.error "There was an error sending the candidate profile update reminder emails: #{err}"
else
log.info "Completed mail task #{mailTaskName}"
lockManager.releaseLock mailTaskName, (err) ->
if err? then return log.error "There was an error releasing the distributed lock for task #{mailTaskName}: #{err}"
generateWeekOffset = (originalDate, numberOfWeeks) ->
return (new Date(originalDate.getTime() - numberOfWeeks * 7 * 24 * 60 * 60 * 1000)).toISOString()
emailTimeRange = (timeRange, emailTimeRangeCallback) ->
waterfallContext =
"timeRange": timeRange
"mailTaskName": @mailTaskName
async.waterfall [
findAllCandidatesWithinTimeRange.bind(waterfallContext)
(unfilteredCandidates, cb) ->
async.reject unfilteredCandidates, candidateFilter.bind(waterfallContext), cb.bind(null, null)
(filteredCandidates, cb) ->
async.each filteredCandidates, sendReminderEmailToCandidate.bind(waterfallContext), cb
], emailTimeRangeCallback
findAllCandidatesWithinTimeRange = (cb) ->
findParameters =
"jobProfile.updated":
$gt: @timeRange.start
$lte: @timeRange.end
"jobProfileApproved": true
selection = "_id email jobProfile.name jobProfile.updated emails" #make sure to check for anyNotes too.
User.find(findParameters).select(selection).lean().exec cb
candidateFilter = (candidate, sentEmailFilterCallback) ->
if candidate.emails?.anyNotes?.enabled is false or candidate.emails?.recruitNotes?.enabled is false
return sentEmailFilterCallback true
findParameters =
"user": candidate._id
"mailTask": @mailTaskName
"metadata.timeRangeName": @timeRange.name
"metadata.updated": candidate.jobProfile.updated
MailSent.find(findParameters).lean().exec (err, sentMail) ->
if err?
log.error "Error finding mail sent for task #{@mailTaskName} and user #{candidate._id}!"
sentEmailFilterCallback true
else
sentEmailFilterCallback Boolean(sentMail.length)
findEmployersSignedUpAfterDate = (dateObject, cb) ->
countParameters =
$or: [{"dateCreated": {$gte: dateObject}},{"signedEmployerAgreement":{$gte: dateObject}}]
employerAt: {$exists: true}
permissions: "employer"
User.count countParameters, cb
sendReminderEmailToCandidate = (candidate, sendEmailCallback) ->
findEmployersSignedUpAfterDate new Date(candidate.jobProfile.updated), (err, employersAfterCount) =>
if err?
log.error "There was an error finding employers who signed up after #{candidate.jobProfile.updated}: #{err}"
return sendEmailCallback err
if employersAfterCount < 2
employersAfterCount = 2
context =
email_id: "tem_CtTLsKQufxrxoPMn7upKiL"
recipient:
address: candidate.email
name: candidate.jobProfile.name
email_data:
new_company: employersAfterCount
company_name: "CodeCombat"
user_profile: "http://codecombat.com/account/profile/#{candidate._id}"
recipient_address: encodeURIComponent(candidate.email)
log.info "Sending #{@timeRange.name} update reminder to #{context.recipient.name}(#{context.recipient.address})"
newSentMail =
mailTask: @mailTaskName
user: candidate._id
metadata:
timeRangeName: @timeRange.name
updated: candidate.jobProfile.updated
MailSent.create newSentMail, (err) ->
if err? then return sendEmailCallback err
sendwithus.api.send context, (err, result) ->
log.error "Error sending candidate update reminder email: #{err} with result #{result}" if err
sendEmailCallback null
### End Candidate Update Reminder Task ###
### Internal Candidate Update Reminder Email ###
internalCandidateUpdateTask = ->
mailTaskName = "internalCandidateUpdateTask"
lockDurationMs = 2 * 60 * 1000
lockManager.setLock mailTaskName, lockDurationMs, (err) ->
if err? then return log.error "Error getting a distributed lock for task #{mailTaskName}: #{err}"
emailInternalCandidateUpdateReminder.call {"mailTaskName":mailTaskName}, (err) ->
if err
log.error "There was an error sending the internal candidate update reminder.: #{err}"
else
log.info "Sent internal candidate update reminder email!"
lockManager.releaseLock mailTaskName, (err) ->
if err? then return log.error "There was an error releasing the distributed lock for task #{mailTaskName}: #{err}"
emailInternalCandidateUpdateReminder = (internalCandidateUpdateReminderCallback) ->
currentTime = new Date()
beginningOfUTCDay = new Date()
beginningOfUTCDay.setUTCHours(0,0,0,0)
asyncContext =
"beginningOfUTCDay": beginningOfUTCDay
"currentTime": currentTime
"mailTaskName": @mailTaskName
async.waterfall [
findNonApprovedCandidatesWhoUpdatedJobProfileToday.bind(asyncContext)
(unfilteredCandidates, cb) ->
async.reject unfilteredCandidates, candidatesUpdatedTodayFilter.bind(asyncContext), cb.bind(null,null)
(filteredCandidates, cb) ->
async.each filteredCandidates, sendInternalCandidateUpdateReminder.bind(asyncContext), cb
], internalCandidateUpdateReminderCallback
findNonApprovedCandidatesWhoUpdatedJobProfileToday = (cb) ->
findParameters =
"jobProfile.updated":
$lte: @currentTime.toISOString()
$gt: @beginningOfUTCDay.toISOString()
"jobProfileApproved": false
User.find(findParameters).select("_id jobProfile.name jobProfile.updated").lean().exec cb
candidatesUpdatedTodayFilter = (candidate, cb) ->
findParameters =
"user": candidate._id
"mailTask": @mailTaskName
"metadata.beginningOfUTCDay": @beginningOfUTCDay
MailSent.find(findParameters).lean().exec (err, sentMail) ->
if err?
log.error "Error finding mail sent for task #{@mailTaskName} and user #{candidate._id}!"
cb true
else
cb Boolean(sentMail.length)
sendInternalCandidateUpdateReminder = (candidate, cb) ->
context =
email_id: "tem_Ac7nhgKqatTHBCgDgjF5pE"
recipient:
address: "team@codecombat.com"
name: "The CodeCombat Team"
email_data:
new_candidate_profile: "https://codecombat.com/account/profile/#{candidate._id}"
log.info "Sending candidate updated reminder for #{candidate.jobProfile.name}"
newSentMail =
mailTask: @mailTaskName
user: candidate._id
metadata:
beginningOfUTCDay: @beginningOfUTCDay
MailSent.create newSentMail, (err) ->
if err? then return cb err
sendwithus.api.send context, (err, result) ->
log.error "Error sending interal candidate update email: #{err} with result #{result}" if err
cb null
### End Internal Candidate Update Reminder Email ###
### Employer New Candidates Available Email ###
employerNewCandidatesAvailableTask = ->
mailTaskName = "employerNewCandidatesAvailableTask"
lockDurationMs = 2 * 60 * 1000
lockManager.setLock mailTaskName, lockDurationMs, (err) ->
if err? then return log.error "Error getting a distributed lock for task #{mailTaskName}: #{err}"
emailEmployerNewCandidatesAvailable.call {"mailTaskName":mailTaskName}, (err) ->
if err
log.error "There was an error completing the new candidates available task: #{err}"
else
log.info "Completed the employer new candidates available task!"
lockManager.releaseLock mailTaskName, (err) ->
if err? then return log.error "There was an error releasing the distributed lock for task #{mailTaskName}: #{err}"
emailEmployerNewCandidatesAvailable = (emailEmployerNewCandidatesAvailableCallback) ->
currentTime = new Date()
asyncContext =
"currentTime": currentTime
"mailTaskName": @mailTaskName
async.waterfall [
findAllEmployers
makeEmployerNamesEasilyAccessible
(allEmployers, cb) ->
async.reject allEmployers, employersEmailedDigestMoreThanWeekAgoFilter.bind(asyncContext), cb.bind(null,null)
(employersToEmail, cb) ->
async.each employersToEmail, sendEmployerNewCandidatesAvailableEmail.bind(asyncContext), cb
], emailEmployerNewCandidatesAvailableCallback
findAllEmployers = (cb) ->
findParameters =
"employerAt":
$exists: true
permissions: "employer"
selection = "_id email employerAt signedEmployerAgreement.data.firstName signedEmployerAgreement.data.lastName activity dateCreated emails"
User.find(findParameters).select(selection).lean().exec cb
makeEmployerNamesEasilyAccessible = (allEmployers, cb) ->
for employer, index in allEmployers
if employer.signedEmployerAgreement?.data?.firstName
employer.name = employer.signedEmployerAgreement.data.firstName + " " + employer.signedEmployerAgreement.data.lastName
delete employer.signedEmployerAgreement
allEmployers[index] = employer
cb null, allEmployers
employersEmailedDigestMoreThanWeekAgoFilter = (employer, cb) ->
if employer.emails?.employerNotes?.enabled is false
return sentEmailFilterCallback true
findParameters =
"user": employer._id
"mailTask": @mailTaskName
"sent":
$gt: new Date(@currentTime.getTime() - 7 * 24 * 60 * 60 * 1000)
MailSent.find(findParameters).lean().exec (err, sentMail) ->
if err?
log.error "Error finding mail sent for task #{@mailTaskName} and employer #employer._id}!"
cb true
else
cb Boolean(sentMail.length)
sendEmployerNewCandidatesAvailableEmail = (employer, cb) ->
lastLoginDate = employer.activity?.login?.last ? employer.dateCreated
countParameters =
"jobProfileApproved": true
$or: [
jobProfileApprovedDate:
$gt: lastLoginDate.toISOString()
,
jobProfileApprovedDate:
$exists: false
"jobProfile.updated":
$gt: lastLoginDate.toISOString()
]
User.count countParameters, (err, numberOfCandidatesSinceLogin) =>
if err? then return cb err
context =
email_id: "tem_CCcHKr95Nvu5bT7c7iHCtm"
recipient:
address: employer.email
email_data:
new_candidates: numberOfCandidatesSinceLogin
employer_company_name: employer.employerAt
company_name: "CodeCombat"
recipient_address: encodeURIComponent(employer.email)
if employer.name
context.recipient.name = employer.name
log.info "Sending available candidates update reminder to #{context.recipient.name}(#{context.recipient.address})"
newSentMail =
mailTask: @mailTaskName
user: employer._id
MailSent.create newSentMail, (err) ->
if err? then return cb err
sendwithus.api.send context, (err, result) ->
log.error "Error sending employer candidates available email: #{err} with result #{result}" if err
cb null
### End Employer New Candidates Available Email ###
### New Recruit Leaderboard Email ###
###
newRecruitLeaderboardEmailTask = ->
# tem_kMQFCKX3v4DNAQDsMAsPJC
#maxRank and maxRankTime should be recorded if isSimulating is false
mailTaskName = "newRecruitLeaderboardEmailTask"
lockDurationMs = 6000
lockManager.setLock mailTaskName, lockDurationMs, (err, lockResult) ->
###
### End New Recruit Leaderboard Email ###
### Employer Matching Candidate Notification Email ###
###
employerMatchingCandidateNotificationTask = ->
# tem_mYsepTfWQ265noKfZJcbBH
#save email filters in their own collection
mailTaskName = "employerMatchingCandidateNotificationTask"
lockDurationMs = 6000
lockManager.setLock mailTaskName, lockDurationMs, (err, lockResult) ->
###
### End Employer Matching Candidate Notification Email ###
### Ladder Update Email ###
DEBUGGING = false
LADDER_PREGAME_INTERVAL = 2 * 3600 * 1000 # Send emails two hours before players last submitted.
@ -28,6 +341,8 @@ isRequestFromDesignatedCronHandler = (req, res) ->
return false
return true
handleLadderUpdate = (req, res) ->
log.info('Going to see about sending ladder update emails.')
requestIsFromDesignatedCronHandler = isRequestFromDesignatedCronHandler req, res
@ -151,6 +466,7 @@ getScoreHistoryGraphURL = (session, daysAgo) ->
chartData = times.join(',') + '|' + scores.join(',')
"https://chart.googleapis.com/chart?chs=600x75&cht=lxy&chtt=Score%3A+#{currentScore}&chts=222222,12,r&chf=a,s,000000FF&chls=2&chd=t:#{chartData}&chxt=y&chxr=0,#{minScore},#{maxScore}"
### End Ladder Update Email ###
handleMailchimpWebHook = (req, res) ->
post = req.body

View file

@ -12,6 +12,10 @@ config.mongo =
host: process.env.COCO_MONGO_HOST or 'localhost'
db: process.env.COCO_MONGO_DATABASE_NAME or 'coco'
mongoose_replica_string: process.env.COCO_MONGO_MONGOOSE_REPLICA_STRING or ''
config.redis =
port: process.env.COCO_REDIS_PORT or 6379
host: process.env.COCO_REDIS_HOST or 'localhost'
if config.unittest
config.port += 1