Skip to content

Commit 742ac01

Browse files
authored
Merge pull request #18212 from mozilla/FXA-10572-create-cloud-task-endpoint
feat(cloud tasks): add route handler for inactive notification cloud tasks
2 parents 9ba2f65 + b6ada73 commit 742ac01

13 files changed

Lines changed: 529 additions & 29 deletions

File tree

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

55
import { CloudTasksConfig } from './cloud-tasks.types';
6+
import { CloudTaskEmailType } from './send-email-tasks';
7+
8+
export type FxACloudTaskHeaders = {
9+
'fxa-cloud-task-delivery-time'?: string;
10+
};
611

712
/** Represents config specific for running cloud tasks */
813
export type DeleteAccountCloudTaskConfig = CloudTasksConfig & {
@@ -42,5 +47,5 @@ export type SendEmailCloudTaskConfig = CloudTasksConfig & {
4247

4348
export type SendEmailTaskPayload = {
4449
uid: string;
45-
emailType: string; // @TODO define type
50+
emailType: CloudTaskEmailType;
4651
};

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44
import { CloudTasksClient } from '@google-cloud/tasks';
55
import { CloudTaskOptions, CloudTasksConfig } from './cloud-tasks.types';
6+
import { FxACloudTaskHeaders } from './account-tasks.types';
67

78
/** Base class for encapsulating common cloud task operations */
89
export class CloudTasks {
@@ -25,6 +26,7 @@ export class CloudTasks {
2526
protected async enqueueTask(
2627
opts: {
2728
taskPayload: unknown;
29+
taskHeaders?: FxACloudTaskHeaders;
2830
taskUrl: string;
2931
queueName: string;
3032
},
@@ -43,7 +45,10 @@ export class CloudTasks {
4345
httpRequest: {
4446
url: opts.taskUrl,
4547
httpMethod: 1, // HttpMethod.POST
46-
headers: { 'Content-Type': 'application/json' },
48+
headers: {
49+
'Content-Type': 'application/json',
50+
...(opts.taskHeaders ?? {}),
51+
},
4752
body: Buffer.from(JSON.stringify(opts.taskPayload)).toString(
4853
'base64'
4954
),
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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 { EmailTypes, SendEmailTasks } from './send-email-tasks';
6+
import { SendEmailTasksFactory } from './account-tasks.factories';
7+
8+
const now = 1736500000000;
9+
jest.useFakeTimers({ now });
10+
11+
describe('send-email-tasks', () => {
12+
const mockStatsd = {
13+
increment: jest.fn(),
14+
};
15+
16+
const mockCloudClient = {
17+
getTask: jest.fn(),
18+
createTask: jest.fn(),
19+
};
20+
21+
const mockConfig = {
22+
cloudTasks: {
23+
useLocalEmulator: true,
24+
projectId: 'pid123',
25+
locationId: 'lid123',
26+
credentials: {
27+
keyFilename: 'foo.cred',
28+
},
29+
oidc: {
30+
aud: 'foo',
31+
serviceAccountEmail: '[email protected]',
32+
},
33+
sendEmails: {
34+
taskUrl: 'http://localhost:9000/v1/cloud-tasks/emails/notify-inactive',
35+
queueName: 'notification-emails',
36+
},
37+
},
38+
publicUrl: 'http://localhost:9000',
39+
apiVersion: '1',
40+
};
41+
42+
describe('factories', () => {
43+
it('produces SendEmailTasks', () => {
44+
const sendEmailTasks = SendEmailTasksFactory(mockConfig, mockStatsd);
45+
expect(sendEmailTasks).toBeDefined();
46+
});
47+
});
48+
49+
describe('send email tasks', () => {
50+
const mockSendEmailPayload = {
51+
uid: 'act0123456789',
52+
emailType: EmailTypes.INACTIVE_DELETE_FIRST_NOTIFICATION,
53+
};
54+
const mockTaskOptions = {
55+
taskId: 'act0123456789-inactive-delete-notification',
56+
};
57+
58+
let sendEmailTasks: SendEmailTasks;
59+
60+
beforeEach(() => {
61+
sendEmailTasks = new SendEmailTasks(
62+
mockConfig,
63+
mockCloudClient,
64+
mockStatsd
65+
);
66+
});
67+
68+
it('creates email task with same delivery and schedule time', async () => {
69+
mockCloudClient.createTask.mockImplementation(() => {
70+
return [{ name: 'task123' }];
71+
});
72+
73+
const taskName = await sendEmailTasks.sendEmail({
74+
payload: mockSendEmailPayload,
75+
taskOptions: mockTaskOptions,
76+
});
77+
expect(taskName).toEqual('task123');
78+
expect(mockStatsd.increment).toBeCalledWith(
79+
'cloud-tasks.send-email.enqueue.success',
80+
['inactiveDeleteFirstNotification']
81+
);
82+
expect(mockCloudClient.createTask).toBeCalledWith({
83+
parent: `projects/${mockConfig.cloudTasks.projectId}/locations/${mockConfig.cloudTasks.locationId}/queues/${mockConfig.cloudTasks.sendEmails.queueName}`,
84+
task: {
85+
httpRequest: {
86+
url: mockConfig.cloudTasks.sendEmails.taskUrl,
87+
httpMethod: 1, // POST
88+
headers: {
89+
'Content-Type': 'application/json',
90+
'fxa-cloud-task-delivery-time': now.toString(),
91+
},
92+
body: Buffer.from(JSON.stringify(mockSendEmailPayload)).toString(
93+
'base64'
94+
),
95+
oidcToken: {
96+
audience: mockConfig.cloudTasks.oidc.aud,
97+
serviceAccountEmail:
98+
mockConfig.cloudTasks.oidc.serviceAccountEmail,
99+
},
100+
},
101+
name: 'projects/pid123/locations/lid123/queues/notification-emails/tasks/act0123456789-inactive-delete-notification',
102+
scheduleTime: {
103+
seconds: now / 1000,
104+
},
105+
},
106+
});
107+
});
108+
109+
it('creates email task with delivery beyond schedule time', async () => {
110+
mockCloudClient.createTask.mockImplementation(() => {
111+
return [{ name: 'task123' }];
112+
});
113+
114+
await sendEmailTasks.sendEmail({
115+
payload: mockSendEmailPayload,
116+
emailOptions: { deliveryTime: now + 60 * 24 * 60 * 60 * 1000 },
117+
taskOptions: mockTaskOptions,
118+
});
119+
expect(mockStatsd.increment).toBeCalledWith(
120+
'cloud-tasks.send-email.enqueue.success',
121+
['inactiveDeleteFirstNotification']
122+
);
123+
expect(mockCloudClient.createTask).toBeCalledWith({
124+
parent: `projects/${mockConfig.cloudTasks.projectId}/locations/${mockConfig.cloudTasks.locationId}/queues/${mockConfig.cloudTasks.sendEmails.queueName}`,
125+
task: {
126+
httpRequest: {
127+
url: mockConfig.cloudTasks.sendEmails.taskUrl,
128+
httpMethod: 1, // POST
129+
headers: {
130+
'Content-Type': 'application/json',
131+
'fxa-cloud-task-delivery-time': '1741684000000',
132+
},
133+
body: Buffer.from(JSON.stringify(mockSendEmailPayload)).toString(
134+
'base64'
135+
),
136+
oidcToken: {
137+
audience: mockConfig.cloudTasks.oidc.aud,
138+
serviceAccountEmail:
139+
mockConfig.cloudTasks.oidc.serviceAccountEmail,
140+
},
141+
},
142+
name: 'projects/pid123/locations/lid123/queues/notification-emails/tasks/act0123456789-inactive-delete-notification',
143+
scheduleTime: {
144+
seconds: 1739092000,
145+
},
146+
},
147+
});
148+
});
149+
150+
it('reports send email task failure', async () => {
151+
mockCloudClient.createTask.mockImplementation(() => {
152+
throw new Error('BOOM');
153+
});
154+
await expect(
155+
sendEmailTasks.sendEmail({
156+
payload: mockSendEmailPayload,
157+
taskOptions: mockTaskOptions,
158+
})
159+
).rejects.toThrow('BOOM');
160+
expect(mockStatsd.increment).toHaveBeenLastCalledWith(
161+
'cloud-tasks.send-email.enqueue.failure',
162+
['inactiveDeleteFirstNotification']
163+
);
164+
});
165+
});
166+
});

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

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@ import { StatsD } from 'hot-shots';
66
import { CloudTasks } from './cloud-tasks';
77
import { CloudTasksClient } from '@google-cloud/tasks';
88
import {
9+
FxACloudTaskHeaders,
910
SendEmailCloudTaskConfig,
1011
SendEmailTaskPayload,
1112
} from './account-tasks.types';
1213
import { CloudTaskOptions } from './cloud-tasks.types';
1314

15+
export enum EmailTypes {
16+
INACTIVE_DELETE_FIRST_NOTIFICATION = 'inactiveDeleteFirstNotification',
17+
}
18+
export type CloudTaskEmailType = (typeof EmailTypes)[keyof typeof EmailTypes];
19+
20+
const thirtyDaysInMs = 30 * 24 * 60 * 60 * 1000;
21+
1422
export class SendEmailTasks extends CloudTasks {
1523
constructor(
1624
protected override config: SendEmailCloudTaskConfig,
@@ -25,28 +33,49 @@ export class SendEmailTasks extends CloudTasks {
2533
*
2634
* @returns A taskName
2735
*/
28-
public async sendEmail(
29-
sendEmailTask: SendEmailTaskPayload,
30-
taskOptions?: CloudTaskOptions
31-
) {
36+
public async sendEmail(task: {
37+
payload: SendEmailTaskPayload;
38+
emailOptions?: { deliveryTime: number };
39+
taskOptions?: CloudTaskOptions;
40+
}) {
41+
// schedule time is when the task is dispatched and there's a limit of
42+
// 30 days. delivery time is when we want to send the email by
43+
// handling the task.
44+
const now = Date.now();
45+
const inThirtyDays = now + thirtyDaysInMs;
46+
const deliveryTime = task.emailOptions?.deliveryTime || now;
47+
const scheduleTime = Math.min(deliveryTime, inThirtyDays);
48+
49+
const taskHeaders: FxACloudTaskHeaders = {
50+
'fxa-cloud-task-delivery-time': deliveryTime.toString(),
51+
};
52+
53+
const taskOptions: CloudTaskOptions = {
54+
...task.taskOptions,
55+
scheduleTime: {
56+
seconds: scheduleTime / 1000,
57+
},
58+
};
59+
3260
try {
3361
const result = await this.enqueueTask(
3462
{
3563
queueName: this.config.cloudTasks.sendEmails.queueName,
3664
taskUrl: this.config.cloudTasks.sendEmails.taskUrl,
37-
taskPayload: sendEmailTask,
65+
taskPayload: task.payload,
66+
taskHeaders,
3867
},
3968
taskOptions
4069
);
4170
const taskName = result[0].name;
4271

4372
this.statsd.increment('cloud-tasks.send-email.enqueue.success', [
44-
sendEmailTask.emailType,
73+
task.payload.emailType,
4574
]);
4675
return taskName;
4776
} catch (err) {
4877
this.statsd.increment('cloud-tasks.send-email.enqueue.failure', [
49-
sendEmailTask.emailType,
78+
task.payload.emailType,
5079
]);
5180
throw err;
5281
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const {
5252
TwilioFactory,
5353
} = require('@fxa/accounts/recovery-phone');
5454
const { setupAccountDatabase } = require('@fxa/shared/db/mysql/account');
55+
const { EmailCloudTaskManager } = require('../lib/email-cloud-tasks');
5556

5657
async function run(config) {
5758
Container.set(AppConfig, config);
@@ -257,6 +258,9 @@ async function run(config) {
257258
});
258259
Container.set(AccountDeleteManager, accountDeleteManager);
259260

261+
const emailCloudTaskManager = new EmailCloudTaskManager({ config, statsd });
262+
Container.set(EmailCloudTaskManager, emailCloudTaskManager);
263+
260264
const profile = new ProfileClient(log, {
261265
...config.profileServer,
262266
serviceName: 'subhub',

packages/fxa-auth-server/docs/swagger/shared/descriptions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const DESCRIPTIONS = {
4646
'The salt used when creating authPW. If not provided, it will be assumed that version one of the password encryption scheme was used.',
4747
clientSecret:
4848
'The OAuth client secret for the requesting client application. Required for confidential clients, forbidden for public clients.',
49+
cloudTaskEmailType: 'An email type that can be sent with cloud tasks.',
4950
code: 'Time based code to verify secondary email',
5051
codeOauth:
5152
'A string that the client will trade with the [token][] endpoint. Codes have a configurable expiration value, default is 15 minutes. Codes are single use only.',

0 commit comments

Comments
 (0)