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