diff --git a/.gitignore b/.gitignore index 37a916a..0c26cad 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,6 @@ /configs/cloudflare/*.yaml /configs/cloudflare/*.yml -.excalidraw \ No newline at end of file +.excalidraw + +/.VSCodeCounter \ No newline at end of file diff --git a/config.sh b/config.sh index cef59a5..d00651f 100755 --- a/config.sh +++ b/config.sh @@ -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:" diff --git a/configs/cloudflare/tunnel-config.yml b/configs/cloudflare/tunnel-config.yml index 80a5078..d3d7862 100644 --- a/configs/cloudflare/tunnel-config.yml +++ b/configs/cloudflare/tunnel-config.yml @@ -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 diff --git a/configs/homepage/services.yaml b/configs/homepage/services.yaml index d30db17..6510fbe 100644 --- a/configs/homepage/services.yaml +++ b/configs/homepage/services.yaml @@ -8,12 +8,6 @@ href: "https://code.cmlite.org" 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 @@ -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: @@ -46,8 +46,13 @@ href: "https://qr.cmlite.org" 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: icon: mdi-robot-industrial diff --git a/influence/README.MD b/influence/README.MD index c1e5a68..1b0b472 100644 --- a/influence/README.MD +++ b/influence/README.MD @@ -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) diff --git a/influence/app/controllers/campaigns.js b/influence/app/controllers/campaigns.js index 0efe3f6..ce169df 100644 --- a/influence/app/controllers/campaigns.js +++ b/influence/app/controllers/campaigns.js @@ -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: ` -

${message.replace(/\n/g, '
')}

-
-

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.

- ` - }); + emailResult = await emailService.sendCampaignEmail( + recipientEmail, + userEmail, + userName || 'A constituent', + postalCode, + subject, + message, + campaign['Campaign Title'] || campaign.title, + recipientName, + recipientLevel + ); } // Log the campaign email diff --git a/influence/app/controllers/emails.js b/influence/app/controllers/emails.js index 5203241..caf2af7 100644 --- a/influence/app/controllers/emails.js +++ b/influence/app/controllers/emails.js @@ -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: ` -

${message.replace(/\n/g, '
')}

-
-

This message was sent via the Alberta Influence Campaign Tool by ${senderName} (${senderEmail}) from postal code ${postalCode}.

- ` - }); + // 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(); \ No newline at end of file diff --git a/influence/app/public/email-test.html b/influence/app/public/email-test.html new file mode 100644 index 0000000..e902962 --- /dev/null +++ b/influence/app/public/email-test.html @@ -0,0 +1,285 @@ + + + + + + Email Testing Interface - Alberta Influence Campaign + + + + +
+
+

Email Testing Interface

+

Test and preview emails before sending to elected officials

+
+
+ + +
+

Quick Test

+

Send a test email to yourself to verify email configuration

+
+ + +
+
+ + +
+

Email Preview & Test

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+ + +
+

Email Logs

+
+ + + +
+
+
Loading email logs...
+
+
+ + +
+

Current Configuration

+
+
Loading configuration...
+
+
+
+ + +
+ + + + + + + + + \ No newline at end of file diff --git a/influence/app/public/index.html b/influence/app/public/index.html index a29fdc0..b9a14c3 100644 --- a/influence/app/public/index.html +++ b/influence/app/public/index.html @@ -116,7 +116,7 @@ diff --git a/influence/app/public/js/api-client.js b/influence/app/public/js/api-client.js index 40b067d..d113f23 100644 --- a/influence/app/public/js/api-client.js +++ b/influence/app/public/js/api-client.js @@ -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; diff --git a/influence/app/public/js/campaign.js b/influence/app/public/js/campaign.js index fb727f9..fc5f3db 100644 --- a/influence/app/public/js/campaign.js +++ b/influence/app/public/js/campaign.js @@ -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(); } diff --git a/influence/app/public/js/email-composer.js b/influence/app/public/js/email-composer.js index 9573d4b..7e982d0 100644 --- a/influence/app/public/js/email-composer.js +++ b/influence/app/public/js/email-composer.js @@ -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; diff --git a/influence/app/public/js/email-testing.js b/influence/app/public/js/email-testing.js new file mode 100644 index 0000000..085d94c --- /dev/null +++ b/influence/app/public/js/email-testing.js @@ -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 ? + `
+ TEST MODE: Email will be redirected to ${preview.redirectTo} +
` : ''; + + previewDiv.innerHTML = ` + ${testModeWarning} +
+ From: ${preview.from}
+ To: ${preview.to}
+ Subject: ${preview.subject}
+ Timestamp: ${new Date(preview.timestamp).toLocaleString()} +
+
+ Message Content: +
+ ${html} +
+
+ `; + } + + /** + * Load and display email logs + */ + async loadEmailLogs() { + const logsDiv = document.getElementById('email-logs'); + logsDiv.innerHTML = '
Loading email logs...
'; + + 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 = `
Failed to load logs: ${response.message}
`; + } + } catch (error) { + logsDiv.innerHTML = `
Error loading logs: ${error.message}
`; + } + } + + /** + * Display email logs + */ + displayEmailLogs(logs) { + const logsDiv = document.getElementById('email-logs'); + + if (!logs || logs.length === 0) { + logsDiv.innerHTML = '
No email logs found
'; + 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 ` +
+
+
+ ${log.Subject} +
To: ${log.Recipient}
+ ${log['Actual Recipient'] && log['Actual Recipient'] !== log.Recipient ? + `
Actually sent to: ${log['Actual Recipient']}
` : ''} + ${log.Error ? `
Error: ${log.Error}
` : ''} +
+
+ ${log.Status} + ${log['Test Mode'] ? 'TEST' : ''} +
+
+
+ ${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']}` : ''} +
+
+ `; + }).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 = ` +
+
+

Email Test Mode

+
+ ${isTestMode ? 'ENABLED' : 'DISABLED'} +
+ + ${isTestMode ? 'All emails will be redirected to test recipient' : 'Emails will be sent to actual recipients'} + +
+
+

Test Email Recipient

+
${testRecipient}
+ Emails will be sent here in test mode +
+
+ `; + } + + /** + * 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; +} \ No newline at end of file diff --git a/influence/app/routes/api.js b/influence/app/routes/api.js index 95ddeac..44a8952 100644 --- a/influence/app/routes/api.js +++ b/influence/app/routes/api.js @@ -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'), diff --git a/influence/app/services/email.js b/influence/app/services/email.js index 020dd5a..c05f494 100644 --- a/influence/app/services/email.js +++ b/influence/app/services/email.js @@ -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(); \ No newline at end of file diff --git a/influence/app/services/emailTemplates.js b/influence/app/services/emailTemplates.js new file mode 100644 index 0000000..81c7b8f --- /dev/null +++ b/influence/app/services/emailTemplates.js @@ -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, '
')); + } + + 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(); \ No newline at end of file diff --git a/influence/app/services/nocodb.js b/influence/app/services/nocodb.js index 11b9c32..22c6fe1 100644 --- a/influence/app/services/nocodb.js +++ b/influence/app/services/nocodb.js @@ -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 = ''; diff --git a/influence/app/templates/email/campaign-email.html b/influence/app/templates/email/campaign-email.html new file mode 100644 index 0000000..ba53d94 --- /dev/null +++ b/influence/app/templates/email/campaign-email.html @@ -0,0 +1,132 @@ + + + + + {{CAMPAIGN_TITLE}} - Campaign Message + + + +
+
+ +
{{CAMPAIGN_TITLE}}
+

Constituent Advocacy Campaign

+
+ +
+
+ {{MESSAGE}} +
+ +
+

Campaign Participant Information:

+
+ Name: + {{USER_NAME}} +
+
+ Email: + {{USER_EMAIL}} +
+
+ Postal Code: + {{POSTAL_CODE}} +
+ {{#if RECIPIENT_NAME}} +
+ To: + {{RECIPIENT_NAME}} ({{RECIPIENT_LEVEL}}) +
+ {{/if}} +
+
+ + +
+ + \ No newline at end of file diff --git a/influence/app/templates/email/campaign-email.txt b/influence/app/templates/email/campaign-email.txt new file mode 100644 index 0000000..2ce6093 --- /dev/null +++ b/influence/app/templates/email/campaign-email.txt @@ -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. \ No newline at end of file diff --git a/influence/app/templates/email/representative-contact.html b/influence/app/templates/email/representative-contact.html new file mode 100644 index 0000000..1cf6047 --- /dev/null +++ b/influence/app/templates/email/representative-contact.html @@ -0,0 +1,115 @@ + + + + + Message from Constituent + + + +
+
+ +

Constituent Communication Platform

+
+ +
+
+ {{MESSAGE}} +
+ +
+

Constituent Information:

+
+ Name: + {{SENDER_NAME}} +
+
+ Email: + {{SENDER_EMAIL}} +
+
+ Postal Code: + {{POSTAL_CODE}} +
+
+
+ + +
+ + \ No newline at end of file diff --git a/influence/app/templates/email/representative-contact.txt b/influence/app/templates/email/representative-contact.txt new file mode 100644 index 0000000..ae054aa --- /dev/null +++ b/influence/app/templates/email/representative-contact.txt @@ -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. \ No newline at end of file diff --git a/influence/app/templates/email/test-email.html b/influence/app/templates/email/test-email.html new file mode 100644 index 0000000..122cbee --- /dev/null +++ b/influence/app/templates/email/test-email.html @@ -0,0 +1,101 @@ + + + + + Test Email - {{APP_NAME}} + + + +
+
+ +
TEST EMAIL
+

Email System Test

+
+ +
+
+

⚠️ This is a test email

+

This email was sent to verify the email system is working correctly.

+
+ +
+ {{MESSAGE}} +
+
+ + +
+ + \ No newline at end of file diff --git a/influence/app/templates/email/test-email.txt b/influence/app/templates/email/test-email.txt new file mode 100644 index 0000000..13f483d --- /dev/null +++ b/influence/app/templates/email/test-email.txt @@ -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. \ No newline at end of file diff --git a/influence/app/utils/rate-limiter.js b/influence/app/utils/rate-limiter.js index 11209fb..2938827 100644 --- a/influence/app/utils/rate-limiter.js +++ b/influence/app/utils/rate-limiter.js @@ -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 }; \ No newline at end of file diff --git a/influence/docker-compose.yml b/influence/docker-compose.yml index ebc2b36..66ad864 100644 --- a/influence/docker-compose.yml +++ b/influence/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/influence/example.env b/influence/example.env new file mode 100644 index 0000000..6b71105 --- /dev/null +++ b/influence/example.env @@ -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 \ No newline at end of file diff --git a/influence/files-explainer.md b/influence/files-explainer.md index 4f02986..8b2f458 100644 --- a/influence/files-explainer.md +++ b/influence/files-explainer.md @@ -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 diff --git a/influence/instruct.md b/influence/instruct.md index 85ce560..802479f 100644 --- a/influence/instruct.md +++ b/influence/instruct.md @@ -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 diff --git a/start-production.sh b/start-production.sh index 2b2bcfe..9dc99be 100755 --- a/start-production.sh +++ b/start-production.sh @@ -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):"