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",
|
||||
"next": "15.4.7",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^7.0.11",
|
||||
"payload": "3.65.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
|
|
@ -45,6 +46,7 @@
|
|||
"@testing-library/react": "16.3.0",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"@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 config from '@payload-config'
|
||||
import { getPostsByCategory, checkRateLimit } from '@/lib/search'
|
||||
import type { Category } from '@/payload-types'
|
||||
|
||||
// Validation constants
|
||||
const MAX_LIMIT = 50
|
||||
|
|
@ -100,9 +101,11 @@ export async function GET(request: NextRequest) {
|
|||
height: post.featuredImage.height,
|
||||
}
|
||||
: null,
|
||||
category: post.category && typeof post.category === 'object'
|
||||
? { name: post.category.name, slug: post.category.slug }
|
||||
: null,
|
||||
categories: Array.isArray(post.categories)
|
||||
? post.categories
|
||||
.filter((cat): cat is Category => cat !== null && typeof cat === 'object' && 'name' in cat)
|
||||
.map((cat) => ({ name: cat.name, slug: cat.slug }))
|
||||
: [],
|
||||
})),
|
||||
pagination: {
|
||||
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 { invalidateEmailCacheHook } from '../hooks/invalidateEmailCache'
|
||||
|
||||
export const Tenants: CollectionConfig = {
|
||||
slug: 'tenants',
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [invalidateEmailCacheHook],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
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('posts:*')
|
||||
break
|
||||
case 'navigation':
|
||||
case 'social-links':
|
||||
await cache.delPattern('nav:*')
|
||||
break
|
||||
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
|
||||
}
|
||||
|
||||
// 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 = [
|
||||
'',
|
||||
'default-pepper-change-me',
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ const getRedisClient = () => {
|
|||
host,
|
||||
port,
|
||||
maxRetriesPerRequest: 3,
|
||||
retryDelayOnFailover: 100,
|
||||
lazyConnect: true,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,10 +41,10 @@ export interface SearchResultItem {
|
|||
excerpt: string | null
|
||||
publishedAt: string | null
|
||||
type: string
|
||||
category: {
|
||||
categories: Array<{
|
||||
name: string
|
||||
slug: string
|
||||
} | null
|
||||
}>
|
||||
}
|
||||
|
||||
export interface SuggestionParams {
|
||||
|
|
@ -365,10 +365,11 @@ export async function searchPosts(
|
|||
excerpt: post.excerpt || null,
|
||||
publishedAt: post.publishedAt || null,
|
||||
type: (post as Post & { type?: string }).type || 'blog',
|
||||
category:
|
||||
post.category && typeof post.category === 'object'
|
||||
? { name: post.category.name, slug: post.category.slug }
|
||||
: null,
|
||||
categories: Array.isArray(post.categories)
|
||||
? post.categories
|
||||
.filter((cat): cat is Category => cat !== null && typeof cat === 'object' && 'name' in cat)
|
||||
.map((cat) => ({ name: cat.name, slug: cat.slug }))
|
||||
: [],
|
||||
})),
|
||||
total: result.totalDocs,
|
||||
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_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_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 = [
|
||||
{
|
||||
|
|
@ -16,6 +18,16 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20251206_071552_portfolio_collections.up,
|
||||
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;
|
||||
testimonials: Testimonial;
|
||||
'newsletter-subscribers': NewsletterSubscriber;
|
||||
'portfolio-categories': PortfolioCategory;
|
||||
portfolios: Portfolio;
|
||||
'cookie-configurations': CookieConfiguration;
|
||||
'cookie-inventory': CookieInventory;
|
||||
'consent-logs': ConsentLog;
|
||||
'privacy-policy-settings': PrivacyPolicySetting;
|
||||
'email-logs': EmailLog;
|
||||
forms: Form;
|
||||
'form-submissions': FormSubmission;
|
||||
redirects: Redirect;
|
||||
|
|
@ -99,10 +102,13 @@ export interface Config {
|
|||
'social-links': SocialLinksSelect<false> | SocialLinksSelect<true>;
|
||||
testimonials: TestimonialsSelect<false> | TestimonialsSelect<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-inventory': CookieInventorySelect<false> | CookieInventorySelect<true>;
|
||||
'consent-logs': ConsentLogsSelect<false> | ConsentLogsSelect<true>;
|
||||
'privacy-policy-settings': PrivacyPolicySettingsSelect<false> | PrivacyPolicySettingsSelect<true>;
|
||||
'email-logs': EmailLogsSelect<false> | EmailLogsSelect<true>;
|
||||
forms: FormsSelect<false> | FormsSelect<true>;
|
||||
'form-submissions': FormSubmissionsSelect<false> | FormSubmissionsSelect<true>;
|
||||
redirects: RedirectsSelect<false> | RedirectsSelect<true>;
|
||||
|
|
@ -158,6 +164,10 @@ export interface UserAuthOperations {
|
|||
*/
|
||||
export interface User {
|
||||
id: number;
|
||||
/**
|
||||
* Super Admins haben Zugriff auf alle Tenants und können neue Tenants erstellen.
|
||||
*/
|
||||
isSuperAdmin?: boolean | null;
|
||||
tenants?:
|
||||
| {
|
||||
tenant: number | Tenant;
|
||||
|
|
@ -196,6 +206,22 @@ export interface Tenant {
|
|||
id?: string | 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;
|
||||
createdAt: string;
|
||||
}
|
||||
|
|
@ -767,6 +793,159 @@ export interface NewsletterSubscriber {
|
|||
updatedAt: 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
|
||||
*
|
||||
|
|
@ -1012,6 +1191,40 @@ export interface PrivacyPolicySetting {
|
|||
updatedAt: 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
|
||||
* via the `definition` "forms".
|
||||
|
|
@ -1268,6 +1481,14 @@ export interface PayloadLockedDocument {
|
|||
relationTo: 'newsletter-subscribers';
|
||||
value: number | NewsletterSubscriber;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'portfolio-categories';
|
||||
value: number | PortfolioCategory;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'portfolios';
|
||||
value: number | Portfolio;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'cookie-configurations';
|
||||
value: number | CookieConfiguration;
|
||||
|
|
@ -1284,6 +1505,10 @@ export interface PayloadLockedDocument {
|
|||
relationTo: 'privacy-policy-settings';
|
||||
value: number | PrivacyPolicySetting;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'email-logs';
|
||||
value: number | EmailLog;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'forms';
|
||||
value: number | Form;
|
||||
|
|
@ -1343,6 +1568,7 @@ export interface PayloadMigration {
|
|||
* via the `definition` "users_select".
|
||||
*/
|
||||
export interface UsersSelect<T extends boolean = true> {
|
||||
isSuperAdmin?: T;
|
||||
tenants?:
|
||||
| T
|
||||
| {
|
||||
|
|
@ -1505,6 +1731,23 @@ export interface TenantsSelect<T extends boolean = true> {
|
|||
domain?: 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;
|
||||
createdAt?: T;
|
||||
}
|
||||
|
|
@ -1870,6 +2113,64 @@ export interface NewsletterSubscribersSelect<T extends boolean = true> {
|
|||
updatedAt?: 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
|
||||
* via the `definition` "cookie-configurations_select".
|
||||
|
|
@ -2003,6 +2304,23 @@ export interface PrivacyPolicySettingsSelect<T extends boolean = true> {
|
|||
updatedAt?: 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
|
||||
* via the `definition` "forms_select".
|
||||
|
|
|
|||
|
|
@ -39,6 +39,15 @@ import { SiteSettings } from './globals/SiteSettings'
|
|||
import { Navigation } from './globals/Navigation'
|
||||
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 dirname = path.dirname(filename)
|
||||
|
||||
|
|
@ -47,6 +56,8 @@ export default buildConfig({
|
|||
admin: {
|
||||
user: Users.slug,
|
||||
},
|
||||
// Multi-Tenant Email Adapter
|
||||
email: multiTenantEmailAdapter,
|
||||
// Admin Panel Internationalization (UI translations)
|
||||
i18n: {
|
||||
supportedLanguages: { de, en },
|
||||
|
|
@ -105,6 +116,8 @@ export default buildConfig({
|
|||
CookieInventory,
|
||||
ConsentLogs,
|
||||
PrivacyPolicySettings,
|
||||
// System
|
||||
EmailLogs,
|
||||
],
|
||||
globals: [SiteSettings, Navigation, SEOSettings],
|
||||
editor: lexicalEditor(),
|
||||
|
|
@ -175,6 +188,11 @@ export default buildConfig({
|
|||
},
|
||||
// Fix für TypeScript Types Generation - das Plugin braucht explizite relationTo Angaben
|
||||
redirectRelationships: ['pages'],
|
||||
formSubmissionOverrides: {
|
||||
hooks: {
|
||||
afterChange: [sendFormNotification],
|
||||
},
|
||||
},
|
||||
}),
|
||||
redirectsPlugin({
|
||||
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