# IMPLEMENTIERUNGS-AUFTRAG: Multi-Tenant E-Mail-System ## Kontext Du arbeitest am Payload CMS 3.x Multi-Tenant-System auf pl.c2sgmbh.de. Das System verwaltet mehrere Websites (porwoll.de, complexcaresolutions.de, gunshin.de, caroline-porwoll.de, etc.) über eine zentrale Payload-Instanz. **Aktueller Status:** Kein E-Mail-Adapter konfiguriert. E-Mails werden nur in der Konsole ausgegeben. **Ziel:** Vollständiges Multi-Tenant E-Mail-System mit tenant-spezifischen SMTP-Servern und Absender-Adressen. --- ## Anforderungen ### Funktionale Anforderungen 1. **Tenant-spezifische E-Mail-Konfiguration** - Jeder Tenant kann eigene SMTP-Credentials haben - Eigene Absender-Adresse und Absender-Name pro Tenant - Eigene Reply-To-Adresse pro Tenant - Fallback auf globale SMTP-Konfiguration wenn Tenant keine eigene hat 2. **Sicherheit** - SMTP-Passwörter dürfen NICHT in API-Responses zurückgegeben werden - Passwörter bleiben erhalten wenn Feld bei Update leer gelassen wird - Verschlüsselte Verbindungen (TLS/SSL) unterstützen 3. **Performance** - SMTP-Transporter cachen (nicht bei jeder E-Mail neu verbinden) - Cache invalidieren wenn Tenant-E-Mail-Config geändert wird 4. **Integration** - Formular-Einsendungen lösen automatisch Benachrichtigungen aus - REST-Endpoint für manuelles E-Mail-Senden - Logging aller gesendeten E-Mails --- ## Architektur ``` Request mit Tenant-Context │ ▼ ┌─────────────────┐ │ TenantEmailService │◄─── Ermittelt Tenant aus Request/Context └────────┬────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ Tenant E-Mail-Konfiguration? │ │ │ │ JA (eigener SMTP) NEIN (Fallback) │ │ │ │ │ │ ▼ ▼ │ │ ┌─────────────────┐ ┌─────────────────┐ │ │ │ Tenant SMTP │ │ Global SMTP │ │ │ │ z.B. smtp.... │ │ aus .env │ │ │ │ from: info@... │ │ from: noreply@ │ │ │ └─────────────────┘ └─────────────────┘ │ └─────────────────────────────────────────────────────────────────────┘ ``` --- ## Implementierung ### Schritt 1: Dependencies installieren ```bash pnpm add nodemailer pnpm add -D @types/nodemailer ``` --- ### Schritt 2: Tenants Collection erweitern **Datei:** `src/collections/Tenants/index.ts` Füge folgende Felder zur bestehenden Tenants Collection hinzu (als `group` Feld): ```typescript { name: 'email', type: 'group', label: 'E-Mail Konfiguration', admin: { description: 'SMTP-Einstellungen für diesen Tenant. Leer = globale Einstellungen.', }, fields: [ { type: 'row', fields: [ { name: 'fromAddress', type: 'email', label: 'Absender E-Mail', admin: { placeholder: 'info@domain.de', width: '50%', }, }, { name: 'fromName', type: 'text', label: 'Absender Name', admin: { placeholder: 'Firmenname', width: '50%', }, }, ], }, { name: 'replyTo', type: 'email', label: 'Antwort-Adresse (Reply-To)', admin: { placeholder: 'kontakt@domain.de (optional)', }, }, { name: 'useCustomSmtp', type: 'checkbox', label: 'Eigenen SMTP-Server verwenden', defaultValue: false, }, { name: 'smtp', type: 'group', label: 'SMTP Einstellungen', admin: { condition: (data, siblingData) => siblingData?.useCustomSmtp, }, fields: [ { type: 'row', fields: [ { name: 'host', type: 'text', label: 'SMTP Host', admin: { placeholder: 'smtp.example.com', width: '50%', }, }, { name: 'port', type: 'number', label: 'Port', defaultValue: 587, admin: { width: '25%', }, }, { name: 'secure', type: 'checkbox', label: 'SSL/TLS', defaultValue: false, admin: { width: '25%', }, }, ], }, { type: 'row', fields: [ { name: 'user', type: 'text', label: 'SMTP Benutzername', admin: { width: '50%', }, }, { name: 'pass', type: 'text', label: 'SMTP Passwort', admin: { width: '50%', }, access: { read: () => false, // Passwort nie in API-Response }, hooks: { beforeChange: [ ({ value, originalDoc }) => { // Behalte altes Passwort wenn Feld leer if (!value && originalDoc?.email?.smtp?.pass) { return originalDoc.email.smtp.pass } return value }, ], }, }, ], }, ], }, ], } ``` --- ### Schritt 3: E-Mail Service erstellen **Datei:** `src/lib/email/tenant-email-service.ts` ```typescript import nodemailer from 'nodemailer' import type { Payload } from 'payload' import type { Tenant } from '@/payload-types' interface EmailOptions { to: string | string[] subject: string html?: string text?: string replyTo?: string attachments?: Array<{ filename: string content: Buffer | string contentType?: string }> } // Cache für SMTP-Transporter const transporterCache = new Map() // Globaler Fallback-Transporter function getGlobalTransporter(): nodemailer.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 function getTenantTransporter(tenant: Tenant): nodemailer.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 export function invalidateTenantEmailCache(tenantId: string): void { transporterCache.delete(`tenant:${tenantId}`) } // Haupt-Funktion: E-Mail für Tenant senden export async function sendTenantEmail( payload: Payload, tenantId: string, options: EmailOptions ): Promise<{ success: boolean; messageId?: string; error?: string }> { 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 const fromAddress = tenant.email?.fromAddress || process.env.SMTP_FROM_ADDRESS || 'noreply@c2sgmbh.de' const fromName = tenant.email?.fromName || tenant.name || 'Payload CMS' const replyTo = options.replyTo || tenant.email?.replyTo || fromAddress // Transporter wählen const transporter = getTenantTransporter(tenant) // E-Mail senden const result = await transporter.sendMail({ from: `"${fromName}" <${fromAddress}>`, to: Array.isArray(options.to) ? options.to.join(', ') : options.to, replyTo, subject: options.subject, html: options.html, text: options.text, attachments: options.attachments, }) console.log(`[Email] Sent to ${options.to} for tenant ${tenant.slug}: ${result.messageId}`) return { success: true, messageId: result.messageId } } catch (error) { console.error(`[Email] Error for tenant ${tenantId}:`, error) return { success: false, error: error instanceof Error ? error.message : 'Unknown error', } } } // Tenant aus Request ermitteln export async function getTenantFromRequest( payload: Payload, req: Request ): Promise { // Aus 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 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 } ``` --- ### Schritt 4: Cache-Invalidierung Hook **Datei:** `src/hooks/invalidateEmailCache.ts` ```typescript import type { CollectionAfterChangeHook } from 'payload' import { invalidateTenantEmailCache } from '@/lib/email/tenant-email-service' export const invalidateEmailCacheHook: CollectionAfterChangeHook = async ({ doc, previousDoc, operation, }) => { if (operation === 'update') { const emailChanged = JSON.stringify(doc.email) !== JSON.stringify(previousDoc?.email) if (emailChanged) { invalidateTenantEmailCache(doc.id) console.log(`[Email] Cache invalidated for tenant ${doc.slug}`) } } return doc } ``` **Hook in Tenants Collection registrieren:** ```typescript // In src/collections/Tenants/index.ts import { invalidateEmailCacheHook } from '@/hooks/invalidateEmailCache' export const Tenants: CollectionConfig = { // ... hooks: { afterChange: [invalidateEmailCacheHook], }, } ``` --- ### Schritt 5: Form-Submission Notification Hook **Datei:** `src/hooks/sendFormNotification.ts` ```typescript import type { CollectionAfterChangeHook } from 'payload' import { sendTenantEmail } from '@/lib/email/tenant-email-service' export const sendFormNotification: CollectionAfterChangeHook = async ({ doc, req, operation, }) => { if (operation !== 'create') return doc const { payload } = req // Form laden const form = await payload.findByID({ collection: 'forms', id: doc.form, depth: 1, }) // Prüfen ob Benachrichtigung aktiviert if (!form?.notifyOnSubmission || !form.notificationEmail) { return doc } // Tenant ermitteln const tenantId = typeof form.tenant === 'string' ? form.tenant : form.tenant?.id if (!tenantId) { console.warn('[Forms] No tenant found for form submission') return doc } // Daten formatieren const submissionData = doc.submissionData as Array<{ field: string; value: string }> const dataHtml = submissionData .map(item => `${item.field}${item.value}`) .join('') // E-Mail senden await sendTenantEmail(payload, tenantId, { to: form.notificationEmail, subject: `Neue Formular-Einsendung: ${form.title}`, html: `

