import * as plugins from '../../plugins.js'; import { logger } from '../../logger.js'; import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js'; import type { MtaService } from '../delivery/classes.mta.js'; import type { Email } from '../core/classes.email.js'; import type { IDnsVerificationResult } from '../routing/classes.dnsmanager.js'; /** * DMARC policy types */ export enum DmarcPolicy { NONE = 'none', QUARANTINE = 'quarantine', REJECT = 'reject' } /** * DMARC alignment modes */ export enum DmarcAlignment { RELAXED = 'r', STRICT = 's' } /** * DMARC record fields */ export interface DmarcRecord { // Required fields version: string; policy: DmarcPolicy; // Optional fields subdomainPolicy?: DmarcPolicy; pct?: number; adkim?: DmarcAlignment; aspf?: DmarcAlignment; reportInterval?: number; failureOptions?: string; reportUriAggregate?: string[]; reportUriForensic?: string[]; } /** * DMARC verification result */ export interface DmarcResult { hasDmarc: boolean; record?: DmarcRecord; spfDomainAligned: boolean; dkimDomainAligned: boolean; spfPassed: boolean; dkimPassed: boolean; policyEvaluated: DmarcPolicy; actualPolicy: DmarcPolicy; appliedPercentage: number; action: 'pass' | 'quarantine' | 'reject'; details: string; error?: string; } /** * Class for verifying and enforcing DMARC policies */ export class DmarcVerifier { private mtaRef: MtaService; constructor(mtaRefArg: MtaService) { this.mtaRef = mtaRefArg; } /** * Parse a DMARC record from a TXT record string * @param record DMARC TXT record string * @returns Parsed DMARC record or null if invalid */ public parseDmarcRecord(record: string): DmarcRecord | null { if (!record.startsWith('v=DMARC1')) { return null; } try { // Initialize record with default values const dmarcRecord: DmarcRecord = { version: 'DMARC1', policy: DmarcPolicy.NONE, pct: 100, adkim: DmarcAlignment.RELAXED, aspf: DmarcAlignment.RELAXED }; // Split the record into tag/value pairs const parts = record.split(';').map(part => part.trim()); for (const part of parts) { if (!part || !part.includes('=')) continue; const [tag, value] = part.split('=').map(p => p.trim()); // Process based on tag switch (tag.toLowerCase()) { case 'v': dmarcRecord.version = value; break; case 'p': dmarcRecord.policy = value as DmarcPolicy; break; case 'sp': dmarcRecord.subdomainPolicy = value as DmarcPolicy; break; case 'pct': const pctValue = parseInt(value, 10); if (!isNaN(pctValue) && pctValue >= 0 && pctValue <= 100) { dmarcRecord.pct = pctValue; } break; case 'adkim': dmarcRecord.adkim = value as DmarcAlignment; break; case 'aspf': dmarcRecord.aspf = value as DmarcAlignment; break; case 'ri': const interval = parseInt(value, 10); if (!isNaN(interval) && interval > 0) { dmarcRecord.reportInterval = interval; } break; case 'fo': dmarcRecord.failureOptions = value; break; case 'rua': dmarcRecord.reportUriAggregate = value.split(',').map(uri => { if (uri.startsWith('mailto:')) { return uri.substring(7).trim(); } return uri.trim(); }); break; case 'ruf': dmarcRecord.reportUriForensic = value.split(',').map(uri => { if (uri.startsWith('mailto:')) { return uri.substring(7).trim(); } return uri.trim(); }); break; } } // Ensure subdomain policy is set if not explicitly provided if (!dmarcRecord.subdomainPolicy) { dmarcRecord.subdomainPolicy = dmarcRecord.policy; } return dmarcRecord; } catch (error) { logger.log('error', `Error parsing DMARC record: ${error.message}`, { record, error: error.message }); return null; } } /** * Check if domains are aligned according to DMARC policy * @param headerDomain Domain from header (From) * @param authDomain Domain from authentication (SPF, DKIM) * @param alignment Alignment mode * @returns Whether the domains are aligned */ private isDomainAligned( headerDomain: string, authDomain: string, alignment: DmarcAlignment ): boolean { if (!headerDomain || !authDomain) { return false; } // For strict alignment, domains must match exactly if (alignment === DmarcAlignment.STRICT) { return headerDomain.toLowerCase() === authDomain.toLowerCase(); } // For relaxed alignment, the authenticated domain must be a subdomain of the header domain // or the same as the header domain const headerParts = headerDomain.toLowerCase().split('.'); const authParts = authDomain.toLowerCase().split('.'); // Ensures we have at least two parts (domain and TLD) if (headerParts.length < 2 || authParts.length < 2) { return false; } // Get organizational domain (last two parts) const headerOrgDomain = headerParts.slice(-2).join('.'); const authOrgDomain = authParts.slice(-2).join('.'); return headerOrgDomain === authOrgDomain; } /** * Extract domain from an email address * @param email Email address * @returns Domain part of the email */ private getDomainFromEmail(email: string): string { if (!email) return ''; // Handle name + email format: "John Doe " const matches = email.match(/<([^>]+)>/); const address = matches ? matches[1] : email; const parts = address.split('@'); return parts.length > 1 ? parts[1] : ''; } /** * Check if DMARC verification should be applied based on percentage * @param record DMARC record * @returns Whether DMARC verification should be applied */ private shouldApplyDmarc(record: DmarcRecord): boolean { if (record.pct === undefined || record.pct === 100) { return true; } // Apply DMARC randomly based on percentage const random = Math.floor(Math.random() * 100) + 1; return random <= record.pct; } /** * Determine the action to take based on DMARC policy * @param policy DMARC policy * @returns Action to take */ private determineAction(policy: DmarcPolicy): 'pass' | 'quarantine' | 'reject' { switch (policy) { case DmarcPolicy.REJECT: return 'reject'; case DmarcPolicy.QUARANTINE: return 'quarantine'; case DmarcPolicy.NONE: default: return 'pass'; } } /** * Verify DMARC for an incoming email * @param email Email to verify * @param spfResult SPF verification result * @param dkimResult DKIM verification result * @returns DMARC verification result */ public async verify( email: Email, spfResult: { domain: string; result: boolean }, dkimResult: { domain: string; result: boolean } ): Promise { const securityLogger = SecurityLogger.getInstance(); // Initialize result const result: DmarcResult = { hasDmarc: false, spfDomainAligned: false, dkimDomainAligned: false, spfPassed: spfResult.result, dkimPassed: dkimResult.result, policyEvaluated: DmarcPolicy.NONE, actualPolicy: DmarcPolicy.NONE, appliedPercentage: 100, action: 'pass', details: 'DMARC not configured' }; try { // Extract From domain const fromHeader = email.getFromEmail(); const fromDomain = this.getDomainFromEmail(fromHeader); if (!fromDomain) { result.error = 'Invalid From domain'; return result; } // Check alignment result.spfDomainAligned = this.isDomainAligned( fromDomain, spfResult.domain, DmarcAlignment.RELAXED ); result.dkimDomainAligned = this.isDomainAligned( fromDomain, dkimResult.domain, DmarcAlignment.RELAXED ); // Lookup DMARC record const dmarcVerificationResult = await this.mtaRef.dnsManager.verifyDmarcRecord(fromDomain); // If DMARC record exists and is valid if (dmarcVerificationResult.found && dmarcVerificationResult.valid) { result.hasDmarc = true; // Parse DMARC record const parsedRecord = this.parseDmarcRecord(dmarcVerificationResult.value); if (parsedRecord) { result.record = parsedRecord; result.actualPolicy = parsedRecord.policy; result.appliedPercentage = parsedRecord.pct || 100; // Override alignment modes if specified in record if (parsedRecord.adkim) { result.dkimDomainAligned = this.isDomainAligned( fromDomain, dkimResult.domain, parsedRecord.adkim ); } if (parsedRecord.aspf) { result.spfDomainAligned = this.isDomainAligned( fromDomain, spfResult.domain, parsedRecord.aspf ); } // Determine DMARC compliance const spfAligned = result.spfPassed && result.spfDomainAligned; const dkimAligned = result.dkimPassed && result.dkimDomainAligned; // Email passes DMARC if either SPF or DKIM passes with alignment const dmarcPass = spfAligned || dkimAligned; // Use record percentage to determine if policy should be applied const applyPolicy = this.shouldApplyDmarc(parsedRecord); if (!dmarcPass) { // DMARC failed, apply policy result.policyEvaluated = applyPolicy ? parsedRecord.policy : DmarcPolicy.NONE; result.action = this.determineAction(result.policyEvaluated); result.details = `DMARC failed: SPF aligned=${spfAligned}, DKIM aligned=${dkimAligned}, policy=${result.policyEvaluated}`; } else { result.policyEvaluated = DmarcPolicy.NONE; result.action = 'pass'; result.details = `DMARC passed: SPF aligned=${spfAligned}, DKIM aligned=${dkimAligned}`; } } else { result.error = 'Invalid DMARC record format'; result.details = 'DMARC record invalid'; } } else { // No DMARC record found or invalid result.details = dmarcVerificationResult.error || 'No DMARC record found'; } // Log the DMARC verification securityLogger.logEvent({ level: result.action === 'pass' ? SecurityLogLevel.INFO : SecurityLogLevel.WARN, type: SecurityEventType.DMARC, message: result.details, domain: fromDomain, details: { fromDomain, spfDomain: spfResult.domain, dkimDomain: dkimResult.domain, spfPassed: result.spfPassed, dkimPassed: result.dkimPassed, spfAligned: result.spfDomainAligned, dkimAligned: result.dkimDomainAligned, dmarcPolicy: result.policyEvaluated, action: result.action }, success: result.action === 'pass' }); return result; } catch (error) { logger.log('error', `Error verifying DMARC: ${error.message}`, { error: error.message, emailId: email.getMessageId() }); result.error = `DMARC verification error: ${error.message}`; // Log error securityLogger.logEvent({ level: SecurityLogLevel.ERROR, type: SecurityEventType.DMARC, message: `DMARC verification failed with error`, details: { error: error.message, emailId: email.getMessageId() }, success: false }); return result; } } /** * Apply DMARC policy to an email * @param email Email to apply policy to * @param dmarcResult DMARC verification result * @returns Whether the email should be accepted */ public applyPolicy(email: Email, dmarcResult: DmarcResult): boolean { // Apply action based on DMARC verification result switch (dmarcResult.action) { case 'reject': // Reject the email email.mightBeSpam = true; logger.log('warn', `Email rejected due to DMARC policy: ${dmarcResult.details}`, { emailId: email.getMessageId(), from: email.getFromEmail(), subject: email.subject }); return false; case 'quarantine': // Quarantine the email (mark as spam) email.mightBeSpam = true; // Add spam header if (!email.headers['X-Spam-Flag']) { email.headers['X-Spam-Flag'] = 'YES'; } // Add DMARC reason header email.headers['X-DMARC-Result'] = dmarcResult.details; logger.log('warn', `Email quarantined due to DMARC policy: ${dmarcResult.details}`, { emailId: email.getMessageId(), from: email.getFromEmail(), subject: email.subject }); return true; case 'pass': default: // Accept the email // Add DMARC result header for information email.headers['X-DMARC-Result'] = dmarcResult.details; return true; } } /** * End-to-end DMARC verification and policy application * This method should be called after SPF and DKIM verification * @param email Email to verify * @param spfResult SPF verification result * @param dkimResult DKIM verification result * @returns Whether the email should be accepted */ public async verifyAndApply( email: Email, spfResult: { domain: string; result: boolean }, dkimResult: { domain: string; result: boolean } ): Promise { // Verify DMARC const dmarcResult = await this.verify(email, spfResult, dkimResult); // Apply DMARC policy return this.applyPolicy(email, dmarcResult); } }