cms.c2sgmbh/tests/int/email.int.spec.ts
Martin Porwoll 19fcb4d837 feat: implement multi-tenant email system with logging
- Add Payload email adapter for system emails (auth, password reset)
- Add EmailLogs collection for tracking all sent emails
- Extend Tenants collection with SMTP configuration fields
- Implement tenant-specific email service with transporter caching
- Add /api/send-email endpoint with:
  - Authentication required
  - Tenant access control (users can only send for their tenants)
  - Rate limiting (10 emails/minute per user)
- Add form submission notification hook with email logging
- Add cache invalidation hook for tenant email config changes

Security:
- SMTP passwords are never returned in API responses
- Passwords are preserved when field is left empty on update
- Only super admins can delete email logs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-07 20:16:54 +00:00

139 lines
3.5 KiB
TypeScript

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)
})
})