528 lines
16 KiB
JavaScript
528 lines
16 KiB
JavaScript
const config = require("./config");
|
|
const nodemailer = require("nodemailer");
|
|
const delay = require("delay");
|
|
const fs = require("fs");
|
|
const templateEngine = require("./lib/templateEngine");
|
|
const attachmentHandler = require("./lib/attachmentHandler");
|
|
const rateLimiter = require("./lib/rateLimiter");
|
|
const errorHandler = require("./lib/errorHandler");
|
|
const logger = require("./lib/logger");
|
|
const database = require("./lib/database");
|
|
const trackingServer = require("./lib/trackingServer");
|
|
|
|
// Initialize database and load firm data
|
|
let uniqueFirms = [];
|
|
let currentCampaignId = null;
|
|
|
|
// Generate test recipients automatically based on test limit
|
|
function generateTestRecipients(testLimit) {
|
|
const baseEmail = config.email.user; // Use your own email as base
|
|
const [localPart, domain] = baseEmail.split("@");
|
|
|
|
const recipients = [];
|
|
for (let i = 1; i <= testLimit; i++) {
|
|
// Create test recipients like: yourname+test1@gmail.com, yourname+test2@gmail.com
|
|
recipients.push(`${localPart}+test${i}@${domain}`);
|
|
}
|
|
|
|
if (config.logging.debug) {
|
|
console.log(`🐛 DEBUG: Generated ${testLimit} test recipients:`);
|
|
recipients.forEach((email, index) => {
|
|
console.log(`🐛 DEBUG: Recipient ${index + 1}: ${email}`);
|
|
});
|
|
}
|
|
|
|
return recipients;
|
|
}
|
|
|
|
// Extract email sending logic for reuse in retries
|
|
async function sendSingleEmail(mailOptions, recipient, transporter) {
|
|
if (config.logging.debug) {
|
|
console.log(`🐛 DEBUG: Sending email to ${recipient}`);
|
|
console.log(`🐛 DEBUG: Subject: ${mailOptions.subject}`);
|
|
console.log(`🐛 DEBUG: Firm: ${mailOptions.firmName}`);
|
|
console.log(`🐛 DEBUG: Tracking ID: ${mailOptions.trackingId}`);
|
|
console.log(
|
|
`🐛 DEBUG: HTML length: ${
|
|
mailOptions.html ? mailOptions.html.length : 0
|
|
} chars`
|
|
);
|
|
console.log(
|
|
`🐛 DEBUG: Text length: ${
|
|
mailOptions.text ? mailOptions.text.length : 0
|
|
} chars`
|
|
);
|
|
console.log(
|
|
`🐛 DEBUG: Attachments: ${
|
|
mailOptions.attachments ? mailOptions.attachments.length : 0
|
|
}`
|
|
);
|
|
}
|
|
|
|
await transporter.sendMail(mailOptions);
|
|
|
|
// Log successful email to database
|
|
if (currentCampaignId && mailOptions.firmId) {
|
|
try {
|
|
await database.logEmailSend({
|
|
campaignId: currentCampaignId,
|
|
firmId: mailOptions.firmId,
|
|
recipientEmail: recipient,
|
|
subject: mailOptions.subject,
|
|
status: "sent",
|
|
trackingId: mailOptions.trackingId,
|
|
});
|
|
} catch (dbError) {
|
|
logger.error("Failed to log email send to database", {
|
|
recipient,
|
|
error: dbError.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Log successful email
|
|
logger.emailSent(
|
|
recipient,
|
|
mailOptions.subject,
|
|
mailOptions.firmName,
|
|
config.email.testMode
|
|
);
|
|
|
|
console.log(
|
|
`✅ Email sent to ${recipient}${
|
|
config.email.testMode ? " (TEST MODE)" : ""
|
|
}`
|
|
);
|
|
|
|
if (config.logging.debug) {
|
|
console.log(`🐛 DEBUG: Email successfully delivered to ${recipient}`);
|
|
}
|
|
}
|
|
|
|
async function initializeData() {
|
|
try {
|
|
// Initialize database
|
|
await database.init();
|
|
|
|
// If in test mode with limit, we can skip migration and just create test data
|
|
if (config.email.testMode && config.email.testLimit) {
|
|
const testRecipients = generateTestRecipients(config.email.testLimit);
|
|
|
|
if (config.logging.debug) {
|
|
console.log(`🐛 DEBUG: Test limit: ${config.email.testLimit}`);
|
|
}
|
|
|
|
console.log(
|
|
`🧪 TEST MODE: ${config.email.testLimit} email${
|
|
config.email.testLimit === 1 ? "" : "s"
|
|
} to auto-generated recipients`
|
|
);
|
|
|
|
// Create test firms - one for each recipient
|
|
const baseTestFirms = [
|
|
{
|
|
id: 1,
|
|
firm_name: "Test Law Firm Alpha",
|
|
location: "New York, NY",
|
|
website: "https://testfirm-alpha.com",
|
|
contact_email: testRecipients[0] || "test@testfirm-alpha.com",
|
|
state: "New York",
|
|
},
|
|
{
|
|
id: 2,
|
|
firm_name: "Test Law Firm Beta",
|
|
location: "Los Angeles, CA",
|
|
website: "https://testfirm-beta.com",
|
|
contact_email: testRecipients[1] || "test@testfirm-beta.com",
|
|
state: "California",
|
|
},
|
|
{
|
|
id: 3,
|
|
firm_name: "Test Law Firm Gamma",
|
|
location: "Chicago, IL",
|
|
website: "https://testfirm-gamma.com",
|
|
contact_email: testRecipients[2] || "test@testfirm-gamma.com",
|
|
state: "Illinois",
|
|
},
|
|
{
|
|
id: 4,
|
|
firm_name: "Test Law Firm Delta",
|
|
location: "Houston, TX",
|
|
website: "https://testfirm-delta.com",
|
|
contact_email: testRecipients[3] || "test@testfirm-delta.com",
|
|
state: "Texas",
|
|
},
|
|
{
|
|
id: 5,
|
|
firm_name: "Test Law Firm Epsilon",
|
|
location: "Miami, FL",
|
|
website: "https://testfirm-epsilon.com",
|
|
contact_email: testRecipients[4] || "test@testfirm-epsilon.com",
|
|
state: "Florida",
|
|
},
|
|
];
|
|
|
|
uniqueFirms = baseTestFirms.slice(0, config.email.testLimit);
|
|
|
|
if (config.logging.debug) {
|
|
console.log(`🐛 DEBUG: Created ${uniqueFirms.length} test firms:`);
|
|
uniqueFirms.forEach((firm, index) => {
|
|
console.log(
|
|
`🐛 DEBUG: Firm ${index + 1}: ${firm.firm_name} → ${
|
|
firm.contact_email
|
|
}`
|
|
);
|
|
});
|
|
}
|
|
|
|
console.log(
|
|
`📧 Ready to send test emails using ${uniqueFirms.length} firms`
|
|
);
|
|
} else {
|
|
// Full initialization for production or full test runs
|
|
if (fs.existsSync("firm.json")) {
|
|
logger.info("Found firm.json, checking if migration is needed");
|
|
const counts = await database.getTableCounts();
|
|
|
|
if (counts.firms === 0) {
|
|
logger.info("Database is empty, running migration from JSON");
|
|
|
|
// Run migration with its own database connection
|
|
const {
|
|
migrateJsonToDatabase,
|
|
} = require("./scripts/migrate-to-database");
|
|
await migrateJsonToDatabase();
|
|
|
|
uniqueFirms = await database.getFirms();
|
|
logger.info(`Loaded ${uniqueFirms.length} firms after migration`);
|
|
} else {
|
|
uniqueFirms = await database.getFirms();
|
|
logger.info(`Loaded ${uniqueFirms.length} firms from database`);
|
|
}
|
|
} else {
|
|
uniqueFirms = await database.getFirms();
|
|
logger.info(`Loaded ${uniqueFirms.length} firms from database`);
|
|
}
|
|
|
|
// Apply test limit if needed
|
|
if (
|
|
config.email.testMode &&
|
|
config.email.testLimit &&
|
|
config.email.testLimit < uniqueFirms.length
|
|
) {
|
|
uniqueFirms = uniqueFirms.slice(0, config.email.testLimit);
|
|
console.log(
|
|
`🧪 TEST MODE: ${config.email.testLimit} email${
|
|
config.email.testLimit === 1 ? "" : "s"
|
|
} to ${config.email.testRecipient}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Validation for test mode
|
|
if (config.email.testMode && !config.email.testLimit) {
|
|
throw new Error(
|
|
"❌ TEST_MODE enabled but EMAIL_TEST_LIMIT not set! Set a test limit to generate recipients automatically."
|
|
);
|
|
}
|
|
|
|
if (config.logging.debug) {
|
|
console.log(`🐛 DEBUG: Firms to process: ${uniqueFirms.length}`);
|
|
}
|
|
|
|
// Create campaign
|
|
currentCampaignId = await database.createCampaign({
|
|
name: `Campaign ${new Date().toISOString().split("T")[0]}`,
|
|
subject: config.email.testMode
|
|
? "[TEST] Legal Partnership Opportunity"
|
|
: "Legal Partnership Opportunity",
|
|
templateName: "outreach",
|
|
testMode: config.email.testMode,
|
|
});
|
|
|
|
await database.startCampaign(currentCampaignId, uniqueFirms.length);
|
|
} catch (error) {
|
|
logger.error("Failed to initialize data", { error: error.message });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function sendEmails() {
|
|
// Initialize data first
|
|
await initializeData();
|
|
|
|
// Start tracking server if enabled and not skipping tracking
|
|
if (config.tracking.enabled && !config.tracking.skipTracking) {
|
|
try {
|
|
await trackingServer.start();
|
|
} catch (error) {
|
|
logger.error("Failed to start tracking server", { error: error.message });
|
|
console.warn(
|
|
"⚠️ Tracking server failed to start. Continuing without tracking."
|
|
);
|
|
}
|
|
} else if (config.tracking.skipTracking) {
|
|
console.log("📊 Tracking disabled - SKIP_TRACKING enabled");
|
|
}
|
|
|
|
const transportConfig = {
|
|
auth: {
|
|
user: config.smtp.user || config.email.user,
|
|
pass: config.smtp.pass || config.email.pass,
|
|
},
|
|
};
|
|
|
|
if (config.smtp.host) {
|
|
transportConfig.host = config.smtp.host;
|
|
transportConfig.port = config.smtp.port;
|
|
transportConfig.secure = config.smtp.secure;
|
|
} else {
|
|
transportConfig.service = "gmail";
|
|
}
|
|
|
|
const transporter = nodemailer.createTransport(transportConfig);
|
|
|
|
// Get attachments once at start
|
|
const attachments = await attachmentHandler.getAttachments();
|
|
if (attachments.length > 0) {
|
|
console.log(`📎 Attaching ${attachments.length} file(s) to emails`);
|
|
if (config.logging.debug) {
|
|
console.log(
|
|
`🐛 DEBUG: Attachment files: ${attachments
|
|
.map((a) => a.filename)
|
|
.join(", ")}`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (config.logging.debug) {
|
|
console.log(
|
|
`🐛 DEBUG: SMTP Config: ${JSON.stringify(
|
|
{
|
|
host: config.smtp.host || "gmail",
|
|
port: config.smtp.port || 587,
|
|
secure: config.smtp.secure,
|
|
user: config.smtp.user || config.email.user,
|
|
},
|
|
null,
|
|
2
|
|
)}`
|
|
);
|
|
}
|
|
|
|
logger.campaignStart(uniqueFirms.length, config.email.testMode);
|
|
console.log(`🚀 Starting email campaign for ${uniqueFirms.length} firms`);
|
|
|
|
for (const firm of uniqueFirms) {
|
|
// Process any pending retries first
|
|
await errorHandler.processRetries(
|
|
transporter,
|
|
async (email, recipient, trans) => {
|
|
await sendSingleEmail(email, recipient, trans);
|
|
}
|
|
);
|
|
|
|
// Format firm data for template (database has different field names)
|
|
const templateData = templateEngine.formatFirmData({
|
|
firmName: firm.firm_name,
|
|
location: firm.location,
|
|
website: firm.website,
|
|
contactEmail: firm.contact_email,
|
|
email: firm.contact_email,
|
|
});
|
|
|
|
// In test mode, firm.contact_email already contains the generated test recipient
|
|
// CRITICAL: In test mode, NEVER send to actual firm email
|
|
const recipient = firm.contact_email;
|
|
|
|
// Log what we're doing for transparency
|
|
if (config.email.testMode) {
|
|
const firmIndex = uniqueFirms.indexOf(firm);
|
|
|
|
if (config.logging.debug) {
|
|
console.log(
|
|
`🐛 DEBUG: Firm ${firmIndex + 1}/${uniqueFirms.length} → ${recipient}`
|
|
);
|
|
}
|
|
|
|
console.log(`🧪 TEST MODE: Email for ${firm.firm_name} → ${recipient}`);
|
|
}
|
|
|
|
const subject = config.email.testMode
|
|
? "[TEST] Legal Partnership Opportunity"
|
|
: "Legal Partnership Opportunity";
|
|
|
|
// Generate unique tracking ID for this email
|
|
const trackingId = `${currentCampaignId}_${
|
|
firm.contact_email
|
|
}_${Date.now()}`;
|
|
|
|
// Render email using template
|
|
const emailContent = await templateEngine.render("outreach", {
|
|
...templateData,
|
|
subject: subject,
|
|
});
|
|
|
|
// Add tracking to HTML content (unless skip tracking is enabled)
|
|
const trackedHtmlContent = trackingServer.addEmailTracking(
|
|
emailContent.html,
|
|
trackingId,
|
|
config.tracking.skipTracking
|
|
);
|
|
|
|
const mailOptions = {
|
|
from: config.email.user,
|
|
to: recipient,
|
|
subject: subject,
|
|
text: emailContent.text,
|
|
html: trackedHtmlContent,
|
|
attachments: attachments,
|
|
// Store firm data for error handling and tracking
|
|
firmName: firm.firm_name,
|
|
firmId: firm.id,
|
|
trackingId: trackingId,
|
|
};
|
|
|
|
try {
|
|
await sendSingleEmail(mailOptions, recipient, transporter);
|
|
|
|
// Increment sent count immediately after success
|
|
rateLimiter.recordSuccess();
|
|
|
|
// Show rate limiting stats
|
|
const stats = rateLimiter.getStats();
|
|
console.log(
|
|
`📊 Progress: ${stats.sentCount} sent, ${stats.averageRate} emails/hour`
|
|
);
|
|
|
|
if (config.logging.debug) {
|
|
console.log(
|
|
`🐛 DEBUG: Rate limiter stats: ${JSON.stringify(stats, null, 2)}`
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (config.logging.debug) {
|
|
console.log(
|
|
`🐛 DEBUG: Email send failed for ${recipient}: ${error.message}`
|
|
);
|
|
console.log(`🐛 DEBUG: Error code: ${error.code}`);
|
|
console.log(`🐛 DEBUG: Error command: ${error.command}`);
|
|
}
|
|
|
|
// Use error handler for intelligent retry logic
|
|
const willRetry = await errorHandler.handleError(
|
|
mailOptions,
|
|
recipient,
|
|
error,
|
|
transporter
|
|
);
|
|
|
|
if (!willRetry) {
|
|
console.error(`💀 Skipping ${recipient} due to permanent failure`);
|
|
} else if (config.logging.debug) {
|
|
console.log(`🐛 DEBUG: Email queued for retry: ${recipient}`);
|
|
}
|
|
}
|
|
|
|
// Show retry queue status
|
|
const retryStats = errorHandler.getRetryStats();
|
|
if (retryStats.totalFailed > 0) {
|
|
console.log(
|
|
`🔄 Retry queue: ${retryStats.pendingRetries} pending, ${retryStats.readyToRetry} ready`
|
|
);
|
|
}
|
|
|
|
// Use rate limiter for intelligent delays (only if there are more emails)
|
|
if (uniqueFirms.indexOf(firm) < uniqueFirms.length - 1) {
|
|
const delayMs = await rateLimiter.getNextSendDelay();
|
|
console.log(`⏱️ Next email in: ${rateLimiter.formatDelay(delayMs)}`);
|
|
|
|
if (config.logging.debug) {
|
|
console.log(`🐛 DEBUG: Delay calculated: ${delayMs}ms`);
|
|
console.log(
|
|
`🐛 DEBUG: Emails remaining: ${
|
|
uniqueFirms.length - uniqueFirms.indexOf(firm) - 1
|
|
}`
|
|
);
|
|
}
|
|
|
|
await delay(delayMs);
|
|
}
|
|
}
|
|
|
|
// Process any remaining retries
|
|
console.log(`🔄 Processing final retries...`);
|
|
await errorHandler.processRetries(
|
|
transporter,
|
|
async (email, recipient, trans) => {
|
|
await sendSingleEmail(email, recipient, trans);
|
|
}
|
|
);
|
|
|
|
// Final stats
|
|
const finalRetryStats = errorHandler.getRetryStats();
|
|
const finalRateStats = rateLimiter.getStats();
|
|
|
|
const campaignStats = {
|
|
totalSent: finalRateStats.sentCount,
|
|
runtime: finalRateStats.runtime,
|
|
averageRate: finalRateStats.averageRate,
|
|
retryStats: finalRetryStats,
|
|
};
|
|
|
|
logger.campaignComplete(campaignStats);
|
|
console.log(`📈 Campaign complete. Final stats:`, campaignStats);
|
|
|
|
// Complete campaign in database
|
|
if (currentCampaignId) {
|
|
await database.completeCampaign(currentCampaignId, {
|
|
sentEmails: finalRateStats.sentCount,
|
|
failedEmails: finalRetryStats.totalFailed,
|
|
});
|
|
}
|
|
|
|
// Stop tracking server if it was started
|
|
if (config.tracking.enabled && !config.tracking.skipTracking) {
|
|
try {
|
|
await trackingServer.stop();
|
|
} catch (error) {
|
|
logger.error("Failed to stop tracking server", { error: error.message });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle graceful shutdown
|
|
process.on("SIGINT", async () => {
|
|
console.log("\n🛑 Received SIGINT. Gracefully shutting down...");
|
|
|
|
// Stop tracking server
|
|
if (process.env.TRACKING_ENABLED === "true") {
|
|
try {
|
|
await trackingServer.stop();
|
|
} catch (error) {
|
|
logger.error("Failed to stop tracking server during shutdown", {
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
process.exit(0);
|
|
});
|
|
|
|
sendEmails().catch(async (error) => {
|
|
console.error("Campaign failed:", error);
|
|
|
|
// Stop tracking server on error
|
|
if (process.env.TRACKING_ENABLED === "true") {
|
|
try {
|
|
await trackingServer.stop();
|
|
} catch (stopError) {
|
|
logger.error("Failed to stop tracking server after error", {
|
|
error: stopError.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
process.exit(1);
|
|
});
|