From a0be815398855b16c3d97a2b1ed385d6d138d600 Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Mon, 24 Aug 2026 20:48:11 +0200 Subject: [PATCH 1/6] Added a mail-service and a email endpoint to send an email from the website. --- package.json | 2 + pnpm-lock.yaml | 19 +++++++++ src/main.ts | 3 ++ src/routers/email.ts | 81 +++++++++++++++++++++++++++++++++++++ src/routers/mail-service.ts | 27 +++++++++++++ 5 files changed, 132 insertions(+) create mode 100644 src/routers/email.ts create mode 100644 src/routers/mail-service.ts diff --git a/package.json b/package.json index efd64d6..cfc9b90 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "drizzle-zod": "^0.5.1", "express": "^5.1.0", "express-zod-safe": "^1.3.3", + "nodemailer": "^9.0.5", "pg": "^8.14.1", "swagger-jsdoc": "^6.2.8", "validator": "^13.15.0", @@ -42,6 +43,7 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/node": "^22.14.0", + "@types/nodemailer": "^8.0.1", "@types/pg": "^8.11.11", "@types/supertest": "^6.0.3", "@types/swagger-jsdoc": "^6.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f26c070..18c663a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: express-zod-safe: specifier: ^1.3.3 version: 1.3.3(@types/express@4.17.21)(express@5.1.0)(zod@3.24.2) + nodemailer: + specifier: ^9.0.5 + version: 9.0.5 pg: specifier: ^8.14.1 version: 8.14.1 @@ -63,6 +66,9 @@ importers: '@types/node': specifier: ^22.14.0 version: 22.14.0 + '@types/nodemailer': + specifier: ^8.0.1 + version: 8.0.1 '@types/pg': specifier: ^8.11.11 version: 8.11.11 @@ -643,6 +649,9 @@ packages: '@types/node@22.14.0': resolution: {integrity: sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==} + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + '@types/pg@8.11.11': resolution: {integrity: sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw==} @@ -1195,6 +1204,10 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + nodemailer@9.0.5: + resolution: {integrity: sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==} + engines: {node: '>=6.0.0'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -1851,6 +1864,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 22.14.0 + '@types/pg@8.11.11': dependencies: '@types/node': 22.14.0 @@ -2403,6 +2420,8 @@ snapshots: negotiator@1.0.0: {} + nodemailer@9.0.5: {} + normalize-path@3.0.0: {} object-assign@4.1.1: {} diff --git a/src/main.ts b/src/main.ts index 6c11fc8..803a361 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,6 +18,7 @@ import { teamsRouter } from "@/src/routers/teams"; import { usersRouter } from "@/src/routers/users"; import { customCors, customHelmetSecurity } from "@/src/security"; import express from "express"; +import { emailRouter } from "@/src/routers/email"; export const api = express(); @@ -45,6 +46,8 @@ api.use("/assistantapplications", assistantApplicationRouter); api.use("/teams", teamsRouter); +api.use("/email", emailRouter) + // Error handling api.use( jsonParsingErrorHandler, diff --git a/src/routers/email.ts b/src/routers/email.ts new file mode 100644 index 0000000..3c81af2 --- /dev/null +++ b/src/routers/email.ts @@ -0,0 +1,81 @@ +import { clientError } from "@/src/error/http-errors"; +import { + listQueryParser, +} from "@/src/request-handling/common"; +import { Router, json } from "express"; +import { sendEmail } from "@/src/routers/mail-service" + +export const emailRouter = Router(); +emailRouter.use(json()); + +/** + * @openapi + * /email: + * post: + * tags: [email] + * summary: Send an email + * description: Send an email using the configured mail service. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - receivingEmail + * - replyTo + * - about + * properties: + * receivingEmail: + * type: string + * format: email + * description: Recipient email address + * replyTo: + * type: string + * format: email + * nullable: true + * description: Optional reply-to address + * about: + * type: string + * description: Email subject + * text: + * type: string + * nullable: true + * description: Plain text email body + * html: + * type: string + * nullable: true + * description: HTML email body + * responses: + * 200: + * description: Email sent successfully + * content: + * application/json: + * schema: + * type: string + * example: Successfully sent the email! + * 400: + * description: Invalid request format + */ +emailRouter.post("/", async (req, res, next) => { + const queryParametersResult = listQueryParser.safeParse(req.query); + if (!queryParametersResult.success) { + return next( + clientError(400, "Invalid request format", queryParametersResult.error), + ); + } + + try { + await sendEmail( + req.body.receivingEmail, + req.body.replyTo, + req.body.about, + req.body.text, + req.body.html, + ); + + res.json("Successfully sent the email!"); + } catch (error) { + next(error); + } +}); diff --git a/src/routers/mail-service.ts b/src/routers/mail-service.ts new file mode 100644 index 0000000..4df7f47 --- /dev/null +++ b/src/routers/mail-service.ts @@ -0,0 +1,27 @@ +import nodemailer from "nodemailer"; + +const transporter = nodemailer.createTransport({ + service: "gmail", + auth: { + user: process.env.GMAIL_USER, + pass: process.env.GMAIL_APP_PASSWORD, + }, + // auth: { + // type: "OAuth2", + // user: process.env.GMAIL_USER, + // clientId: process.env.GOOGLE_CLIENT_ID, + // clientSecret: process.env.GOOGLE_CLIENT_SECRET, + // refreshToken: process.env.GOOGLE_REFRESH_TOKEN, + // }, +}); + +export async function sendEmail(to: string, replyTo: string, subject: string, text?: string, html?: string) { + await transporter.sendMail({ + from: `"Vektorprogrammet" <${process.env.GMAIL_USER}>`, + to, + replyTo, + subject, + text, + html, + }); +} From 1bef2b6ecd0398c4ec84a1cae5eb2a71cc2f59be Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Mon, 7 Sep 2026 23:36:52 +0200 Subject: [PATCH 2/6] Created a mail-service and a contact router, to be able to send mails when someone contacts us. --- src/main.ts | 4 +-- src/routers/{email.ts => contact.ts} | 54 ++++++++++++++++++---------- src/routers/mail-service.ts | 27 -------------- src/services/mail-service.ts | 24 +++++++++++++ 4 files changed, 61 insertions(+), 48 deletions(-) rename src/routers/{email.ts => contact.ts} (58%) delete mode 100644 src/routers/mail-service.ts create mode 100644 src/services/mail-service.ts diff --git a/src/main.ts b/src/main.ts index 803a361..19fab0e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,7 +18,7 @@ import { teamsRouter } from "@/src/routers/teams"; import { usersRouter } from "@/src/routers/users"; import { customCors, customHelmetSecurity } from "@/src/security"; import express from "express"; -import { emailRouter } from "@/src/routers/email"; +import { contactRouter } from "@/src/routers/contact"; export const api = express(); @@ -46,7 +46,7 @@ api.use("/assistantapplications", assistantApplicationRouter); api.use("/teams", teamsRouter); -api.use("/email", emailRouter) +api.use("/contact", contactRouter) // Error handling api.use( diff --git a/src/routers/email.ts b/src/routers/contact.ts similarity index 58% rename from src/routers/email.ts rename to src/routers/contact.ts index 3c81af2..2f45cb0 100644 --- a/src/routers/email.ts +++ b/src/routers/contact.ts @@ -1,20 +1,18 @@ import { clientError } from "@/src/error/http-errors"; -import { - listQueryParser, -} from "@/src/request-handling/common"; import { Router, json } from "express"; -import { sendEmail } from "@/src/routers/mail-service" +import { sendEmail } from "@/src/services/mail-service" +import { z } from "zod"; -export const emailRouter = Router(); -emailRouter.use(json()); +export const contactRouter = Router(); +contactRouter.use(json()); /** * @openapi - * /email: + * /contact: * post: - * tags: [email] - * summary: Send an email - * description: Send an email using the configured mail service. + * tags: [contact] + * summary: Send a contact email + * description: Send an email to contact, using the configured mail service. * requestBody: * required: true * content: @@ -57,25 +55,43 @@ emailRouter.use(json()); * 400: * description: Invalid request format */ -emailRouter.post("/", async (req, res, next) => { - const queryParametersResult = listQueryParser.safeParse(req.query); - if (!queryParametersResult.success) { +contactRouter.post("/", async (req, res, next) => { + const contactSchema = z.object({ + receivingEmail: z.string().email(), + replyTo: z.string().email(), + about: z.string().min(1), + text: z.string().optional(), + html: z.string().optional(), + }); + + const result = contactSchema.safeParse(req.body); + + if (!result.success) { return next( - clientError(400, "Invalid request format", queryParametersResult.error), + clientError(400, "Invalid request format", result.error), ); } + const { + receivingEmail, + replyTo, + about, + text, + html, + } = result.data; + try { await sendEmail( - req.body.receivingEmail, - req.body.replyTo, - req.body.about, - req.body.text, - req.body.html, + receivingEmail, + replyTo, + about, + text, + html, ); res.json("Successfully sent the email!"); } catch (error) { + console.error("CONTACT EMAIL ERROR:", error); next(error); } }); diff --git a/src/routers/mail-service.ts b/src/routers/mail-service.ts deleted file mode 100644 index 4df7f47..0000000 --- a/src/routers/mail-service.ts +++ /dev/null @@ -1,27 +0,0 @@ -import nodemailer from "nodemailer"; - -const transporter = nodemailer.createTransport({ - service: "gmail", - auth: { - user: process.env.GMAIL_USER, - pass: process.env.GMAIL_APP_PASSWORD, - }, - // auth: { - // type: "OAuth2", - // user: process.env.GMAIL_USER, - // clientId: process.env.GOOGLE_CLIENT_ID, - // clientSecret: process.env.GOOGLE_CLIENT_SECRET, - // refreshToken: process.env.GOOGLE_REFRESH_TOKEN, - // }, -}); - -export async function sendEmail(to: string, replyTo: string, subject: string, text?: string, html?: string) { - await transporter.sendMail({ - from: `"Vektorprogrammet" <${process.env.GMAIL_USER}>`, - to, - replyTo, - subject, - text, - html, - }); -} diff --git a/src/services/mail-service.ts b/src/services/mail-service.ts new file mode 100644 index 0000000..0fee5a5 --- /dev/null +++ b/src/services/mail-service.ts @@ -0,0 +1,24 @@ +import nodemailer from "nodemailer"; + +const transporter = nodemailer.createTransport({ + service: "gmail", + auth: { + type: "OAuth2", + user: process.env.GOOGLE_FROM_EMAIL, + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + refreshToken: process.env.GOOGLE_REFRESH_TOKEN, + }, +}); + +// Remember to add protection to prevent people from using the endpoint to send arbitrary emails from the mailbot. +export async function sendEmail(to: string, replyTo: string, subject: string, text?: string, html?: string) { + await transporter.sendMail({ + from: `"Vektorprogrammet" <${process.env.GOOGLE_FROM_EMAIL}>`, + to, + replyTo, + subject, + text, + html, + }); +}; \ No newline at end of file From a71410cc5cae9a7ff98a4bc25355f1e739298484 Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Mon, 14 Sep 2026 20:01:39 +0200 Subject: [PATCH 3/6] Added extra contact form proctection. --- src/error/error-messages.ts | 1 + src/routers/contact.ts | 20 +++++++++++++------- src/services/mail-service.ts | 2 +- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/error/error-messages.ts b/src/error/error-messages.ts index bd749d6..0ad70e6 100644 --- a/src/error/error-messages.ts +++ b/src/error/error-messages.ts @@ -69,6 +69,7 @@ const HTTP_CLIENT_ERROR_MESSAGES = [ "Failed to execute the database command", "Error parsing database response", "Database error", + "Nice try!", ] as const; const HTTP_SERVER_ERROR_MESSAGES = [ "Internal server error occurred", diff --git a/src/routers/contact.ts b/src/routers/contact.ts index 2f45cb0..b241792 100644 --- a/src/routers/contact.ts +++ b/src/routers/contact.ts @@ -40,10 +40,6 @@ contactRouter.use(json()); * type: string * nullable: true * description: Plain text email body - * html: - * type: string - * nullable: true - * description: HTML email body * responses: * 200: * description: Email sent successfully @@ -61,7 +57,6 @@ contactRouter.post("/", async (req, res, next) => { replyTo: z.string().email(), about: z.string().min(1), text: z.string().optional(), - html: z.string().optional(), }); const result = contactSchema.safeParse(req.body); @@ -77,16 +72,27 @@ contactRouter.post("/", async (req, res, next) => { replyTo, about, text, - html, } = result.data; + + const validEmailAddresses = [ + "hovedstyret@vektorprogrammet.no", + "styret.ntnu@vektorprogrammet.no", + "uib@vektorprogrammet.no", + "nmbu@vektorprogrammet.no" + ]; + + + if (!validEmailAddresses.includes(receivingEmail)) { + return next(clientError(418, "Nice try!")); + } + try { await sendEmail( receivingEmail, replyTo, about, text, - html, ); res.json("Successfully sent the email!"); diff --git a/src/services/mail-service.ts b/src/services/mail-service.ts index 0fee5a5..ced38a1 100644 --- a/src/services/mail-service.ts +++ b/src/services/mail-service.ts @@ -11,7 +11,7 @@ const transporter = nodemailer.createTransport({ }, }); -// Remember to add protection to prevent people from using the endpoint to send arbitrary emails from the mailbot. + export async function sendEmail(to: string, replyTo: string, subject: string, text?: string, html?: string) { await transporter.sendMail({ from: `"Vektorprogrammet" <${process.env.GOOGLE_FROM_EMAIL}>`, From d5e3255c12d72a4fec71b5da7192ac8383748b4a Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Mon, 14 Sep 2026 20:37:31 +0200 Subject: [PATCH 4/6] Made nodemailer use https. --- src/services/mail-service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/services/mail-service.ts b/src/services/mail-service.ts index ced38a1..eafd334 100644 --- a/src/services/mail-service.ts +++ b/src/services/mail-service.ts @@ -1,6 +1,8 @@ import nodemailer from "nodemailer"; const transporter = nodemailer.createTransport({ + secure: true, + requireTLS: true, service: "gmail", auth: { type: "OAuth2", @@ -15,7 +17,7 @@ const transporter = nodemailer.createTransport({ export async function sendEmail(to: string, replyTo: string, subject: string, text?: string, html?: string) { await transporter.sendMail({ from: `"Vektorprogrammet" <${process.env.GOOGLE_FROM_EMAIL}>`, - to, + to: "kristoffer.fredrik@gmail.com", replyTo, subject, text, From 30dc22792f034642789af1257bf188def2a50248 Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Mon, 14 Sep 2026 21:05:37 +0200 Subject: [PATCH 5/6] Removed hardcoded email. --- src/services/mail-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/mail-service.ts b/src/services/mail-service.ts index eafd334..2b46ec9 100644 --- a/src/services/mail-service.ts +++ b/src/services/mail-service.ts @@ -17,7 +17,7 @@ const transporter = nodemailer.createTransport({ export async function sendEmail(to: string, replyTo: string, subject: string, text?: string, html?: string) { await transporter.sendMail({ from: `"Vektorprogrammet" <${process.env.GOOGLE_FROM_EMAIL}>`, - to: "kristoffer.fredrik@gmail.com", + to, replyTo, subject, text, From 4b6c52429c821525fc527b6f447e89e137156870 Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Mon, 14 Sep 2026 21:16:28 +0200 Subject: [PATCH 6/6] Fixed lint and biome ci issues. --- src/main.ts | 4 +-- src/routers/contact.ts | 70 +++++++++++++++--------------------- src/services/mail-service.ts | 48 ++++++++++++++----------- 3 files changed, 57 insertions(+), 65 deletions(-) diff --git a/src/main.ts b/src/main.ts index 19fab0e..55d114b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,13 +12,13 @@ import { assistantApplicationRouter, teamApplicationRouter, } from "@/src/routers/applications"; +import { contactRouter } from "@/src/routers/contact"; import { expensesRouter } from "@/src/routers/expenses"; import { sponsorsRouter } from "@/src/routers/sponsors"; import { teamsRouter } from "@/src/routers/teams"; import { usersRouter } from "@/src/routers/users"; import { customCors, customHelmetSecurity } from "@/src/security"; import express from "express"; -import { contactRouter } from "@/src/routers/contact"; export const api = express(); @@ -46,7 +46,7 @@ api.use("/assistantapplications", assistantApplicationRouter); api.use("/teams", teamsRouter); -api.use("/contact", contactRouter) +api.use("/contact", contactRouter); // Error handling api.use( diff --git a/src/routers/contact.ts b/src/routers/contact.ts index b241792..c9401bc 100644 --- a/src/routers/contact.ts +++ b/src/routers/contact.ts @@ -1,6 +1,6 @@ import { clientError } from "@/src/error/http-errors"; +import { sendEmail } from "@/src/services/mail-service"; import { Router, json } from "express"; -import { sendEmail } from "@/src/services/mail-service" import { z } from "zod"; export const contactRouter = Router(); @@ -52,52 +52,38 @@ contactRouter.use(json()); * description: Invalid request format */ contactRouter.post("/", async (req, res, next) => { - const contactSchema = z.object({ - receivingEmail: z.string().email(), - replyTo: z.string().email(), - about: z.string().min(1), - text: z.string().optional(), - }); + const contactSchema = z.object({ + receivingEmail: z.string().email(), + replyTo: z.string().email(), + about: z.string().min(1), + text: z.string().optional(), + }); - const result = contactSchema.safeParse(req.body); + const result = contactSchema.safeParse(req.body); - if (!result.success) { - return next( - clientError(400, "Invalid request format", result.error), - ); - } + if (!result.success) { + return next(clientError(400, "Invalid request format", result.error)); + } - const { - receivingEmail, - replyTo, - about, - text, - } = result.data; + const { receivingEmail, replyTo, about, text } = result.data; + const validEmailAddresses = [ + "hovedstyret@vektorprogrammet.no", + "styret.ntnu@vektorprogrammet.no", + "uib@vektorprogrammet.no", + "nmbu@vektorprogrammet.no", + ]; - const validEmailAddresses = [ - "hovedstyret@vektorprogrammet.no", - "styret.ntnu@vektorprogrammet.no", - "uib@vektorprogrammet.no", - "nmbu@vektorprogrammet.no" - ]; + if (!validEmailAddresses.includes(receivingEmail)) { + return next(clientError(418, "Nice try!")); + } + try { + await sendEmail(receivingEmail, replyTo, about, text); - if (!validEmailAddresses.includes(receivingEmail)) { - return next(clientError(418, "Nice try!")); - } - - try { - await sendEmail( - receivingEmail, - replyTo, - about, - text, - ); - - res.json("Successfully sent the email!"); - } catch (error) { - console.error("CONTACT EMAIL ERROR:", error); - next(error); - } + res.json("Successfully sent the email!"); + } catch (error) { + console.error("CONTACT EMAIL ERROR:", error); + next(error); + } }); diff --git a/src/services/mail-service.ts b/src/services/mail-service.ts index 2b46ec9..91dbf6f 100644 --- a/src/services/mail-service.ts +++ b/src/services/mail-service.ts @@ -1,26 +1,32 @@ import nodemailer from "nodemailer"; const transporter = nodemailer.createTransport({ - secure: true, - requireTLS: true, - service: "gmail", - auth: { - type: "OAuth2", - user: process.env.GOOGLE_FROM_EMAIL, - clientId: process.env.GOOGLE_CLIENT_ID, - clientSecret: process.env.GOOGLE_CLIENT_SECRET, - refreshToken: process.env.GOOGLE_REFRESH_TOKEN, - }, + secure: true, + // biome-ignore lint/style/useNamingConvention: Nodemailer requires the `requireTLS` option name. + requireTLS: true, + service: "gmail", + auth: { + type: "OAuth2", + user: process.env.GOOGLE_FROM_EMAIL, + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + refreshToken: process.env.GOOGLE_REFRESH_TOKEN, + }, }); - -export async function sendEmail(to: string, replyTo: string, subject: string, text?: string, html?: string) { - await transporter.sendMail({ - from: `"Vektorprogrammet" <${process.env.GOOGLE_FROM_EMAIL}>`, - to, - replyTo, - subject, - text, - html, - }); -}; \ No newline at end of file +export async function sendEmail( + to: string, + replyTo: string, + subject: string, + text?: string, + html?: string, +) { + await transporter.sendMail({ + from: `"Vektorprogrammet" <${process.env.GOOGLE_FROM_EMAIL}>`, + to, + replyTo, + subject, + text, + html, + }); +}