diff --git a/docs/anleitungen/TODO.md b/docs/anleitungen/TODO.md
index 51378b6..e0e59f5 100644
--- a/docs/anleitungen/TODO.md
+++ b/docs/anleitungen/TODO.md
@@ -308,13 +308,14 @@
- [ ] Staging-Deployment
#### Admin UX
-- [ ] **Tenant-Wechsel UI**
- - [ ] Dropdown in Admin-Leiste für schnellen Tenant-Wechsel
- - [ ] Tenant-Kontext in Breadcrumbs anzeigen
-- [ ] **Email-Konfiguration UX**
- - [ ] Formularvalidierung für SMTP-Settings
- - [ ] Tooltips für SPF/DKIM-Hinweise
- - [ ] "Test-Email senden" Button
+- [x] **Tenant-Wechsel UI** (Erledigt: 08.12.2025)
+ - [x] Dropdown in Admin-Leiste für schnellen Tenant-Wechsel (Multi-Tenant Plugin integriert)
+ - [x] Tenant-Kontext in Breadcrumbs anzeigen (Custom TenantBreadcrumb Komponente)
+ - [x] Deutsche Übersetzungen für Tenant-Selector hinzugefügt
+- [x] **Email-Konfiguration UX** (Erledigt: 08.12.2025)
+ - [x] Formularvalidierung für SMTP-Settings (Host-Format, Port-Bereich, Pflichtfelder)
+ - [x] Tooltips für SPF/DKIM-Hinweise (aufklappbare Info-Komponente mit Beispielen)
+ - [x] "Test-Email senden" Button (Custom UI-Komponente + API-Endpoint)
- [ ] **Tenant Self-Service**
- [ ] API für Tenant-Admins zum Testen der SMTP-Settings
- [ ] Email-Logs Einsicht für eigenen Tenant
@@ -437,4 +438,4 @@
---
-*Letzte Aktualisierung: 08.12.2025 (Security Test Suite implementiert)*
+*Letzte Aktualisierung: 08.12.2025 (Email-Konfiguration UX implementiert)*
diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js
index 4c05c6a..be43dbe 100644
--- a/src/app/(payload)/admin/importMap.js
+++ b/src/app/(payload)/admin/importMap.js
@@ -1,4 +1,6 @@
import { TenantField as TenantField_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
+import { EmailDeliverabilityInfo as EmailDeliverabilityInfo_2d3125262d0174dacf5e33938b9b89de } from '@/components/admin/EmailDeliverabilityInfo'
+import { TestEmailButton as TestEmailButton_99635bc14de407531576022cd79284db } from '@/components/admin/TestEmailButton'
import { WatchTenantCollection as WatchTenantCollection_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
@@ -23,11 +25,14 @@ import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
+import { TenantBreadcrumb as TenantBreadcrumb_565056ebfdbcabea98e506f7bdcb85b3 } from '@/components/admin/TenantBreadcrumb'
import { TenantSelector as TenantSelector_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
import { TenantSelectionProvider as TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
export const importMap = {
"@payloadcms/plugin-multi-tenant/client#TenantField": TenantField_1d0591e3cf4f332c83a86da13a0de59a,
+ "@/components/admin/EmailDeliverabilityInfo#EmailDeliverabilityInfo": EmailDeliverabilityInfo_2d3125262d0174dacf5e33938b9b89de,
+ "@/components/admin/TestEmailButton#TestEmailButton": TestEmailButton_99635bc14de407531576022cd79284db,
"@payloadcms/plugin-multi-tenant/client#WatchTenantCollection": WatchTenantCollection_1d0591e3cf4f332c83a86da13a0de59a,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
@@ -52,6 +57,7 @@ export const importMap = {
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
+ "@/components/admin/TenantBreadcrumb#TenantBreadcrumb": TenantBreadcrumb_565056ebfdbcabea98e506f7bdcb85b3,
"@payloadcms/plugin-multi-tenant/rsc#TenantSelector": TenantSelector_d6d5f193a167989e2ee7d14202901e62,
"@payloadcms/plugin-multi-tenant/rsc#TenantSelectionProvider": TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62
}
diff --git a/src/app/(payload)/api/test-email/route.ts b/src/app/(payload)/api/test-email/route.ts
new file mode 100644
index 0000000..aa33703
--- /dev/null
+++ b/src/app/(payload)/api/test-email/route.ts
@@ -0,0 +1,223 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getPayload } from 'payload'
+import configPromise from '@payload-config'
+import { sendTestEmail } from '@/lib/email/tenant-email-service'
+import {
+ validateCsrf,
+ createSafeLogger,
+ emailLimiter,
+ rateLimitHeaders,
+ validateIpAccess,
+} from '@/lib/security'
+
+const RATE_LIMIT_MAX = 10
+const logger = createSafeLogger('API:TestEmail')
+
+interface UserWithTenants {
+ id: number
+ email?: string
+ isSuperAdmin?: boolean
+ tenants?: Array<{
+ tenant: { id: number } | number
+ }>
+}
+
+/**
+ * Prüft ob User Zugriff auf den angegebenen Tenant hat
+ * Normalisiert tenant-Objekte korrekt (können { id: number } oder number sein)
+ */
+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) => {
+ // Normalisiere: tenant kann ein Objekt { id: number } oder direkt eine number sein
+ const userTenantId = typeof t.tenant === 'object' && t.tenant !== null
+ ? t.tenant.id
+ : t.tenant
+ return userTenantId === tenantId
+ })
+}
+
+/**
+ * POST /api/test-email
+ *
+ * Sendet eine Test-E-Mail um die SMTP-Konfiguration eines Tenants zu verifizieren.
+ *
+ * Request Body:
+ * - tenantId: number | string - ID des Tenants
+ * - recipientEmail: string - E-Mail-Adresse des Empfängers
+ *
+ * Security:
+ * - IP-Allowlist (optional via SEND_EMAIL_ALLOWED_IPS env)
+ * - CSRF-Schutz (Double Submit Cookie)
+ * - Authentifizierung erforderlich
+ * - Tenant-Zugriffskontrolle
+ * - Rate-Limiting (10/min pro User)
+ */
+export async function POST(req: NextRequest) {
+ try {
+ // IP-Allowlist prüfen (falls konfiguriert) - gleiche Regeln wie /api/send-email
+ const ipCheck = validateIpAccess(req, 'sendEmail')
+ if (!ipCheck.allowed) {
+ logger.warn(`IP blocked: ${ipCheck.ip}`, { reason: ipCheck.reason })
+ return NextResponse.json(
+ { success: false, error: 'Access denied' },
+ { status: 403 },
+ )
+ }
+
+ // CSRF-Schutz für Browser-basierte Requests
+ const csrfResult = validateCsrf(req)
+ if (!csrfResult.valid) {
+ logger.warn('CSRF validation failed', { reason: csrfResult.reason })
+ return NextResponse.json(
+ { success: false, error: 'CSRF validation failed' },
+ { status: 403 },
+ )
+ }
+
+ const payload = await getPayload({ config: configPromise })
+
+ // User aus Session prüfen
+ const { user } = await payload.auth({ headers: req.headers })
+
+ if (!user) {
+ return NextResponse.json(
+ { success: false, error: 'Nicht authentifiziert' },
+ { status: 401 },
+ )
+ }
+
+ const typedUser = user as UserWithTenants
+
+ // Rate Limiting prüfen
+ const rateLimit = await emailLimiter.check(String(typedUser.id))
+ if (!rateLimit.allowed) {
+ logger.warn('Rate limit exceeded', { userId: typedUser.id })
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'Rate limit exceeded',
+ message: `Maximum ${RATE_LIMIT_MAX} Test-E-Mails pro Minute. Versuchen Sie es in ${rateLimit.retryAfter || 60} Sekunden erneut.`,
+ },
+ {
+ status: 429,
+ headers: rateLimitHeaders(rateLimit, RATE_LIMIT_MAX),
+ },
+ )
+ }
+
+ // Request Body parsen
+ const body = await req.json()
+ const { tenantId, recipientEmail } = body
+
+ if (!tenantId) {
+ return NextResponse.json(
+ { success: false, error: 'tenantId erforderlich' },
+ { status: 400 },
+ )
+ }
+
+ const numericTenantId = Number(tenantId)
+ if (isNaN(numericTenantId)) {
+ return NextResponse.json(
+ { success: false, error: 'tenantId muss eine Zahl sein' },
+ { status: 400 },
+ )
+ }
+
+ if (!recipientEmail) {
+ return NextResponse.json(
+ { success: false, error: 'recipientEmail erforderlich' },
+ { status: 400 },
+ )
+ }
+
+ // E-Mail-Format validieren
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+ if (!emailRegex.test(recipientEmail)) {
+ return NextResponse.json(
+ { success: false, error: 'Ungültiges E-Mail-Format' },
+ { status: 400 },
+ )
+ }
+
+ // Zugriffskontrolle: User muss Zugriff auf den Tenant haben
+ if (!userHasAccessToTenant(typedUser, numericTenantId)) {
+ logger.warn('Access denied to tenant', {
+ userId: typedUser.id,
+ tenantId: numericTenantId,
+ })
+ return NextResponse.json(
+ { success: false, error: 'Kein Zugriff auf diesen Tenant' },
+ { status: 403 },
+ )
+ }
+
+ // Tenant prüfen - existiert er?
+ const tenant = await payload.findByID({
+ collection: 'tenants',
+ id: numericTenantId,
+ depth: 0,
+ })
+
+ if (!tenant) {
+ return NextResponse.json(
+ { success: false, error: 'Tenant nicht gefunden' },
+ { status: 404 },
+ )
+ }
+
+ // Rate Limit Headers
+ const rlHeaders = rateLimitHeaders(rateLimit, RATE_LIMIT_MAX)
+
+ // Test-E-Mail senden
+ const result = await sendTestEmail(payload, numericTenantId, recipientEmail)
+
+ if (result.success) {
+ logger.info('Test email sent successfully', {
+ tenantId: numericTenantId,
+ recipient: recipientEmail,
+ messageId: result.messageId,
+ })
+ return NextResponse.json(
+ {
+ success: true,
+ message: `Test-E-Mail erfolgreich an ${recipientEmail} gesendet`,
+ messageId: result.messageId,
+ logId: result.logId,
+ },
+ { headers: rlHeaders },
+ )
+ } else {
+ logger.error('Test email failed', {
+ tenantId: numericTenantId,
+ error: result.error,
+ })
+ return NextResponse.json(
+ {
+ success: false,
+ error: result.error || 'Fehler beim Senden der Test-E-Mail',
+ },
+ { status: 500, headers: rlHeaders },
+ )
+ }
+ } catch (error) {
+ logger.error('test-email error', error)
+ return NextResponse.json(
+ {
+ success: false,
+ error: error instanceof Error ? error.message : 'Interner Server-Fehler',
+ },
+ { status: 500 },
+ )
+ }
+}
diff --git a/src/collections/Tenants.ts b/src/collections/Tenants.ts
index 5312058..98cd094 100644
--- a/src/collections/Tenants.ts
+++ b/src/collections/Tenants.ts
@@ -1,7 +1,39 @@
-import type { CollectionConfig } from 'payload'
+import type { CollectionConfig, FieldHook } from 'payload'
import { invalidateEmailCacheHook } from '../hooks/invalidateEmailCache'
import { auditTenantAfterChange, auditTenantAfterDelete } from '../hooks/auditTenantChanges'
+/**
+ * Validiert SMTP Host Format
+ */
+const validateSmtpHost: FieldHook = ({ value, siblingData }) => {
+ // Nur validieren wenn useCustomSmtp aktiv ist und ein Wert vorhanden
+ const emailData = siblingData as { useCustomSmtp?: boolean }
+ if (!emailData?.useCustomSmtp) return value
+ if (!value) return value
+
+ // Basis-Validierung: Keine Protokoll-Präfixe erlaubt
+ if (value.includes('://')) {
+ throw new Error('SMTP Host ohne Protokoll angeben (z.B. smtp.example.com)')
+ }
+
+ return value
+}
+
+/**
+ * Validiert SMTP Port
+ */
+const validateSmtpPort: FieldHook = ({ value, siblingData }) => {
+ const emailData = siblingData as { useCustomSmtp?: boolean }
+ if (!emailData?.useCustomSmtp) return value
+
+ const port = Number(value)
+ if (port && (port < 1 || port > 65535)) {
+ throw new Error('Port muss zwischen 1 und 65535 liegen')
+ }
+
+ return value
+}
+
export const Tenants: CollectionConfig = {
slug: 'tenants',
admin: {
@@ -53,6 +85,8 @@ export const Tenants: CollectionConfig = {
admin: {
placeholder: 'info@domain.de',
width: '50%',
+ description:
+ 'Tipp: Verwenden Sie eine E-Mail-Adresse der Domain, für die SPF/DKIM konfiguriert ist.',
},
},
{
@@ -74,11 +108,25 @@ export const Tenants: CollectionConfig = {
placeholder: 'kontakt@domain.de (optional)',
},
},
+ // SPF/DKIM Info-Block
+ {
+ name: 'emailDeliverabilityInfo',
+ type: 'ui',
+ admin: {
+ components: {
+ Field: '@/components/admin/EmailDeliverabilityInfo#EmailDeliverabilityInfo',
+ },
+ },
+ },
{
name: 'useCustomSmtp',
type: 'checkbox',
label: 'Eigenen SMTP-Server verwenden',
defaultValue: false,
+ admin: {
+ description:
+ 'Aktivieren Sie diese Option, um einen eigenen SMTP-Server statt der globalen Einstellungen zu verwenden.',
+ },
},
{
name: 'smtp',
@@ -86,6 +134,8 @@ export const Tenants: CollectionConfig = {
label: 'SMTP Einstellungen',
admin: {
condition: (_, siblingData) => siblingData?.useCustomSmtp,
+ description:
+ 'Hinweis: Stellen Sie sicher, dass SPF- und DKIM-Einträge für Ihre Domain konfiguriert sind, um eine optimale E-Mail-Zustellung zu gewährleisten.',
},
fields: [
{
@@ -95,9 +145,21 @@ export const Tenants: CollectionConfig = {
name: 'host',
type: 'text',
label: 'SMTP Host',
+ required: true,
admin: {
placeholder: 'smtp.example.com',
width: '50%',
+ description: 'Hostname ohne Protokoll (z.B. smtp.gmail.com)',
+ },
+ hooks: {
+ beforeValidate: [validateSmtpHost],
+ },
+ validate: (value, { siblingData }) => {
+ const emailData = siblingData as { useCustomSmtp?: boolean }
+ if (emailData?.useCustomSmtp && !value) {
+ return 'SMTP Host ist erforderlich'
+ }
+ return true
},
},
{
@@ -107,15 +169,22 @@ export const Tenants: CollectionConfig = {
defaultValue: 587,
admin: {
width: '25%',
+ description: '587 (STARTTLS) oder 465 (SSL)',
},
+ hooks: {
+ beforeValidate: [validateSmtpPort],
+ },
+ min: 1,
+ max: 65535,
},
{
name: 'secure',
type: 'checkbox',
- label: 'SSL/TLS',
+ label: 'SSL/TLS (Port 465)',
defaultValue: false,
admin: {
width: '25%',
+ description: 'Für Port 465 aktivieren',
},
},
],
@@ -127,8 +196,18 @@ export const Tenants: CollectionConfig = {
name: 'user',
type: 'text',
label: 'SMTP Benutzername',
+ required: true,
admin: {
width: '50%',
+ description: 'Meist die E-Mail-Adresse',
+ },
+ validate: (value, { siblingData }) => {
+ const smtpData = siblingData as { host?: string }
+ // Nur validieren wenn host gesetzt ist (d.h. SMTP aktiv)
+ if (smtpData?.host && !value) {
+ return 'SMTP Benutzername ist erforderlich'
+ }
+ return true
},
},
{
@@ -137,6 +216,7 @@ export const Tenants: CollectionConfig = {
label: 'SMTP Passwort',
admin: {
width: '50%',
+ description: 'Leer lassen um bestehendes Passwort zu behalten',
},
access: {
read: () => false, // Passwort nie in API-Response
@@ -155,6 +235,16 @@ export const Tenants: CollectionConfig = {
},
],
},
+ // Test-Email Button
+ {
+ name: 'testEmailButton',
+ type: 'ui',
+ admin: {
+ components: {
+ Field: '@/components/admin/TestEmailButton#TestEmailButton',
+ },
+ },
+ },
],
},
],
diff --git a/src/components/admin/EmailDeliverabilityInfo.scss b/src/components/admin/EmailDeliverabilityInfo.scss
new file mode 100644
index 0000000..6d68614
--- /dev/null
+++ b/src/components/admin/EmailDeliverabilityInfo.scss
@@ -0,0 +1,140 @@
+.email-deliverability-info {
+ margin: 1rem 0;
+ border: 1px solid var(--theme-elevation-150);
+ border-radius: var(--style-radius-s, 4px);
+ background-color: var(--theme-elevation-50);
+
+ &__toggle {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.75rem 1rem;
+ border: none;
+ background: none;
+ cursor: pointer;
+ text-align: left;
+ font-size: 0.875rem;
+ color: var(--theme-elevation-800);
+ transition: background-color 0.15s ease;
+
+ &:hover {
+ background-color: var(--theme-elevation-100);
+ }
+ }
+
+ &__icon {
+ flex-shrink: 0;
+ transition: transform 0.2s ease;
+ color: var(--theme-elevation-500);
+
+ &--expanded {
+ transform: rotate(90deg);
+ }
+ }
+
+ &__title {
+ font-weight: 500;
+ }
+
+ &__content {
+ padding: 0 1rem 1rem 1rem;
+ border-top: 1px solid var(--theme-elevation-150);
+ }
+
+ &__section {
+ margin-top: 1rem;
+
+ h4 {
+ margin: 0 0 0.5rem 0;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--theme-elevation-800);
+ }
+
+ p {
+ margin: 0 0 0.5rem 0;
+ font-size: 0.8125rem;
+ line-height: 1.5;
+ color: var(--theme-elevation-600);
+ }
+ }
+
+ &__code {
+ display: block;
+ padding: 0.5rem 0.75rem;
+ margin: 0.5rem 0;
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ background-color: var(--theme-elevation-100);
+ border-radius: var(--style-radius-s, 4px);
+ overflow-x: auto;
+ white-space: nowrap;
+ color: var(--theme-elevation-800);
+ }
+
+ &__hint {
+ font-size: 0.75rem !important;
+ font-style: italic;
+ color: var(--theme-elevation-500) !important;
+
+ em {
+ font-style: normal;
+ background-color: var(--theme-elevation-100);
+ padding: 0.125rem 0.25rem;
+ border-radius: 2px;
+ }
+ }
+
+ &__tips {
+ margin-top: 1.25rem;
+ padding-top: 1rem;
+ border-top: 1px dashed var(--theme-elevation-200);
+
+ h4 {
+ margin: 0 0 0.5rem 0;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--theme-elevation-800);
+ }
+
+ ul {
+ margin: 0;
+ padding-left: 1.25rem;
+ font-size: 0.8125rem;
+ line-height: 1.6;
+ color: var(--theme-elevation-600);
+ }
+
+ li {
+ margin-bottom: 0.25rem;
+ }
+
+ a {
+ color: var(--theme-success-500);
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ }
+}
+
+// Dark mode support
+:global(.dark) .email-deliverability-info {
+ background-color: var(--theme-elevation-100);
+ border-color: var(--theme-elevation-200);
+
+ &__content {
+ border-top-color: var(--theme-elevation-200);
+ }
+
+ &__code {
+ background-color: var(--theme-elevation-150);
+ }
+
+ &__tips {
+ border-top-color: var(--theme-elevation-250);
+ }
+}
diff --git a/src/components/admin/EmailDeliverabilityInfo.tsx b/src/components/admin/EmailDeliverabilityInfo.tsx
new file mode 100644
index 0000000..d0af9bf
--- /dev/null
+++ b/src/components/admin/EmailDeliverabilityInfo.tsx
@@ -0,0 +1,106 @@
+'use client'
+
+import React, { useState } from 'react'
+import './EmailDeliverabilityInfo.scss'
+
+/**
+ * Info-Komponente mit SPF/DKIM Hinweisen für E-Mail-Zustellbarkeit
+ */
+export const EmailDeliverabilityInfo: React.FC = () => {
+ const [isExpanded, setIsExpanded] = useState(false)
+
+ return (
+
+
setIsExpanded(!isExpanded)}
+ aria-expanded={isExpanded}
+ >
+
+
+
+
+ Hinweise zur E-Mail-Zustellbarkeit (SPF/DKIM/DMARC)
+
+
+
+ {isExpanded && (
+
+
+
SPF (Sender Policy Framework)
+
+ SPF definiert, welche Server E-Mails für Ihre Domain senden dürfen. Fügen Sie einen
+ TXT-Eintrag in Ihren DNS-Einstellungen hinzu:
+
+
+ v=spf1 include:_spf.ihremailserver.de ~all
+
+
+ Ersetzen Sie _spf.ihremailserver.de mit dem SPF-Include Ihres E-Mail-Providers.
+
+
+
+
+
DKIM (DomainKeys Identified Mail)
+
+ DKIM signiert ausgehende E-Mails kryptografisch. Die Einrichtung erfolgt über Ihren
+ E-Mail-Provider. Typischerweise erhalten Sie einen TXT-Eintrag wie:
+
+
+ selector._domainkey.ihredomain.de → v=DKIM1; k=rsa; p=MIGf...
+
+
+
+
+
DMARC (Domain-based Message Authentication)
+
+ DMARC kombiniert SPF und DKIM und definiert, wie mit fehlgeschlagenen Prüfungen
+ umgegangen werden soll:
+
+
+ _dmarc.ihredomain.de → v=DMARC1; p=quarantine; rua=mailto:dmarc@ihredomain.de
+
+
+
+
+
Tipps für bessere Zustellbarkeit
+
+ Verwenden Sie immer eine Absender-Adresse der konfigurierten Domain
+ Testen Sie die Konfiguration mit dem "Test-E-Mail senden" Button
+
+ Prüfen Sie Ihre DNS-Einträge mit Tools wie{' '}
+
+ MXToolbox
+ {' '}
+ oder{' '}
+
+ Mail-Tester
+
+
+ Bei Problemen kontaktieren Sie Ihren E-Mail-Provider für DKIM-Keys
+
+
+
+ )}
+
+ )
+}
+
+export default EmailDeliverabilityInfo
diff --git a/src/components/admin/TenantBreadcrumb.scss b/src/components/admin/TenantBreadcrumb.scss
new file mode 100644
index 0000000..c11eb8c
--- /dev/null
+++ b/src/components/admin/TenantBreadcrumb.scss
@@ -0,0 +1,37 @@
+.tenant-breadcrumb {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.375rem 0.75rem;
+ margin: 0 0.75rem;
+ border-radius: var(--style-radius-s, 4px);
+ background-color: var(--theme-elevation-50);
+ border: 1px solid var(--theme-elevation-100);
+ font-size: 0.8125rem;
+ line-height: 1;
+ white-space: nowrap;
+
+ &__label {
+ color: var(--theme-elevation-500);
+ font-weight: 400;
+ }
+
+ &__value {
+ color: var(--theme-elevation-800);
+ font-weight: 600;
+ }
+}
+
+// Dark mode support
+:global(.dark) .tenant-breadcrumb {
+ background-color: var(--theme-elevation-100);
+ border-color: var(--theme-elevation-200);
+
+ .tenant-breadcrumb__label {
+ color: var(--theme-elevation-400);
+ }
+
+ .tenant-breadcrumb__value {
+ color: var(--theme-elevation-700);
+ }
+}
diff --git a/src/components/admin/TenantBreadcrumb.tsx b/src/components/admin/TenantBreadcrumb.tsx
new file mode 100644
index 0000000..efbfe61
--- /dev/null
+++ b/src/components/admin/TenantBreadcrumb.tsx
@@ -0,0 +1,30 @@
+'use client'
+
+import React from 'react'
+import { useTenantSelection } from '@payloadcms/plugin-multi-tenant/client'
+import './TenantBreadcrumb.scss'
+
+/**
+ * Custom Breadcrumb-Komponente die den aktuellen Tenant-Kontext anzeigt.
+ * Wird in der Admin-Header-Leiste eingefügt.
+ */
+export const TenantBreadcrumb: React.FC = () => {
+ const { selectedTenantID, options } = useTenantSelection()
+
+ // Finde den aktuellen Tenant aus den Optionen
+ const currentTenant = options?.find((opt) => opt.value === selectedTenantID)
+
+ // Zeige nichts wenn kein Tenant ausgewählt oder nur ein Tenant verfügbar
+ if (!selectedTenantID || !currentTenant || options.length <= 1) {
+ return null
+ }
+
+ return (
+
+ Aktiver Tenant:
+ {currentTenant.label}
+
+ )
+}
+
+export default TenantBreadcrumb
diff --git a/src/components/admin/TestEmailButton.scss b/src/components/admin/TestEmailButton.scss
new file mode 100644
index 0000000..40c34b1
--- /dev/null
+++ b/src/components/admin/TestEmailButton.scss
@@ -0,0 +1,173 @@
+.test-email-button {
+ margin: 1.5rem 0 0.5rem 0;
+ padding: 1rem;
+ border: 1px solid var(--theme-elevation-150);
+ border-radius: var(--style-radius-s, 4px);
+ background-color: var(--theme-elevation-50);
+
+ &__header {
+ margin-bottom: 1rem;
+ }
+
+ &__title {
+ margin: 0 0 0.25rem 0;
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--theme-elevation-800);
+ }
+
+ &__description {
+ margin: 0;
+ font-size: 0.8125rem;
+ color: var(--theme-elevation-500);
+ }
+
+ &__form {
+ display: flex;
+ gap: 0.75rem;
+ align-items: flex-end;
+ }
+
+ &__input-group {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ }
+
+ &__label {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--theme-elevation-600);
+ }
+
+ &__input {
+ padding: 0.5rem 0.75rem;
+ border: 1px solid var(--theme-elevation-200);
+ border-radius: var(--style-radius-s, 4px);
+ font-size: 0.875rem;
+ background-color: var(--theme-input-background);
+ color: var(--theme-elevation-800);
+ transition: border-color 0.15s ease;
+
+ &:focus {
+ outline: none;
+ border-color: var(--theme-success-500);
+ }
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
+ &::placeholder {
+ color: var(--theme-elevation-400);
+ }
+ }
+
+ &__button {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: var(--style-radius-s, 4px);
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ white-space: nowrap;
+ background-color: var(--theme-success-500);
+ color: white;
+
+ &:hover:not(:disabled) {
+ background-color: var(--theme-success-600);
+ }
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
+ &--loading {
+ background-color: var(--theme-elevation-400);
+ }
+ }
+
+ &__icon {
+ flex-shrink: 0;
+ }
+
+ &__spinner {
+ width: 16px;
+ height: 16px;
+ border: 2px solid transparent;
+ border-top-color: currentColor;
+ border-radius: 50%;
+ animation: test-email-spin 0.8s linear infinite;
+ }
+
+ &__result {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.5rem;
+ margin-top: 1rem;
+ padding: 0.75rem;
+ border-radius: var(--style-radius-s, 4px);
+ font-size: 0.8125rem;
+
+ &--success {
+ background-color: rgba(var(--theme-success-500-rgb), 0.1);
+ border: 1px solid var(--theme-success-500);
+ color: var(--theme-success-600);
+ }
+
+ &--error {
+ background-color: rgba(var(--theme-error-500-rgb), 0.1);
+ border: 1px solid var(--theme-error-500);
+ color: var(--theme-error-600);
+ }
+ }
+
+ &__result-icon {
+ flex-shrink: 0;
+ margin-top: 0.125rem;
+ }
+
+ &__result-message {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ }
+
+ &__result-id {
+ font-size: 0.75rem;
+ opacity: 0.8;
+ font-family: var(--font-mono);
+ }
+
+ &__warning {
+ margin: 0.75rem 0 0 0;
+ padding: 0.5rem 0.75rem;
+ font-size: 0.8125rem;
+ color: var(--theme-warning-600);
+ background-color: rgba(var(--theme-warning-500-rgb), 0.1);
+ border-radius: var(--style-radius-s, 4px);
+ }
+}
+
+@keyframes test-email-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+// Dark mode support
+:global(.dark) .test-email-button {
+ background-color: var(--theme-elevation-100);
+ border-color: var(--theme-elevation-200);
+
+ &__input {
+ border-color: var(--theme-elevation-250);
+ }
+}
diff --git a/src/components/admin/TestEmailButton.tsx b/src/components/admin/TestEmailButton.tsx
new file mode 100644
index 0000000..b172520
--- /dev/null
+++ b/src/components/admin/TestEmailButton.tsx
@@ -0,0 +1,257 @@
+'use client'
+
+import React, { useState, useCallback, useEffect } from 'react'
+import { useDocumentInfo, useAuth } from '@payloadcms/ui'
+import './TestEmailButton.scss'
+
+type TestStatus = 'idle' | 'loading' | 'success' | 'error'
+
+interface TestResult {
+ success: boolean
+ message?: string
+ messageId?: string
+}
+
+// CSRF Constants (muss mit src/lib/security/csrf.ts übereinstimmen)
+const CSRF_COOKIE_NAME = 'csrf-token'
+const CSRF_HEADER_NAME = 'X-CSRF-Token'
+
+/**
+ * Liest einen Cookie-Wert
+ */
+function getCookie(name: string): string | null {
+ if (typeof document === 'undefined') return null
+ const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`))
+ return match ? decodeURIComponent(match[2]) : null
+}
+
+/**
+ * Holt ein CSRF-Token vom Server und setzt es als Cookie
+ */
+async function fetchCsrfToken(): Promise {
+ try {
+ const response = await fetch('/api/csrf-token', {
+ method: 'GET',
+ credentials: 'include',
+ })
+
+ if (response.ok) {
+ const data = await response.json()
+ return data.token || null
+ }
+ } catch (error) {
+ console.error('Failed to fetch CSRF token:', error)
+ }
+ return null
+}
+
+/**
+ * Button zum Senden einer Test-E-Mail für SMTP-Konfigurationstest
+ * Implementiert CSRF-Schutz via Double Submit Cookie
+ */
+export const TestEmailButton: React.FC = () => {
+ const { id: documentId } = useDocumentInfo()
+ const { user } = useAuth()
+ const [status, setStatus] = useState('idle')
+ const [result, setResult] = useState(null)
+ const [recipientEmail, setRecipientEmail] = useState('')
+ const [csrfToken, setCsrfToken] = useState(null)
+
+ // Standardmäßig die E-Mail des aktuellen Users verwenden
+ const defaultEmail = (user as { email?: string })?.email || ''
+
+ // CSRF-Token beim Mount laden
+ useEffect(() => {
+ const initCsrf = async () => {
+ // Erst versuchen aus Cookie zu lesen
+ let token = getCookie(CSRF_COOKIE_NAME)
+
+ // Falls kein Cookie vorhanden, Token vom Server holen
+ if (!token) {
+ token = await fetchCsrfToken()
+ }
+
+ setCsrfToken(token)
+ }
+
+ initCsrf()
+ }, [])
+
+ const handleSendTest = useCallback(async () => {
+ const emailToUse = recipientEmail || defaultEmail
+
+ if (!emailToUse) {
+ setResult({ success: false, message: 'Bitte eine Empfänger-E-Mail angeben' })
+ setStatus('error')
+ return
+ }
+
+ if (!documentId) {
+ setResult({ success: false, message: 'Tenant muss erst gespeichert werden' })
+ setStatus('error')
+ return
+ }
+
+ setStatus('loading')
+ setResult(null)
+
+ try {
+ // CSRF-Token aus State oder frisch aus Cookie lesen
+ let token = csrfToken || getCookie(CSRF_COOKIE_NAME)
+
+ // Falls immer noch kein Token, neues holen
+ if (!token) {
+ token = await fetchCsrfToken()
+ if (token) {
+ setCsrfToken(token)
+ }
+ }
+
+ if (!token) {
+ setResult({ success: false, message: 'CSRF-Token konnte nicht geladen werden. Bitte Seite neu laden.' })
+ setStatus('error')
+ return
+ }
+
+ const response = await fetch('/api/test-email', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ [CSRF_HEADER_NAME]: token,
+ },
+ credentials: 'include',
+ body: JSON.stringify({
+ tenantId: documentId,
+ recipientEmail: emailToUse,
+ }),
+ })
+
+ const data = await response.json()
+
+ if (response.ok && data.success) {
+ setResult({
+ success: true,
+ message: 'Test-E-Mail erfolgreich gesendet!',
+ messageId: data.messageId,
+ })
+ setStatus('success')
+ } else {
+ // Bei CSRF-Fehler Token neu laden
+ if (response.status === 403 && data.error?.includes('CSRF')) {
+ const newToken = await fetchCsrfToken()
+ if (newToken) {
+ setCsrfToken(newToken)
+ }
+ setResult({
+ success: false,
+ message: 'Sicherheitstoken abgelaufen. Bitte erneut versuchen.',
+ })
+ } else {
+ setResult({
+ success: false,
+ message: data.error || data.message || 'Fehler beim Senden der Test-E-Mail',
+ })
+ }
+ setStatus('error')
+ }
+ } catch (error) {
+ setResult({
+ success: false,
+ message: error instanceof Error ? error.message : 'Netzwerkfehler',
+ })
+ setStatus('error')
+ }
+ }, [documentId, recipientEmail, defaultEmail, csrfToken])
+
+ return (
+
+
+
SMTP-Konfiguration testen
+
+ Senden Sie eine Test-E-Mail um zu prüfen, ob die SMTP-Einstellungen korrekt sind.
+
+
+
+
+
+
+ Empfänger-E-Mail
+
+ setRecipientEmail(e.target.value)}
+ disabled={status === 'loading'}
+ />
+
+
+
+ {status === 'loading' ? (
+ <>
+
+ Sende...
+ >
+ ) : (
+ <>
+
+
+
+
+ Test-E-Mail senden
+ >
+ )}
+
+
+
+ {result && (
+
+
+ {result.success ? (
+
+
+
+ ) : (
+
+
+
+
+
+ )}
+
+
+ {result.message}
+ {result.messageId && (
+ ID: {result.messageId}
+ )}
+
+
+ )}
+
+ {!documentId && (
+
+ Speichern Sie den Tenant zuerst, um eine Test-E-Mail senden zu können.
+
+ )}
+
+ )
+}
+
+export default TestEmailButton
diff --git a/src/payload.config.ts b/src/payload.config.ts
index e00ed30..e71bb9d 100644
--- a/src/payload.config.ts
+++ b/src/payload.config.ts
@@ -58,6 +58,10 @@ export default buildConfig({
serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL || 'https://pl.c2sgmbh.de',
admin: {
user: Users.slug,
+ components: {
+ // Tenant-Kontext in der Admin-Header-Leiste anzeigen
+ afterNavLinks: ['@/components/admin/TenantBreadcrumb#TenantBreadcrumb'],
+ },
},
// Multi-Tenant Email Adapter
email: multiTenantEmailAdapter,
@@ -165,6 +169,23 @@ export default buildConfig({
// Super Admins haben Zugriff auf alle Tenants
userHasAccessToAllTenants: (user) => Boolean(user?.isSuperAdmin),
debug: true,
+ // Deutsche Übersetzungen für den Tenant-Selector
+ i18n: {
+ translations: {
+ de: {
+ 'nav-tenantSelector-label': 'Nach Tenant filtern',
+ 'assign-tenant-button-label': 'Tenant zuweisen',
+ 'assign-tenant-modal-title': '"{{title}}" zuweisen',
+ 'field-assignedTenant-label': 'Zugewiesener Tenant',
+ },
+ en: {
+ 'nav-tenantSelector-label': 'Filter by Tenant',
+ 'assign-tenant-button-label': 'Assign Tenant',
+ 'assign-tenant-modal-title': 'Assign "{{title}}"',
+ 'field-assignedTenant-label': 'Assigned Tenant',
+ },
+ },
+ },
}),
seoPlugin({
collections: [],
diff --git a/tests/int/security-api.int.spec.ts b/tests/int/security-api.int.spec.ts
index a4c5927..8d118d1 100644
--- a/tests/int/security-api.int.spec.ts
+++ b/tests/int/security-api.int.spec.ts
@@ -515,4 +515,676 @@ describe('Security API Integration', () => {
expect(response.status).toBe(403)
})
})
+
+ // ============================================================================
+ // Test Email Endpoint Security Tests
+ // ============================================================================
+
+ describe('Test Email Endpoint (/api/test-email)', () => {
+ describe('CSRF Protection', () => {
+ it('requires CSRF token for browser requests', async () => {
+ vi.resetModules()
+ const { POST } = await import('@/app/(payload)/api/test-email/route')
+
+ const req = createMockRequest({
+ method: 'POST',
+ url: 'https://test.example.com/api/test-email',
+ headers: {
+ 'content-type': 'application/json',
+ origin: 'https://test.example.com',
+ },
+ ip: randomIp(),
+ })
+
+ Object.defineProperty(req, 'json', {
+ value: vi.fn().mockResolvedValue({
+ tenantId: 1,
+ recipientEmail: 'test@example.com',
+ }),
+ })
+
+ const response = await POST(req)
+
+ expect(response.status).toBe(403)
+ const body = await response.json()
+ expect(body.error).toContain('CSRF')
+ })
+
+ it('accepts valid CSRF token', async () => {
+ vi.resetModules()
+
+ // Mock authenticated user
+ vi.doMock('payload', () => ({
+ getPayload: vi.fn().mockResolvedValue({
+ auth: vi.fn().mockResolvedValue({
+ user: {
+ id: 1,
+ email: 'admin@test.com',
+ isSuperAdmin: true,
+ tenants: [],
+ },
+ }),
+ findByID: vi.fn().mockResolvedValue({ id: 1, name: 'Test Tenant' }),
+ }),
+ }))
+
+ const { POST } = await import('@/app/(payload)/api/test-email/route')
+
+ const token = generateTestCsrfToken('test-csrf-secret-for-testing')
+ const req = createMockRequest({
+ method: 'POST',
+ url: 'https://test.example.com/api/test-email',
+ headers: {
+ 'content-type': 'application/json',
+ origin: 'https://test.example.com',
+ 'X-CSRF-Token': token,
+ },
+ cookies: {
+ 'csrf-token': token,
+ },
+ ip: randomIp(),
+ })
+
+ Object.defineProperty(req, 'json', {
+ value: vi.fn().mockResolvedValue({
+ tenantId: 1,
+ recipientEmail: 'test@example.com',
+ }),
+ })
+
+ const response = await POST(req)
+
+ // Should pass CSRF check (may fail on email send, but not 403)
+ expect(response.status).not.toBe(403)
+ })
+
+ it('rejects expired CSRF tokens', async () => {
+ vi.resetModules()
+ const { POST } = await import('@/app/(payload)/api/test-email/route')
+
+ const expiredToken = generateExpiredCsrfToken('test-csrf-secret-for-testing')
+ const req = createMockRequest({
+ method: 'POST',
+ url: 'https://test.example.com/api/test-email',
+ headers: {
+ 'content-type': 'application/json',
+ origin: 'https://test.example.com',
+ 'X-CSRF-Token': expiredToken,
+ },
+ cookies: {
+ 'csrf-token': expiredToken,
+ },
+ ip: randomIp(),
+ })
+
+ Object.defineProperty(req, 'json', {
+ value: vi.fn().mockResolvedValue({
+ tenantId: 1,
+ recipientEmail: 'test@example.com',
+ }),
+ })
+
+ const response = await POST(req)
+
+ expect(response.status).toBe(403)
+ })
+ })
+
+ describe('IP Blocking', () => {
+ it('blocks requests from blocked IPs', async () => {
+ vi.stubEnv('BLOCKED_IPS', '192.168.200.100')
+ vi.resetModules()
+ const { POST } = await import('@/app/(payload)/api/test-email/route')
+
+ const token = generateTestCsrfToken('test-csrf-secret-for-testing')
+ const req = createMockRequest({
+ method: 'POST',
+ url: 'https://test.example.com/api/test-email',
+ headers: {
+ 'content-type': 'application/json',
+ origin: 'https://test.example.com',
+ 'X-CSRF-Token': token,
+ },
+ cookies: {
+ 'csrf-token': token,
+ },
+ ip: '192.168.200.100',
+ })
+
+ Object.defineProperty(req, 'json', {
+ value: vi.fn().mockResolvedValue({
+ tenantId: 1,
+ recipientEmail: 'test@example.com',
+ }),
+ })
+
+ const response = await POST(req)
+
+ expect(response.status).toBe(403)
+ const body = await response.json()
+ expect(body.error).toContain('Access denied')
+ })
+
+ it('respects SEND_EMAIL_ALLOWED_IPS allowlist', async () => {
+ vi.stubEnv('SEND_EMAIL_ALLOWED_IPS', '10.0.0.1')
+ vi.resetModules()
+ const { POST } = await import('@/app/(payload)/api/test-email/route')
+
+ const token = generateTestCsrfToken('test-csrf-secret-for-testing')
+ // Request from IP not in allowlist
+ const req = createMockRequest({
+ method: 'POST',
+ url: 'https://test.example.com/api/test-email',
+ headers: {
+ 'content-type': 'application/json',
+ origin: 'https://test.example.com',
+ 'X-CSRF-Token': token,
+ },
+ cookies: {
+ 'csrf-token': token,
+ },
+ ip: '192.168.1.100', // Not in allowlist
+ })
+
+ Object.defineProperty(req, 'json', {
+ value: vi.fn().mockResolvedValue({
+ tenantId: 1,
+ recipientEmail: 'test@example.com',
+ }),
+ })
+
+ const response = await POST(req)
+
+ expect(response.status).toBe(403)
+ const body = await response.json()
+ expect(body.error).toContain('Access denied')
+ })
+ })
+
+ describe('Authentication', () => {
+ it('requires authentication', async () => {
+ vi.resetModules()
+
+ // Mock unauthenticated user
+ vi.doMock('payload', () => ({
+ getPayload: vi.fn().mockResolvedValue({
+ auth: vi.fn().mockResolvedValue({ user: null }),
+ }),
+ }))
+
+ const { POST } = await import('@/app/(payload)/api/test-email/route')
+
+ const token = generateTestCsrfToken('test-csrf-secret-for-testing')
+ const req = createMockRequest({
+ method: 'POST',
+ url: 'https://test.example.com/api/test-email',
+ headers: {
+ 'content-type': 'application/json',
+ origin: 'https://test.example.com',
+ 'X-CSRF-Token': token,
+ },
+ cookies: {
+ 'csrf-token': token,
+ },
+ ip: randomIp(),
+ })
+
+ Object.defineProperty(req, 'json', {
+ value: vi.fn().mockResolvedValue({
+ tenantId: 1,
+ recipientEmail: 'test@example.com',
+ }),
+ })
+
+ const response = await POST(req)
+
+ expect(response.status).toBe(401)
+ const body = await response.json()
+ expect(body.error).toContain('authentifiziert')
+ })
+ })
+
+ describe('Tenant Access Control', () => {
+ it('allows super admin access to any tenant', async () => {
+ vi.resetModules()
+
+ vi.doMock('payload', () => ({
+ getPayload: vi.fn().mockResolvedValue({
+ auth: vi.fn().mockResolvedValue({
+ user: {
+ id: 1,
+ email: 'superadmin@test.com',
+ isSuperAdmin: true,
+ tenants: [],
+ },
+ }),
+ findByID: vi.fn().mockResolvedValue({ id: 99, name: 'Any Tenant' }),
+ }),
+ }))
+
+ // Mock sendTestEmail
+ vi.doMock('@/lib/email/tenant-email-service', () => ({
+ sendTestEmail: vi.fn().mockResolvedValue({
+ success: true,
+ messageId: 'test-123',
+ logId: 1,
+ }),
+ }))
+
+ const { POST } = await import('@/app/(payload)/api/test-email/route')
+
+ const token = generateTestCsrfToken('test-csrf-secret-for-testing')
+ const req = createMockRequest({
+ method: 'POST',
+ url: 'https://test.example.com/api/test-email',
+ headers: {
+ 'content-type': 'application/json',
+ origin: 'https://test.example.com',
+ 'X-CSRF-Token': token,
+ },
+ cookies: {
+ 'csrf-token': token,
+ },
+ ip: randomIp(),
+ })
+
+ Object.defineProperty(req, 'json', {
+ value: vi.fn().mockResolvedValue({
+ tenantId: 99,
+ recipientEmail: 'test@example.com',
+ }),
+ })
+
+ const response = await POST(req)
+
+ // Super admin should have access
+ expect(response.status).not.toBe(403)
+ })
+
+ it('denies access to tenants user is not assigned to', async () => {
+ vi.resetModules()
+
+ vi.doMock('payload', () => ({
+ getPayload: vi.fn().mockResolvedValue({
+ auth: vi.fn().mockResolvedValue({
+ user: {
+ id: 2,
+ email: 'user@test.com',
+ isSuperAdmin: false,
+ tenants: [{ tenant: { id: 1 } }], // Only has access to tenant 1
+ },
+ }),
+ findByID: vi.fn().mockResolvedValue({ id: 99, name: 'Other Tenant' }),
+ }),
+ }))
+
+ const { POST } = await import('@/app/(payload)/api/test-email/route')
+
+ const token = generateTestCsrfToken('test-csrf-secret-for-testing')
+ const req = createMockRequest({
+ method: 'POST',
+ url: 'https://test.example.com/api/test-email',
+ headers: {
+ 'content-type': 'application/json',
+ origin: 'https://test.example.com',
+ 'X-CSRF-Token': token,
+ },
+ cookies: {
+ 'csrf-token': token,
+ },
+ ip: randomIp(),
+ })
+
+ Object.defineProperty(req, 'json', {
+ value: vi.fn().mockResolvedValue({
+ tenantId: 99, // Different tenant
+ recipientEmail: 'test@example.com',
+ }),
+ })
+
+ const response = await POST(req)
+
+ expect(response.status).toBe(403)
+ const body = await response.json()
+ expect(body.error).toContain('Zugriff')
+ })
+
+ it('normalizes tenant object format { tenant: { id } }', async () => {
+ vi.resetModules()
+
+ vi.doMock('payload', () => ({
+ getPayload: vi.fn().mockResolvedValue({
+ auth: vi.fn().mockResolvedValue({
+ user: {
+ id: 2,
+ email: 'user@test.com',
+ isSuperAdmin: false,
+ // Multi-tenant plugin format: { tenant: { id: number } }
+ tenants: [{ tenant: { id: 5 } }],
+ },
+ }),
+ findByID: vi.fn().mockResolvedValue({ id: 5, name: 'User Tenant' }),
+ }),
+ }))
+
+ vi.doMock('@/lib/email/tenant-email-service', () => ({
+ sendTestEmail: vi.fn().mockResolvedValue({
+ success: true,
+ messageId: 'test-456',
+ logId: 2,
+ }),
+ }))
+
+ const { POST } = await import('@/app/(payload)/api/test-email/route')
+
+ const token = generateTestCsrfToken('test-csrf-secret-for-testing')
+ const req = createMockRequest({
+ method: 'POST',
+ url: 'https://test.example.com/api/test-email',
+ headers: {
+ 'content-type': 'application/json',
+ origin: 'https://test.example.com',
+ 'X-CSRF-Token': token,
+ },
+ cookies: {
+ 'csrf-token': token,
+ },
+ ip: randomIp(),
+ })
+
+ Object.defineProperty(req, 'json', {
+ value: vi.fn().mockResolvedValue({
+ tenantId: 5,
+ recipientEmail: 'test@example.com',
+ }),
+ })
+
+ const response = await POST(req)
+
+ // Should have access (tenant ID 5 matches)
+ expect(response.status).not.toBe(403)
+ })
+
+ it('normalizes tenant primitive format { tenant: number }', async () => {
+ vi.resetModules()
+
+ vi.doMock('payload', () => ({
+ getPayload: vi.fn().mockResolvedValue({
+ auth: vi.fn().mockResolvedValue({
+ user: {
+ id: 2,
+ email: 'user@test.com',
+ isSuperAdmin: false,
+ // Alternative format: { tenant: number }
+ tenants: [{ tenant: 7 }],
+ },
+ }),
+ findByID: vi.fn().mockResolvedValue({ id: 7, name: 'User Tenant' }),
+ }),
+ }))
+
+ vi.doMock('@/lib/email/tenant-email-service', () => ({
+ sendTestEmail: vi.fn().mockResolvedValue({
+ success: true,
+ messageId: 'test-789',
+ logId: 3,
+ }),
+ }))
+
+ const { POST } = await import('@/app/(payload)/api/test-email/route')
+
+ const token = generateTestCsrfToken('test-csrf-secret-for-testing')
+ const req = createMockRequest({
+ method: 'POST',
+ url: 'https://test.example.com/api/test-email',
+ headers: {
+ 'content-type': 'application/json',
+ origin: 'https://test.example.com',
+ 'X-CSRF-Token': token,
+ },
+ cookies: {
+ 'csrf-token': token,
+ },
+ ip: randomIp(),
+ })
+
+ Object.defineProperty(req, 'json', {
+ value: vi.fn().mockResolvedValue({
+ tenantId: 7,
+ recipientEmail: 'test@example.com',
+ }),
+ })
+
+ const response = await POST(req)
+
+ // Should have access (tenant ID 7 matches)
+ expect(response.status).not.toBe(403)
+ })
+ })
+
+ describe('Input Validation', () => {
+ it('requires tenantId', async () => {
+ vi.resetModules()
+
+ vi.doMock('payload', () => ({
+ getPayload: vi.fn().mockResolvedValue({
+ auth: vi.fn().mockResolvedValue({
+ user: { id: 1, isSuperAdmin: true },
+ }),
+ }),
+ }))
+
+ const { POST } = await import('@/app/(payload)/api/test-email/route')
+
+ const token = generateTestCsrfToken('test-csrf-secret-for-testing')
+ const req = createMockRequest({
+ method: 'POST',
+ url: 'https://test.example.com/api/test-email',
+ headers: {
+ 'content-type': 'application/json',
+ origin: 'https://test.example.com',
+ 'X-CSRF-Token': token,
+ },
+ cookies: {
+ 'csrf-token': token,
+ },
+ ip: randomIp(),
+ })
+
+ Object.defineProperty(req, 'json', {
+ value: vi.fn().mockResolvedValue({
+ recipientEmail: 'test@example.com',
+ // Missing tenantId
+ }),
+ })
+
+ const response = await POST(req)
+
+ expect(response.status).toBe(400)
+ const body = await response.json()
+ expect(body.error).toContain('tenantId')
+ })
+
+ it('requires recipientEmail', async () => {
+ vi.resetModules()
+
+ vi.doMock('payload', () => ({
+ getPayload: vi.fn().mockResolvedValue({
+ auth: vi.fn().mockResolvedValue({
+ user: { id: 1, isSuperAdmin: true },
+ }),
+ }),
+ }))
+
+ const { POST } = await import('@/app/(payload)/api/test-email/route')
+
+ const token = generateTestCsrfToken('test-csrf-secret-for-testing')
+ const req = createMockRequest({
+ method: 'POST',
+ url: 'https://test.example.com/api/test-email',
+ headers: {
+ 'content-type': 'application/json',
+ origin: 'https://test.example.com',
+ 'X-CSRF-Token': token,
+ },
+ cookies: {
+ 'csrf-token': token,
+ },
+ ip: randomIp(),
+ })
+
+ Object.defineProperty(req, 'json', {
+ value: vi.fn().mockResolvedValue({
+ tenantId: 1,
+ // Missing recipientEmail
+ }),
+ })
+
+ const response = await POST(req)
+
+ expect(response.status).toBe(400)
+ const body = await response.json()
+ expect(body.error).toContain('recipientEmail')
+ })
+
+ it('validates email format', async () => {
+ vi.resetModules()
+
+ vi.doMock('payload', () => ({
+ getPayload: vi.fn().mockResolvedValue({
+ auth: vi.fn().mockResolvedValue({
+ user: { id: 1, isSuperAdmin: true },
+ }),
+ }),
+ }))
+
+ const { POST } = await import('@/app/(payload)/api/test-email/route')
+
+ const token = generateTestCsrfToken('test-csrf-secret-for-testing')
+ const req = createMockRequest({
+ method: 'POST',
+ url: 'https://test.example.com/api/test-email',
+ headers: {
+ 'content-type': 'application/json',
+ origin: 'https://test.example.com',
+ 'X-CSRF-Token': token,
+ },
+ cookies: {
+ 'csrf-token': token,
+ },
+ ip: randomIp(),
+ })
+
+ Object.defineProperty(req, 'json', {
+ value: vi.fn().mockResolvedValue({
+ tenantId: 1,
+ recipientEmail: 'invalid-email', // Invalid format
+ }),
+ })
+
+ const response = await POST(req)
+
+ expect(response.status).toBe(400)
+ const body = await response.json()
+ expect(body.error).toContain('E-Mail')
+ })
+
+ it('validates tenantId is numeric', async () => {
+ vi.resetModules()
+
+ vi.doMock('payload', () => ({
+ getPayload: vi.fn().mockResolvedValue({
+ auth: vi.fn().mockResolvedValue({
+ user: { id: 1, isSuperAdmin: true },
+ }),
+ }),
+ }))
+
+ const { POST } = await import('@/app/(payload)/api/test-email/route')
+
+ const token = generateTestCsrfToken('test-csrf-secret-for-testing')
+ const req = createMockRequest({
+ method: 'POST',
+ url: 'https://test.example.com/api/test-email',
+ headers: {
+ 'content-type': 'application/json',
+ origin: 'https://test.example.com',
+ 'X-CSRF-Token': token,
+ },
+ cookies: {
+ 'csrf-token': token,
+ },
+ ip: randomIp(),
+ })
+
+ Object.defineProperty(req, 'json', {
+ value: vi.fn().mockResolvedValue({
+ tenantId: 'not-a-number',
+ recipientEmail: 'test@example.com',
+ }),
+ })
+
+ const response = await POST(req)
+
+ expect(response.status).toBe(400)
+ const body = await response.json()
+ expect(body.error).toContain('Zahl')
+ })
+ })
+
+ describe('Rate Limiting', () => {
+ it('includes rate limit headers', async () => {
+ vi.resetModules()
+
+ vi.doMock('payload', () => ({
+ getPayload: vi.fn().mockResolvedValue({
+ auth: vi.fn().mockResolvedValue({
+ user: { id: 1, isSuperAdmin: true },
+ }),
+ findByID: vi.fn().mockResolvedValue({ id: 1, name: 'Test Tenant' }),
+ }),
+ }))
+
+ vi.doMock('@/lib/email/tenant-email-service', () => ({
+ sendTestEmail: vi.fn().mockResolvedValue({
+ success: true,
+ messageId: 'test-rate',
+ logId: 1,
+ }),
+ }))
+
+ const { POST } = await import('@/app/(payload)/api/test-email/route')
+
+ const token = generateTestCsrfToken('test-csrf-secret-for-testing')
+ const req = createMockRequest({
+ method: 'POST',
+ url: 'https://test.example.com/api/test-email',
+ headers: {
+ 'content-type': 'application/json',
+ origin: 'https://test.example.com',
+ 'X-CSRF-Token': token,
+ },
+ cookies: {
+ 'csrf-token': token,
+ },
+ ip: randomIp(),
+ })
+
+ Object.defineProperty(req, 'json', {
+ value: vi.fn().mockResolvedValue({
+ tenantId: 1,
+ recipientEmail: 'test@example.com',
+ }),
+ })
+
+ const response = await POST(req)
+
+ // Should include rate limit headers on success
+ if (response.status === 200) {
+ expect(response.headers.has('X-RateLimit-Limit')).toBe(true)
+ expect(response.headers.has('X-RateLimit-Remaining')).toBe(true)
+ }
+ })
+ })
+ })
})