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#
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | |
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:
- Loads the message and associated personalizations from the database.
- Renders templates (Handlebars/Mustache via chevron) with dynamic data.
- Applies open/click/subscription tracking by rewriting HTML and injecting headers.
- Resolves inline images (CID or proxy URL rewriting).
- Builds the RFC 5322 message body with all headers.
- Pushes the rendered message onto the
smtp:deliverqueue.
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:
- Resolve MX for the recipient domain (cached for
MX_CACHE_TTLseconds). - Sign the message body with DKIM (private key stored encrypted on the sender domain record).
- Connect to the MX via the connection pool (
SMTP_POOL_SIZE_PER_DOMAINper domain). - Issue the SMTP conversation:
EHLO→STARTTLS→MAIL FROM→RCPT TO→DATA. - Capture the response.
250isdelivered;4xxisdeferred(retry with backoff);5xxisbounced. - 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/scaikeyreceives real-time events when partners, tenants, users, or groups change. Signature-validated withSCAIKEY_WEBHOOK_SECRET; idempotent via theprocessed_webhook_eventstable. - Bulk sync.
scaisend sync --fullwalks 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:
- 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_idstarts withd-, etc.). Insert anemail_messagesrow with statusQUEUED. Push{"message_id": ...}onto theemail:processqueue. Return202 {"message_id": "..."}. - Worker service. Pop the job. Load the template. Render
dynamic_template_datainto the subject, HTML, and plain-text bodies. Apply tracking settings (rewrite links, inject pixel, inject unsubscribe footer). Build the RFC 5322 message. Record aprocessedevent, fan out theprocessedwebhook. Push ontosmtp:deliver. - SMTP service. Pop the job. Resolve MX for the recipient domain. DKIM-sign. Connect to the MX. Send. Capture the response. On
250, write adeliveredevent and fan out thedeliveredwebhook. On4xx, schedule a retry and write adeferredevent. On5xx, write abouncedevent, add the address to the bounce suppression list, and fan out thebouncewebhook.
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_DOMAINand 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 — send your first email.
- Authentication — API keys vs JWTs.
- Deployment — run all three services in production.