---
title: "Email AI-CRM architecture"
description: "Multi-mailbox IMAP via loadCrmChannels, EmailProcessor orchestration, jsonb types + repositories, CRM SMTP vs Auth mailer"
locale: "en"
---
# Email AI-CRM architecture

> **Info**
> Use **Founder** / **Developer** tabs in the docs sidebar to filter this page.

## System diagram

```mermaid
flowchart TB
    subgraph Config["Config"]
        RC["ring-config.emailCrm.channels"]
        SECRETS["CRM_CHANNEL_*_PASSWORD"]
        LC[loadCrmChannels]
    end

    subgraph Ingest["Ingestion"]
        Cron["POST /api/cron/email-processor poll"]
        Webhook["POST /api/webhooks/email/inbound"]
        Idle["EMAIL_PROCESSOR_AUTOSTART IDLE optional"]
    end

    subgraph Core["services/email"]
        EP[EmailProcessor]
        IMAP[ImapListener.pollBatch]
        Parser[EmailParser]
        Sec[SecurityPipeline]
        AI[Intent + Sentiment + ResponseGenerator]
        CRM[ContactService + TaskService]
        Drafts[EmailDraftService]
    end

    subgraph Persist["features/email-crm"]
        Types["types/contact|task|draft"]
        Repos[Jsonb*Repository]
        JC[jsonb-collection.ts]
        DB["db() DatabaseService"]
    end

    subgraph Out["Outbound planes"]
        AuthSMTP["lib/mailer.ts Auth OTP"]
        CRMSMTP[EmailSenderService channel SMTP]
        Notify[EmailNotificationService]
    end

    subgraph Admin["Admin CRM"]
        ChannelsAPI["GET /api/admin/email/channels"]
        API["/api/admin/email/*"]
        UI["/admin/crm/* + sourceChannel filter"]
    end

    RC --> LC
    SECRETS --> LC
    LC --> IMAP
    Cron --> IMAP
    Webhook --> EP
    Idle --> EP
    IMAP --> EP
    EP --> Parser --> Sec --> AI
    AI --> CRM
    AI --> Drafts
    Types --> Repos
    Repos --> JC
    EP --> JC
    CRM --> JC
    Drafts --> JC
    JC --> DB
    Drafts --> CRMSMTP
    EP --> Notify
    ChannelsAPI --> LC
    API --> JC
    UI --> API
    UI --> ChannelsAPI
```

### For founders

## Module boundaries (operator view)

| Stage | What happens |
|-------|----------------|
| **Configure** | Channels in `ring-config.json`; passwords in `CRM_CHANNEL_*` secrets |
| **Ingest** | Cron polls each enabled mailbox, or a signed webhook pushes payloads |
| **Process** | Security scan → AI classification → CRM upsert with `sourceChannel` → draft queue |
| **Review** | Admins filter inbox by channel, approve drafts |
| **Send** | Replies leave via **CRM** SMTP for that channel (not login noreply) |
| **Track** | Tasks and API cost roll up in admin analytics |

Idempotency: duplicate `Message-ID` values are skipped; overlapping poll batches are mutex-guarded.

### For developers

## Code layout

| Path | Role |
|------|------|
| `features/email-crm/pipeline/imap/config.ts` | `loadCrmChannels()`, `validateCrmChannels()`, legacy IMAP_* fallback |
| `features/email-crm/pipeline/email-processor.ts` | Orchestrator; dedup, poll each channel, ingest |
| `features/email-crm/pipeline/smtp/email-sender.ts` | CRM outbound — **not** `lib/mailer.ts` |
| `lib/mailer.ts` | Auth OTP / magic / reset only |
| `features/email-crm/pipeline/crm/` | Contact + task services — static `import { Jsonb*Repository }` |
| `features/email-crm/pipeline/drafts/` | Draft queue + auto-send rules |
| `features/email-crm/pipeline/ai/` | Classifiers, generator, cost tracker |
| `features/email-crm/types/*` | Pure types (no runtime deps) — breaks Jsonb circular require |
| `features/email-crm/repositories/` | `import type` from types; JSONB repos |
| `features/email-crm/lib/jsonb-collection.ts` | `readDoc` / `upsertDoc` / `queryDocs` → `db()` |
| `app/api/cron/email-processor/route.ts` | Constructs processor **per action** |
| `app/api/admin/email/channels/route.ts` | Read-only channel status |

