BREAKING CHANGE(email-ops): migrate email operations to catalog-compatible email model and simplify UI/router
This commit is contained in:
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user