Owlat follows a modern serverless architecture with real-time capabilities.
Owlat follows a modern serverless architecture with real-time capabilities.
Component Library UI Components
Real-time subscriptions Mutations / Actions
Intelligence pipeline IP warming Bounce processing
PostHog Analytics & Errors
← Client (posthog-js) ← Server (posthog-node)
App Description apps/webMain web application (Nuxt 4 + Vue 3) apps/apiBackend (Convex — serverless functions, database, auth) apps/docsDeveloper documentation (VitePress) apps/marketingMarketing / landing page site (Nuxt) apps/mtaCustom Mail Transfer Agent (Hono + GroupMQ + direct SMTP) apps/nestPlatform administration dashboard (Nuxt 3) apps/nest-apiControl plane backend (Convex — VPS provisioning, billing, health monitoring)
Package Description packages/sharedShared types (block types, editor types, compatibility data) 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/uiNuxt layer providing shared UI components and composables packages/sdk-jsJavaScript SDK for the Owlat API packages/sdk-javaJava SDK for the Owlat API
Convex provides automatic real-time updates:
// Frontend - automatically re-renders when data changes
const contacts = useConvexQuery (api.contacts.listFromSession, {});
// 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
},
});
User Login Credentials submitted
BetterAuth Authentication provider
Session Created JWT token issued
Organization Set activeOrganizationId stored in session
Backend queries extract organizationId from session
Data scoped to organization automatically
Template Created JSON Blocks → @owlat/email-renderer → HTML
Domain Added SES Registration → DNS Records Generated → User Configures DNS
Campaign Created Audience Selected → Domain Verified (DNS + SES)
Owlat MTA (default) Direct SMTP · Intelligence pipeline · IP warming
Webhooks → Status Updates → Analytics
Owlat has two levels of multi-tenancy:
Platform-level — The control plane (apps/nest-api) manages multiple VPS instances, one per customer organization. Each VPS runs an isolated Docker Compose stack.Tenant-level — Within each VPS, the Convex backend scopes all data to organizations via BetterAuth (the pattern below).All data is scoped 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 ();
Stores user/session data in Convex Provides auth routes via HTTP handlers Links sessions to organizations JSON Blocks @owlat/email-renderer HTML Email Responsive layouts Cross-client compatibility
Owlat MTA Direct SMTP · Intelligence pipeline · IP warming
Default Optional alternatives
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.
The control plane (apps/nest-api/) manages the lifecycle of customer organizations. Each organization gets a dedicated VPS on Hetzner Cloud running a full Docker Compose stack.
User signs up Selects tier and region
Stripe subscription Payment confirmed via Stripe Elements
Organization created (pending) Provisioning job queued
Hetzner API Server created with cloud-init userdata
Docker Compose stack Convex + Web + MTA + Redis + ClamAV + convex-deploy
Smoke test passes Convex :3210, Web :3000 responding
Organization active Routing table updated, instance accessible
Each provisioned VPS runs six Docker services:
Service Image Port Purpose convexghcr.io/get-convex/convex-backend3210 (API), 3211 (site proxy) Self-hosted Convex backend webghcr.io/owlat/web3000 Nuxt web application mtaghcr.io/owlat/mta3100 (API), 25 (SMTP) Mail Transfer Agent redisredis:7-alpine6379 Queue and cache layer clamavclamav/clamav:1.33310 Antivirus scanning convex-deployghcr.io/owlat/convex-deploy— Schema deployment helper (run once)
A Caddy reverse proxy reads the routingTable in the control plane database to map organization slugs to VPS IP addresses. When an organization is suspended or deleted, its routing entry is deactivated.
VPS instances can be provisioned in six Hetzner Cloud datacenters:
Region Location Continent fsn1Falkenstein, Germany EU nbg1Nuremberg, Germany (default) EU hel1Helsinki, Finland EU ashAshburn, VA, USA US hilHillsboro, OR, USA US sinSingapore Asia
The control plane runs health checks every 5 minutes against all running VPS instances, probing three services:
Service Endpoint Port Convex /version3210 Web /3000 MTA /health3100
Status Condition healthyAll three services responding degradedConvex up + at least one other service up unhealthyOnly one service responding unreachableNo services responding
Alert at 3 consecutive failures (15 minutes) — creates a health alert for adminsAuto-reboot at 6 consecutive failures (30 minutes) — triggers Hetzner server reboot via APIThe Convex schema (~30 tables) follows these patterns:
Table Purpose userProfilesUser profile data linked to BetterAuth organizationSettingsOrganization-level configuration
Table Purpose contactsEmail contacts contactPropertiesCustom field definitions contactPropertyValuesCustom field values per contact topicsContact groupings contactTopicsMany-to-many with DOI status segmentsSaved filter configurations
Table Purpose emailTemplatesMarketing/transactional templates emailBlocksReusable content blocks campaignsOne-time email sends (includes archiveToken, archiveHtml, hardBounces, softBounces) emailSendsPer-recipient tracking transactionalEmailsAPI-triggered templates transactionalSendsTransactional delivery tracking
Table Purpose automationsWorkflow definitions automationStepsSteps within workflows automationRunsContact progress through workflows automationStepRunsIndividual step execution
Table Purpose 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
Table Purpose tiersInfrastructure pricing tiers (Starter/Growth/Enterprise) mapped to Hetzner server types organizationsTenant organizations with lifecycle states (pending → active → suspended → deleted) vpsesVPS instances with Hetzner server IDs, IPs, health status routingTableCaddy slug-to-IP routing entries provisioningJobsJob queue for create/resize/delete/redeploy operations invoicesMonthly billing invoices per organization healthAlertsVPS health alerts (unhealthy, auto-reboot, recovered) auditLogsPlatform admin action logging platformAdminsAdmin user accounts (admin/superadmin roles)
Job Interval Purpose Process scheduled campaigns 1 min Backup for scheduler-based sends Process pending delays 5 min Catch missed automation delays Process account deletions 24 hours Handle expired grace periods Cleanup webhook logs 7 days Remove logs older than 30 days Refresh segment counts 30 min Keep cached counts fresh
Job Interval Purpose Health checks 5 min Check all running VPS instances (Convex, Web, MTA) Process provisioning jobs 1 min Execute queued create/resize/delete/redeploy operations Generate monthly invoices 24 hours Create invoices for active organizations, mark overdue
default - Public pages (landing, auth)dashboard - Authenticated pages with sidebar/ # Landing page
/auth/login # Login
/auth/register # Registration
/auth/forgot-password # Password reset request
/auth/reset-password # Password reset form
/dashboard # Main dashboard
/dashboard/mail/* # Email templates, blocks, media library
/dashboard/mail/media # Media library
/dashboard/emails/* # Email editor
/dashboard/campaigns/* # Campaign management
/dashboard/audience/* # Contacts, lists, segments
/dashboard/automations/* # Automation workflows
/dashboard/transactional/* # Transactional templates
/archive/:token # Campaign archive (public)
/dashboard/settings/* # Organization settings
Convex queries provide reactive data useAuth() - Authentication stateuseCurrentOrganization() - 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 PostHogSome features are toggled via environment variables and can be enabled or disabled at runtime without code changes.
Feature Backend env var Frontend env var Pattern Analytics POSTHOG_API_KEYNUXT_PUBLIC_POSTHOG_API_KEYusePostHog() composable + lib/posthog.ts action
Backend helpers live in apps/api/convex/lib/. Frontend composables read from useRuntimeConfig().public and conditionally subscribe to Convex queries. The pattern:
Backend: a small lib/*.ts file reads process.env and exports boolean helpers. Frontend: a useFeature() composable exposes reactive flags and conditional query subscriptions. Middleware: auth.ts and guest.ts check the composable and redirect as needed. UI: sidebar nav items and settings cards are conditionally rendered.