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
+ + Settings + {dockgeUrl && ( Dockge ↗ diff --git a/manager/src/app/dashboard/settings/actions.ts b/manager/src/app/dashboard/settings/actions.ts new file mode 100644 index 0000000..03f3b19 --- /dev/null +++ b/manager/src/app/dashboard/settings/actions.ts @@ -0,0 +1,143 @@ +"use server"; + +import { applyEmailSettingsToAllBusinesses, type EmailRolloutResult } from "@/lib/email-rollout"; +import { + isEmailConfigComplete, + recordLastTestResult, + resolveEmailSettings, + saveEmailSettings, + type EmailEncryptionMode, + type EmailSettingsInput, +} from "@/lib/global-settings"; +import { sendTestSmtpEmail } from "@/lib/test-email"; + +function parseEncryption(value: FormDataEntryValue | null): EmailEncryptionMode { + return value === "none" || value === "tls" ? value : "starttls"; +} + +function parseEmailSettingsInput(formData: FormData): EmailSettingsInput { + return { + host: String(formData.get("host") ?? "").trim(), + port: Number(formData.get("port") ?? 587) || 587, + username: String(formData.get("username") ?? "").trim(), + password: String(formData.get("password") ?? ""), + encryption: parseEncryption(formData.get("encryption")), + fromAddress: String(formData.get("fromAddress") ?? "").trim(), + fromName: String(formData.get("fromName") ?? "").trim(), + replyTo: String(formData.get("replyTo") ?? "").trim(), + }; +} + +export interface SaveEmailSettingsState { + error: string | null; + saved: boolean; + /** null = settings were saved but not rolled out (incomplete config — see isEmailConfigComplete); an array (possibly empty, if there are no businesses yet) means a rollout was attempted. */ + rollout: EmailRolloutResult[] | null; +} + +export async function saveEmailSettingsAction( + _prevState: SaveEmailSettingsState, + formData: FormData, +): Promise { + const input = parseEmailSettingsInput(formData); + if (!input.host || !input.username || !input.fromAddress) { + return { error: "SMTP host, username, and from address are required.", saved: false, rollout: null }; + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.fromAddress)) { + return { error: "Enter a valid from address.", saved: false, rollout: null }; + } + if (input.replyTo && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.replyTo)) { + return { error: "Enter a valid reply-to address, or leave it blank.", saved: false, rollout: null }; + } + if (!Number.isInteger(input.port) || input.port < 1 || input.port > 65535) { + return { error: "Enter a valid SMTP port.", saved: false, rollout: null }; + } + + let resolved; + try { + resolved = await saveEmailSettings(input); + } catch (error) { + return { error: error instanceof Error ? error.message : "Couldn't save settings.", saved: false, rollout: null }; + } + + if (!isEmailConfigComplete(resolved)) { + // Saved as a draft — no password configured yet (or none was ever + // set), so nothing is rolled out. A business left on "console" mode + // is safe; a business flipped to "brevo" with a blank password would + // hard-fail its very first send. + return { error: null, saved: true, rollout: null }; + } + + const rollout = await applyEmailSettingsToAllBusinesses(resolved); + return { error: null, saved: true, rollout }; +} + +export interface ReapplyEmailSettingsState { + error: string | null; + rollout: EmailRolloutResult[] | null; +} + +/** Retries the rollout without requiring a field edit first — e.g. after a business's container was down during the last save. */ +export async function reapplyEmailSettingsAction( + _prevState: ReapplyEmailSettingsState, + _formData: FormData, +): Promise { + const resolved = await resolveEmailSettings(); + if (!resolved || !isEmailConfigComplete(resolved)) { + return { error: "Email settings aren't fully configured yet — nothing to reapply.", rollout: null }; + } + const rollout = await applyEmailSettingsToAllBusinesses(resolved); + return { error: null, rollout }; +} + +export interface SendTestEmailState { + error: string | null; + result: { ok: boolean; message: string } | null; +} + +/** + * Tests with whatever is currently in the form — including an unsaved + * password change — falling back to the already-stored password only + * when the password field was left blank, the same "keep existing + * password" rule saveEmailSettingsAction follows. This lets an operator + * validate connectivity before committing to Save. + */ +export async function sendTestEmailAction( + _prevState: SendTestEmailState, + formData: FormData, +): Promise { + const recipient = String(formData.get("recipient") ?? "").trim(); + if (!recipient || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(recipient)) { + return { error: "Enter a valid test recipient address.", result: null }; + } + + const input = parseEmailSettingsInput(formData); + if (!input.host || !input.username || !input.fromAddress) { + return { error: "SMTP host, username, and from address are required before sending a test.", result: null }; + } + + let password = (input.password ?? "").trim(); + if (!password) { + const stored = await resolveEmailSettings(); + password = stored?.password ?? ""; + } + if (!password) { + return { error: "Enter the SMTP password (or save it first) before sending a test.", result: null }; + } + + const result = await sendTestSmtpEmail({ + host: input.host, + port: input.port, + username: input.username, + password, + encryption: input.encryption, + fromAddress: input.fromAddress, + fromName: input.fromName, + replyTo: input.replyTo, + recipient, + }); + + await recordLastTestResult({ ok: result.ok, message: result.message, testedAt: new Date().toISOString() }); + + return { error: null, result }; +} diff --git a/manager/src/app/dashboard/settings/email-settings-form.tsx b/manager/src/app/dashboard/settings/email-settings-form.tsx new file mode 100644 index 0000000..c7ab503 --- /dev/null +++ b/manager/src/app/dashboard/settings/email-settings-form.tsx @@ -0,0 +1,307 @@ +"use client"; + +import { useActionState, useState } from "react"; +import type { EmailEncryptionMode, EmailSettingsView } from "@/lib/global-settings"; +import { + reapplyEmailSettingsAction, + saveEmailSettingsAction, + sendTestEmailAction, + type ReapplyEmailSettingsState, + type SaveEmailSettingsState, + type SendTestEmailState, +} from "./actions"; + +const saveInitialState: SaveEmailSettingsState = { error: null, saved: false, rollout: null }; +const testInitialState: SendTestEmailState = { error: null, result: null }; +const reapplyInitialState: ReapplyEmailSettingsState = { error: null, rollout: null }; + +function StatusBadge({ initial, saveState, testState }: { initial: EmailSettingsView; saveState: SaveEmailSettingsState; testState: SendTestEmailState }) { + // Most-recent-action-wins: a test result from this session takes + // priority over the persisted status, which itself takes priority over + // "not configured". + if (testState.result) { + return testState.result.ok ? ( + Test successful + ) : ( + Test failed + ); + } + if (saveState.saved && saveState.rollout) { + const allOk = saveState.rollout.every((r) => r.ok); + return allOk ? ( + Configured + ) : ( + Configured (some businesses failed) + ); + } + if (initial.lastTest) { + return initial.lastTest.ok ? ( + Test successful + ) : ( + Test failed + ); + } + if (initial.hasPassword && initial.host && initial.fromAddress) { + return Configured; + } + return Not configured; +} + +/** + * Every field is a controlled input backed by useState here, deliberately + * NOT `defaultValue`-based uncontrolled inputs (the pattern + * edit-business-form.tsx uses for its simpler 2-field/1-action form). + * This form has three separate actions (Save / Test / Reapply) sharing + * one set of fields — after any one of them resolves, Next.js re-renders + * this component with the same `initial` server prop it always had, + * which would silently reset every uncontrolled field back to its + * original value (losing whatever the operator was mid-typing, e.g. a + * new password, right after clicking "Send Test Email" but before + * clicking "Save"). Controlled state survives that re-render untouched. + */ +export function EmailSettingsForm({ initial }: { initial: EmailSettingsView }) { + const [saveState, saveAction, isSaving] = useActionState(saveEmailSettingsAction, saveInitialState); + const [testState, testAction, isTesting] = useActionState(sendTestEmailAction, testInitialState); + const [reapplyState, reapplyAction, isReapplying] = useActionState(reapplyEmailSettingsAction, reapplyInitialState); + + const [host, setHost] = useState(initial.host); + const [port, setPort] = useState(initial.port); + const [username, setUsername] = useState(initial.username); + const [password, setPassword] = useState(""); + const [encryption, setEncryption] = useState(initial.encryption); + const [fromAddress, setFromAddress] = useState(initial.fromAddress); + const [fromName, setFromName] = useState(initial.fromName); + const [replyTo, setReplyTo] = useState(initial.replyTo); + const [recipient, setRecipient] = useState(""); + + return ( +
+
+

Email / SMTP

+ +
+

+ Shared by every business on this installation — used to send OTP verification codes and other transactional + email. Configuring it here means no business needs its own SMTP setup. +

+ +
+
+
+ + setHost(e.target.value)} + placeholder="smtp-relay.brevo.com" + className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm" + /> +
+
+ + setPort(Number(e.target.value))} + className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm" + /> +
+
+ + setUsername(e.target.value)} + className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm" + /> +
+
+ + setPassword(e.target.value)} + placeholder={initial.hasPassword ? "•••••••••••• (leave blank to keep)" : "Not set"} + className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm" + /> +

