mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 20:54:11 +00:00
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>
This commit is contained in:
parent
cef310c1f6
commit
19fcb4d837
23 changed files with 32073 additions and 20 deletions
627
docs/IMPLEMENTIERUNGS-AUFTRAG.md
Normal file
627
docs/IMPLEMENTIERUNGS-AUFTRAG.md
Normal file
|
|
@ -0,0 +1,627 @@
|
||||||
|
# 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)*
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
"ioredis": "^5.8.2",
|
"ioredis": "^5.8.2",
|
||||||
"next": "15.4.7",
|
"next": "15.4.7",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"nodemailer": "^7.0.11",
|
||||||
"payload": "3.65.0",
|
"payload": "3.65.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
|
@ -45,6 +46,7 @@
|
||||||
"@testing-library/react": "16.3.0",
|
"@testing-library/react": "16.3.0",
|
||||||
"@types/node": "^22.5.4",
|
"@types/node": "^22.5.4",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/nodemailer": "^7.0.4",
|
||||||
"@types/react": "19.1.8",
|
"@types/react": "19.1.8",
|
||||||
"@types/react-dom": "19.1.6",
|
"@types/react-dom": "19.1.6",
|
||||||
"@vitejs/plugin-react": "4.5.2",
|
"@vitejs/plugin-react": "4.5.2",
|
||||||
|
|
|
||||||
1029
pnpm-lock.yaml
1029
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getPayload } from 'payload'
|
import { getPayload } from 'payload'
|
||||||
import config from '@payload-config'
|
import config from '@payload-config'
|
||||||
import { getPostsByCategory, checkRateLimit } from '@/lib/search'
|
import { getPostsByCategory, checkRateLimit } from '@/lib/search'
|
||||||
|
import type { Category } from '@/payload-types'
|
||||||
|
|
||||||
// Validation constants
|
// Validation constants
|
||||||
const MAX_LIMIT = 50
|
const MAX_LIMIT = 50
|
||||||
|
|
@ -100,9 +101,11 @@ export async function GET(request: NextRequest) {
|
||||||
height: post.featuredImage.height,
|
height: post.featuredImage.height,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
category: post.category && typeof post.category === 'object'
|
categories: Array.isArray(post.categories)
|
||||||
? { name: post.category.name, slug: post.category.slug }
|
? post.categories
|
||||||
: null,
|
.filter((cat): cat is Category => cat !== null && typeof cat === 'object' && 'name' in cat)
|
||||||
|
.map((cat) => ({ name: cat.name, slug: cat.slug }))
|
||||||
|
: [],
|
||||||
})),
|
})),
|
||||||
pagination: {
|
pagination: {
|
||||||
page: result.page,
|
page: result.page,
|
||||||
|
|
|
||||||
260
src/app/(payload)/api/send-email/route.ts
Normal file
260
src/app/(payload)/api/send-email/route.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
import { getPayload } from 'payload'
|
||||||
|
import config from '@payload-config'
|
||||||
|
import { sendTenantEmail, sendTestEmail } from '@/lib/email/tenant-email-service'
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
// Rate Limiting: Max 10 E-Mails pro Minute pro User
|
||||||
|
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
|
||||||
|
const RATE_LIMIT_MAX = 10
|
||||||
|
const RATE_LIMIT_WINDOW_MS = 60 * 1000 // 1 Minute
|
||||||
|
|
||||||
|
function checkRateLimit(userId: string): { allowed: boolean; remaining: number; resetIn: number } {
|
||||||
|
const now = Date.now()
|
||||||
|
const userLimit = rateLimitMap.get(userId)
|
||||||
|
|
||||||
|
if (!userLimit || now > userLimit.resetTime) {
|
||||||
|
rateLimitMap.set(userId, { count: 1, resetTime: now + RATE_LIMIT_WINDOW_MS })
|
||||||
|
return { allowed: true, remaining: RATE_LIMIT_MAX - 1, resetIn: RATE_LIMIT_WINDOW_MS }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userLimit.count >= RATE_LIMIT_MAX) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
remaining: 0,
|
||||||
|
resetIn: userLimit.resetTime - now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
userLimit.count++
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: RATE_LIMIT_MAX - userLimit.count,
|
||||||
|
resetIn: userLimit.resetTime - now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserWithTenants {
|
||||||
|
id: number
|
||||||
|
isSuperAdmin?: boolean
|
||||||
|
tenants?: Array<{
|
||||||
|
tenant: { id: number } | number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft ob User Zugriff auf den angegebenen Tenant hat
|
||||||
|
*/
|
||||||
|
function userHasAccessToTenant(user: UserWithTenants, tenantId: number): boolean {
|
||||||
|
// Super Admins haben Zugriff auf alle Tenants
|
||||||
|
if (user.isSuperAdmin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob User dem Tenant zugeordnet ist
|
||||||
|
if (!user.tenants || user.tenants.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.tenants.some((t) => {
|
||||||
|
const userTenantId = typeof t.tenant === 'object' ? t.tenant.id : t.tenant
|
||||||
|
return userTenantId === tenantId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/send-email
|
||||||
|
*
|
||||||
|
* Sendet eine E-Mail über den Tenant-spezifischen E-Mail-Service.
|
||||||
|
* Erfordert Authentifizierung und Zugriff auf den Tenant.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - tenantId: string | number (erforderlich)
|
||||||
|
* - to: string | string[] (erforderlich)
|
||||||
|
* - subject: string (erforderlich)
|
||||||
|
* - html?: string
|
||||||
|
* - text?: string
|
||||||
|
* - replyTo?: string
|
||||||
|
* - test?: boolean (sendet Test-E-Mail)
|
||||||
|
*/
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config })
|
||||||
|
|
||||||
|
// Authentifizierung prüfen (aus Cookie/Header)
|
||||||
|
const { user } = await payload.auth({ headers: req.headers })
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized - Login required' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const typedUser = user as UserWithTenants
|
||||||
|
|
||||||
|
// Rate Limiting prüfen
|
||||||
|
const rateLimit = checkRateLimit(String(typedUser.id))
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Rate limit exceeded',
|
||||||
|
message: `Maximum ${RATE_LIMIT_MAX} emails per minute. Try again in ${Math.ceil(rateLimit.resetIn / 1000)} seconds.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'X-RateLimit-Limit': String(RATE_LIMIT_MAX),
|
||||||
|
'X-RateLimit-Remaining': '0',
|
||||||
|
'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetIn / 1000)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const { tenantId, to, subject, html, text, replyTo, test } = body
|
||||||
|
|
||||||
|
// Validierung
|
||||||
|
if (!tenantId) {
|
||||||
|
return NextResponse.json({ error: 'Missing required field: tenantId' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericTenantId = Number(tenantId)
|
||||||
|
if (isNaN(numericTenantId)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid tenantId: must be a number' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zugriffskontrolle: User muss Zugriff auf den Tenant haben
|
||||||
|
if (!userHasAccessToTenant(typedUser, numericTenantId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Forbidden - You do not have access to this tenant' },
|
||||||
|
{ status: 403 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate Limit Headers hinzufügen
|
||||||
|
const rateLimitHeaders = {
|
||||||
|
'X-RateLimit-Limit': String(RATE_LIMIT_MAX),
|
||||||
|
'X-RateLimit-Remaining': String(rateLimit.remaining),
|
||||||
|
'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetIn / 1000)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test-E-Mail senden
|
||||||
|
if (test) {
|
||||||
|
if (!to) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing required field: to (for test email)' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sendTestEmail(payload, numericTenantId, to)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: 'Test email sent successfully',
|
||||||
|
messageId: result.messageId,
|
||||||
|
logId: result.logId,
|
||||||
|
},
|
||||||
|
{ headers: rateLimitHeaders },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: result.error, logId: result.logId },
|
||||||
|
{ status: 500, headers: rateLimitHeaders },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normale E-Mail senden
|
||||||
|
if (!to || !subject) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing required fields: to, subject' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!html && !text) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'At least one of html or text is required' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sendTenantEmail(payload, numericTenantId, {
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
replyTo,
|
||||||
|
source: 'manual',
|
||||||
|
metadata: { sentBy: typedUser.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: 'Email sent successfully',
|
||||||
|
messageId: result.messageId,
|
||||||
|
logId: result.logId,
|
||||||
|
},
|
||||||
|
{ headers: rateLimitHeaders },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: result.error, logId: result.logId },
|
||||||
|
{ status: 500, headers: rateLimitHeaders },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] send-email error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Unknown error' },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/send-email
|
||||||
|
*
|
||||||
|
* Gibt API-Dokumentation zurück.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({
|
||||||
|
endpoint: '/api/send-email',
|
||||||
|
method: 'POST',
|
||||||
|
description: 'Send emails using the tenant-specific email service',
|
||||||
|
authentication: 'Required (Cookie or Authorization header)',
|
||||||
|
authorization: 'User must have access to the specified tenant',
|
||||||
|
rateLimit: `${RATE_LIMIT_MAX} requests per minute per user`,
|
||||||
|
body: {
|
||||||
|
tenantId: 'number (required) - ID of the tenant',
|
||||||
|
to: 'string | string[] (required) - Recipient email(s)',
|
||||||
|
subject: 'string (required) - Email subject',
|
||||||
|
html: 'string (optional) - HTML content',
|
||||||
|
text: 'string (optional) - Plain text content',
|
||||||
|
replyTo: 'string (optional) - Reply-to address',
|
||||||
|
test: 'boolean (optional) - Send test email',
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
success: 'boolean',
|
||||||
|
messageId: 'string (on success)',
|
||||||
|
logId: 'number (email log ID)',
|
||||||
|
error: 'string (on failure)',
|
||||||
|
},
|
||||||
|
examples: {
|
||||||
|
sendEmail: {
|
||||||
|
tenantId: 1,
|
||||||
|
to: 'recipient@example.com',
|
||||||
|
subject: 'Hello World',
|
||||||
|
html: '<h1>Hello!</h1><p>This is a test email.</p>',
|
||||||
|
},
|
||||||
|
sendTestEmail: {
|
||||||
|
tenantId: 1,
|
||||||
|
to: 'test@example.com',
|
||||||
|
test: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
131
src/collections/EmailLogs.ts
Normal file
131
src/collections/EmailLogs.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import type { CollectionConfig } from 'payload'
|
||||||
|
|
||||||
|
export const EmailLogs: CollectionConfig = {
|
||||||
|
slug: 'email-logs',
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'subject',
|
||||||
|
group: 'System',
|
||||||
|
description: 'Protokoll aller gesendeten E-Mails',
|
||||||
|
defaultColumns: ['to', 'subject', 'status', 'tenant', 'createdAt'],
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
// Nur Admins können Logs lesen
|
||||||
|
read: ({ req }) => {
|
||||||
|
if (!req.user) return false
|
||||||
|
// Super Admins sehen alle
|
||||||
|
if ((req.user as { isSuperAdmin?: boolean }).isSuperAdmin) return true
|
||||||
|
// Normale User sehen nur Logs ihrer Tenants
|
||||||
|
return {
|
||||||
|
tenant: {
|
||||||
|
in: (req.user.tenants || []).map(
|
||||||
|
(t: { tenant: { id: number } | number }) =>
|
||||||
|
typeof t.tenant === 'object' ? t.tenant.id : t.tenant,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Niemand kann manuell Logs erstellen/bearbeiten
|
||||||
|
create: () => false,
|
||||||
|
update: () => false,
|
||||||
|
delete: ({ req }) => {
|
||||||
|
// Nur Super Admins können Logs löschen
|
||||||
|
return Boolean((req.user as { isSuperAdmin?: boolean })?.isSuperAdmin)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'tenant',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: 'tenants',
|
||||||
|
required: true,
|
||||||
|
label: 'Tenant',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'to',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
label: 'Empfänger',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'from',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
label: 'Absender',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'subject',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
label: 'Betreff',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
defaultValue: 'pending',
|
||||||
|
options: [
|
||||||
|
{ label: 'Ausstehend', value: 'pending' },
|
||||||
|
{ label: 'Gesendet', value: 'sent' },
|
||||||
|
{ label: 'Fehlgeschlagen', value: 'failed' },
|
||||||
|
],
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'messageId',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Message-ID',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
description: 'SMTP Message-ID bei erfolgreichem Versand',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'error',
|
||||||
|
type: 'textarea',
|
||||||
|
label: 'Fehlermeldung',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
condition: (data) => data?.status === 'failed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'source',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
defaultValue: 'manual',
|
||||||
|
options: [
|
||||||
|
{ label: 'Manuell (API)', value: 'manual' },
|
||||||
|
{ label: 'Formular-Einsendung', value: 'form' },
|
||||||
|
{ label: 'System (Auth)', value: 'system' },
|
||||||
|
{ label: 'Newsletter', value: 'newsletter' },
|
||||||
|
],
|
||||||
|
label: 'Quelle',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'metadata',
|
||||||
|
type: 'json',
|
||||||
|
label: 'Zusätzliche Daten',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
description: 'Zusätzliche Kontextinformationen (z.B. Form-ID)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import type { CollectionConfig } from 'payload'
|
import type { CollectionConfig } from 'payload'
|
||||||
|
import { invalidateEmailCacheHook } from '../hooks/invalidateEmailCache'
|
||||||
|
|
||||||
export const Tenants: CollectionConfig = {
|
export const Tenants: CollectionConfig = {
|
||||||
slug: 'tenants',
|
slug: 'tenants',
|
||||||
admin: {
|
admin: {
|
||||||
useAsTitle: 'name',
|
useAsTitle: 'name',
|
||||||
},
|
},
|
||||||
|
hooks: {
|
||||||
|
afterChange: [invalidateEmailCacheHook],
|
||||||
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'name',
|
name: 'name',
|
||||||
|
|
@ -28,5 +32,130 @@ export const Tenants: CollectionConfig = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// E-Mail Konfiguration
|
||||||
|
{
|
||||||
|
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: (_, 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
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export const invalidateCacheOnChange: CollectionAfterChangeHook = async ({
|
||||||
await cache.delPattern('post:*')
|
await cache.delPattern('post:*')
|
||||||
await cache.delPattern('posts:*')
|
await cache.delPattern('posts:*')
|
||||||
break
|
break
|
||||||
case 'navigation':
|
case 'social-links':
|
||||||
await cache.delPattern('nav:*')
|
await cache.delPattern('nav:*')
|
||||||
break
|
break
|
||||||
case 'categories':
|
case 'categories':
|
||||||
|
|
|
||||||
24
src/hooks/invalidateEmailCache.ts
Normal file
24
src/hooks/invalidateEmailCache.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import type { CollectionAfterChangeHook } from 'payload'
|
||||||
|
import { invalidateTenantEmailCache } from '../lib/email/tenant-email-service'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook: Invalidiert den E-Mail-Transporter-Cache wenn sich die
|
||||||
|
* E-Mail-Konfiguration eines Tenants ändert.
|
||||||
|
*/
|
||||||
|
export const invalidateEmailCacheHook: CollectionAfterChangeHook = async ({
|
||||||
|
doc,
|
||||||
|
previousDoc,
|
||||||
|
operation,
|
||||||
|
}) => {
|
||||||
|
if (operation === 'update') {
|
||||||
|
// Prüfen ob sich die E-Mail-Konfiguration geändert hat
|
||||||
|
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
|
||||||
|
}
|
||||||
131
src/hooks/sendFormNotification.ts
Normal file
131
src/hooks/sendFormNotification.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import type { CollectionAfterChangeHook } from 'payload'
|
||||||
|
import { sendTenantEmail } from '../lib/email/tenant-email-service'
|
||||||
|
|
||||||
|
interface SubmissionData {
|
||||||
|
field: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormEmail {
|
||||||
|
emailTo?: string | null
|
||||||
|
cc?: string | null
|
||||||
|
bcc?: string | null
|
||||||
|
replyTo?: string | null
|
||||||
|
emailFrom?: string | null
|
||||||
|
subject?: string | null
|
||||||
|
message?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormDocument {
|
||||||
|
id: number | string
|
||||||
|
title: string
|
||||||
|
emails?: FormEmail[]
|
||||||
|
tenant?: { id: number | string } | number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook: Sendet E-Mail-Benachrichtigungen bei neuen Formular-Einsendungen.
|
||||||
|
* Verwendet den Tenant-spezifischen E-Mail-Service.
|
||||||
|
*/
|
||||||
|
export const sendFormNotification: CollectionAfterChangeHook = async ({
|
||||||
|
doc,
|
||||||
|
req,
|
||||||
|
operation,
|
||||||
|
}) => {
|
||||||
|
// Nur bei neuen Einsendungen
|
||||||
|
if (operation !== 'create') return doc
|
||||||
|
|
||||||
|
const { payload } = req
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Form laden mit Details
|
||||||
|
const form = (await payload.findByID({
|
||||||
|
collection: 'forms',
|
||||||
|
id: doc.form,
|
||||||
|
depth: 1,
|
||||||
|
})) as FormDocument | null
|
||||||
|
|
||||||
|
if (!form) {
|
||||||
|
console.warn('[Forms] Form not found for submission:', doc.form)
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfen ob E-Mail-Benachrichtigungen konfiguriert sind
|
||||||
|
if (!form.emails || form.emails.length === 0) {
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant ermitteln
|
||||||
|
const tenantId = typeof form.tenant === 'object' ? form.tenant?.id : form.tenant
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
console.warn('[Forms] No tenant found for form submission, skipping email')
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daten formatieren
|
||||||
|
const submissionData = doc.submissionData as SubmissionData[] | undefined
|
||||||
|
const dataHtml = submissionData
|
||||||
|
? submissionData
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
`<tr><td style="padding: 8px; border: 1px solid #ddd;"><strong>${item.field}</strong></td><td style="padding: 8px; border: 1px solid #ddd;">${item.value || '-'}</td></tr>`,
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
: '<tr><td colspan="2">Keine Daten</td></tr>'
|
||||||
|
|
||||||
|
const dataText = submissionData
|
||||||
|
? submissionData.map((item) => `${item.field}: ${item.value || '-'}`).join('\n')
|
||||||
|
: 'Keine Daten'
|
||||||
|
|
||||||
|
// E-Mails senden für jede konfigurierte E-Mail-Adresse
|
||||||
|
for (const emailConfig of form.emails) {
|
||||||
|
if (!emailConfig.emailTo) continue
|
||||||
|
|
||||||
|
const subject = emailConfig.subject || `Neue Formular-Einsendung: ${form.title}`
|
||||||
|
|
||||||
|
await sendTenantEmail(payload, tenantId, {
|
||||||
|
to: emailConfig.emailTo,
|
||||||
|
subject,
|
||||||
|
replyTo: emailConfig.replyTo || undefined,
|
||||||
|
source: 'form',
|
||||||
|
metadata: {
|
||||||
|
formId: form.id,
|
||||||
|
formTitle: form.title,
|
||||||
|
submissionId: doc.id,
|
||||||
|
},
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #333;">Neue Einsendung über "${form.title}"</h2>
|
||||||
|
|
||||||
|
${emailConfig.message ? `<p>${emailConfig.message}</p>` : ''}
|
||||||
|
|
||||||
|
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background-color: #f5f5f5;">
|
||||||
|
<th style="padding: 8px; border: 1px solid #ddd; text-align: left;">Feld</th>
|
||||||
|
<th style="padding: 8px; border: 1px solid #ddd; text-align: left;">Wert</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${dataHtml}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
Gesendet am ${new Date().toLocaleString('de-DE')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
text: `Neue Einsendung über "${form.title}"\n\n${emailConfig.message || ''}\n\n${dataText}\n\nGesendet am ${new Date().toLocaleString('de-DE')}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`[Forms] Notification sent to ${emailConfig.emailTo} for form ${form.title}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Forms] Error sending notification:', error)
|
||||||
|
// Fehler nicht weiterwerfen, damit die Einsendung trotzdem gespeichert wird
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc
|
||||||
|
}
|
||||||
80
src/lib/email/payload-email-adapter.ts
Normal file
80
src/lib/email/payload-email-adapter.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
/**
|
||||||
|
* Payload Email Adapter
|
||||||
|
*
|
||||||
|
* Integriert den Multi-Tenant E-Mail-Service in Payloads Built-in Email-System.
|
||||||
|
* Damit nutzen alle CMS-eigenen Mails (Auth, Passwort-Reset, etc.) automatisch
|
||||||
|
* den Tenant-spezifischen SMTP.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EmailAdapter, SendEmailOptions as PayloadSendEmailOptions } from 'payload'
|
||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
import type { Transporter } from 'nodemailer'
|
||||||
|
|
||||||
|
// Cache für den globalen Transporter
|
||||||
|
let globalTransporter: Transporter | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt den globalen SMTP-Transporter
|
||||||
|
*/
|
||||||
|
function getGlobalTransporter(): Transporter {
|
||||||
|
if (!globalTransporter) {
|
||||||
|
globalTransporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||||
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
|
auth:
|
||||||
|
process.env.SMTP_USER && process.env.SMTP_PASS
|
||||||
|
? {
|
||||||
|
user: process.env.SMTP_USER,
|
||||||
|
pass: process.env.SMTP_PASS,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return globalTransporter
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-Tenant Email Adapter für Payload CMS
|
||||||
|
*
|
||||||
|
* Dieser Adapter wird für System-E-Mails (Auth, Passwort-Reset) verwendet.
|
||||||
|
* Er nutzt die globale SMTP-Konfiguration als Fallback.
|
||||||
|
*
|
||||||
|
* Für Tenant-spezifische E-Mails sollte weiterhin sendTenantEmail() verwendet werden.
|
||||||
|
*/
|
||||||
|
export const multiTenantEmailAdapter: EmailAdapter = () => {
|
||||||
|
const fromAddress = process.env.SMTP_FROM_ADDRESS || 'noreply@c2sgmbh.de'
|
||||||
|
const fromName = process.env.SMTP_FROM_NAME || 'Payload CMS'
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'multi-tenant-nodemailer',
|
||||||
|
defaultFromAddress: fromAddress,
|
||||||
|
defaultFromName: fromName,
|
||||||
|
|
||||||
|
sendEmail: async (args: PayloadSendEmailOptions): Promise<void> => {
|
||||||
|
const transporter = getGlobalTransporter()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await transporter.sendMail({
|
||||||
|
from: args.from || `"${fromName}" <${fromAddress}>`,
|
||||||
|
to: args.to,
|
||||||
|
subject: args.subject,
|
||||||
|
html: args.html,
|
||||||
|
text: args.text,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`[Email:Payload] Sent to ${args.to}: ${result.messageId}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Email:Payload] Error sending email:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft ob SMTP konfiguriert ist
|
||||||
|
*/
|
||||||
|
export function isSmtpConfigured(): boolean {
|
||||||
|
return Boolean(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS)
|
||||||
|
}
|
||||||
307
src/lib/email/tenant-email-service.ts
Normal file
307
src/lib/email/tenant-email-service.ts
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
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>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
await updateEmailLog(payload, logId, {
|
||||||
|
status: 'failed',
|
||||||
|
error: errorMessage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,29 @@ interface RequiredEnvVars {
|
||||||
IP_ANONYMIZATION_PEPPER: string
|
IP_ANONYMIZATION_PEPPER: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optionale SMTP-Konfiguration (Fallback für Tenants ohne eigene SMTP-Config)
|
||||||
|
export interface SmtpEnvVars {
|
||||||
|
SMTP_HOST?: string
|
||||||
|
SMTP_PORT?: string
|
||||||
|
SMTP_SECURE?: string
|
||||||
|
SMTP_USER?: string
|
||||||
|
SMTP_PASS?: string
|
||||||
|
SMTP_FROM_ADDRESS?: string
|
||||||
|
SMTP_FROM_NAME?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSmtpConfig(): SmtpEnvVars {
|
||||||
|
return {
|
||||||
|
SMTP_HOST: process.env.SMTP_HOST,
|
||||||
|
SMTP_PORT: process.env.SMTP_PORT,
|
||||||
|
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||||
|
SMTP_USER: process.env.SMTP_USER,
|
||||||
|
SMTP_PASS: process.env.SMTP_PASS,
|
||||||
|
SMTP_FROM_ADDRESS: process.env.SMTP_FROM_ADDRESS,
|
||||||
|
SMTP_FROM_NAME: process.env.SMTP_FROM_NAME,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const FORBIDDEN_VALUES = [
|
const FORBIDDEN_VALUES = [
|
||||||
'',
|
'',
|
||||||
'default-pepper-change-me',
|
'default-pepper-change-me',
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ const getRedisClient = () => {
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
maxRetriesPerRequest: 3,
|
maxRetriesPerRequest: 3,
|
||||||
retryDelayOnFailover: 100,
|
|
||||||
lazyConnect: true,
|
lazyConnect: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,10 @@ export interface SearchResultItem {
|
||||||
excerpt: string | null
|
excerpt: string | null
|
||||||
publishedAt: string | null
|
publishedAt: string | null
|
||||||
type: string
|
type: string
|
||||||
category: {
|
categories: Array<{
|
||||||
name: string
|
name: string
|
||||||
slug: string
|
slug: string
|
||||||
} | null
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SuggestionParams {
|
export interface SuggestionParams {
|
||||||
|
|
@ -365,10 +365,11 @@ export async function searchPosts(
|
||||||
excerpt: post.excerpt || null,
|
excerpt: post.excerpt || null,
|
||||||
publishedAt: post.publishedAt || null,
|
publishedAt: post.publishedAt || null,
|
||||||
type: (post as Post & { type?: string }).type || 'blog',
|
type: (post as Post & { type?: string }).type || 'blog',
|
||||||
category:
|
categories: Array.isArray(post.categories)
|
||||||
post.category && typeof post.category === 'object'
|
? post.categories
|
||||||
? { name: post.category.name, slug: post.category.slug }
|
.filter((cat): cat is Category => cat !== null && typeof cat === 'object' && 'name' in cat)
|
||||||
: null,
|
.map((cat) => ({ name: cat.name, slug: cat.slug }))
|
||||||
|
: [],
|
||||||
})),
|
})),
|
||||||
total: result.totalDocs,
|
total: result.totalDocs,
|
||||||
query,
|
query,
|
||||||
|
|
|
||||||
14282
src/migrations/20251206_134750_tenant_email_config.json
Normal file
14282
src/migrations/20251206_134750_tenant_email_config.json
Normal file
File diff suppressed because it is too large
Load diff
27
src/migrations/20251206_134750_tenant_email_config.ts
Normal file
27
src/migrations/20251206_134750_tenant_email_config.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "tenants" ADD COLUMN "email_from_address" varchar;
|
||||||
|
ALTER TABLE "tenants" ADD COLUMN "email_from_name" varchar;
|
||||||
|
ALTER TABLE "tenants" ADD COLUMN "email_reply_to" varchar;
|
||||||
|
ALTER TABLE "tenants" ADD COLUMN "email_use_custom_smtp" boolean DEFAULT false;
|
||||||
|
ALTER TABLE "tenants" ADD COLUMN "email_smtp_host" varchar;
|
||||||
|
ALTER TABLE "tenants" ADD COLUMN "email_smtp_port" numeric DEFAULT 587;
|
||||||
|
ALTER TABLE "tenants" ADD COLUMN "email_smtp_secure" boolean DEFAULT false;
|
||||||
|
ALTER TABLE "tenants" ADD COLUMN "email_smtp_user" varchar;
|
||||||
|
ALTER TABLE "tenants" ADD COLUMN "email_smtp_pass" varchar;`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "tenants" DROP COLUMN "email_from_address";
|
||||||
|
ALTER TABLE "tenants" DROP COLUMN "email_from_name";
|
||||||
|
ALTER TABLE "tenants" DROP COLUMN "email_reply_to";
|
||||||
|
ALTER TABLE "tenants" DROP COLUMN "email_use_custom_smtp";
|
||||||
|
ALTER TABLE "tenants" DROP COLUMN "email_smtp_host";
|
||||||
|
ALTER TABLE "tenants" DROP COLUMN "email_smtp_port";
|
||||||
|
ALTER TABLE "tenants" DROP COLUMN "email_smtp_secure";
|
||||||
|
ALTER TABLE "tenants" DROP COLUMN "email_smtp_user";
|
||||||
|
ALTER TABLE "tenants" DROP COLUMN "email_smtp_pass";`)
|
||||||
|
}
|
||||||
14486
src/migrations/20251206_141403_email_logs_collection.json
Normal file
14486
src/migrations/20251206_141403_email_logs_collection.json
Normal file
File diff suppressed because it is too large
Load diff
41
src/migrations/20251206_141403_email_logs_collection.ts
Normal file
41
src/migrations/20251206_141403_email_logs_collection.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TYPE "public"."enum_email_logs_status" AS ENUM('pending', 'sent', 'failed');
|
||||||
|
CREATE TYPE "public"."enum_email_logs_source" AS ENUM('manual', 'form', 'system', 'newsletter');
|
||||||
|
CREATE TABLE "email_logs" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"tenant_id" integer NOT NULL,
|
||||||
|
"to" varchar NOT NULL,
|
||||||
|
"from" varchar NOT NULL,
|
||||||
|
"subject" varchar NOT NULL,
|
||||||
|
"status" "enum_email_logs_status" DEFAULT 'pending' NOT NULL,
|
||||||
|
"message_id" varchar,
|
||||||
|
"error" varchar,
|
||||||
|
"source" "enum_email_logs_source" DEFAULT 'manual' NOT NULL,
|
||||||
|
"metadata" jsonb,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "email_logs_id" integer;
|
||||||
|
ALTER TABLE "email_logs" ADD CONSTRAINT "email_logs_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
CREATE INDEX "email_logs_tenant_idx" ON "email_logs" USING btree ("tenant_id");
|
||||||
|
CREATE INDEX "email_logs_updated_at_idx" ON "email_logs" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "email_logs_created_at_idx" ON "email_logs" USING btree ("created_at");
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_email_logs_fk" FOREIGN KEY ("email_logs_id") REFERENCES "public"."email_logs"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_email_logs_id_idx" ON "payload_locked_documents_rels" USING btree ("email_logs_id");`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "email_logs" DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP TABLE "email_logs" CASCADE;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_email_logs_fk";
|
||||||
|
|
||||||
|
DROP INDEX "payload_locked_documents_rels_email_logs_id_idx";
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "email_logs_id";
|
||||||
|
DROP TYPE "public"."enum_email_logs_status";
|
||||||
|
DROP TYPE "public"."enum_email_logs_source";`)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import * as migration_20251130_213501_initial_with_localization from './20251130_213501_initial_with_localization';
|
import * as migration_20251130_213501_initial_with_localization from './20251130_213501_initial_with_localization';
|
||||||
import * as migration_20251202_081830_add_is_super_admin_to_users from './20251202_081830_add_is_super_admin_to_users';
|
import * as migration_20251202_081830_add_is_super_admin_to_users from './20251202_081830_add_is_super_admin_to_users';
|
||||||
import * as migration_20251206_071552_portfolio_collections from './20251206_071552_portfolio_collections';
|
import * as migration_20251206_071552_portfolio_collections from './20251206_071552_portfolio_collections';
|
||||||
|
import * as migration_20251206_134750_tenant_email_config from './20251206_134750_tenant_email_config';
|
||||||
|
import * as migration_20251206_141403_email_logs_collection from './20251206_141403_email_logs_collection';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -16,6 +18,16 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20251206_071552_portfolio_collections.up,
|
up: migration_20251206_071552_portfolio_collections.up,
|
||||||
down: migration_20251206_071552_portfolio_collections.down,
|
down: migration_20251206_071552_portfolio_collections.down,
|
||||||
name: '20251206_071552_portfolio_collections'
|
name: '20251206_071552_portfolio_collections',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20251206_134750_tenant_email_config.up,
|
||||||
|
down: migration_20251206_134750_tenant_email_config.down,
|
||||||
|
name: '20251206_134750_tenant_email_config',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20251206_141403_email_logs_collection.up,
|
||||||
|
down: migration_20251206_141403_email_logs_collection.down,
|
||||||
|
name: '20251206_141403_email_logs_collection'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -76,10 +76,13 @@ export interface Config {
|
||||||
'social-links': SocialLink;
|
'social-links': SocialLink;
|
||||||
testimonials: Testimonial;
|
testimonials: Testimonial;
|
||||||
'newsletter-subscribers': NewsletterSubscriber;
|
'newsletter-subscribers': NewsletterSubscriber;
|
||||||
|
'portfolio-categories': PortfolioCategory;
|
||||||
|
portfolios: Portfolio;
|
||||||
'cookie-configurations': CookieConfiguration;
|
'cookie-configurations': CookieConfiguration;
|
||||||
'cookie-inventory': CookieInventory;
|
'cookie-inventory': CookieInventory;
|
||||||
'consent-logs': ConsentLog;
|
'consent-logs': ConsentLog;
|
||||||
'privacy-policy-settings': PrivacyPolicySetting;
|
'privacy-policy-settings': PrivacyPolicySetting;
|
||||||
|
'email-logs': EmailLog;
|
||||||
forms: Form;
|
forms: Form;
|
||||||
'form-submissions': FormSubmission;
|
'form-submissions': FormSubmission;
|
||||||
redirects: Redirect;
|
redirects: Redirect;
|
||||||
|
|
@ -99,10 +102,13 @@ export interface Config {
|
||||||
'social-links': SocialLinksSelect<false> | SocialLinksSelect<true>;
|
'social-links': SocialLinksSelect<false> | SocialLinksSelect<true>;
|
||||||
testimonials: TestimonialsSelect<false> | TestimonialsSelect<true>;
|
testimonials: TestimonialsSelect<false> | TestimonialsSelect<true>;
|
||||||
'newsletter-subscribers': NewsletterSubscribersSelect<false> | NewsletterSubscribersSelect<true>;
|
'newsletter-subscribers': NewsletterSubscribersSelect<false> | NewsletterSubscribersSelect<true>;
|
||||||
|
'portfolio-categories': PortfolioCategoriesSelect<false> | PortfolioCategoriesSelect<true>;
|
||||||
|
portfolios: PortfoliosSelect<false> | PortfoliosSelect<true>;
|
||||||
'cookie-configurations': CookieConfigurationsSelect<false> | CookieConfigurationsSelect<true>;
|
'cookie-configurations': CookieConfigurationsSelect<false> | CookieConfigurationsSelect<true>;
|
||||||
'cookie-inventory': CookieInventorySelect<false> | CookieInventorySelect<true>;
|
'cookie-inventory': CookieInventorySelect<false> | CookieInventorySelect<true>;
|
||||||
'consent-logs': ConsentLogsSelect<false> | ConsentLogsSelect<true>;
|
'consent-logs': ConsentLogsSelect<false> | ConsentLogsSelect<true>;
|
||||||
'privacy-policy-settings': PrivacyPolicySettingsSelect<false> | PrivacyPolicySettingsSelect<true>;
|
'privacy-policy-settings': PrivacyPolicySettingsSelect<false> | PrivacyPolicySettingsSelect<true>;
|
||||||
|
'email-logs': EmailLogsSelect<false> | EmailLogsSelect<true>;
|
||||||
forms: FormsSelect<false> | FormsSelect<true>;
|
forms: FormsSelect<false> | FormsSelect<true>;
|
||||||
'form-submissions': FormSubmissionsSelect<false> | FormSubmissionsSelect<true>;
|
'form-submissions': FormSubmissionsSelect<false> | FormSubmissionsSelect<true>;
|
||||||
redirects: RedirectsSelect<false> | RedirectsSelect<true>;
|
redirects: RedirectsSelect<false> | RedirectsSelect<true>;
|
||||||
|
|
@ -158,6 +164,10 @@ export interface UserAuthOperations {
|
||||||
*/
|
*/
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
|
/**
|
||||||
|
* Super Admins haben Zugriff auf alle Tenants und können neue Tenants erstellen.
|
||||||
|
*/
|
||||||
|
isSuperAdmin?: boolean | null;
|
||||||
tenants?:
|
tenants?:
|
||||||
| {
|
| {
|
||||||
tenant: number | Tenant;
|
tenant: number | Tenant;
|
||||||
|
|
@ -196,6 +206,22 @@ export interface Tenant {
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
}[]
|
}[]
|
||||||
| null;
|
| null;
|
||||||
|
/**
|
||||||
|
* SMTP-Einstellungen für diesen Tenant. Leer = globale Einstellungen.
|
||||||
|
*/
|
||||||
|
email?: {
|
||||||
|
fromAddress?: string | null;
|
||||||
|
fromName?: string | null;
|
||||||
|
replyTo?: string | null;
|
||||||
|
useCustomSmtp?: boolean | null;
|
||||||
|
smtp?: {
|
||||||
|
host?: string | null;
|
||||||
|
port?: number | null;
|
||||||
|
secure?: boolean | null;
|
||||||
|
user?: string | null;
|
||||||
|
pass?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
@ -767,6 +793,159 @@ export interface NewsletterSubscriber {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Kategorien für Portfolio-Galerien (z.B. Hochzeit, Portrait, Landschaft)
|
||||||
|
*
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "portfolio-categories".
|
||||||
|
*/
|
||||||
|
export interface PortfolioCategory {
|
||||||
|
id: number;
|
||||||
|
tenant?: (number | null) | Tenant;
|
||||||
|
/**
|
||||||
|
* z.B. "Hochzeitsfotografie", "Portraits", "Landschaften"
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* URL-freundlicher Name (z.B. "hochzeit", "portrait")
|
||||||
|
*/
|
||||||
|
slug: string;
|
||||||
|
/**
|
||||||
|
* Kurzbeschreibung der Kategorie für SEO und Übersichten
|
||||||
|
*/
|
||||||
|
description?: string | null;
|
||||||
|
/**
|
||||||
|
* Repräsentatives Bild für die Kategorieübersicht
|
||||||
|
*/
|
||||||
|
coverImage?: (number | null) | Media;
|
||||||
|
/**
|
||||||
|
* Niedrigere Zahlen erscheinen zuerst
|
||||||
|
*/
|
||||||
|
order?: number | null;
|
||||||
|
/**
|
||||||
|
* Inaktive Kategorien werden nicht angezeigt
|
||||||
|
*/
|
||||||
|
isActive?: boolean | null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Portfolio-Galerien mit Fotografien
|
||||||
|
*
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "portfolios".
|
||||||
|
*/
|
||||||
|
export interface Portfolio {
|
||||||
|
id: number;
|
||||||
|
tenant?: (number | null) | Tenant;
|
||||||
|
/**
|
||||||
|
* Name der Galerie / des Projekts
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
/**
|
||||||
|
* URL-freundlicher Name (z.B. "hochzeit-maria-thomas")
|
||||||
|
*/
|
||||||
|
slug: string;
|
||||||
|
/**
|
||||||
|
* Ausführliche Beschreibung des Projekts/Shootings
|
||||||
|
*/
|
||||||
|
description?: {
|
||||||
|
root: {
|
||||||
|
type: string;
|
||||||
|
children: {
|
||||||
|
type: any;
|
||||||
|
version: number;
|
||||||
|
[k: string]: unknown;
|
||||||
|
}[];
|
||||||
|
direction: ('ltr' | 'rtl') | null;
|
||||||
|
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||||
|
indent: number;
|
||||||
|
version: number;
|
||||||
|
};
|
||||||
|
[k: string]: unknown;
|
||||||
|
} | null;
|
||||||
|
/**
|
||||||
|
* Kurze Beschreibung für Übersichten und SEO (max. 300 Zeichen)
|
||||||
|
*/
|
||||||
|
excerpt?: string | null;
|
||||||
|
/**
|
||||||
|
* Hauptkategorie dieser Galerie
|
||||||
|
*/
|
||||||
|
category: number | PortfolioCategory;
|
||||||
|
/**
|
||||||
|
* Zusätzliche Schlagwörter für Filterung (z.B. "outdoor", "studio", "schwarz-weiß")
|
||||||
|
*/
|
||||||
|
tags?: string[] | null;
|
||||||
|
/**
|
||||||
|
* Hauptbild für Übersichten und Vorschauen
|
||||||
|
*/
|
||||||
|
coverImage: number | Media;
|
||||||
|
/**
|
||||||
|
* Alle Bilder dieser Galerie
|
||||||
|
*/
|
||||||
|
images: {
|
||||||
|
image: number | Media;
|
||||||
|
/**
|
||||||
|
* Optionale Beschreibung für dieses Bild
|
||||||
|
*/
|
||||||
|
caption?: string | null;
|
||||||
|
/**
|
||||||
|
* Als Highlight-Bild markieren
|
||||||
|
*/
|
||||||
|
isHighlight?: boolean | null;
|
||||||
|
id?: string | null;
|
||||||
|
}[];
|
||||||
|
/**
|
||||||
|
* Zusätzliche Informationen zum Shooting
|
||||||
|
*/
|
||||||
|
projectDetails?: {
|
||||||
|
/**
|
||||||
|
* Name des Kunden (optional, für Referenzen)
|
||||||
|
*/
|
||||||
|
client?: string | null;
|
||||||
|
/**
|
||||||
|
* Wo wurde das Shooting durchgeführt?
|
||||||
|
*/
|
||||||
|
location?: string | null;
|
||||||
|
shootingDate?: string | null;
|
||||||
|
/**
|
||||||
|
* Kamera, Objektive etc. (optional)
|
||||||
|
*/
|
||||||
|
equipment?: string[] | null;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Veröffentlichungsstatus
|
||||||
|
*/
|
||||||
|
status: 'draft' | 'published' | 'archived';
|
||||||
|
/**
|
||||||
|
* Auf der Startseite anzeigen
|
||||||
|
*/
|
||||||
|
isFeatured?: boolean | null;
|
||||||
|
/**
|
||||||
|
* Wann soll die Galerie veröffentlicht werden?
|
||||||
|
*/
|
||||||
|
publishedAt?: string | null;
|
||||||
|
/**
|
||||||
|
* Für manuelle Sortierung (niedrigere Zahlen zuerst)
|
||||||
|
*/
|
||||||
|
order?: number | null;
|
||||||
|
seo?: {
|
||||||
|
/**
|
||||||
|
* Überschreibt den Standardtitel für Suchmaschinen
|
||||||
|
*/
|
||||||
|
metaTitle?: string | null;
|
||||||
|
/**
|
||||||
|
* Beschreibung für Suchmaschinen (max. 160 Zeichen)
|
||||||
|
*/
|
||||||
|
metaDescription?: string | null;
|
||||||
|
/**
|
||||||
|
* Bild für Social Media Shares (verwendet Cover-Bild wenn leer)
|
||||||
|
*/
|
||||||
|
ogImage?: (number | null) | Media;
|
||||||
|
};
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Cookie-Banner Konfiguration pro Tenant
|
* Cookie-Banner Konfiguration pro Tenant
|
||||||
*
|
*
|
||||||
|
|
@ -1012,6 +1191,40 @@ export interface PrivacyPolicySetting {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Protokoll aller gesendeten E-Mails
|
||||||
|
*
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "email-logs".
|
||||||
|
*/
|
||||||
|
export interface EmailLog {
|
||||||
|
id: number;
|
||||||
|
tenant: number | Tenant;
|
||||||
|
to: string;
|
||||||
|
from: string;
|
||||||
|
subject: string;
|
||||||
|
status: 'pending' | 'sent' | 'failed';
|
||||||
|
/**
|
||||||
|
* SMTP Message-ID bei erfolgreichem Versand
|
||||||
|
*/
|
||||||
|
messageId?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
source: 'manual' | 'form' | 'system' | 'newsletter';
|
||||||
|
/**
|
||||||
|
* Zusätzliche Kontextinformationen (z.B. Form-ID)
|
||||||
|
*/
|
||||||
|
metadata?:
|
||||||
|
| {
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
| unknown[]
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "forms".
|
* via the `definition` "forms".
|
||||||
|
|
@ -1268,6 +1481,14 @@ export interface PayloadLockedDocument {
|
||||||
relationTo: 'newsletter-subscribers';
|
relationTo: 'newsletter-subscribers';
|
||||||
value: number | NewsletterSubscriber;
|
value: number | NewsletterSubscriber;
|
||||||
} | null)
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'portfolio-categories';
|
||||||
|
value: number | PortfolioCategory;
|
||||||
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'portfolios';
|
||||||
|
value: number | Portfolio;
|
||||||
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'cookie-configurations';
|
relationTo: 'cookie-configurations';
|
||||||
value: number | CookieConfiguration;
|
value: number | CookieConfiguration;
|
||||||
|
|
@ -1284,6 +1505,10 @@ export interface PayloadLockedDocument {
|
||||||
relationTo: 'privacy-policy-settings';
|
relationTo: 'privacy-policy-settings';
|
||||||
value: number | PrivacyPolicySetting;
|
value: number | PrivacyPolicySetting;
|
||||||
} | null)
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'email-logs';
|
||||||
|
value: number | EmailLog;
|
||||||
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'forms';
|
relationTo: 'forms';
|
||||||
value: number | Form;
|
value: number | Form;
|
||||||
|
|
@ -1343,6 +1568,7 @@ export interface PayloadMigration {
|
||||||
* via the `definition` "users_select".
|
* via the `definition` "users_select".
|
||||||
*/
|
*/
|
||||||
export interface UsersSelect<T extends boolean = true> {
|
export interface UsersSelect<T extends boolean = true> {
|
||||||
|
isSuperAdmin?: T;
|
||||||
tenants?:
|
tenants?:
|
||||||
| T
|
| T
|
||||||
| {
|
| {
|
||||||
|
|
@ -1505,6 +1731,23 @@ export interface TenantsSelect<T extends boolean = true> {
|
||||||
domain?: T;
|
domain?: T;
|
||||||
id?: T;
|
id?: T;
|
||||||
};
|
};
|
||||||
|
email?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
fromAddress?: T;
|
||||||
|
fromName?: T;
|
||||||
|
replyTo?: T;
|
||||||
|
useCustomSmtp?: T;
|
||||||
|
smtp?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
host?: T;
|
||||||
|
port?: T;
|
||||||
|
secure?: T;
|
||||||
|
user?: T;
|
||||||
|
pass?: T;
|
||||||
|
};
|
||||||
|
};
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
|
@ -1870,6 +2113,64 @@ export interface NewsletterSubscribersSelect<T extends boolean = true> {
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "portfolio-categories_select".
|
||||||
|
*/
|
||||||
|
export interface PortfolioCategoriesSelect<T extends boolean = true> {
|
||||||
|
tenant?: T;
|
||||||
|
name?: T;
|
||||||
|
slug?: T;
|
||||||
|
description?: T;
|
||||||
|
coverImage?: T;
|
||||||
|
order?: T;
|
||||||
|
isActive?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "portfolios_select".
|
||||||
|
*/
|
||||||
|
export interface PortfoliosSelect<T extends boolean = true> {
|
||||||
|
tenant?: T;
|
||||||
|
title?: T;
|
||||||
|
slug?: T;
|
||||||
|
description?: T;
|
||||||
|
excerpt?: T;
|
||||||
|
category?: T;
|
||||||
|
tags?: T;
|
||||||
|
coverImage?: T;
|
||||||
|
images?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
image?: T;
|
||||||
|
caption?: T;
|
||||||
|
isHighlight?: T;
|
||||||
|
id?: T;
|
||||||
|
};
|
||||||
|
projectDetails?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
client?: T;
|
||||||
|
location?: T;
|
||||||
|
shootingDate?: T;
|
||||||
|
equipment?: T;
|
||||||
|
};
|
||||||
|
status?: T;
|
||||||
|
isFeatured?: T;
|
||||||
|
publishedAt?: T;
|
||||||
|
order?: T;
|
||||||
|
seo?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
metaTitle?: T;
|
||||||
|
metaDescription?: T;
|
||||||
|
ogImage?: T;
|
||||||
|
};
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "cookie-configurations_select".
|
* via the `definition` "cookie-configurations_select".
|
||||||
|
|
@ -2003,6 +2304,23 @@ export interface PrivacyPolicySettingsSelect<T extends boolean = true> {
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "email-logs_select".
|
||||||
|
*/
|
||||||
|
export interface EmailLogsSelect<T extends boolean = true> {
|
||||||
|
tenant?: T;
|
||||||
|
to?: T;
|
||||||
|
from?: T;
|
||||||
|
subject?: T;
|
||||||
|
status?: T;
|
||||||
|
messageId?: T;
|
||||||
|
error?: T;
|
||||||
|
source?: T;
|
||||||
|
metadata?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "forms_select".
|
* via the `definition` "forms_select".
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,15 @@ import { SiteSettings } from './globals/SiteSettings'
|
||||||
import { Navigation } from './globals/Navigation'
|
import { Navigation } from './globals/Navigation'
|
||||||
import { SEOSettings } from './globals/SEOSettings'
|
import { SEOSettings } from './globals/SEOSettings'
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
import { sendFormNotification } from './hooks/sendFormNotification'
|
||||||
|
|
||||||
|
// Email
|
||||||
|
import { multiTenantEmailAdapter } from './lib/email/payload-email-adapter'
|
||||||
|
|
||||||
|
// Email Logs
|
||||||
|
import { EmailLogs } from './collections/EmailLogs'
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url)
|
const filename = fileURLToPath(import.meta.url)
|
||||||
const dirname = path.dirname(filename)
|
const dirname = path.dirname(filename)
|
||||||
|
|
||||||
|
|
@ -47,6 +56,8 @@ export default buildConfig({
|
||||||
admin: {
|
admin: {
|
||||||
user: Users.slug,
|
user: Users.slug,
|
||||||
},
|
},
|
||||||
|
// Multi-Tenant Email Adapter
|
||||||
|
email: multiTenantEmailAdapter,
|
||||||
// Admin Panel Internationalization (UI translations)
|
// Admin Panel Internationalization (UI translations)
|
||||||
i18n: {
|
i18n: {
|
||||||
supportedLanguages: { de, en },
|
supportedLanguages: { de, en },
|
||||||
|
|
@ -105,6 +116,8 @@ export default buildConfig({
|
||||||
CookieInventory,
|
CookieInventory,
|
||||||
ConsentLogs,
|
ConsentLogs,
|
||||||
PrivacyPolicySettings,
|
PrivacyPolicySettings,
|
||||||
|
// System
|
||||||
|
EmailLogs,
|
||||||
],
|
],
|
||||||
globals: [SiteSettings, Navigation, SEOSettings],
|
globals: [SiteSettings, Navigation, SEOSettings],
|
||||||
editor: lexicalEditor(),
|
editor: lexicalEditor(),
|
||||||
|
|
@ -175,6 +188,11 @@ export default buildConfig({
|
||||||
},
|
},
|
||||||
// Fix für TypeScript Types Generation - das Plugin braucht explizite relationTo Angaben
|
// Fix für TypeScript Types Generation - das Plugin braucht explizite relationTo Angaben
|
||||||
redirectRelationships: ['pages'],
|
redirectRelationships: ['pages'],
|
||||||
|
formSubmissionOverrides: {
|
||||||
|
hooks: {
|
||||||
|
afterChange: [sendFormNotification],
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
redirectsPlugin({
|
redirectsPlugin({
|
||||||
collections: ['pages'],
|
collections: ['pages'],
|
||||||
|
|
|
||||||
139
tests/int/email.int.spec.ts
Normal file
139
tests/int/email.int.spec.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import type { Payload } from 'payload'
|
||||||
|
import type { Tenant } from '@/payload-types'
|
||||||
|
|
||||||
|
const mockSendMail = vi.fn(async () => ({ messageId: 'mocked-id' }))
|
||||||
|
const mockCreateTransport = vi.fn(() => ({ sendMail: mockSendMail }))
|
||||||
|
|
||||||
|
vi.mock('nodemailer', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
createTransport: (...args: unknown[]) => mockCreateTransport(...args),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import {
|
||||||
|
sendTenantEmail,
|
||||||
|
invalidateTenantEmailCache,
|
||||||
|
invalidateGlobalEmailCache,
|
||||||
|
} from '@/lib/email/tenant-email-service'
|
||||||
|
|
||||||
|
describe('tenant email service', () => {
|
||||||
|
let payload: Payload
|
||||||
|
let mockFindByID: ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSendMail.mockClear()
|
||||||
|
mockCreateTransport.mockClear()
|
||||||
|
mockFindByID = vi.fn()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
findByID: mockFindByID,
|
||||||
|
} as unknown as Payload
|
||||||
|
|
||||||
|
process.env.SMTP_HOST = 'smtp.global.test'
|
||||||
|
process.env.SMTP_PORT = '587'
|
||||||
|
process.env.SMTP_SECURE = 'false'
|
||||||
|
process.env.SMTP_USER = 'global-user'
|
||||||
|
process.env.SMTP_PASS = 'global-pass'
|
||||||
|
process.env.SMTP_FROM_ADDRESS = 'noreply@example.com'
|
||||||
|
|
||||||
|
invalidateGlobalEmailCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to global SMTP configuration when tenant has no custom SMTP', async () => {
|
||||||
|
const tenant = {
|
||||||
|
id: 1,
|
||||||
|
slug: 'tenant-a',
|
||||||
|
name: 'Tenant A',
|
||||||
|
email: {
|
||||||
|
useCustomSmtp: false,
|
||||||
|
},
|
||||||
|
} as Tenant
|
||||||
|
|
||||||
|
mockFindByID.mockResolvedValue(tenant)
|
||||||
|
|
||||||
|
const result = await sendTenantEmail(payload, tenant.id, {
|
||||||
|
to: 'user@example.com',
|
||||||
|
subject: 'Test',
|
||||||
|
text: 'Hello from test',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(mockCreateTransport).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockCreateTransport).toHaveBeenCalledWith({
|
||||||
|
host: 'smtp.global.test',
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: 'global-user',
|
||||||
|
pass: 'global-pass',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(mockSendMail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
from: '"Tenant A" <noreply@example.com>',
|
||||||
|
to: 'user@example.com',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses cached tenant-specific transporter and re-creates it after invalidation', async () => {
|
||||||
|
const tenant = {
|
||||||
|
id: 42,
|
||||||
|
slug: 'tenant-b',
|
||||||
|
name: 'Tenant B',
|
||||||
|
email: {
|
||||||
|
useCustomSmtp: true,
|
||||||
|
fromAddress: 'info@tenant-b.de',
|
||||||
|
fromName: 'Tenant B',
|
||||||
|
smtp: {
|
||||||
|
host: 'smtp.tenant-b.de',
|
||||||
|
port: 465,
|
||||||
|
secure: true,
|
||||||
|
user: 'tenant-user',
|
||||||
|
pass: 'tenant-pass',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as Tenant
|
||||||
|
|
||||||
|
mockFindByID.mockResolvedValue(tenant)
|
||||||
|
|
||||||
|
await sendTenantEmail(payload, tenant.id, {
|
||||||
|
to: 'recipient@example.com',
|
||||||
|
subject: 'Hi',
|
||||||
|
text: 'First email',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockCreateTransport).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockCreateTransport).toHaveBeenCalledWith({
|
||||||
|
host: 'smtp.tenant-b.de',
|
||||||
|
port: 465,
|
||||||
|
secure: true,
|
||||||
|
auth: {
|
||||||
|
user: 'tenant-user',
|
||||||
|
pass: 'tenant-pass',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
mockCreateTransport.mockClear()
|
||||||
|
|
||||||
|
await sendTenantEmail(payload, tenant.id, {
|
||||||
|
to: 'recipient@example.com',
|
||||||
|
subject: 'Hi again',
|
||||||
|
text: 'Second email',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockCreateTransport).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
invalidateTenantEmailCache(tenant.id)
|
||||||
|
|
||||||
|
await sendTenantEmail(payload, tenant.id, {
|
||||||
|
to: 'recipient@example.com',
|
||||||
|
subject: 'After invalidation',
|
||||||
|
text: 'Third email',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockCreateTransport).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue