Skip to content

Commit 5f91300

Browse files
committed
feat(inactives): check for email bounce
Because: - we want to delete an inactive account if the first email notification was hard bounced This commit: - checks whether the first email hard bounced prior to sending the second email, and deletes the account if so
1 parent fd535ba commit 5f91300

6 files changed

Lines changed: 260 additions & 11 deletions

File tree

libs/shared/cloud-tasks/src/lib/account-tasks.types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,16 @@ export enum ReasonForDeletion {
2424
UserRequested = 'fxa_user_requested_account_delete',
2525
Unverified = 'fxa_unverified_account_delete',
2626
Cleanup = 'fxa_cleanup_account_delete',
27-
InactiveAccount = 'fxa_inactive_account_delete',
27+
InactiveAccountScheduled = 'fxa_inactive_account_scheduled_delete',
28+
InactiveAccountEmailBounced = 'fxa_inactive_account_email_bounced_delete',
2829
}
2930

3031
/** Task payload requesting an account deletion */
3132
export type DeleteAccountTask = {
3233
/** The account id */
3334
uid: string;
3435
/** The customer id, i.e. a stripe customer id if applicable */
35-
customerId: string | undefined;
36+
customerId?: string;
3637
/** Reason for deletion */
3738
reason: ReasonForDeletion;
3839
};

packages/fxa-auth-server/lib/db.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -366,9 +366,13 @@ export const createDB = (
366366
// TODO delete me
367367
emailRecord = this.accountRecord;
368368

369-
async account(uid: string): Promise<Account> {
369+
async account(
370+
uid: string
371+
): Promise<Account & Required<Pick<Account, 'emails'>>> {
370372
log.trace('DB.account', { uid });
371-
const account = await Account.findByUid(uid, { include: ['emails'] });
373+
const account = (await Account.findByUid(uid, {
374+
include: ['emails'],
375+
})) as Account & Required<Pick<Account, 'emails'>>;
372376
if (!account) {
373377
this.metrics?.increment('db.account.retrieve', { result: 'notFound' });
374378
throw error.unknownAccount();

packages/fxa-auth-server/lib/inactive-accounts/index.ts

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44

55
import { Container } from 'typedi';
66
import { StatsD } from 'hot-shots';
7+
import isEqual from 'lodash/isEqual';
78

89
import {
910
CloudTaskOptions,
1011
EmailTypes,
1112
SendEmailTaskPayload,
1213
InactiveAccountEmailTasks,
1314
InactiveAccountEmailTasksFactory,
15+
ReasonForDeletion,
16+
DeleteAccountTasks,
1417
} from '@fxa/shared/cloud-tasks';
1518
import { ConnectedServicesDb } from 'fxa-shared/connected-services';
1619

@@ -27,6 +30,11 @@ import {
2730
} from './active-status';
2831
import { GleanMetricsType } from '../metrics/glean';
2932
import { Logger } from 'mozlog';
33+
import {
34+
EmailBounce,
35+
getAccountCustomerByUid,
36+
} from 'fxa-shared/db/models/auth';
37+
import { BOUNCE_TYPES } from 'fxa-shared/db/models/auth/email-bounce';
3038

3139
const aDayInMs = 24 * 60 * 60 * 1000;
3240
const sixtyDaysInMs = 60 * aDayInMs;
@@ -130,6 +138,7 @@ export class InactiveAccountsManager {
130138
fxaDb: DB;
131139
oauthDb: ConnectedServicesDb;
132140
accountEventsManager: AccountEventsManager;
141+
accountTasks: DeleteAccountTasks;
133142
mailer: any;
134143
emailCloudTasks: InactiveAccountEmailTasks;
135144
statsd: StatsD;
@@ -156,6 +165,7 @@ export class InactiveAccountsManager {
156165
this.fxaDb = fxaDb;
157166
this.oauthDb = oauthDb;
158167
this.accountEventsManager = Container.get(AccountEventsManager);
168+
this.accountTasks = Container.get(DeleteAccountTasks);
159169
this.mailer = mailer;
160170
this.emailCloudTasks = InactiveAccountEmailTasksFactory(config, statsd);
161171
this.statsd = statsd;
@@ -216,9 +226,21 @@ export class InactiveAccountsManager {
216226
return;
217227
}
218228

229+
const account = await this.fxaDb.account(taskPayload.uid);
230+
const now = Date.now();
231+
232+
// If the very first email hard bounced, we'll delete the account without
233+
// sending further emails.
234+
if (
235+
taskPayload.emailType ===
236+
EmailTypes.INACTIVE_DELETE_SECOND_NOTIFICATION &&
237+
(await this.handleFirstEmailBounce(taskPayload, account, now))
238+
) {
239+
return;
240+
}
241+
219242
// Check to see if we have sent this email before in the current
220243
// notify-and-delete cycle
221-
const now = Date.now();
222244
const sentEmailEvents = await this.accountEventsManager.findEmailEvents(
223245
taskPayload.uid,
224246
'emailSent',
@@ -251,7 +273,6 @@ export class InactiveAccountsManager {
251273
return;
252274
}
253275

254-
const account = await this.fxaDb.account(taskPayload.uid);
255276
const message = {
256277
acceptLanguage: account.locale,
257278
inactiveDeletionEta: now + emailTypeSpecificVals.timeToDeletion,
@@ -331,4 +352,100 @@ export class InactiveAccountsManager {
331352
}
332353
}
333354
}
355+
356+
async handleFirstEmailBounce(taskPayload, account, now) {
357+
const emails = account.emails.map((e) => e.email);
358+
359+
// Because a small number of first notification emails were sent before
360+
// the email types were added to the database
361+
// (https://github.com/mozilla/fxa/pull/18362), we need to carve out an
362+
// exception for a faction of those accounts. This can be removed after
363+
// 2025-04-12.
364+
// Delete the corresponding test when deleting this if block.
365+
if (
366+
account.createdAt >=
367+
new Date('2023-01-01').setUTCHours(0, 0, 0, 0).valueOf() &&
368+
account.createdAt <
369+
new Date('2023-02-01').setUTCHours(0, 0, 0, 0).valueOf()
370+
) {
371+
// check to see if there's a bounced email in Firestore and there's a hard bounce in MySQL with no email type
372+
const bounceEvents = await this.accountEventsManager.findEmailEvents(
373+
taskPayload.uid,
374+
'emailBounced',
375+
'inactiveAccountFirstWarning',
376+
// some wiggle room to account for task and email delays
377+
now - 54 * aDayInMs,
378+
now - 52 * aDayInMs
379+
);
380+
381+
if (bounceEvents.length > 0) {
382+
const bounceTime = bounceEvents.map((x) => x.createdAt);
383+
const min = Math.min(...bounceTime);
384+
const max = Math.max(...bounceTime);
385+
const hardBounces = await EmailBounce.query()
386+
.whereIn('email', emails)
387+
.whereNull('emailTypeId')
388+
.where('bounceType', '=', BOUNCE_TYPES.Permanent)
389+
.where('createdAt', '>', min - 15000)
390+
.where('createdAt', '<', max + 15000)
391+
.select(['email', 'createdAt']);
392+
393+
if (bounceEvents.length === hardBounces.length) {
394+
await this.accountTasks.deleteAccount({
395+
uid: taskPayload.uid,
396+
customerId: (
397+
await getAccountCustomerByUid(taskPayload.uid)
398+
)?.stripeCustomerId,
399+
reason: ReasonForDeletion.InactiveAccountEmailBounced,
400+
});
401+
this.statsd.increment(`account.inactive.second-email.skipped.bounce`);
402+
await this.glean.inactiveAccountDeletion.secondEmailSkipped(
403+
requestForGlean,
404+
{
405+
uid: taskPayload.uid,
406+
reason: 'first_email_bounced',
407+
}
408+
);
409+
return true;
410+
}
411+
}
412+
}
413+
414+
const firstEmailBounces = await EmailBounce.query()
415+
.join('emailTypes', 'emailTypes.id', 'emailBounces.emailTypeId')
416+
.whereIn('emailBounces.email', emails)
417+
.where('emailBounces.bounceType', '=', BOUNCE_TYPES.Permanent)
418+
// some wiggle room to account for cloud task and email delays
419+
.where('emailBounces.createdAt', '>', now - 54 * aDayInMs)
420+
.where('emailBounces.createdAt', '<', now - 52 * aDayInMs)
421+
.where('emailTypes.emailType', '=', 'inactiveAccountFirstWarning')
422+
.select(['email']);
423+
424+
if (
425+
firstEmailBounces.length === emails.length &&
426+
isEqual(
427+
firstEmailBounces.map((x) => x.email),
428+
emails
429+
)
430+
) {
431+
await this.accountTasks.deleteAccount({
432+
uid: taskPayload.uid,
433+
customerId: (
434+
await getAccountCustomerByUid(taskPayload.uid)
435+
)?.stripeCustomerId,
436+
reason: ReasonForDeletion.InactiveAccountEmailBounced,
437+
});
438+
this.statsd.increment(`account.inactive.second-email.skipped.bounce`);
439+
await this.glean.inactiveAccountDeletion.secondEmailSkipped(
440+
requestForGlean,
441+
{
442+
uid: taskPayload.uid,
443+
reason: 'first_email_bounced',
444+
}
445+
);
446+
return true;
447+
}
448+
449+
return false;
450+
}
334451
}

packages/fxa-auth-server/pm2.config.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ const apps = [
2020
SIGNIN_UNBLOCK_FORCED_EMAILS: '^block.*@restmail\\.net$',
2121
SIGNIN_CONFIRMATION_ENABLED: 'true',
2222
SIGNIN_CONFIRMATION_FORCE_EMAIL_REGEX: '^sync.*@restmail\\.net$',
23-
FIRESTORE_EMULATOR_HOST: 'localhost:9090',
23+
FIRESTORE_EMULATOR_HOST:
24+
typeof process.env.FIRESTORE_EMULATOR_HOST !== 'undefined'
25+
? process.env.FIRESTORE_EMULATOR_HOST
26+
: 'localhost:9090',
2427
FORCE_PASSWORD_CHANGE_EMAIL_REGEX: 'forcepwdchange',
2528
CONFIG_FILES: 'config/secrets.json',
2629
EMAIL_CONFIG_USE_REDIS: 'false',

0 commit comments

Comments
 (0)