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>
212 lines
6.2 KiB
JavaScript
212 lines
6.2 KiB
JavaScript
const sgMail = require('@sendgrid/mail');
|
|
const Handlebars = require('handlebars');
|
|
const { convert: htmlToText } = require('html-to-text');
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
const logger = require('../utils/logger.util');
|
|
|
|
// Initialize SendGrid
|
|
if (process.env.SENDGRID_API_KEY) {
|
|
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
|
|
} else {
|
|
logger.warn('[EmailService] SENDGRID_API_KEY not set - email sending disabled');
|
|
}
|
|
|
|
class EmailService {
|
|
constructor() {
|
|
this.templatesPath = path.join(__dirname, '../../email-templates');
|
|
this.templateCache = new Map();
|
|
}
|
|
|
|
/**
|
|
* Load and compile template
|
|
*/
|
|
async loadTemplate(templateName) {
|
|
if (this.templateCache.has(templateName)) {
|
|
return this.templateCache.get(templateName);
|
|
}
|
|
|
|
const templatePath = path.join(this.templatesPath, `${templateName}.html`);
|
|
const templateContent = await fs.readFile(templatePath, 'utf8');
|
|
const compiled = Handlebars.compile(templateContent);
|
|
|
|
this.templateCache.set(templateName, compiled);
|
|
return compiled;
|
|
}
|
|
|
|
/**
|
|
* Render email from templates
|
|
*/
|
|
async renderEmail(tier, variables) {
|
|
try {
|
|
// Load base template
|
|
const baseTemplate = await this.loadTemplate('base-template');
|
|
|
|
// Load content module for tier
|
|
const contentTemplates = {
|
|
'research': 'research-updates-content',
|
|
'implementation': 'implementation-notes-content',
|
|
'governance': 'governance-discussions-content',
|
|
'project-updates': 'project-updates-content'
|
|
};
|
|
|
|
const contentTemplateName = contentTemplates[tier];
|
|
if (!contentTemplateName) {
|
|
throw new Error(`Unknown newsletter tier: ${tier}`);
|
|
}
|
|
|
|
const contentTemplate = await this.loadTemplate(contentTemplateName);
|
|
|
|
// Render content module
|
|
const renderedContent = contentTemplate(variables);
|
|
|
|
// Render final email
|
|
const finalHtml = baseTemplate({
|
|
...variables,
|
|
content_body: renderedContent
|
|
});
|
|
|
|
// Generate plain text
|
|
const plainText = htmlToText(finalHtml, {
|
|
wordwrap: 80,
|
|
ignoreImages: true,
|
|
preserveNewlines: true
|
|
});
|
|
|
|
return { html: finalHtml, text: plainText };
|
|
} catch (error) {
|
|
logger.error('[EmailService] Template rendering error:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send email via SendGrid
|
|
*/
|
|
async send({ to, subject, html, text, from = null }) {
|
|
if (!process.env.SENDGRID_API_KEY) {
|
|
logger.warn('[EmailService] Email sending skipped - no API key');
|
|
return { success: false, error: 'Email service not configured' };
|
|
}
|
|
|
|
const fromAddress = from || {
|
|
email: process.env.EMAIL_FROM_ADDRESS || 'hello@agenticgovernance.digital',
|
|
name: process.env.EMAIL_FROM_NAME || 'Tractatus'
|
|
};
|
|
|
|
try {
|
|
await sgMail.send({
|
|
to,
|
|
from: fromAddress,
|
|
subject,
|
|
html,
|
|
text,
|
|
trackingSettings: {
|
|
clickTracking: { enable: true, enableText: false },
|
|
openTracking: { enable: true }
|
|
}
|
|
});
|
|
|
|
logger.info('[EmailService] Email sent successfully', { to, subject });
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('[EmailService] Send error:', error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send newsletter to subscribers
|
|
*/
|
|
async sendNewsletter({ tier, subject, previewText, variables, testMode = false, testEmail = null }) {
|
|
const NewsletterSubscription = require('../models/NewsletterSubscription.model');
|
|
|
|
try {
|
|
// Get subscribers for tier
|
|
let subscribers;
|
|
if (testMode) {
|
|
subscribers = [{ email: testEmail, name: 'Test User', verification_token: 'test-token' }];
|
|
} else {
|
|
subscribers = await NewsletterSubscription.findByInterest(tier, { verified: true, active: true });
|
|
}
|
|
|
|
if (subscribers.length === 0) {
|
|
return {
|
|
success: true,
|
|
message: 'No subscribers found for tier',
|
|
sent: 0,
|
|
failed: 0
|
|
};
|
|
}
|
|
|
|
// Render base templates
|
|
const results = {
|
|
sent: 0,
|
|
failed: 0,
|
|
errors: []
|
|
};
|
|
|
|
// Send to each subscriber
|
|
for (const subscriber of subscribers) {
|
|
try {
|
|
// Generate unsubscribe/preferences links
|
|
const unsubscribeLink = `${process.env.NEWSLETTER_UNSUBSCRIBE_BASE_URL}?token=${subscriber.verification_token}`;
|
|
const preferencesLink = `${process.env.NEWSLETTER_PREFERENCES_BASE_URL}?token=${subscriber.verification_token}`;
|
|
|
|
// Render email with subscriber-specific data
|
|
const { html, text } = await this.renderEmail(tier, {
|
|
name: subscriber.name || 'there',
|
|
tier_name: this.getTierDisplayName(tier),
|
|
unsubscribe_link: unsubscribeLink,
|
|
preferences_link: preferencesLink,
|
|
...variables
|
|
});
|
|
|
|
// Send
|
|
const result = await this.send({
|
|
to: subscriber.email,
|
|
subject,
|
|
html,
|
|
text
|
|
});
|
|
|
|
if (result.success) {
|
|
results.sent++;
|
|
} else {
|
|
results.failed++;
|
|
results.errors.push({ email: subscriber.email, error: result.error });
|
|
}
|
|
|
|
} catch (error) {
|
|
results.failed++;
|
|
results.errors.push({ email: subscriber.email, error: error.message });
|
|
logger.error('[EmailService] Failed to send to subscriber:', { email: subscriber.email, error });
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: `Newsletter sent: ${results.sent} successful, ${results.failed} failed`,
|
|
sent: results.sent,
|
|
failed: results.failed,
|
|
errors: results.errors.length > 0 ? results.errors : undefined
|
|
};
|
|
|
|
} catch (error) {
|
|
logger.error('[EmailService] Newsletter send error:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
getTierDisplayName(tier) {
|
|
const names = {
|
|
'research': 'Research Updates',
|
|
'implementation': 'Implementation Notes',
|
|
'governance': 'Governance Discussions',
|
|
'project-updates': 'Project Updates'
|
|
};
|
|
return names[tier] || tier;
|
|
}
|
|
}
|
|
|
|
module.exports = new EmailService();
|