Compare commits
4 Commits
ecbc38cf8e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3d7592e7d | ||
|
|
12a82c8d03 | ||
|
|
b34ea2ed84 | ||
|
|
8fd951fd1f |
25
.env.example
25
.env.example
@@ -19,4 +19,27 @@ MYSQL_PASSWORD=change_this_password
|
|||||||
MYSQL_ROOT_PASSWORD=change_this_root_password
|
MYSQL_ROOT_PASSWORD=change_this_root_password
|
||||||
|
|
||||||
# API Configuration
|
# API Configuration
|
||||||
API_TOKEN=change_this_to_a_secure_random_token
|
API_TOKEN=change_this_to_a_secure_random_token
|
||||||
|
|
||||||
|
# Bounce Handling Configuration (Optional)
|
||||||
|
# Choose one of the bounce handling methods below:
|
||||||
|
|
||||||
|
# Method 1: SNS Webhook Bounce Handling (Requires SES Production Access)
|
||||||
|
# Set to 'true' to enable real-time SNS webhook bounce handling
|
||||||
|
ENABLE_SNS_WEBHOOKS=false
|
||||||
|
ENABLE_BOUNCE_HANDLING=false
|
||||||
|
|
||||||
|
# If ENABLE_SNS_WEBHOOKS=true, you need:
|
||||||
|
# 1. AWS SNS topic configured
|
||||||
|
# 2. SES configured to send notifications to SNS topic
|
||||||
|
# 3. Valid HTTPS domain for webhook endpoint
|
||||||
|
# 4. SNS subscription confirmed to your webhook endpoint
|
||||||
|
|
||||||
|
# Method 2: Email-Based Bounce Processing (Works with SES Sandbox)
|
||||||
|
# Set to 'true' to enable email-based bounce processing
|
||||||
|
# This processes bounce emails that are sent to bounces@lists.sasalliance.org
|
||||||
|
ENABLE_EMAIL_BOUNCE_PROCESSING=false
|
||||||
|
|
||||||
|
# Note: You can enable both methods, but typically only one is needed
|
||||||
|
# Email-based processing works with SES sandbox accounts
|
||||||
|
# SNS webhooks provide real-time processing but require SES production access
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
# SES SNS Bounce Handling Setup
|
# SES SNS Bounce Handling Setup (Optional)
|
||||||
|
|
||||||
|
**⚠️ NOTICE: Bounce handling is optional and disabled by default.**
|
||||||
|
|
||||||
This document describes how to configure AWS SES and SNS to handle email bounces automatically in the Mail List Manager.
|
This document describes how to configure AWS SES and SNS to handle email bounces automatically in the Mail List Manager.
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- SES production access (not available in sandbox mode)
|
||||||
|
- Valid HTTPS domain for webhook endpoint
|
||||||
|
- Bounce handling must be enabled in configuration
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The system uses AWS Simple Notification Service (SNS) to receive real-time bounce notifications from AWS Simple Email Service (SES). When an email bounces:
|
The system can optionally use AWS Simple Notification Service (SNS) to receive real-time bounce notifications from AWS Simple Email Service (SES). When bounce handling is enabled and an email bounces:
|
||||||
|
|
||||||
1. SES sends a notification to an SNS topic
|
1. SES sends a notification to an SNS topic
|
||||||
2. SNS forwards the notification to your webhook endpoint
|
2. SNS forwards the notification to your webhook endpoint
|
||||||
@@ -22,13 +29,28 @@ The system uses AWS Simple Notification Service (SNS) to receive real-time bounc
|
|||||||
|
|
||||||
## Setup Instructions
|
## Setup Instructions
|
||||||
|
|
||||||
### 1. Prerequisites
|
### 1. Enable Bounce Handling
|
||||||
|
|
||||||
|
First, enable bounce handling in your `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable SNS webhook bounce handling
|
||||||
|
ENABLE_SNS_WEBHOOKS=true
|
||||||
|
ENABLE_BOUNCE_HANDLING=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the API container after making this change:
|
||||||
|
```bash
|
||||||
|
sudo docker-compose restart api
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Prerequisites
|
||||||
|
|
||||||
- AWS account with SES configured and verified
|
- AWS account with SES configured and verified
|
||||||
- Your Mail List Manager deployed and accessible via HTTPS (required for SNS webhook)
|
- Your Mail List Manager deployed and accessible via HTTPS (required for SNS webhook)
|
||||||
- Domain or subdomain for webhook (e.g., `https://lists.yourdomain.com`)
|
- Domain or subdomain for webhook (e.g., `https://lists.yourdomain.com`)
|
||||||
|
|
||||||
### 2. Create SNS Topic
|
### 3. Create SNS Topic
|
||||||
|
|
||||||
1. Log in to AWS Console and navigate to SNS
|
1. Log in to AWS Console and navigate to SNS
|
||||||
2. Click "Create topic"
|
2. Click "Create topic"
|
||||||
@@ -38,7 +60,7 @@ The system uses AWS Simple Notification Service (SNS) to receive real-time bounc
|
|||||||
6. Click "Create topic"
|
6. Click "Create topic"
|
||||||
7. **Save the Topic ARN** (you'll need it in step 4) arn:aws:sns:eu-west-2:827164363113:ses-bounces
|
7. **Save the Topic ARN** (you'll need it in step 4) arn:aws:sns:eu-west-2:827164363113:ses-bounces
|
||||||
|
|
||||||
### 3. Subscribe Your Webhook to SNS Topic
|
### 4. Subscribe Your Webhook to SNS Topic
|
||||||
|
|
||||||
1. In the SNS topic details, click "Create subscription"
|
1. In the SNS topic details, click "Create subscription"
|
||||||
2. Protocol: `HTTPS`
|
2. Protocol: `HTTPS`
|
||||||
@@ -49,7 +71,7 @@ The system uses AWS Simple Notification Service (SNS) to receive real-time bounc
|
|||||||
5. Click "Create subscription"
|
5. Click "Create subscription"
|
||||||
6. The subscription will be in "PendingConfirmation" status
|
6. The subscription will be in "PendingConfirmation" status
|
||||||
|
|
||||||
### 4. Confirm SNS Subscription
|
### 5. Confirm SNS Subscription
|
||||||
|
|
||||||
When you create the subscription, SNS will send a `SubscriptionConfirmation` request to your webhook endpoint. The Mail List Manager API automatically confirms this subscription.
|
When you create the subscription, SNS will send a `SubscriptionConfirmation` request to your webhook endpoint. The Mail List Manager API automatically confirms this subscription.
|
||||||
|
|
||||||
@@ -61,7 +83,7 @@ When you create the subscription, SNS will send a `SubscriptionConfirmation` req
|
|||||||
3. In the AWS SNS console, refresh the subscriptions list
|
3. In the AWS SNS console, refresh the subscriptions list
|
||||||
4. The status should change from "PendingConfirmation" to "Confirmed"
|
4. The status should change from "PendingConfirmation" to "Confirmed"
|
||||||
|
|
||||||
### 5. Configure SES to Send Bounce Notifications
|
### 6. Configure SES to Send Bounce Notifications
|
||||||
|
|
||||||
1. Navigate to AWS SES console
|
1. Navigate to AWS SES console
|
||||||
2. Go to "Configuration Sets" (or "Verified identities" > select your domain > "Notifications")
|
2. Go to "Configuration Sets" (or "Verified identities" > select your domain > "Notifications")
|
||||||
@@ -78,7 +100,7 @@ When you create the subscription, SNS will send a `SubscriptionConfirmation` req
|
|||||||
- Include original headers: Enabled (optional)
|
- Include original headers: Enabled (optional)
|
||||||
- Click "Save changes"
|
- Click "Save changes"
|
||||||
|
|
||||||
### 6. Verify Setup
|
### 7. Verify Setup
|
||||||
|
|
||||||
#### Test with a Bounce Simulator
|
#### Test with a Bounce Simulator
|
||||||
|
|
||||||
@@ -108,7 +130,7 @@ Or send to your mailing list with a test recipient:
|
|||||||
5. Find the test member and click the "Bounces" button
|
5. Find the test member and click the "Bounces" button
|
||||||
6. You should see the bounce event recorded
|
6. You should see the bounce event recorded
|
||||||
|
|
||||||
### 7. Security Considerations
|
### 8. Security Considerations
|
||||||
|
|
||||||
#### SNS Signature Verification
|
#### SNS Signature Verification
|
||||||
|
|
||||||
@@ -149,7 +171,7 @@ server {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8. Managing Bounces in the UI
|
### 9. Managing Bounces in the UI
|
||||||
|
|
||||||
#### View Bounce Status
|
#### View Bounce Status
|
||||||
|
|
||||||
@@ -178,7 +200,7 @@ If a member's email has been corrected or verified:
|
|||||||
|
|
||||||
**Note**: Only users with write access (administrators and operators) can reset bounce status.
|
**Note**: Only users with write access (administrators and operators) can reset bounce status.
|
||||||
|
|
||||||
### 9. Monitoring and Maintenance
|
### 10. Monitoring and Maintenance
|
||||||
|
|
||||||
#### Check Bounce Logs
|
#### Check Bounce Logs
|
||||||
|
|
||||||
|
|||||||
243
EMAIL_BOUNCE_HANDLING_SETUP.md
Normal file
243
EMAIL_BOUNCE_HANDLING_SETUP.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
# Email-Based Bounce Handling Setup
|
||||||
|
|
||||||
|
This document explains the email-based bounce handling system implemented as an alternative to SNS webhooks for environments without SES production access.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The system processes email bounces directly within the Postfix container by:
|
||||||
|
1. Rewriting return paths to direct bounces to a processing address
|
||||||
|
2. Processing bounce emails via Python script
|
||||||
|
3. Updating member bounce statistics in MySQL database
|
||||||
|
4. Automatically disabling members with excessive bounces
|
||||||
|
2. Setting up an alias that pipes bounce emails to a Python processing script
|
||||||
|
3. The script parses bounce emails, extracts bounced addresses, and updates the database
|
||||||
|
4. Members with hard bounces are automatically deactivated
|
||||||
|
5. Bounce history is tracked and displayed in the UI (same as SNS method)
|
||||||
|
|
||||||
|
## Advantages
|
||||||
|
|
||||||
|
- **Works with SES Sandbox**: No production SES access required
|
||||||
|
- **No External Dependencies**: Doesn't require SNS, webhooks, or HTTPS domains
|
||||||
|
- **Self-Contained**: All processing happens within the existing containers
|
||||||
|
- **Real-time Processing**: Bounces are processed as soon as emails arrive
|
||||||
|
- **Compatible**: Uses the same database schema and UI as SNS bounce handling
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### 1. Enable Email Bounce Processing
|
||||||
|
|
||||||
|
In your `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable email-based bounce processing
|
||||||
|
ENABLE_EMAIL_BOUNCE_PROCESSING=true
|
||||||
|
|
||||||
|
# This will automatically enable bounce handling features
|
||||||
|
ENABLE_BOUNCE_HANDLING=true # Automatically set to true when email processing is enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Restart the System
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker-compose down
|
||||||
|
sudo docker-compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Verify Configuration
|
||||||
|
|
||||||
|
Check that email bounce processing is enabled:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8000/config | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bounce_handling_enabled": true,
|
||||||
|
"sns_webhooks_enabled": false,
|
||||||
|
"email_bounce_processing_enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Postfix Configuration
|
||||||
|
|
||||||
|
The system configures Postfix with:
|
||||||
|
|
||||||
|
- `bounce_notice_recipient = bounces@lists.sasalliance.org`
|
||||||
|
- `2bounce_notice_recipient = bounces@lists.sasalliance.org`
|
||||||
|
- `error_notice_recipient = bounces@lists.sasalliance.org`
|
||||||
|
|
||||||
|
### Aliases Configuration
|
||||||
|
|
||||||
|
The `bounces` address is configured to pipe emails to the processing script:
|
||||||
|
|
||||||
|
```
|
||||||
|
bounces: "|/usr/local/bin/process-bounce.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bounce Processing Script
|
||||||
|
|
||||||
|
The Python script (`/usr/local/bin/process-bounce.py`):
|
||||||
|
|
||||||
|
1. **Reads bounce emails** from stdin (via pipe)
|
||||||
|
2. **Parses email content** using multiple regex patterns to extract bounced addresses
|
||||||
|
3. **Analyzes bounce type** based on SMTP error codes:
|
||||||
|
- 5xx codes = Permanent bounces
|
||||||
|
- 4xx codes = Transient bounces
|
||||||
|
- Unknown = Undetermined
|
||||||
|
4. **Updates database** using the same schema as SNS bounce handling:
|
||||||
|
- Logs bounce in `bounce_logs` table
|
||||||
|
- Updates member `bounce_count`, `bounce_status`, and `last_bounce_at`
|
||||||
|
- Deactivates members with permanent bounces
|
||||||
|
- Marks members with soft bounce status after 3 transient bounces
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test the Processing Script
|
||||||
|
|
||||||
|
You can test the bounce processing script in test mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test with sample bounce email
|
||||||
|
sudo docker-compose exec postfix /usr/local/bin/process-bounce.py --test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
```
|
||||||
|
2025-10-14 15:49:16,041 - bounce-processor - INFO - Starting bounce processing
|
||||||
|
2025-10-14 15:49:16,041 - bounce-processor - INFO - Running in test mode with sample bounce email
|
||||||
|
2025-10-14 15:49:16,050 - bounce-processor - INFO - Extracted addresses: ['testuser@example.com']
|
||||||
|
2025-10-14 15:49:16,050 - bounce-processor - INFO - Test mode - would process 1 bounce(s):
|
||||||
|
2025-10-14 15:49:16,050 - bounce-processor - INFO - {'email': 'testuser@example.com', 'bounce_type': 'Permanent', 'bounce_subtype': 'General', 'diagnostic_code': '', 'timestamp': '2025-10-14 15:49:16'}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test with Real Bounce Email
|
||||||
|
|
||||||
|
To test with a real bounce:
|
||||||
|
|
||||||
|
1. Send an email to a non-existent address via your mailing list
|
||||||
|
2. Wait for the bounce to be processed
|
||||||
|
3. Check the database for bounce logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check bounce logs
|
||||||
|
sudo docker-compose exec mysql mysql -u maillist -pmaillist maillist -e "SELECT * FROM bounce_logs ORDER BY created_at DESC LIMIT 5;"
|
||||||
|
|
||||||
|
# Check member bounce status
|
||||||
|
sudo docker-compose exec mysql mysql -u maillist -pmaillist maillist -e "SELECT member_id, email, bounce_count, bounce_status, last_bounce_at FROM members WHERE bounce_count > 0;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### View in Web Interface
|
||||||
|
|
||||||
|
1. Open http://localhost:3000
|
||||||
|
2. Navigate to the Members tab
|
||||||
|
3. Look for bounce badges and bounce counts next to member names
|
||||||
|
4. Click the "Bounces" button next to a member to view bounce history
|
||||||
|
|
||||||
|
## Supported Bounce Email Formats
|
||||||
|
|
||||||
|
The processing script recognizes these bounce patterns:
|
||||||
|
|
||||||
|
### SMTP Error Codes
|
||||||
|
- 550, 551, 553, 552, 554 (permanent failures)
|
||||||
|
- 450, 451, 452 (temporary failures)
|
||||||
|
|
||||||
|
### Delivery Status Notification (DSN)
|
||||||
|
- `Final-Recipient:` headers
|
||||||
|
- `Original-Recipient:` headers
|
||||||
|
|
||||||
|
### Common Bounce Messages
|
||||||
|
- "user unknown"
|
||||||
|
- "does not exist"
|
||||||
|
- "not found"
|
||||||
|
- "mailbox unavailable"
|
||||||
|
- "recipient rejected"
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### View Processing Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View bounce processing logs
|
||||||
|
sudo docker-compose logs postfix | grep bounce-processor
|
||||||
|
|
||||||
|
# Follow logs in real-time
|
||||||
|
sudo docker-compose logs -f postfix | grep bounce-processor
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Aliases Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify aliases are configured correctly
|
||||||
|
sudo docker-compose exec postfix cat /etc/aliases
|
||||||
|
|
||||||
|
# Check alias database
|
||||||
|
sudo docker-compose exec postfix postmap -q bounces /etc/aliases
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Bounces Not Being Processed
|
||||||
|
|
||||||
|
1. **Check aliases configuration:**
|
||||||
|
```bash
|
||||||
|
sudo docker-compose exec postfix cat /etc/aliases
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Verify script permissions:**
|
||||||
|
```bash
|
||||||
|
sudo docker-compose exec postfix ls -la /usr/local/bin/process-bounce.py
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Test script manually:**
|
||||||
|
```bash
|
||||||
|
sudo docker-compose exec postfix /usr/local/bin/process-bounce.py --test
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Check Postfix logs:**
|
||||||
|
```bash
|
||||||
|
sudo docker-compose logs postfix | grep -i bounce
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Connection Issues
|
||||||
|
|
||||||
|
1. **Check environment variables:**
|
||||||
|
```bash
|
||||||
|
sudo docker-compose exec postfix env | grep MYSQL
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Test database connection:**
|
||||||
|
```bash
|
||||||
|
sudo docker-compose exec postfix python3 -c "import pymysql; print('PyMySQL available')"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Script Errors
|
||||||
|
|
||||||
|
View detailed error logs:
|
||||||
|
```bash
|
||||||
|
sudo docker-compose logs postfix | grep -A 10 -B 10 "bounce-processor.*ERROR"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comparison with SNS Webhooks
|
||||||
|
|
||||||
|
| Feature | Email-Based | SNS Webhooks |
|
||||||
|
|---------|-------------|--------------|
|
||||||
|
| **SES Requirement** | Sandbox OK | Production access required |
|
||||||
|
| **External Dependencies** | None | SNS, HTTPS domain |
|
||||||
|
| **Processing Speed** | Real-time | Real-time |
|
||||||
|
| **Setup Complexity** | Low | High |
|
||||||
|
| **Reliability** | High | High |
|
||||||
|
| **Bounce Detection** | Regex-based | AWS-provided |
|
||||||
|
| **Cost** | Free | SNS charges apply |
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Monitor bounce processing** to ensure it's working correctly
|
||||||
|
2. **Review bounce patterns** in the logs to improve detection if needed
|
||||||
|
3. **Set up bounce notification alerts** (optional)
|
||||||
|
4. **Consider upgrading to SNS webhooks** when SES production access is available
|
||||||
|
|
||||||
|
Email-based bounce handling provides a robust alternative that works immediately with any SES configuration while providing the same bounce management features as the SNS webhook method.
|
||||||
212
EMAIL_BOUNCE_HANDLING_SETUP_COMPREHENSIVE.md
Normal file
212
EMAIL_BOUNCE_HANDLING_SETUP_COMPREHENSIVE.md
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
# Email-Based Bounce Handling Setup (Comprehensive)
|
||||||
|
|
||||||
|
This document explains the complete email-based bounce handling system implemented as an alternative to SNS webhooks for environments without SES production access.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The system processes email bounces directly within the Postfix container by:
|
||||||
|
1. Rewriting return paths to direct bounces to a processing address
|
||||||
|
2. Processing bounce emails via Python script
|
||||||
|
3. Updating member bounce statistics in MySQL database
|
||||||
|
4. Automatically disabling members with excessive bounces
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables (in .env)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Bounce handling feature flags
|
||||||
|
ENABLE_BOUNCE_HANDLING=true # Master switch for bounce functionality
|
||||||
|
ENABLE_EMAIL_BOUNCE_PROCESSING=true # Enable email-based processing
|
||||||
|
ENABLE_SNS_WEBHOOKS=false # Disable SNS webhooks (optional)
|
||||||
|
|
||||||
|
# Database settings (required for bounce processing)
|
||||||
|
MYSQL_ROOT_PASSWORD=your_root_password
|
||||||
|
MYSQL_DATABASE=maillist
|
||||||
|
MYSQL_USER=maillist
|
||||||
|
MYSQL_PASSWORD=your_maillist_password
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
|
||||||
|
#### 1. Return Path Rewriting (`postfix/smtp_generic`)
|
||||||
|
Routes all bounces to the processing address:
|
||||||
|
```
|
||||||
|
@lists.sasalliance.org bounces@lists.sasalliance.org
|
||||||
|
@sasalliance.org bounces@lists.sasalliance.org
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Bounce Processing Script (`postfix/process-bounce.py`)
|
||||||
|
Python script that:
|
||||||
|
- Parses bounce emails for recipient addresses and bounce types
|
||||||
|
- Updates bounce counts in MySQL database
|
||||||
|
- Automatically disables members after 5 hard bounces
|
||||||
|
- Logs all bounce events
|
||||||
|
|
||||||
|
#### 3. Postfix Integration (`postfix/main.cf.template`)
|
||||||
|
```
|
||||||
|
# Return path rewriting for outbound mail
|
||||||
|
smtp_generic_maps = hash:/etc/postfix/smtp_generic
|
||||||
|
|
||||||
|
# Bounce processing
|
||||||
|
bounce_notice_recipient = bounces@lists.sasalliance.org
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Email Aliases (`postfix/entrypoint.sh`)
|
||||||
|
```
|
||||||
|
# Route bounces to processing script
|
||||||
|
bounces: "|/usr/local/bin/python3 /etc/postfix/process-bounce.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Outbound Email Flow
|
||||||
|
1. User sends email to `community@lists.sasalliance.org`
|
||||||
|
2. Postfix expands to member list via MySQL
|
||||||
|
3. Email sent via SES with return path rewritten to `bounces@lists.sasalliance.org`
|
||||||
|
4. If delivery fails, bounce goes to bounce processing address
|
||||||
|
|
||||||
|
### Bounce Processing Flow
|
||||||
|
1. Bounce email arrives at `bounces@lists.sasalliance.org`
|
||||||
|
2. Postfix pipes email to `process-bounce.py` script
|
||||||
|
3. Script parses bounce for recipient and bounce type
|
||||||
|
4. Database updated with bounce information
|
||||||
|
5. Member automatically disabled if hard bounce threshold reached
|
||||||
|
|
||||||
|
### Bounce Types Detected
|
||||||
|
- **Hard Bounces**: Permanent failures (5.x.x SMTP codes)
|
||||||
|
- Invalid email addresses
|
||||||
|
- Domain doesn't exist
|
||||||
|
- Mailbox doesn't exist
|
||||||
|
- **Soft Bounces**: Temporary failures (4.x.x SMTP codes)
|
||||||
|
- Mailbox full
|
||||||
|
- Temporary server issues
|
||||||
|
- Rate limiting
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Bounce Logs Table
|
||||||
|
```sql
|
||||||
|
CREATE TABLE bounce_logs (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
bounce_type ENUM('hard', 'soft', 'complaint') NOT NULL,
|
||||||
|
bounce_reason TEXT,
|
||||||
|
bounced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
raw_message TEXT,
|
||||||
|
INDEX idx_email (email),
|
||||||
|
INDEX idx_bounced_at (bounced_at)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Members Table (bounce tracking fields)
|
||||||
|
```sql
|
||||||
|
ALTER TABLE members ADD COLUMN bounce_count INT DEFAULT 0;
|
||||||
|
ALTER TABLE members ADD COLUMN last_bounce_at TIMESTAMP NULL;
|
||||||
|
ALTER TABLE members ADD COLUMN active BOOLEAN DEFAULT true;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Bounce Handling
|
||||||
|
|
||||||
|
### 1. Test Return Path Configuration
|
||||||
|
```bash
|
||||||
|
# Check that return path rewriting is working
|
||||||
|
sudo docker-compose exec postfix postconf smtp_generic_maps
|
||||||
|
sudo docker-compose exec postfix postmap -q "test@lists.sasalliance.org" hash:/etc/postfix/smtp_generic
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Simulate Bounce Email
|
||||||
|
```bash
|
||||||
|
# Send test bounce to processing script
|
||||||
|
echo "Subject: Delivery Status Notification (Failure)
|
||||||
|
From: MAILER-DAEMON@ses.amazonaws.com
|
||||||
|
To: bounces@lists.sasalliance.org
|
||||||
|
|
||||||
|
The following message could not be delivered:
|
||||||
|
Recipient: test@example.com
|
||||||
|
Reason: 550 5.1.1 User unknown" | docker-compose exec -T postfix mail -s "Test Bounce" bounces@lists.sasalliance.org
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Check Bounce Processing
|
||||||
|
```bash
|
||||||
|
# View bounce logs
|
||||||
|
sudo docker-compose exec mysql mysql -u maillist -pmaillist maillist -e "SELECT * FROM bounce_logs ORDER BY bounced_at DESC LIMIT 5;"
|
||||||
|
|
||||||
|
# Check member bounce counts
|
||||||
|
sudo docker-compose exec mysql mysql -u maillist -pmaillist maillist -e "SELECT email, bounce_count, last_bounce_at, active FROM members WHERE bounce_count > 0;"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring and Maintenance
|
||||||
|
|
||||||
|
### View Processing Logs
|
||||||
|
```bash
|
||||||
|
# Monitor bounce processing
|
||||||
|
sudo docker-compose logs -f postfix | grep -E "(bounce|process-bounce)"
|
||||||
|
|
||||||
|
# Check API bounce handling status
|
||||||
|
curl -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/config
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reset Member Bounce Count
|
||||||
|
```bash
|
||||||
|
# Via API
|
||||||
|
curl -X POST http://localhost:8000/members/{member_id}/reset-bounces \
|
||||||
|
-H "Authorization: Bearer $API_TOKEN"
|
||||||
|
|
||||||
|
# Via Database
|
||||||
|
sudo docker-compose exec mysql mysql -u maillist -pmaillist maillist -e "UPDATE members SET bounce_count=0, active=true WHERE email='user@example.com';"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
#### Bounces Not Being Processed
|
||||||
|
1. Check that `bounces` alias exists: `sudo docker-compose exec postfix cat /etc/aliases | grep bounces`
|
||||||
|
2. Verify Python script permissions: `sudo docker-compose exec postfix ls -la /etc/postfix/process-bounce.py`
|
||||||
|
3. Test script manually: `sudo docker-compose exec postfix python3 /etc/postfix/process-bounce.py --test`
|
||||||
|
|
||||||
|
#### Return Path Not Rewritten
|
||||||
|
1. Check smtp_generic configuration: `sudo docker-compose exec postfix postconf smtp_generic_maps`
|
||||||
|
2. Verify map file exists: `sudo docker-compose exec postfix ls -la /etc/postfix/smtp_generic*`
|
||||||
|
3. Test mapping: `sudo docker-compose exec postfix postmap -q "test@lists.sasalliance.org" hash:/etc/postfix/smtp_generic`
|
||||||
|
|
||||||
|
#### Database Connection Issues
|
||||||
|
1. Check PyMySQL installation: `sudo docker-compose exec postfix python3 -c "import pymysql; print('OK')"`
|
||||||
|
2. Test database connection: `sudo docker-compose exec postfix python3 -c "import pymysql; pymysql.connect(host='mysql', user='maillist', password='your_password', database='maillist')"`
|
||||||
|
3. Verify network connectivity: `sudo docker-compose exec postfix ping mysql`
|
||||||
|
|
||||||
|
### Log Analysis
|
||||||
|
```bash
|
||||||
|
# Postfix logs
|
||||||
|
sudo docker-compose logs postfix | grep -E "(bounce|MAILER-DAEMON|process-bounce)"
|
||||||
|
|
||||||
|
# MySQL connection logs
|
||||||
|
sudo docker-compose logs postfix | grep -E "(pymysql|mysql)"
|
||||||
|
|
||||||
|
# SES relay logs
|
||||||
|
sudo docker-compose logs postfix | grep -E "(relay|sent|deferred)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- Bounce processing script runs with limited privileges
|
||||||
|
- Database credentials secured in environment variables
|
||||||
|
- Bounce emails contain sensitive delivery information - logs are rotated
|
||||||
|
- Return path rewriting prevents bounce loops
|
||||||
|
- Processing script validates email format before database updates
|
||||||
|
|
||||||
|
## Performance Notes
|
||||||
|
|
||||||
|
- Bounce processing is asynchronous (doesn't block email delivery)
|
||||||
|
- Database queries are indexed for bounce lookups
|
||||||
|
- Bounce logs should be periodically archived for large volumes
|
||||||
|
- SMTP generic maps are cached by Postfix for performance
|
||||||
|
|
||||||
|
## Advantages over SNS Webhooks
|
||||||
|
|
||||||
|
- **Works with SES Sandbox**: No production SES access required
|
||||||
|
- **No External Dependencies**: Doesn't require SNS, webhooks, or HTTPS domains
|
||||||
|
- **Self-Contained**: All processing happens within existing containers
|
||||||
|
- **Real-time Processing**: Bounces processed as emails arrive
|
||||||
|
- **Compatible**: Uses same database schema and UI as SNS method
|
||||||
47
README.md
47
README.md
@@ -172,6 +172,53 @@ docker-compose exec postfix postmap -q "community@lists.sasalliance.org" \
|
|||||||
mysql:/etc/postfix/mysql_virtual_alias_maps.cf
|
mysql:/etc/postfix/mysql_virtual_alias_maps.cf
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Bounce Handling (Optional)
|
||||||
|
|
||||||
|
**Email bounce handling is optional and disabled by default.**
|
||||||
|
|
||||||
|
**Three Configuration Options:**
|
||||||
|
|
||||||
|
1. **SNS Webhooks** (Requires SES Production Access):
|
||||||
|
```bash
|
||||||
|
# In .env file
|
||||||
|
ENABLE_SNS_WEBHOOKS=true
|
||||||
|
ENABLE_BOUNCE_HANDLING=true
|
||||||
|
```
|
||||||
|
- Real-time bounce notifications via AWS SNS webhooks
|
||||||
|
- Automatic member deactivation for hard bounces
|
||||||
|
- Bounce history tracking and management in web UI
|
||||||
|
- Requires valid HTTPS domain and SES production access
|
||||||
|
- See `BOUNCE_HANDLING_SETUP.md` for complete setup
|
||||||
|
|
||||||
|
2. **Email-Based Processing** (Works with SES Sandbox):
|
||||||
|
```bash
|
||||||
|
# In .env file
|
||||||
|
ENABLE_EMAIL_BOUNCE_PROCESSING=true
|
||||||
|
ENABLE_BOUNCE_HANDLING=true # Automatically enabled
|
||||||
|
```
|
||||||
|
- Automatic bounce processing via email parsing
|
||||||
|
- Same bounce management features as SNS webhooks
|
||||||
|
- Works with SES sandbox accounts (no production access needed)
|
||||||
|
- Self-contained processing within existing containers
|
||||||
|
- See `EMAIL_BOUNCE_HANDLING_SETUP.md` for complete setup
|
||||||
|
|
||||||
|
3. **Disabled** (Manual Processing Only):
|
||||||
|
```bash
|
||||||
|
# In .env file (or leave these commented out)
|
||||||
|
ENABLE_SNS_WEBHOOKS=false
|
||||||
|
ENABLE_BOUNCE_HANDLING=false
|
||||||
|
ENABLE_EMAIL_BOUNCE_PROCESSING=false
|
||||||
|
```
|
||||||
|
- No automatic bounce processing
|
||||||
|
- Manual bounce management via email notifications
|
||||||
|
- Bounce-related UI elements are hidden
|
||||||
|
|
||||||
|
**When bounce handling is disabled:**
|
||||||
|
- `/webhooks/sns` endpoint is not registered
|
||||||
|
- Bounce history endpoints return empty results
|
||||||
|
- Web UI hides bounce badges and bounce management buttons
|
||||||
|
- No automatic member deactivation occurs
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- **Environment Variables**: All credentials stored in `.env` (git-ignored)
|
- **Environment Variables**: All credentials stored in `.env` (git-ignored)
|
||||||
|
|||||||
591
api/main.py
591
api/main.py
@@ -43,6 +43,14 @@ MYSQL_DATABASE = os.getenv('MYSQL_DATABASE', 'maillist')
|
|||||||
MYSQL_USER = os.getenv('MYSQL_USER', 'maillist')
|
MYSQL_USER = os.getenv('MYSQL_USER', 'maillist')
|
||||||
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD', '')
|
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD', '')
|
||||||
|
|
||||||
|
# Bounce handling configuration
|
||||||
|
ENABLE_SNS_WEBHOOKS = os.getenv('ENABLE_SNS_WEBHOOKS', 'false').lower() == 'true'
|
||||||
|
ENABLE_BOUNCE_HANDLING = os.getenv('ENABLE_BOUNCE_HANDLING', 'false').lower() == 'true'
|
||||||
|
ENABLE_EMAIL_BOUNCE_PROCESSING = os.getenv('ENABLE_EMAIL_BOUNCE_PROCESSING', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
# Enable bounce handling if either method is enabled
|
||||||
|
ENABLE_BOUNCE_HANDLING = ENABLE_BOUNCE_HANDLING or ENABLE_SNS_WEBHOOKS or ENABLE_EMAIL_BOUNCE_PROCESSING
|
||||||
|
|
||||||
# Password hashing
|
# Password hashing
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
@@ -463,6 +471,15 @@ async def health():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=503, detail=f"Unhealthy: {str(e)}")
|
raise HTTPException(status_code=503, detail=f"Unhealthy: {str(e)}")
|
||||||
|
|
||||||
|
@app.get("/config")
|
||||||
|
async def get_config():
|
||||||
|
"""Get public configuration settings"""
|
||||||
|
return {
|
||||||
|
"bounce_handling_enabled": ENABLE_BOUNCE_HANDLING,
|
||||||
|
"sns_webhooks_enabled": ENABLE_SNS_WEBHOOKS,
|
||||||
|
"email_bounce_processing_enabled": ENABLE_EMAIL_BOUNCE_PROCESSING
|
||||||
|
}
|
||||||
|
|
||||||
# Mailing Lists endpoints
|
# Mailing Lists endpoints
|
||||||
@app.get("/lists", response_model=List[MailingList])
|
@app.get("/lists", response_model=List[MailingList])
|
||||||
async def get_lists(current_user: CurrentUser = require_read_access()):
|
async def get_lists(current_user: CurrentUser = require_read_access()):
|
||||||
@@ -824,298 +841,316 @@ async def bulk_import_members(bulk_request: BulkImportRequest, current_user: Cur
|
|||||||
cursor.close()
|
cursor.close()
|
||||||
raise HTTPException(status_code=500, detail=f"Bulk import failed: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Bulk import failed: {str(e)}")
|
||||||
|
|
||||||
# SNS Webhook for Bounce Handling
|
# SNS Webhook for Bounce Handling (conditionally enabled)
|
||||||
async def verify_sns_signature(request: Request) -> dict:
|
if ENABLE_SNS_WEBHOOKS:
|
||||||
"""Verify SNS message signature"""
|
async def verify_sns_signature(request: Request) -> dict:
|
||||||
try:
|
"""Verify SNS message signature"""
|
||||||
body = await request.body()
|
|
||||||
print(f"SNS webhook received body: {body}")
|
|
||||||
print(f"SNS webhook body length: {len(body)}")
|
|
||||||
print(f"SNS webhook headers: {dict(request.headers)}")
|
|
||||||
|
|
||||||
if not body:
|
|
||||||
print("ERROR: Empty body received")
|
|
||||||
raise HTTPException(status_code=400, detail="Empty request body")
|
|
||||||
|
|
||||||
message = json.loads(body.decode('utf-8'))
|
|
||||||
print(f"SNS webhook parsed message type: {message.get('Type')}")
|
|
||||||
|
|
||||||
# For SubscriptionConfirmation and UnsubscribeConfirmation, we don't validate signature
|
|
||||||
# AWS will send a URL to confirm
|
|
||||||
if message.get('Type') in ['SubscriptionConfirmation', 'UnsubscribeConfirmation']:
|
|
||||||
return message
|
|
||||||
|
|
||||||
# Get certificate URL and download certificate
|
|
||||||
cert_url = message.get('SigningCertURL')
|
|
||||||
if not cert_url:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing SigningCertURL")
|
|
||||||
|
|
||||||
# Verify certificate URL is from AWS
|
|
||||||
parsed_url = urlparse(cert_url)
|
|
||||||
if not parsed_url.hostname.endswith('.amazonaws.com'):
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid certificate URL")
|
|
||||||
|
|
||||||
# Download certificate
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
cert_response = await client.get(cert_url)
|
|
||||||
cert_response.raise_for_status()
|
|
||||||
cert_pem = cert_response.content
|
|
||||||
|
|
||||||
# Load certificate and extract public key
|
|
||||||
cert = x509.load_pem_x509_certificate(cert_pem, default_backend())
|
|
||||||
public_key = cert.public_key()
|
|
||||||
|
|
||||||
# Build string to sign
|
|
||||||
if message.get('Type') == 'Notification':
|
|
||||||
string_to_sign = (
|
|
||||||
f"Message\n{message['Message']}\n"
|
|
||||||
f"MessageId\n{message['MessageId']}\n"
|
|
||||||
)
|
|
||||||
if 'Subject' in message:
|
|
||||||
string_to_sign += f"Subject\n{message['Subject']}\n"
|
|
||||||
string_to_sign += (
|
|
||||||
f"Timestamp\n{message['Timestamp']}\n"
|
|
||||||
f"TopicArn\n{message['TopicArn']}\n"
|
|
||||||
f"Type\n{message['Type']}\n"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
string_to_sign = (
|
|
||||||
f"Message\n{message['Message']}\n"
|
|
||||||
f"MessageId\n{message['MessageId']}\n"
|
|
||||||
f"SubscribeURL\n{message['SubscribeURL']}\n"
|
|
||||||
f"Timestamp\n{message['Timestamp']}\n"
|
|
||||||
f"Token\n{message['Token']}\n"
|
|
||||||
f"TopicArn\n{message['TopicArn']}\n"
|
|
||||||
f"Type\n{message['Type']}\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify signature
|
|
||||||
signature = base64.b64decode(message['Signature'])
|
|
||||||
try:
|
try:
|
||||||
public_key.verify(
|
body = await request.body()
|
||||||
signature,
|
print(f"SNS webhook received body: {body}")
|
||||||
string_to_sign.encode('utf-8'),
|
print(f"SNS webhook body length: {len(body)}")
|
||||||
padding.PKCS1v15(),
|
print(f"SNS webhook headers: {dict(request.headers)}")
|
||||||
hashes.SHA1()
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid signature: {str(e)}")
|
|
||||||
|
|
||||||
return message
|
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid JSON")
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Signature verification failed: {str(e)}")
|
|
||||||
|
|
||||||
async def process_bounce(bounce_data: dict):
|
|
||||||
"""Process bounce notification and update database"""
|
|
||||||
try:
|
|
||||||
bounce_type = bounce_data.get('bounceType') # Permanent, Transient, Undetermined
|
|
||||||
bounce_subtype = bounce_data.get('bounceSubType', '')
|
|
||||||
timestamp_str = bounce_data.get('timestamp')
|
|
||||||
feedback_id = bounce_data.get('feedbackId', '')
|
|
||||||
|
|
||||||
# Convert ISO 8601 timestamp to MySQL datetime format
|
|
||||||
# SES sends: '2025-10-13T16:22:40.359Z'
|
|
||||||
# MySQL needs: '2025-10-13 16:22:40'
|
|
||||||
from datetime import datetime as dt
|
|
||||||
timestamp = dt.fromisoformat(timestamp_str.replace('Z', '+00:00')).strftime('%Y-%m-%d %H:%M:%S')
|
|
||||||
|
|
||||||
bounced_recipients = bounce_data.get('bouncedRecipients', [])
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cursor = conn.cursor(dictionary=True)
|
|
||||||
|
|
||||||
for recipient in bounced_recipients:
|
if not body:
|
||||||
email = recipient.get('emailAddress')
|
print("ERROR: Empty body received")
|
||||||
diagnostic_code = recipient.get('diagnosticCode', '')
|
raise HTTPException(status_code=400, detail="Empty request body")
|
||||||
|
|
||||||
|
message = json.loads(body.decode('utf-8'))
|
||||||
|
print(f"SNS webhook parsed message type: {message.get('Type')}")
|
||||||
|
|
||||||
|
# For SubscriptionConfirmation and UnsubscribeConfirmation, we don't validate signature
|
||||||
|
# AWS will send a URL to confirm
|
||||||
|
if message.get('Type') in ['SubscriptionConfirmation', 'UnsubscribeConfirmation']:
|
||||||
|
return message
|
||||||
|
|
||||||
|
# Get certificate URL and download certificate
|
||||||
|
cert_url = message.get('SigningCertURL')
|
||||||
|
if not cert_url:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing SigningCertURL")
|
||||||
|
|
||||||
|
# Verify certificate URL is from AWS
|
||||||
|
parsed_url = urlparse(cert_url)
|
||||||
|
if not parsed_url.hostname.endswith('.amazonaws.com'):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid certificate URL")
|
||||||
|
|
||||||
|
# Download certificate
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
cert_response = await client.get(cert_url)
|
||||||
|
cert_response.raise_for_status()
|
||||||
|
cert_pem = cert_response.content
|
||||||
|
|
||||||
|
# Load certificate and extract public key
|
||||||
|
cert = x509.load_pem_x509_certificate(cert_pem, default_backend())
|
||||||
|
public_key = cert.public_key()
|
||||||
|
|
||||||
|
# Build string to sign
|
||||||
|
if message.get('Type') == 'Notification':
|
||||||
|
string_to_sign = (
|
||||||
|
f"Message\n{message['Message']}\n"
|
||||||
|
f"MessageId\n{message['MessageId']}\n"
|
||||||
|
)
|
||||||
|
if 'Subject' in message:
|
||||||
|
string_to_sign += f"Subject\n{message['Subject']}\n"
|
||||||
|
string_to_sign += (
|
||||||
|
f"Timestamp\n{message['Timestamp']}\n"
|
||||||
|
f"TopicArn\n{message['TopicArn']}\n"
|
||||||
|
f"Type\n{message['Type']}\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
string_to_sign = (
|
||||||
|
f"Message\n{message['Message']}\n"
|
||||||
|
f"MessageId\n{message['MessageId']}\n"
|
||||||
|
f"SubscribeURL\n{message['SubscribeURL']}\n"
|
||||||
|
f"Timestamp\n{message['Timestamp']}\n"
|
||||||
|
f"Token\n{message['Token']}\n"
|
||||||
|
f"TopicArn\n{message['TopicArn']}\n"
|
||||||
|
f"Type\n{message['Type']}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify signature
|
||||||
|
signature = base64.b64decode(message['Signature'])
|
||||||
|
try:
|
||||||
|
public_key.verify(
|
||||||
|
signature,
|
||||||
|
string_to_sign.encode('utf-8'),
|
||||||
|
padding.PKCS1v15(),
|
||||||
|
hashes.SHA1()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid signature: {str(e)}")
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid JSON")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Signature verification failed: {str(e)}")
|
||||||
|
|
||||||
|
async def process_bounce(bounce_data: dict):
|
||||||
|
"""Process bounce notification and update database"""
|
||||||
|
try:
|
||||||
|
bounce_type = bounce_data.get('bounceType') # Permanent, Transient, Undetermined
|
||||||
|
bounce_subtype = bounce_data.get('bounceSubType', '')
|
||||||
|
timestamp_str = bounce_data.get('timestamp')
|
||||||
|
feedback_id = bounce_data.get('feedbackId', '')
|
||||||
|
|
||||||
|
# Convert ISO 8601 timestamp to MySQL datetime format
|
||||||
|
# SES sends: '2025-10-13T16:22:40.359Z'
|
||||||
|
# MySQL needs: '2025-10-13 16:22:40'
|
||||||
|
from datetime import datetime as dt
|
||||||
|
timestamp = dt.fromisoformat(timestamp_str.replace('Z', '+00:00')).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
bounced_recipients = bounce_data.get('bouncedRecipients', [])
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
|
||||||
if not email:
|
for recipient in bounced_recipients:
|
||||||
continue
|
email = recipient.get('emailAddress')
|
||||||
|
diagnostic_code = recipient.get('diagnosticCode', '')
|
||||||
# Find member by email
|
|
||||||
cursor.execute("SELECT member_id FROM members WHERE email = %s", (email,))
|
if not email:
|
||||||
member = cursor.fetchone()
|
continue
|
||||||
member_id = member['member_id'] if member else None
|
|
||||||
|
# Find member by email
|
||||||
# Log the bounce
|
cursor.execute("SELECT member_id FROM members WHERE email = %s", (email,))
|
||||||
cursor.execute("""
|
member = cursor.fetchone()
|
||||||
INSERT INTO bounce_logs
|
member_id = member['member_id'] if member else None
|
||||||
(member_id, email, bounce_type, bounce_subtype, diagnostic_code, timestamp, feedback_id)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
# Log the bounce
|
||||||
""", (member_id, email, bounce_type, bounce_subtype, diagnostic_code, timestamp, feedback_id))
|
cursor.execute("""
|
||||||
|
INSERT INTO bounce_logs
|
||||||
# Update member bounce status
|
(member_id, email, bounce_type, bounce_subtype, diagnostic_code, timestamp, feedback_id)
|
||||||
if member_id:
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
# Determine bounce status
|
""", (member_id, email, bounce_type, bounce_subtype, diagnostic_code, timestamp, feedback_id))
|
||||||
if bounce_type == 'Permanent':
|
|
||||||
new_status = 'hard_bounce'
|
# Update member bounce status
|
||||||
# Deactivate member with hard bounce
|
if member_id:
|
||||||
cursor.execute("""
|
# Determine bounce status
|
||||||
UPDATE members
|
if bounce_type == 'Permanent':
|
||||||
SET bounce_count = bounce_count + 1,
|
new_status = 'hard_bounce'
|
||||||
last_bounce_at = %s,
|
# Deactivate member with hard bounce
|
||||||
bounce_status = %s,
|
|
||||||
active = 0
|
|
||||||
WHERE member_id = %s
|
|
||||||
""", (timestamp, new_status, member_id))
|
|
||||||
elif bounce_type == 'Transient':
|
|
||||||
# Check current bounce count
|
|
||||||
cursor.execute("SELECT bounce_count, bounce_status FROM members WHERE member_id = %s", (member_id,))
|
|
||||||
current = cursor.fetchone()
|
|
||||||
|
|
||||||
# If already hard bounced, don't change status
|
|
||||||
if current and current['bounce_status'] != 'hard_bounce':
|
|
||||||
new_count = current['bounce_count'] + 1
|
|
||||||
# After 3 soft bounces, mark as soft_bounce status
|
|
||||||
new_status = 'soft_bounce' if new_count >= 3 else 'clean'
|
|
||||||
|
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
UPDATE members
|
UPDATE members
|
||||||
SET bounce_count = %s,
|
SET bounce_count = bounce_count + 1,
|
||||||
last_bounce_at = %s,
|
last_bounce_at = %s,
|
||||||
bounce_status = %s
|
bounce_status = %s,
|
||||||
|
active = 0
|
||||||
WHERE member_id = %s
|
WHERE member_id = %s
|
||||||
""", (new_count, timestamp, new_status, member_id))
|
""", (timestamp, new_status, member_id))
|
||||||
else: # Undetermined
|
elif bounce_type == 'Transient':
|
||||||
cursor.execute("""
|
# Check current bounce count
|
||||||
UPDATE members
|
cursor.execute("SELECT bounce_count, bounce_status FROM members WHERE member_id = %s", (member_id,))
|
||||||
SET bounce_count = bounce_count + 1,
|
current = cursor.fetchone()
|
||||||
last_bounce_at = %s
|
|
||||||
WHERE member_id = %s
|
# If already hard bounced, don't change status
|
||||||
""", (timestamp, member_id))
|
if current and current['bounce_status'] != 'hard_bounce':
|
||||||
|
new_count = current['bounce_count'] + 1
|
||||||
|
# After 3 soft bounces, mark as soft_bounce status
|
||||||
|
new_status = 'soft_bounce' if new_count >= 3 else 'clean'
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE members
|
||||||
|
SET bounce_count = %s,
|
||||||
|
last_bounce_at = %s,
|
||||||
|
bounce_status = %s
|
||||||
|
WHERE member_id = %s
|
||||||
|
""", (new_count, timestamp, new_status, member_id))
|
||||||
|
else: # Undetermined
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE members
|
||||||
|
SET bounce_count = bounce_count + 1,
|
||||||
|
last_bounce_at = %s
|
||||||
|
WHERE member_id = %s
|
||||||
|
""", (timestamp, member_id))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Error processing bounce: {str(e)}")
|
||||||
|
print(f"Error type: {type(e).__name__}")
|
||||||
|
print(f"Bounce data: {bounce_data}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
raise
|
||||||
|
|
||||||
|
@app.post("/webhooks/sns", response_class=PlainTextResponse)
|
||||||
|
async def sns_webhook(request: Request):
|
||||||
|
"""Handle SNS notifications for bounces and complaints"""
|
||||||
|
try:
|
||||||
|
print(f"=== SNS Webhook Request ===")
|
||||||
|
print(f"Headers: {dict(request.headers)}")
|
||||||
|
print(f"Content-Type: {request.headers.get('content-type')}")
|
||||||
|
print(f"User-Agent: {request.headers.get('user-agent')}")
|
||||||
|
|
||||||
conn.commit()
|
# Verify SNS signature
|
||||||
|
message = await verify_sns_signature(request)
|
||||||
|
|
||||||
|
print(f"Message Type: {message.get('Type')}")
|
||||||
|
print(f"Message Keys: {list(message.keys())}")
|
||||||
|
|
||||||
|
message_type = message.get('Type')
|
||||||
|
|
||||||
|
# Handle subscription confirmation
|
||||||
|
if message_type == 'SubscriptionConfirmation':
|
||||||
|
subscribe_url = message.get('SubscribeURL')
|
||||||
|
print(f"Subscription confirmation received, URL: {subscribe_url}")
|
||||||
|
if subscribe_url:
|
||||||
|
# Confirm subscription
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(subscribe_url)
|
||||||
|
print(f"Subscription confirmation response: {response.status_code}")
|
||||||
|
return "Subscription confirmed"
|
||||||
|
|
||||||
|
# Handle notification
|
||||||
|
elif message_type == 'Notification':
|
||||||
|
# Parse the message
|
||||||
|
inner_message = message.get('Message', '{}')
|
||||||
|
print(f"Inner message (first 500 chars): {inner_message[:500]}")
|
||||||
|
notification = json.loads(inner_message)
|
||||||
|
|
||||||
|
# SES can send either 'notificationType' or 'eventType' depending on configuration
|
||||||
|
notification_type = notification.get('notificationType') or notification.get('eventType')
|
||||||
|
print(f"Notification type: {notification_type}")
|
||||||
|
|
||||||
|
if notification_type == 'Bounce':
|
||||||
|
bounce = notification.get('bounce', {})
|
||||||
|
print(f"\n✓ Processing Bounce")
|
||||||
|
print(f" Bounce Type: {bounce.get('bounceType')}")
|
||||||
|
print(f" Recipients: {[r.get('emailAddress') for r in bounce.get('bouncedRecipients', [])]}")
|
||||||
|
await process_bounce(bounce)
|
||||||
|
print(f" ✓ Bounce processed successfully")
|
||||||
|
return "Bounce processed"
|
||||||
|
|
||||||
|
elif notification_type == 'Complaint':
|
||||||
|
# We could also track complaints similarly to bounces
|
||||||
|
print(f"\n✓ Complaint received")
|
||||||
|
return "Complaint received"
|
||||||
|
|
||||||
|
print(f"=== End SNS Webhook Request ===")
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"SNS webhook error: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Provide stub functions when SNS webhooks are disabled
|
||||||
|
print("SNS webhooks disabled - bounce handling via email only")
|
||||||
|
|
||||||
|
# Bounce management endpoints (conditionally enabled)
|
||||||
|
if ENABLE_BOUNCE_HANDLING:
|
||||||
|
class BounceLog(BaseModel):
|
||||||
|
bounce_id: int
|
||||||
|
email: str
|
||||||
|
bounce_type: str
|
||||||
|
bounce_subtype: Optional[str] = None
|
||||||
|
diagnostic_code: Optional[str] = None
|
||||||
|
timestamp: datetime
|
||||||
|
feedback_id: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class MemberWithBounces(BaseModel):
|
||||||
|
member_id: int
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
active: bool
|
||||||
|
bounce_count: int
|
||||||
|
last_bounce_at: Optional[datetime] = None
|
||||||
|
bounce_status: str
|
||||||
|
|
||||||
|
@app.get("/members/{member_id}/bounces", response_model=List[BounceLog])
|
||||||
|
async def get_member_bounces(member_id: int, current_user: CurrentUser = require_read_access()):
|
||||||
|
"""Get bounce history for a member"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT bounce_id, email, bounce_type, bounce_subtype, diagnostic_code,
|
||||||
|
timestamp, feedback_id, created_at
|
||||||
|
FROM bounce_logs
|
||||||
|
WHERE member_id = %s
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
""", (member_id,))
|
||||||
|
bounces = cursor.fetchall()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
return bounces
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ Error processing bounce: {str(e)}")
|
|
||||||
print(f"Error type: {type(e).__name__}")
|
|
||||||
print(f"Bounce data: {bounce_data}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
raise
|
|
||||||
|
|
||||||
@app.post("/webhooks/sns", response_class=PlainTextResponse)
|
@app.patch("/members/{member_id}/bounce-status")
|
||||||
async def sns_webhook(request: Request):
|
async def reset_bounce_status(member_id: int, current_user: CurrentUser = require_write_access()):
|
||||||
"""Handle SNS notifications for bounces and complaints"""
|
"""Reset bounce status for a member (e.g., after email address is corrected)"""
|
||||||
try:
|
with get_db() as conn:
|
||||||
print(f"=== SNS Webhook Request ===")
|
cursor = conn.cursor()
|
||||||
print(f"Headers: {dict(request.headers)}")
|
cursor.execute("""
|
||||||
print(f"Content-Type: {request.headers.get('content-type')}")
|
UPDATE members
|
||||||
print(f"User-Agent: {request.headers.get('user-agent')}")
|
SET bounce_count = 0,
|
||||||
|
last_bounce_at = NULL,
|
||||||
# Verify SNS signature
|
bounce_status = 'clean'
|
||||||
message = await verify_sns_signature(request)
|
WHERE member_id = %s
|
||||||
|
""", (member_id,))
|
||||||
print(f"Message Type: {message.get('Type')}")
|
conn.commit()
|
||||||
print(f"Message Keys: {list(message.keys())}")
|
|
||||||
|
|
||||||
message_type = message.get('Type')
|
|
||||||
|
|
||||||
# Handle subscription confirmation
|
|
||||||
if message_type == 'SubscriptionConfirmation':
|
|
||||||
subscribe_url = message.get('SubscribeURL')
|
|
||||||
print(f"Subscription confirmation received, URL: {subscribe_url}")
|
|
||||||
if subscribe_url:
|
|
||||||
# Confirm subscription
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.get(subscribe_url)
|
|
||||||
print(f"Subscription confirmation response: {response.status_code}")
|
|
||||||
return "Subscription confirmed"
|
|
||||||
|
|
||||||
# Handle notification
|
|
||||||
elif message_type == 'Notification':
|
|
||||||
# Parse the message
|
|
||||||
inner_message = message.get('Message', '{}')
|
|
||||||
print(f"Inner message (first 500 chars): {inner_message[:500]}")
|
|
||||||
notification = json.loads(inner_message)
|
|
||||||
|
|
||||||
# SES can send either 'notificationType' or 'eventType' depending on configuration
|
if cursor.rowcount == 0:
|
||||||
notification_type = notification.get('notificationType') or notification.get('eventType')
|
raise HTTPException(status_code=404, detail="Member not found")
|
||||||
print(f"Notification type: {notification_type}")
|
|
||||||
|
|
||||||
if notification_type == 'Bounce':
|
cursor.close()
|
||||||
bounce = notification.get('bounce', {})
|
return {"message": "Bounce status reset successfully"}
|
||||||
print(f"\n✓ Processing Bounce")
|
|
||||||
print(f" Bounce Type: {bounce.get('bounceType')}")
|
|
||||||
print(f" Recipients: {[r.get('emailAddress') for r in bounce.get('bouncedRecipients', [])]}")
|
|
||||||
await process_bounce(bounce)
|
|
||||||
print(f" ✓ Bounce processed successfully")
|
|
||||||
return "Bounce processed"
|
|
||||||
|
|
||||||
elif notification_type == 'Complaint':
|
|
||||||
# We could also track complaints similarly to bounces
|
|
||||||
print(f"\n✓ Complaint received")
|
|
||||||
return "Complaint received"
|
|
||||||
|
|
||||||
print(f"=== End SNS Webhook Request ===")
|
|
||||||
return "OK"
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
print(f"SNS webhook error: {str(e)}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
# Bounce management endpoints
|
else:
|
||||||
class BounceLog(BaseModel):
|
# When bounce handling is disabled, provide stub endpoints that return appropriate responses
|
||||||
bounce_id: int
|
@app.get("/members/{member_id}/bounces")
|
||||||
email: str
|
async def get_member_bounces_disabled(member_id: int, current_user: CurrentUser = require_read_access()):
|
||||||
bounce_type: str
|
"""Bounce history disabled - returns empty list"""
|
||||||
bounce_subtype: Optional[str] = None
|
return []
|
||||||
diagnostic_code: Optional[str] = None
|
|
||||||
timestamp: datetime
|
|
||||||
feedback_id: Optional[str] = None
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
class MemberWithBounces(BaseModel):
|
@app.patch("/members/{member_id}/bounce-status")
|
||||||
member_id: int
|
async def reset_bounce_status_disabled(member_id: int, current_user: CurrentUser = require_write_access()):
|
||||||
name: str
|
"""Bounce status reset disabled"""
|
||||||
email: str
|
raise HTTPException(status_code=501, detail="Bounce handling is disabled")
|
||||||
active: bool
|
|
||||||
bounce_count: int
|
|
||||||
last_bounce_at: Optional[datetime] = None
|
|
||||||
bounce_status: str
|
|
||||||
|
|
||||||
@app.get("/members/{member_id}/bounces", response_model=List[BounceLog])
|
|
||||||
async def get_member_bounces(member_id: int, current_user: CurrentUser = require_read_access()):
|
|
||||||
"""Get bounce history for a member"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cursor = conn.cursor(dictionary=True)
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT bounce_id, email, bounce_type, bounce_subtype, diagnostic_code,
|
|
||||||
timestamp, feedback_id, created_at
|
|
||||||
FROM bounce_logs
|
|
||||||
WHERE member_id = %s
|
|
||||||
ORDER BY timestamp DESC
|
|
||||||
""", (member_id,))
|
|
||||||
bounces = cursor.fetchall()
|
|
||||||
cursor.close()
|
|
||||||
return bounces
|
|
||||||
|
|
||||||
@app.patch("/members/{member_id}/bounce-status")
|
|
||||||
async def reset_bounce_status(member_id: int, current_user: CurrentUser = require_write_access()):
|
|
||||||
"""Reset bounce status for a member (e.g., after email address is corrected)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("""
|
|
||||||
UPDATE members
|
|
||||||
SET bounce_count = 0,
|
|
||||||
last_bounce_at = NULL,
|
|
||||||
bounce_status = 'clean'
|
|
||||||
WHERE member_id = %s
|
|
||||||
""", (member_id,))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
if cursor.rowcount == 0:
|
|
||||||
raise HTTPException(status_code=404, detail="Member not found")
|
|
||||||
|
|
||||||
cursor.close()
|
|
||||||
return {"message": "Bounce status reset successfully"}
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ RUN apt-get update && \
|
|||||||
mailutils \
|
mailutils \
|
||||||
gettext-base \
|
gettext-base \
|
||||||
netcat-openbsd \
|
netcat-openbsd \
|
||||||
|
python3 \
|
||||||
|
python3-pymysql \
|
||||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy configs
|
# Copy configs
|
||||||
@@ -16,11 +18,16 @@ COPY main.cf.template /etc/postfix/main.cf.template
|
|||||||
COPY sasl_passwd.template /etc/postfix/sasl_passwd.template
|
COPY sasl_passwd.template /etc/postfix/sasl_passwd.template
|
||||||
COPY mysql_virtual_alias_maps.cf /etc/postfix/mysql_virtual_alias_maps.cf.template
|
COPY mysql_virtual_alias_maps.cf /etc/postfix/mysql_virtual_alias_maps.cf.template
|
||||||
COPY sender_access /etc/postfix/sender_access
|
COPY sender_access /etc/postfix/sender_access
|
||||||
|
COPY smtp_generic /etc/postfix/smtp_generic
|
||||||
|
COPY aliases /etc/aliases
|
||||||
|
COPY process-bounce.py /usr/local/bin/process-bounce.py
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh /usr/local/bin/process-bounce.py
|
||||||
|
|
||||||
# Generate Postfix maps for sender access
|
# Generate Postfix maps for sender access, sender canonical, and aliases
|
||||||
RUN postmap /etc/postfix/sender_access
|
RUN postmap /etc/postfix/sender_access && \
|
||||||
|
postmap /etc/postfix/smtp_generic && \
|
||||||
|
newaliases
|
||||||
|
|
||||||
# Expose SMTP
|
# Expose SMTP
|
||||||
EXPOSE 25
|
EXPOSE 25
|
||||||
|
|||||||
17
postfix/aliases
Normal file
17
postfix/aliases
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Postfix aliases for bounce handling
|
||||||
|
#
|
||||||
|
# This file defines how bounces are processed
|
||||||
|
# The bounces address pipes messages to our bounce processing script
|
||||||
|
|
||||||
|
# Bounce processing - pipe to Python script
|
||||||
|
bounces: "|/usr/bin/python3 /usr/local/bin/process-bounce.py"
|
||||||
|
|
||||||
|
# Standard aliases
|
||||||
|
postmaster: root
|
||||||
|
mailer-daemon: postmaster
|
||||||
|
abuse: postmaster
|
||||||
|
spam: postmaster
|
||||||
|
root: postmaster
|
||||||
|
|
||||||
|
# Default fallback
|
||||||
|
MAILER-DAEMON: bounces
|
||||||
@@ -32,5 +32,28 @@ chmod 644 /etc/postfix/sender_access /etc/postfix/sender_access.db
|
|||||||
# Set permissions on MySQL config
|
# Set permissions on MySQL config
|
||||||
chmod 644 /etc/postfix/mysql_virtual_alias_maps.cf
|
chmod 644 /etc/postfix/mysql_virtual_alias_maps.cf
|
||||||
|
|
||||||
|
# Configure bounce processing based on environment variable
|
||||||
|
if [ "${ENABLE_EMAIL_BOUNCE_PROCESSING:-false}" = "true" ]; then
|
||||||
|
echo "Email-based bounce processing enabled"
|
||||||
|
# Regenerate aliases database to enable bounce processing
|
||||||
|
newaliases
|
||||||
|
chmod 644 /etc/aliases /etc/aliases.db
|
||||||
|
|
||||||
|
# Generate smtp generic maps to ensure bounces come to our bounce address
|
||||||
|
postmap /etc/postfix/smtp_generic
|
||||||
|
chmod 644 /etc/postfix/smtp_generic /etc/postfix/smtp_generic.db
|
||||||
|
echo "Configured return path rewriting to bounces@lists.sasalliance.org"
|
||||||
|
else
|
||||||
|
echo "Email-based bounce processing disabled"
|
||||||
|
# Create minimal aliases without bounce processing
|
||||||
|
echo "postmaster: root" > /etc/aliases
|
||||||
|
echo "root: postmaster" >> /etc/aliases
|
||||||
|
newaliases
|
||||||
|
|
||||||
|
# Disable smtp generic maps
|
||||||
|
echo "# Email bounce processing disabled" > /etc/postfix/smtp_generic
|
||||||
|
postmap /etc/postfix/smtp_generic
|
||||||
|
fi
|
||||||
|
|
||||||
# Start Postfix in foreground
|
# Start Postfix in foreground
|
||||||
exec postfix start-fg
|
exec postfix start-fg
|
||||||
|
|||||||
@@ -33,3 +33,23 @@ smtpd_recipient_restrictions =
|
|||||||
# Other recommended settings
|
# Other recommended settings
|
||||||
alias_maps = hash:/etc/aliases
|
alias_maps = hash:/etc/aliases
|
||||||
alias_database = hash:/etc/aliases
|
alias_database = hash:/etc/aliases
|
||||||
|
|
||||||
|
# Bounce handling configuration for email-based processing
|
||||||
|
# Configure bounce notification recipients
|
||||||
|
bounce_notice_recipient = bounces@lists.sasalliance.org
|
||||||
|
2bounce_notice_recipient = bounces@lists.sasalliance.org
|
||||||
|
delay_notice_recipient =
|
||||||
|
error_notice_recipient = bounces@lists.sasalliance.org
|
||||||
|
|
||||||
|
# Bounce settings
|
||||||
|
bounce_size_limit = 50000
|
||||||
|
bounce_queue_lifetime = 5d
|
||||||
|
maximal_bounce_delay = 1d
|
||||||
|
|
||||||
|
# Return path configuration - CRITICAL for bounce handling
|
||||||
|
# This ensures that when we relay emails via mailing lists through SES,
|
||||||
|
# bounces come back to our bounce processing address
|
||||||
|
#
|
||||||
|
# Use smtp_generic_maps instead of sender_canonical_maps because we only want
|
||||||
|
# to rewrite the return path for outbound SMTP (via SES), not for local delivery
|
||||||
|
smtp_generic_maps = hash:/etc/postfix/smtp_generic
|
||||||
|
|||||||
348
postfix/process-bounce.py
Normal file
348
postfix/process-bounce.py
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Email-based bounce processing script for Postfix
|
||||||
|
Parses bounce emails and updates the database with bounce information
|
||||||
|
|
||||||
|
This script is called by Postfix when bounce emails are received.
|
||||||
|
It reads the email from stdin, parses it for bounce information,
|
||||||
|
and updates the database accordingly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import email
|
||||||
|
from email.message import Message
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Tuple, Optional, Dict
|
||||||
|
import pymysql
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
log_file = '/var/log/bounce-processor.log'
|
||||||
|
log_handlers = [logging.StreamHandler()]
|
||||||
|
|
||||||
|
# Add file handler if we can write to the log directory
|
||||||
|
try:
|
||||||
|
log_handlers.append(logging.FileHandler(log_file))
|
||||||
|
except (PermissionError, FileNotFoundError):
|
||||||
|
pass # Just use stdout if we can't write to log file
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=log_handlers
|
||||||
|
)
|
||||||
|
logger = logging.getLogger('bounce-processor')
|
||||||
|
|
||||||
|
# Database configuration from environment
|
||||||
|
MYSQL_HOST = os.getenv('MYSQL_HOST', 'mysql')
|
||||||
|
MYSQL_PORT = int(os.getenv('MYSQL_PORT', 3306))
|
||||||
|
MYSQL_DATABASE = os.getenv('MYSQL_DATABASE', 'maillist')
|
||||||
|
MYSQL_USER = os.getenv('MYSQL_USER', 'maillist')
|
||||||
|
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD', '')
|
||||||
|
|
||||||
|
class BounceProcessor:
|
||||||
|
"""Processes bounce emails and updates the database"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.bounce_patterns = self._compile_bounce_patterns()
|
||||||
|
|
||||||
|
def _compile_bounce_patterns(self) -> List[re.Pattern]:
|
||||||
|
"""Compile regex patterns for detecting bounce information"""
|
||||||
|
patterns = [
|
||||||
|
# Standard bounce formats
|
||||||
|
re.compile(r'550.*?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', re.IGNORECASE),
|
||||||
|
re.compile(r'554.*?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', re.IGNORECASE),
|
||||||
|
re.compile(r'553.*?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', re.IGNORECASE),
|
||||||
|
re.compile(r'552.*?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', re.IGNORECASE),
|
||||||
|
re.compile(r'551.*?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', re.IGNORECASE),
|
||||||
|
|
||||||
|
# Delivery Status Notification (DSN) format
|
||||||
|
re.compile(r'Final-Recipient:.*?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', re.IGNORECASE),
|
||||||
|
re.compile(r'Original-Recipient:.*?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', re.IGNORECASE),
|
||||||
|
|
||||||
|
# Common bounce message patterns
|
||||||
|
re.compile(r'user.*?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}).*?unknown', re.IGNORECASE),
|
||||||
|
re.compile(r'([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}).*?does not exist', re.IGNORECASE),
|
||||||
|
re.compile(r'([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}).*?not found', re.IGNORECASE),
|
||||||
|
re.compile(r'([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}).*?mailbox.*?unavailable', re.IGNORECASE),
|
||||||
|
re.compile(r'([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}).*?recipient.*?rejected', re.IGNORECASE),
|
||||||
|
|
||||||
|
# Generic email extraction (fallback)
|
||||||
|
re.compile(r'([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', re.IGNORECASE),
|
||||||
|
]
|
||||||
|
return patterns
|
||||||
|
|
||||||
|
def parse_bounce_email(self, email_content: str) -> List[Dict]:
|
||||||
|
"""Parse bounce email and extract bounce information"""
|
||||||
|
try:
|
||||||
|
# Parse the email
|
||||||
|
msg = email.message_from_string(email_content)
|
||||||
|
bounces = []
|
||||||
|
|
||||||
|
# Get the email body
|
||||||
|
body = self._get_email_body(msg)
|
||||||
|
if not body:
|
||||||
|
logger.warning("No email body found")
|
||||||
|
return bounces
|
||||||
|
|
||||||
|
logger.info(f"Processing email body (first 500 chars): {body[:500]}")
|
||||||
|
|
||||||
|
# Extract bounced addresses
|
||||||
|
bounced_addresses = self._extract_bounced_addresses(body)
|
||||||
|
|
||||||
|
# Determine bounce type and create bounce records
|
||||||
|
for address in bounced_addresses:
|
||||||
|
bounce_info = self._analyze_bounce(body, address)
|
||||||
|
if bounce_info:
|
||||||
|
bounces.append(bounce_info)
|
||||||
|
|
||||||
|
return bounces
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error parsing bounce email: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _get_email_body(self, msg: Message) -> Optional[str]:
|
||||||
|
"""Extract the email body from the message"""
|
||||||
|
body = ""
|
||||||
|
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
content_type = part.get_content_type()
|
||||||
|
if content_type in ['text/plain', 'text/html']:
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
body += payload.decode('utf-8', errors='ignore') + "\n"
|
||||||
|
else:
|
||||||
|
payload = msg.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
body = payload.decode('utf-8', errors='ignore')
|
||||||
|
|
||||||
|
return body.strip() if body else None
|
||||||
|
|
||||||
|
def _extract_bounced_addresses(self, body: str) -> List[str]:
|
||||||
|
"""Extract bounced email addresses from the bounce message"""
|
||||||
|
addresses = []
|
||||||
|
|
||||||
|
for pattern in self.bounce_patterns:
|
||||||
|
matches = pattern.findall(body)
|
||||||
|
for match in matches:
|
||||||
|
email_addr = match.strip().lower()
|
||||||
|
if self._is_valid_email(email_addr) and email_addr not in addresses:
|
||||||
|
# Skip our own addresses
|
||||||
|
if not email_addr.endswith('@lists.sasalliance.org'):
|
||||||
|
addresses.append(email_addr)
|
||||||
|
|
||||||
|
logger.info(f"Extracted addresses: {addresses}")
|
||||||
|
return addresses
|
||||||
|
|
||||||
|
def _is_valid_email(self, email_addr: str) -> bool:
|
||||||
|
"""Validate email address format"""
|
||||||
|
email_pattern = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
|
||||||
|
return bool(email_pattern.match(email_addr))
|
||||||
|
|
||||||
|
def _analyze_bounce(self, body: str, email_addr: str) -> Optional[Dict]:
|
||||||
|
"""Analyze bounce message to determine bounce type and details"""
|
||||||
|
bounce_info = {
|
||||||
|
'email': email_addr,
|
||||||
|
'bounce_type': 'Undetermined',
|
||||||
|
'bounce_subtype': '',
|
||||||
|
'diagnostic_code': '',
|
||||||
|
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
}
|
||||||
|
|
||||||
|
# Analyze bounce type based on SMTP codes and message content
|
||||||
|
if re.search(r'5[0-9]{2}', body): # 5xx codes are permanent failures
|
||||||
|
bounce_info['bounce_type'] = 'Permanent'
|
||||||
|
|
||||||
|
if re.search(r'550|551|553', body):
|
||||||
|
bounce_info['bounce_subtype'] = 'General'
|
||||||
|
elif re.search(r'552', body):
|
||||||
|
bounce_info['bounce_subtype'] = 'MailboxFull'
|
||||||
|
elif re.search(r'554', body):
|
||||||
|
bounce_info['bounce_subtype'] = 'MessageTooLarge'
|
||||||
|
|
||||||
|
elif re.search(r'4[0-9]{2}', body): # 4xx codes are temporary failures
|
||||||
|
bounce_info['bounce_type'] = 'Transient'
|
||||||
|
|
||||||
|
if re.search(r'450|451|452', body):
|
||||||
|
bounce_info['bounce_subtype'] = 'General'
|
||||||
|
elif re.search(r'452', body):
|
||||||
|
bounce_info['bounce_subtype'] = 'MailboxFull'
|
||||||
|
|
||||||
|
# Extract diagnostic code
|
||||||
|
smtp_code_match = re.search(r'([45][0-9]{2}.*?)[\r\n]', body)
|
||||||
|
if smtp_code_match:
|
||||||
|
bounce_info['diagnostic_code'] = smtp_code_match.group(1).strip()[:500] # Limit length
|
||||||
|
|
||||||
|
return bounce_info
|
||||||
|
|
||||||
|
def update_database(self, bounces: List[Dict]) -> None:
|
||||||
|
"""Update the database with bounce information"""
|
||||||
|
if not bounces:
|
||||||
|
logger.info("No bounces to process")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
connection = pymysql.connect(
|
||||||
|
host=MYSQL_HOST,
|
||||||
|
port=MYSQL_PORT,
|
||||||
|
database=MYSQL_DATABASE,
|
||||||
|
user=MYSQL_USER,
|
||||||
|
password=MYSQL_PASSWORD,
|
||||||
|
cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor = connection.cursor()
|
||||||
|
|
||||||
|
for bounce in bounces:
|
||||||
|
try:
|
||||||
|
email_addr = bounce['email']
|
||||||
|
|
||||||
|
# Find member by email
|
||||||
|
cursor.execute("SELECT member_id FROM members WHERE email = %s", (email_addr,))
|
||||||
|
member = cursor.fetchone()
|
||||||
|
member_id = member['member_id'] if member else None
|
||||||
|
|
||||||
|
# Log the bounce
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO bounce_logs
|
||||||
|
(member_id, email, bounce_type, bounce_subtype, diagnostic_code, timestamp, created_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, NOW())
|
||||||
|
""", (member_id, email_addr, bounce['bounce_type'], bounce['bounce_subtype'],
|
||||||
|
bounce['diagnostic_code'], bounce['timestamp']))
|
||||||
|
|
||||||
|
logger.info(f"Logged bounce for {email_addr}: {bounce['bounce_type']}")
|
||||||
|
|
||||||
|
# Update member bounce status if member exists
|
||||||
|
if member_id:
|
||||||
|
if bounce['bounce_type'] == 'Permanent':
|
||||||
|
# Hard bounce - deactivate member
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE members
|
||||||
|
SET bounce_count = bounce_count + 1,
|
||||||
|
last_bounce_at = %s,
|
||||||
|
bounce_status = 'hard_bounce',
|
||||||
|
active = 0
|
||||||
|
WHERE member_id = %s
|
||||||
|
""", (bounce['timestamp'], member_id))
|
||||||
|
logger.info(f"Deactivated member {email_addr} due to hard bounce")
|
||||||
|
|
||||||
|
elif bounce['bounce_type'] == 'Transient':
|
||||||
|
# Soft bounce - increment counter and check threshold
|
||||||
|
cursor.execute("SELECT bounce_count, bounce_status FROM members WHERE member_id = %s", (member_id,))
|
||||||
|
current = cursor.fetchone()
|
||||||
|
|
||||||
|
if current and current['bounce_status'] != 'hard_bounce':
|
||||||
|
new_count = current['bounce_count'] + 1
|
||||||
|
new_status = 'soft_bounce' if new_count >= 3 else 'clean'
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE members
|
||||||
|
SET bounce_count = %s,
|
||||||
|
last_bounce_at = %s,
|
||||||
|
bounce_status = %s
|
||||||
|
WHERE member_id = %s
|
||||||
|
""", (new_count, bounce['timestamp'], new_status, member_id))
|
||||||
|
|
||||||
|
logger.info(f"Updated member {email_addr} bounce count to {new_count}, status: {new_status}")
|
||||||
|
else:
|
||||||
|
# Undetermined - just increment counter
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE members
|
||||||
|
SET bounce_count = bounce_count + 1,
|
||||||
|
last_bounce_at = %s
|
||||||
|
WHERE member_id = %s
|
||||||
|
""", (bounce['timestamp'], member_id))
|
||||||
|
|
||||||
|
logger.info(f"Updated member {email_addr} bounce count (undetermined)")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Database error processing bounce for {bounce['email']}: {str(e)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
cursor.close()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
logger.info(f"Successfully processed {len(bounces)} bounces")
|
||||||
|
|
||||||
|
except pymysql.Error as e:
|
||||||
|
logger.error(f"Database connection error: {str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error updating database: {str(e)}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main function - reads email from stdin and processes bounces"""
|
||||||
|
try:
|
||||||
|
logger.info("Starting bounce processing")
|
||||||
|
|
||||||
|
# Check if we're in test mode
|
||||||
|
test_mode = len(sys.argv) > 1 and sys.argv[1] == '--test'
|
||||||
|
|
||||||
|
if test_mode:
|
||||||
|
# Test mode - use sample bounce email
|
||||||
|
email_content = """From: Mail Delivery Subsystem <MAILER-DAEMON@example.com>
|
||||||
|
To: bounces@lists.sasalliance.org
|
||||||
|
Subject: Delivery Status Notification (Failure)
|
||||||
|
Content-Type: multipart/report; report-type=delivery-status; boundary="boundary123"
|
||||||
|
|
||||||
|
--boundary123
|
||||||
|
Content-Type: text/plain
|
||||||
|
|
||||||
|
This is a test bounce message.
|
||||||
|
|
||||||
|
The following address(es) failed:
|
||||||
|
testuser@example.com
|
||||||
|
SMTP error from remote mail server after RCPT TO:<testuser@example.com>:
|
||||||
|
550 5.1.1 User unknown
|
||||||
|
|
||||||
|
--boundary123
|
||||||
|
Content-Type: message/delivery-status
|
||||||
|
|
||||||
|
Reporting-MTA: dns; mail.example.com
|
||||||
|
Received-From-MTA: dns; localhost
|
||||||
|
|
||||||
|
Final-Recipient: rfc822; testuser@example.com
|
||||||
|
Action: failed
|
||||||
|
Status: 5.1.1
|
||||||
|
Diagnostic-Code: smtp; 550 5.1.1 User unknown
|
||||||
|
|
||||||
|
--boundary123--
|
||||||
|
"""
|
||||||
|
logger.info("Running in test mode with sample bounce email")
|
||||||
|
else:
|
||||||
|
# Read email from stdin
|
||||||
|
email_content = sys.stdin.read()
|
||||||
|
|
||||||
|
if not email_content.strip():
|
||||||
|
logger.warning("No email content received")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Received email content ({len(email_content)} bytes)")
|
||||||
|
|
||||||
|
# Process the bounce
|
||||||
|
processor = BounceProcessor()
|
||||||
|
bounces = processor.parse_bounce_email(email_content)
|
||||||
|
|
||||||
|
if test_mode:
|
||||||
|
logger.info(f"Test mode - would process {len(bounces)} bounce(s):")
|
||||||
|
for bounce in bounces:
|
||||||
|
logger.info(f" {bounce}")
|
||||||
|
else:
|
||||||
|
if bounces:
|
||||||
|
processor.update_database(bounces)
|
||||||
|
logger.info(f"Processed {len(bounces)} bounce(s)")
|
||||||
|
else:
|
||||||
|
logger.info("No bounces detected in email")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in main: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
23
postfix/smtp_generic
Normal file
23
postfix/smtp_generic
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Postfix SMTP Generic Maps
|
||||||
|
# This file controls the return path (envelope sender) for outbound SMTP emails
|
||||||
|
# Only applies to emails being relayed through external SMTP (SES in our case)
|
||||||
|
#
|
||||||
|
# Format: original_sender rewritten_sender
|
||||||
|
#
|
||||||
|
# For mailing list emails, we want bounces to go to our bounce processing address
|
||||||
|
# This is critical for email-based bounce handling to work properly
|
||||||
|
|
||||||
|
# Rewrite envelope sender for all mailing list addresses to bounce address
|
||||||
|
# When emails are forwarded through mailing lists via SES, bounces come back to us
|
||||||
|
community@lists.sasalliance.org bounces@lists.sasalliance.org
|
||||||
|
board@lists.sasalliance.org bounces@lists.sasalliance.org
|
||||||
|
members@lists.sasalliance.org bounces@lists.sasalliance.org
|
||||||
|
announcements@lists.sasalliance.org bounces@lists.sasalliance.org
|
||||||
|
|
||||||
|
# Generic pattern - any @lists.sasalliance.org sender gets rewritten to bounce address
|
||||||
|
# This catches any new lists automatically
|
||||||
|
@lists.sasalliance.org bounces@lists.sasalliance.org
|
||||||
|
|
||||||
|
# Also rewrite any envelope sender that's sending through our system
|
||||||
|
# This ensures ALL outgoing mail via SES has our bounce address as return path
|
||||||
|
@sasalliance.org bounces@lists.sasalliance.org
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Community mailing list - general announcements
|
|
||||||
community@lists.sasalliance.org james@pattinson.org, james.pattinson@sasalliance.org
|
|
||||||
|
|
||||||
# Board members mailing list
|
|
||||||
board@lists.sasalliance.org james.pattinson@sasalliance.org
|
|
||||||
|
|
||||||
# All members mailing list
|
|
||||||
members@lists.sasalliance.org james@pattinson.org, james.pattinson@sasalliance.org
|
|
||||||
|
|
||||||
# Announcements mailing list
|
|
||||||
announcements@lists.sasalliance.org james@pattinson.org, james.pattinson@sasalliance.org
|
|
||||||
@@ -54,22 +54,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Header (shown after login) -->
|
<!-- Menu Bar Header (shown after login) -->
|
||||||
<header class="header" id="mainHeader" style="display: none;">
|
<header class="menu-bar" id="mainHeader" style="display: none;">
|
||||||
<div class="container">
|
<div class="menu-bar-content">
|
||||||
<div class="header-content">
|
<div class="app-title">
|
||||||
<h1 class="logo">
|
<i class="fas fa-envelope"></i>
|
||||||
<i class="fas fa-envelope"></i>
|
<span>Mailing List Manager</span>
|
||||||
Mailing List Manager
|
</div>
|
||||||
</h1>
|
<div class="menu-spacer"></div>
|
||||||
<div class="auth-section">
|
<div class="user-dropdown" id="userDropdown">
|
||||||
<div class="user-info" id="userInfo">
|
<button class="user-dropdown-trigger" id="userDropdownTrigger">
|
||||||
<div class="user-details">
|
<div class="user-avatar">
|
||||||
<span class="user-name" id="currentUsername">User</span>
|
<i class="fas fa-user"></i>
|
||||||
<span class="user-role" id="currentUserRole">role</span>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-secondary" id="logoutBtn">Logout</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="user-details">
|
||||||
|
<span class="user-name" id="currentUsername">User</span>
|
||||||
|
<span class="user-role" id="currentUserRole">role</span>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-chevron-down dropdown-arrow"></i>
|
||||||
|
</button>
|
||||||
|
<div class="user-dropdown-menu" id="userDropdownMenu">
|
||||||
|
<div class="dropdown-header">
|
||||||
|
<div class="dropdown-user-info">
|
||||||
|
<div class="dropdown-avatar">
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-details">
|
||||||
|
<div class="dropdown-name" id="dropdownUsername">User</div>
|
||||||
|
<div class="dropdown-role" id="dropdownUserRole">role</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<button class="dropdown-item" id="userManagementBtn" style="display: none;">
|
||||||
|
<i class="fas fa-user-shield"></i>
|
||||||
|
<span>User Management</span>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-divider" id="userManagementDivider" style="display: none;"></div>
|
||||||
|
<button class="dropdown-item" id="logoutBtn">
|
||||||
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
|
<span>Sign Out</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,10 +113,6 @@
|
|||||||
<i class="fas fa-users"></i>
|
<i class="fas fa-users"></i>
|
||||||
Members
|
Members
|
||||||
</button>
|
</button>
|
||||||
<button class="tab-btn" data-tab="users" id="usersTab" style="display: none;">
|
|
||||||
<i class="fas fa-user-shield"></i>
|
|
||||||
Users
|
|
||||||
</button>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Notification Area -->
|
<!-- Notification Area -->
|
||||||
@@ -134,13 +155,7 @@
|
|||||||
<!-- Members Tab -->
|
<!-- Members Tab -->
|
||||||
<div class="tab-content" id="members-tab">
|
<div class="tab-content" id="members-tab">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<div class="header-content">
|
<h2>Members</h2>
|
||||||
<h2>Members</h2>
|
|
||||||
<div class="header-help">
|
|
||||||
<i class="fas fa-info-circle"></i>
|
|
||||||
<span>Click the "Lists" button next to any member to manage their subscriptions</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<button class="btn btn-primary" id="addMemberBtn">
|
<button class="btn btn-primary" id="addMemberBtn">
|
||||||
<i class="fas fa-plus"></i>
|
<i class="fas fa-plus"></i>
|
||||||
@@ -153,6 +168,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Member Search -->
|
||||||
|
<div class="search-section">
|
||||||
|
<div class="search-container">
|
||||||
|
<div class="search-input-wrapper">
|
||||||
|
<i class="fas fa-search search-icon"></i>
|
||||||
|
<input type="text"
|
||||||
|
id="memberSearchInput"
|
||||||
|
class="search-input"
|
||||||
|
placeholder="Search members by name or email..."
|
||||||
|
autocomplete="off">
|
||||||
|
<button class="search-clear" id="memberSearchClear" style="display: none;">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="search-results-info" id="memberSearchInfo" style="display: none;">
|
||||||
|
<span id="memberSearchCount">0</span> members found
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="data-table">
|
<div class="data-table">
|
||||||
<table class="table" id="membersTable">
|
<table class="table" id="membersTable">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -285,7 +285,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
.header {
|
/* Menu Bar Header */
|
||||||
|
.menu-bar {
|
||||||
background: var(--white);
|
background: var(--white);
|
||||||
border-bottom: 1px solid var(--gray-200);
|
border-bottom: 1px solid var(--gray-200);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
@@ -294,24 +295,29 @@ body {
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content {
|
.menu-bar-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: var(--space-4) 0;
|
justify-content: space-between;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
min-height: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.app-title {
|
||||||
font-size: var(--font-size-xl);
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--primary-color);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo i {
|
.app-title i {
|
||||||
font-size: var(--font-size-2xl);
|
font-size: var(--font-size-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-spacer {
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Authentication */
|
/* Authentication */
|
||||||
@@ -342,31 +348,185 @@ body {
|
|||||||
box-shadow: 0 0 0 3px rgb(37 99 235 / 0.1);
|
box-shadow: 0 0 0 3px rgb(37 99 235 / 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info {
|
/* User Dropdown - Menu Bar Style */
|
||||||
|
.user-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown-trigger {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
height: 40px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown-trigger:hover {
|
||||||
|
background: var(--gray-50);
|
||||||
|
border-color: var(--gray-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown-trigger.active {
|
||||||
|
background: var(--gray-50);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 3px rgb(37 99 235 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: var(--white);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-details {
|
.user-details {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
align-items: flex-start;
|
||||||
gap: var(--space-1);
|
gap: 1px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-name {
|
.user-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--gray-900);
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
text-transform: capitalize;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-arrow {
|
||||||
|
color: var(--gray-400);
|
||||||
|
font-size: 10px;
|
||||||
|
transition: var(--transition);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown-trigger.active .dropdown-arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
min-width: 220px;
|
||||||
|
background: var(--white);
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 1000;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown.active .user-dropdown-menu {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-header {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--gray-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: var(--white);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-name {
|
||||||
|
font-weight: 600;
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--gray-900);
|
color: var(--gray-900);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-role {
|
.dropdown-role {
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
color: var(--gray-500);
|
color: var(--gray-500);
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--gray-100);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--gray-700);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background: var(--gray-50);
|
||||||
|
color: var(--gray-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item i {
|
||||||
|
width: 16px;
|
||||||
|
color: var(--gray-400);
|
||||||
|
}
|
||||||
|
|
||||||
.status-indicator {
|
.status-indicator {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -523,6 +683,113 @@ body {
|
|||||||
color: var(--info-color);
|
color: var(--info-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Search Section */
|
||||||
|
.search-section {
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3) var(--space-4) var(--space-3) var(--space-10);
|
||||||
|
border: 1px solid var(--gray-300);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
transition: var(--transition);
|
||||||
|
background: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 3px rgb(37 99 235 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:not(:placeholder-shown) {
|
||||||
|
padding-right: var(--space-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: var(--space-3);
|
||||||
|
color: var(--gray-400);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: var(--space-3);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--gray-400);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-1);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear:hover {
|
||||||
|
color: var(--gray-600);
|
||||||
|
background: var(--gray-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-info {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--gray-600);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-info #memberSearchCount {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No results message */
|
||||||
|
.no-results {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-8) var(--space-4);
|
||||||
|
color: var(--gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results i {
|
||||||
|
font-size: var(--font-size-3xl);
|
||||||
|
color: var(--gray-300);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results h3 {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
color: var(--gray-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results p {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results .btn {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
/* Notifications */
|
/* Notifications */
|
||||||
.notification {
|
.notification {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -945,11 +1212,35 @@ body {
|
|||||||
padding: 0 var(--space-3);
|
padding: 0 var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content {
|
.menu-bar-content {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-4);
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
order: 1;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown {
|
||||||
|
order: 2;
|
||||||
|
align-self: flex-end;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown-menu {
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
width: auto;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-controls {
|
.auth-controls {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -969,6 +1260,18 @@ body {
|
|||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-input-wrapper {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
font-size: 16px; /* Prevent zoom on iOS */
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
.table-responsive {
|
.table-responsive {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ class APIClient {
|
|||||||
return this.request('/health');
|
return this.request('/health');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getConfig() {
|
||||||
|
return this.request('/config');
|
||||||
|
}
|
||||||
|
|
||||||
async testAuth() {
|
async testAuth() {
|
||||||
return this.request('/');
|
return this.request('/');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,33 @@ class MailingListApp {
|
|||||||
this.logout();
|
this.logout();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// User dropdown functionality
|
||||||
|
const userDropdownTrigger = document.getElementById('userDropdownTrigger');
|
||||||
|
const userDropdown = document.getElementById('userDropdown');
|
||||||
|
|
||||||
|
userDropdownTrigger.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
userDropdown.classList.toggle('active');
|
||||||
|
userDropdownTrigger.classList.toggle('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!userDropdown.contains(e.target)) {
|
||||||
|
userDropdown.classList.remove('active');
|
||||||
|
userDropdownTrigger.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close dropdown on escape key
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
userDropdown.classList.remove('active');
|
||||||
|
userDropdownTrigger.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Bulk import button
|
// Bulk import button
|
||||||
document.getElementById('showBulkImportBtn').addEventListener('click', () => {
|
document.getElementById('showBulkImportBtn').addEventListener('click', () => {
|
||||||
uiManager.showBulkImportModal();
|
uiManager.showBulkImportModal();
|
||||||
@@ -61,6 +88,47 @@ class MailingListApp {
|
|||||||
document.getElementById('addUserBtn').addEventListener('click', () => {
|
document.getElementById('addUserBtn').addEventListener('click', () => {
|
||||||
uiManager.showUserModal();
|
uiManager.showUserModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// User management dropdown item
|
||||||
|
document.getElementById('userManagementBtn').addEventListener('click', () => {
|
||||||
|
// Close the dropdown
|
||||||
|
userDropdown.classList.remove('active');
|
||||||
|
userDropdownTrigger.classList.remove('active');
|
||||||
|
// Switch to users tab
|
||||||
|
this.switchToUsersTab();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Member search functionality
|
||||||
|
const memberSearchInput = document.getElementById('memberSearchInput');
|
||||||
|
const memberSearchClear = document.getElementById('memberSearchClear');
|
||||||
|
|
||||||
|
memberSearchInput.addEventListener('input', (e) => {
|
||||||
|
this.filterMembers(e.target.value);
|
||||||
|
|
||||||
|
// Show/hide clear button
|
||||||
|
if (e.target.value.trim()) {
|
||||||
|
memberSearchClear.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
memberSearchClear.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
memberSearchClear.addEventListener('click', () => {
|
||||||
|
memberSearchInput.value = '';
|
||||||
|
memberSearchClear.style.display = 'none';
|
||||||
|
this.filterMembers('');
|
||||||
|
memberSearchInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear search when switching tabs
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (btn.dataset.tab !== 'members') {
|
||||||
|
memberSearchInput.value = '';
|
||||||
|
memberSearchClear.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -214,10 +282,13 @@ class MailingListApp {
|
|||||||
if (this.currentUser) {
|
if (this.currentUser) {
|
||||||
document.getElementById('currentUsername').textContent = this.currentUser.username;
|
document.getElementById('currentUsername').textContent = this.currentUser.username;
|
||||||
document.getElementById('currentUserRole').textContent = this.currentUser.role;
|
document.getElementById('currentUserRole').textContent = this.currentUser.role;
|
||||||
|
document.getElementById('dropdownUsername').textContent = this.currentUser.username;
|
||||||
|
document.getElementById('dropdownUserRole').textContent = this.currentUser.role;
|
||||||
|
|
||||||
// Show/hide admin-only features
|
// Show/hide admin-only features
|
||||||
const isAdmin = this.currentUser.role === 'administrator';
|
const isAdmin = this.currentUser.role === 'administrator';
|
||||||
document.getElementById('usersTab').style.display = isAdmin ? 'block' : 'none';
|
document.getElementById('userManagementBtn').style.display = isAdmin ? 'block' : 'none';
|
||||||
|
document.getElementById('userManagementDivider').style.display = isAdmin ? 'block' : 'none';
|
||||||
|
|
||||||
// Show/hide write access features
|
// Show/hide write access features
|
||||||
const hasWriteAccess = this.currentUser.role === 'administrator' || this.currentUser.role === 'operator';
|
const hasWriteAccess = this.currentUser.role === 'administrator' || this.currentUser.role === 'operator';
|
||||||
@@ -250,6 +321,28 @@ class MailingListApp {
|
|||||||
document.getElementById('showBulkImportBtn').setAttribute('data-requires-write', '');
|
document.getElementById('showBulkImportBtn').setAttribute('data-requires-write', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to users tab (triggered from user dropdown)
|
||||||
|
*/
|
||||||
|
switchToUsersTab() {
|
||||||
|
// Switch to users tab programmatically
|
||||||
|
const tabButtons = document.querySelectorAll('.tab-btn');
|
||||||
|
const tabContents = document.querySelectorAll('.tab-content');
|
||||||
|
|
||||||
|
// Remove active class from all tabs and contents
|
||||||
|
tabButtons.forEach(btn => btn.classList.remove('active'));
|
||||||
|
tabContents.forEach(content => content.classList.remove('active'));
|
||||||
|
|
||||||
|
// Show users tab content
|
||||||
|
const usersTab = document.getElementById('users-tab');
|
||||||
|
if (usersTab) {
|
||||||
|
usersTab.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load users data if needed
|
||||||
|
this.loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load all data from API
|
* Load all data from API
|
||||||
*/
|
*/
|
||||||
@@ -259,12 +352,15 @@ class MailingListApp {
|
|||||||
try {
|
try {
|
||||||
uiManager.setLoading(true);
|
uiManager.setLoading(true);
|
||||||
|
|
||||||
// Load lists and members in parallel
|
// Load configuration, lists and members in parallel
|
||||||
const [lists, members] = await Promise.all([
|
const [config, lists, members] = await Promise.all([
|
||||||
|
apiClient.getConfig(),
|
||||||
apiClient.getLists(),
|
apiClient.getLists(),
|
||||||
apiClient.getMembers()
|
apiClient.getMembers()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
this.config = config;
|
||||||
|
|
||||||
this.lists = lists;
|
this.lists = lists;
|
||||||
this.members = members;
|
this.members = members;
|
||||||
|
|
||||||
@@ -438,7 +534,7 @@ class MailingListApp {
|
|||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<td>
|
<td>
|
||||||
<div class="font-medium">${uiManager.escapeHtml(member.name)}</div>
|
<div class="font-medium">${uiManager.escapeHtml(member.name)}</div>
|
||||||
${member.bounce_count > 0 ? `<div class="text-xs text-muted" style="margin-top: 2px;"></div>` : ''}
|
${this.config?.bounce_handling_enabled && member.bounce_count > 0 ? `<div class="text-xs text-muted" style="margin-top: 2px;"></div>` : ''}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="mailto:${member.email}" style="color: var(--primary-color)">
|
<a href="mailto:${member.email}" style="color: var(--primary-color)">
|
||||||
@@ -459,8 +555,8 @@ class MailingListApp {
|
|||||||
</td>
|
</td>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Add bounce badge if member has bounces
|
// Add bounce badge if member has bounces (only if bounce handling is enabled)
|
||||||
if (member.bounce_count > 0) {
|
if (this.config?.bounce_handling_enabled && member.bounce_count > 0) {
|
||||||
const bounceInfoDiv = row.cells[0].querySelector('.text-xs');
|
const bounceInfoDiv = row.cells[0].querySelector('.text-xs');
|
||||||
const bounceBadge = uiManager.createBounceStatusBadge(member.bounce_status, member.bounce_count);
|
const bounceBadge = uiManager.createBounceStatusBadge(member.bounce_status, member.bounce_count);
|
||||||
if (bounceBadge) {
|
if (bounceBadge) {
|
||||||
@@ -484,8 +580,8 @@ class MailingListApp {
|
|||||||
uiManager.showMemberSubscriptionsModal(member);
|
uiManager.showMemberSubscriptionsModal(member);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create Bounces button (show if member has any bounces or for admins/operators)
|
// Create Bounces button (show if bounce handling is enabled and member has bounces or for admins/operators)
|
||||||
if (member.bounce_count > 0 || hasWriteAccess) {
|
if (this.config?.bounce_handling_enabled && (member.bounce_count > 0 || hasWriteAccess)) {
|
||||||
const bouncesBtn = document.createElement('button');
|
const bouncesBtn = document.createElement('button');
|
||||||
bouncesBtn.className = `btn btn-sm ${member.bounce_count > 0 ? 'btn-warning' : 'btn-secondary'}`;
|
bouncesBtn.className = `btn btn-sm ${member.bounce_count > 0 ? 'btn-warning' : 'btn-secondary'}`;
|
||||||
bouncesBtn.innerHTML = `<i class="fas fa-exclamation-triangle"></i> Bounces${member.bounce_count > 0 ? ` (${member.bounce_count})` : ''}`;
|
bouncesBtn.innerHTML = `<i class="fas fa-exclamation-triangle"></i> Bounces${member.bounce_count > 0 ? ` (${member.bounce_count})` : ''}`;
|
||||||
@@ -667,6 +763,149 @@ class MailingListApp {
|
|||||||
uiManager.setLoading(false);
|
uiManager.setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter members by search term (name or email)
|
||||||
|
*/
|
||||||
|
filterMembers(searchTerm) {
|
||||||
|
const searchInfo = document.getElementById('memberSearchInfo');
|
||||||
|
const searchCount = document.getElementById('memberSearchCount');
|
||||||
|
const tbody = document.getElementById('membersTableBody');
|
||||||
|
|
||||||
|
if (!this.members || !Array.isArray(this.members)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no search term, show all members
|
||||||
|
if (!searchTerm || searchTerm.trim() === '') {
|
||||||
|
this.renderMembers();
|
||||||
|
searchInfo.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Filter members by name or email
|
||||||
|
const filteredMembers = this.members.filter(member => {
|
||||||
|
const name = (member.name || '').toLowerCase();
|
||||||
|
const email = (member.email || '').toLowerCase();
|
||||||
|
return name.includes(normalizedSearch) || email.includes(normalizedSearch);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update search results info
|
||||||
|
searchCount.textContent = filteredMembers.length;
|
||||||
|
searchInfo.style.display = 'block';
|
||||||
|
|
||||||
|
// Render filtered results
|
||||||
|
this.renderFilteredMembers(filteredMembers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render filtered members (similar to renderMembers but with filtered data)
|
||||||
|
*/
|
||||||
|
renderFilteredMembers(filteredMembers) {
|
||||||
|
const tbody = document.getElementById('membersTableBody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
if (filteredMembers.length === 0) {
|
||||||
|
// Show no results message
|
||||||
|
const row = tbody.insertRow();
|
||||||
|
const cell = row.insertCell();
|
||||||
|
cell.colSpan = 5;
|
||||||
|
cell.className = 'no-results';
|
||||||
|
cell.innerHTML = `
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
<h3>No members found</h3>
|
||||||
|
<p>Try adjusting your search terms or check the spelling.</p>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredMembers.forEach(member => {
|
||||||
|
const row = tbody.insertRow();
|
||||||
|
|
||||||
|
// Name
|
||||||
|
const nameCell = row.insertCell();
|
||||||
|
nameCell.textContent = member.name;
|
||||||
|
|
||||||
|
// Email
|
||||||
|
const emailCell = row.insertCell();
|
||||||
|
const emailLink = document.createElement('a');
|
||||||
|
emailLink.href = `mailto:${member.email}`;
|
||||||
|
emailLink.textContent = member.email;
|
||||||
|
emailLink.style.color = 'var(--primary-color)';
|
||||||
|
emailCell.appendChild(emailLink);
|
||||||
|
|
||||||
|
// Lists (show member's subscriptions)
|
||||||
|
const listsCell = row.insertCell();
|
||||||
|
const memberLists = [];
|
||||||
|
this.subscriptions.forEach((members, listId) => {
|
||||||
|
if (members.some(m => m.member_id === member.member_id)) {
|
||||||
|
const list = this.lists.find(l => l.list_id === listId);
|
||||||
|
if (list) {
|
||||||
|
memberLists.push(list.list_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
listsCell.textContent = memberLists.length > 0 ? memberLists.join(', ') : 'None';
|
||||||
|
|
||||||
|
// Status
|
||||||
|
const statusCell = row.insertCell();
|
||||||
|
const statusBadge = document.createElement('span');
|
||||||
|
statusBadge.className = `status-badge ${member.active ? 'active' : 'inactive'}`;
|
||||||
|
statusBadge.innerHTML = `<i class="fas fa-${member.active ? 'check' : 'times'}"></i> ${member.active ? 'Active' : 'Inactive'}`;
|
||||||
|
|
||||||
|
// Add bounce status if exists and bounce handling is enabled
|
||||||
|
if (this.config?.bounce_handling_enabled && member.bounce_status && member.bounce_status !== 'clean') {
|
||||||
|
const bounceIndicator = document.createElement('span');
|
||||||
|
bounceIndicator.className = `bounce-badge bounce-${member.bounce_status === 'hard_bounce' ? 'hard' : 'soft'}`;
|
||||||
|
bounceIndicator.innerHTML = `<i class="fas fa-exclamation-triangle"></i> Bounces`;
|
||||||
|
bounceIndicator.title = `${member.bounce_count || 0} bounces - ${member.bounce_status}`;
|
||||||
|
statusCell.appendChild(document.createElement('br'));
|
||||||
|
statusCell.appendChild(bounceIndicator);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCell.appendChild(statusBadge);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const actionsCell = row.insertCell();
|
||||||
|
actionsCell.className = 'action-buttons';
|
||||||
|
|
||||||
|
// Bounces button (if bounce handling is enabled and member has bounce data)
|
||||||
|
if (this.config?.bounce_handling_enabled && member.bounce_count > 0) {
|
||||||
|
const bouncesBtn = uiManager.createActionButton('Bounces', 'exclamation-triangle', 'btn-warning', () => {
|
||||||
|
uiManager.showBounceHistory(member);
|
||||||
|
});
|
||||||
|
actionsCell.appendChild(bouncesBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionsBtn = uiManager.createActionButton('Lists', 'list', 'btn-secondary', () => {
|
||||||
|
uiManager.showMemberSubscriptions(member);
|
||||||
|
});
|
||||||
|
actionsCell.appendChild(subscriptionsBtn);
|
||||||
|
|
||||||
|
const editBtn = uiManager.createActionButton('Edit', 'edit', 'btn-primary', () => {
|
||||||
|
uiManager.showMemberModal(member);
|
||||||
|
});
|
||||||
|
editBtn.setAttribute('data-requires-write', '');
|
||||||
|
actionsCell.appendChild(editBtn);
|
||||||
|
|
||||||
|
const deleteBtn = uiManager.createActionButton('Delete', 'trash', 'btn-danger', () => {
|
||||||
|
uiManager.showConfirmation(
|
||||||
|
`Are you sure you want to delete member "${member.name}"? This will also remove them from all mailing lists.`,
|
||||||
|
async () => {
|
||||||
|
await this.deleteMember(member.member_id);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
deleteBtn.setAttribute('data-requires-write', '');
|
||||||
|
actionsCell.appendChild(deleteBtn);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update UI permissions for the filtered results
|
||||||
|
const hasWriteAccess = this.currentUser && (this.currentUser.role === 'administrator' || this.currentUser.role === 'operator');
|
||||||
|
this.updateUIForPermissions(hasWriteAccess);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the application when DOM is loaded
|
// Initialize the application when DOM is loaded
|
||||||
|
|||||||
Reference in New Issue
Block a user