fxa-auth-server/test/local/email/bounce.js

556 строки
19 KiB
JavaScript

/* 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 assert = require('insist')
const bounces = require(`${ROOT_DIR}/lib/email/bounces`)
const error = require(`${ROOT_DIR}/lib/error`)
const { EventEmitter } = require('events')
const { mockLog } = require('../../mocks')
const P = require(`${ROOT_DIR}/lib/promise`)
const sinon = require('sinon')
const mockBounceQueue = new EventEmitter()
mockBounceQueue.start = function start() {}
function mockMessage(msg) {
msg.del = sinon.spy()
msg.headers = {}
return msg
}
function mockedBounces(log, db) {
return bounces(log, error)(mockBounceQueue, db)
}
describe('bounce messages', () => {
let log, mockDB
beforeEach(() => {
log = mockLog()
mockDB = {
createEmailBounce: sinon.spy(() =>P.resolve({})),
accountRecord: sinon.spy((email) => {
return P.resolve({
createdAt: Date.now(),
email: email,
emailVerified: false,
uid: '123456'
})
}),
deleteAccount: sinon.spy(() => P.resolve({}))
}
})
afterEach(() => {
mockBounceQueue.removeAllListeners()
})
it('should not log an error for headers', () => {
return mockedBounces(log, {})
.handleBounce(mockMessage({ junk: 'message' }))
.then(() => assert.equal(log.error.callCount, 0))
})
it('should log an error for missing headers', () => {
const message = mockMessage({
junk: 'message'
})
message.headers = undefined
return mockedBounces(log, {})
.handleBounce(message)
.then(() => assert.equal(log.error.callCount, 1))
})
it('should ignore unknown message types', () => {
return mockedBounces(log, {}).handleBounce(mockMessage({
junk: 'message'
})).then(() => {
assert.equal(log.info.callCount, 0)
assert.equal(log.error.callCount, 0)
assert.equal(log.warn.callCount, 1)
assert.equal(log.warn.args[0][0].op, 'emailHeaders.keys')
})
})
it('should handle multiple recipients in turn', () => {
const bounceType = 'Permanent'
const mockMsg = mockMessage({
bounce: {
bounceType: bounceType,
bouncedRecipients: [
{emailAddress: 'test@example.com'},
{emailAddress: 'foobar@example.com'}
]
}
})
return mockedBounces(log, mockDB).handleBounce(mockMsg).then(() => {
assert.equal(mockDB.createEmailBounce.callCount, 2)
assert.equal(mockDB.accountRecord.callCount, 2)
assert.equal(mockDB.deleteAccount.callCount, 2)
assert.equal(mockDB.accountRecord.args[0][0], 'test@example.com')
assert.equal(mockDB.accountRecord.args[1][0], 'foobar@example.com')
assert.equal(log.info.callCount, 6)
assert.equal(log.info.args[5][0].op, 'accountDeleted')
assert.equal(log.info.args[5][0].email, 'foobar@example.com')
assert.equal(mockMsg.del.callCount, 1)
})
})
it('should delete account registered with a Transient bounce', () => {
const bounceType = 'Transient'
const mockMsg = mockMessage({
bounce: {
bounceType: bounceType,
bouncedRecipients: [
{emailAddress: 'test@example.com'},
]
},
mail: {
headers: [
{
name: 'X-Template-Name',
value: 'verifyEmail'
}
]
}
})
return mockedBounces(log, mockDB).handleBounce(mockMsg).then(() => {
assert.equal(mockDB.deleteAccount.callCount, 1, 'deletes the account')
assert.equal(mockMsg.del.callCount, 1)
})
})
it('should not delete account that bounces and is older than 6 hours', () => {
const SEVEN_HOURS_AGO = Date.now() - 1000 * 60 * 60 * 7
mockDB.accountRecord = sinon.spy((email) => {
return P.resolve({
createdAt: SEVEN_HOURS_AGO,
uid: '123456',
email: email,
emailVerified: (email === 'verified@example.com')
})
})
const bounceType = 'Transient'
const mockMsg = mockMessage({
bounce: {
bounceType: bounceType,
bouncedRecipients: [
{emailAddress: 'test@example.com'},
]
},
mail: {
headers: [
{
name: 'X-Template-Name',
value: 'verifyLoginEmail'
}
]
}
})
return mockedBounces(log, mockDB).handleBounce(mockMsg).then(() => {
assert.equal(mockDB.deleteAccount.callCount, 0, 'does not delete the account')
assert.equal(mockMsg.del.callCount, 1)
})
})
it('should delete account that bounces and is younger than 6 hours', () => {
const FOUR_HOURS_AGO = Date.now() - 1000 * 60 * 60 * 5
mockDB.accountRecord = sinon.spy((email) => {
return P.resolve({
createdAt: FOUR_HOURS_AGO,
uid: '123456',
email: email,
emailVerified: (email === 'verified@example.com')
})
})
const bounceType = 'Transient'
const mockMsg = mockMessage({
bounce: {
bounceType: bounceType,
bouncedRecipients: [
{emailAddress: 'test@example.com'},
]
},
mail: {
headers: [
{
name: 'X-Template-Name',
value: 'verifyLoginEmail'
}
]
}
})
return mockedBounces(log, mockDB).handleBounce(mockMsg).then(() => {
assert.equal(mockDB.deleteAccount.callCount, 1, 'delete the account')
assert.equal(mockMsg.del.callCount, 1)
})
})
it('should delete accounts on login verification with a Transient bounce', () => {
const bounceType = 'Transient'
const mockMsg = mockMessage({
bounce: {
bounceType: bounceType,
bouncedRecipients: [
{emailAddress: 'test@example.com'},
]
},
mail: {
headers: [
{
name: 'X-Template-Name',
value: 'verifyLoginEmail'
}
]
}
})
return mockedBounces(log, mockDB).handleBounce(mockMsg).then(() => {
assert.equal(mockDB.deleteAccount.callCount, 1, 'deletes the account')
assert.equal(mockMsg.del.callCount, 1)
})
})
it('should treat complaints like bounces', () => {
const complaintType = 'abuse'
return mockedBounces(log, mockDB).handleBounce(mockMessage({
complaint: {
userAgent: 'AnyCompany Feedback Loop (V0.01)',
complaintFeedbackType: complaintType,
complainedRecipients: [
{emailAddress: 'test@example.com'},
{emailAddress: 'foobar@example.com'}
]
}
})).then(() => {
assert.equal(mockDB.createEmailBounce.callCount, 2)
assert.equal(mockDB.createEmailBounce.args[0][0].bounceType, 'Complaint')
assert.equal(mockDB.createEmailBounce.args[0][0].bounceSubType, complaintType)
assert.equal(mockDB.accountRecord.callCount, 2)
assert.equal(mockDB.deleteAccount.callCount, 2)
assert.equal(mockDB.accountRecord.args[0][0], 'test@example.com')
assert.equal(mockDB.accountRecord.args[1][0], 'foobar@example.com')
assert.equal(log.info.callCount, 6)
assert.equal(log.info.args[0][0].op, 'emailEvent')
assert.equal(log.info.args[0][0].domain, 'other')
assert.equal(log.info.args[0][0].type, 'bounced')
assert.equal(log.info.args[4][0].complaint, true)
assert.equal(log.info.args[4][0].complaintFeedbackType, complaintType)
assert.equal(log.info.args[4][0].complaintUserAgent, 'AnyCompany Feedback Loop (V0.01)')
})
})
it('should not delete verified accounts on bounce', () => {
mockDB.accountRecord = sinon.spy((email) => {
return P.resolve({
createdAt: Date.now(),
uid: '123456',
email: email,
emailVerified: (email === 'verified@example.com')
})
})
return mockedBounces(log, mockDB).handleBounce(mockMessage({
bounce: {
bounceType: 'Permanent',
// docs: http://docs.aws.amazon.com/ses/latest/DeveloperGuide/notification-contents.html#bounced-recipients
bouncedRecipients: [
{ emailAddress: 'test@example.com', action: 'failed', status: '5.0.0', diagnosticCode: 'smtp; 550 user unknown' },
{ emailAddress: 'verified@example.com', status: '4.0.0' }
]
}
})).then(() => {
assert.equal(mockDB.accountRecord.callCount, 2)
assert.equal(mockDB.accountRecord.args[0][0], 'test@example.com')
assert.equal(mockDB.accountRecord.args[1][0], 'verified@example.com')
assert.equal(mockDB.deleteAccount.callCount, 1)
assert.equal(mockDB.deleteAccount.args[0][0].email, 'test@example.com')
assert.equal(log.info.callCount, 5)
assert.equal(log.info.args[1][0].op, 'handleBounce')
assert.equal(log.info.args[1][0].email, 'test@example.com')
assert.equal(log.info.args[1][0].domain, 'other')
assert.equal(log.info.args[1][0].status, '5.0.0')
assert.equal(log.info.args[1][0].action, 'failed')
assert.equal(log.info.args[1][0].diagnosticCode, 'smtp; 550 user unknown')
assert.equal(log.info.args[2][0].op, 'accountDeleted')
assert.equal(log.info.args[2][0].email, 'test@example.com')
assert.equal(log.info.args[4][0].op, 'handleBounce')
assert.equal(log.info.args[4][0].email, 'verified@example.com')
assert.equal(log.info.args[4][0].status, '4.0.0')
})
})
it('should log errors when looking up the email record', () => {
mockDB.accountRecord = sinon.spy(() => P.reject(new error({})))
const mockMsg = mockMessage({
bounce: {
bounceType: 'Permanent',
bouncedRecipients: [
{emailAddress: 'test@example.com'},
]
}
})
return mockedBounces(log, mockDB).handleBounce(mockMsg).then(() => {
assert.equal(mockDB.accountRecord.callCount, 1)
assert.equal(mockDB.accountRecord.args[0][0], 'test@example.com')
assert.equal(log.info.callCount, 2)
assert.equal(log.info.args[1][0].op, 'handleBounce')
assert.equal(log.info.args[1][0].email, 'test@example.com')
assert.equal(log.error.callCount, 2)
assert.equal(log.error.args[1][0].op, 'databaseError')
assert.equal(log.error.args[1][0].email, 'test@example.com')
assert.equal(mockMsg.del.callCount, 1)
})
})
it('should log errors when deleting the email record', () => {
mockDB.deleteAccount = sinon.spy(() => P.reject(new error.unknownAccount('test@example.com')))
const mockMsg = mockMessage({
bounce: {
bounceType: 'Permanent',
bouncedRecipients: [
{emailAddress: 'test@example.com'},
]
}
})
return mockedBounces(log, mockDB).handleBounce(mockMsg).then(() => {
assert.equal(mockDB.accountRecord.callCount, 1)
assert.equal(mockDB.accountRecord.args[0][0], 'test@example.com')
assert.equal(mockDB.deleteAccount.callCount, 1)
assert.equal(mockDB.deleteAccount.args[0][0].email, 'test@example.com')
assert.equal(log.info.callCount, 2)
assert.equal(log.info.args[1][0].op, 'handleBounce')
assert.equal(log.info.args[1][0].email, 'test@example.com')
assert.equal(log.error.callCount, 2)
assert.equal(log.error.args[1][0].op, 'databaseError')
assert.equal(log.error.args[1][0].email, 'test@example.com')
assert.equal(log.error.args[1][0].err.errno, error.ERRNO.ACCOUNT_UNKNOWN)
assert.equal(mockMsg.del.callCount, 1)
})
})
it('should normalize quoted email addresses for lookup', () => {
mockDB.accountRecord = sinon.spy((email) => {
// Lookup only succeeds when using original, unquoted email addr.
if (email !== 'test.@example.com') {
return P.reject(new error.unknownAccount(email))
}
return P.resolve({
createdAt: Date.now(),
uid: '123456',
email: email,
emailVerified: false
})
})
return mockedBounces(log, mockDB).handleBounce(mockMessage({
bounce: {
bounceType: 'Permanent',
bouncedRecipients: [
// Bounce message has email addr in quoted form, since some
// mail agents normalize it in this way.
{emailAddress: '"test."@example.com'},
]
}
})).then(() => {
assert.equal(mockDB.createEmailBounce.callCount, 1)
assert.equal(mockDB.createEmailBounce.args[0][0].email, 'test.@example.com')
assert.equal(mockDB.accountRecord.callCount, 1)
assert.equal(mockDB.accountRecord.args[0][0], 'test.@example.com')
assert.equal(mockDB.deleteAccount.callCount, 1)
assert.equal(mockDB.deleteAccount.args[0][0].email, 'test.@example.com')
})
})
it('should handle multiple consecutive dots even if not quoted', () => {
mockDB.accountRecord = sinon.spy((email) => {
// Lookup only succeeds when using original, unquoted email addr.
if (email !== 'test..me@example.com') {
return P.reject(new error.unknownAccount(email))
}
return P.resolve({
createdAt: Date.now(),
uid: '123456',
email: email,
emailVerified: false
})
})
return mockedBounces(log, mockDB).handleBounce(mockMessage({
bounce: {
bounceType: 'Permanent',
bouncedRecipients: [
// Some mail agents incorrectly fail to quote addresses that
// contain multiple consecutive dots. Ensure we work around it.
{emailAddress: 'test..me@example.com'},
]
}
})).then(() => {
assert.equal(mockDB.createEmailBounce.callCount, 1)
assert.equal(mockDB.createEmailBounce.args[0][0].email, 'test..me@example.com')
assert.equal(mockDB.accountRecord.callCount, 1)
assert.equal(mockDB.accountRecord.args[0][0], 'test..me@example.com')
assert.equal(mockDB.deleteAccount.callCount, 1)
assert.equal(mockDB.deleteAccount.args[0][0].email, 'test..me@example.com')
})
})
it('should log a warning if it receives an unparseable email address', () => {
mockDB.accountRecord = sinon.spy(() => P.reject(new error.unknownAccount()))
return mockedBounces(log, mockDB).handleBounce(mockMessage({
bounce: {
bounceType: 'Permanent',
bouncedRecipients: [
{emailAddress: 'how did this even happen?'},
]
}
})).then(() => {
assert.equal(mockDB.createEmailBounce.callCount, 0)
assert.equal(mockDB.accountRecord.callCount, 0)
assert.equal(mockDB.deleteAccount.callCount, 0)
assert.equal(log.warn.callCount, 2)
assert.equal(log.warn.args[1][0].op, 'handleBounce.addressParseFailure')
})
})
it('should log email template name, language, and bounceType', () => {
const mockMsg = mockMessage({
bounce: {
bounceType: 'Permanent',
bounceSubType: 'General',
bouncedRecipients: [
{emailAddress: 'test@example.com'}
]
},
mail: {
headers: [
{
name: 'Content-Language',
value: 'db-LB'
},
{
name: 'X-Template-Name',
value: 'verifyLoginEmail'
}
]
}
})
return mockedBounces(log, mockDB).handleBounce(mockMsg).then(() => {
assert.equal(mockDB.accountRecord.callCount, 1)
assert.equal(mockDB.accountRecord.args[0][0], 'test@example.com')
assert.equal(mockDB.deleteAccount.callCount, 1)
assert.equal(mockDB.deleteAccount.args[0][0].email, 'test@example.com')
assert.equal(log.info.callCount, 3)
assert.equal(log.info.args[1][0].op, 'handleBounce')
assert.equal(log.info.args[1][0].email, 'test@example.com')
assert.equal(log.info.args[1][0].template, 'verifyLoginEmail')
assert.equal(log.info.args[1][0].bounceType, 'Permanent')
assert.equal(log.info.args[1][0].bounceSubType, 'General')
assert.equal(log.info.args[1][0].lang, 'db-LB')
})
})
it('should emit flow metrics', () => {
const mockMsg = mockMessage({
bounce: {
bounceType: 'Permanent',
bounceSubType: 'General',
bouncedRecipients: [
{emailAddress: 'test@example.com'}
]
},
mail: {
headers: [
{
name: 'X-Template-Name',
value: 'verifyLoginEmail'
},
{
name: 'X-Flow-Id',
value: 'someFlowId'
},
{
name: 'X-Flow-Begin-Time',
value: '1234'
},
{
name: 'Content-Language',
value: 'en'
}
]
}
})
return mockedBounces(log, mockDB).handleBounce(mockMsg).then(function () {
assert.equal(mockDB.accountRecord.callCount, 1)
assert.equal(mockDB.accountRecord.args[0][0], 'test@example.com')
assert.equal(mockDB.deleteAccount.callCount, 1)
assert.equal(mockDB.deleteAccount.args[0][0].email, 'test@example.com')
assert.equal(log.flowEvent.callCount, 1)
assert.equal(log.flowEvent.args[0][0].event, 'email.verifyLoginEmail.bounced')
assert.equal(log.flowEvent.args[0][0].flow_id, 'someFlowId')
assert.equal(log.flowEvent.args[0][0].flow_time > 0, true)
assert.equal(log.flowEvent.args[0][0].time > 0, true)
assert.equal(log.info.callCount, 3)
assert.equal(log.info.args[0][0].op, 'emailEvent')
assert.equal(log.info.args[0][0].type, 'bounced')
assert.equal(log.info.args[0][0].template, 'verifyLoginEmail')
assert.equal(log.info.args[0][0].flow_id, 'someFlowId')
})
})
it('should log email domain if popular one', () => {
const mockMsg = mockMessage({
bounce: {
bounceType: 'Permanent',
bounceSubType: 'General',
bouncedRecipients: [
{emailAddress: 'test@aol.com'}
]
},
mail: {
headers: [
{
name: 'X-Template-Name',
value: 'verifyLoginEmail'
},
{
name: 'X-Flow-Id',
value: 'someFlowId'
},
{
name: 'X-Flow-Begin-Time',
value: '1234'
},
{
name: 'Content-Language',
value: 'en'
}
]
}
})
return mockedBounces(log, mockDB).handleBounce(mockMsg).then(function () {
assert.equal(log.flowEvent.callCount, 1)
assert.equal(log.flowEvent.args[0][0].event, 'email.verifyLoginEmail.bounced')
assert.equal(log.flowEvent.args[0][0].flow_id, 'someFlowId')
assert.equal(log.flowEvent.args[0][0].flow_time > 0, true)
assert.equal(log.flowEvent.args[0][0].time > 0, true)
assert.equal(log.info.callCount, 3)
assert.equal(log.info.args[0][0].op, 'emailEvent')
assert.equal(log.info.args[0][0].domain, 'aol.com')
assert.equal(log.info.args[0][0].type, 'bounced')
assert.equal(log.info.args[0][0].template, 'verifyLoginEmail')
assert.equal(log.info.args[0][0].locale, 'en')
assert.equal(log.info.args[0][0].flow_id, 'someFlowId')
assert.equal(log.info.args[1][0].email, 'test@aol.com')
assert.equal(log.info.args[1][0].domain, 'aol.com')
})
})
})