Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

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#

scdoc
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   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 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: EHLOSTARTTLSMAIL FROMRCPT TODATA.
  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.
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 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 and 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#

Updated 2026-05-17 01:33:26 View source (.md) rev 1