+ {initial.hasPassword ? "A password is already saved — leave this blank to keep it." : "No password saved yet."} +

+
+
+ + +
+
+ + setFromAddress(e.target.value)} + className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm" + /> +
+
+ + setFromName(e.target.value)} + placeholder="NextChair" + className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm" + /> +

Only used when a business's own name can't be resolved (e.g. before install).

+
+
+ + setReplyTo(e.target.value)} + className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm" + /> +

+ Unlike the settings above, this isn't forced onto every business — it's only used as a + fallback for a business that hasn't set its own reply-to address in its own .env. +

+
+
+ + {saveState.error && ( +

+ {saveState.error} +

+ )} + {saveState.saved && !saveState.error && ( +
+ {saveState.rollout === null ? ( +

Settings saved as a draft. Add a password to enable delivery and roll it out to businesses.

+ ) : ( +

+ Settings saved and applied to {saveState.rollout.filter((r) => r.ok).length} of {saveState.rollout.length}{" "} + business{saveState.rollout.length === 1 ? "" : "es"}. + {saveState.rollout.some((r) => !r.ok) && ( + <> + {" "} + Failed: {saveState.rollout.filter((r) => !r.ok).map((r) => `${r.slug} (${r.error})`).join(", ")} + + )} +

+ )} +
+ )} + +
+ + setRecipient(e.target.value)} + placeholder="you@example.com" + className="mt-1 w-full max-w-sm rounded-md border border-line px-3 py-2 text-sm" + /> + {testState.error && ( +

+ {testState.error} +

+ )} + {testState.result && ( +

+ {testState.result.message} +

+ )} +
+ +
+ + + +
+ {reapplyState.error && ( +

+ {reapplyState.error} +

+ )} + {reapplyState.rollout && ( +

+ Reapplied to {reapplyState.rollout.filter((r) => r.ok).length} of {reapplyState.rollout.length} business + {reapplyState.rollout.length === 1 ? "" : "es"}. +

+ )} +
+
+ ); +} diff --git a/manager/src/app/dashboard/settings/page.tsx b/manager/src/app/dashboard/settings/page.tsx new file mode 100644 index 0000000..a4714b2 --- /dev/null +++ b/manager/src/app/dashboard/settings/page.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import { readEmailSettingsView } from "@/lib/global-settings"; +import { EmailSettingsForm } from "./email-settings-form"; + +export const metadata: Metadata = { title: "Settings" }; + +/** + * Platform-wide settings shared by every business on this installation — + * see src/lib/global-settings.ts's own header for the storage design. + * Only "Email" exists today; a later section (SMS, storage, backups, + * branding, locale, monitoring) is a sibling here, not a rewrite. + */ +export default async function SettingsPage() { + const email = await readEmailSettingsView(); + + return ( +
+
+

Settings

+

Platform-wide configuration shared by every business on this installation.

+
+ +
+ ); +} diff --git a/manager/src/lib/compose-template.ts b/manager/src/lib/compose-template.ts index 73968e9..a2b0bee 100644 --- a/manager/src/lib/compose-template.ts +++ b/manager/src/lib/compose-template.ts @@ -46,6 +46,17 @@ networks: `; } +export interface BusinessEnvEmailValues { + host: string; + port: number; + username: string; + password: string; + encryption: string; + fromAddress: string; + fromName: string; + replyTo: string; +} + export interface BusinessEnvValues { name: string; domain: string; @@ -55,10 +66,40 @@ export interface BusinessEnvValues { authSecret: string; installationToken: string; cronSecret: string; + /** + * Global platform email settings (see src/lib/global-settings.ts), + * resolved once at provisioning time and baked straight into this + * business's own .env — exactly like every other generated secret here + * (authSecret, installationToken). Omitted (or incomplete) falls back + * to today's blank/"console" defaults, unchanged, so a business + * provisioned before global settings exist behaves exactly as before. + */ + email?: BusinessEnvEmailValues; } /** Same shape as .env.business.example, filled in with generated/collected values instead of placeholders. */ export function generateBusinessEnv(values: BusinessEnvValues): string { + const email = values.email; + const emailBlock = email + ? `EMAIL_PROVIDER="brevo" +EMAIL_SMTP_HOST="${email.host}" +EMAIL_SMTP_PORT="${email.port}" +EMAIL_SMTP_USERNAME="${email.username}" +EMAIL_SMTP_PASSWORD="${email.password}" +EMAIL_SMTP_ENCRYPTION="${email.encryption}" +EMAIL_FROM_ADDRESS="${email.fromAddress}" +EMAIL_FROM_NAME="${email.fromName}" +EMAIL_REPLY_TO="${email.replyTo}"` + : `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=""`; + return `# Generated by the management UI. See .env.business.example for what # each of these does. # BUSINESS_NAME is Manager-only bookkeeping (shown on the dashboard/edit @@ -94,13 +135,10 @@ MEDIA_MAX_UPLOAD_BYTES="5000000" GOOGLE_PLACES_API_KEY="" -EMAIL_PROVIDER="console" -EMAIL_SMTP_HOST="smtp-relay.brevo.com" -EMAIL_SMTP_PORT="587" -EMAIL_SMTP_USERNAME="" -EMAIL_SMTP_PASSWORD="" -EMAIL_FROM_ADDRESS="" -EMAIL_FROM_NAME="" +# Managed centrally — see the Manager's Settings > Email page. Changing it +# there updates every business automatically; there's normally no reason +# to hand-edit these for one business only. +${emailBlock} PUSH_PROVIDER="console" VAPID_PUBLIC_KEY="" diff --git a/manager/src/lib/email-rollout.ts b/manager/src/lib/email-rollout.ts new file mode 100644 index 0000000..1216b51 --- /dev/null +++ b/manager/src/lib/email-rollout.ts @@ -0,0 +1,74 @@ +import path from "node:path"; +import { composeUp } from "./docker"; +import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file"; +import type { ResolvedEmailSettings } from "./global-settings"; +import { listBusinessStacks, stackDir } from "./stacks"; + +export interface EmailRolloutResult { + slug: string; + ok: boolean; + error?: string; +} + +/** + * Pushes global email settings into every business's own `.env` (the + * same surgical, non-destructive `upsertEnvValues` helper + * provisioning.ts's own updateBusiness already uses for name/domain + * edits — never a full-file regenerate, so nothing else hand-edited into + * a business's .env is touched), then force-recreates its container so + * the change actually takes effect — a container only reads its `.env` + * at creation (see docker.ts's composeUp), the same reason + * updateBusiness recreates on a domain change. + * + * EMAIL_REPLY_TO is deliberately excluded from the "always overwrite" + * set: unlike the SMTP transport itself (host/credentials/from-address), + * a reply-to address is something a business plausibly wants to be its + * own (e.g. its own support inbox) rather than a platform-wide value. + * The global setting is only ever applied as a *fallback* — written the + * first time (a fresh business's .env has it blank), left untouched + * forever after a business (or a human editing its .env by hand) sets + * its own. This mirrors updateBusiness()'s own "never clobber anything + * hand-edited since provisioning" rule. + * + * Runs sequentially, not in parallel: this is an infrequent admin action + * on a single-VPS, modest-business-count deployment, and sequential + * keeps one slow/failing business from making the Docker daemon/MySQL + * connection pool contend with N simultaneous `docker compose up`s. A + * per-business result is returned so a partial failure (one business's + * container already down, a stack in a broken state, etc.) is visible + * rather than silently swallowed — the caller can offer a "Reapply" + * action that just calls this again. + */ +export async function applyEmailSettingsToAllBusinesses(settings: ResolvedEmailSettings): Promise { + const businesses = await listBusinessStacks(); + const results: EmailRolloutResult[] = []; + + for (const business of businesses) { + try { + const dir = stackDir(business.slug); + const envPath = path.join(dir, ".env"); + + const currentEnv = await readEnvFile(envPath); + const currentReplyTo = currentEnv ? readEnvValue(currentEnv, "EMAIL_REPLY_TO") : null; + const businessHasOwnReplyTo = Boolean(currentReplyTo && currentReplyTo.trim()); + + await upsertEnvValues(envPath, { + EMAIL_PROVIDER: "brevo", + EMAIL_SMTP_HOST: settings.host, + EMAIL_SMTP_PORT: String(settings.port), + EMAIL_SMTP_USERNAME: settings.username, + EMAIL_SMTP_PASSWORD: settings.password, + EMAIL_SMTP_ENCRYPTION: settings.encryption, + EMAIL_FROM_ADDRESS: settings.fromAddress, + EMAIL_FROM_NAME: settings.fromName, + ...(businessHasOwnReplyTo ? {} : { EMAIL_REPLY_TO: settings.replyTo }), + }); + await composeUp(dir, { forceRecreate: true }); + results.push({ slug: business.slug, ok: true }); + } catch (error) { + results.push({ slug: business.slug, ok: false, error: error instanceof Error ? error.message : String(error) }); + } + } + + return results; +} diff --git a/manager/src/lib/global-settings.ts b/manager/src/lib/global-settings.ts new file mode 100644 index 0000000..e267f87 --- /dev/null +++ b/manager/src/lib/global-settings.ts @@ -0,0 +1,247 @@ +import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { stacksRoot } from "./stacks"; + +/** + * Platform-wide settings shared by every business hosted on this + * NextChair installation — stored as one flat JSON file under + * `STACKS_DIR`, not a database. Manager has deliberately never had one + * (see nextchair-manager's own package.json description) and one small, + * slow-changing settings blob doesn't warrant introducing SQLite/Prisma + * into an app that has avoided that on purpose. `STACKS_DIR` is already + * bind-mounted at an identical absolute path into the Manager container + * (see stacks/manager/compose.yaml), so this file survives container + * restarts/redeploys with zero new volumes — the same guarantee every + * business's own `.env` already relies on. + * + * Shaped as `{ email: EmailSettings | null, ... }` so a later section + * (SMS, storage, backups, branding, locale, monitoring) is just one more + * top-level key sharing these same read/write/encrypt helpers, not a new + * storage decision each time. + */ + +export type EmailEncryptionMode = "none" | "starttls" | "tls"; + +/** Persisted shape — the password field only ever holds the encrypted envelope, never plaintext. */ +interface StoredEmailSettings { + host: string; + port: number; + username: string; + encryptedPassword: EncryptedPayload | null; + encryption: EmailEncryptionMode; + fromAddress: string; + fromName: string; + replyTo: string; + lastTest: LastTestResult | null; +} + +interface StoredGlobalSettings { + email: StoredEmailSettings | null; +} + +export interface LastTestResult { + ok: boolean; + message: string; + testedAt: string; +} + +/** Safe-to-render shape — never carries the real password, only whether one is set. */ +export interface EmailSettingsView { + host: string; + port: number; + username: string; + hasPassword: boolean; + encryption: EmailEncryptionMode; + fromAddress: string; + fromName: string; + replyTo: string; + lastTest: LastTestResult | null; +} + +/** Write-side input — `password` omitted/blank means "keep whatever is already stored". */ +export interface EmailSettingsInput { + host: string; + port: number; + username: string; + password?: string; + encryption: EmailEncryptionMode; + fromAddress: string; + fromName: string; + replyTo: string; +} + +/** The fully-resolved shape (real plaintext password included) — used only server-side, for the actual .env push and test-send, never rendered or logged. */ +export interface ResolvedEmailSettings { + host: string; + port: number; + username: string; + password: string; + encryption: EmailEncryptionMode; + fromAddress: string; + fromName: string; + replyTo: string; +} + +function settingsFilePath(): string { + return path.join(stacksRoot(), "manager", "global-settings.json"); +} + +async function readStoredSettings(): Promise { + try { + const raw = await readFile(settingsFilePath(), "utf8"); + const parsed = JSON.parse(raw) as StoredGlobalSettings; + return { email: parsed.email ?? null }; + } catch { + // No file yet (first run) or unreadable — treat as "nothing configured" rather than throwing, so the Settings page can always render. + return { email: null }; + } +} + +async function writeStoredSettings(settings: StoredGlobalSettings): Promise { + const file = settingsFilePath(); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, JSON.stringify(settings, null, 2), "utf8"); +} + +// ── Encryption ─────────────────────────────────────────────────────────── + +interface EncryptedPayload { + iv: string; + authTag: string; + ciphertext: string; +} + +function encryptionKey(): Buffer { + const raw = process.env.MANAGER_SETTINGS_ENCRYPTION_KEY; + if (!raw) { + throw new Error( + "MANAGER_SETTINGS_ENCRYPTION_KEY must be set to store or read global settings secrets — generate one with: openssl rand -hex 32", + ); + } + const key = Buffer.from(raw, "hex"); + if (key.length !== 32) { + throw new Error("MANAGER_SETTINGS_ENCRYPTION_KEY must be a 32-byte value, hex-encoded (64 hex characters)."); + } + return key; +} + +function encrypt(plaintext: string): EncryptedPayload { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", encryptionKey(), iv); + const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + return { + iv: iv.toString("base64"), + authTag: cipher.getAuthTag().toString("base64"), + ciphertext: ciphertext.toString("base64"), + }; +} + +function decrypt(payload: EncryptedPayload): string { + const decipher = createDecipheriv("aes-256-gcm", encryptionKey(), Buffer.from(payload.iv, "base64")); + decipher.setAuthTag(Buffer.from(payload.authTag, "base64")); + const plaintext = Buffer.concat([decipher.update(Buffer.from(payload.ciphertext, "base64")), decipher.final()]); + return plaintext.toString("utf8"); +} + +// ── Public API ─────────────────────────────────────────────────────────── + +const DEFAULT_EMAIL_VIEW: EmailSettingsView = { + host: "", + port: 587, + username: "", + hasPassword: false, + encryption: "starttls", + fromAddress: "", + fromName: "", + replyTo: "", + lastTest: null, +}; + +/** Safe for the Settings page to render directly — the password is never included, only whether one is set. */ +export async function readEmailSettingsView(): Promise { + const { email } = await readStoredSettings(); + if (!email) return DEFAULT_EMAIL_VIEW; + return { + host: email.host, + port: email.port, + username: email.username, + hasPassword: email.encryptedPassword !== null, + encryption: email.encryption, + fromAddress: email.fromAddress, + fromName: email.fromName, + replyTo: email.replyTo, + lastTest: email.lastTest, + }; +} + +/** Server-side only — decrypts the real password. Used for the .env push and the test-send fallback ("keep existing password"). Returns null if nothing is configured yet. */ +export async function resolveEmailSettings(): Promise { + const { email } = await readStoredSettings(); + if (!email) return null; + return { + host: email.host, + port: email.port, + username: email.username, + password: email.encryptedPassword ? decrypt(email.encryptedPassword) : "", + encryption: email.encryption, + fromAddress: email.fromAddress, + fromName: email.fromName, + replyTo: email.replyTo, + }; +} + +export function isEmailConfigComplete(settings: Pick): boolean { + return Boolean(settings.host.trim() && settings.username.trim() && settings.password.trim() && settings.fromAddress.trim()); +} + +/** + * Persists new email settings — a blank/omitted `password` keeps + * whatever is already stored (see this file's own header). Returns the + * fully-resolved settings (real password included) so the caller can + * immediately validate completeness / roll out to businesses without a + * second read+decrypt round trip. + */ +export async function saveEmailSettings(input: EmailSettingsInput): Promise { + const current = await readStoredSettings(); + const keepExistingPassword = !input.password || !input.password.trim(); + + let encryptedPassword = current.email?.encryptedPassword ?? null; + let resolvedPassword = ""; + if (!keepExistingPassword) { + encryptedPassword = encrypt(input.password!.trim()); + resolvedPassword = input.password!.trim(); + } else if (encryptedPassword) { + resolvedPassword = decrypt(encryptedPassword); + } + + const next: StoredEmailSettings = { + host: input.host.trim(), + port: input.port, + username: input.username.trim(), + encryptedPassword, + encryption: input.encryption, + fromAddress: input.fromAddress.trim(), + fromName: input.fromName.trim(), + replyTo: input.replyTo.trim(), + lastTest: current.email?.lastTest ?? null, + }; + await writeStoredSettings({ email: next }); + + return { + host: next.host, + port: next.port, + username: next.username, + password: resolvedPassword, + encryption: next.encryption, + fromAddress: next.fromAddress, + fromName: next.fromName, + replyTo: next.replyTo, + }; +} + +export async function recordLastTestResult(result: LastTestResult): Promise { + const current = await readStoredSettings(); + if (!current.email) return; // nothing to attach the result to + await writeStoredSettings({ email: { ...current.email, lastTest: result } }); +} diff --git a/manager/src/lib/provisioning.ts b/manager/src/lib/provisioning.ts index 07a0437..f1fae7f 100644 --- a/manager/src/lib/provisioning.ts +++ b/manager/src/lib/provisioning.ts @@ -5,6 +5,7 @@ import mariadb, { type Connection } from "mariadb"; import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./compose-template"; import { composeDown, composeUp, reloadCaddy, runMigrations } from "./docker"; import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file"; +import { isEmailConfigComplete, resolveEmailSettings } from "./global-settings"; import { isValidSlug, listBusinessStacks, stackDir, stackExists, stacksRoot } from "./stacks"; /** Hard safety check on top of isValidSlug (which already constrains the slug itself) — this is the one place that string ends up inside a raw SQL statement, where an identifier can't be parameter-bound the way a value can. */ @@ -139,6 +140,29 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise< await createDatabaseAndUser(databaseIdentifier, databaseIdentifier, databasePassword); + // Global email settings (Manager's Settings > Email page) are baked + // straight into this business's own .env at creation, the same as + // every other generated secret below — see global-settings.ts's own + // header for why this is a push at creation time rather than a runtime + // lookup. Only used when actually complete: a blank/partial global + // config would otherwise flip a fresh business's email from working + // "console" mode into a "brevo" mode with missing credentials, which + // hard-fails on the very first send. + const globalEmailSettings = await resolveEmailSettings(); + const email = + globalEmailSettings && isEmailConfigComplete(globalEmailSettings) + ? { + host: globalEmailSettings.host, + port: globalEmailSettings.port, + username: globalEmailSettings.username, + password: globalEmailSettings.password, + encryption: globalEmailSettings.encryption, + fromAddress: globalEmailSettings.fromAddress, + fromName: globalEmailSettings.fromName, + replyTo: globalEmailSettings.replyTo, + } + : undefined; + const dir = stackDir(slug); await mkdir(dir, { recursive: true }); await writeFile(path.join(dir, "compose.yaml"), generateComposeYaml(slug), "utf8"); @@ -153,6 +177,7 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise< authSecret, installationToken, cronSecret, + email, }), "utf8", ); diff --git a/manager/src/lib/test-email.ts b/manager/src/lib/test-email.ts new file mode 100644 index 0000000..48b1e54 --- /dev/null +++ b/manager/src/lib/test-email.ts @@ -0,0 +1,87 @@ +import nodemailer from "nodemailer"; +import type { EmailEncryptionMode } from "./global-settings"; + +/** + * A small, standalone nodemailer call — Manager and the main app are + * separate deployable processes/images that share no code today (see + * nextchair-manager's own package.json description); duplicating this + * ~30-line transport setup is simpler than inventing a cross-package + * shared module for one function, and mirrors the main app's own + * src/services/notifications/providers/brevo-email-provider.ts closely + * enough that a future shared package (if the two apps ever grow more + * code in common) would be a mechanical extraction, not a rewrite. + */ +export interface TestEmailConfig { + host: string; + port: number; + username: string; + password: string; + encryption: EmailEncryptionMode; + fromAddress: string; + fromName: string; + replyTo: string; + recipient: string; +} + +export interface TestEmailResult { + ok: boolean; + message: string; +} + +/** + * Never rethrows the raw SMTP error to the caller — nodemailer/SMTP error + * messages can echo back the configured username or other connection + * details, and this result is ultimately shown in the browser. Categorized + * instead: auth failure, connection failure, or a generic fallback. + */ +export async function sendTestSmtpEmail(config: TestEmailConfig): Promise { + const transporter = nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.encryption === "tls", + ignoreTLS: config.encryption === "none", + auth: { user: config.username, pass: config.password }, + connectionTimeout: 10_000, + greetingTimeout: 10_000, + socketTimeout: 20_000, + }); + + try { + await transporter.verify(); + await transporter.sendMail({ + from: config.fromName ? `"${config.fromName}" <${config.fromAddress}>` : config.fromAddress, + to: config.recipient, + replyTo: config.replyTo || undefined, + subject: "NextChair test email", + text: "This is a test email from your NextChair Manager Settings page — if you received this, your SMTP configuration is working.", + html: "

