[{"data":1,"prerenderedAt":1910},["ShallowReactive",2],{"search":3,"content-developer\u002Faudience-internals":442,"surround-\u002Fdeveloper\u002Faudience-internals":1905},[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":266,"body":444,"description":267,"extension":1899,"meta":1900,"navigation":1901,"path":265,"seo":1902,"stem":1903,"__hash__":1904},"content\u002F3.developer\u002F17.audience-internals.md",{"type":445,"value":446,"toc":1875},"minimark",[447,461,482,487,493,535,550,613,645,650,657,710,714,736,775,793,809,813,840,887,909,977,984,1035,1061,1065,1090,1094,1109,1159,1187,1191,1227,1231,1241,1319,1344,1348,1387,1503,1529,1571,1580,1584,1616,1619,1660,1681,1705,1709,1712,1716,1743,1788,1792,1820,1824,1855,1859,1863,1866,1869,1872],[448,449,450,451,454,455,457,458,460],"p",{},"This is the developer reference for the audience\u002Fcontacts layer of the Convex backend: how a Contact is found-or-created, how double opt-in (DOI) is driven, how topic memberships are written, and how segments and automation conditions are evaluated. Every \"given an identifier, find the Contact\" and \"does this Contact match\" path in the codebase routes through the modules described here. For the product-facing view of these features, see ",[452,453,178],"a",{"href":177},", ",[452,456,18],{"href":17},", and ",[452,459,22],{"href":21},".",[448,462,463,464,454,468,454,471,457,474,477,478,481],{},"The modules live under ",[465,466,467],"code",{},"apps\u002Fapi\u002Fconvex\u002Fcontacts\u002F",[465,469,470],{},"apps\u002Fapi\u002Fconvex\u002Ftopics\u002F",[465,472,473],{},"apps\u002Fapi\u002Fconvex\u002Fconditions\u002F",[465,475,476],{},"apps\u002Fapi\u002Fconvex\u002Flib\u002F",". Several carry an ADR; the ADRs are the canonical design record and the source comments cite them by path (",[465,479,480],{},"docs\u002Fadr\u002F00xx-*.md",").",[483,484,486],"h2",{"id":485},"contact-resolution-creation","Contact resolution & creation",[448,488,489,492],{},[465,490,491],{},"apps\u002Fapi\u002Fconvex\u002Fcontacts\u002Fresolution.ts"," is the single find-or-create primitive. Every intake path — inbound email, channel webhook, bulk import, HTTP API, automation trigger, form submission — reaches a Contact through it.",[448,494,495,496,499,500,503,504,508,509,512,513,516,517,520,521,524,525,454,528,454,531,534],{},"The lookup is uniform: every Contact is keyed by ",[465,497,498],{},"contactIdentities.by_identifier"," (the ",[465,501,502],{},"(channel, identifier)"," index), ",[505,506,507],"strong",{},"never"," by ",[465,510,511],{},"contacts.by_email",". For the ",[465,514,515],{},"email"," channel, ",[465,518,519],{},"contacts.email"," is denormalized off the primary identity row so legacy reads of ",[465,522,523],{},"contact.email"," keep working, but the identity row is the lookup primitive. Email identifiers are lowercased; phone-derived channels (",[465,526,527],{},"sms",[465,529,530],{},"whatsapp",[465,532,533],{},"phone",") are kept verbatim — callers normalize to E.164 first.",[448,536,537,538,541,542,545,546,549],{},"The exported helper ",[465,539,540],{},"resolveContact(ctx, signal)"," (and its wire mutation ",[465,543,544],{},"resolve",") forks on ",[465,547,548],{},"mode",":",[551,552,553,569],"table",{},[554,555,556],"thead",{},[557,558,559,563,566],"tr",{},[560,561,562],"th",{},"Mode",[560,564,565],{},"On match",[560,567,568],{},"On no match",[570,571,572,589,601],"tbody",{},[557,573,574,580,586],{},[575,576,577],"td",{},[465,578,579],{},"strict",[575,581,582,583],{},"throw ",[465,584,585],{},"ALREADY_EXISTS",[575,587,588],{},"create",[557,590,591,596,599],{},[575,592,593],{},[465,594,595],{},"upsert",[575,597,598],{},"return matched id, no field update",[575,600,588],{},[557,602,603,608,611],{},[575,604,605],{},[465,606,607],{},"merge",[575,609,610],{},"patch fields where the new value is non-empty (existing wins for empty\u002Fundefined)",[575,612,588],{},[448,614,615,618,619,622,623,618,626,629,630,633,634,637,638,642,643,460],{},[465,616,617],{},"ResolveResult"," is ",[465,620,621],{},"{ contactId, action }"," where ",[465,624,625],{},"action",[465,627,628],{},"'matched' | 'created' | 'updated'",". The resolution module is deliberately ",[505,631,632],{},"effect-free",": it owns the identity-row write on create, the ",[465,635,636],{},"searchableText"," computation, and the soft-delete filter on lookup — but it does ",[639,640,641],"em",{},"not"," log activity, fan out automation triggers, or maintain the contact count. Those stay with callers, branched on ",[465,644,625],{},[646,647,649],"h3",{"id":648},"channels-and-sources","Channels and sources",[448,651,652,653,656],{},"Two enumerations bound the inputs (defined in ",[465,654,655],{},"resolution.ts","):",[658,659,660,681],"ul",{},[661,662,663,666,667,454,669,454,671,454,673,454,675,454,678,460],"li",{},[465,664,665],{},"ChannelKind",": ",[465,668,515],{},[465,670,527],{},[465,672,530],{},[465,674,533],{},[465,676,677],{},"generic",[465,679,680],{},"chat",[661,682,683,666,686,454,689,454,692,454,695,454,698,701,702,705,706,709],{},[465,684,685],{},"ContactSource",[465,687,688],{},"api",[465,690,691],{},"import",[465,693,694],{},"form",[465,696,697],{},"transactional",[465,699,700],{},"inbound",". This becomes ",[465,703,704],{},"contacts.source"," and is recorded in the ",[465,707,708],{},"created"," activity's metadata.",[646,711,713],{"id":712},"the-creation-effect-bundle","The creation effect bundle",[448,715,716,717,720,721,724,725,728,729,735],{},"Single-create callers do not call ",[465,718,719],{},"resolveContact"," directly — they go through ",[465,722,723],{},"createContact"," in ",[465,726,727],{},"apps\u002Fapi\u002Fconvex\u002Fcontacts\u002Fcreation.ts",", which wraps the effect-free primitive and, ",[505,730,731,732],{},"only on ",[465,733,734],{},"action === 'created'",", fires the uniform created-effect bundle:",[737,738,739,749,756,765],"ol",{},[661,740,741,744,745,748],{},[465,742,743],{},"incrementContactCount(ctx, 1)"," — keep ",[465,746,747],{},"cachedContactCount"," true.",[661,750,751,752,755],{},"The ",[465,753,754],{},"contact_created"," automation trigger.",[661,757,758,759,761,762,460],{},"A ",[465,760,708],{}," Contact activity row tagged with ",[465,763,764],{},"metadata.source",[661,766,758,767,770,771,774],{},[465,768,769],{},"contact.created"," customer webhook fanout via ",[465,772,773],{},"scheduleFanout"," (email channel only).",[448,776,777,778,781,782,784,785,788,789,792],{},"The bulk ",[452,779,780],{"href":125},"Contact import"," module is the sole exception: it calls ",[465,783,719],{}," directly and batches one ",[465,786,787],{},"incrementContactCount"," per page. Keeping these effects in this layer ",[639,790,791],{},"above"," the still-effect-free primitive is what lets import's batched count and the single-create bundle coexist without double-counting.",[794,795,798],"callout",{"title":796,"type":797},"Reclaimable identifiers","info",[448,799,800,801,804,805,808],{},"On soft-delete, ",[465,802,803],{},"deleteIdentitiesForContact"," hard-deletes every ",[465,806,807],{},"contactIdentities"," row for the Contact immediately (not after the 30-day retention window). The identifier — the privacy-sensitive datum — becomes reclaimable on day one, so creating a fresh Contact for a previously-used address never collides.",[483,810,812],{"id":811},"double-opt-in-lifecycle","Double opt-in lifecycle",[448,814,815,818,819,822,823,826,827,454,830,454,833,454,836,839],{},[465,816,817],{},"apps\u002Fapi\u002Fconvex\u002Fcontacts\u002FdoiLifecycle.ts"," is the ",[505,820,821],{},"single writer"," of ",[465,824,825],{},"contacts.doiStatus"," and its companion fields (",[465,828,829],{},"doiConfirmationToken",[465,831,832],{},"doiTokenExpiresAt",[465,834,835],{},"doiConfirmedAt",[465,837,838],{},"doiAttestedSource","). It implements a three-state machine with these legal edges:",[551,841,842,852],{},[554,843,844],{},[557,845,846,849],{},[560,847,848],{},"From",[560,850,851],{},"Legal transitions",[570,853,854,867,878],{},[557,855,856,861],{},[575,857,858],{},[465,859,860],{},"not_required",[575,862,863,864],{},"→ ",[465,865,866],{},"pending",[557,868,869,873],{},[575,870,871],{},[465,872,866],{},[575,874,863,875],{},[465,876,877],{},"confirmed",[557,879,880,884],{},[575,881,882],{},[465,883,877],{},[575,885,886],{},"terminal (no edges)",[448,888,889,890,893,894,897,898,901,902,905,906,549],{},"There are two entry points: ",[465,891,892],{},"transition"," (keyed by ",[465,895,896],{},"contactId",") and ",[465,899,900],{},"transitionByConfirmationToken"," (keyed by the URL token, used by the customer-facing confirm endpoints). Both return a ",[465,903,904],{},"TransitionOutcome"," — duplicate, illegal, terminal, expired, and not-found cases are ",[505,907,908],{},"reported, never thrown",[551,910,911,923],{},[554,912,913],{},[557,914,915,920],{},[560,916,917],{},[465,918,919],{},"reason",[560,921,922],{},"Meaning",[570,924,925,935,945,957,967],{},[557,926,927,932],{},[575,928,929],{},[465,930,931],{},"contact_not_found",[575,933,934],{},"No Contact for the id",[557,936,937,942],{},[575,938,939],{},[465,940,941],{},"token_not_found",[575,943,944],{},"No Contact holds that confirmation token",[557,946,947,952],{},[575,948,949],{},[465,950,951],{},"token_expired",[575,953,954,955],{},"Token past ",[465,956,832],{},[557,958,959,964],{},[575,960,961],{},[465,962,963],{},"illegal_edge",[575,965,966],{},"Transition not allowed from the current state",[557,968,969,974],{},[575,970,971],{},[465,972,973],{},"terminal",[575,975,976],{},"Already in a state with no outgoing edges",[448,978,979,980,983],{},"Transitions are reducer-driven: a reducer returns ",[465,981,982],{},"{ patch, effects, applied }",", and the runner is the only place that touches the DB and the scheduler. Effects are:",[658,985,986,1006,1012,1026],{},[661,987,988,991,992,995,996,999,1000,1002,1003,1005],{},[465,989,990],{},"send_confirmation_email"," — schedules the confirmation email. Only fires when the caller supplies a ",[465,993,994],{},"siteUrl"," ",[505,997,998],{},"and"," the Contact has an ",[465,1001,515],{}," (admin imports that pre-confirm out-of-band leave ",[465,1004,994],{}," absent).",[661,1007,1008,1011],{},[465,1009,1010],{},"fire_topic_subscribed_triggers"," — at confirm time, fans out to every DOI-required Topic membership the Contact currently holds.",[661,1013,1014,1017,1018,1021,1022,1025],{},[465,1015,1016],{},"contact_activity"," — one ",[465,1019,1020],{},"topic_confirmed"," row per DOI-required membership, plus a ",[465,1023,1024],{},"doi_attested"," row on the admin-attest path.",[661,1027,1028,1031,1032,481],{},[465,1029,1030],{},"audit_log"," — fires only on the admin-attest path (",[465,1033,1034],{},"doi.admin_attested",[448,1036,1037,1038,1041,1042,1045,1046,1049,1050,1053,1054,1057,1058,1060],{},"Tokens have a 7-day TTL (",[465,1039,1040],{},"DOI_TOKEN_TTL_MS","). A separate operation, ",[465,1043,1044],{},"refreshPendingToken",", regenerates the token and re-sends the email ",[505,1047,1048],{},"without"," changing ",[465,1051,1052],{},"doiStatus"," — it refuses with ",[465,1055,1056],{},"not_pending"," if the Contact is not currently ",[465,1059,866],{},". It lives in this module so every write to the DOI fields goes through one file.",[646,1062,1064],{"id":1063},"admin-attest","Admin-attest",[448,1066,1067,1068,1071,1072,1075,1076,1078,1079,1081,1082,1084,1085,1087,1088,460],{},"When a Contact was already DOI-confirmed at a source platform (Mailchimp, Klaviyo, Stripe, a trusted CSV), the import path can attest that out-of-band. The admin-attest variant (",[465,1069,1070],{},"{ to: 'confirmed', source: 'admin_attest', attestSource }",") relaxes the otherwise-refused ",[465,1073,1074],{},"not_required → confirmed"," edge, records ",[465,1077,838],{},", and emits the ",[465,1080,1030],{}," + ",[465,1083,1024],{}," activity. The token-keyed confirm path can never reach ",[465,1086,877],{}," from ",[465,1089,860],{},[483,1091,1093],{"id":1092},"topic-subscription","Topic subscription",[448,1095,1096,818,1099,822,1101,1104,1105,1108],{},[465,1097,1098],{},"apps\u002Fapi\u002Fconvex\u002Ftopics\u002Fsubscription.ts",[505,1100,821],{},[465,1102,1103],{},"contactTopics",". It also owns every maintenance of ",[465,1106,1107],{},"topics.cachedMemberCount",", the DOI gate at subscribe time, and the per-source effect bundle on unsubscribe. There are five entry points keyed by shape:",[551,1110,1111,1121],{},[554,1112,1113],{},[557,1114,1115,1118],{},[560,1116,1117],{},"Entry point",[560,1119,1120],{},"Shape",[570,1122,1123,1137,1149],{},[557,1124,1125,1134],{},[575,1126,1127,1130,1131],{},[465,1128,1129],{},"subscribe"," \u002F ",[465,1132,1133],{},"subscribeMany",[575,1135,1136],{},"one topic, one-or-many contacts",[557,1138,1139,1147],{},[575,1140,1141,1130,1144],{},[465,1142,1143],{},"unsubscribe",[465,1145,1146],{},"unsubscribeMany",[575,1148,1136],{},[557,1150,1151,1156],{},[575,1152,1153],{},[465,1154,1155],{},"unsubscribeAllForContact",[575,1157,1158],{},"one contact, one-or-all topics",[448,1160,1161,1164,1165,454,1168,1171,1172,1175,1176,1178,1179,1182,1183,1186],{},[465,1162,1163],{},"SubscribeOutcome"," resolves to one of ",[465,1166,1167],{},"subscribed",[465,1169,1170],{},"pending_doi",", or ",[465,1173,1174],{},"already_member",". The ",[465,1177,1170],{}," outcome carries the freshly-written ",[465,1180,1181],{},"doiToken"," so a caller recording a sibling row (the form module's ",[465,1184,1185],{},"formSubmissions.confirmationToken",") avoids re-reading the Contact.",[646,1188,1190],{"id":1189},"the-doi-gate-at-subscribe-time","The DOI gate at subscribe time",[448,1192,1193,1194,995,1197,1199,1200,1203,1204,1206,1207,1209,1210,1213,1214,1217,1218,1220,1221,1223,1224,1226],{},"When subscribing, DOI is required iff ",[465,1195,1196],{},"topic.requireDoubleOptIn === true",[505,1198,998],{}," the caller did not pass ",[465,1201,1202],{},"skipDoi",". If DOI is ",[639,1205,641],{}," in the way (or the Contact is already ",[465,1208,877],{},"), the ",[465,1211,1212],{},"topic_subscribed"," trigger fires immediately. Otherwise the module hands off to the DOI lifecycle's ",[465,1215,1216],{},"transition(... to: 'pending')"," and returns ",[465,1219,1170],{}," — it does ",[505,1222,641],{}," fire the trigger itself, because the lifecycle's ",[465,1225,1010],{}," effect at confirm time covers every DOI-required membership at once and would otherwise double-fire.",[646,1228,1230],{"id":1229},"source-conditional-unsubscribe-effects","Source-conditional unsubscribe effects",[448,1232,1233,1234,1237,1238,656],{},"Per-call unsubscribe effects are gated by the unsubscribe ",[465,1235,1236],{},"source",". This table is the single place where \"which side effects fire for which trigger\" lives (",[465,1239,1240],{},"effectFlagsForUnsubscribeSource",[551,1242,1243,1263],{},[554,1244,1245],{},[557,1246,1247,1250,1253,1256],{},[560,1248,1249],{},"Source",[560,1251,1252],{},"Clear form confirmations",[560,1254,1255],{},"Increment campaign unsub stats",[560,1257,1258,1259,1262],{},"Fire ",[465,1260,1261],{},"topic.unsubscribed"," webhook",[570,1264,1265,1279,1293,1306],{},[557,1266,1267,1272,1275,1277],{},[575,1268,1269],{},[465,1270,1271],{},"public_email_link",[575,1273,1274],{},"yes",[575,1276,1274],{},[575,1278,1274],{},[557,1280,1281,1286,1288,1291],{},[575,1282,1283],{},[465,1284,1285],{},"preferences_page",[575,1287,1274],{},[575,1289,1290],{},"no",[575,1292,1274],{},[557,1294,1295,1300,1302,1304],{},[575,1296,1297],{},[465,1298,1299],{},"admin",[575,1301,1290],{},[575,1303,1290],{},[575,1305,1290],{},[557,1307,1308,1313,1315,1317],{},[575,1309,1310],{},[465,1311,1312],{},"public_api",[575,1314,1290],{},[575,1316,1290],{},[575,1318,1290],{},[448,1320,1321,1322,1325,1326,1329,1330,1333,1334,1337,1338,1340,1341,1343],{},"Per-call effects (the ",[465,1323,1324],{},"cachedMemberCount"," patch, ",[465,1327,1328],{},"contact.updatedAt",", form-clear, campaign-stats, webhook) fire ",[505,1331,1332],{},"once per call"," regardless of how many memberships are touched; per-membership effects (the membership row delete and the ",[465,1335,1336],{},"topic_unsubscribed"," activity row) fire N times. ",[465,1339,1155],{}," is the entry point used by the public unsubscribe link and groups deletions per topic so each topic's ",[465,1342,1324],{}," is patched once.",[483,1345,1347],{"id":1346},"conditions-registry","Conditions registry",[448,1349,1350,1352,1353,1355,1356,1359,1360,1363,1364,1130,1367,1130,1370,1130,1373,1376,1377,1380,1381,1383,1384,1386],{},[465,1351,473],{}," holds the typed, pluggable registry that powers both ",[452,1354,22],{"href":21}," and the ",[452,1357,1358],{"href":269},"automation"," condition step. There are three condition kinds, each a ",[465,1361,1362],{},"ConditionTypeModule"," with ",[465,1365,1366],{},"parseCondition",[465,1368,1369],{},"preloadLookup",[465,1371,1372],{},"preloadLookupForContacts",[465,1374,1375],{},"evaluate"," (",[465,1378,1379],{},"conditions\u002Ftypes.ts","). ",[465,1382,1369],{}," is the whole-population preload; ",[465,1385,1372],{}," is the bounded per-contact variant used by the single-contact automation path and the per-page segment builder:",[551,1388,1389,1402],{},[554,1390,1391],{},[557,1392,1393,1396,1399],{},[560,1394,1395],{},"Kind",[560,1397,1398],{},"Fields",[560,1400,1401],{},"Operators",[570,1403,1404,1463,1484],{},[557,1405,1406,1411,1425],{},[575,1407,1408],{},[465,1409,1410],{},"contact_property",[575,1412,1413,1414,454,1416,454,1419,454,1422,1424],{},"any built-in (",[465,1415,515],{},[465,1417,1418],{},"firstName",[465,1420,1421],{},"lastName",[465,1423,1236],{},") or custom property key",[575,1426,1427,454,1430,454,1433,454,1436,454,1439,454,1442,454,1445,454,1448,454,1451,454,1454,454,1457,454,1460],{},[465,1428,1429],{},"equals",[465,1431,1432],{},"not_equals",[465,1434,1435],{},"contains",[465,1437,1438],{},"not_contains",[465,1440,1441],{},"gt",[465,1443,1444],{},"lt",[465,1446,1447],{},"gte",[465,1449,1450],{},"lte",[465,1452,1453],{},"is_empty",[465,1455,1456],{},"not_empty",[465,1458,1459],{},"is_true",[465,1461,1462],{},"is_false",[557,1464,1465,1470,1478],{},[575,1466,1467],{},[465,1468,1469],{},"email_activity",[575,1471,1472,454,1475],{},[465,1473,1474],{},"opened",[465,1476,1477],{},"clicked",[575,1479,1480,454,1482],{},[465,1481,1459],{},[465,1483,1462],{},[557,1485,1486,1491,1497],{},[575,1487,1488],{},[465,1489,1490],{},"topic_membership",[575,1492,1493,1494],{},"a ",[465,1495,1496],{},"topicId",[575,1498,1499,454,1501],{},[465,1500,1429],{},[465,1502,1432],{},[448,1504,1505,1506,1509,1510,1513,1514,1517,1518,1521,1522,1524,1525,1528],{},"The registry's value is the ",[505,1507,1508],{},"batched evaluation"," seam (",[465,1511,1512],{},"conditions\u002Findex.ts","): rather than re-querying per Contact, ",[465,1515,1516],{},"preloadConditionsLookup"," groups the conditions by kind, hands each batch to its module, and stores a typed lookup keyed by kind. (",[465,1519,1520],{},"preloadConditionsLookupForContacts"," is the bounded sibling that drives each module's ",[465,1523,1372],{}," for the per-contact paths.) Then ",[465,1526,1527],{},"evaluateOne(condition, contact, lookup)"," is a pure O(1) check. The preloads:",[658,1530,1531,1543,1559],{},[661,1532,1533,1535,1536,1539,1540,460],{},[465,1534,1410],{}," resolves custom-property IDs by key, then preloads ",[465,1537,1538],{},"contactPropertyValues"," for those properties; built-in fields read straight off the Contact doc. String operators are case-insensitive; numeric operators coerce via ",[465,1541,1542],{},"Number()",[661,1544,1545,1547,1548,1130,1551,1554,1555,1558],{},[465,1546,1469],{}," is evaluated directly off the denormalized ",[465,1549,1550],{},"contact.hasOpened",[465,1552,1553],{},"contact.hasClicked"," flags (maintained by ",[465,1556,1557],{},"contactActivities\u002Fwriter.ts","); it has no preloaded lookup and performs no scan — an O(1) read off the already-loaded Contact row.",[661,1560,1561,1563,1564,1567,1568,460],{},[465,1562,1490],{}," reads ",[465,1565,1566],{},"contactTopics.by_topic"," for each referenced topic into a membership ",[465,1569,1570],{},"Set",[448,1572,1573,1575,1576,1579],{},[465,1574,1366],{}," throws on a shape\u002Foperator\u002Ffield violation — callers treat a parse failure as ",[505,1577,1578],{},"corrupt stored data, not user input",", because conditions are storage-validated at write time.",[483,1581,1583],{"id":1582},"segment-evaluation-the-listing-engine","Segment evaluation & the Listing engine",[448,1585,1586,1587,1590,1591,1594,1595,454,1598,454,1601,1604,1605,1608,1609,1612,1613,460],{},"The layer above per-condition evaluation — filter normalization, the empty\u002FAND\u002FOR combine, and the live-Contact scan — lives in ",[465,1588,1589],{},"apps\u002Fapi\u002Fconvex\u002Fconditions\u002FsegmentMatch.ts",". It is the single owner of \"match a Contact population against a stored filter set\", consolidating logic that previously drifted across five open-coded copies. ",[465,1592,1593],{},"apps\u002Fapi\u002Fconvex\u002Fconditions\u002Findex.ts"," re-exports a backward-compatible surface (",[465,1596,1597],{},"evaluateCondition",[465,1599,1600],{},"evaluateSegmentCount",[465,1602,1603],{},"countLiveMatchesForSegments",") from ",[465,1606,1607],{},"segmentMatch.ts"," that ",[465,1610,1611],{},"apps\u002Fapi\u002Fconvex\u002Fsegments.ts"," imports via ",[465,1614,1615],{},".\u002Fconditions",[448,1617,1618],{},"The module has two layers:",[658,1620,1621,1638],{},[661,1622,1623,1626,1627,1081,1630,1633,1634,1637],{},[505,1624,1625],{},"Pure core"," — ",[465,1628,1629],{},"parseSegmentFilters",[465,1631,1632],{},"makeSegmentPredicate",". These ",[639,1635,1636],{},"throw"," on corrupt filters. Empty conditions match every Contact; otherwise conditions combine with short-circuit AND\u002FOR. This is the test surface and the seam the send path uses (it builds the predicate directly so it can interleave eligibility filtering in one walk).",[661,1639,1640,1626,1643,454,1646,454,1649,1651,1652,1655,1656,1659],{},[505,1641,1642],{},"Lenient async conveniences",[465,1644,1645],{},"countLiveMatches",[465,1647,1648],{},"matchLiveContacts",[465,1650,1603],{},". These bake in the soft-delete-excluding paginated ",[465,1653,1654],{},"by_deleted_at"," scan and treat corrupt filters as a ",[505,1657,1658],{},"zero match"," — the posture the preview, count, and cron paths want.",[448,1661,1662,1663,1666,1667,1670,1671,1130,1674,1677,1678,1680],{},"A cron (",[465,1664,1665],{},"crons.ts",", every 30 minutes) calls ",[465,1668,1669],{},"refreshAllSegmentCounts"," to keep ",[465,1672,1673],{},"segments.cachedCount",[465,1675,1676],{},"cachedCountUpdatedAt"," fresh for the segments list UI. ",[465,1679,1603],{}," evaluates many segments in one pass: it flattens every segment's conditions into a single preloaded lookup and reuses one Contact scan.",[794,1682,1684],{"title":1683,"type":797},"Listing engine vs. segment scan",[448,1685,1686,1687,1355,1689,1692,1693,1696,1697,1700,1701,1704],{},"The paged, sortable list of Contacts in the dashboard rides indexed reads via the resource Listing engine (see ",[452,1688,349],{"href":348},[465,1690,1691],{},"by_deleted_at_and_created_at"," index, where ",[465,1694,1695],{},"deletedAt"," leads so soft-deleted rows are dropped ",[639,1698,1699],{},"inside"," the index). Segment matching is a different operation: arbitrary filter predicates that cannot ride a single index, so it is a deliberately ",[505,1702,1703],{},"bounded full-table scan"," of live Contacts (see \"Bounded scans\" below).",[483,1706,1708],{"id":1707},"surprising-behaviors","Surprising behaviors",[448,1710,1711],{},"A few behaviors trip up new readers — they are intentional, but worth calling out.",[646,1713,1715],{"id":1714},"doi-defaults-to-required","DOI defaults to required",[448,1717,1718,1719,1376,1722,454,1725,1728,1729,1731,1732,1734,1735,1738,1739,1742],{},"New Topics default to ",[465,1720,1721],{},"requireDoubleOptIn: true",[465,1723,1724],{},"topics\u002Ftopics.ts",[465,1726,1727],{},"args.requireDoubleOptIn ?? true","). A Contact subscribing to a fresh Topic therefore lands in ",[465,1730,866],{}," and must confirm, unless the operator explicitly sets the Topic to single opt-in or the caller passes ",[465,1733,1202],{},". Newly-created Contacts themselves start at ",[465,1736,1737],{},"doiStatus: 'not_required'"," — DOI is gated ",[505,1740,1741],{},"at the Topic level",", applied when a membership is created, not at Contact creation.",[794,1744,1746],{"title":1745,"type":797},"The per-form doubleOptIn toggle forces DOI",[448,1747,1748,1749,1752,1753,1756,1757,1760,1761,1763,1764,1767,1768,1771,1772,1775,1776,1779,1780,1783,1784,1787],{},"A form's ",[465,1750,1751],{},"doubleOptIn"," field is the ",[505,1754,1755],{},"union"," of form and topic controls: when ",[465,1758,1759],{},"form.doubleOptIn === true",", the ",[452,1762,26],{"href":25}," submission path (",[465,1765,1766],{},"forms\u002Fsubmission.ts",") passes ",[465,1769,1770],{},"forceDoi: true"," into ",[465,1773,1774],{},"topics.subscription.subscribe",", and the subscribe gate (",[465,1777,1778],{},"topics\u002Fsubscription.ts",") requires DOI when ",[465,1781,1782],{},"topic.requireDoubleOptIn === true || forceDoi === true"," (and ",[465,1785,1786],{},"skipDoi !== true","). So a form can require confirmation even on a single-opt-in topic, but it can never weaken a DOI-required topic. Segment audiences are never DOI-gated at all.",[646,1789,1791],{"id":1790},"emailless-contacts","Emailless contacts",[448,1793,1794,1796,1797,454,1799,454,1801,1171,1803,1805,1806,1808,1809,1811,1812,1815,1816,1819],{},[465,1795,519],{}," is optional. A Contact that arrived via ",[465,1798,527],{},[465,1800,530],{},[465,1802,533],{},[465,1804,677],{}," has no email at all. Identity lookups go through ",[465,1807,498],{},", so an emailless Contact is fully addressable by its channel identifier. Code that needs an email defends accordingly: the ",[465,1810,1261],{}," webhook falls back to ",[465,1813,1814],{},"''"," when a Contact has no email (the payload contract requires a string), and ",[465,1817,1818],{},"ensureEmailIdentity"," is a no-op for emailless Contacts.",[646,1821,1823],{"id":1822},"bounded-scans","Bounded scans",[448,1825,1826,1827,1829,1830,1832,1833,1835,1836,1376,1839,1842,1843,1845,1846,1130,1848,1850,1851,1854],{},"Segment matching is a ",[505,1828,1703],{},", not an indexed read — an arbitrary filter predicate cannot ride a single index. ",[465,1831,1607],{}," scans live Contacts in bounded 500-row pages via the ",[465,1834,1654],{}," index pinned to ",[465,1837,1838],{},"deletedAt === undefined",[465,1840,1841],{},"forEachLiveContact","), so soft-deleted rows are dropped inside the index. (",[465,1844,1469],{}," is no longer a scan — it is an O(1) denormalized-flag read off ",[465,1847,1550],{},[465,1849,1553],{},".) Keep this in mind for very large audiences — the count cron amortizes the segment scan, and the ",[452,1852,1853],{"href":261},"campaign send path"," runs the same predicate once per send rather than per preview.",[483,1856,1858],{"id":1857},"see-also","See also",[1860,1861],"link-card",{"description":1862,"title":178,"to":177},"The product-facing view of contacts, identities, and the activity timeline.",[1860,1864],{"description":1865,"title":18,"to":17},"How topics, double opt-in, and preferences work for end users.",[1860,1867],{"description":1868,"title":22,"to":21},"Building reusable audience filters in the dashboard.",[1860,1870],{"description":1871,"title":270,"to":269},"The automation engine that shares the conditions registry.",[1860,1873],{"description":1874,"title":262,"to":261},"The audience-resolution and send path that consumes topics and segments.",{"title":1876,"searchDepth":1877,"depth":1877,"links":1878},"",2,[1879,1884,1887,1891,1892,1893,1898],{"id":485,"depth":1877,"text":486,"children":1880},[1881,1883],{"id":648,"depth":1882,"text":649},3,{"id":712,"depth":1882,"text":713},{"id":811,"depth":1877,"text":812,"children":1885},[1886],{"id":1063,"depth":1882,"text":1064},{"id":1092,"depth":1877,"text":1093,"children":1888},[1889,1890],{"id":1189,"depth":1882,"text":1190},{"id":1229,"depth":1882,"text":1230},{"id":1346,"depth":1877,"text":1347},{"id":1582,"depth":1877,"text":1583},{"id":1707,"depth":1877,"text":1708,"children":1894},[1895,1896,1897],{"id":1714,"depth":1882,"text":1715},{"id":1790,"depth":1882,"text":1791},{"id":1822,"depth":1882,"text":1823},{"id":1857,"depth":1877,"text":1858},"md",{},true,{"title":266,"description":267},"3.developer\u002F17.audience-internals","Qdj-gJzYBnbL8O7R-6URl6MFyHUelP2bIgiA_dE1goM",[1906,1908],{"title":262,"path":261,"stem":1907,"children":-1},"3.developer\u002F16.campaign-internals",{"title":270,"path":269,"stem":1909,"children":-1},"3.developer\u002F18.automation-internals",1782846428269]