Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
152 changes: 152 additions & 0 deletions backend/src/controllers/emailController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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)
});
}
};
40 changes: 33 additions & 7 deletions backend/src/controllers/googleController.js
Original file line number Diff line number Diff line change
@@ -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"],
Expand All @@ -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);
Expand All @@ -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" });
Expand Down
33 changes: 33 additions & 0 deletions backend/src/models/Campaign.js
Original file line number Diff line number Diff line change
@@ -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);
13 changes: 12 additions & 1 deletion backend/src/routes/emailRoutes.js
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions backend/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading