305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
// ==================== SMTP SERVICE ====================
|
|
// Service für E-Mail-Versand via SMTP
|
|
|
|
import nodemailer from 'nodemailer';
|
|
import type { Transporter } from 'nodemailer';
|
|
import MailComposer from 'nodemailer/lib/mail-composer';
|
|
|
|
// Verschlüsselungstyp
|
|
export type MailEncryption = 'SSL' | 'STARTTLS' | 'NONE';
|
|
|
|
export interface SmtpCredentials {
|
|
host: string;
|
|
port: number;
|
|
user: string;
|
|
password: string;
|
|
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
|
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
|
|
}
|
|
|
|
// Anhang-Interface
|
|
export interface EmailAttachment {
|
|
filename: string;
|
|
content: string; // Base64-kodierter Inhalt
|
|
contentType?: string; // MIME-Type (z.B. 'application/pdf')
|
|
}
|
|
|
|
export interface SendEmailParams {
|
|
to: string | string[];
|
|
cc?: string | string[];
|
|
subject: string;
|
|
text?: string;
|
|
html?: string;
|
|
inReplyTo?: string; // Message-ID der E-Mail auf die geantwortet wird
|
|
references?: string[]; // Thread-Referenzen
|
|
attachments?: EmailAttachment[]; // Anhänge
|
|
}
|
|
|
|
export interface SendEmailResult {
|
|
success: boolean;
|
|
messageId?: string;
|
|
rawEmail?: Buffer; // RFC 5322 formatierte E-Mail für IMAP-Speicherung
|
|
error?: string;
|
|
}
|
|
|
|
// E-Mail senden
|
|
export async function sendEmail(
|
|
credentials: SmtpCredentials,
|
|
fromAddress: string,
|
|
params: SendEmailParams
|
|
): Promise<SendEmailResult> {
|
|
// Verschlüsselungs-Einstellungen basierend auf Modus
|
|
const encryption = credentials.encryption ?? 'SSL';
|
|
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
|
|
|
|
// Transport-Optionen je nach Verschlüsselungstyp
|
|
// SSL: secure=true (implicit TLS, Port 465)
|
|
// STARTTLS: secure=false (upgrades to TLS, Port 587)
|
|
// NONE: secure=false + ignoreTLS=true (no encryption, Port 25)
|
|
const transportOptions: nodemailer.TransportOptions & {
|
|
host: string;
|
|
port: number;
|
|
secure: boolean;
|
|
auth: { user: string; pass: string };
|
|
tls?: { rejectUnauthorized: boolean };
|
|
ignoreTLS?: boolean;
|
|
requireTLS?: boolean;
|
|
connectionTimeout: number;
|
|
greetingTimeout: number;
|
|
socketTimeout: number;
|
|
} = {
|
|
host: credentials.host,
|
|
port: credentials.port,
|
|
secure: encryption === 'SSL',
|
|
auth: {
|
|
user: credentials.user,
|
|
pass: credentials.password,
|
|
},
|
|
connectionTimeout: 10000,
|
|
greetingTimeout: 10000,
|
|
socketTimeout: 30000,
|
|
};
|
|
|
|
// TLS-Optionen nur wenn nicht NONE
|
|
if (encryption !== 'NONE') {
|
|
transportOptions.tls = { rejectUnauthorized };
|
|
} else {
|
|
// Keine Verschlüsselung: STARTTLS ignorieren
|
|
transportOptions.ignoreTLS = true;
|
|
}
|
|
|
|
// Bei STARTTLS: requireTLS erzwingen
|
|
if (encryption === 'STARTTLS') {
|
|
transportOptions.requireTLS = true;
|
|
}
|
|
|
|
// Debug-Logging für Entwicklung
|
|
console.log(`[SMTP] Connecting to ${credentials.host}:${credentials.port} (${encryption}), user: ${credentials.user}`);
|
|
|
|
// Transporter erstellen
|
|
const transporter: Transporter = nodemailer.createTransport(transportOptions);
|
|
|
|
try {
|
|
// E-Mail-Optionen zusammenstellen
|
|
const mailOptions: nodemailer.SendMailOptions = {
|
|
from: fromAddress,
|
|
to: Array.isArray(params.to) ? params.to.join(', ') : params.to,
|
|
subject: params.subject,
|
|
};
|
|
|
|
// CC hinzufügen falls vorhanden
|
|
if (params.cc) {
|
|
mailOptions.cc = Array.isArray(params.cc) ? params.cc.join(', ') : params.cc;
|
|
}
|
|
|
|
// Body hinzufügen
|
|
if (params.html) {
|
|
mailOptions.html = params.html;
|
|
// Auch Text-Version für Clients ohne HTML-Support
|
|
mailOptions.text = params.text || stripHtml(params.html);
|
|
} else if (params.text) {
|
|
mailOptions.text = params.text;
|
|
}
|
|
|
|
// Threading-Header für Antworten
|
|
if (params.inReplyTo) {
|
|
mailOptions.inReplyTo = params.inReplyTo;
|
|
}
|
|
if (params.references && params.references.length > 0) {
|
|
mailOptions.references = params.references.join(' ');
|
|
}
|
|
|
|
// Anhänge hinzufügen
|
|
if (params.attachments && params.attachments.length > 0) {
|
|
mailOptions.attachments = params.attachments.map((att) => ({
|
|
filename: att.filename,
|
|
content: Buffer.from(att.content, 'base64'),
|
|
contentType: att.contentType,
|
|
}));
|
|
}
|
|
|
|
// E-Mail senden
|
|
const result = await transporter.sendMail(mailOptions);
|
|
|
|
// Raw E-Mail für IMAP-Speicherung bauen (mit tatsächlicher Message-ID)
|
|
let rawEmail: Buffer | undefined;
|
|
try {
|
|
const composerOptions = {
|
|
...mailOptions,
|
|
messageId: result.messageId, // Tatsächliche Message-ID vom Server
|
|
};
|
|
const composer = new MailComposer(composerOptions);
|
|
rawEmail = await composer.compile().build();
|
|
} catch (compileError) {
|
|
console.error('Error compiling raw email:', compileError);
|
|
// Nicht kritisch - E-Mail wurde trotzdem gesendet
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
messageId: result.messageId,
|
|
rawEmail,
|
|
};
|
|
} catch (error) {
|
|
console.error('SMTP sendEmail error:', error);
|
|
|
|
// Bessere Fehlermeldungen
|
|
let errorMessage = 'Unbekannter Fehler beim E-Mail-Versand';
|
|
|
|
if (error instanceof Error) {
|
|
const msg = error.message.toLowerCase();
|
|
const errorCode = (error as NodeJS.ErrnoException).code?.toLowerCase() || '';
|
|
|
|
if (msg.includes('authentication') || msg.includes('auth') || msg.includes('login')) {
|
|
errorMessage = 'SMTP-Authentifizierung fehlgeschlagen - Zugangsdaten prüfen';
|
|
} else if (msg.includes('econnrefused') || errorCode === 'econnrefused') {
|
|
errorMessage = `SMTP-Server nicht erreichbar: ${credentials.host}:${credentials.port} - Verbindung verweigert`;
|
|
} else if (msg.includes('greeting never received') || msg.includes('etimedout') || errorCode === 'etimedout') {
|
|
// Detaillierte Fehlermeldung je nach Port/Verschlüsselung
|
|
const enc = credentials.encryption ?? 'SSL';
|
|
if (enc === 'STARTTLS' && credentials.port === 587) {
|
|
errorMessage = `SMTP-Verbindung zu Port 587 fehlgeschlagen - STARTTLS (Submission) ist möglicherweise nicht aktiviert auf ${credentials.host}`;
|
|
} else if (enc === 'NONE' && credentials.port === 25) {
|
|
errorMessage = `SMTP-Verbindung zu Port 25 fehlgeschlagen - Port möglicherweise blockiert oder nicht erreichbar auf ${credentials.host}`;
|
|
} else {
|
|
errorMessage = `SMTP-Verbindung zu ${credentials.host}:${credentials.port} fehlgeschlagen - Port nicht erreichbar oder Timeout`;
|
|
}
|
|
} else if (msg.includes('timeout')) {
|
|
errorMessage = `SMTP-Verbindung: Zeitüberschreitung bei ${credentials.host}:${credentials.port}`;
|
|
} else if (msg.includes('recipient') || msg.includes('rejected')) {
|
|
errorMessage = 'Empfänger-Adresse wurde vom Server abgelehnt';
|
|
} else if (msg.includes('certificate') || msg.includes('cert')) {
|
|
errorMessage = 'SSL-Zertifikatfehler - Aktiviere "Selbstsignierte Zertifikate erlauben" in den Provider-Einstellungen';
|
|
} else if (msg.includes('socket close') || msg.includes('socket hang up') || msg.includes('econnreset') || errorCode === 'econnreset') {
|
|
// Server schließt Verbindung unerwartet - oft TLS-Problem bei STARTTLS
|
|
const enc = credentials.encryption ?? 'SSL';
|
|
if (enc === 'STARTTLS') {
|
|
errorMessage = `SMTP-Verbindung abgebrochen bei STARTTLS - Aktiviere "Selbstsignierte Zertifikate erlauben" oder verwende SSL/TLS auf Port 465`;
|
|
} else {
|
|
errorMessage = `SMTP-Verbindung unerwartet geschlossen von ${credentials.host}:${credentials.port}`;
|
|
}
|
|
} else {
|
|
errorMessage = error.message;
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: errorMessage,
|
|
};
|
|
} finally {
|
|
// Transporter schließen
|
|
transporter.close();
|
|
}
|
|
}
|
|
|
|
// SMTP-Verbindung testen
|
|
export async function testSmtpConnection(credentials: SmtpCredentials): Promise<void> {
|
|
// Verschlüsselungs-Einstellungen basierend auf Modus
|
|
const encryption = credentials.encryption ?? 'SSL';
|
|
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
|
|
|
|
const transportOptions: nodemailer.TransportOptions & {
|
|
host: string;
|
|
port: number;
|
|
secure: boolean;
|
|
auth: { user: string; pass: string };
|
|
tls?: { rejectUnauthorized: boolean };
|
|
ignoreTLS?: boolean;
|
|
connectionTimeout: number;
|
|
greetingTimeout: number;
|
|
} = {
|
|
host: credentials.host,
|
|
port: credentials.port,
|
|
secure: encryption === 'SSL',
|
|
auth: {
|
|
user: credentials.user,
|
|
pass: credentials.password,
|
|
},
|
|
connectionTimeout: 10000,
|
|
greetingTimeout: 10000,
|
|
};
|
|
|
|
if (encryption !== 'NONE') {
|
|
transportOptions.tls = { rejectUnauthorized };
|
|
} else {
|
|
transportOptions.ignoreTLS = true;
|
|
}
|
|
|
|
const transporter: Transporter = nodemailer.createTransport(transportOptions);
|
|
|
|
try {
|
|
// Verbindung verifizieren
|
|
await transporter.verify();
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
const msg = error.message.toLowerCase();
|
|
const errorCode = (error as NodeJS.ErrnoException).code?.toLowerCase() || '';
|
|
|
|
if (msg.includes('authentication') || msg.includes('auth') || msg.includes('login')) {
|
|
throw new Error('SMTP-Authentifizierung fehlgeschlagen');
|
|
}
|
|
if (msg.includes('econnrefused') || errorCode === 'econnrefused') {
|
|
throw new Error(`SMTP-Server nicht erreichbar: ${credentials.host}:${credentials.port} - Verbindung verweigert`);
|
|
}
|
|
if (msg.includes('greeting never received') || msg.includes('etimedout') || errorCode === 'etimedout') {
|
|
if (encryption === 'STARTTLS' && credentials.port === 587) {
|
|
throw new Error(`SMTP Port 587 (STARTTLS/Submission) ist nicht erreichbar - In Plesk unter Tools & Settings > Mail Server Settings aktivieren`);
|
|
} else if (encryption === 'NONE' && credentials.port === 25) {
|
|
throw new Error(`SMTP Port 25 ist nicht erreichbar auf ${credentials.host}`);
|
|
} else {
|
|
throw new Error(`SMTP-Verbindung zu ${credentials.host}:${credentials.port} fehlgeschlagen - Port nicht erreichbar`);
|
|
}
|
|
}
|
|
if (msg.includes('certificate') || msg.includes('cert')) {
|
|
throw new Error('SSL-Zertifikatfehler - Aktiviere "Selbstsignierte Zertifikate erlauben"');
|
|
}
|
|
}
|
|
|
|
throw error;
|
|
} finally {
|
|
transporter.close();
|
|
}
|
|
}
|
|
|
|
// Helper: HTML zu Text konvertieren (einfache Version)
|
|
function stripHtml(html: string): string {
|
|
return html
|
|
// Zeilenumbrüche für Block-Elemente
|
|
.replace(/<br\s*\/?>/gi, '\n')
|
|
.replace(/<\/p>/gi, '\n\n')
|
|
.replace(/<\/div>/gi, '\n')
|
|
.replace(/<\/li>/gi, '\n')
|
|
// Alle HTML-Tags entfernen
|
|
.replace(/<[^>]+>/g, '')
|
|
// HTML-Entities dekodieren
|
|
.replace(/ /g, ' ')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
// Mehrfache Leerzeilen reduzieren
|
|
.replace(/\n{3,}/g, '\n\n')
|
|
.trim();
|
|
}
|