Files
CapaKraken/packages/api/src/lib/email.ts
T
Hartmut fceceeee4b feat: SMTP full ENV override, password reset flow, and E2E email testing
- 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>
2026-04-02 08:55:39 +02:00

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) };
}
}