263 lines
6.2 KiB
TypeScript
263 lines
6.2 KiB
TypeScript
|
|
import * as plugins from '../../plugins.js';
|
||
|
|
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||
|
|
import { CacheDb } from '../classes.cachedb.js';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Helper to get the smartdata database instance
|
||
|
|
*/
|
||
|
|
const getDb = () => CacheDb.getInstance().getDb();
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Reason for suppression
|
||
|
|
*/
|
||
|
|
export type TSuppressionReason =
|
||
|
|
| 'hard-bounce'
|
||
|
|
| 'soft-bounce-exceeded'
|
||
|
|
| 'complaint'
|
||
|
|
| 'unsubscribe'
|
||
|
|
| 'manual'
|
||
|
|
| 'spam-trap'
|
||
|
|
| 'invalid-address';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* CachedSuppression - Stores email suppression list entries
|
||
|
|
*
|
||
|
|
* Emails to addresses in the suppression list should not be sent.
|
||
|
|
* Supports both temporary (30-day) and permanent suppression.
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.Collection(() => getDb())
|
||
|
|
export class CachedSuppression extends CachedDocument<CachedSuppression> {
|
||
|
|
/**
|
||
|
|
* Email address to suppress (unique identifier)
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.unI()
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public email: string;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Reason for suppression
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public reason: TSuppressionReason;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Human-readable description of why this address is suppressed
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public description: string;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Whether this is a permanent suppression
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public permanent: boolean = false;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Number of times we've tried to send to this address after suppression
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public blockedAttempts: number = 0;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Domain of the suppressed email
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public domain: string;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Related bounce record ID (if suppressed due to bounce)
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public relatedBounceId: string;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Source that caused the suppression (e.g., campaign ID, message ID)
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public source: string;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Date when the suppression was first created
|
||
|
|
*/
|
||
|
|
@plugins.smartdata.svDb()
|
||
|
|
public suppressedAt: Date;
|
||
|
|
|
||
|
|
constructor() {
|
||
|
|
super();
|
||
|
|
this.setTTL(TTL.DAYS_30); // Default 30-day TTL
|
||
|
|
this.suppressedAt = new Date();
|
||
|
|
this.blockedAttempts = 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create a new suppression entry
|
||
|
|
*/
|
||
|
|
public static createNew(email: string, reason: TSuppressionReason): CachedSuppression {
|
||
|
|
const suppression = new CachedSuppression();
|
||
|
|
suppression.email = email.toLowerCase().trim();
|
||
|
|
suppression.reason = reason;
|
||
|
|
suppression.updateDomain();
|
||
|
|
|
||
|
|
// Hard bounces and spam traps should be permanent
|
||
|
|
if (reason === 'hard-bounce' || reason === 'spam-trap' || reason === 'complaint') {
|
||
|
|
suppression.setPermanent();
|
||
|
|
}
|
||
|
|
|
||
|
|
return suppression;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Make this suppression permanent (never expires)
|
||
|
|
*/
|
||
|
|
public setPermanent(): void {
|
||
|
|
this.permanent = true;
|
||
|
|
this.setNeverExpires();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Make this suppression temporary with specific TTL
|
||
|
|
*/
|
||
|
|
public setTemporary(ttlMs: number): void {
|
||
|
|
this.permanent = false;
|
||
|
|
this.setTTL(ttlMs);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Extract domain from email
|
||
|
|
*/
|
||
|
|
public updateDomain(): void {
|
||
|
|
if (this.email) {
|
||
|
|
const match = this.email.match(/@(.+)$/);
|
||
|
|
if (match) {
|
||
|
|
this.domain = match[1].toLowerCase();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if an email is suppressed
|
||
|
|
*/
|
||
|
|
public static async isSuppressed(email: string): Promise<boolean> {
|
||
|
|
const normalizedEmail = email.toLowerCase().trim();
|
||
|
|
const entry = await CachedSuppression.getInstance({
|
||
|
|
email: normalizedEmail,
|
||
|
|
});
|
||
|
|
return entry !== null && !entry.isExpired();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get suppression entry for an email
|
||
|
|
*/
|
||
|
|
public static async findByEmail(email: string): Promise<CachedSuppression | null> {
|
||
|
|
const normalizedEmail = email.toLowerCase().trim();
|
||
|
|
return await CachedSuppression.getInstance({
|
||
|
|
email: normalizedEmail,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Find all suppressions for a domain
|
||
|
|
*/
|
||
|
|
public static async findByDomain(domain: string): Promise<CachedSuppression[]> {
|
||
|
|
return await CachedSuppression.getInstances({
|
||
|
|
domain: domain.toLowerCase(),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Find all permanent suppressions
|
||
|
|
*/
|
||
|
|
public static async findPermanent(): Promise<CachedSuppression[]> {
|
||
|
|
return await CachedSuppression.getInstances({
|
||
|
|
permanent: true,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Find all suppressions by reason
|
||
|
|
*/
|
||
|
|
public static async findByReason(reason: TSuppressionReason): Promise<CachedSuppression[]> {
|
||
|
|
return await CachedSuppression.getInstances({
|
||
|
|
reason,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Record a blocked attempt to send to this address
|
||
|
|
*/
|
||
|
|
public recordBlockedAttempt(): void {
|
||
|
|
this.blockedAttempts++;
|
||
|
|
this.touch();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Remove suppression (delete from database)
|
||
|
|
*/
|
||
|
|
public static async remove(email: string): Promise<boolean> {
|
||
|
|
const normalizedEmail = email.toLowerCase().trim();
|
||
|
|
const entry = await CachedSuppression.getInstance({
|
||
|
|
email: normalizedEmail,
|
||
|
|
});
|
||
|
|
if (entry) {
|
||
|
|
await entry.delete();
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Add or update a suppression entry
|
||
|
|
*/
|
||
|
|
public static async addOrUpdate(
|
||
|
|
email: string,
|
||
|
|
reason: TSuppressionReason,
|
||
|
|
options?: {
|
||
|
|
permanent?: boolean;
|
||
|
|
description?: string;
|
||
|
|
source?: string;
|
||
|
|
relatedBounceId?: string;
|
||
|
|
}
|
||
|
|
): Promise<CachedSuppression> {
|
||
|
|
const normalizedEmail = email.toLowerCase().trim();
|
||
|
|
|
||
|
|
// Check if already suppressed
|
||
|
|
let entry = await CachedSuppression.findByEmail(normalizedEmail);
|
||
|
|
|
||
|
|
if (entry) {
|
||
|
|
// Update existing entry
|
||
|
|
entry.reason = reason;
|
||
|
|
if (options?.permanent) {
|
||
|
|
entry.setPermanent();
|
||
|
|
}
|
||
|
|
if (options?.description) {
|
||
|
|
entry.description = options.description;
|
||
|
|
}
|
||
|
|
if (options?.source) {
|
||
|
|
entry.source = options.source;
|
||
|
|
}
|
||
|
|
if (options?.relatedBounceId) {
|
||
|
|
entry.relatedBounceId = options.relatedBounceId;
|
||
|
|
}
|
||
|
|
entry.touch();
|
||
|
|
} else {
|
||
|
|
// Create new entry
|
||
|
|
entry = CachedSuppression.createNew(normalizedEmail, reason);
|
||
|
|
if (options?.permanent) {
|
||
|
|
entry.setPermanent();
|
||
|
|
}
|
||
|
|
if (options?.description) {
|
||
|
|
entry.description = options.description;
|
||
|
|
}
|
||
|
|
if (options?.source) {
|
||
|
|
entry.source = options.source;
|
||
|
|
}
|
||
|
|
if (options?.relatedBounceId) {
|
||
|
|
entry.relatedBounceId = options.relatedBounceId;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
await entry.save();
|
||
|
|
return entry;
|
||
|
|
}
|
||
|
|
}
|