Skip to content

Commit 1b03e44

Browse files
committed
feat: implement mass email campaign system with CSV upload
1 parent 96c45c1 commit 1b03e44

File tree

12 files changed

+862
-11
lines changed

12 files changed

+862
-11
lines changed

backend/package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "mailmern-backend",
33
"version": "0.1.0",
4-
"main": "src/server.js",
4+
"main": "src/server.js",
55
"scripts": {
66
"start": "node src/server.js",
77
"dev": "nodemon src/server.js"

backend/src/controllers/emailController.js

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ const { sendEmail } = require('../utils/SendEmail');
33
const logger = require('../utils/logger');
44
const mongoose = require('mongoose');
55
const EmailEvent = require('../models/EmailEvent');
6+
const { sendBulkEmails, getCampaignStatus, getUserCampaigns } = require('../services/bulkEmailService');
7+
const csvParser = require('csv-parser');
8+
const { Readable } = require('stream');
69

710
// POST /api/emails/send
811
// Body: { to, subject, text, html, from }
@@ -162,3 +165,152 @@ exports.testEthereal = async (req, res) => {
162165
});
163166
}
164167
};
168+
169+
// POST /api/emails/bulk-send
170+
exports.bulkSend = async (req, res) => {
171+
try {
172+
const { name, subject, html, text } = req.body;
173+
let recipients = [];
174+
175+
if (req.file && req.file.buffer) {
176+
recipients = await parseCSVFile(req.file.buffer);
177+
} else if (req.body.recipients) {
178+
try {
179+
recipients = typeof req.body.recipients === 'string'
180+
? JSON.parse(req.body.recipients)
181+
: req.body.recipients;
182+
} catch (e) {
183+
return res.status(400).json({
184+
success: false,
185+
error: 'Invalid recipients format. Expected JSON array or CSV file.'
186+
});
187+
}
188+
} else {
189+
return res.status(400).json({
190+
success: false,
191+
error: 'Either CSV file or recipients array is required'
192+
});
193+
}
194+
195+
if (!name || !subject) {
196+
return res.status(400).json({
197+
success: false,
198+
error: 'Campaign name and subject are required'
199+
});
200+
}
201+
if (!html && !text) {
202+
return res.status(400).json({
203+
success: false,
204+
error: 'Either HTML or text content is required'
205+
});
206+
}
207+
if (!recipients || recipients.length === 0) {
208+
return res.status(400).json({
209+
success: false,
210+
error: 'No valid recipients found in CSV or recipients array'
211+
});
212+
}
213+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
214+
const validRecipients = recipients.filter(r => {
215+
if (!r.email || !emailRegex.test(r.email)) {
216+
logger.warn(`Invalid email address: ${r.email}`);
217+
return false;
218+
}
219+
return true;
220+
});
221+
if (validRecipients.length === 0) {
222+
return res.status(400).json({
223+
success: false,
224+
error: 'No valid email addresses found'
225+
});
226+
}
227+
228+
const userId = req.user?.id || null;
229+
230+
// Send bulk emails
231+
const result = await sendBulkEmails(
232+
{ name, subject, html, text, userId },
233+
validRecipients
234+
);
235+
return res.status(200).json({
236+
success: true,
237+
...result
238+
});
239+
240+
} catch (error) {
241+
logger.error('Error in bulk send controller:', error && (error.stack || error));
242+
return res.status(500).json({
243+
success: false,
244+
error: error && (error.message || error)
245+
});
246+
}
247+
};
248+
249+
//function to parse CSV
250+
const parseCSVFile = (buffer) => {
251+
return new Promise((resolve, reject) => {
252+
const rows = [];
253+
const stream = Readable.from(buffer.toString());
254+
255+
stream
256+
.pipe(csvParser({
257+
skipLines: 0,
258+
mapHeaders: ({ header }) => header && header.trim().toLowerCase()
259+
}))
260+
.on('data', (row) => {
261+
const email = (row.email || row['e-mail'] || row['email address'] || '').toString().trim().toLowerCase();
262+
const name = (row.name || row['full name'] || row['fullname'] || row['first name'] || '').toString().trim();
263+
264+
if (email) {
265+
rows.push({ email, name });
266+
}
267+
})
268+
.on('end', () => {
269+
resolve(rows);
270+
})
271+
.on('error', (err) => {
272+
reject(err);
273+
});
274+
});
275+
};
276+
277+
// GET /api/emails/campaign/:id
278+
exports.getCampaign = async (req, res) => {
279+
try {
280+
const { id } = req.params;
281+
const campaign = await getCampaignStatus(id);
282+
283+
return res.status(200).json({
284+
success: true,
285+
campaign
286+
});
287+
} catch (error) {
288+
logger.error('Error getting campaign:', error && (error.stack || error));
289+
return res.status(500).json({
290+
success: false,
291+
error: error && (error.message || error)
292+
});
293+
}
294+
};
295+
296+
// GET /api/emails/campaigns
297+
exports.getCampaigns = async (req, res) => {
298+
try {
299+
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
300+
const limit = Math.max(1, parseInt(req.query.limit, 10) || 20);
301+
const userId = req.user?.id || null;
302+
303+
const result = await getUserCampaigns(userId, page, limit);
304+
305+
return res.status(200).json({
306+
success: true,
307+
...result
308+
});
309+
} catch (error) {
310+
logger.error('Error getting campaigns:', error && (error.stack || error));
311+
return res.status(500).json({
312+
success: false,
313+
error: error && (error.message || error)
314+
});
315+
}
316+
};

