- Add Payload email adapter for system emails (auth, password reset) - Add EmailLogs collection for tracking all sent emails - Extend Tenants collection with SMTP configuration fields - Implement tenant-specific email service with transporter caching - Add /api/send-email endpoint with: - Authentication required - Tenant access control (users can only send for their tenants) - Rate limiting (10 emails/minute per user) - Add form submission notification hook with email logging - Add cache invalidation hook for tenant email config changes Security: - SMTP passwords are never returned in API responses - Passwords are preserved when field is left empty on update - Only super admins can delete email logs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
16 KiB
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
-
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
-
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
-
Performance
- SMTP-Transporter cachen (nicht bei jeder E-Mail neu verbinden)
- Cache invalidieren wenn Tenant-E-Mail-Config geändert wird
-
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
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):
{
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
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<string, nodemailer.Transporter>()
// 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<Tenant | null> {
// 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
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:
// 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
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 => `<tr><td><strong>${item.field}</strong></td><td>${item.value}</td></tr>`)
.join('')
// E-Mail senden
await sendTenantEmail(payload, tenantId, {
to: form.notificationEmail,
subject: `Neue Formular-Einsendung: ${form.title}`,
html: `
<h2>Neue Einsendung über ${form.title}</h2>
<table border="1" cellpadding="8" cellspacing="0">
<tbody>${dataHtml}</tbody>
</table>
<p><small>Gesendet am ${new Date().toLocaleString('de-DE')}</small></p>
`,
})
return doc
}
Schritt 6: REST-Endpoint für manuelles Senden
Datei: src/app/(payload)/api/send-email/route.ts
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)
# 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
- Gehe zu Tenants → [beliebiger Tenant]
- Scrolle zu "E-Mail Konfiguration"
- Trage Absender-E-Mail und Name ein
- Optional: Aktiviere "Eigenen SMTP-Server verwenden" und trage Credentials ein
- Speichern
2. Test-E-Mail via API
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": "<h1>Hallo Welt</h1><p>Dies ist ein Test.</p>"
}'
3. Formular-Test
- Erstelle ein Formular für einen Tenant
- Aktiviere "Notify on Submission" und trage E-Mail ein
- Sende eine Test-Einsendung über das Frontend
- Prüfe ob E-Mail ankommt
Wichtige Hinweise
-
Types generieren nach Änderung der Tenants Collection:
pnpm generate:types -
Build testen vor Commit:
pnpm build -
SMTP-Credentials sind sensibel - niemals in Git committen!
-
Logging prüfen bei Problemen:
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-emailermöglicht manuelles Senden
Erstellt: 06. Dezember 2025 Projekt: Payload CMS Multi-Tenant Server: pl.c2sgmbh.de (Development)