Skip to content

Commit 1023e54

Browse files
authored
Merge pull request #18248 from mozilla/FXA-10573-implement-first-email-notification
feat(cloud tasks): send first inactive deletion email
2 parents b291d3a + 89f72b5 commit 1023e54

19 files changed

Lines changed: 1157 additions & 58 deletions

File tree

libs/shared/cloud-tasks/src/lib/send-email-tasks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { CloudTaskOptions } from './cloud-tasks.types';
1414

1515
export enum EmailTypes {
1616
INACTIVE_DELETE_FIRST_NOTIFICATION = 'inactiveDeleteFirstNotification',
17+
INACTIVE_DELETE_SECOND_NOTIFICATION = 'inactiveDeleteSecondNotification',
1718
}
1819
export type CloudTaskEmailType = (typeof EmailTypes)[keyof typeof EmailTypes];
1920

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

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,16 @@ async function run(config) {
249249
);
250250
Container.set(RecoveryPhoneService, recoveryPhoneService);
251251

252+
const profile = new ProfileClient(log, {
253+
...config.profileServer,
254+
serviceName: 'subhub',
255+
});
256+
Container.set(ProfileClient, profile);
257+
258+
const bounces = require('../lib/bounces')(config, database);
259+
const senders = await require('../lib/senders')(log, config, bounces, statsd);
260+
const glean = gleanMetrics(config);
261+
252262
// The AccountDeleteManager is dependent on some of the object set into
253263
// Container above.
254264
const accountTasks = DeleteAccountTasksFactory(config, statsd);
@@ -263,16 +273,16 @@ async function run(config) {
263273
});
264274
Container.set(AccountDeleteManager, accountDeleteManager);
265275

