mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
183 lines
5.8 KiB
TypeScript
183 lines
5.8 KiB
TypeScript
// 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),
|
|
});
|
|
}
|
|
}
|
|
}
|