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: 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 diff --git a/lib/global-variables.ts b/lib/global-variables.ts index 4ee694c..370a942 100644 --- a/lib/global-variables.ts +++ b/lib/global-variables.ts @@ -2,3 +2,10 @@ 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", +]; 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/enviroment.ts b/src/enviroment.ts index d12143f..eed73b7 100644 --- a/src/enviroment.ts +++ b/src/enviroment.ts @@ -24,3 +24,26 @@ 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/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..149f794 --- /dev/null +++ b/src/routers/contact.ts @@ -0,0 +1,82 @@ +import { VALID_CONTACT_EMAIL_ADDRESSES } from "@/lib/global-variables"; +import { clientError, serverError } 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; + + if (!VALID_CONTACT_EMAIL_ADDRESSES.includes(receivingEmail)) { + return next(clientError(418, "Nice try!")); + } + + const emailResult = await sendEmail(receivingEmail, replyTo, about, text); + + 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 new file mode 100644 index 0000000..3c1ffe5 --- /dev/null +++ b/src/services/mail-service.ts @@ -0,0 +1,43 @@ +import type { Result } from "@/lib/types"; +import { mailOptions } from "@/src/enviroment"; +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: mailOptions.fromEmail, + clientId: mailOptions.clientId, + clientSecret: mailOptions.clientSecret, + refreshToken: mailOptions.refreshToken, + }, +}); + +export async function sendEmail( + to: string, + replyTo: string, + subject: string, + text?: string, + html?: string, +): 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 }), + }; + } +}