feat: Add Phase 2 newsletter enhancements and email templates
Phase 2 Implementation: - Add RSS feed discovery links to footer (Subscribe section) - Create email templates (base-template.html, research-updates-content.html) - Add comprehensive newsletter sending implementation plan - Fix CSP check to exclude email-templates directory Email templates use inline styles for cross-client compatibility (Gmail, Outlook, Apple Mail) and are excluded from CSP checks. Next steps: Install dependencies (handlebars, @sendgrid/mail), implement EmailService, controller methods, and admin UI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
5827047334
commit
7d635bf3dc
6 changed files with 795 additions and 1 deletions
554
docs/NEWSLETTER_SENDING_IMPLEMENTATION.md
Normal file
554
docs/NEWSLETTER_SENDING_IMPLEMENTATION.md
Normal file
|
|
@ -0,0 +1,554 @@
|
|||
# Newsletter Sending Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Implement complete newsletter sending functionality with email service integration, template rendering, and admin controls.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies to Install
|
||||
|
||||
```bash
|
||||
npm install --save handlebars
|
||||
npm install --save @sendgrid/mail
|
||||
npm install --save html-to-text
|
||||
```
|
||||
|
||||
**Why these packages?**
|
||||
- `handlebars`: Template engine for email rendering (Mustache-compatible)
|
||||
- `@sendgrid/mail`: SendGrid email service client
|
||||
- `html-to-text`: Generate plain-text versions from HTML
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
# Email Service
|
||||
SENDGRID_API_KEY=your_key_here
|
||||
EMAIL_FROM_ADDRESS=research@agenticgovernance.digital
|
||||
EMAIL_FROM_NAME=Tractatus Research Team
|
||||
|
||||
# Newsletter URLs
|
||||
NEWSLETTER_UNSUBSCRIBE_BASE_URL=https://agenticgovernance.digital/api/newsletter/unsubscribe
|
||||
NEWSLETTER_PREFERENCES_BASE_URL=https://agenticgovernance.digital/newsletter/preferences
|
||||
```
|
||||
|
||||
**SendGrid Setup:**
|
||||
1. Create account: https://signup.sendgrid.com/
|
||||
2. Verify domain (agenticgovernance.digital)
|
||||
3. Create API key with "Mail Send" permissions
|
||||
4. Set `SENDGRID_API_KEY` in `.env`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── services/
|
||||
│ └── email.service.js # NEW - Email rendering & sending
|
||||
├── controllers/
|
||||
│ └── newsletter.controller.js # MODIFY - Add send() function
|
||||
├── routes/
|
||||
│ └── newsletter.routes.js # MODIFY - Add /send endpoint
|
||||
└── utils/
|
||||
└── template-renderer.util.js # NEW - Handlebars helpers
|
||||
|
||||
email-templates/
|
||||
├── base-template.html # EXISTS
|
||||
├── research-updates-content.html # EXISTS
|
||||
├── implementation-notes-content.html # TODO
|
||||
├── governance-discussions-content.html # TODO
|
||||
└── project-updates-content.html # TODO
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create Email Service (`src/services/email.service.js`)
|
||||
|
||||
```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' }];
|
||||
} 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();
|
||||
```
|
||||
|
||||
### Step 2: Add Method to NewsletterSubscription Model
|
||||
|
||||
Add to `src/models/NewsletterSubscription.model.js`:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Add Send Endpoint to Newsletter Controller
|
||||
|
||||
Add to `src/controllers/newsletter.controller.js`:
|
||||
|
||||
```javascript
|
||||
const emailService = require('../services/email.service');
|
||||
|
||||
/**
|
||||
* 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
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Step 4: Add Routes
|
||||
|
||||
Add to `src/routes/newsletter.routes.js`:
|
||||
|
||||
```javascript
|
||||
// Admin routes
|
||||
router.post('/admin/send', requireAuth, requireAdmin, csrfProtection, asyncHandler(newsletterController.send));
|
||||
router.post('/admin/preview', requireAuth, requireAdmin, csrfProtection, asyncHandler(newsletterController.preview));
|
||||
```
|
||||
|
||||
### Step 5: Update Admin UI JavaScript
|
||||
|
||||
Create `public/js/admin/newsletter-sending.js`:
|
||||
|
||||
```javascript
|
||||
// Newsletter sending functionality
|
||||
document.getElementById('send-newsletter-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const tier = document.getElementById('newsletter-tier').value;
|
||||
const subject = document.getElementById('newsletter-subject').value;
|
||||
const preview = document.getElementById('newsletter-preview').value;
|
||||
const contentRaw = document.getElementById('newsletter-content').value;
|
||||
|
||||
if (!tier || !subject || !contentRaw) {
|
||||
alert('Please fill all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
let variables;
|
||||
try {
|
||||
variables = JSON.parse(contentRaw);
|
||||
} catch (error) {
|
||||
alert('Invalid JSON in content field');
|
||||
return;
|
||||
}
|
||||
|
||||
const statusDiv = document.getElementById('send-status');
|
||||
statusDiv.className = 'bg-blue-50 border border-blue-200 text-blue-800 px-4 py-3 rounded';
|
||||
statusDiv.textContent = 'Sending newsletter...';
|
||||
statusDiv.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const csrfToken = document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('csrf-token='))
|
||||
?.split('=')[1];
|
||||
|
||||
const response = await fetch('/api/newsletter/admin/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tier,
|
||||
subject,
|
||||
previewText: preview,
|
||||
variables,
|
||||
testMode: false
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
statusDiv.className = 'bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded';
|
||||
statusDiv.textContent = `Success! ${result.message}`;
|
||||
} else {
|
||||
throw new Error(result.error || 'Send failed');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
statusDiv.className = 'bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded';
|
||||
statusDiv.textContent = `Error: ${error.message}`;
|
||||
}
|
||||
});
|
||||
|
||||
// Preview button
|
||||
document.getElementById('preview-newsletter-btn')?.addEventListener('click', async () => {
|
||||
// Similar implementation - opens preview in new window
|
||||
});
|
||||
|
||||
// Test send button
|
||||
document.getElementById('test-newsletter-btn')?.addEventListener('click', async () => {
|
||||
// Similar implementation with testMode: true
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Install dependencies
|
||||
- [ ] Set up SendGrid account and API key
|
||||
- [ ] Configure environment variables
|
||||
- [ ] Test template rendering (preview endpoint)
|
||||
- [ ] Test email sending (test mode with your email)
|
||||
- [ ] Verify plain-text version generation
|
||||
- [ ] Test unsubscribe/preferences links
|
||||
- [ ] Send test newsletter to small group
|
||||
- [ ] Monitor SendGrid dashboard for delivery rates
|
||||
- [ ] Test across email clients (Gmail, Outlook, Apple Mail)
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Rate Limiting**: Add rate limiting to send endpoint (max 1 send per 5 minutes)
|
||||
2. **Admin Only**: Ensure send endpoint requires authentication and admin role
|
||||
3. **CSRF Protection**: Endpoint uses CSRF token
|
||||
4. **Input Validation**: Validate tier, subject, and variables
|
||||
5. **Error Handling**: Don't expose SendGrid errors to client
|
||||
6. **Logging**: Log all send attempts with admin user ID
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Scheduled Sending**: Use cron job or scheduler (e.g., `node-cron`)
|
||||
2. **A/B Testing**: Send different versions to test groups
|
||||
3. **Analytics Dashboard**: Track open rates, click rates, unsubscribes
|
||||
4. **Email Templates Editor**: Visual editor for non-technical users
|
||||
5. **Subscriber Segmentation**: Advanced targeting beyond just interests
|
||||
6. **Bounce Handling**: Process SendGrid webhooks for bounces/spam reports
|
||||
|
||||
---
|
||||
|
||||
## Estimated Implementation Time
|
||||
|
||||
- **Email Service**: 2 hours
|
||||
- **Controller & Routes**: 1 hour
|
||||
- **Admin UI JavaScript**: 1 hour
|
||||
- **Testing & Debugging**: 2 hours
|
||||
- **Documentation**: 30 minutes
|
||||
|
||||
**Total**: ~6.5 hours
|
||||
|
||||
---
|
||||
|
||||
## Cost Estimate (SendGrid)
|
||||
|
||||
- Free tier: 100 emails/day
|
||||
- Essentials: $19.95/month for 50,000 emails/month
|
||||
- Pro: $89.95/month for 100,000 emails/month
|
||||
|
||||
**Recommendation**: Start with free tier, upgrade as subscriber base grows.
|
||||
|
||||
|
|
@ -31,3 +31,104 @@ Templates are loaded by the newsletter admin UI and processed with a template en
|
|||
- Brand-consistent (blue gradient header, clean typography)
|
||||
- Clear CTA buttons with good hit targets
|
||||
- Unsubscribe link always visible in footer
|
||||
|
||||
## Template Usage
|
||||
|
||||
### Base Template (`base-template.html`)
|
||||
|
||||
The base template provides the outer HTML structure with header, footer, and styling. It uses these placeholders:
|
||||
|
||||
- `{{email_title}}` - Email subject (for <title> tag)
|
||||
- `{{header_title}}` - Main header text (e.g., "Tractatus Research Updates")
|
||||
- `{{header_subtitle}}` - Subtitle text (e.g., "Monthly insights from AI governance research")
|
||||
- `{{name}}` - Subscriber name
|
||||
- `{{content_body}}` - HTML content injected here
|
||||
- `{{tier_name}}` - Newsletter tier name (for footer)
|
||||
- `{{unsubscribe_link}}` - Unsubscribe URL
|
||||
- `{{preferences_link}}` - Preferences management URL
|
||||
|
||||
### Content Modules
|
||||
|
||||
Content modules are HTML snippets that get injected into `{{content_body}}`. Example:
|
||||
- `research-updates-content.html` - Research Updates tier content structure
|
||||
|
||||
### Implementation with Template Engine
|
||||
|
||||
```javascript
|
||||
const Handlebars = require('handlebars');
|
||||
const fs = require('fs');
|
||||
|
||||
// Load templates
|
||||
const baseTemplate = fs.readFileSync('email-templates/base-template.html', 'utf8');
|
||||
const contentModule = fs.readFileSync('email-templates/research-updates-content.html', 'utf8');
|
||||
|
||||
// Compile
|
||||
const compiledContent = Handlebars.compile(contentModule);
|
||||
const compiledBase = Handlebars.compile(baseTemplate);
|
||||
|
||||
// Render content
|
||||
const renderedContent = compiledContent({
|
||||
highlight_1_title: "...",
|
||||
highlight_1_summary: "...",
|
||||
// ... all other variables
|
||||
});
|
||||
|
||||
// Render final email
|
||||
const finalEmail = compiledBase({
|
||||
email_title: "Tractatus Research Updates - November 2025",
|
||||
header_title: "Tractatus Research Updates",
|
||||
header_subtitle: "Monthly insights from AI governance research",
|
||||
name: subscriber.name || "there",
|
||||
content_body: renderedContent,
|
||||
tier_name: "Research Updates",
|
||||
unsubscribe_link: `https://agenticgovernance.digital/api/newsletter/unsubscribe?token=${subscriber.token}`,
|
||||
preferences_link: `https://agenticgovernance.digital/newsletter/preferences?token=${subscriber.token}`
|
||||
});
|
||||
```
|
||||
|
||||
### Plain Text Version
|
||||
|
||||
Always generate a plain-text version alongside HTML for accessibility and spam filtering:
|
||||
|
||||
```javascript
|
||||
const htmlToText = require('html-to-text');
|
||||
const plainText = htmlToText.convert(finalEmail, {
|
||||
wordwrap: 80,
|
||||
ignoreImages: true
|
||||
});
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
Test emails with [Litmus](https://www.litmus.com/) or [Email on Acid](https://www.emailonacid.com/) across:
|
||||
- Gmail (desktop, mobile)
|
||||
- Outlook (2016+, 365)
|
||||
- Apple Mail (macOS, iOS)
|
||||
- Yahoo Mail
|
||||
- Thunderbird
|
||||
|
||||
### Sending
|
||||
|
||||
Use a transactional email service:
|
||||
- **SendGrid**: https://sendgrid.com/
|
||||
- **Mailgun**: https://www.mailgun.com/
|
||||
- **AWS SES**: https://aws.amazon.com/ses/
|
||||
|
||||
Example with SendGrid:
|
||||
|
||||
```javascript
|
||||
const sgMail = require('@sendgrid/mail');
|
||||
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
|
||||
|
||||
await sgMail.send({
|
||||
to: subscriber.email,
|
||||
from: 'research@agenticgovernance.digital',
|
||||
subject: 'Tractatus Research Updates - November 2025',
|
||||
html: finalEmail,
|
||||
text: plainText,
|
||||
trackingSettings: {
|
||||
clickTracking: { enable: true },
|
||||
openTracking: { enable: true }
|
||||
}
|
||||
});
|
||||
```
|
||||
|
|
|
|||
65
email-templates/base-template.html
Normal file
65
email-templates/base-template.html
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{email_title}}</title>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background-color: #f9fafb;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: #f9fafb;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<!-- Main Container -->
|
||||
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" style="max-width: 600px; background-color: #ffffff;">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%); padding: 40px 20px; text-align: center;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700;">{{header_title}}</h1>
|
||||
<p style="color: #e0e7ff; margin: 10px 0 0 0; font-size: 14px;">{{header_subtitle}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 40px 20px;">
|
||||
<p style="margin: 0 0 20px 0; color: #1f2937; line-height: 1.6;">Hi {{name}},</p>
|
||||
|
||||
{{content_body}}
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color: #f3f4f6; padding: 30px 20px; text-align: center;">
|
||||
<p style="margin: 0 0 10px 0; font-weight: 600; color: #1f2937; font-size: 14px;">Tractatus AI Safety Framework</p>
|
||||
<p style="margin: 0 0 15px 0; color: #6b7280; font-size: 12px;">Architectural constraints for AI safety that preserve human agency</p>
|
||||
<p style="margin: 0; font-size: 12px;">
|
||||
<a href="https://agenticgovernance.digital" style="color: #2563eb; text-decoration: none;">Website</a> |
|
||||
<a href="https://agenticgovernance.digital/docs.html" style="color: #2563eb; text-decoration: none;">Documentation</a> |
|
||||
<a href="https://github.com/AgenticGovernance/tractatus-framework" style="color: #2563eb; text-decoration: none;">GitHub</a>
|
||||
</p>
|
||||
<p style="margin: 20px 0 0 0; color: #9ca3af; font-size: 11px;">
|
||||
You're receiving this because you subscribed to {{tier_name}}.<br>
|
||||
<a href="{{unsubscribe_link}}" style="color: #2563eb; text-decoration: none;">Unsubscribe</a> |
|
||||
<a href="{{preferences_link}}" style="color: #2563eb; text-decoration: none;">Update Preferences</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
64
email-templates/research-updates-content.html
Normal file
64
email-templates/research-updates-content.html
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<!-- Research Updates Content Module -->
|
||||
<!-- This gets injected into {{content_body}} in base-template.html -->
|
||||
|
||||
<p style="margin: 0 0 30px 0; color: #4b5563; line-height: 1.6;">
|
||||
Welcome to this month's research update from the Tractatus AI Safety Framework. Here's what we've been working on and learning.
|
||||
</p>
|
||||
|
||||
<!-- Research Highlights Section -->
|
||||
<h2 style="font-size: 20px; font-weight: 600; color: #1f2937; margin: 0 0 15px 0; border-bottom: 2px solid #2563eb; padding-bottom: 10px;">Research Highlights</h2>
|
||||
|
||||
<!-- Highlight 1 -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin-bottom: 15px; background-color: #f9fafb; border-left: 4px solid #2563eb;">
|
||||
<tr>
|
||||
<td style="padding: 15px;">
|
||||
<h3 style="font-weight: 600; color: #1f2937; margin: 0 0 8px 0; font-size: 16px;">{{highlight_1_title}}</h3>
|
||||
<p style="color: #4b5563; margin: 0 0 10px 0; font-size: 14px; line-height: 1.6;">{{highlight_1_summary}}</p>
|
||||
<a href="{{highlight_1_link}}" style="display: inline-block; padding: 10px 20px; background-color: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px;">Read More</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Highlight 2 -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin-bottom: 30px; background-color: #f9fafb; border-left: 4px solid #2563eb;">
|
||||
<tr>
|
||||
<td style="padding: 15px;">
|
||||
<h3 style="font-weight: 600; color: #1f2937; margin: 0 0 8px 0; font-size: 16px;">{{highlight_2_title}}</h3>
|
||||
<p style="color: #4b5563; margin: 0 0 10px 0; font-size: 14px; line-height: 1.6;">{{highlight_2_summary}}</p>
|
||||
<a href="{{highlight_2_link}}" style="display: inline-block; padding: 10px 20px; background-color: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px;">Read More</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Key Findings Section -->
|
||||
<h2 style="font-size: 20px; font-weight: 600; color: #1f2937; margin: 0 0 15px 0; border-bottom: 2px solid #2563eb; padding-bottom: 10px;">Key Findings</h2>
|
||||
|
||||
<ul style="color: #4b5563; margin: 0 0 30px 0; padding-left: 20px; line-height: 1.8;">
|
||||
<li style="margin-bottom: 8px;">{{finding_1}}</li>
|
||||
<li style="margin-bottom: 8px;">{{finding_2}}</li>
|
||||
<li style="margin-bottom: 8px;">{{finding_3}}</li>
|
||||
</ul>
|
||||
|
||||
<!-- Open Questions Section -->
|
||||
<h2 style="font-size: 20px; font-weight: 600; color: #1f2937; margin: 0 0 15px 0; border-bottom: 2px solid #2563eb; padding-bottom: 10px;">Open Questions</h2>
|
||||
|
||||
<p style="margin: 0 0 10px 0; color: #4b5563; line-height: 1.6;">We're still investigating:</p>
|
||||
|
||||
<ul style="color: #4b5563; margin: 0 0 15px 0; padding-left: 20px; line-height: 1.8;">
|
||||
<li style="margin-bottom: 8px;">{{question_1}}</li>
|
||||
<li style="margin-bottom: 8px;">{{question_2}}</li>
|
||||
</ul>
|
||||
|
||||
<p style="margin: 0 0 30px 0; font-size: 14px; color: #6b7280;">
|
||||
Have insights on these? <a href="{{feedback_link}}" style="color: #2563eb; text-decoration: none;">Share your thoughts</a>
|
||||
</p>
|
||||
|
||||
<!-- Call to Action -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<p style="font-weight: 600; color: #1f2937; margin: 0 0 15px 0;">Want to dive deeper?</p>
|
||||
<a href="{{blog_link}}" style="display: inline-block; padding: 12px 24px; background-color: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">Explore Our Research</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
@ -79,6 +79,14 @@
|
|||
<li><a href="/media-inquiry.html" class="hover:text-white transition" data-i18n="footer.support_links.media_inquiries">Media Inquiries</a></li>
|
||||
<li><a href="/case-submission.html" class="hover:text-white transition" data-i18n="footer.support_links.submit_case">Submit Case Study</a></li>
|
||||
</ul>
|
||||
|
||||
<h3 class="text-white font-semibold mb-4 mt-6" data-i18n="footer.subscribe_heading">Subscribe</h3>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li><button data-newsletter-trigger class="hover:text-white transition cursor-pointer text-left" data-i18n="footer.subscribe_links.newsletter">Newsletter</button></li>
|
||||
<li><a href="/feed.xml" class="hover:text-white transition" data-i18n="footer.subscribe_links.rss_all">RSS Feed (All)</a></li>
|
||||
<li><a href="/feed/research.xml" class="hover:text-white transition" data-i18n="footer.subscribe_links.rss_research">RSS (Research)</a></li>
|
||||
<li><a href="/feed/governance.xml" class="hover:text-white transition" data-i18n="footer.subscribe_links.rss_governance">RSS (Governance)</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Legal & Contact -->
|
||||
|
|
@ -88,7 +96,6 @@
|
|||
<li><a href="/privacy.html" class="hover:text-white transition" data-i18n="footer.legal_links.privacy">Privacy Policy</a></li>
|
||||
<li><a href="/gdpr.html" class="hover:text-white transition" data-i18n="footer.legal_links.gdpr">GDPR Compliance</a></li>
|
||||
<li><button id="open-contact-modal" class="hover:text-white transition cursor-pointer text-left" data-i18n="footer.legal_links.contact">Contact Us</button></li>
|
||||
<li><button data-newsletter-trigger class="hover:text-white transition cursor-pointer text-left" data-i18n="footer.legal_links.newsletter">Newsletter</button></li>
|
||||
<li><a href="https://github.com/AgenticGovernance/tractatus-framework" class="hover:text-white transition" target="_blank" rel="noopener">GitHub</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ function scanForViolations(files = null) {
|
|||
}
|
||||
}
|
||||
|
||||
// Exclude email templates - they require inline styles for email client compatibility
|
||||
htmlFiles = htmlFiles.filter(f => !f.startsWith('email-templates/'));
|
||||
|
||||
if (htmlFiles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue