Send.dev - Technical Architecture & Backend Stack
A developer-first platform for sending communications (email → SMS → print), built on AWS for delivery infrastructure with Convex for real-time data and Upstash for high-performance caching.
Last Updated: January 2025
Architecture Overview
┌─────────────────────────────────────────────────────────────────────────────────┐
│ SEND.DEV ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────────────────────────────────┐ │
│ │ Next.js 15 │ │ AWS LAMBDA LAYER │ │
│ │ Dashboard │───────▶│ ┌────────────────────────────────────────┐ │ │
│ │ (send.dev) │ │ │ API Gateway v2 (HTTP) │ │ │
│ └──────────────────┘ │ │ • REST endpoints (/v1/*) │ │ │
│ │ │ • CORS enabled │ │ │
│ ┌──────────────────┐ │ │ • Custom domain support │ │ │
│ │ Customer │───────▶│ └────────────────────────────────────────┘ │ │
│ │ APIs │ │ │ │ │
│ │ (REST/SDK) │ │ ▼ │ │
│ └──────────────────┘ │ ┌────────────────────────────────────────┐ │ │
│ │ │ Lambda Functions │ │ │
│ │ │ • send.ts (email API) │ │ │
│ │ │ • templates.ts (CRUD) │ │ │
│ │ │ • domains.ts (verification) │ │ │
│ │ │ • webhooks.ts (management) │ │ │
│ │ │ • analytics.ts (metrics) │ │ │
│ │ └────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────┼─────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────┐ ┌──────────────────────────┐ ┌─────────────────┐│
│ │ UPSTASH REDIS │ │ CONVEX │ │ AWS SES ││
│ │ (Cache Layer) │ │ (Source of Truth) │ │ (Email Delivery)││
│ │ │ │ │ │ ││
│ │ • API Key cache │ │ • users │ │ • SendEmail ││
│ │ • Rate limiting │ │ • apiKeys (master) │ │ • DKIM/SPF ││
│ │ • Usage counters │ │ • templates │ │ • Bounce/Comp ││
│ │ • Session tokens │ │ • emailLogs │ │ • Tracking ││
│ │ │ │ • analytics │ │ ││
│ │ O(1) lookups ~1ms │ │ • domains │ │ ││
│ │ │ │ • webhooks │ │ ││
│ └──────────────────────────┘ └──────────────────────────┘ └────────┬────────┘│
│ │ │ │ │
│ │ │ │ │
│ └─────────────────────────────┼──────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ ASYNC PROCESSING │ │
│ │ │ │
│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ │
│ │ │ SQS Queue │ │ SQS Priority │ │ SQS DLQ │ │ │
│ │ │ (standard) │ │ (fast) │ │ (failures) │ │ │
│ │ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ │ │
│ │ │ │ │ │ │
│ │ └───────────────────┼───────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌────────────────────┐ │ │
│ │ │ email-worker.ts │ │ │
│ │ │ (SQS Consumer) │ │ │
│ │ └────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ │
│ │ │ SNS Topic │ │ EventBridge │ │ Upstash QStash │ │ │
│ │ │ (SES Events) │─▶│ (Routing) │─▶│ (Webhooks) │ │ │
│ │ └────────────────┘ └────────────────┘ └────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ STORAGE │ │
│ │ │ │
│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ │
│ │ │ S3 Attachments │ │ S3 Archive │ │ S3 Tracking │ │ │
│ │ │ (30-day TTL) │ │ (90d→Glacier) │ │ (pixels) │ │ │
│ │ └────────────────┘ └────────────────┘ └────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘Complete Technology Stack
Core Infrastructure
| Layer | Technology | Purpose |
|---|---|---|
| Frontend | Next.js 15 + Turbopack | Dashboard web application |
| UI Components | ShadCN/ui + @do/catalyst | Consistent design system |
| Styling | Tailwind CSS v4 | Utility-first CSS |
| API Gateway | AWS API Gateway v2 (HTTP) | REST endpoint routing |
| Compute | AWS Lambda (Node.js 20) | Serverless functions |
| IaC | SST v3 (Serverless Stack) | Infrastructure as Code |
| Monorepo | Turborepo + pnpm | Build system & dependencies |
| Code Quality | Biome.js | Linting and formatting |
Data Layer
| Storage | Technology | Purpose |
|---|---|---|
| Primary Database | Convex | Real-time data, source of truth |
| Cache Layer | Upstash Redis | API key cache, rate limiting |
| Rate Limiting | @upstash/ratelimit | Per-key enforcement |
| Webhook Delivery | Upstash QStash | Guaranteed delivery with retry |
| File Storage | AWS S3 | Attachments, archive, tracking |
Email Delivery
| Provider | Role | Status |
|---|---|---|
| AWS SES | Primary (high volume, low cost) | ✅ Implemented |
| Resend | Fallback + React Email support | ✅ Implemented |
| SendGrid | Future third provider | 🔜 Planned |
SES Tenant Isolation (Multi-Tenant Reputation)
Send.dev uses AWS SES Tenants to provide per-organization email reputation isolation. This ensures that one customer's poor sending practices don't affect other customers' deliverability.
| Component | Purpose |
|---|---|
| SES Tenant | Per-organization reputation container |
| Configuration Set | Per-tenant event tracking and metrics |
| Reputation Policy | Auto-pause rules ("standard", "strict", "none") |
| EventBridge Rule | Captures reputation findings for automation |
Architecture:
┌─────────────────────────────────────────────────────────────────┐
│ MULTI-TENANT ISOLATION │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Organization A Organization B │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ Tenant: org-abc12345 │ │ Tenant: org-xyz67890 │ │
│ │ Config: send-dev-abc │ │ Config: send-dev-xyz │ │
│ │ Reputation: ISOLATED │ │ Reputation: ISOLATED │ │
│ └──────────┬───────────┘ └──────────┬───────────┘ │
│ │ │ │
│ ┌──────────▼───────────┐ ┌──────────▼───────────┐ │
│ │ Domains: │ │ Domains: │ │
│ │ • example.com │ │ • myapp.io │ │
│ │ • mail.example.com │ │ • notifications.app │ │
│ └──────────────────────┘ └──────────────────────┘ │
│ │
│ If Org A has high bounce rate → Only Org A is affected │
│ Org B continues with normal deliverability │
└─────────────────────────────────────────────────────────────────┘Reputation Policy Options:
standard(default): Auto-pause on HIGH severity findings onlystrict: Auto-pause on ANY reputation findingnone: Monitoring only, never auto-pause
See: AWS SES Tenants Documentation
Authentication
| Type | Technology | Purpose |
|---|---|---|
| User Auth | WorkOS AuthKit | OAuth, OTP, SSO |
| API Auth | API Keys (Convex + Upstash) | Bearer token validation |
API Key Architecture
Design Philosophy
Convex = Source of Truth | Upstash = Performance Cache
┌─────────────────────────────────────────────────────────────────────────────┐
│ API KEY FLOW │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────────────────────────────────────────┐ │
│ │ API │ │ UPSTASH REDIS │ │
│ │ Request │─────▶│ Key: apikey:{prefix} │ │
│ │ │ │ TTL: 5 minutes │ │
│ └─────────────┘ │ │ │
│ │ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ │ { │ │ │
│ │ │ │ keyHash: "sha256...", │ │ │
│ │ │ │ userId: "user_xxx", │ │ │
│ │ │ │ organizationId: "org_xxx", │ │ │
│ │ CACHE │ │ permissions: ["send:email", ...], │ │ │
│ │ HIT? │ │ rateLimit: { requests: 1000, period: 60 },│ │ │
│ │ │ │ services: ["send", "sms", "voip"], │ │ │
│ │ │ │ environment: "live" | "test", │ │ │
│ │ │ │ status: "active", │ │ │
│ │ │ │ expiresAt: 1234567890 │ │ │
│ │ │ │ } │ │ │
│ │ │ └─────────────────────────────────────────────┘ │ │
│ │ └─────────────────────────────────────────────────────┘ │
│ │ │ │
│ │ YES ◄─────────────────────┘ │
│ │ │
│ │ NO (cache miss) │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ CONVEX DATABASE │ │
│ │ (Source of Truth) │ │
│ │ │ │
│ │ Table: apiKeys │ │
│ │ ┌─────────────────────────────────────────────────────────────────────┐│ │
│ │ │ _id, keyHash, keyPrefix, userId, organizationId, name, ││ │
│ │ │ permissions, rateLimit, services, environment, status, ││ │
│ │ │ expiresAt, lastUsedAt, createdAt, deletedAt ││ │
│ │ └─────────────────────────────────────────────────────────────────────┘│ │
│ │ │ │
│ │ On lookup: Fetch from Convex → Write to Upstash cache → Return │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘Key Format (do.dev Ecosystem)
do_live_abcdef1234567890xyz # Production key (32 char random suffix)
do_test_abcdef1234567890xyz # Test/sandbox key
┌──────────┬────────────────────────────┐
│ Prefix │ Random ID │
│ (lookup) │ (never stored) │
├──────────┼────────────────────────────┤
│ do_live_ │ abcdef1234567890xyz │
│ (8 char) │ (24 char, cryptographic) │
└──────────┴────────────────────────────┘
Storage: Only keyHash (SHA-256) + keyPrefix stored
Lookup: Extract prefix → Check Upstash → Fallback to Convex → Verify hashCache Strategy
| Operation | Upstash | Convex |
|---|---|---|
| Key Validation | Check first (O(1), ~1ms) | Fallback on cache miss |
| Key Creation | Write-through | Primary write |
| Key Revocation | Immediate delete | Update status |
| Rate Limiting | @upstash/ratelimit | - |
| Usage Tracking | Atomic INCR | Periodic sync |
Upstash Redis Structure
KEYS:
─────────────────────────────────────────────────────────────────
apikey:{prefix} → JSON (key metadata, 5min TTL)
apikey:usage:{prefix} → HASH (requests, lastUsed, byService)
ratelimit:{prefix}:{window} → (managed by @upstash/ratelimit)
SECONDARY INDEXES (Convex handles these):
─────────────────────────────────────────────────────────────────
user:keys:{userId} → Convex query (by_user index)
org:keys:{orgId} → Convex query (by_organization index)Why This Architecture?
| Requirement | Solution |
|---|---|
| Cross-service keys | Same do_live_xxx key works for send.dev, sms.dev, voip.dev |
| Sub-ms validation | Upstash Redis global replication |
| Data durability | Convex as authoritative source |
| Rate limiting | @upstash/ratelimit (sliding window) |
| Audit trail | Full history in Convex |
| Real-time sync | Convex subscriptions for dashboard |
Convex Database Schema
Tables Overview
| Table | Purpose | Key Fields |
|---|---|---|
users | User profiles (WorkOS sync) | authId, email, roles, timestamps |
apiKeys | API key management (master) | keyHash, keyPrefix, permissions, services |
desktopSessions | Token refresh storage | sessionId, refreshToken, expiresAt |
templates | Email templates | subject, htmlContent, variables, status |
emailLogs | Email tracking | messageId, status, opens, clicks, sentiment |
analytics | Metrics aggregation | period, timestamp, emailsSent, delivered |
domains | Domain verification | domain, dnsRecords, dkimTokens, status |
webhooks | Event notifications | url, events, secret, health tracking |
suppressions | Bounce/complaint list | email, reason, source, timestamp |
Schema Definition
// packages/convex-send/convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
authId: v.string(),
email: v.string(),
name: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
roles: v.array(v.string()),
organizationId: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_auth_id", ["authId"])
.index("by_email", ["email"]),
apiKeys: defineTable({
keyHash: v.string(), // SHA-256 hash (never store raw key)
keyPrefix: v.string(), // First 16 chars for lookup (do_live_abcdef12)
userId: v.string(),
organizationId: v.optional(v.string()),
name: v.string(),
permissions: v.array(v.string()), // ["send:email", "templates:read", ...]
services: v.array(v.string()), // ["send", "sms", "voip"] - do.dev ecosystem
environment: v.string(), // "live" | "test"
rateLimit: v.object({
requests: v.number(),
period: v.number(), // seconds
}),
status: v.string(), // "active" | "revoked" | "expired"
expiresAt: v.optional(v.number()),
lastUsedAt: v.optional(v.number()),
createdAt: v.number(),
deletedAt: v.optional(v.number()),
})
.index("by_key_prefix", ["keyPrefix"])
.index("by_user", ["userId"])
.index("by_organization", ["organizationId"])
.index("by_status", ["status"]),
templates: defineTable({
userId: v.string(),
organizationId: v.optional(v.string()),
name: v.string(),
subject: v.string(),
htmlContent: v.string(),
textContent: v.optional(v.string()),
variables: v.array(v.string()), // Extracted variable names
status: v.string(), // "draft" | "active" | "archived"
version: v.number(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_user", ["userId"])
.index("by_organization", ["organizationId"]),
emailLogs: defineTable({
messageId: v.string(), // send_xxxxxxxxxxxxx
userId: v.string(),
organizationId: v.optional(v.string()),
apiKeyPrefix: v.string(),
templateId: v.optional(v.id("templates")),
from: v.string(),
to: v.string(),
subject: v.string(),
status: v.string(), // "pending" | "sent" | "delivered" | "bounced" | "complained"
provider: v.string(), // "ses" | "resend"
providerMessageId: v.optional(v.string()),
opens: v.number(),
clicks: v.number(),
links: v.optional(v.array(v.object({
url: v.string(),
clicks: v.number(),
}))),
firstOpenedAt: v.optional(v.number()),
lastOpenedAt: v.optional(v.number()),
deliveredAt: v.optional(v.number()),
bouncedAt: v.optional(v.number()),
bounceType: v.optional(v.string()),
complainedAt: v.optional(v.number()),
errorMessage: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_message_id", ["messageId"])
.index("by_user", ["userId"])
.index("by_organization", ["organizationId"])
.index("by_status", ["status"])
.index("by_created", ["createdAt"]),
analytics: defineTable({
userId: v.string(),
organizationId: v.optional(v.string()),
period: v.string(), // "hourly" | "daily"
timestamp: v.number(), // Start of period
emailsSent: v.number(),
emailsDelivered: v.number(),
emailsBounced: v.number(),
emailsComplained: v.number(),
emailsOpened: v.number(),
emailsClicked: v.number(),
uniqueOpens: v.number(),
uniqueClicks: v.number(),
})
.index("by_user_period", ["userId", "period", "timestamp"])
.index("by_org_period", ["organizationId", "period", "timestamp"]),
domains: defineTable({
userId: v.string(),
organizationId: v.optional(v.string()),
domain: v.string(),
status: v.string(), // "pending" | "verifying" | "verified" | "failed"
dkimStatus: v.string(), // "pending" | "success" | "failed"
verificationToken: v.string(),
dkimTokens: v.array(v.string()), // 3 tokens from SES
dnsRecords: v.array(v.object({
type: v.string(),
name: v.string(),
value: v.string(),
})),
verifiedAt: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_domain", ["domain"])
.index("by_user", ["userId"])
.index("by_organization", ["organizationId"]),
webhooks: defineTable({
userId: v.string(),
organizationId: v.optional(v.string()),
url: v.string(),
secret: v.string(), // For HMAC signing
events: v.array(v.string()), // ["email.sent", "email.delivered", ...]
status: v.string(), // "active" | "paused" | "failed"
failureCount: v.number(),
lastDeliveredAt: v.optional(v.number()),
lastFailedAt: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_user", ["userId"])
.index("by_organization", ["organizationId"]),
suppressions: defineTable({
email: v.string(),
userId: v.string(),
organizationId: v.optional(v.string()),
reason: v.string(), // "bounce" | "complaint" | "unsubscribe" | "manual"
bounceType: v.optional(v.string()),
source: v.string(), // "ses" | "manual"
createdAt: v.number(),
})
.index("by_email", ["email"])
.index("by_user", ["userId"]),
});AWS Infrastructure (SST)
Stack Configuration
// apps/projects/send/aws/sst.config.ts
export default $config({
app(input) {
return {
name: "send-dev",
removal: input?.stage === "production" ? "retain" : "remove",
home: "aws",
providers: {
aws: { region: "us-east-1" },
},
};
},
async run() {
// === SQS QUEUES ===
const dlq = new sst.aws.Queue("SendEmailDLQ", {
retention: "14 days",
});
const emailQueue = new sst.aws.Queue("SendEmailQueue", {
dlq: dlq.arn,
visibilityTimeout: "60 seconds",
});
const priorityQueue = new sst.aws.Queue("SendEmailPriorityQueue", {
dlq: dlq.arn,
visibilityTimeout: "30 seconds",
});
// === S3 BUCKETS ===
const attachmentsBucket = new sst.aws.Bucket("SendAttachments", {
lifecycle: [{ expiration: "30 days" }],
});
const archiveBucket = new sst.aws.Bucket("SendEmailArchive", {
lifecycle: [
{ transition: { days: 90, storageClass: "GLACIER" } },
],
});
const trackingBucket = new sst.aws.Bucket("SendTracking", {
cors: [{ allowedOrigins: ["*"], allowedMethods: ["GET"] }],
});
// === LAMBDA WORKERS ===
const emailWorker = new sst.aws.Function("SendEmailWorker", {
handler: "functions/workers/email-worker.handler",
timeout: "60 seconds",
memory: "512 MB",
environment: {
CONVEX_URL: process.env.CONVEX_URL!,
RESEND_API_KEY: process.env.RESEND_API_KEY!,
UPSTASH_REDIS_URL: process.env.UPSTASH_REDIS_URL!,
UPSTASH_REDIS_TOKEN: process.env.UPSTASH_REDIS_TOKEN!,
},
});
emailQueue.subscribe(emailWorker, { batch: { size: 10 } });
priorityQueue.subscribe(emailWorker, { batch: { size: 5 } });
// === API GATEWAY ===
const api = new sst.aws.ApiGatewayV2("SendAPI", {
cors: {
allowOrigins: ["*"],
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization"],
},
});
// Email endpoints
api.route("POST /v1/emails", "functions/emails/send.handler");
api.route("POST /v1/emails/batch", "functions/emails/batch.handler");
api.route("GET /v1/emails/{id}", "functions/emails/get.handler");
// Template endpoints
api.route("GET /v1/templates", "functions/templates/list.handler");
api.route("POST /v1/templates", "functions/templates/create.handler");
api.route("GET /v1/templates/{id}", "functions/templates/get.handler");
api.route("PUT /v1/templates/{id}", "functions/templates/update.handler");
api.route("DELETE /v1/templates/{id}", "functions/templates/delete.handler");
// Domain endpoints
api.route("GET /v1/domains", "functions/domains/list.handler");
api.route("POST /v1/domains", "functions/domains/create.handler");
api.route("GET /v1/domains/{id}/verify", "functions/domains/verify.handler");
// Webhook endpoints
api.route("GET /v1/webhooks", "functions/webhooks/list.handler");
api.route("POST /v1/webhooks", "functions/webhooks/create.handler");
api.route("PUT /v1/webhooks/{id}", "functions/webhooks/update.handler");
api.route("DELETE /v1/webhooks/{id}", "functions/webhooks/delete.handler");
// Analytics endpoints
api.route("GET /v1/analytics/overview", "functions/analytics/overview.handler");
api.route("GET /v1/analytics/events", "functions/analytics/events.handler");
return {
api: api.url,
queues: {
email: emailQueue.url,
priority: priorityQueue.url,
dlq: dlq.url,
},
buckets: {
attachments: attachmentsBucket.name,
archive: archiveBucket.name,
tracking: trackingBucket.name,
},
};
},
});Lambda Functions Structure
apps/projects/send/aws/
├── sst.config.ts
├── functions/
│ ├── emails/
│ │ ├── send.ts # POST /v1/emails
│ │ ├── batch.ts # POST /v1/emails/batch
│ │ └── get.ts # GET /v1/emails/{id}
│ ├── templates/
│ │ ├── list.ts
│ │ ├── create.ts
│ │ ├── get.ts
│ │ ├── update.ts
│ │ └── delete.ts
│ ├── domains/
│ │ ├── list.ts
│ │ ├── create.ts
│ │ └── verify.ts
│ ├── webhooks/
│ │ ├── list.ts
│ │ ├── create.ts
│ │ ├── update.ts
│ │ └── delete.ts
│ ├── analytics/
│ │ ├── overview.ts
│ │ └── events.ts
│ ├── workers/
│ │ ├── email-worker.ts # SQS consumer - sends emails
│ │ ├── dlq-processor.ts # DLQ handler
│ │ └── event-processor.ts # SNS/SES event handler
│ └── lib/
│ ├── auth.ts # API key validation (Upstash + Convex)
│ ├── convex.ts # Convex client
│ ├── upstash.ts # Upstash Redis client
│ ├── validation.ts # Request validation
│ └── providers/
│ ├── ses.ts # AWS SES integration
│ └── resend.ts # Resend integrationEmail Processing Flow
┌─────────────────────────────────────────────────────────────────────────────┐
│ EMAIL SEND FLOW │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. API REQUEST │
│ ─────────────── │
│ POST /v1/emails │
│ Authorization: Bearer do_live_xxxxxxxxxxxxx │
│ { │
│ "from": "hello@example.com", │
│ "to": "user@email.com", │
│ "subject": "Welcome!", │
│ "html": "<h1>Hello</h1>", │
│ "priority": "normal" │
│ } │
│ │
│ 2. AUTH & VALIDATION (Lambda: send.ts) │
│ ─────────────────────────────────────── │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ a) Extract API key from header │ │
│ │ b) Check Upstash cache: apikey:{prefix} │ │
│ │ └─ Cache hit? → Verify hash, check permissions │ │
│ │ └─ Cache miss? → Query Convex → Cache result (5min TTL) │ │
│ │ c) Check rate limit: @upstash/ratelimit │ │
│ │ d) Validate request payload │ │
│ │ e) Check domain authorization (Convex: domains table) │ │
│ │ f) Check suppression list (Convex: suppressions table) │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │
│ 3. CREATE LOG & QUEUE │
│ ───────────────────── │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ a) Generate messageId: send_xxxxxxxxxxxxx │ │
│ │ b) Create emailLog in Convex (status: "pending") │ │
│ │ c) Queue to SQS (normal or priority based on request) │ │
│ │ d) Increment usage counter in Upstash │ │
│ │ e) Return { id: messageId, status: "queued" } │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │
│ 4. ASYNC PROCESSING (Lambda: email-worker.ts) │
│ ───────────────────────────────────────────── │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ a) Receive batch from SQS (up to 10 messages) │ │
│ │ b) For each message: │ │
│ │ ├─ Fetch template if templateId provided (Convex) │ │
│ │ ├─ Render variables (Handlebars) │ │
│ │ ├─ Select provider (SES primary, Resend fallback) │ │
│ │ ├─ Send email via provider │ │
│ │ ├─ Update emailLog (status: "sent", providerMessageId) │ │
│ │ └─ On error: Update log, message goes to DLQ after 3 retries │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │
│ 5. EVENT PROCESSING (Lambda: event-processor.ts) │
│ ───────────────────────────────────────────── │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ SES → SNS → Lambda │ │
│ │ │ │
│ │ Events: send, delivery, bounce, complaint, open, click │ │
│ │ │ │
│ │ a) Parse SNS message │ │
│ │ b) Extract messageId from tags │ │
│ │ c) Update emailLog in Convex │ │
│ │ d) Update analytics counters │ │
│ │ e) If bounce/complaint: Add to suppressions table │ │
│ │ f) Queue webhook delivery via Upstash QStash │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │
│ 6. WEBHOOK DELIVERY (Upstash QStash) │
│ ──────────────────────────────────── │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ a) Fetch active webhooks for user (Convex) │ │
│ │ b) For each webhook matching event type: │ │
│ │ ├─ Sign payload with HMAC-SHA256 (webhook.secret) │ │
│ │ ├─ POST to webhook.url with signature header │ │
│ │ ├─ Retry with exponential backoff (up to 5 attempts) │ │
│ │ └─ Update webhook health status in Convex │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘API Endpoints
Email API
POST /v1/emails # Send single email
POST /v1/emails/batch # Send batch (up to 1000)
GET /v1/emails/{id} # Get email status and eventsTemplates API
GET /v1/templates # List templates
POST /v1/templates # Create template
GET /v1/templates/{id} # Get template
PUT /v1/templates/{id} # Update template
DELETE /v1/templates/{id} # Delete templateDomains API
GET /v1/domains # List domains
POST /v1/domains # Add domain
GET /v1/domains/{id}/verify # Get DNS records needed
POST /v1/domains/{id}/verify # Trigger verificationWebhooks API
GET /v1/webhooks # List webhooks
POST /v1/webhooks # Create webhook
PUT /v1/webhooks/{id} # Update webhook
DELETE /v1/webhooks/{id} # Delete webhookAnalytics API
GET /v1/analytics/overview # Dashboard stats
GET /v1/analytics/deliverability # Bounce/complaint rates
GET /v1/analytics/events # Event streamShared Packages
@do/api-keys (Planned)
Shared API key management for the do.dev ecosystem.
packages/api-keys/
├── src/
│ ├── index.ts # Main exports
│ ├── client.ts # Upstash Redis client
│ ├── generate.ts # Key generation (do_live_xxx, do_test_xxx)
│ ├── validate.ts # Cache lookup + hash verification
│ ├── permissions.ts # Permission definitions and checking
│ ├── ratelimit.ts # @upstash/ratelimit integration
│ └── types.ts # TypeScript interfaces
├── package.json
└── tsconfig.jsonUsage across do.dev services:
import { validateApiKey, checkRateLimit } from "@do/api-keys";
// In Lambda auth middleware
const key = await validateApiKey(authHeader, {
service: "send", // Check service access
permissions: ["send:email"], // Required permissions
});
if (!key) {
return { statusCode: 401, body: "Unauthorized" };
}
const { allowed } = await checkRateLimit(key.prefix);
if (!allowed) {
return { statusCode: 429, body: "Rate limit exceeded" };
}Environment Variables
Required for AWS Lambda
# Convex
CONVEX_URL=https://standing-bird-371.convex.cloud
CONVEX_DEPLOY_KEY=prod:standing-bird-371:xxxxxx
# Upstash Redis (API Key Cache + Rate Limiting)
UPSTASH_REDIS_URL=https://xxx.upstash.io
UPSTASH_REDIS_TOKEN=AxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxQ=
# Upstash QStash (Webhook Delivery)
QSTASH_URL=https://qstash.upstash.io
QSTASH_TOKEN=xxxxxxxxxx
# Email Providers
RESEND_API_KEY=re_xxxxxxxxxxxxx
# AWS SES uses IAM role credentials
# Optional
SES_CONFIGURATION_SET=send-dev-productionRequired for Next.js Dashboard
# Convex
NEXT_PUBLIC_CONVEX_URL=https://standing-bird-371.convex.cloud
# WorkOS Auth
WORKOS_API_KEY=sk_xxxxxx
WORKOS_CLIENT_ID=client_xxxxxx
NEXT_PUBLIC_WORKOS_REDIRECT_URI=https://send.dev/api/auth/callbackPhase Rollout Plan
Phase 1: API Key Infrastructure
| Task | Status |
|---|---|
| Create @do/api-keys package | 🔜 Planned |
| Upstash Redis setup | 🔜 Planned |
| Update Lambda auth layer | 🔜 Planned |
| Dashboard API key management | 🔜 Planned |
Phase 2: Email Core Completion
| Task | Status |
|---|---|
| Template variable rendering (Handlebars) | 🔜 Planned |
| Attachment handling (S3 presigned URLs) | ✅ Infrastructure ready |
| SES event processing (SNS subscription) | 🔜 Planned |
| Suppression list management | 🔜 Planned |
Phase 3: Webhook Delivery
| Task | Status |
|---|---|
| Upstash QStash integration | 🔜 Planned |
| Webhook signatures (HMAC-SHA256) | 🔜 Planned |
| Delivery tracking & retry | 🔜 Planned |
| Dashboard webhook management | 🔜 Planned |
Phase 4: Dashboard & Analytics
| Task | Status |
|---|---|
| Metrics dashboard (charts) | 🔜 Planned |
| Email logs browser | 🔜 Planned |
| Template editor | 🔜 Planned |
| Domain manager | 🔜 Planned |
Phase 5: SMS (Future)
| Task | Status |
|---|---|
| AWS Pinpoint integration | 📋 Backlog |
| Phone number provisioning | 📋 Backlog |
| Two-way messaging | 📋 Backlog |
Phase 6: Print (Future)
| Task | Status |
|---|---|
| Lob API integration | 📋 Backlog |
| Address verification | 📋 Backlog |
| Template designer | 📋 Backlog |
Cost Estimates (Monthly)
| Service | 100K emails/mo | 1M emails/mo |
|---|---|---|
| AWS Lambda | $5 | $25 |
| AWS API Gateway | $3 | $15 |
| AWS SQS | $1 | $5 |
| AWS SES | $10 | $100 |
| AWS S3 | $5 | $20 |
| Convex | Free tier | $25 |
| Upstash Redis | $10 | $30 |
| Upstash QStash | $5 | $20 |
| Vercel (Frontend) | $20 | $20 |
| Total | ~$60 | ~$260 |
Security Considerations
API Security
- API keys with SHA-256 hashing (never stored plain)
- Scoped permissions per key
- Rate limiting per key
- Key expiration support
- IP allowlisting (optional feature)
Data Security
- TLS everywhere
- S3 encryption at rest
- Convex encryption at rest
- PII handling (email content encryption)
- 90-day message retention default
Email Security
- Domain verification required
- DKIM/SPF/DMARC enforcement
- Automatic suppression lists
- Reputation monitoring per domain
- Abuse detection (volume spikes)
Infrastructure
- AWS IAM least-privilege
- Secrets in environment variables
- Audit logging (CloudTrail)
- Monitoring & alerting (CloudWatch)
Related Documents
- SEND_DEV_PRD.md - Product Requirements Document
- CODEBASE_STATUS.md - Current implementation status
- docs/development/TYPESCRIPT_BEST_PRACTICES.md - TypeScript guidelines