mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 23:14:12 +00:00
- 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>
338 lines
8.7 KiB
TypeScript
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' },
|
|
})
|
|
}
|