Email Settings
CI / ci (push) Canceled after 0s

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:
Stefan Lazic committed 2026-09-06 10:38:21 +01:00
1 parent 973187dcc7
commit 2d4f9c5bf0
17 files changed
+1147 -14

No files matched your search

+7
View File
@@ -86,13 +86,20 @@ MEDIA_MAX_UPLOAD_BYTES="5000000"
GOOGLE_PLACES_API_KEY="" GOOGLE_PLACES_API_KEY=""
# ── Email (optional — defaults to logging to the console instead of sending) ─ # ── 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_PROVIDER="console"
EMAIL_SMTP_HOST="smtp-relay.brevo.com" EMAIL_SMTP_HOST="smtp-relay.brevo.com"
EMAIL_SMTP_PORT="587" EMAIL_SMTP_PORT="587"
EMAIL_SMTP_USERNAME="" EMAIL_SMTP_USERNAME=""
EMAIL_SMTP_PASSWORD="" EMAIL_SMTP_PASSWORD=""
EMAIL_SMTP_ENCRYPTION="starttls"
EMAIL_FROM_ADDRESS="" EMAIL_FROM_ADDRESS=""
EMAIL_FROM_NAME="" EMAIL_FROM_NAME=""
EMAIL_REPLY_TO=""
# ── Web Push (optional — defaults to logging to the console instead of sending) ─ # ── Web Push (optional — defaults to logging to the console instead of sending) ─
# Generate with: npx web-push generate-vapid-keys # Generate with: npx web-push generate-vapid-keys
+11 -2
View File
@@ -32,19 +32,28 @@ GOOGLE_PLACES_API_KEY=""
# Email (Phase 7) — see src/services/notifications/providers. # Email (Phase 7) — see src/services/notifications/providers.
# "console" logs would-be emails to the server console instead of sending # "console" logs would-be emails to the server console instead of sending
# (safe default — never send real email from an unconfigured environment). # (safe default — never send real email from an unconfigured environment).
# "brevo" sends for real over Brevo's SMTP relay via nodemailer (see # "brevo" sends for real over SMTP via nodemailer (see brevo-email-
# brevo-email-provider.ts for why SMTP rather than Brevo's REST API). # 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_PROVIDER="console"
EMAIL_SMTP_HOST="smtp-relay.brevo.com" EMAIL_SMTP_HOST="smtp-relay.brevo.com"
EMAIL_SMTP_PORT="587" EMAIL_SMTP_PORT="587"
EMAIL_SMTP_USERNAME="" EMAIL_SMTP_USERNAME=""
EMAIL_SMTP_PASSWORD="" 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_ADDRESS=""
# EMAIL_FROM_NAME is optional and is only used when no business name can # EMAIL_FROM_NAME is optional and is only used when no business name can
# be resolved. Normal business emails use the installed business name # be resolved. Normal business emails use the installed business name
# automatically (see docs/phase-8-email-sender-summary.md) — you do not # automatically (see docs/phase-8-email-sender-summary.md) — you do not
# need to set or keep this in sync with Business Settings. # need to set or keep this in sync with Business Settings.
EMAIL_FROM_NAME="" 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 # 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 — # NEXT_PUBLIC_VAPID_PUBLIC_KEY must be the same value as VAPID_PUBLIC_KEY —
+5
View File
@@ -40,6 +40,11 @@ stacks/*/.env
stacks/infra/sites/*.caddy stacks/infra/sites/*.caddy
!stacks/infra/sites/.gitkeep !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
prisma/dev.db prisma/dev.db
prisma/dev.db-journal prisma/dev.db-journal
+6
View File
@@ -9,6 +9,12 @@ MANAGER_EMAIL="you@example.com"
MANAGER_PASSWORD_HASH="replace-me" MANAGER_PASSWORD_HASH="replace-me"
# Signs the session cookie — generate with: openssl rand -hex 32 # Signs the session cookie — generate with: openssl rand -hex 32
MANAGER_SESSION_SECRET="replace-me" 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 # 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". # session cookie needs its Secure attribute) — e.g. "https://manager.example.com".
MANAGER_PUBLIC_URL="https://manager.example.com" MANAGER_PUBLIC_URL="https://manager.example.com"
+21
View File
@@ -11,6 +11,7 @@
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"mariadb": "^3.5.3", "mariadb": "^3.5.3",
"next": "^16.2.12", "next": "^16.2.12",
"nodemailer": "^8.0.11",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8"
}, },
@@ -18,6 +19,7 @@
"@tailwindcss/postcss": "^4.3.3", "@tailwindcss/postcss": "^4.3.3",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/node": "^26.1.2", "@types/node": "^26.1.2",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",
"eslint": "^9.39.5", "eslint": "^9.39.5",
@@ -1733,6 +1735,16 @@
"undici-types": "~8.3.0" "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": { "node_modules/@types/react": {
"version": "19.2.18", "version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
@@ -5455,6 +5467,15 @@
"node": ">=18" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+2
View File
@@ -14,6 +14,7 @@
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"mariadb": "^3.5.3", "mariadb": "^3.5.3",
"next": "^16.2.12", "next": "^16.2.12",
"nodemailer": "^8.0.11",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8"
}, },
@@ -21,6 +22,7 @@
"@tailwindcss/postcss": "^4.3.3", "@tailwindcss/postcss": "^4.3.3",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/node": "^26.1.2", "@types/node": "^26.1.2",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",
"eslint": "^9.39.5", "eslint": "^9.39.5",
+3
View File
@@ -11,6 +11,9 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
NextChair Manager NextChair Manager
</Link> </Link>
<div className="flex items-center gap-4 text-sm"> <div className="flex items-center gap-4 text-sm">
<Link href="/dashboard/settings" className="text-muted hover:text-ink">
Settings
</Link>
{dockgeUrl && ( {dockgeUrl && (
<a href={dockgeUrl} target="_blank" rel="noreferrer" className="text-muted hover:text-ink"> <a href={dockgeUrl} target="_blank" rel="noreferrer" className="text-muted hover:text-ink">
Dockge ↗ 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&apos;s own name can&apos;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&apos;t forced onto every business — it&apos;s only used as a
fallback for a business that hasn&apos;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>
);
}
+45 -7
View File
@@ -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 { export interface BusinessEnvValues {
name: string; name: string;
domain: string; domain: string;
@@ -55,10 +66,40 @@ export interface BusinessEnvValues {
authSecret: string; authSecret: string;
installationToken: string; installationToken: string;
cronSecret: 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. */ /** Same shape as .env.business.example, filled in with generated/collected values instead of placeholders. */
export function generateBusinessEnv(values: BusinessEnvValues): string { 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 return `# Generated by the management UI. See .env.business.example for what
# each of these does. # each of these does.
# BUSINESS_NAME is Manager-only bookkeeping (shown on the dashboard/edit # BUSINESS_NAME is Manager-only bookkeeping (shown on the dashboard/edit
@@ -94,13 +135,10 @@ MEDIA_MAX_UPLOAD_BYTES="5000000"
GOOGLE_PLACES_API_KEY="" GOOGLE_PLACES_API_KEY=""
EMAIL_PROVIDER="console" # Managed centrally — see the Manager's Settings > Email page. Changing it
EMAIL_SMTP_HOST="smtp-relay.brevo.com" # there updates every business automatically; there's normally no reason
EMAIL_SMTP_PORT="587" # to hand-edit these for one business only.
EMAIL_SMTP_USERNAME="" ${emailBlock}
EMAIL_SMTP_PASSWORD=""
EMAIL_FROM_ADDRESS=""
EMAIL_FROM_NAME=""
PUSH_PROVIDER="console" PUSH_PROVIDER="console"
VAPID_PUBLIC_KEY="" VAPID_PUBLIC_KEY=""
+74
View File
@@ -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;
}
+247
View File
@@ -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 } });
}
+25
View File
@@ -5,6 +5,7 @@ import mariadb, { type Connection } from "mariadb";
import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./compose-template"; import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./compose-template";
import { composeDown, composeUp, reloadCaddy, runMigrations } from "./docker"; import { composeDown, composeUp, reloadCaddy, runMigrations } from "./docker";
import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file"; import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file";
import { isEmailConfigComplete, resolveEmailSettings } from "./global-settings";
import { isValidSlug, listBusinessStacks, stackDir, stackExists, stacksRoot } from "./stacks"; 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. */ /** 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); 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); const dir = stackDir(slug);
await mkdir(dir, { recursive: true }); await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "compose.yaml"), generateComposeYaml(slug), "utf8"); await writeFile(path.join(dir, "compose.yaml"), generateComposeYaml(slug), "utf8");
@@ -153,6 +177,7 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise<
authSecret, authSecret,
installationToken, installationToken,
cronSecret, cronSecret,
email,
}), }),
"utf8", "utf8",
); );
+87
View File
@@ -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 sendMail = vi.fn();
const createTransport = vi.fn(() => ({ sendMail }));
vi.mock("nodemailer", () => ({ 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 * Exercises BrevoEmailProvider's retry classification (see isRetryable in
@@ -20,6 +21,7 @@ describe("BrevoEmailProvider", () => {
username: "test-user", username: "test-user",
password: "test-pass", password: "test-pass",
fromAddress: "noreply@example.com", fromAddress: "noreply@example.com",
encryption: "starttls",
}); });
beforeEach(() => { beforeEach(() => {
@@ -85,4 +87,103 @@ describe("BrevoEmailProvider", () => {
expect.objectContaining({ from: { address: "noreply@example.com", name: "Riverside Barbershop" } }), 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 — * deliberately has no Prisma import and no business lookup of any kind —
* see that same doc for why the resolution lives one layer up. * see that same doc for why the resolution lives one layer up.
*/ */
export type EmailEncryptionMode = "none" | "starttls" | "tls";
export interface BrevoEmailProviderConfig { export interface BrevoEmailProviderConfig {
host: string; host: string;
port: number; port: number;
username: string; username: string;
password: string; password: string;
fromAddress: 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 { 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.", "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 { return {
host, host,
port: Number(process.env.EMAIL_SMTP_PORT ?? 587), port,
username, username,
password, password,
fromAddress, fromAddress,
encryption,
replyTo: process.env.EMAIL_REPLY_TO?.trim() || undefined,
}; };
} }
@@ -98,13 +124,19 @@ export class BrevoEmailProvider implements EmailProvider {
readonly name = "brevo"; readonly name = "brevo";
private readonly transporter: nodemailer.Transporter; private readonly transporter: nodemailer.Transporter;
private readonly fromAddress: string; private readonly fromAddress: string;
private readonly replyTo?: string;
constructor(config: BrevoEmailProviderConfig) { constructor(config: BrevoEmailProviderConfig) {
this.fromAddress = config.fromAddress; this.fromAddress = config.fromAddress;
this.replyTo = config.replyTo;
this.transporter = nodemailer.createTransport({ this.transporter = nodemailer.createTransport({
host: config.host, host: config.host,
port: config.port, 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 }, auth: { user: config.username, pass: config.password },
connectionTimeout: 10_000, connectionTimeout: 10_000,
greetingTimeout: 10_000, greetingTimeout: 10_000,
@@ -121,6 +153,7 @@ export class BrevoEmailProvider implements EmailProvider {
// display name comes from the caller. // display name comes from the caller.
from: { address: this.fromAddress, name: params.sender.name }, from: { address: this.fromAddress, name: params.sender.name },
to: { address: params.to.email, name: params.to.name }, to: { address: params.to.email, name: params.to.name },
replyTo: this.replyTo,
subject: params.subject, subject: params.subject,
html: params.html, html: params.html,
text: params.text, text: params.text,