cms.c2sgmbh/src/lib/email/tenant-email-service.ts
Martin Porwoll 6b4dae8eeb fix: handle non-JSON responses in test email and prevent cascading failures
- Add content-type check in TestEmailButton before parsing response as JSON
- Wrap updateEmailLog in error handler with try-catch to prevent double failures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:32:32 +00:00

338 lines
8.7 KiB
TypeScript

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<string, unknown>
}
// Cache für SMTP-Transporter
const transporterCache = new Map<string, Transporter>()
/**
* 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<string, unknown>
},
): Promise<number | null> {
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<void> {
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<SendEmailResult> {
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<Tenant | null> {
// 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<SendEmailResult> {
return sendTenantEmail(payload, tenantId, {
to: recipientEmail,
subject: 'Test E-Mail - SMTP Konfiguration',
html: `
<h2>SMTP-Konfiguration erfolgreich!</h2>
<p>Diese Test-E-Mail bestätigt, dass die E-Mail-Konfiguration für diesen Tenant funktioniert.</p>
<p><small>Gesendet am ${new Date().toLocaleString('de-DE')}</small></p>
`,
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' },
})
}