Skip to content

Commit

Permalink
Merge branch 'main' into feature-package-snap
Browse files Browse the repository at this point in the history
  • Loading branch information
thwalker6 authored Jan 30, 2025
2 parents 44526e6 + b6c09a7 commit 9573670
Show file tree
Hide file tree
Showing 26 changed files with 502 additions and 657 deletions.
79 changes: 57 additions & 22 deletions lib/lambda/processEmails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { getEmailTemplates, getAllStateUsers } from "libs/email";
import * as os from "libs/opensearch-lib";
import { EMAIL_CONFIG, getCpocEmail, getSrtEmails } from "libs/email/content/email-components";
import { htmlToText, HtmlToTextOptions } from "html-to-text";
import pLimit from "p-limit";
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
import { getOsNamespace } from "libs/utils";

Expand All @@ -37,6 +36,13 @@ interface ProcessEmailConfig {
isDev: boolean;
}

interface EmailTemplate {
to: string[];
cc?: string[];
subject: string;
body: string;
}

export const handler: Handler<KafkaEvent> = async (event) => {
const requiredEnvVars = [
"emailAddressLookupSecretName",
Expand Down Expand Up @@ -140,6 +146,11 @@ export async function processRecord(kafkaRecord: KafkaRecord, config: ProcessEma
return;
}

if (item._source.withdrawEmailSent) {
console.log("Withdraw email previously sent");
return;
}

const recordToPass = {
timestamp,
...safeSeatoolRecord.data,
Expand All @@ -151,6 +162,18 @@ export async function processRecord(kafkaRecord: KafkaRecord, config: ProcessEma
};

await processAndSendEmails(recordToPass as Events[keyof Events], safeID, config);

const indexObject = {
index: getOsNamespace("main"),
id: safeID,
body: {
doc: {
withdrawEmailSent: true,
},
},
};

await os.updateData(config.osDomain, indexObject);
} catch (error) {
console.error("Error processing record:", JSON.stringify(error, null, 2));
throw error;
Expand Down Expand Up @@ -210,7 +233,7 @@ export async function processAndSendEmails(

if (!templates) {
console.log(
`The kafka record has an event type that does not have email support. event: ${record.event}. Doing nothing.`,
`The kafka record has an event type that does not have email support. event: ${record.event}. Doing nothing.`,
);
return;
}
Expand Down Expand Up @@ -251,9 +274,12 @@ export async function processAndSendEmails(
};

console.log("Template variables:", JSON.stringify(templateVariables, null, 2));
const limit = pLimit(5); // Limit concurrent emails
const sendEmailPromises = templates.map((template) =>
limit(async () => {

const results = [];

// Process templates sequentially
for (const template of templates) {
try {
const filledTemplate = await template(templateVariables);
validateEmailTemplate(filledTemplate);
const params = createEmailParams(
Expand All @@ -262,34 +288,43 @@ export async function processAndSendEmails(
config.applicationEndpointUrl,
config.isDev,
);
try {
await sendEmail(params, config.region);
} catch (error) {
console.error("Error sending email:", error);
throw error;
}
}),
);

try {
await Promise.all(sendEmailPromises);
} catch (error) {
console.error("Error sending emails:", error);
throw error;
const result = await sendEmail(params, config.region);
results.push({ success: true, result });
console.log(`Successfully sent email for template: ${JSON.stringify(result)}`);
} catch (error) {
console.error("Error processing template:", error);
results.push({ success: false, error });
// Continue with next template instead of throwing
}
}

// Log final results
const successCount = results.filter((r) => r.success).length;
const failureCount = results.filter((r) => !r.success).length;

console.log(`Email sending complete. Success: ${successCount}, Failures: ${failureCount}`);

// If all emails failed, throw an error to trigger retry/DLQ logic
if (failureCount === templates.length) {
throw new Error(`All ${failureCount} email(s) failed to send`);
}

return results;
}

export function createEmailParams(
filledTemplate: any,
filledTemplate: EmailTemplate,
sourceEmail: string,
baseUrl: string,
isDev: boolean,
): SendEmailCommandInput {
const params = {
const params: SendEmailCommandInput = {
Destination: {
ToAddresses: filledTemplate.to,
CcAddresses: filledTemplate.cc,
BccAddresses: isDev ? [`State Submitter <${EMAIL_CONFIG.DEV_EMAIL}>`] : [], // this is so emails can be tested in dev as they should have the correct recipients but be blind copied on all emails on dev
CcAddresses: isDev
? [...(filledTemplate.cc || []), `State Submitter <${EMAIL_CONFIG.DEV_EMAIL}>`]
: filledTemplate.cc,
},
Message: {
Body: {
Expand Down
41 changes: 32 additions & 9 deletions lib/libs/email/content/email-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -308,11 +308,17 @@ const WithdrawRAI: React.FC<WithdrawRAIProps> = ({ variables, relatedEvent }) =>

const getCpocEmail = (item?: os.main.ItemResult): string[] => {
try {
if (item?._source?.leadAnalystEmail && item?._source?.leadAnalystName) {
const cpocEmail = `${item._source.leadAnalystName} <${item._source.leadAnalystEmail}>`;
return [cpocEmail];
const email = item?._source?.leadAnalystEmail;
const name = item?._source?.leadAnalystName;

const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

if (!email || !emailRegex.test(email)) {
console.error(`Invalid or missing email for item: ${JSON.stringify(item?._source, null, 2)}`);
return [];
}
return [];

return [`${name} <${email}>`];
} catch (e) {
console.error("Error getting CPOC email", e);
return [];
Expand All @@ -321,12 +327,29 @@ const getCpocEmail = (item?: os.main.ItemResult): string[] => {

const getSrtEmails = (item?: os.main.ItemResult): string[] => {
try {
if (item?._source?.reviewTeam && item._source.reviewTeam.length > 0) {
return item._source.reviewTeam.map(
(reviewer: { name: string; email: string }) => `${reviewer.name} <${reviewer.email}>`,
);
const reviewTeam = item?._source?.reviewTeam;

// Email validation regex
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

if (!reviewTeam || reviewTeam.length === 0) {
return [];
}
return [];

return reviewTeam
.map((reviewer: { name: string; email: string }) => {
const { name, email } = reviewer;

if (!email || !emailRegex.test(email)) {
console.error(
`Invalid or missing email for reviewer: ${JSON.stringify(reviewer, null, 2)}`,
);
return null;
}

return `${name} <${email}>`;
})
.filter((email): email is string => email !== null);
} catch (e) {
console.error("Error getting SRT emails", e);
return [];
Expand Down
8 changes: 6 additions & 2 deletions lib/libs/email/content/withdrawPackage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ export const withdrawPackage: AuthoritiesWithUserTypesTemplate = {
variables: Events["WithdrawPackage"] & CommonEmailVariables & { emails: EmailAddresses },
) => {
return {
to: [`${variables.submitterName} <${variables.submitterEmail}>`], // TODO: change to ALL state users
to: variables.allStateUsersEmails || [
`${variables.submitterName} <${variables.submitterEmail}>`,
],
subject: `Waiver Package ${variables.id} Withdraw Request`,
body: await render(<WaiverStateEmail variables={variables} />),
};
Expand All @@ -97,7 +99,9 @@ export const withdrawPackage: AuthoritiesWithUserTypesTemplate = {
variables: Events["WithdrawPackage"] & CommonEmailVariables & { emails: EmailAddresses },
) => {
return {
to: [`${variables.submitterName} <${variables.submitterEmail}>`], // TODO: change to ALL state users
to: variables.allStateUsersEmails || [
`${variables.submitterName} <${variables.submitterEmail}>`,
],
subject: `Waiver Package ${variables.id} Withdraw Request`,
body: await render(<WaiverStateEmail variables={variables} />),
};
Expand Down
53 changes: 40 additions & 13 deletions lib/libs/email/getAllStateUsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
ListUsersCommand,
ListUsersCommandInput,
ListUsersCommandOutput,
AttributeType,
} from "@aws-sdk/client-cognito-identity-provider";

export type StateUser = {
Expand All @@ -12,6 +13,14 @@ export type StateUser = {
formattedEmailAddress: string;
};

type CognitoUserAttributes = {
email?: string;
given_name?: string;
family_name?: string;
"custom:state"?: string;
[key: string]: string | undefined;
};

export const getAllStateUsers = async ({
userPoolId,
state,
Expand All @@ -33,26 +42,44 @@ export const getAllStateUsers = async ({
if (!response.Users || response.Users.length === 0) {
return [];
}

const filteredStateUsers = response.Users.filter((user) => {
const stateAttribute = user.Attributes?.find((attr) => attr.Name === "custom:state");
const stateAttribute = user.Attributes?.find(
(attr): attr is AttributeType => attr.Name === "custom:state" && attr.Value !== undefined,
);
return stateAttribute?.Value?.split(",").includes(state);
}).map((user) => {
const attributes = user.Attributes?.reduce(
(acc, attr) => {
acc[attr.Name as any] = attr.Value;
return acc;
},
{} as Record<string, string | undefined>,
);
const attributes = user.Attributes?.reduce<CognitoUserAttributes>((acc, attr) => {
if (attr.Name && attr.Value) {
acc[attr.Name] = attr.Value;
}
return acc;
}, {});

// Skip users without valid email components
if (!attributes?.email) {
console.error(`No email found for user: ${JSON.stringify(user, null, 2)}`);
return null;
}

// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(attributes.email)) {
console.error(`Invalid email format for user: ${attributes.email}`);
return null;
}

const formattedEmailAddress = `${attributes.given_name} ${attributes.family_name} <${attributes.email}>`;

return {
firstName: attributes?.["given_name"],
lastName: attributes?.["family_name"],
email: attributes?.["email"],
formattedEmailAddress: `${attributes?.["given_name"]} ${attributes?.["family_name"]} <${attributes?.["email"]}>`,
firstName: attributes.given_name ?? "",
lastName: attributes.family_name ?? "",
email: attributes.email,
formattedEmailAddress,
};
});

return filteredStateUsers as StateUser[];
return filteredStateUsers.filter((user): user is StateUser => user !== null);
} catch (error) {
console.error("Error fetching users:", error);
throw new Error("Error fetching users");
Expand Down
1 change: 1 addition & 0 deletions lib/packages/shared-types/opensearch/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export type Document = AppkDocument &
changeMade?: string;
idToBeUpdated?: string;
mockEvent?: string;
withdrawEmailSent?: boolean;
};

export type Response = Res<Document>;
Expand Down
2 changes: 1 addition & 1 deletion react-app/src/components/Inputs/upload.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Upload } from "./upload";
import { screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderWithQueryClient } from "@/utils/test-helpers/renderForm";
import { renderWithQueryClient } from "@/utils/test-helpers";

const defaultProps = {
dataTestId: "upload-component",
Expand Down
2 changes: 1 addition & 1 deletion react-app/src/components/Layout/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import userEvent from "@testing-library/user-event";
import { Auth } from "aws-amplify";
import * as hooks from "@/hooks";
import * as api from "@/api";
import { renderWithQueryClientAndMemoryRouter } from "@/utils/test-helpers/renderForm";
import { renderWithQueryClientAndMemoryRouter } from "@/utils/test-helpers";
import { setMockUsername, makoStateSubmitter, noRoleUser, AUTH_CONFIG } from "mocks";

/**
Expand Down
Loading

0 comments on commit 9573670

Please sign in to comment.