Skip to content

Commit cfeb035

Browse files
authored
Add an optional "call-to-action" button for email notifications (#173)
Also adds email notifications for POST/DELETE api keys, group membership changes.
1 parent 56921d4 commit cfeb035

File tree

11 files changed

+323
-8
lines changed

11 files changed

+323
-8
lines changed

src/api/functions/entraId.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { ConfidentialClientApplication } from "@azure/msal-node";
2121
import { getItemFromCache, insertItemIntoCache } from "./cache.js";
2222
import {
2323
EntraGroupActions,
24+
EntraGroupMetadata,
2425
EntraInvitationResponse,
2526
ProfilePatchRequest,
2627
} from "../../common/types/iam.js";
@@ -553,3 +554,54 @@ export async function listGroupIDsByEmail(
553554
});
554555
}
555556
}
557+
558+
/**
559+
* Retrieves metadata for a specific Entra ID group.
560+
* @param token - Entra ID token authorized to take this action.
561+
* @param groupId - The group ID to fetch metadata for.
562+
* @throws {EntraGroupError} If fetching the group metadata fails.
563+
* @returns {Promise<EntraGroupMetadata>} The group's metadata.
564+
*/
565+
export async function getGroupMetadata(
566+
token: string,
567+
groupId: string,
568+
): Promise<EntraGroupMetadata> {
569+
if (!validateGroupId(groupId)) {
570+
throw new EntraGroupError({
571+
message: "Invalid group ID format",
572+
group: groupId,
573+
});
574+
}
575+
try {
576+
const url = `https://graph.microsoft.com/v1.0/groups/${groupId}?$select=id,displayName,mail,description`;
577+
const response = await fetch(url, {
578+
method: "GET",
579+
headers: {
580+
Authorization: `Bearer ${token}`,
581+
"Content-Type": "application/json",
582+
},
583+
});
584+
585+
if (!response.ok) {
586+
const errorData = (await response.json()) as {
587+
error?: { message?: string };
588+
};
589+
throw new EntraGroupError({
590+
message: errorData?.error?.message ?? response.statusText,
591+
group: groupId,
592+
});
593+
}
594+
595+
const data = (await response.json()) as EntraGroupMetadata;
596+
return data;
597+
} catch (error) {
598+
if (error instanceof EntraGroupError) {
599+
throw error;
600+
}
601+
602+
throw new EntraGroupError({
603+
message: error instanceof Error ? error.message : String(error),
604+
group: groupId,
605+
});
606+
}
607+
}

src/api/routes/apiKey.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
ValidationError,
2323
} from "common/errors/index.js";
2424
import { z } from "zod";
25+
import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js";
26+
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
2527

2628
const apiKeyRoute: FastifyPluginAsync = async (fastify, _options) => {
2729
await fastify.register(rateLimiter, {
@@ -86,6 +88,49 @@ const apiKeyRoute: FastifyPluginAsync = async (fastify, _options) => {
8688
message: "Could not create API key.",
8789
});
8890
}
91+
request.log.debug("Constructing SQS payload to send email notification.");
92+
const sqsPayload: SQSPayload<AvailableSQSFunctions.EmailNotifications> = {
93+
function: AvailableSQSFunctions.EmailNotifications,
94+
metadata: {
95+
initiator: request.username!,
96+
reqId: request.id,
97+
},
98+
payload: {
99+
to: [request.username!],
100+
subject: "Important: API Key Created",
101+
content: `
102+
This email confirms that an API key for the Core API has been generated from your account.
103+
104+
Key ID: acmuiuc_${keyId}
105+
106+
Key Description: ${description}
107+
108+
IP address: ${request.ip}.
109+
110+
Roles: ${roles.join(", ")}.
111+
112+
If you did not create this API key, please secure your account and notify the ACM Infrastructure team.
113+
`,
114+
callToActionButton: {
115+
name: "View API Keys",
116+
url: `${fastify.environmentConfig.UserFacingUrl}/apiKeys`,
117+
},
118+
},
119+
};
120+
if (!fastify.sqsClient) {
121+
fastify.sqsClient = new SQSClient({
122+
region: genericConfig.AwsRegion,
123+
});
124+
}
125+
const result = await fastify.sqsClient.send(
126+
new SendMessageCommand({
127+
QueueUrl: fastify.environmentConfig.SqsQueueUrl,
128+
MessageBody: JSON.stringify(sqsPayload),
129+
}),
130+
);
131+
if (result.MessageId) {
132+
request.log.info(`Queued notification with ID ${result.MessageId}.`);
133+
}
89134
return reply.status(201).send({
90135
apiKey,
91136
expiresAt,
@@ -149,6 +194,45 @@ const apiKeyRoute: FastifyPluginAsync = async (fastify, _options) => {
149194
message: "Could not delete API key.",
150195
});
151196
}
197+
request.log.debug("Constructing SQS payload to send email notification.");
198+
const sqsPayload: SQSPayload<AvailableSQSFunctions.EmailNotifications> = {
199+
function: AvailableSQSFunctions.EmailNotifications,
200+
metadata: {
201+
initiator: request.username!,
202+
reqId: request.id,
203+
},
204+
payload: {
205+
to: [request.username!],
206+
subject: "Important: API Key Deleted",
207+
content: `
208+
This email confirms that an API key for the Core API has been deleted from your account.
209+
210+
Key ID: acmuiuc_${keyId}
211+
212+
IP address: ${request.ip}.
213+
214+
If you did not delete this API key, please secure your account and notify the ACM Infrastructure team.
215+
`,
216+
callToActionButton: {
217+
name: "View API Keys",
218+
url: `${fastify.environmentConfig.UserFacingUrl}/apiKeys`,
219+
},
220+
},
221+
};
222+
if (!fastify.sqsClient) {
223+
fastify.sqsClient = new SQSClient({
224+
region: genericConfig.AwsRegion,
225+
});
226+
}
227+
const result = await fastify.sqsClient.send(
228+
new SendMessageCommand({
229+
QueueUrl: fastify.environmentConfig.SqsQueueUrl,
230+
MessageBody: JSON.stringify(sqsPayload),
231+
}),
232+
);
233+
if (result.MessageId) {
234+
request.log.info(`Queued notification with ID ${result.MessageId}.`);
235+
}
152236
return reply.status(204).send();
153237
},
154238
);

