Architecture Overview

Owlat follows a modern serverless architecture with real-time capabilities.

Owlat follows a modern serverless architecture with real-time capabilities.

System Architecture

FrontendNuxt 4 + Vue 3
PagesRouter
ComposablesState
Component LibraryUI Components
Real-time subscriptionsMutations / Actions
Convex BackendServerless
QueriesReal-time
MutationsACID
ActionsExternal APIs
SchemaDatabase
BetterAuthAuth
HTTP HandlersREST API
Owlat MTADefault — Direct SMTP delivery
Intelligence pipelineIP warmingBounce processing
or
AWS SES
Resend
WebhooksEvents
PostHogAnalytics & Errors
← Client (posthog-js)← Server (posthog-node)

Monorepo Structure

Apps

AppDescription
apps/webMain web application (Nuxt 4 + Vue 3)
apps/apiBackend (Convex — serverless functions, database, auth)
apps/docsDeveloper documentation (Nuxt Content)
apps/marketingMarketing / landing page site (Nuxt)
apps/mtaOutbound Mail Transfer Agent (Hono + GroupMQ + direct SMTP, attachment scan endpoint)
apps/imapIMAP4rev1 server for the Postbox feature (port 993, implicit TLS — RFC 8314, no STARTTLS), backed by Convex
apps/mail-syncWorker 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-cliowlat-setup — wizard, feature/pack/env management, doctor checks
apps/updaterIn-place update sidecar that rewrites compose files and redeploys the stack
apps/code-workerBackground 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/desktopTauri 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

PackageDescription
packages/sharedShared types (block types, editor types, compatibility data) and the feature-flag registry
packages/email-builderEmail builder Vue components (Notion-like editor)
packages/email-rendererEmail HTML rendering engine (table-based, VML, CSS inlining)
packages/email-scannerEmail security scanning (content analysis, file validation, URL reputation, ClamAV client)
packages/email-previewerEmail client preview components (compatibility analysis, Can I Email data)
packages/channelsCommunication 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/uiNuxt layer providing shared UI components and composables
packages/sdk-jsJavaScript SDK for the Owlat API
packages/sdk-javaJava 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

User LoginCredentials submitted
BetterAuthAuthentication provider
Session CreatedJWT token issued
Organization SetactiveOrganizationId stored in session
Backend queries extract organizationId from session
Data scoped to organization automatically

Email Sending Flow

Template CreatedJSON Blocks → @owlat/email-renderer → HTML
Domain AddedSES Registration → DNS Records Generated → User Configures DNS
Campaign CreatedAudience Selected → Domain Verified (DNS + SES)
Owlat MTA (default)Direct SMTP · Intelligence pipeline · IP warming
Webhooks → Status Updates → Analytics

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

BetterAuth
UsersAuth
SessionsJWT
OrganizationsTeams
Convex Adapter
  • Stores user/session data in Convex
  • Provides auth routes via HTTP handlers
  • Links sessions to organizations

Email System Architecture

Domain management

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

TablePurpose
userProfilesUser profile data linked to BetterAuth

Contact Management

TablePurpose
contactsEmail contacts
contactPropertiesCustom field definitions
contactPropertyValuesCustom field values per contact
topicsContact groupings
contactTopicsMany-to-many with DOI status
segmentsSaved filter configurations

Email System

TablePurpose
emailTemplatesMarketing/transactional templates
emailBlocksReusable content blocks
campaignsOne-time email sends (includes archiveToken, archiveHtmlContent, and the stats* family — e.g. statsHardBounced, statsSoftBounced)
emailSendsPer-recipient tracking
transactionalEmailsAPI-triggered templates
transactionalSendsTransactional delivery tracking

Automations

TablePurpose
automationsWorkflow definitions
automationStepsSteps within workflows
automationRunsContact progress through workflows
automationStepRunsIndividual step execution

Settings & Security

TablePurpose
domainsCustom sending domains
apiKeysAPI authentication
webhooksEvent notifications
webhookDeliveryLogsDelivery tracking
blockedEmailsBounce/complaint blocklist
auditLogsAction history
formEndpointsPublic form configuration
formSubmissionsForm submission records
mediaAssetsMedia library files
contactActivitiesContact activity tracking
instanceSettingsPer-deployment toggles incl. featureFlags

Postbox (personal mail · flag: postbox)

