diff --git a/.env.business.example b/.env.business.example index 08ed9d3..5f0fbc4 100644 --- a/.env.business.example +++ b/.env.business.example @@ -86,13 +86,20 @@ MEDIA_MAX_UPLOAD_BYTES="5000000" GOOGLE_PLACES_API_KEY="" # ── Email (optional — defaults to logging to the console instead of sending) ─ +# Managed centrally by the Manager's Settings > Email page and pushed +# into every business's .env automatically — see manager/src/lib/ +# global-settings.ts. There's normally no reason to hand-edit these for +# one business only; this by-hand template still needs them set if +# you're not using the Manager. EMAIL_PROVIDER="console" EMAIL_SMTP_HOST="smtp-relay.brevo.com" EMAIL_SMTP_PORT="587" EMAIL_SMTP_USERNAME="" EMAIL_SMTP_PASSWORD="" +EMAIL_SMTP_ENCRYPTION="starttls" EMAIL_FROM_ADDRESS="" EMAIL_FROM_NAME="" +EMAIL_REPLY_TO="" # ── Web Push (optional — defaults to logging to the console instead of sending) ─ # Generate with: npx web-push generate-vapid-keys diff --git a/.env.example b/.env.example index 9f39eef..e62e65d 100644 --- a/.env.example +++ b/.env.example @@ -32,19 +32,28 @@ GOOGLE_PLACES_API_KEY="" # Email (Phase 7) — see src/services/notifications/providers. # "console" logs would-be emails to the server console instead of sending # (safe default — never send real email from an unconfigured environment). -# "brevo" sends for real over Brevo's SMTP relay via nodemailer (see -# brevo-email-provider.ts for why SMTP rather than Brevo's REST API). +# "brevo" sends for real over SMTP via nodemailer (see brevo-email- +# provider.ts — it's genuinely generic SMTP, not Brevo-API-specific, +# despite the name). On a multi-business deployment these are normally +# managed centrally by the Manager's Settings > Email page and pushed into +# every business's .env automatically — you don't need to fill these in +# by hand there. This single-business template still needs them set. EMAIL_PROVIDER="console" EMAIL_SMTP_HOST="smtp-relay.brevo.com" EMAIL_SMTP_PORT="587" EMAIL_SMTP_USERNAME="" EMAIL_SMTP_PASSWORD="" +# "none" | "starttls" | "tls" — unset infers from port (465 = tls, else +# starttls) for backward compatibility; set explicitly to represent "none". +EMAIL_SMTP_ENCRYPTION="starttls" EMAIL_FROM_ADDRESS="" # EMAIL_FROM_NAME is optional and is only used when no business name can # be resolved. Normal business emails use the installed business name # automatically (see docs/phase-8-email-sender-summary.md) — you do not # need to set or keep this in sync with Business Settings. EMAIL_FROM_NAME="" +# Optional — a distinct reply-to address, separate from the verified sender address. +EMAIL_REPLY_TO="" # Web Push (Phase 7) — generate with: npx web-push generate-vapid-keys # NEXT_PUBLIC_VAPID_PUBLIC_KEY must be the same value as VAPID_PUBLIC_KEY — diff --git a/.gitignore b/.gitignore index 81ff6a8..1ed4174 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,11 @@ stacks/*/.env stacks/infra/sites/*.caddy !stacks/infra/sites/.gitkeep +# Manager's global platform settings (Settings > Email, etc. — see +# manager/src/lib/global-settings.ts) — contains an encrypted SMTP +# password, generated at runtime, never hand-authored into the repo. +stacks/manager/global-settings.json + # prisma prisma/dev.db prisma/dev.db-journal diff --git a/manager/.env.example b/manager/.env.example index f60d02e..690cb4f 100644 --- a/manager/.env.example +++ b/manager/.env.example @@ -9,6 +9,12 @@ MANAGER_EMAIL="you@example.com" MANAGER_PASSWORD_HASH="replace-me" # Signs the session cookie — generate with: openssl rand -hex 32 MANAGER_SESSION_SECRET="replace-me" +# Encrypts secrets (e.g. the SMTP password) stored in Settings > Email — +# see src/lib/global-settings.ts. Must be a 32-byte value, hex-encoded. +# Generate with: openssl rand -hex 32 +# Losing/rotating this makes any previously-saved secret undecryptable — +# you'd need to re-enter it in Settings afterward. +MANAGER_SETTINGS_ENCRYPTION_KEY="replace-me" # The URL this app is actually served at (used to decide whether the # session cookie needs its Secure attribute) — e.g. "https://manager.example.com". MANAGER_PUBLIC_URL="https://manager.example.com" diff --git a/manager/package-lock.json b/manager/package-lock.json index 6efb176..7bc4a4b 100644 --- a/manager/package-lock.json +++ b/manager/package-lock.json @@ -11,6 +11,7 @@ "bcryptjs": "^3.0.3", "mariadb": "^3.5.3", "next": "^16.2.12", + "nodemailer": "^8.0.11", "react": "^19.2.8", "react-dom": "^19.2.8" }, @@ -18,6 +19,7 @@ "@tailwindcss/postcss": "^4.3.3", "@types/bcryptjs": "^2.4.6", "@types/node": "^26.1.2", + "@types/nodemailer": "^8.0.1", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "eslint": "^9.39.5", @@ -1733,6 +1735,16 @@ "undici-types": "~8.3.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/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -5455,6 +5467,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz", + "integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/manager/package.json b/manager/package.json index b69d54e..cba2d95 100644 --- a/manager/package.json +++ b/manager/package.json @@ -14,6 +14,7 @@ "bcryptjs": "^3.0.3", "mariadb": "^3.5.3", "next": "^16.2.12", + "nodemailer": "^8.0.11", "react": "^19.2.8", "react-dom": "^19.2.8" }, @@ -21,6 +22,7 @@ "@tailwindcss/postcss": "^4.3.3", "@types/bcryptjs": "^2.4.6", "@types/node": "^26.1.2", + "@types/nodemailer": "^8.0.1", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "eslint": "^9.39.5", diff --git a/manager/src/app/dashboard/layout.tsx b/manager/src/app/dashboard/layout.tsx index 05fdf46..d34c299 100644 --- a/manager/src/app/dashboard/layout.tsx +++ b/manager/src/app/dashboard/layout.tsx @@ -11,6 +11,9 @@ export default function DashboardLayout({ children }: { children: React.ReactNod NextChair Manager