Compare commits
3 Commits
ecbc38cf8e
...
12a82c8d03
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12a82c8d03 | ||
|
|
b34ea2ed84 | ||
|
|
8fd951fd1f |
14
.env.example
14
.env.example
@@ -19,4 +19,16 @@ MYSQL_PASSWORD=change_this_password
|
||||
MYSQL_ROOT_PASSWORD=change_this_root_password
|
||||
|
||||
# API Configuration
|
||||
API_TOKEN=change_this_to_a_secure_random_token
|
||||
API_TOKEN=change_this_to_a_secure_random_token
|
||||
|
||||
# Bounce Handling Configuration (Optional)
|
||||
# Set to 'true' to enable SNS webhook bounce handling
|
||||
# Set to 'false' to disable and rely on email-based 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
|
||||
@@ -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.
|
||||
|
||||
**Prerequisites:**
|
||||
- SES production access (not available in sandbox mode)
|
||||
- Valid HTTPS domain for webhook endpoint
|
||||
- Bounce handling must be enabled in configuration
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
### 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
|
||||
- Your Mail List Manager deployed and accessible via HTTPS (required for SNS webhook)
|
||||
- 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
|
||||
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"
|
||||
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"
|
||||
2. Protocol: `HTTPS`
|
||||
@@ -49,7 +71,7 @@ The system uses AWS Simple Notification Service (SNS) to receive real-time bounc
|
||||
5. Click "Create subscription"
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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)
|
||||
- Click "Save changes"
|
||||
|
||||
### 6. Verify Setup
|
||||
### 7. Verify Setup
|
||||
|
||||
#### 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
|
||||
6. You should see the bounce event recorded
|
||||
|
||||
### 7. Security Considerations
|
||||
### 8. Security Considerations
|
||||
|
||||
#### SNS Signature Verification
|
||||
|
||||
@@ -149,7 +171,7 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Managing Bounces in the UI
|
||||
### 9. Managing Bounces in the UI
|
||||
|
||||
#### 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.
|
||||
|
||||
### 9. Monitoring and Maintenance
|
||||
### 10. Monitoring and Maintenance
|
||||
|
||||
#### Check Bounce Logs
|
||||
|
||||
|
||||
35
README.md
35
README.md
@@ -172,6 +172,41 @@ docker-compose exec postfix postmap -q "community@lists.sasalliance.org" \
|
||||
mysql:/etc/postfix/mysql_virtual_alias_maps.cf
|
||||
```
|
||||
|
||||
### Bounce Handling (Optional)
|
||||
|
||||
**Email bounce handling is optional and disabled by default.**
|
||||
|
||||
**Two 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
|
||||
- Automatic member deactivation for hard bounces
|
||||
- Bounce history tracking and management
|
||||
- Requires valid HTTPS domain and SES production access
|
||||
- See `BOUNCE_HANDLING_SETUP.md` for complete setup
|
||||
|
||||
2. **Email-Based Handling** (Default for SES Sandbox):
|
||||
```bash
|
||||
# In .env file (or leave these commented out)
|
||||
ENABLE_SNS_WEBHOOKS=false
|
||||
ENABLE_BOUNCE_HANDLING=false
|
||||
```
|
||||
- Manual bounce management via email notifications
|
||||
- No automatic processing - requires manual member cleanup
|
||||
- Works with SES sandbox accounts
|
||||
- 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
|
||||
|
||||
- **Environment Variables**: All credentials stored in `.env` (git-ignored)
|
||||
|
||||
586
api/main.py
586
api/main.py
@@ -43,6 +43,10 @@ MYSQL_DATABASE = os.getenv('MYSQL_DATABASE', 'maillist')
|
||||
MYSQL_USER = os.getenv('MYSQL_USER', 'maillist')
|
||||
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'
|
||||
|
||||
# Password hashing
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
@@ -463,6 +467,14 @@ async def health():
|
||||
except Exception as 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
|
||||
}
|
||||
|
||||
# Mailing Lists endpoints
|
||||
@app.get("/lists", response_model=List[MailingList])
|
||||
async def get_lists(current_user: CurrentUser = require_read_access()):
|
||||
@@ -824,298 +836,316 @@ async def bulk_import_members(bulk_request: BulkImportRequest, current_user: Cur
|
||||
cursor.close()
|
||||
raise HTTPException(status_code=500, detail=f"Bulk import failed: {str(e)}")
|
||||
|
||||
# SNS Webhook for Bounce Handling
|
||||
async def verify_sns_signature(request: Request) -> dict:
|
||||
"""Verify SNS message signature"""
|
||||
try:
|
||||
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'])
|
||||
# SNS Webhook for Bounce Handling (conditionally enabled)
|
||||
if ENABLE_SNS_WEBHOOKS:
|
||||
async def verify_sns_signature(request: Request) -> dict:
|
||||
"""Verify SNS 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)
|
||||
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)}")
|
||||
|
||||
for recipient in bounced_recipients:
|
||||
email = recipient.get('emailAddress')
|
||||
diagnostic_code = recipient.get('diagnosticCode', '')
|
||||
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:
|
||||
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:
|
||||
continue
|
||||
|
||||
# Find member by email
|
||||
cursor.execute("SELECT member_id FROM members WHERE email = %s", (email,))
|
||||
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, feedback_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""", (member_id, email, bounce_type, bounce_subtype, diagnostic_code, timestamp, feedback_id))
|
||||
|
||||
# Update member bounce status
|
||||
if member_id:
|
||||
# Determine bounce status
|
||||
if bounce_type == 'Permanent':
|
||||
new_status = 'hard_bounce'
|
||||
# Deactivate member with hard bounce
|
||||
cursor.execute("""
|
||||
UPDATE members
|
||||
SET bounce_count = bounce_count + 1,
|
||||
last_bounce_at = %s,
|
||||
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'
|
||||
|
||||
for recipient in bounced_recipients:
|
||||
email = recipient.get('emailAddress')
|
||||
diagnostic_code = recipient.get('diagnosticCode', '')
|
||||
|
||||
if not email:
|
||||
continue
|
||||
|
||||
# Find member by email
|
||||
cursor.execute("SELECT member_id FROM members WHERE email = %s", (email,))
|
||||
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, feedback_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""", (member_id, email, bounce_type, bounce_subtype, diagnostic_code, timestamp, feedback_id))
|
||||
|
||||
# Update member bounce status
|
||||
if member_id:
|
||||
# Determine bounce status
|
||||
if bounce_type == 'Permanent':
|
||||
new_status = 'hard_bounce'
|
||||
# Deactivate member with hard bounce
|
||||
cursor.execute("""
|
||||
UPDATE members
|
||||
SET bounce_count = %s,
|
||||
SET bounce_count = bounce_count + 1,
|
||||
last_bounce_at = %s,
|
||||
bounce_status = %s
|
||||
bounce_status = %s,
|
||||
active = 0
|
||||
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))
|
||||
""", (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("""
|
||||
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()
|
||||
|
||||
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
|
||||
return bounces
|
||||
|
||||
@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')}")
|
||||
|
||||
# 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)
|
||||
@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()
|
||||
|
||||
# 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 cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
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))
|
||||
cursor.close()
|
||||
return {"message": "Bounce status reset successfully"}
|
||||
|
||||
# Bounce management endpoints
|
||||
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
|
||||
else:
|
||||
# When bounce handling is disabled, provide stub endpoints that return appropriate responses
|
||||
@app.get("/members/{member_id}/bounces")
|
||||
async def get_member_bounces_disabled(member_id: int, current_user: CurrentUser = require_read_access()):
|
||||
"""Bounce history disabled - returns empty list"""
|
||||
return []
|
||||
|
||||
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()
|
||||
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"}
|
||||
@app.patch("/members/{member_id}/bounce-status")
|
||||
async def reset_bounce_status_disabled(member_id: int, current_user: CurrentUser = require_write_access()):
|
||||
"""Bounce status reset disabled"""
|
||||
raise HTTPException(status_code=501, detail="Bounce handling is disabled")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
@@ -54,22 +54,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header (shown after login) -->
|
||||
<header class="header" id="mainHeader" style="display: none;">
|
||||
<div class="container">
|
||||
<div class="header-content">
|
||||
<h1 class="logo">
|
||||
<i class="fas fa-envelope"></i>
|
||||
Mailing List Manager
|
||||
</h1>
|
||||
<div class="auth-section">
|
||||
<div class="user-info" id="userInfo">
|
||||
<div class="user-details">
|
||||
<span class="user-name" id="currentUsername">User</span>
|
||||
<span class="user-role" id="currentUserRole">role</span>
|
||||
</div>
|
||||
<button class="btn btn-secondary" id="logoutBtn">Logout</button>
|
||||
<!-- Menu Bar Header (shown after login) -->
|
||||
<header class="menu-bar" id="mainHeader" style="display: none;">
|
||||
<div class="menu-bar-content">
|
||||
<div class="app-title">
|
||||
<i class="fas fa-envelope"></i>
|
||||
<span>Mailing List Manager</span>
|
||||
</div>
|
||||
<div class="menu-spacer"></div>
|
||||
<div class="user-dropdown" id="userDropdown">
|
||||
<button class="user-dropdown-trigger" id="userDropdownTrigger">
|
||||
<div class="user-avatar">
|
||||
<i class="fas fa-user"></i>
|
||||
</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>
|
||||
@@ -88,10 +113,6 @@
|
||||
<i class="fas fa-users"></i>
|
||||
Members
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="users" id="usersTab" style="display: none;">
|
||||
<i class="fas fa-user-shield"></i>
|
||||
Users
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Notification Area -->
|
||||
@@ -134,13 +155,7 @@
|
||||
<!-- Members Tab -->
|
||||
<div class="tab-content" id="members-tab">
|
||||
<div class="section-header">
|
||||
<div class="header-content">
|
||||
<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>
|
||||
<h2>Members</h2>
|
||||
<div class="button-group">
|
||||
<button class="btn btn-primary" id="addMemberBtn">
|
||||
<i class="fas fa-plus"></i>
|
||||
@@ -153,6 +168,26 @@
|
||||
</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">
|
||||
<table class="table" id="membersTable">
|
||||
<thead>
|
||||
|
||||
@@ -285,7 +285,8 @@ body {
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
/* Menu Bar Header */
|
||||
.menu-bar {
|
||||
background: var(--white);
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
box-shadow: var(--shadow-sm);
|
||||
@@ -294,24 +295,29 @@ body {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
.menu-bar-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-4) 0;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
.app-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.logo i {
|
||||
font-size: var(--font-size-2xl);
|
||||
.app-title i {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
.menu-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Authentication */
|
||||
@@ -342,31 +348,185 @@ body {
|
||||
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;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-1);
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
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);
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.user-role {
|
||||
.dropdown-role {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--gray-500);
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -523,6 +683,113 @@ body {
|
||||
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 */
|
||||
.notification {
|
||||
display: flex;
|
||||
@@ -945,11 +1212,35 @@ body {
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
.menu-bar-content {
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
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 {
|
||||
flex-direction: column;
|
||||
@@ -969,6 +1260,18 @@ body {
|
||||
gap: var(--space-4);
|
||||
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 {
|
||||
overflow-x: auto;
|
||||
|
||||
@@ -105,6 +105,10 @@ class APIClient {
|
||||
return this.request('/health');
|
||||
}
|
||||
|
||||
async getConfig() {
|
||||
return this.request('/config');
|
||||
}
|
||||
|
||||
async testAuth() {
|
||||
return this.request('/');
|
||||
}
|
||||
|
||||
@@ -52,6 +52,33 @@ class MailingListApp {
|
||||
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
|
||||
document.getElementById('showBulkImportBtn').addEventListener('click', () => {
|
||||
uiManager.showBulkImportModal();
|
||||
@@ -61,6 +88,47 @@ class MailingListApp {
|
||||
document.getElementById('addUserBtn').addEventListener('click', () => {
|
||||
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) {
|
||||
document.getElementById('currentUsername').textContent = this.currentUser.username;
|
||||
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
|
||||
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
|
||||
const hasWriteAccess = this.currentUser.role === 'administrator' || this.currentUser.role === 'operator';
|
||||
@@ -250,6 +321,28 @@ class MailingListApp {
|
||||
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
|
||||
*/
|
||||
@@ -259,12 +352,15 @@ class MailingListApp {
|
||||
try {
|
||||
uiManager.setLoading(true);
|
||||
|
||||
// Load lists and members in parallel
|
||||
const [lists, members] = await Promise.all([
|
||||
// Load configuration, lists and members in parallel
|
||||
const [config, lists, members] = await Promise.all([
|
||||
apiClient.getConfig(),
|
||||
apiClient.getLists(),
|
||||
apiClient.getMembers()
|
||||
]);
|
||||
|
||||
this.config = config;
|
||||
|
||||
this.lists = lists;
|
||||
this.members = members;
|
||||
|
||||
@@ -438,7 +534,7 @@ class MailingListApp {
|
||||
row.innerHTML = `
|
||||
<td>
|
||||
<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>
|
||||
<a href="mailto:${member.email}" style="color: var(--primary-color)">
|
||||
@@ -459,8 +555,8 @@ class MailingListApp {
|
||||
</td>
|
||||
`;
|
||||
|
||||
// Add bounce badge if member has bounces
|
||||
if (member.bounce_count > 0) {
|
||||
// Add bounce badge if member has bounces (only if bounce handling is enabled)
|
||||
if (this.config?.bounce_handling_enabled && member.bounce_count > 0) {
|
||||
const bounceInfoDiv = row.cells[0].querySelector('.text-xs');
|
||||
const bounceBadge = uiManager.createBounceStatusBadge(member.bounce_status, member.bounce_count);
|
||||
if (bounceBadge) {
|
||||
@@ -484,8 +580,8 @@ class MailingListApp {
|
||||
uiManager.showMemberSubscriptionsModal(member);
|
||||
});
|
||||
|
||||
// Create Bounces button (show if member has any bounces or for admins/operators)
|
||||
if (member.bounce_count > 0 || hasWriteAccess) {
|
||||
// Create Bounces button (show if bounce handling is enabled and member has bounces or for admins/operators)
|
||||
if (this.config?.bounce_handling_enabled && (member.bounce_count > 0 || hasWriteAccess)) {
|
||||
const bouncesBtn = document.createElement('button');
|
||||
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})` : ''}`;
|
||||
@@ -667,6 +763,149 @@ class MailingListApp {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user