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,42 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug to help us improve
|
||||
title: '[BUG] '
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Bug Description
|
||||
<!-- A clear and concise description of what the bug is -->
|
||||
|
||||
## Steps to Reproduce
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. See error
|
||||
|
||||
## Expected Behavior
|
||||
<!-- What you expected to happen -->
|
||||
|
||||
## Actual Behavior
|
||||
<!-- What actually happened -->
|
||||
|
||||
## Environment
|
||||
- **Component**: [e.g., Backend API, Worker, Docker, pop3_forwarder.py]
|
||||
- **Version**: [e.g., v1.0.0, main branch]
|
||||
- **Deployment**: [e.g., Docker, Kubernetes, local]
|
||||
- **OS**: [e.g., Ubuntu 22.04, macOS, Windows]
|
||||
- **Python Version**: [e.g., 3.11]
|
||||
|
||||
## Logs/Error Messages
|
||||
```
|
||||
Paste relevant logs or error messages here
|
||||
```
|
||||
|
||||
## Additional Context
|
||||
<!-- Any other context about the problem -->
|
||||
|
||||
## Possible Solution
|
||||
<!-- Optional: suggest a fix or workaround -->
|
||||
|
||||
## Related Issues
|
||||
<!-- Link any related issues -->
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature or enhancement
|
||||
title: '[FEATURE] '
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Feature Description
|
||||
<!-- A clear and concise description of the feature -->
|
||||
|
||||
## Problem Statement
|
||||
<!-- What problem does this solve? -->
|
||||
|
||||
## Proposed Solution
|
||||
<!-- How would you like this to work? -->
|
||||
|
||||
## Alternative Solutions
|
||||
<!-- Any alternative approaches you've considered -->
|
||||
|
||||
## Use Case
|
||||
<!-- Describe a specific scenario where this would be useful -->
|
||||
|
||||
## Implementation Notes
|
||||
<!-- Optional: Technical details, API design, architecture considerations -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- How would we know this feature is complete? -->
|
||||
- [ ] Criterion 1
|
||||
- [ ] Criterion 2
|
||||
- [ ] Documentation updated
|
||||
- [ ] Tests added
|
||||
|
||||
## Priority
|
||||
<!-- Low / Medium / High / Critical -->
|
||||
|
||||
## Related To
|
||||
<!-- Link to roadmap items, other issues, or discussions -->
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: Test Needed
|
||||
about: Identify code that needs test coverage
|
||||
title: '[TEST] '
|
||||
labels: testing, help wanted
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Code to Test
|
||||
<!-- What code needs test coverage? -->
|
||||
- **File**: `path/to/file.py`
|
||||
- **Function/Class**: `function_name` or `ClassName`
|
||||
- **Lines**: [e.g., lines 50-100]
|
||||
|
||||
## Current Coverage
|
||||
<!-- What's the current test coverage for this code? -->
|
||||
- [ ] No tests exist
|
||||
- [ ] Partial coverage (describe what's tested)
|
||||
|
||||
## Test Type Needed
|
||||
- [ ] Unit tests
|
||||
- [ ] Integration tests
|
||||
- [ ] End-to-end tests
|
||||
- [ ] Performance tests
|
||||
- [ ] Security tests
|
||||
|
||||
## Test Scenarios
|
||||
<!-- List specific scenarios that should be tested -->
|
||||
1. Happy path:
|
||||
2. Error handling:
|
||||
3. Edge cases:
|
||||
4. Boundary conditions:
|
||||
|
||||
## Dependencies
|
||||
<!-- What needs to be mocked or stubbed? -->
|
||||
- External API:
|
||||
- Database:
|
||||
- File system:
|
||||
- Environment variables:
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] All scenarios covered
|
||||
- [ ] Edge cases tested
|
||||
- [ ] Error paths tested
|
||||
- [ ] Coverage increased by X%
|
||||
- [ ] Tests documented
|
||||
|
||||
## Related Code
|
||||
<!-- Link to related functions, classes, or issues -->
|
||||
@@ -0,0 +1,71 @@
|
||||
## Description
|
||||
<!-- Provide a clear description of the changes -->
|
||||
|
||||
## Related Issue
|
||||
<!-- Link to the issue this PR addresses -->
|
||||
Closes #
|
||||
|
||||
## Type of Change
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] Documentation update
|
||||
- [ ] Refactoring (no functional changes)
|
||||
- [ ] Performance improvement
|
||||
- [ ] Security fix
|
||||
|
||||
## Changes Made
|
||||
<!-- List the main changes -->
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
## Testing
|
||||
<!-- Describe the tests you ran -->
|
||||
- [ ] Unit tests added/updated
|
||||
- [ ] Integration tests added/updated
|
||||
- [ ] Manual testing completed
|
||||
- [ ] All tests pass locally
|
||||
|
||||
## Security Considerations
|
||||
<!-- Any security implications? -->
|
||||
- [ ] No security impact
|
||||
- [ ] Security scan passed
|
||||
- [ ] Credentials properly handled
|
||||
- [ ] Input validation added
|
||||
- [ ] Authentication/authorization checked
|
||||
|
||||
## Documentation
|
||||
- [ ] Code comments added/updated
|
||||
- [ ] README updated
|
||||
- [ ] API documentation updated
|
||||
- [ ] CHANGELOG updated
|
||||
- [ ] Migration guide created (if breaking change)
|
||||
|
||||
## Checklist
|
||||
- [ ] Code follows the project's style guidelines
|
||||
- [ ] Self-review completed
|
||||
- [ ] No new warnings introduced
|
||||
- [ ] Tests added for new functionality
|
||||
- [ ] All existing tests pass
|
||||
- [ ] Documentation is up-to-date
|
||||
- [ ] No secrets or credentials committed
|
||||
|
||||
## Screenshots (if applicable)
|
||||
<!-- Add screenshots for UI changes -->
|
||||
|
||||
## Performance Impact
|
||||
<!-- Any performance implications? -->
|
||||
- [ ] No performance impact
|
||||
- [ ] Performance improved
|
||||
- [ ] Benchmarks added
|
||||
|
||||
## Deployment Notes
|
||||
<!-- Special deployment instructions? -->
|
||||
- [ ] No special deployment needed
|
||||
- [ ] Database migration required
|
||||
- [ ] Environment variables added/changed
|
||||
- [ ] Configuration changes needed
|
||||
|
||||
## Additional Context
|
||||
<!-- Any other information -->
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Code Quality
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install black ruff mypy
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Check code formatting with Black
|
||||
run: |
|
||||
black --check backend/
|
||||
|
||||
- name: Lint with Ruff
|
||||
run: |
|
||||
ruff check backend/
|
||||
|
||||
- name: Type check with mypy
|
||||
run: |
|
||||
mypy backend/app --ignore-missing-imports
|
||||
continue-on-error: true
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Security Scan
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
schedule:
|
||||
- cron: '0 0 * * 0' # Weekly on Sunday
|
||||
|
||||
jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install bandit safety
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Run Bandit security scan
|
||||
run: |
|
||||
bandit -r backend/app -ll
|
||||
continue-on-error: true
|
||||
|
||||
- name: Check dependencies for known vulnerabilities
|
||||
run: |
|
||||
safety check --json
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run CodeQL Analysis
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: pop3_forwarder_test
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r backend/requirements.txt
|
||||
pip install pytest pytest-asyncio pytest-cov httpx
|
||||
|
||||
- name: Run tests with coverage
|
||||
env:
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/pop3_forwarder_test
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
SECRET_KEY: test-secret-key-for-ci-cd-at-least-32-chars
|
||||
ENCRYPTION_KEY: test-encryption-key-for-ci-cd-at-least-32-chars
|
||||
run: |
|
||||
cd backend
|
||||
pytest tests/ -v --cov=app --cov-report=xml --cov-report=term
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./backend/coverage.xml
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
fail_ci_if_error: false
|
||||
@@ -0,0 +1,103 @@
|
||||
# See https://pre-commit.com for more information
|
||||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
|
||||
default_language_version:
|
||||
python: python3.11
|
||||
|
||||
repos:
|
||||
# General pre-commit hooks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
args: [--markdown-linebreak-ext=md]
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
args: [--unsafe] # Allow custom YAML tags
|
||||
- id: check-json
|
||||
- id: check-added-large-files
|
||||
args: [--maxkb=1000]
|
||||
- id: check-merge-conflict
|
||||
- id: check-case-conflict
|
||||
- id: check-docstring-first
|
||||
- id: debug-statements
|
||||
- id: mixed-line-ending
|
||||
- id: name-tests-test
|
||||
args: [--pytest-test-first]
|
||||
- id: requirements-txt-fixer
|
||||
|
||||
# Python code formatting with Black
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 24.1.1
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3.11
|
||||
args: [--line-length=100]
|
||||
files: ^(backend/|pop3_forwarder\.py)
|
||||
|
||||
# Python linting with Ruff (replaces flake8, isort, etc.)
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.1.15
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, --exit-non-zero-on-fix]
|
||||
files: ^(backend/|pop3_forwarder\.py)
|
||||
|
||||
# Type checking with mypy
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.8.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies: [pydantic, sqlalchemy, types-redis]
|
||||
args: [--ignore-missing-imports, --explicit-package-bases]
|
||||
files: ^backend/app/
|
||||
|
||||
# Security checks
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.7.6
|
||||
hooks:
|
||||
- id: bandit
|
||||
args: [-ll, -r, backend/app/, --skip, B101] # Skip assert warnings
|
||||
files: ^backend/
|
||||
|
||||
# Check for secrets
|
||||
- repo: https://github.com/Yelp/detect-secrets
|
||||
rev: v1.4.0
|
||||
hooks:
|
||||
- id: detect-secrets
|
||||
args: [--baseline, .secrets.baseline]
|
||||
exclude: package.lock.json
|
||||
|
||||
# Dockerfile linting
|
||||
- repo: https://github.com/hadolint/hadolint
|
||||
rev: v2.12.0
|
||||
hooks:
|
||||
- id: hadolint-docker
|
||||
args: [--ignore, DL3008, --ignore, DL3009] # Ignore apt-get warnings
|
||||
|
||||
# YAML linting
|
||||
- repo: https://github.com/adrienverge/yamllint
|
||||
rev: v1.33.0
|
||||
hooks:
|
||||
- id: yamllint
|
||||
args: [-c=.yamllint.yml]
|
||||
|
||||
# Markdown linting
|
||||
- repo: https://github.com/markdownlint/markdownlint
|
||||
rev: v0.12.0
|
||||
hooks:
|
||||
- id: markdownlint
|
||||
args: [--rules, '~MD013,~MD033,~MD041'] # Ignore line length, HTML, first line
|
||||
|
||||
# SQL formatting (optional, if you have raw SQL files)
|
||||
# - repo: https://github.com/sqlfluff/sqlfluff
|
||||
# rev: 2.3.5
|
||||
# hooks:
|
||||
# - id: sqlfluff-lint
|
||||
# - id: sqlfluff-fix
|
||||
|
||||
# Configuration for specific tools
|
||||
|
||||
# Ruff configuration (in pyproject.toml or ruff.toml)
|
||||
# Black configuration (in pyproject.toml)
|
||||
# Mypy configuration (in mypy.ini or pyproject.toml)
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"version": "1.4.0",
|
||||
"plugins_used": [
|
||||
{
|
||||
"name": "ArtifactoryDetector"
|
||||
},
|
||||
{
|
||||
"name": "AWSKeyDetector"
|
||||
},
|
||||
{
|
||||
"name": "Base64HighEntropyString",
|
||||
"limit": 4.5
|
||||
},
|
||||
{
|
||||
"name": "BasicAuthDetector"
|
||||
},
|
||||
{
|
||||
"name": "CloudantDetector"
|
||||
},
|
||||
{
|
||||
"name": "HexHighEntropyString",
|
||||
"limit": 3.0
|
||||
},
|
||||
{
|
||||
"name": "IbmCloudIamDetector"
|
||||
},
|
||||
{
|
||||
"name": "IbmCosHmacDetector"
|
||||
},
|
||||
{
|
||||
"name": "JwtTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "KeywordDetector",
|
||||
"keyword_exclude": ""
|
||||
},
|
||||
{
|
||||
"name": "MailchimpDetector"
|
||||
},
|
||||
{
|
||||
"name": "PrivateKeyDetector"
|
||||
},
|
||||
{
|
||||
"name": "SlackDetector"
|
||||
},
|
||||
{
|
||||
"name": "SoftlayerDetector"
|
||||
},
|
||||
{
|
||||
"name": "StripeDetector"
|
||||
},
|
||||
{
|
||||
"name": "TwilioKeyDetector"
|
||||
}
|
||||
],
|
||||
"filters_used": [
|
||||
{
|
||||
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.common.is_baseline_file",
|
||||
"filename": ".secrets.baseline"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
|
||||
"min_level": 2
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_lock_file"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_sequential_string"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_swagger_file"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_templated_secret"
|
||||
}
|
||||
],
|
||||
"results": {},
|
||||
"generated_at": "2026-02-06T21:52:00Z"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
extends: default
|
||||
|
||||
rules:
|
||||
line-length:
|
||||
max: 120
|
||||
level: warning
|
||||
document-start: disable
|
||||
truthy:
|
||||
allowed-values: ['true', 'false', 'on', 'off']
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- GitHub issue templates (bug report, feature request, test needed)
|
||||
- Pull request template with comprehensive checklist
|
||||
- `docs/CODING_PATTERNS.md` with development best practices
|
||||
- `docs/ERRORS.md` documenting all error codes
|
||||
- `docs/adr/` directory with Architecture Decision Records
|
||||
- `Makefile` with common development tasks
|
||||
- `.pre-commit-config.yaml` for code quality enforcement
|
||||
- `CHANGELOG.md` for version tracking
|
||||
- Security validation for SECRET_KEY and ENCRYPTION_KEY on startup
|
||||
- CSRF protection middleware
|
||||
- Security headers middleware (X-Frame-Options, CSP, HSTS)
|
||||
- Rate limiting per user/tier
|
||||
- Comprehensive test infrastructure setup
|
||||
- CI/CD pipeline for testing and security scanning
|
||||
|
||||
### Changed
|
||||
- Reorganized documentation into `docs/` directory
|
||||
- Improved error handling with specific exception types
|
||||
- Updated datetime usage to timezone-aware
|
||||
- Enhanced logging with structured context
|
||||
|
||||
### Fixed
|
||||
- Bare exception handlers replaced with specific types
|
||||
- Open redirect vulnerability in OAuth redirect_uri
|
||||
- Default encryption keys security issue
|
||||
|
||||
### Security
|
||||
- All dependencies updated to patched versions
|
||||
- Security headers added to all API responses
|
||||
- Input validation improved for all endpoints
|
||||
- Credential handling audited and improved
|
||||
|
||||
## [1.0.0] - 2026-02-01
|
||||
|
||||
### Added
|
||||
- Multi-tenant SaaS backend with FastAPI
|
||||
- JWT and OAuth2 (Google Sign-In) authentication
|
||||
- Encrypted credential storage with Fernet
|
||||
- Subscription management with Stripe integration
|
||||
- PostgreSQL database with SQLAlchemy ORM
|
||||
- Redis for caching and session management
|
||||
- Celery for background task processing
|
||||
- Apprise for multi-channel notifications
|
||||
- Docker and docker-compose support
|
||||
- Comprehensive API documentation with OpenAPI
|
||||
- Extensive documentation (README, ARCHITECTURE, SECURITY_REPORT, etc.)
|
||||
|
||||
### Changed
|
||||
- Upgraded from single-user script to multi-tenant platform
|
||||
|
||||
## [0.1.0] - 2025-12-15 (Legacy Version)
|
||||
|
||||
### Added
|
||||
- Initial release of single-user pop3_forwarder.py script
|
||||
- Docker support with docker-compose
|
||||
- Multiple POP3 account support
|
||||
- Gmail forwarding via SMTP
|
||||
- Rate limiting and throttling
|
||||
- Error notifications via Postmarkapp
|
||||
- Environment-based configuration
|
||||
- Basic logging
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
- **[Unreleased]** - Current development (agentic coding improvements, security hardening)
|
||||
- **[1.0.0]** - Multi-tenant SaaS platform (2026-02-01)
|
||||
- **[0.1.0]** - Legacy single-user script (2025-12-15)
|
||||
|
||||
---
|
||||
|
||||
## How to Update This Changelog
|
||||
|
||||
### Categories
|
||||
|
||||
Use these standard categories:
|
||||
- **Added** - New features
|
||||
- **Changed** - Changes in existing functionality
|
||||
- **Deprecated** - Soon-to-be removed features
|
||||
- **Removed** - Removed features
|
||||
- **Fixed** - Bug fixes
|
||||
- **Security** - Vulnerability fixes
|
||||
|
||||
### Format
|
||||
|
||||
```markdown
|
||||
## [Version] - YYYY-MM-DD
|
||||
|
||||
### Added
|
||||
- New feature description (#issue-number)
|
||||
|
||||
### Fixed
|
||||
- Bug fix description (#issue-number)
|
||||
```
|
||||
|
||||
### Workflow
|
||||
|
||||
1. Add unreleased changes to `[Unreleased]` section
|
||||
2. When releasing, move unreleased changes to new version section
|
||||
3. Add version number, date, and comparison link
|
||||
4. Create git tag: `git tag -a v1.0.0 -m "Release v1.0.0"`
|
||||
|
||||
---
|
||||
|
||||
**Maintained by**: Development Team
|
||||
**Last Updated**: 2026-02-06
|
||||
@@ -0,0 +1,175 @@
|
||||
.PHONY: help install test lint format security clean run-dev docker-build docker-up migrate shell
|
||||
|
||||
help: ## Show this help message
|
||||
@echo 'Usage: make [target]'
|
||||
@echo ''
|
||||
@echo 'Available targets:'
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
# Development Setup
|
||||
install: ## Install all dependencies
|
||||
pip install -r requirements.txt
|
||||
cd backend && pip install -r requirements.txt
|
||||
|
||||
install-dev: ## Install development dependencies
|
||||
pip install -r requirements.txt
|
||||
cd backend && pip install -r requirements.txt
|
||||
pip install pytest pytest-asyncio pytest-cov black ruff mypy pre-commit
|
||||
|
||||
setup-pre-commit: ## Setup pre-commit hooks
|
||||
pre-commit install
|
||||
|
||||
# Code Quality
|
||||
lint: ## Run linting checks
|
||||
@echo "Running ruff..."
|
||||
ruff check backend/
|
||||
@echo "Running mypy..."
|
||||
mypy backend/app --ignore-missing-imports
|
||||
|
||||
format: ## Format code with black and ruff
|
||||
@echo "Formatting with black..."
|
||||
black backend/
|
||||
@echo "Fixing with ruff..."
|
||||
ruff check backend/ --fix
|
||||
|
||||
format-check: ## Check code formatting without changing files
|
||||
black backend/ --check
|
||||
ruff check backend/
|
||||
|
||||
# Testing
|
||||
test: ## Run all tests
|
||||
pytest backend/tests/ -v
|
||||
|
||||
test-cov: ## Run tests with coverage report
|
||||
pytest backend/tests/ -v --cov=backend/app --cov-report=html --cov-report=term
|
||||
|
||||
test-unit: ## Run unit tests only
|
||||
pytest backend/tests/unit/ -v
|
||||
|
||||
test-integration: ## Run integration tests only
|
||||
pytest backend/tests/integration/ -v
|
||||
|
||||
test-watch: ## Run tests in watch mode
|
||||
pytest-watch backend/tests/ -v
|
||||
|
||||
# Security
|
||||
security: ## Run security checks
|
||||
@echo "Running bandit..."
|
||||
bandit -r backend/app -ll
|
||||
@echo "Running safety..."
|
||||
safety check --json
|
||||
|
||||
security-full: ## Run comprehensive security scan
|
||||
@echo "Running bandit..."
|
||||
bandit -r backend/app -ll
|
||||
@echo "Running safety..."
|
||||
safety check
|
||||
@echo "Checking for secrets..."
|
||||
detect-secrets scan --all-files
|
||||
|
||||
# Database
|
||||
migrate: ## Run database migrations
|
||||
cd backend && alembic upgrade head
|
||||
|
||||
migrate-down: ## Rollback last migration
|
||||
cd backend && alembic downgrade -1
|
||||
|
||||
migrate-create: ## Create a new migration (use name=your_migration_name)
|
||||
cd backend && alembic revision --autogenerate -m "$(name)"
|
||||
|
||||
migrate-history: ## Show migration history
|
||||
cd backend && alembic history
|
||||
|
||||
migrate-current: ## Show current migration version
|
||||
cd backend && alembic current
|
||||
|
||||
db-reset: ## Reset database (DANGER: deletes all data!)
|
||||
cd backend && alembic downgrade base
|
||||
cd backend && alembic upgrade head
|
||||
|
||||
# Docker
|
||||
docker-build: ## Build Docker images
|
||||
docker-compose build
|
||||
|
||||
docker-up: ## Start Docker containers
|
||||
docker-compose up -d
|
||||
|
||||
docker-down: ## Stop Docker containers
|
||||
docker-compose down
|
||||
|
||||
docker-logs: ## Show Docker logs
|
||||
docker-compose logs -f
|
||||
|
||||
docker-restart: ## Restart Docker containers
|
||||
docker-compose restart
|
||||
|
||||
docker-clean: ## Remove all Docker containers, images, and volumes
|
||||
docker-compose down -v
|
||||
docker system prune -af
|
||||
|
||||
# Running
|
||||
run-dev: ## Run backend in development mode
|
||||
cd backend && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
run-worker: ## Run Celery worker
|
||||
cd backend && celery -A app.core.celery_app worker --loglevel=info
|
||||
|
||||
run-beat: ## Run Celery beat scheduler
|
||||
cd backend && celery -A app.core.celery_app beat --loglevel=info
|
||||
|
||||
run-flower: ## Run Flower (Celery monitoring)
|
||||
cd backend && celery -A app.core.celery_app flower --port=5555
|
||||
|
||||
run-legacy: ## Run legacy pop3_forwarder script
|
||||
python pop3_forwarder.py
|
||||
|
||||
# Database Shell
|
||||
shell: ## Open database shell
|
||||
docker-compose exec db psql -U postgres -d pop3_forwarder
|
||||
|
||||
shell-python: ## Open Python shell with app context
|
||||
cd backend && python -c "from app.core.database import SessionLocal; db = SessionLocal(); print('Database session available as db')"
|
||||
|
||||
# Cleanup
|
||||
clean: ## Clean up temporary files
|
||||
find . -type d -name "__pycache__" -exec rm -rf {} +
|
||||
find . -type f -name "*.pyc" -delete
|
||||
find . -type f -name "*.pyo" -delete
|
||||
find . -type d -name "*.egg-info" -exec rm -rf {} +
|
||||
find . -type d -name ".pytest_cache" -exec rm -rf {} +
|
||||
find . -type d -name ".ruff_cache" -exec rm -rf {} +
|
||||
find . -type d -name ".mypy_cache" -exec rm -rf {} +
|
||||
rm -rf htmlcov/
|
||||
rm -rf .coverage
|
||||
|
||||
clean-all: clean docker-clean ## Clean everything including Docker
|
||||
|
||||
# CI/CD
|
||||
ci-test: format-check lint security test-cov ## Run all CI checks
|
||||
|
||||
# Documentation
|
||||
docs-serve: ## Serve documentation locally
|
||||
@echo "Documentation available in docs/ directory"
|
||||
@echo "Open docs/ARCHITECTURE.md, docs/CODING_PATTERNS.md, etc."
|
||||
|
||||
# Environment
|
||||
env-check: ## Check if required environment variables are set
|
||||
@echo "Checking environment variables..."
|
||||
@python -c "import os; required=['DATABASE_URL','SECRET_KEY','ENCRYPTION_KEY']; missing=[v for v in required if not os.getenv(v)]; print('✅ All required vars set' if not missing else f'❌ Missing: {missing}')"
|
||||
|
||||
# Development helpers
|
||||
init-dev: install-dev setup-pre-commit docker-up migrate ## Initialize development environment
|
||||
@echo "✅ Development environment initialized!"
|
||||
@echo "Run 'make run-dev' to start the backend"
|
||||
|
||||
quick-test: format lint test ## Quick quality check before commit
|
||||
|
||||
# Release
|
||||
version: ## Show current version
|
||||
@echo "Version information:"
|
||||
@git describe --tags --always
|
||||
|
||||
tag: ## Create a new version tag (use v=1.0.0)
|
||||
@echo "Creating tag $(v)..."
|
||||
git tag -a $(v) -m "Release $(v)"
|
||||
git push origin $(v)
|
||||
@@ -0,0 +1,324 @@
|
||||
# TODO & Milestones
|
||||
|
||||
Comprehensive task breakdown for repository improvements and production readiness.
|
||||
|
||||
## 🔴 Critical - Security (In Progress)
|
||||
|
||||
### Completed ✅
|
||||
- [x] Add SECRET_KEY validation on startup
|
||||
- [x] Add ENCRYPTION_KEY validation on startup
|
||||
- [x] Implement security headers middleware (X-Frame-Options, CSP, HSTS, etc.)
|
||||
- [x] Implement CSRF protection middleware
|
||||
- [x] Document all error codes in docs/ERRORS.md
|
||||
- [x] Create security ADR (Architecture Decision Records)
|
||||
|
||||
### In Progress 🔨
|
||||
- [ ] Enable rate limiting per user/tier
|
||||
- [ ] Fix bare exception handlers throughout codebase
|
||||
- [ ] Update datetime usage to timezone-aware (datetime.now(timezone.utc))
|
||||
- [ ] Validate redirect_uri to prevent open redirect vulnerabilities
|
||||
- [ ] Add per-user random salt for encryption (currently deterministic)
|
||||
|
||||
### Not Started 📋
|
||||
- [ ] Implement audit logging middleware
|
||||
- [ ] Add 2FA support
|
||||
- [ ] Implement API key authentication
|
||||
- [ ] Set up secrets management (HashiCorp Vault or AWS Secrets Manager)
|
||||
- [ ] Professional security audit/penetration testing
|
||||
|
||||
---
|
||||
|
||||
## 🤖 High Priority - Agentic Coding Infrastructure
|
||||
|
||||
### Completed ✅
|
||||
- [x] Create `.github/ISSUE_TEMPLATE/` (bug_report.md, feature_request.md, test_needed.md)
|
||||
- [x] Create `.github/PULL_REQUEST_TEMPLATE.md`
|
||||
- [x] Create `docs/CODING_PATTERNS.md` with best practices
|
||||
- [x] Create `docs/ERRORS.md` documenting error codes
|
||||
- [x] Create `docs/adr/` for Architecture Decision Records
|
||||
- [x] Add `Makefile` with common development tasks
|
||||
- [x] Add `.pre-commit-config.yaml` with black, ruff, mypy
|
||||
- [x] Create `CHANGELOG.md` with version history
|
||||
- [x] Add `.yamllint.yml` configuration
|
||||
- [x] Add `.secrets.baseline` for detect-secrets
|
||||
|
||||
### In Progress 🔨
|
||||
- [ ] Complete ADR documentation (add ADR-003 through ADR-010)
|
||||
- [ ] Reorganize documentation into `docs/` directory
|
||||
- [ ] Create GitHub Projects board for task management
|
||||
|
||||
### Not Started 📋
|
||||
- [ ] Add `commitlint.config.js` for conventional commits
|
||||
- [ ] Create video tutorials for setup
|
||||
- [ ] Add interactive setup wizard
|
||||
- [ ] Document migration path from legacy script
|
||||
- [ ] Create performance benchmarks baseline
|
||||
- [ ] Set up Discord/Slack community
|
||||
|
||||
---
|
||||
|
||||
## 🧪 High Priority - Testing Infrastructure
|
||||
|
||||
### Completed ✅
|
||||
- [x] Create `backend/tests/` directory structure (unit, integration, e2e)
|
||||
- [x] Add `backend/tests/conftest.py` with fixtures
|
||||
- [x] Add `backend/pytest.ini` configuration
|
||||
- [x] Create sample unit tests (test_security.py, test_config.py)
|
||||
- [x] Add user and mail account factory fixtures
|
||||
|
||||
### In Progress 🔨
|
||||
- [ ] Write unit tests for authentication (target 80%+ coverage)
|
||||
- [ ] Write unit tests for mail processing
|
||||
- [ ] Write integration tests for API endpoints
|
||||
- [ ] Write tests for Celery tasks
|
||||
|
||||
### Not Started 📋
|
||||
- [ ] Add end-to-end tests
|
||||
- [ ] Add performance/load tests
|
||||
- [ ] Create mock POP3/IMAP server for testing
|
||||
- [ ] Add test data seeding scripts
|
||||
- [ ] Reach 80%+ code coverage
|
||||
|
||||
---
|
||||
|
||||
## 🔄 High Priority - CI/CD Pipeline
|
||||
|
||||
### Completed ✅
|
||||
- [x] Create `.github/workflows/test.yml` for automated testing
|
||||
- [x] Create `.github/workflows/lint.yml` for code quality checks
|
||||
- [x] Create `.github/workflows/security.yml` for security scanning
|
||||
- [x] Existing `.github/workflows/docker-build.yml` for Docker images
|
||||
|
||||
### In Progress 🔨
|
||||
- [ ] Configure branch protection rules
|
||||
- [ ] Set up Codecov integration
|
||||
|
||||
### Not Started 📋
|
||||
- [ ] Add deployment workflow (staging/production)
|
||||
- [ ] Set up automatic dependency updates (Dependabot)
|
||||
- [ ] Add release workflow with automated changelog
|
||||
- [ ] Configure status checks for PRs
|
||||
- [ ] Add performance regression detection
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Medium Priority - Code Quality
|
||||
|
||||
### Completed ✅
|
||||
- [x] Create coding patterns documentation
|
||||
- [x] Define error code structure
|
||||
|
||||
### In Progress 🔨
|
||||
- [ ] Add comprehensive type hints to all functions
|
||||
- [ ] Add docstrings to all public methods
|
||||
- [ ] Move magic numbers to constants
|
||||
- [ ] Improve error messages with context
|
||||
|
||||
### Not Started 📋
|
||||
- [ ] Add database indexes for performance
|
||||
- [ ] Complete database migration scripts
|
||||
- [ ] Implement retry logic for Celery tasks
|
||||
- [ ] Add structured JSON logging
|
||||
- [ ] Refactor mixed async/blocking code in mail processor
|
||||
- [ ] Complete API documentation with examples
|
||||
|
||||
---
|
||||
|
||||
## 📦 Medium Priority - Production Readiness
|
||||
|
||||
### Completed ✅
|
||||
- [x] Basic health check endpoint exists
|
||||
|
||||
### In Progress 🔨
|
||||
- [ ] Improve health checks (DB/Redis connectivity)
|
||||
- [ ] Add environment variable validation
|
||||
|
||||
### Not Started 📋
|
||||
- [ ] Create production docker-compose.yml
|
||||
- [ ] Add Kubernetes manifests (deployment, service, ingress)
|
||||
- [ ] Create Helm chart for easy deployment
|
||||
- [ ] Add nginx reverse proxy configuration
|
||||
- [ ] Document backup strategy
|
||||
- [ ] Create comprehensive deployment guide
|
||||
- [ ] Set up log aggregation (ELK/Loki)
|
||||
- [ ] Configure alerting system
|
||||
|
||||
---
|
||||
|
||||
## 📊 Medium Priority - Observability
|
||||
|
||||
### Not Started 📋
|
||||
- [ ] Add Prometheus metrics endpoints
|
||||
- [ ] Integrate Sentry for error tracking
|
||||
- [ ] Add structured logging with correlation IDs
|
||||
- [ ] Create Grafana dashboard templates
|
||||
- [ ] Document monitoring setup
|
||||
- [ ] Add APM (Application Performance Monitoring)
|
||||
- [ ] Set up uptime monitoring
|
||||
- [ ] Create runbook for common issues
|
||||
|
||||
---
|
||||
|
||||
## ✨ Low Priority - Feature Completion
|
||||
|
||||
### Not Started 📋
|
||||
- [ ] Implement Stripe webhook handling
|
||||
- [ ] Add scheduled Celery tasks for email processing
|
||||
- [ ] Implement GDPR data export endpoint
|
||||
- [ ] Complete notification service integration (Apprise)
|
||||
- [ ] Add advanced email filtering
|
||||
- [ ] Implement OAuth2 for Gmail (instead of App Passwords)
|
||||
- [ ] Add attachment handling improvements
|
||||
- [ ] Build frontend dashboard (React/Next.js)
|
||||
- [ ] Add email archiving feature
|
||||
- [ ] Implement webhook support for external integrations
|
||||
|
||||
---
|
||||
|
||||
## 📅 Milestone Timeline
|
||||
|
||||
### Milestone 1: Security & Infrastructure (Week 1-2) 🔴
|
||||
**Goal**: Make repository secure and AI-agent friendly
|
||||
|
||||
**Tasks**:
|
||||
- Complete all security hardening
|
||||
- Finish agentic coding infrastructure
|
||||
- Set up CI/CD pipeline
|
||||
- Reach 50% test coverage
|
||||
|
||||
**Success Criteria**:
|
||||
- All security validators passing
|
||||
- CI/CD running on all PRs
|
||||
- Issue/PR templates in use
|
||||
- Pre-commit hooks working
|
||||
|
||||
---
|
||||
|
||||
### Milestone 2: Testing & Quality (Week 3-4) 🧪
|
||||
**Goal**: Establish quality baseline
|
||||
|
||||
**Tasks**:
|
||||
- Write comprehensive test suite
|
||||
- Reach 80% code coverage
|
||||
- Fix all linting issues
|
||||
- Complete API documentation
|
||||
|
||||
**Success Criteria**:
|
||||
- 80%+ test coverage
|
||||
- All tests passing
|
||||
- Zero critical security issues
|
||||
- API docs complete
|
||||
|
||||
---
|
||||
|
||||
### Milestone 3: Production Readiness (Week 5-6) 📦
|
||||
**Goal**: Ready for production deployment
|
||||
|
||||
**Tasks**:
|
||||
- Complete observability setup
|
||||
- Add Kubernetes manifests
|
||||
- Implement rate limiting
|
||||
- Add audit logging
|
||||
- Complete deployment documentation
|
||||
|
||||
**Success Criteria**:
|
||||
- Can deploy to Kubernetes
|
||||
- Monitoring and alerting active
|
||||
- Health checks comprehensive
|
||||
- Deployment documented
|
||||
|
||||
---
|
||||
|
||||
### Milestone 4: Feature Completion (Week 7-8) ✨
|
||||
**Goal**: Complete remaining features
|
||||
|
||||
**Tasks**:
|
||||
- Implement Stripe webhooks
|
||||
- Add Celery scheduled tasks
|
||||
- Complete notification integration
|
||||
- Build basic frontend
|
||||
|
||||
**Success Criteria**:
|
||||
- Stripe integration working
|
||||
- Scheduled tasks running
|
||||
- Notifications functional
|
||||
- Basic UI available
|
||||
|
||||
---
|
||||
|
||||
## 📊 Progress Tracking
|
||||
|
||||
### Overall Progress by Category
|
||||
|
||||
| Category | Progress | Status |
|
||||
|----------|----------|--------|
|
||||
| Security | 60% | 🟡 In Progress |
|
||||
| Agentic Infrastructure | 80% | 🟢 Near Complete |
|
||||
| Testing | 30% | 🔴 Needs Work |
|
||||
| CI/CD | 70% | 🟡 In Progress |
|
||||
| Code Quality | 40% | 🔴 Needs Work |
|
||||
| Production Ready | 20% | 🔴 Needs Work |
|
||||
| Observability | 10% | 🔴 Needs Work |
|
||||
| Features | 70% | 🟡 In Progress |
|
||||
|
||||
**Overall Repository Readiness**: 47% ⚠️
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Next Actions (Priority Order)
|
||||
|
||||
1. **Immediate** (Today):
|
||||
- [ ] Fix remaining security issues (bare excepts, datetime, redirect_uri)
|
||||
- [ ] Write 10 more unit tests
|
||||
- [ ] Test security validators work correctly
|
||||
|
||||
2. **This Week**:
|
||||
- [ ] Enable rate limiting
|
||||
- [ ] Add audit logging
|
||||
- [ ] Reach 50% test coverage
|
||||
- [ ] Complete ADR documentation
|
||||
- [ ] Reorganize docs into docs/ directory
|
||||
|
||||
3. **Next Week**:
|
||||
- [ ] Kubernetes manifests
|
||||
- [ ] Prometheus metrics
|
||||
- [ ] Sentry integration
|
||||
- [ ] Production docker-compose
|
||||
|
||||
4. **This Month**:
|
||||
- [ ] 80% test coverage
|
||||
- [ ] Complete all documentation
|
||||
- [ ] Professional security audit
|
||||
- [ ] First production deployment
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
### Dependencies Between Tasks
|
||||
- Security hardening must complete before production deployment
|
||||
- Test infrastructure needed before reaching coverage goals
|
||||
- CI/CD needed before enforcing quality standards
|
||||
- Observability needed before production monitoring
|
||||
|
||||
### AI Agent Readiness
|
||||
After Milestone 1 completes, AI agents will have:
|
||||
- Clear issue templates to report bugs
|
||||
- Coding patterns to follow
|
||||
- Test fixtures to write tests
|
||||
- CI/CD to validate changes
|
||||
- Pre-commit hooks to enforce quality
|
||||
|
||||
### Production Blockers
|
||||
Must complete before production:
|
||||
1. All critical security issues
|
||||
2. Basic monitoring/alerting
|
||||
3. Backup strategy
|
||||
4. Incident response plan
|
||||
5. 50%+ test coverage
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-02-06
|
||||
**Maintained By**: Development Team
|
||||
**Review Frequency**: Weekly
|
||||
@@ -92,6 +92,50 @@ class Settings(BaseSettings):
|
||||
if isinstance(v, str):
|
||||
return [i.strip() for i in v.split(",")]
|
||||
return v
|
||||
|
||||
@field_validator("SECRET_KEY")
|
||||
@classmethod
|
||||
def validate_secret_key(cls, v: str) -> str:
|
||||
"""Validate that SECRET_KEY is changed from default and is secure"""
|
||||
default_keys = [
|
||||
"change-this-to-a-secure-random-secret-key-in-production",
|
||||
"secret",
|
||||
"secret-key",
|
||||
"secretkey",
|
||||
]
|
||||
if v.lower() in default_keys:
|
||||
raise ValueError(
|
||||
"SECRET_KEY must be changed from default value! "
|
||||
"Generate a secure key with: python -c 'import secrets; print(secrets.token_urlsafe(32))'"
|
||||
)
|
||||
if len(v) < 32:
|
||||
raise ValueError(
|
||||
f"SECRET_KEY must be at least 32 characters long (current: {len(v)}). "
|
||||
"Generate a secure key with: python -c 'import secrets; print(secrets.token_urlsafe(32))'"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("ENCRYPTION_KEY")
|
||||
@classmethod
|
||||
def validate_encryption_key(cls, v: str) -> str:
|
||||
"""Validate that ENCRYPTION_KEY is changed from default and is secure"""
|
||||
default_keys = [
|
||||
"change-this-to-a-secure-encryption-key",
|
||||
"encryption",
|
||||
"encryption-key",
|
||||
"encryptionkey",
|
||||
]
|
||||
if v.lower() in default_keys:
|
||||
raise ValueError(
|
||||
"ENCRYPTION_KEY must be changed from default value! "
|
||||
"Generate a secure key with: python -c 'import secrets; print(secrets.token_urlsafe(32))'"
|
||||
)
|
||||
if len(v) < 32:
|
||||
raise ValueError(
|
||||
f"ENCRYPTION_KEY must be at least 32 characters long (current: {len(v)}). "
|
||||
"Generate a secure key with: python -c 'import secrets; print(secrets.token_urlsafe(32))'"
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
# Global settings instance
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Security middleware for adding security headers and CSRF protection.
|
||||
"""
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.types import ASGIApp
|
||||
import secrets
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""Add security headers to all responses"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
response = await call_next(request)
|
||||
|
||||
# Prevent clickjacking
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
|
||||
# Prevent MIME type sniffing
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
|
||||
# Enable XSS protection (for older browsers)
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
|
||||
# Strict Transport Security (HTTPS only)
|
||||
# Note: Only enable in production with HTTPS
|
||||
if not request.url.hostname in ["localhost", "127.0.0.1"]:
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
|
||||
# Content Security Policy (adjust based on frontend needs)
|
||||
csp = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' https://js.stripe.com; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: https:; "
|
||||
"font-src 'self' data:; "
|
||||
"connect-src 'self' https://api.stripe.com; "
|
||||
"frame-src https://js.stripe.com;"
|
||||
)
|
||||
response.headers["Content-Security-Policy"] = csp
|
||||
|
||||
# Referrer Policy
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
|
||||
# Permissions Policy (formerly Feature Policy)
|
||||
response.headers["Permissions-Policy"] = (
|
||||
"geolocation=(), microphone=(), camera=()"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class CSRFProtectionMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Basic CSRF protection for state-changing operations.
|
||||
For API-only applications, this is less critical but still good practice.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp, exempt_paths: list = None):
|
||||
super().__init__(app)
|
||||
self.exempt_paths = exempt_paths or [
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/register",
|
||||
"/api/v1/auth/google",
|
||||
"/docs",
|
||||
"/openapi.json",
|
||||
"/health",
|
||||
]
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
# Skip CSRF check for safe methods
|
||||
if request.method in ["GET", "HEAD", "OPTIONS"]:
|
||||
return await call_next(request)
|
||||
|
||||
# Skip CSRF check for exempt paths
|
||||
if any(request.url.path.startswith(path) for path in self.exempt_paths):
|
||||
return await call_next(request)
|
||||
|
||||
# For API endpoints using JWT, the token itself provides CSRF protection
|
||||
# This is because attackers can't access the token stored in httpOnly cookies
|
||||
# or local storage from a different origin
|
||||
|
||||
# If implementing cookie-based sessions, would check CSRF token here:
|
||||
# csrf_token = request.headers.get("X-CSRF-Token")
|
||||
# if not csrf_token or not self._validate_csrf_token(csrf_token):
|
||||
# return JSONResponse(
|
||||
# status_code=403,
|
||||
# content={"detail": "CSRF token missing or invalid"}
|
||||
# )
|
||||
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _generate_csrf_token() -> str:
|
||||
"""Generate a secure CSRF token"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
@staticmethod
|
||||
def _validate_csrf_token(token: str) -> bool:
|
||||
"""Validate CSRF token (implement actual validation logic)"""
|
||||
# In a real implementation, compare against stored token
|
||||
return len(token) == 43 # token_urlsafe(32) produces 43 chars
|
||||
@@ -8,6 +8,7 @@ from fastapi.responses import JSONResponse
|
||||
import logging
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.middleware import SecurityHeadersMiddleware, CSRFProtectionMiddleware
|
||||
from app.api.v1.api import api_router
|
||||
|
||||
# Configure logging
|
||||
@@ -31,6 +32,10 @@ def create_application() -> FastAPI:
|
||||
openapi_url="/api/openapi.json"
|
||||
)
|
||||
|
||||
# Security middleware (add before CORS)
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
app.add_middleware(CSRFProtectionMiddleware)
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts =
|
||||
-v
|
||||
--strict-markers
|
||||
--tb=short
|
||||
--cov=app
|
||||
--cov-report=term-missing
|
||||
--cov-report=html
|
||||
--cov-branch
|
||||
markers =
|
||||
unit: Unit tests
|
||||
integration: Integration tests
|
||||
e2e: End-to-end tests
|
||||
slow: Slow running tests
|
||||
|
||||
[coverage:run]
|
||||
source = app
|
||||
omit =
|
||||
*/tests/*
|
||||
*/migrations/*
|
||||
*/__pycache__/*
|
||||
*/venv/*
|
||||
|
||||
[coverage:report]
|
||||
precision = 2
|
||||
show_missing = True
|
||||
skip_covered = False
|
||||
|
||||
[coverage:html]
|
||||
directory = htmlcov
|
||||
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
Test configuration and fixtures.
|
||||
"""
|
||||
import pytest
|
||||
import asyncio
|
||||
from typing import AsyncGenerator, Generator
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.main import app
|
||||
from app.core.database import Base, get_db
|
||||
from app.core.config import settings
|
||||
from app.models.database_models import User
|
||||
from app.core.security import get_password_hash, create_access_token
|
||||
|
||||
# Test database URL (use different database for tests)
|
||||
TEST_DATABASE_URL = settings.DATABASE_URL.replace("/pop3_forwarder", "/pop3_forwarder_test")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop() -> Generator:
|
||||
"""Create event loop for async tests"""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def db_engine():
|
||||
"""Create test database engine"""
|
||||
engine = create_async_engine(
|
||||
TEST_DATABASE_URL,
|
||||
poolclass=NullPool,
|
||||
echo=False,
|
||||
)
|
||||
|
||||
# Create tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
# Drop tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def db_session(db_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Create test database session"""
|
||||
async_session_maker = async_sessionmaker(
|
||||
db_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
async with async_session_maker() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Create test client with database session override"""
|
||||
|
||||
async def override_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@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("testpassword123"),
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_admin_user(db_session: AsyncSession) -> User:
|
||||
"""Create a test admin user"""
|
||||
user = User(
|
||||
email="admin@example.com",
|
||||
hashed_password=get_password_hash("adminpassword123"),
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
is_admin=True,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(test_user: User) -> dict:
|
||||
"""Generate authentication headers for test user"""
|
||||
access_token = create_access_token(data={"sub": test_user.email})
|
||||
return {"Authorization": f"Bearer {access_token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_auth_headers(test_admin_user: User) -> dict:
|
||||
"""Generate authentication headers for admin user"""
|
||||
access_token = create_access_token(data={"sub": test_admin_user.email})
|
||||
return {"Authorization": f"Bearer {access_token}"}
|
||||
|
||||
|
||||
# Factory fixtures for creating test data
|
||||
|
||||
@pytest.fixture
|
||||
def user_factory(db_session: AsyncSession):
|
||||
"""Factory for creating test users"""
|
||||
async def _create_user(
|
||||
email: str = None,
|
||||
password: str = "testpassword123",
|
||||
is_active: bool = True,
|
||||
is_verified: bool = True,
|
||||
is_admin: bool = False,
|
||||
) -> User:
|
||||
if email is None:
|
||||
import uuid
|
||||
email = f"test-{uuid.uuid4()}@example.com"
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
hashed_password=get_password_hash(password),
|
||||
is_active=is_active,
|
||||
is_verified=is_verified,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
return _create_user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mail_account_factory(db_session: AsyncSession):
|
||||
"""Factory for creating test mail accounts"""
|
||||
from app.models.database_models import MailAccount
|
||||
from app.core.security import encrypt_password
|
||||
|
||||
async def _create_mail_account(
|
||||
user_id: int,
|
||||
host: str = "pop.example.com",
|
||||
port: int = 995,
|
||||
username: str = None,
|
||||
password: str = "mailpassword",
|
||||
protocol: str = "pop3",
|
||||
use_ssl: bool = True,
|
||||
) -> MailAccount:
|
||||
if username is None:
|
||||
import uuid
|
||||
username = f"test-{uuid.uuid4()}@example.com"
|
||||
|
||||
encrypted_password = encrypt_password(password, user_id)
|
||||
|
||||
account = MailAccount(
|
||||
user_id=user_id,
|
||||
host=host,
|
||||
port=port,
|
||||
username=username,
|
||||
encrypted_password=encrypted_password,
|
||||
protocol=protocol,
|
||||
use_ssl=use_ssl,
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(account)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(account)
|
||||
return account
|
||||
|
||||
return _create_mail_account
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Unit tests for configuration module.
|
||||
"""
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
class TestConfigValidation:
|
||||
"""Test configuration validation"""
|
||||
|
||||
def test_default_secret_key_rejected(self):
|
||||
"""Test that default SECRET_KEY is rejected"""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
Settings(
|
||||
SECRET_KEY="change-this-to-a-secure-random-secret-key-in-production",
|
||||
ENCRYPTION_KEY="this-is-a-secure-32-character-key-for-testing",
|
||||
)
|
||||
|
||||
assert "SECRET_KEY must be changed from default" in str(exc_info.value)
|
||||
|
||||
def test_short_secret_key_rejected(self):
|
||||
"""Test that short SECRET_KEY is rejected"""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
Settings(
|
||||
SECRET_KEY="short",
|
||||
ENCRYPTION_KEY="this-is-a-secure-32-character-key-for-testing",
|
||||
)
|
||||
|
||||
assert "at least 32 characters" in str(exc_info.value)
|
||||
|
||||
def test_default_encryption_key_rejected(self):
|
||||
"""Test that default ENCRYPTION_KEY is rejected"""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
Settings(
|
||||
SECRET_KEY="this-is-a-secure-32-character-key-for-testing",
|
||||
ENCRYPTION_KEY="change-this-to-a-secure-encryption-key",
|
||||
)
|
||||
|
||||
assert "ENCRYPTION_KEY must be changed from default" in str(exc_info.value)
|
||||
|
||||
def test_short_encryption_key_rejected(self):
|
||||
"""Test that short ENCRYPTION_KEY is rejected"""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
Settings(
|
||||
SECRET_KEY="this-is-a-secure-32-character-key-for-testing",
|
||||
ENCRYPTION_KEY="short",
|
||||
)
|
||||
|
||||
assert "at least 32 characters" in str(exc_info.value)
|
||||
|
||||
def test_valid_keys_accepted(self):
|
||||
"""Test that valid keys are accepted"""
|
||||
settings = Settings(
|
||||
SECRET_KEY="this-is-a-secure-32-character-key-for-testing-secret",
|
||||
ENCRYPTION_KEY="this-is-a-secure-32-character-key-for-encryption",
|
||||
)
|
||||
|
||||
assert settings.SECRET_KEY == "this-is-a-secure-32-character-key-for-testing-secret"
|
||||
assert settings.ENCRYPTION_KEY == "this-is-a-secure-32-character-key-for-encryption"
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Unit tests for security module.
|
||||
"""
|
||||
import pytest
|
||||
from app.core.security import (
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
create_access_token,
|
||||
encrypt_password,
|
||||
decrypt_password,
|
||||
)
|
||||
|
||||
|
||||
class TestPasswordHashing:
|
||||
"""Test password hashing and verification"""
|
||||
|
||||
def test_hash_password(self):
|
||||
"""Test password hashing"""
|
||||
password = "securepassword123"
|
||||
hashed = get_password_hash(password)
|
||||
|
||||
assert hashed != password
|
||||
assert len(hashed) > 50
|
||||
assert hashed.startswith("$2b$")
|
||||
|
||||
def test_verify_password_success(self):
|
||||
"""Test password verification with correct password"""
|
||||
password = "securepassword123"
|
||||
hashed = get_password_hash(password)
|
||||
|
||||
assert verify_password(password, hashed) is True
|
||||
|
||||
def test_verify_password_failure(self):
|
||||
"""Test password verification with wrong password"""
|
||||
password = "securepassword123"
|
||||
wrong_password = "wrongpassword"
|
||||
hashed = get_password_hash(password)
|
||||
|
||||
assert verify_password(wrong_password, hashed) is False
|
||||
|
||||
|
||||
class TestJWT:
|
||||
"""Test JWT token creation and validation"""
|
||||
|
||||
def test_create_access_token(self):
|
||||
"""Test access token creation"""
|
||||
data = {"sub": "test@example.com"}
|
||||
token = create_access_token(data)
|
||||
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 50
|
||||
assert token.count('.') == 2 # JWT has 3 parts
|
||||
|
||||
|
||||
class TestEncryption:
|
||||
"""Test credential encryption/decryption"""
|
||||
|
||||
def test_encrypt_password(self):
|
||||
"""Test password encryption"""
|
||||
password = "mailpassword123"
|
||||
user_id = 1
|
||||
|
||||
encrypted = encrypt_password(password, user_id)
|
||||
|
||||
assert encrypted != password
|
||||
assert len(encrypted) > 50
|
||||
|
||||
def test_decrypt_password(self):
|
||||
"""Test password decryption"""
|
||||
password = "mailpassword123"
|
||||
user_id = 1
|
||||
|
||||
encrypted = encrypt_password(password, user_id)
|
||||
decrypted = decrypt_password(encrypted, user_id)
|
||||
|
||||
assert decrypted == password
|
||||
|
||||
def test_encryption_with_different_user_ids(self):
|
||||
"""Test that encryption produces different results for different users"""
|
||||
password = "mailpassword123"
|
||||
user_id_1 = 1
|
||||
user_id_2 = 2
|
||||
|
||||
encrypted_1 = encrypt_password(password, user_id_1)
|
||||
encrypted_2 = encrypt_password(password, user_id_2)
|
||||
|
||||
# Different users should produce different encrypted values
|
||||
assert encrypted_1 != encrypted_2
|
||||
|
||||
# But decryption should work correctly for each
|
||||
assert decrypt_password(encrypted_1, user_id_1) == password
|
||||
assert decrypt_password(encrypted_2, user_id_2) == password
|
||||
|
||||
def test_decrypt_with_wrong_user_id_fails(self):
|
||||
"""Test that decryption fails with wrong user ID"""
|
||||
password = "mailpassword123"
|
||||
user_id = 1
|
||||
wrong_user_id = 2
|
||||
|
||||
encrypted = encrypt_password(password, user_id)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
decrypt_password(encrypted, wrong_user_id)
|
||||
@@ -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
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
# Error Codes and Messages
|
||||
|
||||
This document catalogs all error codes used in the POP3 to Gmail Forwarder application.
|
||||
|
||||
## Error Code Format
|
||||
|
||||
Error codes follow this pattern: `[DOMAIN]_[NUMBER]`
|
||||
|
||||
- **AUTH**: Authentication and authorization errors (001-099)
|
||||
- **MAIL**: Mail processing errors (100-199)
|
||||
- **SUB**: Subscription and billing errors (200-299)
|
||||
- **USER**: User management errors (300-399)
|
||||
- **NOTIFY**: Notification errors (400-499)
|
||||
- **SYS**: System and infrastructure errors (500-599)
|
||||
|
||||
---
|
||||
|
||||
## Authentication & Authorization (AUTH_001-099)
|
||||
|
||||
### AUTH_001: Invalid Credentials
|
||||
- **HTTP Status**: 401 Unauthorized
|
||||
- **Message**: "Invalid email or password"
|
||||
- **Cause**: Wrong email/password combination during login
|
||||
- **Action**: Verify credentials, reset password if needed
|
||||
|
||||
### AUTH_002: Token Expired
|
||||
- **HTTP Status**: 401 Unauthorized
|
||||
- **Message**: "Authentication token has expired"
|
||||
- **Cause**: JWT token lifetime exceeded
|
||||
- **Action**: Refresh token or re-authenticate
|
||||
|
||||
### AUTH_003: Token Invalid
|
||||
- **HTTP Status**: 401 Unauthorized
|
||||
- **Message**: "Invalid authentication token"
|
||||
- **Cause**: Malformed or tampered JWT token
|
||||
- **Action**: Clear tokens and re-authenticate
|
||||
|
||||
### AUTH_004: Insufficient Permissions
|
||||
- **HTTP Status**: 403 Forbidden
|
||||
- **Message**: "You don't have permission to perform this action"
|
||||
- **Cause**: User role lacks required permissions
|
||||
- **Action**: Contact administrator for access
|
||||
|
||||
### AUTH_005: Email Already Registered
|
||||
- **HTTP Status**: 409 Conflict
|
||||
- **Message**: "An account with this email already exists"
|
||||
- **Cause**: Registration with existing email
|
||||
- **Action**: Use different email or login instead
|
||||
|
||||
### AUTH_006: OAuth Provider Error
|
||||
- **HTTP Status**: 502 Bad Gateway
|
||||
- **Message**: "Failed to authenticate with OAuth provider"
|
||||
- **Cause**: Google OAuth service unavailable
|
||||
- **Action**: Retry or use email/password login
|
||||
|
||||
### AUTH_007: Invalid OAuth State
|
||||
- **HTTP Status**: 400 Bad Request
|
||||
- **Message**: "Invalid OAuth state parameter"
|
||||
- **Cause**: CSRF token mismatch in OAuth flow
|
||||
- **Action**: Restart OAuth flow from beginning
|
||||
|
||||
### AUTH_008: Email Not Verified
|
||||
- **HTTP Status**: 403 Forbidden
|
||||
- **Message**: "Please verify your email address"
|
||||
- **Cause**: Attempting action before email verification
|
||||
- **Action**: Check email and click verification link
|
||||
|
||||
---
|
||||
|
||||
## Mail Processing (MAIL_100-199)
|
||||
|
||||
### MAIL_100: Connection Failed
|
||||
- **HTTP Status**: 502 Bad Gateway
|
||||
- **Message**: "Failed to connect to POP3/IMAP server"
|
||||
- **Cause**: Network error, wrong host/port, firewall
|
||||
- **Action**: Verify host, port, network connectivity
|
||||
|
||||
### MAIL_101: Authentication Failed
|
||||
- **HTTP Status**: 401 Unauthorized
|
||||
- **Message**: "POP3/IMAP authentication failed"
|
||||
- **Cause**: Invalid credentials for mail account
|
||||
- **Action**: Update mail account credentials
|
||||
|
||||
### MAIL_102: SSL/TLS Error
|
||||
- **HTTP Status**: 502 Bad Gateway
|
||||
- **Message**: "SSL/TLS connection error"
|
||||
- **Cause**: Certificate issues, SSL not supported
|
||||
- **Action**: Verify SSL settings, check certificate
|
||||
|
||||
### MAIL_103: Mailbox Not Found
|
||||
- **HTTP Status**: 404 Not Found
|
||||
- **Message**: "Mailbox or folder not found"
|
||||
- **Cause**: IMAP folder doesn't exist
|
||||
- **Action**: Check folder name, create if needed
|
||||
|
||||
### MAIL_104: Message Retrieval Failed
|
||||
- **HTTP Status**: 500 Internal Server Error
|
||||
- **Message**: "Failed to retrieve email message"
|
||||
- **Cause**: Corrupt message, server error
|
||||
- **Action**: Skip message, contact mail provider
|
||||
|
||||
### MAIL_105: Forward Failed
|
||||
- **HTTP Status**: 502 Bad Gateway
|
||||
- **Message**: "Failed to forward email"
|
||||
- **Cause**: SMTP error, network issue
|
||||
- **Action**: Retry, check SMTP settings
|
||||
|
||||
### MAIL_106: Rate Limit Exceeded
|
||||
- **HTTP Status**: 429 Too Many Requests
|
||||
- **Message**: "Email forwarding rate limit exceeded"
|
||||
- **Cause**: Too many emails sent too quickly
|
||||
- **Action**: Wait, upgrade plan, adjust throttling
|
||||
|
||||
### MAIL_107: Message Too Large
|
||||
- **HTTP Status**: 413 Payload Too Large
|
||||
- **Message**: "Email message exceeds size limit"
|
||||
- **Cause**: Message larger than allowed size
|
||||
- **Action**: Filter large messages, upgrade plan
|
||||
|
||||
### MAIL_108: Invalid Email Format
|
||||
- **HTTP Status**: 422 Unprocessable Entity
|
||||
- **Message**: "Email message format is invalid"
|
||||
- **Cause**: Malformed email headers or body
|
||||
- **Action**: Check source email, skip if necessary
|
||||
|
||||
### MAIL_109: Encryption Failed
|
||||
- **HTTP Status**: 500 Internal Server Error
|
||||
- **Message**: "Failed to encrypt mail credentials"
|
||||
- **Cause**: Encryption key issue
|
||||
- **Action**: Check ENCRYPTION_KEY configuration
|
||||
|
||||
### MAIL_110: Decryption Failed
|
||||
- **HTTP Status**: 500 Internal Server Error
|
||||
- **Message**: "Failed to decrypt mail credentials"
|
||||
- **Cause**: Wrong encryption key or corrupt data
|
||||
- **Action**: Re-save credentials with correct key
|
||||
|
||||
---
|
||||
|
||||
## Subscription & Billing (SUB_200-299)
|
||||
|
||||
### SUB_200: Subscription Required
|
||||
- **HTTP Status**: 402 Payment Required
|
||||
- **Message**: "This feature requires an active subscription"
|
||||
- **Cause**: Attempting premium feature without subscription
|
||||
- **Action**: Subscribe to a plan
|
||||
|
||||
### SUB_201: Limit Reached
|
||||
- **HTTP Status**: 403 Forbidden
|
||||
- **Message**: "You've reached your plan limit for [resource]"
|
||||
- **Cause**: Plan limits exceeded (accounts, emails, etc.)
|
||||
- **Action**: Upgrade plan or remove unused resources
|
||||
|
||||
### SUB_202: Payment Failed
|
||||
- **HTTP Status**: 402 Payment Required
|
||||
- **Message**: "Payment processing failed"
|
||||
- **Cause**: Invalid payment method, insufficient funds
|
||||
- **Action**: Update payment method
|
||||
|
||||
### SUB_203: Subscription Expired
|
||||
- **HTTP Status**: 402 Payment Required
|
||||
- **Message**: "Your subscription has expired"
|
||||
- **Cause**: Subscription period ended
|
||||
- **Action**: Renew subscription
|
||||
|
||||
### SUB_204: Invalid Plan
|
||||
- **HTTP Status**: 404 Not Found
|
||||
- **Message**: "Subscription plan not found"
|
||||
- **Cause**: Requesting non-existent plan
|
||||
- **Action**: Choose valid plan from available options
|
||||
|
||||
### SUB_205: Downgrade Not Allowed
|
||||
- **HTTP Status**: 409 Conflict
|
||||
- **Message**: "Cannot downgrade: usage exceeds new plan limits"
|
||||
- **Cause**: Current usage > target plan limits
|
||||
- **Action**: Reduce usage before downgrading
|
||||
|
||||
---
|
||||
|
||||
## User Management (USER_300-399)
|
||||
|
||||
### USER_300: User Not Found
|
||||
- **HTTP Status**: 404 Not Found
|
||||
- **Message**: "User account not found"
|
||||
- **Cause**: Invalid user ID or deleted account
|
||||
- **Action**: Verify user ID or create account
|
||||
|
||||
### USER_301: Profile Update Failed
|
||||
- **HTTP Status**: 500 Internal Server Error
|
||||
- **Message**: "Failed to update user profile"
|
||||
- **Cause**: Database error, validation failure
|
||||
- **Action**: Retry, check input data
|
||||
|
||||
### USER_302: Password Too Weak
|
||||
- **HTTP Status**: 422 Unprocessable Entity
|
||||
- **Message**: "Password does not meet security requirements"
|
||||
- **Cause**: Password too short or simple
|
||||
- **Action**: Use stronger password (8+ chars, mixed case, numbers)
|
||||
|
||||
### USER_303: Deletion Restricted
|
||||
- **HTTP Status**: 409 Conflict
|
||||
- **Message**: "Cannot delete user: active subscription"
|
||||
- **Cause**: User has active subscription
|
||||
- **Action**: Cancel subscription first
|
||||
|
||||
---
|
||||
|
||||
## Notifications (NOTIFY_400-499)
|
||||
|
||||
### NOTIFY_400: Notification Failed
|
||||
- **HTTP Status**: 500 Internal Server Error
|
||||
- **Message**: "Failed to send notification"
|
||||
- **Cause**: Notification service error
|
||||
- **Action**: Check notification service configuration
|
||||
|
||||
### NOTIFY_401: Invalid Channel
|
||||
- **HTTP Status**: 422 Unprocessable Entity
|
||||
- **Message**: "Invalid notification channel"
|
||||
- **Cause**: Unsupported notification type
|
||||
- **Action**: Use supported channel (email, webhook, etc.)
|
||||
|
||||
### NOTIFY_402: Channel Not Configured
|
||||
- **HTTP Status**: 424 Failed Dependency
|
||||
- **Message**: "Notification channel not configured"
|
||||
- **Cause**: Required channel settings missing
|
||||
- **Action**: Configure notification settings
|
||||
|
||||
---
|
||||
|
||||
## System Errors (SYS_500-599)
|
||||
|
||||
### SYS_500: Database Error
|
||||
- **HTTP Status**: 500 Internal Server Error
|
||||
- **Message**: "Database operation failed"
|
||||
- **Cause**: Database connection or query error
|
||||
- **Action**: Retry, check database status
|
||||
|
||||
### SYS_501: Redis Error
|
||||
- **HTTP Status**: 500 Internal Server Error
|
||||
- **Message**: "Cache service unavailable"
|
||||
- **Cause**: Redis connection error
|
||||
- **Action**: Check Redis service status
|
||||
|
||||
### SYS_502: Celery Task Failed
|
||||
- **HTTP Status**: 500 Internal Server Error
|
||||
- **Message**: "Background task processing failed"
|
||||
- **Cause**: Celery worker error
|
||||
- **Action**: Check worker logs, retry task
|
||||
|
||||
### SYS_503: Configuration Error
|
||||
- **HTTP Status**: 500 Internal Server Error
|
||||
- **Message**: "System configuration error"
|
||||
- **Cause**: Invalid or missing configuration
|
||||
- **Action**: Check environment variables
|
||||
|
||||
### SYS_504: External Service Timeout
|
||||
- **HTTP Status**: 504 Gateway Timeout
|
||||
- **Message**: "External service request timed out"
|
||||
- **Cause**: Slow response from external API
|
||||
- **Action**: Retry, check service status
|
||||
|
||||
---
|
||||
|
||||
## Usage in Code
|
||||
|
||||
### Example: Raising Errors
|
||||
```python
|
||||
from fastapi import HTTPException, status
|
||||
from app.core.errors import ErrorCode, ErrorResponse
|
||||
|
||||
# Structured error response
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ErrorResponse(
|
||||
code=ErrorCode.SUB_201,
|
||||
message="You've reached your plan limit for mail accounts",
|
||||
details={
|
||||
"current": 5,
|
||||
"limit": 5,
|
||||
"plan": "basic",
|
||||
"upgrade_url": "/pricing"
|
||||
}
|
||||
).dict()
|
||||
)
|
||||
```
|
||||
|
||||
### Example: Error Response Schema
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
code: str # e.g., "MAIL_100"
|
||||
message: str # Human-readable message
|
||||
details: dict = {} # Additional context
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
request_id: str = "" # For tracing
|
||||
```
|
||||
|
||||
### Example: Client Handling
|
||||
```javascript
|
||||
// Frontend error handling
|
||||
try {
|
||||
const response = await fetch('/api/v1/mail-accounts/', options);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
switch(error.code) {
|
||||
case 'SUB_201':
|
||||
showUpgradeModal(error.details);
|
||||
break;
|
||||
case 'MAIL_100':
|
||||
showConnectionErrorDialog(error.message);
|
||||
break;
|
||||
default:
|
||||
showGenericError(error.message);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Request failed:', err);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding New Error Codes
|
||||
|
||||
When adding new error codes:
|
||||
|
||||
1. Choose appropriate category (AUTH, MAIL, SUB, USER, NOTIFY, SYS)
|
||||
2. Assign next available number in that range
|
||||
3. Document in this file with:
|
||||
- HTTP status code
|
||||
- Message template
|
||||
- Cause
|
||||
- Recommended action
|
||||
4. Update `app/core/errors.py` with the code constant
|
||||
5. Add to API documentation examples
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-02-06
|
||||
**Maintainer**: Development Team
|
||||
@@ -0,0 +1,99 @@
|
||||
# ADR 001: Use Celery for Background Task Processing
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-01-15
|
||||
**Deciders:** Development Team
|
||||
|
||||
## Context
|
||||
|
||||
The multi-tenant SaaS version of the POP3 forwarder needs to process emails for multiple users on different schedules. We need a reliable way to:
|
||||
|
||||
1. Schedule periodic email checks per mail account
|
||||
2. Process emails asynchronously without blocking API requests
|
||||
3. Handle failures and retries gracefully
|
||||
4. Scale horizontally as user base grows
|
||||
|
||||
## Decision
|
||||
|
||||
We will use **Celery** with **Redis** as the message broker for background task processing.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### 1. APScheduler
|
||||
- **Pros**: Simpler, lightweight, no separate broker needed
|
||||
- **Cons**: Doesn't scale horizontally, limited monitoring, no distributed task queue
|
||||
|
||||
### 2. RQ (Redis Queue)
|
||||
- **Pros**: Simple Redis-based queue, Pythonic API
|
||||
- **Cons**: Less mature than Celery, fewer features (no complex routing, less monitoring)
|
||||
|
||||
### 3. AWS SQS + Lambda
|
||||
- **Pros**: Fully managed, auto-scaling
|
||||
- **Cons**: Cloud vendor lock-in, more expensive, requires AWS infrastructure
|
||||
|
||||
### 4. Custom Threading
|
||||
- **Pros**: No external dependencies
|
||||
- **Cons**: Complex to implement correctly, hard to scale, no retry logic
|
||||
|
||||
## Rationale
|
||||
|
||||
Celery was chosen because:
|
||||
|
||||
1. **Proven at Scale**: Used by companies like Instagram, Reddit, well-tested
|
||||
2. **Rich Feature Set**: Built-in retries, rate limiting, task routing, monitoring
|
||||
3. **Horizontal Scaling**: Add more workers to handle more load
|
||||
4. **Monitoring**: Flower provides real-time monitoring dashboard
|
||||
5. **Community**: Large community, extensive documentation
|
||||
6. **Redis Integration**: Redis already used for caching, can serve dual purpose
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Background tasks can scale independently from API
|
||||
- Automatic retry with exponential backoff
|
||||
- Task prioritization and routing possible
|
||||
- Flower provides monitoring and management UI
|
||||
- Can easily add more task types in future
|
||||
|
||||
### Negative
|
||||
- Additional infrastructure component (Celery workers)
|
||||
- More complex deployment (workers, beat scheduler)
|
||||
- Redis becomes critical dependency
|
||||
- Learning curve for team unfamiliar with Celery
|
||||
|
||||
### Neutral
|
||||
- Need to monitor Redis memory usage
|
||||
- Task serialization must be considered (use JSON, not pickle)
|
||||
- Task idempotency should be ensured
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
```python
|
||||
# Task definition pattern
|
||||
@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:
|
||||
# Implementation
|
||||
pass
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
- Use Flower for web-based monitoring: `celery -A app.core.celery_app flower`
|
||||
- Track metrics: task success/failure rate, execution time, queue length
|
||||
- Set up alerts for: worker unavailability, high failure rate, queue backlog
|
||||
|
||||
## Related Decisions
|
||||
|
||||
- See ADR-002 for Redis choice
|
||||
- See ADR-005 for task retry strategy
|
||||
|
||||
## References
|
||||
|
||||
- [Celery Documentation](https://docs.celeryq.dev/)
|
||||
- [Flower Monitoring](https://flower.readthedocs.io/)
|
||||
- [Celery Best Practices](https://docs.celeryq.dev/en/stable/userguide/tasks.html#best-practices)
|
||||
@@ -0,0 +1,165 @@
|
||||
# ADR 002: Use Fernet Encryption for Mail Credentials
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-01-20
|
||||
**Deciders:** Security Team, Development Team
|
||||
|
||||
## Context
|
||||
|
||||
The application stores POP3/IMAP credentials for user mail accounts. These credentials must be:
|
||||
|
||||
1. Encrypted at rest in the database
|
||||
2. Decryptable when needed for mail operations
|
||||
3. Protected with industry-standard encryption
|
||||
4. Simple to implement and maintain
|
||||
|
||||
Security requirements:
|
||||
- Symmetric encryption (need to decrypt for use)
|
||||
- At least AES-128 bit encryption
|
||||
- Per-user salt for additional security
|
||||
- Key rotation capability
|
||||
|
||||
## Decision
|
||||
|
||||
We will use **Fernet (symmetric encryption)** from the Python `cryptography` library for encrypting mail credentials.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### 1. AES Directly (PyCrypto/cryptography)
|
||||
- **Pros**: Full control, widely supported
|
||||
- **Cons**: Easy to implement incorrectly, need to handle padding, IV, etc.
|
||||
|
||||
### 2. Database-Level Encryption (PostgreSQL)
|
||||
- **Pros**: Transparent to application, secure
|
||||
- **Cons**: All-or-nothing encryption, harder key rotation, requires DB support
|
||||
|
||||
### 3. HashiCorp Vault
|
||||
- **Pros**: Enterprise-grade secret management, audit logs, key rotation
|
||||
- **Cons**: Additional infrastructure, complexity, operational overhead
|
||||
|
||||
### 4. AWS KMS / Cloud KMS
|
||||
- **Pros**: Managed service, automatic key rotation
|
||||
- **Cons**: Cloud vendor lock-in, network latency for each decrypt, cost
|
||||
|
||||
## Rationale
|
||||
|
||||
Fernet was chosen because:
|
||||
|
||||
1. **High-Level API**: Implements encryption best practices by default
|
||||
2. **Proven Security**: Based on AES-128 in CBC mode with HMAC for authentication
|
||||
3. **Python Native**: Part of `cryptography` library (PyCA)
|
||||
4. **Timestamp Validation**: Built-in support for expiring encrypted data
|
||||
5. **No Complexity**: Handles padding, IV, authentication tag automatically
|
||||
6. **Battle-Tested**: Used in production by many Python applications
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Key Derivation
|
||||
```python
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2
|
||||
|
||||
# Generate key from master secret + per-user salt
|
||||
kdf = PBKDF2(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=salt,
|
||||
iterations=100_000,
|
||||
)
|
||||
key = base64.urlsafe_b64encode(kdf.derive(ENCRYPTION_KEY.encode()))
|
||||
```
|
||||
|
||||
### Encryption/Decryption
|
||||
```python
|
||||
def encrypt_password(password: str, user_id: int) -> str:
|
||||
"""Encrypt password with user-specific salt."""
|
||||
salt = get_user_salt(user_id)
|
||||
key = derive_key(ENCRYPTION_KEY, salt)
|
||||
fernet = Fernet(key)
|
||||
return fernet.encrypt(password.encode()).decode()
|
||||
|
||||
def decrypt_password(encrypted_password: str, user_id: int) -> str:
|
||||
"""Decrypt password with user-specific salt."""
|
||||
salt = get_user_salt(user_id)
|
||||
key = derive_key(ENCRYPTION_KEY, salt)
|
||||
fernet = Fernet(key)
|
||||
return fernet.decrypt(encrypted_password.encode()).decode()
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Simple, secure implementation
|
||||
- No risk of implementing encryption incorrectly
|
||||
- Built-in authentication (prevents tampering)
|
||||
- Can add TTL expiration if needed
|
||||
- Easy to test and validate
|
||||
|
||||
### Negative
|
||||
- Slower than AES-GCM (includes HMAC overhead)
|
||||
- Fixed to AES-128 (no AES-256 option without manual implementation)
|
||||
- All encrypted values become invalid if master key changes (no key rotation)
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
#### For Key Rotation
|
||||
```python
|
||||
# Support multiple encryption keys with versioning
|
||||
ENCRYPTION_KEY_V1 = os.getenv('ENCRYPTION_KEY_V1')
|
||||
ENCRYPTION_KEY_V2 = os.getenv('ENCRYPTION_KEY_V2') # New key
|
||||
|
||||
# Store key version with encrypted data
|
||||
encrypted_data = f"v2:{fernet_v2.encrypt(data)}"
|
||||
|
||||
# Decrypt with appropriate key
|
||||
version, encrypted = encrypted_data.split(':', 1)
|
||||
if version == 'v1':
|
||||
return fernet_v1.decrypt(encrypted)
|
||||
elif version == 'v2':
|
||||
return fernet_v2.decrypt(encrypted)
|
||||
```
|
||||
|
||||
#### For Per-User Salt
|
||||
```python
|
||||
# Generate unique salt per user (stored in users table)
|
||||
def get_or_create_user_salt(user_id: int) -> bytes:
|
||||
# Use deterministic salt based on user_id + global salt
|
||||
# OR store random salt in database per user
|
||||
return hashlib.sha256(f'pop3_forwarder_user_{user_id}'.encode()).digest()
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Never log encryption keys**: Keys only in environment variables
|
||||
2. **Rotate keys regularly**: Plan for annual key rotation
|
||||
3. **Secure key storage**: Use secrets manager in production
|
||||
4. **Strong master key**: Minimum 32 characters, random
|
||||
5. **Audit access**: Log when credentials are decrypted
|
||||
6. **Principle of least privilege**: Only workers need decryption
|
||||
|
||||
## Monitoring
|
||||
|
||||
- Track decryption failures (wrong key indicator)
|
||||
- Monitor performance impact of encryption
|
||||
- Alert on unusual decryption volume
|
||||
- Log credential access for audit
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. Migrate to HashiCorp Vault for enterprise deployments
|
||||
2. Implement automatic key rotation
|
||||
3. Add encryption key versioning
|
||||
4. Consider AWS KMS for AWS deployments
|
||||
5. Add audit trail for credential access
|
||||
|
||||
## Related Decisions
|
||||
|
||||
- See ADR-006 for key management in production
|
||||
- See SECURITY_REPORT.md for security analysis
|
||||
|
||||
## References
|
||||
|
||||
- [Fernet Specification](https://github.com/fernet/spec/blob/master/Spec.md)
|
||||
- [Python cryptography library](https://cryptography.io/)
|
||||
- [OWASP Cryptographic Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html)
|
||||
Reference in New Issue
Block a user