Skip to content

Commit 59b1f90

Browse files
authored
Merge pull request #48 from ADARSHsri2004/main
feat: implemented email tracking
2 parents db2810a + a1fa84e commit 59b1f90

7 files changed

Lines changed: 147 additions & 35 deletions

File tree

backend/src/controllers/emailController.js

Lines changed: 68 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,55 @@
11
const nodemailer = require('nodemailer');
2-
const { sendEmail, transporter } = require('../utils/sendEmail');
2+
const { sendEmail } = require('../utils/SendEmail');
33
const logger = require('../utils/logger');
4+
const mongoose = require('mongoose');
5+
const EmailEvent = require('../models/EmailEvent');
46

5-
//POST /api/emails/send
6-
//Body: {to,subject,text,html,from}
7+
// POST /api/emails/send
8+
// Body: { to, subject, text, html, from }
79
exports.send = async (req, res) => {
810
try {
911
const { to, subject, text, html, from } = req.body;
10-
if (!to) return res.status(400).json({ success: false, error: 'Recipient "to" is required' });
12+
if (!to) {
13+
return res.status(400).json({ success: false, error: 'Recipient "to" is required' });
14+
}
15+
16+
// create unique emailId for tracking
17+
const emailId = new mongoose.Types.ObjectId();
18+
19+
// ensure base URL is defined
20+
const baseUrl = process.env.BASE_URL || 'http://localhost:5000';
21+
22+
// inject tracking pixel and link into HTML body
23+
const trackedHtml = `
24+
${html || ''}
25+
<img src="${baseUrl}/api/track/open/${emailId}.png" width="1" height="1" style="display:none;" />
26+
<p><a href="${baseUrl}/api/track/click/${emailId}?redirect=https://mailmern.vercel.app">Click here</a></p>
27+
`;
28+
29+
// send email through configured transporter
30+
const info = await sendEmail({ to, subject, text, html: trackedHtml, from });
1131

12-
const info = await sendEmail({ to, subject, text, html, from });
32+
// store initial record in database
33+
await EmailEvent.create({
34+
_id: emailId,
35+
recipient: to,
36+
subject,
37+
status: 'sent',
38+
createdAt: new Date()
39+
});
1340

1441
return res.status(200).json({
1542
success: true,
16-
message: 'Email sent',
43+
message: 'Email sent (with tracking)',
1744
info: {
1845
messageId: info.messageId,
1946
accepted: info.accepted,
2047
rejected: info.rejected,
2148
response: info.response
22-
}
49+
},
50+
emailId
2351
});
2452
} catch (error) {
25-
//full error.message for debugging
2653
logger.error('Error in send email controller:', error && (error.stack || error));
2754
return res.status(500).json({
2855
success: false,
@@ -31,33 +58,49 @@ exports.send = async (req, res) => {
3158
}
3259
};
3360

34-
//POST /api/emails/test
35-
//Body optional: {to} provide in body or in .env
61+
// POST /api/emails/test
3662
exports.test = async (req, res) => {
3763
try {
38-
const to = req.body?.to || process.env.EMAIL_TEST_TO || process.env.SMTP_USER || process.env.EMAIL_USER;
64+
const to =
65+
req.body?.to ||
66+
process.env.EMAIL_TEST_TO ||
67+
process.env.SMTP_USER ||
68+
process.env.EMAIL_USER;
3969
if (!to) {
4070
return res.status(400).json({
4171
success: false,
42-
error: 'No test recipient configured. Provide "to" in body or set EMAIL_TEST_TO/SMTP_USER/EMAIL_USER in env.'
72+
error:
73+
'No test recipient configured. Provide "to" in body or set EMAIL_TEST_TO/SMTP_USER/EMAIL_USER in env.'
4374
});
4475
}
4576

46-
const subject = 'MailMERN — Test Email';
47-
const text = `MailMERN test email sent at ${new Date().toISOString()}`;
48-
const html = `<p>MailMERN test email sent at <strong>${new Date().toISOString()}</strong></p>`;
77+
const subject = 'MailMERN — Test Email (Tracked)';
78+
const html = `<p>Hello! This is a MailMERN tracking test email.</p>`;
79+
80+
const emailId = new mongoose.Types.ObjectId();
81+
const baseUrl = process.env.BASE_URL || 'http://localhost:5000';
82+
83+
const trackedHtml = `
84+
${html}
85+
<img src="${baseUrl}/api/track/open/${emailId}.png" width="1" height="1" style="display:none;" />
86+
<p><a href="${baseUrl}/api/track/click/${emailId}?redirect=https://mailmern.vercel.app">Click here</a></p>
87+
`;
4988

50-
const info = await sendEmail({ to, subject, text, html });
89+
const info = await sendEmail({ to, subject, html: trackedHtml });
90+
91+
await EmailEvent.create({
92+
_id: emailId,
93+
recipient: to,
94+
subject,
95+
status: 'sent',
96+
createdAt: new Date()
97+
});
5198

5299
return res.status(200).json({
53100
success: true,
54-
message: `Test email sent to ${to}`,
55-
info: {
56-
messageId: info.messageId,
57-
accepted: info.accepted,
58-
rejected: info.rejected,
59-
response: info.response
60-
}
101+
message: `Test tracked email sent to ${to}`,
102+
info,
103+
emailId
61104
});
62105
} catch (error) {
63106
logger.error('Error in email test controller:', error && (error.stack || error));
@@ -68,9 +111,7 @@ exports.test = async (req, res) => {
68111
}
69112
};
70113

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

88129
const to = req.body?.to || process.env.EMAIL_TEST_TO || 'recipient@example.com';
89130
const subject = 'MailMERN — Ethereal Test Email';
90-
const text = `Ethereal test email sent at ${new Date().toISOString()}`;
91131
const html = `<p>Ethereal test email sent at <strong>${new Date().toISOString()}</strong></p>`;
92132

93133
const info = await ethTransport.sendMail({
94134
from: process.env.EMAIL_FROM || `MailMERN <${testAccount.user}>`,
95135
to,
96136
subject,
97-
text,
98137
html
99138
});
100139

@@ -122,4 +161,4 @@ exports.testEthereal = async (req, res) => {
122161
error: error && (error.message || error)
123162
});
124163
}
125-
};
164+
};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// controllers/trackingController.js
2+
const path = require('path');
3+
const fs = require('fs');
4+
const EmailEvent = require('../models/EmailEvent');
5+
const logger = require('../utils/logger');
6+
7+
exports.trackOpen = async (req, res) => {
8+
const { emailId } = req.params;
9+
try {
10+
await EmailEvent.create({
11+
email: emailId, // ✅ required field
12+
eventType: 'opened',
13+
timestamp: new Date()
14+
});
15+
logger.info(`Open event logged for ${emailId}`);
16+
} catch (err) {
17+
logger.error('Error logging open event:', err);
18+
}
19+
20+
const pixelPath = path.join(__dirname, '../assets/pixel.png');
21+
if (fs.existsSync(pixelPath)) {
22+
res.setHeader('Content-Type', 'image/png');
23+
fs.createReadStream(pixelPath).pipe(res);
24+
} else {
25+
const img = Buffer.from(
26+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFxgJ/2fT8ZgAAAABJRU5ErkJggg==',
27+
'base64'
28+
);
29+
res.setHeader('Content-Type', 'image/png');
30+
res.end(img);
31+
}
32+
};
33+
34+
exports.trackClick = async (req, res) => {
35+
const { emailId } = req.params;
36+
const redirect = req.query.redirect;
37+
38+
try {
39+
await EmailEvent.create({
40+
email: emailId, // ✅ required field
41+
eventType: 'clicked',
42+
timestamp: new Date(),
43+
metadata: { redirect }
44+
});
45+
logger.info(`Click event logged for ${emailId}`);
46+
} catch (err) {
47+
logger.error('Error logging click event:', err);
48+
}
49+
50+
if (redirect) return res.redirect(redirect);
51+
res.status(400).send('Missing redirect URL');
52+
};
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
export function errorMiddleware(err, req, res, next) {
1+
export function errorMiddleware(err, req, res, next) {
22
const statusCode = err.statusCode || 500;
33
const message = err.message || 'Internal Server Error';
4-
res.status(statusCode).json({
4+
res.status(statusCode).json({
55
success: false,
66
message,
77
stack: process.env.NODE_ENV === 'production' ? null : err.stack
8-
});
9-
next();
8+
});
9+
next();
1010
}

backend/src/models/EmailEvent.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
const mongoose = require('mongoose');
2+
3+
const emailEventSchema = new mongoose.Schema({
4+
email: { type: String, required: true },
5+
subject: { type: String },
6+
messageId: { type: String },
7+
status: { type: String, enum: ['sent', 'opened', 'clicked'], default: 'sent' },
8+
eventType: { type: String },
9+
createdAt: { type: Date, default: Date.now }
10+
});
11+
12+
module.exports = mongoose.model('EmailEvent', emailEventSchema);

backend/src/routes/trackRoutes.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
const express = require('express');
2+
const router = express.Router();
3+
const { trackOpen, trackClick } = require('../controllers/trackController');
4+
router.get('/open/:emailId.png', trackOpen);
5+
router.get('/click/:emailId', trackClick);
6+
7+
module.exports = router;

backend/src/server.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
require('dotenv').config();
2-
32
const express = require('express');
43
const cors = require('cors');
54
const connectDB = require('./config/db');
65
const userRoutes = require('./routes/userRoutes');
76
const { errorMiddleware } = require('./middlewares/errorMiddleware');
87
const chatbotRoutes = require('./routes/chatbotRoutes');
98
const emailRoutes = require('./routes/emailRoutes');
9+
const trackRoutes = require('./routes/trackRoutes');
10+
const { configDotenv } = require('dotenv');
1011
const contactRoutes = require('./routes/contactRoutes');
1112
const app = express();
1213
app.use(
@@ -22,6 +23,7 @@ app.use('/api/users', userRoutes);
2223
app.use('/api/chatbot', chatbotRoutes);
2324
app.use('/api/auth', userRoutes);
2425
app.use('/api/emails', emailRoutes);
26+
app.use('/api/track', trackRoutes);
2527
app.use('/api/contacts',contactRoutes);
2628

2729
const PORT = process.env.PORT || 5000;

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@
1313
"nodemon": "^3.1.10",
1414
"react-hot-toast": "^2.6.0"
1515
}
16-
}
16+
}

0 commit comments

Comments
 (0)