diff --git a/.env.example b/.env.example index ea9b566..5b02e7e 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,12 @@ SESSION_SECRET=vervang-dit-met-een-willekeurige-string-van-64-tekens DB_PATH=./data/app.db NODE_ENV=development -# Email (voor workflow-notificaties) +# E-mail (uitnodigingen voor experts). Voorkeur: SendGrid, dezelfde instellingen als het Rikx ZRM-platform +SENDGRID_API_KEY= +SENDGRID_FROM_EMAIL=no-reply@rikxplatform.nl +SENDGRID_FROM_NAME=Rikx + +# Alternatief: SMTP (alleen gebruikt als SENDGRID_API_KEY leeg is) SMTP_HOST= SMTP_PORT=587 SMTP_USER= diff --git a/CLAUDE.md b/CLAUDE.md index fa4d0ec..68be9ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ Beheerportaal voor de Rikx-methodiek. Beheert de sociaal ondernemers (accounts i - Impactmaker van een project = sociaal ondernemer: `projects.impactmaker_account_id` verwijst naar `accounts.id` in de platformdatabase (geen foreign key; namen via `listAccountNames()` in `lib/platform-db.ts`) - `/rondes` — Rikx rondes beheren (label, Rikx waarde, Rikx prijs, gekoppelde experts; dezelfde koppeling `expert_rondes` als op `/experts`); een project hoort optioneel bij één ronde (`projects.ronde_id`) - `/projecten/[id]` — projectpagina met tabs: Project plan (velden en keuzelijsten gedefinieerd in `lib/projectPlan.ts`) en Rikx calculatie (alleen zichtbaar vanaf status Waardebepaling, zie `RIKX_CALCULATIE_VANAF_STATUS` in `lib/projectStatus.ts`; rekenmethode `arbeid`, `jeugd` is zichtbaar maar nog niet beschikbaar). Onderaan de tab: Beoordeling (`beoordeling`: goedgekeurd/afgekeurd/leeg) met `beoordeling_reden`. Kolom `rikx_calculatie_aan` is van de oude schuif en wordt niet meer gebruikt. Uitkomsten worden live berekend met `lib/rikxCalculatie.ts` -- `/experts` (CRM → Experts) — experts uitnodigen, rondes koppelen, opnieuw uitnodigen (ook voor een vergeten wachtwoord), verwijderen, en "Inloggen als": een admin bekijkt `/expert` als die expert (alleen lezen; sessie met `impersonatorId`, terug via "Terug naar beheer"). Uitnodiging per e-mail via SMTP; zonder SMTP_HOST toont de pagina de link om zelf door te geven +- `/experts` (CRM → Experts) — experts uitnodigen, rondes koppelen, opnieuw uitnodigen (ook voor een vergeten wachtwoord), verwijderen, en "Inloggen als": een admin bekijkt `/expert` als die expert (alleen lezen; sessie met `impersonatorId`, terug via "Terug naar beheer"). Uitnodiging per e-mail via SendGrid of SMTP; zonder beide toont de pagina de link om zelf door te geven - `/uitnodiging/[token]` — openbaar: expert stelt wachtwoord in (token 7 dagen geldig, eenmalig; alleen de SHA-256-hash staat in de database) - `/expert` — de enige pagina voor experts: projecten in Waardebepaling uit hun rondes, project plan (alleen lezen) en hun eigen beoordeling (3 scores 1–10, advies Bonus/Malus, toelichting). Experts zien elkaars beoordeling niet - Rollen: `users.role` = `admin` of `expert`. Elke beheerpagina en -actie begint met `requireAdmin()`, de expertpagina met `requireExpert()` (`lib/auth.ts`); de rol komt uit de database, niet uit de cookie @@ -21,7 +21,7 @@ Beheerportaal voor de Rikx-methodiek. Beheert de sociaal ondernemers (accounts i - Next.js 16 + TypeScript + Tailwind CSS - SQLite via `node:sqlite` (ingebouwd in Node 22, geen extra package) - HMAC-signed sessie-cookie auth (geen externe auth-provider) -- Nodemailer voor email-notificaties +- E-mail via SendGrid (zoals het ZRM-platform) of Nodemailer/SMTP — `lib/mail.ts` ## Lokaal draaien ```bash @@ -61,7 +61,10 @@ npm run create-admin maarten@rikx.nl wachtwoord "Maarten" | `DB_PATH` | `./data/app.db` | **In productie: `/data/app.db`** (absoluut pad) | | `RIKX_PLATFORM_DB_PATH` | `/rikx-platform-data/app.db` | Database van het ZRM-platform (sociaal ondernemers) | | `NODE_ENV` | `development` | In productie: `production` | -| `SMTP_HOST` | — | SMTP server voor e-mail (o.a. uitnodigingen van experts); leeg = geen e-mail, link wordt in beeld getoond | +| `SENDGRID_API_KEY` | — | E-mail via SendGrid (uitnodigingen voor experts); zelfde key als het Rikx ZRM-platform. Nooit committen. Leeg én geen SMTP = geen e-mail, link wordt in beeld getoond | +| `SENDGRID_FROM_EMAIL` | `no-reply@rikxplatform.nl` | Afzender bij SendGrid (domein is daar al geverifieerd voor ZRM) | +| `SENDGRID_FROM_NAME` | `Rikx` | Afzendernaam | +| `SMTP_HOST` | — | Alternatief voor SendGrid: SMTP server (alleen gebruikt als `SENDGRID_API_KEY` leeg is) | | `SMTP_PORT` | `587` | SMTP poort | | `SMTP_USER` | — | SMTP gebruikersnaam | | `SMTP_PASS` | — | SMTP wachtwoord | diff --git a/app/experts/actions.ts b/app/experts/actions.ts index 2d66466..f0b9822 100644 --- a/app/experts/actions.ts +++ b/app/experts/actions.ts @@ -14,6 +14,10 @@ export type InviteState = | { status: "sent"; message: string } | { status: "link"; message: string; link: string }; +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); +} + const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; function readRondeIds(formData: FormData): number[] { @@ -39,15 +43,21 @@ async function invite(userId: number, name: string, email: string): PromiseBeste ${escapeHtml(name)},

