BREAKING CHANGE(email-ops): migrate email operations to catalog-compatible email model and simplify UI/router
This commit is contained in:
10
changelog.md
10
changelog.md
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-22 - 8.0.0 - BREAKING CHANGE(email-ops)
|
||||
migrate email operations to catalog-compatible email model and simplify UI/router
|
||||
|
||||
- Add @serve.zone/catalog dependency and import (szCatalog) in web plugins
|
||||
- Replace queue-based typedrequest methods with catalog APIs: getQueuedEmails / getSentEmails / getFailedEmails => getAllEmails and getEmailDetail (request/response shapes changed)
|
||||
- Update TypeScript interfaces: IEmailQueueItem/IBounceRecord/ISecurityIncident etc. replaced by IEmail, IEmailDetail, ISmtpLogEntry, IConnectionInfo, IAuthenticationResults (breaking type changes)
|
||||
- Frontend state and actions consolidated: emailOps state now holds emails array; multiple fetch actions removed and replaced by fetchAllEmailsAction and getEmailDetail usage
|
||||
- UI components updated: ops-view-emails switched to list/detail view and now requests email detail via new API; router no longer exposes email folder routes and email-folder navigation removed
|
||||
- Ops server handler refactored to return catalog-style emails and email detail; added status mapping and size formatting helpers
|
||||
|
||||
## 2026-02-21 - 7.4.3 - fix(logging)
|
||||
add adaptive rate-limited DNS query logging, flush pending DNS logs on shutdown, and enhance email delivery logging
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstate": "^2.0.30",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@serve.zone/catalog": "^2.2.0",
|
||||
"@serve.zone/interfaces": "^5.3.0",
|
||||
"@serve.zone/remoteingress": "^4.0.0",
|
||||
"@tsclass/tsclass": "^9.3.0",
|
||||
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -92,6 +92,9 @@ importers:
|
||||
'@push.rocks/smartunique':
|
||||
specifier: ^3.0.9
|
||||
version: 3.0.9
|
||||
'@serve.zone/catalog':
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0(@tiptap/pm@2.27.2)
|
||||
'@serve.zone/interfaces':
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
@@ -1330,6 +1333,9 @@ packages:
|
||||
'@selderee/plugin-htmlparser2@0.11.0':
|
||||
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
|
||||
|
||||
'@serve.zone/catalog@2.2.0':
|
||||
resolution: {integrity: sha512-FxRGjuz8PdOXnfjHAGuPWP4jUTVGl5r9rsnxZlGSgTT+dHAm6Ue9AoTCkwVTKV9hP/Ac4yy8KKeNtNYIlidfJQ==}
|
||||
|
||||
'@serve.zone/interfaces@5.3.0':
|
||||
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==}
|
||||
|
||||
@@ -6779,6 +6785,19 @@ snapshots:
|
||||
domhandler: 5.0.3
|
||||
selderee: 0.11.0
|
||||
|
||||
'@serve.zone/catalog@2.2.0(@tiptap/pm@2.27.2)':
|
||||
dependencies:
|
||||
'@design.estate/dees-catalog': 3.43.2(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-domtools': 2.3.8
|
||||
'@design.estate/dees-element': 2.1.6
|
||||
'@design.estate/dees-wcctools': 3.8.0
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- '@tiptap/pm'
|
||||
- react
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@serve.zone/interfaces@5.3.0':
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,162 +2,93 @@ import * as plugins from '../plugins.js';
|
||||
import * as authInterfaces from '../data/auth.js';
|
||||
|
||||
// ============================================================================
|
||||
// Email Queue Item Interface (matches backend IQueueItem)
|
||||
// Catalog-compatible email types (matches @serve.zone/catalog IEmail/IEmailDetail)
|
||||
// ============================================================================
|
||||
export type TEmailQueueStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred';
|
||||
export type TEmailStatus = 'delivered' | 'bounced' | 'rejected' | 'deferred' | 'pending';
|
||||
export type TEmailDirection = 'inbound' | 'outbound';
|
||||
|
||||
export interface IEmailQueueItem {
|
||||
export interface IEmail {
|
||||
id: string;
|
||||
processingMode: 'forward' | 'mta' | 'process';
|
||||
status: TEmailQueueStatus;
|
||||
attempts: number;
|
||||
nextAttempt: number; // timestamp
|
||||
lastError?: string;
|
||||
createdAt: number; // timestamp
|
||||
updatedAt: number; // timestamp
|
||||
deliveredAt?: number; // timestamp
|
||||
// Email details extracted from processingResult
|
||||
from?: string;
|
||||
to?: string[];
|
||||
subject?: string;
|
||||
direction: TEmailDirection;
|
||||
status: TEmailStatus;
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
timestamp: string;
|
||||
messageId: string;
|
||||
size: string;
|
||||
}
|
||||
|
||||
export interface ISmtpLogEntry {
|
||||
timestamp: string;
|
||||
direction: 'client' | 'server';
|
||||
command: string;
|
||||
responseCode?: number;
|
||||
}
|
||||
|
||||
export interface IConnectionInfo {
|
||||
sourceIp: string;
|
||||
sourceHostname: string;
|
||||
destinationIp: string;
|
||||
destinationPort: number;
|
||||
tlsVersion: string;
|
||||
tlsCipher: string;
|
||||
authenticated: boolean;
|
||||
authMethod: string;
|
||||
authUser: string;
|
||||
}
|
||||
|
||||
export interface IAuthenticationResults {
|
||||
spf: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none';
|
||||
spfDomain: string;
|
||||
dkim: 'pass' | 'fail' | 'none';
|
||||
dkimDomain: string;
|
||||
dmarc: 'pass' | 'fail' | 'none';
|
||||
dmarcPolicy: string;
|
||||
}
|
||||
|
||||
export interface IEmailDetail extends IEmail {
|
||||
toList: string[];
|
||||
cc?: string[];
|
||||
smtpLog: ISmtpLogEntry[];
|
||||
connectionInfo: IConnectionInfo;
|
||||
authenticationResults: IAuthenticationResults;
|
||||
rejectionReason?: string;
|
||||
bounceMessage?: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Bounce Record Interface (matches backend BounceRecord)
|
||||
// Get All Emails Request
|
||||
// ============================================================================
|
||||
export type TBounceType =
|
||||
| 'invalid_recipient'
|
||||
| 'domain_not_found'
|
||||
| 'mailbox_full'
|
||||
| 'mailbox_inactive'
|
||||
| 'blocked'
|
||||
| 'spam_related'
|
||||
| 'policy_related'
|
||||
| 'server_unavailable'
|
||||
| 'temporary_failure'
|
||||
| 'quota_exceeded'
|
||||
| 'network_error'
|
||||
| 'timeout'
|
||||
| 'auto_response'
|
||||
| 'challenge_response'
|
||||
| 'unknown';
|
||||
|
||||
export type TBounceCategory = 'hard' | 'soft' | 'auto_response' | 'unknown';
|
||||
|
||||
export interface IBounceRecord {
|
||||
id: string;
|
||||
originalEmailId?: string;
|
||||
recipient: string;
|
||||
sender: string;
|
||||
domain: string;
|
||||
subject?: string;
|
||||
bounceType: TBounceType;
|
||||
bounceCategory: TBounceCategory;
|
||||
timestamp: number;
|
||||
smtpResponse?: string;
|
||||
diagnosticCode?: string;
|
||||
statusCode?: string;
|
||||
processed: boolean;
|
||||
retryCount?: number;
|
||||
nextRetryTime?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Security Incident Interface (matches backend ISecurityEvent)
|
||||
// ============================================================================
|
||||
export type TSecurityLogLevel = 'info' | 'warn' | 'error' | 'critical';
|
||||
|
||||
export type TSecurityEventType =
|
||||
| 'authentication'
|
||||
| 'access_control'
|
||||
| 'email_validation'
|
||||
| 'email_processing'
|
||||
| 'email_forwarding'
|
||||
| 'email_delivery'
|
||||
| 'dkim'
|
||||
| 'spf'
|
||||
| 'dmarc'
|
||||
| 'rate_limit'
|
||||
| 'rate_limiting'
|
||||
| 'spam'
|
||||
| 'malware'
|
||||
| 'connection'
|
||||
| 'data_exposure'
|
||||
| 'configuration'
|
||||
| 'ip_reputation'
|
||||
| 'rejected_connection';
|
||||
|
||||
export interface ISecurityIncident {
|
||||
timestamp: number;
|
||||
level: TSecurityLogLevel;
|
||||
type: TSecurityEventType;
|
||||
message: string;
|
||||
details?: any;
|
||||
ipAddress?: string;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
emailId?: string;
|
||||
domain?: string;
|
||||
action?: string;
|
||||
result?: string;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Get Queued Emails Request
|
||||
// ============================================================================
|
||||
export interface IReq_GetQueuedEmails extends plugins.typedrequestInterfaces.implementsTR<
|
||||
export interface IReq_GetAllEmails extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetQueuedEmails
|
||||
IReq_GetAllEmails
|
||||
> {
|
||||
method: 'getQueuedEmails';
|
||||
method: 'getAllEmails';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
status?: TEmailQueueStatus;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
response: {
|
||||
items: IEmailQueueItem[];
|
||||
total: number;
|
||||
emails: IEmail[];
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Get Sent Emails Request
|
||||
// Get Email Detail Request
|
||||
// ============================================================================
|
||||
export interface IReq_GetSentEmails extends plugins.typedrequestInterfaces.implementsTR<
|
||||
export interface IReq_GetEmailDetail extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSentEmails
|
||||
IReq_GetEmailDetail
|
||||
> {
|
||||
method: 'getSentEmails';
|
||||
method: 'getEmailDetail';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
emailId: string;
|
||||
};
|
||||
response: {
|
||||
items: IEmailQueueItem[];
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Get Failed Emails Request
|
||||
// ============================================================================
|
||||
export interface IReq_GetFailedEmails extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetFailedEmails
|
||||
> {
|
||||
method: 'getFailedEmails';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
response: {
|
||||
items: IEmailQueueItem[];
|
||||
total: number;
|
||||
email: IEmailDetail | null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,61 +110,3 @@ export interface IReq_ResendEmail extends plugins.typedrequestInterfaces.impleme
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Get Security Incidents Request
|
||||
// ============================================================================
|
||||
export interface IReq_GetSecurityIncidents extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSecurityIncidents
|
||||
> {
|
||||
method: 'getSecurityIncidents';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
type?: TSecurityEventType;
|
||||
level?: TSecurityLogLevel;
|
||||
limit?: number;
|
||||
};
|
||||
response: {
|
||||
incidents: ISecurityIncident[];
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Get Bounce Records Request
|
||||
// ============================================================================
|
||||
export interface IReq_GetBounceRecords extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetBounceRecords
|
||||
> {
|
||||
method: 'getBounceRecords';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
response: {
|
||||
records: IBounceRecord[];
|
||||
suppressionList: string[];
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Remove from Suppression List Request
|
||||
// ============================================================================
|
||||
export interface IReq_RemoveFromSuppressionList extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_RemoveFromSuppressionList
|
||||
> {
|
||||
method: 'removeFromSuppressionList';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
email: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -67,14 +67,7 @@ export interface ICertificateState {
|
||||
}
|
||||
|
||||
export interface IEmailOpsState {
|
||||
currentView: 'queued' | 'sent' | 'failed' | 'received' | 'security';
|
||||
queuedEmails: interfaces.requests.IEmailQueueItem[];
|
||||
sentEmails: interfaces.requests.IEmailQueueItem[];
|
||||
failedEmails: interfaces.requests.IEmailQueueItem[];
|
||||
securityIncidents: interfaces.requests.ISecurityIncident[];
|
||||
bounceRecords: interfaces.requests.IBounceRecord[];
|
||||
suppressionList: string[];
|
||||
selectedEmailId: string | null;
|
||||
emails: interfaces.requests.IEmail[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: number;
|
||||
@@ -165,14 +158,7 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
|
||||
export const emailOpsStatePart = await appState.getStatePart<IEmailOpsState>(
|
||||
'emailOps',
|
||||
{
|
||||
currentView: 'queued',
|
||||
queuedEmails: [],
|
||||
sentEmails: [],
|
||||
failedEmails: [],
|
||||
securityIncidents: [],
|
||||
bounceRecords: [],
|
||||
suppressionList: [],
|
||||
selectedEmailId: null,
|
||||
emails: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: 0,
|
||||
@@ -492,35 +478,22 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
|
||||
// Email Operations Actions
|
||||
// ============================================================================
|
||||
|
||||
// Set Email Ops View Action
|
||||
export const setEmailOpsViewAction = emailOpsStatePart.createAction<IEmailOpsState['currentView']>(
|
||||
async (statePartArg, view) => {
|
||||
return {
|
||||
...statePartArg.getState(),
|
||||
currentView: view,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Fetch Queued Emails Action
|
||||
export const fetchQueuedEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
||||
// Fetch All Emails Action
|
||||
export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetQueuedEmails
|
||||
>('/typedrequest', 'getQueuedEmails');
|
||||
interfaces.requests.IReq_GetAllEmails
|
||||
>('/typedrequest', 'getAllEmails');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
status: 'pending',
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
queuedEmails: response.items,
|
||||
emails: response.emails,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
@@ -529,197 +502,11 @@ export const fetchQueuedEmailsAction = emailOpsStatePart.createAction(async (sta
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch queued emails',
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch emails',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch Sent Emails Action
|
||||
export const fetchSentEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSentEmails
|
||||
>('/typedrequest', 'getSentEmails');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
sentEmails: response.items,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch sent emails',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch Failed Emails Action
|
||||
export const fetchFailedEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetFailedEmails
|
||||
>('/typedrequest', 'getFailedEmails');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
failedEmails: response.items,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch failed emails',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch Security Incidents Action
|
||||
export const fetchSecurityIncidentsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSecurityIncidents
|
||||
>('/typedrequest', 'getSecurityIncidents');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
securityIncidents: response.incidents,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch security incidents',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch Bounce Records Action
|
||||
export const fetchBounceRecordsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetBounceRecords
|
||||
>('/typedrequest', 'getBounceRecords');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
bounceRecords: response.records,
|
||||
suppressionList: response.suppressionList,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch bounce records',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Resend Failed Email Action
|
||||
export const resendEmailAction = emailOpsStatePart.createAction<string>(async (statePartArg, emailId) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ResendEmail
|
||||
>('/typedrequest', 'resendEmail');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
emailId,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
// Refresh failed emails list
|
||||
await emailOpsStatePart.dispatchAction(fetchFailedEmailsAction, null);
|
||||
await emailOpsStatePart.dispatchAction(fetchQueuedEmailsAction, null);
|
||||
}
|
||||
|
||||
return statePartArg.getState();
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to resend email',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Remove from Suppression List Action
|
||||
export const removeFromSuppressionListAction = emailOpsStatePart.createAction<string>(
|
||||
async (statePartArg, email) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_RemoveFromSuppressionList
|
||||
>('/typedrequest', 'removeFromSuppressionList');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
email,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
// Refresh bounce records
|
||||
await emailOpsStatePart.dispatchAction(fetchBounceRecordsAction, null);
|
||||
}
|
||||
|
||||
return statePartArg.getState();
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to remove from suppression list',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Certificate Actions
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element';
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as shared from './shared/index.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { appRouter } from '../router.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -10,67 +10,30 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
type TEmailFolder = 'queued' | 'sent' | 'failed' | 'received' | 'security';
|
||||
|
||||
@customElement('ops-view-emails')
|
||||
export class OpsViewEmails extends DeesElement {
|
||||
@state()
|
||||
accessor selectedFolder: TEmailFolder = 'queued';
|
||||
accessor emails: interfaces.requests.IEmail[] = [];
|
||||
|
||||
@state()
|
||||
accessor queuedEmails: interfaces.requests.IEmailQueueItem[] = [];
|
||||
accessor selectedEmail: interfaces.requests.IEmailDetail | null = null;
|
||||
|
||||
@state()
|
||||
accessor sentEmails: interfaces.requests.IEmailQueueItem[] = [];
|
||||
|
||||
@state()
|
||||
accessor failedEmails: interfaces.requests.IEmailQueueItem[] = [];
|
||||
|
||||
@state()
|
||||
accessor securityIncidents: interfaces.requests.ISecurityIncident[] = [];
|
||||
|
||||
@state()
|
||||
accessor selectedEmail: interfaces.requests.IEmailQueueItem | null = null;
|
||||
|
||||
@state()
|
||||
accessor selectedIncident: interfaces.requests.ISecurityIncident | null = null;
|
||||
|
||||
@state()
|
||||
accessor showCompose = false;
|
||||
accessor currentView: 'list' | 'detail' = 'list';
|
||||
|
||||
@state()
|
||||
accessor isLoading = false;
|
||||
|
||||
@state()
|
||||
accessor searchTerm = '';
|
||||
|
||||
@state()
|
||||
accessor emailDomains: string[] = [];
|
||||
|
||||
private stateSubscription: any;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.loadData();
|
||||
this.loadEmailDomains();
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
// Subscribe to state changes
|
||||
this.stateSubscription = appstate.emailOpsStatePart.state.subscribe((state) => {
|
||||
this.queuedEmails = state.queuedEmails;
|
||||
this.sentEmails = state.sentEmails;
|
||||
this.failedEmails = state.failedEmails;
|
||||
this.securityIncidents = state.securityIncidents;
|
||||
this.emails = state.emails;
|
||||
this.isLoading = state.isLoading;
|
||||
|
||||
// Sync folder from state (e.g., when URL changes)
|
||||
if (state.currentView !== this.selectedFolder) {
|
||||
this.selectedFolder = state.currentView as TEmailFolder;
|
||||
this.loadFolderData(state.currentView as TEmailFolder);
|
||||
}
|
||||
});
|
||||
// Initial fetch
|
||||
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchAllEmailsAction, null);
|
||||
}
|
||||
|
||||
async disconnectedCallback() {
|
||||
@@ -89,730 +52,58 @@ export class OpsViewEmails extends DeesElement {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.emailLayout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
.viewContainer {
|
||||
height: 100%;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.mainArea {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.emailToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.searchBox {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.emailList {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.emailPreview {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: ${cssManager.bdTheme('#fff', '#222')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.emailHeader {
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
}
|
||||
|
||||
.emailSubject {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
}
|
||||
|
||||
.emailMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
}
|
||||
|
||||
.emailMetaRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.emailMetaLabel {
|
||||
font-weight: 600;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.emailBody {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.emailActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
background: ${cssManager.bdTheme('#fafafa', '#1a1a1a')};
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
color: ${cssManager.bdTheme('#f59e0b', '#fbbf24')};
|
||||
}
|
||||
|
||||
.status-processing {
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
}
|
||||
|
||||
.status-delivered {
|
||||
color: ${cssManager.bdTheme('#10b981', '#34d399')};
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
color: ${cssManager.bdTheme('#ef4444', '#f87171')};
|
||||
}
|
||||
|
||||
.status-deferred {
|
||||
color: ${cssManager.bdTheme('#f97316', '#fb923c')};
|
||||
}
|
||||
|
||||
.severity-info {
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
}
|
||||
|
||||
.severity-warn {
|
||||
color: ${cssManager.bdTheme('#f59e0b', '#fbbf24')};
|
||||
}
|
||||
|
||||
.severity-error {
|
||||
color: ${cssManager.bdTheme('#ef4444', '#f87171')};
|
||||
}
|
||||
|
||||
.severity-critical {
|
||||
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.incidentDetails {
|
||||
padding: 24px;
|
||||
background: ${cssManager.bdTheme('#fff', '#222')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.incidentHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.incidentTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.incidentMeta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.incidentField {
|
||||
padding: 12px;
|
||||
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.incidentFieldLabel {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.incidentFieldValue {
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render() {
|
||||
if (this.selectedEmail) {
|
||||
return this.renderEmailDetail();
|
||||
}
|
||||
|
||||
if (this.selectedIncident) {
|
||||
return this.renderIncidentDetail();
|
||||
}
|
||||
|
||||
return html`
|
||||
<ops-sectionheading>Email Operations</ops-sectionheading>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="emailToolbar" style="margin-bottom: 16px;">
|
||||
<dees-button @click=${() => this.openComposeModal()} type="highlighted">
|
||||
<dees-icon icon="lucide:penLine" slot="iconSlot"></dees-icon>
|
||||
Compose
|
||||
</dees-button>
|
||||
|
||||
<dees-input-text
|
||||
class="searchBox"
|
||||
placeholder="Search..."
|
||||
.value=${this.searchTerm}
|
||||
@input=${(e: Event) => this.searchTerm = (e.target as any).value}
|
||||
>
|
||||
<dees-icon icon="lucide:search" slot="iconSlot"></dees-icon>
|
||||
</dees-input-text>
|
||||
|
||||
<dees-button @click=${() => this.refreshData()}>
|
||||
${this.isLoading ? html`<dees-spinner slot="iconSlot" size="small"></dees-spinner>` : html`<dees-icon slot="iconSlot" icon="lucide:refreshCw"></dees-icon>`}
|
||||
Refresh
|
||||
</dees-button>
|
||||
|
||||
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||||
<dees-button-group>
|
||||
<dees-button
|
||||
@click=${() => this.selectFolder('queued')}
|
||||
.type=${this.selectedFolder === 'queued' ? 'highlighted' : 'normal'}
|
||||
>
|
||||
Queued ${this.queuedEmails.length > 0 ? `(${this.queuedEmails.length})` : ''}
|
||||
</dees-button>
|
||||
<dees-button
|
||||
@click=${() => this.selectFolder('sent')}
|
||||
.type=${this.selectedFolder === 'sent' ? 'highlighted' : 'normal'}
|
||||
>
|
||||
Sent
|
||||
</dees-button>
|
||||
<dees-button
|
||||
@click=${() => this.selectFolder('failed')}
|
||||
.type=${this.selectedFolder === 'failed' ? 'highlighted' : 'normal'}
|
||||
>
|
||||
Failed ${this.failedEmails.length > 0 ? `(${this.failedEmails.length})` : ''}
|
||||
</dees-button>
|
||||
<dees-button
|
||||
@click=${() => this.selectFolder('security')}
|
||||
.type=${this.selectedFolder === 'security' ? 'highlighted' : 'normal'}
|
||||
>
|
||||
Security ${this.securityIncidents.length > 0 ? `(${this.securityIncidents.length})` : ''}
|
||||
</dees-button>
|
||||
</dees-button-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.renderContent()}
|
||||
`;
|
||||
<div class="viewContainer">
|
||||
${this.currentView === 'detail' && this.selectedEmail
|
||||
? html`
|
||||
<sz-mta-detail-view
|
||||
.email=${this.selectedEmail}
|
||||
@back=${this.handleBack}
|
||||
></sz-mta-detail-view>
|
||||
`
|
||||
: html`
|
||||
<sz-mta-list-view
|
||||
.emails=${this.emails}
|
||||
@email-click=${this.handleEmailClick}
|
||||
></sz-mta-list-view>
|
||||
`
|
||||
}
|
||||
|
||||
private renderContent() {
|
||||
switch (this.selectedFolder) {
|
||||
case 'queued':
|
||||
return this.renderEmailTable(this.queuedEmails, 'Queued Emails', 'Emails waiting to be delivered');
|
||||
case 'sent':
|
||||
return this.renderEmailTable(this.sentEmails, 'Sent Emails', 'Successfully delivered emails');
|
||||
case 'failed':
|
||||
return this.renderEmailTable(this.failedEmails, 'Failed Emails', 'Emails that failed to deliver', true);
|
||||
case 'security':
|
||||
return this.renderSecurityIncidents();
|
||||
default:
|
||||
return this.renderEmptyState('Select a folder');
|
||||
}
|
||||
}
|
||||
|
||||
private renderEmailTable(
|
||||
emails: interfaces.requests.IEmailQueueItem[],
|
||||
heading1: string,
|
||||
heading2: string,
|
||||
showResend = false
|
||||
) {
|
||||
const filteredEmails = this.filterEmails(emails);
|
||||
|
||||
if (filteredEmails.length === 0) {
|
||||
return this.renderEmptyState(`No emails in ${this.selectedFolder}`);
|
||||
}
|
||||
|
||||
const actions = [
|
||||
{
|
||||
name: 'View Details',
|
||||
iconName: 'lucide:eye',
|
||||
type: ['doubleClick', 'inRow'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
this.selectedEmail = actionData.item;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
if (showResend) {
|
||||
actions.push({
|
||||
name: 'Resend',
|
||||
iconName: 'lucide:send',
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
await this.resendEmail(actionData.item.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return html`
|
||||
<dees-table
|
||||
.data=${filteredEmails}
|
||||
.displayFunction=${(email: interfaces.requests.IEmailQueueItem) => ({
|
||||
'Status': html`<span class="status-${email.status}">${email.status}</span>`,
|
||||
'From': email.from || 'N/A',
|
||||
'To': email.to?.join(', ') || 'N/A',
|
||||
'Subject': email.subject || 'No subject',
|
||||
'Attempts': email.attempts,
|
||||
'Created': this.formatDate(email.createdAt),
|
||||
})}
|
||||
.dataActions=${actions}
|
||||
.selectionMode=${'single'}
|
||||
heading1=${heading1}
|
||||
heading2=${`${filteredEmails.length} emails - ${heading2}`}
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSecurityIncidents() {
|
||||
const incidents = this.securityIncidents;
|
||||
|
||||
if (incidents.length === 0) {
|
||||
return this.renderEmptyState('No security incidents');
|
||||
}
|
||||
|
||||
return html`
|
||||
<dees-table
|
||||
.data=${incidents}
|
||||
.displayFunction=${(incident: interfaces.requests.ISecurityIncident) => ({
|
||||
'Severity': html`<span class="severity-${incident.level}">${incident.level.toUpperCase()}</span>`,
|
||||
'Type': incident.type,
|
||||
'Message': incident.message,
|
||||
'IP': incident.ipAddress || 'N/A',
|
||||
'Domain': incident.domain || 'N/A',
|
||||
'Time': this.formatDate(incident.timestamp),
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'View Details',
|
||||
iconName: 'lucide:eye',
|
||||
type: ['doubleClick', 'inRow'],
|
||||
actionFunc: async (actionData: any) => {
|
||||
this.selectedIncident = actionData.item;
|
||||
}
|
||||
}
|
||||
]}
|
||||
.selectionMode=${'single'}
|
||||
heading1="Security Incidents"
|
||||
heading2=${`${incidents.length} incidents`}
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEmailDetail() {
|
||||
if (!this.selectedEmail) return '';
|
||||
|
||||
return html`
|
||||
<ops-sectionheading>Email Details</ops-sectionheading>
|
||||
<div class="emailLayout">
|
||||
<div class="sidebar">
|
||||
<dees-windowbox>
|
||||
<dees-button @click=${() => this.selectedEmail = null} type="secondary" style="width: 100%;">
|
||||
<dees-icon icon="lucide:arrowLeft" slot="iconSlot"></dees-icon>
|
||||
Back to List
|
||||
</dees-button>
|
||||
</dees-windowbox>
|
||||
</div>
|
||||
<div class="mainArea">
|
||||
<div class="emailPreview">
|
||||
<div class="emailHeader">
|
||||
<div class="emailSubject">${this.selectedEmail.subject || 'No subject'}</div>
|
||||
<div class="emailMeta">
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">Status:</span>
|
||||
<span class="status-${this.selectedEmail.status}">${this.selectedEmail.status}</span>
|
||||
</div>
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">From:</span>
|
||||
<span>${this.selectedEmail.from || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">To:</span>
|
||||
<span>${this.selectedEmail.to?.join(', ') || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">Mode:</span>
|
||||
<span>${this.selectedEmail.processingMode}</span>
|
||||
</div>
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">Attempts:</span>
|
||||
<span>${this.selectedEmail.attempts}</span>
|
||||
</div>
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">Created:</span>
|
||||
<span>${new Date(this.selectedEmail.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
${this.selectedEmail.deliveredAt ? html`
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">Delivered:</span>
|
||||
<span>${new Date(this.selectedEmail.deliveredAt).toLocaleString()}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${this.selectedEmail.lastError ? html`
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">Last Error:</span>
|
||||
<span style="color: #ef4444;">${this.selectedEmail.lastError}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="emailActions">
|
||||
${this.selectedEmail.status === 'failed' ? html`
|
||||
<dees-button @click=${() => this.resendEmail(this.selectedEmail!.id)} type="highlighted">
|
||||
<dees-icon icon="lucide:send" slot="iconSlot"></dees-icon>
|
||||
Resend
|
||||
</dees-button>
|
||||
` : ''}
|
||||
<dees-button @click=${() => this.selectedEmail = null}>
|
||||
<dees-icon icon="lucide:x" slot="iconSlot"></dees-icon>
|
||||
Close
|
||||
</dees-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderIncidentDetail() {
|
||||
if (!this.selectedIncident) return '';
|
||||
|
||||
const incident = this.selectedIncident;
|
||||
|
||||
return html`
|
||||
<ops-sectionheading>Security Incident Details</ops-sectionheading>
|
||||
<div style="margin-bottom: 16px;">
|
||||
<dees-button @click=${() => this.selectedIncident = null} type="secondary">
|
||||
<dees-icon icon="lucide:arrowLeft" slot="iconSlot"></dees-icon>
|
||||
Back to List
|
||||
</dees-button>
|
||||
</div>
|
||||
<div class="incidentDetails">
|
||||
<div class="incidentHeader">
|
||||
<div>
|
||||
<div class="incidentTitle">${incident.message}</div>
|
||||
<div style="margin-top: 8px; color: #666;">
|
||||
${new Date(incident.timestamp).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<span class="severity-${incident.level}" style="font-size: 16px; padding: 4px 12px; background: rgba(0,0,0,0.1); border-radius: 4px;">
|
||||
${incident.level.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="incidentMeta">
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">Type</div>
|
||||
<div class="incidentFieldValue">${incident.type}</div>
|
||||
</div>
|
||||
${incident.ipAddress ? html`
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">IP Address</div>
|
||||
<div class="incidentFieldValue">${incident.ipAddress}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${incident.domain ? html`
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">Domain</div>
|
||||
<div class="incidentFieldValue">${incident.domain}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${incident.emailId ? html`
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">Email ID</div>
|
||||
<div class="incidentFieldValue">${incident.emailId}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${incident.userId ? html`
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">User ID</div>
|
||||
<div class="incidentFieldValue">${incident.userId}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${incident.action ? html`
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">Action</div>
|
||||
<div class="incidentFieldValue">${incident.action}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${incident.result ? html`
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">Result</div>
|
||||
<div class="incidentFieldValue">${incident.result}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${incident.success !== undefined ? html`
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">Success</div>
|
||||
<div class="incidentFieldValue">${incident.success ? 'Yes' : 'No'}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
${incident.details ? html`
|
||||
<div style="margin-top: 24px;">
|
||||
<div class="incidentFieldLabel" style="margin-bottom: 8px;">Details</div>
|
||||
<pre style="background: #1a1a1a; color: #e5e5e5; padding: 16px; border-radius: 6px; overflow-x: auto; font-size: 13px;">
|
||||
${JSON.stringify(incident.details, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEmptyState(message: string) {
|
||||
return html`
|
||||
<div class="emptyState">
|
||||
<dees-icon class="emptyIcon" icon="lucide:inbox"></dees-icon>
|
||||
<div class="emptyText">${message}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async openComposeModal() {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
// Ensure domains are loaded before opening modal
|
||||
if (this.emailDomains.length === 0) {
|
||||
await this.loadEmailDomains();
|
||||
}
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'New Email',
|
||||
width: 'large',
|
||||
content: html`
|
||||
<div>
|
||||
<dees-form @formData=${async (e: CustomEvent) => {
|
||||
await this.sendEmail(e.detail);
|
||||
const modals = document.querySelectorAll('dees-modal');
|
||||
modals.forEach(m => (m as any).destroy?.());
|
||||
}}>
|
||||
<div style="display: flex; gap: 8px; align-items: flex-end;">
|
||||
<dees-input-text
|
||||
key="fromUsername"
|
||||
label="From"
|
||||
placeholder="username"
|
||||
.value=${'admin'}
|
||||
required
|
||||
style="flex: 1;"
|
||||
></dees-input-text>
|
||||
<span style="padding-bottom: 12px; font-size: 18px; color: #666;">@</span>
|
||||
<dees-input-dropdown
|
||||
key="fromDomain"
|
||||
label=" "
|
||||
.options=${this.emailDomains.length > 0
|
||||
? this.emailDomains.map(domain => ({ key: domain, value: domain }))
|
||||
: [{ key: 'dcrouter.local', value: 'dcrouter.local' }]}
|
||||
.selectedKey=${this.emailDomains[0] || 'dcrouter.local'}
|
||||
required
|
||||
style="flex: 1;"
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
|
||||
<dees-input-tags
|
||||
key="to"
|
||||
label="To"
|
||||
placeholder="Enter recipient email addresses..."
|
||||
required
|
||||
></dees-input-tags>
|
||||
|
||||
<dees-input-tags
|
||||
key="cc"
|
||||
label="CC"
|
||||
placeholder="Enter CC recipients..."
|
||||
></dees-input-tags>
|
||||
|
||||
<dees-input-text
|
||||
key="subject"
|
||||
label="Subject"
|
||||
placeholder="Enter email subject..."
|
||||
required
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-wysiwyg
|
||||
key="body"
|
||||
label="Message"
|
||||
outputFormat="html"
|
||||
></dees-input-wysiwyg>
|
||||
</dees-form>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Send',
|
||||
iconName: 'lucide:send',
|
||||
action: async (modalArg) => {
|
||||
const form = modalArg.shadowRoot?.querySelector('dees-form') as any;
|
||||
form?.submit();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg) => await modalArg.destroy()
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
private filterEmails(emails: interfaces.requests.IEmailQueueItem[]): interfaces.requests.IEmailQueueItem[] {
|
||||
if (!this.searchTerm) {
|
||||
return emails;
|
||||
}
|
||||
|
||||
const search = this.searchTerm.toLowerCase();
|
||||
return emails.filter(e =>
|
||||
(e.subject?.toLowerCase().includes(search)) ||
|
||||
(e.from?.toLowerCase().includes(search)) ||
|
||||
(e.to?.some(t => t.toLowerCase().includes(search)))
|
||||
);
|
||||
}
|
||||
|
||||
private selectFolder(folder: TEmailFolder) {
|
||||
// Use router for navigation to update URL
|
||||
appRouter.navigateToEmailFolder(folder);
|
||||
// Clear selections
|
||||
this.selectedEmail = null;
|
||||
this.selectedIncident = null;
|
||||
}
|
||||
|
||||
private formatDate(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const hours = diff / (1000 * 60 * 60);
|
||||
|
||||
if (hours < 24) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (hours < 168) { // 7 days
|
||||
return date.toLocaleDateString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' });
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
}
|
||||
|
||||
private async loadData() {
|
||||
this.isLoading = true;
|
||||
await this.loadFolderData(this.selectedFolder);
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
private async loadFolderData(folder: TEmailFolder) {
|
||||
switch (folder) {
|
||||
case 'queued':
|
||||
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchQueuedEmailsAction, null);
|
||||
break;
|
||||
case 'sent':
|
||||
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchSentEmailsAction, null);
|
||||
break;
|
||||
case 'failed':
|
||||
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchFailedEmailsAction, null);
|
||||
break;
|
||||
case 'security':
|
||||
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchSecurityIncidentsAction, null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadEmailDomains() {
|
||||
private async handleEmailClick(e: CustomEvent<interfaces.requests.IEmail>) {
|
||||
const emailSummary = e.detail;
|
||||
try {
|
||||
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
|
||||
const config = appstate.configStatePart.getState().config;
|
||||
const context = appstate.loginStatePart.getState();
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetEmailDetail
|
||||
>('/typedrequest', 'getEmailDetail');
|
||||
|
||||
if (config?.email?.domains && Array.isArray(config.email.domains) && config.email.domains.length > 0) {
|
||||
this.emailDomains = config.email.domains;
|
||||
} else {
|
||||
this.emailDomains = ['dcrouter.local'];
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
emailId: emailSummary.id,
|
||||
});
|
||||
|
||||
if (response.email) {
|
||||
this.selectedEmail = response.email;
|
||||
this.currentView = 'detail';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load email domains:', error);
|
||||
this.emailDomains = ['dcrouter.local'];
|
||||
console.error('Failed to fetch email detail:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshData() {
|
||||
this.isLoading = true;
|
||||
await this.loadFolderData(this.selectedFolder);
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
private async sendEmail(formData: any) {
|
||||
try {
|
||||
console.log('Sending email:', formData);
|
||||
// TODO: Implement actual email sending via API
|
||||
// For now, just log the data
|
||||
const fromEmail = `${formData.fromUsername || 'admin'}@${formData.fromDomain || this.emailDomains[0] || 'dcrouter.local'}`;
|
||||
console.log('From:', fromEmail);
|
||||
console.log('To:', formData.to);
|
||||
console.log('Subject:', formData.subject);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to send email', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async resendEmail(emailId: string) {
|
||||
try {
|
||||
await appstate.emailOpsStatePart.dispatchAction(appstate.resendEmailAction, emailId);
|
||||
private handleBack() {
|
||||
this.selectedEmail = null;
|
||||
} catch (error) {
|
||||
console.error('Failed to resend email:', error);
|
||||
}
|
||||
this.currentView = 'list';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as shared from './shared/index.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
|
||||
@@ -20,15 +19,6 @@ export class OpsViewLogs extends DeesElement {
|
||||
filters: {},
|
||||
};
|
||||
|
||||
@state()
|
||||
accessor filterLevel: string | undefined;
|
||||
|
||||
@state()
|
||||
accessor filterCategory: string | undefined;
|
||||
|
||||
@state()
|
||||
accessor filterLimit: number = 100;
|
||||
|
||||
private lastPushedCount = 0;
|
||||
|
||||
constructor() {
|
||||
@@ -44,63 +34,13 @@ export class OpsViewLogs extends DeesElement {
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
shared.viewHostCss,
|
||||
css`
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filterGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
`,
|
||||
css``,
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<ops-sectionheading>Logs</ops-sectionheading>
|
||||
|
||||
<div class="controls">
|
||||
<div class="filterGroup">
|
||||
<dees-button
|
||||
@click=${() => this.fetchLogs()}
|
||||
>
|
||||
Refresh Logs
|
||||
</dees-button>
|
||||
</div>
|
||||
|
||||
<div class="filterGroup">
|
||||
<label>Level:</label>
|
||||
<dees-input-dropdown
|
||||
.options=${['all', 'debug', 'info', 'warn', 'error']}
|
||||
.selectedOption=${'all'}
|
||||
@selectedOption=${(e: any) => this.updateFilter('level', e.detail)}
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="filterGroup">
|
||||
<label>Category:</label>
|
||||
<dees-input-dropdown
|
||||
.options=${['all', 'smtp', 'dns', 'security', 'system', 'email']}
|
||||
.selectedOption=${'all'}
|
||||
@selectedOption=${(e: any) => this.updateFilter('category', e.detail)}
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="filterGroup">
|
||||
<label>Limit:</label>
|
||||
<dees-input-dropdown
|
||||
.options=${['50', '100', '200', '500']}
|
||||
.selectedOption=${'100'}
|
||||
@selectedOption=${(e: any) => this.updateFilter('limit', e.detail)}
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dees-chart-log
|
||||
.label=${'Application Logs'}
|
||||
.autoScroll=${true}
|
||||
@@ -115,7 +55,7 @@ export class OpsViewLogs extends DeesElement {
|
||||
this.lastPushedCount = 0;
|
||||
// Only fetch if state is empty (streaming will handle new entries)
|
||||
if (this.logState.recentLogs.length === 0) {
|
||||
this.fetchLogs();
|
||||
await appstate.logStatePart.dispatchAction(appstate.fetchRecentLogsAction, { limit: 100 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,29 +106,4 @@ export class OpsViewLogs extends DeesElement {
|
||||
}));
|
||||
}
|
||||
|
||||
private async fetchLogs() {
|
||||
await appstate.logStatePart.dispatchAction(appstate.fetchRecentLogsAction, {
|
||||
limit: this.filterLimit,
|
||||
level: this.filterLevel as 'debug' | 'info' | 'warn' | 'error' | undefined,
|
||||
category: this.filterCategory as 'smtp' | 'dns' | 'security' | 'system' | 'email' | undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private updateFilter(type: string, value: string) {
|
||||
const resolved = value === 'all' ? undefined : value;
|
||||
|
||||
switch (type) {
|
||||
case 'level':
|
||||
this.filterLevel = resolved;
|
||||
break;
|
||||
case 'category':
|
||||
this.filterCategory = resolved;
|
||||
break;
|
||||
case 'limit':
|
||||
this.filterLimit = resolved ? parseInt(resolved, 10) : 100;
|
||||
break;
|
||||
}
|
||||
|
||||
this.fetchLogs();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
import * as deesElement from '@design.estate/dees-element';
|
||||
import * as deesCatalog from '@design.estate/dees-catalog';
|
||||
|
||||
// @serve.zone scope
|
||||
import * as szCatalog from '@serve.zone/catalog';
|
||||
|
||||
// TypedSocket for real-time push communication
|
||||
import * as typedsocket from '@api.global/typedsocket';
|
||||
|
||||
export {
|
||||
deesElement,
|
||||
deesCatalog,
|
||||
szCatalog,
|
||||
typedsocket,
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,8 @@ import * as appstate from './appstate.js';
|
||||
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
||||
|
||||
export const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates', 'remoteingress'] as const;
|
||||
export const validEmailFolders = ['queued', 'sent', 'failed', 'security'] as const;
|
||||
|
||||
export type TValidView = typeof validViews[number];
|
||||
export type TValidEmailFolder = typeof validEmailFolders[number];
|
||||
|
||||
class AppRouter {
|
||||
private router: InstanceType<typeof SmartRouter>;
|
||||
@@ -27,32 +25,11 @@ class AppRouter {
|
||||
}
|
||||
|
||||
private setupRoutes(): void {
|
||||
// Main views
|
||||
for (const view of validViews) {
|
||||
if (view === 'emails') {
|
||||
// Email root - default to queued
|
||||
this.router.on('/emails', async () => {
|
||||
this.updateViewState('emails');
|
||||
this.updateEmailFolder('queued');
|
||||
});
|
||||
|
||||
// Email with folder parameter
|
||||
this.router.on('/emails/:folder', async (routeInfo) => {
|
||||
const folder = routeInfo.params.folder as string;
|
||||
if (validEmailFolders.includes(folder as TValidEmailFolder)) {
|
||||
this.updateViewState('emails');
|
||||
this.updateEmailFolder(folder as TValidEmailFolder);
|
||||
} else {
|
||||
// Invalid folder, redirect to queued
|
||||
this.navigateTo('/emails/queued');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.router.on(`/${view}`, async () => {
|
||||
this.updateViewState(view);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Root redirect
|
||||
this.router.on('/', async () => {
|
||||
@@ -61,60 +38,32 @@ class AppRouter {
|
||||
}
|
||||
|
||||
private setupStateSync(): void {
|
||||
// Sync URL when state changes programmatically (not from router)
|
||||
appstate.uiStatePart.state.subscribe((uiState) => {
|
||||
if (this.suppressStateUpdate) return;
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
const expectedPath = this.getExpectedPath(uiState.activeView);
|
||||
const expectedPath = `/${uiState.activeView}`;
|
||||
|
||||
// Only update URL if it doesn't match current state
|
||||
if (!currentPath.startsWith(expectedPath)) {
|
||||
if (currentPath !== expectedPath) {
|
||||
this.suppressStateUpdate = true;
|
||||
if (uiState.activeView === 'emails') {
|
||||
const emailState = appstate.emailOpsStatePart.getState();
|
||||
this.router.pushUrl(`/emails/${emailState.currentView}`);
|
||||
} else {
|
||||
this.router.pushUrl(`/${uiState.activeView}`);
|
||||
}
|
||||
this.router.pushUrl(expectedPath);
|
||||
this.suppressStateUpdate = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getExpectedPath(view: string): string {
|
||||
if (view === 'emails') {
|
||||
return '/emails';
|
||||
}
|
||||
return `/${view}`;
|
||||
}
|
||||
|
||||
private handleInitialRoute(): void {
|
||||
const path = window.location.pathname;
|
||||
|
||||
if (!path || path === '/') {
|
||||
// Redirect root to overview
|
||||
this.router.pushUrl('/overview');
|
||||
} else {
|
||||
// Parse current path and update state
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const view = segments[0];
|
||||
|
||||
if (validViews.includes(view as TValidView)) {
|
||||
this.updateViewState(view as TValidView);
|
||||
|
||||
if (view === 'emails' && segments[1]) {
|
||||
const folder = segments[1];
|
||||
if (validEmailFolders.includes(folder as TValidEmailFolder)) {
|
||||
this.updateEmailFolder(folder as TValidEmailFolder);
|
||||
} else {
|
||||
this.updateEmailFolder('queued');
|
||||
}
|
||||
} else if (view === 'emails') {
|
||||
this.updateEmailFolder('queued');
|
||||
}
|
||||
} else {
|
||||
// Invalid view, redirect to overview
|
||||
this.router.pushUrl('/overview');
|
||||
}
|
||||
}
|
||||
@@ -132,18 +81,6 @@ class AppRouter {
|
||||
this.suppressStateUpdate = false;
|
||||
}
|
||||
|
||||
private updateEmailFolder(folder: TValidEmailFolder): void {
|
||||
this.suppressStateUpdate = true;
|
||||
const currentState = appstate.emailOpsStatePart.getState();
|
||||
if (currentState.currentView !== folder) {
|
||||
appstate.emailOpsStatePart.setState({
|
||||
...currentState,
|
||||
currentView: folder as appstate.IEmailOpsState['currentView'],
|
||||
});
|
||||
}
|
||||
this.suppressStateUpdate = false;
|
||||
}
|
||||
|
||||
public navigateTo(path: string): void {
|
||||
this.router.pushUrl(path);
|
||||
}
|
||||
@@ -156,22 +93,10 @@ class AppRouter {
|
||||
}
|
||||
}
|
||||
|
||||
public navigateToEmailFolder(folder: string): void {
|
||||
if (validEmailFolders.includes(folder as TValidEmailFolder)) {
|
||||
this.navigateTo(`/emails/${folder}`);
|
||||
} else {
|
||||
this.navigateTo('/emails/queued');
|
||||
}
|
||||
}
|
||||
|
||||
public getCurrentView(): string {
|
||||
return appstate.uiStatePart.getState().activeView;
|
||||
}
|
||||
|
||||
public getCurrentEmailFolder(): string {
|
||||
return appstate.emailOpsStatePart.getState().currentView;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.router.destroy();
|
||||
this.initialized = false;
|
||||
|
||||
Reference in New Issue
Block a user