From 72f3297a800920f1b800a443e7ca8fd3effacd70 Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Mon, 13 Oct 2025 15:05:42 +0000 Subject: [PATCH] SES SNS Bounce Handling --- BOUNCE_HANDLING_SETUP.md | 255 +++++++++++++++++++++++++++++++++++++ README.md | 3 + api/main.py | 264 +++++++++++++++++++++++++++++++++++++++ api/requirements.txt | 1 + database/schema.sql | 26 +++- simulate_bounce.sh | 206 ++++++++++++++++++++++++++++++ test_bounce_webhook.sh | 76 +++++++++++ web/index.html | 63 +++++++++- web/static/css/style.css | 172 ++++++++++++++++++++++++- web/static/js/api.js | 11 ++ web/static/js/app.js | 22 ++++ web/static/js/ui.js | 180 ++++++++++++++++++++++++++ 12 files changed, 1276 insertions(+), 3 deletions(-) create mode 100644 BOUNCE_HANDLING_SETUP.md create mode 100755 simulate_bounce.sh create mode 100755 test_bounce_webhook.sh diff --git a/BOUNCE_HANDLING_SETUP.md b/BOUNCE_HANDLING_SETUP.md new file mode 100644 index 0000000..a0ad168 --- /dev/null +++ b/BOUNCE_HANDLING_SETUP.md @@ -0,0 +1,255 @@ +# SES SNS Bounce Handling Setup + +This document describes how to configure AWS SES and SNS to handle email bounces automatically in the Mail List Manager. + +## 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: + +1. SES sends a notification to an SNS topic +2. SNS forwards the notification to your webhook endpoint +3. The API processes the notification and updates the database +4. Members with hard bounces are automatically deactivated +5. Bounce history is tracked and displayed in the UI + +## Bounce Status Types + +- **Clean**: No bounces recorded +- **Soft Bounce**: Temporary delivery issues (e.g., mailbox full, temporary server issues) + - After 3 soft bounces, the member is marked with soft bounce status +- **Hard Bounce**: Permanent delivery failure (e.g., invalid email address, domain doesn't exist) + - Member is automatically deactivated and cannot receive emails + +## Setup Instructions + +### 1. 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 + +1. Log in to AWS Console and navigate to SNS +2. Click "Create topic" +3. Choose "Standard" topic type +4. Name: `ses-bounce-notifications` (or your preferred name) +5. Display name: `SES Bounce Notifications` +6. Click "Create topic" +7. **Save the Topic ARN** (you'll need it in step 4) + +### 3. Subscribe Your Webhook to SNS Topic + +1. In the SNS topic details, click "Create subscription" +2. Protocol: `HTTPS` +3. Endpoint: `https://yourdomain.com:8000/webhooks/sns` + - Replace `yourdomain.com` with your actual domain + - The API must be accessible via HTTPS (SNS doesn't support HTTP) +4. Enable raw message delivery: **Unchecked** +5. Click "Create subscription" +6. The subscription will be in "PendingConfirmation" status + +### 4. 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. + +1. Check your API logs: + ```bash + sudo docker-compose logs -f api + ``` +2. You should see a log entry indicating the subscription was confirmed +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 + +1. Navigate to AWS SES console +2. Go to "Configuration Sets" (or "Verified identities" > select your domain > "Notifications") +3. For configuration sets: + - Create a new configuration set or select existing + - Add "Event destination" + - Event types: Select **"Bounce"** (and optionally "Complaint") + - Destination: SNS topic + - Select your SNS topic created in step 2 +4. For verified identities: + - Select your sending domain/email + - Click "Edit" in the "Notifications" section + - Bounce feedback: Select your SNS topic + - Include original headers: Enabled (optional) + - Click "Save changes" + +### 6. Verify Setup + +#### Test with a Bounce Simulator + +AWS SES provides bounce simulator addresses: + +```bash +# From inside Postfix container +docker-compose exec postfix bash +echo "Test bounce" | mail -s "Test" bounce@simulator.amazonses.com +``` + +Or send to your mailing list with a test recipient: + +1. Add `bounce@simulator.amazonses.com` as a member +2. Subscribe to a test list +3. Send an email to the list + +#### Check the Results + +1. Wait a few minutes for SES to process and send the notification +2. Check API logs: + ```bash + sudo docker-compose logs api | grep -i bounce + ``` +3. Log in to the web UI +4. Go to Members tab +5. Find the test member and click the "Bounces" button +6. You should see the bounce event recorded + +### 7. Security Considerations + +#### SNS Signature Verification + +The webhook endpoint automatically verifies SNS message signatures to ensure notifications are genuine AWS messages. This prevents unauthorized parties from sending fake bounce notifications. + +#### HTTPS Requirement + +SNS requires HTTPS for webhooks. You'll need: +- Valid SSL/TLS certificate for your domain +- Reverse proxy (e.g., Nginx, Apache) in front of the API container +- Or use AWS API Gateway as a proxy + +#### Example Nginx Configuration + +```nginx +server { + listen 443 ssl http2; + server_name lists.yourdomain.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + # Webhook endpoint + location /webhooks/sns { + proxy_pass http://localhost:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Optional: proxy API for web UI + location /api { + proxy_pass http://localhost:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +### 8. Managing Bounces in the UI + +#### View Bounce Status + +In the Members tab, bounced emails are indicated with: +- Warning badge showing bounce count +- Color-coded status (yellow for soft bounce, red for hard bounce) +- Last bounce timestamp + +#### View Bounce History + +1. Click the "Bounces" button next to a member +2. View detailed bounce history including: + - Bounce type (Permanent, Transient, Undetermined) + - Bounce subtype + - Diagnostic code from the receiving mail server + - Timestamp of each bounce + +#### Reset Bounce Status + +If a member's email has been corrected or verified: + +1. Open the bounce history modal +2. Click "Reset Bounce Status" +3. Confirm the action +4. The member's bounce count is cleared and they can receive emails again + +**Note**: Only users with write access (administrators and operators) can reset bounce status. + +### 9. Monitoring and Maintenance + +#### Check Bounce Logs + +```bash +# View all bounces in database +sudo docker-compose exec mysql mysql -u maillist -p maillist -e "SELECT * FROM bounce_logs ORDER BY timestamp DESC LIMIT 20;" + +# Count bounces by type +sudo docker-compose exec mysql mysql -u maillist -p maillist -e "SELECT bounce_type, COUNT(*) as count FROM bounce_logs GROUP BY bounce_type;" + +# Find members with bounces +sudo docker-compose exec mysql mysql -u maillist -p maillist -e "SELECT name, email, bounce_count, bounce_status FROM members WHERE bounce_count > 0;" +``` + +#### API Health Check + +```bash +# Check if webhook is accessible +curl -X POST https://yourdomain.com:8000/webhooks/sns \ + -H "Content-Type: application/json" \ + -d '{"Type":"test"}' +``` + +#### Clean Up Old Bounce Records + +Periodically review and clean up old bounce records: + +```sql +-- Delete bounce logs older than 90 days +DELETE FROM bounce_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL 90 DAY); +``` + +## Troubleshooting + +### SNS Subscription Not Confirming + +- Ensure the API container is running and accessible via HTTPS +- Check API logs for errors +- Verify firewall rules allow HTTPS traffic to port 8000 +- Test the endpoint manually: `curl https://yourdomain.com:8000/health` + +### Bounces Not Being Recorded + +1. Verify SNS topic is receiving messages: + - Check SNS topic metrics in AWS Console +2. Verify subscription is active: + - Check subscription status in SNS console +3. Check API logs for webhook errors: + ```bash + sudo docker-compose logs api | grep -i "sns\|bounce" + ``` +4. Test signature verification: + - Temporarily add debug logging to the webhook endpoint + +### Members Not Being Deactivated + +- Check if bounce type is "Permanent" +- Review member's bounce_status in database: + ```bash + sudo docker-compose exec mysql mysql -u maillist -p maillist -e "SELECT * FROM members WHERE email='problem@example.com';" + ``` +- Verify bounce processing logic in API logs + +### SSL Certificate Issues + +If using self-signed certificates, SNS will reject the webhook. You must use: +- Valid certificate from a trusted CA (Let's Encrypt, etc.) +- Or use AWS Certificate Manager with API Gateway + +## Additional Resources + +- [AWS SES Bounce Handling](https://docs.aws.amazon.com/ses/latest/dg/event-publishing-retrieving-sns.html) +- [AWS SNS HTTPS Subscriptions](https://docs.aws.amazon.com/sns/latest/dg/sns-http-https-endpoint-as-subscriber.html) +- [SES Bounce Types](https://docs.aws.amazon.com/ses/latest/dg/notification-contents.html#bounce-types) diff --git a/README.md b/README.md index 4702879..234a3d0 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,7 @@ docker-compose up -d # Reinitializes from schema.sql - ✅ **REST API** - Complete programmatic access with token auth - ✅ **Sender Whitelist** - Only authorized domains can send to lists - ✅ **SES Integration** - Reliable email delivery through Amazon SES +- ✅ **Bounce Handling** - Automatic tracking and management of email bounces via SNS - ✅ **Secure** - Private Docker network, token auth, environment-based credentials - ✅ **Flexible** - Manage via web, API, or direct database access - ✅ **Scalable** - Database-driven architecture supports many lists and members @@ -326,6 +327,7 @@ docker-compose up -d # Reinitializes from schema.sql - **Web Interface**: See `web/README.md` for frontend features and usage - **REST API**: See `api/README.md` for complete API reference - **Database**: See `database/README.md` for schema and SQL examples +- **Bounce Handling**: See `BOUNCE_HANDLING_SETUP.md` for SNS configuration - **AI Agents**: See `.github/copilot-instructions.md` for development guidance ## Roadmap @@ -336,6 +338,7 @@ docker-compose up -d # Reinitializes from schema.sql - [x] Multi-service Docker Compose architecture - [x] REST API with authentication - [x] Sender whitelist for authorized domains +- [x] Bounce handling with SES SNS integration - [ ] Email verification workflow for new members - [ ] Subscription confirmation (double opt-in) - [ ] List archive functionality diff --git a/api/main.py b/api/main.py index f243965..2fabb1a 100644 --- a/api/main.py +++ b/api/main.py @@ -5,6 +5,7 @@ FastAPI-based REST API for managing mailing lists and members from fastapi import FastAPI, HTTPException, Depends, Header, status, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi.responses import PlainTextResponse from pydantic import BaseModel, EmailStr from typing import List, Optional, Annotated import mysql.connector @@ -19,6 +20,15 @@ import bcrypt from jose import JWTError, jwt from passlib.context import CryptContext from enum import Enum +import json +import base64 +from urllib.parse import urlparse +import httpx +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.serialization import load_pem_public_key # Configuration API_TOKEN = os.getenv('API_TOKEN', 'change-this-token') # Keep for backward compatibility during transition @@ -229,6 +239,9 @@ class Member(BaseModel): name: str email: EmailStr active: bool = True + bounce_count: Optional[int] = 0 + last_bounce_at: Optional[datetime] = None + bounce_status: Optional[str] = 'clean' class MemberUpdate(BaseModel): name: Optional[str] = None @@ -808,6 +821,257 @@ 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() + message = json.loads(body.decode('utf-8')) + + # 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 = bounce_data.get('timestamp') + feedback_id = bounce_data.get('feedbackId', '') + + bounced_recipients = bounce_data.get('bouncedRecipients', []) + + with get_db() as conn: + cursor = conn.cursor(dictionary=True) + + 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 = 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' + + 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)}") + raise + +@app.post("/webhooks/sns", response_class=PlainTextResponse) +async def sns_webhook(request: Request): + """Handle SNS notifications for bounces and complaints""" + try: + # Verify SNS signature + message = await verify_sns_signature(request) + + message_type = message.get('Type') + + # Handle subscription confirmation + if message_type == 'SubscriptionConfirmation': + subscribe_url = message.get('SubscribeURL') + if subscribe_url: + # Confirm subscription + async with httpx.AsyncClient() as client: + await client.get(subscribe_url) + return "Subscription confirmed" + + # Handle notification + elif message_type == 'Notification': + # Parse the message + notification = json.loads(message.get('Message', '{}')) + notification_type = notification.get('notificationType') + + if notification_type == 'Bounce': + bounce = notification.get('bounce', {}) + await process_bounce(bounce) + return "Bounce processed" + + elif notification_type == 'Complaint': + # We could also track complaints similarly to bounces + return "Complaint received" + + return "OK" + + except HTTPException: + raise + except Exception as e: + print(f"SNS webhook error: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + +# 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 + +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"} + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/api/requirements.txt b/api/requirements.txt index 4d62226..b2ed1f7 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -8,3 +8,4 @@ email-validator==2.1.0 bcrypt==4.0.1 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 +httpx==0.25.2 diff --git a/database/schema.sql b/database/schema.sql index 025bff4..ad77e01 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -55,8 +55,12 @@ CREATE TABLE IF NOT EXISTS members ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, active BOOLEAN DEFAULT TRUE, + bounce_count INT DEFAULT 0, + last_bounce_at TIMESTAMP NULL, + bounce_status ENUM('clean', 'soft_bounce', 'hard_bounce') DEFAULT 'clean', INDEX idx_email (email), - INDEX idx_active (active) + INDEX idx_active (active), + INDEX idx_bounce_status (bounce_status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- Table: list_members @@ -75,6 +79,26 @@ CREATE TABLE IF NOT EXISTS list_members ( INDEX idx_active (active) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- Table: bounce_logs +-- Stores bounce notifications from SES SNS +CREATE TABLE IF NOT EXISTS bounce_logs ( + bounce_id INT AUTO_INCREMENT PRIMARY KEY, + member_id INT, + email VARCHAR(255) NOT NULL, + bounce_type ENUM('Permanent', 'Transient', 'Undetermined') NOT NULL, + bounce_subtype VARCHAR(50), + diagnostic_code TEXT, + timestamp TIMESTAMP NOT NULL, + sns_message_id VARCHAR(255), + feedback_id VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (member_id) REFERENCES members(member_id) ON DELETE SET NULL, + INDEX idx_member_id (member_id), + INDEX idx_email (email), + INDEX idx_timestamp (timestamp), + INDEX idx_bounce_type (bounce_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- Insert sample data -- Create default admin user (password: 'password') diff --git a/simulate_bounce.sh b/simulate_bounce.sh new file mode 100755 index 0000000..ca6a36d --- /dev/null +++ b/simulate_bounce.sh @@ -0,0 +1,206 @@ +#!/bin/bash +# Simulate Bounce Events for Testing +# This script inserts bounce data directly into the database to test the UI +# without needing to set up AWS SNS + +set -e + +echo "=== Bounce Simulation Script ===" +echo +echo "This script will create test bounce events for existing members" +echo + +# Get database password from .env +DB_PASSWORD=$(grep MYSQL_ROOT_PASSWORD /home/jamesp/docker/maillist/.env | cut -d'=' -f2) + +# Check if containers are running +if ! sudo docker-compose ps | grep -q "maillist-mysql.*Up"; then + echo "❌ MySQL container is not running. Starting containers..." + sudo docker-compose up -d + echo "Waiting for MySQL to be ready..." + sleep 5 +fi + +echo "1. Fetching existing members..." +echo + +# Get list of members +MEMBERS=$(sudo docker-compose exec -T mysql mysql -u root -p"$DB_PASSWORD" maillist -N -e "SELECT member_id, name, email FROM members LIMIT 5;") + +if [ -z "$MEMBERS" ]; then + echo "❌ No members found in database. Please add members first." + exit 1 +fi + +echo "Available members:" +echo "$MEMBERS" | while read -r line; do + MEMBER_ID=$(echo "$line" | awk '{print $1}') + NAME=$(echo "$line" | awk '{print $2, $3}') + EMAIL=$(echo "$line" | awk '{$1=""; $2=""; print $0}' | xargs) + echo " [$MEMBER_ID] $NAME - $EMAIL" +done +echo + +# Prompt for member ID +read -p "Enter member ID to simulate bounce for (or press Enter for first member): " MEMBER_ID + +if [ -z "$MEMBER_ID" ]; then + MEMBER_ID=$(echo "$MEMBERS" | head -1 | awk '{print $1}') + echo "Using member ID: $MEMBER_ID" +fi + +# Get member details +MEMBER_INFO=$(sudo docker-compose exec -T mysql mysql -u root -p"$DB_PASSWORD" maillist -N -e "SELECT member_id, name, email FROM members WHERE member_id = $MEMBER_ID;") + +if [ -z "$MEMBER_INFO" ]; then + echo "❌ Member ID $MEMBER_ID not found" + exit 1 +fi + +MEMBER_EMAIL=$(echo "$MEMBER_INFO" | awk '{$1=""; $2=""; print $0}' | xargs) +MEMBER_NAME=$(echo "$MEMBER_INFO" | awk '{print $2, $3}') + +echo +echo "Selected member: $MEMBER_NAME ($MEMBER_EMAIL)" +echo + +# Bounce type selection +echo "Select bounce type to simulate:" +echo " 1) Soft Bounce (Transient - e.g., mailbox full)" +echo " 2) Hard Bounce (Permanent - e.g., invalid address)" +echo " 3) Multiple Soft Bounces (3 bounces to trigger soft_bounce status)" +echo " 4) Undetermined Bounce" +echo +read -p "Enter choice (1-4): " BOUNCE_CHOICE + +case $BOUNCE_CHOICE in + 1) + BOUNCE_TYPE="Transient" + BOUNCE_SUBTYPE="MailboxFull" + DIAGNOSTIC="smtp; 452 4.2.2 Mailbox full" + COUNT=1 + ;; + 2) + BOUNCE_TYPE="Permanent" + BOUNCE_SUBTYPE="General" + DIAGNOSTIC="smtp; 550 5.1.1 User unknown" + COUNT=1 + ;; + 3) + BOUNCE_TYPE="Transient" + BOUNCE_SUBTYPE="General" + DIAGNOSTIC="smtp; 451 4.4.1 Temporary failure" + COUNT=3 + ;; + 4) + BOUNCE_TYPE="Undetermined" + BOUNCE_SUBTYPE="" + DIAGNOSTIC="Unknown error occurred" + COUNT=1 + ;; + *) + echo "Invalid choice" + exit 1 + ;; +esac + +echo +echo "Simulating $COUNT bounce event(s)..." +echo + +# Insert bounce events +for i in $(seq 1 $COUNT); do + TIMESTAMP=$(date -u -d "-$((i * 24)) hours" '+%Y-%m-%d %H:%M:%S') + FEEDBACK_ID="test-feedback-$(date +%s)-$i" + + echo "Creating bounce event $i/$COUNT (timestamp: $TIMESTAMP)..." + + sudo docker-compose exec -T mysql mysql -u root -p"$DB_PASSWORD" maillist < /tmp/test_bounce.json +{ + "Type": "Notification", + "MessageId": "test-notification-id", + "TopicArn": "arn:aws:sns:eu-west-2:123456789:test-topic", + "Subject": "Amazon SES Email Event Notification", + "Message": "{\"notificationType\":\"Bounce\",\"bounce\":{\"bounceType\":\"Permanent\",\"bounceSubType\":\"General\",\"bouncedRecipients\":[{\"emailAddress\":\"bounce@simulator.amazonses.com\",\"diagnosticCode\":\"smtp; 550 5.1.1 user unknown\"}],\"timestamp\":\"2025-01-01T12:00:00.000Z\",\"feedbackId\":\"test-feedback-id\"}}", + "Timestamp": "2025-01-01T12:00:00.000Z", + "SignatureVersion": "1", + "Signature": "test-signature", + "SigningCertURL": "https://sns.eu-west-2.amazonaws.com/test.pem" +} +EOF + +cat /tmp/test_bounce.json | jq . +echo +echo "Expected behavior: Signature verification will fail without real AWS credentials" +echo "In production, AWS SNS will send properly signed messages that will be verified" +echo + +# Test 4: Check if database schema has bounce tables +echo "4. Checking database schema for bounce tables..." +sudo docker-compose exec -T mysql mysql -u maillist -pmaillist maillist -e "SHOW TABLES LIKE '%bounce%';" 2>/dev/null +echo + +# Test 5: Check members table for bounce columns +echo "5. Checking members table for bounce columns..." +sudo docker-compose exec -T mysql mysql -u maillist -pmaillist maillist -e "DESCRIBE members;" 2>/dev/null | grep -i bounce +echo + +echo "✓ Setup complete!" +echo +echo "To test with real AWS SNS:" +echo "1. Set up SNS topic in AWS Console" +echo "2. Subscribe webhook: https://your-domain.com:8000/webhooks/sns" +echo "3. Configure SES to send bounce notifications to the SNS topic" +echo "4. Send test email to bounce@simulator.amazonses.com" +echo +echo "See BOUNCE_HANDLING_SETUP.md for detailed setup instructions" diff --git a/web/index.html b/web/index.html index 1b981fa..53275ac 100644 --- a/web/index.html +++ b/web/index.html @@ -581,6 +581,67 @@ + + +