Skip to content
This repository was archived by the owner on Apr 3, 2019. It is now read-only.

Commit 296f152

Browse files
Shane Tomlinsonvbudhram
authored andcommitted
feat(scripts): Add a bulk mailer
1 parent 493f917 commit 296f152

File tree

3 files changed

+349
-0
lines changed

3 files changed

+349
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"grunt-nsp": "2.1.2",
6666
"hawk": "2.3.1",
6767
"jws": "3.0.0",
68+
"leftpad": "0.0.0",
6869
"load-grunt-tasks": "3.1.0",
6970
"mailparser": "0.5.1",
7071
"nock": "1.7.1",

scripts/bulk-mailer.js

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
#!/usr/bin/env node
2+
3+
/* This Source Code Form is subject to the terms of the Mozilla Public
4+
* License, v. 2.0. If a copy of the MPL was not distributed with this
5+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6+
7+
var commandLineOptions = require('commander')
8+
var config = require('../config').getProperties()
9+
var fs = require('fs')
10+
var leftpad = require('leftpad')
11+
var log = require('../lib/log')(config.log.level, 'bulk-mailer')
12+
var Mailer = require('fxa-auth-mailer')
13+
var nodeMailerMock = require('./bulk-mailer/nodemailer-mock')
14+
var P = require('bluebird')
15+
var path = require('path')
16+
17+
commandLineOptions
18+
.option('-b, --batchsize [size]', 'Number of emails to send in a batch. Defaults to 10', parseInt)
19+
.option('-d, --delay [seconds]', 'Delay in seconds between batches. Defaults to 5', parseInt)
20+
.option('-e, --errors <filename>', 'JSON output file that contains errored emails')
21+
.option('-i, --input <filename>', 'JSON input file')
22+
.option('-t, --template <template>', 'Template filename to render')
23+
.option('-u, --unsent <filename>', 'JSON output file that contains emails that were not sent')
24+
.option('-w, --write [directory]', 'Directory where emails should be stored')
25+
.option('--real', 'Use real email addresses, fake ones are used by default')
26+
.option('--send', 'Send emails, for real. *** THIS REALLY SENDS ***')
27+
.parse(process.argv)
28+
29+
var BATCH_DELAY = typeof commandLineOptions.delay === "undefined" ? 5 : commandLineOptions.delay
30+
var BATCH_SIZE = commandLineOptions.batchsize || 10
31+
32+
var requiredOptions = [
33+
'errors',
34+
'input',
35+
'template',
36+
'unsent'
37+
]
38+
39+
requiredOptions.forEach(checkRequiredOption)
40+
41+
var mailer;
42+
var mailerFunctionName = templateToMailerFunctionName(commandLineOptions.template)
43+
44+
var currentBatch = []
45+
var emailQueue = []
46+
var errorCount = 0
47+
var runningError
48+
var successCount = 0
49+
50+
P.resolve()
51+
.then(createMailer)
52+
.then(function (_mailer) {
53+
mailer = _mailer
54+
55+
if (! mailer[mailerFunctionName]) {
56+
console.error(commandLineOptions.template, 'is not a valid template')
57+
process.exit(1)
58+
}
59+
})
60+
.then(readRecords)
61+
.then(normalizeRecords)
62+
.then(function (normalizedRecords) {
63+
emailQueue = normalizedRecords
64+
65+
log.info({
66+
op: 'send.begin',
67+
count: emailQueue.length,
68+
test: ! commandLineOptions.send
69+
})
70+
})
71+
.then(nextBatch)
72+
.then(function () {
73+
log.info({
74+
op: 'send.complete',
75+
count: errorCount + successCount,
76+
successCount: successCount,
77+
errorCount: errorCount
78+
})
79+
})
80+
.then(null, function (error) {
81+
console.error('error', error)
82+
log.error({
83+
op: 'send.abort',
84+
err: error
85+
})
86+
runningError = error
87+
})
88+
.then(writeErrors)
89+
.then(writeUnsent)
90+
.then(function () {
91+
console.log('bye bye!')
92+
process.exit(runningError ? 1 : 0)
93+
})
94+
95+
var fakeEmailCount = 0
96+
function normalizeRecords(records) {
97+
return records.filter(function (record) {
98+
// no email can be sent if the record does not contain an email
99+
return !! record.email
100+
}).map(function (record) {
101+
// real emails are replaced by fake emails by default.
102+
if (! commandLineOptions.real) {
103+
record.email = 'fake_email' + fakeEmailCount + '@fakedomain.com'
104+
fakeEmailCount++
105+
}
106+
107+
// The Chinese translations were handed to us as "zh" w/o a country
108+
// specified. We put these translations into "zh-cn", use "zh-cn" for
109+
// Taiwan as well.
110+
if (! record.acceptLanguage && record.locale) {
111+
record.acceptLanguage = record.locale.replace(/zh-tw/gi, 'zh-cn')
112+
}
113+
114+
if (! record.locations) {
115+
record.locations = []
116+
} else {
117+
var translator = mailer.translator(record.acceptLanguage)
118+
var language = translator.language
119+
120+
record.locations.forEach(function (location) {
121+
var timestamp = new Date(location.timestamp || location.date)
122+
location.timestamp = formatTimestamp(timestamp, record.acceptLanguage)
123+
124+
// first, try to generate a localized locality
125+
if (! location.location && location.citynames && location.countrynames) {
126+
var parts = [];
127+
128+
var localizedCityName = location.citynames[language]
129+
if (localizedCityName) {
130+
parts.push(localizedCityName)
131+
}
132+
133+
var localizedCountryName = location.countrynames[language]
134+
if (localizedCountryName) {
135+
parts.push(localizedCountryName)
136+
}
137+
138+
location.location = parts.join(', ')
139+
}
140+
141+
// if that can't be done, fall back to the english locality
142+
if (! location.location && location.locality) {
143+
location.location = location.locality
144+
}
145+
})
146+
}
147+
148+
return record
149+
})
150+
}
151+
152+
function nextBatch() {
153+
currentBatch = emailQueue.splice(0, BATCH_SIZE)
154+
155+
if (! currentBatch.length) {
156+
return
157+
}
158+
159+
return sendBatch(currentBatch)
160+
.then(function () {
161+
if (emailQueue.length) {
162+
return P.delay(BATCH_DELAY * 1000)
163+
.then(nextBatch)
164+
}
165+
})
166+
}
167+
168+
function sendBatch(batch) {
169+
return P.all(
170+
batch.map(function (emailConfig) {
171+
return mailer[mailerFunctionName](emailConfig)
172+
.then(function () {
173+
successCount++
174+
log.info({
175+
op: 'send.success',
176+
email: emailConfig.email
177+
})
178+
}, function (err) {
179+
handleEmailError(emailConfig, err)
180+
})
181+
})
182+
)
183+
}
184+
185+
var erroredEmailConfigs = []
186+
function handleEmailError(emailConfig, error) {
187+
errorCount++
188+
189+
emailConfig.error = String(error)
190+
erroredEmailConfigs.push(emailConfig)
191+
192+
log.error({
193+
op: 'send.error',
194+
email: emailConfig.email,
195+
error: error
196+
})
197+
}
198+
199+
// output format should be identical to the input format. This makes it
200+
// possible to use the error output as input to another test run.
201+
function writeErrors() {
202+
var outputFileName = path.resolve(commandLineOptions.errors)
203+
fs.writeFileSync(outputFileName, JSON.stringify(cleanEmailConfigsConfigs(erroredEmailConfigs), null, 2))
204+
}
205+
206+
function writeUnsent() {
207+
var outputFileName = path.resolve(commandLineOptions.unsent)
208+
209+
// consider all emails in the current batch +
210+
// all emails in the emailQueue as unsent.
211+
// If there was an error sending the current batch,
212+
// we aren't fully sure which are sent, and which aren't.
213+
var unsentEmails = [].concat(currentBatch).concat(emailQueue)
214+
215+
fs.writeFileSync(outputFileName, JSON.stringify(cleanEmailConfigsConfigs(unsentEmails), null, 2))
216+
}
217+
218+
function cleanEmailConfigsConfigs(erroredEmailConfigs) {
219+
return erroredEmailConfigs.map(function (emailConfig) {
220+
emailConfig.locations.forEach(function (location) {
221+
delete location.translator
222+
})
223+
return emailConfig
224+
})
225+
}
226+
227+
function camelize(str) {
228+
return str.replace(/_(.)/g,
229+
function(match, c) {
230+
return c.toUpperCase()
231+
}
232+
)
233+
}
234+
235+
function templateToMailerFunctionName(templateName) {
236+
return camelize(templateName) + 'Email'
237+
}
238+
239+
function createMailer () {
240+
var sender = commandLineOptions.send ? null : nodeMailerMock({
241+
failureRate: 0.01,
242+
outputDir: commandLineOptions.write ? path.resolve(commandLineOptions.write) : null
243+
})
244+
245+
var defaultLanguage = config.i18n.defaultLanguage
246+
247+
return Mailer(log, {
248+
locales: config.i18n.supportedLanguages,
249+
defaultLanguage: defaultLanguage,
250+
mail: config.smtp
251+
}, sender)
252+
}
253+
254+
function checkRequiredOption(optionName) {
255+
if (! commandLineOptions[optionName]) {
256+
console.error('--' + optionName + ' required')
257+
process.exit(1)
258+
}
259+
}
260+
261+
function readRecords() {
262+
var inputFileName = path.resolve(commandLineOptions.input)
263+
var fsStats
264+
try {
265+
fsStats = fs.statSync(inputFileName)
266+
} catch (e) {
267+
console.error(inputFileName, 'invalid filename')
268+
process.exit(1)
269+
}
270+
271+
if (! fsStats.isFile()) {
272+
console.error(inputFileName, 'is not a file')
273+
process.exit(1)
274+
}
275+
276+
var records = []
277+
try {
278+
records = require(inputFileName)
279+
} catch(e) {
280+
console.error(inputFileName, 'does not contain JSON')
281+
process.exit(1)
282+
}
283+
284+
if (! records.length) {
285+
console.error('uh oh, no emails found')
286+
process.exit(1)
287+
}
288+
289+
return records
290+
}
291+
292+
function formatTimestamp(timestamp, locale) {
293+
return timestamp.getUTCFullYear() + '-' + leftpad(timestamp.getUTCMonth(), 2) + '-' + leftpad(timestamp.getUTCDate(), 2) + ' @ ' + leftpad(timestamp.getUTCHours(), 2) + ':' + leftpad(timestamp.getUTCMinutes(), 2) + ' UTC'
294+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
var fs = require('fs')
6+
var path = require('path')
7+
8+
module.exports = function (config) {
9+
var messageId = 0;
10+
11+
function ensureOutputDirExists(outputDir) {
12+
var dirStats
13+
try {
14+
dirStats = fs.statSync(config.outputDir)
15+
} catch (e) {
16+
fs.mkdirSync(outputDir);
17+
return;
18+
}
19+
20+
if (! dirStats.isDirectory()) {
21+
console.error(outputDir + ' is not a directory');
22+
process.exit(1)
23+
}
24+
}
25+
26+
return {
27+
sendMail: function (emailConfig, callback) {
28+
if (config.outputDir) {
29+
30+
ensureOutputDirExists(config.outputDir)
31+
32+
var outputPath = path.join(config.outputDir, emailConfig.to)
33+
34+
var textPath = outputPath + '.txt';
35+
fs.writeFileSync(textPath, emailConfig.text)
36+
37+
var htmlPath = outputPath + '.html'
38+
fs.writeFileSync(htmlPath, emailConfig.html)
39+
}
40+
41+
if (Math.random() > config.failureRate) {
42+
messageId++
43+
callback(null, {
44+
message: 'good',
45+
messageId: messageId
46+
})
47+
} else {
48+
callback(new Error('uh oh'))
49+
}
50+
},
51+
52+
close: function () {}
53+
};
54+
};

0 commit comments

Comments
 (0)