From 6b3d56e1e85c49e657e86b0b34aad84bb6471429 Mon Sep 17 00:00:00 2001 From: Claude VM Date: Wed, 22 Apr 2026 07:49:08 +0300 Subject: [PATCH] =?UTF-8?q?refactor(deploy):=20externalize=20all=20secrets?= =?UTF-8?q?=20to=20.env,=20migrate=20Brevo=20SMTP=20=E2=86=92=20REST=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-compose.yml: replace 43 hardcoded env values with ${VAR} references. Operators must provide /opt/architools/.env (chmod 600, gitignored) with the matching keys. Removes the historical leak surface where every edit risked echoing secrets. - email-service.ts: drop nodemailer SMTP transport; use Brevo REST API (POST https://api.brevo.com/v3/smtp/email) with BREVO_API_KEY header. Brevo SMTP relay credentials have been deleted upstream. - package.json: remove nodemailer + @types/nodemailer. NOTE: legacy hardcoded credentials present in git history must still be rotated separately (DB password, Authentik client secret, ENCRYPTION_SECRET, ANCPI password, etc.). Co-Authored-By: Claude Opus 4.7 (1M context) --- docker-compose.yml | 295 ++++++++++++------------ package-lock.json | 34 +-- package.json | 2 - src/core/notifications/email-service.ts | 76 +++--- 4 files changed, 181 insertions(+), 226 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index f5a95ee..357efe5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,149 +1,146 @@ -services: - architools: - build: - context: . - args: - - NEXT_PUBLIC_STORAGE_ADAPTER=${NEXT_PUBLIC_STORAGE_ADAPTER:-database} - - NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME:-ArchiTools} - - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-https://tools.beletage.ro} - - NEXT_PUBLIC_MARTIN_URL=https://tools.beletage.ro/tiles - - NEXT_PUBLIC_PMTILES_URL=/tiles/pmtiles/overview.pmtiles - container_name: architools - restart: unless-stopped - ports: - - "3000:3000" - environment: - - NODE_ENV=production - # Database - - DATABASE_URL=postgresql://architools_user:stictMyFon34!_gonY@10.10.10.166:5432/architools_db?schema=public - # MinIO - - MINIO_ENDPOINT=10.10.10.166 - - MINIO_PORT=9002 - - MINIO_USE_SSL=false - - MINIO_ACCESS_KEY=admin - - MINIO_SECRET_KEY=MinioStrongPass123 - - MINIO_BUCKET_NAME=tools - # Authentication (Authentik OIDC) - - NEXTAUTH_URL=https://tools.beletage.ro - - NEXTAUTH_SECRET=8IL9Kpipj0EZwZPNvekbNRPhV6a2/UY4cGVzE3n0pUY= - - AUTHENTIK_CLIENT_ID=V59GMiYle87yd9VZOgUmdSmzYQALqNsKVAUR6QMi - - AUTHENTIK_CLIENT_SECRET=TMeewkusUro0hQ2DMwS0Z5lNpNMdmziO9WXywNAGlK3Y6Y8HYULZBEtMtm53lioIkszWbpPRQcv1cxHMtwftMvsaSnbliDsL1f707wmUJhMFKjeZ0ypIFKFG4dJkp7Jr - - AUTHENTIK_ISSUER=https://auth.beletage.ro/application/o/architools/ - # Vault encryption - - ENCRYPTION_SECRET=ArchiTools-Vault-2025!SecureKey@AES256 - # ManicTime Tags.txt sync (SMB mount path) - - MANICTIME_TAGS_PATH=/mnt/manictime/Tags.txt - # AI Chat (set AI_PROVIDER to openai/anthropic/ollama; demo if no key) - - AI_PROVIDER=${AI_PROVIDER:-demo} - - AI_API_KEY=${AI_API_KEY:-} - - AI_MODEL=${AI_MODEL:-} - - AI_BASE_URL=${AI_BASE_URL:-} - - AI_MAX_TOKENS=${AI_MAX_TOKENS:-2048} - # Visual CoPilot (at-vim) - - VIM_URL=${VIM_URL:-} - # eTerra ANCPI (parcel-sync module) - - ETERRA_USERNAME=${ETERRA_USERNAME:-} - - ETERRA_PASSWORD=${ETERRA_PASSWORD:-} - # ANCPI ePay (CF extract ordering) - - ANCPI_USERNAME=m.tarau@beletage.ro - - ANCPI_PASSWORD=Beletage@112 - - ANCPI_BASE_URL=https://epay.ancpi.ro/epay - - ANCPI_LOGIN_URL=https://oassl.ancpi.ro/openam/UI/Login - - ANCPI_DEFAULT_SOLICITANT_ID=14452 - - MINIO_BUCKET_ANCPI=ancpi-documente - # Stirling PDF (local PDF tools) - - STIRLING_PDF_URL=http://10.10.10.166:8087 - - STIRLING_PDF_API_KEY=cd829f62-6eef-43eb-a64d-c91af727b53a - # iLovePDF cloud compression (free: 250 files/month) - - ILOVEPDF_PUBLIC_KEY=${ILOVEPDF_PUBLIC_KEY:-} - # Martin vector tile server (geoportal) - - NEXT_PUBLIC_MARTIN_URL=https://tools.beletage.ro/tiles - # PMTiles overview tiles — proxied through tile-cache nginx (HTTPS, no mixed-content) - - NEXT_PUBLIC_PMTILES_URL=/tiles/pmtiles/overview.pmtiles - # DWG-to-DXF sidecar - - DWG2DXF_URL=http://dwg2dxf:5001 - # Email notifications (Brevo SMTP) - - BREVO_SMTP_HOST=smtp-relay.brevo.com - - BREVO_SMTP_PORT=587 - - BREVO_SMTP_USER=a2d94b001@smtp-brevo.com - - BREVO_SMTP_PASS=xsmtpsib-c2f5dfe1a7809c962d8907afafdc9edc1ff7e74340518539de8f8eccfd1dcc90-ipkNHpvN9RByv1V6 - - NOTIFICATION_FROM_EMAIL=noreply@beletage.ro - - NOTIFICATION_FROM_NAME=Alerte Termene - - NOTIFICATION_CRON_SECRET=1547a198feca43af6c05622588c6d3b820bad5163b8c20175b2b5bbf8fc1a987 - # Weekend Deep Sync email reports (comma-separated for multiple recipients) - - WEEKEND_SYNC_EMAIL=${WEEKEND_SYNC_EMAIL:-} - # PMTiles rebuild webhook (pmtiles-webhook systemd service on host) - - N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-http://10.10.10.166:9876} - # Portal-only users (comma-separated, redirected to /portal) - - PORTAL_ONLY_USERS=dtiurbe,d.tiurbe - # Address Book API (inter-service auth for external tools) - - ADDRESSBOOK_API_KEY=abook-7f3e9a2b4c1d8e5f6a0b3c7d9e2f4a1b5c8d0e3f6a9b2c5d8e1f4a7b0c3d6e - depends_on: - dwg2dxf: - condition: service_healthy - volumes: - # SMB share for ManicTime Tags.txt (mount on host: //time/tags → /mnt/manictime) - - /mnt/manictime:/mnt/manictime - labels: - - "com.centurylinklabs.watchtower.enable=true" - - dwg2dxf: - build: - context: ./dwg2dxf-api - container_name: dwg2dxf - restart: unless-stopped - healthcheck: - test: - [ - "CMD", - "python3", - "-c", - "import urllib.request; urllib.request.urlopen('http://localhost:5001/health')", - ] - interval: 30s - timeout: 5s - retries: 3 - start_period: 10s - - martin: - build: - context: . - dockerfile: martin.Dockerfile - container_name: martin - restart: unless-stopped - # No host port — only accessible via tile-cache nginx proxy - command: ["--config", "/config/martin.yaml"] - environment: - - DATABASE_URL=postgresql://architools_user:stictMyFon34!_gonY@10.10.10.166:5432/architools_db - - tile-cache: - build: - context: . - dockerfile: tile-cache.Dockerfile - container_name: tile-cache - restart: unless-stopped - ports: - - "3010:80" - depends_on: - - martin - volumes: - - tile-cache-data:/var/cache/nginx/tiles - - tippecanoe: - build: - context: . - dockerfile: tippecanoe.Dockerfile - container_name: tippecanoe - profiles: ["tools"] - environment: - - DB_HOST=10.10.10.166 - - DB_PORT=5432 - - DB_NAME=architools_db - - DB_USER=architools_user - - DB_PASS=stictMyFon34!_gonY - - MINIO_ENDPOINT=http://10.10.10.166:9002 - - MINIO_ACCESS_KEY=admin - - MINIO_SECRET_KEY=MinioStrongPass123 - -volumes: - tile-cache-data: +services: + architools: + build: + context: . + args: + - NEXT_PUBLIC_STORAGE_ADAPTER=${NEXT_PUBLIC_STORAGE_ADAPTER:-database} + - NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME:-ArchiTools} + - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-https://tools.beletage.ro} + - NEXT_PUBLIC_MARTIN_URL=${NEXT_PUBLIC_MARTIN_URL} + - NEXT_PUBLIC_PMTILES_URL=${NEXT_PUBLIC_PMTILES_URL} + container_name: architools + restart: unless-stopped + ports: + - "3000:3000" + environment: + - NODE_ENV=${NODE_ENV} + # Database + - DATABASE_URL=${DATABASE_URL} + # MinIO + - MINIO_ENDPOINT=${MINIO_ENDPOINT} + - MINIO_PORT=${MINIO_PORT} + - MINIO_USE_SSL=${MINIO_USE_SSL} + - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY} + - MINIO_SECRET_KEY=${MINIO_SECRET_KEY} + - MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME} + # Authentication (Authentik OIDC) + - NEXTAUTH_URL=${NEXTAUTH_URL} + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET} + - AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID} + - AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET} + - AUTHENTIK_ISSUER=${AUTHENTIK_ISSUER} + # Vault encryption + - ENCRYPTION_SECRET=${ENCRYPTION_SECRET} + # ManicTime Tags.txt sync (SMB mount path) + - MANICTIME_TAGS_PATH=${MANICTIME_TAGS_PATH} + # AI Chat (set AI_PROVIDER to openai/anthropic/ollama; demo if no key) + - AI_PROVIDER=${AI_PROVIDER:-demo} + - AI_API_KEY=${AI_API_KEY:-} + - AI_MODEL=${AI_MODEL:-} + - AI_BASE_URL=${AI_BASE_URL:-} + - AI_MAX_TOKENS=${AI_MAX_TOKENS:-2048} + # Visual CoPilot (at-vim) + - VIM_URL=${VIM_URL:-} + # eTerra ANCPI (parcel-sync module) + - ETERRA_USERNAME=${ETERRA_USERNAME:-} + - ETERRA_PASSWORD=${ETERRA_PASSWORD:-} + # ANCPI ePay (CF extract ordering) + - ANCPI_USERNAME=${ANCPI_USERNAME} + - ANCPI_PASSWORD=${ANCPI_PASSWORD} + - ANCPI_BASE_URL=${ANCPI_BASE_URL} + - ANCPI_LOGIN_URL=${ANCPI_LOGIN_URL} + - ANCPI_DEFAULT_SOLICITANT_ID=${ANCPI_DEFAULT_SOLICITANT_ID} + - MINIO_BUCKET_ANCPI=${MINIO_BUCKET_ANCPI} + # Stirling PDF (local PDF tools) + - STIRLING_PDF_URL=${STIRLING_PDF_URL} + - STIRLING_PDF_API_KEY=${STIRLING_PDF_API_KEY} + # iLovePDF cloud compression (free: 250 files/month) + - ILOVEPDF_PUBLIC_KEY=${ILOVEPDF_PUBLIC_KEY:-} + # Martin vector tile server (geoportal) + - NEXT_PUBLIC_MARTIN_URL=${NEXT_PUBLIC_MARTIN_URL} + # PMTiles overview tiles — proxied through tile-cache nginx (HTTPS, no mixed-content) + - NEXT_PUBLIC_PMTILES_URL=${NEXT_PUBLIC_PMTILES_URL} + # DWG-to-DXF sidecar + - DWG2DXF_URL=${DWG2DXF_URL} + # Email notifications (Brevo REST API) + - BREVO_API_KEY=${BREVO_API_KEY} + - NOTIFICATION_FROM_EMAIL=${NOTIFICATION_FROM_EMAIL} + - NOTIFICATION_FROM_NAME=${NOTIFICATION_FROM_NAME} + - NOTIFICATION_CRON_SECRET=${NOTIFICATION_CRON_SECRET} + # Weekend Deep Sync email reports (comma-separated for multiple recipients) + - WEEKEND_SYNC_EMAIL=${WEEKEND_SYNC_EMAIL:-} + # PMTiles rebuild webhook (pmtiles-webhook systemd service on host) + - N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-http://10.10.10.166:9876} + # Portal-only users (comma-separated, redirected to /portal) + - PORTAL_ONLY_USERS=${PORTAL_ONLY_USERS} + # Address Book API (inter-service auth for external tools) + - ADDRESSBOOK_API_KEY=${ADDRESSBOOK_API_KEY} + depends_on: + dwg2dxf: + condition: service_healthy + volumes: + # SMB share for ManicTime Tags.txt (mount on host: //time/tags → /mnt/manictime) + - /mnt/manictime:/mnt/manictime + labels: + - "com.centurylinklabs.watchtower.enable=true" + + dwg2dxf: + build: + context: ./dwg2dxf-api + container_name: dwg2dxf + restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "python3", + "-c", + "import urllib.request; urllib.request.urlopen('http://localhost:5001/health')", + ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + martin: + build: + context: . + dockerfile: martin.Dockerfile + container_name: martin + restart: unless-stopped + # No host port — only accessible via tile-cache nginx proxy + command: ["--config", "/config/martin.yaml"] + environment: + - DATABASE_URL=${DATABASE_URL} + + tile-cache: + build: + context: . + dockerfile: tile-cache.Dockerfile + container_name: tile-cache + restart: unless-stopped + ports: + - "3010:80" + depends_on: + - martin + volumes: + - tile-cache-data:/var/cache/nginx/tiles + + tippecanoe: + build: + context: . + dockerfile: tippecanoe.Dockerfile + container_name: tippecanoe + profiles: ["tools"] + environment: + - DB_HOST=${DB_HOST} + - DB_PORT=${DB_PORT} + - DB_NAME=${DB_NAME} + - DB_USER=${DB_USER} + - DB_PASS=${DB_PASS} + - MINIO_ENDPOINT=${MINIO_ENDPOINT} + - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY} + - MINIO_SECRET_KEY=${MINIO_SECRET_KEY} + +volumes: + tile-cache-data: diff --git a/package-lock.json b/package-lock.json index f9b39e4..04b22e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,6 @@ "next": "16.1.6", "next-auth": "^4.24.13", "next-themes": "^0.4.6", - "nodemailer": "^7.0.13", "pmtiles": "^4.4.0", "proj4": "^2.20.3", "qrcode": "^1.5.4", @@ -42,7 +41,6 @@ "@types/busboy": "^1.5.4", "@types/jszip": "^3.4.0", "@types/node": "^20", - "@types/nodemailer": "^7.0.11", "@types/proj4": "^2.5.6", "@types/qrcode": "^1.5.6", "@types/react": "^19", @@ -124,7 +122,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -693,7 +690,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1994,7 +1990,6 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -4162,16 +4157,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/nodemailer": { - "version": "7.0.11", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", - "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/pako": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", @@ -4208,7 +4193,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4219,7 +4203,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4313,7 +4296,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -4847,7 +4829,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5232,7 +5213,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", @@ -5398,7 +5378,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6734,7 +6713,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6920,7 +6898,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7240,7 +7217,6 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -8130,7 +8106,6 @@ "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -10169,6 +10144,7 @@ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", "license": "MIT-0", + "optional": true, "peer": true, "engines": { "node": ">=6.0.0" @@ -10901,7 +10877,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.4.tgz", "integrity": "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -10958,7 +10933,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "6.19.2", "@prisma/engines": "6.19.2" @@ -11438,7 +11412,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11448,7 +11421,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -12907,7 +12879,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12967,7 +12938,6 @@ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "tldts": "^7.0.5" }, @@ -13175,7 +13145,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13910,7 +13879,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index b5825b8..b7d60b1 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,6 @@ "next": "16.1.6", "next-auth": "^4.24.13", "next-themes": "^0.4.6", - "nodemailer": "^7.0.13", "pmtiles": "^4.4.0", "proj4": "^2.20.3", "qrcode": "^1.5.4", @@ -43,7 +42,6 @@ "@types/busboy": "^1.5.4", "@types/jszip": "^3.4.0", "@types/node": "^20", - "@types/nodemailer": "^7.0.11", "@types/proj4": "^2.5.6", "@types/qrcode": "^1.5.6", "@types/react": "^19", diff --git a/src/core/notifications/email-service.ts b/src/core/notifications/email-service.ts index 2c2f8b6..1f35885 100644 --- a/src/core/notifications/email-service.ts +++ b/src/core/notifications/email-service.ts @@ -1,55 +1,47 @@ -import nodemailer from "nodemailer"; -import type { Transporter } from "nodemailer"; import type { EmailPayload } from "./types"; -// ── Singleton transport (lazy init, same pattern as prisma) ── - -const globalForEmail = globalThis as unknown as { - emailTransport: Transporter | undefined; -}; - -function getTransport(): Transporter { - if (globalForEmail.emailTransport) return globalForEmail.emailTransport; - - const host = process.env.BREVO_SMTP_HOST ?? "smtp-relay.brevo.com"; - const port = parseInt(process.env.BREVO_SMTP_PORT ?? "587", 10); - const user = process.env.BREVO_SMTP_USER ?? ""; - const pass = process.env.BREVO_SMTP_PASS ?? ""; - - if (!user || !pass) { - throw new Error( - "BREVO_SMTP_USER and BREVO_SMTP_PASS must be set for email notifications", - ); - } - - const transport = nodemailer.createTransport({ - host, - port, - secure: false, // STARTTLS on port 587 - auth: { user, pass }, - }); - - if (process.env.NODE_ENV !== "production") { - globalForEmail.emailTransport = transport; - } - - return transport; -} +const BREVO_ENDPOINT = "https://api.brevo.com/v3/smtp/email"; /** - * Send a single email via Brevo SMTP relay. + * Send a single transactional email via Brevo REST API. */ export async function sendEmail(payload: EmailPayload): Promise { + const apiKey = process.env.BREVO_API_KEY; + if (!apiKey) { + throw new Error("BREVO_API_KEY must be set for email notifications"); + } + const fromEmail = process.env.NOTIFICATION_FROM_EMAIL ?? "noreply@beletage.ro"; const fromName = process.env.NOTIFICATION_FROM_NAME ?? "ArchiTools"; - const transport = getTransport(); + const recipients = payload.to + .split(",") + .map((addr) => addr.trim()) + .filter(Boolean) + .map((email) => ({ email })); - await transport.sendMail({ - from: `"${fromName}" <${fromEmail}>`, - to: payload.to, - subject: payload.subject, - html: payload.html, + if (recipients.length === 0) { + throw new Error("sendEmail: no recipients"); + } + + const res = await fetch(BREVO_ENDPOINT, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + "api-key": apiKey, + }, + body: JSON.stringify({ + sender: { name: fromName, email: fromEmail }, + to: recipients, + subject: payload.subject, + htmlContent: payload.html, + }), }); + + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`Brevo API ${res.status}: ${detail.slice(0, 300)}`); + } }