Skip to content

Commit 3b87258

Browse files
committed
route emails you the pass instead
1 parent 67eb04d commit 3b87258

File tree

13 files changed

+809
-23
lines changed

13 files changed

+809
-23
lines changed

cloudformation/iam.yml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ Parameters:
1010
LambdaFunctionName:
1111
Type: String
1212
AllowedPattern: ^[a-zA-Z0-9]+[a-zA-Z0-9-]+[a-zA-Z0-9]+$
13+
SesEmailDomain:
14+
Type: String
15+
1316
Resources:
1417
ApiLambdaIAMRole:
1518
Type: AWS::IAM::Role
@@ -24,6 +27,21 @@ Resources:
2427
Service:
2528
- lambda.amazonaws.com
2629
Policies:
30+
- PolicyDocument:
31+
Version: '2012-10-17'
32+
Statement:
33+
- Action:
34+
- ses:SendEmail
35+
- ses:SendRawEmail
36+
Effect: Allow
37+
Resource: "*"
38+
Condition:
39+
StringEquals:
40+
ses:FromAddress: !Sub "membership@${SesEmailDomain}"
41+
ForAllValues:StringLike:
42+
ses:Recipients:
43+
- "*@illinois.edu"
44+
PolicyName: ses-membership
2745
- PolicyDocument:
2846
Version: '2012-10-17'
2947
Statement:
@@ -85,4 +103,4 @@ Outputs:
85103
Value:
86104
Fn::GetAtt:
87105
- ApiLambdaIAMRole
88-
- Arn
106+
- Arn

cloudformation/main.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,14 @@ Mappings:
4040
HostedZoneId: Z04502822NVIA85WM2SML
4141
ApiDomainName: "aws.qa.acmuiuc.org"
4242
LinkryApiDomainName: "aws.qa.acmuiuc.org"
43+
SesDomain: "aws.qa.acmuiuc.org"
4344
LinkryApiCertificateArn: arn:aws:acm:us-east-1:427040638965:certificate/63ccdf0b-d2b5-44f0-b589-eceffb935c23
4445
prod:
4546
ApiCertificateArn: arn:aws:acm:us-east-1:298118738376:certificate/6142a0e2-d62f-478e-bf15-5bdb616fe705
4647
HostedZoneId: Z05246633460N5MEB9DBF
4748
ApiDomainName: "aws.acmuiuc.org"
4849
LinkryApiDomainName: "acm.illinois.edu"
50+
SesDomain: "acm.illinois.edu"
4951
LinkryApiCertificateArn: arn:aws:acm:us-east-1:298118738376:certificate/aeb93d9e-b0b7-4272-9c12-24ca5058c77e
5052
EnvironmentToCidr:
5153
dev:
@@ -71,6 +73,7 @@ Resources:
7173
Parameters:
7274
RunEnvironment: !Ref RunEnvironment
7375
LambdaFunctionName: !Sub ${ApplicationPrefix}-lambda
76+
SesEmailDomain: !FindInMap [General, !Ref RunEnvironment, SesDomain]
7477

7578
AppLogGroups:
7679
Type: AWS::Serverless::Application

src/api/functions/entraId.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "../../common/config.js";
88
import {
99
BaseError,
10+
EntraFetchError,
1011
EntraGroupError,
1112
EntraInvitationError,
1213
InternalServerError,
@@ -19,6 +20,7 @@ import {
1920
EntraInvitationResponse,
2021
} from "../../common/types/iam.js";
2122
import { FastifyInstance } from "fastify";
23+
import { UserProfileDataBase } from "common/types/msGraphApi.js";
2224

2325
function validateGroupId(groupId: string): boolean {
2426
const groupIdPattern = /^[a-zA-Z0-9-]+$/; // Adjust the pattern as needed
@@ -351,3 +353,47 @@ export async function listGroupMembers(
351353
});
352354
}
353355
}
356+
357+
/**
358+
* Retrieves the profile of a user from Entra ID.
359+
* @param token - Entra ID token authorized to perform this action.
360+
* @param userId - The user ID to fetch the profile for.
361+
* @throws {EntraUserError} If fetching the user profile fails.
362+
* @returns {Promise<UserProfileDataBase>} The user's profile information.
363+
*/
364+
export async function getUserProfile(
365+
token: string,
366+
email: string,
367+
): Promise<UserProfileDataBase> {
368+
const userId = await resolveEmailToOid(token, email);
369+
try {
370+
const url = `https://graph.microsoft.com/v1.0/users/${userId}?$select=userPrincipalName,givenName,surname,displayName,otherMails,mail`;
371+
const response = await fetch(url, {
372+
method: "GET",
373+
headers: {
374+
Authorization: `Bearer ${token}`,
375+
"Content-Type": "application/json",
376+
},
377+
});
378+
379+
if (!response.ok) {
380+
const errorData = (await response.json()) as {
381+
error?: { message?: string };
382+
};
383+
throw new EntraFetchError({
384+
message: errorData?.error?.message ?? response.statusText,
385+
email,
386+
});
387+
}
388+
return (await response.json()) as UserProfileDataBase;
389+
} catch (error) {
390+
if (error instanceof EntraFetchError) {
391+
throw error;
392+
}
393+
394+
throw new EntraFetchError({
395+
message: error instanceof Error ? error.message : String(error),
396+
email,
397+
});
398+
}
399+
}

