Developer Guide

Technical architecture, feature-flag model, and provider abstractions used by Owlat.

This guide covers the technical architecture and development patterns used in Owlat. Owlat is built as a set of feature areas (sending, receiving, AI, integrations, security, deliverability) that share a common platform — the Convex backend, the email rendering engine, and the feature-flag registry that turns each area on or off.

Architecture & core systems

Outbound mail

Inbound mail

Self-hosting

Reference

Development Setup

Prerequisites

  • Node.js 22+
  • Bun package manager
  • Docker + Docker Compose v2 (for the full self-host stack)

Local Development

  1. Install dependencies:
    bun install
    
  2. Start the Convex backend (keeps running):
    bun run dev:api
    
  3. Start the Nuxt frontend:
    bun run dev
    

Environment Variables

Both the Nuxt frontend and the Convex backend use environment variables for configuration. Feature flags follow a dual-variable pattern: one Convex env var for the backend and one NUXT_PUBLIC_* var for the frontend.

See the Environment Variables page for a complete reference with setup instructions for providers, security, and analytics.

Quick reference — essential variables:

VariableWherePurpose
BETTER_AUTH_SECRETConvexSession signing secret
SITE_URLConvexPublic site URL for redirects
EMAIL_PROVIDERConvexmta (default), ses, or resend
LLM_PROVIDERConvexopenai (default), openrouter, ollama (all OpenAI-compatible) — only required when ai flag is on. A Claude endpoint is reachable only by pointing LLM_BASE_URL at an OpenAI-compatible proxy, not by setting LLM_PROVIDER=anthropic
UNSUBSCRIBE_SECRETConvexHMAC secret for unsubscribe tokens
NUXT_PUBLIC_CONVEX_URLNuxt .envConvex deployment URL
NUXT_PUBLIC_CONVEX_SITE_URLNuxt .envConvex site URL (for auth)
NUXT_PUBLIC_SITE_URLNuxt .envPublic site URL

Code Quality

bun run typecheck  # TypeScript checking
bun run lint       # Oxlint
bun run ox:fmt     # Format with Oxfmt

Project Structure

owlat/
├── apps/
│   ├── web/                # Nuxt 4 dashboard
│   │   └── app/
│   │       ├── components/     # Vue components
│   │       │   └── ui/         # App-specific UI components
│   │       ├── composables/    # Vue composables
│   │       ├── layouts/        # Page layouts
│   │       ├── pages/          # File-based routing
│   │       └── plugins/        # Nuxt plugins
│   ├── api/                # Convex backend
│   │   └── convex/
│   │       ├── _generated/         # Auto-generated (don't edit)
│   │       ├── lib/                # Shared utilities + provider factories
│   │       │   ├── llm/             # LLM helpers
│   │       │   ├── llmProvider.ts   # OpenAI-compatible LLM dispatch
│   │       │   ├── sendProviders/      # MTA, Resend, SES senders + routing/dispatch
│   │       │   ├── emailProviders/     # Identity + domain verification (mtaIdentity, sesIdentity, domainVerification)
│   │       │   └── posthog.ts       # PostHog analytics
│   │       ├── schema.ts           # Database schema
│   │       ├── mail/               # Postbox / mailbox modules
│   │       └── *.ts                # API functions
│   ├── mta/                # Outbound Mail Transfer Agent (SMTP sender + scan endpoint)
│   ├── imap/               # IMAP4rev1 server (Postbox personal mail)
│   ├── mail-sync/          # Syncs users' external IMAP/SMTP accounts (IMAP IDLE inbound + outbound relay)
│   ├── setup-cli/          # `owlat-setup` — wizard, feature/pack/env management
│   ├── updater/            # In-place update sidecar
│   ├── docs/               # Nuxt Content documentation
│   ├── marketing/          # Marketing / landing page (Nuxt)
│   ├── desktop/            # Desktop client shell (Tauri)
│   └── code-worker/        # Code-task worker (for `codeWorkTasks`)
├── infra/
│   └── templates/          # Per-instance VPS compose, ACME (lego) sidecar, env template
├── docker-compose.yml      # Root self-host stack (web/convex/mta + optional Caddy --profile tls)
├── Caddyfile.example       # Reference reverse-proxy config for --profile tls
└── packages/
    ├── email-builder/      # Vue email editor component
    ├── email-renderer/     # JSON blocks → HTML renderer
    ├── email-scanner/      # Email security scanning
    ├── email-previewer/    # Email preview / compatibility analysis
    ├── channels/           # Notification channel abstractions
    ├── shared/             # Shared types, validation, feature flag registry
    ├── ui/                 # Nuxt layer: shared UI components and composables
    ├── sdk-js/             # Official TypeScript SDK
    └── sdk-java/           # Official Java SDK

Key Technologies

TechnologyPurpose
Nuxt 4Vue 3 full-stack framework
ConvexReal-time serverless backend
BetterAuthAuthentication with organization support
Tailwind CSS 4Utility-first styling
@owlat/email-rendererCustom HTML email rendering
HonoHTTP framework (MTA scan/relay endpoints)
GroupMQ + RedisMTA job queue
CaddyBundled HTTPS reverse proxy for web/Convex/MTA (--profile tls)
lego / ACMESelf-host TLS via DNS-01 for IMAP/SMTP mail certs
Docker Compose profilesOptional services per feature flag
LucideIcon library

Patterns and Conventions

File Naming

  • Vue components: PascalCase.vue
  • Composables: useCamelCase.ts
  • Convex functions: camelCase.ts
  • Pages: kebab-case.vue or [param].vue

Imports

Use the ~/ alias for imports within the Nuxt app:

import { useAuth } from '~/composables/useAuth';

The feature-flag registry is imported from the shared package on every layer:

import { FEATURE_FLAGS, resolveFlags, applyToggle } from '@owlat/shared/featureFlags';

TypeScript

All code is TypeScript. Use explicit types for function parameters and return values:

function formatDate(timestamp: number): string {
    return new Date(timestamp).toLocaleDateString();
}

Error Handling

  • Frontend: Use useToast() for user feedback
  • Backend: Throw Error with descriptive messages
  • API: Return structured error responses
// Frontend
const { showToast } = useToast();
try {
    await mutation();
    showToast('Saved successfully');
} catch (e) {
    showToast('Failed to save', 'error');
}

// Backend (Convex)
if (!organizationId) {
    throw new Error('Organization not found');
}

Feature-gated routes

Pages that belong to a toggleable feature declare it via definePageMeta:

definePageMeta({
    layout: 'dashboard',
    middleware: 'auth',
    requiresFeature: 'postbox',
});

A global middleware redirects to a "feature off" page if the flag is disabled. Convex queries also check the flag server-side so a stale client can't bypass it.