Compare commits
2 Commits
b54014ac76
...
b8a91103e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8a91103e9 | ||
|
|
35f710049a |
13
.env.example
13
.env.example
@@ -8,4 +8,15 @@ SES_PASS=your_ses_secret_access_key
|
||||
# Optional: SMTP server configuration
|
||||
# Default is EU West 2 - change if using different region
|
||||
SMTP_HOST=email-smtp.eu-west-2.amazonaws.com
|
||||
SMTP_PORT=587
|
||||
SMTP_PORT=587
|
||||
|
||||
# MySQL Database Configuration
|
||||
MYSQL_HOST=mysql
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_DATABASE=maillist
|
||||
MYSQL_USER=maillist
|
||||
MYSQL_PASSWORD=change_this_password
|
||||
MYSQL_ROOT_PASSWORD=change_this_root_password
|
||||
|
||||
# API Configuration
|
||||
API_TOKEN=change_this_to_a_secure_random_token
|
||||
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -24,7 +24,7 @@ This is a containerized mailing list management system built around Postfix as a
|
||||
3. **Postfix Maps**: Hash databases generated at build time (virtual aliases) and runtime (SASL)
|
||||
|
||||
**Future (Dynamic):** Database-driven configuration:
|
||||
- Member lists stored in SQL database
|
||||
- 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
|
||||
|
||||
16
api/Dockerfile
Normal file
16
api/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
222
api/README.md
Normal file
222
api/README.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# Mailing List API Documentation
|
||||
|
||||
REST API for managing mailing lists and members with token-based authentication.
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
http://localhost:8000
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
All endpoints (except `/` and `/health`) require Bearer token authentication.
|
||||
|
||||
Add the token to request headers:
|
||||
```
|
||||
Authorization: Bearer your_api_token_here
|
||||
```
|
||||
|
||||
Set your API token in `.env`:
|
||||
```
|
||||
API_TOKEN=your_secure_token_here
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Health Check
|
||||
|
||||
**GET** `/health`
|
||||
- No authentication required
|
||||
- Returns API and database status
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
### Mailing Lists
|
||||
|
||||
**GET** `/lists`
|
||||
- Get all mailing lists
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer your_token" http://localhost:8000/lists
|
||||
```
|
||||
|
||||
**GET** `/lists/{list_id}`
|
||||
- Get a specific mailing list
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer your_token" http://localhost:8000/lists/1
|
||||
```
|
||||
|
||||
**POST** `/lists`
|
||||
- Create a new mailing list
|
||||
|
||||
```bash
|
||||
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
|
||||
}'
|
||||
```
|
||||
|
||||
**PATCH** `/lists/{list_id}`
|
||||
- Update a mailing list
|
||||
|
||||
```bash
|
||||
curl -X PATCH http://localhost:8000/lists/1 \
|
||||
-H "Authorization: Bearer your_token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"description": "Updated description",
|
||||
"active": false
|
||||
}'
|
||||
```
|
||||
|
||||
**DELETE** `/lists/{list_id}`
|
||||
- Delete a mailing list
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:8000/lists/1 \
|
||||
-H "Authorization: Bearer your_token"
|
||||
```
|
||||
|
||||
### Members
|
||||
|
||||
**GET** `/members`
|
||||
- Get all members
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer your_token" http://localhost:8000/members
|
||||
```
|
||||
|
||||
**GET** `/members/{member_id}`
|
||||
- Get a specific member
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer your_token" http://localhost:8000/members/1
|
||||
```
|
||||
|
||||
**POST** `/members`
|
||||
- Create a new member
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/members \
|
||||
-H "Authorization: Bearer your_token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "John Doe",
|
||||
"email": "john.doe@example.com",
|
||||
"active": true
|
||||
}'
|
||||
```
|
||||
|
||||
**PATCH** `/members/{member_id}`
|
||||
- Update a member
|
||||
|
||||
```bash
|
||||
curl -X PATCH http://localhost:8000/members/1 \
|
||||
-H "Authorization: Bearer your_token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "John Q. Doe",
|
||||
"active": true
|
||||
}'
|
||||
```
|
||||
|
||||
**DELETE** `/members/{member_id}`
|
||||
- Delete a member
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:8000/members/1 \
|
||||
-H "Authorization: Bearer your_token"
|
||||
```
|
||||
|
||||
### Subscriptions
|
||||
|
||||
**GET** `/lists/{list_id}/members`
|
||||
- Get all members of a specific list
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer your_token" http://localhost:8000/lists/1/members
|
||||
```
|
||||
|
||||
**POST** `/subscriptions`
|
||||
- Subscribe a member to a list
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/subscriptions \
|
||||
-H "Authorization: Bearer your_token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"list_email": "community@lists.sasalliance.org",
|
||||
"member_email": "john.doe@example.com",
|
||||
"active": true
|
||||
}'
|
||||
```
|
||||
|
||||
**DELETE** `/subscriptions?list_email=X&member_email=Y`
|
||||
- Unsubscribe a member from a list
|
||||
|
||||
```bash
|
||||
curl -X DELETE "http://localhost:8000/subscriptions?list_email=community@lists.sasalliance.org&member_email=john.doe@example.com" \
|
||||
-H "Authorization: Bearer your_token"
|
||||
```
|
||||
|
||||
## Interactive Documentation
|
||||
|
||||
Once the API is running, visit:
|
||||
|
||||
- **Swagger UI**: http://localhost:8000/docs
|
||||
- **ReDoc**: http://localhost:8000/redoc
|
||||
|
||||
These provide interactive API documentation where you can test endpoints directly in your browser.
|
||||
|
||||
## Example Workflow
|
||||
|
||||
1. **Create a new member:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/members \
|
||||
-H "Authorization: Bearer your_token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Jane Smith", "email": "jane@example.com", "active": true}'
|
||||
```
|
||||
|
||||
2. **Create a new mailing list:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/lists \
|
||||
-H "Authorization: Bearer your_token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"list_name": "Marketing", "list_email": "marketing@lists.sasalliance.org", "active": true}'
|
||||
```
|
||||
|
||||
3. **Subscribe the member to the list:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/subscriptions \
|
||||
-H "Authorization: Bearer your_token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"list_email": "marketing@lists.sasalliance.org", "member_email": "jane@example.com"}'
|
||||
```
|
||||
|
||||
4. **Verify the subscription:**
|
||||
```bash
|
||||
curl -H "Authorization: Bearer your_token" http://localhost:8000/lists/1/members
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
- `401 Unauthorized` - Invalid or missing authentication token
|
||||
- `404 Not Found` - Resource not found
|
||||
- `400 Bad Request` - Invalid request data
|
||||
- `500 Internal Server Error` - Database or server error
|
||||
|
||||
## Notes
|
||||
|
||||
- All changes take effect immediately in Postfix (no reload needed)
|
||||
- Email validation is enforced on all email fields
|
||||
- Deleting a list or member also removes associated subscriptions (CASCADE)
|
||||
360
api/main.py
Normal file
360
api/main.py
Normal file
@@ -0,0 +1,360 @@
|
||||
"""
|
||||
Mailing List Management API
|
||||
FastAPI-based REST API for managing mailing lists and members
|
||||
"""
|
||||
from fastapi import FastAPI, HTTPException, Depends, Header
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import List, Optional
|
||||
import mysql.connector
|
||||
from mysql.connector import Error
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
|
||||
# Configuration
|
||||
API_TOKEN = os.getenv('API_TOKEN', 'change-this-token')
|
||||
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', '')
|
||||
|
||||
# FastAPI app
|
||||
app = FastAPI(
|
||||
title="Mailing List Manager API",
|
||||
description="API for managing mailing lists and members",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
# Database connection
|
||||
@contextmanager
|
||||
def get_db():
|
||||
"""Database connection context manager"""
|
||||
connection = None
|
||||
try:
|
||||
connection = mysql.connector.connect(
|
||||
host=MYSQL_HOST,
|
||||
port=MYSQL_PORT,
|
||||
database=MYSQL_DATABASE,
|
||||
user=MYSQL_USER,
|
||||
password=MYSQL_PASSWORD
|
||||
)
|
||||
yield connection
|
||||
except Error as e:
|
||||
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
||||
finally:
|
||||
if connection and connection.is_connected():
|
||||
connection.close()
|
||||
|
||||
# Authentication
|
||||
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""Verify API token"""
|
||||
if credentials.credentials != API_TOKEN:
|
||||
raise HTTPException(status_code=401, detail="Invalid authentication token")
|
||||
return credentials.credentials
|
||||
|
||||
# Pydantic models
|
||||
class MailingList(BaseModel):
|
||||
list_id: Optional[int] = None
|
||||
list_name: str
|
||||
list_email: EmailStr
|
||||
description: Optional[str] = None
|
||||
active: bool = True
|
||||
|
||||
class MailingListUpdate(BaseModel):
|
||||
list_name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
active: Optional[bool] = None
|
||||
|
||||
class Member(BaseModel):
|
||||
member_id: Optional[int] = None
|
||||
name: str
|
||||
email: EmailStr
|
||||
active: bool = True
|
||||
|
||||
class MemberUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
active: Optional[bool] = None
|
||||
|
||||
class Subscription(BaseModel):
|
||||
list_email: EmailStr
|
||||
member_email: EmailStr
|
||||
active: bool = True
|
||||
|
||||
# Routes
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""API information"""
|
||||
return {
|
||||
"name": "Mailing List Manager API",
|
||||
"version": "1.0.0",
|
||||
"status": "running"
|
||||
}
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Health check endpoint"""
|
||||
try:
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.fetchone()
|
||||
cursor.close()
|
||||
return {"status": "healthy", "database": "connected"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=f"Unhealthy: {str(e)}")
|
||||
|
||||
# Mailing Lists endpoints
|
||||
@app.get("/lists", response_model=List[MailingList])
|
||||
async def get_lists(token: str = Depends(verify_token)):
|
||||
"""Get all mailing lists"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT * FROM lists ORDER BY list_name")
|
||||
lists = cursor.fetchall()
|
||||
cursor.close()
|
||||
return lists
|
||||
|
||||
@app.get("/lists/{list_id}", response_model=MailingList)
|
||||
async def get_list(list_id: int, token: str = Depends(verify_token)):
|
||||
"""Get a specific mailing list"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT * FROM lists WHERE list_id = %s", (list_id,))
|
||||
mailing_list = cursor.fetchone()
|
||||
cursor.close()
|
||||
|
||||
if not mailing_list:
|
||||
raise HTTPException(status_code=404, detail="List not found")
|
||||
return mailing_list
|
||||
|
||||
@app.post("/lists", response_model=MailingList, status_code=201)
|
||||
async def create_list(mailing_list: MailingList, token: str = Depends(verify_token)):
|
||||
"""Create a new mailing list"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute(
|
||||
"INSERT INTO lists (list_name, list_email, description, active) VALUES (%s, %s, %s, %s)",
|
||||
(mailing_list.list_name, mailing_list.list_email, mailing_list.description, mailing_list.active)
|
||||
)
|
||||
conn.commit()
|
||||
mailing_list.list_id = cursor.lastrowid
|
||||
cursor.close()
|
||||
return mailing_list
|
||||
except Error as e:
|
||||
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)):
|
||||
"""Update a mailing list"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# Build update query dynamically
|
||||
update_fields = []
|
||||
values = []
|
||||
|
||||
if updates.list_name is not None:
|
||||
update_fields.append("list_name = %s")
|
||||
values.append(updates.list_name)
|
||||
if updates.description is not None:
|
||||
update_fields.append("description = %s")
|
||||
values.append(updates.description)
|
||||
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(list_id)
|
||||
query = f"UPDATE lists SET {', '.join(update_fields)} WHERE list_id = %s"
|
||||
|
||||
cursor.execute(query, values)
|
||||
conn.commit()
|
||||
|
||||
# Return updated list
|
||||
cursor.execute("SELECT * FROM lists WHERE list_id = %s", (list_id,))
|
||||
updated_list = cursor.fetchone()
|
||||
cursor.close()
|
||||
|
||||
if not updated_list:
|
||||
raise HTTPException(status_code=404, detail="List not found")
|
||||
return updated_list
|
||||
|
||||
@app.delete("/lists/{list_id}", status_code=204)
|
||||
async def delete_list(list_id: int, token: str = Depends(verify_token)):
|
||||
"""Delete a mailing list"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM lists WHERE list_id = %s", (list_id,))
|
||||
conn.commit()
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="List not found")
|
||||
cursor.close()
|
||||
|
||||
# Members endpoints
|
||||
@app.get("/members", response_model=List[Member])
|
||||
async def get_members(token: str = Depends(verify_token)):
|
||||
"""Get all members"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT * FROM members ORDER BY name")
|
||||
members = cursor.fetchall()
|
||||
cursor.close()
|
||||
return members
|
||||
|
||||
@app.get("/members/{member_id}", response_model=Member)
|
||||
async def get_member(member_id: int, token: str = Depends(verify_token)):
|
||||
"""Get a specific member"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT * FROM members WHERE member_id = %s", (member_id,))
|
||||
member = cursor.fetchone()
|
||||
cursor.close()
|
||||
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
return member
|
||||
|
||||
@app.post("/members", response_model=Member, status_code=201)
|
||||
async def create_member(member: Member, token: str = Depends(verify_token)):
|
||||
"""Create a new member"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute(
|
||||
"INSERT INTO members (name, email, active) VALUES (%s, %s, %s)",
|
||||
(member.name, member.email, member.active)
|
||||
)
|
||||
conn.commit()
|
||||
member.member_id = cursor.lastrowid
|
||||
cursor.close()
|
||||
return member
|
||||
except Error as e:
|
||||
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)):
|
||||
"""Update a member"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
update_fields = []
|
||||
values = []
|
||||
|
||||
if updates.name is not None:
|
||||
update_fields.append("name = %s")
|
||||
values.append(updates.name)
|
||||
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(member_id)
|
||||
query = f"UPDATE members SET {', '.join(update_fields)} WHERE member_id = %s"
|
||||
|
||||
cursor.execute(query, values)
|
||||
conn.commit()
|
||||
|
||||
cursor.execute("SELECT * FROM members WHERE member_id = %s", (member_id,))
|
||||
updated_member = cursor.fetchone()
|
||||
cursor.close()
|
||||
|
||||
if not updated_member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
return updated_member
|
||||
|
||||
@app.delete("/members/{member_id}", status_code=204)
|
||||
async def delete_member(member_id: int, token: str = Depends(verify_token)):
|
||||
"""Delete a member"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM members WHERE member_id = %s", (member_id,))
|
||||
conn.commit()
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
cursor.close()
|
||||
|
||||
# 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)):
|
||||
"""Get all members of a specific list"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("""
|
||||
SELECT m.*
|
||||
FROM members m
|
||||
JOIN list_members lm ON m.member_id = lm.member_id
|
||||
WHERE lm.list_id = %s AND lm.active = 1
|
||||
ORDER BY m.name
|
||||
""", (list_id,))
|
||||
members = cursor.fetchall()
|
||||
cursor.close()
|
||||
return members
|
||||
|
||||
@app.post("/subscriptions", status_code=201)
|
||||
async def subscribe_member(subscription: Subscription, token: str = Depends(verify_token)):
|
||||
"""Subscribe a member to a list"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
# Get list_id and member_id
|
||||
cursor.execute("SELECT list_id FROM lists WHERE list_email = %s", (subscription.list_email,))
|
||||
list_result = cursor.fetchone()
|
||||
if not list_result:
|
||||
raise HTTPException(status_code=404, detail="List not found")
|
||||
|
||||
cursor.execute("SELECT member_id FROM members WHERE email = %s", (subscription.member_email,))
|
||||
member_result = cursor.fetchone()
|
||||
if not member_result:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
list_id = list_result[0]
|
||||
member_id = member_result[0]
|
||||
|
||||
# Insert subscription
|
||||
cursor.execute(
|
||||
"INSERT INTO list_members (list_id, member_id, active) VALUES (%s, %s, %s)",
|
||||
(list_id, member_id, subscription.active)
|
||||
)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
|
||||
return {"message": "Subscription created", "list_email": subscription.list_email, "member_email": subscription.member_email}
|
||||
except Error as e:
|
||||
if "Duplicate entry" in str(e):
|
||||
raise HTTPException(status_code=400, detail="Member already subscribed to this list")
|
||||
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)):
|
||||
"""Unsubscribe a member from a list"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
DELETE lm FROM list_members lm
|
||||
JOIN lists l ON lm.list_id = l.list_id
|
||||
JOIN members m ON lm.member_id = m.member_id
|
||||
WHERE l.list_email = %s AND m.email = %s
|
||||
""", (list_email, member_email))
|
||||
|
||||
conn.commit()
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||
|
||||
cursor.close()
|
||||
return {"message": "Unsubscribed successfully"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
7
api/requirements.txt
Normal file
7
api/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
mysql-connector-python==8.2.0
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.1.0
|
||||
python-multipart==0.0.6
|
||||
email-validator==2.1.0
|
||||
30
api/test_api.sh
Executable file
30
api/test_api.sh
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# Quick test script for the Mailing List API
|
||||
|
||||
API_URL="http://localhost:8000"
|
||||
TOKEN="your_api_token_here_change_this"
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${BLUE}Testing Mailing List API${NC}\n"
|
||||
|
||||
echo -e "${GREEN}1. Health Check:${NC}"
|
||||
curl -s $API_URL/health | jq .
|
||||
echo -e "\n"
|
||||
|
||||
echo -e "${GREEN}2. Get All Lists:${NC}"
|
||||
curl -s -H "Authorization: Bearer $TOKEN" $API_URL/lists | jq .
|
||||
echo -e "\n"
|
||||
|
||||
echo -e "${GREEN}3. Get All Members:${NC}"
|
||||
curl -s -H "Authorization: Bearer $TOKEN" $API_URL/members | jq .
|
||||
echo -e "\n"
|
||||
|
||||
echo -e "${GREEN}4. Get Members of Community List:${NC}"
|
||||
curl -s -H "Authorization: Bearer $TOKEN" $API_URL/lists/1/members | jq .
|
||||
echo -e "\n"
|
||||
|
||||
echo -e "${BLUE}Visit http://localhost:8000/docs for interactive API documentation${NC}"
|
||||
137
database/README.md
Normal file
137
database/README.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Database-Driven Mailing List Management
|
||||
|
||||
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.
|
||||
|
||||
## Database Schema
|
||||
|
||||
Three-table design with many-to-many relationships:
|
||||
|
||||
### Tables
|
||||
|
||||
**`lists`** - Mailing list definitions
|
||||
- `list_id` (primary key)
|
||||
- `list_name` - Display name
|
||||
- `list_email` - Full email address (e.g., `community@lists.sasalliance.org`)
|
||||
- `description` - Optional description
|
||||
- `active` - Boolean flag to enable/disable list
|
||||
|
||||
**`members`** - Member information
|
||||
- `member_id` (primary key)
|
||||
- `name` - Display name
|
||||
- `email` - Email address
|
||||
- `active` - Boolean flag to enable/disable member
|
||||
|
||||
**`list_members`** - Subscription junction table
|
||||
- `list_id` + `member_id` (composite unique key)
|
||||
- `active` - Boolean flag to enable/disable subscription
|
||||
- Foreign keys to `lists` and `members`
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Incoming email** arrives for `community@lists.sasalliance.org`
|
||||
2. **Postfix queries MySQL** using the config in `mysql_virtual_alias_maps.cf`
|
||||
3. **Database returns** comma-separated list of active member emails
|
||||
4. **Postfix expands** the alias and delivers to all members
|
||||
5. **Changes take effect immediately** - no restart or reload needed!
|
||||
|
||||
## Managing Lists and Members
|
||||
|
||||
### Via MySQL Client
|
||||
|
||||
Connect to the database:
|
||||
|
||||
```bash
|
||||
docker-compose exec mysql mysql -u maillist -p maillist
|
||||
```
|
||||
|
||||
### Common Operations
|
||||
|
||||
**View all lists:**
|
||||
```sql
|
||||
SELECT list_id, list_name, list_email, active FROM lists;
|
||||
```
|
||||
|
||||
**View all members:**
|
||||
```sql
|
||||
SELECT member_id, name, email, active FROM members;
|
||||
```
|
||||
|
||||
**View subscriptions for a list:**
|
||||
```sql
|
||||
SELECT m.name, m.email
|
||||
FROM members m
|
||||
JOIN list_members lm ON m.member_id = lm.member_id
|
||||
JOIN lists l ON lm.list_id = l.list_id
|
||||
WHERE l.list_email = 'community@lists.sasalliance.org'
|
||||
AND lm.active = TRUE AND m.active = TRUE;
|
||||
```
|
||||
|
||||
**Add a new member:**
|
||||
```sql
|
||||
INSERT INTO members (name, email)
|
||||
VALUES ('John Doe', 'john.doe@example.com');
|
||||
```
|
||||
|
||||
**Subscribe member to list:**
|
||||
```sql
|
||||
-- Method 1: Using subqueries (one step)
|
||||
INSERT INTO list_members (list_id, member_id)
|
||||
VALUES (
|
||||
(SELECT list_id FROM lists WHERE list_email = 'community@lists.sasalliance.org'),
|
||||
(SELECT member_id FROM members WHERE email = 'john.doe@example.com')
|
||||
);
|
||||
```
|
||||
|
||||
**Unsubscribe member from list:**
|
||||
```sql
|
||||
DELETE FROM list_members
|
||||
WHERE list_id = (SELECT list_id FROM lists WHERE list_email = 'community@lists.sasalliance.org')
|
||||
AND member_id = (SELECT member_id FROM members WHERE email = 'john.doe@example.com');
|
||||
```
|
||||
|
||||
**Create a new mailing list:**
|
||||
```sql
|
||||
INSERT INTO lists (list_name, list_email, description)
|
||||
VALUES ('Developers', 'dev@lists.sasalliance.org', 'Developer discussions');
|
||||
```
|
||||
|
||||
**Disable a list (keeps data, stops delivery):**
|
||||
```sql
|
||||
UPDATE lists SET active = FALSE WHERE list_email = 'community@lists.sasalliance.org';
|
||||
```
|
||||
|
||||
**Re-enable a list:**
|
||||
```sql
|
||||
UPDATE lists SET active = TRUE WHERE list_email = 'community@lists.sasalliance.org';
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
Test that Postfix can query the database:
|
||||
|
||||
```bash
|
||||
docker-compose exec postfix postmap -q "community@lists.sasalliance.org" mysql:/etc/postfix/mysql_virtual_alias_maps.cf
|
||||
```
|
||||
|
||||
This should return a comma-separated list of member email addresses.
|
||||
|
||||
## Database Initialization
|
||||
|
||||
The database is automatically initialized from `database/schema.sql` when the MySQL container first starts. Sample data includes:
|
||||
|
||||
- 4 mailing lists (community, board, members, announcements)
|
||||
- 2 sample members
|
||||
- Sample subscriptions
|
||||
|
||||
### Reset Database
|
||||
|
||||
To completely reset the database (deletes all data!):
|
||||
|
||||
```bash
|
||||
docker-compose down -v # Remove volumes
|
||||
docker-compose up -d # Reinitialize from schema.sql
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
Postfix caches MySQL query results, so the database isn't queried for every single email. The cache TTL is configurable in `mysql_virtual_alias_maps.cf` if needed.
|
||||
75
database/schema.sql
Normal file
75
database/schema.sql
Normal file
@@ -0,0 +1,75 @@
|
||||
-- Mail List Manager Database Schema
|
||||
|
||||
-- Table: lists
|
||||
-- Stores mailing list information
|
||||
CREATE TABLE IF NOT EXISTS lists (
|
||||
list_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
list_name VARCHAR(100) NOT NULL UNIQUE,
|
||||
list_email VARCHAR(255) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
INDEX idx_list_email (list_email),
|
||||
INDEX idx_active (active)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Table: members
|
||||
-- Stores member information
|
||||
CREATE TABLE IF NOT EXISTS members (
|
||||
member_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
INDEX idx_email (email),
|
||||
INDEX idx_active (active)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Table: list_members
|
||||
-- Junction table for many-to-many relationship between lists and members
|
||||
CREATE TABLE IF NOT EXISTS list_members (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
list_id INT NOT NULL,
|
||||
member_id INT NOT NULL,
|
||||
subscribed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
FOREIGN KEY (list_id) REFERENCES lists(list_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (member_id) REFERENCES members(member_id) ON DELETE CASCADE,
|
||||
UNIQUE KEY unique_list_member (list_id, member_id),
|
||||
INDEX idx_list_id (list_id),
|
||||
INDEX idx_member_id (member_id),
|
||||
INDEX idx_active (active)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Insert sample data
|
||||
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'),
|
||||
('Members', 'members@lists.sasalliance.org', 'All members'),
|
||||
('Announcements', 'announcements@lists.sasalliance.org', 'Important announcements');
|
||||
|
||||
INSERT INTO members (name, email) VALUES
|
||||
('James Pattinson', 'james.pattinson@sasalliance.org'),
|
||||
('James Pattinson (Personal)', 'james@pattinson.org');
|
||||
|
||||
-- Subscribe members to lists
|
||||
-- Community list - both addresses
|
||||
INSERT INTO list_members (list_id, member_id) VALUES
|
||||
(1, 1), -- James (work) on Community
|
||||
(1, 2); -- James (personal) on Community
|
||||
|
||||
-- Board list - work address only
|
||||
INSERT INTO list_members (list_id, member_id) VALUES
|
||||
(2, 1); -- James (work) on Board
|
||||
|
||||
-- Members list - both addresses
|
||||
INSERT INTO list_members (list_id, member_id) VALUES
|
||||
(3, 1), -- James (work) on Members
|
||||
(3, 2); -- James (personal) on Members
|
||||
|
||||
-- Announcements list - both addresses
|
||||
INSERT INTO list_members (list_id, member_id) VALUES
|
||||
(4, 1), -- James (work) on Announcements
|
||||
(4, 2); -- James (personal) on Announcements
|
||||
@@ -1,8 +1,52 @@
|
||||
version: "3.9"
|
||||
|
||||
networks:
|
||||
maillist-internal:
|
||||
driver: bridge
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: maillist-mysql
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-maillist}
|
||||
MYSQL_USER: ${MYSQL_USER:-maillist}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
- ./database/schema.sql:/docker-entrypoint-initdb.d/schema.sql
|
||||
networks:
|
||||
- maillist-internal
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
postfix:
|
||||
build: ./postfix
|
||||
container_name: postfix
|
||||
env_file: .env
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "25:25"
|
||||
networks:
|
||||
- maillist-internal
|
||||
|
||||
api:
|
||||
build: ./api
|
||||
container_name: maillist-api
|
||||
env_file: .env
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8000:8000"
|
||||
networks:
|
||||
- maillist-internal
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
|
||||
@@ -4,21 +4,22 @@ FROM debian:stable-slim
|
||||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
postfix \
|
||||
postfix-mysql \
|
||||
libsasl2-modules \
|
||||
mailutils \
|
||||
gettext-base \
|
||||
netcat-openbsd \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy configs
|
||||
COPY main.cf.template /etc/postfix/main.cf.template
|
||||
COPY sasl_passwd.template /etc/postfix/sasl_passwd.template
|
||||
COPY virtual_aliases.cf /etc/postfix/virtual_aliases.cf
|
||||
COPY mysql_virtual_alias_maps.cf /etc/postfix/mysql_virtual_alias_maps.cf.template
|
||||
COPY sender_access /etc/postfix/sender_access
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Generate Postfix maps for virtual aliases and sender access
|
||||
RUN postmap /etc/postfix/virtual_aliases.cf
|
||||
# Generate Postfix maps for sender access
|
||||
RUN postmap /etc/postfix/sender_access
|
||||
|
||||
# Expose SMTP
|
||||
|
||||
@@ -4,16 +4,33 @@ set -e
|
||||
# Generate main.cf from template with environment variables
|
||||
envsubst < /etc/postfix/main.cf.template > /etc/postfix/main.cf
|
||||
|
||||
# Generate MySQL virtual alias config from template
|
||||
envsubst < /etc/postfix/mysql_virtual_alias_maps.cf.template > /etc/postfix/mysql_virtual_alias_maps.cf
|
||||
|
||||
# Generate SASL password file from environment variables
|
||||
envsubst < /etc/postfix/sasl_passwd.template > /etc/postfix/sasl_passwd
|
||||
|
||||
# Wait for MySQL to be ready
|
||||
echo "Waiting for MySQL to be ready..."
|
||||
for i in $(seq 1 30); do
|
||||
if nc -z ${MYSQL_HOST} ${MYSQL_PORT} 2>/dev/null; then
|
||||
echo "MySQL is ready!"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for MySQL... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Generate Postfix hash databases
|
||||
postmap /etc/postfix/sasl_passwd
|
||||
chmod 600 /etc/postfix/sasl_passwd /etc/postfix/sasl_passwd.db
|
||||
|
||||
# Regenerate sender_access database (in case of updates)
|
||||
# Regenerate sender_access database
|
||||
postmap /etc/postfix/sender_access
|
||||
chmod 644 /etc/postfix/sender_access /etc/postfix/sender_access.db
|
||||
|
||||
# Set permissions on MySQL config
|
||||
chmod 644 /etc/postfix/mysql_virtual_alias_maps.cf
|
||||
|
||||
# Start Postfix in foreground
|
||||
exec postfix start-fg
|
||||
|
||||
@@ -16,8 +16,8 @@ smtp_sasl_auth_enable = yes
|
||||
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
|
||||
smtp_sasl_security_options = noanonymous
|
||||
|
||||
# Virtual aliases (static for now)
|
||||
virtual_alias_maps = hash:/etc/postfix/virtual_aliases.cf
|
||||
# Virtual aliases - dynamic MySQL lookup
|
||||
virtual_alias_maps = mysql:/etc/postfix/mysql_virtual_alias_maps.cf
|
||||
|
||||
# Sender restrictions - enforce whitelist
|
||||
smtpd_sender_restrictions =
|
||||
|
||||
14
postfix/mysql_virtual_alias_maps.cf
Normal file
14
postfix/mysql_virtual_alias_maps.cf
Normal file
@@ -0,0 +1,14 @@
|
||||
# Postfix MySQL query for virtual aliases
|
||||
# This file queries the database to expand mailing list addresses to member emails
|
||||
|
||||
# Database connection settings
|
||||
hosts = ${MYSQL_HOST}
|
||||
port = ${MYSQL_PORT}
|
||||
user = ${MYSQL_USER}
|
||||
password = ${MYSQL_PASSWORD}
|
||||
dbname = ${MYSQL_DATABASE}
|
||||
|
||||
# Query to get recipients for a mailing list
|
||||
# Input: full email address (e.g., community@lists.sasalliance.org)
|
||||
# Output: comma-separated list of recipient emails
|
||||
query = SELECT GROUP_CONCAT(m.email SEPARATOR ', ') FROM lists l INNER JOIN list_members lm ON l.list_id = lm.list_id INNER JOIN members m ON lm.member_id = m.member_id WHERE l.list_email = '%s' AND l.active = 1 AND m.active = 1 AND lm.active = 1 GROUP BY l.list_id
|
||||
@@ -1 +1,11 @@
|
||||
community@lists.sasalliance.org james@pattinson.org, james.pattinson@sasalliance.org
|
||||
# Community mailing list - general announcements
|
||||
community@lists.sasalliance.org james@pattinson.org, james.pattinson@sasalliance.org
|
||||
|
||||
# Board members mailing list
|
||||
board@lists.sasalliance.org james.pattinson@sasalliance.org
|
||||
|
||||
# All members mailing list
|
||||
members@lists.sasalliance.org james@pattinson.org, james.pattinson@sasalliance.org
|
||||
|
||||
# Announcements mailing list
|
||||
announcements@lists.sasalliance.org james@pattinson.org, james.pattinson@sasalliance.org
|
||||
Reference in New Issue
Block a user