src/api/functions/mobileWallet.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export async function issueAppleWalletMembershipCard(
2828
app: FastifyInstance,
2929
request: FastifyRequest,
3030
email: string,
31-
name: string,
31+
name?: string,
3232
) {
3333
if (!email.endsWith("@illinois.edu")) {
3434
throw new UnauthorizedError({

src/api/functions/ses.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { SendRawEmailCommand } from "@aws-sdk/client-ses";
2+
import { encode } from "base64-arraybuffer";
3+
4+
/**
5+
* Generates a SendRawEmailCommand for SES to send an email with an attached membership pass.
6+
*
7+
* @param recipientEmail - The email address of the recipient.
8+
* * @param recipientEmail - The email address of the sender with a verified identity in SES.
9+
* @param attachmentBuffer - The membership pass in ArrayBufferLike format.
10+
* @returns The command to send the email via SES.
11+
*/
12+
export function generateMembershipEmailCommand(
13+
recipientEmail: string,
14+
senderEmail: string,
15+
attachmentBuffer: ArrayBufferLike,
16+
): SendRawEmailCommand {
17+
const encodedAttachment = encode(attachmentBuffer);
18+
const boundary = "----BoundaryForEmail";
19+
20+
const emailTemplate = `
21+
<!doctype html>
22+
<html>
23+
<head>
24+
<title>Your ACM @ UIUC Membership</title>
25+
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
26+
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
27+
<base target="_blank">
28+
<style>
29+
body {
30+
background-color: #F0F1F3;
31+
font-family: 'Helvetica Neue', 'Segoe UI', Helvetica, sans-serif;
32+
font-size: 15px;
33+
line-height: 26px;
34+
margin: 0;
35+
color: #444;
36+
}
37+
.wrap {
38+
background-color: #fff;
39+
padding: 30px;
40+
max-width: 525px;
41+
margin: 0 auto;
42+
border-radius: 5px;
43+
}
44+
.button {
45+
background: #0055d4;
46+
border-radius: 3px;
47+
text-decoration: none !important;
48+
color: #fff !important;
49+
font-weight: bold;
50+
padding: 10px 30px;
51+
display: inline-block;
52+
}
53+
.button:hover {
54+
background: #111;
55+
}
56+
.footer {
57+
text-align: center;
58+
font-size: 12px;
59+
color: #888;
60+
}
61+
img {
62+
max-width: 100%;
63+
height: auto;
64+
}
65+
a {
66+
color: #0055d4;
67+
}
68+
a:hover {
69+
color: #111;
70+
}
71+
@media screen and (max-width: 600px) {
72+
.wrap {
73+
max-width: auto;
74+
}
75+
}
76+
</style>
77+
</head>
78+
<body>
79+
<div class="gutter" style="padding: 30px;">&nbsp;</div>
80+
<img src="https://acm-brand-images.s3.amazonaws.com/banner-blue.png" style="height: 100px; width: 210px; align-self: center;"/>
81+
<br />
82+
<div class="wrap">
83+
<h2 style="text-align: center;">Welcome</h2>
84+
<p>
85+
Thank you for becoming a member of ACM @ UIUC! Attached is your membership pass.
86+
You can add it to your Apple or Google Wallet for easy access.
87+
</p>
88+
<p>
89+
If you have any questions, feel free to contact us at
90+
<a href="mailto:[email protected]">[email protected]</a>.
91+
</p>
92+
<div style="text-align: center; margin-top: 20px;">
93+
<a href="https://acm.illinois.edu" class="button">Visit ACM @ UIUC</a>
94+
</div>
95+
</div>
96+
<div class="footer">
97+
<p>
98+
<a href="https://acm.illinois.edu">ACM @ UIUC Homepage</a>
99+
<a href="mailto:[email protected]">Email ACM @ UIUC</a>
100+
</p>
101+
</div>
102+
</body>
103+
</html>
104+
`;
105+
106+
const rawEmail = `
107+
MIME-Version: 1.0
108+
Content-Type: multipart/mixed; boundary="${boundary}"
109+
From: ACM @ UIUC <${senderEmail}>
110+
To: ${recipientEmail}
111+
Subject: Your ACM @ UIUC Membership
112+
113+
--${boundary}
114+
Content-Type: text/html; charset="UTF-8"
115+
116+
${emailTemplate}
117+
118+
--${boundary}
119+
Content-Type: application/vnd.apple.pkpass
120+
Content-Transfer-Encoding: base64
121+
Content-Disposition: attachment; filename="membership.pkpass"
122+
123+
${encodedAttachment}
124+
--${boundary}--`.trim();
125+
return new SendRawEmailCommand({
126+
RawMessage: {
127+
Data: new TextEncoder().encode(rawEmail),
128+
},
129+
});
130+
}

src/api/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import NodeCache from "node-cache";
2222
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2323
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
2424
import mobileWalletRoute from "./routes/mobileWallet.js";
25+
import { SESClient } from "@aws-sdk/client-ses";
2526

2627
dotenv.config();
2728

@@ -36,6 +37,10 @@ async function init() {
3637
region: genericConfig.AwsRegion,
3738
});
3839

40+
const sesClient = new SESClient({
41+
region: genericConfig.AwsRegion,
42+
});
43+
3944
const app: FastifyInstance = fastify({
4045
logger: {
4146
level: process.env.LOG_LEVEL || "info",
@@ -84,6 +89,7 @@ async function init() {
8489
app.dynamoClient = dynamoClient;
8590
app.secretsManagerClient = secretsManagerClient;
8691
app.secretsManagerData = null;
92+
app.sesClient = sesClient;
8793
app.addHook("onRequest", (req, _, done) => {
8894
req.startTime = now();
8995
const hostname = req.hostname;
@@ -112,7 +118,7 @@ async function init() {
112118
api.register(organizationsPlugin, { prefix: "/organizations" });
113119
api.register(icalPlugin, { prefix: "/ical" });
114120
api.register(iamRoutes, { prefix: "/iam" });
115-
api.register(mobileWalletRoute, { prefix: "/mobile" });
121+
api.register(mobileWalletRoute, { prefix: "/mobileWallet" });
116122
api.register(ticketsPlugin, { prefix: "/tickets" });
117123
if (app.runEnvironment === "dev") {
118124
api.register(vendingPlugin, { prefix: "/vending" });

src/api/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"type": "module",
99
"scripts": {
1010
"build": "tsc",
11-
"dev": "cross-env LOG_LEVEL=debug node esbuild.config.js --watch & nodemon ../../dist/index.js",
11+
"dev": "cross-env LOG_LEVEL=debug concurrently --names 'esbuild,server' 'node esbuild.config.js --watch' 'cd ../../dist_devel && nodemon index.js'",
1212
"typecheck": "tsc --noEmit",
1313
"lint": "eslint . --ext .ts --cache",
1414
"prettier": "prettier --check *.ts **/*.ts",
@@ -17,6 +17,7 @@
1717
"dependencies": {
1818
"@aws-sdk/client-dynamodb": "^3.624.0",
1919
"@aws-sdk/client-secrets-manager": "^3.624.0",
20+
"@aws-sdk/client-ses": "^3.734.0",
2021
"@aws-sdk/client-sts": "^3.726.0",
2122
"@aws-sdk/util-dynamodb": "^3.624.0",
2223
"@azure/msal-node": "^2.16.1",
@@ -25,6 +26,7 @@
2526
"@fastify/caching": "^9.0.1",
2627
"@fastify/cors": "^10.0.1",
2728
"@touch4it/ical-timezones": "^1.9.0",
29+
"base64-arraybuffer": "^1.0.2",
2830
"discord.js": "^14.15.3",
2931
"dotenv": "^16.4.5",
3032
"esbuild": "^0.24.2",
@@ -44,6 +46,7 @@
4446
},
4547
"devDependencies": {
4648
"@tsconfig/node22": "^22.0.0",
49+
"@types/base64-arraybuffer": "^0.2.4",
4750
"nodemon": "^3.1.9"
4851
}
49-
}
52+
}

src/api/plugins/auth.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import {
1515
} from "../../common/errors/index.js";
1616
import { genericConfig, SecretConfig } from "../../common/config.js";
1717
import { getGroupRoles, getUserRoles } from "../functions/authorization.js";
18-
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
1918

2019
function intersection<T>(setA: Set<T>, setB: Set<T>): Set<T> {
2120
const _intersection = new Set<T>();

0 commit comments

Comments
 (0)