src/api/routes/iam.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AppRoles } from "../../common/roles.js";
33
import {
44
addToTenant,
55
getEntraIdToken,
6+
getGroupMetadata,
67
listGroupMembers,
78
modifyGroup,
89
patchUserProfile,
@@ -39,6 +40,10 @@ import { Modules } from "common/modules.js";
3940
import { groupId, withRoles, withTags } from "api/components/index.js";
4041
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
4142
import { z } from "zod";
43+
import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js";
44+
import { SendMessageBatchCommand, SQSClient } from "@aws-sdk/client-sqs";
45+
import { v4 as uuidv4 } from "uuid";
46+
import { randomUUID } from "crypto";
4247

4348
const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
4449
const getAuthorizedClients = async () => {
@@ -305,6 +310,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
305310
await getAuthorizedClients(),
306311
fastify.environmentConfig.AadValidClientId,
307312
);
313+
const groupMetadataPromise = getGroupMetadata(entraIdToken, groupId);
308314
const addResults = await Promise.allSettled(
309315
request.body.add.map((email) =>
310316
modifyGroup(
@@ -327,15 +333,19 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
327333
),
328334
),
329335
);
336+
const groupMetadata = await groupMetadataPromise;
330337
const response: Record<string, Record<string, string>[]> = {
331338
success: [],
332339
failure: [],
333340
};
334341
const logPromises = [];
342+
const addedEmails = [];
343+
const removedEmails = [];
335344
for (let i = 0; i < addResults.length; i++) {
336345
const result = addResults[i];
337346
if (result.status === "fulfilled") {
338347
response.success.push({ email: request.body.add[i] });
348+
addedEmails.push(request.body.add[i]);
339349
logPromises.push(
340350
createAuditLogEntry({
341351
dynamoClient: fastify.dynamoClient,
@@ -378,6 +388,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
378388
const result = removeResults[i];
379389
if (result.status === "fulfilled") {
380390
response.success.push({ email: request.body.remove[i] });
391+
removedEmails.push(request.body.remove[i]);
381392
logPromises.push(
382393
createAuditLogEntry({
383394
dynamoClient: fastify.dynamoClient,
@@ -416,6 +427,95 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
416427
}
417428
}
418429
}
430+
const sqsAddedPayloads = addedEmails
431+
.filter((x) => !!x)
432+
.map((x) => {
433+
return {
434+
function: AvailableSQSFunctions.EmailNotifications,
435+
metadata: {
436+
initiator: request.username!,
437+
reqId: request.id,
438+
},
439+
payload: {
440+
to: [x],
441+
subject: "You have been added to an access group",
442+
content: `
443+
Hello,
444+
445+
We're letting you know that you have been added to the "${groupMetadata.displayName}" access group by ${request.username}. Changes may take up to 2 hours to reflect in all systems.
446+
447+
No action is required from you at this time.
448+
`,
449+
},
450+
};
451+
});
452+
const sqsRemovedPayloads = removedEmails
453+
.filter((x) => !!x)
454+
.map((x) => {
455+
return {
456+
function: AvailableSQSFunctions.EmailNotifications,
457+
metadata: {
458+
initiator: request.username!,
459+
reqId: request.id,
460+
},
461+
payload: {
462+
to: [x],
463+
subject: "You have been removed from an access group",
464+
content: `
465+
Hello,
466+
467+
We're letting you know that you have been removed from the "${groupMetadata.displayName}" access group by ${request.username}.
468+
469+
No action is required from you at this time.
470+
`,
471+
},
472+
};
473+
});
474+
if (!fastify.sqsClient) {
475+
fastify.sqsClient = new SQSClient({
476+
region: genericConfig.AwsRegion,
477+
});
478+
}
479+
if (sqsAddedPayloads.length > 0) {
480+
request.log.debug("Sending added emails");
481+
let chunkId = 0;
482+
for (let i = 0; i < sqsAddedPayloads.length; i += 10) {
483+
chunkId += 1;
484+
const chunk = sqsAddedPayloads.slice(i, i + 10);
485+
const removedQueued = await fastify.sqsClient.send(
486+
new SendMessageBatchCommand({
487+
QueueUrl: fastify.environmentConfig.SqsQueueUrl,
488+
Entries: chunk.map((x) => ({
489+
Id: randomUUID(),
490+
MessageBody: JSON.stringify(x),
491+
})),
492+
}),
493+
);
494+
request.log.info(
495+
`Sent added emails chunk ${chunkId}, queue ID ${removedQueued.$metadata.requestId}`,
496+
);
497+
}
498+
}
499+
if (sqsRemovedPayloads.length > 0) {
500+
request.log.debug("Sending removed emails");
501+
let chunkId = 0;
502+
for (let i = 0; i < sqsRemovedPayloads.length; i += 10) {
503+
chunkId += 1;
504+
const chunk = sqsRemovedPayloads.slice(i, i + 10);
505+
const removedQueued = await fastify.sqsClient.send(
506+
new SendMessageBatchCommand({
507+
QueueUrl: fastify.environmentConfig.SqsQueueUrl,
508+
Entries: chunk.map((x) => ({
509+
Id: randomUUID(),
510+
MessageBody: JSON.stringify(x),
511+
})),
512+
}),
513+
);
514+
request.log.info(
515+
`Sent removed emails chunk ${chunkId}, queue ID ${removedQueued.$metadata.requestId}`,
516+
);
517+
}
518+
}
419519
await Promise.allSettled(logPromises);
420520
reply.status(202).send(response);
421521
},

src/api/routes/roomRequests.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,11 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
139139
payload: {
140140
to: [originalRequestor],
141141
subject: "Room Reservation Request Status Change",
142-
content: `Your Room Reservation Request has been been moved to status "${formatStatus(request.body.status)}". Please visit ${fastify.environmentConfig.UserFacingUrl}/roomRequests/${semesterId}/${requestId} to view details.`,
142+
content: `Your Room Reservation Request has been been moved to status "${formatStatus(request.body.status)}". Please visit the management portal for more details.`,
143+
callToActionButton: {
144+
name: "View Room Request",
145+
url: `${fastify.environmentConfig.UserFacingUrl}/roomRequests/${semesterId}/${requestId}`,
146+
},
143147
},
144148
};
145149
if (!fastify.sqsClient) {
@@ -353,7 +357,11 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
353357
payload: {
354358
to: [notificationRecipients[fastify.runEnvironment].OfficerBoard],
355359
subject: "New Room Reservation Request",
356-
content: `A new room reservation request has been created (${request.body.host} | ${request.body.title}). Please visit ${fastify.environmentConfig.UserFacingUrl}/roomRequests/${request.body.semester}/${requestId} to view details.`,
360+
content: `A new room reservation request has been created (${request.body.host} | ${request.body.title}). Please visit the management portal for more details.`,
361+
callToActionButton: {
362+
name: "View Room Request",
363+
url: `${fastify.environmentConfig.UserFacingUrl}/roomRequests/${request.body.semester}/${requestId}`,
364+
},
357365
},
358366
};
359367
if (!fastify.sqsClient) {

src/api/routes/stripe.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
StripeLinkCreateParams,
1818
} from "api/functions/stripe.js";
1919
import { getSecretValue } from "api/plugins/auth.js";
20-
import { genericConfig } from "common/config.js";
20+
import { environmentConfig, genericConfig } from "common/config.js";
2121
import {
2222
BaseError,
2323
DatabaseFetchError,
@@ -407,7 +407,11 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => {
407407
payload: {
408408
to: [unmarshalledEntry.userId],
409409
subject: `Payment Recieved for Invoice ${unmarshalledEntry.invoiceId}`,
410-
content: `ACM @ UIUC has received ${paidInFull ? "full" : "partial"} payment for Invoice ${unmarshalledEntry.invoiceId} (${withCurrency} by ${name}, ${email}).\n\nPlease contact Officer Board with any questions.`,
410+
content: `ACM @ UIUC has received ${paidInFull ? "full" : "partial"} payment for Invoice ${unmarshalledEntry.invoiceId} (${withCurrency} paid by ${name}, ${email}).\n\nPlease contact Officer Board with any questions.`,
411+
callToActionButton: {
412+
name: "View Your Stripe Links",
413+
url: `${fastify.environmentConfig.UserFacingUrl}/stripe`,
414+
},
411415
},
412416
};
413417
if (!fastify.sqsClient) {

src/api/sqs/handlers/templates/notification.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,24 @@ const template = /*html*/ `
3333
<tr>
3434
<td style="padding: 30px;"> {{nl2br content}} </td>
3535
</tr>
36+
37+
{{#if callToActionButton}}
38+
<tr>
39+
<td align="center" style="padding: 0 30px 30px 30px;">
40+
<table border="0" cellspacing="0" cellpadding="0">
41+
<tr>
42+
<td align="center" style="border-radius: 5px;" bgcolor="#5386E4">
43+
<a href="{{callToActionButton.url}}" target="_blank"
44+
style="font-size: 16px; font-family: 'Helvetica Neue', 'Segoe UI', Helvetica, sans-serif; font-weight: bold; color: #ffffff; text-decoration: none; border-radius: 5px; padding: 12px 25px; border: 1px solid #5386E4; display: inline-block;">
45+
{{callToActionButton.name}}
46+
</a>
47+
</td>
48+
</tr>
49+
</table>
50+
</td>
51+
</tr>
52+
{{/if}}
53+
3654
<tr>
3755
<td align="center" style="padding-bottom: 30px;">
3856
<p style="font-size: 12px; color: #888; text-align: center;"> <a href="https://acm.illinois.edu"

0 commit comments

Comments
 (0)