Skip to content

Commit 6e91a4d

Browse files
authored
SQS handler to send ticketing/merch emails (#78)
* test sales email * coding is hard * Change email behavior * fix email
1 parent 8ea9be5 commit 6e91a4d

File tree

10 files changed

+379
-3
lines changed

10 files changed

+379
-3
lines changed

cloudformation/iam.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ Parameters:
1616
SqsQueueArn:
1717
Type: String
1818

19+
Conditions:
20+
IsDev: !Equals [!Ref RunEnvironment, "dev"]
21+
1922
Resources:
2023
# Managed Policy for Common Lambda Permissions
2124
CommonLambdaManagedPolicy:
@@ -241,6 +244,25 @@ Resources:
241244
ForAllValues:StringLike:
242245
ses:Recipients:
243246
- "*@illinois.edu"
247+
- PolicyName: ses-sales
248+
PolicyDocument:
249+
Version: "2012-10-17"
250+
Statement:
251+
- Action:
252+
- ses:SendEmail
253+
- ses:SendRawEmail
254+
Effect: Allow
255+
Resource: "*"
256+
Condition:
257+
StringEquals:
258+
ses:FromAddress:
259+
Fn::Sub: "sales@${SesEmailDomain}"
260+
ForAllValues:StringLike:
261+
ses:Recipients:
262+
- !If
263+
- IsDev
264+
- "*@illinois.edu"
265+
- "*"
244266

245267

246268
EdgeLambdaIAMRole:

cloudformation/main.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,17 @@ Resources:
258258
FunctionResponseTypes:
259259
- ReportBatchItemFailures
260260

261+
SQSLambdaEventMappingSales:
262+
Type: AWS::Lambda::EventSourceMapping
263+
DependsOn:
264+
- AppSqsLambdaFunction
265+
Properties:
266+
BatchSize: 5
267+
EventSourceArn: !GetAtt AppSQSQueues.Outputs.SalesEmailQueueArn
268+
FunctionName: !Sub ${ApplicationPrefix}-sqs-lambda
269+
FunctionResponseTypes:
270+
- ReportBatchItemFailures
271+
261272
MembershipRecordsTable:
262273
Type: "AWS::DynamoDB::Table"
263274
DeletionPolicy: "Retain"
@@ -765,3 +776,7 @@ Outputs:
765776
CloudfrontDistributionId:
766777
Description: Cloudfront Distribution ID
767778
Value: !GetAtt AppFrontendCloudfrontDistribution.Id
779+
780+
SalesEmailQueueArn:
781+
Description: Sales Email Queue Arn
782+
Value: !GetAtt AppSQSQueues.Outputs.SalesEmailQueueArn

cloudformation/sqs.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@ Resources:
2424
- "AppDLQ"
2525
- "Arn"
2626
maxReceiveCount: 3
27+
SalesEmailQueue:
28+
Type: AWS::SQS::Queue
29+
Properties:
30+
QueueName: !Sub ${QueueName}-sales
31+
VisibilityTimeout: !Ref MessageTimeout
32+
RedrivePolicy:
33+
deadLetterTargetArn:
34+
Fn::GetAtt:
35+
- "AppDLQ"
36+
- "Arn"
37+
maxReceiveCount: 3
2738

2839
Outputs:
2940
MainQueueArn:
@@ -38,3 +49,9 @@ Outputs:
3849
Fn::GetAtt:
3950
- AppDLQ
4051
- Arn
52+
SalesEmailQueueArn:
53+
Description: Sales Email Queue Arn
54+
Value:
55+
Fn::GetAtt:
56+
- SalesEmailQueue
57+
- Arn

src/api/functions/ses.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { SendRawEmailCommand } from "@aws-sdk/client-ses";
22
import { encode } from "base64-arraybuffer";
3+
import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js";
34

45
/**
56
* Generates a SendRawEmailCommand for SES to send an email with an attached membership pass.
@@ -132,3 +133,134 @@ ${encodedAttachment}
132133
},
133134
});
134135
}
136+
137+
/**
138+
* Generates a SendRawEmailCommand for SES to send a sales confirmation email
139+
*
140+
* @param payload - The SQS Payload for sending sale emails
141+
* @param senderEmail - The email address of the sender with a verified identity in SES.
142+
* @param imageBuffer - The normal image ticket/pass in ArrayBufferLike format.
143+
* @returns The command to send the email via SES.
144+
*/
145+
export function generateSalesEmail(
146+
payload: SQSPayload<AvailableSQSFunctions.SendSaleEmail>["payload"],
147+
senderEmail: string,
148+
imageBuffer: ArrayBufferLike,
149+
): SendRawEmailCommand {
150+
const encodedImage = encode(imageBuffer);
151+
const boundary = "----BoundaryForEmail";
152+
153+
const subject = `Your ${payload.type === "merch" ? "order" : "ticket"} has been confirmed!`;
154+
155+
const emailTemplate = `
156+
<!doctype html>
157+
<html>
158+
<head>
159+
<title>${subject}</title>
160+
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
161+
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
162+
<base target="_blank">
163+
<style>
164+
body {
165+
background-color: #F0F1F3;
166+
font-family: 'Helvetica Neue', 'Segoe UI', Helvetica, sans-serif;
167+
font-size: 15px;
168+
line-height: 26px;
169+
margin: 0;
170+
color: #444;
171+
}
172+
.wrap {
173+
background-color: #fff;
174+
padding: 30px;
175+
max-width: 525px;
176+
margin: 0 auto;
177+
border-radius: 5px;
178+
}
179+
.button {
180+
background: #0055d4;
181+
border-radius: 3px;
182+
text-decoration: none !important;
183+
color: #fff !important;
184+
font-weight: bold;
185+
padding: 10px 30px;
186+
display: inline-block;
187+
}
188+
.button:hover {
189+
background: #111;
190+
}
191+
.footer {
192+
text-align: center;
193+
font-size: 12px;
194+
color: #888;
195+
}
196+
img {
197+
max-width: 100%;
198+
height: auto;
199+
}
200+
a {
201+
color: #0055d4;
202+
}
203+
a:hover {
204+
color: #111;
205+
}
206+
@media screen and (max-width: 600px) {
207+
.wrap {
208+
max-width: auto;
209+
}
210+
}
211+
</style>
212+
</head>
213+
<body>
214+
<div class="gutter" style="padding: 30px;">&nbsp;</div>
215+
<img src="https://acm-brand-images.s3.amazonaws.com/banner-blue.png" style="height: 100px; width: 210px; align-self: center;"/>
216+
<br />
217+
<div class="wrap">
218+
<h2 style="text-align: center;">${subject}</h2>
219+
<p>
220+
Thank you for your purchase of ${payload.quantity} ${payload.itemName} ${payload.size ? `(size ${payload.size})` : ""}.
221+
${payload.type === "merch" ? "When picking up your order" : "When attending the event"}, show the attached QR code to our staff to verify your purchase.
222+
</p>
223+
${payload.customText ? `<p>${payload.customText}</p>` : ""}
224+
<p>
225+
If you have any questions, feel free to ask on our Discord!
226+
</p>
227+
<div style="text-align: center; margin-top: 20px;">
228+
<a href="https://go.acm.illinois.edu/discord" class="button">Join our Discord</a>
229+
</div>
230+
</div>
231+
<div class="footer">
232+
<p>
233+
<a href="https://acm.illinois.edu">ACM @ UIUC Homepage</a>
234+
<a href="mailto:[email protected]">Email ACM @ UIUC</a>
235+
</p>
236+
</div>
237+
</body>
238+
</html>
239+
`;
240+
241+
const rawEmail = `
242+
MIME-Version: 1.0
243+
Content-Type: multipart/mixed; boundary="${boundary}"
244+
From: ACM @ UIUC <${senderEmail}>
245+
To: ${payload.email}
246+
Subject: Your ACM @ UIUC Purchase
247+
248+
--${boundary}
249+
Content-Type: text/html; charset="UTF-8"
250+
251+
${emailTemplate}
252+
253+
--${boundary}
254+
Content-Type: image/png
255+
Content-Transfer-Encoding: base64
256+
Content-Disposition: attachment; filename="${payload.itemName}.png"
257+
258+
${encodedImage}
259+
--${boundary}--`.trim();
260+
261+
return new SendRawEmailCommand({
262+
RawMessage: {
263+
Data: new TextEncoder().encode(rawEmail),
264+
},
265+
});
266+
}

src/api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"passkit-generator": "^3.3.1",
4747
"pino": "^9.6.0",
4848
"pluralize": "^8.0.0",
49+
"qrcode": "^1.5.4",
4950
"stripe": "^17.6.0",
5051
"uuid": "^11.0.5",
5152
"zod": "^3.23.8",
@@ -55,6 +56,7 @@
5556
"devDependencies": {
5657
"@tsconfig/node22": "^22.0.0",
5758
"@types/aws-lambda": "^8.10.147",
59+
"@types/qrcode": "^1.5.5",
5860
"nodemon": "^3.1.9"
5961
}
6062
}

