BREAKING CHANGE(config): convert configuration management to read-only; remove updateConfiguration endpoint and client-side editing
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '3.1.0',
|
||||
version: '4.0.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
170
ts/cache/classes.cache.cleaner.ts
vendored
Normal file
170
ts/cache/classes.cache.cleaner.ts
vendored
Normal file
@@ -0,0 +1,170 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { CacheDb } from './classes.cachedb.js';
|
||||
|
||||
// Import document classes for cleanup
|
||||
import { CachedEmail } from './documents/classes.cached.email.js';
|
||||
import { CachedIPReputation } from './documents/classes.cached.ip.reputation.js';
|
||||
import { CachedBounce } from './documents/classes.cached.bounce.js';
|
||||
import { CachedSuppression } from './documents/classes.cached.suppression.js';
|
||||
import { CachedDKIMKey } from './documents/classes.cached.dkim.js';
|
||||
|
||||
/**
|
||||
* Configuration for the cache cleaner
|
||||
*/
|
||||
export interface ICacheCleanerOptions {
|
||||
/** Cleanup interval in milliseconds (default: 1 hour) */
|
||||
intervalMs?: number;
|
||||
/** Enable verbose logging */
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CacheCleaner - Periodically removes expired documents from the cache
|
||||
*
|
||||
* Runs on a configurable interval (default: hourly) and queries each
|
||||
* collection for documents where expiresAt < now(), then deletes them.
|
||||
*/
|
||||
export class CacheCleaner {
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private isRunning: boolean = false;
|
||||
private options: Required<ICacheCleanerOptions>;
|
||||
private cacheDb: CacheDb;
|
||||
|
||||
constructor(cacheDb: CacheDb, options: ICacheCleanerOptions = {}) {
|
||||
this.cacheDb = cacheDb;
|
||||
this.options = {
|
||||
intervalMs: options.intervalMs || 60 * 60 * 1000, // 1 hour default
|
||||
verbose: options.verbose || false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the periodic cleanup process
|
||||
*/
|
||||
public start(): void {
|
||||
if (this.isRunning) {
|
||||
logger.log('warn', 'CacheCleaner already running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
|
||||
// Run cleanup immediately on start
|
||||
this.runCleanup().catch((error) => {
|
||||
logger.log('error', `Initial cache cleanup failed: ${error.message}`);
|
||||
});
|
||||
|
||||
// Schedule periodic cleanup
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.runCleanup().catch((error) => {
|
||||
logger.log('error', `Cache cleanup failed: ${error.message}`);
|
||||
});
|
||||
}, this.options.intervalMs);
|
||||
|
||||
logger.log(
|
||||
'info',
|
||||
`CacheCleaner started with interval: ${this.options.intervalMs / 1000 / 60} minutes`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the periodic cleanup process
|
||||
*/
|
||||
public stop(): void {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
|
||||
this.isRunning = false;
|
||||
logger.log('info', 'CacheCleaner stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single cleanup cycle
|
||||
*/
|
||||
public async runCleanup(): Promise<void> {
|
||||
if (!this.cacheDb.isReady()) {
|
||||
logger.log('warn', 'CacheDb not ready, skipping cleanup');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const results: { collection: string; deleted: number }[] = [];
|
||||
|
||||
try {
|
||||
// Clean CachedEmail documents
|
||||
const emailsDeleted = await this.cleanCollection(CachedEmail, now);
|
||||
results.push({ collection: 'CachedEmail', deleted: emailsDeleted });
|
||||
|
||||
// Clean CachedIPReputation documents
|
||||
const ipReputationDeleted = await this.cleanCollection(CachedIPReputation, now);
|
||||
results.push({ collection: 'CachedIPReputation', deleted: ipReputationDeleted });
|
||||
|
||||
// Clean CachedBounce documents
|
||||
const bouncesDeleted = await this.cleanCollection(CachedBounce, now);
|
||||
results.push({ collection: 'CachedBounce', deleted: bouncesDeleted });
|
||||
|
||||
// Clean CachedSuppression documents (but not permanent ones)
|
||||
const suppressionDeleted = await this.cleanCollection(CachedSuppression, now);
|
||||
results.push({ collection: 'CachedSuppression', deleted: suppressionDeleted });
|
||||
|
||||
// Clean CachedDKIMKey documents
|
||||
const dkimDeleted = await this.cleanCollection(CachedDKIMKey, now);
|
||||
results.push({ collection: 'CachedDKIMKey', deleted: dkimDeleted });
|
||||
|
||||
// Log results
|
||||
const totalDeleted = results.reduce((sum, r) => sum + r.deleted, 0);
|
||||
if (totalDeleted > 0 || this.options.verbose) {
|
||||
const summary = results
|
||||
.filter((r) => r.deleted > 0)
|
||||
.map((r) => `${r.collection}: ${r.deleted}`)
|
||||
.join(', ');
|
||||
logger.log(
|
||||
'info',
|
||||
`Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Cache cleanup error: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean expired documents from a specific collection
|
||||
*/
|
||||
private async cleanCollection<T>(
|
||||
documentClass: { deleteMany: (filter: any) => Promise<any> },
|
||||
now: Date
|
||||
): Promise<number> {
|
||||
try {
|
||||
const result = await documentClass.deleteMany({
|
||||
expiresAt: { $lt: now },
|
||||
});
|
||||
return result?.deletedCount || 0;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error cleaning collection: ${error.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the cleaner is running
|
||||
*/
|
||||
public isActive(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cleanup interval in milliseconds
|
||||
*/
|
||||
public getIntervalMs(): number {
|
||||
return this.options.intervalMs;
|
||||
}
|
||||
}
|
||||
108
ts/cache/classes.cached.document.ts
vendored
Normal file
108
ts/cache/classes.cached.document.ts
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
/**
|
||||
* Base class for all cached documents with TTL support
|
||||
*
|
||||
* Extends smartdata's SmartDataDbDoc to add:
|
||||
* - Automatic timestamps (createdAt, lastAccessedAt)
|
||||
* - TTL/expiration support (expiresAt)
|
||||
* - Helper methods for TTL management
|
||||
*/
|
||||
export abstract class CachedDocument<T extends CachedDocument<T>> extends plugins.smartdata.SmartDataDbDoc<T, T> {
|
||||
/**
|
||||
* Timestamp when the document was created
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Timestamp when the document expires and should be cleaned up
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public expiresAt: Date;
|
||||
|
||||
/**
|
||||
* Timestamp of last access (for LRU-style eviction if needed)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public lastAccessedAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Set the TTL (time to live) for this document
|
||||
* @param ttlMs Time to live in milliseconds
|
||||
*/
|
||||
public setTTL(ttlMs: number): void {
|
||||
this.expiresAt = new Date(Date.now() + ttlMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set TTL using days
|
||||
* @param days Number of days until expiration
|
||||
*/
|
||||
public setTTLDays(days: number): void {
|
||||
this.setTTL(days * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set TTL using hours
|
||||
* @param hours Number of hours until expiration
|
||||
*/
|
||||
public setTTLHours(hours: number): void {
|
||||
this.setTTL(hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this document has expired
|
||||
*/
|
||||
public isExpired(): boolean {
|
||||
if (!this.expiresAt) {
|
||||
return false; // No expiration set
|
||||
}
|
||||
return new Date() > this.expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the lastAccessedAt timestamp
|
||||
*/
|
||||
public touch(): void {
|
||||
this.lastAccessedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining TTL in milliseconds
|
||||
* Returns 0 if expired, -1 if no expiration set
|
||||
*/
|
||||
public getRemainingTTL(): number {
|
||||
if (!this.expiresAt) {
|
||||
return -1;
|
||||
}
|
||||
const remaining = this.expiresAt.getTime() - Date.now();
|
||||
return remaining > 0 ? remaining : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the TTL by the specified milliseconds from now
|
||||
* @param ttlMs Additional time to live in milliseconds
|
||||
*/
|
||||
public extendTTL(ttlMs: number): void {
|
||||
this.expiresAt = new Date(Date.now() + ttlMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the document to never expire (100 years in the future)
|
||||
*/
|
||||
public setNeverExpires(): void {
|
||||
this.expiresAt = new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TTL constants in milliseconds
|
||||
*/
|
||||
export const TTL = {
|
||||
HOURS_1: 1 * 60 * 60 * 1000,
|
||||
HOURS_24: 24 * 60 * 60 * 1000,
|
||||
DAYS_7: 7 * 24 * 60 * 60 * 1000,
|
||||
DAYS_30: 30 * 24 * 60 * 60 * 1000,
|
||||
DAYS_90: 90 * 24 * 60 * 60 * 1000,
|
||||
} as const;
|
||||
152
ts/cache/classes.cachedb.ts
vendored
Normal file
152
ts/cache/classes.cachedb.ts
vendored
Normal file
@@ -0,0 +1,152 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
* Configuration options for CacheDb
|
||||
*/
|
||||
export interface ICacheDbOptions {
|
||||
/** Base storage path for TsmDB data (default: /etc/dcrouter/tsmdb) */
|
||||
storagePath?: string;
|
||||
/** Database name (default: dcrouter) */
|
||||
dbName?: string;
|
||||
/** Enable debug logging */
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CacheDb - Wrapper around LocalTsmDb and smartdata
|
||||
*
|
||||
* Provides persistent caching using smartdata as the ORM layer
|
||||
* and LocalTsmDb as the embedded database engine.
|
||||
*/
|
||||
export class CacheDb {
|
||||
private static instance: CacheDb | null = null;
|
||||
|
||||
private localTsmDb: plugins.smartmongo.LocalTsmDb;
|
||||
private smartdataDb: plugins.smartdata.SmartdataDb;
|
||||
private options: Required<ICacheDbOptions>;
|
||||
private isStarted: boolean = false;
|
||||
|
||||
constructor(options: ICacheDbOptions = {}) {
|
||||
this.options = {
|
||||
storagePath: options.storagePath || '/etc/dcrouter/tsmdb',
|
||||
dbName: options.dbName || 'dcrouter',
|
||||
debug: options.debug || false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the singleton instance
|
||||
*/
|
||||
public static getInstance(options?: ICacheDbOptions): CacheDb {
|
||||
if (!CacheDb.instance) {
|
||||
CacheDb.instance = new CacheDb(options);
|
||||
}
|
||||
return CacheDb.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (useful for testing)
|
||||
*/
|
||||
public static resetInstance(): void {
|
||||
CacheDb.instance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the cache database
|
||||
* - Initializes LocalTsmDb with file persistence
|
||||
* - Connects smartdata to the LocalTsmDb via Unix socket
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
if (this.isStarted) {
|
||||
logger.log('warn', 'CacheDb already started');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure storage directory exists
|
||||
await plugins.fsUtils.ensureDir(this.options.storagePath);
|
||||
|
||||
// Create LocalTsmDb instance
|
||||
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({
|
||||
dbDir: this.options.storagePath,
|
||||
});
|
||||
|
||||
// Start LocalTsmDb and get connection URI
|
||||
await this.localTsmDb.start();
|
||||
const mongoDescriptor = this.localTsmDb.mongoDescriptor;
|
||||
|
||||
if (this.options.debug) {
|
||||
logger.log('debug', `LocalTsmDb started with descriptor: ${JSON.stringify(mongoDescriptor)}`);
|
||||
}
|
||||
|
||||
// Initialize smartdata with the connection
|
||||
this.smartdataDb = new plugins.smartdata.SmartdataDb(mongoDescriptor);
|
||||
await this.smartdataDb.init();
|
||||
|
||||
this.isStarted = true;
|
||||
logger.log('info', `CacheDb started at ${this.options.storagePath}`);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to start CacheDb: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the cache database
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.isStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Close smartdata connection
|
||||
if (this.smartdataDb) {
|
||||
await this.smartdataDb.close();
|
||||
}
|
||||
|
||||
// Stop LocalTsmDb
|
||||
if (this.localTsmDb) {
|
||||
await this.localTsmDb.stop();
|
||||
}
|
||||
|
||||
this.isStarted = false;
|
||||
logger.log('info', 'CacheDb stopped');
|
||||
} catch (error) {
|
||||
logger.log('error', `Error stopping CacheDb: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the smartdata database instance
|
||||
*/
|
||||
public getDb(): plugins.smartdata.SmartdataDb {
|
||||
if (!this.isStarted) {
|
||||
throw new Error('CacheDb not started. Call start() first.');
|
||||
}
|
||||
return this.smartdataDb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the database is ready
|
||||
*/
|
||||
public isReady(): boolean {
|
||||
return this.isStarted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage path
|
||||
*/
|
||||
public getStoragePath(): string {
|
||||
return this.options.storagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database name
|
||||
*/
|
||||
public getDbName(): string {
|
||||
return this.options.dbName;
|
||||
}
|
||||
}
|
||||
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';
|
||||
7
ts/cache/index.ts
vendored
Normal file
7
ts/cache/index.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
// Core cache infrastructure
|
||||
export * from './classes.cachedb.js';
|
||||
export * from './classes.cached.document.js';
|
||||
export * from './classes.cache.cleaner.js';
|
||||
|
||||
// Document classes
|
||||
export * from './documents/index.js';
|
||||
@@ -1,19 +1,18 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { requireAdminIdentity } from '../helpers/guards.js';
|
||||
|
||||
export class ConfigHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get Configuration Handler
|
||||
// Get Configuration Handler (read-only)
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
|
||||
'getConfiguration',
|
||||
@@ -26,33 +25,6 @@ export class ConfigHandler {
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Update Configuration Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateConfiguration>(
|
||||
'updateConfiguration',
|
||||
async (dataArg, toolsArg) => {
|
||||
try {
|
||||
// Require admin access to update configuration
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
|
||||
const updatedConfig = await this.updateConfiguration(dataArg.section, dataArg.config);
|
||||
return {
|
||||
updated: true,
|
||||
config: updatedConfig,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) {
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
updated: false,
|
||||
config: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async getConfiguration(section?: string): Promise<{
|
||||
@@ -133,31 +105,4 @@ export class ConfigHandler {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async updateConfiguration(section: string, config: any): Promise<any> {
|
||||
// TODO: Implement actual configuration updates
|
||||
// This would involve:
|
||||
// 1. Validating the configuration changes
|
||||
// 2. Applying them to the running services
|
||||
// 3. Persisting them to storage
|
||||
// 4. Potentially restarting affected services
|
||||
|
||||
// For now, just validate and return the config
|
||||
if (section === 'email' && config.maxMessageSize && config.maxMessageSize < 1024) {
|
||||
throw new Error('Maximum message size must be at least 1KB');
|
||||
}
|
||||
|
||||
if (section === 'dns' && config.ttl && (config.ttl < 0 || config.ttl > 86400)) {
|
||||
throw new Error('DNS TTL must be between 0 and 86400 seconds');
|
||||
}
|
||||
|
||||
if (section === 'proxy' && config.maxConnections && config.maxConnections < 1) {
|
||||
throw new Error('Maximum connections must be at least 1');
|
||||
}
|
||||
|
||||
// In a real implementation, apply the changes here
|
||||
// For now, return the current configuration
|
||||
const currentConfig = await this.getConfiguration(section);
|
||||
return currentConfig;
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import * as smartjwt from '@push.rocks/smartjwt';
|
||||
import * as smartlog from '@push.rocks/smartlog';
|
||||
import * as smartmail from '@push.rocks/smartmail';
|
||||
import * as smartmetrics from '@push.rocks/smartmetrics';
|
||||
import * as smartmongo from '@push.rocks/smartmongo';
|
||||
import * as smartnetwork from '@push.rocks/smartnetwork';
|
||||
import * as smartpath from '@push.rocks/smartpath';
|
||||
import * as smartproxy from '@push.rocks/smartproxy';
|
||||
@@ -61,7 +62,7 @@ import * as smartrule from '@push.rocks/smartrule';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import * as smartunique from '@push.rocks/smartunique';
|
||||
|
||||
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmail, smartmetrics, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrule, smartrx, smartunique };
|
||||
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmail, smartmetrics, smartmongo, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrule, smartrx, smartunique };
|
||||
|
||||
// Define SmartLog types for use in error handling
|
||||
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
||||
|
||||
Reference in New Issue
Block a user