245 lines
5.4 KiB
TypeScript
245 lines
5.4 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();
|
|
|
|
/**
|
|
* Bounce type classification
|
|
*/
|
|
export type TBounceType = 'hard' | 'soft' | 'complaint' | 'unknown';
|
|
|
|
/**
|
|
* Bounce category for detailed classification
|
|
*/
|
|
export type TBounceCategory =
|
|
| 'invalid-recipient'
|
|
| 'mailbox-full'
|
|
| 'domain-not-found'
|
|
| 'connection-failed'
|
|
| 'policy-rejection'
|
|
| 'spam-rejection'
|
|
| 'rate-limited'
|
|
| 'other';
|
|
|
|
/**
|
|
* CachedBounce - Stores email bounce records
|
|
*
|
|
* Tracks bounce events for emails to help with deliverability
|
|
* analysis and suppression list management.
|
|
*/
|
|
@plugins.smartdata.Collection(() => getDb())
|
|
export class CachedBounce extends CachedDocument<CachedBounce> {
|
|
/**
|
|
* Unique identifier for this bounce record
|
|
*/
|
|
@plugins.smartdata.unI()
|
|
@plugins.smartdata.svDb()
|
|
public id: string;
|
|
|
|
/**
|
|
* Email address that bounced
|
|
*/
|
|
@plugins.smartdata.svDb()
|
|
public recipient: string;
|
|
|
|
/**
|
|
* Sender email address
|
|
*/
|
|
@plugins.smartdata.svDb()
|
|
public sender: string;
|
|
|
|
/**
|
|
* Recipient domain
|
|
*/
|
|
@plugins.smartdata.svDb()
|
|
public domain: string;
|
|
|
|
/**
|
|
* Type of bounce (hard/soft/complaint)
|
|
*/
|
|
@plugins.smartdata.svDb()
|
|
public bounceType: TBounceType;
|
|
|
|
/**
|
|
* Detailed bounce category
|
|
*/
|
|
@plugins.smartdata.svDb()
|
|
public bounceCategory: TBounceCategory;
|
|
|
|
/**
|
|
* SMTP response code
|
|
*/
|
|
@plugins.smartdata.svDb()
|
|
public smtpCode: number;
|
|
|
|
/**
|
|
* Full SMTP response message
|
|
*/
|
|
@plugins.smartdata.svDb()
|
|
public smtpResponse: string;
|
|
|
|
/**
|
|
* Diagnostic code from DSN
|
|
*/
|
|
@plugins.smartdata.svDb()
|
|
public diagnosticCode: string;
|
|
|
|
/**
|
|
* Original message ID that bounced
|
|
*/
|
|
@plugins.smartdata.svDb()
|
|
public originalMessageId: string;
|
|
|
|
/**
|
|
* Number of bounces for this recipient
|
|
*/
|
|
@plugins.smartdata.svDb()
|
|
public bounceCount: number = 1;
|
|
|
|
/**
|
|
* Timestamp of the first bounce
|
|
*/
|
|
@plugins.smartdata.svDb()
|
|
public firstBounceAt: Date;
|
|
|
|
/**
|
|
* Timestamp of the most recent bounce
|
|
*/
|
|
@plugins.smartdata.svDb()
|
|
public lastBounceAt: Date;
|
|
|
|
constructor() {
|
|
super();
|
|
this.setTTL(TTL.DAYS_30); // Default 30-day TTL
|
|
this.bounceType = 'unknown';
|
|
this.bounceCategory = 'other';
|
|
this.firstBounceAt = new Date();
|
|
this.lastBounceAt = new Date();
|
|
}
|
|
|
|
/**
|
|
* Create a new bounce record
|
|
*/
|
|
public static createNew(): CachedBounce {
|
|
const bounce = new CachedBounce();
|
|
bounce.id = plugins.uuid.v4();
|
|
return bounce;
|
|
}
|
|
|
|
/**
|
|
* Find bounces by recipient email
|
|
*/
|
|
public static async findByRecipient(recipient: string): Promise<CachedBounce[]> {
|
|
return await CachedBounce.getInstances({
|
|
recipient,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Find bounces by domain
|
|
*/
|
|
public static async findByDomain(domain: string): Promise<CachedBounce[]> {
|
|
return await CachedBounce.getInstances({
|
|
domain,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Find all hard bounces
|
|
*/
|
|
public static async findHardBounces(): Promise<CachedBounce[]> {
|
|
return await CachedBounce.getInstances({
|
|
bounceType: 'hard',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Find bounces by category
|
|
*/
|
|
public static async findByCategory(category: TBounceCategory): Promise<CachedBounce[]> {
|
|
return await CachedBounce.getInstances({
|
|
bounceCategory: category,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if a recipient has recent hard bounces
|
|
*/
|
|
public static async hasRecentHardBounce(recipient: string): Promise<boolean> {
|
|
const bounces = await CachedBounce.getInstances({
|
|
recipient,
|
|
bounceType: 'hard',
|
|
});
|
|
return bounces.length > 0;
|
|
}
|
|
|
|
/**
|
|
* Record an additional bounce for the same recipient
|
|
*/
|
|
public recordAdditionalBounce(smtpCode?: number, smtpResponse?: string): void {
|
|
this.bounceCount++;
|
|
this.lastBounceAt = new Date();
|
|
if (smtpCode) {
|
|
this.smtpCode = smtpCode;
|
|
}
|
|
if (smtpResponse) {
|
|
this.smtpResponse = smtpResponse;
|
|
}
|
|
this.touch();
|
|
}
|
|
|
|
/**
|
|
* Extract domain from recipient email
|
|
*/
|
|
public updateDomain(): void {
|
|
if (this.recipient) {
|
|
const match = this.recipient.match(/@([^>]+)>?$/);
|
|
if (match) {
|
|
this.domain = match[1].toLowerCase();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Classify bounce based on SMTP code
|
|
*/
|
|
public classifyFromSmtpCode(code: number): void {
|
|
this.smtpCode = code;
|
|
|
|
// 5xx = permanent failure (hard bounce)
|
|
if (code >= 500 && code < 600) {
|
|
this.bounceType = 'hard';
|
|
|
|
if (code === 550) {
|
|
this.bounceCategory = 'invalid-recipient';
|
|
} else if (code === 551) {
|
|
this.bounceCategory = 'policy-rejection';
|
|
} else if (code === 552) {
|
|
this.bounceCategory = 'mailbox-full';
|
|
} else if (code === 553) {
|
|
this.bounceCategory = 'invalid-recipient';
|
|
} else if (code === 554) {
|
|
this.bounceCategory = 'spam-rejection';
|
|
}
|
|
}
|
|
// 4xx = temporary failure (soft bounce)
|
|
else if (code >= 400 && code < 500) {
|
|
this.bounceType = 'soft';
|
|
|
|
if (code === 421) {
|
|
this.bounceCategory = 'rate-limited';
|
|
} else if (code === 450) {
|
|
this.bounceCategory = 'mailbox-full';
|
|
} else if (code === 451) {
|
|
this.bounceCategory = 'connection-failed';
|
|
} else if (code === 452) {
|
|
this.bounceCategory = 'rate-limited';
|
|
}
|
|
}
|
|
}
|
|
}
|