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/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/main.ts b/src/main.ts index 6c11fc8..55d114b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,6 +12,7 @@ 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"; @@ -45,6 +46,8 @@ api.use("/assistantapplications", assistantApplicationRouter); api.use("/teams", teamsRouter); +api.use("/contact", contactRouter); + // Error handling api.use( jsonParsingErrorHandler, diff --git a/src/routers/contact.ts b/src/routers/contact.ts new file mode 100644 index 0000000..c9401bc --- /dev/null +++ b/src/routers/contact.ts @@ -0,0 +1,89 @@ +import { clientError } from "@/src/error/http-errors"; +import { sendEmail } from "@/src/services/mail-service"; +import { Router, json } from "express"; +import { z } from "zod"; + +export const contactRouter = Router(); +contactRouter.use(json()); + +/** + * @openapi + * /contact: + * post: + * tags: [contact] + * summary: Send a contact email + * description: Send an email to contact, 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 + * responses: + * 200: + * description: Email sent successfully + * content: + * application/json: + * schema: + * type: string + * example: Successfully sent the email! + * 400: + * 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 result = contactSchema.safeParse(req.body); + + if (!result.success) { + return next(clientError(400, "Invalid request format", result.error)); + } + + 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)) { + 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); + } +}); diff --git a/src/services/mail-service.ts b/src/services/mail-service.ts new file mode 100644 index 0000000..91dbf6f --- /dev/null +++ b/src/services/mail-service.ts @@ -0,0 +1,32 @@ +import nodemailer from "nodemailer"; + +const transporter = nodemailer.createTransport({ + 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, + }); +}