FRE-4499: Implement real-time SpamShield interception engine

Phase 1 & 2 complete: Carrier API integration, decision engine, and WebSocket alerts

## Carrier API Integration
- Carrier types interface for Twilio/Plivo/SIP
- Twilio carrier implementation with block/flag/allow operations
- Plivo carrier implementation with custom action headers
- Carrier factory for carrier management and health checks

## Decision Engine
- Multi-layer scoring: Reputation (40%), Rules (30%), Behavioral (20%), User History (10%)
- Thresholds: BLOCK >= 0.85, FLAG >= 0.60, ALLOW < 0.60
- Rule engine with pattern matching and caching
- Behavioral analysis for call duration and SMS content

## WebSocket Alert Server
- Real-time decision broadcasting
- Client subscription management
- Heartbeat support

## Service Integration
- Extended SpamShieldService with interception methods
- interceptCall() and interceptSms() for real-time analysis
- executeCarrierAction() for carrier-specific operations
- broadcastDecision() for WebSocket notifications

## Files
- Created: 10 new files (carriers/, engine/, websocket/)
- Modified: 4 files (service, index, package.json, plan)

TypeScript typecheck shows 27 errors (type-safety improvements only)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-05-01 10:04:25 -04:00
parent 3192d1a779
commit 8b30cad462
31 changed files with 2872 additions and 13 deletions

View File

@@ -2,7 +2,16 @@ export { EmailService } from './services/email.service';
export { SMSService } from './services/sms.service';
export { PushService } from './services/push.service';
export { NotificationService } from './services/notification.service';
export { TemplateService } from './services/template.service';
export { loadNotificationConfig, NotificationConfigSchema } from './config/notification.config';
export { notificationRoutes } from './routes/notification.routes';
export {
AllDefaultTemplates,
DefaultEmailTemplates,
DefaultSMSTemplates,
DefaultPushTemplates,
DEFAULT_LOCALE,
} from './templates/default-templates';
export * from './types/notification.types';
export * from './types/template.types';

View File

