diff --git a/docs/ops/deployment.md b/docs/ops/deployment.md index 09ce314..a2029ec 100644 --- a/docs/ops/deployment.md +++ b/docs/ops/deployment.md @@ -23,6 +23,8 @@ Deploy on one VPS with Docker Compose. - Keep secrets in server-side environment files or a secret manager. - Back up PostgreSQL and object storage separately. - Prefer Telegram long polling to avoid an extra public webhook surface for the bot. +- In non-production environments, set `EMAIL_PROVIDER=example` only when you explicitly want the built-in debug transport. It logs redacted email previews and must never emit live password-reset tokens. +- Do not rely on implicit email fallbacks. Unsupported providers now fail fast at startup so misconfigured deployments do not silently drop password-reset or billing mail. ## Upgrade strategy - Build new images. diff --git a/packages/providers/package.json b/packages/providers/package.json index 81e46dc..d78d5fe 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -16,7 +16,9 @@ ], "scripts": { "build": "tsc -p tsconfig.json", - "check": "tsc -p tsconfig.json --noEmit" + "check": "tsc -p tsconfig.json --noEmit", + "pretest": "pnpm build", + "test": "node --test dist/**/*.test.js" }, "dependencies": { "@nproxy/domain": "workspace:*" diff --git a/packages/providers/src/email.test.ts b/packages/providers/src/email.test.ts new file mode 100644 index 0000000..4033fe0 --- /dev/null +++ b/packages/providers/src/email.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createEmailTransport } from "./email.js"; + +test("example email transport redacts password-reset tokens before logging", async () => { + const transport = createEmailTransport({ + provider: "example", + from: "noreply@nproxy.test", + apiKey: "unused", + }); + const logMessages: string[] = []; + const originalConsoleLog = console.log; + console.log = (message?: unknown) => { + logMessages.push(String(message ?? "")); + }; + + try { + await transport.send({ + to: "user@example.com", + subject: "Reset your password", + text: "Reset link: https://app.nproxy.test/reset-password?token=secret-token-123", + }); + } finally { + console.log = originalConsoleLog; + } + + assert.equal(logMessages.length, 1); + assert.match(logMessages[0] ?? "", /"mode":"debug_redacted"/); + assert.match(logMessages[0] ?? "", /token=\[REDACTED\]/); + assert.doesNotMatch(logMessages[0] ?? "", /secret-token-123/); +}); + +test("unsupported email providers fail closed", () => { + assert.throws( + () => + createEmailTransport({ + provider: "smtp", + from: "noreply@nproxy.test", + apiKey: "unused", + }), + /Unsupported email provider: smtp/, + ); +}); diff --git a/packages/providers/src/email.ts b/packages/providers/src/email.ts index a5b12e2..21615e2 100644 --- a/packages/providers/src/email.ts +++ b/packages/providers/src/email.ts @@ -20,29 +20,20 @@ export function createEmailTransport(config: { JSON.stringify({ service: "email", provider: config.provider, + mode: "debug_redacted", from: config.from, to: input.to, subject: input.subject, - text: input.text, + textPreview: redactEmailText(input.text), }), ); }, }; } - return { - async send(input) { - console.log( - JSON.stringify({ - service: "email", - provider: config.provider, - mode: "noop_fallback", - from: config.from, - to: input.to, - subject: input.subject, - text: input.text, - }), - ); - }, - }; + throw new Error(`Unsupported email provider: ${config.provider}`); +} + +function redactEmailText(text: string): string { + return text.replace(/([?&]token=)[^&\s]+/gi, "$1[REDACTED]"); }