Tonne of updates to influence, the configs, update the homepage, and generally just did more bug testing with Influence
This commit is contained in:
parent
cd1099c428
commit
a26d9b8d78
2
.gitignore
vendored
2
.gitignore
vendored
@ -12,3 +12,5 @@
|
||||
/configs/cloudflare/*.yml
|
||||
|
||||
.excalidraw
|
||||
|
||||
/.VSCodeCounter
|
||||
@ -183,6 +183,7 @@ initialize_available_ports() {
|
||||
["GITEA_WEB_PORT"]=3030
|
||||
["GITEA_SSH_PORT"]=2222
|
||||
["MAP_PORT"]=3000
|
||||
["INFLUENCE_PORT"]=3333
|
||||
["MINI_QR_PORT"]=8089
|
||||
)
|
||||
|
||||
@ -248,6 +249,7 @@ HOMEPAGE_PORT=${HOMEPAGE_PORT:-3010}
|
||||
GITEA_WEB_PORT=${GITEA_WEB_PORT:-3030}
|
||||
GITEA_SSH_PORT=${GITEA_SSH_PORT:-2222}
|
||||
MAP_PORT=${MAP_PORT:-3000}
|
||||
INFLUENCE_PORT=${INFLUENCE_PORT:-3333}
|
||||
MINI_QR_PORT=${MINI_QR_PORT:-8089}
|
||||
|
||||
# Domain Configuration
|
||||
@ -317,6 +319,7 @@ EOL
|
||||
echo "Gitea Web: ${GITEA_WEB_PORT:-3030}"
|
||||
echo "Gitea SSH: ${GITEA_SSH_PORT:-2222}"
|
||||
echo "Map: ${MAP_PORT:-3000}"
|
||||
echo "Influence: ${INFLUENCE_PORT:-3333}"
|
||||
echo "Mini QR: ${MINI_QR_PORT:-8089}"
|
||||
echo "================================"
|
||||
}
|
||||
@ -374,6 +377,7 @@ update_services_yaml() {
|
||||
["Listmonk"]="listmonk.$new_domain"
|
||||
["NocoDB"]="db.$new_domain"
|
||||
["Map Server"]="map.$new_domain"
|
||||
["Influence"]="influence.$new_domain"
|
||||
["Main Site"]="$new_domain"
|
||||
["MkDocs (Live)"]="docs.$new_domain"
|
||||
["Mini QR"]="qr.$new_domain"
|
||||
@ -527,6 +531,9 @@ ingress:
|
||||
- hostname: map.$new_domain
|
||||
service: http://localhost:${MAP_PORT:-3000}
|
||||
|
||||
- hostname: influence.$new_domain
|
||||
service: http://localhost:${INFLUENCE_PORT:-3333}
|
||||
|
||||
- hostname: qr.$new_domain
|
||||
service: http://localhost:${MINI_QR_PORT:-8089}
|
||||
|
||||
@ -1203,6 +1210,7 @@ echo " - n8n: http://localhost:${N8N_PORT:-5678}"
|
||||
echo " - NocoDB: http://localhost:${NOCODB_PORT:-8090}"
|
||||
echo " - Gitea: http://localhost:${GITEA_WEB_PORT:-3030}"
|
||||
echo " - Map: http://localhost:${MAP_PORT:-3000}"
|
||||
echo " - Influence: http://localhost:${INFLUENCE_PORT:-3333}"
|
||||
echo " - Mini QR: http://localhost:${MINI_QR_PORT:-8089}"
|
||||
echo ""
|
||||
echo "3. When ready for production:"
|
||||
|
||||
@ -24,4 +24,6 @@ ingress:
|
||||
service: http://localhost:3000
|
||||
- hostname: qr.cmlite.org
|
||||
service: http://localhost:8089
|
||||
- hostname: influence.cmlite.org
|
||||
service: http://localhost:3333
|
||||
- service: http_status:404
|
||||
|
||||
@ -9,12 +9,6 @@
|
||||
description: VS Code in the browser - Platform Editor
|
||||
container: code-server-changemaker
|
||||
|
||||
- Listmonk:
|
||||
icon: mdi-email-newsletter
|
||||
href: "https://listmonk.cmlite.org"
|
||||
description: Newsletter & mailing list manager
|
||||
container: listmonk_app
|
||||
|
||||
- NocoDB:
|
||||
icon: mdi-database
|
||||
href: "https://db.cmlite.org"
|
||||
@ -27,6 +21,12 @@
|
||||
description: Map server for geospatial data
|
||||
container: nocodb-map-viewer
|
||||
|
||||
- Influence:
|
||||
icon: mdi-account-group
|
||||
href: "https://influence.cmlite.org"
|
||||
description: Political influence and campaign management
|
||||
container: influence-app-1
|
||||
|
||||
|
||||
- Content & Documentation:
|
||||
- Main Site:
|
||||
@ -47,6 +47,11 @@
|
||||
description: QR code generator
|
||||
container: mini-qr
|
||||
|
||||
- Listmonk:
|
||||
icon: mdi-email-newsletter
|
||||
href: "https://listmonk.cmlite.org"
|
||||
description: Newsletter & mailing list manager
|
||||
container: listmonk_app
|
||||
|
||||
- Automation & Infrastructure:
|
||||
- n8n:
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Alberta Influence Campaign Tool
|
||||
# BNKops Influence Campaign Tool
|
||||
|
||||
A comprehensive web application that helps Alberta residents connect with their elected representatives across all levels of government. Users can find their representatives by postal code and send direct emails to advocate for important issues.
|
||||
|
||||
@ -57,6 +57,111 @@ A comprehensive web application that helps Alberta residents connect with their
|
||||
- Enter an Alberta postal code (e.g., T5N4B8)
|
||||
- View your representatives and send emails
|
||||
|
||||
## Development Mode
|
||||
|
||||
### Email Testing with MailHog
|
||||
|
||||
For development and testing, the application includes MailHog integration to safely test email functionality without sending real emails to elected officials.
|
||||
|
||||
#### Quick Setup for Development
|
||||
|
||||
1. **Use development configuration**:
|
||||
```bash
|
||||
# Your .env should include these settings for development:
|
||||
NODE_ENV=development
|
||||
EMAIL_TEST_MODE=true
|
||||
SMTP_HOST=mailhog
|
||||
SMTP_PORT=1025
|
||||
SMTP_USER=test
|
||||
SMTP_PASS=test
|
||||
TEST_EMAIL_RECIPIENT=your-email@example.com
|
||||
```
|
||||
|
||||
2. **Start with MailHog included**:
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
3. **Access development tools**:
|
||||
- **Application**: http://localhost:3333
|
||||
- **Email Testing Interface**: http://localhost:3333/email-test.html (admin login required)
|
||||
- **MailHog Web UI**: http://localhost:8025 (view all caught emails)
|
||||
|
||||
#### Email Testing Features
|
||||
|
||||
**Test Mode Benefits:**
|
||||
- ✅ All emails redirected to your test recipient
|
||||
- ✅ Original recipient shown in subject line: `[TEST - Original: real@email.com] Subject`
|
||||
- ✅ Safe testing without spamming elected officials
|
||||
- ✅ Complete email logging with test mode indicators
|
||||
|
||||
**Email Testing Interface** (`/email-test.html`):
|
||||
- **Quick Test**: Send test email with one click
|
||||
- **Email Preview**: See exactly how emails will look before sending
|
||||
- **Custom Composition**: Test with your own subject and message content
|
||||
- **Email Logs**: View all sent emails with test/live filtering
|
||||
- **SMTP Diagnostics**: Test connection and troubleshoot issues
|
||||
|
||||
**MailHog Web Interface** (`http://localhost:8025`):
|
||||
- View all emails caught during development
|
||||
- Inspect email content, headers, and formatting
|
||||
- Search and filter caught emails
|
||||
- No emails leave your local environment
|
||||
|
||||
#### Development Workflow
|
||||
|
||||
1. **Safe Development**:
|
||||
```bash
|
||||
# Ensure test mode is enabled
|
||||
EMAIL_TEST_MODE=true
|
||||
|
||||
# Start development environment
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
2. **Test Email Functionality**:
|
||||
- Use the main app to send emails (they'll be redirected)
|
||||
- Check MailHog UI to see the actual email content
|
||||
- Use `/email-test.html` for advanced testing and preview
|
||||
|
||||
3. **Production Deployment**:
|
||||
```bash
|
||||
# Switch to production SMTP settings
|
||||
EMAIL_TEST_MODE=false
|
||||
SMTP_HOST=smtp.your-provider.com
|
||||
SMTP_USER=your-real-email@domain.com
|
||||
SMTP_PASS=your-real-password
|
||||
|
||||
# Restart application
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
#### Development Environment Variables
|
||||
|
||||
```bash
|
||||
# Development Mode Configuration
|
||||
NODE_ENV=development
|
||||
EMAIL_TEST_MODE=true
|
||||
|
||||
# MailHog SMTP (for development)
|
||||
SMTP_HOST=mailhog
|
||||
SMTP_PORT=1025
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=test
|
||||
SMTP_PASS=test
|
||||
SMTP_FROM_EMAIL=dev@albertainfluence.local
|
||||
SMTP_FROM_NAME="Alberta Influence Campaign (DEV)"
|
||||
|
||||
# Email Testing
|
||||
TEST_EMAIL_RECIPIENT=developer@example.com
|
||||
|
||||
# Production SMTP (commented out for dev)
|
||||
# SMTP_HOST=smtp.protonmail.ch
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=your-production-email@domain.com
|
||||
# SMTP_PASS=your-production-password
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables (.env)
|
||||
@ -95,6 +200,11 @@ RATE_LIMIT_MAX_REQUESTS=100
|
||||
- `POST /api/emails/send` - Send email to representative
|
||||
- `GET /api/emails/logs` - Get email sending logs (with filters)
|
||||
|
||||
### Email Testing (Development)
|
||||
- `POST /api/emails/preview` - Preview email without sending (admin only)
|
||||
- `POST /api/emails/test` - Send test email to configured recipient (admin only)
|
||||
- `GET /api/test-smtp` - Test SMTP connection (admin only)
|
||||
|
||||
### Health
|
||||
- `GET /api/health` - Application health check
|
||||
- `GET /api/test-represent` - Test Represent API connection
|
||||
@ -137,10 +247,11 @@ influence/
|
||||
### Key Components
|
||||
|
||||
- **RepresentativesController**: Handles postal code lookups and caching
|
||||
- **EmailController**: Manages email composition and sending
|
||||
- **EmailController**: Manages email composition, sending, and testing
|
||||
- **NocoDBService**: Database operations with error handling
|
||||
- **RepresentAPI**: Integration with OpenNorth Represent API
|
||||
- **EmailService**: SMTP email functionality
|
||||
- **EmailService**: SMTP email functionality with test mode support
|
||||
- **Email Testing System**: Preview, test, and log email functionality for development
|
||||
|
||||
## Features in Detail
|
||||
|
||||
@ -201,6 +312,8 @@ docker compose up --scale app=2
|
||||
- Verify SMTP credentials in .env
|
||||
- Check spam/junk folders
|
||||
- Review email logs via API endpoint
|
||||
- In development: Check MailHog UI at http://localhost:8025
|
||||
- Use email testing interface at `/email-test.html` for diagnostics
|
||||
|
||||
3. **No Representatives Found**:
|
||||
- Ensure postal code starts with 'T' (Alberta)
|
||||
|
||||
@ -399,21 +399,17 @@ class CampaignsController {
|
||||
|
||||
// Send email if SMTP method
|
||||
if (emailMethod === 'smtp') {
|
||||
emailResult = await emailService.sendEmail({
|
||||
to: recipientEmail,
|
||||
from: {
|
||||
email: process.env.SMTP_FROM_EMAIL,
|
||||
name: process.env.SMTP_FROM_NAME
|
||||
},
|
||||
replyTo: userEmail,
|
||||
subject: subject,
|
||||
text: message,
|
||||
html: `
|
||||
<p>${message.replace(/\n/g, '<br>')}</p>
|
||||
<hr>
|
||||
<p><small>This message was sent via the BNKops Influence Campaign Tool by ${userName || 'A constituent'} (${userEmail}) from postal code ${postalCode} as part of the "${campaign['Campaign Title'] || campaign.title}" campaign.</small></p>
|
||||
`
|
||||
});
|
||||
emailResult = await emailService.sendCampaignEmail(
|
||||
recipientEmail,
|
||||
userEmail,
|
||||
userName || 'A constituent',
|
||||
postalCode,
|
||||
subject,
|
||||
message,
|
||||
campaign['Campaign Title'] || campaign.title,
|
||||
recipientName,
|
||||
recipientLevel
|
||||
);
|
||||
}
|
||||
|
||||
// Log the campaign email
|
||||
|
||||
@ -6,22 +6,15 @@ class EmailsController {
|
||||
try {
|
||||
const { recipientEmail, senderName, senderEmail, subject, message, postalCode } = req.body;
|
||||
|
||||
// Send the email
|
||||
const emailResult = await emailService.sendEmail({
|
||||
to: recipientEmail,
|
||||
from: {
|
||||
email: process.env.SMTP_FROM_EMAIL,
|
||||
name: process.env.SMTP_FROM_NAME
|
||||
},
|
||||
replyTo: senderEmail,
|
||||
subject: subject,
|
||||
text: message,
|
||||
html: `
|
||||
<p>${message.replace(/\n/g, '<br>')}</p>
|
||||
<hr>
|
||||
<p><small>This message was sent via the Alberta Influence Campaign Tool by ${senderName} (${senderEmail}) from postal code ${postalCode}.</small></p>
|
||||
`
|
||||
});
|
||||
// Send the email using template system
|
||||
const emailResult = await emailService.sendRepresentativeEmail(
|
||||
recipientEmail,
|
||||
senderName,
|
||||
senderEmail,
|
||||
subject,
|
||||
message,
|
||||
postalCode
|
||||
);
|
||||
|
||||
// Log the email send event
|
||||
await nocoDB.logEmailSend({
|
||||
@ -31,7 +24,8 @@ class EmailsController {
|
||||
subject,
|
||||
postalCode,
|
||||
status: emailResult.success ? 'sent' : 'failed',
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: new Date().toISOString(),
|
||||
senderIP: req.ip || req.connection.remoteAddress
|
||||
});
|
||||
|
||||
if (emailResult.success) {
|
||||
@ -56,6 +50,147 @@ class EmailsController {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async previewEmail(req, res, next) {
|
||||
try {
|
||||
const { recipientEmail, subject, message, senderName, senderEmail, postalCode } = req.body;
|
||||
|
||||
const templateVariables = {
|
||||
MESSAGE: message,
|
||||
SENDER_NAME: senderName || 'Anonymous',
|
||||
SENDER_EMAIL: senderEmail || 'unknown@example.com',
|
||||
POSTAL_CODE: postalCode || 'Unknown'
|
||||
};
|
||||
|
||||
const emailOptions = {
|
||||
to: recipientEmail,
|
||||
from: {
|
||||
email: process.env.SMTP_FROM_EMAIL,
|
||||
name: process.env.SMTP_FROM_NAME
|
||||
},
|
||||
replyTo: senderEmail || process.env.SMTP_FROM_EMAIL,
|
||||
subject: subject
|
||||
};
|
||||
|
||||
const preview = await emailService.previewTemplatedEmail('representative-contact', templateVariables, emailOptions);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
preview: preview,
|
||||
html: emailOptions.html
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Email preview error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to generate email preview',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async sendTestEmail(req, res, next) {
|
||||
try {
|
||||
const { subject, message } = req.body;
|
||||
|
||||
const testRecipient = process.env.TEST_EMAIL_RECIPIENT || req.user?.email || 'admin@example.com';
|
||||
|
||||
const emailResult = await emailService.sendTestEmail(subject, message, testRecipient);
|
||||
|
||||
if (emailResult.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Test email sent successfully',
|
||||
messageId: emailResult.messageId,
|
||||
sentTo: testRecipient,
|
||||
testMode: emailResult.testMode
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to send test email',
|
||||
message: emailResult.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Send test email error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to send test email',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getEmailLogs(req, res, next) {
|
||||
try {
|
||||
const { status, senderEmail, postalCode } = req.query;
|
||||
|
||||
if (!process.env.NOCODB_TABLE_EMAILS) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Email logging not configured'
|
||||
});
|
||||
}
|
||||
|
||||
const filters = {};
|
||||
if (status) filters.status = status;
|
||||
if (senderEmail) filters.senderEmail = senderEmail;
|
||||
if (postalCode) filters.postalCode = postalCode;
|
||||
|
||||
const logs = await nocoDB.getEmailLogs(filters);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
logs: logs || []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get email logs error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve email logs',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async testSMTPConnection(req, res, next) {
|
||||
try {
|
||||
const testResult = await emailService.testConnection();
|
||||
|
||||
res.json({
|
||||
success: testResult.success,
|
||||
message: testResult.message,
|
||||
error: testResult.error
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('SMTP test error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to test SMTP connection',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async testSMTPConnection(req, res, next) {
|
||||
try {
|
||||
const result = await emailService.testConnection();
|
||||
|
||||
res.json({
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
error: result.error
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('SMTP test error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to test SMTP connection',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new EmailsController();
|
||||
285
influence/app/public/email-test.html
Normal file
285
influence/app/public/email-test.html
Normal file
@ -0,0 +1,285 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Email Testing Interface - Alberta Influence Campaign</title>
|
||||
<link rel="stylesheet" href="css/styles.css">
|
||||
<style>
|
||||
.test-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.test-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.test-controls button {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.test-controls button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.test-controls button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.email-preview {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
background: white;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.email-preview.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.logs-section {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.log-entry.test-mode {
|
||||
border-left: 4px solid #ffc107;
|
||||
}
|
||||
|
||||
.log-entry.failed {
|
||||
border-left: 4px solid #dc3545;
|
||||
}
|
||||
|
||||
.log-entry.sent {
|
||||
border-left: 4px solid #28a745;
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-sent {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.status-test {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="test-container">
|
||||
<header style="text-align: center; margin-bottom: 30px;">
|
||||
<h1>Email Testing Interface</h1>
|
||||
<p>Test and preview emails before sending to elected officials</p>
|
||||
<div id="auth-status" style="margin-top: 10px;"></div>
|
||||
</header>
|
||||
|
||||
<!-- Quick Test Section -->
|
||||
<div class="test-section">
|
||||
<h2>Quick Test</h2>
|
||||
<p>Send a test email to yourself to verify email configuration</p>
|
||||
<div class="test-controls">
|
||||
<button id="quick-test-btn" class="btn-primary">Send Quick Test Email</button>
|
||||
<button id="smtp-test-btn" class="btn-secondary">Test SMTP Connection</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email Composition & Preview Section -->
|
||||
<div class="test-section">
|
||||
<h2>Email Preview & Test</h2>
|
||||
<form id="email-test-form">
|
||||
<div class="form-group">
|
||||
<label for="test-recipient">Test Recipient Email:</label>
|
||||
<input type="email" id="test-recipient" name="recipientEmail"
|
||||
placeholder="Enter email address for testing">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="test-subject">Subject:</label>
|
||||
<input type="text" id="test-subject" name="subject"
|
||||
placeholder="Enter email subject" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="test-message">Message:</label>
|
||||
<textarea id="test-message" name="message"
|
||||
placeholder="Enter your message to elected officials..." required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="test-controls">
|
||||
<button type="button" id="preview-btn" class="btn-secondary">Preview Email</button>
|
||||
<button type="button" id="send-test-btn" class="btn-primary">Send Test Email</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="email-preview" class="email-preview empty">
|
||||
Click "Preview Email" to see how your email will look
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email Logs Section -->
|
||||
<div class="test-section">
|
||||
<h2>Email Logs</h2>
|
||||
<div class="test-controls">
|
||||
<button id="refresh-logs-btn" class="btn-secondary">Refresh Logs</button>
|
||||
<button id="filter-test-btn" class="btn-warning">Show Test Emails Only</button>
|
||||
<button id="filter-all-btn" class="btn-success">Show All Emails</button>
|
||||
</div>
|
||||
<div id="email-logs" class="logs-section">
|
||||
<div class="loading">Loading email logs...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Mode Status -->
|
||||
<div class="test-section">
|
||||
<h2>Current Configuration</h2>
|
||||
<div id="config-status">
|
||||
<div class="loading">Loading configuration...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success/Error Messages -->
|
||||
<div id="message-container"></div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="js/auth.js"></script>
|
||||
<script src="js/api-client.js"></script>
|
||||
<script src="js/email-testing.js"></script>
|
||||
|
||||
<script>
|
||||
// Initialize the email testing interface
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Check authentication
|
||||
const authManager = new AuthManager();
|
||||
authManager.checkAuthStatus().then(isAuthenticated => {
|
||||
if (!isAuthenticated) {
|
||||
window.location.href = '/login.html?redirect=/email-test.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize email testing
|
||||
const emailTest = new EmailTesting();
|
||||
emailTest.init();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -116,7 +116,7 @@
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2025 Alberta Influence Campaign Tool. Connect with democracy.</p>
|
||||
<p>© 2025 <a href="https://bnkops.com/" target="_blank">BNKops</a> Influence Campaign Tool. Connect with democracy.</p>
|
||||
<p><small>This tool uses the <a href="https://represent.opennorth.ca" target="_blank">Represent API</a> by Open North to find your representatives.</small></p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@ -18,7 +18,11 @@ class APIClient {
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || data.message || `HTTP ${response.status}`);
|
||||
// Create enhanced error with response data for better error handling
|
||||
const error = new Error(data.error || data.message || `HTTP ${response.status}`);
|
||||
error.status = response.status;
|
||||
error.data = data;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
@ -403,7 +403,14 @@ class CampaignPage {
|
||||
throw new Error(data.error || 'Failed to send email');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showError('Failed to send email: ' + error.message);
|
||||
// Handle rate limit errors specifically
|
||||
if (error.message && error.message.includes('You can only send one email per representative every 5 minutes')) {
|
||||
this.showError('Rate limit reached: You can only send one email per representative every 5 minutes. Please wait before sending another email to this representative. You can still email other representatives.');
|
||||
} else if (error.message && error.message.includes('Too many emails')) {
|
||||
this.showError('Too many emails sent. Please wait before sending more emails.');
|
||||
} else {
|
||||
this.showError('Failed to send email: ' + error.message);
|
||||
}
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
|
||||
@ -177,7 +177,23 @@ class EmailComposer {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Email send failed:', error);
|
||||
window.messageDisplay.show(`Failed to send email: ${error.message}`, 'error');
|
||||
|
||||
// Handle rate limit errors specifically
|
||||
if (error.status === 429 && error.data && error.data.rateLimitType === 'per-recipient') {
|
||||
const retryMinutes = Math.ceil((error.data.retryAfter || 300) / 60);
|
||||
window.messageDisplay.show(
|
||||
`Rate limit reached: You can only send one email per representative every 5 minutes. Please wait ${retryMinutes} more minutes before sending another email to this representative. You can still send emails to other representatives.`,
|
||||
'warning'
|
||||
);
|
||||
} else if (error.status === 429) {
|
||||
// General rate limit
|
||||
window.messageDisplay.show(
|
||||
`Too many emails sent. Please wait before sending more emails.`,
|
||||
'warning'
|
||||
);
|
||||
} else {
|
||||
window.messageDisplay.show(`Failed to send email: ${error.message}`, 'error');
|
||||
}
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = originalText;
|
||||
|
||||
365
influence/app/public/js/email-testing.js
Normal file
365
influence/app/public/js/email-testing.js
Normal file
@ -0,0 +1,365 @@
|
||||
/**
|
||||
* Email Testing Interface
|
||||
* Handles email preview, testing, and logging functionality
|
||||
*/
|
||||
class EmailTesting {
|
||||
constructor() {
|
||||
this.apiClient = new APIClient();
|
||||
this.currentFilter = 'all'; // 'all', 'test', 'live'
|
||||
this.logLimit = 50;
|
||||
this.logOffset = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the email testing interface
|
||||
*/
|
||||
async init() {
|
||||
this.bindEvents();
|
||||
await this.loadConfiguration();
|
||||
await this.loadEmailLogs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind event listeners to UI elements
|
||||
*/
|
||||
bindEvents() {
|
||||
// Quick test buttons
|
||||
document.getElementById('quick-test-btn').addEventListener('click', () => this.sendQuickTest());
|
||||
document.getElementById('smtp-test-btn').addEventListener('click', () => this.testSMTPConnection());
|
||||
|
||||
// Email form buttons
|
||||
document.getElementById('preview-btn').addEventListener('click', () => this.previewEmail());
|
||||
document.getElementById('send-test-btn').addEventListener('click', () => this.sendTestEmail());
|
||||
|
||||
// Log control buttons
|
||||
document.getElementById('refresh-logs-btn').addEventListener('click', () => this.loadEmailLogs());
|
||||
document.getElementById('filter-test-btn').addEventListener('click', () => this.filterLogs('test'));
|
||||
document.getElementById('filter-all-btn').addEventListener('click', () => this.filterLogs('all'));
|
||||
|
||||
// Form validation
|
||||
document.getElementById('email-test-form').addEventListener('input', () => this.validateForm());
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a quick test email with default content
|
||||
*/
|
||||
async sendQuickTest() {
|
||||
const button = document.getElementById('quick-test-btn');
|
||||
button.disabled = true;
|
||||
button.textContent = 'Sending...';
|
||||
|
||||
try {
|
||||
const response = await this.apiClient.post('/api/emails/test', {
|
||||
subject: 'Quick Test Email',
|
||||
message: 'This is a quick test email sent from the Alberta Influence Campaign Tool email testing interface.'
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
this.showMessage(`Test email sent successfully to ${response.sentTo}`, 'success');
|
||||
await this.loadEmailLogs(); // Refresh logs
|
||||
} else {
|
||||
this.showMessage(`Failed to send test email: ${response.message}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showMessage(`Error sending test email: ${error.message}`, 'error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = 'Send Quick Test Email';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test SMTP connection
|
||||
*/
|
||||
async testSMTPConnection() {
|
||||
const button = document.getElementById('smtp-test-btn');
|
||||
button.disabled = true;
|
||||
button.textContent = 'Testing...';
|
||||
|
||||
try {
|
||||
// Note: This endpoint would need to be added to the API
|
||||
const response = await this.apiClient.get('/api/test-smtp');
|
||||
|
||||
if (response.success) {
|
||||
this.showMessage('SMTP connection test successful', 'success');
|
||||
} else {
|
||||
this.showMessage(`SMTP connection failed: ${response.message}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showMessage(`SMTP test error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = 'Test SMTP Connection';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview email content
|
||||
*/
|
||||
async previewEmail() {
|
||||
const formData = this.getFormData();
|
||||
if (!formData) return;
|
||||
|
||||
const button = document.getElementById('preview-btn');
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
const recipientEmail = formData.recipientEmail || 'recipient@example.com';
|
||||
const response = await this.apiClient.post('/api/emails/preview', {
|
||||
recipientEmail: recipientEmail,
|
||||
subject: formData.subject,
|
||||
message: formData.message
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
this.displayEmailPreview(response.preview, response.html);
|
||||
} else {
|
||||
this.showMessage(`Preview failed: ${response.message}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showMessage(`Preview error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send test email
|
||||
*/
|
||||
async sendTestEmail() {
|
||||
const formData = this.getFormData();
|
||||
if (!formData) return;
|
||||
|
||||
const button = document.getElementById('send-test-btn');
|
||||
button.disabled = true;
|
||||
button.textContent = 'Sending...';
|
||||
|
||||
try {
|
||||
const response = await this.apiClient.post('/api/emails/test', {
|
||||
subject: formData.subject,
|
||||
message: formData.message
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
this.showMessage(`Test email sent successfully to ${response.sentTo}`, 'success');
|
||||
await this.loadEmailLogs(); // Refresh logs
|
||||
} else {
|
||||
this.showMessage(`Failed to send test email: ${response.message}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showMessage(`Error sending test email: ${error.message}`, 'error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = 'Send Test Email';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get form data and validate
|
||||
*/
|
||||
getFormData() {
|
||||
const subject = document.getElementById('test-subject').value.trim();
|
||||
const message = document.getElementById('test-message').value.trim();
|
||||
const recipientEmail = document.getElementById('test-recipient').value.trim();
|
||||
|
||||
if (!subject || !message) {
|
||||
this.showMessage('Please fill in subject and message', 'error');
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
subject,
|
||||
message,
|
||||
recipientEmail: recipientEmail || null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate form and update button states
|
||||
*/
|
||||
validateForm() {
|
||||
const subject = document.getElementById('test-subject').value.trim();
|
||||
const message = document.getElementById('test-message').value.trim();
|
||||
|
||||
const isValid = subject && message;
|
||||
|
||||
document.getElementById('preview-btn').disabled = !isValid;
|
||||
document.getElementById('send-test-btn').disabled = !isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display email preview
|
||||
*/
|
||||
displayEmailPreview(preview, html) {
|
||||
const previewDiv = document.getElementById('email-preview');
|
||||
previewDiv.classList.remove('empty');
|
||||
|
||||
const testModeWarning = preview.testMode ?
|
||||
`<div style="background: #fff3cd; color: #856404; padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<strong>TEST MODE:</strong> Email will be redirected to ${preview.redirectTo}
|
||||
</div>` : '';
|
||||
|
||||
previewDiv.innerHTML = `
|
||||
${testModeWarning}
|
||||
<div style="margin-bottom: 15px;">
|
||||
<strong>From:</strong> ${preview.from}<br>
|
||||
<strong>To:</strong> ${preview.to}<br>
|
||||
<strong>Subject:</strong> ${preview.subject}<br>
|
||||
<strong>Timestamp:</strong> ${new Date(preview.timestamp).toLocaleString()}
|
||||
</div>
|
||||
<div style="border-top: 1px solid #ccc; padding-top: 15px;">
|
||||
<strong>Message Content:</strong>
|
||||
<div style="margin-top: 10px; padding: 10px; background: #f8f9fa; border-radius: 4px;">
|
||||
${html}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and display email logs
|
||||
*/
|
||||
async loadEmailLogs() {
|
||||
const logsDiv = document.getElementById('email-logs');
|
||||
logsDiv.innerHTML = '<div class="loading">Loading email logs...</div>';
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append('limit', this.logLimit);
|
||||
params.append('offset', this.logOffset);
|
||||
|
||||
if (this.currentFilter === 'test') {
|
||||
params.append('testMode', 'true');
|
||||
} else if (this.currentFilter === 'live') {
|
||||
params.append('testMode', 'false');
|
||||
}
|
||||
|
||||
const response = await this.apiClient.get(`/api/emails/logs?${params.toString()}`);
|
||||
|
||||
if (response.success) {
|
||||
this.displayEmailLogs(response.logs);
|
||||
} else {
|
||||
logsDiv.innerHTML = `<div class="error-message">Failed to load logs: ${response.message}</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
logsDiv.innerHTML = `<div class="error-message">Error loading logs: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display email logs
|
||||
*/
|
||||
displayEmailLogs(logs) {
|
||||
const logsDiv = document.getElementById('email-logs');
|
||||
|
||||
if (!logs || logs.length === 0) {
|
||||
logsDiv.innerHTML = '<div style="text-align: center; color: #6c757d; padding: 20px;">No email logs found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
logsDiv.innerHTML = logs.map(log => {
|
||||
const statusClass = log.Status === 'sent' ? 'sent' : 'failed';
|
||||
const testModeClass = log['Test Mode'] ? 'test-mode' : '';
|
||||
const statusIndicator = log['Test Mode'] ? 'test' : statusClass;
|
||||
|
||||
return `
|
||||
<div class="log-entry ${statusClass} ${testModeClass}">
|
||||
<div style="display: flex; justify-content: between; align-items: flex-start;">
|
||||
<div style="flex: 1;">
|
||||
<strong>${log.Subject}</strong>
|
||||
<div>To: ${log.Recipient}</div>
|
||||
${log['Actual Recipient'] && log['Actual Recipient'] !== log.Recipient ?
|
||||
`<div style="color: #856404;">Actually sent to: ${log['Actual Recipient']}</div>` : ''}
|
||||
${log.Error ? `<div style="color: #dc3545;">Error: ${log.Error}</div>` : ''}
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<span class="status-indicator status-${statusIndicator}">${log.Status}</span>
|
||||
${log['Test Mode'] ? '<span class="status-indicator status-test" style="margin-left: 5px;">TEST</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-meta">
|
||||
${log['Sent At'] ? new Date(log['Sent At']).toLocaleString() :
|
||||
(log.CreatedAt ? new Date(log.CreatedAt).toLocaleString() : 'Unknown time')}
|
||||
${log['Message ID'] ? ` • ID: ${log['Message ID']}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter logs by type
|
||||
*/
|
||||
async filterLogs(filter) {
|
||||
this.currentFilter = filter;
|
||||
this.logOffset = 0; // Reset offset when filtering
|
||||
|
||||
// Update button states
|
||||
document.getElementById('filter-all-btn').classList.toggle('btn-success', filter === 'all');
|
||||
document.getElementById('filter-test-btn').classList.toggle('btn-warning', filter === 'test');
|
||||
|
||||
await this.loadEmailLogs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and display current configuration
|
||||
*/
|
||||
async loadConfiguration() {
|
||||
const configDiv = document.getElementById('config-status');
|
||||
|
||||
// Since we don't have a dedicated config endpoint, we'll show env-based info
|
||||
const isTestMode = true; // Assuming test mode based on .env
|
||||
const testRecipient = 'admin@example.com'; // From .env
|
||||
|
||||
configDiv.innerHTML = `
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
||||
<div>
|
||||
<h4>Email Test Mode</h4>
|
||||
<div style="color: ${isTestMode ? '#856404' : '#155724'};">
|
||||
${isTestMode ? 'ENABLED' : 'DISABLED'}
|
||||
</div>
|
||||
<small style="color: #6c757d;">
|
||||
${isTestMode ? 'All emails will be redirected to test recipient' : 'Emails will be sent to actual recipients'}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Test Email Recipient</h4>
|
||||
<div>${testRecipient}</div>
|
||||
<small style="color: #6c757d;">Emails will be sent here in test mode</small>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show success or error message
|
||||
*/
|
||||
showMessage(message, type) {
|
||||
const messageContainer = document.getElementById('message-container');
|
||||
const messageClass = type === 'success' ? 'success-message' : 'error-message';
|
||||
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = messageClass;
|
||||
messageDiv.textContent = message;
|
||||
messageDiv.style.position = 'fixed';
|
||||
messageDiv.style.top = '20px';
|
||||
messageDiv.style.left = '50%';
|
||||
messageDiv.style.transform = 'translateX(-50%)';
|
||||
messageDiv.style.zIndex = '1000';
|
||||
messageDiv.style.maxWidth = '500px';
|
||||
|
||||
messageContainer.appendChild(messageDiv);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (messageDiv.parentNode) {
|
||||
messageDiv.parentNode.removeChild(messageDiv);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = EmailTesting;
|
||||
}
|
||||
@ -22,6 +22,7 @@ router.get('/health', (req, res) => {
|
||||
});
|
||||
|
||||
router.get('/test-represent', representativesController.testConnection);
|
||||
router.get('/test-smtp', requireAdmin, emailsController.testSMTPConnection);
|
||||
|
||||
// Representatives endpoints
|
||||
router.get(
|
||||
@ -42,7 +43,8 @@ router.post(
|
||||
// Email endpoints
|
||||
router.post(
|
||||
'/emails/send',
|
||||
rateLimiter.email,
|
||||
rateLimiter.email, // General hourly rate limit
|
||||
rateLimiter.perRecipientEmailLimiter, // Per-recipient 5-minute rate limit
|
||||
[
|
||||
body('recipientEmail').isEmail().withMessage('Valid email is required'),
|
||||
body('senderName').notEmpty().withMessage('Sender name is required'),
|
||||
@ -55,6 +57,39 @@ router.post(
|
||||
emailsController.sendEmail
|
||||
);
|
||||
|
||||
// Email testing endpoints
|
||||
router.post(
|
||||
'/emails/preview',
|
||||
requireAdmin,
|
||||
rateLimiter.general,
|
||||
[
|
||||
body('recipientEmail').isEmail().withMessage('Valid recipient email is required'),
|
||||
body('subject').notEmpty().withMessage('Subject is required'),
|
||||
body('message').notEmpty().withMessage('Message is required')
|
||||
],
|
||||
handleValidationErrors,
|
||||
emailsController.previewEmail
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/emails/test',
|
||||
requireAdmin,
|
||||
rateLimiter.general,
|
||||
[
|
||||
body('subject').notEmpty().withMessage('Subject is required'),
|
||||
body('message').notEmpty().withMessage('Message is required')
|
||||
],
|
||||
handleValidationErrors,
|
||||
emailsController.sendTestEmail
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/emails/logs',
|
||||
requireAdmin,
|
||||
rateLimiter.general,
|
||||
emailsController.getEmailLogs
|
||||
);
|
||||
|
||||
// Campaign endpoints (Admin) - Protected
|
||||
router.get('/admin/campaigns', requireAdmin, rateLimiter.general, campaignsController.getAllCampaigns);
|
||||
router.get('/admin/campaigns/:id', requireAdmin, rateLimiter.general, campaignsController.getCampaignById);
|
||||
@ -88,7 +123,8 @@ router.post(
|
||||
);
|
||||
router.post(
|
||||
'/campaigns/:slug/send-email',
|
||||
rateLimiter.email,
|
||||
rateLimiter.email, // General hourly rate limit
|
||||
rateLimiter.perRecipientEmailLimiter, // Per-recipient 5-minute rate limit
|
||||
[
|
||||
body('recipientEmail').isEmail().withMessage('Valid recipient email is required'),
|
||||
body('postalCode').matches(/^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/).withMessage('Invalid postal code format'),
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
const emailTemplates = require('./emailTemplates');
|
||||
|
||||
class EmailService {
|
||||
constructor() {
|
||||
@ -6,6 +7,33 @@ class EmailService {
|
||||
this.initializeTransporter();
|
||||
}
|
||||
|
||||
initializeTransporter() {
|
||||
try {
|
||||
const transporterConfig = {
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT) || 587,
|
||||
secure: process.env.SMTP_PORT === '465', // true for 465, false for other ports
|
||||
tls: {
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
};
|
||||
|
||||
// Add auth if credentials are provided
|
||||
if (process.env.SMTP_USER && process.env.SMTP_PASS) {
|
||||
transporterConfig.auth = {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS
|
||||
};
|
||||
}
|
||||
|
||||
this.transporter = nodemailer.createTransporter(transporterConfig);
|
||||
|
||||
console.log('Email transporter initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize email transporter:', error);
|
||||
}
|
||||
}
|
||||
|
||||
initializeTransporter() {
|
||||
try {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
@ -47,32 +75,80 @@ class EmailService {
|
||||
}
|
||||
}
|
||||
|
||||
async sendEmail(emailOptions) {
|
||||
async sendEmail(emailOptions, isTest = false) {
|
||||
try {
|
||||
if (!this.transporter) {
|
||||
throw new Error('Email transporter not initialized');
|
||||
}
|
||||
|
||||
let to = emailOptions.to;
|
||||
let subject = emailOptions.subject;
|
||||
|
||||
// Test mode - redirect emails and modify subject
|
||||
const testMode = isTest || process.env.EMAIL_TEST_MODE === 'true';
|
||||
if (testMode) {
|
||||
const originalTo = to;
|
||||
to = process.env.TEST_EMAIL_RECIPIENT || 'admin@example.com';
|
||||
subject = `[TEST - Original: ${originalTo}] ${subject}`;
|
||||
|
||||
console.log(`Email redirected from ${originalTo} to ${to} (Test Mode)`);
|
||||
}
|
||||
|
||||
const mailOptions = {
|
||||
from: `"${emailOptions.from.name}" <${emailOptions.from.email}>`,
|
||||
to: emailOptions.to,
|
||||
to: to,
|
||||
replyTo: emailOptions.replyTo,
|
||||
subject: emailOptions.subject,
|
||||
subject: subject,
|
||||
text: emailOptions.text,
|
||||
html: emailOptions.html
|
||||
};
|
||||
|
||||
// Log email details in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('Email Preview:', {
|
||||
to: mailOptions.to,
|
||||
subject: mailOptions.subject,
|
||||
preview: emailOptions.text ? emailOptions.text.substring(0, 200) + '...' : 'No text content',
|
||||
testMode: testMode
|
||||
});
|
||||
}
|
||||
|
||||
const info = await this.transporter.sendMail(mailOptions);
|
||||
|
||||
console.log('Email sent successfully:', info.messageId);
|
||||
|
||||
// Log email to database if NocoDB service is available
|
||||
await this.logEmailSent({
|
||||
to: emailOptions.to, // Log original recipient
|
||||
subject: emailOptions.subject, // Log original subject
|
||||
status: 'sent',
|
||||
messageId: info.messageId,
|
||||
testMode: testMode,
|
||||
senderName: emailOptions.from?.name || 'System',
|
||||
senderEmail: emailOptions.from?.email || process.env.SMTP_FROM_EMAIL
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: info.messageId,
|
||||
response: info.response
|
||||
response: info.response,
|
||||
testMode: testMode,
|
||||
originalRecipient: testMode ? emailOptions.to : undefined
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Email send error:', error);
|
||||
|
||||
// Log failed email attempt
|
||||
await this.logEmailSent({
|
||||
to: emailOptions.to,
|
||||
subject: emailOptions.subject,
|
||||
status: 'failed',
|
||||
error: error.message,
|
||||
testMode: isTest || process.env.EMAIL_TEST_MODE === 'true',
|
||||
senderName: emailOptions.from?.name || 'System',
|
||||
senderEmail: emailOptions.from?.email || process.env.SMTP_FROM_EMAIL
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
@ -123,6 +199,155 @@ class EmailService {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
async logEmailSent(emailData) {
|
||||
try {
|
||||
// Use the existing logEmailSend method with the correct field structure
|
||||
const nocodbService = require('./nocodb');
|
||||
if (nocodbService && process.env.NOCODB_TABLE_EMAILS) {
|
||||
await nocodbService.logEmailSend({
|
||||
recipientEmail: emailData.to,
|
||||
senderName: emailData.senderName || 'System',
|
||||
senderEmail: emailData.senderEmail || process.env.SMTP_FROM_EMAIL,
|
||||
subject: emailData.subject,
|
||||
postalCode: emailData.postalCode || 'N/A',
|
||||
status: emailData.status || 'sent',
|
||||
timestamp: new Date().toISOString(),
|
||||
senderIP: emailData.senderIP || 'localhost'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to log email:', error);
|
||||
// Don't throw - logging failure shouldn't prevent email sending
|
||||
}
|
||||
}
|
||||
|
||||
async previewEmail(emailOptions) {
|
||||
// Generate email preview without sending
|
||||
return {
|
||||
to: emailOptions.to,
|
||||
subject: emailOptions.subject,
|
||||
body: emailOptions.text,
|
||||
html: emailOptions.html,
|
||||
from: `"${emailOptions.from.name}" <${emailOptions.from.email}>`,
|
||||
replyTo: emailOptions.replyTo,
|
||||
timestamp: new Date().toISOString(),
|
||||
testMode: process.env.EMAIL_TEST_MODE === 'true',
|
||||
redirectTo: process.env.EMAIL_TEST_MODE === 'true' ? process.env.TEST_EMAIL_RECIPIENT : null
|
||||
};
|
||||
}
|
||||
|
||||
// Template-based email methods
|
||||
async sendTemplatedEmail(templateName, templateVariables, emailOptions, isTest = false) {
|
||||
try {
|
||||
// Render the template
|
||||
const { html, text } = await emailTemplates.render(templateName, templateVariables);
|
||||
|
||||
// Prepare email options with rendered content
|
||||
const mailOptions = {
|
||||
...emailOptions,
|
||||
text: text,
|
||||
html: html
|
||||
};
|
||||
|
||||
// Send the email using existing sendEmail method
|
||||
return await this.sendEmail(mailOptions, isTest);
|
||||
} catch (error) {
|
||||
console.error('Failed to send templated email:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async sendRepresentativeEmail(recipientEmail, senderName, senderEmail, subject, message, postalCode) {
|
||||
const templateVariables = {
|
||||
MESSAGE: message,
|
||||
SENDER_NAME: senderName,
|
||||
SENDER_EMAIL: senderEmail,
|
||||
POSTAL_CODE: postalCode
|
||||
};
|
||||
|
||||
const emailOptions = {
|
||||
to: recipientEmail,
|
||||
from: {
|
||||
email: process.env.SMTP_FROM_EMAIL,
|
||||
name: process.env.SMTP_FROM_NAME
|
||||
},
|
||||
replyTo: senderEmail,
|
||||
subject: subject
|
||||
};
|
||||
|
||||
return await this.sendTemplatedEmail('representative-contact', templateVariables, emailOptions);
|
||||
}
|
||||
|
||||
async sendCampaignEmail(recipientEmail, userEmail, userName, postalCode, subject, message, campaignTitle, recipientName = null, recipientLevel = null) {
|
||||
const templateVariables = {
|
||||
MESSAGE: message,
|
||||
USER_NAME: userName,
|
||||
USER_EMAIL: userEmail,
|
||||
POSTAL_CODE: postalCode,
|
||||
CAMPAIGN_TITLE: campaignTitle,
|
||||
RECIPIENT_NAME: recipientName,
|
||||
RECIPIENT_LEVEL: recipientLevel
|
||||
};
|
||||
|
||||
const emailOptions = {
|
||||
to: recipientEmail,
|
||||
from: {
|
||||
email: process.env.SMTP_FROM_EMAIL,
|
||||
name: process.env.SMTP_FROM_NAME
|
||||
},
|
||||
replyTo: userEmail,
|
||||
subject: subject
|
||||
};
|
||||
|
||||
return await this.sendTemplatedEmail('campaign-email', templateVariables, emailOptions);
|
||||
}
|
||||
|
||||
async sendTestEmail(subject, message, testRecipient = null) {
|
||||
const recipient = testRecipient || process.env.TEST_EMAIL_RECIPIENT || 'admin@example.com';
|
||||
|
||||
const templateVariables = {
|
||||
MESSAGE: message
|
||||
};
|
||||
|
||||
const emailOptions = {
|
||||
to: recipient,
|
||||
from: {
|
||||
email: process.env.SMTP_FROM_EMAIL,
|
||||
name: process.env.SMTP_FROM_NAME
|
||||
},
|
||||
replyTo: process.env.SMTP_FROM_EMAIL,
|
||||
subject: `[TEST EMAIL] ${subject}`
|
||||
};
|
||||
|
||||
return await this.sendTemplatedEmail('test-email', templateVariables, emailOptions, true);
|
||||
}
|
||||
|
||||
async previewTemplatedEmail(templateName, templateVariables, emailOptions) {
|
||||
try {
|
||||
const { html, text } = await emailTemplates.render(templateName, templateVariables);
|
||||
|
||||
return {
|
||||
to: emailOptions.to,
|
||||
subject: emailOptions.subject,
|
||||
body: text,
|
||||
html: html,
|
||||
from: `"${emailOptions.from.name}" <${emailOptions.from.email}>`,
|
||||
replyTo: emailOptions.replyTo,
|
||||
timestamp: new Date().toISOString(),
|
||||
testMode: process.env.EMAIL_TEST_MODE === 'true',
|
||||
redirectTo: process.env.EMAIL_TEST_MODE === 'true' ? process.env.TEST_EMAIL_RECIPIENT : null,
|
||||
templateName: templateName,
|
||||
templateVariables: templateVariables
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to preview templated email:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new EmailService();
|
||||
135
influence/app/services/emailTemplates.js
Normal file
135
influence/app/services/emailTemplates.js
Normal file
@ -0,0 +1,135 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
class EmailTemplateService {
|
||||
constructor() {
|
||||
this.templatesDir = path.join(__dirname, '../templates/email');
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
async loadTemplate(templateName, type = 'html') {
|
||||
const cacheKey = `${templateName}.${type}`;
|
||||
|
||||
// Check cache first
|
||||
if (this.cache.has(cacheKey)) {
|
||||
return this.cache.get(cacheKey);
|
||||
}
|
||||
|
||||
try {
|
||||
const templatePath = path.join(this.templatesDir, `${templateName}.${type}`);
|
||||
const template = await fs.readFile(templatePath, 'utf-8');
|
||||
|
||||
// Cache the template
|
||||
this.cache.set(cacheKey, template);
|
||||
|
||||
return template;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load email template ${templateName}.${type}:`, error);
|
||||
throw new Error(`Email template not found: ${templateName}.${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
processTemplate(template, variables) {
|
||||
if (!template) return '';
|
||||
|
||||
let processed = template;
|
||||
|
||||
// Handle conditional blocks {{#if VARIABLE}}...{{/if}}
|
||||
processed = processed.replace(/\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, (match, varName, content) => {
|
||||
const value = variables[varName];
|
||||
// Check if value exists and is not empty string
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
// Recursively process the content inside the conditional block
|
||||
return this.processTemplate(content, variables);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// Replace variables {{VARIABLE}}
|
||||
processed = processed.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
|
||||
const value = variables[varName];
|
||||
// Return the value or empty string if undefined
|
||||
return value !== undefined && value !== null ? String(value) : '';
|
||||
});
|
||||
|
||||
// Handle line breaks in MESSAGE field for HTML templates
|
||||
if (variables.MESSAGE && processed.includes('{{MESSAGE}}')) {
|
||||
processed = processed.replace(/\{\{MESSAGE\}\}/g, variables.MESSAGE.replace(/\n/g, '<br>'));
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
async render(templateName, variables) {
|
||||
try {
|
||||
// Load both HTML and text versions
|
||||
const [htmlTemplate, textTemplate] = await Promise.all([
|
||||
this.loadTemplate(templateName, 'html'),
|
||||
this.loadTemplate(templateName, 'txt')
|
||||
]);
|
||||
|
||||
// Add default variables
|
||||
const defaultVariables = {
|
||||
APP_NAME: process.env.APP_NAME || 'BNKops Influence Tool',
|
||||
TIMESTAMP: new Date().toLocaleString(),
|
||||
...variables
|
||||
};
|
||||
|
||||
// Use processTemplate which handles conditionals properly
|
||||
return {
|
||||
html: this.processTemplate(htmlTemplate, defaultVariables),
|
||||
text: this.processTemplate(textTemplate, defaultVariables)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to render email template:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get available template names
|
||||
async getAvailableTemplates() {
|
||||
try {
|
||||
const files = await fs.readdir(this.templatesDir);
|
||||
const templates = new Set();
|
||||
|
||||
files.forEach(file => {
|
||||
const ext = path.extname(file);
|
||||
const name = path.basename(file, ext);
|
||||
if (ext === '.html' || ext === '.txt') {
|
||||
templates.add(name);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(templates);
|
||||
} catch (error) {
|
||||
console.error('Failed to get available templates:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Clear template cache (useful for development)
|
||||
clearCache() {
|
||||
this.cache.clear();
|
||||
console.log('Email template cache cleared');
|
||||
}
|
||||
|
||||
// Check if template exists
|
||||
async templateExists(templateName) {
|
||||
try {
|
||||
const htmlPath = path.join(this.templatesDir, `${templateName}.html`);
|
||||
const txtPath = path.join(this.templatesDir, `${templateName}.txt`);
|
||||
|
||||
// Check if at least one format exists
|
||||
const [htmlExists, txtExists] = await Promise.all([
|
||||
fs.access(htmlPath).then(() => true).catch(() => false),
|
||||
fs.access(txtPath).then(() => true).catch(() => false)
|
||||
]);
|
||||
|
||||
return htmlExists || txtExists;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new EmailTemplateService();
|
||||
@ -202,7 +202,8 @@ class NocoDBService {
|
||||
'Subject': emailData.subject,
|
||||
'Postal Code': emailData.postalCode,
|
||||
'Status': emailData.status,
|
||||
'Sent At': emailData.timestamp
|
||||
'Sent At': emailData.timestamp,
|
||||
'Sender IP': emailData.senderIP || null // Add IP tracking for rate limiting
|
||||
};
|
||||
|
||||
await this.create(this.tableIds.emails, record);
|
||||
@ -213,6 +214,25 @@ class NocoDBService {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if an email was recently sent to this recipient from this IP
|
||||
async checkRecentEmailSend(senderIP, recipientEmail, windowMinutes = 5) {
|
||||
try {
|
||||
const windowStart = new Date(Date.now() - (windowMinutes * 60 * 1000)).toISOString();
|
||||
|
||||
const params = {
|
||||
where: `(Sender IP,eq,${senderIP})~and(Recipient Email,eq,${recipientEmail})~and(Sent At,gte,${windowStart})`,
|
||||
sort: '-CreatedAt',
|
||||
limit: 1
|
||||
};
|
||||
|
||||
const response = await this.getAll(this.tableIds.emails, params);
|
||||
return response.list && response.list.length > 0 ? response.list[0] : null;
|
||||
} catch (error) {
|
||||
console.error('Error checking recent email send:', error);
|
||||
return null; // On error, allow the send (fallback to in-memory limiter)
|
||||
}
|
||||
}
|
||||
|
||||
async getEmailLogs(filters = {}) {
|
||||
try {
|
||||
let whereClause = '';
|
||||
|
||||
132
influence/app/templates/email/campaign-email.html
Normal file
132
influence/app/templates/email/campaign-email.html
Normal file
@ -0,0 +1,132 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{CAMPAIGN_TITLE}} - Campaign Message</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid #e74c3c;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.logo {
|
||||
color: #e74c3c;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.campaign-badge {
|
||||
background: linear-gradient(135deg, #e74c3c, #c0392b);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.content {
|
||||
background-color: white;
|
||||
padding: 25px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
border-left: 4px solid #e74c3c;
|
||||
}
|
||||
.message-body {
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
margin: 20px 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.sender-info {
|
||||
background-color: #fff5f5;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin: 20px 0;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
.info-item {
|
||||
margin: 8px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.info-label {
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
width: 120px;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
.info-value {
|
||||
color: #2c3e50;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
.app-branding {
|
||||
color: #e74c3c;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">{{APP_NAME}}</div>
|
||||
<div class="campaign-badge">{{CAMPAIGN_TITLE}}</div>
|
||||
<p style="margin: 10px 0 0 0; color: #6c757d;">Constituent Advocacy Campaign</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="message-body">
|
||||
{{MESSAGE}}
|
||||
</div>
|
||||
|
||||
<div class="sender-info">
|
||||
<h4 style="margin: 0 0 10px 0; color: #7f1d1d;">Campaign Participant Information:</h4>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Name:</span>
|
||||
<span class="info-value">{{USER_NAME}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Email:</span>
|
||||
<span class="info-value">{{USER_EMAIL}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Postal Code:</span>
|
||||
<span class="info-value">{{POSTAL_CODE}}</span>
|
||||
</div>
|
||||
{{#if RECIPIENT_NAME}}
|
||||
<div class="info-item">
|
||||
<span class="info-label">To:</span>
|
||||
<span class="info-value">{{RECIPIENT_NAME}} ({{RECIPIENT_LEVEL}})</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>This message was sent via the <span class="app-branding">{{APP_NAME}}</span> as part of the "<strong>{{CAMPAIGN_TITLE}}</strong>" campaign at {{TIMESTAMP}}</p>
|
||||
<p>This platform enables constituents to participate in organized advocacy campaigns to communicate with their elected representatives.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
16
influence/app/templates/email/campaign-email.txt
Normal file
16
influence/app/templates/email/campaign-email.txt
Normal file
@ -0,0 +1,16 @@
|
||||
{{CAMPAIGN_TITLE}} - Campaign Message
|
||||
|
||||
{{MESSAGE}}
|
||||
|
||||
---
|
||||
Campaign Participant Information:
|
||||
Name: {{USER_NAME}}
|
||||
Email: {{USER_EMAIL}}
|
||||
Postal Code: {{POSTAL_CODE}}
|
||||
{{#if RECIPIENT_NAME}}
|
||||
To: {{RECIPIENT_NAME}} ({{RECIPIENT_LEVEL}})
|
||||
{{/if}}
|
||||
|
||||
---
|
||||
This message was sent via the {{APP_NAME}} as part of the "{{CAMPAIGN_TITLE}}" campaign at {{TIMESTAMP}}
|
||||
This platform enables constituents to participate in organized advocacy campaigns to communicate with their elected representatives.
|
||||
115
influence/app/templates/email/representative-contact.html
Normal file
115
influence/app/templates/email/representative-contact.html
Normal file
@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Message from Constituent</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid #3498db;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.logo {
|
||||
color: #3498db;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.content {
|
||||
background-color: white;
|
||||
padding: 25px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
border-left: 4px solid #3498db;
|
||||
}
|
||||
.message-body {
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
margin: 20px 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.sender-info {
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin: 20px 0;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
.info-item {
|
||||
margin: 8px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.info-label {
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
width: 120px;
|
||||
color: #495057;
|
||||
}
|
||||
.info-value {
|
||||
color: #2c3e50;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
.app-branding {
|
||||
color: #3498db;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">{{APP_NAME}}</div>
|
||||
<p style="margin: 5px 0 0 0; color: #6c757d;">Constituent Communication Platform</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="message-body">
|
||||
{{MESSAGE}}
|
||||
</div>
|
||||
|
||||
<div class="sender-info">
|
||||
<h4 style="margin: 0 0 10px 0; color: #495057;">Constituent Information:</h4>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Name:</span>
|
||||
<span class="info-value">{{SENDER_NAME}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Email:</span>
|
||||
<span class="info-value">{{SENDER_EMAIL}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Postal Code:</span>
|
||||
<span class="info-value">{{POSTAL_CODE}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>This message was sent via the <span class="app-branding">{{APP_NAME}}</span> at {{TIMESTAMP}}</p>
|
||||
<p>This platform enables constituents to communicate directly with their elected representatives.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
13
influence/app/templates/email/representative-contact.txt
Normal file
13
influence/app/templates/email/representative-contact.txt
Normal file
@ -0,0 +1,13 @@
|
||||
Message from Constituent - {{APP_NAME}}
|
||||
|
||||
{{MESSAGE}}
|
||||
|
||||
---
|
||||
Constituent Information:
|
||||
Name: {{SENDER_NAME}}
|
||||
Email: {{SENDER_EMAIL}}
|
||||
Postal Code: {{POSTAL_CODE}}
|
||||
|
||||
---
|
||||
This message was sent via the {{APP_NAME}} at {{TIMESTAMP}}
|
||||
This platform enables constituents to communicate directly with their elected representatives.
|
||||
101
influence/app/templates/email/test-email.html
Normal file
101
influence/app/templates/email/test-email.html
Normal file
@ -0,0 +1,101 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Test Email - {{APP_NAME}}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
background-color: #fff3cd;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
border: 2px solid #ffc107;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid #ffc107;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.logo {
|
||||
color: #856404;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.test-badge {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.content {
|
||||
background-color: white;
|
||||
padding: 25px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
border-left: 4px solid #ffc107;
|
||||
}
|
||||
.message-body {
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
margin: 20px 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.test-info {
|
||||
background-color: #f8d7da;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin: 20px 0;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
.app-branding {
|
||||
color: #856404;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">{{APP_NAME}}</div>
|
||||
<div class="test-badge">TEST EMAIL</div>
|
||||
<p style="margin: 10px 0 0 0; color: #856404;">Email System Test</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="test-info">
|
||||
<h4 style="margin: 0 0 10px 0; color: #721c24;">⚠️ This is a test email</h4>
|
||||
<p style="margin: 0; color: #721c24;">This email was sent to verify the email system is working correctly.</p>
|
||||
</div>
|
||||
|
||||
<div class="message-body">
|
||||
{{MESSAGE}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>TEST EMAIL</strong> sent from <span class="app-branding">{{APP_NAME}}</span> at {{TIMESTAMP}}</p>
|
||||
<p>If you received this email, the email system is functioning properly.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
10
influence/app/templates/email/test-email.txt
Normal file
10
influence/app/templates/email/test-email.txt
Normal file
@ -0,0 +1,10 @@
|
||||
TEST EMAIL - {{APP_NAME}}
|
||||
|
||||
⚠️ This is a test email
|
||||
This email was sent to verify the email system is working correctly.
|
||||
|
||||
{{MESSAGE}}
|
||||
|
||||
---
|
||||
TEST EMAIL sent from {{APP_NAME}} at {{TIMESTAMP}}
|
||||
If you received this email, the email system is functioning properly.
|
||||
@ -1,5 +1,21 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
|
||||
// In-memory store for per-recipient email tracking
|
||||
const emailTracker = new Map();
|
||||
|
||||
// Helper function to clean up expired entries
|
||||
function cleanupExpiredEntries() {
|
||||
const now = Date.now();
|
||||
for (const [key, timestamp] of emailTracker.entries()) {
|
||||
if (now - timestamp > 5 * 60 * 1000) { // 5 minutes
|
||||
emailTracker.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up expired entries every minute
|
||||
setInterval(cleanupExpiredEntries, 60 * 1000);
|
||||
|
||||
// General API rate limiter
|
||||
const general = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
@ -12,7 +28,7 @@ const general = rateLimit({
|
||||
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
||||
});
|
||||
|
||||
// Email sending rate limiter
|
||||
// Email sending rate limiter (general - keeps existing behavior)
|
||||
const email = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 10, // limit each IP to 10 emails per hour
|
||||
@ -25,6 +41,35 @@ const email = rateLimit({
|
||||
skipSuccessfulRequests: false, // Don't skip counting successful requests
|
||||
});
|
||||
|
||||
// Custom middleware for per-recipient email rate limiting
|
||||
const perRecipientEmailLimiter = (req, res, next) => {
|
||||
const clientIp = req.ip || req.connection.remoteAddress;
|
||||
const recipientEmail = req.body.recipientEmail;
|
||||
|
||||
if (!recipientEmail) {
|
||||
return next(); // Let validation middleware handle missing recipient
|
||||
}
|
||||
|
||||
const trackingKey = `${clientIp}:${recipientEmail}`;
|
||||
const now = Date.now();
|
||||
const lastSent = emailTracker.get(trackingKey);
|
||||
|
||||
if (lastSent && (now - lastSent) < 5 * 60 * 1000) { // 5 minutes
|
||||
const timeRemaining = Math.ceil((5 * 60 * 1000 - (now - lastSent)) / 1000);
|
||||
return res.status(429).json({
|
||||
success: false,
|
||||
error: 'Rate limit exceeded',
|
||||
message: `You can only send one email per representative every 5 minutes. Please wait ${Math.ceil(timeRemaining / 60)} more minutes before sending another email to this representative.`,
|
||||
retryAfter: timeRemaining,
|
||||
rateLimitType: 'per-recipient'
|
||||
});
|
||||
}
|
||||
|
||||
// Store the current timestamp for this IP-recipient combination
|
||||
emailTracker.set(trackingKey, now);
|
||||
next();
|
||||
};
|
||||
|
||||
// Represent API rate limiter (more restrictive)
|
||||
const representAPI = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
@ -40,5 +85,6 @@ const representAPI = rateLimit({
|
||||
module.exports = {
|
||||
general,
|
||||
email,
|
||||
perRecipientEmailLimiter,
|
||||
representAPI
|
||||
};
|
||||
@ -5,11 +5,17 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3333:3333"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./app:/usr/src/app
|
||||
- /usr/src/app/node_modules
|
||||
restart: unless-stopped
|
||||
|
||||
# MailHog for local email testing and development
|
||||
mailhog:
|
||||
image: mailhog/mailhog:latest
|
||||
ports:
|
||||
- "1025:1025" # SMTP server
|
||||
- "8025:8025" # Web UI
|
||||
restart: unless-stopped
|
||||
97
influence/example.env
Normal file
97
influence/example.env
Normal file
@ -0,0 +1,97 @@
|
||||
# Alberta Influence Campaign Tool - Environment Configuration Example
|
||||
# Copy this file to .env and update with your actual values
|
||||
|
||||
# NocoDB Configuration
|
||||
# Your NocoDB instance URL and API configuration
|
||||
NOCODB_URL=https://your-nocodb-instance.com
|
||||
NOCODB_API_URL=https://your-nocodb-instance.com/api/v1
|
||||
NOCODB_API_TOKEN=your_nocodb_api_token_here
|
||||
NOCODB_PROJECT_ID=your_project_id
|
||||
|
||||
# SMTP Configuration
|
||||
# Configure your email service provider settings
|
||||
SMTP_HOST=smtp.your-provider.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@domain.com
|
||||
SMTP_PASS=your_email_password_or_app_password
|
||||
SMTP_FROM_EMAIL=your-sender@domain.com
|
||||
SMTP_FROM_NAME="Your Campaign Name"
|
||||
|
||||
# Admin Configuration
|
||||
# Set a strong password for admin access
|
||||
ADMIN_PASSWORD=change_this_to_a_strong_password
|
||||
|
||||
# Represent API Configuration
|
||||
# Canadian electoral data API (usually no changes needed)
|
||||
REPRESENT_API_BASE=https://represent.opennorth.ca
|
||||
REPRESENT_API_RATE_LIMIT=60
|
||||
|
||||
# App Configuration
|
||||
# Your application URL and basic settings
|
||||
APP_URL=http://localhost:3333
|
||||
PORT=3333
|
||||
SESSION_SECRET=generate_a_long_random_string_here_at_least_64_characters_long
|
||||
NODE_ENV=development
|
||||
|
||||
# Email Testing Configuration
|
||||
# IMPORTANT: Set to true for development/testing, false for production
|
||||
EMAIL_TEST_MODE=true
|
||||
TEST_EMAIL_RECIPIENT=your-test-email@domain.com
|
||||
|
||||
# NocoDB Table IDs
|
||||
# These will be auto-generated when you run build-nocodb.sh
|
||||
# DO NOT modify these manually - they are set by the setup script
|
||||
NOCODB_TABLE_REPRESENTATIVES=
|
||||
NOCODB_TABLE_EMAILS=
|
||||
NOCODB_TABLE_POSTAL_CODES=
|
||||
NOCODB_TABLE_CAMPAIGN_EMAILS=
|
||||
NOCODB_TABLE_CAMPAIGNS=
|
||||
NOCODB_TABLE_USERS=
|
||||
|
||||
# Optional: Development Mode Settings
|
||||
# Uncomment and modify these for local development with MailHog
|
||||
# SMTP_HOST=mailhog
|
||||
# SMTP_PORT=1025
|
||||
# SMTP_SECURE=false
|
||||
# SMTP_USER=
|
||||
# SMTP_PASS=
|
||||
# SMTP_FROM_EMAIL=dev@albertainfluence.local
|
||||
# SMTP_FROM_NAME="Alberta Influence Campaign (DEV)"
|
||||
|
||||
# Security Notes:
|
||||
# - Keep your .env file secure and never commit it to version control
|
||||
# - Use strong, unique passwords for ADMIN_PASSWORD
|
||||
# - Generate a secure random string for SESSION_SECRET
|
||||
# - For production, ensure EMAIL_TEST_MODE=false
|
||||
# - Use app passwords or API keys for SMTP_PASS, not your main email password
|
||||
|
||||
# Common SMTP Provider Examples:
|
||||
#
|
||||
# Gmail:
|
||||
# SMTP_HOST=smtp.gmail.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SECURE=false
|
||||
# SMTP_USER=your-email@gmail.com
|
||||
# SMTP_PASS=your_app_password
|
||||
#
|
||||
# ProtonMail:
|
||||
# SMTP_HOST=smtp.protonmail.ch
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SECURE=false
|
||||
# SMTP_USER=your-email@protonmail.com
|
||||
# SMTP_PASS=your_app_password
|
||||
#
|
||||
# Outlook/Hotmail:
|
||||
# SMTP_HOST=smtp-mail.outlook.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SECURE=false
|
||||
# SMTP_USER=your-email@outlook.com
|
||||
# SMTP_PASS=your_app_password
|
||||
#
|
||||
# SendGrid:
|
||||
# SMTP_HOST=smtp.sendgrid.net
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SECURE=false
|
||||
# SMTP_USER=apikey
|
||||
# SMTP_PASS=your_sendgrid_api_key
|
||||
@ -9,9 +9,10 @@ The application includes a complete authentication system for admin panel access
|
||||
## Root Directory Files
|
||||
|
||||
### Configuration Files
|
||||
- **`.env`** - Environment variables (database URLs, SMTP config, API keys)
|
||||
- **`.env`** - Environment variables (database URLs, SMTP config, API keys, email testing config)
|
||||
- **`.env.development`** - Development environment configuration with MailHog SMTP settings
|
||||
- **`.env.example`** - Template for environment configuration
|
||||
- **`docker-compose.yml`** - Docker container orchestration for development/production
|
||||
- **`docker-compose.yml`** - Docker container orchestration with MailHog for email testing
|
||||
- **`Dockerfile`** - Container definition for the Node.js application
|
||||
- **`package.json`** - Node.js dependencies and scripts
|
||||
- **`package-lock.json`** - Locked dependency versions for reproducible builds
|
||||
@ -43,9 +44,12 @@ Business logic layer that handles HTTP requests and responses:
|
||||
- Handles caching logic and fallback to API when cache fails
|
||||
|
||||
- **`emails.js`** - Email composition and sending functionality
|
||||
- `send()` - Process and send emails to representatives
|
||||
- `getLogs()` - Retrieve email history with filtering
|
||||
- Integrates with SMTP service and logs to database
|
||||
- `sendEmail()` - Process and send emails to representatives with test mode support
|
||||
- `previewEmail()` - Generate email preview without sending for testing purposes
|
||||
- `sendTestEmail()` - Send test emails to configured test recipient
|
||||
- `getEmailLogs()` - Retrieve email history with filtering and pagination
|
||||
- `testSMTPConnection()` - Test SMTP server connectivity for diagnostics
|
||||
- Integrates with SMTP service and logs to database with test mode tracking
|
||||
|
||||
### Routes (`app/routes/`)
|
||||
API endpoint definitions and request validation:
|
||||
@ -58,7 +62,9 @@ API endpoint definitions and request validation:
|
||||
|
||||
- **`api.js`** - Main API routes with validation middleware
|
||||
- Representatives endpoints with postal code validation
|
||||
- Email endpoints with input sanitization
|
||||
- Email endpoints with input sanitization and test mode support
|
||||
- Email testing endpoints: `/api/emails/preview`, `/api/emails/test`, `/api/emails/logs`
|
||||
- SMTP testing endpoint: `/api/test-smtp` for connection diagnostics
|
||||
- Health check and testing endpoints
|
||||
- Rate limiting and error handling middleware
|
||||
|
||||
@ -67,23 +73,26 @@ External system integrations and data access layer:
|
||||
|
||||
- **`nocodb.js`** - NocoDB database integration
|
||||
- User management methods: `getUserByEmail()`, `createUser()`, `updateUser()`, `getAllUsers()`
|
||||
- Handles users table operations for authentication system
|
||||
- CRUD operations for representatives, emails, postal codes
|
||||
- Table ID mapping and API client configuration
|
||||
- Error handling with graceful degradation
|
||||
/* Lines 76-80 omitted */
|
||||
- Caching logic with automatic retry mechanisms
|
||||
|
||||
- **`represent-api.js`** - Represent OpenNorth API integration
|
||||
- Postal code lookup against Canadian electoral data
|
||||
- Representative data fetching and processing
|
||||
- API response transformation and error handling
|
||||
/* Lines 84-86 omitted */
|
||||
- Support for both concordance and centroid representative data
|
||||
|
||||
- **`email.js`** - SMTP email service
|
||||
- Email composition and HTML template rendering
|
||||
- SMTP client configuration and sending
|
||||
- Delivery confirmation and error handling
|
||||
- Email logging and status tracking
|
||||
- **`emailTemplates.js`** - Email template service for managing HTML/text email templates
|
||||
- Template loading and caching system with support for conditional blocks
|
||||
- Variable replacement and template processing for dynamic content
|
||||
- Template rendering for both HTML and text formats with fallback support
|
||||
- Available templates: representative-contact, campaign-email, test-email
|
||||
|
||||
- **`email.js`** - SMTP email service with comprehensive testing support
|
||||
- Template-based email composition using emailTemplates service
|
||||
- Legacy sendEmail method for backward compatibility
|
||||
- New templated methods: sendRepresentativeEmail(), sendCampaignEmail(), sendTestEmail()
|
||||
- Email preview functionality with template support
|
||||
- SMTP connection testing for diagnostics and troubleshooting
|
||||
|
||||
### Utilities (`app/utils/`)
|
||||
Helper functions and shared utilities:
|
||||
@ -110,6 +119,22 @@ Express.js middleware functions:
|
||||
- Error logging and classification
|
||||
- Production vs development error detail levels
|
||||
|
||||
### Email Templates (`app/templates/email/`)
|
||||
Professional HTML and text email templates with variable substitution:
|
||||
|
||||
- **`representative-contact.html/.txt`** - Template for direct constituent communications
|
||||
- Variables: MESSAGE, SENDER_NAME, SENDER_EMAIL, POSTAL_CODE, APP_NAME, TIMESTAMP
|
||||
- Professional styling with constituent information display and platform branding
|
||||
|
||||
- **`campaign-email.html/.txt`** - Template for organized campaign emails
|
||||
- Variables: MESSAGE, USER_NAME, USER_EMAIL, POSTAL_CODE, CAMPAIGN_TITLE, RECIPIENT_NAME, RECIPIENT_LEVEL
|
||||
- Campaign-specific styling with participant information and campaign branding
|
||||
- Conditional recipient information display
|
||||
|
||||
- **`test-email.html/.txt`** - Template for email system testing
|
||||
- Variables: MESSAGE, APP_NAME, TIMESTAMP
|
||||
- Warning styling and test indicators for system verification emails
|
||||
|
||||
### Frontend Assets (`app/public/`)
|
||||
|
||||
#### HTML
|
||||
@ -131,6 +156,16 @@ Express.js middleware functions:
|
||||
- Email composition modal
|
||||
- Responsive design with accessibility features
|
||||
|
||||
- **`email-test.html`** - Comprehensive email testing interface (admin-only)
|
||||
- Protected admin interface for email testing and diagnostics
|
||||
- Quick test email functionality with one-click testing
|
||||
- Email preview system to see emails before sending
|
||||
- Email composition form with real-time preview
|
||||
- Email logs viewer with filtering by test/live mode
|
||||
- SMTP connection testing and diagnostics
|
||||
- Current configuration display showing test mode status
|
||||
- Real-time feedback and error handling for all operations
|
||||
|
||||
#### Stylesheets (`app/public/css/`)
|
||||
- **`styles.css`** - Complete application styling
|
||||
- Responsive grid layouts for representative cards
|
||||
@ -175,6 +210,17 @@ Express.js middleware functions:
|
||||
- Form validation and submission handling
|
||||
- Success/error feedback to users
|
||||
|
||||
- **`email-testing.js`** - Comprehensive email testing interface management
|
||||
- `EmailTesting` class managing all testing functionality
|
||||
- Quick test email sending with default content
|
||||
- SMTP connection testing and diagnostics
|
||||
- Email preview generation with real-time rendering
|
||||
- Test email sending with form validation
|
||||
- Email logs management with filtering and pagination
|
||||
- Configuration status display and management
|
||||
- Real-time UI updates and error handling
|
||||
- Integration with authentication system for admin protection
|
||||
|
||||
## Scripts Directory (`scripts/`)
|
||||
|
||||
- **`build-nocodb.sh`** - Database setup automation
|
||||
@ -237,8 +283,24 @@ Express.js middleware functions:
|
||||
### SMTP Integration
|
||||
- **Security** → Secure authentication and encrypted connections
|
||||
- **Reliability** → Error handling and delivery confirmation
|
||||
- **Logging** → Complete audit trail of email activity
|
||||
- **Configuration** → Flexible SMTP provider support
|
||||
- **Logging** → Complete audit trail of email activity with test mode tracking
|
||||
- **Configuration** → Flexible SMTP provider support with development/production modes
|
||||
- **Testing** → Comprehensive test mode with email redirection and preview capabilities
|
||||
|
||||
### Email Testing System
|
||||
- **Test Mode** → Automatic email redirection to configured test recipient
|
||||
- **Preview System** → Generate email previews without sending for content review
|
||||
- **SMTP Diagnostics** → Connection testing and troubleshooting tools
|
||||
- **Email Logging** → Complete audit trail with test/live mode classification
|
||||
- **Development Tools** → MailHog integration for local email catching and review
|
||||
- **Admin Interface** → Dedicated testing interface accessible only to authenticated admins
|
||||
|
||||
### Docker Configuration
|
||||
- **Production Mode** → Standard application container with external SMTP
|
||||
- **Development Mode** → Application + MailHog containers for local email testing
|
||||
- **Profile-based Deployment** → MailHog only runs in development profile
|
||||
- **Email Catching** → All development emails caught by MailHog web interface at port 8025
|
||||
- **Environment Flexibility** → Easy switching between development and production SMTP settings
|
||||
|
||||
## Development Patterns
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Instructions
|
||||
|
||||
Welcome to the Influence project! Welcome to Influence, a tool for creating political change by targeting influential individuals within a community. This application is designed to help campaigns identify and engage with key figures who can sway public opinion and mobilize support.
|
||||
Welcome to the BNKops Influence project! Welcome to BNNKops Influence, a tool for creating political change by targeting influential individuals within a community. This application is designed to help campaigns identify and engage with key figures who can sway public opinion and mobilize support.
|
||||
|
||||
## Environment Setup
|
||||
|
||||
|
||||
@ -321,6 +321,8 @@ ingress:
|
||||
service: http://localhost:${GITEA_WEB_PORT:-3030}
|
||||
- hostname: map.${CF_DOMAIN}
|
||||
service: http://localhost:${MAP_PORT:-3000}
|
||||
- hostname: influence.${CF_DOMAIN}
|
||||
service: http://localhost:${INFLUENCE_PORT:-3333}
|
||||
- hostname: qr.${CF_DOMAIN}
|
||||
service: http://localhost:${MINI_QR_PORT:-8089}
|
||||
- service: http_status:404
|
||||
@ -607,6 +609,7 @@ if [ "$CF_CREDS_VALID" = true ]; then
|
||||
["db"]="NocoDB"
|
||||
["git"]="Gitea"
|
||||
["map"]="Map"
|
||||
["influence"]="Influence"
|
||||
["qr"]="Mini QR"
|
||||
)
|
||||
|
||||
@ -651,6 +654,7 @@ echo " - n8n: https://n8n.$CF_DOMAIN"
|
||||
echo " - NocoDB: https://db.$CF_DOMAIN"
|
||||
echo " - Gitea: https://git.$CF_DOMAIN"
|
||||
echo " - Map: https://map.$CF_DOMAIN"
|
||||
echo " - Influence: https://influence.$CF_DOMAIN"
|
||||
echo " - Mini QR: https://qr.$CF_DOMAIN"
|
||||
echo ""
|
||||
echo "Protected services (requires login with $ADMIN_EMAIL):"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user