Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion libs/shared/cloud-tasks/src/lib/account-tasks.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { CloudTasksConfig } from './cloud-tasks.types';
import { CloudTaskEmailType } from './send-email-tasks';

export type FxACloudTaskHeaders = {
'fxa-cloud-task-delivery-time'?: string;
};

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

export type SendEmailTaskPayload = {
uid: string;
emailType: string; // @TODO define type
emailType: CloudTaskEmailType;
};
7 changes: 6 additions & 1 deletion libs/shared/cloud-tasks/src/lib/cloud-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { CloudTasksClient } from '@google-cloud/tasks';
import { CloudTaskOptions, CloudTasksConfig } from './cloud-tasks.types';
import { FxACloudTaskHeaders } from './account-tasks.types';

/** Base class for encapsulating common cloud task operations */
export class CloudTasks {
Expand All @@ -25,6 +26,7 @@ export class CloudTasks {
protected async enqueueTask(
opts: {
taskPayload: unknown;
taskHeaders?: FxACloudTaskHeaders;
taskUrl: string;
queueName: string;
},
Expand All @@ -43,7 +45,10 @@ export class CloudTasks {
httpRequest: {
url: opts.taskUrl,
httpMethod: 1, // HttpMethod.POST
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(opts.taskHeaders ?? {}),
},
body: Buffer.from(JSON.stringify(opts.taskPayload)).toString(
'base64'
),
Expand Down
166 changes: 166 additions & 0 deletions libs/shared/cloud-tasks/src/lib/send-email-tasks.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { EmailTypes, SendEmailTasks } from './send-email-tasks';
import { SendEmailTasksFactory } from './account-tasks.factories';

const now = 1736500000000;
jest.useFakeTimers({ now });

describe('send-email-tasks', () => {
const mockStatsd = {
increment: jest.fn(),
};

const mockCloudClient = {
getTask: jest.fn(),
createTask: jest.fn(),
};

const mockConfig = {
cloudTasks: {
useLocalEmulator: true,
projectId: 'pid123',
locationId: 'lid123',
credentials: {
keyFilename: 'foo.cred',
},
oidc: {
aud: 'foo',
serviceAccountEmail: 'foo@mozilla.com',
},
sendEmails: {
taskUrl: 'http://localhost:9000/v1/cloud-tasks/emails/notify-inactive',
queueName: 'notification-emails',
},
},
publicUrl: 'http://localhost:9000',
apiVersion: '1',
};

describe('factories', () => {
it('produces SendEmailTasks', () => {
const sendEmailTasks = SendEmailTasksFactory(mockConfig, mockStatsd);
expect(sendEmailTasks).toBeDefined();
});
});

describe('send email tasks', () => {
const mockSendEmailPayload = {
uid: 'act0123456789',
emailType: EmailTypes.INACTIVE_DELETE_FIRST_NOTIFICATION,
};
const mockTaskOptions = {
taskId: 'act0123456789-inactive-delete-notification',
};

let sendEmailTasks: SendEmailTasks;

beforeEach(() => {
sendEmailTasks = new SendEmailTasks(
mockConfig,
mockCloudClient,
mockStatsd
);
});

it('creates email task with same delivery and schedule time', async () => {
mockCloudClient.createTask.mockImplementation(() => {
return [{ name: 'task123' }];
});

const taskName = await sendEmailTasks.sendEmail({
payload: mockSendEmailPayload,
taskOptions: mockTaskOptions,
});
expect(taskName).toEqual('task123');
expect(mockStatsd.increment).toBeCalledWith(
'cloud-tasks.send-email.enqueue.success',
['inactiveDeleteFirstNotification']
);
expect(mockCloudClient.createTask).toBeCalledWith({
parent: `projects/${mockConfig.cloudTasks.projectId}/locations/${mockConfig.cloudTasks.locationId}/queues/${mockConfig.cloudTasks.sendEmails.queueName}`,
task: {
httpRequest: {
url: mockConfig.cloudTasks.sendEmails.taskUrl,
httpMethod: 1, // POST
headers: {
'Content-Type': 'application/json',
'fxa-cloud-task-delivery-time': now.toString(),
},
body: Buffer.from(JSON.stringify(mockSendEmailPayload)).toString(
'base64'
),
oidcToken: {
audience: mockConfig.cloudTasks.oidc.aud,
serviceAccountEmail:
mockConfig.cloudTasks.oidc.serviceAccountEmail,
},
},
name: 'projects/pid123/locations/lid123/queues/notification-emails/tasks/act0123456789-inactive-delete-notification',
scheduleTime: {
seconds: now / 1000,
},
},
});
});

it('creates email task with delivery beyond schedule time', async () => {
mockCloudClient.createTask.mockImplementation(() => {
return [{ name: 'task123' }];
});

await sendEmailTasks.sendEmail({
payload: mockSendEmailPayload,
emailOptions: { deliveryTime: now + 60 * 24 * 60 * 60 * 1000 },
taskOptions: mockTaskOptions,
});
expect(mockStatsd.increment).toBeCalledWith(
'cloud-tasks.send-email.enqueue.success',
['inactiveDeleteFirstNotification']
);
expect(mockCloudClient.createTask).toBeCalledWith({
parent: `projects/${mockConfig.cloudTasks.projectId}/locations/${mockConfig.cloudTasks.locationId}/queues/${mockConfig.cloudTasks.sendEmails.queueName}`,
task: {
httpRequest: {
url: mockConfig.cloudTasks.sendEmails.taskUrl,
httpMethod: 1, // POST
headers: {
'Content-Type': 'application/json',
'fxa-cloud-task-delivery-time': '1741684000000',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might make sense to pull these into a constant with deliveryTime above.

},
body: Buffer.from(JSON.stringify(mockSendEmailPayload)).toString(
'base64'
),
oidcToken: {
audience: mockConfig.cloudTasks.oidc.aud,
serviceAccountEmail:
mockConfig.cloudTasks.oidc.serviceAccountEmail,
},
},
name: 'projects/pid123/locations/lid123/queues/notification-emails/tasks/act0123456789-inactive-delete-notification',
scheduleTime: {
seconds: 1739092000,
},
},
});
});

it('reports send email task failure', async () => {
mockCloudClient.createTask.mockImplementation(() => {
throw new Error('BOOM');
});
await expect(
sendEmailTasks.sendEmail({
payload: mockSendEmailPayload,
taskOptions: mockTaskOptions,
})
).rejects.toThrow('BOOM');
expect(mockStatsd.increment).toHaveBeenLastCalledWith(
'cloud-tasks.send-email.enqueue.failure',
['inactiveDeleteFirstNotification']
);
});
});
});
43 changes: 36 additions & 7 deletions libs/shared/cloud-tasks/src/lib/send-email-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,19 @@ import { StatsD } from 'hot-shots';
import { CloudTasks } from './cloud-tasks';
import { CloudTasksClient } from '@google-cloud/tasks';
import {
FxACloudTaskHeaders,
SendEmailCloudTaskConfig,
SendEmailTaskPayload,
} from './account-tasks.types';
import { CloudTaskOptions } from './cloud-tasks.types';

export enum EmailTypes {
INACTIVE_DELETE_FIRST_NOTIFICATION = 'inactiveDeleteFirstNotification',
}
export type CloudTaskEmailType = (typeof EmailTypes)[keyof typeof EmailTypes];

const thirtyDaysInMs = 30 * 24 * 60 * 60 * 1000;

export class SendEmailTasks extends CloudTasks {
constructor(
protected override config: SendEmailCloudTaskConfig,
Expand All @@ -25,28 +33,49 @@ export class SendEmailTasks extends CloudTasks {
*
* @returns A taskName
*/
public async sendEmail(
sendEmailTask: SendEmailTaskPayload,
taskOptions?: CloudTaskOptions
) {
public async sendEmail(task: {
payload: SendEmailTaskPayload;
emailOptions?: { deliveryTime: number };
taskOptions?: CloudTaskOptions;
}) {
// schedule time is when the task is dispatched and there's a limit of
// 30 days. delivery time is when we want to send the email by
// handling the task.
const now = Date.now();
const inThirtyDays = now + thirtyDaysInMs;
const deliveryTime = task.emailOptions?.deliveryTime || now;
const scheduleTime = Math.min(deliveryTime, inThirtyDays);

const taskHeaders: FxACloudTaskHeaders = {
'fxa-cloud-task-delivery-time': deliveryTime.toString(),
};

const taskOptions: CloudTaskOptions = {
...task.taskOptions,
scheduleTime: {
seconds: scheduleTime / 1000,
},
};

try {
const result = await this.enqueueTask(
{
queueName: this.config.cloudTasks.sendEmails.queueName,
taskUrl: this.config.cloudTasks.sendEmails.taskUrl,
taskPayload: sendEmailTask,
taskPayload: task.payload,
taskHeaders,
},
taskOptions
);
const taskName = result[0].name;

this.statsd.increment('cloud-tasks.send-email.enqueue.success', [
sendEmailTask.emailType,
task.payload.emailType,
]);
return taskName;
} catch (err) {
this.statsd.increment('cloud-tasks.send-email.enqueue.failure', [
sendEmailTask.emailType,
task.payload.emailType,
]);
throw err;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/fxa-auth-server/bin/key_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const {
TwilioFactory,
} = require('@fxa/accounts/recovery-phone');
const { setupAccountDatabase } = require('@fxa/shared/db/mysql/account');
const { EmailCloudTaskManager } = require('../lib/email-cloud-tasks');

async function run(config) {
Container.set(AppConfig, config);
Expand Down Expand Up @@ -257,6 +258,9 @@ async function run(config) {
});
Container.set(AccountDeleteManager, accountDeleteManager);

const emailCloudTaskManager = new EmailCloudTaskManager({ config, statsd });
Container.set(EmailCloudTaskManager, emailCloudTaskManager);

const profile = new ProfileClient(log, {
...config.profileServer,
serviceName: 'subhub',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const DESCRIPTIONS = {
'The salt used when creating authPW. If not provided, it will be assumed that version one of the password encryption scheme was used.',
clientSecret:
'The OAuth client secret for the requesting client application. Required for confidential clients, forbidden for public clients.',
cloudTaskEmailType: 'An email type that can be sent with cloud tasks.',
code: 'Time based code to verify secondary email',
codeOauth:
'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.',
Expand Down
Loading