92d67369e9
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/82f2f361-3513-44e6-991b-db1a19902772 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
584 lines
14 KiB
Markdown
584 lines
14 KiB
Markdown
# Coding Patterns and Best Practices
|
|
|
|
This document outlines the coding patterns, conventions, and best practices for the InboxConverge 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
|