[{"data":1,"prerenderedAt":2119},["ShallowReactive",2],{"search":3,"content-developer\u002Fcampaign-internals":442,"surround-\u002Fdeveloper\u002Fcampaign-internals":2114},[4,8,12,16,20,24,28,32,36,40,44,48,52,56,60,64,68,72,76,80,84,88,92,96,100,104,108,112,116,120,124,128,132,136,140,144,148,152,156,160,164,168,172,176,180,184,188,192,196,200,204,208,212,216,220,224,228,232,236,240,244,248,252,256,260,264,268,272,276,280,284,288,292,296,300,304,308,312,316,320,323,327,331,335,339,343,347,351,355,359,363,367,371,375,379,383,387,391,395,399,403,407,411,415,419,423,426,430,434,438],{"path":5,"title":6,"description":7},"\u002Fguide","Guide","Product guides for Owlat — a modular, self-hosted email platform. Learn how to send campaigns, run a personal mailbox, manage a team inbox, and more.",{"path":9,"title":10,"description":11},"\u002Fguide\u002Fgetting-started","Welcome to Owlat","Set up your Owlat workspace and send your first email — from deploying the stack to verifying a domain, building your audience, and launching a campaign.",{"path":13,"title":14,"description":15},"\u002Fguide\u002Fcontact-properties","Contact Properties","Custom fields that extend built-in contact data with your own values for segmentation.",{"path":17,"title":18,"description":19},"\u002Fguide\u002Ftopics","Topics","Topics are explicit audience groups you manage by hand — ideal for opt-in subscribers, imported cohorts, and organized contact buckets you target with campaigns.",{"path":21,"title":22,"description":23},"\u002Fguide\u002Fsegments","Segments","Build dynamic, rule-based contact groups from properties, email activity, and topic membership, re-evaluated from current data each time they're used.",{"path":25,"title":26,"description":27},"\u002Fguide\u002Fforms","Forms","Form Endpoints collect new contacts from your website or landing pages by exposing a public endpoint that accepts submissions and feeds them into a topic.",{"path":29,"title":30,"description":31},"\u002Fguide\u002Fcampaigns","Campaigns & Reporting","Build and send marketing campaigns to a topic or segment with the five-step wizard, optional A\u002FB testing, and full delivery reporting.",{"path":33,"title":34,"description":35},"\u002Fguide\u002Fab-testing","A\u002FB Testing","Compare two variants of a campaign on a test group, then automatically or manually send the winning version to the rest of your audience.",{"path":37,"title":38,"description":39},"\u002Fguide\u002Fautomations","Automations","Send emails automatically based on triggers, delays, and conditions — build welcome series, trial flows, and follow-ups once and let Owlat run them.",{"path":41,"title":42,"description":43},"\u002Fguide\u002Ftransactional","Transactional Emails","One-to-one emails your application triggers in response to a user action — password resets, order confirmations, welcome emails, and similar notifications.",{"path":45,"title":46,"description":47},"\u002Fguide\u002Fcreate-campaign","Create a Campaign","Walk through Owlat's five-step campaign wizard: Basics, Audience, Content, A\u002FB Test, and Review & Send.",{"path":49,"title":50,"description":51},"\u002Fguide\u002Fsend-campaign","Send & Monitor a Campaign","How to send your campaign and track its performance with real-time metrics.",{"path":53,"title":54,"description":55},"\u002Fguide\u002Fquick-start","Quick Start","The fastest path from a blank Owlat workspace to a live email campaign, from your first template through sending and reviewing results.",{"path":57,"title":58,"description":59},"\u002Fguide\u002Ftransactional-setup","Transactional Email Setup","Set up and send transactional emails like password resets and order confirmations via the Owlat API and SDKs.",{"path":61,"title":62,"description":63},"\u002Fguide\u002Fdeliverability","Deliverability","Verify sending domains, manage your blocklist, monitor sending reputation, and stay compliant so your emails reach the inbox.",{"path":65,"title":66,"description":67},"\u002Fguide\u002Fapi-keys-webhooks","API Keys & Webhooks","Create API keys for programmatic access and set up outbound webhooks to receive real-time notifications for email and contact events.",{"path":69,"title":70,"description":71},"\u002Fguide\u002Ffeature-flags","Feature flags","Owlat is modular — every feature listed in this guide can be turned on or off. This page is the user-facing overview of how to do it.",{"path":73,"title":74,"description":75},"\u002Fguide\u002Fteam-permissions","Team & Permissions","Use role-based access to control what each member of your organization can do, with Owner, Admin, and Editor roles.",{"path":77,"title":78,"description":79},"\u002Fguide\u002Faudit-logs","Audit Logs","A chronological record of significant actions in your Owlat organization, so you can see who did what and when.",{"path":81,"title":82,"description":83},"\u002Fguide\u002Fshare-links","Share Links","Create temporary preview links to share email designs with stakeholders who don't have dashboard access.",{"path":85,"title":86,"description":87},"\u002Fguide\u002Fpostbox","Postbox — Personal Email","Per-user mailboxes with a webmail interface and native IMAP\u002FSMTP support. Run your own Gmail-equivalent personal mailbox on your Owlat instance.",{"path":89,"title":90,"description":91},"\u002Fguide\u002Fmigrate-from-google","Migrate from Google","Import your full Gmail history into Owlat over IMAP, and let your AI assistant learn from every imported conversation.",{"path":93,"title":94,"description":95},"\u002Fguide\u002Fteam-inbox","Team Inbox","Triage inbound email as a team: read AI-classified threads, approve, edit or reject agent drafts, work the review queue, and manage quarantine.",{"path":97,"title":98,"description":99},"\u002Fguide\u002Femail-editor","Email Editor","A block-based visual editor for building responsive emails that render consistently across desktop, mobile, Outlook, Gmail, and Apple Mail.",{"path":101,"title":102,"description":103},"\u002Fguide\u002Fai-agent","AI Agent & Autonomy","Configure the AI agent that classifies and drafts replies to inbound mail: auto-reply settings, the health dashboard, circuit breakers, autonomy rules, and the knowledge backfill.",{"path":105,"title":106,"description":107},"\u002Fguide\u002Fknowledge-graph","Knowledge Graph","Browse, search, and manage Owlat's typed organizational knowledge — the 7 entry types, source attribution, confidence decay, relations, and how entries are extracted from mail.",{"path":109,"title":110,"description":111},"\u002Fguide\u002Ffiles","Files","Upload, browse, search, tag, and version documents in the file library.",{"path":113,"title":114,"description":115},"\u002Fguide\u002Fchat","Team Chat","Use Owlat's built-in team chat: public and private channels, direct messages, mentions, attachments, and channels linked to an inbox conversation.",{"path":117,"title":118,"description":119},"\u002Fguide\u002Fcode-tasks","Code Tasks","Queue coding-agent tasks, watch them move from queued through review, and run the code-worker sidecar that opens the pull requests.",{"path":121,"title":122,"description":123},"\u002Fguide\u002Faudience-data","Audience Data: Identities, Relationships & Timeline","Unify a contact across email, phone, and messaging channels, merge duplicates, map relationships, and read the cross-channel interaction timeline.",{"path":125,"title":126,"description":127},"\u002Fguide\u002Fimporting-contacts","Importing & Exporting Contacts","Bring contacts into Owlat from a CSV or from Mailchimp and Stripe, export them back out, and run bulk operations on your audience.",{"path":129,"title":130,"description":131},"\u002Fguide\u002Faccount","Your Account & Data","Export your data as JSON or CSV, request account deletion with a 30-day grace period, and use the onboarding checklist and the public preference center.",{"path":133,"title":134,"description":135},"\u002Fguide\u002Fchannels","Communication Channels","Configure SMS, WhatsApp, and generic-webhook channels, monitor channel health, and understand which channels are fully live today.",{"path":137,"title":138,"description":139},"\u002Fguide\u002Fdesktop-app","Desktop App","Install the Owlat desktop app, connect one or more workspaces, switch between them, and use native notifications, tray badges, shortcuts, and deep links.",{"path":141,"title":142,"description":143},"\u002Fguide\u002Femail-templates","Email Templates","Reusable email designs that define the structure, content, and personalization of every campaign and transactional message you send in Owlat.",{"path":145,"title":146,"description":147},"\u002Fguide\u002Fai-assistant","AI Assistant","Owlat's multi-turn, streaming, tool-calling AI assistant — a private chat surface that can search your workspace and draft copy, plus @assistant replies inside team chat.",{"path":149,"title":150,"description":151},"\u002Fguide\u002Fsecurity-scanning","Sending Security & Scanning","Owlat's security scanning: a content check for spam and phishing, an attachment scan for malware, and a Google Safe Browsing URL check. Suspicious content goes to a review queue.",{"path":153,"title":154,"description":155},"\u002Fguide\u002Fsystem-updates","System & Updates","The owner-only System & Updates screen: your current Owlat version, container health, LLM spend, and the in-app one-click updater with history.",{"path":157,"title":158,"description":159},"\u002Fguide\u002Foperating-modes","Operating Modes","The different ways to run Owlat at a company — read external mailboxes over IMAP, send transactional or marketing email through a delivery provider, host your own mail server, or run a team inbox with AI — and the rules that keep each combination coherent.",{"path":161,"title":162,"description":163},"\u002Fguide\u002Fsaved-blocks","Saved Blocks","Create reusable, linked content blocks you can drop into any email — edit one and every email that uses it updates automatically.",{"path":165,"title":166,"description":167},"\u002Fguide\u002Fmedia-library","Media Library","Manage, organize, search, and reuse images and files across your emails from one centralized hub.",{"path":169,"title":170,"description":171},"\u002Fguide\u002Femail-theme","Email Theme","Set your organization's default colors, font, and email width so every new template starts from a consistent baseline.",{"path":173,"title":174,"description":175},"\u002Fguide\u002Ftranslations","Translations","Send one email in multiple languages: add per-language translations to a single template and Owlat picks the right version for each recipient.",{"path":177,"title":178,"description":179},"\u002Fguide\u002Fcontacts","Contacts","How to add, view, organize, and manage contacts in Owlat, including sources, the contact detail tabs, and subscription compliance.",{"path":181,"title":182,"description":183},"\u002Fapi","API Overview","Owlat exposes authenticated API endpoints under your Convex site URL.",{"path":185,"title":186,"description":187},"\u002Fapi\u002Fwebhooks","Webhooks","Owlat supports both outbound customer webhooks and inbound provider webhooks.",{"path":189,"title":190,"description":191},"\u002Fapi\u002Fpublic-endpoints","Public Endpoints","These routes are public-facing and usually accessed from email links or embedded forms.",{"path":193,"title":194,"description":195},"\u002Fapi\u002Fwebhook-payloads","Webhook Payloads","The authoritative wire contract for outbound webhooks: envelope, signature headers, per-event data shapes, and payload versioning.",{"path":197,"title":198,"description":199},"\u002Fapi\u002Finbound-channels","Inbound Channel Webhooks","Provider webhook reference for inbound SMS, WhatsApp, and generic-channel messages, plus the MTA mailbox and credential callbacks.",{"path":201,"title":202,"description":203},"\u002Fapi\u002Fauthentication","Authentication","Secure API access with organization-scoped API keys.",{"path":205,"title":206,"description":207},"\u002Fapi\u002Fsdk","TypeScript SDK","Typed client for the Owlat API, usable from Node.js, Bun, Deno, or any server-side JavaScript runtime.",{"path":209,"title":210,"description":211},"\u002Fapi\u002Fsdk-java","Java SDK","The official `owlat-sdk` package provides a typed client for interacting with the Owlat API from any JVM application. Requires Java 11+.",{"path":213,"title":214,"description":215},"\u002Fapi\u002Fcontacts","Contacts API","Manage contacts for your organization.",{"path":217,"title":218,"description":219},"\u002Fapi\u002Ftopics","Topics API","Manage topic membership through authenticated endpoints.",{"path":221,"title":222,"description":223},"\u002Fapi\u002Fevents","Events API","Send contact events to drive segmentation and automation triggers.",{"path":225,"title":226,"description":227},"\u002Fapi\u002Ftransactional","Transactional API","Send published transactional templates to a recipient.",{"path":229,"title":230,"description":231},"\u002Fapi\u002Fforms","Forms API","Capture subscribers through public form endpoints.",{"path":233,"title":234,"description":235},"\u002Fdeveloper","Developer Guide","Technical architecture, feature-flag model, and provider abstractions used by Owlat.",{"path":237,"title":238,"description":239},"\u002Fdeveloper\u002Fmta-system","MTA System","Owlat's custom Mail Transfer Agent for direct SMTP delivery with intelligent rate limiting, bounce processing, and IP warming.",{"path":241,"title":242,"description":243},"\u002Fdeveloper\u002Ffeature-flags","Feature flags — developer reference","How the Owlat feature flag system works: single source of truth, dependency resolution, docker profile mapping, and how to add a new flag.",{"path":245,"title":246,"description":247},"\u002Fdeveloper\u002Fhow-email-works","How Email Works","A technical deep-dive into how email actually works — from SMTP and DNS to authentication, deliverability, and the differences between marketing and private email.",{"path":249,"title":250,"description":251},"\u002Fdeveloper\u002Femail-security","Email Security","Content scanning, attachment validation, URL reputation checking, and malware detection for outbound emails.",{"path":253,"title":254,"description":255},"\u002Fdeveloper\u002Fpostbox-architecture","Postbox Architecture","How the Postbox personal-mail feature is wired — schema, IMAP server, app-password auth, outbound relay, inbound delivery, and external mailboxes.",{"path":257,"title":258,"description":259},"\u002Fdeveloper\u002Fproviders","Providers","Pluggable provider abstractions for LLM, email sending, notifications, vector stores, and analytics, selected per-deployment so self-hosters can swap implementations without code changes.",{"path":261,"title":262,"description":263},"\u002Fdeveloper\u002Fcampaign-internals","Campaign Internals","How the campaign backend works: two status machines, send pre-flight, the send orchestrator, emailSends records, and the priority workpools.",{"path":265,"title":266,"description":267},"\u002Fdeveloper\u002Faudience-internals","Audience Internals","Backend reference for contact resolution, the double opt-in lifecycle, topic subscription, the conditions registry, and segment evaluation.",{"path":269,"title":270,"description":271},"\u002Fdeveloper\u002Fautomation-internals","Automation Internals","How the automation run engine works: the step walker, the lifecycle state machine, trigger fanout, the three step types, and the resilience cron.",{"path":273,"title":274,"description":275},"\u002Fdeveloper\u002Fdeliverability-infrastructure","Deliverability Infrastructure","The Convex-side deliverability backend: provider routing, health-aware failover, sending reputation with auto-enforcement, IP warming cache, the blocklist, and the content-scan gate.",{"path":277,"title":278,"description":279},"\u002Fdeveloper\u002Farchitecture","Architecture Overview","Owlat follows a modern serverless architecture with real-time capabilities.",{"path":281,"title":282,"description":283},"\u002Fdeveloper\u002Fplatform-operations","Platform Operations","Operator reference for abuse status and the sending gate, the platform-admin roster, content review, org deletion, in-app self-update, dev endpoints, crons, and migrations.",{"path":285,"title":286,"description":287},"\u002Fdeveloper\u002Fscopes","Scopes","What each app and package in the Owlat monorepo is responsible for.",{"path":289,"title":290,"description":291},"\u002Fdeveloper\u002Fself-hosting","Self-Hosting","Deploy Owlat on your own infrastructure with Docker Compose. Complete guide from first boot to production.",{"path":293,"title":294,"description":295},"\u002Fdeveloper\u002Fself-hosting-config","Self-Hosting Configuration","Complete reference for Docker environment variables, Convex backend variables, service topology, and volume persistence.",{"path":297,"title":298,"description":299},"\u002Fdeveloper\u002Fself-hosting-dns-email","DNS & Email Setup","Configure DNS records, DKIM signing, SPF, DMARC, and bounce handling for reliable email delivery.",{"path":301,"title":302,"description":303},"\u002Fdeveloper\u002Fself-hosting-production","Production Deployment","Secure your self-hosted Owlat instance with TLS, firewall rules, backups, and monitoring.",{"path":305,"title":306,"description":307},"\u002Fdeveloper\u002Fself-hosting-maintenance","Maintenance & Updates","Keep your self-hosted Owlat instance up to date, manage backups, scale performance, and troubleshoot common issues.",{"path":309,"title":310,"description":311},"\u002Fdeveloper\u002Fself-hosting-desktop","Desktop Installer","Install Owlat on a bare Linux VPS straight from the desktop app over SSH — no terminal — with a live, animated provisioning timeline.",{"path":313,"title":314,"description":315},"\u002Fdeveloper\u002Fsetup-cli","Setup CLI & Installer","Operator reference for the Owlat self-host tooling: the install.sh one-liner, the owlat-setup CLI, the convex-deploy flow, and admin bootstrap.",{"path":317,"title":318,"description":319},"\u002Fdeveloper\u002Fconvex","Convex Backend","Owlat uses Convex as its serverless backend, providing real-time subscriptions, ACID transactions, and TypeScript-first development.",{"path":321,"title":202,"description":322},"\u002Fdeveloper\u002Fauthentication","Owlat uses BetterAuth with the Convex adapter for authentication and organization (team) management.",{"path":324,"title":325,"description":326},"\u002Fdeveloper\u002Femail-system","Email System","Owlat's email system consists of a visual editor, template management, and multi-provider sending infrastructure.",{"path":328,"title":329,"description":330},"\u002Fdeveloper\u002Femail-renderer","Email Renderer","The @owlat\u002Femail-renderer package converts editor JSON blocks into production-ready HTML emails with cross-client compatibility, CSS inlining, dark mode, and Outlook VML fallbacks.",{"path":332,"title":333,"description":334},"\u002Fdeveloper\u002Fenvironment-variables","Environment Variables","Reference for every environment variable Owlat reads across the Convex backend, web app, MTA, IMAP server, and mail-sync worker.",{"path":336,"title":337,"description":338},"\u002Fdeveloper\u002Fcomponents","Component Library","Reference for the reusable, auto-imported Vue UI components shipped in the packages\u002Fui layer.",{"path":340,"title":341,"description":342},"\u002Fdeveloper\u002Fdecisions","Architectural Decision Records","The architectural decision records for the Owlat project, each capturing the context, the decision, and the trade-offs involved.",{"path":344,"title":345,"description":346},"\u002Fdeveloper\u002Fdecisions\u002F009-model-routing","ADR-009: Task-Based Model Routing","Why Owlat supports per-task LLM model selection instead of using a single model for all pipeline steps.",{"path":348,"title":349,"description":350},"\u002Fdeveloper\u002Fdecisions\u002F010-listing-engine","ADR-010: Listing Engine","Why Owlat replaced four incompatible list-query contracts with one generic listing engine driven by per-entity descriptors.",{"path":352,"title":353,"description":354},"\u002Fdeveloper\u002Fdecisions\u002F001-custom-email-renderer","ADR-001: Custom Email Renderer Over MJML","Why Owlat built a custom table-based HTML email renderer instead of using MJML, gaining full control over VML, dark mode, and per-client rendering.",{"path":356,"title":357,"description":358},"\u002Fdeveloper\u002Fdecisions\u002F002-convex-backend","ADR-002: Convex as Backend","Why Owlat chose Convex over PostgreSQL and Firebase for real-time reactivity, co-located TypeScript logic, and zero-config scaling.",{"path":360,"title":361,"description":362},"\u002Fdeveloper\u002Fdecisions\u002F003-notion-like-builder","ADR-003: Notion-like Email Builder","Why Owlat replaced the traditional 3-panel email editor with a Notion-like single-column canvas for inline WYSIWYG editing.",{"path":364,"title":365,"description":366},"\u002Fdeveloper\u002Fdecisions\u002F004-monorepo-bun-workspaces","ADR-004: Monorepo with Bun Workspaces","Why Owlat uses a monorepo with Bun workspaces and Turborepo for fast installs, atomic cross-package changes, and cached CI.",{"path":368,"title":369,"description":370},"\u002Fdeveloper\u002Fdecisions\u002F005-custom-mta","ADR-005: Custom MTA","Why Owlat built a custom Mail Transfer Agent instead of relying solely on third-party email providers.",{"path":372,"title":373,"description":374},"\u002Fdeveloper\u002Fdecisions\u002F006-self-hosted-convex","ADR-006: Self-Hosted Convex","Why Owlat uses the open-source Convex backend for self-hosting instead of migrating to a different database.",{"path":376,"title":377,"description":378},"\u002Fdeveloper\u002Fdecisions\u002F007-pluggable-llm","ADR-007: Pluggable LLM Provider","Why Owlat uses the Vercel AI SDK with a provider abstraction layer instead of hardcoding a single LLM vendor.",{"path":380,"title":381,"description":382},"\u002Fdeveloper\u002Fdecisions\u002F008-process-architecture","ADR-008: Agent Process Architecture","Why Owlat processes inbound messages with a self-scheduling step walker plus a lifecycle coordinator instead of one sequential function.",{"path":384,"title":385,"description":386},"\u002Fexamples","Examples","Copy-pasteable integration patterns for common Owlat use cases.",{"path":388,"title":389,"description":390},"\u002Fexamples\u002Fwelcome-email","Welcome Email","Send a personalized welcome email when a new user signs up.",{"path":392,"title":393,"description":394},"\u002Fexamples\u002Fbilling-email","Billing Email","Send a billing receipt with an invoice PDF attached after a successful payment.",{"path":396,"title":397,"description":398},"\u002Fexamples\u002Fevent-automation","Event Automation","Trigger automations with custom events for trial lifecycle, feature adoption, and more.",{"path":400,"title":401,"description":402},"\u002Fexamples\u002Fcontact-sync","Contact Sync","Sync contacts from your database to Owlat using upsert patterns and bulk operations.",{"path":404,"title":405,"description":406},"\u002Fexamples\u002Fwebhook-handler","Webhook Handler","Handle Owlat delivery webhooks with signature verification and event routing.",{"path":408,"title":409,"description":410},"\u002Fexamples\u002Fmultilingual-email","Multilingual Email","Send emails in the recipient's preferred language using template translations.",{"path":412,"title":413,"description":414},"\u002Fvision","Vision","Where Owlat is heading — from email platform to unified communication intelligence powered by AI agents.",{"path":416,"title":417,"description":418},"\u002Fvision\u002Fself-hosting","Self-Hosting Architecture","How Owlat runs as a fully self-hosted stack using Docker Compose — open-source Convex backend, custom MTA, and a pluggable LLM provider.",{"path":420,"title":421,"description":422},"\u002Fvision\u002Fagent-pipeline","Agent Pipeline","Technical architecture for the inbound email agent pipeline — step modules, the walker, security scanning, threading, and human review.",{"path":424,"title":106,"description":425},"\u002Fvision\u002Fknowledge-graph","Technical architecture for Owlat's typed knowledge storage — how organizational knowledge is stored, searched, decayed, and maintained.",{"path":427,"title":428,"description":429},"\u002Fvision\u002Fmulti-channel","Multi-Channel & CRM","Technical architecture for channel adapters, unified messaging, contact identity unification, and the CRM hub.",{"path":431,"title":432,"description":433},"\u002Fvision\u002Ffile-system","Semantic File System","Technical architecture for Owlat's semantic file storage — version tracking with provenance today, plus the planned embedding-based retrieval and auto-tagging layer.",{"path":435,"title":436,"description":437},"\u002Fvision\u002Fdesktop-app","Desktop App & Advanced Agents","Architecture of the Owlat desktop shell, visualization agent, adaptive dashboard, agent health, graduated autonomy, and coding agents.",{"path":439,"title":440,"description":441},"\u002Fvision\u002Froadmap","Roadmap","What's planned next for Owlat — the documented-but-unbuilt pieces still being wired, and the enhancements on our radar.",{"id":443,"title":262,"body":444,"description":263,"extension":2108,"meta":2109,"navigation":2110,"path":261,"seo":2111,"stem":2112,"__hash__":2113},"content\u002F3.developer\u002F16.campaign-internals.md",{"type":445,"value":446,"toc":2080},"minimark",[447,468,491,496,539,544,629,633,648,738,753,798,802,816,903,941,961,968,984,1011,1052,1056,1081,1173,1176,1231,1252,1256,1273,1283,1391,1405,1409,1440,1658,1662,1710,1728,1732,1752,1854,1879,1938,1951,1955,1962,2010,2030,2066,2070,2074,2077],[448,449,450,451,455,456,460,461,464,465,467],"p",{},"This page is the developer reference for the campaign send path on the Convex backend — what happens between a user clicking ",[452,453,454],"strong",{},"Send"," and a recipient's mailbox. It covers the two lifecycle state machines, the send pre-flight gate, the orchestrator pipeline, the per-recipient ",[457,458,459],"code",{},"emailSends"," records, and the rate-limited workpools. For the user-facing walkthrough see ",[462,463,50],"a",{"href":49},"; for how the audience is resolved into recipients see ",[462,466,266],{"href":265},".",[469,470,473],"callout",{"title":471,"type":472},"Two-machine design","info",[448,474,475,476,479,480,483,484,487,488,467],{},"A campaign row carries ",[452,477,478],{},"two independent status columns"," — ",[457,481,482],{},"status"," (the campaign lifecycle) and ",[457,485,486],{},"abTestStatus"," (the A\u002FB-test lifecycle). Each has its own legal-edges graph and its own single-writer module. This split is recorded in ",[457,489,490],{},"docs\u002Fadr\u002F0017-campaign-lifecycle-modules.md",[492,493,495],"h2",{"id":494},"campaign-lifecycle-state-machine","Campaign lifecycle state machine",[448,497,498,499,501,502,505,506,509,510,513,514,517,518,521,522,521,525,521,528,531,532,535,536,538],{},"The campaign's ",[457,500,482],{}," field is written by exactly one module — ",[457,503,504],{},"apps\u002Fapi\u002Fconvex\u002Fcampaigns\u002Flifecycle.ts",". Its public ",[457,507,508],{},"transition"," mutation is the ",[452,511,512],{},"only"," writer of ",[457,515,516],{},"campaigns.status"," and the companion fields it patches alongside it (",[457,519,520],{},"sentAt",", ",[457,523,524],{},"cancelledAt",[457,526,527],{},"scheduledAt",[457,529,530],{},"contentBlockReason",", and the stats-zero block on send). Direct ",[457,533,534],{},"ctx.db.patch"," of ",[457,537,482],{}," anywhere else is a layering violation.",[540,541,543],"h3",{"id":542},"the-six-statuses","The six statuses",[545,546,547,560],"table",{},[548,549,550],"thead",{},[551,552,553,557],"tr",{},[554,555,556],"th",{},"Status",[554,558,559],{},"Meaning",[561,562,563,574,584,594,604,614],"tbody",{},[551,564,565,571],{},[566,567,568],"td",{},[457,569,570],{},"draft",[566,572,573],{},"Editable; not queued. The default for a new campaign.",[551,575,576,581],{},[566,577,578],{},[457,579,580],{},"scheduled",[566,582,583],{},"A future send time is set; the orchestrator is scheduled to fire then.",[551,585,586,591],{},[566,587,588],{},[457,589,590],{},"sending",[566,592,593],{},"The orchestrator is running (or its sends are in flight).",[551,595,596,601],{},[566,597,598],{},[457,599,600],{},"sent",[566,602,603],{},"Terminal. Every queued send has left the queue.",[551,605,606,611],{},[566,607,608],{},[457,609,610],{},"cancelled",[566,612,613],{},"Terminal. The user cancelled before or during sending.",[551,615,616,621],{},[566,617,618],{},[457,619,620],{},"pending_review",[566,622,623,624,628],{},"The content scanner flagged the send as ",[625,626,627],"em",{},"suspicious","; held pending review.",[540,630,632],{"id":631},"legal-edges","Legal edges",[448,634,635,636,639,640,643,644,647],{},"The reducer rejects any transition not in ",[457,637,638],{},"LEGAL_EDGES",". There is no exception thrown — an illegal call returns ",[457,641,642],{},"{ ok: false, reason: 'illegal_edge' }",", and a transition out of a terminal status returns ",[457,645,646],{},"reason: 'terminal'",". Callers translate the outcome into a user response.",[545,649,650,663],{},[548,651,652],{},[551,653,654,657],{},[554,655,656],{},"From",[554,658,659,660],{},"Legal ",[457,661,662],{},"to",[561,664,665,677,691,705,717,728],{},[551,666,667,671],{},[566,668,669],{},[457,670,570],{},[566,672,673,521,675],{},[457,674,580],{},[457,676,590],{},[551,678,679,683],{},[566,680,681],{},[457,682,580],{},[566,684,685,521,687,521,689],{},[457,686,570],{},[457,688,610],{},[457,690,590],{},[551,692,693,697],{},[566,694,695],{},[457,696,590],{},[566,698,699,521,701,521,703],{},[457,700,600],{},[457,702,570],{},[457,704,620],{},[551,706,707,711],{},[566,708,709],{},[457,710,620],{},[566,712,713,521,715],{},[457,714,590],{},[457,716,570],{},[551,718,719,723],{},[566,720,721],{},[457,722,600],{},[566,724,725],{},[625,726,727],{},"(terminal)",[551,729,730,734],{},[566,731,732],{},[457,733,610],{},[566,735,736],{},[625,737,727],{},[448,739,740,741,744,745,748,749,752],{},"A self-loop (",[457,742,743],{},"from === to",") is ",[452,746,747],{},"idempotent",": it writes an audit-log row, returns ",[457,750,751],{},"applied: 'recorded'",", and emits no patch, no scheduler hop, and no PostHog event. This is what makes a re-fired scheduler tick safe.",[469,754,757],{"title":755,"type":756},"Review approve\u002Freject is not yet wired","warning",[448,758,759,760,762,763,766,767,769,770,773,774,777,778,781,782,785,786,789,790,793,794,797],{},"The content scanner can move a ",[625,761,627],{}," campaign ",[452,764,765],{},"into"," ",[457,768,620],{},", but the two edges ",[452,771,772],{},"out"," of it (",[457,775,776],{},"pending_review → sending"," approve, ",[457,779,780],{},"pending_review → draft"," reject) have ",[452,783,784],{},"no product caller"," today — there is no review-queue UI and no approve\u002Freject mutation in this OSS repo. The edges exist in the legal-edges graph and the audit actions (",[457,787,788],{},"campaign.review_approved"," \u002F ",[457,791,792],{},"campaign.review_rejected",") are defined, but an operator would have to invoke the internal ",[457,795,796],{},"lifecycle.transition"," mutation by hand to release a held campaign.",[540,799,801],{"id":800},"effects","Effects",[448,803,804,805,808,809,812,813,815],{},"The reducer is pure: given the loaded campaign, the typed input, and a ",[457,806,807],{},"userId",", it returns a ",[457,810,811],{},"patch"," plus a list of ",[457,814,800],{},". A separate runner applies them in order, atomically with the row patch. Four effect kinds exist:",[545,817,818,828],{},[548,819,820],{},[551,821,822,825],{},[554,823,824],{},"Effect",[554,826,827],{},"When it fires",[561,829,830,840,865,883],{},[551,831,832,837],{},[566,833,834],{},[457,835,836],{},"audit_log",[566,838,839],{},"Every transition (including idempotent self-loops).",[551,841,842,847],{},[566,843,844],{},[457,845,846],{},"schedule_campaign_send_orchestrator",[566,848,849,850,853,854,857,858,861,862,467],{},"On ",[457,851,852],{},"→ scheduled"," (delay = ",[457,855,856],{},"scheduledAt - at",") and ",[457,859,860],{},"→ sending"," (delay = 0). Consumer is ",[457,863,864],{},"campaigns.send.startCampaignSend",[551,866,867,872],{},[566,868,869],{},[457,870,871],{},"track_event",[566,873,874,875,789,877,789,879,882],{},"On user-driven ",[457,876,852],{},[457,878,860],{},[457,880,881],{},"→ cancelled"," only. Captured to PostHog.",[551,884,885,890],{},[566,886,887],{},[457,888,889],{},"start_ab_test_if_enabled",[566,891,892,893,895,896,899,900,467],{},"Cross-machine kickoff on ",[457,894,860],{}," when ",[457,897,898],{},"isABTest",". Calls the A\u002FB-test lifecycle's ",[457,901,902],{},"→ testing",[448,904,905,906,908,909,912,913,916,917,521,920,521,923,521,926,929,930,932,933,936,937,940],{},"The ",[457,907,807],{}," argument discriminates user-driven from system-source transitions. User-facing mutations pass ",[457,910,911],{},"session.userId","; internal callers pass a ",[457,914,915],{},"'system:\u003Csource>'"," tag (e.g. ",[457,918,919],{},"'system:scheduler_tick'",[457,921,922],{},"'system:content_scan'",[457,924,925],{},"'system:orchestrator'",[457,927,928],{},"'system:send_completion'","). The audit log records the tag verbatim, and the ",[457,931,871],{}," effect is ",[452,934,935],{},"suppressed"," for any ",[457,938,939],{},"system:","-prefixed caller — so background transitions never pollute PostHog.",[469,942,945],{"title":943,"type":944},"\"Campaign sent\" semantics","tip",[448,946,905,947,950,951,953,954,957,958,960],{},[457,948,949],{},"campaign_sent"," PostHog event fires on the ",[457,952,860],{}," edge, not ",[457,955,956],{},"→ sent",". For the user, \"the campaign was sent\" means \"the send was kicked off.\" The ",[457,959,776],{}," edge is excluded from the event — it would be a review release, not a user-initiated send (and, as noted above, it has no caller yet anyway).",[540,962,964,965,967],{"id":963},"reaching-sent-batch-completion","Reaching ",[457,966,600],{},": batch completion",[448,969,970,972,973,975,976,979,980,983],{},[457,971,956],{}," is not driven by the user. Each per-send workpool callback advances its own ",[457,974,459],{}," row, but the campaign itself only completes when its ",[452,977,978],{},"last queued send leaves the queue",". ",[457,981,982],{},"tryCompleteCampaign"," (in the same module) is the shared guard, called from two places:",[985,986,987,994],"ul",{},[988,989,990,993],"li",{},[457,991,992],{},"reconcileCampaignCompletion"," — the per-send entry point, invoked from the workpool completion callback after every send reaches a terminal status. A cheap no-op until the campaign is genuinely done.",[988,995,996,999,1000,1003,1004,1007,1008,1010],{},[457,997,998],{},"reconcileSendingCampaigns"," — a safety-net cron (",[457,1001,1002],{},"process scheduled campaigns"," runs every minute; this sweep also runs every minute as ",[457,1005,1006],{},"reconcile sending campaigns",") that reconciles every campaign still in ",[457,1009,590],{},". It catches callbacks that errored or final sends transitioned by a provider webhook rather than the workpool.",[448,1012,1013,1015,1016,895,1018,1021,1022,1024,1025,1028,1029,1032,1033,1036,1037,1039,1040,766,1043,1045,1046,979,1049,1051],{},[457,1014,982],{}," only advances to ",[457,1017,600],{},[452,1019,1020],{},"all"," of these hold: status is ",[457,1023,590],{},"; the checkpointed send walker has finished streaming (no ",[457,1026,1027],{},"campaignSendJobs"," row still in phase ",[457,1030,1031],{},"resolving","); an A\u002FB test (if any) has reached ",[457,1034,1035],{},"winner_selected"," (otherwise the second-wave remainder send would be skipped); at least one ",[457,1038,459],{}," row exists; and ",[452,1041,1042],{},"no",[457,1044,459],{}," row remains in ",[457,1047,1048],{},"queued",[457,1050,1048],{}," is the sole non-terminal send status, so its absence means the campaign is done.",[492,1053,1055],{"id":1054},"ab-test-lifecycle-and-the-remainder-send-guarantee","A\u002FB test lifecycle and the remainder-send guarantee",[448,1057,905,1058,1060,1061,1064,1065,1067,1068,1070,1071,521,1074,521,1077,1080],{},[457,1059,486],{}," column has its own machine in ",[457,1062,1063],{},"apps\u002Fapi\u002Fconvex\u002Fcampaigns\u002FabTestLifecycle.ts"," — same row, different column, separate graph. Its ",[457,1066,508],{}," mutation is the only writer of ",[457,1069,486],{}," and its companions (",[457,1072,1073],{},"abTestConfig",[457,1075,1076],{},"abWinner",[457,1078,1079],{},"abWinnerSelectedAt",", and the variant-stat reset block on disable).",[545,1082,1083,1096],{},[548,1084,1085],{},[551,1086,1087,1089,1093],{},[554,1088,656],{},[554,1090,659,1091],{},[457,1092,662],{},[554,1094,1095],{},"Trigger",[561,1097,1098,1116,1135,1155],{},[551,1099,1100,1105,1110],{},[566,1101,1102],{},[625,1103,1104],{},"(none)",[566,1106,1107],{},[457,1108,1109],{},"pending",[566,1111,1112,1115],{},[457,1113,1114],{},"enableABTest"," mutation",[551,1117,1118,1122,1127],{},[566,1119,1120],{},[457,1121,1109],{},[566,1123,1124],{},[457,1125,1126],{},"testing",[566,1128,1129,1130,1132,1133],{},"Cross-machine — the campaign lifecycle's ",[457,1131,889],{}," effect on ",[457,1134,860],{},[551,1136,1137,1141,1145],{},[566,1138,1139],{},[457,1140,1126],{},[566,1142,1143],{},[457,1144,1035],{},[566,1146,1147,1150,1151,1154],{},[457,1148,1149],{},"declareABTestWinner"," (manual) or ",[457,1152,1153],{},"autoDeclareWinner"," (criteria-driven)",[551,1156,1157,1162,1167],{},[566,1158,1159],{},[457,1160,1161],{},"*",[566,1163,1164],{},[457,1165,1166],{},"none",[566,1168,1169,1172],{},[457,1170,1171],{},"disableABTest"," (full reset)",[448,1174,1175],{},"Two effects guarantee the held-back audience is never orphaned:",[985,1177,1178,1216],{},[988,1179,1180,1185,1186,895,1188,1191,1192,1195,1196,1199,1200,1203,1204,789,1207,1210,1211,1213,1214,467],{},[452,1181,1182],{},[457,1183,1184],{},"schedule_auto_winner"," fires on ",[457,1187,902],{},[457,1189,1190],{},"winnerCriteria"," is not ",[457,1193,1194],{},"manual"," and ",[457,1197,1198],{},"testDuration"," (hours) is set. It schedules ",[457,1201,1202],{},"campaigns.abTest.autoDeclareWinner"," after the test window. Without it, an ",[457,1205,1206],{},"open_rate",[457,1208,1209],{},"click_rate"," campaign (the wizard default) would sit in ",[457,1212,1126],{}," forever and the 40–60% remainder audience would never be sent. Manual criteria instead rely on the user clicking a \"choose winner\" button, which the report page surfaces only for ",[457,1215,1194],{},[988,1217,1218,1185,1223,1226,1227,1230],{},[452,1219,1220],{},[457,1221,1222],{},"schedule_winner_remainder",[457,1224,1225],{},"→ winner_selected",". It schedules ",[457,1228,1229],{},"campaigns.send.sendCampaignWinnerToRemainder"," (the second-phase orchestrator, below) to deliver the winning variant's content to everyone who was held back.",[469,1232,1234],{"title":1233,"type":472},"What splitPercentage means",[448,1235,1236,1239,1240,1243,1244,1247,1248,1251],{},[457,1237,1238],{},"splitPercentage"," (validated 10–50) is the percentage ",[452,1241,1242],{},"per variant of the test cohort",". The cohort is therefore ",[457,1245,1246],{},"2 × splitPercentage %"," of the audience — e.g. ",[457,1249,1250],{},"20"," produces a 40% test cohort (20% A, 20% B) and a 60% held-back remainder.",[492,1253,1255],{"id":1254},"send-pre-flight-validation","Send pre-flight validation",[448,1257,1258,1259,1261,1262,1264,1265,1268,1269,1272],{},"Before any caller transitions a campaign to ",[457,1260,580],{}," or ",[457,1263,590],{},", it runs the pre-flight in ",[457,1266,1267],{},"apps\u002Fapi\u002Fconvex\u002Fcampaigns\u002Fpreflight.ts",". The lifecycle reducer ",[452,1270,1271],{},"trusts its input"," — it does not re-validate readiness — so pre-flight is the single gate that the four send\u002Fschedule entry points (and the orchestrator at fire time) share.",[448,1274,1275,1278,1279,1282],{},[457,1276,1277],{},"validateReadyToSend"," returns a ",[457,1280,1281],{},"PreflightResult"," union; the first failing check wins. The ordered checks are:",[545,1284,1285,1297],{},[548,1286,1287],{},[551,1288,1289,1294],{},[554,1290,1291],{},[457,1292,1293],{},"reason",[554,1295,1296],{},"Condition",[561,1298,1299,1312,1324,1336,1356,1366,1376],{},[551,1300,1301,1306],{},[566,1302,1303],{},[457,1304,1305],{},"no_template",[566,1307,1308,1311],{},[457,1309,1310],{},"emailTemplateId"," is unset",[551,1313,1314,1319],{},[566,1315,1316],{},[457,1317,1318],{},"no_audience",[566,1320,1321,1311],{},[457,1322,1323],{},"audience",[551,1325,1326,1331],{},[566,1327,1328],{},[457,1329,1330],{},"no_from_email",[566,1332,1333,1311],{},[457,1334,1335],{},"fromEmail",[551,1337,1338,1343],{},[566,1339,1340],{},[457,1341,1342],{},"sending_not_allowed",[566,1344,1345,1346,1349,1350,1261,1353],{},"The instance's ",[457,1347,1348],{},"abuseStatus"," is ",[457,1351,1352],{},"suspended",[457,1354,1355],{},"banned",[551,1357,1358,1363],{},[566,1359,1360],{},[457,1361,1362],{},"no_delivery_provider",[566,1364,1365],{},"No email delivery provider (EMAIL_PROVIDER + credentials, or a provider route) is configured. A connected external IMAP mailbox does not satisfy this.",[551,1367,1368,1373],{},[566,1369,1370],{},[457,1371,1372],{},"domain_not_verified",[566,1374,1375],{},"The from-address domain is not verified",[551,1377,1378,1383],{},[566,1379,1380],{},[457,1381,1382],{},"scheduled_in_past",[566,1384,1385,1387,1388,1390],{},[457,1386,527],{}," is in the past (only checked when ",[457,1389,527],{}," is supplied)",[448,1392,905,1393,1396,1397,1400,1401,1404],{},[457,1394,1395],{},"validateReadyToSendQuery"," internal query wraps the same logic so the orchestrator can re-run pre-flight ",[452,1398,1399],{},"at fire time"," — catching state that drifted between the original ",[457,1402,1403],{},"schedule"," call and the scheduler tick (org went suspended, template deleted, domain verification expired).",[492,1406,1408],{"id":1407},"the-send-orchestrator","The send orchestrator",[448,1410,1411,1413,1414,1417,1418,1421,1422,1425,1426,1428,1429,1432,1433,1436,1437,467],{},[457,1412,864],{}," (",[457,1415,1416],{},"apps\u002Fapi\u002Fconvex\u002Fcampaigns\u002Fsend.ts",") is the single live action that takes a campaign from ",[457,1419,1420],{},"scheduled | sending"," through the full prep pipeline. It is fired by three producers: the ",[457,1423,1424],{},"processScheduledCampaigns"," cron tick, the lifecycle's ",[457,1427,846],{}," effect, and a direct reschedule in ",[457,1430,1431],{},"campaigns\u002Fscheduling.ts",". It is the only writer of ",[457,1434,1435],{},"emailSends.abVariant"," and the only first-phase caller of ",[457,1438,1439],{},"enqueueCampaignEmails",[1441,1442,1443,1447,1476,1480,1489,1495,1508,1512,1562,1566,1588,1592,1598,1602,1645],"steps",{},[540,1444,1446],{"id":1445},"status-race-guard","Status-race guard",[448,1448,1449,1450,1261,1452,1454,1455,1457,1458,1461,1462,1465,1466,1468,1469,1472,1473,1475],{},"If the campaign was ",[457,1451,610],{},[457,1453,570],{}," (unscheduled), or already ",[457,1456,600],{},", the orchestrator returns ",[457,1459,1460],{},"skipped: true"," with a reason. It does ",[452,1463,1464],{},"not"," skip on ",[457,1467,590],{}," — the ",[457,1470,1471],{},"sendNow"," path arrives already flipped to ",[457,1474,590],{},", and same-state transitions don't re-fire the orchestrator effect.",[540,1477,1479],{"id":1478},"re-run-pre-flight","Re-run pre-flight",[448,1481,1482,1484,1485,1488],{},[457,1483,1395],{}," runs again. A failure returns ",[457,1486,1487],{},"skipped"," with the pre-flight message — no recipients are touched.",[540,1490,1492,1493],{"id":1491},"flip-to-sending","Flip to ",[457,1494,590],{},[448,1496,1497,1498,1500,1501,1503,1504,1507],{},"If the campaign is still ",[457,1499,580],{},", the orchestrator transitions it to ",[457,1502,590],{}," via the lifecycle (userId ",[457,1505,1506],{},"system:scheduler_tick",").",[540,1509,1511],{"id":1510},"content-scan-gate","Content scan gate",[448,1513,1514,1515,1518,1519,1521,1522,1525,1526,521,1529,1525,1532,1534,1535,1538,1539,1542,1543,1545,1546,1548,1549,1551,1552,1555,1556,1558,1559,1561],{},"The subject and rendered HTML are scanned for spam, phishing, and prohibited content. When a ",[457,1516,1517],{},"GOOGLE_SAFE_BROWSING_API_KEY"," is configured, link URLs are also checked against Google Safe Browsing (a failure of that check does ",[452,1520,1464],{}," block the send). The combined score classifies the send: ",[457,1523,1524],{},"≥ 40"," → ",[457,1527,1528],{},"blocked",[457,1530,1531],{},"≥ 15",[457,1533,627],{},", else ",[457,1536,1537],{},"clean",". A non-clean result is persisted to ",[457,1540,1541],{},"contentScanResults",". A ",[457,1544,1528],{}," send reverts the campaign to ",[457,1547,570],{}," with a ",[457,1550,530],{}," (userId ",[457,1553,1554],{},"system:content_scan","); a ",[457,1557,627],{}," send transitions it to ",[457,1560,620],{},". Both short-circuit the orchestrator.",[540,1563,1565],{"id":1564},"archive-snapshot","Archive snapshot",[448,1567,1568,1569,1572,1573,1576,1577,766,1580,1583,1584,1587],{},"If archiving is enabled (per-campaign ",[457,1570,1571],{},"archiveEnabled",", falling back to the resolved ",[457,1574,1575],{},"campaigns.archive"," feature flag) ",[452,1578,1579],{},"and",[457,1581,1582],{},"SITE_URL"," is set, the orchestrator generates a public archive HTML snapshot, stores it with a 24-char token, and computes a ",[457,1585,1586],{},"viewInBrowserUrl"," that is threaded into each send.",[540,1589,1591],{"id":1590},"freeze-the-audience","Freeze the audience",[448,1593,1594,1597],{},[457,1595,1596],{},"freezeCampaignAudience"," snapshots a segment audience's filters at send time (ADR-0033), so the campaign reproduces the exact audience it targeted even if the segment is later edited. Topic audiences and already-frozen segments pass through unchanged.",[540,1599,1601],{"id":1600},"open-a-send-job-checkpoint-and-stream-recipients","Open a send-job checkpoint and stream recipients",[448,1603,1604,1605,1607,1608,1611,1612,1615,1616,1413,1619,1622,1623,1626,1627,1630,1631,1634,1635,1637,1638,1641,1642,1507],{},"The orchestrator no longer resolves the whole audience inline. It opens a ",[457,1606,1027],{}," checkpoint (",[457,1609,1610],{},"createSendJob",", in ",[457,1613,1614],{},"campaigns\u002FsendJob.ts",") and schedules ",[457,1617,1618],{},"resolveCampaignPage",[457,1620,1621],{},"campaigns\u002Fsend.ts:611","), a self-rescheduling walker that streams the frozen audience one ",[452,1624,1625],{},"bounded page"," at a time via ",[457,1628,1629],{},"resolveRecipientPage",". Each page applies the single eligibility predicate — live contact → email present → not suppressed → double-opt-in confirmed (",[452,1632,1633],{},"topic audiences only"," — segment audiences are never DOI-gated) — then groups by language and buckets each contact into the A\u002FB split (below), enqueueing the page before fetching the next. The walker re-schedules itself until the audience is exhausted. The empty-audience → ",[457,1636,600],{}," fast-path lives in the walker's last-page branch (",[457,1639,1640],{},"campaigns\u002Fsend.ts:884-895",", userId ",[457,1643,1644],{},"system:orchestrator",[448,1646,1647,1648,1651,1652,1195,1654,1657],{},"Recipients are grouped by their language preference (falling back to the template's ",[457,1649,1650],{},"defaultLanguage",") for i18n. Within each language group, the A\u002FB fanout (if ",[457,1653,898],{},[457,1655,1656],{},"abTestStatus === 'testing'",") buckets each contact by hash into the test cohort or the held-back remainder, then variants A and B are enqueued separately. Non-A\u002FB groups enqueue everyone with variant A's content, untagged.",[540,1659,1661],{"id":1660},"ab-fanout-details","A\u002FB fanout details",[448,1663,1664,1413,1667,1670,1671,521,1673,1675,1676,1261,1678,1680,1681,535,1684,1413,1687,1690,1691,1694,1695,1698,1699,1702,1703,1706,1707,1709],{},[457,1665,1666],{},"resolveAbFanout",[457,1668,1669],{},"campaigns\u002FsendVariantSplit.ts",") returns a fanout only when the campaign ",[457,1672,898],{},[457,1674,1656],{},", and a config is present — so a ",[457,1677,1035],{},[457,1679,1109],{}," campaign never first-phase-splits. The split is a ",[452,1682,1683],{},"deterministic FNV-1a hash",[457,1685,1686],{},"(campaignId:contactId)",[457,1688,1689],{},"hashFraction",") compared against ",[457,1692,1693],{},"testFraction = 2 × splitPercentage \u002F 100",": ",[457,1696,1697],{},"h \u003C testFraction"," is the test cohort (sub-bucketed A\u002FB at the cohort midpoint via ",[457,1700,1701],{},"variantForHash","), and ",[457,1704,1705],{},"h >= testFraction"," is the held-back remainder. Recipients stream page-by-page through the checkpointed send walker (",[457,1708,1618],{},") — they are never shuffled or materialized in full.",[448,1711,1712,1713,1715,1716,1718,1719,1722,1723,1725,1726,467],{},"The held-back remainder is not sent in the first phase. After a winner is declared, the second-phase action ",[457,1714,1229],{}," resolves the audience again, excludes every contact that already has an ",[457,1717,459],{}," row for this campaign (covering the test cohort and guarding against double-sends), and enqueues the winning variant's content to the rest. Both phases share the per-variant ",[457,1720,1721],{},"enqueueVariantBatch"," helper — the single site that creates ",[457,1724,459],{}," rows and schedules ",[457,1727,1439],{},[492,1729,1731],{"id":1730},"emailsends-records-and-ever-reached-stat-semantics","emailSends records and \"ever-reached\" stat semantics",[448,1733,1734,1735,1737,1738,1741,1742,1745,1746,1748,1749,1751],{},"Each recipient of a campaign gets one ",[457,1736,459],{}," row (",[457,1739,1740],{},"apps\u002Fapi\u002Fconvex\u002Fschema\u002Fcampaigns.ts","). The contact's email, first name, and last name are ",[452,1743,1744],{},"snapshotted"," at send time and never updated — the row is the audit trail of what was actually sent, not a live view of the contact. ",[457,1747,482],{}," is a single field with eight values; ",[457,1750,1048],{}," is the only non-terminal one.",[545,1753,1754,1764],{},[548,1755,1756],{},[551,1757,1758,1762],{},[554,1759,1760],{},[457,1761,482],{},[554,1763,559],{},[561,1765,1766,1775,1784,1794,1804,1814,1824,1844],{},[551,1767,1768,1772],{},[566,1769,1770],{},[457,1771,1048],{},[566,1773,1774],{},"Enqueued, not yet handed to the provider.",[551,1776,1777,1781],{},[566,1778,1779],{},[457,1780,600],{},[566,1782,1783],{},"Accepted by the provider.",[551,1785,1786,1791],{},[566,1787,1788],{},[457,1789,1790],{},"failed",[566,1792,1793],{},"The workpool action errored (distinct from a bounce).",[551,1795,1796,1801],{},[566,1797,1798],{},[457,1799,1800],{},"delivered",[566,1802,1803],{},"Provider confirmed delivery.",[551,1805,1806,1811],{},[566,1807,1808],{},[457,1809,1810],{},"opened",[566,1812,1813],{},"An open was tracked.",[551,1815,1816,1821],{},[566,1817,1818],{},[457,1819,1820],{},"clicked",[566,1822,1823],{},"A link click was tracked.",[551,1825,1826,1831],{},[566,1827,1828],{},[457,1829,1830],{},"bounced",[566,1832,1833,1834,1837,1838,789,1841,467],{},"Provider accepted but the receiver rejected. ",[457,1835,1836],{},"bounceType"," records ",[457,1839,1840],{},"hard",[457,1842,1843],{},"soft",[551,1845,1846,1851],{},[566,1847,1848],{},[457,1849,1850],{},"complained",[566,1852,1853],{},"Recipient marked it as spam.",[448,1855,1856,1857,1548,1860,1863,1864,1867,1868,1870,1871,1874,1875,1878],{},"Status writes go through one writer — ",[457,1858,1859],{},"internal.delivery.sendLifecycle.transition",[457,1861,1862],{},"SendRef { kind: 'campaign', id }",". The legacy per-event mutations in ",[457,1865,1866],{},"delivery\u002Fsends.ts"," (the ",[457,1869,459],{},"-table mutation module) were removed; that module now only reads, creates (single via ",[457,1872,1873],{},"create"," and batched via ",[457,1876,1877],{},"createBatch","), and deletes.",[469,1880,1882],{"title":1881,"type":756},"Stats are \"ever-reached\", not current-status",[448,1883,1884,1887,1888,1890,1891,1893,1894,1896,1897,1899,1900,1902,1903,1905,1906,1694,1909,1911,1912,521,1915,1918,1919,1922,1923,1925,1926,1922,1928,1930,1931,1933,1934,1937],{},[457,1885,1886],{},"getStatsByCampaign"," does ",[452,1889,1464],{}," count delivered \u002F opened \u002F clicked by current ",[457,1892,482],{},". Because a row's ",[457,1895,482],{}," advances as new events arrive (a delivered row becomes ",[457,1898,1810],{},", an opened row can later become ",[457,1901,1830],{},"), counting by ",[457,1904,482],{}," would silently drop any recipient who progressed past a bucket and break every rate denominator. Instead, those buckets are derived from ",[452,1907,1908],{},"monotonic timestamps",[457,1910,1800],{}," counts any row carrying a ",[457,1913,1914],{},"deliveredAt",[457,1916,1917],{},"openedAt",", or ",[457,1920,1921],{},"clickedAt","; ",[457,1924,1810],{}," counts any row with an ",[457,1927,1917],{},[457,1929,1820],{}," counts any row with a ",[457,1932,1921],{}," or non-empty ",[457,1935,1936],{},"clickedLinks",". The same \"ever delivered\" denominator is used for the per-variant A\u002FB stats, so the two surfaces can't drift.",[448,1939,1940,1942,1943,1946,1947,1950],{},[457,1941,1886],{}," and the other per-send aggregate reads bound their scans (typically ",[457,1944,1945],{},"take(10_000)","); campaigns larger than that should rely on the denormalized ",[457,1948,1949],{},"stats*"," counters on the campaign row, which the send lifecycle bumps per recipient.",[492,1952,1954],{"id":1953},"campaign-vs-transactional-workpools-and-rate-limiting","Campaign vs transactional workpools and rate limiting",[448,1956,1957,1958,1961],{},"Email sends run through two separate Convex workpools defined in ",[457,1959,1960],{},"apps\u002Fapi\u002Fconvex\u002Fdelivery\u002Fworkpool.ts",":",[545,1963,1964,1979],{},[548,1965,1966],{},[551,1967,1968,1971,1976],{},[554,1969,1970],{},"Pool",[554,1972,1973],{},[457,1974,1975],{},"maxParallelism",[554,1977,1978],{},"Used for",[561,1980,1981,1997],{},[551,1982,1983,1988,1991],{},[566,1984,1985],{},[457,1986,1987],{},"transactionalEmailPool",[566,1989,1990],{},"30\u002Fsec",[566,1992,1993,1994,1996],{},"Time-sensitive transactional emails (",[462,1995,226],{"href":225},")",[551,1998,1999,2004,2007],{},[566,2000,2001],{},[457,2002,2003],{},"campaignEmailPool",[566,2005,2006],{},"20\u002Fsec",[566,2008,2009],{},"Bulk marketing campaign sends",[448,2011,2012,2013,2016,2017,2020,2021,2023,2024,2027,2028,467],{},"Two pools keep transactional mail from being blocked behind a long campaign queue. The combined ~50\u002Fsec stays under provider rate ceilings with a safety margin. Both pools set ",[457,2014,2015],{},"retryActionsByDefault"," but ",[457,2018,2019],{},"maxAttempts: 1"," — exactly one worker run and ",[452,2022,1042],{}," pool-level retry. The send-side retry loop is owned solely by the dispatch helper (",[457,2025,2026],{},"lib\u002FsendProviders\u002Fdispatch.ts",", ADR-0020); a pool retry would re-run the whole worker and risk duplicate sends. The 1s\u002Fbase-2 backoff fields exist but are never exercised at ",[457,2029,2019],{},[448,2031,2032,2033,2035,2036,2038,2039,2042,2043,2046,2047,2050,2051,2054,2055,2058,2059,789,2062,2065],{},"The orchestrator's ",[457,2034,1721],{}," schedules ",[457,2037,1439],{},", which calls ",[457,2040,2041],{},"campaignEmailPool.enqueueAction"," once per recipient, targeting ",[457,2044,2045],{},"internal.delivery.worker.sendSingleEmail",". Each enqueue wires the completion callback to ",[457,2048,2049],{},"internal.delivery.sendCompletion.completeSend"," and carries a typed ",[457,2052,2053],{},"sendRef"," in the workpool context so the completion module can translate worker outcomes into send-lifecycle transitions uniformly. Standard sends are scheduled immediately in chunks of 50; timezone-aware sends (",[457,2056,2057],{},"useRecipientTimezone"," plus ",[457,2060,2061],{},"scheduledHour",[457,2063,2064],{},"scheduledMinute",") are grouped by IANA timezone and delayed so each zone lands at the recipient's local time (DST-correct, not offset-based).",[492,2067,2069],{"id":2068},"related-reading","Related reading",[2071,2072],"link-card",{"description":2073,"title":266,"to":265},"How a topic or segment audience resolves into eligible recipients.",[2071,2075],{"description":2076,"title":34,"to":33},"The user-facing guide to configuring and reading A\u002FB test campaigns.",[2071,2078],{"description":2079,"title":318,"to":317},"Directory layout, secure-by-default function builders, and conventions.",{"title":2081,"searchDepth":2082,"depth":2082,"links":2083},"",2,[2084,2092,2093,2094,2105,2106,2107],{"id":494,"depth":2082,"text":495,"children":2085},[2086,2088,2089,2090],{"id":542,"depth":2087,"text":543},3,{"id":631,"depth":2087,"text":632},{"id":800,"depth":2087,"text":801},{"id":963,"depth":2087,"text":2091},"Reaching sent: batch completion",{"id":1054,"depth":2082,"text":1055},{"id":1254,"depth":2082,"text":1255},{"id":1407,"depth":2082,"text":1408,"children":2095},[2096,2097,2098,2100,2101,2102,2103,2104],{"id":1445,"depth":2087,"text":1446},{"id":1478,"depth":2087,"text":1479},{"id":1491,"depth":2087,"text":2099},"Flip to sending",{"id":1510,"depth":2087,"text":1511},{"id":1564,"depth":2087,"text":1565},{"id":1590,"depth":2087,"text":1591},{"id":1600,"depth":2087,"text":1601},{"id":1660,"depth":2087,"text":1661},{"id":1730,"depth":2082,"text":1731},{"id":1953,"depth":2082,"text":1954},{"id":2068,"depth":2082,"text":2069},"md",{},true,{"title":262,"description":263},"3.developer\u002F16.campaign-internals","rDvcaoZtcTN2MGcHF_Qhq9NuPGsY5nFCVm_H5-BTMwU",[2115,2117],{"title":258,"path":257,"stem":2116,"children":-1},"3.developer\u002F15.providers",{"title":266,"path":265,"stem":2118,"children":-1},"3.developer\u002F17.audience-internals",1782846428180]