266-
const emailCloudTaskManager = new EmailCloudTaskManager({ config, statsd });
267-
Container.set(EmailCloudTaskManager, emailCloudTaskManager);
268-
269-
const profile = new ProfileClient(log, {
270-
...config.profileServer,
271-
serviceName: 'subhub',
276+
const emailCloudTaskManager = new EmailCloudTaskManager({
277+
fxaDb: database,
278+
oauthDb,
279+
mailer: senders.email,
280+
config,
281+
statsd,
282+
glean,
283+
log,
272284
});
273-
Container.set(ProfileClient, profile);
274-
const bounces = require('../lib/bounces')(config, database);
275-
const senders = await require('../lib/senders')(log, config, bounces, statsd);
285+
Container.set(EmailCloudTaskManager, emailCloudTaskManager);
276286

277287
const serverPublicKeys = {
278288
primary: JWTool.JWK.fromFile(config.publicKeyFile, {
@@ -297,7 +307,6 @@ async function run(config) {
297307
const zendeskClient = require('../lib/zendesk-client').createZendeskClient(
298308
config
299309
);
300-
const glean = gleanMetrics(config);
301310
const routes = require('../lib/routes')(
302311
log,
303312
serverPublicKeys,

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,13 @@ const convictConf = convict({
495495
env: 'SUBSCRIPTION_TERMS_URL',
496496
format: String,
497497
},
498+
unsubscribeUrl: {
499+
doc: 'URL to unsubscribe from MoCo and MoFo emails',
500+
format: String,
501+
env: 'UNSUBSCRIBE_EMAIL_LISTS_URL',
502+
default:
503+
'https://privacyportal.onetrust.com/webform/1350748f-7139-405c-8188-22740b3b5587/4ba08202-2ede-4934-a89e-f0b0870f95f0',
504+
},
498505
sesConfigurationSet: {
499506
doc:
500507
'AWS SES Configuration Set for SES Event Publishing. If defined, ' +

packages/fxa-auth-server/lib/account-events.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ type AuthDatabase = {
3333
securityEvent: (arg: SecurityEvent) => void;
3434
};
3535

36+
type EmailEventName =
37+
| 'emailSent'
38+
| 'emailDelivered'
39+
| 'emailBounced'
40+
| 'emailComplaint';
41+
3642
export class AccountEventsManager {
3743
private firestore?: Firestore;
3844
private usersDbRef?;
@@ -64,7 +70,7 @@ export class AccountEventsManager {
6470
public async recordEmailEvent(
6571
uid: string,
6672
message: EmailEvent,
67-
name: 'emailSent' | 'emailDelivered' | 'emailBounced' | 'emailComplaint'
73+
name: EmailEventName
6874
) {
6975
try {
7076
const { template, deviceId, flowId, service } = message;
@@ -93,6 +99,26 @@ export class AccountEventsManager {
9399
}
94100
}
95101

102+
public async findEmailEvents(
103+
uid: string,
104+
eventName: EmailEventName,
105+
template: string,
106+
startDate: number,
107+
endDate: number
108+
) {
109+
const query = this.usersDbRef
110+
?.doc(uid)
111+
.collection('events')
112+
.where('eventType', '==', 'emailEvent')
113+
.where('name', '==', eventName)
114+
.where('template', '==', template)
115+
.where('createdAt', '>=', startDate)
116+
.where('createdAt', '<=', endDate);
117+
118+
const snapshot = await query?.get();
119+
return snapshot?.docs.map((doc) => doc.data());
120+
}
121+
96122
/**
97123
* Record a security event for the user. This is based on our security events
98124
* that are stored in MySQL.

packages/fxa-auth-server/lib/email-cloud-tasks.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { StatsD } from 'hot-shots';
66

77
import {
8+
EmailTypes,
89
SendEmailTaskPayload,
910
SendEmailTasks,
1011
SendEmailTasksFactory,
@@ -13,6 +14,12 @@ import {
1314
import { ConfigType } from '../config';
1415
import { AuthRequest } from './types';
1516
import { IncomingHttpHeaders } from 'http';
17+
import AppError from './error';
18+
import { InactiveAccountsManager } from './inactive-accounts';
19+
import { DB } from './db';
20+
import { ConnectedServicesDb } from 'fxa-shared/connected-services';
21+
import { GleanMetricsType } from './metrics/glean';
22+
import { Logger } from 'mozlog';
1623

1724
const fxaCloudTaskDeliveryTimeHeaderName = 'fxa-cloud-task-delivery-time';
1825

@@ -51,12 +58,38 @@ export class EmailCloudTaskManager {
5158
private config: ConfigType;
5259
private statsd: StatsD;
5360
private emailCloudTasks: SendEmailTasks;
54-
55-
constructor({ config, statsd }) {
61+
private inactiveAccountsManager: InactiveAccountsManager;
62+
63+
constructor({
64+
config,
65+
statsd,
66+
mailer,
67+
fxaDb,
68+
oauthDb,
69+
glean,
70+
log,
71+
}: {
72+
config: ConfigType;
73+
statsd: StatsD;
74+
mailer: any;
75+
fxaDb: DB;
76+
oauthDb: ConnectedServicesDb;
77+
glean: GleanMetricsType;
78+
log: Logger;
79+
}) {
5680
this.config = config;
5781
this.statsd = statsd;
5882

5983
this.emailCloudTasks = SendEmailTasksFactory(config, statsd);
84+
this.inactiveAccountsManager = new InactiveAccountsManager({
85+
fxaDb,
86+
oauthDb,
87+
mailer,
88+
config,
89+
statsd,
90+
glean,
91+
log,
92+
});
6093
}
6194

6295
async handleInactiveAccountNotification(request: AuthRequest) {
@@ -87,6 +120,18 @@ export class EmailCloudTaskManager {
87120
return;
88121
}
89122

90-
// @TODO FXA-10573, FXA-10574, FXA-10942
123+
// @TODO FXA-10574, FXA-10942
124+
switch ((request.payload as SendEmailTaskPayload).emailType) {
125+
case EmailTypes.INACTIVE_DELETE_FIRST_NOTIFICATION:
126+
await this.inactiveAccountsManager.handleFirstNotificationTask(
127+
request.payload as SendEmailTaskPayload
128+
);
129+
break;
130+
default:
131+
// the payload is validated before it reaches the handler, so in the
132+
// normal course of handling a cloud task, this should not happen. but
133+
// this code can also be called from outside of the request handler.
134+
throw AppError.invalidCloudTaskEmailType();
135+
}
91136
}
92137
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1574,6 +1574,15 @@ AppError.subscriptionPromotionCodeNotApplied = (error, message) => {
15741574
);
15751575
};
15761576

1577+
AppError.invalidCloudTaskEmailType = function () {
1578+
return new AppError({
1579+
code: 400,
1580+
error: 'Bad Request',
1581+
errno: ERRNO.INVALID_CLOUDTASK_EMAILTYPE,
1582+
message: 'Invalid email type',
1583+
});
1584+
};
1585+
15771586
function decorateErrorWithRequest(error, request) {
15781587
if (request) {
15791588
error.output.payload.request = {
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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 { SessionToken } from 'fxa-shared/connected-services/models/SessionToken';
6+
import { Email, SecurityEvent } from 'fxa-shared/db/models/auth';
7+
import { EVENT_NAMES } from 'fxa-shared/db/models/auth/security-event';
8+
9+
export type GetTokensFn<T> = (uid: string) => Promise<T[]>;
10+
export type ActiveConditionFn = (
11+
uid: string,
12+
activeByDateTimestamp: number
13+
) => Promise<boolean> | Promise<Promise<boolean>>;
14+
export type NoTimestampActiveConditionFn = (
15+
uid: string
16+
) => Promise<boolean> | Promise<Promise<boolean>>;
17+
18+
// this includes the agumented last access time from redis
19+
export const hasActiveSessionToken = async (
20+
tokensFn: GetTokensFn<SessionToken>,
21+
uid: string,
22+
activeByDateTimestamp: number
23+
) => {
24+
const sessionTokens = await tokensFn(uid);
25+
return sessionTokens.some(
26+
(token) =>
27+
token.lastAccessTime && token.lastAccessTime >= activeByDateTimestamp
28+
);
29+
};
30+
export const hasActiveRefreshToken = async (
31+
tokensFn: GetTokensFn<{ lastUsedAt: number }>,
32+
uid: string,
33+
activeByDateTimestamp: number
34+
) => {
35+
const refreshTokens = await tokensFn(uid);
36+
return refreshTokens.some((t) => t.lastUsedAt >= activeByDateTimestamp);
37+
};
38+
export const hasAccessToken = async (
39+
tokensFn: GetTokensFn<{ lastUsedAt: number }>,
40+
uid: string
41+
) => {
42+
const accessTokens = await tokensFn(uid);
43+
return accessTokens.length > 0;
44+
};
45+
46+
export const emailVerificationQuery = (uid, activeByDateTimestamp) =>
47+
Email.query()
48+
.select('uid')
49+
.where('uid', uid)
50+
.andWhere('verifiedAt', '>=', activeByDateTimestamp)
51+
.limit(1)
52+
.first();
53+
54+
export const securityEventsQuery = (uid, activeByDateTimestamp) =>
55+
SecurityEvent.query()
56+
.select('uid')
57+
.where('uid', uid)
58+
.andWhere('createdAt', '>=', activeByDateTimestamp)
59+
.whereIn('nameId', [
60+
EVENT_NAMES['account.login'],
61+
EVENT_NAMES['account.password_reset_success'],
62+
EVENT_NAMES['account.password_changed'],
63+
])
64+
.limit(1)
65+
.first();

0 commit comments

Comments
 (0)