Compare commits

...

3 Commits

Author SHA1 Message Date
James Pattinson
12a82c8d03 Ability to disable SNS bounce handling 2025-10-14 15:39:33 +00:00
James Pattinson
b34ea2ed84 Dynamic search 2025-10-13 20:05:08 +00:00
James Pattinson
8fd951fd1f FIx menu bar 2025-10-13 19:58:46 +00:00
8 changed files with 1021 additions and 341 deletions

View File

@@ -20,3 +20,15 @@ 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)
# 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

View File

@@ -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

View File

@@ -172,6 +172,41 @@ 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.**
**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 ## Security
- **Environment Variables**: All credentials stored in `.env` (git-ignored) - **Environment Variables**: All credentials stored in `.env` (git-ignored)

View File

@@ -43,6 +43,10 @@ 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'
# Password hashing # Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@@ -463,6 +467,14 @@ 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
}
# 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,8 +836,9 @@ 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:
async def verify_sns_signature(request: Request) -> dict:
"""Verify SNS message signature""" """Verify SNS message signature"""
try: try:
body = await request.body() body = await request.body()
@@ -908,7 +921,7 @@ async def verify_sns_signature(request: Request) -> dict:
except Exception as e: except Exception as e:
raise HTTPException(status_code=400, detail=f"Signature verification failed: {str(e)}") raise HTTPException(status_code=400, detail=f"Signature verification failed: {str(e)}")
async def process_bounce(bounce_data: dict): async def process_bounce(bounce_data: dict):
"""Process bounce notification and update database""" """Process bounce notification and update database"""
try: try:
bounce_type = bounce_data.get('bounceType') # Permanent, Transient, Undetermined bounce_type = bounce_data.get('bounceType') # Permanent, Transient, Undetermined
@@ -997,8 +1010,8 @@ async def process_bounce(bounce_data: dict):
traceback.print_exc() traceback.print_exc()
raise raise
@app.post("/webhooks/sns", response_class=PlainTextResponse) @app.post("/webhooks/sns", response_class=PlainTextResponse)
async def sns_webhook(request: Request): async def sns_webhook(request: Request):
"""Handle SNS notifications for bounces and complaints""" """Handle SNS notifications for bounces and complaints"""
try: try:
print(f"=== SNS Webhook Request ===") print(f"=== SNS Webhook Request ===")
@@ -1061,8 +1074,13 @@ async def sns_webhook(request: Request):
traceback.print_exc() traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# Bounce management endpoints else:
class BounceLog(BaseModel): # 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 bounce_id: int
email: str email: str
bounce_type: str bounce_type: str
@@ -1072,7 +1090,7 @@ class BounceLog(BaseModel):
feedback_id: Optional[str] = None feedback_id: Optional[str] = None
created_at: datetime created_at: datetime
class MemberWithBounces(BaseModel): class MemberWithBounces(BaseModel):
member_id: int member_id: int
name: str name: str
email: str email: str
@@ -1081,8 +1099,8 @@ class MemberWithBounces(BaseModel):
last_bounce_at: Optional[datetime] = None last_bounce_at: Optional[datetime] = None
bounce_status: str bounce_status: str
@app.get("/members/{member_id}/bounces", response_model=List[BounceLog]) @app.get("/members/{member_id}/bounces", response_model=List[BounceLog])
async def get_member_bounces(member_id: int, current_user: CurrentUser = require_read_access()): async def get_member_bounces(member_id: int, current_user: CurrentUser = require_read_access()):
"""Get bounce history for a member""" """Get bounce history for a member"""
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
@@ -1097,8 +1115,8 @@ async def get_member_bounces(member_id: int, current_user: CurrentUser = require
cursor.close() cursor.close()
return bounces return bounces
@app.patch("/members/{member_id}/bounce-status") @app.patch("/members/{member_id}/bounce-status")
async def reset_bounce_status(member_id: int, current_user: CurrentUser = require_write_access()): 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)""" """Reset bounce status for a member (e.g., after email address is corrected)"""
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@@ -1117,6 +1135,18 @@ async def reset_bounce_status(member_id: int, current_user: CurrentUser = requir
cursor.close() cursor.close()
return {"message": "Bounce status reset successfully"} return {"message": "Bounce status reset successfully"}
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 []
@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__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000) uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -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>
Mailing List Manager <span>Mailing List Manager</span>
</h1> </div>
<div class="auth-section"> <div class="menu-spacer"></div>
<div class="user-info" id="userInfo"> <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"> <div class="user-details">
<span class="user-name" id="currentUsername">User</span> <span class="user-name" id="currentUsername">User</span>
<span class="user-role" id="currentUserRole">role</span> <span class="user-role" id="currentUserRole">role</span>
</div> </div>
<button class="btn btn-secondary" id="logoutBtn">Logout</button> <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>
<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>

View File

@@ -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,12 +1212,36 @@ 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;
gap: var(--space-2); gap: var(--space-2);
@@ -970,6 +1261,18 @@ body {
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;
} }

View File

@@ -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('/');
} }

View File

@@ -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