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>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user