Files
craftvia/src/server/mail/provider-smtp.ts
T
msolarczekandClaude Opus 5 c8e6f30a27
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s
Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:05:39 +02:00

111 lines
3.5 KiB
TypeScript

import nodemailer, { type Transporter } from "nodemailer";
import { getMailConfig, type MailConfig } from "./config";
import { TransientMailError, type MailProvider, type OutgoingMail, type SendResult } from "./provider";
/**
* SEC1 — SMTP-Transport auf Basis von nodemailer.
*
* Verbindungs-Pool: der Transporter wird einmal erzeugt und wiederverwendet.
* TLS ist Pflicht — bei Port 465 implizit, sonst `requireTLS` (STARTTLS). Die
* Zertifikatsprüfung bleibt aktiv; sie wird nur für `localhost` gelockert, weil
* Mailpit/Mailhog in der Entwicklung ein selbstsigniertes Zertifikat verwenden
* bzw. gar kein TLS anbieten.
*/
/** SMTP-Antwortcodes 4xx sind temporär (Greylisting, Ratelimit) → Retry sinnvoll. */
function isTransient(err: unknown): boolean {
const e = err as { responseCode?: number; code?: string } | null;
if (!e) return false;
if (typeof e.responseCode === "number") return e.responseCode >= 400 && e.responseCode < 500;
return (
e.code === "ETIMEDOUT" ||
e.code === "ECONNRESET" ||
e.code === "ECONNECTION" ||
e.code === "ESOCKET" ||
e.code === "EDNS" ||
e.code === "EAI_AGAIN"
);
}
function createTransport(config: MailConfig): Transporter {
const isLocal = /^(localhost|127\.0\.0\.1|::1|mailpit|mailhog)$/i.test(config.host);
return nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
// TLS erzwingen, außer gegen den lokalen Test-SMTP (Mailpit/Mailhog).
requireTLS: !config.secure && !isLocal,
auth: config.user ? { user: config.user, pass: config.pass ?? "" } : undefined,
pool: true,
maxConnections: 3,
maxMessages: 100,
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 20_000,
tls: { rejectUnauthorized: !isLocal },
});
}
export class SmtpMailProvider implements MailProvider {
private transporter: Transporter | null = null;
constructor(private readonly config: MailConfig) {}
private get transport(): Transporter {
if (!this.transporter) this.transporter = createTransport(this.config);
return this.transporter;
}
async send(msg: OutgoingMail): Promise<SendResult> {
try {
const info = await this.transport.sendMail({
from: msg.from,
to: msg.to,
replyTo: msg.replyTo,
subject: msg.subject,
html: msg.html,
text: msg.text,
headers: msg.headers,
});
return { messageId: info.messageId };
} catch (err) {
if (isTransient(err)) {
throw new TransientMailError(
err instanceof Error ? err.message : "SMTP-Zustellung temporär fehlgeschlagen",
{ cause: err },
);
}
throw err;
}
}
async close(): Promise<void> {
this.transporter?.close();
this.transporter = null;
}
/** Verbindungstest ohne Versand (für den Admin-Testversand hilfreich). */
async verify(): Promise<void> {
await this.transport.verify();
}
}
let singleton: SmtpMailProvider | null = null;
/**
* Der konfigurierte Provider — oder `null`, wenn keine SMTP-Konfiguration
* vorliegt. Aufrufer müssen den Null-Fall behandeln (kein stiller Fehlversand).
*/
export function getMailProvider(): SmtpMailProvider | null {
const { config } = getMailConfig();
if (!config) return null;
if (!singleton) singleton = new SmtpMailProvider(config);
return singleton;
}
/** Nur für Tests/Shutdown. */
export async function closeMailProvider(): Promise<void> {
await singleton?.close();
singleton = null;
}