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
@@ -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.";
|
||||
}
|
||||
Reference in new issue
Block a user