import * as plugins from '../../plugins.js'; import type { OpsServer } from '../classes.opsserver.js'; import * as interfaces from '../../../ts_interfaces/index.js'; import { requireOpsAuth } from '../helpers/auth.js'; import { CachedEmail } from '../../db/index.js'; import { aggregateEmailLogTrafficInMemory, buildEmailLogTraffic, EMAIL_LOG_RETENTION_MS, normalizeEmailLogSearch, selectEmailLogTrafficWindow, } from '../../email/helpers.email-log-traffic.js'; import { evaluateInboundAcceptance } from '../../email/helpers.inbound-security.js'; export class EmailOpsHandler { constructor(private opsServerRef: OpsServer) { this.registerHandlers(); } private registerHandlers(): void { const viewRouter = this.opsServerRef.viewRouter; const adminRouter = this.opsServerRef.adminRouter; // ---- Read endpoints (viewRouter — valid identity required via middleware) ---- // Get All Emails Handler viewRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getAllEmails', async (dataArg) => { await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'emails:read' }); return await this.getEmailLogSnapshot( dataArg.direction, dataArg.limit, dataArg.search, dataArg.from, dataArg.to, ); } ) ); viewRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getEmailSecurityFindings', async (dataArg) => { await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'emails:read' }); return { findings: await this.getEmailSecurityFindings(dataArg.limit), }; }, ), ); // Get Email Detail Handler viewRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getEmailDetail', async (dataArg) => { await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'emails:read' }); const email = await this.getEmailDetail(dataArg.emailId); return { email }; } ) ); // ---- Write endpoints (adminRouter) ---- // Resend Failed Email Handler adminRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'resendEmail', async (dataArg) => { await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'emails:write', requireAdminIdentity: true, }); const emailServer = this.opsServerRef.dcRouterRef.emailServer; if (!emailServer?.deliveryQueue) { return { success: false, error: 'Email server not available' }; } const queue = emailServer.deliveryQueue; const item = emailServer.getQueueItem(dataArg.emailId); if (!item) { return { success: false, error: 'Email not found in queue' }; } if (item.status !== 'failed') { return { success: false, error: `Email is not in failed state (current: ${item.status})` }; } try { const newQueueId = await queue.enqueue( item.processingResult, item.processingMode, item.route ); await queue.removeItem(dataArg.emailId); return { success: true, newQueueId }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to resend email' }; } } ) ); } /** * List accepted emails (inbound + outbound) from the durable CachedEmail * store, plus any live outbound queue items not yet represented there. */ private async getAllEmails( direction?: interfaces.requests.TEmailDirection, limitArg?: number, searchArg?: string, fromArg?: number, toArg?: number, ): Promise { const limit = Math.min(Math.max(Math.floor(limitArg ?? 200), 1), 1000); const search = normalizeEmailLogSearch(searchArg); const range = this.normalizeEmailLogRange(fromArg, toArg); const cachedDocs = this.isCachedEmailStoreAvailable() ? await CachedEmail.findRecent(limit, { direction, search: search || undefined, ...(range || {}), }) : []; const emails = cachedDocs.map((doc) => this.mapCachedEmailToEmail(doc)); const representedIds = new Set(cachedDocs.map((doc) => doc.id)); // Live delivery-queue items are always outbound; every spooled message // carries the cache-id header, so dedupe against the cached listing. if (direction !== 'inbound') { const emailServer = this.opsServerRef.dcRouterRef.emailServer; const spool = this.opsServerRef.dcRouterRef.acceptedEmailSpool; for (const item of emailServer?.getQueueItems() ?? []) { const cachedId = spool.getCachedEmailIdFromQueueItem(item); if (cachedId && representedIds.has(cachedId)) { continue; } const email = this.mapQueueItemToEmail(item); const emailTimestamp = new Date(email.timestamp).getTime(); if ( this.matchesEmailSearch(email, search) && (!range || (emailTimestamp >= range.from && emailTimestamp <= range.to)) ) { emails.push(email); } } } // Sort by timestamp descending (newest first) emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); return emails.slice(0, limit); } private async getEmailLogSnapshot( direction?: interfaces.requests.TEmailDirection, limitArg?: number, searchArg?: string, fromArg?: number, toArg?: number, ): Promise { const search = normalizeEmailLogSearch(searchArg); const now = Date.now(); const storeAvailable = this.isCachedEmailStoreAvailable(); const filter = { direction, search: search || undefined }; const emails = await this.getAllEmails(direction, limitArg, search, fromArg, toArg); const trafficContextEmails = !storeAvailable && (fromArg !== undefined || toArg !== undefined) ? await this.getAllEmails(direction, 1000, search) : emails; let oldestMatchAt: number | null = null; if (search) { oldestMatchAt = storeAvailable ? await CachedEmail.findOldestAcceptedAt(filter, now - EMAIL_LOG_RETENTION_MS) : trafficContextEmails.reduce((oldest, email) => { const timestamp = new Date(email.timestamp).getTime(); if (!Number.isFinite(timestamp)) return oldest; return oldest === null ? timestamp : Math.min(oldest, timestamp); }, null); } const descriptor = selectEmailLogTrafficWindow(search, oldestMatchAt, now); const emptyTraffic = buildEmailLogTraffic(descriptor, [], now); const sparseTraffic = storeAvailable ? await CachedEmail.aggregateEmailLogTraffic( filter, emptyTraffic.windowStart, emptyTraffic.windowEnd, descriptor.bucketSizeMs, ) : aggregateEmailLogTrafficInMemory(trafficContextEmails, descriptor, now); return { emails, traffic: buildEmailLogTraffic(descriptor, sparseTraffic, now), }; } private normalizeEmailLogRange( fromArg?: number, toArg?: number, ): { from: number; to: number } | undefined { if (fromArg === undefined && toArg === undefined) return undefined; const now = Date.now(); if ( !Number.isSafeInteger(fromArg) || !Number.isSafeInteger(toArg) || (fromArg as number) < now - EMAIL_LOG_RETENTION_MS || (toArg as number) > now + 60_000 || (toArg as number) < (fromArg as number) ) { throw new plugins.typedrequest.TypedResponseError( 'Email log time range is invalid or outside retention.', ); } return { from: fromArg as number, to: toArg as number }; } private async getEmailSecurityFindings( limitArg?: number, ): Promise { if (!this.isCachedEmailStoreAvailable()) return []; const limit = Math.min(Math.max(Math.floor(limitArg ?? 200), 1), 200); const candidates = await CachedEmail.findRecentSecurityCandidates( Math.min(limit * 5, 1000), Date.now() - EMAIL_LOG_RETENTION_MS, ); const findings: interfaces.requests.IEmailSecurityFinding[] = []; for (const candidate of candidates) { let securityResults: unknown; try { securityResults = JSON.parse(candidate.inboundSecurityResults || ''); } catch { continue; } const evaluation = evaluateInboundAcceptance(securityResults); if (evaluation.doubts.length === 0) continue; findings.push({ id: candidate.id, timestamp: new Date(candidate.acceptedAt || 0).toISOString(), from: candidate.from || '', to: (candidate.to || []).join(', '), subject: candidate.subject || '', disposition: candidate.status === 'rejected' || evaluation.dmarcReject ? 'rejected' : 'flagged', failures: evaluation.doubts, failureSummary: evaluation.doubts.join('; '), }); if (findings.length >= limit) break; } return findings; } private matchesEmailSearch( emailArg: interfaces.requests.IEmail, searchArg: string, ): boolean { if (!searchArg) return true; const search = searchArg.toLowerCase(); return [emailArg.from, emailArg.to, emailArg.subject, emailArg.messageId] .some((value) => value.toLowerCase().includes(search)); } /** * Get a single email detail by ID — live queue item first (freshest state), * else the durable CachedEmail row (covers inbound + historical mail). */ private async getEmailDetail(emailId: string): Promise { const emailServer = this.opsServerRef.dcRouterRef.emailServer; const item = emailServer?.getQueueItem(emailId); if (item) { return this.mapQueueItemToEmailDetail(item); } if (this.isCachedEmailStoreAvailable()) { const cachedEmail = await CachedEmail.findById(emailId); if (cachedEmail) { return await this.mapCachedEmailToEmailDetail(cachedEmail); } } return null; } /** Durable listing needs the DB; without it only the live queue is visible. */ private isCachedEmailStoreAvailable(): boolean { return this.opsServerRef.dcRouterRef.dcRouterDb?.isReady() === true; } /** * Map a CachedEmail row to catalog IEmail format */ private mapCachedEmailToEmail(doc: CachedEmail): interfaces.requests.IEmail { const mappedStatus = this.mapStatus(doc.status); return { id: doc.id, direction: doc.direction, status: doc.direction === 'inbound' && mappedStatus === 'accepted' && doc.inboundSecurityDisposition === 'flagged' ? 'flagged' : mappedStatus, from: doc.from || '', to: doc.to?.[0] || '', subject: doc.subject || '', timestamp: new Date(doc.acceptedAt || 0).toISOString(), messageId: doc.messageId || '', size: this.formatSize(doc.rawContentSize || 0), }; } /** * Map a CachedEmail row to catalog IEmailDetail format */ private async mapCachedEmailToEmailDetail( doc: CachedEmail, ): Promise { const base = this.mapCachedEmailToEmail(doc); let rawContent = ''; try { rawContent = (await this.opsServerRef.dcRouterRef.acceptedEmailSpool.readRawMessage(doc)) .toString('utf8'); } catch { // Transaction history and delivery state remain available when a retained // legacy row no longer has its raw payload. } const { headers, body } = this.parseRawEmail(rawContent); const session = this.parseCachedEmailSession(doc); // Display SmartMTA's true retry schedule — doc.nextAttempt is the spool's // recovery lease (retry time + 30min grace), not the user-facing countdown. const nextAttemptMs = doc.smartMtaNextAttempt ? new Date(doc.smartMtaNextAttempt).getTime() : NaN; const outboundAuthentication = doc.direction === 'outbound' ? this.getLatestOutboundAuthentication(doc.smtpTransactions || []) : undefined; return { ...base, smtpTransactions: structuredClone(doc.smtpTransactions || []), ...(outboundAuthentication ? { outboundAuthentication } : {}), ...(base.status === 'deferred' && Number.isFinite(nextAttemptMs) && nextAttemptMs > 0 ? { nextAttemptAt: nextAttemptMs, nextAttemptNumber: (doc.attempts || 0) + 1, } : {}), toList: doc.to || [], cc: doc.cc || [], smtpLog: this.flattenSmtpTransactions(doc.smtpTransactions || []), connectionInfo: { sourceIp: session.remoteAddress || '', sourceHostname: session.clientHostname || '', destinationIp: '', destinationPort: 0, tlsVersion: session.secure ? 'TLS' : '', tlsCipher: '', authenticated: !!session.authenticated, authMethod: '', authUser: this.extractSessionUser(session), }, authenticationResults: outboundAuthentication ? this.mapOutboundAuthenticationResults(outboundAuthentication) : this.mapInboundSecurityResults(doc.inboundSecurityResults), rejectionReason: doc.status === 'failed' ? doc.lastError : undefined, bounceMessage: doc.status === 'failed' ? doc.lastError : undefined, headers, body, }; } /** Session snapshot persisted in routeData at acceptance time. */ private parseCachedEmailSession(doc: CachedEmail): { remoteAddress?: string; clientHostname?: string; secure?: boolean; authenticated?: boolean; user?: unknown; } { try { const routeData = doc.routeData ? JSON.parse(doc.routeData) : {}; return routeData.session || {}; } catch { return {}; } } private extractSessionUser(session: { user?: unknown }): string { if (!session.user) { return ''; } if (typeof session.user === 'string') { return session.user; } const user = session.user as { username?: string; id?: string }; return user.username || user.id || ''; } /** Split raw RFC822 content into an unfolded header map and the body. */ private parseRawEmail(rawContent: string): { headers: Record; body: string } { const separatorMatch = rawContent.match(/\r?\n\r?\n/); const headerBlock = separatorMatch ? rawContent.slice(0, separatorMatch.index) : rawContent; const body = separatorMatch ? rawContent.slice((separatorMatch.index || 0) + separatorMatch[0].length) : ''; const headers: Record = {}; let currentKey: string | undefined; for (const line of headerBlock.split(/\r?\n/)) { if (/^[\t ]/.test(line) && currentKey) { headers[currentKey] += ` ${line.trim()}`; continue; } const idx = line.indexOf(':'); if (idx > 0) { currentKey = line.slice(0, idx).trim(); headers[currentKey] = line.slice(idx + 1).trim(); } } return { headers, body }; } /** * 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; const outboundAuthentication = this.getLatestOutboundAuthentication(item.smtpTransactions || []); let toList: string[] = []; let cc: string[] = []; let headers: Record = {}; 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, smtpTransactions: structuredClone(item.smtpTransactions || []), toList, ...(outboundAuthentication ? { outboundAuthentication } : {}), cc, smtpLog: this.flattenSmtpTransactions(item.smtpTransactions || []), connectionInfo: { sourceIp: '', sourceHostname: '', destinationIp: '', destinationPort: 0, tlsVersion: '', tlsCipher: '', authenticated: false, authMethod: '', authUser: '', }, authenticationResults: this.mapOutboundAuthenticationResults(outboundAuthentication), rejectionReason: item.status === 'failed' ? item.lastError : undefined, bounceMessage: item.status === 'failed' ? item.lastError : undefined, headers, body, }; } private getLatestOutboundAuthentication( transactionsArg: Array<{ outboundAuthentication?: interfaces.requests.IOutboundAuthenticationCheck }>, ): interfaces.requests.IOutboundAuthenticationCheck | undefined { for (let index = transactionsArg.length - 1; index >= 0; index--) { const authentication = transactionsArg[index]?.outboundAuthentication; if (!authentication) continue; return structuredClone(authentication); } return undefined; } private mapOutboundAuthenticationResults( authenticationArg?: interfaces.requests.IOutboundAuthenticationCheck, ): interfaces.requests.IEmailDetail['authenticationResults'] { const noneResult: interfaces.requests.IEmailDetail['authenticationResults'] = { spf: 'none', spfDomain: '', dkim: 'none', dkimDomain: '', dmarc: 'none', dmarcPolicy: '', }; if (authenticationArg?.state !== 'completed') return noneResult; const dkimSignatures = (authenticationArg.dkim || []) .filter((signature) => signature.status.toLowerCase() !== 'none'); const validDkim = dkimSignatures.find((signature) => signature.is_valid); const spfValue = (authenticationArg.spf?.result || '').toLowerCase(); const spf = (['pass', 'fail', 'softfail', 'neutral'] as string[]).includes(spfValue) ? spfValue as interfaces.requests.IAuthenticationResults['spf'] : 'none'; return { spf, spfDomain: authenticationArg.spf?.domain || '', dkim: validDkim ? 'pass' : dkimSignatures.length > 0 ? 'fail' : 'none', dkimDomain: validDkim?.domain || dkimSignatures[0]?.domain || '', dmarc: authenticationArg.dmarc ? (authenticationArg.dmarc.passed ? 'pass' : 'fail') : 'none', dmarcPolicy: authenticationArg.dmarc?.policy || '', }; } /** * Map queue status to catalog TEmailStatus */ /** * Maps SmartMTA inbound SPF/DKIM/DMARC verdicts (persisted as JSON on the * cached email) into the catalog authenticationResults shape. Falls back to * all-'none' when no verdicts were captured. */ private mapInboundSecurityResults(serializedArg?: string): interfaces.requests.IEmailDetail['authenticationResults'] { const noneResult: interfaces.requests.IEmailDetail['authenticationResults'] = { spf: 'none', spfDomain: '', dkim: 'none', dkimDomain: '', dmarc: 'none', dmarcPolicy: '', }; if (!serializedArg) return noneResult; try { const parsed = JSON.parse(serializedArg) as { spf?: { result?: string; domain?: string } | null; dkim?: Array<{ is_valid?: boolean; domain?: string | null; status?: string }> | null; dmarc?: { passed?: boolean; policy?: string; domain?: string } | null; }; const spfResult = (parsed.spf?.result || '').toLowerCase(); const spf = (['pass', 'fail', 'softfail', 'neutral'] as const).find((value) => value === spfResult) || 'none'; const dkimSignatures = (Array.isArray(parsed.dkim) ? parsed.dkim : []) .filter((signature) => String(signature?.status || '').toLowerCase() !== 'none'); const validSignature = dkimSignatures.find((signature) => signature?.is_valid); const dkim = validSignature ? 'pass' : dkimSignatures.length > 0 ? 'fail' : 'none'; const dmarc = parsed.dmarc ? (parsed.dmarc.passed ? 'pass' : 'fail') : 'none'; return { spf, spfDomain: parsed.spf?.domain || '', dkim, dkimDomain: validSignature?.domain || dkimSignatures[0]?.domain || '', dmarc, dmarcPolicy: parsed.dmarc?.policy || '', }; } catch { return noneResult; } } private mapStatus(queueStatus: string): interfaces.requests.TEmailStatus { switch (queueStatus) { case 'pending': case 'processing': case 'queued': return 'pending'; case 'delivered': return 'delivered'; case 'stored': return 'accepted'; case 'accepted': return 'accepted'; case 'acceptedWithDoubts': case 'flagged': return 'flagged'; case 'rejected': return 'rejected'; case 'failed': return 'bounced'; case 'deferred': return 'deferred'; default: return 'pending'; } } private flattenSmtpTransactions( transactionsArg: interfaces.requests.ISmtpTransactionAttempt[], ): interfaces.requests.ISmtpLogEntry[] { return transactionsArg.flatMap((transaction) => transaction.transcript.map((entry) => ({ timestamp: new Date(entry.timestampMs).toISOString(), direction: entry.direction, command: entry.text, responseCode: entry.responseCode, }))); } /** * 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`; } }