feat(email): write live email-sending config to redis

This commit is contained in:
Phil Booth 2018-08-09 18:43:29 +01:00
Родитель 4998f2b2ba
Коммит c6ad40270a
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B8E710D82AE27976
6 изменённых файлов: 493 добавлений и 0 удалений

Просмотреть файл

@ -40,6 +40,7 @@ script:
- if [ $DB == "mysql" ]; then ./scripts/start-travis-auth-db-mysql.sh; fi
- npm run test-ci
- npm run test-e2e
- npm run test-scripts
# Test fxa-auth-mailer
- grunt templates && git status -s | (! grep 'M lib/senders/templates/')
- grunt l10n-extract

Просмотреть файл

@ -134,6 +134,65 @@ can be overridden in two ways:
export CONFIG_FILES="~/fxa-content-server.json,~/fxa-db.json"
```
### Email config
There is also some live config
loaded from Redis for the email service.
This config is stored as a JSON string
that looks like this
(every property is optional):
```json
{
"sendgrid": {
"percentage": 100,
"regex": "^.+@example\\.com$"
},
"socketlabs": {
"percentage": 100,
"regex": "^.+@example\\.org$"
},
"ses": {
"percentage": 10,
"regex": ".*"
}
}
```
`scripts/email-config.js`
has been written to help
manage this config.
* To print the current live config to stdout:
```
node scripts/email-config read
```
* To set the live config from a JSON file on disk:
```
cat foo.json | node scripts/email-config write
```
* To set the live config from a string:
```
echo '{"sendgrid":{"percentage":10}}' | node scripts/email-config write
```
* To undo the last change:
```
node scripts/email-config revert
```
* To check the resolved config for a specific email address:
```
node scripts/email-config check foo@example.com
```
## Troubleshooting
Firefox Accounts authorization is a complicated flow. You can get verbose logging by adjusting the log level in the `config.json` on your deployed instance. Add a stanza like:

Просмотреть файл

@ -19,6 +19,7 @@
"test": "VERIFIER_VERSION=0 MEMCACHE_METRICS_CONTEXT_ADDRESS=none NO_COVERAGE=1 scripts/test-local.sh",
"test-ci": "scripts/test-local.sh",
"test-e2e": "NODE_ENV=dev mocha test/e2e",
"test-scripts": "mocha test/scripts",
"test-remote": "MAILER_HOST=restmail.net MAILER_PORT=80 CORS_ORIGIN=http://baz mocha --timeout=300000 test/remote"
},
"repository": {

141
scripts/email-config.js Normal file
Просмотреть файл

@ -0,0 +1,141 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict'
const ROOT_DIR = '..'
const LIB_DIR = `${ROOT_DIR}/lib`
const config = require(`${ROOT_DIR}/config`).getProperties()
const log = require(`${LIB_DIR}/log`)(config.log.level, 'email-config')
const Promise = require(`${LIB_DIR}/promise`)
const redis = require(`${LIB_DIR}/redis`)({ ...config.redis, ...config.redis.email }, log)
if (! redis) {
console.error('redis is disabled in config, aborting')
process.exit(1)
}
const COMMANDS = {
read,
write,
revert,
check
}
const KEYS = {
current: 'config',
previous: 'config.previous'
}
const { argv } = process
main()
.then(() => redis.close())
async function main () {
try {
const command = argv[2]
switch (command) {
case 'read':
case 'write':
case 'revert':
assertArgs(0)
break
case 'check':
assertArgs(1)
break
default:
usageError()
}
const result = await COMMANDS[command](...argv.slice(3))
if (result) {
console.log(result)
}
} catch (error) {
console.error(error.message)
process.exit(1)
}
}
function assertArgs (count) {
if (argv.length !== count + 3) {
usageError()
}
}
function usageError () {
const scriptName = argv[1].substr(argv[1].indexOf('/scripts/') + 1)
throw new Error([
'Usage:',
`${scriptName} read - Read the current config to stdout`,
`${scriptName} write - Write the current config from stdin`,
`${scriptName} revert - Undo the last write or revert`,
`${scriptName} check <email address> - Check whether <email address> matches config`
].join('\n'))
}
async function read () {
const current = await redis.get(KEYS.current)
if (current) {
// Parse then stringify for pretty printing
return JSON.stringify(JSON.parse(current), null, ' ')
}
}
async function write () {
// Parse then stringify for validation
const current = JSON.stringify(JSON.parse(await stdin()))
const previous = await redis.get(KEYS.current)
await redis.set(KEYS.current, current)
if (previous) {
await redis.set(KEYS.previous, previous)
}
}
function stdin () {
return new Promise((resolve, reject) => {
const chunks = []
process.stdin.on('readable', () => {
const chunk = process.stdin.read()
if (chunk !== null) {
chunks.push(chunk)
}
})
process.stdin.on('error', reject)
process.stdin.on('end', () => resolve(chunks.join('')))
})
}
async function revert () {
const previous = await redis.get(KEYS.previous)
const current = await redis.get(KEYS.current)
if (previous) {
await redis.set(KEYS.current, previous)
} else {
await redis.del(KEYS.current)
}
if (current) {
await redis.set(KEYS.previous, current)
}
}
async function check (emailAddress) {
const current = await redis.get(KEYS.current)
if (current) {
const config = JSON.parse(await redis.get(KEYS.current))
const result = Object.entries(config)
.filter(([ sender, senderConfig ]) => {
if (senderConfig.regex) {
return new RegExp(senderConfig.regex).test(emailAddress)
}
return true
})
.reduce((matches, [ sender, senderConfig ]) => {
matches[sender] = senderConfig
return matches
}, {})
return JSON.stringify(result, null, ' ')
}
}

Просмотреть файл

@ -7,11 +7,15 @@
const ROOT_DIR = '../../..'
const assert = require('insist')
const cp = require('child_process')
const mocks = require('../../mocks')
const P = require('bluebird')
const path = require('path')
const proxyquire = require('proxyquire').noPreserveCache()
const sinon = require('sinon')
cp.execAsync = P.promisify(cp.exec)
const config = require(`${ROOT_DIR}/config`)
const TEMPLATE_VERSIONS = require(`${ROOT_DIR}/lib/senders/templates/_versions.json`)
@ -1873,3 +1877,66 @@ describe('call selectEmailServices with mocked safe-regex, regex-only match and
})
})
})
if (config.get('redis.email.enabled')) {
const emailAddress = 'foo@example.com';
[ 'sendgrid', 'ses', 'socketlabs' ].reduce((promise, service) => {
return promise.then(() => {
return new P((resolve, reject) => {
describe(`call selectEmailServices with real redis containing ${service} config:`, () => {
let mailer, result
before(() => {
return P.all([
require(`${ROOT_DIR}/lib/senders/translator`)(['en'], 'en'),
require(`${ROOT_DIR}/lib/senders/templates`).init()
]).spread((translator, templates) => {
const mockLog = mocks.mockLog()
const Mailer = require(`${ROOT_DIR}/lib/senders/email`)(mockLog, config.getProperties())
mailer = new Mailer(translator, templates, config.get('smtp'))
return redisWrite({
[service]: {
regex: '^foo@example\.com$',
percentage: 100
}
})
})
.then(() => mailer.selectEmailServices({ email: emailAddress }))
.then(r => result = r)
})
after(() => {
return redisRevert()
.then(() => mailer.stop())
.then(resolve)
.catch(reject)
})
it('returned the correct result', () => {
assert.deepEqual(result, [
{
emailAddresses: [ emailAddress ],
emailService: 'fxa-email-service',
emailSender: service,
mailer: mailer.emailService
}
])
})
})
})
})
}, P.resolve())
}
function redisWrite (config) {
return cp.execAsync(`echo '${JSON.stringify(config)}' | node scripts/email-config write`, {
cwd: path.resolve(__dirname, '../../..')
})
}
function redisRevert () {
return cp.execAsync('node scripts/email-config revert', {
cwd: path.resolve(__dirname, '../../..')
})
}

Просмотреть файл

@ -0,0 +1,224 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict'
const ROOT_DIR = '../..'
const LIB_DIR = `${ROOT_DIR}/lib`
const assert = require('insist')
const cp = require('child_process')
const mocks = require(`${ROOT_DIR}/test/mocks`)
const P = require('bluebird')
const path = require('path')
cp.execAsync = P.promisify(cp.exec)
const config = require(`${ROOT_DIR}/config`).getProperties()
const redis = require(`${LIB_DIR}/redis`)({
...config.redis,
...config.redis.email
}, mocks.mockLog())
const cwd = path.resolve(__dirname, ROOT_DIR)
const KEYS = {
current: 'config',
previous: 'config.previous'
}
describe('scripts/email-config:', () => {
let current, previous
beforeEach(() => {
return redis.get(KEYS.current)
.then(result => {
current = result
return redis.get(KEYS.previous)
})
.then(result => {
previous = result
return redis.del(KEYS.current)
})
.then(() => redis.del(KEYS.previous))
})
afterEach(() => {
return P.resolve()
.then(() => {
if (current) {
return redis.set(KEYS.current, current)
}
return redis.del(KEYS.current)
})
.then(() => {
if (previous) {
return redis.set(KEYS.previous, previous)
}
return redis.del(KEYS.previous)
})
})
it('read does not fail', () => {
return cp.execAsync('node scripts/email-config read', { cwd })
})
it('write does not fail', () => {
return cp.execAsync('echo "{}" | node scripts/email-config write', { cwd })
})
it('write fails if stdin is not valid JSON', () => {
return cp.execAsync('echo "wibble" | node scripts/email-config write', { cwd })
.then(() => assert(false, 'script should have failed'))
.catch(() => {})
})
it('revert does not fail', () => {
return cp.execAsync('node scripts/email-config revert', { cwd })
})
it('check does not fail', () => {
return cp.execAsync('node scripts/email-config check foo@example.com', { cwd })
})
it('check fails without argument', () => {
return cp.execAsync('node scripts/email-config check', { cwd })
.then(() => assert(false, 'script should have failed'))
.catch(() => {})
})
describe('write config with regex:', () => {
let config
beforeEach(() => {
config = {
sendgrid: {
regex: 'foo',
percentage: 42
}
}
return cp.execAsync(`echo '${JSON.stringify(config)}' | node scripts/email-config write`, { cwd })
})
it('read prints the config to stdout', () => {
return cp.execAsync('node scripts/email-config read', { cwd })
.then((stdout, stderr) => {
assert.equal(stdout, `${JSON.stringify(config, null, ' ')}\n`)
assert.equal(stderr, undefined)
})
})
it('check matching email prints the config to stdout', () => {
return cp.execAsync('node scripts/email-config check foo@example.com', { cwd })
.then((stdout, stderr) => {
assert.equal(stdout, `${JSON.stringify(config, null, ' ')}\n`)
assert.equal(stderr, undefined)
})
})
it('check non-matching email does not print', () => {
return cp.execAsync('node scripts/email-config check bar@example.com', { cwd })
.then((stdout, stderr) => {
assert.equal(stdout, '{}\n')
assert.equal(stderr, undefined)
})
})
describe('write config without regex:', () => {
beforeEach(() => {
config.socketlabs = {
percentage: 10
}
return cp.execAsync(`echo '${JSON.stringify(config)}' | node scripts/email-config write`, { cwd })
})
it('read prints the config to stdout', () => {
return cp.execAsync('node scripts/email-config read', { cwd })
.then((stdout, stderr) => {
assert.equal(stdout, `${JSON.stringify(config, null, ' ')}\n`)
assert.equal(stderr, undefined)
})
})
it('check matching email prints the both configs to stdout', () => {
return cp.execAsync('node scripts/email-config check foo@example.com', { cwd })
.then((stdout, stderr) => {
assert.equal(stdout, `${JSON.stringify(config, null, ' ')}\n`)
assert.equal(stderr, undefined)
})
})
it('check non-matching email prints one config to stdout', () => {
return cp.execAsync('node scripts/email-config check bar@example.com', { cwd })
.then((stdout, stderr) => {
const expected = {
socketlabs: config.socketlabs
}
assert.equal(stdout, `${JSON.stringify(expected, null, ' ')}\n`)
assert.equal(stderr, undefined)
})
})
describe('revert:', () => {
beforeEach(() => {
return cp.execAsync('node scripts/email-config revert', { cwd })
})
it('read prints the previous config to stdout', () => {
return cp.execAsync('node scripts/email-config read', { cwd })
.then((stdout, stderr) => {
const expected = {
sendgrid: config.sendgrid
}
assert.equal(stdout, `${JSON.stringify(expected, null, ' ')}\n`)
assert.equal(stderr, undefined)
})
})
it('check matching email prints the previous config to stdout', () => {
return cp.execAsync('node scripts/email-config check foo@example.com', { cwd })
.then((stdout, stderr) => {
const expected = {
sendgrid: config.sendgrid
}
assert.equal(stdout, `${JSON.stringify(expected, null, ' ')}\n`)
assert.equal(stderr, undefined)
})
})
it('check non-matching email does not print', () => {
return cp.execAsync('node scripts/email-config check bar@example.com', { cwd })
.then((stdout, stderr) => {
assert.equal(stdout, '{}\n')
assert.equal(stderr, undefined)
})
})
})
})
describe('revert:', () => {
beforeEach(() => {
return cp.execAsync('node scripts/email-config revert', { cwd })
})
it('read does not print', () => {
return cp.execAsync('node scripts/email-config read', { cwd })
.then((stdout, stderr) => {
assert.equal(stdout, '')
assert.equal(stderr, undefined)
})
})
it('check matching email does not print', () => {
return cp.execAsync('node scripts/email-config check foo@example.com', { cwd })
.then((stdout, stderr) => {
assert.equal(stdout, '')
assert.equal(stderr, undefined)
})
})
})
})
})