Neue Einsendung über ${form.title}

${dataHtml}

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

`, }) return doc } ``` --- ### Schritt 6: REST-Endpoint für manuelles Senden **Datei:** `src/app/(payload)/api/send-email/route.ts` ```typescript import { getPayload } from 'payload' import config from '@payload-config' import { sendTenantEmail } from '@/lib/email/tenant-email-service' import { NextResponse } from 'next/server' export async function POST(req: Request) { try { const payload = await getPayload({ config }) const body = await req.json() const { tenantId, to, subject, html, text } = body if (!tenantId || !to || !subject) { return NextResponse.json( { error: 'Missing required fields: tenantId, to, subject' }, { status: 400 } ) } const result = await sendTenantEmail(payload, tenantId, { to, subject, html, text, }) if (result.success) { return NextResponse.json({ success: true, messageId: result.messageId }) } else { return NextResponse.json({ success: false, error: result.error }, { status: 500 }) } } catch (error) { return NextResponse.json( { error: error instanceof Error ? error.message : 'Unknown error' }, { status: 500 } ) } } ``` --- ### Schritt 7: Environment Variables **Datei:** `.env` (ergänzen) ```env # Globale SMTP-Einstellungen (Fallback) SMTP_HOST=smtp.c2sgmbh.de SMTP_PORT=587 SMTP_SECURE=false SMTP_USER=noreply@c2sgmbh.de SMTP_PASS=HIER_PASSWORT_EINTRAGEN SMTP_FROM_ADDRESS=noreply@c2sgmbh.de SMTP_FROM_NAME=C2S System ``` --- ## Dateistruktur nach Implementierung ``` src/ ├── collections/ │ └── Tenants/ │ └── index.ts # + email group field ├── hooks/ │ ├── invalidateEmailCache.ts # NEU │ └── sendFormNotification.ts # NEU ├── lib/ │ └── email/ │ └── tenant-email-service.ts # NEU └── app/ └── (payload)/ └── api/ └── send-email/ └── route.ts # NEU ``` --- ## Testen ### 1. Tenant E-Mail-Config im Admin UI 1. Gehe zu Tenants → [beliebiger Tenant] 2. Scrolle zu "E-Mail Konfiguration" 3. Trage Absender-E-Mail und Name ein 4. Optional: Aktiviere "Eigenen SMTP-Server verwenden" und trage Credentials ein 5. Speichern ### 2. Test-E-Mail via API ```bash curl -X POST https://pl.c2sgmbh.de/api/send-email \ -H "Content-Type: application/json" \ -d '{ "tenantId": "TENANT_ID_HIER", "to": "test@example.com", "subject": "Test E-Mail", "html": "

