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