mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 18:34:13 +00:00
- 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>
139 lines
3.5 KiB
TypeScript
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)
|
|
})
|
|
})
|