Skip to content

Commit c284b33

Browse files
authored
Merge pull request #19799 from mozilla/fxa-12792
feat(emails): Add support to handle delivery delayed email notifictions
2 parents a200fdb + 5ef97d9 commit c284b33

5 files changed

Lines changed: 413 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
@@ -771,6 +771,12 @@ const convictConf = convict({
771771
env: 'DELIVERY_QUEUE_URL',
772772
default: '',
773773
},
774+
deliveryDelayQueueUrl: {
775+
doc: 'The email delivery delay queue URL to use (should include https://sqs.<region>.amazonaws.com/<account-id>/<queue-name>)',
776+
format: String,
777+
env: 'DELIVERY_DELAY_QUEUE_URL',
778+
default: '',
779+
},
774780
notificationQueueUrl: {
775781
doc: 'Queue URL for notifications from fxa-email-service (eventually this will be the only email-related queue)',
776782
format: String,
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
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+
/**
6+
* Handles AWS SES Delivery Delay notifications from SQS.
7+
*
8+
* Delivery delays are TRANSIENT failures where email delivery is temporarily delayed
9+
* but may eventually succeed. This differs from bounces which are PERMANENT failures.
10+
* Delays can occur due to mailbox full, temporary network issues, rate limiting, etc.
11+
*
12+
* Integration Requirements:
13+
* - AWS SES must be configured to publish DeliveryDelay notifications to an SQS queue
14+
* - Environment variable DELIVERY_DELAY_QUEUE_URL must point to the queue
15+
* - SQS queue must have proper IAM permissions for the auth-server to consume messages
16+
*/
17+
18+
import { StatsD } from 'hot-shots';
19+
import { Logger } from 'mozlog';
20+
import { EventEmitter } from 'events';
21+
import * as utils from './utils/helpers';
22+
23+
interface SESMailHeader {
24+
name: string;
25+
value: string;
26+
}
27+
28+
interface SESMail {
29+
timestamp: string;
30+
messageId: string;
31+
source: string;
32+
headers?: SESMailHeader[];
33+
}
34+
35+
interface DelayedRecipient {
36+
emailAddress: string;
37+
status?: string;
38+
diagnosticCode?: string;
39+
}
40+
41+
/**
42+
* AWS SES Delivery Delay types as documented in:
43+
* https://docs.aws.amazon.com/ses/latest/dg/event-publishing-retrieving-sns-contents.html
44+
*/
45+
interface DeliveryDelay {
46+
delayType:
47+
| 'InternalFailure'
48+
| 'General'
49+
| 'MailboxFull'
50+
| 'SpamDetected'
51+
| 'RecipientServerError'
52+
| 'IPFailure'
53+
| 'TransientCommunicationFailure'
54+
| 'BYOIPHostNameLookupUnavailable'
55+
| 'Undetermined'
56+
| 'SendingDeferral';
57+
delayedRecipients: DelayedRecipient[];
58+
expirationTime?: string;
59+
reportingMTA?: string;
60+
timestamp?: string;
61+
}
62+
63+
interface SESDeliveryDelayMessage {
64+
eventType?: 'DeliveryDelay';
65+
notificationType?: 'DeliveryDelay';
66+
deliveryDelay: DeliveryDelay;
67+
mail: SESMail;
68+
del: () => void;
69+
}
70+
71+
interface SQSReceiver extends EventEmitter {
72+
start: () => void;
73+
}
74+
75+
export = function (log: Logger, statsd: StatsD) {
76+
return function start(deliveryDelayQueue: SQSReceiver) {
77+
async function handleDeliveryDelay(message: SESDeliveryDelayMessage) {
78+
try {
79+
utils.logErrorIfHeadersAreWeirdOrMissing(log, message, 'deliveryDelay');
80+
81+
// Track message age to monitor how long delays persist
82+
let messageAgeSeconds = 0;
83+
if (message.mail?.timestamp) {
84+
const mailTimestamp = new Date(message.mail.timestamp).getTime();
85+
const now = Date.now();
86+
messageAgeSeconds = Math.floor((now - mailTimestamp) / 1000);
87+
statsd.timing('email.deliveryDelay.ageSeconds', messageAgeSeconds);
88+
}
89+
90+
statsd.increment('email.deliveryDelay.message', {
91+
delayType: message?.deliveryDelay?.delayType || 'none',
92+
hasExpiration: String(!!message?.deliveryDelay?.expirationTime),
93+
template: utils.getHeaderValue('X-Template-Name', message) || 'none',
94+
});
95+
96+
let recipients: DelayedRecipient[] = [];
97+
if (
98+
message.deliveryDelay &&
99+
(message.eventType === 'DeliveryDelay' ||
100+
message.notificationType === 'DeliveryDelay')
101+
) {
102+
recipients = message.deliveryDelay.delayedRecipients || [];
103+
}
104+
105+
const templateName = utils.getHeaderValue('X-Template-Name', message);
106+
const language = utils.getHeaderValue('Content-Language', message);
107+
const delayType = message.deliveryDelay?.delayType;
108+
const expirationTime = message.deliveryDelay?.expirationTime;
109+
const reportingMTA = message.deliveryDelay?.reportingMTA;
110+
const timestamp = message.deliveryDelay?.timestamp;
111+
112+
for (const recipient of recipients) {
113+
const email = recipient.emailAddress;
114+
const emailDomain = utils.getAnonymizedEmailDomain(email);
115+
const logData: {
116+
email: string;
117+
domain: string;
118+
delayType?: DeliveryDelay['delayType'];
119+
status?: string;
120+
diagnosticCode?: string;
121+
template?: string;
122+
lang?: string;
123+
expirationTime?: string;
124+
reportingMTA?: string;
125+
timestamp?: string;
126+
messageAgeSeconds?: number;
127+
} = {
128+
email: email,
129+
domain: emailDomain,
130+
delayType: delayType,
131+
};
132+
133+
if (recipient.status) {
134+
logData.status = recipient.status;
135+
}
136+
if (recipient.diagnosticCode) {
137+
logData.diagnosticCode = recipient.diagnosticCode;
138+
}
139+
140+
if (templateName) {
141+
logData.template = templateName;
142+
}
143+
144+
if (language) {
145+
logData.lang = language;
146+
}
147+
148+
if (expirationTime) {
149+
logData.expirationTime = expirationTime;
150+
}
151+
152+
if (reportingMTA) {
153+
logData.reportingMTA = reportingMTA;
154+
}
155+
156+
if (timestamp) {
157+
logData.timestamp = timestamp;
158+
}
159+
160+
if (messageAgeSeconds > 0) {
161+
logData.messageAgeSeconds = messageAgeSeconds;
162+
}
163+
164+
utils.logAccountEventFromMessage(message, 'emailDelayed');
165+
166+
log.info('handleDeliveryDelay', logData);
167+
}
168+
169+
message.del();
170+
} catch (err) {
171+
// Log error but still delete message to prevent infinite retry loop
172+
log.error('handleDeliveryDelay.error', {
173+
err: err,
174+
messageId: message?.mail?.messageId,
175+
});
176+
statsd.increment('email.deliveryDelay.error');
177+
message.del();
178+
}
179+
}
180+
181+
deliveryDelayQueue.on('data', handleDeliveryDelay);
182+
deliveryDelayQueue.start();
183+
184+
return {
185+
deliveryDelayQueue: deliveryDelayQueue,
186+
handleDeliveryDelay: handleDeliveryDelay,
187+
};
188+
};
189+
};

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);

0 commit comments

Comments
 (0)