Skip to content

Commit

Permalink
feat(export): send email confirmation
Browse files Browse the repository at this point in the history
  • Loading branch information
kschelonka committed Feb 13, 2025
1 parent 4490ca4 commit 281c1e1
Show file tree
Hide file tree
Showing 8 changed files with 175 additions and 8 deletions.
18 changes: 13 additions & 5 deletions infrastructure/transactional-emails/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,24 @@ export const config = {
listExportReadyCampaignId: isDev
? '45b2e986-d80b-f068-ce9f-5781296e91a4'
: '8a5f66b9-6e28-48e9-af99-e44e2a16a81a',
exportRequestAckCampaignId: 'cab551ad-730e-4a42-8052-a7ae77856a9b',
},
},
eventBridge: {
prefix: 'PocketEventBridge',
topics: [
'UserEvents',
'PremiumPurchaseEvents',
'UserRegistrationEvents',
'ForgotPasswordEvents',
'ListExportReadyEvents',
{ name: 'UserEvents' },
{ name: 'PremiumPurchaseEvents' },
{ name: 'UserRegistrationEvents' },
{ name: 'ForgotPasswordEvents' },
{ name: 'ListExportReadyEvents' },
{
name: 'ListEvents',
filterPolicyScope: 'MessageBody',
filterPolicy: JSON.stringify({
'detail-type': ['list-export-requested'],
}),
},
],
},
};
8 changes: 5 additions & 3 deletions infrastructure/transactional-emails/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ class TransactionalEmails extends TerraformStack {
});

