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>
111 lines
3.5 KiB
TypeScript
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;
|
|
}
|