This is a test email from your NextChair Manager Settings page — if you received this, your SMTP configuration is working.

", + }); + return { ok: true, message: `Test email sent to ${config.recipient}.` }; + } catch (error) { + return { ok: false, message: categorizeSmtpError(error) }; + } finally { + transporter.close(); + } +} + +function categorizeSmtpError(error: unknown): string { + const code = typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code) : ""; + const responseCode = + typeof error === "object" && error !== null && "responseCode" in error + ? Number((error as { responseCode?: unknown }).responseCode) + : undefined; + + if (code === "EAUTH" || responseCode === 535) { + return "Authentication failed — check the SMTP username and password."; + } + if (code === "ECONNECTION" || code === "ESOCKET" || code === "ECONNREFUSED") { + return "Couldn't connect to the SMTP host — check the host, port, and encryption mode."; + } + if (code === "ETIMEDOUT") { + return "Connection to the SMTP host timed out."; + } + if (code === "EENVELOPE") { + return "The mail server rejected the sender or recipient address."; + } + return "Couldn't send the test email — check your SMTP settings and try again."; +} diff --git a/src/services/notifications/providers/__tests__/brevo-email-provider.test.ts b/src/services/notifications/providers/__tests__/brevo-email-provider.test.ts index e244436..fda9fa1 100644 --- a/src/services/notifications/providers/__tests__/brevo-email-provider.test.ts +++ b/src/services/notifications/providers/__tests__/brevo-email-provider.test.ts @@ -1,11 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const sendMail = vi.fn(); +const createTransport = vi.fn(() => ({ sendMail })); vi.mock("nodemailer", () => ({ - default: { createTransport: vi.fn(() => ({ sendMail })) }, + default: { createTransport }, })); -const { BrevoEmailProvider } = await import("../brevo-email-provider"); +const { BrevoEmailProvider, loadBrevoEmailProviderConfigFromEnv } = await import("../brevo-email-provider"); /** * Exercises BrevoEmailProvider's retry classification (see isRetryable in @@ -20,6 +21,7 @@ describe("BrevoEmailProvider", () => { username: "test-user", password: "test-pass", fromAddress: "noreply@example.com", + encryption: "starttls", }); beforeEach(() => { @@ -85,4 +87,103 @@ describe("BrevoEmailProvider", () => { expect.objectContaining({ from: { address: "noreply@example.com", name: "Riverside Barbershop" } }), ); }); + + it("passes the configured reply-to address through to sendMail when set", async () => { + const withReplyTo = new BrevoEmailProvider({ + host: "smtp-relay.brevo.com", + port: 587, + username: "test-user", + password: "test-pass", + fromAddress: "noreply@example.com", + encryption: "starttls", + replyTo: "support@example.com", + }); + sendMail.mockResolvedValueOnce({ messageId: "" }); + await withReplyTo.sendEmail(params); + expect(sendMail).toHaveBeenCalledWith(expect.objectContaining({ replyTo: "support@example.com" })); + }); +}); + +describe("BrevoEmailProvider encryption mode", () => { + beforeEach(() => createTransport.mockClear()); + + it("maps 'none' to secure:false, ignoreTLS:true", () => { + new BrevoEmailProvider({ + host: "h", + port: 25, + username: "u", + password: "p", + fromAddress: "a@example.com", + encryption: "none", + }); + expect(createTransport).toHaveBeenCalledWith(expect.objectContaining({ secure: false, ignoreTLS: true })); + }); + + it("maps 'starttls' to secure:false, ignoreTLS:false", () => { + new BrevoEmailProvider({ + host: "h", + port: 587, + username: "u", + password: "p", + fromAddress: "a@example.com", + encryption: "starttls", + }); + expect(createTransport).toHaveBeenCalledWith(expect.objectContaining({ secure: false, ignoreTLS: false })); + }); + + it("maps 'tls' to secure:true", () => { + new BrevoEmailProvider({ + host: "h", + port: 465, + username: "u", + password: "p", + fromAddress: "a@example.com", + encryption: "tls", + }); + expect(createTransport).toHaveBeenCalledWith(expect.objectContaining({ secure: true, ignoreTLS: false })); + }); +}); + +describe("loadBrevoEmailProviderConfigFromEnv", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + function setBaseEnv() { + process.env.EMAIL_SMTP_HOST = "smtp-relay.brevo.com"; + process.env.EMAIL_SMTP_USERNAME = "test-user"; + process.env.EMAIL_SMTP_PASSWORD = "test-pass"; + process.env.EMAIL_FROM_ADDRESS = "noreply@example.com"; + delete process.env.EMAIL_SMTP_ENCRYPTION; + delete process.env.EMAIL_SMTP_PORT; + delete process.env.EMAIL_REPLY_TO; + } + + it("infers 'tls' for port 465 when EMAIL_SMTP_ENCRYPTION is unset (preserves pre-existing behavior)", () => { + setBaseEnv(); + process.env.EMAIL_SMTP_PORT = "465"; + expect(loadBrevoEmailProviderConfigFromEnv().encryption).toBe("tls"); + }); + + it("infers 'starttls' for any other port when EMAIL_SMTP_ENCRYPTION is unset", () => { + setBaseEnv(); + process.env.EMAIL_SMTP_PORT = "587"; + expect(loadBrevoEmailProviderConfigFromEnv().encryption).toBe("starttls"); + }); + + it("respects an explicit EMAIL_SMTP_ENCRYPTION even on port 465", () => { + setBaseEnv(); + process.env.EMAIL_SMTP_PORT = "465"; + process.env.EMAIL_SMTP_ENCRYPTION = "none"; + expect(loadBrevoEmailProviderConfigFromEnv().encryption).toBe("none"); + }); + + it("reads EMAIL_REPLY_TO when set, and leaves it undefined otherwise", () => { + setBaseEnv(); + expect(loadBrevoEmailProviderConfigFromEnv().replyTo).toBeUndefined(); + process.env.EMAIL_REPLY_TO = "support@example.com"; + expect(loadBrevoEmailProviderConfigFromEnv().replyTo).toBe("support@example.com"); + }); }); diff --git a/src/services/notifications/providers/brevo-email-provider.ts b/src/services/notifications/providers/brevo-email-provider.ts index 8b97bef..52b83c8 100644 --- a/src/services/notifications/providers/brevo-email-provider.ts +++ b/src/services/notifications/providers/brevo-email-provider.ts @@ -49,12 +49,25 @@ import type { EmailProvider, EmailSendResult, SendEmailParams } from "./email-pr * deliberately has no Prisma import and no business lookup of any kind — * see that same doc for why the resolution lives one layer up. */ +export type EmailEncryptionMode = "none" | "starttls" | "tls"; + export interface BrevoEmailProviderConfig { host: string; port: number; username: string; password: string; fromAddress: string; + /** + * Defaults to "starttls" when unset (see loadBrevoEmailProviderConfigFromEnv) + * — this preserves this provider's original behavior for anyone who + * never set EMAIL_SMTP_ENCRYPTION (secure only when port===465, opportunistic + * STARTTLS otherwise), while letting the Manager's Settings > Email page + * (see manager/src/lib/global-settings.ts) represent "None" explicitly, + * which a port-based inference alone can't do. + */ + encryption: EmailEncryptionMode; + /** Optional — a distinct reply-to address, separate from the verified sender address. */ + replyTo?: string; } export function loadBrevoEmailProviderConfigFromEnv(): BrevoEmailProviderConfig { @@ -67,12 +80,25 @@ export function loadBrevoEmailProviderConfigFromEnv(): BrevoEmailProviderConfig "Brevo SMTP is not configured — EMAIL_SMTP_HOST, EMAIL_SMTP_USERNAME, EMAIL_SMTP_PASSWORD, and EMAIL_FROM_ADDRESS must all be set. See .env.example.", ); } + const port = Number(process.env.EMAIL_SMTP_PORT ?? 587); + const encryptionEnv = process.env.EMAIL_SMTP_ENCRYPTION; + // Unset: infer exactly like this provider always has (port 465 implies + // implicit TLS, everything else opportunistic STARTTLS) — a deployment + // that never sets this new var keeps its existing behavior byte-for-byte. + const encryption: EmailEncryptionMode = + encryptionEnv === "none" || encryptionEnv === "starttls" || encryptionEnv === "tls" + ? encryptionEnv + : port === 465 + ? "tls" + : "starttls"; return { host, - port: Number(process.env.EMAIL_SMTP_PORT ?? 587), + port, username, password, fromAddress, + encryption, + replyTo: process.env.EMAIL_REPLY_TO?.trim() || undefined, }; } @@ -98,13 +124,19 @@ export class BrevoEmailProvider implements EmailProvider { readonly name = "brevo"; private readonly transporter: nodemailer.Transporter; private readonly fromAddress: string; + private readonly replyTo?: string; constructor(config: BrevoEmailProviderConfig) { this.fromAddress = config.fromAddress; + this.replyTo = config.replyTo; this.transporter = nodemailer.createTransport({ host: config.host, port: config.port, - secure: config.port === 465, // 587/2525 use STARTTLS, not implicit TLS + secure: config.encryption === "tls", + // "none" explicitly refuses the opportunistic STARTTLS upgrade a + // plain `secure:false` would otherwise still attempt if the server + // offers it — some internal/legacy relays require this. + ignoreTLS: config.encryption === "none", auth: { user: config.username, pass: config.password }, connectionTimeout: 10_000, greetingTimeout: 10_000, @@ -121,6 +153,7 @@ export class BrevoEmailProvider implements EmailProvider { // display name comes from the caller. from: { address: this.fromAddress, name: params.sender.name }, to: { address: params.to.email, name: params.to.name }, + replyTo: this.replyTo, subject: params.subject, html: params.html, text: params.text,