TablePurpose
mailboxesPer-user mailbox, hosted or external (one BetterAuth user can own many)
externalMailAccountsExternal IMAP/SMTP account credentials + sync config, synced by apps/mail-sync
externalMailFolderSyncPer-folder sync cursor for external accounts
mailThreadsConversation grouping across folders
mailFoldersSystem folders (INBOX/Sent/Drafts/Trash/Spam/Archive) + user folders
mailMessagesInbound/sent messages with envelope + body parts (snooze via the snoozedUntil field; outbound state in the outbound object)
mailLabelsUser-created labels (in addition to system folders)
mailDraftsCompose drafts (also the outbound queue — mail/outboundCron dispatches scheduled/pending drafts)
mailAliasesAddress aliases routing into a mailbox
mailForwardingForwarding rules
mailFiltersSieve-like rules (match → actions)
mailSignaturesPer-mailbox signatures
mailContactsPer-mailbox address book
mailVacationRespondersOut-of-office responder windows
mailVacationLogPer-sender anti-loop record for the vacation responder
mailAppPasswordsScrypt-hashed credentials for native IMAP/SMTP clients
mailAuditLogMailbox-level event log (delivery, IMAP login, …)
mailAuthFailuresSliding-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:

JobIntervalPurpose
Process scheduled campaigns1 minBackup for scheduler-based sends
Reconcile sending campaigns1 minSafety net advancing sendingsent when no queued sends remain
Process pending delays5 minCatch missed automation delay steps
Process account deletions24 hoursHandle accounts past their 30-day grace period
Cleanup webhook logs7 daysRemove delivery logs older than 30 days
Refresh segment counts30 minKeep cached segment counts fresh
Reconcile contact counts24 hoursCorrect drift in cached contact counts
Reconcile topic member counts24 hoursCorrect drift in cached topic member counts
Sync warming state5 minPull IP-warming state from the MTA
Cleanup sending reputation1 hourDrop reputation buckets older than 60 days (risk is derived on read, ADR-0042)
Knowledge graph maintenance24 hoursConfidence decay + expiry cleanup
Retry failed agent actions5 minRe-run agent pipeline actions under the retry limit
Channel health checks5 minProbe SMS / WhatsApp / generic channel connectivity
Agent metrics rollup5 minQueue depth, latency, error rates; evaluate circuit breakers
Reset autonomy daily counts24 hoursReset the per-day autonomy action counters
Adjust autonomy thresholds7 daysAuto-tightens a category on a rejection spike; on a low rejection rate records a graduation suggestion (loosening requires explicit user acceptance, never automatic)
Report analytics15 minOptionally 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 counts24 hoursCorrect drift in the cached transactional send count
Postbox dispatch overdue drafts1 minSend overdue scheduled/pending Postbox drafts the per-draft scheduler may have missed
Postbox wake snoozed messages1 minReturn messages whose snoozedUntil has passed
Cleanup soft-deleted contacts24 hoursPermanently delete contacts past their 30-day retention
Rollup sent campaign stats2 minRoll up per-recipient events into campaign stats* totals
Rollup automation stats1 minRoll up automation step-run counters
Cleanup webhook payloads7 daysDrop stored webhook payloads past retention
Retention: audit logs24 hoursPurge audit logs past their retention window
Retention: mail audit log24 hoursPurge mailbox audit-log entries past retention
Retention: form submission metadata24 hoursStrip form-submission metadata past retention
Retention: mail auth failures24 hoursPurge SMTP auth-failure records past retention
Evaluate reputation auto-enforce1 hourApply reputation-based auto-enforcement actions
Knowledge graph dedup24 hoursMerge duplicate knowledge-graph entities
Backfill unprocessed files15 minProcess files that were not yet indexed
Reconcile stuck approved inbox messages5 minAdvance approved inbox messages stuck in the pipeline
Auto-merge duplicate contacts6 hoursMerge detected duplicate contacts
Source of truth

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.

Not yet active: graduated autonomy

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 state
  • useOrganization() / useOrganizationContext() - Current organization context
  • useMediaLibrary() - Media asset management
  • useToast() - Global toast notifications
  • useFocusMode() - Editor focus mode
  • usePostHog() - 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:

  1. RegistryFEATURE_FLAGS declares each flag's category, default state, dependencies (requires, cascadesOff), required env vars, and Docker Compose profile.
  2. Storage — current state lives in the Convex instanceSettings.featureFlags row.
  3. ResolutionresolveFlags(stored) applies dependency rules and returns the effective state. Always use this before reading a flag.
  4. Activation — turning a flag on activates its Docker Compose profile (recompiled into docker-compose.override.yml by owlat-setup) and validates its required env vars.
  5. EnforcementrequiresFeature page meta gates routes; Convex queries call isFlagEnabled() 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.

SystemFactoryEnv varImplementations
LLMlib/llmProvider.tsLLM_PROVIDERopenai (default), openrouter, ollama. Claude / any OpenAI-compatible endpoint is reached via LLM_BASE_URL, not a discrete provider value
Email sendlib/sendProviders/index.ts (registry SEND_PROVIDERS; routing/fallback in lib/sendProviders/routing.ts)EMAIL_PROVIDERmta (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.