Files
opencrm/backend/src/services/smtpService.ts
T

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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
// Mehrfache Leerzeilen reduzieren
.replace(/\n{3,}/g, '\n\n')
.trim();
}