---
title: Architecture
path: concepts/architecture
status: published
---

# Architecture

ScaiSend is three independently scalable services behind a shared MySQL database and a shared Redis queue. The surface area the HTTP API exposes comes from one of them; the other two do the work that happens after you get back your `202`.

## The three services

```
┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   API Service   │     │ Worker Service  │     │  SMTP Service   │
│   (FastAPI)     │     │     (arq)       │     │  (aiosmtpd)     │
│                 │     │                 │     │                 │
│ • /v3/* REST    │     │ • Render        │     │ • MX lookup     │
│ • /api/admin/*  │────▶│   templates     │────▶│ • DKIM sign     │
│ • /t/* tracking │     │ • Build MIME    │     │ • Send outbound │
│ • /i/* images   │     │ • Fan out       │     │ • Parse bounces │
│ • JWT + API key │     │   webhooks      │     │ • Parse FBL     │
│ • Validation    │     │ • Stats roll-up │     │                 │
└────────┬────────┘     └────────┬────────┘     └────────┬────────┘
         │                       │                       │
         └───────────────────────┼───────────────────────┘
                                 │
              ┌──────────────────┼──────────────────┐
              │                  │                  │
        ┌─────▼─────┐      ┌─────▼─────┐     ┌─────▼─────┐
        │   MySQL   │      │   Redis   │     │    S3     │
        │ (state)   │      │  (queues) │     │(attachments)│
        └───────────┘      └───────────┘     └───────────┘
```

### API service

FastAPI. Accepts HTTP requests, validates them, authorizes them against the tenant and scopes, and — for the ones that need async work — writes to the database and pushes a job onto Redis. Returns quickly. Never talks to recipient MX servers or does template rendering on the request path.

Started with `scaisend serve`. Horizontally scalable; put any number of instances behind a load balancer.

### Worker service

[arq](https://arq-docs.helpmanual.io/) consuming Redis queues. Picks up queued sends, rebuilds the full email message:

1. Loads the message and associated personalizations from the database.
2. Renders templates (Handlebars/Mustache via chevron) with dynamic data.
3. Applies open/click/subscription tracking by rewriting HTML and injecting headers.
4. Resolves inline images (CID or proxy URL rewriting).
5. Builds the RFC 5322 message body with all headers.
6. Pushes the rendered message onto the `smtp:deliver` queue.

Also handles: webhook delivery to tenant endpoints (with retries and exponential backoff), statistics aggregation into `daily_stats`, and cleanup tasks.

Started with `scaisend worker`. Scale by running more workers against the same Redis.

### SMTP service

Two concurrent roles:

**Outbound.** An arq consumer on `smtp:deliver`. For each message:

1. Resolve MX for the recipient domain (cached for `MX_CACHE_TTL` seconds).
2. Sign the message body with DKIM (private key stored encrypted on the sender domain record).
3. Connect to the MX via the connection pool (`SMTP_POOL_SIZE_PER_DOMAIN` per domain).
4. Issue the SMTP conversation: `EHLO` → `STARTTLS` → `MAIL FROM` → `RCPT TO` → `DATA`.
5. Capture the response. `250` is `delivered`; `4xx` is `deferred` (retry with backoff); `5xx` is `bounced`.
6. Write the event to the message timeline and fan out the webhook.

**Inbound.** An `aiosmtpd` server (default port 25) that accepts DSN bounces (RFC 3464) and ARF feedback-loop reports (RFC 5965). The bounce parser correlates the incoming DSN with the message by message-id and writes a `bounce` event. The FBL parser adds the recipient to the spam-reports suppression list.

Started with `scaisend smtp`. The inbound listener needs port 25 reachable from the internet and an `A`/`AAAA` record pointing at it.

## Where state lives

| Store | What's in it |
|-------|--------------|
| **MySQL** | Everything durable: tenants, users, API keys, templates, messages, events, suppressions, webhook endpoints, webhook delivery history, stats. 27 tables, enumerated in the [deployment guide](../troubleshooting/deployment). |
| **Redis** | Transient queues (`email:process`, `smtp:deliver`, webhook delivery queue), rate-limit counters, idempotency records for ScaiKey webhooks, MX-lookup cache. |
| **S3 (Garage/MinIO/any S3-compatible)** | Attachments and uploaded images. Referenced by URL from the message body or from the `/i/{image_id}` proxy. |

Nothing critical lives only in Redis. If Redis is wiped, queued messages may be lost; persisted messages stay intact.

## ScaiKey integration

[ScaiKey](https://scaikey.scailabs.ai) is ScaiLabs' identity service. ScaiSend delegates identity, RBAC, and partner/tenant hierarchy to it.

Two flows keep ScaiSend in sync with ScaiKey:

- **Webhooks.** `POST /webhooks/scaikey` receives real-time events when partners, tenants, users, or groups change. Signature-validated with `SCAIKEY_WEBHOOK_SECRET`; idempotent via the `processed_webhook_events` table.
- **Bulk sync.** `scaisend sync --full` walks the ScaiKey API and mirrors everything into MySQL. Run this on first install and after long downtime.

JWTs presented by human users are issued by ScaiKey; ScaiSend validates them against `SCAIKEY_JWKS_URL`. API keys (`sg_live_*` / `sg_test_*`) are issued locally by ScaiSend and validated by SHA-256 hash lookup.

## Request flow, end to end

A `POST /v3/mail/send` with one personalization, one recipient, one template:

1. **API service.** Parse JSON. Resolve the API key to a tenant. Check that the key has `mail.send`. Validate the request body (max 20MB, max 10 attachments, `template_id` starts with `d-`, etc.). Insert an `email_messages` row with status `QUEUED`. Push `{"message_id": ...}` onto the `email:process` queue. Return `202 {"message_id": "..."}`.
2. **Worker service.** Pop the job. Load the template. Render `dynamic_template_data` into the subject, HTML, and plain-text bodies. Apply tracking settings (rewrite links, inject pixel, inject unsubscribe footer). Build the RFC 5322 message. Record a `processed` event, fan out the `processed` webhook. Push onto `smtp:deliver`.
3. **SMTP service.** Pop the job. Resolve MX for the recipient domain. DKIM-sign. Connect to the MX. Send. Capture the response. On `250`, write a `delivered` event and fan out the `delivered` webhook. On `4xx`, schedule a retry and write a `deferred` event. On `5xx`, write a `bounced` event, add the address to the bounce suppression list, and fan out the `bounce` webhook.

Total latency on the API is the first step only — typically single-digit milliseconds. The rest happens in the background and is observable via [messages](../tutorials/messages-and-events) and [webhooks](../tutorials/webhooks).

## Scaling

Each service is stateless (Redis and MySQL hold all state). To scale:

- **More send throughput?** Add API instances for sync throughput, add workers for render throughput, add SMTP services for delivery throughput. They're independent bottlenecks.
- **More outbound connections?** Raise `SMTP_POOL_SIZE_PER_DOMAIN` and add SMTP service replicas.
- **Higher DB load?** MySQL read replicas aren't currently supported; the app assumes a single writer. Vertical scale + index tuning is the answer.
- **Bigger attachments?** Increase S3 quota; ScaiSend streams attachments, so the API and worker processes don't buffer them in memory.

## What's next

- [Quickstart](../quickstart) — send your first email.
- [Authentication](authentication) — API keys vs JWTs.
- [Deployment](../troubleshooting/deployment) — run all three services in production.
