tractatus/src/services/email.service.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

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();