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/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/email-builder | Email builder Vue components (Notion-like editor) |
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/email-previewer | Email client preview components (compatibility analysis, Can I Email data) |
packages/channels | Communication channel adapters — normalize each transport (email/MTA, SMS, WhatsApp, generic, native chat) into one ChannelAdapter interface. Email and chat are fully wired; SMS / WhatsApp / generic are inbound + health only — outbound send is not wired end-to-end |
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 |
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/postbox/settings/* # 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/settings/* # 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 | lib/sendProviders/index.ts (registry SEND_PROVIDERS; routing/fallback in lib/sendProviders/routing.ts) | EMAIL_PROVIDER | mta (default), ses, resend |
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.