+

Je bent uitgenodigd voor het Rikx expertteam. Via onderstaande knop stel je je wachtwoord in en kun je de projecten in waardebepaling beoordelen.

+

Wachtwoord instellen

+

De link is ${UITNODIGING_GELDIG_DAGEN} dagen geldig. Werkt de knop niet? Kopieer dan deze link: ${link}

+

Met vriendelijke groet,
Rikx

`, }); return { status: "sent", message: `Uitnodiging verstuurd naar ${email}.` }; } catch (err) { diff --git a/lib/mail.ts b/lib/mail.ts index c4530b4..fea1ee7 100644 --- a/lib/mail.ts +++ b/lib/mail.ts @@ -1,12 +1,39 @@ import nodemailer from "nodemailer"; -// E-mail via SMTP (zie SMTP_* in CLAUDE.md). Zonder SMTP_HOST wordt er niets verstuurd; -// de aanroeper toont dan bijvoorbeeld de uitnodigingslink in beeld. +// E-mail versturen. Voorkeur: SendGrid, met dezelfde instellingen als het Rikx ZRM-platform +// (SENDGRID_API_KEY, SENDGRID_FROM_EMAIL, SENDGRID_FROM_NAME). Anders SMTP (SMTP_*). +// Zonder beide wordt er niets verstuurd; de aanroeper toont dan bijvoorbeeld de uitnodigingslink in beeld. export function isMailConfigured(): boolean { - return Boolean(process.env.SMTP_HOST); + return Boolean(process.env.SENDGRID_API_KEY || process.env.SMTP_HOST); } -export async function sendMail(message: { to: string; subject: string; text: string; html?: string }): Promise { +type Message = { to: string; toName?: string; subject: string; text: string; html?: string }; + +export async function sendMail(message: Message): Promise { + if (process.env.SENDGRID_API_KEY) return sendViaSendGrid(message, process.env.SENDGRID_API_KEY); + return sendViaSmtp(message); +} + +async function sendViaSendGrid(message: Message, apiKey: string): Promise { + const res = await fetch("https://api.sendgrid.com/v3/mail/send", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: message.to, name: message.toName }], subject: message.subject }], + from: { + email: process.env.SENDGRID_FROM_EMAIL || "no-reply@rikxplatform.nl", + name: process.env.SENDGRID_FROM_NAME || "Rikx", + }, + content: [ + { type: "text/plain", value: message.text }, + ...(message.html ? [{ type: "text/html", value: message.html }] : []), + ], + }), + }); + if (!res.ok) throw new Error(`SendGrid ${res.status}: ${await res.text()}`); +} + +async function sendViaSmtp(message: Message): Promise { const port = Number(process.env.SMTP_PORT || 587); const transport = nodemailer.createTransport({ host: process.env.SMTP_HOST, @@ -14,5 +41,6 @@ export async function sendMail(message: { to: string; subject: string; text: str secure: port === 465, auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } : undefined, }); - await transport.sendMail({ from: process.env.SMTP_FROM || "noreply@rikx.nl", ...message }); + const { toName: _toName, ...rest } = message; + await transport.sendMail({ from: process.env.SMTP_FROM || "noreply@rikx.nl", ...rest }); }