const topicArns = config.eventBridge.topics.map((topic) => {
const topicArn = `arn:aws:sns:${region.name}:${caller.accountId}:${config.eventBridge.prefix}-${config.environment}-${topic}`;
const topicArn = `arn:aws:sns:${region.name}:${caller.accountId}:${config.eventBridge.prefix}-${config.environment}-${topic.name}`;
this.subscribeSqsToSnsTopic(sqsLambda, snsTopicDlq, topicArn, topic);
return topicArn;
});
Expand Down Expand Up @@ -92,19 +92,21 @@ class TransactionalEmails extends TerraformStack {
sqsLambda: TransactionalEmailSQSLambda,
snsTopicDlq: sqsQueue.SqsQueue,
snsTopicArn: string,
topicName: string,
topic: { name: string; filterPolicy?: string; filterPolicyScope?: string },
) {
// This Topic already exists and is managed elsewhere
return new snsTopicSubscription.SnsTopicSubscription(
this,
`${topicName}-sns-subscription`,
`${topic.name}-sns-subscription`,
{
topicArn: snsTopicArn,
protocol: 'sqs',
endpoint: sqsLambda.construct.applicationSqsQueue.sqsQueue.arn,
redrivePolicy: JSON.stringify({
deadLetterTargetArn: snsTopicDlq.arn,
}),
filterPolicy: topic.filterPolicy,
filterPolicyScope: topic.filterPolicyScope,
},
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export class TransactionalEmailSQSLambda extends Construct {
config.lambda.braze.forgotPasswordCampaignId,
BRAZE_LIST_EXPORT_READY_CAMPAIGN_ID:
config.lambda.braze.listExportReadyCampaignId,
BRAZE_EXPORT_REQUEST_ACK_CAMPAIGN_ID:
config.lambda.braze.exportRequestAckCampaignId,
},
ignoreEnvironmentVars: ['GIT_SHA'],
vpcConfig: {
Expand Down
29 changes: 29 additions & 0 deletions lambdas/transactional-emails/src/braze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,35 @@ export async function sendListExportReadyEmail(options: {
return res;
}

export async function sendExportRequestAcknowledged(options: {
encodedId: string;
requestId: string;
}) {
const campaignData: CampaignsTriggerSendObject = {
campaign_id: config.braze.exportRequestAckCampaignId,
recipients: [
{
external_user_id: options.encodedId,
trigger_properties: {
request_id: options.requestId,
},
},
],
};
Sentry.addBreadcrumb({
data: { campaign: 'ExportRequestAcknowledged', campaignData },
});

const body = JSON.stringify(campaignData);
const res = await brazePost(config.braze.campaignTriggerPath, body);
if (!res.ok) {
throw new Error(
`Error ${res.status}: Failed to send Export Request Acknowledged email`,
);
}
return res;
}

export async function sendAccountDeletionEmail(email: string) {
const campaignData: CampaignsTriggerSendObject = {
campaign_id: config.braze.accountDeletionCampaignId,
Expand Down
2 changes: 2 additions & 0 deletions lambdas/transactional-emails/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,7 @@ export const config = {
listExportReadyCampaignId:
process.env.BRAZE_LIST_EXPORT_READY_CAMPAIGN_ID ||
'asdasd-asdasd-asdasd-asdasdasd-asdas',
exportRequestAckCampaignId:
process.env.BRAZE_EXPORT_REQUEST_ACK_CAMPAIGN_ID || 'asd-asf-asdf-asdf',
},
};
2 changes: 2 additions & 0 deletions lambdas/transactional-emails/src/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { userRegistrationEventHandler } from './userRegistrationEventHandler.ts'
import { forgotPasswordHandler } from './forgotPassword.ts';
import { exportReadyHandler } from './listExportReady.ts';
import { PocketEvent, PocketEventType } from '@pocket-tools/event-bridge';
import { exportRequestedHandler } from './listExportRequested.ts';

// Mapping of detail-type (via event bridge message)
// to function that should be invoked to process the message
Expand All @@ -15,4 +16,5 @@ export const handlers: {
[PocketEventType.ACCOUNT_REGISTRATION]: userRegistrationEventHandler,
[PocketEventType.FORGOT_PASSWORD]: forgotPasswordHandler,
[PocketEventType.EXPORT_READY]: exportReadyHandler,
[PocketEventType.EXPORT_REQUESTED]: exportRequestedHandler,
};
105 changes: 105 additions & 0 deletions lambdas/transactional-emails/src/handlers/listExportRequested.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import nock, { cleanAll } from 'nock';
import { SQSEvent, SQSRecord } from 'aws-lambda';
import { config } from '../config.ts';
import * as ssm from '../ssm.ts';
import { sendExportRequestAcknowledged } from '../braze.ts';
import { PocketEventType } from '@pocket-tools/event-bridge';
import { serverLogger } from '@pocket-tools/ts-logger';
import { processor } from '../index.ts';

describe('listExportRequested handler', () => {
let serverLoggerSpy: jest.SpyInstance;

beforeEach(() => {
jest
.spyOn(ssm, 'getBrazeApiKey')
.mockImplementation(() => Promise.resolve('api-key'));
serverLoggerSpy = jest.spyOn(serverLogger, 'error');
});

afterEach(() => {
cleanAll();
jest.restoreAllMocks();
});

it('throw an error if event payload is missing encodedId', async () => {
nock(config.braze.endpoint)
.post(config.braze.campaignTriggerPath)
.reply(400, { errors: ['this is an error'] });

const recordWithoutId = {
body: JSON.stringify({
Message: JSON.stringify({
id: '1234567890',
version: '0',
account: '123456789012',
region: 'us-east-2',
time: new Date(),
'detail-type': PocketEventType.EXPORT_REQUESTED,
source: 'web-repo',
detail: {
requestId: 'abc123',
},
}),
}),
};
await processor({
Records: [recordWithoutId] as SQSRecord[],
} as SQSEvent);
expect(serverLoggerSpy).toHaveBeenCalled();
expect(serverLoggerSpy.mock.calls[0][0]['errorData']['message']).toContain(
"data/detail must have required property 'encodedId'",
);
});

it('throws an error if email send response is not 200 OK', async () => {
const record = {
body: JSON.stringify({
Message: JSON.stringify({
id: '1234567890',
version: '0',
account: '123456789012',
region: 'us-east-2',
time: new Date(),
'detail-type': PocketEventType.EXPORT_REQUESTED,
source: 'web-repo',
detail: {
encodedId: 'abc-123',
requestId: '000-111',
archiveUrl:
'https://pocket.co/share/085ba173-fb35-48b0-a76c-33c1561570b9',
},
}),
}),
};
nock(config.braze.endpoint)
.post(config.braze.campaignTriggerPath)
.reply(400, { errors: ['this is an error'] });

await processor({
Records: [record] as SQSRecord[],
} as SQSEvent);
expect(serverLoggerSpy).toHaveBeenCalledTimes(2);
expect(serverLoggerSpy.mock.calls[1][0]['errorData']['message']).toContain(
'Error 400: Failed to send Export Request Acknowledged email',
);
});

it('should retry 3 times if post fails', async () => {
nock(config.braze.endpoint)
.post(config.braze.campaignTriggerPath)
.times(2)
.reply(500, { errors: ['this is an error'] });

nock(config.braze.endpoint)
.post(config.braze.campaignTriggerPath)
.reply(200, { data: ['this is a data'] });

const res = await sendExportRequestAcknowledged({
requestId: 'abc123',
encodedId: '111',
});
const result = (await res.json()) as any;
expect(result.data).toEqual(['this is a data']);
});
});
17 changes: 17 additions & 0 deletions lambdas/transactional-emails/src/handlers/listExportRequested.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { sendExportRequestAcknowledged } from '../braze.ts';
import { PocketEvent, PocketEventType } from '@pocket-tools/event-bridge';

/**
* Given an list export ready event, make a request to send the export ready
* email.
* @param record SQSRecord containing forwarded event from eventbridge
* @throws Error if response is not ok
*/
export async function exportRequestedHandler(event: PocketEvent) {
if (event?.['detail-type'] === PocketEventType.EXPORT_REQUESTED) {
await sendExportRequestAcknowledged({
encodedId: event.detail.encodedId,
requestId: event.detail.requestId,
});
}
}

0 comments on commit 281c1e1

Please sign in to comment.