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