Skip to content

Architecture

Copilot edited this page Jul 11, 2026 · 1 revision

Architecture

Request flow

Static site (GitHub Pages, etc.)
   |  POST /send  (Origin header identifies the domain)
   v
API Gateway (HTTP API)
   |-- /send          -> SendApiFunction   (public, narrow IAM, never touches Users/SES identities)
   `-- /admin/*        -> AdminApiFunction  (cookie-authenticated dashboard, never sends email)
                              |
                              v
                    5 DynamoDB tables (domains, users, templates, rate-limits, send-logs)
                    SES (send + domain identity verification)

A visitor's static site posts a form submission to /send. API Gateway routes it to SendApiFunction, which:

  1. Reads the Origin header and resolves it to a Domain record via nmailx_common.security.extract_origin_host - never a client-supplied domain field (there isn't one - SendRequest in models.py has no domain field on purpose).
  2. Verifies the request against that domain's Cloudflare Turnstile site key (spam/bot protection) and atomic rate-limit counters.
  3. Renders the requested template in a sandboxed Jinja2 environment against a per-template field whitelist.
  4. Sends the email via SES, using the admin-configured From/To addresses on the Domain record - never anything from the request body. This is the whole reason nmailx can't be used as an open relay.
  5. Writes a SendLogEntry for the dashboard's Logs view.

The admin dashboard (/admin/*) is a completely separate Lambda, AdminApiFunction. It's cookie-authenticated (JWT session tokens, argon2id password hashing), server-rendered (HTMX + Jinja2, no client-side JS framework), and never sends email itself - it only manages the DynamoDB records that SendApiFunction reads at send time.

Two Lambdas, one shared layer

Both Lambdas package their own src/<function>/app.py (required by AWS SAM) but share a single Lambda layer for everything else:

layers/common/python/nmailx_common/
  models.py            # Pydantic models: Domain, User, Template, SendLogEntry, RateLimitCounter, SendRequest
  db.py                # boto3 DynamoDB table accessors (env-var-driven table names)
  auth.py              # argon2 hashing, JWT session tokens, CSRF tokens - framework-agnostic
  security.py          # header-injection sanitization, Origin -> domain host extraction
  turnstile.py         # Cloudflare siteverify client (stdlib urllib, no `requests` dependency)
  github_fetch.py      # raw.githubusercontent.com fetch for GitHub-backed templates
  templates_engine.py  # sandboxed Jinja2 rendering + field whitelist
  ratelimit.py         # atomic conditional-UpdateItem rate limiting
  ses_client.py        # SES send + domain identity management wrappers
  config.py            # APP_NAME and everything derived from it (cookie name, JWT issuer)

src/send_api/app.py is the only code path that calls ses_client.send_email - that's a deliberate narrowing, not an accident, and it's why SendApiFunction's IAM role can stay tightly scoped (it never needs permissions for user/template management).

src/admin_api/ is the dashboard:

  • app.py - resolver setup, static asset routes, dashboard home, includes routers
  • deps.py - admin-only helpers: render(), require_roles(), CSRF, cookies (Powertools-specific, so it lives here rather than in the shared layer)
  • routes/*.py - one Router() per resource: auth, domains, templates, users, logs, settings
  • templates/*.html - Jinja2, extends base.html
  • static/ - vendored htmx/Bootstrap/Bootstrap Icons + nmailx-admin.css/js (see Vendored Assets)

The 5 DynamoDB tables

Table Holds
Domains One record per sending domain: From/To addresses, Turnstile site key, per-domain rate limits, SES verification status
Users Admin dashboard accounts: email, argon2id password hash, role (Owner/Editor/Viewer)
Templates Named email templates: inline body or a pointer to a body file in a public GitHub repo, plus the field whitelist
RateLimitCounters Atomic hourly/daily send counters per domain, TTL-expired automatically
SendLogEntries One entry per successful/failed send attempt, for the dashboard's Logs view, TTL-expired after SendLogRetentionDays

All five are defined in template.yaml (the source of truth for every AWS resource) and mirrored by hand in tests/table_defs.py for moto-backed tests - tests/integration/test_schema_matches_template.py fails the build if the two drift apart, so any schema change in template.yaml needs a matching update there.

Why this shape

  • No EC2, no containers in production - both Lambdas, API Gateway, and DynamoDB on-demand billing keep the project comfortably under $10/month at moderate volume (mostly SES's per-email cost).
  • Two Lambdas instead of one is a security boundary, not just code organization: the public, unauthenticated /send path and the authenticated /admin path have deliberately different IAM permissions, so a bug in one can't reach the other's capabilities.
  • No new AWS services beyond SES/Lambda/API Gateway/DynamoDB/CloudWatch without a deliberate decision - see the project's AGENTS.md for the full list of things intentionally left out (Secrets Manager, SSM, S3, Cognito, SQS, SNS, EventBridge, CloudFront) and why.

Clone this wiki locally