Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SQS handler to send ticketing/merch emails #78

Merged
merged 4 commits into from
Mar 21, 2025
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
22 changes: 22 additions & 0 deletions cloudformation/iam.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ Parameters:
SqsQueueArn:
Type: String

Conditions:
IsDev: !Equals [!Ref RunEnvironment, "dev"]

Resources:
# Managed Policy for Common Lambda Permissions
CommonLambdaManagedPolicy:
Expand Down Expand Up @@ -241,6 +244,25 @@ Resources:
ForAllValues:StringLike:
ses:Recipients:
- "*@illinois.edu"
- PolicyName: ses-sales
PolicyDocument:
Version: "2012-10-17"
Statement:
- Action:
- ses:SendEmail
- ses:SendRawEmail
Effect: Allow
Resource: "*"
Condition:
StringEquals:
ses:FromAddress:
Fn::Sub: "sales@${SesEmailDomain}"
ForAllValues:StringLike:
ses:Recipients:
- !If
- IsDev
- "*@illinois.edu"
- "*"


EdgeLambdaIAMRole:
Expand Down
15 changes: 15 additions & 0 deletions cloudformation/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,17 @@ Resources:
FunctionResponseTypes:
- ReportBatchItemFailures

SQSLambdaEventMappingSales:
Type: AWS::Lambda::EventSourceMapping
DependsOn:
- AppSqsLambdaFunction
Properties:
BatchSize: 5
EventSourceArn: !GetAtt AppSQSQueues.Outputs.SalesEmailQueueArn
FunctionName: !Sub ${ApplicationPrefix}-sqs-lambda
FunctionResponseTypes:
- ReportBatchItemFailures

MembershipRecordsTable:
Type: "AWS::DynamoDB::Table"
DeletionPolicy: "Retain"
Expand Down Expand Up @@ -765,3 +776,7 @@ Outputs:
CloudfrontDistributionId:
Description: Cloudfront Distribution ID
Value: !GetAtt AppFrontendCloudfrontDistribution.Id

SalesEmailQueueArn:
Description: Sales Email Queue Arn
Value: !GetAtt AppSQSQueues.Outputs.SalesEmailQueueArn
17 changes: 17 additions & 0 deletions cloudformation/sqs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ Resources:
- "AppDLQ"
- "Arn"
maxReceiveCount: 3
SalesEmailQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: !Sub ${QueueName}-sales
VisibilityTimeout: !Ref MessageTimeout
RedrivePolicy:
deadLetterTargetArn:
Fn::GetAtt:
- "AppDLQ"
- "Arn"
maxReceiveCount: 3

Outputs:
MainQueueArn:
Expand All @@ -38,3 +49,9 @@ Outputs:
Fn::GetAtt:
- AppDLQ
- Arn
SalesEmailQueueArn:
Description: Sales Email Queue Arn
Value:
Fn::GetAtt:
- SalesEmailQueue
- Arn
132 changes: 132 additions & 0 deletions src/api/functions/ses.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { SendRawEmailCommand } from "@aws-sdk/client-ses";
import { encode } from "base64-arraybuffer";
import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js";

