tractatus/src/controllers/newsletter.controller.js
TheFlow 973be3e61d feat: Implement newsletter email sending functionality (Phase 3)
Complete implementation of newsletter sending system with SendGrid integration:

Backend Implementation:
- EmailService class with template rendering (Handlebars)
- sendNewsletter() method with subscriber iteration
- Preview and send controller methods
- Admin routes with CSRF protection and authentication
- findByInterest() method in NewsletterSubscription model

Frontend Implementation:
- Newsletter send form with validation
- Preview functionality (opens in new window)
- Test send to single email
- Production send to all tier subscribers
- Real-time status updates

Dependencies:
- handlebars (template engine)
- @sendgrid/mail (email delivery)
- html-to-text (plain text generation)

Security:
- Admin-only routes with authentication
- CSRF protection on all POST endpoints
- Input validation and sanitization
- Confirmation dialogs for production sends

Next steps: Configure SendGrid API key in environment variables

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 11:32:39 +13:00

398 lines
9.6 KiB
JavaScript

/**
* Newsletter Controller
* Handles newsletter subscriptions and management
*/
const NewsletterSubscription = require('../models/NewsletterSubscription.model');
const emailService = require('../services/email.service');
const logger = require('../utils/logger.util');
/**
* Subscribe to newsletter (public)
* POST /api/newsletter/subscribe
*/
exports.subscribe = async (req, res) => {
try {
const { email, name, source, interests } = req.body;
// Validate email
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return res.status(400).json({
success: false,
error: 'Valid email address is required'
});
}
// Capture metadata
const metadata = {
ip_address: req.ip || req.connection.remoteAddress,
user_agent: req.get('user-agent'),
referrer: req.get('referer')
};
const subscription = await NewsletterSubscription.subscribe({
email,
name,
source: source || 'blog',
interests: interests || [],
...metadata
});
logger.info('Newsletter subscription created', {
email: subscription.email,
source: subscription.source,
verified: subscription.verified
});
// In a real system, send verification email here
// For now, we'll auto-verify (remove this in production)
await NewsletterSubscription.verify(subscription.verification_token);
res.status(201).json({
success: true,
message: 'Successfully subscribed to newsletter',
subscription: {
email: subscription.email,
verified: true
}
});
} catch (subscribeError) {
console.error('Newsletter subscription error:', subscribeError);
res.status(500).json({
success: false,
error: 'Failed to subscribe to newsletter',
details: process.env.NODE_ENV === 'development' ? subscribeError.message : undefined
});
}
};
/**
* Verify email subscription
* GET /api/newsletter/verify/:token
*/
exports.verify = async (req, res) => {
try {
const { token } = req.params;
const verified = await NewsletterSubscription.verify(token);
if (!verified) {
return res.status(404).json({
success: false,
error: 'Invalid or expired verification token'
});
}
res.json({
success: true,
message: 'Email verified successfully'
});
} catch (error) {
logger.error('Newsletter verification error:', error);
res.status(500).json({
success: false,
error: 'Failed to verify email'
});
}
};
/**
* Unsubscribe from newsletter (public)
* POST /api/newsletter/unsubscribe
*/
exports.unsubscribe = async (req, res) => {
try {
const { email, token } = req.body;
if (!email && !token) {
return res.status(400).json({
success: false,
error: 'Email or token is required'
});
}
const unsubscribed = await NewsletterSubscription.unsubscribe(email, token);
if (!unsubscribed) {
return res.status(404).json({
success: false,
error: 'Subscription not found'
});
}
logger.info('Newsletter unsubscribe', { email: email || 'via token' });
res.json({
success: true,
message: 'Successfully unsubscribed from newsletter'
});
} catch (error) {
logger.error('Newsletter unsubscribe error:', error);
res.status(500).json({
success: false,
error: 'Failed to unsubscribe'
});
}
};
/**
* Update subscription preferences (public)
* PUT /api/newsletter/preferences
*/
exports.updatePreferences = async (req, res) => {
try {
const { email, name, interests } = req.body;
if (!email) {
return res.status(400).json({
success: false,
error: 'Email is required'
});
}
const updated = await NewsletterSubscription.updatePreferences(email, {
name,
interests
});
if (!updated) {
return res.status(404).json({
success: false,
error: 'Active subscription not found'
});
}
res.json({
success: true,
message: 'Preferences updated successfully'
});
} catch (error) {
logger.error('Newsletter preferences update error:', error);
res.status(500).json({
success: false,
error: 'Failed to update preferences'
});
}
};
/**
* Get newsletter statistics (admin only)
* GET /api/admin/newsletter/stats
*/
exports.getStats = async (req, res) => {
try {
const stats = await NewsletterSubscription.getStats();
res.json({
success: true,
stats
});
} catch (error) {
logger.error('Newsletter stats error:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve statistics'
});
}
};
/**
* List all subscriptions (admin only)
* GET /api/admin/newsletter/subscriptions
*/
exports.listSubscriptions = async (req, res) => {
try {
const {
limit = 100,
skip = 0,
active = 'true',
verified = null,
source = null
} = req.query;
const options = {
limit: parseInt(limit),
skip: parseInt(skip),
active: active === 'true',
verified: verified === null ? null : verified === 'true',
source: source || null
};
const subscriptions = await NewsletterSubscription.list(options);
const total = await NewsletterSubscription.count({
active: options.active,
...(options.verified !== null && { verified: options.verified }),
...(options.source && { source: options.source })
});
// Convert ObjectId to string for JSON serialization
const serializedSubscriptions = subscriptions.map(sub => ({
...sub,
_id: sub._id.toString()
}));
res.json({
success: true,
subscriptions: serializedSubscriptions,
pagination: {
total,
limit: options.limit,
skip: options.skip,
has_more: skip + subscriptions.length < total
}
});
} catch (error) {
logger.error('Newsletter list error:', error);
res.status(500).json({
success: false,
error: 'Failed to list subscriptions'
});
}
};
/**
* Export subscriptions as CSV (admin only)
* GET /api/admin/newsletter/export
*/
exports.exportSubscriptions = async (req, res) => {
try {
const { active = 'true' } = req.query;
const subscriptions = await NewsletterSubscription.list({
active: active === 'true',
limit: 10000
});
// Generate CSV
const csv = [
'Email,Name,Source,Verified,Subscribed At',
...subscriptions.map(sub =>
`${sub.email},"${sub.name || ''}",${sub.source},${sub.verified},${sub.subscribed_at.toISOString()}`
)
].join('\n');
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="newsletter-subscriptions-${Date.now()}.csv"`);
res.send(csv);
} catch (error) {
logger.error('Newsletter export error:', error);
res.status(500).json({
success: false,
error: 'Failed to export subscriptions'
});
}
};
/**
* Delete subscription (admin only)
* DELETE /api/admin/newsletter/subscriptions/:id
*/
exports.deleteSubscription = async (req, res) => {
try {
const { id } = req.params;
const deleted = await NewsletterSubscription.delete(id);
if (!deleted) {
return res.status(404).json({
success: false,
error: 'Subscription not found'
});
}
logger.info('Newsletter subscription deleted', { id });
res.json({
success: true,
message: 'Subscription deleted successfully'
});
} catch (error) {
logger.error('Newsletter delete error:', error);
res.status(500).json({
success: false,
error: 'Failed to delete subscription'
});
}
};
/**
* Send newsletter (admin only)
* POST /api/newsletter/admin/send
*/
exports.send = async (req, res) => {
try {
const { tier, subject, previewText, variables, testMode, testEmail } = req.body;
// Validation
if (!tier || !subject || !variables) {
return res.status(400).json({
success: false,
error: 'Missing required fields: tier, subject, variables'
});
}
if (testMode && !testEmail) {
return res.status(400).json({
success: false,
error: 'Test email required when testMode is true'
});
}
// Send newsletter
const result = await emailService.sendNewsletter({
tier,
subject,
previewText,
variables,
testMode: testMode === true,
testEmail
});
logger.info('[Newsletter] Newsletter sent', { tier, sent: result.sent, failed: result.failed });
res.json(result);
} catch (error) {
logger.error('[Newsletter] Send error:', error);
res.status(500).json({
success: false,
error: 'Failed to send newsletter',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
};
/**
* Preview newsletter (admin only)
* POST /api/newsletter/admin/preview
*/
exports.preview = async (req, res) => {
try {
const { tier, variables } = req.body;
if (!tier || !variables) {
return res.status(400).json({
success: false,
error: 'Missing required fields: tier, variables'
});
}
const { html } = await emailService.renderEmail(tier, {
name: 'Preview User',
tier_name: emailService.getTierDisplayName(tier),
unsubscribe_link: '#',
preferences_link: '#',
...variables
});
res.send(html);
} catch (error) {
logger.error('[Newsletter] Preview error:', error);
res.status(500).json({
success: false,
error: 'Failed to generate preview',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
};