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 +}