/**
* Generates a SendRawEmailCommand for SES to send an email with an attached membership pass.
Expand Down Expand Up @@ -132,3 +133,134 @@ ${encodedAttachment}
},
});
}

/**
* Generates a SendRawEmailCommand for SES to send a sales confirmation email
*
* @param payload - The SQS Payload for sending sale emails
* @param senderEmail - The email address of the sender with a verified identity in SES.
* @param imageBuffer - The normal image ticket/pass in ArrayBufferLike format.
* @returns The command to send the email via SES.
*/
export function generateSalesEmail(
payload: SQSPayload<AvailableSQSFunctions.SendSaleEmail>["payload"],
senderEmail: string,
imageBuffer: ArrayBufferLike,
): SendRawEmailCommand {
const encodedImage = encode(imageBuffer);
const boundary = "----BoundaryForEmail";

const subject = `Your ${payload.type === "merch" ? "order" : "ticket"} has been confirmed!`;

const emailTemplate = `
<!doctype html>
<html>
<head>
<title>${subject}</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<base target="_blank">
<style>
body {
background-color: #F0F1F3;
font-family: 'Helvetica Neue', 'Segoe UI', Helvetica, sans-serif;
font-size: 15px;
line-height: 26px;
margin: 0;
color: #444;
}
.wrap {
background-color: #fff;
padding: 30px;
max-width: 525px;
margin: 0 auto;
border-radius: 5px;
}
.button {
background: #0055d4;
border-radius: 3px;
text-decoration: none !important;
color: #fff !important;
font-weight: bold;
padding: 10px 30px;
display: inline-block;
}
.button:hover {
background: #111;
}
.footer {
text-align: center;
font-size: 12px;
color: #888;
}
img {
max-width: 100%;
height: auto;
}
a {
color: #0055d4;
}
a:hover {
color: #111;
}
@media screen and (max-width: 600px) {
.wrap {
max-width: auto;
}
}
</style>
</head>
<body>
<div class="gutter" style="padding: 30px;">&nbsp;</div>
<img src="https://acm-brand-images.s3.amazonaws.com/banner-blue.png" style="height: 100px; width: 210px; align-self: center;"/>
<br />
<div class="wrap">
<h2 style="text-align: center;">${subject}</h2>
<p>
Thank you for your purchase of ${payload.quantity} ${payload.itemName} ${payload.size ? `(size ${payload.size})` : ""}.
${payload.type === "merch" ? "When picking up your order" : "When attending the event"}, show the attached QR code to our staff to verify your purchase.
</p>
${payload.customText ? `<p>${payload.customText}</p>` : ""}
<p>
If you have any questions, feel free to ask on our Discord!
</p>
<div style="text-align: center; margin-top: 20px;">
<a href="https://go.acm.illinois.edu/discord" class="button">Join our Discord</a>
</div>
</div>
<div class="footer">
<p>
<a href="https://acm.illinois.edu">ACM @ UIUC Homepage</a>
<a href="mailto:[email protected]">Email ACM @ UIUC</a>
</p>
</div>
</body>
</html>
`;

const rawEmail = `
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="${boundary}"
From: ACM @ UIUC <${senderEmail}>
To: ${payload.email}
Subject: Your ACM @ UIUC Purchase

--${boundary}
Content-Type: text/html; charset="UTF-8"

${emailTemplate}

--${boundary}
Content-Type: image/png
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="${payload.itemName}.png"

${encodedImage}
--${boundary}--`.trim();

return new SendRawEmailCommand({
RawMessage: {
Data: new TextEncoder().encode(rawEmail),
},
});
}
2 changes: 2 additions & 0 deletions src/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"passkit-generator": "^3.3.1",
"pino": "^9.6.0",
"pluralize": "^8.0.0",
"qrcode": "^1.5.4",
"stripe": "^17.6.0",
"uuid": "^11.0.5",
"zod": "^3.23.8",
Expand All @@ -55,6 +56,7 @@
"devDependencies": {
"@tsconfig/node22": "^22.0.0",
"@types/aws-lambda": "^8.10.147",
"@types/qrcode": "^1.5.5",
"nodemon": "^3.1.9"
}
}
7 changes: 5 additions & 2 deletions src/api/sqs/driver.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
import { environmentConfig, genericConfig } from "common/config.js";
import { parseSQSPayload } from "common/types/sqsMessage.js";
import {
AvailableSQSFunctions,
parseSQSPayload,
} from "common/types/sqsMessage.js";

const queueUrl = environmentConfig["dev"].SqsQueueUrl;
const sqsClient = new SQSClient({
region: genericConfig.AwsRegion,
});

