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:
76
src/server/smtp/client.test.ts
Normal file
76
src/server/smtp/client.test.ts
Normal 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
38
src/server/smtp/client.ts
Normal 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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/server/suppression/check.test.ts
Normal file
45
src/server/suppression/check.test.ts
Normal 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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
9
src/server/suppression/check.ts
Normal file
9
src/server/suppression/check.ts
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user