import nodemailer from 'nodemailer' import type { Transporter } from 'nodemailer' import type { Payload } from 'payload' import type { Tenant } from '../../payload-types' export interface EmailOptions { to: string | string[] subject: string html?: string text?: string replyTo?: string attachments?: Array<{ filename: string content: Buffer | string contentType?: string }> } export interface SendEmailResult { success: boolean messageId?: string error?: string logId?: number } export type EmailSource = 'manual' | 'form' | 'system' | 'newsletter' interface SendEmailOptions extends EmailOptions { source?: EmailSource metadata?: Record } // Cache für SMTP-Transporter const transporterCache = new Map() /** * Prüft ob der SMTP-Versand übersprungen werden soll * - wird automatisch in Test-Umgebungen deaktiviert * - kann via EMAIL_DELIVERY_DISABLED explizit deaktiviert werden */ function isEmailDeliveryDisabled(): boolean { return process.env.NODE_ENV === 'test' || process.env.EMAIL_DELIVERY_DISABLED === 'true' } /** * Globaler Fallback-Transporter aus .env Variablen */ function getGlobalTransporter(): Transporter { const cacheKey = 'global' if (!transporterCache.has(cacheKey)) { const transporter = nodemailer.createTransport({ host: process.env.SMTP_HOST, port: parseInt(process.env.SMTP_PORT || '587'), secure: process.env.SMTP_SECURE === 'true', auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, }, }) transporterCache.set(cacheKey, transporter) } return transporterCache.get(cacheKey)! } /** * Tenant-spezifischer Transporter (falls eigener SMTP konfiguriert) */ function getTenantTransporter(tenant: Tenant): Transporter { const smtp = tenant.email?.smtp if (!smtp?.host || !tenant.email?.useCustomSmtp) { return getGlobalTransporter() } const cacheKey = `tenant:${tenant.id}` if (!transporterCache.has(cacheKey)) { const transporter = nodemailer.createTransport({ host: smtp.host, port: smtp.port || 587, secure: smtp.secure || false, auth: { user: smtp.user || '', pass: smtp.pass || '', }, }) transporterCache.set(cacheKey, transporter) } return transporterCache.get(cacheKey)! } /** * Cache invalidieren wenn Tenant-E-Mail-Config geändert wird */ export function invalidateTenantEmailCache(tenantId: string | number): void { transporterCache.delete(`tenant:${tenantId}`) } /** * Globalen Cache invalidieren */ export function invalidateGlobalEmailCache(): void { transporterCache.delete('global') } /** * E-Mail-Log erstellen */ async function createEmailLog( payload: Payload, tenantId: string | number, data: { to: string from: string subject: string status: 'pending' | 'sent' | 'failed' messageId?: string error?: string source: EmailSource metadata?: Record }, ): Promise { try { const log = await payload.create({ collection: 'email-logs', data: { tenant: Number(tenantId), to: data.to, from: data.from, subject: data.subject, status: data.status, messageId: data.messageId, error: data.error, source: data.source, metadata: data.metadata, }, overrideAccess: true, }) return log.id } catch (error) { console.error('[EmailLog] Failed to create log:', error) return null } } /** * E-Mail-Log aktualisieren */ async function updateEmailLog( payload: Payload, logId: number, data: { status: 'sent' | 'failed' messageId?: string error?: string }, ): Promise { try { await payload.update({ collection: 'email-logs', id: logId, data, overrideAccess: true, }) } catch (error) { console.error('[EmailLog] Failed to update log:', error) } } /** * Haupt-Funktion: E-Mail für einen Tenant senden mit Logging * * @param payload - Payload Instanz * @param tenantId - ID des Tenants * @param options - E-Mail-Optionen (to, subject, html/text, etc.) */ export async function sendTenantEmail( payload: Payload, tenantId: string | number, options: SendEmailOptions, ): Promise { const source = options.source || 'manual' let logId: number | null = null try { // Tenant laden mit Admin-Zugriff (für SMTP-Pass) const tenant = (await payload.findByID({ collection: 'tenants', id: tenantId, depth: 0, overrideAccess: true, })) as Tenant if (!tenant) { throw new Error(`Tenant ${tenantId} nicht gefunden`) } // E-Mail-Konfiguration mit Fallbacks const fromAddress = tenant.email?.fromAddress || process.env.SMTP_FROM_ADDRESS || 'noreply@c2sgmbh.de' const fromName = tenant.email?.fromName || tenant.name || 'Payload CMS' const from = `"${fromName}" <${fromAddress}>` const replyTo = options.replyTo || tenant.email?.replyTo || fromAddress const toAddress = Array.isArray(options.to) ? options.to.join(', ') : options.to // Log erstellen (status: pending) logId = await createEmailLog(payload, tenantId, { to: toAddress, from, subject: options.subject, status: 'pending', source, metadata: options.metadata, }) // E-Mail-Versand in Tests oder wenn explizit deaktiviert: sofort Erfolg melden if (isEmailDeliveryDisabled()) { const mockMessageId = `test-message-${Date.now()}` if (logId) { await updateEmailLog(payload, logId, { status: 'sent', messageId: mockMessageId, }) } console.info('[Email] Delivery disabled - skipping SMTP send') return { success: true, messageId: mockMessageId, logId: logId || undefined, } } // Transporter wählen (Tenant-spezifisch oder global) const transporter = getTenantTransporter(tenant) // E-Mail senden const result = await transporter.sendMail({ from, to: toAddress, replyTo, subject: options.subject, html: options.html, text: options.text, attachments: options.attachments, }) // Log aktualisieren (status: sent) if (logId) { await updateEmailLog(payload, logId, { status: 'sent', messageId: result.messageId, }) } console.log(`[Email] Sent to ${options.to} for tenant ${tenant.slug}: ${result.messageId}`) return { success: true, messageId: result.messageId, logId: logId || undefined } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' // Log aktualisieren (status: failed) if (logId) { try { await updateEmailLog(payload, logId, { status: 'failed', error: errorMessage, }) } catch (logError) { console.error(`[Email] Failed to update email log ${logId}:`, logError) } } console.error(`[Email] Error for tenant ${tenantId}:`, error) return { success: false, error: errorMessage, logId: logId || undefined, } } } /** * Tenant aus Request ermitteln (via Header oder Host) */ export async function getTenantFromRequest( payload: Payload, req: Request, ): Promise { // Aus X-Tenant-Slug Header const tenantSlug = req.headers.get('x-tenant-slug') if (tenantSlug) { const result = await payload.find({ collection: 'tenants', where: { slug: { equals: tenantSlug } }, limit: 1, }) return (result.docs[0] as Tenant) || null } // Aus Host-Header (Domain) const host = req.headers.get('host')?.replace(/:\d+$/, '') if (host) { const result = await payload.find({ collection: 'tenants', where: { 'domains.domain': { equals: host } }, limit: 1, }) return (result.docs[0] as Tenant) || null } return null } /** * Test-E-Mail senden um SMTP-Konfiguration zu verifizieren */ export async function sendTestEmail( payload: Payload, tenantId: string | number, recipientEmail: string, ): Promise { return sendTenantEmail(payload, tenantId, { to: recipientEmail, subject: 'Test E-Mail - SMTP Konfiguration', html: `

SMTP-Konfiguration erfolgreich!

Diese Test-E-Mail bestätigt, dass die E-Mail-Konfiguration für diesen Tenant funktioniert.

Gesendet am ${new Date().toLocaleString('de-DE')}

`, text: `SMTP-Konfiguration erfolgreich!\n\nDiese Test-E-Mail bestätigt, dass die E-Mail-Konfiguration für diesen Tenant funktioniert.\n\nGesendet am ${new Date().toLocaleString('de-DE')}`, source: 'manual', metadata: { type: 'test' }, }) }