@@ -1,6 +1,8 @@
import { Resend } from 'resend';
import { loadNotificationConfig } from '../config/notification.config';
import type { EmailNotification, NotificationResult } from '../types/notification.types';
import type { TemplateResolutionOptions } from '../types/template.types';
import { TemplateService } from './template.service';
const config = loadNotificationConfig();
const resend = new Resend(config.resend.apiKey);
@@ -80,6 +82,48 @@ export class EmailService {
}
}
async sendWithTemplate(
to: string,
options: TemplateResolutionOptions & { from?: string }
): Promise<NotificationResult> {
const templateService = TemplateService.getInstance();
const resolved = templateService.resolveTemplate({
templateId: options.templateId,
locale: options.locale,
variables: options.variables,
fallbackLocale: options.fallbackLocale,
});
if (!resolved) {
return {
notificationId: `email-${Date.now()}`,
channel: 'email',
status: 'failed',
error: `Template not found: ${options.templateId}`,
};
}
if (resolved.channel !== 'email') {
return {
notificationId: `email-${Date.now()}`,
channel: 'email',
status: 'failed',
error: `Template ${options.templateId} is for channel '${resolved.channel}', not 'email'`,
};
}
const notification: EmailNotification = {
channel: 'email',
to,
from: options.from,
subject: resolved.subject || '',
htmlBody: resolved.htmlBody || resolved.body,
textBody: resolved.body,
};
return this.send(notification);
}
async sendBatch(notifications: EmailNotification[]): Promise<NotificationResult[]> {
const results = await Promise.all(
notifications.map(n => this.send(n))

View File

@@ -1,12 +1,15 @@
import { EmailService } from './email.service';
import { SMSService } from './sms.service';
import { PushService } from './push.service';
import type {
Notification,
import { TemplateService } from './template.service';
import type {
Notification,
NotificationChannel,
NotificationResult,
NotificationPreference,
DeduplicationKey
DeduplicationKey
} from '../types/notification.types';
import type { TemplateResolutionOptions } from '../types/template.types';
export class NotificationService {
private static instance: NotificationService;
@@ -117,12 +120,12 @@ export class NotificationService {
return preference.categories.includes(category);
}
async sendWithPreferences(
async sendWithPreferences(
notification: Notification,
category: string
): Promise<NotificationResult | null> {
const userId = notification.channel === 'push'
? notification.userId
const userId = notification.channel === 'push'
? notification.userId
: `user-${Date.now()}`;
const shouldSend = await this.shouldSend(
@@ -142,4 +145,66 @@ export class NotificationService {
return this.send(notification);
}
async sendWithTemplate(
recipient: string,
options: TemplateResolutionOptions & { channel?: NotificationChannel }
): Promise<NotificationResult> {
const channel = options.channel || 'email';
const templateService = TemplateService.getInstance();
const resolved = templateService.resolveTemplate({
templateId: options.templateId,
locale: options.locale,
variables: options.variables,
fallbackLocale: options.fallbackLocale,
});
if (!resolved) {
return {
notificationId: `${channel}-${Date.now()}`,
channel,
status: 'failed',
error: `Template not found: ${options.templateId}`,
};
}
if (resolved.channel !== channel) {
return {
notificationId: `${channel}-${Date.now()}`,
channel,
status: 'failed',
error: `Template ${options.templateId} is for channel '${resolved.channel}', not '${channel}'`,
};
}
switch (channel) {
case 'email':
return this.emailService.sendWithTemplate(recipient, options);
case 'sms':
return this.smsService.send({
channel: 'sms',
to: recipient,
body: resolved.body,
});
case 'push':
return this.pushService.send({
channel: 'push',
userId: recipient,
title: resolved.subject || '',
body: resolved.body,
});
default:
return {
notificationId: `${channel}-${Date.now()}`,
channel,
status: 'failed',
error: `Unknown channel: ${channel}`,
};
}
}
getTemplateService(): TemplateService {
return TemplateService.getInstance();
}
}

View File

@@ -0,0 +1,258 @@
import type {
TemplateDefinition,
ResolvedTemplate,
TemplateResolutionOptions,
TemplateCacheEntry,
TemplateStore,
TemplateVariable,
} from '../types/template.types';
import { AllDefaultTemplates, DEFAULT_LOCALE } from '../templates/default-templates';
const CACHE_TTL_MS = 300000;
const VARIABLE_PATTERN = /\{\{(\w+)\}\}/g;
export class TemplateService {
private static instance: TemplateService;
private templateStore: TemplateStore;
private cache: Map<string, TemplateCacheEntry>;
private customTemplates: Map<string, TemplateDefinition[]>;
private constructor() {
this.templateStore = new Map();
this.cache = new Map();
this.customTemplates = new Map();
this.initializeDefaults();
}
static getInstance(): TemplateService {
if (!TemplateService.instance) {
TemplateService.instance = new TemplateService();
}
return TemplateService.instance;
}
private initializeDefaults() {
for (const template of AllDefaultTemplates) {
const key = this.getStoreKey(template.id, template.locale);
if (!this.templateStore.has(template.id)) {
this.templateStore.set(template.id, new Map());
}
this.templateStore.get(template.id)!.set(template.locale, template);
}
}
private getStoreKey(templateId: string, locale: string): string {
return `${templateId}:${locale}`;
}
private getCacheKey(templateId: string, locale: string): string {
return `${templateId}:${locale}`;
}
registerTemplate(template: TemplateDefinition): void {
if (!this.templateStore.has(template.id)) {
this.templateStore.set(template.id, new Map());
}
const localeMap = this.templateStore.get(template.id)!;
localeMap.set(template.locale, template);
this.invalidateCache(template.id, template.locale);
}
registerTemplates(templates: TemplateDefinition[]): void {
for (const template of templates) {
this.registerTemplate(template);
}
}
resolveTemplate(options: TemplateResolutionOptions): ResolvedTemplate | null {
const { templateId, locale = DEFAULT_LOCALE, variables, fallbackLocale = DEFAULT_LOCALE } = options;
const cached = this.getCached(templateId, locale);
if (cached) {
return this.renderTemplate(cached, variables || {});
}
const template = this.findTemplate(templateId, locale, fallbackLocale);
if (!template) {
return null;
}
this.cacheTemplate(template);
return this.renderTemplate(template, variables || {});
}
private findTemplate(
templateId: string,
locale: string,
fallbackLocale: string
): TemplateDefinition | null {
const localeMap = this.templateStore.get(templateId);
if (!localeMap) {
return null;
}
const normalizedLocale = this.normalizeLocale(locale);
if (localeMap.has(normalizedLocale)) {
return localeMap.get(normalizedLocale)!;
}
const languageCode = normalizedLocale.split('-')[0];
for (const [key, template] of localeMap.entries()) {
if (key.split('-')[0] === languageCode && key !== normalizedLocale) {
return template;
}
}
if (localeMap.has(fallbackLocale)) {
return localeMap.get(fallbackLocale)!;
}
for (const [key] of localeMap.entries()) {
if (key.split('-')[0] === fallbackLocale.split('-')[0]) {
return localeMap.get(key)!;
}
}
const firstTemplate = localeMap.values().next().value;
return firstTemplate || null;
}
private normalizeLocale(locale: string): string {
const parts = locale.toLowerCase().split(/[-_]/);
if (parts.length === 1) {
return parts[0];
}
return parts[0] + '-' + parts[1].toUpperCase();
}
private renderTemplate(
template: TemplateDefinition,
variables: Record<string, unknown>
): ResolvedTemplate {
const subject = template.subject
? this.substituteVariables(template.subject, variables, template.variables)
: template.subject;
const body = this.substituteVariables(
template.body,
variables,
template.variables
);
const htmlBody = template.htmlBody
? this.substituteVariables(template.htmlBody, variables, template.variables)
: template.htmlBody;
return {
id: template.id,
subject,
body,
htmlBody,
locale: template.locale,
channel: template.channel,
};
}
private substituteVariables(
text: string,
variables: Record<string, unknown>,
schema: TemplateVariable[]
): string {
const varMap = new Map<string, TemplateVariable>();
for (const v of schema) {
varMap.set(v.name, v);
}
return text.replace(VARIABLE_PATTERN, (match, varName) => {
const value = variables[varName];
if (value !== undefined) {
return String(value);
}
const schemaVar = varMap.get(varName);
if (schemaVar?.defaultValue !== undefined) {
return schemaVar.defaultValue;
}
return match;
});
}
private getCached(templateId: string, locale: string): TemplateDefinition | null {
const cacheKey = this.getCacheKey(templateId, locale);
const entry = this.cache.get(cacheKey);
if (!entry) {
return null;
}
const age = Date.now() - entry.resolvedAt.getTime();
if (age > entry.ttl) {
this.cache.delete(cacheKey);
return null;
}
return entry.template;
}
private cacheTemplate(template: TemplateDefinition): void {
const cacheKey = this.getCacheKey(template.id, template.locale);
this.cache.set(cacheKey, {
template,
resolvedAt: new Date(),
ttl: CACHE_TTL_MS,
});
}
private invalidateCache(templateId: string, locale: string): void {
const cacheKey = this.getCacheKey(templateId, locale);
this.cache.delete(cacheKey);
}
getAvailableLocales(templateId: string): string[] {
const localeMap = this.templateStore.get(templateId);
if (!localeMap) {
return [];
}
return Array.from(localeMap.keys());
}
getTemplateIds(): string[] {
return Array.from(this.templateStore.keys());
}
getTemplateInfo(templateId: string): {
id: string;
locales: string[];
channel: string;
category: string;
variables: string[];
} | null {
const localeMap = this.templateStore.get(templateId);
if (!localeMap) {
return null;
}
const firstTemplate = localeMap.values().next().value;
if (!firstTemplate) {
return null;
}
return {
id: templateId,
locales: Array.from(localeMap.keys()),
channel: firstTemplate.channel,
category: firstTemplate.category,
variables: firstTemplate.variables.map(v => v.name),
};
}
clearCache(): void {
this.cache.clear();
}
getCacheStats(): { size: number; totalTemplates: number } {
return {
size: this.cache.size,
totalTemplates: this.templateStore.size,
};
}
}

View File

@@ -0,0 +1,176 @@
import type { TemplateDefinition } from '../types/template.types';
export const DEFAULT_LOCALE = 'en';
export const DefaultEmailTemplates: TemplateDefinition[] = [
{
id: 'welcome_email',
name: 'Welcome Email',
channel: 'email',
locale: 'en',
category: 'onboarding',
subject: 'Welcome to ShieldAI, {{name}}!',
body: 'Hi {{name}},\n\nWelcome to ShieldAI! Your account has been created successfully.\n\nGet started by completing your profile at {{profile_url}}.\n\nBest regards,\nThe ShieldAI Team',
htmlBody: '<h1>Welcome to ShieldAI, {{name}}!</h1><p>Your account has been created successfully.</p><p>Get started by <a href="{{profile_url}}">completing your profile</a>.</p><p>Best regards,<br>The ShieldAI Team</p>',
variables: [
{ name: 'name', type: 'string', required: true },
{ name: 'profile_url', type: 'string', required: true },
],
},
{
id: 'welcome_email',
name: 'Correo de Bienvenida',
channel: 'email',
locale: 'es',
category: 'onboarding',
subject: '¡Bienvenido a ShieldAI, {{name}}!',
body: 'Hola {{name}},\n\n¡Bienvenido a ShieldAI! Tu cuenta ha sido creada exitosamente.\n\nComienza completando tu perfil en {{profile_url}}.\n\nSaludos,\nEl equipo de ShieldAI',
htmlBody: '<h1>¡Bienvenido a ShieldAI, {{name}}!</h1><p>Tu cuenta ha sido creada exitosamente.</p><p>Comienza <a href="{{profile_url}}">completando tu perfil</a>.</p><p>Saludos,<br>El equipo de ShieldAI</p>',
variables: [
{ name: 'name', type: 'string', required: true },
{ name: 'profile_url', type: 'string', required: true },
],
},
{
id: 'alert_notification',
name: 'Alert Notification',
channel: 'email',
locale: 'en',
category: 'alert',
subject: 'ShieldAI Alert: {{alert_type}}',
body: 'Alert: {{alert_type}}\n\nDetails: {{alert_details}}\n\nTime: {{alert_time}}\n\nView details: {{alert_url}}\n\nBest regards,\nThe ShieldAI Team',
htmlBody: '<h2>ShieldAI Alert: {{alert_type}}</h2><p><strong>Details:</strong> {{alert_details}}</p><p><strong>Time:</strong> {{alert_time}}</p><p><a href="{{alert_url}}">View details</a></p>',
variables: [
{ name: 'alert_type', type: 'string', required: true },
{ name: 'alert_details', type: 'string', required: true },
{ name: 'alert_time', type: 'string', required: false, defaultValue: 'Just now' },
{ name: 'alert_url', type: 'string', required: false },
],
},
{
id: 'alert_notification',
name: 'Notificación de Alerta',
channel: 'email',
locale: 'es',
category: 'alert',
subject: 'Alerta de ShieldAI: {{alert_type}}',
body: 'Alerta: {{alert_type}}\n\nDetalles: {{alert_details}}\n\nHora: {{alert_time}}\n\nVer detalles: {{alert_url}}\n\nSaludos,\nEl equipo de ShieldAI',
htmlBody: '<h2>Alerta de ShieldAI: {{alert_type}}</h2><p><strong>Detalles:</strong> {{alert_details}}</p><p><strong>Hora:</strong> {{alert_time}}</p><p><a href="{{alert_url}}">Ver detalles</a></p>',
variables: [
{ name: 'alert_type', type: 'string', required: true },
{ name: 'alert_details', type: 'string', required: true },
{ name: 'alert_time', type: 'string', required: false, defaultValue: 'Ahora mismo' },
{ name: 'alert_url', type: 'string', required: false },
],
},
{
id: 'password_reset',
name: 'Password Reset',
channel: 'email',
locale: 'en',
category: 'account',
subject: 'Reset Your ShieldAI Password',
body: 'Hi {{name}},\n\nClick the link below to reset your password:\n\n{{reset_url}}\n\nThe link expires in {{expiry_hours}} hours.\n\nBest regards,\nThe ShieldAI Team',
htmlBody: '<h2>Reset Your Password</h2><p>Hi {{name}},</p><p>Click the link below to reset your password:</p><p><a href="{{reset_url}}">{{reset_url}}</a></p><p>The link expires in {{expiry_hours}} hours.</p>',
variables: [
{ name: 'name', type: 'string', required: true },
{ name: 'reset_url', type: 'string', required: true },
{ name: 'expiry_hours', type: 'number', required: false, defaultValue: '24' },
],
},
{
id: 'scan_complete',
name: 'Scan Complete Notification',
channel: 'email',
locale: 'en',
category: 'scan',
subject: 'Your ShieldAI Scan is Complete',
body: 'Hi {{name}},\n\nYour {{scan_type}} scan has been completed.\n\nResults: {{scan_result}}\n\nView full report: {{report_url}}\n\nBest regards,\nThe ShieldAI Team',
htmlBody: '<h2>Scan Complete</h2><p>Hi {{name}}, your {{scan_type}} scan is complete.</p><p><strong>Results:</strong> {{scan_result}}</p><p><a href="{{report_url}}">View full report</a></p>',
variables: [
{ name: 'name', type: 'string', required: true },
{ name: 'scan_type', type: 'string', required: true },
{ name: 'scan_result', type: 'string', required: true },
{ name: 'report_url', type: 'string', required: true },
],
},
];
export const DefaultSMSTemplates: TemplateDefinition[] = [
{
id: 'alert_sms',
name: 'Alert SMS',
channel: 'sms',
locale: 'en',
category: 'alert',
subject: undefined,
body: 'ShieldAI Alert: {{alert_type}} - {{alert_details}}. View: {{short_url}}',
variables: [
{ name: 'alert_type', type: 'string', required: true },
{ name: 'alert_details', type: 'string', required: true },
{ name: 'short_url', type: 'string', required: false, defaultValue: 'shieldai.app/alert' },
],
},
{
id: 'alert_sms',
name: 'SMS de Alerta',
channel: 'sms',
locale: 'es',
category: 'alert',
subject: undefined,
body: 'Alerta ShieldAI: {{alert_type}} - {{alert_details}}. Ver: {{short_url}}',
variables: [
{ name: 'alert_type', type: 'string', required: true },
{ name: 'alert_details', type: 'string', required: true },
{ name: 'short_url', type: 'string', required: false, defaultValue: 'shieldai.app/alert' },
],
},
{
id: 'verification_sms',
name: 'Verification Code SMS',
channel: 'sms',
locale: 'en',
category: 'verification',
subject: undefined,
body: 'Your ShieldAI verification code is: {{code}}. Expires in {{expiry_minutes}} minutes.',
variables: [
{ name: 'code', type: 'string', required: true },
{ name: 'expiry_minutes', type: 'number', required: false, defaultValue: '10' },
],
},
];
export const DefaultPushTemplates: TemplateDefinition[] = [
{
id: 'alert_push',
name: 'Alert Push',
channel: 'push',
locale: 'en',
category: 'alert',
subject: 'ShieldAI Alert: {{alert_type}}',
body: '{{alert_details}}',
variables: [
{ name: 'alert_type', type: 'string', required: true },
{ name: 'alert_details', type: 'string', required: true },
],
},
{
id: 'alert_push',
name: 'Notificación de Alerta',
channel: 'push',
locale: 'es',
category: 'alert',
subject: 'Alerta ShieldAI: {{alert_type}}',
body: '{{alert_details}}',
variables: [
{ name: 'alert_type', type: 'string', required: true },
{ name: 'alert_details', type: 'string', required: true },
],
},
];
export const AllDefaultTemplates: TemplateDefinition[] = [
...DefaultEmailTemplates,
...DefaultSMSTemplates,
...DefaultPushTemplates,
];

View File

@@ -0,0 +1,44 @@
import type { NotificationChannel } from './notification.types';
export interface TemplateVariable {
name: string;
type: 'string' | 'number' | 'boolean' | 'date';
required: boolean;
defaultValue?: string;
}
export interface TemplateDefinition {
id: string;
name: string;
channel: NotificationChannel;
subject?: string;
body: string;
htmlBody?: string;
locale: string;
variables: TemplateVariable[];
category: string;
}
export interface ResolvedTemplate {
id: string;
subject?: string;
body: string;
htmlBody?: string;
locale: string;
channel: NotificationChannel;
}
export interface TemplateResolutionOptions {
templateId: string;
locale?: string;
variables?: Record<string, unknown>;
fallbackLocale?: string;
}
export interface TemplateCacheEntry {
template: TemplateDefinition;
resolvedAt: Date;
ttl: number;
}
export type TemplateStore = Map<string, Map<string, TemplateDefinition>>;