## Persistence — types + jsonb-collection → `db()`

CRM repositories and services never call raw SQL. Types are split into `features/email-crm/types/{contact,task,draft}.ts` so repositories can `import type` without pulling service factories into a CJS `require` cycle.

| Helper | `db()` method |
|--------|---------------|
| `readDoc(collection, id)` | `readDoc()` |
| `upsertDoc(collection, id, record)` | `readDoc()` then `updateDoc()` or `createDoc()` |
| `queryDocs({ collection, filters, orderBy, limit })` | `queryDocs()` |
| `deleteDoc(collection, id)` | `deleteDoc()` |

## Active JSONB collections

| Collection | Document id | Indexed fields |
|------------|-------------|----------------|
| `email_threads` | RFC thread root / Message-ID | `status`, `fromEmail`, `lastMessageAt`, `sourceChannel` |
| `email_contacts` | `contact_<sha256(email)>` | `email`, `type` |
| `email_messages` | RFC Message-ID | `threadId` |
| `email_drafts` | `draft_` | `threadId`, `status` |
| `email_tasks` | `task_` | `threadId`, `status`, `dueDate` |
| `email_api_usage` | `req__` | `timestamp`, `operation`, `emailId` |

Migrations: `009_email_crm_jsonb.sql` + `010_email_crm_tasks_jsonb.sql`.

## Idempotency and concurrency

1. **DB dedup** — `EmailMessageService.exists(messageId)` at start of `handleEmail`.
2. **Poll mutex** — overlapping `pollInboundBatch` returns `{ skipped: true }`.
3. **Await handlers** — `pollBatch` waits for all `handleEmail` promises before disconnect.
4. **Webhook** — `uid === 0` skips IMAP `markAsSeen`.

## Cron vs IDLE

| Mode | When | Mechanism |
|------|------|-----------|
| **poll** (recommended) | k8s CronJob, serverless | Connect → fetch UNSEEN → process → disconnect |
| **start** | Dedicated Node pod | Persistent IMAP IDLE via `instrumentation.ts` |
| **HTTP start** | Debug only | `EMAIL_PROCESSOR_ALLOW_HTTP_START=true` + cron `action:start` |

## Security

- Cron/webhook: fail-closed `Bearer $CRON_SECRET` or HMAC `X-Email-Webhook-Signature`.
- Admin API: session + `isPlatformAdmin`.
- Inbound: 4-layer injection pipeline before any LLM call.
- Outbound: output validation on generated replies; CRM SMTP credentials required at send time.

## Schema-only tables (001 — not wired)

`001_email_crm_schema.sql` defines **`email_analytics`**, **`email_knowledge_base`**, and **`email_security_events`**. No TypeScript module reads or writes these tables. Use `009` + `010` JSONB migrations instead.

## Related documentation

  
- [features/email-ai-crm](/docs/features/email-ai-crm.md) — Next-step: operator checklist, channel secrets, and admin CRM shell.

  
- [api/email-ai-crm](/docs/api/email-ai-crm.md) — Deep-dive: channels status route and sourceChannel query contract.

  
- [features/ring-mailer](/docs/features/ring-mailer.md) — Depends-on: Auth SMTP plane stays on lib/mailer.ts.

  
- [architecture/backend-modes-and-databases](/docs/architecture/backend-modes-and-databases.md) — See-also: how DB_BACKEND_MODE selects the db() adapter.

  
- [features/owner-project-lab](/docs/features/owner-project-lab.md) — Same-workflow: orders desk under CrmAdminShell.