backend/src/controllers/googleController.js

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,31 @@
11
// controllers/googleController.js
2-
const { google } = require("googleapis");
2+
let google;
3+
try {
4+
google = require("googleapis").google;
5+
} catch (error) {
6+
console.warn(' googleapis package not found. Google Calendar features will be disabled.');
7+
google = null;
8+
}
39
const { DateTime } = require("luxon");
410

5-
const oauth2Client = new google.auth.OAuth2(
6-
process.env.GOOGLE_CLIENT_ID,
7-
process.env.GOOGLE_CLIENT_SECRET,
8-
"http://localhost:5001/api/google-calendar/oauth2callback"
9-
);
11+
let oauth2Client;
12+
if (google) {
13+
oauth2Client = new google.auth.OAuth2(
14+
process.env.GOOGLE_CLIENT_ID,
15+
process.env.GOOGLE_CLIENT_SECRET,
16+
"http://localhost:5001/api/google-calendar/oauth2callback"
17+
);
18+
} else {
19+
oauth2Client = null;
20+
}
1021

1122
exports.getAuthUrl = (req, res) => {
23+
if (!google || !oauth2Client) {
24+
return res.status(503).json({
25+
success: false,
26+
error: "Google Calendar feature is not available. Please install googleapis package: npm install googleapis"
27+
});
28+
}
1229
const url = oauth2Client.generateAuthUrl({
1330
access_type: "offline",
1431
scope: ["https://www.googleapis.com/auth/calendar.events"],
@@ -17,6 +34,9 @@ exports.getAuthUrl = (req, res) => {
1734
};
1835

1936
exports.oauthCallback = async (req, res) => {
37+
if (!google || !oauth2Client) {
38+
return res.status(503).send("Google Calendar feature is not available. Please install googleapis package.");
39+
}
2040
try {
2141
const { code } = req.query;
2242
const { tokens } = await oauth2Client.getToken(code);
@@ -29,10 +49,16 @@ exports.oauthCallback = async (req, res) => {
2949
};
3050

3151
exports.scheduleMeeting = async (req, res) => {
52+
if (!google || !oauth2Client) {
53+
return res.status(503).json({
54+
success: false,
55+
error: "Google Calendar feature is not available. Please install googleapis package: npm install googleapis"
56+
});
57+
}
3258
try {
3359
const { title, date, time, duration = 30 } = req.body;
3460

35-
if (!oauth2Client.credentials.access_token) {
61+
if (!oauth2Client.credentials || !oauth2Client.credentials.access_token) {
3662
return res
3763
.status(401)
3864
.json({ success: false, message: "Google Calendar not connected" });

backend/src/models/Campaign.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
const mongoose = require('mongoose');
2+
3+
const campaignSchema = new mongoose.Schema({
4+
name: { type: String, required: true, trim: true },
5+
subject: { type: String, required: true },
6+
html: { type: String },
7+
text: { type: String },
8+
status: {
9+
type: String,
10+
enum: ['pending', 'processing', 'completed', 'failed', 'cancelled'],
11+
default: 'pending'
12+
},
13+
totalRecipients: { type: Number, default: 0 },
14+
sentCount: { type: Number, default: 0 },
15+
failedCount: { type: Number, default: 0 },
16+
progress: { type: Number, default: 0 },
17+
startedAt: { type: Date },
18+
completedAt: { type: Date },
19+
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
20+
recipients: [{
21+
email: { type: String, required: true },
22+
name: { type: String },
23+
status: { type: String, enum: ['pending', 'sent', 'failed'], default: 'pending' },
24+
error: { type: String },
25+
sentAt: { type: Date }
26+
}],
27+
errors: [{ type: String }]
28+
}, { timestamps: true });
29+
30+
campaignSchema.index({ userId: 1, createdAt: -1 });
31+
campaignSchema.index({ status: 1 });
32+
33+
module.exports = mongoose.model('Campaign', campaignSchema);

backend/src/routes/emailRoutes.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
const express = require('express');
22
const router = express.Router();
3-
const { send, test, testEthereal } = require('../controllers/emailController');
3+
const multer = require('multer');
4+
const { send, test, testEthereal, bulkSend, getCampaign, getCampaigns } = require('../controllers/emailController');
5+
6+
const upload = multer({
7+
storage: multer.memoryStorage(),
8+
limits: {
9+
fileSize: 10 * 1024 * 1024 // 10MB limit
10+
}
11+
});
412

513
router.post('/send', send);
614

715
router.post('/test', test);
816
router.post('/test-ethereal', testEthereal);
17+
router.post('/bulk-send', upload.single('file'), bulkSend);
18+
router.get('/campaign/:id', getCampaign);
19+
router.get('/campaigns', getCampaigns);
920
module.exports = router;

backend/src/server.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const emailRoutes = require('./routes/emailRoutes');
99
const trackRoutes = require('./routes/trackRoutes');
1010
const { configDotenv } = require('dotenv');
1111
const contactRoutes = require('./routes/contactRoutes');
12+
const googleRoutes = require('./routes/googleRoute');
1213
const app = express();
1314
app.use(
1415
cors({

0 commit comments

Comments
 (0)