Skip to content

Commit 88d01f0

Browse files
committed
test sales email
1 parent 8ea9be5 commit 88d01f0

File tree

7 files changed

+511
-10
lines changed

7 files changed

+511
-10
lines changed

cloudformation/iam.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,19 @@ Resources:
241241
ForAllValues:StringLike:
242242
ses:Recipients:
243243
- "*@illinois.edu"
244+
- PolicyName: ses-sales
245+
PolicyDocument:
246+
Version: "2012-10-17"
247+
Statement:
248+
- Action:
249+
- ses:SendEmail
250+
- ses:SendRawEmail
251+
Effect: Allow
252+
Resource: "*"
253+
Condition:
254+
StringEquals:
255+
ses:FromAddress:
256+
Fn::Sub: "sales@${SesEmailDomain}"
244257

245258

246259
EdgeLambdaIAMRole:

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://www.acm.illinois.edu/resources" class="button">ACM @ UIUC Resources</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: ${subject}
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/index.ts

Lines changed: 2 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,6 +36,7 @@ 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];

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)