Skip to content

Commit 0febaa2

Browse files
committed
feat(emails): Add support to handle delivery delayed email notifictions
1 parent 04725ea commit 0febaa2

5 files changed

Lines changed: 354 additions & 0 deletions

File tree

packages/fxa-auth-server/bin/email_notifications.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ const Token = require('../lib/tokens')(log, config);
6767
const SQSReceiver = require('../lib/sqs')(log, statsd);
6868
const bounces = require('../lib/email/bounces')(log, error, config, statsd);
6969
const delivery = require('../lib/email/delivery')(log, glean);
70+
const deliveryDelay = require('../lib/email/delivery-delay')(log, statsd);
7071
const notifications = require('../lib/email/notifications')(log, error);
7172

7273
const { createDB } = require('../lib/db');
@@ -76,6 +77,7 @@ const {
7677
bounceQueueUrl,
7778
complaintQueueUrl,
7879
deliveryQueueUrl,
80+
deliveryDelayQueueUrl,
7981
notificationQueueUrl,
8082
region,
8183
} = config.emailNotifications;
@@ -85,10 +87,12 @@ const bounceQueue = new SQSReceiver(region, [
8587
complaintQueueUrl,
8688
]);
8789
const deliveryQueue = new SQSReceiver(region, [deliveryQueueUrl]);
90+
const deliveryDelayQueue = new SQSReceiver(region, [deliveryDelayQueueUrl]);
8891
const notificationQueue = new SQSReceiver(region, [notificationQueueUrl]);
8992

9093
DB.connect(config).then((db) => {
9194
bounces(bounceQueue, db);
9295
delivery(deliveryQueue);
96+
deliveryDelay(deliveryDelayQueue);
9397
notifications(notificationQueue, db);
9498
});

