366 lines
30 KiB
JavaScript
366 lines
30 KiB
JavaScript
|
|
import * as plugins from '../../plugins.js';
|
||
|
|
import * as paths from '../../paths.js';
|
||
|
|
import { Email } from '../core/classes.email.js';
|
||
|
|
import { EmailSignJob } from './classes.emailsignjob.js';
|
||
|
|
// Email delivery status
|
||
|
|
export var DeliveryStatus;
|
||
|
|
(function (DeliveryStatus) {
|
||
|
|
DeliveryStatus["PENDING"] = "pending";
|
||
|
|
DeliveryStatus["SENDING"] = "sending";
|
||
|
|
DeliveryStatus["DELIVERED"] = "delivered";
|
||
|
|
DeliveryStatus["FAILED"] = "failed";
|
||
|
|
DeliveryStatus["DEFERRED"] = "deferred"; // Temporary failure, will retry
|
||
|
|
})(DeliveryStatus || (DeliveryStatus = {}));
|
||
|
|
export class EmailSendJob {
|
||
|
|
emailServerRef;
|
||
|
|
email;
|
||
|
|
mxServers = [];
|
||
|
|
currentMxIndex = 0;
|
||
|
|
options;
|
||
|
|
deliveryInfo;
|
||
|
|
constructor(emailServerRef, emailArg, options = {}) {
|
||
|
|
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() {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
validateEmail() {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
async resolveMxRecords() {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
async attemptDelivery() {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
async connectAndSend(mxServer) {
|
||
|
|
this.log(`Connecting to ${mxServer}:25`);
|
||
|
|
try {
|
||
|
|
// Check if IP warmup is enabled and get an IP to use
|
||
|
|
let localAddress = 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 = 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
|
||
|
|
*/
|
||
|
|
recordDeliveryEvent(eventType, isHardBounce = false) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
isPermanentFailure(error) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
resolveMx(domain) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
plugins.dns.resolveMx(domain, (err, addresses) => {
|
||
|
|
if (err) {
|
||
|
|
reject(err);
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
resolve(addresses || []);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Log a message with timestamp
|
||
|
|
*/
|
||
|
|
log(message) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
async saveSuccess() {
|
||
|
|
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.smartfs.directory(paths.sentEmailsDir).recursive().create();
|
||
|
|
await plugins.smartfs.file(filePath).write(emailContent);
|
||
|
|
// Also save delivery info
|
||
|
|
const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_info.json`;
|
||
|
|
const infoPath = plugins.path.join(paths.sentEmailsDir, infoFileName);
|
||
|
|
await plugins.smartfs.file(infoPath).write(JSON.stringify(this.deliveryInfo, null, 2));
|
||
|
|
this.log(`Email saved to ${fileName}`);
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
this.log(`Failed to save email: ${error.message}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Save failed email to storage
|
||
|
|
*/
|
||
|
|
async saveFailed() {
|
||
|
|
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.smartfs.directory(paths.failedEmailsDir).recursive().create();
|
||
|
|
await plugins.smartfs.file(filePath).write(emailContent);
|
||
|
|
// Also save delivery info with error details
|
||
|
|
const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_error.json`;
|
||
|
|
const infoPath = plugins.path.join(paths.failedEmailsDir, infoFileName);
|
||
|
|
await plugins.smartfs.file(infoPath).write(JSON.stringify(this.deliveryInfo, null, 2));
|
||
|
|
this.log(`Failed email saved to ${fileName}`);
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
this.log(`Failed to save failed email: ${error.message}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Delay for specified milliseconds
|
||
|
|
*/
|
||
|
|
delay(ms) {
|
||
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5lbWFpbHNlbmRqb2IuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL2RlbGl2ZXJ5L2NsYXNzZXMuZW1haWxzZW5kam9iLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFDNUMsT0FBTyxLQUFLLEtBQUssTUFBTSxnQkFBZ0IsQ0FBQztBQUN4QyxPQUFPLEVBQUUsS0FBSyxFQUFFLE1BQU0sMEJBQTBCLENBQUM7QUFDakQsT0FBTyxFQUFFLFlBQVksRUFBRSxNQUFNLDJCQUEyQixDQUFDO0FBY3pELHdCQUF3QjtBQUN4QixNQUFNLENBQU4sSUFBWSxjQU1YO0FBTkQsV0FBWSxjQUFjO0lBQ3hCLHFDQUFtQixDQUFBO0lBQ25CLHFDQUFtQixDQUFBO0lBQ25CLHlDQUF1QixDQUFBO0lBQ3ZCLG1DQUFpQixDQUFBO0lBQ2pCLHVDQUFxQixDQUFBLENBQUMsZ0NBQWdDO0FBQ3hELENBQUMsRUFOVyxjQUFjLEtBQWQsY0FBYyxRQU16QjtBQWNELE1BQU0sT0FBTyxZQUFZO0lBQ3ZCLGNBQWMsQ0FBcUI7SUFDM0IsS0FBSyxDQUFRO0lBQ2IsU0FBUyxHQUFhLEVBQUUsQ0FBQztJQUN6QixjQUFjLEdBQUcsQ0FBQyxDQUFDO0lBQ25CLE9BQU8sQ0FBb0I7SUFDNUIsWUFBWSxDQUFlO0lBRWxDLFlBQVksY0FBa0MsRUFBRSxRQUFlLEVBQUUsVUFBNkIsRUFBRTtRQUM5RixJQUFJLENBQUMsS0FBSyxHQUFHLFFBQVEsQ0FBQztRQUN0QixJQUFJLENBQUMsY0FBYyxHQUFHLGNBQWMsQ0FBQztRQUVyQyxzQkFBc0I7UUFDdEIsSUFBSSxDQUFDLE9BQU8sR0FBRztZQUNiLFVBQVUsRUFBRSxPQUFPLENBQUMsVUFBVSxJQUFJLENBQUM7WUFDbkMsVUFBVSxFQUFFLE9BQU8sQ0FBQyxVQUFVLElBQUksS0FBSyxFQUFFLGFBQWE7WUFDdEQsaUJBQWlCLEVB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
|