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>
This commit is contained in:
TheFlow 2025-11-04 11:32:39 +13:00
parent 1c768a19e4
commit 973be3e61d
7 changed files with 715 additions and 3 deletions

164
package-lock.json generated
View file

@ -1,14 +1,15 @@
{
"name": "tractatus-website",
"version": "0.1.0",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tractatus-website",
"version": "0.1.0",
"version": "0.1.2",
"license": "Apache-2.0",
"dependencies": {
"@sendgrid/mail": "^8.1.6",
"axios": "^1.12.2",
"bcrypt": "^5.1.1",
"cookie-parser": "^1.4.7",
@ -17,8 +18,10 @@
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.5.1",
"handlebars": "^4.7.8",
"helmet": "^7.1.0",
"highlight.js": "^11.9.0",
"html-to-text": "^9.0.5",
"i18next": "^25.6.0",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
@ -1415,6 +1418,57 @@
"node": ">=18"
}
},
"node_modules/@selderee/plugin-htmlparser2": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
"integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"selderee": "^0.11.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/@sendgrid/client": {
"version": "8.1.6",
"resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-8.1.6.tgz",
"integrity": "sha512-/BHu0hqwXNHr2aLhcXU7RmmlVqrdfrbY9KpaNj00KZHlVOVoRxRVrpOCabIB+91ISXJ6+mLM9vpaVUhK6TwBWA==",
"license": "MIT",
"dependencies": {
"@sendgrid/helpers": "^8.0.0",
"axios": "^1.12.0"
},
"engines": {
"node": ">=12.*"
}
},
"node_modules/@sendgrid/helpers": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@sendgrid/helpers/-/helpers-8.0.0.tgz",
"integrity": "sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/@sendgrid/mail": {
"version": "8.1.6",
"resolved": "https://registry.npmjs.org/@sendgrid/mail/-/mail-8.1.6.tgz",
"integrity": "sha512-/ZqxUvKeEztU9drOoPC/8opEPOk+jLlB2q4+xpx6HVLq6aFu3pMpalkTpAQz8XfRfpLp8O25bh6pGPcHDCYpqg==",
"license": "MIT",
"dependencies": {
"@sendgrid/client": "^8.1.5",
"@sendgrid/helpers": "^8.0.0"
},
"engines": {
"node": ">=12.*"
}
},
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@ -4391,6 +4445,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/handlebars": {
"version": "4.7.8",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.5",
"neo-async": "^2.6.2",
"source-map": "^0.6.1",
"wordwrap": "^1.0.0"
},
"bin": {
"handlebars": "bin/handlebars"
},
"engines": {
"node": ">=0.4.7"
},
"optionalDependencies": {
"uglify-js": "^3.1.4"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -4531,6 +4606,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/html-to-text": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
"integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
"license": "MIT",
"dependencies": {
"@selderee/plugin-htmlparser2": "^0.11.0",
"deepmerge": "^4.3.1",
"dom-serializer": "^2.0.0",
"htmlparser2": "^8.0.2",
"selderee": "^0.11.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
@ -5789,6 +5880,15 @@
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
"license": "MIT"
},
"node_modules/leac": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
"integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@ -6356,6 +6456,12 @@
"node": ">= 0.6"
}
},
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"license": "MIT"
},
"node_modules/netmask": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz",
@ -6858,6 +6964,19 @@
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/parseley": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
"integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
"license": "MIT",
"dependencies": {
"leac": "^0.6.0",
"peberminta": "^0.9.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -6933,6 +7052,15 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/peberminta": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
"integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@ -7722,6 +7850,18 @@
"postcss": "^8.3.11"
}
},
"node_modules/selderee": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
"integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
"license": "MIT",
"dependencies": {
"parseley": "^0.12.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
@ -8010,7 +8150,6 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"devOptional": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@ -8811,6 +8950,19 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
"license": "BSD-2-Clause",
"optional": true,
"bin": {
"uglifyjs": "bin/uglifyjs"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
@ -9051,6 +9203,12 @@
"node": ">=0.10.0"
}
},
"node_modules/wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
"license": "MIT"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View file

