diff --git a/package-lock.json b/package-lock.json index 261032d3..00e4915c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index deb28798..926dfec4 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "author": "John Stroh ", "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", diff --git a/public/js/admin/newsletter-management.js b/public/js/admin/newsletter-management.js index 369f3fa7..bff3cd12 100644 --- a/public/js/admin/newsletter-management.js +++ b/public/js/admin/newsletter-management.js @@ -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); diff --git a/src/controllers/newsletter.controller.js b/src/controllers/newsletter.controller.js index 8146a325..7612d11d 100644 --- a/src/controllers/newsletter.controller.js +++ b/src/controllers/newsletter.controller.js @@ -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 + }); + } +}; diff --git a/src/models/NewsletterSubscription.model.js b/src/models/NewsletterSubscription.model.js index 500eb80d..8d5b94c0 100644 --- a/src/models/NewsletterSubscription.model.js +++ b/src/models/NewsletterSubscription.model.js @@ -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 */ diff --git a/src/routes/newsletter.routes.js b/src/routes/newsletter.routes.js index f7557d13..5b314b74 100644 --- a/src/routes/newsletter.routes.js +++ b/src/routes/newsletter.routes.js @@ -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; diff --git a/src/services/email.service.js b/src/services/email.service.js new file mode 100644 index 00000000..d9ef9c87 --- /dev/null +++ b/src/services/email.service.js @@ -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();