initial
This commit is contained in:
447
ts/mail/delivery/classes.emailsendjob.ts
Normal file
447
ts/mail/delivery/classes.emailsendjob.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import * as paths from '../../paths.ts';
|
||||
import { Email } from '../core/classes.email.ts';
|
||||
import { EmailSignJob } from './classes.emailsignjob.ts';
|
||||
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.ts';
|
||||
import type { SmtpClient } from './smtpclient/smtp-client.ts';
|
||||
import type { ISmtpSendResult } from './smtpclient/interfaces.ts';
|
||||
|
||||
// Configuration options for email sending
|
||||
export interface IEmailSendOptions {
|
||||
maxRetries?: number;
|
||||
retryDelay?: number; // in milliseconds
|
||||
connectionTimeout?: number; // in milliseconds
|
||||
tlsOptions?: plugins.tls.ConnectionOptions;
|
||||
debugMode?: boolean;
|
||||
}
|
||||
|
||||
// Email delivery status
|
||||
export enum DeliveryStatus {
|
||||
PENDING = 'pending',
|
||||
SENDING = 'sending',
|
||||
DELIVERED = 'delivered',
|
||||
FAILED = 'failed',
|
||||
DEFERRED = 'deferred' // Temporary failure, will retry
|
||||
}
|
||||
|
||||
// Detailed information about delivery attempts
|
||||
export interface DeliveryInfo {
|
||||
status: DeliveryStatus;
|
||||
attempts: number;
|
||||
error?: Error;
|
||||
lastAttempt?: Date;
|
||||
nextAttempt?: Date;
|
||||
mxServer?: string;
|
||||
deliveryTime?: Date;
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
export class EmailSendJob {
|
||||
emailServerRef: UnifiedEmailServer;
|
||||
private email: Email;
|
||||
private mxServers: string[] = [];
|
||||
private currentMxIndex = 0;
|
||||
private options: IEmailSendOptions;
|
||||
public deliveryInfo: DeliveryInfo;
|
||||
|
||||
constructor(emailServerRef: UnifiedEmailServer, emailArg: Email, options: IEmailSendOptions = {}) {
|
||||
this.email = emailArg;
|
||||
this.emailServerRef = emailServerRef;
|
||||
|
||||
// Set default options
|
||||
this.options = {
|
||||
maxRetries: options.maxRetries || 3,
|
||||
retryDelay: options.retryDelay || 30000, // 30 seconds
|
||||
connectionTimeout: options.connectionTimeout || 60000, // 60 seconds
|
||||
tlsOptions: options.tlsOptions || {},
|
||||
debugMode: options.debugMode || false
|
||||
};
|
||||
|
||||
// Initialize delivery info
|
||||
this.deliveryInfo = {
|
||||
status: DeliveryStatus.PENDING,
|
||||
attempts: 0,
|
||||
logs: []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the email to its recipients
|
||||
*/
|
||||
async send(): Promise<DeliveryStatus> {
|
||||
try {
|
||||
// Check if the email is valid before attempting to send
|
||||
this.validateEmail();
|
||||
|
||||
// Resolve MX records for the recipient domain
|
||||
await this.resolveMxRecords();
|
||||
|
||||
// Try to send the email
|
||||
return await this.attemptDelivery();
|
||||
} catch (error) {
|
||||
this.log(`Critical error in send process: ${error.message}`);
|
||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
||||
this.deliveryInfo.error = error;
|
||||
|
||||
// Save failed email for potential future retry or analysis
|
||||
await this.saveFailed();
|
||||
return DeliveryStatus.FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the email before sending
|
||||
*/
|
||||
private validateEmail(): void {
|
||||
if (!this.email.to || this.email.to.length === 0) {
|
||||
throw new Error('No recipients specified');
|
||||
}
|
||||
|
||||
if (!this.email.from) {
|
||||
throw new Error('No sender specified');
|
||||
}
|
||||
|
||||
const fromDomain = this.email.getFromDomain();
|
||||
if (!fromDomain) {
|
||||
throw new Error('Invalid sender domain');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve MX records for the recipient domain
|
||||
*/
|
||||
private async resolveMxRecords(): Promise<void> {
|
||||
const domain = this.email.getPrimaryRecipient()?.split('@')[1];
|
||||
if (!domain) {
|
||||
throw new Error('Invalid recipient domain');
|
||||
}
|
||||
|
||||
this.log(`Resolving MX records for domain: ${domain}`);
|
||||
try {
|
||||
const addresses = await this.resolveMx(domain);
|
||||
|
||||
// Sort by priority (lowest number = highest priority)
|
||||
addresses.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
this.mxServers = addresses.map(mx => mx.exchange);
|
||||
this.log(`Found ${this.mxServers.length} MX servers: ${this.mxServers.join(', ')}`);
|
||||
|
||||
if (this.mxServers.length === 0) {
|
||||
throw new Error(`No MX records found for domain: ${domain}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`Failed to resolve MX records: ${error.message}`);
|
||||
throw new Error(`MX lookup failed for ${domain}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to deliver the email with retries
|
||||
*/
|
||||
private async attemptDelivery(): Promise<DeliveryStatus> {
|
||||
while (this.deliveryInfo.attempts < this.options.maxRetries) {
|
||||
this.deliveryInfo.attempts++;
|
||||
this.deliveryInfo.lastAttempt = new Date();
|
||||
this.deliveryInfo.status = DeliveryStatus.SENDING;
|
||||
|
||||
try {
|
||||
this.log(`Delivery attempt ${this.deliveryInfo.attempts} of ${this.options.maxRetries}`);
|
||||
|
||||
// Try each MX server in order of priority
|
||||
while (this.currentMxIndex < this.mxServers.length) {
|
||||
const currentMx = this.mxServers[this.currentMxIndex];
|
||||
this.deliveryInfo.mxServer = currentMx;
|
||||
|
||||
try {
|
||||
this.log(`Attempting delivery to MX server: ${currentMx}`);
|
||||
await this.connectAndSend(currentMx);
|
||||
|
||||
// If we get here, email was sent successfully
|
||||
this.deliveryInfo.status = DeliveryStatus.DELIVERED;
|
||||
this.deliveryInfo.deliveryTime = new Date();
|
||||
this.log(`Email delivered successfully to ${currentMx}`);
|
||||
|
||||
// Record delivery for sender reputation monitoring
|
||||
this.recordDeliveryEvent('delivered');
|
||||
|
||||
// Save successful email record
|
||||
await this.saveSuccess();
|
||||
return DeliveryStatus.DELIVERED;
|
||||
} catch (error) {
|
||||
this.log(`Failed to deliver to ${currentMx}: ${error.message}`);
|
||||
this.currentMxIndex++;
|
||||
|
||||
// If this MX server failed, try the next one
|
||||
if (this.currentMxIndex >= this.mxServers.length) {
|
||||
throw error; // No more MX servers to try
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('All MX servers failed');
|
||||
} catch (error) {
|
||||
this.deliveryInfo.error = error;
|
||||
|
||||
// Check if this is a permanent failure
|
||||
if (this.isPermanentFailure(error)) {
|
||||
this.log('Permanent failure detected, not retrying');
|
||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
||||
|
||||
// Record permanent failure for bounce management
|
||||
this.recordDeliveryEvent('bounced', true);
|
||||
|
||||
await this.saveFailed();
|
||||
return DeliveryStatus.FAILED;
|
||||
}
|
||||
|
||||
// This is a temporary failure
|
||||
if (this.deliveryInfo.attempts < this.options.maxRetries) {
|
||||
this.log(`Temporary failure, will retry in ${this.options.retryDelay}ms`);
|
||||
this.deliveryInfo.status = DeliveryStatus.DEFERRED;
|
||||
this.deliveryInfo.nextAttempt = new Date(Date.now() + this.options.retryDelay);
|
||||
|
||||
// Record temporary failure for monitoring
|
||||
this.recordDeliveryEvent('deferred');
|
||||
|
||||
// Reset MX server index for next retry
|
||||
this.currentMxIndex = 0;
|
||||
|
||||
// Wait before retrying
|
||||
await this.delay(this.options.retryDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, all retries failed
|
||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
||||
await this.saveFailed();
|
||||
return DeliveryStatus.FAILED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a specific MX server and send the email using SmtpClient
|
||||
*/
|
||||
private async connectAndSend(mxServer: string): Promise<void> {
|
||||
this.log(`Connecting to ${mxServer}:25`);
|
||||
|
||||
try {
|
||||
// Check if IP warmup is enabled and get an IP to use
|
||||
let localAddress: string | undefined = undefined;
|
||||
try {
|
||||
const fromDomain = this.email.getFromDomain();
|
||||
const bestIP = this.emailServerRef.getBestIPForSending({
|
||||
from: this.email.from,
|
||||
to: this.email.getAllRecipients(),
|
||||
domain: fromDomain,
|
||||
isTransactional: this.email.priority === 'high'
|
||||
});
|
||||
|
||||
if (bestIP) {
|
||||
this.log(`Using warmed-up IP ${bestIP} for sending`);
|
||||
localAddress = bestIP;
|
||||
|
||||
// Record the send for warm-up tracking
|
||||
this.emailServerRef.recordIPSend(bestIP);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`Error selecting IP address: ${error.message}`);
|
||||
}
|
||||
|
||||
// Get SMTP client from UnifiedEmailServer
|
||||
const smtpClient = this.emailServerRef.getSmtpClient(mxServer, 25);
|
||||
|
||||
// Sign the email with DKIM if available
|
||||
let signedEmail = this.email;
|
||||
try {
|
||||
const fromDomain = this.email.getFromDomain();
|
||||
if (fromDomain && this.emailServerRef.hasDkimKey(fromDomain)) {
|
||||
// Convert email to RFC822 format for signing
|
||||
const emailMessage = this.email.toRFC822String();
|
||||
|
||||
// Create sign job with proper options
|
||||
const emailSignJob = new EmailSignJob(this.emailServerRef, {
|
||||
domain: fromDomain,
|
||||
selector: 'default', // Using default selector
|
||||
headers: {}, // Headers will be extracted from emailMessage
|
||||
body: emailMessage
|
||||
});
|
||||
|
||||
// Get the DKIM signature header
|
||||
const signatureHeader = await emailSignJob.getSignatureHeader(emailMessage);
|
||||
|
||||
// Add the signature to the email
|
||||
if (signatureHeader) {
|
||||
// For now, we'll use the email as-is since SmtpClient will handle DKIM
|
||||
this.log(`Email ready for DKIM signing for domain: ${fromDomain}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`Failed to prepare DKIM: ${error.message}`);
|
||||
}
|
||||
|
||||
// Send the email using SmtpClient
|
||||
const result: ISmtpSendResult = await smtpClient.sendMail(signedEmail);
|
||||
|
||||
if (result.success) {
|
||||
this.log(`Email sent successfully: ${result.response}`);
|
||||
|
||||
// Record the send for reputation monitoring
|
||||
this.recordDeliveryEvent('delivered');
|
||||
} else {
|
||||
throw new Error(result.error?.message || 'Failed to send email');
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`Failed to send email via ${mxServer}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record delivery event for monitoring
|
||||
*/
|
||||
private recordDeliveryEvent(
|
||||
eventType: 'delivered' | 'bounced' | 'deferred',
|
||||
isHardBounce: boolean = false
|
||||
): void {
|
||||
try {
|
||||
const domain = this.email.getFromDomain();
|
||||
if (domain) {
|
||||
if (eventType === 'delivered') {
|
||||
this.emailServerRef.recordDelivery(domain);
|
||||
} else if (eventType === 'bounced') {
|
||||
// Get the receiving domain for bounce recording
|
||||
let receivingDomain = null;
|
||||
const primaryRecipient = this.email.getPrimaryRecipient();
|
||||
if (primaryRecipient) {
|
||||
receivingDomain = primaryRecipient.split('@')[1];
|
||||
}
|
||||
|
||||
if (receivingDomain) {
|
||||
this.emailServerRef.recordBounce(
|
||||
domain,
|
||||
receivingDomain,
|
||||
isHardBounce ? 'hard' : 'soft',
|
||||
this.deliveryInfo.error?.message || 'Unknown error'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`Failed to record delivery event: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error represents a permanent failure
|
||||
*/
|
||||
private isPermanentFailure(error: Error): boolean {
|
||||
const permanentFailurePatterns = [
|
||||
'User unknown',
|
||||
'No such user',
|
||||
'Mailbox not found',
|
||||
'Invalid recipient',
|
||||
'Account disabled',
|
||||
'Account suspended',
|
||||
'Domain not found',
|
||||
'No such domain',
|
||||
'Invalid domain',
|
||||
'Relay access denied',
|
||||
'Access denied',
|
||||
'Blacklisted',
|
||||
'Blocked',
|
||||
'550', // Permanent failure SMTP code
|
||||
'551',
|
||||
'552',
|
||||
'553',
|
||||
'554'
|
||||
];
|
||||
|
||||
const errorMessage = error.message.toLowerCase();
|
||||
return permanentFailurePatterns.some(pattern =>
|
||||
errorMessage.includes(pattern.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve MX records for a domain
|
||||
*/
|
||||
private resolveMx(domain: string): Promise<plugins.dns.MxRecord[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
plugins.dns.resolveMx(domain, (err, addresses) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(addresses || []);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message with timestamp
|
||||
*/
|
||||
private log(message: string): void {
|
||||
const timestamp = new Date().toISOString();
|
||||
const logEntry = `[${timestamp}] ${message}`;
|
||||
this.deliveryInfo.logs.push(logEntry);
|
||||
|
||||
if (this.options.debugMode) {
|
||||
console.log(`[EmailSendJob] ${logEntry}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save successful email to storage
|
||||
*/
|
||||
private async saveSuccess(): Promise<void> {
|
||||
try {
|
||||
// Use the existing email storage path
|
||||
const emailContent = this.email.toRFC822String();
|
||||
const fileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_success.eml`;
|
||||
const filePath = plugins.path.join(paths.sentEmailsDir, fileName);
|
||||
|
||||
await plugins.smartfile.fs.ensureDir(paths.sentEmailsDir);
|
||||
await plugins.smartfile.memory.toFs(emailContent, filePath);
|
||||
|
||||
// Also save delivery info
|
||||
const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_info.tson`;
|
||||
const infoPath = plugins.path.join(paths.sentEmailsDir, infoFileName);
|
||||
await plugins.smartfile.memory.toFs(JSON.stringify(this.deliveryInfo, null, 2), infoPath);
|
||||
|
||||
this.log(`Email saved to ${fileName}`);
|
||||
} catch (error) {
|
||||
this.log(`Failed to save email: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save failed email to storage
|
||||
*/
|
||||
private async saveFailed(): Promise<void> {
|
||||
try {
|
||||
// Use the existing email storage path
|
||||
const emailContent = this.email.toRFC822String();
|
||||
const fileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_failed.eml`;
|
||||
const filePath = plugins.path.join(paths.failedEmailsDir, fileName);
|
||||
|
||||
await plugins.smartfile.fs.ensureDir(paths.failedEmailsDir);
|
||||
await plugins.smartfile.memory.toFs(emailContent, filePath);
|
||||
|
||||
// Also save delivery info with error details
|
||||
const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_error.tson`;
|
||||
const infoPath = plugins.path.join(paths.failedEmailsDir, infoFileName);
|
||||
await plugins.smartfile.memory.toFs(JSON.stringify(this.deliveryInfo, null, 2), infoPath);
|
||||
|
||||
this.log(`Failed email saved to ${fileName}`);
|
||||
} catch (error) {
|
||||
this.log(`Failed to save failed email: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay for specified milliseconds
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user