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:
parent
1c768a19e4
commit
973be3e61d
7 changed files with 715 additions and 3 deletions
164
package-lock.json
generated
164
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
212
src/services/email.service.js
Normal file
212
src/services/email.service.js
Normal 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();
|
||||
Loading…
Add table
Reference in a new issue