Skip to content

Commit

Permalink
Refactor email alias (#726)
Browse files Browse the repository at this point in the history
* Fix incorrect pluralization

* Refactor email alias system
  • Loading branch information
danieladugyan authored Feb 17, 2025
1 parent 7aebe7d commit a72be60
Show file tree
Hide file tree
Showing 5 changed files with 175 additions and 252 deletions.
148 changes: 23 additions & 125 deletions src/routes/(app)/api/mail/alias/+server.ts
Original file line number Diff line number Diff line change
@@ -1,136 +1,34 @@
import type { EmailAlias, Member } from "@prisma/client";
import type { RequestHandler } from "./$types";

import {
getCurrentMembersForPosition,
getEmailsForManyMembers,
getAliasToPositions,
fetchAliasReceivers,
fetchSpecialReceivers,
addFallbackEmail,
stringifyAliases,
mergeAliases,
} from "./utils";
import authorizedPrismaClient from "$lib/server/authorizedPrisma";

/**
* Returns a text response where each line contains an email alias followed by
* a list of ", " separated emails that should receive emails to that alias.
*
* @example
* GET /api/mail/alias
*
* Response:
* ```
* sexm@dsek.se xenon.shadow.6768@user.dsek.se
* styrelsen@dsek.se jackson.maverick.4216@user.dsek.se, luna.skylark.2499@user.dsek.se
* ```
*/
export const GET: RequestHandler = async ({ setHeaders }) => {
// This is the main data structure that we will use to create the response
// It stores all the positions for a given alias, and the user emails for those positions
// All code below is to fill this data structure
const aliasToPosToUserEmails = new Map<string, Map<string, string[]>>();

// Fetch all positions which have an alias (could be multiple aliases for a position)
const posToAlias: Map<string, EmailAlias[]> = await getAliasToPositions(
authorizedPrismaClient,
);

const positionIds = new Set<string>(
Array.from(posToAlias.values()).flatMap((alist) =>
alist.map((a) => a.positionId),
),
);

// Store all the members which have a mandate for a position
const positionIdsToMembers = new Map<
string,
Set<{ memberId: Member["id"]; studentId: Member["studentId"] }>
>();
for (const posId of positionIds) {
// Fetch which members currently have a mandate for the position
const members = await getCurrentMembersForPosition(
posId,
authorizedPrismaClient,
);
positionIdsToMembers.set(posId, new Set(members));
}

// We now store all members which we need to find the email for
const allMembersWithPos: Array<{
memberId: Member["id"];
studentId: Member["studentId"];
}> = [];
for (const [, members] of positionIdsToMembers) {
allMembersWithPos.push(...Array.from(members));
}

// Fetches all the emails for the members from Keycloak
const userToEmail = await getEmailsForManyMembers(
allMembersWithPos.map((m) => m.memberId),
authorizedPrismaClient,
const emailAliases = await fetchAliasReceivers();
const specialReceivers = await fetchSpecialReceivers();
const aliases = addFallbackEmail(
mergeAliases([...emailAliases, ...specialReceivers]),
);

for (const [alias, positions] of posToAlias) {
const posMap = new Map();
for (const pos of positions) {
// Find which members have a mandate for the position
const members = positionIdsToMembers.get(pos.positionId) ?? new Set();

// Find the emails for those members
const emails = Array.from(members).reduce<string[]>((acc, cur) => {
// Ignore members without a studentId, they don't have an email
if (cur.studentId === null) {
return acc;
}
const email = userToEmail.get(cur.studentId);
if (email !== undefined) {
acc.push(email);
}
return acc;
}, []);
// Store the users emails for the position
posMap.set(pos.positionId, emails);
}
aliasToPosToUserEmails.set(alias, posMap);
}

// Special receivers are stored in Prisma
const specialReceivers = (
await authorizedPrismaClient.specialReceiver.findMany({
orderBy: {
email: "asc",
},
})
).reduce<Map<string, Set<string>>>((acc, cur) => {
if (acc.has(cur.email)) {
acc.get(cur.email)?.add(cur.targetEmail);
} else {
acc.set(cur.email, new Set([cur.targetEmail]));
}
return acc;
}, new Map());

// Now we have all the data we need to create the response
let text = "";
for (const [alias, positions] of aliasToPosToUserEmails) {
text += `${alias.trim()} `;
let shouldTrim = false;
for (const [, userEmailsForPosition] of positions) {
// Maybe we have a special receiver for the alias, like for kallelse@dsek.se
if (specialReceivers.has(alias)) {
for (const target of specialReceivers.get(alias) ?? []) {
text += `${target.trim()}, `;
shouldTrim = true;
}
// If found, remove it from the specialReceivers map
// So we don't include it twice
specialReceivers.delete(alias);
}
// Add all the emails for the position
for (const userEmail of userEmailsForPosition) {
text += `${userEmail.trim()}, `;
shouldTrim = true;
}
}
if (shouldTrim) text = text.slice(0, -2); // remove trailing comma and whitespace
text += "\n";
}
// Finally go through the specialReceivers and add them to the response
for (const [email, targets] of specialReceivers) {
text += `${email.trim()} `;
for (const target of targets) {
text += `${target.trim()}, `;
}
if (targets.size > 0) text = text.slice(0, -2); // remove trailing comma and whitespace
text += "\n";
}

setHeaders({
"Content-Type": "text/plain; charset=utf-8",
});
return new Response(text);
return new Response(stringifyAliases(aliases));
};
67 changes: 12 additions & 55 deletions src/routes/(app)/api/mail/alias/senders/+server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { PrismaClient } from "@prisma/client";
import type { RequestHandler } from "./$types";
import { getCurrentMembersForPosition } from "../utils";
import authorizedPrismaClient from "$lib/server/authorizedPrisma";
import {
fetchAliasSenders,
fetchSpecialSenders,
mergeAliases,
removeEmptySenders,
stringifyAliases,
} from "../utils";

/**
* Returns a text response where each line contains an email alias
Expand All @@ -18,61 +22,14 @@ import authorizedPrismaClient from "$lib/server/authorizedPrisma";
* ```
*/
export const GET: RequestHandler = async ({ setHeaders }) => {
const emailAddresses = await getAllEmailAddresses(authorizedPrismaClient);
const emailToSenders = await getAllAliasSenders(
authorizedPrismaClient,
emailAddresses,
const emailAliases = await fetchAliasSenders();
const specialReceivers = await fetchSpecialSenders();
const aliases = removeEmptySenders(
mergeAliases([...emailAliases, ...specialReceivers]),
);

const output: string[] = [];
for (const [emailAddress, senders] of Object.entries(emailToSenders)) {
output.push(emailAddress + " " + senders.join(", "));
}

setHeaders({
"Content-Type": "text/plain; charset=utf-8",
});
return new Response(output.join("\n"));
return new Response(stringifyAliases(aliases));
};

/**
* @returns object containing all email addresses that are allowed to send emails
*/
async function getAllEmailAddresses(prisma: PrismaClient) {
const aliases = await prisma.emailAlias.findMany({
where: { canSend: true },
orderBy: { email: "asc" },
});
const specialAliases = await prisma.specialSender.findMany({
orderBy: { email: "asc" },
});
return { aliases, specialAliases };
}

type AliasObjects = Awaited<ReturnType<typeof getAllEmailAddresses>>;
/**
* @returns map of email addresses -> student IDs that are allowed to send emails from that alias
*/
async function getAllAliasSenders(
prisma: PrismaClient,
{ aliases, specialAliases }: AliasObjects,
) {
const res: Record<string, string[]> = {};

for (const alias of aliases) {
res[alias.email] ??= [];
const studentIds = (
await getCurrentMembersForPosition(alias.positionId, prisma)
)
.map((member) => member.studentId)
.filter((id): id is string => !!id);
res[alias.email]!.push(...studentIds);
}

for (const specialAlias of specialAliases) {
res[specialAlias.email] ??= [];
res[specialAlias.email]!.push(specialAlias.studentId);
}

return res;
}
9 changes: 9 additions & 0 deletions src/routes/(app)/api/mail/alias/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ beforeAll(async () => {
name: TEST_ID,
mandates: {
create: {
id: TEST_ID,
startDate: new Date(),
endDate: dayjs().add(1, "hour").toDate(),
member: {
Expand Down Expand Up @@ -84,6 +85,14 @@ test("email alias list contains alias", async () => {
expect(body).toContain(TEST_EMAIL_ALIAS);
});

test("email alias list adds root@dsek.se to empty alias", async () => {
const mandate = await prisma.mandate.delete({ where: { id: TEST_ID } });
const response = await GET(mockEvent);
await prisma.mandate.create({ data: mandate });
const body = await response.text();
expect(body).toContain("root@dsek.se");
});

test("email alias list contains alias receiver", async () => {
const response = await GET(mockEvent);
const body = await response.text();
Expand Down
Loading

0 comments on commit a72be60

Please sign in to comment.