-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
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:
- Reads the
Originheader and resolves it to aDomainrecord vianmailx_common.security.extract_origin_host- never a client-supplieddomainfield (there isn't one -SendRequestinmodels.pyhas nodomainfield on purpose). - Verifies the request against that domain's Cloudflare Turnstile site key (spam/bot protection) and atomic rate-limit counters.
- Renders the requested template in a sandboxed Jinja2 environment against a per-template field whitelist.
- Sends the email via SES, using the admin-configured
From/Toaddresses on theDomainrecord - never anything from the request body. This is the whole reason nmailx can't be used as an open relay. - Writes a
SendLogEntryfor 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.
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- oneRouter()per resource: auth, domains, templates, users, logs, settings -
templates/*.html- Jinja2, extendsbase.html -
static/- vendored htmx/Bootstrap/Bootstrap Icons +nmailx-admin.css/js(see Vendored Assets)
| 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.
- 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
/sendpath and the authenticated/adminpath 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.mdfor the full list of things intentionally left out (Secrets Manager, SSM, S3, Cognito, SQS, SNS, EventBridge, CloudFront) and why.