feat: Suppression-Check (normalisiert, parametrisiert) und SMTP-Client mit RFC-8058-Header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 10:51:26 +00:00
parent 5b24c9f129
commit a60b08876c
4 changed files with 168 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockSendMail = vi.hoisted(() => vi.fn().mockResolvedValue({ messageId: 'test-id' }))
vi.mock('nodemailer', () => ({
default: {
createTransport: vi.fn().mockReturnValue({ sendMail: mockSendMail }),
},
}))
import { sendEmail } from './client'
describe('sendEmail', () => {
beforeEach(() => vi.clearAllMocks())
it('sendet E-Mail mit korrekten Basis-Feldern', async () => {
const result = await sendEmail({
to: 'empfaenger@example.com',
subject: 'Test-Betreff',
html: '<p>Hallo</p>',
text: 'Hallo',
listUnsubscribeHeader: '<https://example.com/unsub?id=1>',
})
expect(result.ok).toBe(true)
expect(mockSendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'empfaenger@example.com',
subject: 'Test-Betreff',
})
)
})
it('setzt List-Unsubscribe-Header (RFC 8058)', async () => {
await sendEmail({
to: 'x@example.com',
subject: 'Test',
html: '<p>Hi</p>',
text: 'Hi',
listUnsubscribeHeader: '<https://example.com/unsub?id=1>',
})
expect(mockSendMail).toHaveBeenCalledWith(
expect.objectContaining({
headers: expect.objectContaining({
'List-Unsubscribe': '<https://example.com/unsub?id=1>',
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
}),
})
)
})
it('gibt err zurück bei SMTP-Fehler', async () => {
mockSendMail.mockRejectedValueOnce(new Error('SMTP Verbindungsfehler'))
const result = await sendEmail({
to: 'x@example.com',
subject: 'Test',
html: '<p>Hi</p>',
text: 'Hi',
listUnsubscribeHeader: '<https://example.com/unsub>',
})
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.message).toBe('SMTP Verbindungsfehler')
})
it('loggt keine E-Mail-Adresse im Klartext', async () => {
const consoleSpy = vi.spyOn(console, 'log')
await sendEmail({
to: 'geheim@example.com',
subject: 'Test',
html: '<p>Hi</p>',
text: 'Hi',
listUnsubscribeHeader: '<https://example.com/unsub>',
})
const loggedOutput = consoleSpy.mock.calls.flat().join(' ')
expect(loggedOutput).not.toContain('geheim@example.com')
})
})

38
src/server/smtp/client.ts Normal file
View File

@@ -0,0 +1,38 @@
import nodemailer from 'nodemailer'
import { ok, err, type Result } from '../../lib/result'
export interface SendEmailInput {
to: string
subject: string
html: string
text: string
listUnsubscribeHeader: string
}
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST ?? 'localhost',
port: Number(process.env.SMTP_PORT ?? 1025),
secure: false,
auth: process.env.SMTP_USER
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
: undefined,
})
export async function sendEmail(input: SendEmailInput): Promise<Result<void>> {
try {
await transport.sendMail({
from: process.env.SMTP_FROM ?? 'newsletter@localhost',
to: input.to,
subject: input.subject,
html: input.html,
text: input.text,
headers: {
'List-Unsubscribe': input.listUnsubscribeHeader,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
},
})
return ok(undefined)
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)))
}
}

View File

@@ -0,0 +1,45 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockClient = vi.hoisted(() => ({
execute: vi.fn(),
query: vi.fn(),
}))
vi.mock('../db/tenant', () => ({
withTenant: vi.fn((_, fn) => fn(mockClient)),
}))
import { checkSuppression } from './check'
describe('checkSuppression', () => {
beforeEach(() => vi.clearAllMocks())
it('gibt true zurück wenn E-Mail in Suppression-Liste', async () => {
mockClient.query.mockResolvedValueOnce([{ exists: true }])
const result = await checkSuppression('tenant1', 'suppressed@example.com')
expect(result).toBe(true)
})
it('gibt false zurück wenn E-Mail nicht in Liste', async () => {
mockClient.query.mockResolvedValueOnce([])
const result = await checkSuppression('tenant1', 'clean@example.com')
expect(result).toBe(false)
})
it('normalisiert E-Mail (lowercase + trim) vor dem Check', async () => {
mockClient.query.mockResolvedValueOnce([])
await checkSuppression('tenant1', ' TEST@EXAMPLE.COM ')
expect(mockClient.query).toHaveBeenCalledWith(
expect.any(String),
['test@example.com']
)
})
it('Query ist parametrisiert (kein String-Interpolation)', async () => {
mockClient.query.mockResolvedValueOnce([])
await checkSuppression('tenant1', 'x@x.com')
const call = mockClient.query.mock.calls[0]
expect(call[0]).toContain('$1')
expect(call[1]).toEqual(['x@x.com'])
})
})

View File

@@ -0,0 +1,9 @@
import { withTenant } from '../db/tenant'
export async function checkSuppression(tenantId: string, email: string): Promise<boolean> {
const normalized = email.toLowerCase().trim()
const rows = await withTenant(tenantId, (client) =>
client.query('SELECT 1 FROM suppression_list WHERE email = $1 LIMIT 1', [normalized])
)
return rows.length > 0
}