cms.c2sgmbh/docs/IMPLEMENTIERUNGS-AUFTRAG.md
Martin Porwoll 19fcb4d837 feat: implement multi-tenant email system with logging
- 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>
2025-12-07 20:16:54 +00:00

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

  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

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

  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

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:

    pnpm generate:types
    
  2. Build testen vor Commit:

    pnpm build
    
  3. SMTP-Credentials sind sensibel - niemals in Git committen!

  4. 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-email ermöglicht manuelles Senden

Erstellt: 06. Dezember 2025 Projekt: Payload CMS Multi-Tenant Server: pl.c2sgmbh.de (Development)