Skip to content

Commit a3d9e7a

Browse files
authored
Merge pull request #19865 from mozilla/double-match-on-bounce-check
chore(bounces): Use a normalized and aliased email to search bounces
2 parents ba48315 + 6e1f849 commit a3d9e7a

File tree

3 files changed

+67
-12
lines changed

3 files changed

+67
-12
lines changed

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -614,12 +614,18 @@ const convictConf = convict({
614614
},
615615
emailAliasNormalization: {
616616
default: JSON.stringify([
617-
{ domain: 'mozilla.com', regex: '\\+.*', replace: '%' },
617+
{ domain: 'mozilla.com', regex: '\\+.*', replace: '' },
618618
]),
619-
doc: 'List of email domain configurations for alias normalization. Each entry should have domain, regex, and replace properties. Example: [{domain: "mozilla.com", regex: "\\+[^@]+" }].',
619+
doc: 'List of email domain configurations for alias normalization. Each entry should have domain, regex, and replace properties. The replace value is used for the root email (strip alias), and can be overridden with a wildcard pattern at runtime. Example: [{domain: "mozilla.com", regex: "\\+.*", replace: "" }].',
620620
env: 'BOUNCES_EMAIL_ALIAS_NORMALIZATION',
621621
format: String,
622622
},
623+
aliasCheckEnabled: {
624+
doc: 'Flag to enable checking email bounces on normalized email aliases',
625+
format: Boolean,
626+
default: false,
627+
env: 'BOUNCES_ALIAS_CHECK_ENABLED',
628+
},
623629
},
624630
connectionTimeout: {
625631
doc: 'Milliseconds to wait for the connection to establish (default is 2 minutes)',

packages/fxa-auth-server/lib/bounces.js

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ module.exports = (config, db) => {
1111
const configBounces = (config.smtp && config.smtp.bounces) || {};
1212
const ignoreTemplates = configBounces.ignoreTemplates || [];
1313
const BOUNCES_ENABLED = !!configBounces.enabled;
14+
const BOUNCES_ALIAS_CHECK_ENABLED = !!configBounces.aliasCheckEnabled;
1415

1516
const BOUNCE_TYPE_HARD = 1;
1617
const BOUNCE_TYPE_SOFT = 2;
@@ -38,17 +39,58 @@ module.exports = (config, db) => {
3839
return;
3940
}
4041

41-
// This strips out 'alias' stuff from an email and replace
42-
// them with wildcards, allowing us to turn up bounce records
43-
// on email aliases.
44-
const normalizedEmail = emailNormalization.normalizeEmailAliases(
45-
email,
46-
'%'
47-
);
48-
const bounces = await db.emailBounces(normalizedEmail);
42+
let bounces;
43+
44+
if (BOUNCES_ALIAS_CHECK_ENABLED) {
45+
bounces = await checkBouncesWithAliases(email);
46+
} else {
47+
bounces = await db.emailBounces(email);
48+
}
49+
4950
return applyRules(bounces);
5051
}
5152

53+
async function checkBouncesWithAliases(email) {
54+
// Given an email alias like test+123@domain.com:
55+
// We look for bounces to the 'root' email -> `test@domain.com`
56+
// And look for bounces to the alias with a wildcard -> `test+%@domain.com`
57+
//
58+
// This prevents us from picking up false positives when we replace the alias
59+
// with a wildcard, and doesn't miss the root email bounces either. We have to
60+
// use both because just using the wildcard would miss bounces sent to the root
61+
// and just using the root with a wildcard would pickup false positives.
62+
//
63+
// So, test+123@domain.com would match:
64+
// - test@domain.com Covered by normalized email
65+
// - test+123@domain.com Covered by wildcard email
66+
// - test+asdf@domain.com Covered by wildcard email
67+
// but not
68+
// - testing@domain.com Not picked up by wildcard since we include the '+'
69+
const normalizedEmail = emailNormalization.normalizeEmailAliases(email, '');
70+
const wildcardEmail = emailNormalization.normalizeEmailAliases(email, '+%');
71+
72+
const [normalizedBounces, wildcardBounces] = await Promise.all([
73+
db.emailBounces(normalizedEmail),
74+
db.emailBounces(wildcardEmail),
75+
]);
76+
77+
// Merge and deduplicate by email+createdAt
78+
// there shouldn't be any overlap, but just in case
79+
const seen = new Set();
80+
const merged = [...normalizedBounces, ...wildcardBounces].filter(
81+
(bounce) => {
82+
const key = `${bounce.email}:${bounce.createdAt}`;
83+
if (seen.has(key)) {
84+
return false;
85+
}
86+
seen.add(key);
87+
return true;
88+
}
89+
);
90+
91+
return merged.sort((a, b) => b.createdAt - a.createdAt);
92+
}
93+
5294
// Relies on the order of the bounces array to be sorted by date,
5395
// descending. So, each bounce in the array must be older than the
5496
// previous.

packages/fxa-shared/email/email-normalization.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,14 @@ export class EmailNormalization {
3333
}
3434
}
3535

36-
normalizeEmailAliases(email: string): string {
36+
/**
37+
* Normalizes email aliases by applying configured regex replacements.
38+
* Optionally, a replacement string can be overridden.
39+
* @param email The email address to normalize.
40+
* @param replaceOverride Optional string to replace matched aliases.
41+
* @returns The normalized email address.
42+
*/
43+
normalizeEmailAliases(email: string, replaceOverride?: string): string {
3744
email = email?.trim()?.toLocaleLowerCase() || '';
3845
const parts = email.split('@');
3946
if (parts?.length !== 2) {
@@ -48,7 +55,7 @@ export class EmailNormalization {
4855
this.emailTransforms
4956
.filter((x) => x.domain === domain)
5057
.forEach((x) => {
51-
email = email.replace(x.regex, x.replace);
58+
email = email.replace(x.regex, replaceOverride || x.replace);
5259
});
5360

5461
return `${email}@${domain}`;

0 commit comments

Comments
 (0)