// SPDX-License-Identifier: AGPL-3.0-or-later import crypto from 'node:crypto'; import {SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants'; import type {UserID} from '../BrandedTypes'; import type {GatewayDispatchEvent} from '../constants/Gateway'; import type {UserRow} from '../database/types/UserTypes'; import {Logger} from '../Logger'; import type {User} from '../models/User'; import {mapUserToPrivateResponse} from '../user/UserMappers'; import {isJsonRecord, parseJsonWithGuard} from '../utils/JsonBoundaryUtils'; interface ISweegoUserRepository { findByEmail(email: string): Promise; patchUpsert(userId: UserID, patch: Partial, currentRow: UserRow): Promise; } interface ISweegoGatewayService { dispatchPresence(params: {userId: UserID; event: GatewayDispatchEvent; data: unknown}): Promise; } type SweegoEventType = | 'email_sent' | 'delivered' | 'soft-bounce' | 'hard_bounce' | 'list_unsub' | 'complaint' | 'email_clicked' | 'email_opened' | string; interface SweegoEvent { event_type: SweegoEventType; timestamp: string; swg_uid?: string; event_id?: string; details?: string; channel?: string; transaction_id?: string; recipient: string; domain_from?: string; campaign_type?: string; campaign_id?: string; } function isOptionalString(value: unknown): boolean { return value === undefined || typeof value === 'string'; } function isSweegoEvent(value: unknown): value is SweegoEvent { if (!isJsonRecord(value)) return false; return ( typeof value['event_type'] === 'string' && typeof value['timestamp'] === 'string' && typeof value['recipient'] === 'string' && isOptionalString(value['swg_uid']) && isOptionalString(value['event_id']) && isOptionalString(value['details']) && isOptionalString(value['channel']) && isOptionalString(value['transaction_id']) && isOptionalString(value['domain_from']) && isOptionalString(value['campaign_type']) && isOptionalString(value['campaign_id']) ); } export class SweegoWebhookService { constructor( private readonly userRepository: ISweegoUserRepository, private readonly gatewayService: ISweegoGatewayService, ) {} verifySignature(body: string, webhookId: string, timestamp: string, signature: string, secret: string): boolean { try { const secretBytes = Buffer.from(secret, 'base64'); const contentToSign = `${webhookId}.${timestamp}.${body}`; const digest = crypto.createHmac('sha256', secretBytes).update(contentToSign).digest(); const computedSignature = digest.toString('base64'); const computedBuffer = Buffer.from(computedSignature); const receivedBuffer = Buffer.from(signature); if (computedBuffer.length !== receivedBuffer.length) { return false; } return crypto.timingSafeEqual(computedBuffer, receivedBuffer); } catch (error) { Logger.error({error}, 'Error verifying Sweego webhook signature'); return false; } } async handleWebhook(params: { body: string; webhookId?: string; timestamp?: string; signature?: string; secret?: string | null; }): Promise<{ status: number; body: string | null; }> { const {body, webhookId, timestamp, signature, secret} = params; if (!secret) { Logger.error('Sweego webhook secret is not configured'); return {status: 503, body: 'Sweego webhook secret not configured'}; } if (!webhookId || !timestamp || !signature) { Logger.warn('Sweego webhook missing signature headers'); return {status: 401, body: 'Missing signature headers'}; } const isValid = this.verifySignature(body, webhookId, timestamp, signature, secret); if (!isValid) { Logger.warn('Sweego webhook signature verification failed'); return {status: 401, body: 'Invalid signature'}; } const event = parseJsonWithGuard(body, isSweegoEvent); if (!event) { Logger.error({body: body.slice(0, 1000)}, 'Failed to parse Sweego webhook JSON body'); return {status: 400, body: 'Invalid JSON'}; } await this.processEvent(event); return {status: 200, body: null}; } async processEvent(event: SweegoEvent): Promise { if (event.event_type !== 'soft-bounce' && event.event_type !== 'hard_bounce' && event.event_type !== 'complaint') { Logger.debug({eventType: event.event_type, recipient: event.recipient}, 'Sweego event received (ignored)'); return; } if (event.event_type === 'hard_bounce' || event.event_type === 'complaint') { await this.handleHardBounceOrComplaint(event); return; } Logger.info( {recipient: event.recipient, details: event.details, eventType: event.event_type}, 'Sweego soft bounce received', ); } private async handleHardBounceOrComplaint(event: SweegoEvent): Promise { Logger.warn( { recipient: event.recipient, eventType: event.event_type, details: event.details, eventId: event.event_id, }, 'Processing hard bounce or complaint - marking email as invalid', ); const user = await this.userRepository.findByEmail(event.recipient); if (!user) { Logger.warn({recipient: event.recipient}, 'User not found for bounced email'); return; } if (user.emailBounced) { Logger.debug({userId: user.id, recipient: event.recipient}, 'Email already marked as bounced'); return; } const currentFlags = user.suspiciousActivityFlags || 0; const newFlags = currentFlags | SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL; const updatedUser = await this.userRepository.patchUpsert( user.id, { email_bounced: true, email_verified: false, suspicious_activity_flags: newFlags, }, user.toRow(), ); Logger.info( {userId: user.id, recipient: event.recipient, details: event.details}, 'User email marked as bounced and requires reverification', ); if (updatedUser) { await this.gatewayService.dispatchPresence({ userId: updatedUser.id, event: 'USER_UPDATE', data: mapUserToPrivateResponse(updatedUser), }); } } }