From 1b03e4449fd89a668e2e6fbeb592f7cd46fd3364 Mon Sep 17 00:00:00 2001 From: indar suthar Date: Thu, 13 Nov 2025 15:45:57 +0530 Subject: [PATCH] feat: implement mass email campaign system with CSV upload --- backend/package-lock.json | 1 + backend/package.json | 2 +- backend/src/controllers/emailController.js | 152 ++++++++ backend/src/controllers/googleController.js | 40 ++- backend/src/models/Campaign.js | 33 ++ backend/src/routes/emailRoutes.js | 13 +- backend/src/server.js | 1 + backend/src/services/bulkEmailService.js | 211 +++++++++++ frontend/src/App.jsx | 4 +- frontend/src/components/Navbar.jsx | 1 + frontend/src/pages/BulkEmail.jsx | 379 ++++++++++++++++++++ frontend/src/services/emailService.js | 36 +- 12 files changed, 862 insertions(+), 11 deletions(-) create mode 100644 backend/src/models/Campaign.js create mode 100644 backend/src/services/bulkEmailService.js create mode 100644 frontend/src/pages/BulkEmail.jsx diff --git a/backend/package-lock.json b/backend/package-lock.json index eee8739..3c0ff15 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -4523,6 +4523,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/backend/package.json b/backend/package.json index f528b34..9f9c6ef 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,7 +1,7 @@ { "name": "mailmern-backend", "version": "0.1.0", - "main": "src/server.js", + "main": "src/server.js", "scripts": { "start": "node src/server.js", "dev": "nodemon src/server.js" diff --git a/backend/src/controllers/emailController.js b/backend/src/controllers/emailController.js index c814e84..da654e2 100644 --- a/backend/src/controllers/emailController.js +++ b/backend/src/controllers/emailController.js @@ -3,6 +3,9 @@ const { sendEmail } = require('../utils/SendEmail'); const logger = require('../utils/logger'); const mongoose = require('mongoose'); const EmailEvent = require('../models/EmailEvent'); +const { sendBulkEmails, getCampaignStatus, getUserCampaigns } = require('../services/bulkEmailService'); +const csvParser = require('csv-parser'); +const { Readable } = require('stream'); // POST /api/emails/send // Body: { to, subject, text, html, from } @@ -162,3 +165,152 @@ exports.testEthereal = async (req, res) => { }); } }; + +// POST /api/emails/bulk-send +exports.bulkSend = async (req, res) => { + try { + const { name, subject, html, text } = req.body; + let recipients = []; + + if (req.file && req.file.buffer) { + recipients = await parseCSVFile(req.file.buffer); + } else if (req.body.recipients) { + try { + recipients = typeof req.body.recipients === 'string' + ? JSON.parse(req.body.recipients) + : req.body.recipients; + } catch (e) { + return res.status(400).json({ + success: false, + error: 'Invalid recipients format. Expected JSON array or CSV file.' + }); + } + } else { + return res.status(400).json({ + success: false, + error: 'Either CSV file or recipients array is required' + }); + } + + if (!name || !subject) { + return res.status(400).json({ + success: false, + error: 'Campaign name and subject are required' + }); + } + if (!html && !text) { + return res.status(400).json({ + success: false, + error: 'Either HTML or text content is required' + }); + } + if (!recipients || recipients.length === 0) { + return res.status(400).json({ + success: false, + error: 'No valid recipients found in CSV or recipients array' + }); + } + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const validRecipients = recipients.filter(r => { + if (!r.email || !emailRegex.test(r.email)) { + logger.warn(`Invalid email address: ${r.email}`); + return false; + } + return true; + }); + if (validRecipients.length === 0) { + return res.status(400).json({ + success: false, + error: 'No valid email addresses found' + }); + } + + const userId = req.user?.id || null; + + // Send bulk emails + const result = await sendBulkEmails( + { name, subject, html, text, userId }, + validRecipients + ); + return res.status(200).json({ + success: true, + ...result + }); + + } catch (error) { + logger.error('Error in bulk send controller:', error && (error.stack || error)); + return res.status(500).json({ + success: false, + error: error && (error.message || error) + }); + } +}; + +//function to parse CSV +const parseCSVFile = (buffer) => { + return new Promise((resolve, reject) => { + const rows = []; + const stream = Readable.from(buffer.toString()); + + stream + .pipe(csvParser({ + skipLines: 0, + mapHeaders: ({ header }) => header && header.trim().toLowerCase() + })) + .on('data', (row) => { + const email = (row.email || row['e-mail'] || row['email address'] || '').toString().trim().toLowerCase(); + const name = (row.name || row['full name'] || row['fullname'] || row['first name'] || '').toString().trim(); + + if (email) { + rows.push({ email, name }); + } + }) + .on('end', () => { + resolve(rows); + }) + .on('error', (err) => { + reject(err); + }); + }); +}; + +// GET /api/emails/campaign/:id +exports.getCampaign = async (req, res) => { + try { + const { id } = req.params; + const campaign = await getCampaignStatus(id); + + return res.status(200).json({ + success: true, + campaign + }); + } catch (error) { + logger.error('Error getting campaign:', error && (error.stack || error)); + return res.status(500).json({ + success: false, + error: error && (error.message || error) + }); + } +}; + +// GET /api/emails/campaigns +exports.getCampaigns = async (req, res) => { + try { + const page = Math.max(1, parseInt(req.query.page, 10) || 1); + const limit = Math.max(1, parseInt(req.query.limit, 10) || 20); + const userId = req.user?.id || null; + + const result = await getUserCampaigns(userId, page, limit); + + return res.status(200).json({ + success: true, + ...result + }); + } catch (error) { + logger.error('Error getting campaigns:', error && (error.stack || error)); + return res.status(500).json({ + success: false, + error: error && (error.message || error) + }); + } +}; \ No newline at end of file diff --git a/backend/src/controllers/googleController.js b/backend/src/controllers/googleController.js index 9cf1685..27f6a0a 100644 --- a/backend/src/controllers/googleController.js +++ b/backend/src/controllers/googleController.js @@ -1,14 +1,31 @@ // controllers/googleController.js -const { google } = require("googleapis"); +let google; +try { + google = require("googleapis").google; +} catch (error) { + console.warn(' googleapis package not found. Google Calendar features will be disabled.'); + google = null; +} const { DateTime } = require("luxon"); -const oauth2Client = new google.auth.OAuth2( - process.env.GOOGLE_CLIENT_ID, - process.env.GOOGLE_CLIENT_SECRET, - "http://localhost:5001/api/google-calendar/oauth2callback" -); +let oauth2Client; +if (google) { + oauth2Client = new google.auth.OAuth2( + process.env.GOOGLE_CLIENT_ID, + process.env.GOOGLE_CLIENT_SECRET, + "http://localhost:5001/api/google-calendar/oauth2callback" + ); +} else { + oauth2Client = null; +} exports.getAuthUrl = (req, res) => { + if (!google || !oauth2Client) { + return res.status(503).json({ + success: false, + error: "Google Calendar feature is not available. Please install googleapis package: npm install googleapis" + }); + } const url = oauth2Client.generateAuthUrl({ access_type: "offline", scope: ["https://www.googleapis.com/auth/calendar.events"], @@ -17,6 +34,9 @@ exports.getAuthUrl = (req, res) => { }; exports.oauthCallback = async (req, res) => { + if (!google || !oauth2Client) { + return res.status(503).send("Google Calendar feature is not available. Please install googleapis package."); + } try { const { code } = req.query; const { tokens } = await oauth2Client.getToken(code); @@ -29,10 +49,16 @@ exports.oauthCallback = async (req, res) => { }; exports.scheduleMeeting = async (req, res) => { + if (!google || !oauth2Client) { + return res.status(503).json({ + success: false, + error: "Google Calendar feature is not available. Please install googleapis package: npm install googleapis" + }); + } try { const { title, date, time, duration = 30 } = req.body; - if (!oauth2Client.credentials.access_token) { + if (!oauth2Client.credentials || !oauth2Client.credentials.access_token) { return res .status(401) .json({ success: false, message: "Google Calendar not connected" }); diff --git a/backend/src/models/Campaign.js b/backend/src/models/Campaign.js new file mode 100644 index 0000000..5320428 --- /dev/null +++ b/backend/src/models/Campaign.js @@ -0,0 +1,33 @@ +const mongoose = require('mongoose'); + +const campaignSchema = new mongoose.Schema({ + name: { type: String, required: true, trim: true }, + subject: { type: String, required: true }, + html: { type: String }, + text: { type: String }, + status: { + type: String, + enum: ['pending', 'processing', 'completed', 'failed', 'cancelled'], + default: 'pending' + }, + totalRecipients: { type: Number, default: 0 }, + sentCount: { type: Number, default: 0 }, + failedCount: { type: Number, default: 0 }, + progress: { type: Number, default: 0 }, + startedAt: { type: Date }, + completedAt: { type: Date }, + userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + recipients: [{ + email: { type: String, required: true }, + name: { type: String }, + status: { type: String, enum: ['pending', 'sent', 'failed'], default: 'pending' }, + error: { type: String }, + sentAt: { type: Date } + }], + errors: [{ type: String }] +}, { timestamps: true }); + +campaignSchema.index({ userId: 1, createdAt: -1 }); +campaignSchema.index({ status: 1 }); + +module.exports = mongoose.model('Campaign', campaignSchema); diff --git a/backend/src/routes/emailRoutes.js b/backend/src/routes/emailRoutes.js index 6a32d5f..3880035 100644 --- a/backend/src/routes/emailRoutes.js +++ b/backend/src/routes/emailRoutes.js @@ -1,9 +1,20 @@ const express = require('express'); const router = express.Router(); -const { send, test, testEthereal } = require('../controllers/emailController'); +const multer = require('multer'); +const { send, test, testEthereal, bulkSend, getCampaign, getCampaigns } = require('../controllers/emailController'); + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 10 * 1024 * 1024 // 10MB limit + } +}); router.post('/send', send); router.post('/test', test); router.post('/test-ethereal', testEthereal); +router.post('/bulk-send', upload.single('file'), bulkSend); +router.get('/campaign/:id', getCampaign); +router.get('/campaigns', getCampaigns); module.exports = router; \ No newline at end of file diff --git a/backend/src/server.js b/backend/src/server.js index c38bb8f..291dd3a 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -9,6 +9,7 @@ const emailRoutes = require('./routes/emailRoutes'); const trackRoutes = require('./routes/trackRoutes'); const { configDotenv } = require('dotenv'); const contactRoutes = require('./routes/contactRoutes'); +const googleRoutes = require('./routes/googleRoute'); const app = express(); app.use( cors({ diff --git a/backend/src/services/bulkEmailService.js b/backend/src/services/bulkEmailService.js new file mode 100644 index 0000000..57698d3 --- /dev/null +++ b/backend/src/services/bulkEmailService.js @@ -0,0 +1,211 @@ +const { sendEmail } = require('../utils/SendEmail'); +const EmailEvent = require('../models/EmailEvent'); +const Campaign = require('../models/Campaign'); +const mongoose = require('mongoose'); +const logger = require('../utils/logger'); + +const processBatch = async (batch, campaignId, startIndex, html, text, subject, baseUrl) => { + const results = await Promise.allSettled( + batch.map(async (recipient, batchIndex) => { + const index = startIndex + batchIndex; + try { + const emailId = new mongoose.Types.ObjectId(); + + //email content + let personalizedHtml = html || ''; + let personalizedText = text || ''; + + if (recipient.name) { + personalizedHtml = personalizedHtml.replace(/\{\{name\}\}/g, recipient.name); + personalizedText = personalizedText.replace(/\{\{name\}\}/g, recipient.name); + } + const trackedHtml = ` + ${personalizedHtml} + + `; + + // Send email + await sendEmail({ + to: recipient.email, + subject: subject, + html: trackedHtml, + text: personalizedText + }); + + // Create email event + await EmailEvent.create({ + _id: emailId, + email: recipient.email, + subject: subject, + status: 'sent', + messageId: emailId.toString(), + eventType: 'sent', + createdAt: new Date() + }); + await Campaign.updateOne( + { _id: campaignId, 'recipients.email': recipient.email }, + { + $set: { + 'recipients.$.status': 'sent', + 'recipients.$.sentAt': new Date() + } + } + ); + + return { success: true, email: recipient.email }; + } catch (error) { + logger.error(`Failed to send email to ${recipient.email}:`, error); + + await Campaign.updateOne( + { _id: campaignId, 'recipients.email': recipient.email }, + { + $set: { + 'recipients.$.status': 'failed', + 'recipients.$.error': error.message || 'Unknown error' + } + } + ); + + return { success: false, email: recipient.email, error: error.message }; + } + }) + ); + + return results; +}; +exports.sendBulkEmails = async (campaignData, recipients) => { + const { name, subject, html, text, userId } = campaignData; + + //campaign record + const campaign = await Campaign.create({ + name, + subject, + html, + text, + userId: userId || null, + status: 'pending', + totalRecipients: recipients.length, + recipients: recipients.map(r => ({ + email: r.email, + name: r.name || '', + status: 'pending' + })) + }); + campaign.status = 'processing'; + campaign.startedAt = new Date(); + await campaign.save(); + + processBulkEmailsAsync(campaign, recipients, html, text, subject); + + return { + campaignId: campaign._id, + status: 'processing', + totalRecipients: recipients.length, + message: 'Campaign started. Emails are being sent in the background.' + }; +}; + +const processBulkEmailsAsync = async (campaign, recipients, html, text, subject) => { + const batchSize = parseInt(process.env.EMAIL_BATCH_SIZE) || 10; + const delayBetweenBatches = parseInt(process.env.EMAIL_BATCH_DELAY) || 1000; + const baseUrl = process.env.BASE_URL || 'http://localhost:5000'; + const campaignId = campaign._id; + + let sentCount = 0; + let failedCount = 0; + const errors = []; + + try { + for (let i = 0; i < recipients.length; i += batchSize) { + const batch = recipients.slice(i, i + batchSize); + const results = await processBatch(batch, campaignId, i, html, text, subject, baseUrl); + results.forEach((result, batchIndex) => { + if (result.status === 'fulfilled') { + if (result.value.success) { + sentCount++; + } else { + failedCount++; + if (result.value.error) { + errors.push(`${result.value.email}: ${result.value.error}`); + } + } + } else { + failedCount++; + const email = batch[batchIndex]?.email || 'unknown'; + errors.push(`${email}: ${result.reason?.message || 'Unknown error'}`); + } + }); + const updatedCampaign = await Campaign.findById(campaignId); + if (!updatedCampaign) { + throw new Error('Campaign not found'); + } + + const totalProcessed = sentCount + failedCount; + updatedCampaign.progress = Math.round((totalProcessed / updatedCampaign.totalRecipients) * 100); + updatedCampaign.sentCount = sentCount; + updatedCampaign.failedCount = failedCount; + updatedCampaign.errors = errors.slice(0, 100); + + await updatedCampaign.save(); + + if (i + batchSize < recipients.length) { + await new Promise(resolve => setTimeout(resolve, delayBetweenBatches)); + } + } + + const finalCampaign = await Campaign.findById(campaignId); + if (!finalCampaign) { + throw new Error('Campaign not found'); + } + + finalCampaign.status = failedCount === finalCampaign.totalRecipients ? 'failed' : 'completed'; + finalCampaign.completedAt = new Date(); + finalCampaign.progress = 100; + finalCampaign.sentCount = sentCount; + finalCampaign.failedCount = failedCount; + await finalCampaign.save(); + + logger.info(`Campaign ${campaignId} completed: ${sentCount} sent, ${failedCount} failed`); + + } catch (error) { + logger.error(`Campaign ${campaignId} failed:`, error); + const failedCampaign = await Campaign.findById(campaignId); + if (failedCampaign) { + failedCampaign.status = 'failed'; + failedCampaign.errors.push(`Campaign error: ${error.message}`); + await failedCampaign.save(); + } + } +}; + +exports.getCampaignStatus = async (campaignId) => { + const campaign = await Campaign.findById(campaignId); + if (!campaign) { + throw new Error('Campaign not found'); + } + return campaign; +}; + +exports.getUserCampaigns = async (userId, page = 1, limit = 20) => { + const skip = (page - 1) * limit; + const query = userId ? { userId } : {}; + + const [campaigns, total] = await Promise.all([ + Campaign.find(query) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit) + .select('-recipients -html -text') + .lean(), + Campaign.countDocuments(query) + ]); + + return { + campaigns, + total, + page, + limit, + totalPages: Math.ceil(total / limit) + }; +}; + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2314bbf..f4a5c42 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,7 +14,8 @@ import NotFound from "./pages/NotFound"; import TemplateBuilder from "./pages/Campaign"; import { AuthProvider } from "./context/AuthContext"; import ForgotPassword from "./pages/Forgotpassword"; -import Contacts from "./pages/Contact"; +import Contacts from "./pages/Contact"; +import BulkEmail from "./pages/BulkEmail"; export default function App() { return ( @@ -33,6 +34,7 @@ export default function App() { } /> }/> }/> + }/> } /> diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index a7d82d6..52fe941 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -11,6 +11,7 @@ export default function Navbar() { { name: "Home", path: "/" }, { name: "Dashboard", path: "/dashboard" }, { name: "Chatbot", path: "/chatbot" }, + { name: "Bulk Email", path: "/bulk-email" }, { name: "Login", path: "/login" }, { name: "Register", path: "/register" }, { name:"Email Builder", path:"/builder"}, diff --git a/frontend/src/pages/BulkEmail.jsx b/frontend/src/pages/BulkEmail.jsx new file mode 100644 index 0000000..a7e97a8 --- /dev/null +++ b/frontend/src/pages/BulkEmail.jsx @@ -0,0 +1,379 @@ +import React, { useState } from "react"; +import { motion } from "framer-motion"; +import { Upload, Send, FileText, Users, Loader2, CheckCircle2, XCircle, AlertCircle } from "lucide-react"; +import { sendBulkEmail, getCampaignStatus } from "../services/emailService"; +import toast from "react-hot-toast"; + +export default function BulkEmail() { + const [campaignName, setCampaignName] = useState(""); + const [subject, setSubject] = useState(""); + const [htmlContent, setHtmlContent] = useState(""); + const [textContent, setTextContent] = useState(""); + const [csvFile, setCsvFile] = useState(null); + const [csvPreview, setCsvPreview] = useState([]); + const [isSending, setIsSending] = useState(false); + const [campaignId, setCampaignId] = useState(null); + const [campaignStatus, setCampaignStatus] = useState(null); + const [pollingInterval, setPollingInterval] = useState(null); + + const handleFileUpload = (e) => { + const file = e.target.files[0]; + if (!file) return; + + if (!file.name.endsWith('.csv')) { + toast.error("Please upload a CSV file"); + return; + } + + setCsvFile(file); + + // Parse CSV + const reader = new FileReader(); + reader.onload = (event) => { + const text = event.target.result; + const lines = text.split('\n').filter(line => line.trim()); + const headers = lines[0].split(',').map(h => h.trim().toLowerCase()); + + const preview = []; + for (let i = 1; i < Math.min(lines.length, 6); i++) { + const values = lines[i].split(',').map(v => v.trim()); + const row = {}; + headers.forEach((header, index) => { + row[header] = values[index] || ''; + }); + preview.push(row); + } + + setCsvPreview(preview); + toast.success(`CSV file loaded. Found ${lines.length - 1} recipients.`); + }; + reader.readAsText(file); + }; + + //form submission + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!campaignName || !subject) { + toast.error("Please fill in campaign name and subject"); + return; + } + + if (!htmlContent && !textContent) { + toast.error("Please provide either HTML or text content"); + return; + } + + if (!csvFile) { + toast.error("Please upload a CSV file"); + return; + } + + setIsSending(true); + + try { + const formData = new FormData(); + formData.append("name", campaignName); + formData.append("subject", subject); + formData.append("html", htmlContent); + formData.append("text", textContent); + formData.append("file", csvFile); + + const response = await sendBulkEmail(formData); + + if (response.success) { + setCampaignId(response.campaignId); + toast.success(response.message || "Campaign started successfully!"); + + startPolling(response.campaignId); + } else { + toast.error(response.error || "Failed to start campaign"); + } + } catch (error) { + console.error("Error sending bulk email:", error); + toast.error(error.response?.data?.error || error.message || "Failed to send bulk emails"); + } finally { + setIsSending(false); + } + }; + + const startPolling = (id) => { + const interval = setInterval(async () => { + try { + const status = await getCampaignStatus(id); + setCampaignStatus(status.campaign); + + if (status.campaign.status === 'completed' || status.campaign.status === 'failed') { + clearInterval(interval); + setPollingInterval(null); + } + } catch (error) { + console.error("Error fetching campaign status:", error); + } + }, 2000); + + setPollingInterval(interval); + }; + + React.useEffect(() => { + return () => { + if (pollingInterval) { + clearInterval(pollingInterval); + } + }; + }, [pollingInterval]); + + return ( +
+
+ +

Bulk Email Sending

+

Send mass emails from a CSV list with batch processing

+
+ +
+ {/* Main Form */} + +
+
+ {/* Campaign Name */} +
+ + setCampaignName(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="e.g., Q4 Product Launch" + required + /> +
+ + {/* Subject */} +
+ + setSubject(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="e.g., Exciting News About Our New Product" + required + /> +
+ + {/* HTML Content */} +
+ +