diff --git a/src/server/smtp/client.test.ts b/src/server/smtp/client.test.ts
new file mode 100644
index 0000000..aa6e434
--- /dev/null
+++ b/src/server/smtp/client.test.ts
@@ -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: '
Hallo
',
+ text: 'Hallo',
+ listUnsubscribeHeader: '',
+ })
+ 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: 'Hi
',
+ text: 'Hi',
+ listUnsubscribeHeader: '',
+ })
+ expect(mockSendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ 'List-Unsubscribe': '',
+ '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: 'Hi
',
+ text: 'Hi',
+ listUnsubscribeHeader: '',
+ })
+ 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: 'Hi
',
+ text: 'Hi',
+ listUnsubscribeHeader: '',
+ })
+ const loggedOutput = consoleSpy.mock.calls.flat().join(' ')
+ expect(loggedOutput).not.toContain('geheim@example.com')
+ })
+})
diff --git a/src/server/smtp/client.ts b/src/server/smtp/client.ts
new file mode 100644
index 0000000..7d9e317
--- /dev/null
+++ b/src/server/smtp/client.ts
@@ -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> {
+ 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)))
+ }
+}
diff --git a/src/server/suppression/check.test.ts b/src/server/suppression/check.test.ts
new file mode 100644
index 0000000..6891ec9
--- /dev/null
+++ b/src/server/suppression/check.test.ts
@@ -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'])
+ })
+})
diff --git a/src/server/suppression/check.ts b/src/server/suppression/check.ts
new file mode 100644
index 0000000..38a2536
--- /dev/null
+++ b/src/server/suppression/check.ts
@@ -0,0 +1,9 @@
+import { withTenant } from '../db/tenant'
+
+export async function checkSuppression(tenantId: string, email: string): Promise {
+ 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
+}