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
97 changes: 68 additions & 29 deletions backend/src/controllers/emailController.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,55 @@
const nodemailer = require('nodemailer');
const { sendEmail, transporter } = require('../utils/sendEmail');
const { sendEmail } = require('../utils/SendEmail');
const logger = require('../utils/logger');
const mongoose = require('mongoose');
const EmailEvent = require('../models/EmailEvent');

//POST /api/emails/send
//Body: {to,subject,text,html,from}
// POST /api/emails/send
// Body: { to, subject, text, html, from }
exports.send = async (req, res) => {
try {
const { to, subject, text, html, from } = req.body;
if (!to) return res.status(400).json({ success: false, error: 'Recipient "to" is required' });
if (!to) {
return res.status(400).json({ success: false, error: 'Recipient "to" is required' });
}

// create unique emailId for tracking
const emailId = new mongoose.Types.ObjectId();

// ensure base URL is defined
const baseUrl = process.env.BASE_URL || 'http://localhost:5000';

// inject tracking pixel and link into HTML body
const trackedHtml = `
${html || ''}
<img src="${baseUrl}/api/track/open/${emailId}.png" width="1" height="1" style="display:none;" />
<p><a href="${baseUrl}/api/track/click/${emailId}?redirect=https://mailmern.vercel.app">Click here</a></p>
`;

// send email through configured transporter
const info = await sendEmail({ to, subject, text, html: trackedHtml, from });

const info = await sendEmail({ to, subject, text, html, from });
// store initial record in database
await EmailEvent.create({
_id: emailId,
recipient: to,
subject,
status: 'sent',
createdAt: new Date()
});

return res.status(200).json({
success: true,
message: 'Email sent',
message: 'Email sent (with tracking)',
info: {
messageId: info.messageId,
accepted: info.accepted,
rejected: info.rejected,
response: info.response
}
},
emailId
});
} catch (error) {
//full error.message for debugging
logger.error('Error in send email controller:', error && (error.stack || error));
return res.status(500).json({
success: false,
Expand All @@ -31,33 +58,49 @@ exports.send = async (req, res) => {
}
};

//POST /api/emails/test
//Body optional: {to} provide in body or in .env
// POST /api/emails/test
exports.test = async (req, res) => {
try {
const to = req.body?.to || process.env.EMAIL_TEST_TO || process.env.SMTP_USER || process.env.EMAIL_USER;
const to =
req.body?.to ||
process.env.EMAIL_TEST_TO ||
process.env.SMTP_USER ||
process.env.EMAIL_USER;
if (!to) {
return res.status(400).json({
success: false,
error: 'No test recipient configured. Provide "to" in body or set EMAIL_TEST_TO/SMTP_USER/EMAIL_USER in env.'
error:
'No test recipient configured. Provide "to" in body or set EMAIL_TEST_TO/SMTP_USER/EMAIL_USER in env.'
});
}

const subject = 'MailMERN — Test Email';
const text = `MailMERN test email sent at ${new Date().toISOString()}`;
const html = `<p>MailMERN test email sent at <strong>${new Date().toISOString()}</strong></p>`;
const subject = 'MailMERN — Test Email (Tracked)';
const html = `<p>Hello! This is a MailMERN tracking test email.</p>`;

const emailId = new mongoose.Types.ObjectId();
const baseUrl = process.env.BASE_URL || 'http://localhost:5000';

const trackedHtml = `
${html}
<img src="${baseUrl}/api/track/open/${emailId}.png" width="1" height="1" style="display:none;" />
<p><a href="${baseUrl}/api/track/click/${emailId}?redirect=https://mailmern.vercel.app">Click here</a></p>
`;

const info = await sendEmail({ to, subject, text, html });
const info = await sendEmail({ to, subject, html: trackedHtml });

await EmailEvent.create({
_id: emailId,
recipient: to,
subject,
status: 'sent',
createdAt: new Date()
});

return res.status(200).json({
success: true,
message: `Test email sent to ${to}`,
info: {
messageId: info.messageId,
accepted: info.accepted,
rejected: info.rejected,
response: info.response
}
message: `Test tracked email sent to ${to}`,
info,
emailId
});
} catch (error) {
logger.error('Error in email test controller:', error && (error.stack || error));
Expand All @@ -68,9 +111,7 @@ exports.test = async (req, res) => {
}
};


//POST /api/emails/test-ethereal
//Creates a temporary Ethereal account and sends a test email there.useful to check sending code path without real SMTP credentials
// POST /api/emails/test-ethereal
exports.testEthereal = async (req, res) => {
try {
const testAccount = await nodemailer.createTestAccount();
Expand All @@ -87,14 +128,12 @@ exports.testEthereal = async (req, res) => {

const to = req.body?.to || process.env.EMAIL_TEST_TO || '[email protected]';
const subject = 'MailMERN — Ethereal Test Email';
const text = `Ethereal test email sent at ${new Date().toISOString()}`;
const html = `<p>Ethereal test email sent at <strong>${new Date().toISOString()}</strong></p>`;

const info = await ethTransport.sendMail({
from: process.env.EMAIL_FROM || `MailMERN <${testAccount.user}>`,
to,
subject,
text,
html
});

Expand Down Expand Up @@ -122,4 +161,4 @@ exports.testEthereal = async (req, res) => {
error: error && (error.message || error)
});
}
};
};
52 changes: 52 additions & 0 deletions backend/src/controllers/trackController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// controllers/trackingController.js
const path = require('path');
const fs = require('fs');
const EmailEvent = require('../models/EmailEvent');
const logger = require('../utils/logger');

exports.trackOpen = async (req, res) => {
const { emailId } = req.params;
try {
await EmailEvent.create({
email: emailId, // ✅ required field
eventType: 'opened',
timestamp: new Date()
});
logger.info(`Open event logged for ${emailId}`);
} catch (err) {
logger.error('Error logging open event:', err);
}

const pixelPath = path.join(__dirname, '../assets/pixel.png');
if (fs.existsSync(pixelPath)) {
res.setHeader('Content-Type', 'image/png');
fs.createReadStream(pixelPath).pipe(res);
} else {
const img = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFxgJ/2fT8ZgAAAABJRU5ErkJggg==',
'base64'
);
res.setHeader('Content-Type', 'image/png');
res.end(img);
}
};

exports.trackClick = async (req, res) => {
const { emailId } = req.params;
const redirect = req.query.redirect;

try {
await EmailEvent.create({
email: emailId, // ✅ required field
eventType: 'clicked',
timestamp: new Date(),
metadata: { redirect }
});
logger.info(`Click event logged for ${emailId}`);
} catch (err) {
logger.error('Error logging click event:', err);
}

if (redirect) return res.redirect(redirect);
res.status(400).send('Missing redirect URL');
};
8 changes: 4 additions & 4 deletions backend/src/middlewares/errorMiddleware.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
export function errorMiddleware(err, req, res, next) {
export function errorMiddleware(err, req, res, next) {
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
res.status(statusCode).json({
res.status(statusCode).json({
success: false,
message,
stack: process.env.NODE_ENV === 'production' ? null : err.stack
});
next();
});
next();
}
12 changes: 12 additions & 0 deletions backend/src/models/EmailEvent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const mongoose = require('mongoose');

const emailEventSchema = new mongoose.Schema({
email: { type: String, required: true },
subject: { type: String },
messageId: { type: String },
status: { type: String, enum: ['sent', 'opened', 'clicked'], default: 'sent' },
eventType: { type: String },
createdAt: { type: Date, default: Date.now }
});

module.exports = mongoose.model('EmailEvent', emailEventSchema);
7 changes: 7 additions & 0 deletions backend/src/routes/trackRoutes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const express = require('express');
const router = express.Router();
const { trackOpen, trackClick } = require('../controllers/trackController');
router.get('/open/:emailId.png', trackOpen);
router.get('/click/:emailId', trackClick);

module.exports = router;
4 changes: 3 additions & 1 deletion backend/src/server.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
require('dotenv').config();

const express = require('express');
const cors = require('cors');
const connectDB = require('./config/db');
const userRoutes = require('./routes/userRoutes');
const { errorMiddleware } = require('./middlewares/errorMiddleware');
const chatbotRoutes = require('./routes/chatbotRoutes');
const emailRoutes = require('./routes/emailRoutes');
const trackRoutes = require('./routes/trackRoutes');
const { configDotenv } = require('dotenv');
const contactRoutes = require('./routes/contactRoutes');
const app = express();
app.use(
Expand All @@ -22,6 +23,7 @@ app.use('/api/users', userRoutes);
app.use('/api/chatbot', chatbotRoutes);
app.use('/api/auth', userRoutes);
app.use('/api/emails', emailRoutes);
app.use('/api/track', trackRoutes);
app.use('/api/contacts',contactRoutes);

const PORT = process.env.PORT || 5000;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
"nodemon": "^3.1.10",
"react-hot-toast": "^2.6.0"
}
}
}
Loading