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 { 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 { this.transporter?.close(); this.transporter = null; } /** Verbindungstest ohne Versand (für den Admin-Testversand hilfreich). */ async verify(): Promise { 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 { await singleton?.close(); singleton = null; }