Compare commits

...

2 Commits

Author SHA1 Message Date
James Pattinson
b8a91103e9 Now we have an API 2025-10-12 19:33:45 +00:00
James Pattinson
35f710049a MySQL support 2025-10-12 19:24:14 +00:00
15 changed files with 953 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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