Architecture Overview
Owlat follows a modern serverless architecture with real-time capabilities.
Owlat follows a modern serverless architecture with real-time capabilities.
System Architecture
Monorepo Structure
Apps
| App | Description |
|---|---|
apps/web | Main web application (Nuxt 4 + Vue 3) |
apps/api | Backend (Convex — serverless functions, database, auth) |
apps/docs | Developer documentation (Nuxt Content) |
apps/marketing | Marketing / landing page site (Nuxt) |
apps/mta | Outbound Mail Transfer Agent (Hono + GroupMQ + direct SMTP, attachment scan endpoint) |
apps/imap | IMAP4rev1 server for the Postbox feature (port 993, implicit TLS — RFC 8314, no STARTTLS), backed by Convex |
apps/mail-sync | Worker that connects out to users' external IMAP/SMTP accounts: syncs inbound mail near-real-time (IMAP IDLE) and relays outbound sends. Backs the externalMailAccounts / externalMailFolderSync tables |
apps/setup-cli | owlat-setup — wizard, feature/pack/env management, doctor checks |
apps/updater | In-place update sidecar that rewrites compose files and redeploys the stack |
apps/code-worker | Background worker that polls the codeWorkTasks queue and runs the OpenCode agent against each queued task (manual task creation only — inbound-mail auto-creation is not wired) |
apps/convex-fn-proxy | Least-privilege sidecar in front of Convex for the code worker: holds the deployment admin key and forwards ONLY the worker's allowlisted codeWorkTasks/plugins/workerTasks functions, so the sandbox never holds an admin key |
apps/desktop | Tauri desktop client (multi-workspace, OS-keychain auth, native notifications, dock/taskbar unread badge, deep links). Auto-update is not production-ready; distribution is operator-provided |
Packages
| Package | Description |
|---|---|
packages/shared | Shared types (block types, editor types, compatibility data) and the feature-flag registry |
packages/mta-protocol | The Convex↔MTA wire contract — send intake, routing decisions, the IP-reputation snapshot and the webhook events, each declared once and imported by both apps |
packages/email-builder | Email builder Vue components (Notion-like editor) and the client preview under src/preview/ (compatibility analysis, Can I Email data) |
packages/email-renderer | Email HTML rendering engine (table-based, VML, CSS inlining) |
packages/email-scanner | Email security scanning (content analysis, file validation, URL reputation, ClamAV client) |
packages/mail-message | In-house RFC 5322 / MIME message library, two halves behind one entry: the parser (header engine, part tree, charset decoding, attachments) and the composer (pure RFC 5322 / RFC 2045 construction). Replaces mailparser inbound and nodemailer's MIME builder outbound |
packages/mail-auth | In-house email authentication — SPF (RFC 7208), DMARC (RFC 7489), DKIM verification (RFC 6376), and a cached injectable DNS resolver |
packages/mail-canon | The one DKIM canonicalizer (RFC 6376 §3.4). A dependency-free leaf, so the inbound verifier and the outbound signer share it without a build cycle |
packages/smtp-client | In-house SMTP client — the wire role nodemailer used to hold. Reply parsing, ESMTP negotiation, TLS and the send transaction |
packages/smtp-listener | In-house SMTP listener — raw-net command loop, byte budget and timeouts, behind the port-25 MX and submission paths. Replaces smtp-server |
packages/provider-kit | Runtime-neutral universal send-provider bundle, trust and host-verifier contracts shared by core and plugin providers |
packages/plugin-kit | Public contracts for building Owlat plugins: the manifest, contribution buckets, capabilities and per-seam module shapes |
packages/plugin-codegen | Validated build-time composition — verifies package identity, lockfile integrity and realpath containment, then emits the generated catalogs and registries |
packages/plugin-host | Runtime-neutral enforcement kernel for statically composed plugins: flag, grant, environment and scope authorization before every call |
packages/plugin-cli | owlat plugins — create/add/remove/dev/codegen over the checked-in plugins.config.ts |
packages/ui | Nuxt layer providing shared UI components and composables |
packages/sdk-js | JavaScript SDK for the Owlat API |
packages/sdk-java | Java SDK for the Owlat API (not a workspace member — no package.json, built with Maven) |
Examples
Workspace members too (examples/conformance and examples/plugins/*), and not
decoration: the reference plugins are one per trust tier and the conformance
gallery drives them through the shipped host, codegen and CLI, so the plugin
platform's extension points are proven by something that has to keep compiling.
| Example | Description |
|---|---|
examples/conformance | The plugin-platform conformance gallery: lifecycle and full-pipeline replay suites driving the three reference plugins through the real host, codegen and CLI, plus the plugin-provider parity proof — a fixture ESP bundle driven through the shipped routing, dispatch, measurement, return-path and credential-form modules |
examples/plugins/escalation-guard | Tier-1 reference plugin: an in-process escalation guard contributing an agent step, a draft strategy, an automation trigger/step/condition, a webhook event and bundled UI entries |
examples/plugins/slack-approvals | Tier-2 reference connected app: a restrict-only Slack approvals hold gate over the signed synchronous-hook protocol |
examples/plugins/deliverability-lab | Tier-3 protocol reference: live pre-send checks behind a restrict-only gate plus a seed-list worker request fixture; no host enqueue adapter is shipped |
Data Flow
Real-time Updates
Convex provides automatic real-time updates:
// Frontend - automatically re-renders when data changes
const contacts = useConvexQuery(api.contacts.contacts.list, {});
// Backend - changes automatically push to subscribed clients
export const create = mutation({
handler: async (ctx, args) => {
await ctx.db.insert('contacts', { ...args });
// Clients subscribed to list queries automatically update
},
});
Authentication Flow
Email Sending Flow
Multi-tenancy
Within an Owlat instance, the Convex backend scopes all data to organizations via BetterAuth:
// Schema pattern - every table has organizationId
contacts: defineTable({
organizationId: v.string(),
// ... other fields
}).index('by_org', ['organizationId']);
// Query pattern - always filter by organization
const contacts = await ctx.db
.query('contacts')
.withIndex('by_org', (q) => q.eq('organizationId', organizationId))
.collect();
Authentication Architecture
- Stores user/session data in Convex
- Provides auth routes via HTTP handlers
- Links sessions to organizations
Email System Architecture
sesIdentity.ts- Domain registration (VerifyDomainIdentity/DKIM)
- MAIL FROM configuration
- Verification status polling
resolveRoute() → getProviderByType()Domain management (registration, DNS verification, SES identity setup) is handled through the dashboard UI. There is no public API for domain management.
Database Schema Overview
The Convex schema (~97 tables, split into per-domain modules under apps/api/convex/schema/ and spread into defineSchema()) follows these patterns:
Core Tables
| Table | Purpose |
|---|---|
userProfiles | User profile data linked to BetterAuth |
Contact Management
| Table | Purpose |
|---|---|
contacts | Email contacts |
contactProperties | Custom field definitions |
contactPropertyValues | Custom field values per contact |
topics | Contact groupings |
contactTopics | Many-to-many with DOI status |
segments | Saved filter configurations |
Email System
| Table | Purpose |
|---|---|
emailTemplates | Marketing/transactional templates |
emailBlocks | Reusable content blocks |
campaigns | One-time email sends (includes archiveToken, archiveHtmlContent, and the stats* family — e.g. statsHardBounced, statsSoftBounced) |
emailSends | Per-recipient tracking |
transactionalEmails | API-triggered templates |
transactionalSends | Transactional delivery tracking |
Automations
| Table | Purpose |
|---|---|
automations | Workflow definitions |
automationSteps | Steps within workflows |
automationRuns | Contact progress through workflows |
automationStepRuns | Individual step execution |
Settings & Security
| Table | Purpose |
|---|---|
domains | Custom sending domains |
apiKeys | API authentication |
webhooks | Event notifications |
webhookDeliveryLogs | Delivery tracking |
blockedEmails | Bounce/complaint blocklist |
auditLogs | Action history |
formEndpoints | Public form configuration |
formSubmissions | Form submission records |
mediaAssets | Media library files |
contactActivities | Contact activity tracking |
instanceSettings | Per-deployment toggles incl. featureFlags |
Postbox (personal mail · flag: postbox)
| Table | Purpose |
|---|---|
mailboxes | Per-user mailbox, hosted or external (one BetterAuth user can own many) |
externalMailAccounts | External IMAP/SMTP account credentials + sync config, synced by apps/mail-sync |
externalMailFolderSync | Per-folder sync cursor for external accounts |
mailThreads | Conversation grouping across folders |
mailFolders | System folders (INBOX/Sent/Drafts/Trash/Spam/Archive) + user folders |
mailMessages | Inbound/sent messages with envelope + body parts (snooze via the snoozedUntil field; outbound state in the outbound object) |
mailLabels | User-created labels (in addition to system folders) |
mailDrafts | Compose drafts (also the outbound queue — mail/outboundCron dispatches scheduled/pending drafts) |
mailAliases | Address aliases routing into a mailbox |
mailForwarding | Forwarding rules |
mailFilters | Sieve-like rules (match → actions) |
mailSignatures | Per-mailbox signatures |
mailContacts | Per-mailbox address book |
mailVacationResponders | Out-of-office responder windows |
mailVacationLog | Per-sender anti-loop record for the vacation responder |
mailAppPasswords | Scrypt-hashed credentials for native IMAP/SMTP clients |
mailAuditLog | Mailbox-level event log (delivery, IMAP login, …) |
mailAuthFailures | Sliding-window auth-failure log backing the SMTP rate limit |
See Postbox Architecture for the schema relationships, IMAP command flow, and inbound delivery routing.
Background Jobs
Backend (apps/api/convex/crons.ts)
All scheduled jobs are registered in apps/api/convex/crons.ts — the authoritative list (34 jobs). A representative set:
| Job | Interval | Purpose |
|---|---|---|
| Process scheduled campaigns | 1 min | Backup for scheduler-based sends |
| Reconcile sending campaigns | 1 min | Safety net advancing sending → sent when no queued sends remain |
| Process pending delays | 5 min | Catch missed automation delay steps |
| Process account deletions | 24 hours | Handle accounts past their 30-day grace period |
| Cleanup webhook logs | 7 days | Remove delivery logs older than 30 days |
| Refresh segment counts | 30 min | Keep cached segment counts fresh |
| Reconcile contact counts | 24 hours | Correct drift in cached contact counts |
| Reconcile topic member counts | 24 hours | Correct drift in cached topic member counts |
| Sync warming state | 5 min | Pull IP-warming state from the MTA |
| Cleanup sending reputation | 1 hour | Drop reputation buckets older than 60 days (risk is derived on read, ADR-0042) |
| Knowledge graph maintenance | 24 hours | Confidence decay + expiry cleanup |
| Retry failed agent actions | 5 min | Re-run agent pipeline actions under the retry limit |
| Channel health checks | 5 min | Probe SMS / WhatsApp / generic channel connectivity |
| Agent metrics rollup | 5 min | Queue depth, latency, error rates; evaluate circuit breakers |
| Reset autonomy daily counts | 24 hours | Reset the per-day autonomy action counters |
| Adjust autonomy thresholds | 7 days | Auto-tightens a category on a rejection spike; on a low rejection rate records a graduation suggestion (loosening requires explicit user acceptance, never automatic) |
| Report analytics | 15 min | Optionally POST instance metrics to an external control plane; no-op unless CONTROL_PLANE_URL + INSTANCE_SECRET are set (not shipped with OSS self-host) |
| Reconcile transactional send counts | 24 hours | Correct drift in the cached transactional send count |
| Postbox dispatch overdue drafts | 1 min | Send overdue scheduled/pending Postbox drafts the per-draft scheduler may have missed |
| Postbox wake snoozed messages | 1 min | Return messages whose snoozedUntil has passed |
| Cleanup soft-deleted contacts | 24 hours | Permanently delete contacts past their 30-day retention |
| Rollup sent campaign stats | 2 min | Roll up per-recipient events into campaign stats* totals |
| Rollup automation stats | 1 min | Roll up automation step-run counters |
| Cleanup webhook payloads | 7 days | Drop stored webhook payloads past retention |
| Retention: audit logs | 24 hours | Purge audit logs past their retention window |
| Retention: mail audit log | 24 hours | Purge mailbox audit-log entries past retention |
| Retention: form submission metadata | 24 hours | Strip form-submission metadata past retention |
| Retention: mail auth failures | 24 hours | Purge SMTP auth-failure records past retention |
| Evaluate reputation auto-enforce | 1 hour | Apply reputation-based auto-enforcement actions |
| Knowledge graph dedup | 24 hours | Merge duplicate knowledge-graph entities |
| Backfill unprocessed files | 15 min | Process files that were not yet indexed |
| Reconcile stuck approved inbox messages | 5 min | Advance approved inbox messages stuck in the pipeline |
| Auto-merge duplicate contacts | 6 hours | Merge detected duplicate contacts |
The table above is illustrative. apps/api/convex/crons.ts registers 34 jobs and is the authoritative, current set — consult it rather than this list.
The reset autonomy daily counts and adjust autonomy thresholds crons maintain the autonomy-rules + feedback tables, but the graduated-autonomy decision loop has no production callers yet — only the agent's daily cap, confidence threshold, and the llm_failure circuit breaker are live. Likewise, several agentMetrics fields surfaced by the metrics rollup are not yet populated.
Frontend Architecture
Layouts
default- Public pages (landing, auth)dashboard- Authenticated pages with sidebar
Route Structure
/ # Landing page
/auth/login # Login
/auth/register # Registration
/auth/forgot-password # Password reset request
/auth/reset-password # Password reset form
/dashboard # Main dashboard
/dashboard/send/* # Email templates, blocks, media library
/dashboard/send/media # Media library
/dashboard/send/emails/* # Email editor
/dashboard/campaigns/* # Campaign management (flag: campaigns)
/dashboard/automations/* # Automation workflows (flag: automations)
/dashboard/send/transactional/* # Transactional templates (flag: transactional)
/dashboard/audience/* # Contacts, topics, segments
/dashboard/inbox/* # Shared team inbox + triage (flag: inbox)
/dashboard/chat/* # Real-time chat (flag: chat)
/dashboard/postbox/* # Personal mailbox webmail UI (flag: postbox)
/dashboard/preferences/* # Aliases, filters, app passwords (flag: postbox)
/dashboard/knowledge/* # Knowledge graph (flag: ai.knowledge)
/dashboard/visualizations # AI dashboards (flag: ai.visualizations)
/dashboard/files/* # File browser
/archive/:token # Campaign archive (public)
/dashboard/admin/* # Organization settings (incl. Features)
Pages that belong to a toggleable area declare requiresFeature: '<flagKey>' in their definePageMeta block. The auth middleware redirects to a "feature off" placeholder when the flag is disabled, and Convex queries enforce the same check server-side.
State Management
- Convex queries provide reactive data
useAuth()- Authentication stateuseOrganization()/useOrganizationContext()- Current organization contextuseMediaLibrary()- Media asset managementuseToast()- Global toast notificationsuseFocusMode()- Editor focus modeusePostHog()- Product analytics (capture events, identify users)usePostHogIdentity()- Auto-syncs auth/org state to PostHog
Feature Flags
Every toggleable product surface is declared once in packages/shared/src/featureFlags.ts — the single source of truth read by the setup CLI, the admin UI, the Convex backend, and Nuxt route middleware. See Feature flags — developer reference for the full registry and helper APIs.
The runtime pattern:
- Registry —
FEATURE_FLAGSdeclares each flag's category, default state, dependencies (requires,cascadesOff), required env vars, and Docker Compose profile. - Storage — current state lives in the Convex
instanceSettings.featureFlagsrow. - Resolution —
resolveFlags(stored)applies dependency rules and returns the effective state. Always use this before reading a flag. - Activation — turning a flag on activates its Docker Compose profile (recompiled into
docker-compose.override.ymlbyowlat-setup) and validates its required env vars. - Enforcement —
requiresFeaturepage meta gates routes; Convex queries callisFlagEnabled()server-side.
Providers
Where Owlat talks to an external system, it goes through a provider factory keyed off an env var. This lets self-hosters swap implementations without code changes. Today this is wired for LLM and email send; notifications and the vector store are declared env keys without an implemented factory yet.
| System | Factory | Env var | Implementations |
|---|---|---|---|
| LLM | lib/llmProvider.ts | LLM_PROVIDER | openai (default), openrouter, ollama. Claude / any OpenAI-compatible endpoint is reached via LLM_BASE_URL, not a discrete provider value |
| Email send | providers/composition.ts (universal bundles; routing/fallback in lib/sendProviders/routing.ts) | EMAIL_PROVIDER | mta, ses, resend, smtp, mandrill, emailit (the catalog's kinds) — no implicit default; unset resolves to unconfigured |
There are no notification-provider or vector-store env keys: notifications are handled client-side by the desktop app, and knowledge retrieval uses Convex's built-in vector index (ctx.vectorSearch) directly. Analytics is PostHog, configured via POSTHOG_API_KEY / POSTHOG_HOST — there is no provider-factory abstraction and no ANALYTICS_PROVIDER switch.
See Providers for interface contracts and how to add a new provider.