src/api/sqs/driver.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
22
import { environmentConfig, genericConfig } from "common/config.js";
3-
import { parseSQSPayload } from "common/types/sqsMessage.js";
3+
import {
4+
AvailableSQSFunctions,
5+
parseSQSPayload,
6+
} from "common/types/sqsMessage.js";
47

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

1013
const payload = parseSQSPayload({
11-
function: "ping",
14+
function: AvailableSQSFunctions.Ping,
1215
payload: {},
1316
metadata: {
1417
reqId: "1",

src/api/sqs/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
import { ValidationError } from "../../common/errors/index.js";
2121
import { RunEnvironment } from "../../common/roles.js";
2222
import { environmentConfig } from "../../common/config.js";
23+
import { sendSaleEmailhandler } from "./sales.js";
2324

2425
export type SQSFunctionPayloadTypes = {
2526
[K in keyof typeof sqsPayloadSchemas]: SQSHandlerFunction<K>;
@@ -35,15 +36,21 @@ const handlers: SQSFunctionPayloadTypes = {
3536
[AvailableSQSFunctions.EmailMembershipPass]: emailMembershipPassHandler,
3637
[AvailableSQSFunctions.Ping]: pingHandler,
3738
[AvailableSQSFunctions.ProvisionNewMember]: provisionNewMemberHandler,
39+
[AvailableSQSFunctions.SendSaleEmail]: sendSaleEmailhandler,
3840
};
3941
export const runEnvironment = process.env.RunEnvironment as RunEnvironment;
4042
export const currentEnvironmentConfig = environmentConfig[runEnvironment];
4143

44+
const restrictedQueues: Record<string, AvailableSQSFunctions[]> = {
45+
"infra-core-api-sqs-sales": [AvailableSQSFunctions.SendSaleEmail],
46+
};
47+
4248
export const handler = middy()
4349
.use(eventNormalizerMiddleware())
4450
.use(sqsPartialBatchFailure())
4551
.handler((event: SQSEvent, _context: Context, { signal: _signal }) => {
4652
const recordsPromises = event.Records.map(async (record, _index) => {
53+
const sourceQueue = record.eventSourceARN.split(":").slice(-1)[0];
4754
try {
4855
let parsedBody = parseSQSPayload(record.body);
4956
if (parsedBody instanceof ZodError) {
@@ -56,6 +63,13 @@ export const handler = middy()
5663
});
5764
}
5865
parsedBody = parsedBody as AnySQSPayload;
66+
if (
67+
restrictedQueues[sourceQueue]?.includes(parsedBody.function) === false
68+
) {
69+
throw new ValidationError({
70+
message: `Queue ${sourceQueue} is not permitted to call the function ${parsedBody.function}!`,
71+
});
72+
}
5973
const childLogger = logger.child({
6074
sqsMessageId: record.messageId,
6175
metadata: parsedBody.metadata,

src/api/sqs/sales.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { AvailableSQSFunctions } from "common/types/sqsMessage.js";
2+
import { currentEnvironmentConfig, SQSHandlerFunction } from "./index.js";
3+
import { SESClient } from "@aws-sdk/client-ses";
4+
import QRCode from "qrcode";
5+
import { generateSalesEmail } from "api/functions/ses.js";
6+
import { genericConfig } from "common/config.js";
7+
8+
export const sendSaleEmailhandler: SQSHandlerFunction<
9+
AvailableSQSFunctions.SendSaleEmail
10+
> = async (payload, _metadata, logger) => {
11+
const { qrCodeContent } = payload;
12+
const senderEmail = `sales@${currentEnvironmentConfig["EmailDomain"]}`;
13+
logger.info("Constructing QR Code...");
14+
const qrCode = await QRCode.toBuffer(qrCodeContent, {
15+
errorCorrectionLevel: "H",
16+
});
17+
logger.info("Constructing email...");
18+
const emailCommand = generateSalesEmail(payload, senderEmail, qrCode);
19+
logger.info("Constructing email...");
20+
const sesClient = new SESClient({ region: genericConfig.AwsRegion });
21+
const response = await sesClient.send(emailCommand);
22+
logger.info("Sent!");
23+
return response;
24+
};

src/common/types/sqsMessage.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export enum AvailableSQSFunctions {
44
Ping = "ping",
55
EmailMembershipPass = "emailMembershipPass",
66
ProvisionNewMember = "provisionNewMember",
7+
SendSaleEmail = "sendSaleEmail",
78
}
89

910
const sqsMessageMetadataSchema = z.object({
@@ -42,12 +43,25 @@ export const sqsPayloadSchemas = {
4243
AvailableSQSFunctions.ProvisionNewMember,
4344
z.object({ email: z.string().email() }),
4445
),
46+
[AvailableSQSFunctions.SendSaleEmail]: createSQSSchema(
47+
AvailableSQSFunctions.SendSaleEmail,
48+
z.object({
49+
email: z.string().email(),
50+
qrCodeContent: z.string().min(1),
51+
itemName: z.string().min(1),
52+
quantity: z.number().min(1),
53+
size: z.string().optional(),
54+
customText: z.string().optional(),
55+
type: z.union([z.literal('event'), z.literal('merch')])
56+
}),
57+
),
4558
} as const;
4659

4760
export const sqsPayloadSchema = z.discriminatedUnion("function", [
4861
sqsPayloadSchemas[AvailableSQSFunctions.Ping],
4962
sqsPayloadSchemas[AvailableSQSFunctions.EmailMembershipPass],
5063
sqsPayloadSchemas[AvailableSQSFunctions.ProvisionNewMember],
64+
sqsPayloadSchemas[AvailableSQSFunctions.SendSaleEmail],
5165
] as const);
5266

5367
export type SQSPayload<T extends AvailableSQSFunctions> = z.infer<

0 commit comments

Comments
 (0)