BREAKING CHANGE(email-ops): migrate email operations to catalog-compatible email model and simplify UI/router

This commit is contained in:
2026-02-22 00:45:01 +00:00
parent 4759c4f011
commit 48d3d1218f
12 changed files with 338 additions and 1561 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '7.4.3',
version: '8.0.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -1,7 +1,6 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { SecurityLogger } from '../../security/index.js';
export class EmailOpsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -13,68 +12,24 @@ export class EmailOpsHandler {
}
private registerHandlers(): void {
// Get Queued Emails Handler
// Get All Emails Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueuedEmails>(
'getQueuedEmails',
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAllEmails>(
'getAllEmails',
async (dataArg) => {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer?.deliveryQueue) {
return { items: [], total: 0 };
}
const queue = emailServer.deliveryQueue;
const stats = queue.getStats();
// Get all queue items and filter by status if provided
const items = this.getQueueItems(
dataArg.status,
dataArg.limit || 50,
dataArg.offset || 0
);
return {
items,
total: stats.queueSize,
};
const emails = this.getAllQueueEmails();
return { emails };
}
)
);
// Get Sent Emails Handler
// Get Email Detail Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSentEmails>(
'getSentEmails',
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDetail>(
'getEmailDetail',
async (dataArg) => {
const items = this.getQueueItems(
'delivered',
dataArg.limit || 50,
dataArg.offset || 0
);
return {
items,
total: items.length, // Note: total would ideally come from a counter
};
}
)
);
// Get Failed Emails Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetFailedEmails>(
'getFailedEmails',
async (dataArg) => {
const items = this.getQueueItems(
'failed',
dataArg.limit || 50,
dataArg.offset || 0
);
return {
items,
total: items.length,
};
const email = this.getEmailDetail(dataArg.emailId);
return { email };
}
)
);
@@ -101,17 +56,12 @@ export class EmailOpsHandler {
}
try {
// Re-enqueue the failed email by creating a new queue entry
// with the same data but reset attempt count
const newQueueId = await queue.enqueue(
item.processingResult,
item.processingMode,
item.route
);
// Optionally remove the old failed entry
await queue.removeItem(dataArg.emailId);
return { success: true, newQueueId };
} catch (error) {
return {
@@ -122,197 +72,199 @@ export class EmailOpsHandler {
}
)
);
// Get Security Incidents Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityIncidents>(
'getSecurityIncidents',
async (dataArg) => {
const securityLogger = SecurityLogger.getInstance();
const filter: {
level?: any;
type?: any;
} = {};
if (dataArg.level) {
filter.level = dataArg.level;
}
if (dataArg.type) {
filter.type = dataArg.type;
}
const incidents = securityLogger.getRecentEvents(
dataArg.limit || 100,
Object.keys(filter).length > 0 ? filter : undefined
);
return {
incidents: incidents.map(event => ({
timestamp: event.timestamp,
level: event.level as interfaces.requests.TSecurityLogLevel,
type: event.type as interfaces.requests.TSecurityEventType,
message: event.message,
details: event.details,
ipAddress: event.ipAddress,
userId: event.userId,
sessionId: event.sessionId,
emailId: event.emailId,
domain: event.domain,
action: event.action,
result: event.result,
success: event.success,
})),
total: incidents.length,
};
}
)
);
// Get Bounce Records Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBounceRecords>(
'getBounceRecords',
async (dataArg) => {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer) {
return { records: [], suppressionList: [], total: 0 };
}
// Use smartmta's public API for bounce/suppression data
const suppressionList = emailServer.getSuppressionList();
const hardBouncedAddresses = emailServer.getHardBouncedAddresses();
// Create bounce records from the available data
const records: interfaces.requests.IBounceRecord[] = [];
for (const email of hardBouncedAddresses) {
const bounceInfo = emailServer.getBounceHistory(email);
if (bounceInfo) {
records.push({
id: `bounce-${email}`,
recipient: email,
sender: '',
domain: email.split('@')[1] || '',
bounceType: (bounceInfo as any).type as interfaces.requests.TBounceType,
bounceCategory: (bounceInfo as any).category as interfaces.requests.TBounceCategory,
timestamp: (bounceInfo as any).lastBounce,
processed: true,
});
}
}
// Apply limit and offset
const limit = dataArg.limit || 50;
const offset = dataArg.offset || 0;
const paginatedRecords = records.slice(offset, offset + limit);
return {
records: paginatedRecords,
suppressionList,
total: records.length,
};
}
)
);
// Remove from Suppression List Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveFromSuppressionList>(
'removeFromSuppressionList',
async (dataArg) => {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer) {
return { success: false, error: 'Email server not available' };
}
try {
emailServer.removeFromSuppressionList(dataArg.email);
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to remove from suppression list'
};
}
}
)
);
}
/**
* Helper method to get queue items with filtering and pagination
* Get all queue items mapped to catalog IEmail format
*/
private getQueueItems(
status?: interfaces.requests.TEmailQueueStatus,
limit: number = 50,
offset: number = 0
): interfaces.requests.IEmailQueueItem[] {
private getAllQueueEmails(): interfaces.requests.IEmail[] {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer?.deliveryQueue) {
return [];
}
const queue = emailServer.deliveryQueue;
const items: interfaces.requests.IEmailQueueItem[] = [];
// Access the internal queue map via reflection
// This is necessary because the queue doesn't expose iteration methods
const queueMap = (queue as any).queue as Map<string, any>;
if (!queueMap) {
return [];
}
// Filter and convert items
const emails: interfaces.requests.IEmail[] = [];
for (const [id, item] of queueMap.entries()) {
// Apply status filter if provided
if (status && item.status !== status) {
continue;
}
// Extract email details from processingResult if available
const processingResult = item.processingResult;
let from = '';
let to: string[] = [];
let subject = '';
if (processingResult) {
// Check if it's an Email object or raw email data
if (processingResult.email) {
from = processingResult.email.from || '';
to = processingResult.email.to || [];
subject = processingResult.email.subject || '';
} else if (processingResult.from) {
from = processingResult.from;
to = processingResult.to || [];
subject = processingResult.subject || '';
}
}
items.push({
id: item.id,
processingMode: item.processingMode,
status: item.status,
attempts: item.attempts,
nextAttempt: item.nextAttempt instanceof Date ? item.nextAttempt.getTime() : item.nextAttempt,
lastError: item.lastError,
createdAt: item.createdAt instanceof Date ? item.createdAt.getTime() : item.createdAt,
updatedAt: item.updatedAt instanceof Date ? item.updatedAt.getTime() : item.updatedAt,
deliveredAt: item.deliveredAt instanceof Date ? item.deliveredAt.getTime() : item.deliveredAt,
from,
to,
subject,
});
emails.push(this.mapQueueItemToEmail(item));
}
// Sort by createdAt descending (newest first)
items.sort((a, b) => b.createdAt - a.createdAt);
emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
// Apply pagination
return items.slice(offset, offset + limit);
return emails;
}
/**
* Get a single email detail by ID
*/
private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer?.deliveryQueue) {
return null;
}
const queue = emailServer.deliveryQueue;
const item = queue.getItem(emailId);
if (!item) {
return null;
}
return this.mapQueueItemToEmailDetail(item);
}
/**
* Map a queue item to catalog IEmail format
*/
private mapQueueItemToEmail(item: any): interfaces.requests.IEmail {
const processingResult = item.processingResult;
let from = '';
let to = '';
let subject = '';
let messageId = '';
let size = '0 B';
if (processingResult) {
if (processingResult.email) {
from = processingResult.email.from || '';
to = (processingResult.email.to || [])[0] || '';
subject = processingResult.email.subject || '';
} else if (processingResult.from) {
from = processingResult.from;
to = (processingResult.to || [])[0] || '';
subject = processingResult.subject || '';
}
// Try to get messageId
if (typeof processingResult.getMessageId === 'function') {
try {
messageId = processingResult.getMessageId() || '';
} catch {
messageId = '';
}
}
// Compute approximate size
const textLen = processingResult.text?.length || 0;
const htmlLen = processingResult.html?.length || 0;
let attachSize = 0;
if (typeof processingResult.getAttachmentsSize === 'function') {
try {
attachSize = processingResult.getAttachmentsSize() || 0;
} catch {
attachSize = 0;
}
}
size = this.formatSize(textLen + htmlLen + attachSize);
}
// Map queue status to catalog TEmailStatus
const status = this.mapStatus(item.status);
const createdAt = item.createdAt instanceof Date ? item.createdAt.getTime() : item.createdAt;
return {
id: item.id,
direction: 'outbound' as interfaces.requests.TEmailDirection,
status,
from,
to,
subject,
timestamp: new Date(createdAt).toISOString(),
messageId,
size,
};
}
/**
* Map a queue item to catalog IEmailDetail format
*/
private mapQueueItemToEmailDetail(item: any): interfaces.requests.IEmailDetail {
const base = this.mapQueueItemToEmail(item);
const processingResult = item.processingResult;
let toList: string[] = [];
let cc: string[] = [];
let headers: Record<string, string> = {};
let body = '';
if (processingResult) {
if (processingResult.email) {
toList = processingResult.email.to || [];
cc = processingResult.email.cc || [];
} else {
toList = processingResult.to || [];
cc = processingResult.cc || [];
}
headers = processingResult.headers || {};
body = processingResult.html || processingResult.text || '';
}
return {
...base,
toList,
cc,
smtpLog: [],
connectionInfo: {
sourceIp: '',
sourceHostname: '',
destinationIp: '',
destinationPort: 0,
tlsVersion: '',
tlsCipher: '',
authenticated: false,
authMethod: '',
authUser: '',
},
authenticationResults: {
spf: 'none',
spfDomain: '',
dkim: 'none',
dkimDomain: '',
dmarc: 'none',
dmarcPolicy: '',
},
rejectionReason: item.status === 'failed' ? item.lastError : undefined,
bounceMessage: item.status === 'failed' ? item.lastError : undefined,
headers,
body,
};
}
/**
* Map queue status to catalog TEmailStatus
*/
private mapStatus(queueStatus: string): interfaces.requests.TEmailStatus {
switch (queueStatus) {
case 'pending':
case 'processing':
return 'pending';
case 'delivered':
return 'delivered';
case 'failed':
return 'bounced';
case 'deferred':
return 'deferred';
default:
return 'pending';
}
}
/**
* Format byte size to human-readable string
*/
private formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
}