Architecture Decision Records¶
This document captures key architectural decisions made during the project.
What is an ADR?¶
An Architecture Decision Record (ADR) documents a significant decision made during the project, including context, options considered, and rationale for the choice.
ADR-001: Use Docker for Deployment¶
Status¶
Accepted
Context¶
Need a consistent, reproducible deployment method for the Odoo ERP system across different environments (development, staging, production).
Options Considered¶
| Option | Pros | Cons |
|---|---|---|
| Docker Compose | Consistent environment, easy deployment, isolated services | Learning curve, resource overhead |
| Native Installation | Direct access, no container overhead | Dependency conflicts, hard to reproduce |
| Kubernetes | Scalable, production-ready | Complex for small deployments |
Decision¶
Use Docker Compose for all environments.
Rationale¶
- Consistent environment between development and production
- Easy onboarding for new developers
- Isolated services prevent conflicts
- Simple rollback via images
- Good fit for current scale
Consequences¶
- All team members must learn Docker basics
- Need Docker installed on all machines
- Some debugging is more complex in containers
- Resource usage higher than native
ADR-002: Nginx as Reverse Proxy¶
Status¶
Accepted
Context¶
Need a way to route traffic to multiple services (Odoo, PWA, Docs) from a single entry point with SSL termination.
Options Considered¶
| Option | Pros | Cons |
|---|---|---|
| Nginx | Fast, mature, well-documented | Configuration complexity |
| Traefik | Auto-discovery, dynamic config | Less mature, different paradigm |
| HAProxy | High performance | More complex configuration |
| Direct access | Simple | No SSL termination, multiple ports |
Decision¶
Use Nginx as reverse proxy.
Rationale¶
- Industry standard, well-documented
- Excellent performance
- Good SSL/TLS support
- Team familiarity
- Static file caching
Consequences¶
- Need to maintain Nginx configuration
- Must update config for new services
- SSL certificate management required
ADR-003: PostgreSQL 15 for Database¶
Status¶
Accepted
Context¶
Odoo requires PostgreSQL. Need to choose version and deployment method.
Options Considered¶
| Option | Pros | Cons |
|---|---|---|
| PostgreSQL 15 (Container) | Latest features, containerized | Must manage ourselves |
| PostgreSQL 13 (Container) | Stable, tested | Older features |
| AWS RDS | Managed, automatic backups | Cost, external dependency |
| PostgreSQL on host | Direct access | Not isolated, harder to migrate |
Decision¶
- Development: PostgreSQL 15 in Docker
- Production: Consider AWS RDS
Rationale¶
- PostgreSQL 15 has performance improvements
- Container provides isolation in development
- RDS for production offloads backup/scaling concerns
Consequences¶
- Need backup strategy for containerized DB
- May have slight version differences dev/prod
- RDS adds infrastructure cost
ADR-004: Flask for PWA Backend¶
Status¶
Superseded by ADR-015
Context¶
Need a lightweight backend for the field service Progressive Web App that communicates with Odoo.
Options Considered¶
| Option | Pros | Cons |
|---|---|---|
| Flask | Lightweight, Python, flexible | Need to build structure |
| Django | Batteries included | Heavy for simple API proxy |
| Node.js/Express | Fast, good for real-time | Different language from Odoo |
| Odoo Website | Native integration | Heavy, less flexible |
Decision¶
Use Flask with Gunicorn for production.
Rationale¶
- Same language as Odoo (Python)
- Lightweight for API proxy use case
- Easy to understand and modify
- Good PWA support with service workers
Consequences¶
- Need to handle session management
- Must build authentication integration
- Separate deployment from Odoo
ADR-005: MkDocs Material for Documentation¶
Status¶
Accepted
Context¶
Need a documentation system that is easy to maintain and provides good navigation/search.
Options Considered¶
| Option | Pros | Cons |
|---|---|---|
| MkDocs Material | Beautiful, searchable, markdown | Build step required |
| Sphinx | Python standard, powerful | Complex RST format |
| GitBook | Nice UI, collaboration | External service |
| Wiki | Easy editing | Poor organization |
| README files | No build needed | No navigation/search |
Decision¶
Use MkDocs with Material theme.
Rationale¶
- Markdown is easy to write
- Material theme looks professional
- Good search functionality
- Can be containerized
- Navigation structure is clear
Consequences¶
- Docs must be rebuilt after changes
- Need to maintain mkdocs.yml
- Learning curve for MkDocs features
ADR-006: Module Prefix Naming Convention¶
Status¶
Accepted
Context¶
Need a way to identify custom modules vs. community/core modules.
Options Considered¶
| Option | Pros | Cons |
|---|---|---|
jdx_ prefix |
Clear identification | Longer names |
custom_ prefix |
Generic | Not company-specific |
| No prefix | Shorter names | Hard to identify custom |
| Separate directory | Physical separation | Path complexity |
Decision¶
Use jdx_ prefix for company custom modules.
Rationale¶
- Immediately identifies custom code
- Follows Odoo community conventions
- Easy to filter in searches
- Clear ownership
Consequences¶
- All new modules must follow convention
- Need to migrate any non-prefixed modules
- Longer module names
ADR-007: REST API Module for External Integration¶
Status¶
Accepted
Context¶
Need to expose Odoo functionality to external systems (PWA, third-party integrations).
Options Considered¶
| Option | Pros | Cons |
|---|---|---|
| Custom REST API module | Full control, documented | Development effort |
| Odoo XML-RPC | Built-in | Complex, not RESTful |
| OCA REST Framework | Community maintained | Learning curve |
| GraphQL | Flexible queries | Different paradigm |
Decision¶
Use custom REST API module with API key authentication.
Rationale¶
- RESTful design is intuitive
- API key auth is simple and secure
- Full control over endpoints
- Good documentation with Swagger
Consequences¶
- Must maintain API module
- Need to document all endpoints
- Version management for API changes
ADR-008: JustCall for SMS Integration¶
Status¶
Accepted
Context¶
Need SMS/MMS capability for customer communication from Odoo.
Options Considered¶
| Option | Pros | Cons |
|---|---|---|
| JustCall | MMS support, good API | Cost per message |
| Twilio | Industry standard | Complex pricing |
| AWS SNS | AWS ecosystem | SMS only, no MMS |
| Custom SMS gateway | Full control | High maintenance |
Decision¶
Use JustCall for SMS/MMS integration.
Rationale¶
- MMS support required
- Good API documentation
- Reliable delivery
- Call integration possible
Consequences¶
- Vendor dependency
- Per-message costs
- API key management
ADR-009: S3 for Signature Storage¶
Status¶
Accepted
Context¶
Need reliable storage for digital signature images captured in the field.
Options Considered¶
| Option | Pros | Cons |
|---|---|---|
| AWS S3 | Scalable, durable, CDN | AWS account needed |
| Odoo filestore | No external service | Less scalable, backups complex |
| Local filesystem | Simple | Not shared, no redundancy |
| Azure Blob | Similar to S3 | Different ecosystem |
Decision¶
Use AWS S3 for signature image storage.
Rationale¶
- Highly durable (99.999999999%)
- Scalable without management
- CDN for fast delivery
- Easy backup/lifecycle policies
Consequences¶
- AWS account and costs
- Network dependency
- Need to handle S3 permissions
- IAM key management
ADR-010: Semantic Versioning for Modules¶
Status¶
Accepted
Context¶
Need a consistent versioning strategy for modules and releases.
Options Considered¶
| Option | Pros | Cons |
|---|---|---|
| Semantic Versioning | Clear meaning, industry standard | Discipline required |
| Date-based versions | Easy to understand | No indication of change type |
| Sequential numbers | Simple | No semantic meaning |
| Git commit hashes | Precise | Hard to compare |
Decision¶
Use Semantic Versioning: 15.0.MAJOR.MINOR.PATCH for modules.
Rationale¶
- Clear indication of change impact
- Industry standard
- Odoo convention compatible
- Enables automated version checks
Consequences¶
- Must evaluate changes for version bump
- All team members must understand SemVer
- Documentation needed for version policy
ADR-011: Flask + Tailwind + Alpine.js for Landing Page¶
Status¶
Superseded by ADR-015
Context¶
Need a public-facing landing page to generate leads (estimate requests) and support tickets that integrate with Odoo CRM and Helpdesk modules. The landing page must be: - Fast and SEO-friendly - Mobile responsive - Themeable for multi-company deployment - Integrated with the existing Docker infrastructure
Options Considered¶
| Option | Pros | Cons |
|---|---|---|
| Flask + Tailwind + Alpine.js | Lightweight, consistent with PWA, theme support | Another service to maintain |
| Odoo Website | Native CRM integration | Heavy, less flexible design |
| Static Site (Hugo/Jekyll) | Fast, simple | No form processing, separate API needed |
| Next.js/React | Modern, SSR | Overkill for landing page, different stack |
| WordPress | Easy content editing | PHP stack, security concerns, separate DB |
Decision¶
Use Flask with Tailwind CSS and Alpine.js for the landing page.
Rationale¶
- Consistency: Same Python/Flask stack as PWA, familiar patterns
- Lightweight: Minimal dependencies, fast load times
- Themeable: CSS variables allow runtime theme switching via .env
- Alpine.js: Minimal JS for forms without SPA complexity
- Tailwind CSS: Utility-first CSS, no custom CSS maintenance
- Docker Integration: Fits existing infrastructure seamlessly
Technical Choices¶
| Component | Choice | Reason |
|---|---|---|
| Backend | Flask + Gunicorn | Python consistency, production-ready |
| CSS Framework | Tailwind CSS | Utility-first, themeable via CSS variables |
| JavaScript | Alpine.js | Lightweight reactivity, no build step |
| Form Protection | reCAPTCHA v3 | Invisible, no user friction |
| API Integration | Odoo REST API | Consistent with PWA approach |
| Theme System | CSS Variables + .env | Runtime switching without rebuild |
Architecture¶
┌─────────────────┐ ┌────────────────────┐
│ Landing Page │────▶│ Odoo REST API │
│ (Flask:8001) │ │ (/restapi/1.0/) │
└─────────────────┘ └────────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌────────────────────┐
│ Google │ │ crm.lead │
│ reCAPTCHA v3 │ │ helpdesk.ticket │
└─────────────────┘ └────────────────────┘
Consequences¶
- Additional container in Docker stack
- Theme configuration via .env for easy multi-company support
- reCAPTCHA keys required for form protection
- Odoo API key needed for form submission
- CSS changes require Tailwind rebuild (
npm run build)
ADR-012: FastAPI Control Plane Architecture¶
Status¶
Accepted
Context¶
The current architecture has PWA and Landing Page directly communicating with Odoo via JSON-RPC. This creates several challenges: - Each user needs Odoo credentials - Audit trail is split between multiple systems - Email/SMS sent through Odoo (less control) - Odoo exposed to external access - Difficult to scale to multi-tenant SaaS model
Options Considered¶
| Option | Pros | Cons |
|---|---|---|
| FastAPI Control Plane | Modern async, JWT auth, centralized audit, SaaS-ready | Additional service, learning curve |
| Keep Direct Odoo Access | Simple, no new services | No audit trail, per-user Odoo auth |
| Node.js Gateway | Fast, good ecosystem | Different language from Python stack |
| Django Gateway | Batteries included | Heavy for gateway use case |
Decision¶
Implement FastAPI as the control plane gateway between all external applications (PWA, Landing Page, Customer Portal) and Odoo.
Architecture¶
┌─────────────────────────────────────────────────────────────────┐
│ Public Internet │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ PWA │ │ Landing Page │ │ Customer │ │
│ │ (Flask) │ │ (Flask) │ │ Portal │ │
│ └────┬────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └────────────────┼───────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ FastAPI (Gateway) │◀──── AWS SES (Email) │
│ │ • JWT Auth │◀──── JustCall (SMS) │
│ │ • Audit Logging │◀──── Stripe (Payments) │
│ │ • Rate Limiting │ │
│ └──────────┬──────────┘ │
│ │ │
├─────────────────────────┼────────────────────────────────────────┤
│ │ Private Network (Docker / VPC) │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Odoo (Private) │ │
│ │ • No public access│ │
│ │ • JSON-RPC only │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Rationale¶
- Centralized Authentication: FastAPI owns user identity, sessions, JWT tokens
- Complete Audit Trail: All user actions logged in FastAPI database
- Odoo Isolation: Odoo is internal-only, accessed via service account
- Email/SMS Control: Direct integration with AWS SES and JustCall
- SaaS Ready: Foundation for multi-tenant architecture
- Modern Stack: Async Python, Pydantic validation, automatic OpenAPI docs
Technical Stack¶
| Component | Technology | Purpose |
|---|---|---|
| Framework | FastAPI | Async API gateway |
| Database | PostgreSQL | Control plane data (users, audit, tokens) |
| Auth | JWT + Passlib | Stateless authentication |
| HTTP Client | aiohttp | Async Odoo communication |
| Rate Limiting | slowapi | API throttling |
| Logging | structlog | Structured JSON logs |
| Resilience | circuitbreaker, tenacity | Odoo fault tolerance |
Consequences¶
- Additional container in Docker stack
- New database for control plane data
- Migration of users from Odoo to FastAPI
- PWA and Landing Page updates to use FastAPI
- Odoo becomes internal-only service
- Need to maintain FastAPI codebase
ADR-013: Multi-Tenant SaaS with Database-per-Tenant¶
Status¶
Accepted
Context¶
Planning to offer the platform as SaaS to multiple companies. Need to decide on the multi-tenancy model that balances cost, isolation, and operational complexity.
Options Considered¶
| Option | Pros | Cons |
|---|---|---|
| Database-per-Tenant (Bridge) | Complete data isolation, per-tenant customization | More databases to manage |
| Shared Database (Pool) | Simple, cost-effective | Weak isolation, noisy neighbor |
| Separate Everything (Silo) | Maximum isolation | Expensive, hard to manage |
| Shared Schema with tenant_id | Easy queries | Complex isolation, security risks |
Decision¶
Use Shared Control Plane + Isolated Data Plane pattern: - One FastAPI instance handles all tenants (control plane) - One Odoo database per tenant (data plane) - OdooRouting table maps tenants to their Odoo databases
This is the same pattern used by: - Odoo.sh (Odoo's own SaaS) - Salesforce - Shopify
Architecture¶
┌─────────────────────────────────────────────────────────────────────┐
│ CONTROL PLANE (Shared) │
├─────────────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ │
│ │ FastAPI │ ← Single instance handles ALL tenants │
│ │ + FastAPI │ ← Users, Auth, Audit, Subscriptions │
│ │ DB │ ← OdooRouting table (tenant → Odoo DB) │
│ └──────┬──────┘ │
│ │ │
│ │ Tenant-aware routing │
│ │ │
├──────────┼──────────────────────────────────────────────────────────┤
│ │ DATA PLANE (Isolated per Tenant) │
│ ┌─────┴─────┬──────────────┬──────────────┐ │
│ ▼ ▼ ▼ ▼ │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │Odoo │ │Odoo │ │Odoo │ │Odoo │ │
│ │DB: A │ │DB: B │ │DB: C │ │DB: N │ │
│ └──────┘ └──────┘ └──────┘ └──────┘ │
└─────────────────────────────────────────────────────────────────────┘
Rationale¶
| Concern | This Pattern |
|---|---|
| Cost | Medium (shared FastAPI, separate DBs) |
| Data Isolation | Complete (separate databases) |
| Customization | Per-tenant Odoo configurations possible |
| Scaling | Add Odoo databases as needed |
| Compliance | Easy per-tenant data residency |
| Backup/Restore | Independent per tenant |
Data Ownership¶
| FastAPI Database (Control Plane) | Odoo Database (Data Plane) |
|---|---|
| Users, authentication | Customers (res.partner) |
| Sessions, JWT tokens | Sales orders |
| Audit logs | Purchase orders |
| Subscriptions, billing | Invoices |
| Tenant settings | Products, inventory |
| OdooRouting (tenant → DB map) | FSM jobs, CRM leads |
Consequences¶
- Multiple Odoo databases to manage
- Automated provisioning needed for new tenants
- Per-tenant backup strategy
- Connection pooling per tenant
- More complex deployment
- Clear data isolation for compliance
ADR-014: Tenant-Aware Odoo Routing¶
Status¶
Accepted
Context¶
With database-per-tenant architecture (ADR-013), FastAPI needs a way to route each request to the correct Odoo database based on tenant context.
Options Considered¶
| Option | Pros | Cons |
|---|---|---|
| OdooRouting table + Factory | Flexible, encrypted credentials, health tracking | Complexity |
| Environment variables per tenant | Simple | Not scalable |
| Hardcoded mapping | Very simple | Not maintainable |
| External config service | Separation of concerns | Additional service |
Decision¶
Implement tenant-aware Odoo routing with: 1. OdooRouting table: Stores tenant → Odoo database mapping with encrypted credentials 2. TenantOdooClientFactory: Maintains connection pool per tenant 3. TenantMiddleware: Extracts tenant context from JWT/header/subdomain
Database Schema¶
CREATE TABLE odoo_routing (
id UUID PRIMARY KEY,
tenant_id UUID UNIQUE NOT NULL REFERENCES tenants(id),
odoo_db_name VARCHAR(100) NOT NULL, -- Database name
odoo_url VARCHAR(255) NOT NULL, -- Odoo instance URL
odoo_service_user VARCHAR(100) NOT NULL, -- Service account
odoo_service_password_encrypted TEXT NOT NULL, -- Fernet encrypted
is_active BOOLEAN DEFAULT true,
health_status VARCHAR(20) DEFAULT 'unknown',
last_health_check TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
Tenant Resolution Flow¶
Request arrives
│
▼
┌─────────────────────┐
│ TenantMiddleware │
│ Extract tenant from:│
│ 1. JWT token claim │
│ 2. X-Tenant-ID │
│ 3. Subdomain │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ OdooRoutingService │
│ Lookup tenant → │
│ Odoo connection │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│TenantOdooClientFactory│
│ Get/create client │
│ from pool │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ OdooClient │
│ Execute request on │
│ tenant's database │
└─────────────────────┘
Security Features¶
- Fernet encryption for Odoo passwords at rest
- Per-tenant service accounts (no shared credentials)
- IP whitelist per Odoo routing (optional)
- Health status tracking for circuit breaker integration
Auto-Provisioning¶
When new tenant signs up: 1. Create tenant record in FastAPI 2. OdooProvisioner duplicates template database 3. Create service account in new database 4. Store encrypted credentials in OdooRouting 5. Tenant can immediately access their isolated Odoo
Rationale¶
- Secure: Credentials encrypted, per-tenant isolation
- Scalable: Connection pooling, async clients
- Observable: Health status, connection metrics
- Automatable: Programmatic tenant provisioning
Consequences¶
- Fernet encryption key must be securely managed
- Connection pool memory usage scales with tenants
- Template database needed for provisioning
- Health check background job recommended
ADR Template¶
Use this template for new ADRs:
## ADR-XXX: Title
### Status
Proposed | Accepted | Deprecated | Superseded
### Context
What is the issue that we're seeing that is motivating this decision?
### Options Considered
| Option | Pros | Cons |
|--------|------|------|
| Option 1 | ... | ... |
| Option 2 | ... | ... |
### Decision
What is the change that we're proposing and/or doing?
### Rationale
Why is this decision being made?
### Consequences
What becomes easier or more difficult because of this change?
ADR-015: Next.js for Frontend Applications¶
Status¶
Accepted
Context¶
The current architecture uses Flask-based backends for both the Field Service PWA (ADR-004) and Landing Page (ADR-011). With the FastAPI Control Plane (ADR-012) becoming the central API gateway, the frontend architecture needs modernization to: - Provide a superior user experience with modern React patterns - Enable better SEO with server-side rendering (SSR) and static generation - Consolidate the frontend stack into a single, maintainable codebase - Support future features like real-time updates, offline-first PWA, and improved performance - Leverage the React ecosystem for component libraries, state management, and developer tooling
Options Considered¶
| Option | Pros | Cons |
|---|---|---|
| Next.js 15 | SSR/SSG, React 19, App Router, built-in optimizations, TypeScript, excellent DX | Learning curve for team new to React |
| Keep Flask PWA | Team familiarity, existing code | Outdated UX, limited SPA capabilities, duplicate codebases |
| Remix | Full-stack React, good DX | Smaller ecosystem than Next.js |
| SvelteKit | Excellent performance, less boilerplate | Smaller talent pool, different paradigm |
| Vue/Nuxt | Good alternative, SSR support | Team more familiar with React |
Decision¶
Replace Flask-based PWA and Landing Page with a unified Next.js 15 application that serves: - Public Landing Page - Marketing, lead generation, SEO-optimized - Customer Portal - Quote viewing, approval, job status tracking - Field Service PWA - Technician mobile app with offline support - Admin Dashboard - Internal operations (optional, could remain in Odoo)
Architecture¶
┌─────────────────────────────────────────────────────────────────┐
│ Next.js Frontend │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Landing │ │ Portal │ │ Field PWA │ │
│ │ (/*) │ │ (/portal) │ │ (/field) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │
│ React Query / SWR │
│ (Data Fetching + Caching) │
└───────────────────────────┬─────────────────────────────────────┘
│ HTTPS
▼
┌─────────────────────────┐
│ FastAPI Control Plane │
│ (JWT Auth, API Gateway)│
└─────────────────────────┘
Technical Stack¶
| Component | Technology | Purpose |
|---|---|---|
| Framework | Next.js 15 | React framework with SSR/SSG |
| React | React 19 | UI library |
| Language | TypeScript | Type safety |
| Styling | Tailwind CSS 4 | Utility-first CSS |
| State | Zustand / React Query | Client + server state |
| Forms | React Hook Form + Zod | Form handling + validation |
| API Client | Axios / ky | HTTP client for FastAPI |
| PWA | next-pwa / Serwist | Service worker, offline support |
| Testing | Vitest + Playwright | Unit + E2E testing |
| Auth | NextAuth.js | OAuth/JWT integration |
Route Structure¶
app/
├── (marketing)/ # Landing page group
│ ├── page.tsx # Home
│ ├── about/
│ ├── services/
│ └── contact/
├── (portal)/ # Customer portal group
│ ├── layout.tsx # Portal layout with token auth
│ ├── quote/[token]/ # View/sign quote
│ └── job/[token]/ # Track job status
├── (field)/ # Field service PWA
│ ├── layout.tsx # PWA layout with JWT auth
│ ├── jobs/ # Job list
│ ├── jobs/[id]/ # Job detail
│ └── settings/
├── api/ # API routes (optional, most in FastAPI)
│ └── auth/[...nextauth]/
└── manifest.webmanifest # PWA manifest
Migration Strategy¶
Phase 1: Scaffold Next.js Project¶
- Create
nextjs-frontend/directory - Configure TypeScript, Tailwind, ESLint
- Setup Docker for development and production
Phase 2: Landing Page Migration¶
- Port landing page routes from Flask templates
- Implement lead form with FastAPI integration
- Setup SEO metadata, sitemap, robots.txt
Phase 3: Field PWA Migration¶
- Port job list, job detail, completion flow
- Implement offline-first with service workers
- Camera/signature capture components
- Push notifications
Phase 4: Customer Portal¶
- Token-based quote viewing
- Digital signature for quote approval
- Job status tracking
Phase 5: Decommission Flask Apps¶
- Remove Flask PWA container
- Remove Landing Page container
- Update nginx routing
Rationale¶
- Unified Codebase: One frontend repo instead of two Flask apps
- Modern UX: React 19 features, better loading states, transitions
- SEO: Next.js SSG/SSR for marketing pages, dynamic OG images
- PWA: Built-in service worker support, offline capabilities
- TypeScript: Type-safe API integration with FastAPI schemas
- Performance: Image optimization, code splitting, edge caching
- Developer Experience: Hot reload, excellent tooling, large ecosystem
- Talent Pool: React/Next.js skills more common than Flask frontend
Consequences¶
- Need to port existing Flask templates to React components
- Learning curve for team members new to React/Next.js
- Additional Docker container for Next.js
- Need to setup CI/CD for frontend builds
- Improved user experience and performance
- Single codebase to maintain instead of two Flask apps
Supersedes¶
- ADR-004: Flask for PWA Backend
- ADR-011: Flask + Tailwind + Alpine.js for Landing Page
ADR-016: Hybrid Email Architecture (Postmark + SES)¶
Status¶
Accepted
Context¶
The FastAPI control plane (ADR-012) needs a robust email system for: - Customer portal magic links (speed-critical, must reach inbox) - Transactional emails (order confirmations, invoices) - Multi-tenant support (each tenant sends from their own domain) - High deliverability for customer-facing communications
Current AWS SES integration works but lacks: - Optimal delivery speed for time-sensitive emails - Easy per-tenant domain management - Built-in deliverability optimization
Options Considered¶
| Option | Pros | Cons |
|---|---|---|
| AWS SES Only | Lowest cost ($0.10/1K), already integrated | Slower delivery, complex domain setup, DIY deliverability |
| Postmark Only | Fastest delivery, best deliverability | Higher cost, no existing integration |
| SendGrid | Feature-rich, marketing + transactional | Deliverability issues on shared IPs |
| Hybrid Postmark + SES | Best delivery for critical emails, cost-effective for bulk | Two systems to maintain |
Decision¶
Implement Hybrid Email Architecture: - Postmark Pro ($16.50/mo) as primary for customer-facing emails - AWS SES as secondary for bulk/internal and failover
Architecture¶
┌─────────────────────────────────────────────────────────────────────┐
│ EMAIL SERVICE ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ FastAPI Application │
│ │ │
│ ├── EmailService.queue() │
│ │ │ │
│ │ ▼ │
│ │ UnifiedEmailService │
│ │ │ │
│ │ ├── Check Suppression List ──► Skip if suppressed │
│ │ │ │
│ │ ├── Get Tenant Email Config │
│ │ │ └── tenant.settings.email.postmark_domain_id │
│ │ │ └── tenant.settings.email.email_from_address │
│ │ │ │
│ │ ├── Route by Priority │
│ │ │ ├── CRITICAL/HIGH ──► Postmark (tenant domain) │
│ │ │ └── NORMAL/LOW ────► AWS SES │
│ │ │ │
│ │ └── Failover: If Postmark fails ──► Try SES │
│ │ │
│ └── Webhook Endpoints │
│ ├── POST /webhooks/postmark ──► Bounce/Complaint Handler │
│ └── POST /webhooks/ses ──────► SNS Notification Handler │
│ │ │
│ ▼ │
│ Suppression List (email_suppressions table) │
│ │
└─────────────────────────────────────────────────────────────────────┘
Email Priority Routing¶
| Priority | Provider | Use Cases |
|---|---|---|
| CRITICAL | Postmark | Magic links, password resets |
| HIGH | Postmark | Order confirmations, invoices, quote notifications |
| NORMAL | SES | Status updates, shipping notifications |
| LOW | SES | Internal alerts, bulk notifications |
Per-Tenant Domain Management¶
# Tenant email settings (stored in tenant.settings JSONB)
{
"email": {
"postmark_domain_id": 12345,
"email_domain": "blinds-pro.com.au",
"email_from_address": "orders@blinds-pro.com.au",
"email_from_name": "Blinds Pro",
"dkim_verified": true,
"return_path_verified": true
}
}
Automated Domain Provisioning: 1. Admin adds tenant domain via API 2. FastAPI calls Postmark Domains API to register 3. System returns DNS records for tenant to configure 4. Background job checks verification status 5. Once verified, tenant emails use their domain
Technical Implementation¶
New Files Required¶
| File | Purpose |
|---|---|
services/email/postmark_client.py |
Postmark API client |
services/postmark_domain_service.py |
Domain management via Postmark API |
services/email/unified_service.py |
Provider routing and failover |
models/email_suppression.py |
Suppression list models |
api/admin/tenants.py |
Tenant + domain management endpoints |
Database Schema¶
-- Email suppression list
CREATE TABLE email_suppressions (
id SERIAL PRIMARY KEY,
email_hash VARCHAR(64) NOT NULL, -- SHA256 for fast lookup
email VARCHAR(255) NOT NULL,
suppression_type VARCHAR(50) NOT NULL, -- hard_bounce, complaint, unsubscribe
reason TEXT,
source_provider VARCHAR(50), -- postmark, ses
tenant_id INTEGER, -- NULL = global, else tenant-specific
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(email_hash, tenant_id)
);
-- Soft bounce tracking
CREATE TABLE email_soft_bounces (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
bounce_count INTEGER DEFAULT 1,
first_bounce_at TIMESTAMP DEFAULT NOW(),
last_bounce_at TIMESTAMP DEFAULT NOW()
);
DNS Configuration Per Tenant¶
Each tenant must configure:
# SPF (authorize Postmark)
@ TXT "v=spf1 include:spf.postmarkapp.com -all"
# DKIM (Postmark provides the key)
[selector]._domainkey CNAME [postmark-provided-value]
# DMARC (recommended)
_dmarc TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@tenant.com"
# Return-Path (for bounce handling)
pm-bounces CNAME pm.mtasv.net
Monitoring & Alerting¶
| Metric | Warning | Critical | Action |
|---|---|---|---|
| Bounce Rate | > 2% | > 5% | Review suppression, check email list quality |
| Complaint Rate | > 0.05% | > 0.1% | Immediate review, may suspend sending |
| Delivery Latency | > 30s | > 60s | Check provider status |
| Postmark Failures | > 1% | > 5% | Automatic failover to SES |
Rationale¶
- Postmark for Speed: Industry-leading delivery times ensure magic links arrive instantly
- Postmark for Deliverability: 99%+ inbox placement vs 90-95% for generic providers
- SES for Cost: $0.10/1K vs $1.30/1K for bulk/internal emails
- Failover: If Postmark is down, critical emails still go via SES
- Multi-tenant Ready: Postmark Pro supports 10 domains (expandable with Platform plan)
- Unified Suppression: Single source of truth for bounced/complained emails
Consequences¶
Positive: - Better deliverability for customer-facing emails - Faster delivery for time-sensitive emails - Automatic failover improves reliability - Per-tenant branding with custom domains - Centralized bounce/complaint handling
Negative: - Additional monthly cost (~$16.50 base) - Two email providers to monitor - More complex email service code - DNS setup required per tenant
Related Decisions¶
- ADR-012: FastAPI Control Plane (centralizes email sending)
- ADR-013: Multi-Tenant SaaS (per-tenant email domains)
- PDR-007: Email Service Provider Strategy (business decision)
Decision Log¶
| ID | Title | Status | Date |
|---|---|---|---|
| ADR-001 | Use Docker for Deployment | Accepted | 2025-01 |
| ADR-002 | Nginx as Reverse Proxy | Accepted | 2025-01 |
| ADR-003 | PostgreSQL 15 for Database | Accepted | 2025-01 |
| ADR-004 | Flask for PWA Backend | Superseded by ADR-015 | 2025-01 |
| ADR-005 | MkDocs Material for Documentation | Accepted | 2025-01 |
| ADR-006 | Module Prefix Naming Convention | Accepted | 2025-01 |
| ADR-007 | REST API Module for External Integration | Accepted | 2025-01 |
| ADR-008 | JustCall for SMS Integration | Accepted | 2025-01 |
| ADR-009 | S3 for Signature Storage | Accepted | 2025-01 |
| ADR-010 | Semantic Versioning for Modules | Accepted | 2025-12 |
| ADR-011 | Flask + Tailwind + Alpine.js for Landing Page | Superseded by ADR-015 | 2025-12 |
| ADR-012 | FastAPI Control Plane Architecture | Accepted | 2025-12 |
| ADR-013 | Multi-Tenant SaaS with Database-per-Tenant | Accepted | 2025-12 |
| ADR-014 | Tenant-Aware Odoo Routing | Accepted | 2025-12 |
| ADR-015 | Next.js for Frontend Applications | Accepted | 2025-12 |
| ADR-016 | Hybrid Email Architecture (Postmark + SES) | Accepted | 2025-12 |