BREAKING CHANGE(config): convert configuration management to read-only; remove updateConfiguration endpoint and client-side editing
This commit is contained in:
244
ts/cache/documents/classes.cached.bounce.ts
vendored
Normal file
244
ts/cache/documents/classes.cached.bounce.ts
vendored
Normal file
@@ -0,0 +1,244 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
241
ts/cache/documents/classes.cached.dkim.ts
vendored
Normal file
241
ts/cache/documents/classes.cached.dkim.ts
vendored
Normal file
@@ -0,0 +1,241 @@
|
||||
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();
|
||||
|
||||
/**
|
||||
* CachedDKIMKey - Stores DKIM key pairs for email signing
|
||||
*
|
||||
* Caches DKIM private/public key pairs per domain and selector.
|
||||
* Default TTL is 90 days (typical key rotation interval).
|
||||
*/
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class CachedDKIMKey extends CachedDocument<CachedDKIMKey> {
|
||||
/**
|
||||
* Composite key: domain:selector
|
||||
*/
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public domainSelector: string;
|
||||
|
||||
/**
|
||||
* Domain for this DKIM key
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public domain: string;
|
||||
|
||||
/**
|
||||
* DKIM selector (e.g., 'mta', 'default', '2024')
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public selector: string;
|
||||
|
||||
/**
|
||||
* Private key in PEM format
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public privateKey: string;
|
||||
|
||||
/**
|
||||
* Public key in PEM format
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public publicKey: string;
|
||||
|
||||
/**
|
||||
* Public key for DNS TXT record (base64, no headers)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public publicKeyDns: string;
|
||||
|
||||
/**
|
||||
* Key size in bits (e.g., 1024, 2048)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public keySize: number = 2048;
|
||||
|
||||
/**
|
||||
* Key algorithm (e.g., 'rsa-sha256')
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public algorithm: string = 'rsa-sha256';
|
||||
|
||||
/**
|
||||
* When the key was generated
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public generatedAt: Date;
|
||||
|
||||
/**
|
||||
* When the key was last rotated
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public rotatedAt: Date;
|
||||
|
||||
/**
|
||||
* Previous selector (for key rotation)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public previousSelector: string;
|
||||
|
||||
/**
|
||||
* Number of emails signed with this key
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public signCount: number = 0;
|
||||
|
||||
/**
|
||||
* Whether this key is currently active
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isActive: boolean = true;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.setTTL(TTL.DAYS_90); // Default 90-day TTL
|
||||
this.generatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the composite key from domain and selector
|
||||
*/
|
||||
public static createDomainSelector(domain: string, selector: string): string {
|
||||
return `${domain.toLowerCase()}:${selector.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new DKIM key entry
|
||||
*/
|
||||
public static createNew(domain: string, selector: string): CachedDKIMKey {
|
||||
const key = new CachedDKIMKey();
|
||||
key.domain = domain.toLowerCase();
|
||||
key.selector = selector.toLowerCase();
|
||||
key.domainSelector = CachedDKIMKey.createDomainSelector(domain, selector);
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find by domain and selector
|
||||
*/
|
||||
public static async findByDomainSelector(
|
||||
domain: string,
|
||||
selector: string
|
||||
): Promise<CachedDKIMKey | null> {
|
||||
const domainSelector = CachedDKIMKey.createDomainSelector(domain, selector);
|
||||
return await CachedDKIMKey.getInstance({
|
||||
domainSelector,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all keys for a domain
|
||||
*/
|
||||
public static async findByDomain(domain: string): Promise<CachedDKIMKey[]> {
|
||||
return await CachedDKIMKey.getInstances({
|
||||
domain: domain.toLowerCase(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the active key for a domain
|
||||
*/
|
||||
public static async findActiveForDomain(domain: string): Promise<CachedDKIMKey | null> {
|
||||
const keys = await CachedDKIMKey.getInstances({
|
||||
domain: domain.toLowerCase(),
|
||||
isActive: true,
|
||||
});
|
||||
return keys.length > 0 ? keys[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all active keys
|
||||
*/
|
||||
public static async findAllActive(): Promise<CachedDKIMKey[]> {
|
||||
return await CachedDKIMKey.getInstances({
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the key pair
|
||||
*/
|
||||
public setKeyPair(privateKey: string, publicKey: string, publicKeyDns?: string): void {
|
||||
this.privateKey = privateKey;
|
||||
this.publicKey = publicKey;
|
||||
this.publicKeyDns = publicKeyDns || this.extractPublicKeyDns(publicKey);
|
||||
this.generatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the base64 public key for DNS from PEM format
|
||||
*/
|
||||
private extractPublicKeyDns(publicKeyPem: string): string {
|
||||
// Remove PEM headers and newlines
|
||||
return publicKeyPem
|
||||
.replace(/-----BEGIN PUBLIC KEY-----/g, '')
|
||||
.replace(/-----END PUBLIC KEY-----/g, '')
|
||||
.replace(/\s/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the DNS TXT record value
|
||||
*/
|
||||
public getDnsTxtRecord(): string {
|
||||
return `v=DKIM1; k=rsa; p=${this.publicKeyDns}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full DNS record name
|
||||
*/
|
||||
public getDnsRecordName(): string {
|
||||
return `${this.selector}._domainkey.${this.domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that this key was used to sign an email
|
||||
*/
|
||||
public recordSign(): void {
|
||||
this.signCount++;
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate this key (e.g., during rotation)
|
||||
*/
|
||||
public deactivate(): void {
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate this key
|
||||
*/
|
||||
public activate(): void {
|
||||
this.isActive = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate to a new selector
|
||||
*/
|
||||
public rotate(newSelector: string): void {
|
||||
this.previousSelector = this.selector;
|
||||
this.selector = newSelector.toLowerCase();
|
||||
this.domainSelector = CachedDKIMKey.createDomainSelector(this.domain, this.selector);
|
||||
this.rotatedAt = new Date();
|
||||
this.signCount = 0;
|
||||
// Reset TTL on rotation
|
||||
this.setTTL(TTL.DAYS_90);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if key needs rotation (based on age or sign count)
|
||||
*/
|
||||
public needsRotation(maxAgeDays: number = 90, maxSignCount: number = 1000000): boolean {
|
||||
const ageMs = Date.now() - this.generatedAt.getTime();
|
||||
const ageDays = ageMs / (24 * 60 * 60 * 1000);
|
||||
return ageDays > maxAgeDays || this.signCount > maxSignCount;
|
||||
}
|
||||
}
|
||||
230
ts/cache/documents/classes.cached.email.ts
vendored
Normal file
230
ts/cache/documents/classes.cached.email.ts
vendored
Normal file
@@ -0,0 +1,230 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||
import { CacheDb } from '../classes.cachedb.js';
|
||||
|
||||
/**
|
||||
* Email status in the cache
|
||||
*/
|
||||
export type TCachedEmailStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred';
|
||||
|
||||
/**
|
||||
* Helper to get the smartdata database instance
|
||||
*/
|
||||
const getDb = () => CacheDb.getInstance().getDb();
|
||||
|
||||
/**
|
||||
* CachedEmail - Stores email queue items in the cache
|
||||
*
|
||||
* Used for persistent email queue storage, tracking delivery status,
|
||||
* and maintaining email history for the configured TTL period.
|
||||
*/
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||
/**
|
||||
* Unique identifier for this email
|
||||
*/
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id: string;
|
||||
|
||||
/**
|
||||
* Email message ID (RFC 822 Message-ID header)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public messageId: string;
|
||||
|
||||
/**
|
||||
* Sender email address (envelope from)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public from: string;
|
||||
|
||||
/**
|
||||
* Recipient email addresses
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public to: string[];
|
||||
|
||||
/**
|
||||
* CC recipients
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public cc: string[];
|
||||
|
||||
/**
|
||||
* BCC recipients
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public bcc: string[];
|
||||
|
||||
/**
|
||||
* Email subject
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public subject: string;
|
||||
|
||||
/**
|
||||
* Raw RFC822 email content
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public rawContent: string;
|
||||
|
||||
/**
|
||||
* Current status of the email
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public status: TCachedEmailStatus;
|
||||
|
||||
/**
|
||||
* Number of delivery attempts
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public attempts: number = 0;
|
||||
|
||||
/**
|
||||
* Maximum number of delivery attempts
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public maxAttempts: number = 3;
|
||||
|
||||
/**
|
||||
* Timestamp for next delivery attempt
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public nextAttempt: Date;
|
||||
|
||||
/**
|
||||
* Last error message if delivery failed
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public lastError: string;
|
||||
|
||||
/**
|
||||
* Timestamp when the email was successfully delivered
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public deliveredAt: Date;
|
||||
|
||||
/**
|
||||
* Sender domain (for querying/filtering)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public senderDomain: string;
|
||||
|
||||
/**
|
||||
* Priority level (higher = more important)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public priority: number = 0;
|
||||
|
||||
/**
|
||||
* JSON-serialized route data
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public routeData: string;
|
||||
|
||||
/**
|
||||
* DKIM signature status
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public dkimSigned: boolean = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.setTTL(TTL.DAYS_30); // Default 30-day TTL
|
||||
this.status = 'pending';
|
||||
this.to = [];
|
||||
this.cc = [];
|
||||
this.bcc = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new CachedEmail with a unique ID
|
||||
*/
|
||||
public static createNew(): CachedEmail {
|
||||
const email = new CachedEmail();
|
||||
email.id = plugins.uuid.v4();
|
||||
return email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an email by ID
|
||||
*/
|
||||
public static async findById(id: string): Promise<CachedEmail | null> {
|
||||
return await CachedEmail.getInstance({
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all emails with a specific status
|
||||
*/
|
||||
public static async findByStatus(status: TCachedEmailStatus): Promise<CachedEmail[]> {
|
||||
return await CachedEmail.getInstances({
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all emails pending delivery (status = pending and nextAttempt <= now)
|
||||
*/
|
||||
public static async findPendingForDelivery(): Promise<CachedEmail[]> {
|
||||
const now = new Date();
|
||||
return await CachedEmail.getInstances({
|
||||
status: 'pending',
|
||||
nextAttempt: { $lte: now },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find emails by sender domain
|
||||
*/
|
||||
public static async findBySenderDomain(domain: string): Promise<CachedEmail[]> {
|
||||
return await CachedEmail.getInstances({
|
||||
senderDomain: domain,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark as delivered
|
||||
*/
|
||||
public markDelivered(): void {
|
||||
this.status = 'delivered';
|
||||
this.deliveredAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark as failed with error
|
||||
*/
|
||||
public markFailed(error: string): void {
|
||||
this.status = 'failed';
|
||||
this.lastError = error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment attempt counter and schedule next attempt
|
||||
*/
|
||||
public scheduleRetry(delayMs: number = 5 * 60 * 1000): void {
|
||||
this.attempts++;
|
||||
this.status = 'deferred';
|
||||
this.nextAttempt = new Date(Date.now() + delayMs);
|
||||
|
||||
// If max attempts reached, mark as failed
|
||||
if (this.attempts >= this.maxAttempts) {
|
||||
this.status = 'failed';
|
||||
this.lastError = `Max attempts (${this.maxAttempts}) reached`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract sender domain from email address
|
||||
*/
|
||||
public updateSenderDomain(): void {
|
||||
if (this.from) {
|
||||
const match = this.from.match(/@([^>]+)>?$/);
|
||||
if (match) {
|
||||
this.senderDomain = match[1].toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
237
ts/cache/documents/classes.cached.ip.reputation.ts
vendored
Normal file
237
ts/cache/documents/classes.cached.ip.reputation.ts
vendored
Normal file
@@ -0,0 +1,237 @@
|
||||
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();
|
||||
|
||||
/**
|
||||
* IP reputation result data
|
||||
*/
|
||||
export interface IIPReputationData {
|
||||
score: number;
|
||||
isSpam: boolean;
|
||||
isProxy: boolean;
|
||||
isTor: boolean;
|
||||
isVPN: boolean;
|
||||
country?: string;
|
||||
asn?: string;
|
||||
org?: string;
|
||||
blacklists?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* CachedIPReputation - Stores IP reputation lookup results
|
||||
*
|
||||
* Caches the results of IP reputation checks to avoid repeated
|
||||
* external API calls. Default TTL is 24 hours.
|
||||
*/
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
|
||||
/**
|
||||
* IP address (unique identifier)
|
||||
*/
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public ipAddress: string;
|
||||
|
||||
/**
|
||||
* Reputation score (0-100, higher = better)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public score: number;
|
||||
|
||||
/**
|
||||
* Whether the IP is flagged as spam source
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isSpam: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a known proxy
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isProxy: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a Tor exit node
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isTor: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a VPN endpoint
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isVPN: boolean;
|
||||
|
||||
/**
|
||||
* Country code (ISO 3166-1 alpha-2)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public country: string;
|
||||
|
||||
/**
|
||||
* Autonomous System Number
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public asn: string;
|
||||
|
||||
/**
|
||||
* Organization name
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public org: string;
|
||||
|
||||
/**
|
||||
* List of blacklists the IP appears on
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public blacklists: string[];
|
||||
|
||||
/**
|
||||
* Number of times this IP has been checked
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public checkCount: number = 0;
|
||||
|
||||
/**
|
||||
* Number of connections from this IP
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public connectionCount: number = 0;
|
||||
|
||||
/**
|
||||
* Number of emails received from this IP
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public emailCount: number = 0;
|
||||
|
||||
/**
|
||||
* Number of spam emails from this IP
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public spamCount: number = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.setTTL(TTL.HOURS_24); // Default 24-hour TTL
|
||||
this.blacklists = [];
|
||||
this.score = 50; // Default neutral score
|
||||
this.isSpam = false;
|
||||
this.isProxy = false;
|
||||
this.isTor = false;
|
||||
this.isVPN = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from reputation data
|
||||
*/
|
||||
public static fromReputationData(ipAddress: string, data: IIPReputationData): CachedIPReputation {
|
||||
const cached = new CachedIPReputation();
|
||||
cached.ipAddress = ipAddress;
|
||||
cached.score = data.score;
|
||||
cached.isSpam = data.isSpam;
|
||||
cached.isProxy = data.isProxy;
|
||||
cached.isTor = data.isTor;
|
||||
cached.isVPN = data.isVPN;
|
||||
cached.country = data.country || '';
|
||||
cached.asn = data.asn || '';
|
||||
cached.org = data.org || '';
|
||||
cached.blacklists = data.blacklists || [];
|
||||
cached.checkCount = 1;
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to reputation data object
|
||||
*/
|
||||
public toReputationData(): IIPReputationData {
|
||||
this.touch();
|
||||
return {
|
||||
score: this.score,
|
||||
isSpam: this.isSpam,
|
||||
isProxy: this.isProxy,
|
||||
isTor: this.isTor,
|
||||
isVPN: this.isVPN,
|
||||
country: this.country,
|
||||
asn: this.asn,
|
||||
org: this.org,
|
||||
blacklists: this.blacklists,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find by IP address
|
||||
*/
|
||||
public static async findByIP(ipAddress: string): Promise<CachedIPReputation | null> {
|
||||
return await CachedIPReputation.getInstance({
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all IPs flagged as spam
|
||||
*/
|
||||
public static async findSpamIPs(): Promise<CachedIPReputation[]> {
|
||||
return await CachedIPReputation.getInstances({
|
||||
isSpam: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find IPs with score below threshold
|
||||
*/
|
||||
public static async findLowScoreIPs(threshold: number): Promise<CachedIPReputation[]> {
|
||||
return await CachedIPReputation.getInstances({
|
||||
score: { $lt: threshold },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a connection from this IP
|
||||
*/
|
||||
public recordConnection(): void {
|
||||
this.connectionCount++;
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an email from this IP
|
||||
*/
|
||||
public recordEmail(isSpam: boolean = false): void {
|
||||
this.emailCount++;
|
||||
if (isSpam) {
|
||||
this.spamCount++;
|
||||
}
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the reputation data
|
||||
*/
|
||||
public updateReputation(data: IIPReputationData): void {
|
||||
this.score = data.score;
|
||||
this.isSpam = data.isSpam;
|
||||
this.isProxy = data.isProxy;
|
||||
this.isTor = data.isTor;
|
||||
this.isVPN = data.isVPN;
|
||||
this.country = data.country || this.country;
|
||||
this.asn = data.asn || this.asn;
|
||||
this.org = data.org || this.org;
|
||||
this.blacklists = data.blacklists || this.blacklists;
|
||||
this.checkCount++;
|
||||
this.touch();
|
||||
// Refresh TTL on update
|
||||
this.setTTL(TTL.HOURS_24);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this IP should be blocked
|
||||
*/
|
||||
public shouldBlock(): boolean {
|
||||
return this.isSpam || this.score < 20 || this.blacklists.length > 2;
|
||||
}
|
||||
}
|
||||
262
ts/cache/documents/classes.cached.suppression.ts
vendored
Normal file
262
ts/cache/documents/classes.cached.suppression.ts
vendored
Normal file
@@ -0,0 +1,262 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
5
ts/cache/documents/index.ts
vendored
Normal file
5
ts/cache/documents/index.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './classes.cached.email.js';
|
||||
export * from './classes.cached.ip.reputation.js';
|
||||
export * from './classes.cached.bounce.js';
|
||||
export * from './classes.cached.suppression.js';
|
||||
export * from './classes.cached.dkim.js';
|
||||
Reference in New Issue
Block a user