outreach/index.js
2025-08-15 01:03:38 -08:00

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);
});