Skip to content

Add an optional "call-to-action" button for email notifications #173

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

Merged
merged 8 commits into from
Jun 24, 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
52 changes: 52 additions & 0 deletions src/api/functions/entraId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { ConfidentialClientApplication } from "@azure/msal-node";
import { getItemFromCache, insertItemIntoCache } from "./cache.js";
import {
EntraGroupActions,
EntraGroupMetadata,
EntraInvitationResponse,
ProfilePatchRequest,
} from "../../common/types/iam.js";
Expand Down Expand Up @@ -553,3 +554,54 @@ export async function listGroupIDsByEmail(
});
}
}

/**
* Retrieves metadata for a specific Entra ID group.
* @param token - Entra ID token authorized to take this action.
* @param groupId - The group ID to fetch metadata for.
* @throws {EntraGroupError} If fetching the group metadata fails.
* @returns {Promise<EntraGroupMetadata>} The group's metadata.
*/
export async function getGroupMetadata(
token: string,
groupId: string,
): Promise<EntraGroupMetadata> {
if (!validateGroupId(groupId)) {
throw new EntraGroupError({
message: "Invalid group ID format",
group: groupId,
});
}
try {
const url = `https://graph.microsoft.com/v1.0/groups/${groupId}?$select=id,displayName,mail,description`;
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});

if (!response.ok) {
const errorData = (await response.json()) as {
error?: { message?: string };
};
throw new EntraGroupError({
message: errorData?.error?.message ?? response.statusText,
group: groupId,
});
}

const data = (await response.json()) as EntraGroupMetadata;
return data;
} catch (error) {
if (error instanceof EntraGroupError) {
throw error;
}

throw new EntraGroupError({
message: error instanceof Error ? error.message : String(error),
group: groupId,
});
}
}
84 changes: 84 additions & 0 deletions src/api/routes/apiKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
ValidationError,
} from "common/errors/index.js";
import { z } from "zod";
import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js";
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";

const apiKeyRoute: FastifyPluginAsync = async (fastify, _options) => {
await fastify.register(rateLimiter, {
Expand Down Expand Up @@ -86,6 +88,49 @@ const apiKeyRoute: FastifyPluginAsync = async (fastify, _options) => {
message: "Could not create API key.",
});
}
request.log.debug("Constructing SQS payload to send email notification.");
const sqsPayload: SQSPayload<AvailableSQSFunctions.EmailNotifications> = {
function: AvailableSQSFunctions.EmailNotifications,
metadata: {
initiator: request.username!,
reqId: request.id,
},
payload: {
to: [request.username!],
subject: "Important: API Key Created",
content: `
This email confirms that an API key for the Core API has been generated from your account.

Key ID: acmuiuc_${keyId}

Key Description: ${description}

IP address: ${request.ip}.

Roles: ${roles.join(", ")}.

If you did not create this API key, please secure your account and notify the ACM Infrastructure team.
`,
callToActionButton: {
name: "View API Keys",
url: `${fastify.environmentConfig.UserFacingUrl}/apiKeys`,
},
},
};
if (!fastify.sqsClient) {
fastify.sqsClient = new SQSClient({
region: genericConfig.AwsRegion,
});
}
const result = await fastify.sqsClient.send(
new SendMessageCommand({
QueueUrl: fastify.environmentConfig.SqsQueueUrl,
MessageBody: JSON.stringify(sqsPayload),
}),
);
if (result.MessageId) {
request.log.info(`Queued notification with ID ${result.MessageId}.`);
}
return reply.status(201).send({
apiKey,
expiresAt,
Expand Down Expand Up @@ -149,6 +194,45 @@ const apiKeyRoute: FastifyPluginAsync = async (fastify, _options) => {
message: "Could not delete API key.",
});
}
request.log.debug("Constructing SQS payload to send email notification.");
const sqsPayload: SQSPayload<AvailableSQSFunctions.EmailNotifications> = {
function: AvailableSQSFunctions.EmailNotifications,
metadata: {
initiator: request.username!,
reqId: request.id,
},
payload: {
to: [request.username!],
subject: "Important: API Key Deleted",
content: `
This email confirms that an API key for the Core API has been deleted from your account.

Key ID: acmuiuc_${keyId}

IP address: ${request.ip}.

If you did not delete this API key, please secure your account and notify the ACM Infrastructure team.
`,
callToActionButton: {
name: "View API Keys",
url: `${fastify.environmentConfig.UserFacingUrl}/apiKeys`,
},
},
};
if (!fastify.sqsClient) {
fastify.sqsClient = new SQSClient({
region: genericConfig.AwsRegion,
});
}
const result = await fastify.sqsClient.send(
new SendMessageCommand({
QueueUrl: fastify.environmentConfig.SqsQueueUrl,
MessageBody: JSON.stringify(sqsPayload),
}),
);
if (result.MessageId) {
request.log.info(`Queued notification with ID ${result.MessageId}.`);
}
return reply.status(204).send();
},
);
Expand Down
100 changes: 100 additions & 0 deletions src/api/routes/iam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AppRoles } from "../../common/roles.js";
import {
addToTenant,
getEntraIdToken,
getGroupMetadata,
listGroupMembers,
modifyGroup,
patchUserProfile,
Expand Down Expand Up @@ -39,6 +40,10 @@ import { Modules } from "common/modules.js";
import { groupId, withRoles, withTags } from "api/components/index.js";
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
import { z } from "zod";
import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js";
import { SendMessageBatchCommand, SQSClient } from "@aws-sdk/client-sqs";
import { v4 as uuidv4 } from "uuid";
import { randomUUID } from "crypto";

const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
const getAuthorizedClients = async () => {
Expand Down Expand Up @@ -305,6 +310,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
await getAuthorizedClients(),
fastify.environmentConfig.AadValidClientId,
);
const groupMetadataPromise = getGroupMetadata(entraIdToken, groupId);
const addResults = await Promise.allSettled(
request.body.add.map((email) =>
modifyGroup(
Expand All @@ -327,15 +333,19 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
),
),
);
const groupMetadata = await groupMetadataPromise;
const response: Record<string, Record<string, string>[]> = {
success: [],
failure: [],
};
const logPromises = [];
const addedEmails = [];
const removedEmails = [];
for (let i = 0; i < addResults.length; i++) {
const result = addResults[i];
if (result.status === "fulfilled") {
response.success.push({ email: request.body.add[i] });
addedEmails.push(request.body.add[i]);
logPromises.push(
createAuditLogEntry({
dynamoClient: fastify.dynamoClient,
Expand Down Expand Up @@ -378,6 +388,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
const result = removeResults[i];
if (result.status === "fulfilled") {
response.success.push({ email: request.body.remove[i] });
removedEmails.push(request.body.remove[i]);
logPromises.push(
createAuditLogEntry({
dynamoClient: fastify.dynamoClient,
Expand Down Expand Up @@ -416,6 +427,95 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
}
}
}
const sqsAddedPayloads = addedEmails
.filter((x) => !!x)
.map((x) => {
return {
function: AvailableSQSFunctions.EmailNotifications,
metadata: {
initiator: request.username!,
reqId: request.id,
},
payload: {
to: [x],
subject: "You have been added to an access group",
content: `
Hello,

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.

No action is required from you at this time.
`,
},
};
});
const sqsRemovedPayloads = removedEmails
.filter((x) => !!x)
.map((x) => {
return {
function: AvailableSQSFunctions.EmailNotifications,
metadata: {
initiator: request.username!,
reqId: request.id,
},
payload: {
to: [x],
subject: "You have been removed from an access group",
content: `
Hello,

We're letting you know that you have been removed from the "${groupMetadata.displayName}" access group by ${request.username}.

No action is required from you at this time.
`,
},
};
});
if (!fastify.sqsClient) {
fastify.sqsClient = new SQSClient({
region: genericConfig.AwsRegion,
});
}
if (sqsAddedPayloads.length > 0) {
request.log.debug("Sending added emails");
let chunkId = 0;
for (let i = 0; i < sqsAddedPayloads.length; i += 10) {
chunkId += 1;
const chunk = sqsAddedPayloads.slice(i, i + 10);
const removedQueued = await fastify.sqsClient.send(
new SendMessageBatchCommand({
QueueUrl: fastify.environmentConfig.SqsQueueUrl,
Entries: chunk.map((x) => ({
Id: randomUUID(),
MessageBody: JSON.stringify(x),
})),
}),
);
request.log.info(
`Sent added emails chunk ${chunkId}, queue ID ${removedQueued.$metadata.requestId}`,
);
}
}
if (sqsRemovedPayloads.length > 0) {
request.log.debug("Sending removed emails");
let chunkId = 0;
for (let i = 0; i < sqsRemovedPayloads.length; i += 10) {
chunkId += 1;
const chunk = sqsRemovedPayloads.slice(i, i + 10);
const removedQueued = await fastify.sqsClient.send(
new SendMessageBatchCommand({
QueueUrl: fastify.environmentConfig.SqsQueueUrl,
Entries: chunk.map((x) => ({
Id: randomUUID(),
MessageBody: JSON.stringify(x),
})),
}),
);
request.log.info(
`Sent removed emails chunk ${chunkId}, queue ID ${removedQueued.$metadata.requestId}`,
);
}
}
await Promise.allSettled(logPromises);
reply.status(202).send(response);
},
Expand Down
12 changes: 10 additions & 2 deletions src/api/routes/roomRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,11 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
payload: {
to: [originalRequestor],
subject: "Room Reservation Request Status Change",
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.`,
content: `Your Room Reservation Request has been been moved to status "${formatStatus(request.body.status)}". Please visit the management portal for more details.`,
callToActionButton: {
name: "View Room Request",
url: `${fastify.environmentConfig.UserFacingUrl}/roomRequests/${semesterId}/${requestId}`,
},
},
};
if (!fastify.sqsClient) {
Expand Down Expand Up @@ -353,7 +357,11 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
payload: {
to: [notificationRecipients[fastify.runEnvironment].OfficerBoard],
subject: "New Room Reservation Request",
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.`,
content: `A new room reservation request has been created (${request.body.host} | ${request.body.title}). Please visit the management portal for more details.`,
callToActionButton: {
name: "View Room Request",
url: `${fastify.environmentConfig.UserFacingUrl}/roomRequests/${request.body.semester}/${requestId}`,
},
},
};
if (!fastify.sqsClient) {
Expand Down
8 changes: 6 additions & 2 deletions src/api/routes/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
StripeLinkCreateParams,
} from "api/functions/stripe.js";
import { getSecretValue } from "api/plugins/auth.js";
import { genericConfig } from "common/config.js";
import { environmentConfig, genericConfig } from "common/config.js";
import {
BaseError,
DatabaseFetchError,
Expand Down Expand Up @@ -407,7 +407,11 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => {
payload: {
to: [unmarshalledEntry.userId],
subject: `Payment Recieved for Invoice ${unmarshalledEntry.invoiceId}`,
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.`,
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.`,
callToActionButton: {
name: "View Your Stripe Links",
url: `${fastify.environmentConfig.UserFacingUrl}/stripe`,
},
},
};
if (!fastify.sqsClient) {
Expand Down
18 changes: 18 additions & 0 deletions src/api/sqs/handlers/templates/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,24 @@ const template = /*html*/ `
<tr>
<td style="padding: 30px;"> {{nl2br content}} </td>
</tr>

{{#if callToActionButton}}
<tr>
<td align="center" style="padding: 0 30px 30px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 5px;" bgcolor="#5386E4">
<a href="{{callToActionButton.url}}" target="_blank"
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;">
{{callToActionButton.name}}
</a>
</td>
</tr>
</table>
</td>
</tr>
{{/if}}

<tr>
<td align="center" style="padding-bottom: 30px;">
<p style="font-size: 12px; color: #888; text-align: center;"> <a href="https://acm.illinois.edu"
Expand Down
Loading
Loading