const payload = parseSQSPayload({
function: "ping",
function: AvailableSQSFunctions.Ping,
payload: {},
metadata: {
reqId: "1",
Expand Down
14 changes: 14 additions & 0 deletions src/api/sqs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { ValidationError } from "../../common/errors/index.js";
import { RunEnvironment } from "../../common/roles.js";
import { environmentConfig } from "../../common/config.js";
import { sendSaleEmailhandler } from "./sales.js";

export type SQSFunctionPayloadTypes = {
[K in keyof typeof sqsPayloadSchemas]: SQSHandlerFunction<K>;
Expand All @@ -35,15 +36,21 @@ const handlers: SQSFunctionPayloadTypes = {
[AvailableSQSFunctions.EmailMembershipPass]: emailMembershipPassHandler,
[AvailableSQSFunctions.Ping]: pingHandler,
[AvailableSQSFunctions.ProvisionNewMember]: provisionNewMemberHandler,
[AvailableSQSFunctions.SendSaleEmail]: sendSaleEmailhandler,
};
export const runEnvironment = process.env.RunEnvironment as RunEnvironment;
export const currentEnvironmentConfig = environmentConfig[runEnvironment];

const restrictedQueues: Record<string, AvailableSQSFunctions[]> = {
"infra-core-api-sqs-sales": [AvailableSQSFunctions.SendSaleEmail],
};

export const handler = middy()
.use(eventNormalizerMiddleware())
.use(sqsPartialBatchFailure())
.handler((event: SQSEvent, _context: Context, { signal: _signal }) => {
const recordsPromises = event.Records.map(async (record, _index) => {
const sourceQueue = record.eventSourceARN.split(":").slice(-1)[0];
try {
let parsedBody = parseSQSPayload(record.body);
if (parsedBody instanceof ZodError) {
Expand All @@ -56,6 +63,13 @@ export const handler = middy()
});
}
parsedBody = parsedBody as AnySQSPayload;
if (
restrictedQueues[sourceQueue]?.includes(parsedBody.function) === false
) {
throw new ValidationError({
message: `Queue ${sourceQueue} is not permitted to call the function ${parsedBody.function}!`,
});
}
const childLogger = logger.child({
sqsMessageId: record.messageId,
metadata: parsedBody.metadata,
Expand Down
24 changes: 24 additions & 0 deletions src/api/sqs/sales.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { AvailableSQSFunctions } from "common/types/sqsMessage.js";
import { currentEnvironmentConfig, SQSHandlerFunction } from "./index.js";
import { SESClient } from "@aws-sdk/client-ses";
import QRCode from "qrcode";
import { generateSalesEmail } from "api/functions/ses.js";
import { genericConfig } from "common/config.js";

export const sendSaleEmailhandler: SQSHandlerFunction<
AvailableSQSFunctions.SendSaleEmail
> = async (payload, _metadata, logger) => {
const { qrCodeContent } = payload;
const senderEmail = `sales@${currentEnvironmentConfig["EmailDomain"]}`;
logger.info("Constructing QR Code...");
const qrCode = await QRCode.toBuffer(qrCodeContent, {
errorCorrectionLevel: "H",
});
logger.info("Constructing email...");
const emailCommand = generateSalesEmail(payload, senderEmail, qrCode);
logger.info("Constructing email...");
const sesClient = new SESClient({ region: genericConfig.AwsRegion });
const response = await sesClient.send(emailCommand);
logger.info("Sent!");
return response;
};
14 changes: 14 additions & 0 deletions src/common/types/sqsMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export enum AvailableSQSFunctions {
Ping = "ping",
EmailMembershipPass = "emailMembershipPass",
ProvisionNewMember = "provisionNewMember",
SendSaleEmail = "sendSaleEmail",
}

const sqsMessageMetadataSchema = z.object({
Expand Down Expand Up @@ -42,12 +43,25 @@ export const sqsPayloadSchemas = {
AvailableSQSFunctions.ProvisionNewMember,
z.object({ email: z.string().email() }),
),
[AvailableSQSFunctions.SendSaleEmail]: createSQSSchema(
AvailableSQSFunctions.SendSaleEmail,
z.object({
email: z.string().email(),
qrCodeContent: z.string().min(1),
itemName: z.string().min(1),
quantity: z.number().min(1),
size: z.string().optional(),
customText: z.string().optional(),
type: z.union([z.literal('event'), z.literal('merch')])
}),
),
} as const;

export const sqsPayloadSchema = z.discriminatedUnion("function", [
sqsPayloadSchemas[AvailableSQSFunctions.Ping],
sqsPayloadSchemas[AvailableSQSFunctions.EmailMembershipPass],
sqsPayloadSchemas[AvailableSQSFunctions.ProvisionNewMember],
sqsPayloadSchemas[AvailableSQSFunctions.SendSaleEmail],
] as const);

export type SQSPayload<T extends AvailableSQSFunctions> = z.infer<
Expand Down
Loading
Loading