fix: safeInsertEvent verhindert Doppelversand, Union-Typ für eventType, Privacy-Tests ergänzt

This commit is contained in:
2026-04-17 12:13:31 +00:00
parent 8bf143735e
commit e49552236d
2 changed files with 34 additions and 13 deletions

View File

@@ -65,10 +65,15 @@ describe('processEmailSendJob', () => {
expect(clickhouse.insert).toHaveBeenCalledWith( expect(clickhouse.insert).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
values: expect.arrayContaining([ values: expect.arrayContaining([
expect.objectContaining({ event_type: 'suppressed' }), expect.objectContaining({
event_type: 'suppressed',
recipient_hash: 'abc123hash',
}),
]), ]),
}) })
) )
const insertCall = vi.mocked(clickhouse.insert).mock.calls[0][0]
expect(JSON.stringify(insertCall.values)).not.toContain('empfaenger@example.com')
}) })
it('gibt err zurück wenn Kampagne nicht gefunden', async () => { it('gibt err zurück wenn Kampagne nicht gefunden', async () => {
@@ -117,4 +122,15 @@ describe('processEmailSendJob', () => {
}) })
) )
}) })
it('ClickHouse-Fehler nach erfolgreichem SMTP führt nicht zu err (verhindert Doppelversand)', async () => {
vi.mocked(getCampaign).mockResolvedValue({ ok: true, data: mockCampaign })
vi.mocked(checkSuppression).mockResolvedValue(false)
vi.mocked(sendEmail).mockResolvedValue({ ok: true, data: undefined })
vi.mocked(clickhouse.insert).mockRejectedValueOnce(new Error('ClickHouse down'))
const result = await processEmailSendJob(jobData)
expect(result.ok).toBe(true)
})
}) })

View File

@@ -14,7 +14,7 @@ export async function processEmailSendJob(data: EmailSendJobData): Promise<Resul
// Suppression-Check ist PFLICHT — kein Opt-out-Empfänger darf E-Mail erhalten // Suppression-Check ist PFLICHT — kein Opt-out-Empfänger darf E-Mail erhalten
const suppressed = await checkSuppression(data.tenantId, data.recipientEmail) const suppressed = await checkSuppression(data.tenantId, data.recipientEmail)
if (suppressed) { if (suppressed) {
await insertEvent('suppressed', data) await safeInsertEvent('suppressed', data)
return ok(undefined) return ok(undefined)
} }
@@ -31,26 +31,31 @@ export async function processEmailSendJob(data: EmailSendJobData): Promise<Resul
if (!sendResult.ok) return err(sendResult.error) if (!sendResult.ok) return err(sendResult.error)
await insertEvent('sent', data) // Analytics-Fehler nicht an BullMQ weitergeben — verhindert Doppelversand bei Retry
await safeInsertEvent('sent', data)
return ok(undefined) return ok(undefined)
} }
async function insertEvent(eventType: string, data: EmailSendJobData): Promise<void> { async function safeInsertEvent(
await clickhouse.insert({ eventType: 'sent' | 'suppressed',
table: 'email_events', data: EmailSendJobData
values: [ ): Promise<void> {
{ try {
await clickhouse.insert({
table: 'email_events',
values: [{
event_type: eventType, event_type: eventType,
tenant_id: data.tenantId, tenant_id: data.tenantId,
campaign_id: data.campaignId, campaign_id: data.campaignId,
// Datenschutz: nur Hash wird gespeichert — keine Klartext-E-Mail-Adresse in ClickHouse
recipient_hash: data.recipientHash, recipient_hash: data.recipientHash,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
metadata: {}, metadata: {},
}, }],
], format: 'JSONEachRow',
format: 'JSONEachRow', })
}) } catch (e) {
console.error(JSON.stringify({ level: 'warn', msg: 'ClickHouse insert fehlgeschlagen', error: String(e) }))
}
} }
const connection = { const connection = {