fceceeee4b
- SMTP: SMTP_HOST/PORT/USER/FROM/TLS now all have ENV override support (previously only SMTP_PASSWORD was env-aware). ENV takes priority over DB. - docker-compose.yml: forward all SMTP_* env vars to app container + add Mailhog service (ports 1025 SMTP / 8025 HTTP, always available in dev) - Password reset: PasswordResetToken Prisma model + authRouter with requestPasswordReset (timing-safe, no email enumeration) + resetPassword - UI: /auth/forgot-password, /auth/reset-password/[token] pages + "Forgot password?" link on sign-in page - E2E: Mailhog helpers (getLatestEmailTo, clearMailhog, extractUrlFromEmail) + invite-flow.spec.ts + password-reset.spec.ts Co-Authored-By: claude-flow <ruv@ruv.net>
124 lines
3.8 KiB
TypeScript
124 lines
3.8 KiB
TypeScript
/**
|
|
* Email sending utility using nodemailer.
|
|
* Non-blocking — errors are logged, not thrown.
|
|
*/
|
|
import nodemailer from "nodemailer";
|
|
import { prisma as db } from "@capakraken/db";
|
|
import { logger } from "./logger.js";
|
|
import { resolveSystemSettingsRuntime } from "./system-settings-runtime.js";
|
|
|
|
interface EmailPayload {
|
|
to: string | string[];
|
|
subject: string;
|
|
text: string;
|
|
html?: string;
|
|
}
|
|
|
|
function sanitizeSmtpDiagnostic(err: unknown): string {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return message
|
|
.replace(/https?:\/\/[^\s)\]}]+/gi, "<redacted-url>")
|
|
.replace(/\b[^\s@]+@[^\s@]+\.[^\s@]+\b/g, "<redacted-email>")
|
|
.replace(/\b(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}\b/g, "<redacted-host>")
|
|
.replace(/^Error:\s*/, "")
|
|
.slice(0, 300);
|
|
}
|
|
|
|
export function parseSmtpError(err: unknown): string {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
const lower = message.toLowerCase();
|
|
|
|
if (lower.includes("auth") || lower.includes("invalid login") || lower.includes("535")) {
|
|
return "SMTP authentication failed — check username and password.";
|
|
}
|
|
if (lower.includes("certificate") || lower.includes("tls") || lower.includes("ssl")) {
|
|
return "SMTP TLS negotiation failed — verify port and TLS settings.";
|
|
}
|
|
if (
|
|
lower.includes("econnrefused")
|
|
|| lower.includes("enotfound")
|
|
|| lower.includes("etimedout")
|
|
|| lower.includes("timeout")
|
|
) {
|
|
return "Cannot reach the SMTP server — check host, port, and network access.";
|
|
}
|
|
|
|
return "SMTP connection failed — review host, port, TLS, and credentials.";
|
|
}
|
|
|
|
async function getSmtpConfig() {
|
|
const settings = resolveSystemSettingsRuntime(
|
|
await db.systemSettings.findUnique({ where: { id: "singleton" } }),
|
|
);
|
|
if (!settings.smtpHost) return null;
|
|
const port = typeof settings.smtpPort === "number"
|
|
? settings.smtpPort
|
|
: settings.smtpPort !== null && settings.smtpPort !== undefined
|
|
? parseInt(String(settings.smtpPort), 10)
|
|
: 587;
|
|
return {
|
|
host: settings.smtpHost,
|
|
port,
|
|
secure: settings.smtpTls === false ? false : true,
|
|
auth:
|
|
settings.smtpUser && settings.smtpPassword
|
|
? { user: settings.smtpUser, pass: settings.smtpPassword }
|
|
: undefined,
|
|
from: settings.smtpFrom ?? settings.smtpUser ?? "noreply@capakraken.app",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Send an email. Swallows errors so calling code is never blocked.
|
|
* Returns true if sent successfully.
|
|
*/
|
|
export async function sendEmail(payload: EmailPayload): Promise<boolean> {
|
|
try {
|
|
const config = await getSmtpConfig();
|
|
if (!config) return false;
|
|
|
|
const transporter = nodemailer.createTransport({
|
|
host: config.host,
|
|
port: config.port,
|
|
secure: config.secure,
|
|
auth: config.auth,
|
|
});
|
|
|
|
await transporter.sendMail({
|
|
from: config.from,
|
|
to: Array.isArray(payload.to) ? payload.to.join(", ") : payload.to,
|
|
subject: payload.subject,
|
|
text: payload.text,
|
|
html: payload.html,
|
|
});
|
|
|
|
return true;
|
|
} catch (err) {
|
|
logger.warn({ diagnostic: sanitizeSmtpDiagnostic(err) }, "Email send failed");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test SMTP connection. Returns { ok: boolean; error?: string }.
|
|
*/
|
|
export async function testSmtpConnection(): Promise<{ ok: boolean; error?: string }> {
|
|
try {
|
|
const config = await getSmtpConfig();
|
|
if (!config) return { ok: false, error: "SMTP not configured" };
|
|
|
|
const transporter = nodemailer.createTransport({
|
|
host: config.host,
|
|
port: config.port,
|
|
secure: config.secure,
|
|
auth: config.auth,
|
|
});
|
|
|
|
await transporter.verify();
|
|
return { ok: true };
|
|
} catch (err) {
|
|
logger.warn({ diagnostic: sanitizeSmtpDiagnostic(err) }, "SMTP connection test failed");
|
|
return { ok: false, error: parseSmtpError(err) };
|
|
}
|
|
}
|