Hallo Welt

Dies ist ein Test.

" }' ``` ### 3. Formular-Test 1. Erstelle ein Formular für einen Tenant 2. Aktiviere "Notify on Submission" und trage E-Mail ein 3. Sende eine Test-Einsendung über das Frontend 4. Prüfe ob E-Mail ankommt --- ## Wichtige Hinweise 1. **Types generieren** nach Änderung der Tenants Collection: ```bash pnpm generate:types ``` 2. **Build testen** vor Commit: ```bash pnpm build ``` 3. **SMTP-Credentials** sind sensibel - niemals in Git committen! 4. **Logging** prüfen bei Problemen: ```bash pm2 logs payload ``` --- ## Erwartetes Ergebnis Nach erfolgreicher Implementierung: - ✅ Jeder Tenant hat im Admin UI eine "E-Mail Konfiguration" Sektion - ✅ Tenants ohne eigene SMTP-Config nutzen automatisch globale Einstellungen - ✅ E-Mails werden mit korrektem Absender pro Tenant gesendet - ✅ Formular-Einsendungen lösen automatisch Benachrichtigungen aus - ✅ SMTP-Passwörter sind geschützt und nicht via API abrufbar - ✅ API-Endpoint `/api/send-email` ermöglicht manuelles Senden --- *Erstellt: 06. Dezember 2025* *Projekt: Payload CMS Multi-Tenant* *Server: pl.c2sgmbh.de (Development)*