mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 19:44:12 +00:00
- 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>
627 lines
16 KiB
Markdown
627 lines
16 KiB
Markdown
# 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<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`
|
|
|
|
```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 => `<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`
|
|
|
|
```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": "<h1>Hallo Welt</h1><p>Dies ist ein Test.</p>"
|
|
}'
|
|
```
|
|
|
|
### 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)*
|