Initial commit
This commit is contained in:
@@ -0,0 +1,527 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user