Skip to content

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

  1. Unified Codebase: One frontend repo instead of two Flask apps
  2. Modern UX: React 19 features, better loading states, transitions
  3. SEO: Next.js SSG/SSR for marketing pages, dynamic OG images
  4. PWA: Built-in service worker support, offline capabilities
  5. TypeScript: Type-safe API integration with FastAPI schemas
  6. Performance: Image optimization, code splitting, edge caching
  7. Developer Experience: Hot reload, excellent tooling, large ecosystem
  8. 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

  1. Postmark for Speed: Industry-leading delivery times ensure magic links arrive instantly
  2. Postmark for Deliverability: 99%+ inbox placement vs 90-95% for generic providers
  3. SES for Cost: $0.10/1K vs $1.30/1K for bulk/internal emails
  4. Failover: If Postmark is down, critical emails still go via SES
  5. Multi-tenant Ready: Postmark Pro supports 10 domains (expandable with Platform plan)
  6. 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

  • 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