feat: Add security hardening, agentic coding infrastructure, and test framework
- Add SECRET_KEY and ENCRYPTION_KEY validation on startup - Implement security headers middleware (X-Frame-Options, CSP, HSTS) - Add CSRF protection middleware - Create comprehensive GitHub issue templates and PR template - Add Makefile with common development tasks - Configure pre-commit hooks (black, ruff, mypy, bandit, detect-secrets) - Create docs/CODING_PATTERNS.md with best practices - Create docs/ERRORS.md documenting all error codes - Add Architecture Decision Records (ADR) for Celery and Fernet encryption - Create CHANGELOG.md for version tracking - Set up pytest test infrastructure with fixtures and factories - Add sample unit tests for security and config validation - Create CI/CD workflows (test, lint, security) - Add comprehensive TODO.md with milestones and progress tracking Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,583 @@
|
||||
# Coding Patterns and Best Practices
|
||||
|
||||
This document outlines the coding patterns, conventions, and best practices for the POP3 to Gmail Forwarder project.
|
||||
|
||||
## Table of Contents
|
||||
- [General Principles](#general-principles)
|
||||
- [Python Style](#python-style)
|
||||
- [API Development](#api-development)
|
||||
- [Database Patterns](#database-patterns)
|
||||
- [Error Handling](#error-handling)
|
||||
- [Security Patterns](#security-patterns)
|
||||
- [Testing Patterns](#testing-patterns)
|
||||
- [Async/Await Patterns](#asyncawait-patterns)
|
||||
|
||||
---
|
||||
|
||||
## General Principles
|
||||
|
||||
### 1. Explicit is Better Than Implicit
|
||||
```python
|
||||
# Good ✅
|
||||
async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
# Bad ❌
|
||||
async def get_user(db, id): # Missing type hints
|
||||
return await db.execute(select(User).where(User.id == id)).scalar() # Chained calls
|
||||
```
|
||||
|
||||
### 2. Dependency Injection
|
||||
Use FastAPI's dependency injection for shared resources:
|
||||
```python
|
||||
# Good ✅
|
||||
async def create_mail_account(
|
||||
account_in: MailAccountCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> MailAccount:
|
||||
# Function implementation
|
||||
pass
|
||||
|
||||
# Bad ❌
|
||||
# Accessing global db connection or parsing tokens manually
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Python Style
|
||||
|
||||
### Type Hints (Required)
|
||||
```python
|
||||
# Good ✅
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
def process_emails(
|
||||
account_id: int,
|
||||
max_count: int = 50,
|
||||
since: Optional[datetime] = None
|
||||
) -> List[Email]:
|
||||
pass
|
||||
|
||||
# Bad ❌
|
||||
def process_emails(account_id, max_count=50, since=None): # No type hints
|
||||
pass
|
||||
```
|
||||
|
||||
### Docstrings (Required for Public APIs)
|
||||
```python
|
||||
# Good ✅
|
||||
async def fetch_emails_from_pop3(account: MailAccount) -> List[Email]:
|
||||
"""
|
||||
Fetch emails from a POP3 account.
|
||||
|
||||
Args:
|
||||
account: The mail account to fetch from
|
||||
|
||||
Returns:
|
||||
List of Email objects retrieved from the server
|
||||
|
||||
Raises:
|
||||
ConnectionError: If POP3 connection fails
|
||||
AuthenticationError: If credentials are invalid
|
||||
"""
|
||||
pass
|
||||
```
|
||||
|
||||
### Constants
|
||||
```python
|
||||
# Good ✅ - In backend/app/core/constants.py
|
||||
MAX_EMAILS_PER_RUN = 50
|
||||
DEFAULT_CHECK_INTERVAL_MINUTES = 5
|
||||
PBKDF2_ITERATIONS = 100_000
|
||||
|
||||
# Bad ❌ - Magic numbers in code
|
||||
if len(emails) > 50:
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Development
|
||||
|
||||
### Endpoint Structure
|
||||
```python
|
||||
# Good ✅
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from app.models.schemas import MailAccountCreate, MailAccountResponse
|
||||
from app.core.deps import get_current_user, get_db
|
||||
|
||||
router = APIRouter(prefix="/mail-accounts", tags=["Mail Accounts"])
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=MailAccountResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new mail account"
|
||||
)
|
||||
async def create_mail_account(
|
||||
account_in: MailAccountCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> MailAccount:
|
||||
"""Create a new POP3/IMAP mail account for the current user."""
|
||||
# Validate subscription limits
|
||||
# Create account with encrypted credentials
|
||||
# Return response
|
||||
pass
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
```python
|
||||
# Good ✅
|
||||
from app.core.errors import ErrorCode, ErrorResponse
|
||||
|
||||
if not can_add_account:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ErrorResponse(
|
||||
code=ErrorCode.SUBSCRIPTION_LIMIT_REACHED,
|
||||
message="Your plan allows maximum 5 mail accounts",
|
||||
details={"current": 5, "limit": 5, "upgrade_url": "/pricing"}
|
||||
).dict()
|
||||
)
|
||||
|
||||
# Bad ❌
|
||||
if not can_add_account:
|
||||
raise HTTPException(status_code=403, detail="Limit reached") # Not helpful
|
||||
```
|
||||
|
||||
### Validation
|
||||
```python
|
||||
# Good ✅ - Use Pydantic validators
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
class MailAccountCreate(BaseModel):
|
||||
host: str
|
||||
port: int
|
||||
username: str
|
||||
password: str
|
||||
|
||||
@validator('port')
|
||||
def validate_port(cls, v):
|
||||
if not 1 <= v <= 65535:
|
||||
raise ValueError('Port must be between 1 and 65535')
|
||||
return v
|
||||
|
||||
@validator('host')
|
||||
def validate_host(cls, v):
|
||||
if not v or v.strip() == "":
|
||||
raise ValueError('Host cannot be empty')
|
||||
return v.strip()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Patterns
|
||||
|
||||
### Queries
|
||||
```python
|
||||
# Good ✅ - Use SQLAlchemy select statements
|
||||
from sqlalchemy import select
|
||||
|
||||
async def get_user_mail_accounts(db: AsyncSession, user_id: int) -> List[MailAccount]:
|
||||
result = await db.execute(
|
||||
select(MailAccount)
|
||||
.where(MailAccount.user_id == user_id)
|
||||
.order_by(MailAccount.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
# Bad ❌ - Raw SQL or no await
|
||||
def get_accounts(db, user_id):
|
||||
return db.query(MailAccount).filter_by(user_id=user_id).all() # Sync, old style
|
||||
```
|
||||
|
||||
### Transactions
|
||||
```python
|
||||
# Good ✅ - Explicit transaction management
|
||||
async def create_user_with_account(
|
||||
db: AsyncSession,
|
||||
user_data: UserCreate,
|
||||
account_data: MailAccountCreate
|
||||
) -> User:
|
||||
try:
|
||||
user = User(**user_data.dict())
|
||||
db.add(user)
|
||||
await db.flush() # Get user.id
|
||||
|
||||
account = MailAccount(**account_data.dict(), user_id=user.id)
|
||||
db.add(account)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
# Bad ❌ - No explicit error handling
|
||||
async def create_user_with_account(db, user_data, account_data):
|
||||
user = User(**user_data.dict())
|
||||
db.add(user)
|
||||
await db.commit() # What if this fails?
|
||||
```
|
||||
|
||||
### Relationships
|
||||
```python
|
||||
# Good ✅ - Use eager loading when needed
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
async def get_user_with_accounts(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.mail_accounts))
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
# Bad ❌ - N+1 queries
|
||||
user = await get_user(db, user_id)
|
||||
for account in user.mail_accounts: # Lazy loads each account
|
||||
print(account.email)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Specific Exceptions
|
||||
```python
|
||||
# Good ✅ - Catch specific exceptions
|
||||
from smtplib import SMTPAuthenticationError, SMTPException
|
||||
from poplib import error_proto
|
||||
|
||||
try:
|
||||
await send_email(message)
|
||||
except SMTPAuthenticationError as e:
|
||||
logger.error(f"SMTP authentication failed: {e}")
|
||||
raise HTTPException(status_code=401, detail="Invalid email credentials")
|
||||
except SMTPException as e:
|
||||
logger.error(f"SMTP error: {e}")
|
||||
raise HTTPException(status_code=500, detail="Email delivery failed")
|
||||
|
||||
# Bad ❌ - Bare except
|
||||
try:
|
||||
await send_email(message)
|
||||
except Exception as e: # Too broad
|
||||
logger.error(f"Error: {e}")
|
||||
```
|
||||
|
||||
### Logging
|
||||
```python
|
||||
# Good ✅ - Structured logging with context
|
||||
logger.info(
|
||||
"Email forwarded successfully",
|
||||
extra={
|
||||
"user_id": user.id,
|
||||
"account_id": account.id,
|
||||
"email_size": len(email_data),
|
||||
"destination": destination_email
|
||||
}
|
||||
)
|
||||
|
||||
# Bad ❌ - String formatting in logs
|
||||
logger.info(f"Email forwarded for user {user.id}") # No structure
|
||||
```
|
||||
|
||||
### Resource Cleanup
|
||||
```python
|
||||
# Good ✅ - Use context managers
|
||||
async with aiosmtplib.SMTP(hostname=smtp_host, port=smtp_port) as smtp:
|
||||
await smtp.login(username, password)
|
||||
await smtp.send_message(message)
|
||||
# Connection automatically closed
|
||||
|
||||
# Bad ❌ - Manual cleanup
|
||||
smtp = aiosmtplib.SMTP(hostname=smtp_host, port=smtp_port)
|
||||
try:
|
||||
await smtp.connect()
|
||||
await smtp.send_message(message)
|
||||
finally:
|
||||
smtp.close() # Might be forgotten
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Patterns
|
||||
|
||||
### Credential Encryption
|
||||
```python
|
||||
# Good ✅ - Always encrypt credentials before storage
|
||||
from app.core.security import encrypt_password
|
||||
|
||||
async def create_mail_account(
|
||||
db: AsyncSession,
|
||||
account_data: MailAccountCreate,
|
||||
user_id: int
|
||||
) -> MailAccount:
|
||||
encrypted_password = encrypt_password(account_data.password, user_id)
|
||||
account = MailAccount(
|
||||
**account_data.dict(exclude={'password'}),
|
||||
encrypted_password=encrypted_password,
|
||||
user_id=user_id
|
||||
)
|
||||
db.add(account)
|
||||
await db.commit()
|
||||
return account
|
||||
|
||||
# Bad ❌ - Plain text storage
|
||||
account = MailAccount(password=account_data.password) # NEVER DO THIS
|
||||
```
|
||||
|
||||
### Input Validation
|
||||
```python
|
||||
# Good ✅ - Validate all external inputs
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@validator('redirect_uri')
|
||||
def validate_redirect_uri(cls, v):
|
||||
allowed_domains = ['localhost', 'app.yourdomain.com']
|
||||
parsed = urlparse(v)
|
||||
if parsed.netloc not in allowed_domains:
|
||||
raise ValueError('Invalid redirect URI')
|
||||
return v
|
||||
|
||||
# Bad ❌ - Trust user input
|
||||
redirect_uri = request.args.get('redirect_uri')
|
||||
return redirect(redirect_uri) # Open redirect vulnerability
|
||||
```
|
||||
|
||||
### Never Log Secrets
|
||||
```python
|
||||
# Good ✅
|
||||
logger.info(f"Connecting to POP3 server {host} as {username}")
|
||||
|
||||
# Bad ❌
|
||||
logger.debug(f"Connecting with password: {password}") # NEVER LOG PASSWORDS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Test Structure
|
||||
```python
|
||||
# Good ✅ - Arrange, Act, Assert
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_mail_account(
|
||||
client: AsyncClient,
|
||||
auth_headers: dict,
|
||||
db_session: AsyncSession
|
||||
):
|
||||
# Arrange
|
||||
account_data = {
|
||||
"host": "pop.example.com",
|
||||
"port": 995,
|
||||
"username": "test@example.com",
|
||||
"password": "secure_password"
|
||||
}
|
||||
|
||||
# Act
|
||||
response = await client.post(
|
||||
"/api/v1/mail-accounts/",
|
||||
json=account_data,
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["username"] == account_data["username"]
|
||||
assert "password" not in data # Never return passwords
|
||||
```
|
||||
|
||||
### Fixtures
|
||||
```python
|
||||
# Good ✅ - In conftest.py
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@pytest.fixture
|
||||
async def test_user(db_session: AsyncSession) -> User:
|
||||
"""Create a test user."""
|
||||
user = User(
|
||||
email="test@example.com",
|
||||
hashed_password=get_password_hash("testpass")
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
```
|
||||
|
||||
### Mocking
|
||||
```python
|
||||
# Good ✅ - Mock external services
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_email_success():
|
||||
with patch('aiosmtplib.SMTP') as mock_smtp:
|
||||
mock_instance = AsyncMock()
|
||||
mock_smtp.return_value.__aenter__.return_value = mock_instance
|
||||
|
||||
await send_email("test@example.com", "Subject", "Body")
|
||||
|
||||
mock_instance.send_message.assert_called_once()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Async/Await Patterns
|
||||
|
||||
### Always Await Async Functions
|
||||
```python
|
||||
# Good ✅
|
||||
result = await db.execute(query)
|
||||
await db.commit()
|
||||
|
||||
# Bad ❌
|
||||
result = db.execute(query) # Returns coroutine, not result!
|
||||
```
|
||||
|
||||
### Use AsyncSession
|
||||
```python
|
||||
# Good ✅ - Backend uses AsyncSession
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
async def get_user(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
# Bad ❌ - Mixing sync code with async
|
||||
from sqlalchemy.orm import Session # Wrong import
|
||||
|
||||
def get_user(db: Session, user_id: int): # Sync function
|
||||
return db.query(User).filter_by(id=user_id).first()
|
||||
```
|
||||
|
||||
### Don't Block the Event Loop
|
||||
```python
|
||||
# Good ✅ - Use async libraries
|
||||
import aiofiles
|
||||
|
||||
async def read_large_file(filepath: str) -> str:
|
||||
async with aiofiles.open(filepath, 'r') as f:
|
||||
return await f.read()
|
||||
|
||||
# Bad ❌ - Blocking I/O in async function
|
||||
async def read_large_file(filepath: str) -> str:
|
||||
with open(filepath, 'r') as f: # Blocks event loop!
|
||||
return f.read()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Celery Task Patterns
|
||||
|
||||
### Task Definition
|
||||
```python
|
||||
# Good ✅ - With retry and error handling
|
||||
from celery import Task
|
||||
from app.core.celery_app import celery_app
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
autoretry_for=(Exception,),
|
||||
retry_kwargs={'max_retries': 3, 'countdown': 60},
|
||||
retry_backoff=True
|
||||
)
|
||||
def process_mail_account(self: Task, account_id: int) -> dict:
|
||||
"""Process emails for a mail account."""
|
||||
try:
|
||||
# Task logic
|
||||
return {"status": "success", "count": 10}
|
||||
except Exception as exc:
|
||||
logger.error(f"Task failed for account {account_id}: {exc}")
|
||||
raise self.retry(exc=exc)
|
||||
|
||||
# Bad ❌ - No retry logic
|
||||
@celery_app.task
|
||||
def process_mail_account(account_id):
|
||||
# If this fails, it just fails
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration Management
|
||||
|
||||
### Use Pydantic Settings
|
||||
```python
|
||||
# Good ✅ - In app/core/config.py
|
||||
from pydantic import BaseSettings, validator
|
||||
|
||||
class Settings(BaseSettings):
|
||||
SECRET_KEY: str
|
||||
DATABASE_URL: str
|
||||
|
||||
@validator('SECRET_KEY')
|
||||
def validate_secret_key(cls, v):
|
||||
if v == "change-this-to-a-secure-random-secret-key-in-production":
|
||||
raise ValueError("SECRET_KEY must be changed from default!")
|
||||
if len(v) < 32:
|
||||
raise ValueError("SECRET_KEY must be at least 32 characters")
|
||||
return v
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
# Bad ❌ - Direct os.getenv without validation
|
||||
SECRET_KEY = os.getenv('SECRET_KEY', 'default_key') # Dangerous default
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation Patterns
|
||||
|
||||
### API Endpoint Documentation
|
||||
```python
|
||||
# Good ✅
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=MailAccountResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new mail account",
|
||||
description="Creates a new POP3/IMAP mail account for the authenticated user. "
|
||||
"Credentials are encrypted before storage.",
|
||||
responses={
|
||||
201: {"description": "Mail account created successfully"},
|
||||
403: {"description": "Subscription limit reached"},
|
||||
422: {"description": "Invalid input data"}
|
||||
}
|
||||
)
|
||||
async def create_mail_account(...):
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary Checklist
|
||||
|
||||
Before committing code, ensure:
|
||||
- [ ] Type hints on all functions
|
||||
- [ ] Docstrings on public APIs
|
||||
- [ ] Specific exception handling (no bare `except`)
|
||||
- [ ] Input validation with Pydantic
|
||||
- [ ] Credentials encrypted, never logged
|
||||
- [ ] Async/await used correctly
|
||||
- [ ] Tests added/updated
|
||||
- [ ] Error codes documented
|
||||
- [ ] Security considerations checked
|
||||
- [ ] Code follows these patterns
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-02-06
|
||||
**Maintainer**: Development Team
|
||||
Reference in New Issue
Block a user