This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183// 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<User | null>; patchUpsert(userId: UserID, patch: Partial<UserRow>, currentRow: UserRow): Promise<User | null>;}
interface ISweegoGatewayService { dispatchPresence(params: {userId: UserID; event: GatewayDispatchEvent; data: unknown}): Promise<void>;}
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<void> { 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<void> { 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), }); } }}