packages/fxa-auth-server/config/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,12 @@ const convictConf = convict({
751751
env: 'DELIVERY_QUEUE_URL',
752752
default: '',
753753
},
754+
deliveryDelayQueueUrl: {
755+
doc: 'The email delivery delay queue URL to use (should include https://sqs.<region>.amazonaws.com/<account-id>/<queue-name>)',
756+
format: String,
757+
env: 'DELIVERY_DELAY_QUEUE_URL',
758+
default: '',
759+
},
754760
notificationQueueUrl: {
755761
doc: 'Queue URL for notifications from fxa-email-service (eventually this will be the only email-related queue)',
756762
format: String,
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
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+
import { StatsD } from 'hot-shots';
6+
import { Logger } from 'mozlog';
7+
import { EventEmitter } from 'events';
8+
const utils = require('./utils/helpers');
9+
10+
interface SESMailHeader {
11+
name: string;
12+
value: string;
13+
}
14+
15+
interface SESMail {
16+
timestamp: string;
17+
messageId: string;
18+
source: string;
19+
headers?: SESMailHeader[];
20+
}
21+
22+
interface DelayedRecipient {
23+
emailAddress: string;
24+
status?: string;
25+
diagnosticCode?: string;
26+
}
27+
28+
interface DeliveryDelay {
29+
delayType:
30+
| 'InternalFailure'
31+
| 'General'
32+
| 'MailboxFull'
33+
| 'SpamDetected'
34+
| 'RecipientServerError'
35+
| 'IPFailure'
36+
| 'TransientCommunicationFailure'
37+
| 'BYOIPHostNameLookupUnavailable'
38+
| 'Undetermined'
39+
| 'SendingDeferral';
40+
delayedRecipients: DelayedRecipient[];
41+
expirationTime?: string;
42+
reportingMTA?: string;
43+
timestamp?: string;
44+
}
45+
46+
interface SESDeliveryDelayMessage {
47+
eventType?: 'DeliveryDelay';
48+
notificationType?: 'DeliveryDelay';
49+
deliveryDelay: DeliveryDelay;
50+
mail: SESMail;
51+
del: () => void;
52+
}
53+
54+
interface SQSReceiver extends EventEmitter {
55+
start: () => void;
56+
}
57+
58+
export = function (log: Logger, statsd: StatsD) {
59+
return function start(deliveryDelayQueue: SQSReceiver) {
60+
async function handleDeliveryDelay(message: SESDeliveryDelayMessage) {
61+
utils.logErrorIfHeadersAreWeirdOrMissing(log, message, 'deliveryDelay');
62+
63+
statsd.increment('email.deliveryDelay.message', {
64+
delayType: message?.deliveryDelay?.delayType || 'none',
65+
hasExpiration: String(!!message?.deliveryDelay?.expirationTime),
66+
template: utils.getHeaderValue('X-Template-Name', message) || 'none',
67+
});
68+
69+
let recipients: DelayedRecipient[] = [];
70+
if (
71+
message.deliveryDelay &&
72+
(message.eventType === 'DeliveryDelay' ||
73+
message.notificationType === 'DeliveryDelay')
74+
) {
75+
recipients = message.deliveryDelay.delayedRecipients || [];
76+
}
77+
78+
const templateName = utils.getHeaderValue('X-Template-Name', message);
79+
const language = utils.getHeaderValue('Content-Language', message);
80+
const delayType = message.deliveryDelay?.delayType;
81+
const expirationTime = message.deliveryDelay?.expirationTime;
82+
const reportingMTA = message.deliveryDelay?.reportingMTA;
83+
const timestamp = message.deliveryDelay?.timestamp;
84+
85+
for (const recipient of recipients) {
86+
const email = recipient.emailAddress;
87+
const emailDomain = utils.getAnonymizedEmailDomain(email);
88+
const logData: {
89+
email: string;
90+
domain: string;
91+
delayType?: DeliveryDelay['delayType'];
92+
status?: string;
93+
diagnosticCode?: string;
94+
template?: string;
95+
lang?: string;
96+
expirationTime?: string;
97+
reportingMTA?: string;
98+
timestamp?: string;
99+
} = {
100+
email: email,
101+
domain: emailDomain,
102+
delayType: delayType,
103+
};
104+
105+
if (recipient.status) {
106+
logData.status = recipient.status;
107+
}
108+
if (recipient.diagnosticCode) {
109+
logData.diagnosticCode = recipient.diagnosticCode;
110+
}
111+
112+
if (templateName) {
113+
logData.template = templateName;
114+
}
115+
116+
if (language) {
117+
logData.lang = language;
118+
}
119+
120+
if (expirationTime) {
121+
logData.expirationTime = expirationTime;
122+
}
123+
124+
if (reportingMTA) {
125+
logData.reportingMTA = reportingMTA;
126+
}
127+
128+
if (timestamp) {
129+
logData.timestamp = timestamp;
130+
}
131+
132+
utils.logAccountEventFromMessage(message, 'emailDelayed');
133+
134+
log.info('handleDeliveryDelay', logData);
135+
}
136+
137+
message.del();
138+
}
139+
140+
deliveryDelayQueue.on('data', handleDeliveryDelay);
141+
deliveryDelayQueue.start();
142+
143+
return {
144+
deliveryDelayQueue: deliveryDelayQueue,
145+
handleDeliveryDelay: handleDeliveryDelay,
146+
};
147+
};
148+
};

packages/fxa-auth-server/lib/email/utils/helpers.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,10 @@ function logEmailEventFromMessage(log, message, type, emailDomain) {
207207
emailEventInfo.complaint = true;
208208
}
209209

210+
if (message.deliveryDelay) {
211+
emailEventInfo.delayed = true;
212+
}
213+
210214
log.info('emailEvent', emailEventInfo);
211215

212216
logAmplitudeEvent(log, message, emailEventInfo);
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
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+
'use strict';
6+
7+
const { assert } = require('chai');
8+
const EventEmitter = require('events').EventEmitter;
9+
const { mockLog, mockStatsd } = require('../../mocks');
10+
const sinon = require('sinon');
11+
const emailHelpers = require('../../../lib/email/utils/helpers');
12+
const deliveryDelay = require('../../../lib/email/delivery-delay');
13+
14+
let sandbox;
15+
const mockDeliveryDelayQueue = new EventEmitter();
16+
mockDeliveryDelayQueue.start = function start() {};
17+
18+
function mockMessage(msg) {
19+
msg.del = sandbox.spy();
20+
msg.headers = msg.headers || {};
21+
return msg;
22+
}
23+
24+
function createDeliveryDelayMessage(overrides = {}) {
25+
const defaults = {
26+
eventType: 'DeliveryDelay',
27+
deliveryDelay: {
28+
delayType: 'TransientCommunicationFailure',
29+
delayedRecipients: [{ emailAddress: '[email protected]' }],
30+
},
31+
mail: {
32+
timestamp: '2023-12-17T14:59:38.237Z',
33+
messageId: 'test-message-id',
34+
source: '[email protected]',
35+
headers: [],
36+
},
37+
};
38+
return mockMessage({ ...defaults, ...overrides });
39+
}
40+
41+
function mockedDeliveryDelay(log, statsd) {
42+
return deliveryDelay(log, statsd)(mockDeliveryDelayQueue);
43+
}
44+
45+
describe('delivery delay messages', () => {
46+
beforeEach(() => {
47+
sandbox = sinon.createSandbox();
48+
});
49+
50+
afterEach(() => {
51+
sandbox.restore();
52+
});
53+
54+
it('should not log an error for headers', async () => {
55+
const log = mockLog();
56+
const statsd = mockStatsd();
57+
await mockedDeliveryDelay(log, statsd).handleDeliveryDelay(
58+
mockMessage({ junk: 'message' })
59+
);
60+
assert.equal(log.error.callCount, 0);
61+
});
62+
63+
it('should log an error for missing headers', async () => {
64+
const log = mockLog();
65+
const statsd = mockStatsd();
66+
const message = mockMessage({ junk: 'message' });
67+
message.headers = undefined;
68+
await mockedDeliveryDelay(log, statsd).handleDeliveryDelay(message);
69+
assert.equal(log.error.callCount, 1);
70+
});
71+
72+
it('should log delivery delay with all fields', async () => {
73+
const log = mockLog();
74+
const statsd = mockStatsd();
75+
const mockMsg = createDeliveryDelayMessage({
76+
deliveryDelay: {
77+
delayType: 'TransientCommunicationFailure',
78+
timestamp: '2023-12-17T14:59:38.237Z',
79+
delayedRecipients: [
80+
{
81+
emailAddress: '[email protected]',
82+
status: '4.4.7',
83+
diagnosticCode: 'smtp; 450 4.4.7 Message delayed',
84+
},
85+
],
86+
expirationTime: '2023-12-18T14:59:38.237Z',
87+
reportingMTA: 'a1-23.smtp-out.amazonses.com',
88+
},
89+
mail: {
90+
headers: [
91+
{ name: 'X-Template-Name', value: 'verifyLoginEmail' },
92+
{ name: 'Content-Language', value: 'en' },
93+
],
94+
},
95+
});
96+
97+
await mockedDeliveryDelay(log, statsd).handleDeliveryDelay(mockMsg);
98+
99+
sinon.assert.calledOnceWithExactly(
100+
statsd.increment,
101+
'email.deliveryDelay.message',
102+
{
103+
delayType: 'TransientCommunicationFailure',
104+
hasExpiration: 'true',
105+
template: 'verifyLoginEmail',
106+
}
107+
);
108+
109+
const loggedData = log.info.args[0][1];
110+
assert.equal(log.info.args[0][0], 'handleDeliveryDelay');
111+
assert.include(loggedData, {
112+
113+
domain: 'other',
114+
delayType: 'TransientCommunicationFailure',
115+
status: '4.4.7',
116+
template: 'verifyLoginEmail',
117+
lang: 'en',
118+
expirationTime: '2023-12-18T14:59:38.237Z',
119+
reportingMTA: 'a1-23.smtp-out.amazonses.com',
120+
});
121+
});
122+
123+
it('should handle delivery delay with notificationType', async () => {
124+
const log = mockLog();
125+
const statsd = mockStatsd();
126+
const mockMsg = createDeliveryDelayMessage({
127+
notificationType: 'DeliveryDelay',
128+
eventType: undefined,
129+
deliveryDelay: {
130+
delayType: 'MailboxFull',
131+
delayedRecipients: [{ emailAddress: '[email protected]', status: '4.2.2' }],
132+
},
133+
});
134+
135+
await mockedDeliveryDelay(log, statsd).handleDeliveryDelay(mockMsg);
136+
137+
assert.equal(statsd.increment.args[0][1].delayType, 'MailboxFull');
138+
assert.include(log.info.args[0][1], {
139+
140+
status: '4.2.2',
141+
});
142+
});
143+
144+
it('should log account email event (emailDelayed)', async () => {
145+
sandbox.stub(emailHelpers, 'logAccountEventFromMessage').returns(Promise.resolve());
146+
const log = mockLog();
147+
const statsd = mockStatsd();
148+
const mockMsg = createDeliveryDelayMessage({
149+
deliveryDelay: {
150+
delayType: 'SpamDetected',
151+
delayedRecipients: [{ emailAddress: '[email protected]' }],
152+
},
153+
mail: { headers: [{ name: 'X-Uid', value: 'test-uid-123' }] },
154+
});
155+
156+
await mockedDeliveryDelay(log, statsd).handleDeliveryDelay(mockMsg);
157+
sinon.assert.calledOnceWithExactly(
158+
emailHelpers.logAccountEventFromMessage,
159+
mockMsg,
160+
'emailDelayed'
161+
);
162+
});
163+
164+
it('should handle popular email domain', async () => {
165+
const log = mockLog();
166+
const statsd = mockStatsd();
167+
const mockMsg = createDeliveryDelayMessage({
168+
deliveryDelay: {
169+
delayType: 'RecipientServerError',
170+
delayedRecipients: [{ emailAddress: '[email protected]' }],
171+
},
172+
});
173+
174+
await mockedDeliveryDelay(log, statsd).handleDeliveryDelay(mockMsg);
175+
176+
assert.equal(log.info.args[0][1].domain, 'yahoo.com');
177+
});
178+
179+
it('should handle missing delayedRecipients gracefully', async () => {
180+
const log = mockLog();
181+
const statsd = mockStatsd();
182+
const mockMsg = createDeliveryDelayMessage({
183+
deliveryDelay: { delayType: 'Undetermined', delayedRecipients: undefined },
184+
});
185+
186+
await mockedDeliveryDelay(log, statsd).handleDeliveryDelay(mockMsg);
187+
188+
sinon.assert.calledOnce(statsd.increment);
189+
assert.equal(log.info.callCount, 0);
190+
sinon.assert.calledOnce(mockMsg.del);
191+
});
192+
});

0 commit comments

Comments
 (0)