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>
257 lines
6.4 KiB
JavaScript
257 lines
6.4 KiB
JavaScript
/**
|
|
* Newsletter Subscription Model
|
|
* Manages email subscriptions for blog updates and framework news
|
|
*/
|
|
|
|
const { ObjectId } = require('mongodb');
|
|
const { getCollection } = require('../utils/db.util');
|
|
|
|
class NewsletterSubscription {
|
|
/**
|
|
* Subscribe a new email address
|
|
*/
|
|
static async subscribe(data) {
|
|
const collection = await getCollection('newsletter_subscriptions');
|
|
|
|
// Check if already subscribed
|
|
const existing = await collection.findOne({
|
|
email: data.email.toLowerCase()
|
|
});
|
|
|
|
if (existing) {
|
|
// If previously unsubscribed, reactivate
|
|
if (!existing.active) {
|
|
await collection.updateOne(
|
|
{ _id: existing._id },
|
|
{
|
|
$set: {
|
|
active: true,
|
|
resubscribed_at: new Date(),
|
|
updated_at: new Date()
|
|
}
|
|
}
|
|
);
|
|
return { ...existing, active: true };
|
|
}
|
|
// Already subscribed and active
|
|
return existing;
|
|
}
|
|
|
|
// Create new subscription
|
|
const subscription = {
|
|
email: data.email.toLowerCase(),
|
|
name: data.name || null,
|
|
source: data.source || 'blog', // blog/homepage/docs/etc
|
|
interests: data.interests || [], // e.g., ['framework-updates', 'case-studies', 'research']
|
|
verification_token: this._generateToken(),
|
|
verified: false, // Will be true after email verification
|
|
active: true,
|
|
subscribed_at: new Date(),
|
|
created_at: new Date(),
|
|
updated_at: new Date(),
|
|
metadata: {
|
|
ip_address: data.ip_address || null,
|
|
user_agent: data.user_agent || null,
|
|
referrer: data.referrer || null
|
|
}
|
|
};
|
|
|
|
const result = await collection.insertOne(subscription);
|
|
return { ...subscription, _id: result.insertedId };
|
|
}
|
|
|
|
/**
|
|
* Verify email subscription
|
|
*/
|
|
static async verify(token) {
|
|
const collection = await getCollection('newsletter_subscriptions');
|
|
|
|
const result = await collection.updateOne(
|
|
{ verification_token: token, active: true },
|
|
{
|
|
$set: {
|
|
verified: true,
|
|
verified_at: new Date(),
|
|
updated_at: new Date()
|
|
}
|
|
}
|
|
);
|
|
|
|
return result.modifiedCount > 0;
|
|
}
|
|
|
|
/**
|
|
* Unsubscribe an email address
|
|
*/
|
|
static async unsubscribe(email, token = null) {
|
|
const collection = await getCollection('newsletter_subscriptions');
|
|
|
|
const filter = token
|
|
? { verification_token: token }
|
|
: { email: email.toLowerCase() };
|
|
|
|
const result = await collection.updateOne(
|
|
filter,
|
|
{
|
|
$set: {
|
|
active: false,
|
|
unsubscribed_at: new Date(),
|
|
updated_at: new Date()
|
|
}
|
|
}
|
|
);
|
|
|
|
return result.modifiedCount > 0;
|
|
}
|
|
|
|
/**
|
|
* Find subscription by email
|
|
*/
|
|
static async findByEmail(email) {
|
|
const collection = await getCollection('newsletter_subscriptions');
|
|
return await collection.findOne({ email: email.toLowerCase() });
|
|
}
|
|
|
|
/**
|
|
* Find subscription by ID
|
|
*/
|
|
static async findById(id) {
|
|
const collection = await getCollection('newsletter_subscriptions');
|
|
return await collection.findOne({ _id: new ObjectId(id) });
|
|
}
|
|
|
|
/**
|
|
* Find subscribers by interest tier
|
|
*/
|
|
static async findByInterest(interest, options = {}) {
|
|
const collection = await getCollection('newsletter_subscriptions');
|
|
|
|
const query = {
|
|
interests: interest,
|
|
active: options.active !== undefined ? options.active : true
|
|
};
|
|
|
|
if (options.verified !== undefined) {
|
|
query.verified = options.verified;
|
|
}
|
|
|
|
return await collection
|
|
.find(query)
|
|
.sort({ subscribed_at: -1 })
|
|
.limit(options.limit || 10000)
|
|
.toArray();
|
|
}
|
|
|
|
/**
|
|
* List all subscriptions
|
|
*/
|
|
static async list(options = {}) {
|
|
const collection = await getCollection('newsletter_subscriptions');
|
|
const {
|
|
limit = 100,
|
|
skip = 0,
|
|
active = true,
|
|
verified = null,
|
|
source = null
|
|
} = options;
|
|
|
|
const filter = {};
|
|
if (active !== null) filter.active = active;
|
|
if (verified !== null) filter.verified = verified;
|
|
if (source) filter.source = source;
|
|
|
|
const subscriptions = await collection
|
|
.find(filter)
|
|
.sort({ created_at: -1 })
|
|
.skip(skip)
|
|
.limit(limit)
|
|
.toArray();
|
|
|
|
return subscriptions;
|
|
}
|
|
|
|
/**
|
|
* Count subscriptions
|
|
*/
|
|
static async count(filter = {}) {
|
|
const collection = await getCollection('newsletter_subscriptions');
|
|
return await collection.countDocuments(filter);
|
|
}
|
|
|
|
/**
|
|
* Update subscription preferences
|
|
*/
|
|
static async updatePreferences(email, preferences) {
|
|
const collection = await getCollection('newsletter_subscriptions');
|
|
|
|
const result = await collection.updateOne(
|
|
{ email: email.toLowerCase(), active: true },
|
|
{
|
|
$set: {
|
|
interests: preferences.interests || [],
|
|
name: preferences.name || null,
|
|
updated_at: new Date()
|
|
}
|
|
}
|
|
);
|
|
|
|
return result.modifiedCount > 0;
|
|
}
|
|
|
|
/**
|
|
* Get subscription statistics
|
|
*/
|
|
static async getStats() {
|
|
const collection = await getCollection('newsletter_subscriptions');
|
|
|
|
const [
|
|
totalSubscribers,
|
|
activeSubscribers,
|
|
verifiedSubscribers,
|
|
recentSubscribers
|
|
] = await Promise.all([
|
|
collection.countDocuments({}),
|
|
collection.countDocuments({ active: true }),
|
|
collection.countDocuments({ active: true, verified: true }),
|
|
collection.countDocuments({
|
|
active: true,
|
|
created_at: { $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }
|
|
})
|
|
]);
|
|
|
|
// Get source breakdown
|
|
const sourceBreakdown = await collection.aggregate([
|
|
{ $match: { active: true } },
|
|
{ $group: { _id: '$source', count: { $sum: 1 } } }
|
|
]).toArray();
|
|
|
|
return {
|
|
total: totalSubscribers,
|
|
active: activeSubscribers,
|
|
verified: verifiedSubscribers,
|
|
recent_30_days: recentSubscribers,
|
|
by_source: sourceBreakdown.reduce((acc, item) => {
|
|
acc[item._id || 'unknown'] = item.count;
|
|
return acc;
|
|
}, {})
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Delete subscription (admin only)
|
|
*/
|
|
static async delete(id) {
|
|
const collection = await getCollection('newsletter_subscriptions');
|
|
const result = await collection.deleteOne({ _id: new ObjectId(id) });
|
|
return result.deletedCount > 0;
|
|
}
|
|
|
|
/**
|
|
* Generate verification token
|
|
*/
|
|
static _generateToken() {
|
|
return require('crypto').randomBytes(32).toString('hex');
|
|
}
|
|
}
|
|
|
|
module.exports = NewsletterSubscription;
|