diff --git a/.env b/.env index 5dd6dc7f5..7433d49e3 100644 --- a/.env +++ b/.env @@ -65,6 +65,19 @@ NEXT_PUBLIC_GTM_ID= NEXT_PUBLIC_API_URL=$API_URL API_BASIC_AUTH_SYSTEM_USER_PASSWORD=$BASIC_AUTH_SYSTEM_USER_PASSWORD +# mailpit (catches all mails sent in development, web interface: http://localhost:${MAILPIT_UI_PORT}) +MAILPIT_SMTP_PORT=1025 +MAILPIT_UI_PORT=8025 + +# mail (sent by the site, see the contact form route) +MAIL_HOST=localhost +MAIL_PORT=$MAILPIT_SMTP_PORT +# mailpit doesn't require authentication +MAIL_USER= +MAIL_PASSWORD= +MAIL_FROM= +CONTACT_FORM_TO_EMAIL= + # jaegertracing JAEGER_UI_PORT=16686 JAEGER_HOST=localhost diff --git a/AGENTS.md b/AGENTS.md index 39d4f6e8e..d24c5a07e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,6 +124,7 @@ The `site-configs/` directory manages site configurations, compiled into environ - PostgreSQL (port 5432) - imgproxy (port 6080) - image optimization - Jaeger (port 16686) - distributed tracing +- Mailpit (SMTP port 1025, web interface port 8025) - catches all mails sent in development ### Local Ports @@ -165,6 +166,10 @@ The `site-configs/` directory manages site configurations, compiled into environ After making code changes, always run `npm --prefix run lint:fix` for each affected package. This auto-fixes import ordering, removes unused imports, and applies Prettier formatting. Run this before committing or presenting changes as complete. +### BFF Routes + +Server-side logic that doesn't belong into the CMS API is implemented as Next.js route handlers in `site/src/app/[visibility]/[domain]/`. See `api/contact-form/route.ts` for an example: it validates the submitted values with zod and sends them as a mail with nodemailer, directly from the site instead of the API. In development, mails are caught by Mailpit. + ### API Module Structure Feature-based organization: `auth/`, `documents/`, `dam/`, `menus/`, `footers/`, `redirects/`, `healthcheck/` diff --git a/docker-compose.yml b/docker-compose.yml index fee6cad36..9653afc68 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,6 +45,13 @@ services: COLLECTOR_OTLP_ENABLED: "true" COLLECTOR_OTLP_HTTP_HOST_PORT: 0.0.0.0:4318 + mailpit: + image: mirror.gcr.io/axllent/mailpit:v1.31 + pull_policy: weekly + ports: + - "127.0.0.1:${MAILPIT_SMTP_PORT}:1025" + - "127.0.0.1:${MAILPIT_UI_PORT}:8025" + # valkey: # image: mirror.gcr.io/valkey/valkey:9 # pull_policy: weekly diff --git a/site/package-lock.json b/site/package-lock.json index 570378656..de16a2b0f 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -20,13 +20,15 @@ "keyv": "^5.6.0", "lru-cache": "^11.5.2", "next": "^16.3.3", + "nodemailer": "^9.0.6", "react": "^19.2.8", "react-dom": "^19.2.8", "react-focus-lock": "^2.13.7", "react-intl": "^7.1.14", "redraft": "^0.10.2", "swiper": "^14.2.0", - "usehooks-ts": "^3.1.1" + "usehooks-ts": "^3.1.1", + "zod": "^4.4.3" }, "devDependencies": { "@dextinity/cli": "10.2.0", @@ -40,6 +42,7 @@ "@graphql-codegen/typescript-operations": "^6.1.6", "@parcel/watcher": "^2.6.0", "@types/node": "^24.13.3", + "@types/nodemailer": "^8.0.1", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.5", "check-node-version": "^4.2.1", @@ -6812,6 +6815,16 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/oracledb": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/@types/oracledb/-/oracledb-6.5.2.tgz", @@ -12754,6 +12767,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.6.tgz", + "integrity": "sha512-IQUGFdhdGwI9+AWX+FpUt4DLmvFaOjTMEoneTIWX/RXxuy1TdenPwWrvFMSfLkPKl+HQEXWuSAxEMMbPYXtBmg==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -16867,7 +16889,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/site/package.json b/site/package.json index 9b0223b91..d89944ea6 100644 --- a/site/package.json +++ b/site/package.json @@ -43,13 +43,15 @@ "keyv": "^5.6.0", "lru-cache": "^11.5.2", "next": "^16.3.3", + "nodemailer": "^9.0.6", "react": "^19.2.8", "react-dom": "^19.2.8", "react-focus-lock": "^2.13.7", "react-intl": "^7.1.14", "redraft": "^0.10.2", "swiper": "^14.2.0", - "usehooks-ts": "^3.1.1" + "usehooks-ts": "^3.1.1", + "zod": "^4.4.3" }, "devDependencies": { "@dextinity/cli": "10.2.0", @@ -63,6 +65,7 @@ "@graphql-codegen/typescript-operations": "^6.1.6", "@parcel/watcher": "^2.6.0", "@types/node": "^24.13.3", + "@types/nodemailer": "^8.0.1", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.5", "check-node-version": "^4.2.1", diff --git a/site/src/app/[visibility]/[domain]/api/contact-form/route.ts b/site/src/app/[visibility]/[domain]/api/contact-form/route.ts new file mode 100644 index 000000000..fd907d8df --- /dev/null +++ b/site/src/app/[visibility]/[domain]/api/contact-form/route.ts @@ -0,0 +1,58 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { createTransport } from "nodemailer"; +import { z } from "zod"; + +const queryValidationSchema = z.object({ + email: z.email(), + message: z.string(), +}); + +// The [visibility] and [domain] segments are added by the domain rewrite middleware, so a form submits to /api/contact-form. +export async function POST(request: NextRequest) { + const body = await request.json(); + const validationResult = queryValidationSchema.safeParse(body); + + if (!validationResult.success) { + return NextResponse.json( + { + cause: validationResult.error, + message: "Validation failed", + }, + { + status: 400, + }, + ); + } + + const { email, message } = validationResult.data; + + try { + const port = parseInt(process.env.MAIL_PORT || "587", 10); + const user = process.env.MAIL_USER; + + const transport = createTransport({ + host: process.env.MAIL_HOST, + port, + secure: port === 465, // all other ports use STARTTLS + auth: user ? { user, pass: process.env.MAIL_PASSWORD } : undefined, + }); + + await transport.sendMail({ + from: process.env.MAIL_FROM, + to: process.env.CONTACT_FORM_TO_EMAIL, + replyTo: email, + subject: "Contact form", + text: message, + }); + + return NextResponse.json( + { success: true }, + { + status: 200, + }, + ); + } catch (e) { + console.error(e); + return NextResponse.json({ error: "Something went wrong processing the contact form" }, { status: 500 }); + } +}