@ -42,6 +42,7 @@
"author": "John Stroh <john.stroh.nz@pm.me>",
"license": "Apache-2.0",
"dependencies": {
"@sendgrid/mail": "^8.1.6",
"axios": "^1.12.2",
"bcrypt": "^5.1.1",
"cookie-parser": "^1.4.7",
@ -50,8 +51,10 @@
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.5.1",
"handlebars": "^4.7.8",
"helmet": "^7.1.0",
"highlight.js": "^11.9.0",
"html-to-text": "^9.0.5",
"i18next": "^25.6.0",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",

View file

@ -21,6 +21,11 @@ async function init() {
document.getElementById('prev-page').addEventListener('click', () => changePage(-1));
document.getElementById('next-page').addEventListener('click', () => changePage(1));
// Newsletter sending form listeners
document.getElementById('send-newsletter-form').addEventListener('submit', handleSendNewsletter);
document.getElementById('preview-newsletter-btn').addEventListener('click', handlePreviewNewsletter);
document.getElementById('test-newsletter-btn').addEventListener('click', handleTestNewsletter);
// Load data
await loadAll();
}
@ -289,5 +294,218 @@ function escapeHtml(text) {
return div.innerHTML;
}
/**
* Get CSRF token from cookie
*/
function getCsrfToken() {
const cookies = document.cookie.split('; ');
const csrfCookie = cookies.find(row => row.startsWith('csrf-token='));
return csrfCookie ? csrfCookie.split('=')[1] : null;
}
/**
* Validate newsletter form
*/
function validateNewsletterForm() {
const tier = document.getElementById('newsletter-tier').value;
const subject = document.getElementById('newsletter-subject').value;
const contentRaw = document.getElementById('newsletter-content').value;
if (!tier) {
showStatus('error', 'Please select a newsletter tier');
return null;
}
if (!subject) {
showStatus('error', 'Please enter a subject line');
return null;
}
if (!contentRaw) {
showStatus('error', 'Please enter content variables (JSON)');
return null;
}
let variables;
try {
variables = JSON.parse(contentRaw);
} catch (error) {
showStatus('error', 'Invalid JSON in content field');
return null;
}
return {
tier,
subject,
previewText: document.getElementById('newsletter-preview').value,
variables
};
}
/**
* Show status message
*/
function showStatus(type, message) {
const statusDiv = document.getElementById('send-status');
statusDiv.className = `px-4 py-3 rounded ${
type === 'success' ? 'bg-green-50 border border-green-200 text-green-800' :
type === 'error' ? 'bg-red-50 border border-red-200 text-red-800' :
'bg-blue-50 border border-blue-200 text-blue-800'
}`;
statusDiv.textContent = message;
statusDiv.classList.remove('hidden');
}
/**
* Handle newsletter preview
*/
async function handlePreviewNewsletter(e) {
e.preventDefault();
const formData = validateNewsletterForm();
if (!formData) return;
try {
showStatus('info', 'Generating preview...');
const token = localStorage.getItem('admin_token');
const csrfToken = getCsrfToken();
const response = await fetch('/api/newsletter/admin/preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({
tier: formData.tier,
variables: formData.variables
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Preview failed');
}
const html = await response.text();
// Open preview in new window
const previewWindow = window.open('', '_blank', 'width=800,height=600');
previewWindow.document.write(html);
previewWindow.document.close();
showStatus('success', 'Preview opened in new window');
} catch (error) {
console.error('Preview error:', error);
showStatus('error', `Preview failed: ${error.message}`);
}
}
/**
* Handle test newsletter send
*/
async function handleTestNewsletter(e) {
e.preventDefault();
const formData = validateNewsletterForm();
if (!formData) return;
const testEmail = prompt('Enter your email address for test send:');
if (!testEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(testEmail)) {
showStatus('error', 'Valid email address is required for test send');
return;
}
try {
showStatus('info', 'Sending test email...');
const token = localStorage.getItem('admin_token');
const csrfToken = getCsrfToken();
const response = await fetch('/api/newsletter/admin/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({
...formData,
testMode: true,
testEmail
})
});
const result = await response.json();
if (response.ok && result.success) {
showStatus('success', `Test email sent to ${testEmail}`);
} else {
throw new Error(result.error || 'Test send failed');
}
} catch (error) {
console.error('Test send error:', error);
showStatus('error', `Test send failed: ${error.message}`);
}
}
/**
* Handle newsletter send to all subscribers
*/
async function handleSendNewsletter(e) {
e.preventDefault();
const formData = validateNewsletterForm();
if (!formData) return;
const confirmation = confirm(
`Are you sure you want to send this newsletter to all subscribers of the "${formData.tier}" tier?\n\n` +
`Subject: ${formData.subject}\n\n` +
`This action cannot be undone.`
);
if (!confirmation) {
return;
}
try {
showStatus('info', 'Sending newsletter to subscribers...');
const token = localStorage.getItem('admin_token');
const csrfToken = getCsrfToken();
const response = await fetch('/api/newsletter/admin/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({
...formData,
testMode: false
})
});
const result = await response.json();
if (response.ok && result.success) {
showStatus('success', `${result.message}\n\nSent: ${result.sent}, Failed: ${result.failed}`);
// Clear form on success
if (result.failed === 0) {
document.getElementById('send-newsletter-form').reset();
}
} else {
throw new Error(result.error || 'Send failed');
}
} catch (error) {
console.error('Send error:', error);
showStatus('error', `Send failed: ${error.message}`);
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', init);

View file

@ -4,6 +4,7 @@
*/
const NewsletterSubscription = require('../models/NewsletterSubscription.model');
const emailService = require('../services/email.service');
const logger = require('../utils/logger.util');
/**
@ -313,3 +314,85 @@ exports.deleteSubscription = async (req, res) => {
});
}
};
/**
* 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
});
}
};

View file

@ -120,6 +120,28 @@ class NewsletterSubscription {
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
*/

View file

@ -81,4 +81,20 @@ router.delete('/admin/subscriptions/:id',
asyncHandler(newsletterController.deleteSubscription)
);
// POST /api/newsletter/admin/send - Send newsletter to subscribers
router.post('/admin/send',
authenticateToken,
requireRole('admin'),
csrfProtection,
asyncHandler(newsletterController.send)
);
// POST /api/newsletter/admin/preview - Preview newsletter rendering
router.post('/admin/preview',
authenticateToken,
requireRole('admin'),
csrfProtection,
asyncHandler(newsletterController.preview)
);
module.exports = router;

View file

@ -0,0 +1,212 @@
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();