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 01/14] 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 02/14] 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 03/14] 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 04/14] 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 05/14] 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 06/14] 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, + }); +} From 3a640de7ae3bbedb5c68086a45949401000975db Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Wed, 30 Sep 2026 17:27:46 +0200 Subject: [PATCH 07/14] Changed the environment variables usage to match the rest of the project. --- src/enviroment.ts | 26 ++++++++++++++++++++++++++ src/services/mail-service.ts | 11 ++++++----- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/enviroment.ts b/src/enviroment.ts index d12143f..fcf57aa 100644 --- a/src/enviroment.ts +++ b/src/enviroment.ts @@ -24,3 +24,29 @@ if (!hostOptionsResult.success) { } export const hostOptions = hostOptionsResult.data; + + + + +const mailOptionsResult = z + .object({ + GOOGLE_FROM_EMAIL: z.string().email(), + GOOGLE_CLIENT_ID: z.string().nonempty(), + GOOGLE_CLIENT_SECRET: z.string().nonempty(), + GOOGLE_REFRESH_TOKEN: z.string().nonempty(), + }) + .transform((schema) => ({ + fromEmail: schema.GOOGLE_FROM_EMAIL, + clientId: schema.GOOGLE_CLIENT_ID, + clientSecret: schema.GOOGLE_CLIENT_SECRET, + refreshToken: schema.GOOGLE_REFRESH_TOKEN, + })) + .safeParse(env); + +if (!mailOptionsResult.success) { + console.error("Error when parsing environment variables."); + console.error(fromZodError(mailOptionsResult.error).message); + process.exit(1); +} + +export const mailOptions = mailOptionsResult.data; diff --git a/src/services/mail-service.ts b/src/services/mail-service.ts index 91dbf6f..b8ab34e 100644 --- a/src/services/mail-service.ts +++ b/src/services/mail-service.ts @@ -1,4 +1,5 @@ import nodemailer from "nodemailer"; +import { mailOptions } from "@/src/enviroment"; const transporter = nodemailer.createTransport({ secure: true, @@ -7,10 +8,10 @@ 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, + user: mailOptions.fromEmail, + clientId: mailOptions.clientId, + clientSecret: mailOptions.clientSecret, + refreshToken: mailOptions.refreshToken, }, }); @@ -22,7 +23,7 @@ export async function sendEmail( html?: string, ) { await transporter.sendMail({ - from: `"Vektorprogrammet" <${process.env.GOOGLE_FROM_EMAIL}>`, + from: `"Vektorprogrammet" <${mailOptions.fromEmail}>`, to, replyTo, subject, From f41971d35ccd93671b2dc755895a0a22ada212c1 Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Wed, 30 Sep 2026 17:37:41 +0200 Subject: [PATCH 08/14] Removed the magic constant from contacts.ts and added it to the global-variables.ts file --- lib/global-variables.ts | 8 ++++++++ src/routers/contact.ts | 10 ++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/global-variables.ts b/lib/global-variables.ts index 4ee694c..5ea9a47 100644 --- a/lib/global-variables.ts +++ b/lib/global-variables.ts @@ -2,3 +2,11 @@ export const MAX_TEXT_LENGTH = 2500 as const; export const DEFAULT_QUERY_LIMIT = 10 as const; export const DEFAULT_QUERY_SORT_OPTION = "desc" as const; + + +export const VALID_CONTACT_EMAIL_ADDRESSES: readonly string[] = [ + "hovedstyret@vektorprogrammet.no", + "styret.ntnu@vektorprogrammet.no", + "uib@vektorprogrammet.no", + "nmbu@vektorprogrammet.no", +]; \ No newline at end of file diff --git a/src/routers/contact.ts b/src/routers/contact.ts index c9401bc..9d31600 100644 --- a/src/routers/contact.ts +++ b/src/routers/contact.ts @@ -1,4 +1,5 @@ import { clientError } from "@/src/error/http-errors"; +import { VALID_CONTACT_EMAIL_ADDRESSES } from "@/lib/global-variables"; import { sendEmail } from "@/src/services/mail-service"; import { Router, json } from "express"; import { z } from "zod"; @@ -67,14 +68,7 @@ contactRouter.post("/", async (req, res, next) => { const { receivingEmail, replyTo, about, text } = result.data; - const validEmailAddresses = [ - "hovedstyret@vektorprogrammet.no", - "styret.ntnu@vektorprogrammet.no", - "uib@vektorprogrammet.no", - "nmbu@vektorprogrammet.no", - ]; - - if (!validEmailAddresses.includes(receivingEmail)) { + if (!VALID_CONTACT_EMAIL_ADDRESSES.includes(receivingEmail)) { return next(clientError(418, "Nice try!")); } From e6afd782a9e827bf1b4811e67226d40084f742f4 Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Wed, 30 Sep 2026 18:17:49 +0200 Subject: [PATCH 09/14] Updated the readme file to include what values to add to the .env file to send emails. --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 79f84f1..2dab8f1 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,26 @@ i `.env`. For å kjøre appen og migrere databasen, se [scripts](#database). + +### Email setup + +The API sends email through Gmail using OAuth 2.0. Add the following values to your `.env` file: + +```.env +GOOGLE_FROM_EMAIL=vektorbot@vektorprogrammet.no +GOOGLE_CLIENT_ID=*Google OAuth client ID* +GOOGLE_CLIENT_SECRET=*Google OAuth client secret* +GOOGLE_REFRESH_TOKEN=*OAuth refresh token for the sender account* +``` + +`GOOGLE_FROM_EMAIL` is the email address used as the sender. `GOOGLE_CLIENT_ID` and +`GOOGLE_CLIENT_SECRET` identify the OAuth client, and `GOOGLE_REFRESH_TOKEN` lets +the API obtain access to Gmail without storing the account password. All four +values are required for the API to start. Keep the client secret and refresh token +private, and do not commit them to the repository. + + + ## Recommended Extensions ### Imports Autocomplete From d9f73393e8a10139aef89741fd7ff9eb55c83602 Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Wed, 30 Sep 2026 18:28:00 +0200 Subject: [PATCH 10/14] fixed the biome and lint issues. --- lib/global-variables.ts | 11 +++++------ src/enviroment.ts | 13 +++++-------- src/routers/contact.ts | 2 +- src/services/mail-service.ts | 2 +- 4 files changed, 12 insertions(+), 16 deletions(-) diff --git a/lib/global-variables.ts b/lib/global-variables.ts index 5ea9a47..370a942 100644 --- a/lib/global-variables.ts +++ b/lib/global-variables.ts @@ -3,10 +3,9 @@ export const DEFAULT_QUERY_LIMIT = 10 as const; export const DEFAULT_QUERY_SORT_OPTION = "desc" as const; - export const VALID_CONTACT_EMAIL_ADDRESSES: readonly string[] = [ - "hovedstyret@vektorprogrammet.no", - "styret.ntnu@vektorprogrammet.no", - "uib@vektorprogrammet.no", - "nmbu@vektorprogrammet.no", -]; \ No newline at end of file + "hovedstyret@vektorprogrammet.no", + "styret.ntnu@vektorprogrammet.no", + "uib@vektorprogrammet.no", + "nmbu@vektorprogrammet.no", +]; diff --git a/src/enviroment.ts b/src/enviroment.ts index fcf57aa..eed73b7 100644 --- a/src/enviroment.ts +++ b/src/enviroment.ts @@ -25,16 +25,13 @@ if (!hostOptionsResult.success) { export const hostOptions = hostOptionsResult.data; - - - const mailOptionsResult = z .object({ - GOOGLE_FROM_EMAIL: z.string().email(), - GOOGLE_CLIENT_ID: z.string().nonempty(), - GOOGLE_CLIENT_SECRET: z.string().nonempty(), - GOOGLE_REFRESH_TOKEN: z.string().nonempty(), - }) + GOOGLE_FROM_EMAIL: z.string().email(), + GOOGLE_CLIENT_ID: z.string().nonempty(), + GOOGLE_CLIENT_SECRET: z.string().nonempty(), + GOOGLE_REFRESH_TOKEN: z.string().nonempty(), + }) .transform((schema) => ({ fromEmail: schema.GOOGLE_FROM_EMAIL, clientId: schema.GOOGLE_CLIENT_ID, diff --git a/src/routers/contact.ts b/src/routers/contact.ts index 9d31600..46ac16a 100644 --- a/src/routers/contact.ts +++ b/src/routers/contact.ts @@ -1,5 +1,5 @@ -import { clientError } from "@/src/error/http-errors"; import { VALID_CONTACT_EMAIL_ADDRESSES } from "@/lib/global-variables"; +import { clientError } from "@/src/error/http-errors"; import { sendEmail } from "@/src/services/mail-service"; import { Router, json } from "express"; import { z } from "zod"; diff --git a/src/services/mail-service.ts b/src/services/mail-service.ts index b8ab34e..64e1db9 100644 --- a/src/services/mail-service.ts +++ b/src/services/mail-service.ts @@ -1,5 +1,5 @@ -import nodemailer from "nodemailer"; import { mailOptions } from "@/src/enviroment"; +import nodemailer from "nodemailer"; const transporter = nodemailer.createTransport({ secure: true, From 7d3163e4fcdd22795f373d1b5a2bcb7fa64f4ecc Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Mon, 5 Oct 2026 16:38:11 +0200 Subject: [PATCH 11/14] Added the new email env vars to the .github workflows, so the CI can use it. --- .github/workflows/pull-request-check.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/pull-request-check.yml b/.github/workflows/pull-request-check.yml index bf45c9d..e6dfe30 100644 --- a/.github/workflows/pull-request-check.yml +++ b/.github/workflows/pull-request-check.yml @@ -9,6 +9,10 @@ env: DATABASE_NAME: vektor-database PORT: 8080 HOSTING_URL: localhost + GOOGLE_FROM_EMAIL: ${{ secrets.GOOGLE_FROM_EMAIL }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + GOOGLE_REFRESH_TOKEN: ${{ secrets.GOOGLE_REFRESH_TOKEN }} on: push: From d8a95e89b8906663f9e5343de2a8e4c6ba27a836 Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Mon, 5 Oct 2026 17:29:40 +0200 Subject: [PATCH 12/14] Moved the try catch further in to make the contactRouter cleaner. --- src/routers/contact.ts | 13 ++++++------- src/services/mail-service.ts | 25 ++++++++++++++++--------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/routers/contact.ts b/src/routers/contact.ts index 46ac16a..149f794 100644 --- a/src/routers/contact.ts +++ b/src/routers/contact.ts @@ -1,5 +1,5 @@ import { VALID_CONTACT_EMAIL_ADDRESSES } from "@/lib/global-variables"; -import { clientError } from "@/src/error/http-errors"; +import { clientError, serverError } from "@/src/error/http-errors"; import { sendEmail } from "@/src/services/mail-service"; import { Router, json } from "express"; import { z } from "zod"; @@ -72,12 +72,11 @@ contactRouter.post("/", async (req, res, next) => { return next(clientError(418, "Nice try!")); } - try { - await sendEmail(receivingEmail, replyTo, about, text); + const emailResult = await sendEmail(receivingEmail, replyTo, about, text); - res.json("Successfully sent the email!"); - } catch (error) { - console.error("CONTACT EMAIL ERROR:", error); - next(error); + if (!emailResult.success) { + return next(serverError(500, "Data processing error", emailResult.error)); } + + return res.json("Successfully sent the email!"); }); diff --git a/src/services/mail-service.ts b/src/services/mail-service.ts index 64e1db9..ec52bf8 100644 --- a/src/services/mail-service.ts +++ b/src/services/mail-service.ts @@ -1,3 +1,4 @@ +import type { Result } from "@/lib/types"; import { mailOptions } from "@/src/enviroment"; import nodemailer from "nodemailer"; @@ -21,13 +22,19 @@ export async function sendEmail( subject: string, text?: string, html?: string, -) { - await transporter.sendMail({ - from: `"Vektorprogrammet" <${mailOptions.fromEmail}>`, - to, - replyTo, - subject, - text, - html, - }); +): Promise> { + try { + await transporter.sendMail({ + from: `"Vektorprogrammet" <${mailOptions.fromEmail}>`, + to, + replyTo, + subject, + text, + html, + }); + + return { success: true, data: undefined }; + } catch (error: unknown) { + return { success: false, error: new Error("Failed to send email", { cause: error })}; + } } From 814b165ff2f6d333fde13b9bf27b83454cd8eea1 Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Mon, 5 Oct 2026 17:31:36 +0200 Subject: [PATCH 13/14] Fixed a lint issue. --- src/services/mail-service.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/services/mail-service.ts b/src/services/mail-service.ts index ec52bf8..1c186b8 100644 --- a/src/services/mail-service.ts +++ b/src/services/mail-service.ts @@ -35,6 +35,9 @@ export async function sendEmail( return { success: true, data: undefined }; } catch (error: unknown) { - return { success: false, error: new Error("Failed to send email", { cause: error })}; + return { + success: false, + error: new Error("Failed to send email", { cause: error }) + }; } } From e55c02ef971a6da98f55689f363ef19359835aa9 Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Mon, 5 Oct 2026 17:32:47 +0200 Subject: [PATCH 14/14] Fixed the lint issue for real this time. --- src/services/mail-service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/services/mail-service.ts b/src/services/mail-service.ts index 1c186b8..3c1ffe5 100644 --- a/src/services/mail-service.ts +++ b/src/services/mail-service.ts @@ -35,9 +35,9 @@ export async function sendEmail( return { success: true, data: undefined }; } catch (error: unknown) { - return { - success: false, - error: new Error("Failed to send email", { cause: error }) + return { + success: false, + error: new Error("Failed to send email", { cause: error }), }; } }