feat: BullMQ Worker — Suppression-Check vor SMTP, ClickHouse-Event-Insert, RFC-8058-Header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
120
src/queues/email-send.worker.test.ts
Normal file
120
src/queues/email-send.worker.test.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../server/db/campaigns', () => ({
|
||||||
|
getCampaign: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.mock('../server/suppression/check', () => ({
|
||||||
|
checkSuppression: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.mock('../server/smtp/client', () => ({
|
||||||
|
sendEmail: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.mock('../server/clickhouse/client', () => ({
|
||||||
|
clickhouse: { insert: vi.fn().mockResolvedValue(undefined) },
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { processEmailSendJob } from './email-send.worker'
|
||||||
|
import { getCampaign } from '../server/db/campaigns'
|
||||||
|
import { checkSuppression } from '../server/suppression/check'
|
||||||
|
import { sendEmail } from '../server/smtp/client'
|
||||||
|
import { clickhouse } from '../server/clickhouse/client'
|
||||||
|
|
||||||
|
const mockCampaign = {
|
||||||
|
id: 'campaign-1',
|
||||||
|
subject: 'Newsletter April',
|
||||||
|
htmlBody: '<p>Hallo</p>',
|
||||||
|
plainBody: 'Hallo',
|
||||||
|
name: 'April Newsletter',
|
||||||
|
status: 'sending' as const,
|
||||||
|
scheduledAt: null,
|
||||||
|
cronExpression: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobData = {
|
||||||
|
tenantId: 'tenant1',
|
||||||
|
campaignId: 'campaign-1',
|
||||||
|
recipientEmail: 'empfaenger@example.com',
|
||||||
|
recipientHash: 'abc123hash',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('processEmailSendJob', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('sendet E-Mail wenn nicht suppressed', async () => {
|
||||||
|
vi.mocked(getCampaign).mockResolvedValue({ ok: true, data: mockCampaign })
|
||||||
|
vi.mocked(checkSuppression).mockResolvedValue(false)
|
||||||
|
vi.mocked(sendEmail).mockResolvedValue({ ok: true, data: undefined })
|
||||||
|
|
||||||
|
const result = await processEmailSendJob(jobData)
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true)
|
||||||
|
expect(sendEmail).toHaveBeenCalledOnce()
|
||||||
|
expect(clickhouse.insert).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('überspringt SMTP wenn Empfänger suppressed ist', async () => {
|
||||||
|
vi.mocked(getCampaign).mockResolvedValue({ ok: true, data: mockCampaign })
|
||||||
|
vi.mocked(checkSuppression).mockResolvedValue(true)
|
||||||
|
|
||||||
|
const result = await processEmailSendJob(jobData)
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true)
|
||||||
|
expect(sendEmail).not.toHaveBeenCalled()
|
||||||
|
expect(clickhouse.insert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
values: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ event_type: 'suppressed' }),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gibt err zurück wenn Kampagne nicht gefunden', async () => {
|
||||||
|
vi.mocked(getCampaign).mockResolvedValue({ ok: false, error: new Error('nicht gefunden') })
|
||||||
|
|
||||||
|
const result = await processEmailSendJob(jobData)
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false)
|
||||||
|
if (!result.ok) expect(result.error.message).toContain('nicht gefunden')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gibt err zurück wenn SMTP fehlschlägt', async () => {
|
||||||
|
vi.mocked(getCampaign).mockResolvedValue({ ok: true, data: mockCampaign })
|
||||||
|
vi.mocked(checkSuppression).mockResolvedValue(false)
|
||||||
|
vi.mocked(sendEmail).mockResolvedValue({ ok: false, error: new Error('SMTP-Fehler') })
|
||||||
|
|
||||||
|
const result = await processEmailSendJob(jobData)
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false)
|
||||||
|
if (!result.ok) expect(result.error.message).toBe('SMTP-Fehler')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ClickHouse-Event enthält recipient_hash (kein Klartext)', async () => {
|
||||||
|
vi.mocked(getCampaign).mockResolvedValue({ ok: true, data: mockCampaign })
|
||||||
|
vi.mocked(checkSuppression).mockResolvedValue(false)
|
||||||
|
vi.mocked(sendEmail).mockResolvedValue({ ok: true, data: undefined })
|
||||||
|
|
||||||
|
await processEmailSendJob(jobData)
|
||||||
|
|
||||||
|
const insertCall = vi.mocked(clickhouse.insert).mock.calls[0][0]
|
||||||
|
const eventValue = (insertCall.values as Array<Record<string, unknown>>)[0]
|
||||||
|
expect(eventValue.recipient_hash).toBe('abc123hash')
|
||||||
|
expect(JSON.stringify(eventValue)).not.toContain('empfaenger@example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sendEmail erhält List-Unsubscribe-Header', async () => {
|
||||||
|
vi.mocked(getCampaign).mockResolvedValue({ ok: true, data: mockCampaign })
|
||||||
|
vi.mocked(checkSuppression).mockResolvedValue(false)
|
||||||
|
vi.mocked(sendEmail).mockResolvedValue({ ok: true, data: undefined })
|
||||||
|
|
||||||
|
await processEmailSendJob(jobData)
|
||||||
|
|
||||||
|
expect(sendEmail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
listUnsubscribeHeader: expect.stringContaining('unsub'),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
71
src/queues/email-send.worker.ts
Normal file
71
src/queues/email-send.worker.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Worker } from 'bullmq'
|
||||||
|
import { getCampaign } from '../server/db/campaigns'
|
||||||
|
import { checkSuppression } from '../server/suppression/check'
|
||||||
|
import { sendEmail } from '../server/smtp/client'
|
||||||
|
import { clickhouse } from '../server/clickhouse/client'
|
||||||
|
import { ok, err, type Result } from '../lib/result'
|
||||||
|
import type { EmailSendJobData } from './email-send.queue'
|
||||||
|
|
||||||
|
export async function processEmailSendJob(data: EmailSendJobData): Promise<Result<void>> {
|
||||||
|
const campaignResult = await getCampaign(data.tenantId, data.campaignId)
|
||||||
|
if (!campaignResult.ok) return err(campaignResult.error)
|
||||||
|
const campaign = campaignResult.data
|
||||||
|
|
||||||
|
// Suppression-Check ist PFLICHT — kein Opt-out-Empfänger darf E-Mail erhalten
|
||||||
|
const suppressed = await checkSuppression(data.tenantId, data.recipientEmail)
|
||||||
|
if (suppressed) {
|
||||||
|
await insertEvent('suppressed', data)
|
||||||
|
return ok(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const appUrl = process.env.APP_URL ?? 'http://localhost:3000'
|
||||||
|
const unsubUrl = `${appUrl}/unsub?tid=${data.tenantId}&cid=${data.campaignId}&r=${data.recipientHash}`
|
||||||
|
|
||||||
|
const sendResult = await sendEmail({
|
||||||
|
to: data.recipientEmail,
|
||||||
|
subject: campaign.subject,
|
||||||
|
html: campaign.htmlBody,
|
||||||
|
text: campaign.plainBody,
|
||||||
|
listUnsubscribeHeader: `<${unsubUrl}>`,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!sendResult.ok) return err(sendResult.error)
|
||||||
|
|
||||||
|
await insertEvent('sent', data)
|
||||||
|
return ok(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertEvent(eventType: string, data: EmailSendJobData): Promise<void> {
|
||||||
|
await clickhouse.insert({
|
||||||
|
table: 'email_events',
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
event_type: eventType,
|
||||||
|
tenant_id: data.tenantId,
|
||||||
|
campaign_id: data.campaignId,
|
||||||
|
// Datenschutz: nur Hash wird gespeichert — keine Klartext-E-Mail-Adresse in ClickHouse
|
||||||
|
recipient_hash: data.recipientHash,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
format: 'JSONEachRow',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = {
|
||||||
|
host: process.env.REDIS_HOST ?? 'localhost',
|
||||||
|
port: Number(process.env.REDIS_PORT ?? 6379),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worker nur außerhalb von Tests starten
|
||||||
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
new Worker<EmailSendJobData>(
|
||||||
|
'email-send',
|
||||||
|
async (job) => {
|
||||||
|
const result = await processEmailSendJob(job.data)
|
||||||
|
if (!result.ok) throw result.error
|
||||||
|
},
|
||||||
|
{ connection, concurrency: 10 }
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user