BREAKING CHANGE(config): convert configuration management to read-only; remove updateConfiguration endpoint and client-side editing

This commit is contained in:
2026-02-03 23:26:51 +00:00
parent 5de3344905
commit 9e0e77737b
25 changed files with 2129 additions and 269 deletions

View File

@@ -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
View 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
View 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
View 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;
}
}

View 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';
}
}
}
}

View 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;
}
}

View 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();
}
}
}
}

View 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;
}
}

View 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
View 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
View 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';

View File

@@ -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;
}
}

View File

@@ -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';