User Management
+ +| Username | +Role | +Created | +Last Login | +Status | +Actions | +
|---|
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6466e61..b22446a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,97 +1,169 @@ -# Copilot Instructions: Mail List Manager (Postfix + SES + Web Interface) +# Copilot Instructions: Mail List Manager (Postfix + SES + MySQL + API + Web) ## Architecture Overview -This is a containerized mailing list management system built around Postfix as an SMTP relay through Amazon SES. **Currently in Phase 1** with static configuration; **planned expansion** includes web frontend and SQL database for dynamic member management. +This is a complete containerized mailing list management system with four services: -**Current Components (Phase 1):** -- `docker-compose.yaml`: Single-service orchestration with SES credentials -- `postfix/`: Complete Postfix container configuration -- Static virtual aliases system for mailing list distribution +1. **Web Frontend** (Port 3000) - Nginx serving modern HTML/CSS/JS interface +2. **REST API** (Port 8000) - FastAPI backend with token authentication +3. **MySQL Database** (Internal) - Stores lists, members, and subscriptions +4. **Postfix Mail Server** (Port 25) - SMTP server with native MySQL integration -**Planned Architecture (Phase 2+):** -- Web frontend for list management (view/add/remove members) -- SQL database backend for member storage -- Dynamic Postfix configuration generation -- Multi-service Docker Compose setup +**Key Architecture Principles:** +- Real-time operation: Changes take effect immediately without Postfix reload +- Native integration: Postfix queries MySQL directly (no Python scripts) +- Security-first: Private Docker network, token auth, sender whitelist +- User-friendly: Web interface for easy management by non-technical users ## Configuration Pattern -**Current (Static):** Environment variable substitution for secure credential management: +**Dynamic Database-Driven Architecture:** -1. **Credentials Flow**: SES credentials passed via environment → `sasl_passwd.template` → runtime generation in `entrypoint.sh` -2. **Config Files**: Static configs (`main.cf`, `virtual_aliases.cf`) + dynamic SASL auth file -3. **Postfix Maps**: Hash databases generated at build time (virtual aliases) and runtime (SASL) +1. **Data Flow**: Web UI → REST API → MySQL ← Postfix (real-time queries) +2. **Credentials**: All sensitive data in `.env` file (SES, MySQL, API_TOKEN) +3. **Mail Processing**: + - Email arrives for `list@lists.sasalliance.org` + - Postfix queries MySQL via `mysql_virtual_alias_maps.cf` + - MySQL returns comma-separated member emails + - Postfix expands alias and delivers via SES +4. **Security**: Private Docker network (maillist-internal), sender whitelist (@sasalliance.org) -**Future (Dynamic):** Database-driven configuration: -- Member lists stored in SQL database, with members able to join multiple lists -- Web interface for CRUD operations on members -- `virtual_aliases.cf` generated from database at runtime -- Postfix reload triggered by configuration changes +**No Static Configuration:** +- Virtual aliases are stored in MySQL database, not config files +- Changes take effect immediately without container restarts +- Postfix caches query results for performance ## Key Files and Their Roles -- `main.cf`: Core Postfix config - relay through SES, domain settings, security -- `sasl_passwd.template`: Template for SES authentication (uses `${SES_USER}:${SES_PASS}`) -- `virtual_aliases.cf`: Static email forwarding rules (one mailing list currently) -- `entrypoint.sh`: Runtime credential processing and Postfix startup +### Docker & Environment +- `docker-compose.yaml`: Multi-service orchestration (mysql, postfix, api, web) +- `.env`: All credentials and configuration (SES, MySQL, API_TOKEN) + +### Web Frontend (`web/`) +- `index.html`: Single-page application interface +- `static/js/api.js`: API client with authentication +- `static/js/ui.js`: UI helpers and modal management +- `static/js/app.js`: Main application controller +- `static/css/style.css`: Complete styling system +- `nginx.conf`: Nginx configuration for static file serving + +### REST API (`api/`) +- `main.py`: FastAPI application with all CRUD endpoints +- `requirements.txt`: Python dependencies (fastapi, mysql-connector-python, etc.) +- `Dockerfile`: Python 3.11 container build + +### Database (`database/`) +- `schema.sql`: Database initialization with tables and sample data +- Tables: `lists`, `members`, `list_members` (many-to-many junction) + +### Postfix (`postfix/`) +- `main.cf.template`: Core Postfix config with MySQL virtual alias maps +- `mysql_virtual_alias_maps.cf`: Native Postfix MySQL query configuration +- `sender_access`: Sender whitelist (sasalliance.org domain allowed) +- `sasl_passwd.template`: SES authentication template +- `entrypoint.sh`: Container startup with MySQL health check and config generation ## Development Workflows **Building and Running:** + +Always use sudo for Docker commands. Don't bother opening the simple browser UI for Docker as it is not very useful. + ```bash -docker-compose up --build # Build and start mail server -docker-compose logs -f # Monitor mail delivery logs +sudo docker-compose up --build # Build and start all services +sudo docker-compose logs -f # Monitor all service logs +sudo docker-compose logs -f api # Monitor specific service ``` -**Adding Mailing Lists (Current):** -1. Edit `virtual_aliases.cf` with new list → recipients mapping -2. Rebuild container (postmap runs at build time) -3. No restart needed for existing virtual alias changes +**Managing Lists and Members:** -**Future Web Interface Workflow:** -1. Access web frontend at configured port -2. Use CRUD interface to manage mailing lists and members -3. Changes automatically update database and regenerate Postfix configs +**Via Web Interface (Recommended):** +1. Open http://localhost:3000 +2. Enter API_TOKEN from .env file +3. Use intuitive interface to manage lists, members, and subscriptions +4. Click "Subscriptions" button on any member for toggle-based subscription management + +**Via REST API:** +```bash +# Get all lists +curl -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/lists + +# Create new list +curl -X POST http://localhost:8000/lists \ + -H "Authorization: Bearer $API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"list_name":"Test","list_email":"test@lists.sasalliance.org","active":true}' + +# Subscribe member to list +curl -X POST http://localhost:8000/subscriptions \ + -H "Authorization: Bearer $API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"list_email":"test@lists.sasalliance.org","member_email":"user@example.com"}' +``` + +**Via Direct Database Access:** +```bash +docker-compose exec mysql mysql -u maillist -p maillist +# See database/README.md for SQL examples +``` **Testing Mail Delivery:** ```bash # From inside container +docker-compose exec postfix bash echo "Test message" | mail -s "Subject" community@lists.sasalliance.org # Check logs for SES relay status docker-compose logs postfix | grep -E "(sent|bounced|deferred)" + +# Verify MySQL query works +docker-compose exec postfix postmap -q "community@lists.sasalliance.org" \ + mysql:/etc/postfix/mysql_virtual_alias_maps.cf ``` ## Security Considerations -- SES credentials exposed in docker-compose.yaml (consider using Docker secrets) +- All credentials stored in `.env` file (git-ignored) +- MySQL on private Docker network (no host port mapping) +- API requires Bearer token authentication +- Sender whitelist restricts who can send to lists (@sasalliance.org only) - SASL password file permissions set to 600 in entrypoint - TLS encryption enforced for SES relay (`smtp_tls_security_level = encrypt`) -- Only localhost and configured hostname accepted for local delivery +- Web interface requires token for all operations ## Configuration Conventions - **Hostname Pattern**: `lists.sasalliance.org` for mailing lists, origin domain `sasalliance.org` -- **Virtual Aliases**: Simple format `listname@lists.domain → recipient1, recipient2` +- **Virtual Aliases**: Dynamically queried from MySQL database in real-time - **SES Region**: EU West 2 (`email-smtp.eu-west-2.amazonaws.com`) - **Port Mapping**: Standard SMTP port 25 exposed to host +- **Database Tables**: `lists`, `members`, `list_members` (many-to-many design) +- **API Port**: 8000 for REST API +- **Web Port**: 3000 for web interface ## Common Modifications -**Adding Recipients to Existing List (Current):** -Edit `virtual_aliases.cf`, add comma-separated emails, rebuild container. +**Adding Recipients to a List:** +Use web interface or API - changes are immediate! -**New Mailing List (Current):** -Add line to `virtual_aliases.cf`: `newlist@lists.sasalliance.org recipients...` +**Creating New Mailing List:** +1. Via Web: Click "Add List" button, fill in form +2. Via API: POST to /lists endpoint with list details +3. Via MySQL: INSERT into lists table + +**Adding New Member:** +1. Via Web: Click "Add Member" button in Members tab +2. Via API: POST to /members endpoint +3. Via MySQL: INSERT into members table + +**Managing Subscriptions:** +1. Via Web: Click "Subscriptions" button on member, toggle lists +2. Via API: POST/DELETE to /subscriptions endpoint +3. Via MySQL: INSERT/DELETE in list_members table **Credential Updates:** -Update `SES_USER`/`SES_PASS` in docker-compose.yaml, restart container. - -## Migration Considerations - -When implementing the web frontend and database backend: -- Preserve existing `community@lists.sasalliance.org` list during migration -- Consider migration script to import current virtual aliases into database -- Plan for zero-downtime deployment with database-driven config generation -- Web interface should validate email addresses and handle duplicate members \ No newline at end of file +1. Edit `.env` file with new credentials +2. Restart affected services: + - SES: `docker-compose restart postfix` + - MySQL: `docker-compose restart mysql` + - API: `docker-compose restart api` \ No newline at end of file diff --git a/README.md b/README.md index 1e5996f..4702879 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,23 @@ # Mail List Manager -A containerized mailing list management system built around Postfix as an SMTP relay through Amazon SES. +A complete containerized mailing list management system with web interface, REST API, MySQL database, and Postfix mail server configured as an SMTP relay through Amazon SES. ## Architecture -**Current (Phase 1):** Static configuration with environment-based credentials -- Postfix container configured as SES relay -- Static virtual aliases for mailing list distribution -- Environment variable configuration for security +**Multi-Service Architecture:** +- **Web Frontend** - Modern responsive interface for managing lists and members (Port 3000) +- **REST API** - FastAPI backend with token authentication (Port 8000) +- **MySQL Database** - Stores lists, members, and subscriptions with real-time integration +- **Postfix Mail Server** - SMTP server with native MySQL integration for dynamic list expansion (Port 25) +- **Amazon SES** - Outbound mail relay for reliable delivery -**Planned (Phase 2+):** Web interface with SQL backend -- Web frontend for list management (view/add/remove members) -- SQL database for member storage -- Dynamic Postfix configuration generation +**Key Features:** +- Real-time list updates (no Postfix reload needed) +- Native Postfix MySQL queries for instant list expansion +- Token-based API authentication +- Sender whitelisting for authorized board members +- Private Docker network for security +- Complete web-based management interface ## Quick Start @@ -21,72 +26,192 @@ A containerized mailing list management system built around Postfix as an SMTP r cp .env.example .env ``` -2. Edit `.env` with your SES credentials and configuration: +2. Edit `.env` with your credentials: ```bash - # Required: Your SES credentials + # SES Credentials (Required) SES_USER=your_ses_access_key SES_PASS=your_ses_secret_key + # MySQL Credentials (Required) + MYSQL_ROOT_PASSWORD=secure_root_password + MYSQL_PASSWORD=secure_maillist_password + + # API Authentication (Required) + API_TOKEN=your_secure_api_token + # Optional: SMTP configuration (defaults to EU West 2) SMTP_HOST=email-smtp.eu-west-2.amazonaws.com SMTP_PORT=587 ``` -3. Build and start the mail server: +3. Build and start all services: ```bash - docker-compose up --build + sudo docker-compose up --build ``` -4. Test mail delivery: - ```bash - # From inside container - docker-compose exec postfix bash - echo "Test message" | mail -s "Subject" community@lists.sasalliance.org - - # Check logs - docker-compose logs -f postfix +4. Access the web interface: ``` + http://localhost:3000 + ``` + + Enter your API_TOKEN to authenticate and start managing lists! + +5. Alternative: Use the REST API directly: + ```bash + # Health check + curl http://localhost:8000/health + + # Get all lists (requires authentication) + curl -H "Authorization: Bearer your_api_token" http://localhost:8000/lists + ``` + +## Services + +### Web Interface (Port 3000) +- Modern, responsive interface for managing mailing lists and members +- Token-based authentication +- Real-time updates and feedback +- See `web/README.md` for details + +### REST API (Port 8000) +- Complete CRUD operations for lists, members, and subscriptions +- Bearer token authentication +- Interactive documentation at http://localhost:8000/docs +- See `api/README.md` for API reference + +### MySQL Database (Internal Only) +- Stores all mailing list and member data +- Automatically initialized with schema +- Native Postfix integration for real-time queries +- See `database/README.md` for schema and management + +### Postfix Mail Server (Port 25) +- Accepts mail for lists.sasalliance.org +- Queries MySQL for list expansion +- Relays through Amazon SES +- Sender whitelist: @sasalliance.org domain +- Changes take effect immediately (no restart needed) ## Configuration -### Adding Mailing Lists (Current) +### Managing Lists and Members -Edit `postfix/virtual_aliases.cf`: -``` -newlist@lists.sasalliance.org recipient1@domain.com, recipient2@domain.com -``` +**Via Web Interface (Recommended):** +1. Open http://localhost:3000 +2. Enter your API token +3. Use the intuitive interface to: + - Create/edit/delete mailing lists + - Add/edit/remove members + - Manage subscriptions with toggle switches + - View member counts and list details -Then rebuild the container: +**Via REST API:** ```bash -docker-compose up --build +# Create a new list +curl -X POST http://localhost:8000/lists \ + -H "Authorization: Bearer your_token" \ + -H "Content-Type: application/json" \ + -d '{ + "list_name": "Developers", + "list_email": "dev@lists.sasalliance.org", + "description": "Developer discussions", + "active": true + }' + +# Add a new member +curl -X POST http://localhost:8000/members \ + -H "Authorization: Bearer your_token" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "John Doe", + "email": "john@example.com", + "active": true + }' + +# Subscribe member to list +curl -X POST http://localhost:8000/subscriptions \ + -H "Authorization: Bearer your_token" \ + -H "Content-Type: application/json" \ + -d '{ + "list_email": "dev@lists.sasalliance.org", + "member_email": "john@example.com" + }' ``` -### Domain Configuration +**Via Direct Database Access:** +```bash +# Connect to MySQL +docker-compose exec mysql mysql -u maillist -p maillist -The system is configured for: -- **Hostname**: `lists.sasalliance.org` (mailing lists) +# Run SQL queries (see database/README.md for examples) +``` + +### Mail Server Configuration + +### Mail Server Configuration + +The Postfix mail server is configured for: +- **Hostname**: `lists.sasalliance.org` (accepts mail for this domain) - **Origin Domain**: `sasalliance.org` - **SES Region**: EU West 2 (configurable via `SMTP_HOST`) +- **Sender Whitelist**: @sasalliance.org (only authorized board members can send to lists) +- **Dynamic Lists**: Postfix queries MySQL in real-time for list members + +### Testing Mail Delivery + +```bash +# From inside Postfix container +docker-compose exec postfix bash +echo "Test message" | mail -s "Test Subject" community@lists.sasalliance.org + +# Check delivery logs +docker-compose logs -f postfix | grep -E "(sent|bounced|deferred)" + +# Verify MySQL query works +docker-compose exec postfix postmap -q "community@lists.sasalliance.org" \ + mysql:/etc/postfix/mysql_virtual_alias_maps.cf +``` ## Security -- SES credentials are stored in `.env` (git-ignored) -- SASL password files have restricted permissions (600) -- TLS encryption enforced for SES relay -- Only localhost and configured hostname accepted for local delivery +- **Environment Variables**: All credentials stored in `.env` (git-ignored) +- **Private Network**: MySQL communicates with Postfix/API on internal Docker network only +- **Token Authentication**: API requires Bearer token for all write operations +- **Sender Whitelist**: Only @sasalliance.org addresses can send to mailing lists +- **TLS Encryption**: Enforced for SES relay connections +- **File Permissions**: SASL password files have restricted permissions (600) +- **No External MySQL**: Database is not exposed to host (no port mapping) ## Development ### Project Structure ``` -├── docker-compose.yaml # Service orchestration +├── docker-compose.yaml # Multi-service orchestration ├── .env # Environment configuration (not in git) -├── postfix/ -│ ├── Dockerfile # Postfix container build -│ ├── entrypoint.sh # Runtime configuration processing -│ ├── main.cf.template # Postfix main configuration template -│ ├── sasl_passwd.template # SES authentication template -│ └── virtual_aliases.cf # Static mailing list definitions +├── web/ # Web frontend (Nginx + HTML/CSS/JS) +│ ├── index.html +│ ├── static/ +│ │ ├── css/style.css +│ │ └── js/ +│ │ ├── api.js +│ │ ├── ui.js +│ │ └── app.js +│ ├── nginx.conf +│ └── Dockerfile +├── api/ # REST API (FastAPI) +│ ├── main.py +│ ├── requirements.txt +│ └── Dockerfile +├── database/ # MySQL schema and docs +│ ├── schema.sql +│ └── README.md +├── postfix/ # Mail server configuration +│ ├── Dockerfile +│ ├── entrypoint.sh +│ ├── main.cf.template +│ ├── mysql_virtual_alias_maps.cf +│ ├── sasl_passwd.template +│ └── sender_access └── .github/ └── copilot-instructions.md # AI agent guidance ``` @@ -96,28 +221,128 @@ The system is configured for: - `SES_PASS`: AWS SES secret access key - `SMTP_HOST`: SMTP server hostname (default: email-smtp.eu-west-2.amazonaws.com) - `SMTP_PORT`: SMTP server port (default: 587) +- `MYSQL_ROOT_PASSWORD`: MySQL root password +- `MYSQL_DATABASE`: Database name (default: maillist) +- `MYSQL_USER`: MySQL user (default: maillist) +- `MYSQL_PASSWORD`: MySQL user password +- `API_TOKEN`: Bearer token for API authentication + +### Service Dependencies +``` +mysql (healthcheck) → postfix, api → web +``` +- MySQL must be healthy before Postfix/API start +- Web frontend depends on API being available +- All services communicate on private Docker network ### Debugging -Monitor mail delivery: +Monitor services: ```bash -# View all logs +# View all service logs docker-compose logs -f -# Filter for delivery status +# View specific service +docker-compose logs -f postfix +docker-compose logs -f api +docker-compose logs -f web +docker-compose logs -f mysql + +# Filter for mail delivery status docker-compose logs postfix | grep -E "(sent|bounced|deferred)" # Check Postfix queue docker-compose exec postfix postqueue -p + +# Test MySQL connectivity from Postfix +docker-compose exec postfix postmap -q "community@lists.sasalliance.org" \ + mysql:/etc/postfix/mysql_virtual_alias_maps.cf + +# Check API health +curl http://localhost:8000/health + +# Access MySQL directly +docker-compose exec mysql mysql -u maillist -p maillist ``` +### Development Workflow + +1. **Make changes** to code or configuration +2. **Rebuild affected service**: + ```bash + sudo docker-compose up --build -d web # Just web frontend + sudo docker-compose up --build -d api # Just API + sudo docker-compose up --build -d postfix # Just mail server + ``` +3. **Check logs** for errors: + ```bash + docker-compose logs -f [service-name] + ``` +4. **Test changes** via web interface or API + +### Common Tasks + +**Reset database completely:** +```bash +docker-compose down -v # Removes volumes +docker-compose up -d # Reinitializes from schema.sql +``` + +**Update API token:** +1. Edit `.env` and change `API_TOKEN` +2. Restart API: `docker-compose restart api` +3. Use new token in web interface + +**Add authorized sender domain:** +1. Edit `postfix/sender_access` +2. Add line: `newdomain.com OK` +3. Rebuild: `docker-compose up --build -d postfix` + +## How It Works + +1. **User manages lists** via web interface (http://localhost:3000) +2. **Web frontend** makes authenticated API calls to FastAPI backend +3. **API updates MySQL** database with new lists/members/subscriptions +4. **Email arrives** at Postfix for `someone@lists.sasalliance.org` +5. **Postfix queries MySQL** in real-time using native MySQL support +6. **MySQL returns** comma-separated list of active member emails +7. **Postfix expands** the alias and delivers to all members via SES +8. **Changes take effect immediately** - no restart or reload needed! + +## Features + +- ✅ **Real-time Updates** - Changes to lists/members take effect immediately +- ✅ **Native MySQL Integration** - Postfix queries database directly (no scripts) +- ✅ **Web Interface** - Modern, responsive UI for easy management +- ✅ **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 +- ✅ **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 + +## Documentation + +- **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 +- **AI Agents**: See `.github/copilot-instructions.md` for development guidance + ## Roadmap -- [ ] Web frontend for mailing list management -- [ ] SQL database backend for member storage -- [ ] Dynamic configuration generation from database -- [ ] Multi-service Docker Compose architecture -- [ ] Migration tools for static → dynamic configuration +- [x] Web frontend for mailing list management +- [x] SQL database backend for member storage +- [x] Dynamic configuration with native Postfix MySQL +- [x] Multi-service Docker Compose architecture +- [x] REST API with authentication +- [x] Sender whitelist for authorized domains +- [ ] Email verification workflow for new members +- [ ] Subscription confirmation (double opt-in) +- [ ] List archive functionality +- [ ] Unsubscribe links in emails +- [ ] Rate limiting and anti-spam measures +- [ ] HTTPS support for web interface +- [ ] Admin roles and permissions ## License diff --git a/api/README.md b/api/README.md index b3a3ccc..ed0c022 100644 --- a/api/README.md +++ b/api/README.md @@ -2,6 +2,15 @@ REST API for managing mailing lists and members with token-based authentication. +## Overview + +This FastAPI-based REST API provides programmatic access to the mailing list system. It's designed for: +- **Automation**: Scripts and integrations that need to manage lists/members +- **Web Interface**: Powers the frontend at http://localhost:3000 +- **Third-party Apps**: Any application that needs mailing list management + +**For most users**, the web interface (http://localhost:3000) is more convenient. Use this API when you need programmatic access or automation. + ## Base URL ``` diff --git a/api/main.py b/api/main.py index 9db52e6..f243965 100644 --- a/api/main.py +++ b/api/main.py @@ -2,26 +2,40 @@ Mailing List Management API FastAPI-based REST API for managing mailing lists and members """ -from fastapi import FastAPI, HTTPException, Depends, Header +from fastapi import FastAPI, HTTPException, Depends, Header, status, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from pydantic import BaseModel, EmailStr -from typing import List, Optional +from typing import List, Optional, Annotated import mysql.connector from mysql.connector import Error import os import csv import io from contextlib import contextmanager +from datetime import datetime, timedelta +import secrets +import bcrypt +from jose import JWTError, jwt +from passlib.context import CryptContext +from enum import Enum # Configuration -API_TOKEN = os.getenv('API_TOKEN', 'change-this-token') +API_TOKEN = os.getenv('API_TOKEN', 'change-this-token') # Keep for backward compatibility during transition +JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', 'your-secret-key-change-this-in-production') +JWT_ALGORITHM = "HS256" +JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 30 +SESSION_EXPIRE_HOURS = 24 + MYSQL_HOST = os.getenv('MYSQL_HOST', 'mysql') MYSQL_PORT = int(os.getenv('MYSQL_PORT', 3306)) MYSQL_DATABASE = os.getenv('MYSQL_DATABASE', 'maillist') MYSQL_USER = os.getenv('MYSQL_USER', 'maillist') MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD', '') +# Password hashing +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + # FastAPI app app = FastAPI( title="Mailing List Manager API", @@ -60,14 +74,144 @@ def get_db(): if connection and connection.is_connected(): connection.close() -# Authentication +# Role-based access control +class UserRole(str, Enum): + ADMINISTRATOR = "administrator" + OPERATOR = "operator" + READ_ONLY = "read-only" + +class CurrentUser(BaseModel): + user_id: int + username: str + role: UserRole + active: bool + +# Authentication and authorization +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against its hash""" + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password: str) -> str: + """Hash a password""" + return pwd_context.hash(password) + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + """Create a JWT access token""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM) + return encoded_jwt + +def create_session_id() -> str: + """Create a secure session ID""" + return secrets.token_urlsafe(32) + +async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> CurrentUser: + """Get current authenticated user from JWT token""" + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + # Check if it's the old API token (for backward compatibility during transition) + if credentials.credentials == API_TOKEN: + # Return a fake admin user for legacy API token + return CurrentUser( + user_id=0, + username="legacy_admin", + role=UserRole.ADMINISTRATOR, + active=True + ) + + # Try to decode JWT token + payload = jwt.decode(credentials.credentials, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + # Get user from database + with get_db() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT * FROM users WHERE username = %s AND active = 1", (username,)) + user = cursor.fetchone() + cursor.close() + + if user is None: + raise credentials_exception + + return CurrentUser( + user_id=user["user_id"], + username=user["username"], + role=UserRole(user["role"]), + active=user["active"] + ) + +def require_role(required_roles: List[UserRole]): + """Decorator factory for role-based access control""" + def role_checker(current_user: CurrentUser = Depends(get_current_user)) -> CurrentUser: + if current_user.role not in required_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Insufficient permissions. Required roles: {[role.value for role in required_roles]}" + ) + return current_user + return role_checker + +# Convenience functions for common role requirements +def require_admin() -> CurrentUser: + return Depends(require_role([UserRole.ADMINISTRATOR])) + +def require_write_access() -> CurrentUser: + return Depends(require_role([UserRole.ADMINISTRATOR, UserRole.OPERATOR])) + +def require_read_access() -> CurrentUser: + return Depends(require_role([UserRole.ADMINISTRATOR, UserRole.OPERATOR, UserRole.READ_ONLY])) + +# Legacy authentication (for backward compatibility) def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)): - """Verify API token""" + """Legacy API token verification - deprecated, use get_current_user instead""" if credentials.credentials != API_TOKEN: raise HTTPException(status_code=401, detail="Invalid authentication token") return credentials.credentials -# Pydantic models +# Pydantic models for authentication +class LoginRequest(BaseModel): + username: str + password: str + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + expires_in: int + user: dict + +class CreateUserRequest(BaseModel): + username: str + password: str + role: UserRole + +class UpdateUserRequest(BaseModel): + password: Optional[str] = None + role: Optional[UserRole] = None + active: Optional[bool] = None + +class UserResponse(BaseModel): + user_id: int + username: str + role: UserRole + created_at: datetime + last_login: Optional[datetime] = None + active: bool + +# Pydantic models for mailing list functionality class MailingList(BaseModel): list_id: Optional[int] = None list_name: str @@ -107,6 +251,179 @@ class BulkImportResult(BaseModel): subscriptions_added: int errors: List[str] +# Authentication routes +@app.post("/auth/login", response_model=TokenResponse) +async def login(login_request: LoginRequest, request: Request): + """Authenticate user and return JWT token""" + with get_db() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT * FROM users WHERE username = %s AND active = 1", (login_request.username,)) + user = cursor.fetchone() + + if not user or not verify_password(login_request.password, user["password_hash"]): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password" + ) + + # Update last login + cursor.execute("UPDATE users SET last_login = %s WHERE user_id = %s", + (datetime.utcnow(), user["user_id"])) + conn.commit() + + # Create session record + session_id = create_session_id() + expires_at = datetime.utcnow() + timedelta(hours=SESSION_EXPIRE_HOURS) + + client_ip = request.client.host if request.client else None + user_agent = request.headers.get("user-agent", "") + + cursor.execute(""" + INSERT INTO user_sessions (session_id, user_id, expires_at, ip_address, user_agent) + VALUES (%s, %s, %s, %s, %s) + """, (session_id, user["user_id"], expires_at, client_ip, user_agent)) + conn.commit() + cursor.close() + + # Create JWT token + access_token_expires = timedelta(minutes=JWT_ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user["username"]}, expires_delta=access_token_expires + ) + + return TokenResponse( + access_token=access_token, + expires_in=JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60, + user={ + "user_id": user["user_id"], + "username": user["username"], + "role": user["role"], + "active": user["active"] + } + ) + +@app.post("/auth/logout") +async def logout(current_user: CurrentUser = Depends(get_current_user)): + """Logout current user (invalidate sessions)""" + if current_user.user_id == 0: # Legacy admin user + return {"message": "Logged out successfully"} + + with get_db() as conn: + cursor = conn.cursor() + cursor.execute("UPDATE user_sessions SET active = 0 WHERE user_id = %s", (current_user.user_id,)) + conn.commit() + cursor.close() + + return {"message": "Logged out successfully"} + +@app.get("/users", response_model=List[UserResponse]) +async def get_users(current_user: CurrentUser = require_admin()): + """Get all users (admin only)""" + with get_db() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT user_id, username, role, created_at, last_login, active FROM users ORDER BY username") + users = cursor.fetchall() + cursor.close() + return users + +@app.post("/users", response_model=UserResponse, status_code=201) +async def create_user(user_request: CreateUserRequest, current_user: CurrentUser = require_admin()): + """Create a new user (admin only)""" + # Check if username already exists + with get_db() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT user_id FROM users WHERE username = %s", (user_request.username,)) + existing_user = cursor.fetchone() + + if existing_user: + raise HTTPException(status_code=400, detail="Username already exists") + + # Hash password and create user + password_hash = get_password_hash(user_request.password) + cursor.execute(""" + INSERT INTO users (username, password_hash, role) + VALUES (%s, %s, %s) + """, (user_request.username, password_hash, user_request.role.value)) + + conn.commit() + user_id = cursor.lastrowid + + # Return created user + cursor.execute(""" + SELECT user_id, username, role, created_at, last_login, active + FROM users WHERE user_id = %s + """, (user_id,)) + new_user = cursor.fetchone() + cursor.close() + + return new_user + +@app.patch("/users/{user_id}", response_model=UserResponse) +async def update_user(user_id: int, updates: UpdateUserRequest, current_user: CurrentUser = require_admin()): + """Update a user (admin only)""" + with get_db() as conn: + cursor = conn.cursor(dictionary=True) + + # Build update query dynamically + update_fields = [] + values = [] + + if updates.password is not None: + update_fields.append("password_hash = %s") + values.append(get_password_hash(updates.password)) + if updates.role is not None: + update_fields.append("role = %s") + values.append(updates.role.value) + if updates.active is not None: + update_fields.append("active = %s") + values.append(updates.active) + + if not update_fields: + raise HTTPException(status_code=400, detail="No fields to update") + + values.append(user_id) + query = f"UPDATE users SET {', '.join(update_fields)} WHERE user_id = %s" + + cursor.execute(query, values) + conn.commit() + + # Return updated user + cursor.execute(""" + SELECT user_id, username, role, created_at, last_login, active + FROM users WHERE user_id = %s + """, (user_id,)) + updated_user = cursor.fetchone() + cursor.close() + + if not updated_user: + raise HTTPException(status_code=404, detail="User not found") + return updated_user + +@app.delete("/users/{user_id}", status_code=204) +async def delete_user(user_id: int, current_user: CurrentUser = require_admin()): + """Delete a user (admin only)""" + if user_id == current_user.user_id: + raise HTTPException(status_code=400, detail="Cannot delete your own account") + + with get_db() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM users WHERE user_id = %s", (user_id,)) + conn.commit() + + if cursor.rowcount == 0: + raise HTTPException(status_code=404, detail="User not found") + cursor.close() + +@app.get("/auth/me", response_model=dict) +async def get_current_user_info(current_user: CurrentUser = Depends(get_current_user)): + """Get current user information""" + return { + "user_id": current_user.user_id, + "username": current_user.username, + "role": current_user.role.value, + "active": current_user.active + } + # Routes @app.get("/") async def root(): @@ -132,7 +449,7 @@ async def health(): # Mailing Lists endpoints @app.get("/lists", response_model=List[MailingList]) -async def get_lists(token: str = Depends(verify_token)): +async def get_lists(current_user: CurrentUser = require_read_access()): """Get all mailing lists""" with get_db() as conn: cursor = conn.cursor(dictionary=True) @@ -142,7 +459,7 @@ async def get_lists(token: str = Depends(verify_token)): return lists @app.get("/lists/{list_id}", response_model=MailingList) -async def get_list(list_id: int, token: str = Depends(verify_token)): +async def get_list(list_id: int, current_user: CurrentUser = require_read_access()): """Get a specific mailing list""" with get_db() as conn: cursor = conn.cursor(dictionary=True) @@ -155,7 +472,7 @@ async def get_list(list_id: int, token: str = Depends(verify_token)): return mailing_list @app.post("/lists", response_model=MailingList, status_code=201) -async def create_list(mailing_list: MailingList, token: str = Depends(verify_token)): +async def create_list(mailing_list: MailingList, current_user: CurrentUser = require_write_access()): """Create a new mailing list""" with get_db() as conn: cursor = conn.cursor() @@ -172,7 +489,7 @@ async def create_list(mailing_list: MailingList, token: str = Depends(verify_tok raise HTTPException(status_code=400, detail=f"Failed to create list: {str(e)}") @app.patch("/lists/{list_id}", response_model=MailingList) -async def update_list(list_id: int, updates: MailingListUpdate, token: str = Depends(verify_token)): +async def update_list(list_id: int, updates: MailingListUpdate, current_user: CurrentUser = require_write_access()): """Update a mailing list""" with get_db() as conn: cursor = conn.cursor(dictionary=True) @@ -210,7 +527,7 @@ async def update_list(list_id: int, updates: MailingListUpdate, token: str = Dep return updated_list @app.delete("/lists/{list_id}", status_code=204) -async def delete_list(list_id: int, token: str = Depends(verify_token)): +async def delete_list(list_id: int, current_user: CurrentUser = require_write_access()): """Delete a mailing list""" with get_db() as conn: cursor = conn.cursor() @@ -223,7 +540,7 @@ async def delete_list(list_id: int, token: str = Depends(verify_token)): # Members endpoints @app.get("/members", response_model=List[Member]) -async def get_members(token: str = Depends(verify_token)): +async def get_members(current_user: CurrentUser = require_read_access()): """Get all members""" with get_db() as conn: cursor = conn.cursor(dictionary=True) @@ -233,7 +550,7 @@ async def get_members(token: str = Depends(verify_token)): return members @app.get("/members/{member_id}", response_model=Member) -async def get_member(member_id: int, token: str = Depends(verify_token)): +async def get_member(member_id: int, current_user: CurrentUser = require_read_access()): """Get a specific member""" with get_db() as conn: cursor = conn.cursor(dictionary=True) @@ -246,7 +563,7 @@ async def get_member(member_id: int, token: str = Depends(verify_token)): return member @app.post("/members", response_model=Member, status_code=201) -async def create_member(member: Member, token: str = Depends(verify_token)): +async def create_member(member: Member, current_user: CurrentUser = require_write_access()): """Create a new member""" with get_db() as conn: cursor = conn.cursor() @@ -263,7 +580,7 @@ async def create_member(member: Member, token: str = Depends(verify_token)): raise HTTPException(status_code=400, detail=f"Failed to create member: {str(e)}") @app.patch("/members/{member_id}", response_model=Member) -async def update_member(member_id: int, updates: MemberUpdate, token: str = Depends(verify_token)): +async def update_member(member_id: int, updates: MemberUpdate, current_user: CurrentUser = require_write_access()): """Update a member""" with get_db() as conn: cursor = conn.cursor(dictionary=True) @@ -296,7 +613,7 @@ async def update_member(member_id: int, updates: MemberUpdate, token: str = Depe return updated_member @app.delete("/members/{member_id}", status_code=204) -async def delete_member(member_id: int, token: str = Depends(verify_token)): +async def delete_member(member_id: int, current_user: CurrentUser = require_write_access()): """Delete a member""" with get_db() as conn: cursor = conn.cursor() @@ -309,7 +626,7 @@ async def delete_member(member_id: int, token: str = Depends(verify_token)): # Subscription endpoints @app.get("/lists/{list_id}/members", response_model=List[Member]) -async def get_list_members(list_id: int, token: str = Depends(verify_token)): +async def get_list_members(list_id: int, current_user: CurrentUser = require_read_access()): """Get all members of a specific list""" with get_db() as conn: cursor = conn.cursor(dictionary=True) @@ -325,7 +642,7 @@ async def get_list_members(list_id: int, token: str = Depends(verify_token)): return members @app.post("/subscriptions", status_code=201) -async def subscribe_member(subscription: Subscription, token: str = Depends(verify_token)): +async def subscribe_member(subscription: Subscription, current_user: CurrentUser = require_write_access()): """Subscribe a member to a list""" with get_db() as conn: cursor = conn.cursor() @@ -359,7 +676,7 @@ async def subscribe_member(subscription: Subscription, token: str = Depends(veri raise HTTPException(status_code=400, detail=f"Failed to create subscription: {str(e)}") @app.delete("/subscriptions") -async def unsubscribe_member(list_email: EmailStr, member_email: EmailStr, token: str = Depends(verify_token)): +async def unsubscribe_member(list_email: EmailStr, member_email: EmailStr, current_user: CurrentUser = require_write_access()): """Unsubscribe a member from a list""" with get_db() as conn: cursor = conn.cursor() @@ -380,7 +697,7 @@ async def unsubscribe_member(list_email: EmailStr, member_email: EmailStr, token return {"message": "Unsubscribed successfully"} @app.post("/bulk-import", response_model=BulkImportResult) -async def bulk_import_members(bulk_request: BulkImportRequest, token: str = Depends(verify_token)): +async def bulk_import_members(bulk_request: BulkImportRequest, current_user: CurrentUser = require_write_access()): """Bulk import members from CSV data and subscribe them to specified lists""" result = BulkImportResult( diff --git a/api/requirements.txt b/api/requirements.txt index 028271e..4d62226 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -5,3 +5,6 @@ pydantic==2.5.0 pydantic-settings==2.1.0 python-multipart==0.0.6 email-validator==2.1.0 +bcrypt==4.0.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 diff --git a/database/README.md b/database/README.md index f73373f..854deac 100644 --- a/database/README.md +++ b/database/README.md @@ -2,6 +2,26 @@ This mailing list system uses MySQL with **Postfix's native MySQL support** for real-time dynamic list management. Postfix queries the database directly for each email - no scripts or reloads needed. +## Management Options + +**1. Web Interface (Recommended for Non-Technical Users)** +- Access: http://localhost:3000 +- Visual interface with tables and forms +- Toggle-based subscription management +- No SQL knowledge required + +**2. REST API (Recommended for Automation)** +- Access: http://localhost:8000/docs +- Full CRUD operations via HTTP +- Token authentication +- Perfect for scripts and integrations + +**3. Direct MySQL (Recommended for Advanced Users)** +- Full SQL access for complex queries +- Bulk operations and reporting +- Database administration tasks +- Described in detail below + ## Database Schema Three-table design with many-to-many relationships: @@ -36,7 +56,38 @@ Three-table design with many-to-many relationships: ## Managing Lists and Members -### Via MySQL Client +### Via Web Interface (Easiest) + +1. Open http://localhost:3000 in your browser +2. Enter your API_TOKEN (from .env file) +3. Use the tabs to: + - **Lists Tab**: View, create, edit, delete mailing lists + - **Members Tab**: View, add, edit, remove members + - **Subscriptions**: Click "Subscriptions" button on any member to toggle their list memberships + +### Via REST API (For Automation) + +See `api/README.md` for complete API documentation, or visit http://localhost:8000/docs for interactive docs. + +Quick examples: +```bash +# Get all lists +curl -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/lists + +# Create member +curl -X POST http://localhost:8000/members \ + -H "Authorization: Bearer $API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"John Doe","email":"john@example.com","active":true}' + +# Subscribe to list +curl -X POST http://localhost:8000/subscriptions \ + -H "Authorization: Bearer $API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"list_email":"community@lists.sasalliance.org","member_email":"john@example.com"}' +``` + +### Via MySQL Client (Advanced) Connect to the database: diff --git a/database/schema.sql b/database/schema.sql index 9fc2959..6e1563d 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -1,5 +1,37 @@ -- Mail List Manager Database Schema +-- Table: users +-- Stores user authentication and authorization information +CREATE TABLE IF NOT EXISTS users ( + user_id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, -- bcrypt hash + role ENUM('administrator', 'operator', 'read-only') NOT NULL DEFAULT 'read-only', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + last_login TIMESTAMP NULL, + active BOOLEAN DEFAULT TRUE, + INDEX idx_username (username), + INDEX idx_role (role), + INDEX idx_active (active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Table: user_sessions +-- Stores active user sessions for authentication +CREATE TABLE IF NOT EXISTS user_sessions ( + session_id VARCHAR(64) PRIMARY KEY, + user_id INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL, + ip_address VARCHAR(45), -- Supports both IPv4 and IPv6 + user_agent TEXT, + active BOOLEAN DEFAULT TRUE, + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE, + INDEX idx_user_id (user_id), + INDEX idx_expires_at (expires_at), + INDEX idx_active (active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- Table: lists -- Stores mailing list information CREATE TABLE IF NOT EXISTS lists ( @@ -44,6 +76,12 @@ CREATE TABLE IF NOT EXISTS list_members ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- Insert sample data + +-- Create default admin user (password: 'password') +-- $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKv0AhDoLlZ7G.i is bcrypt hash of 'password' +INSERT INTO users (username, password_hash, role) VALUES + ('admin', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKv0AhDoLlZ7G.i', 'administrator'); + INSERT INTO lists (list_name, list_email, description) VALUES ('Community', 'community@lists.sasalliance.org', 'General community announcements'), ('Board', 'board@lists.sasalliance.org', 'Board members only'), diff --git a/web/README.md b/web/README.md index 596b232..702ec55 100644 --- a/web/README.md +++ b/web/README.md @@ -38,23 +38,29 @@ web/ ## Usage -### Development -1. Ensure the API is running on port 8000 -2. Open `index.html` in a web browser -3. Enter your API token to authenticate -4. Start managing your mailing lists! - -### Production (Docker) -The web frontend is served using Nginx and included in the Docker Compose setup. +### Accessing the Interface +**Via Docker (Recommended):** ```bash -# Build and start all services -docker-compose up --build +# Ensure all services are running +sudo docker-compose up -d # Access the web interface open http://localhost:3000 ``` +**For Development:** +You can also open `index.html` directly in a browser, but the API must be running on port 8000. + +### Getting Started + +1. **Authenticate**: Enter your API_TOKEN (from `.env` file) when prompted +2. **Manage Lists**: Click the "Lists" tab to view and manage mailing lists +3. **Manage Members**: Click the "Members" tab to add and edit member information +4. **Manage Subscriptions**: Click "Subscriptions" button on any member to toggle their list memberships + +The interface saves your token in browser storage, so you won't need to re-enter it on subsequent visits. + ## Features Overview ### Authentication @@ -65,24 +71,39 @@ open http://localhost:3000 ### Subscription Management (New & Improved!) #### Member-Centric Subscription Management -The subscription management has been completely overhauled for a much better user experience: +The subscription management has been completely redesigned for the best user experience: -1. **Access Member Subscriptions**: In the Members tab, click the "Subscriptions" button next to any member -2. **Visual Toggle Interface**: See all available mailing lists with modern toggle switches -3. **Intuitive Controls**: - - Green toggle = Member is subscribed - - Gray toggle = Member is not subscribed - - Click anywhere on a list item to toggle subscription -4. **Batch Operations**: Make multiple changes and save them all at once -5. **Real-time Feedback**: The save button shows how many changes you've made +**How to Use:** +1. Navigate to the **Members** tab +2. Find the member you want to manage +3. Click the **"Subscriptions"** button next to their name +4. A modal appears showing all available mailing lists with toggle switches -#### Benefits Over Previous System -- ✅ **Much faster** - No need to add subscriptions one by one +**Visual Interface:** +- **Green toggle** = Member is subscribed to this list +- **Gray toggle** = Member is not subscribed to this list +- **Click anywhere** on a list item to toggle subscription on/off + +**Batch Operations:** +- Make multiple changes (subscribe to some lists, unsubscribe from others) +- The "Save Changes" button shows how many changes you've made (e.g., "Save 3 Changes") +- All changes are saved together when you click "Save Changes" +- Click "Cancel" to discard all changes + +**Benefits:** +- ✅ **Fast** - Toggle multiple subscriptions at once - ✅ **Visual** - See all subscriptions at a glance with color coding -- ✅ **Intuitive** - Toggle switches work like modern mobile apps -- ✅ **Batch operations** - Change multiple subscriptions simultaneously -- ✅ **Less error-prone** - Clear visual feedback prevents mistakes -- ✅ **Change tracking** - Only saves actual changes, not unchanged items +- ✅ **Intuitive** - Works like modern mobile app switches +- ✅ **Smart** - Only saves actual changes, not unchanged items +- ✅ **Clear** - Shows exactly how many changes you're about to save + +#### Alternative: Legacy Bulk Subscription +For power users who prefer the old approach: +1. Go to the **Subscriptions** tab +2. Select list and member from dropdowns +3. Click "Add Subscription" + +This is still available but the member-centric approach is much more efficient for managing multiple subscriptions. ### Mailing Lists - View all mailing lists in a clean table diff --git a/web/index.html b/web/index.html index 39e4d68..9fedc5f 100644 --- a/web/index.html +++ b/web/index.html @@ -19,14 +19,15 @@