1. Architecture chosen
Manager pushes environment variables into every business's .env, rather than businesses fetching config at runtime. Manager has no database of its own today (confirmed via code — no Prisma, no ORM dependency anywhere in manager/); every piece of its state is either an env var or a flat file under STACKS_DIR. A "runtime shared-config source" design would make every single email send (including OTP login) depend on Manager being reachable — a new failure mode this app has never had, for no real benefit. Instead:
Storage: one JSON file, stacks/manager/global-settings.json (gitignored), shaped for future sections (SMS, storage, backups, branding...) to slot in as sibling keys.
Secret at rest: the SMTP password is AES-256-GCM encrypted with a new MANAGER_SETTINGS_ENCRYPTION_KEY, never returned to the browser.
Rollout: saving complete settings automatically pushes the 7 EMAIL_* keys into every business's .env via the exact same surgical upsertEnvValues helper updateBusiness() already uses for name/domain edits, then force-recreates each container (composeUp(dir, {forceRecreate:true})) — the identical mechanism that already applies domain changes today.
New businesses get the current global settings baked into their .env at provisioning time — no manual step, ever.
2. Files changed
New (Manager): global-settings.ts (storage+encryption), email-rollout.ts (push-to-all-businesses), test-email.ts (standalone nodemailer test-send), dashboard/settings/ (page.tsx, actions.ts, email-settings-form.tsx).
Modified (Manager): compose-template.ts (optional email block in generated .env), provisioning.ts (reads global settings at creation), dashboard/layout.tsx (Settings nav link), package.json/package-lock.json (added nodemailer).
Modified (main app): brevo-email-provider.ts (added encryption mode + replyTo, backward-compatible), its test file (8 new tests).
Modified (docs): root .gitignore, .env.example, .env.business.example, manager/.env.example.
3. New volumes/databases/config files
None — no new Docker volume, no database. One new gitignored file, stacks/manager/global-settings.json, living inside the STACKS_DIR bind-mount Manager already has.
4. New environment variables
MANAGER_SETTINGS_ENCRYPTION_KEY (Manager only, required to use Settings) — 32-byte hex, generate with openssl rand -hex 32.
EMAIL_SMTP_ENCRYPTION and EMAIL_REPLY_TO (main app, both optional) — unset EMAIL_SMTP_ENCRYPTION preserves today's exact port-based inference, so existing deployments are unaffected.
Reply-to is now business-specific with global fallback,
5. How settings are persisted
stacks/manager/global-settings.json, written by saveEmailSettings(). The password is encrypted before writing; every other field is plain text (host, port, username, from-address, from-name, reply-to, encryption mode, last-test result) since none of those are secrets.
6. How business containers receive global settings
Via their own .env file (Docker Compose's env_file:, unchanged mechanism) — Manager writes the 7 EMAIL_* keys into it and recreates the container. Nothing in the business app itself needed a new runtime dependency; getEmailProvider() still just reads process.env once at process start, exactly as before.
7. What happens to existing businesses when settings change
Saving complete settings (host+username+password+from-address all present) automatically loops every business, updates its .env, and force-recreates its container in one action — the UI reports "applied to N of M businesses" with per-business failure reasons. A separate "Reapply to all businesses" button retries this without re-entering anything, for recovering from a business that was down during the last save. Saving incomplete settings persists them as a draft and touches no business (verified live — this is what stops a business from being flipped into a broken "brevo with blank credentials" state).
8. Manual deployment/update steps on the VPS
Generate a key: openssl rand -hex 32, add it as MANAGER_SETTINGS_ENCRYPTION_KEY to stacks/manager/.env.
Rebuild/redeploy the Manager image and the main app image (both changed) as usual.
Open Manager → Settings, fill in real SMTP details, send a test email, then Save — this rolls out to every existing business automatically. Nothing else needs to run by hand.
9. Security considerations
Password never logged, never returned to the browser after save (only a hasPassword boolean) — confirmed live: the form re-shows a masked placeholder, not the real value.
Test-email errors are categorized (auth/connection/timeout) before display — the raw SMTP error is never forwarded.
Encrypted at rest with AES-256-GCM; losing/rotating the key makes the stored password undecryptable (documented in manager/.env.example).
Settings routes sit under manager/src/proxy.ts's existing auth gate — no new auth surface.
Validated before rollout: malformed addresses/ports are rejected before Save, and rollout only fires when configuration is actually complete.
On JSON vs. database for Manager: JSON is the right call here. Reasoning:
Manager has zero DB dependencies today, deliberately — its own package.json description says an earlier attempt at giving it business-data access "repeatedly broke." Introducing SQLite/Prisma just for one settings blob breaks that design principle for not much gain.
The access pattern is a poor fit for a DB anyway: read on page-load/provisioning/rollout, written by a human clicking Save occasionally. No queries, no joins, no need for transactions beyond "read-modify-write one small file."
Reconsider if: multiple admins need to edit concurrently with conflict detection, you want an audit history of who-changed-what-when beyond "last test result," or the settings graph grows relational (e.g., per-business overrides tied by foreign key to businesses) rather than a flat global blob. None of that applies yet.
This commit is contained in:
17 files changed
+1147
-14
No files matched your search
@@ -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
|
||||
|
||||
+11
-2
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+21
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,6 +11,9 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
NextChair Manager
|
||||
</Link>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<Link href="/dashboard/settings" className="text-muted hover:text-ink">
|
||||
Settings
|
||||
</Link>
|
||||
{dockgeUrl && (
|
||||
<a href={dockgeUrl} target="_blank" rel="noreferrer" className="text-muted hover:text-ink">
|
||||
Dockge ↗
|
||||
|
||||
@@ -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<SaveEmailSettingsState> {
|
||||
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<ReapplyEmailSettingsState> {
|
||||
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<SendTestEmailState> {
|
||||
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 };
|
||||
}
|
||||
@@ -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 ? (
|
||||
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Test successful</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-800">Test failed</span>
|
||||
);
|
||||
}
|
||||
if (saveState.saved && saveState.rollout) {
|
||||
const allOk = saveState.rollout.every((r) => r.ok);
|
||||
return allOk ? (
|
||||
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Configured</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-800">Configured (some businesses failed)</span>
|
||||
);
|
||||
}
|
||||
if (initial.lastTest) {
|
||||
return initial.lastTest.ok ? (
|
||||
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Test successful</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-800">Test failed</span>
|
||||
);
|
||||
}
|
||||
if (initial.hasPassword && initial.host && initial.fromAddress) {
|
||||
return <span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Configured</span>;
|
||||
}
|
||||
return <span className="rounded-full bg-stone px-3 py-1 text-xs font-semibold text-muted">Not configured</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<EmailEncryptionMode>(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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Email / SMTP</h2>
|
||||
<StatusBadge initial={initial} saveState={saveState} testState={testState} />
|
||||
</div>
|
||||
<p className="text-sm text-muted">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<form className="space-y-4 rounded-md border border-line bg-surface p-5">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="host" className="block text-sm font-medium text-muted">
|
||||
SMTP host
|
||||
</label>
|
||||
<input
|
||||
id="host"
|
||||
name="host"
|
||||
type="text"
|
||||
required
|
||||
value={host}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="port" className="block text-sm font-medium text-muted">
|
||||
SMTP port
|
||||
</label>
|
||||
<input
|
||||
id="port"
|
||||
name="port"
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
max={65535}
|
||||
value={port}
|
||||
onChange={(e) => setPort(Number(e.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-muted">
|
||||
SMTP username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-muted">
|
||||
SMTP password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
{initial.hasPassword ? "A password is already saved — leave this blank to keep it." : "No password saved yet."}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="encryption" className="block text-sm font-medium text-muted">
|
||||
Encryption
|
||||
</label>
|
||||
<select
|
||||
id="encryption"
|
||||
name="encryption"
|
||||
value={encryption}
|
||||
onChange={(e) => setEncryption(e.target.value as EmailEncryptionMode)}
|
||||
className="mt-1 w-full rounded-md border border-line bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="starttls">STARTTLS</option>
|
||||
<option value="tls">TLS/SSL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fromAddress" className="block text-sm font-medium text-muted">
|
||||
From email address
|
||||
</label>
|
||||
<input
|
||||
id="fromAddress"
|
||||
name="fromAddress"
|
||||
type="email"
|
||||
required
|
||||
value={fromAddress}
|
||||
onChange={(e) => setFromAddress(e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fromName" className="block text-sm font-medium text-muted">
|
||||
From name
|
||||
</label>
|
||||
<input
|
||||
id="fromName"
|
||||
name="fromName"
|
||||
type="text"
|
||||
value={fromName}
|
||||
onChange={(e) => setFromName(e.target.value)}
|
||||
placeholder="NextChair"
|
||||
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">Only used when a business's own name can't be resolved (e.g. before install).</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="replyTo" className="block text-sm font-medium text-muted">
|
||||
Default reply-to address (optional)
|
||||
</label>
|
||||
<input
|
||||
id="replyTo"
|
||||
name="replyTo"
|
||||
type="email"
|
||||
value={replyTo}
|
||||
onChange={(e) => setReplyTo(e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveState.error && (
|
||||
<p role="alert" className="text-sm font-medium text-danger">
|
||||
{saveState.error}
|
||||
</p>
|
||||
)}
|
||||
{saveState.saved && !saveState.error && (
|
||||
<div className="text-sm text-green-700">
|
||||
{saveState.rollout === null ? (
|
||||
<p>Settings saved as a draft. Add a password to enable delivery and roll it out to businesses.</p>
|
||||
) : (
|
||||
<p>
|
||||
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(", ")}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-line pt-4">
|
||||
<label htmlFor="recipient" className="block text-sm font-medium text-muted">
|
||||
Test recipient
|
||||
</label>
|
||||
<input
|
||||
id="recipient"
|
||||
name="recipient"
|
||||
type="email"
|
||||
value={recipient}
|
||||
onChange={(e) => 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 && (
|
||||
<p role="alert" className="mt-2 text-sm font-medium text-danger">
|
||||
{testState.error}
|
||||
</p>
|
||||
)}
|
||||
{testState.result && (
|
||||
<p className={`mt-2 text-sm font-medium ${testState.result.ok ? "text-green-700" : "text-danger"}`}>
|
||||
{testState.result.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
formAction={saveAction}
|
||||
disabled={isSaving}
|
||||
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save Changes"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
formAction={testAction}
|
||||
disabled={isTesting}
|
||||
className="rounded-md border border-line px-4 py-2 text-sm font-semibold disabled:opacity-60"
|
||||
>
|
||||
{isTesting ? "Sending…" : "Send Test Email"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
formAction={reapplyAction}
|
||||
disabled={isReapplying || !initial.hasPassword}
|
||||
title={!initial.hasPassword ? "Save complete settings first" : undefined}
|
||||
className="ml-auto text-sm font-medium text-muted hover:text-ink disabled:opacity-40"
|
||||
>
|
||||
{isReapplying ? "Reapplying…" : "Reapply to all businesses"}
|
||||
</button>
|
||||
</div>
|
||||
{reapplyState.error && (
|
||||
<p role="alert" className="text-sm font-medium text-danger">
|
||||
{reapplyState.error}
|
||||
</p>
|
||||
)}
|
||||
{reapplyState.rollout && (
|
||||
<p className="text-sm text-green-700">
|
||||
Reapplied to {reapplyState.rollout.filter((r) => r.ok).length} of {reapplyState.rollout.length} business
|
||||
{reapplyState.rollout.length === 1 ? "" : "es"}.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Settings</h1>
|
||||
<p className="text-sm text-muted">Platform-wide configuration shared by every business on this installation.</p>
|
||||
</div>
|
||||
<EmailSettingsForm initial={email} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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=""
|
||||
|
||||
@@ -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<EmailRolloutResult[]> {
|
||||
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;
|
||||
}
|
||||
@@ -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<StoredGlobalSettings> {
|
||||
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<void> {
|
||||
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<EmailSettingsView> {
|
||||
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<ResolvedEmailSettings | null> {
|
||||
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<ResolvedEmailSettings, "host" | "username" | "password" | "fromAddress">): 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<ResolvedEmailSettings> {
|
||||
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<void> {
|
||||
const current = await readStoredSettings();
|
||||
if (!current.email) return; // nothing to attach the result to
|
||||
await writeStoredSettings({ email: { ...current.email, lastTest: result } });
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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<TestEmailResult> {
|
||||
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: "<p>This is a test email from your NextChair Manager Settings page — if you received this, your SMTP configuration is working.</p>",
|
||||
});
|
||||
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.";
|
||||
}
|
||||
@@ -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: "<abc123@smtp-relay.brevo.com>" });
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user