Source: https://cli.nylas.com/guides/export-email-to-zoho-crm

Guide

# Export Email Data to Zoho CRM

Zoho CRM is part of the Zoho One ecosystem with 45+ integrated apps. This guide exports email data from any email provider using Nylas CLI and pushes it into Zoho CRM — covering the module system (Leads, Contacts, Accounts, Deals), COQL queries, Deluge scripting, Blueprint workflow triggers, and Zoho Flow automation.

Written by [Prem Keshari](https://cli.nylas.com/authors/prem-keshari) • Senior SRE

Reviewed by [Nick Barraclough](https://cli.nylas.com/authors/nick-barraclough)

Updated April 11, 2026

Verified

 —

CLI

3.1.1

 ·

Gmail, Outlook

 ·

last tested

April 11, 2026

> **TL;DR:** Use COQL to find existing records before import, reducing API calls by 40-60%. Export with `nylas email list --json` and `nylas contacts list --json`, then upsert Leads with `duplicate_check_fields: ["Email"]`. Blueprint transitions fire automatically on import.

> **Part of the CRM Email Workflows series.** This guide is one of 8 covering how to extract CRM intelligence from your inbox. Start here if your organization uses Zoho CRM or the Zoho One suite. [View the full series →](https://cli.nylas.com/guides/crm-email-workflows)

## SalesInbox vs. CLI-based import

Zoho CRM includes **SalesInbox**, a built-in email client that organizes your inbox by CRM context. Emails appear in columns based on whether the sender is a Deal contact, Lead, Contact, or unknown. It connects to Gmail, Outlook, and IMAP servers.

SalesInbox is useful for daily email management, but it has limits for data import:

- **No historical import**. SalesInbox processes emails as they arrive. You can't backfill 6 months of email history. According to Zoho's documentation, emails are synced “from the time the account is configured.”
- **Per-user setup**. Each team member connects their own inbox. There's no central admin import for the whole team's email data.
- **Available on Professional plans and above** ($35/user/month as of 2026). Free and Standard plans don't include SalesInbox.
- **No custom field mapping**. SalesInbox creates standard email associations but can't populate custom fields like thread count, response time, or CC patterns.

A CLI-based approach processes historical data, works with any provider Nylas supports, maps to any field including custom fields, and doesn't require per-user license upgrades.

## Reshape data for Zoho's module format

Zoho CRM's upsert endpoint expects title-case field names: `First_Name`, `Last_Name`, `Company`. This naming convention is unique to Zoho -- Salesforce uses camelCase, HubSpot uses snake_case. Export your data and reshape it before pushing.

File: `export.sh`

```bash
# Pull email history and contacts
nylas email list --json --limit 500 > emails.json
nylas contacts list --json --limit 500 > contacts.json

# Zoho-specific: reshape into Lead module format with Title_Case field names
cat emails.json | jq '[.[:3][] | .from[0] | select(.email) | {
  Email: .email,
  First_Name: (.name // "" | split(" ")[0]),
  Last_Name: (.name // "" | split(" ")[1:] | join(" ")),
  Company: (.email | split("@")[1] | split(".")[0]),
  Lead_Source: "Email"
}]'
```

## Zoho CRM module system

Zoho CRM organizes data into **modules**. Each module is a table with fields. The core modules for email import:

- **Leads** — unqualified prospects. Every new email sender you haven't qualified yet goes here. Leads have a `Lead_Status` field with values like “New”, “Contacted”, “Qualified”. When qualified, you convert a Lead into a Contact + Account + Deal in one operation.
- **Contacts** — qualified individuals. Linked to an Account. Contacts have standard fields (Email, Phone, Title) and support unlimited custom fields.
- **Accounts** — companies. One Account has many Contacts. Derived from the email domain (e.g., acme.com → “Acme”).
- **Deals** — opportunities with a stage, amount, and closing date. Linked to a Contact and Account.
- **Notes** — free-text records attached to any module record. Use these to log email content as activity history.

The Lead-to-Contact conversion is a key Zoho concept. When you call `POST /crm/v2/Leads/{id}/actions/convert`, Zoho creates a Contact, an Account, and optionally a Deal from the Lead data. Any Notes attached to the Lead automatically transfer to the new Contact.

## COQL queries for deduplication

COQL (CRM Object Query Language) is Zoho's SQL-like query language. It's faster than the search endpoint for finding existing records before import, and it supports JOINs and aggregate functions.

File: `coql-queries.sh`

```bash
# Find existing Contacts by email using COQL
curl -X POST "https://www.zohoapis.com/crm/v2/coql" \
  -H "Authorization: Zoho-oauthtoken $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "select_query": "SELECT id, Email, First_Name, Last_Name, Account_Name FROM Contacts WHERE Email = '\''sarah@acme.com'\'' LIMIT 1"
  }'

# Find all Leads created from email import in the last 7 days
curl -X POST "https://www.zohoapis.com/crm/v2/coql" \
  -H "Authorization: Zoho-oauthtoken $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "select_query": "SELECT id, Email, First_Name, Last_Name, Lead_Source, Created_Time FROM Leads WHERE Lead_Source = '\''Email'\'' AND Created_Time > '\''2026-03-19T00:00:00+00:00'\'' ORDER BY Created_Time DESC LIMIT 200"
  }'

# Count Leads by company domain (aggregate)
curl -X POST "https://www.zohoapis.com/crm/v2/coql" \
  -H "Authorization: Zoho-oauthtoken $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "select_query": "SELECT Company, COUNT(id) as lead_count FROM Leads WHERE Lead_Source = '\''Email'\'' GROUP BY Company ORDER BY lead_count DESC LIMIT 20"
  }'
```

COQL is particularly useful for pre-import deduplication. Before upserting 500 contacts, run a COQL query to find which emails already exist. Then only create the new ones, reducing API calls by 40-60% in a typical import.

## API v2 upsert with duplicate detection

Zoho CRM's upsert endpoint is the safest way to import data. It checks a field you specify (usually Email) and either creates or updates the record. The endpoint accepts up to 100 records per request.

File: `upsert.sh`

```bash
# Upsert Leads — deduplicate by Email
cat emails.json | jq '{
  data: [
    [.[] | .from[0] | select(.email != null)]
    | unique_by(.email)
    | .[:100][]
    | {
        Email: .email,
        First_Name: (.name // "" | split(" ")[0]),
        Last_Name: (.name // "" | split(" ")[1:] | join(" ") | if . == "" then "(Unknown)" else . end),
        Company: (.email | split("@")[1] | split(".")[0] | (.[:1] | ascii_upcase) + .[1:]),
        Lead_Source: "Email"
      }
  ],
  duplicate_check_fields: ["Email"]
}' | curl -X POST "https://www.zohoapis.com/crm/v2/Leads/upsert" \
  -H "Authorization: Zoho-oauthtoken $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d @- | jq '.data[] | {status, action, id: .details.id}'

# Upsert Accounts by Website
cat emails.json | jq '{
  data: [
    [.[] | .from[0].email | select(. != null) | split("@")[1]]
    | unique
    | map(select(. as $d | ["gmail.com","yahoo.com","outlook.com","hotmail.com"] | index($d) | not))
    | .[:100][]
    | {
        Account_Name: (split(".")[0] | (.[:1] | ascii_upcase) + .[1:]),
        Website: ("https://" + .)
      }
  ],
  duplicate_check_fields: ["Website"]
}' | curl -X POST "https://www.zohoapis.com/crm/v2/Accounts/upsert" \
  -H "Authorization: Zoho-oauthtoken $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d @-
```

## Deluge scripting for custom logic

Deluge is Zoho's proprietary scripting language. It runs inside Zoho CRM as custom functions, workflow actions, and button scripts. You can create a Deluge function that processes incoming email data and applies business logic Zoho's standard workflows can't handle.

For email import, Deluge is useful for:

- **Auto-assigning Leads** based on email domain territory rules (e.g.,.co.uk domains go to the EMEA team)
- **Scoring Leads** based on email engagement metrics (thread count, response time, CC count)
- **Converting Leads** automatically when engagement crosses a threshold
- **Creating related records** (Deals, Tasks, Notes) in a single transaction

File: `auto-assign-lead.dg`

```javascript
// Deluge custom function: auto-assign Lead by email domain
// Create in Setup > Developer Space > Functions > New Function
// Trigger via Workflow Rule on Lead creation

void autoAssignLead(int leadId)
{
    lead = zoho.crm.getRecordById("Leads", leadId);
    email = lead.get("Email");

    if (email == null)
    {
        return;
    }

    domain = email.getSuffix("@");

    // Territory-based assignment
    ukDomains = {".co.uk", ".uk", ".org.uk"};
    deDomains = {".de", ".at", ".ch"};

    emeaOwner = "3000000012345";   // EMEA rep Zoho user ID
    usOwner = "3000000012346";     // US rep Zoho user ID
    defaultOwner = "3000000012347";

    assignTo = defaultOwner;

    for each suffix in ukDomains
    {
        if (domain.endsWith(suffix))
        {
            assignTo = emeaOwner;
            break;
        }
    }

    for each suffix in deDomains
    {
        if (domain.endsWith(suffix))
        {
            assignTo = emeaOwner;
            break;
        }
    }

    // Update Lead owner
    updateMap = Map();
    updateMap.put("Owner", assignTo);
    updateMap.put("Lead_Status", "Contacted");

    zoho.crm.updateRecord("Leads", leadId, updateMap);

    // Create a Note with email context
    noteMap = Map();
    noteMap.put("Note_Title", "Auto-assigned from email import");
    noteMap.put("Note_Content", "Domain: " + domain + ". Assigned to territory owner.");
    noteMap.put("Parent_Id", leadId + "");
    noteMap.put("se_module", "Leads");

    zoho.crm.createRecord("Notes", noteMap);
}
```

## Blueprint workflows triggered by email data

Blueprint is Zoho CRM's process automation engine. It defines allowed state transitions for records and enforces them. When your email import creates or updates a Lead, Blueprint transitions can fire automatically.

A typical email-to-Lead Blueprint:

1. **New** → **Contacted**: triggers when Lead_Source is set to “Email” (your import sets this)
2. **Contacted** → **Engaged**: triggers when a second Note is attached (your sync logs a follow-up email)
3. **Engaged** → **Qualified**: requires a sales rep to manually qualify after reviewing email history
4. **Qualified** → **Converted**: auto-creates Contact + Account + Deal via the Lead conversion API

Blueprint transitions can include mandatory fields (e.g., the rep must enter a Deal amount before converting), SLA timers (e.g., new Leads must be contacted within 4 hours), and Deluge scripts (e.g., auto-score the Lead based on email thread count).

Configure Blueprints in Setup → Process Management → Blueprint. Select the Leads module, define states, and map transitions with conditions.

## Zoho Flow for scheduled sync

Zoho Flow is Zoho's integration platform (like Zapier, but free for Zoho One customers). You can create a Flow that triggers on a schedule, calls a webhook, and runs your sync script.

The architecture for scheduled email sync with Zoho Flow:

1. Create a Zoho Flow with a **Schedule** trigger (e.g., every hour)
2. Add a **Webhook** action that calls your server running Nylas CLI
3. Your server exports emails, transforms them, and calls the Zoho CRM API
4. Zoho Flow logs the run result and sends a notification on failure

For Zoho One subscribers, Zoho Flow is included at no additional cost. This makes it cheaper than Zapier ($150-450/month for comparable volume) and better integrated with Zoho CRM.

## Zoho One ecosystem integration

If your organization uses Zoho One ($45/employee/month for all 45+ Zoho apps), imported email data flows across the ecosystem:

- **Zoho Analytics** — build dashboards on email engagement metrics. Track response rates, thread counts, and pipeline velocity by source.
- **Zoho Campaigns** — imported Contacts sync to your email marketing lists. Segment by engagement level (high email volume = warm lead).
- **Zoho Desk** — if a Contact submits a support ticket, agents see the full email history from CRM in the ticket sidebar.
- **Zoho Projects** — create project tasks from CRM Deals. Email context from the Contact record is visible in the task.
- **Zia (Zoho AI)** — Zoho's AI assistant analyzes CRM data including imported email activity. It suggests the best time to contact a Lead based on their historical response patterns.

## Python sync with OAuth refresh

A complete Python script that handles Zoho OAuth token refresh, COQL-based deduplication, batched upserts, and email logging as Notes.

File: `sync_zoho.py`

```python
#!/usr/bin/env python3
"""Sync Nylas CLI email data to Zoho CRM via API v2."""

import json
import subprocess
import os
import sys
import requests

ZOHO_API = "https://www.zohoapis.com/crm/v2"
ZOHO_AUTH = "https://accounts.zoho.com/oauth/v2/token"
BATCH_SIZE = 100
FREEMAIL = {"gmail.com", "yahoo.com", "outlook.com", "hotmail.com",
            "aol.com", "icloud.com", "protonmail.com"}

# Refresh OAuth token
token_resp = requests.post(ZOHO_AUTH, data={
    "grant_type": "refresh_token",
    "client_id": os.environ["ZOHO_CLIENT_ID"],
    "client_secret": os.environ["ZOHO_CLIENT_SECRET"],
    "refresh_token": os.environ["ZOHO_REFRESH_TOKEN"],
})
token_resp.raise_for_status()
token = token_resp.json().get("access_token")
if not token:
    print(f"Token refresh failed: {token_resp.text}", file=sys.stderr)
    sys.exit(1)

headers = {
    "Authorization": f"Zoho-oauthtoken {token}",
    "Content-Type": "application/json",
}

# Export from Nylas CLI
emails = json.loads(subprocess.run(
    ["nylas", "email", "list", "--json", "--limit", "500"],
    capture_output=True, text=True, check=True,
).stdout)
contacts = json.loads(subprocess.run(
    ["nylas", "contacts", "list", "--json", "--limit", "500"],
    capture_output=True, text=True, check=True,
).stdout)
print(f"Exported {len(emails)} emails, {len(contacts)} contacts")

# Phase 1: Find existing records via COQL to minimize API calls
existing_emails: set[str] = set()
try:
    coql_resp = requests.post(f"{ZOHO_API}/coql", headers=headers, json={
        "select_query": "SELECT Email FROM Leads WHERE Lead_Source = 'Email' LIMIT 2000"
    })
    if coql_resp.ok:
        for rec in coql_resp.json().get("data", []):
            if rec.get("Email"):
                existing_emails.add(rec["Email"].lower())
    coql_resp2 = requests.post(f"{ZOHO_API}/coql", headers=headers, json={
        "select_query": "SELECT Email FROM Contacts LIMIT 2000"
    })
    if coql_resp2.ok:
        for rec in coql_resp2.json().get("data", []):
            if rec.get("Email"):
                existing_emails.add(rec["Email"].lower())
except Exception:
    pass  # Fall back to upsert dedup

print(f"Found {len(existing_emails)} existing records in Zoho")

# Phase 2: Build Lead records from email senders
seen: set[str] = set()
leads: list[dict] = []
for msg in emails:
    sender = (msg.get("from") or [{}])[0]
    addr = sender.get("email", "")
    if not addr or addr.lower() in seen:
        continue
    seen.add(addr.lower())

    domain = addr.split("@")[1] if "@" in addr else ""
    if domain in FREEMAIL:
        continue

    name = sender.get("name", "")
    parts = name.split(" ", 1) if name else ["", ""]
    company = domain.split(".")[0].title() if domain else ""

    leads.append({
        "Email": addr,
        "First_Name": parts[0],
        "Last_Name": parts[1] if len(parts) > 1 else "(Unknown)",
        "Company": company,
        "Lead_Source": "Email",
        "Description": f"Last email: {msg.get('subject', '(no subject)')}",
    })

print(f"{len(leads)} unique business leads to upsert")

# Phase 3: Batch upsert Leads
stats = {"created": 0, "updated": 0, "errors": 0}
for i in range(0, len(leads), BATCH_SIZE):
    batch = leads[i:i+BATCH_SIZE]
    resp = requests.post(f"{ZOHO_API}/Leads/upsert", headers=headers, json={
        "data": batch,
        "duplicate_check_fields": ["Email"],
    })
    if resp.ok:
        for item in resp.json().get("data", []):
            if item.get("status") == "success":
                if item.get("action") == "insert":
                    stats["created"] += 1
                else:
                    stats["updated"] += 1
            else:
                stats["errors"] += 1

print(f"Leads: {stats['created']} created, {stats['updated']} updated, {stats['errors']} errors")

# Phase 4: Upsert Accounts from unique domains
accounts: list[dict] = []
domains_seen: set[str] = set()
for lead in leads:
    domain = lead["Email"].split("@")[1] if "@" in lead["Email"] else ""
    if domain and domain not in domains_seen and domain not in FREEMAIL:
        domains_seen.add(domain)
        accounts.append({
            "Account_Name": domain.split(".")[0].title(),
            "Website": f"https://{domain}",
        })

if accounts:
    for i in range(0, len(accounts), BATCH_SIZE):
        batch = accounts[i:i+BATCH_SIZE]
        requests.post(f"{ZOHO_API}/Accounts/upsert", headers=headers, json={
            "data": batch,
            "duplicate_check_fields": ["Website"],
        })
    print(f"Upserted {len(accounts)} accounts")

# Phase 5: Log email subjects as Notes on matching Leads
# Get Lead IDs by email
lead_ids: dict[str, str] = {}
coql_resp = requests.post(f"{ZOHO_API}/coql", headers=headers, json={
    "select_query": "SELECT id, Email FROM Leads WHERE Lead_Source = 'Email' LIMIT 2000"
})
if coql_resp.ok:
    for rec in coql_resp.json().get("data", []):
        if rec.get("Email"):
            lead_ids[rec["Email"].lower()] = str(rec["id"])

note_count = 0
for msg in emails[:200]:
    sender = (msg.get("from") or [{}])[0]
    addr = sender.get("email", "")
    lead_id = lead_ids.get(addr.lower())
    if not lead_id:
        continue

    note = {
        "Note_Title": f"Email: {msg.get('subject', '(no subject)')}"[:100],
        "Note_Content": (
            f"From: {addr}\n"
            f"Date: {msg.get('date', '')}\n"
            f"Subject: {msg.get('subject', '')}"
        ),
        "Parent_Id": lead_id,
        "se_module": "Leads",
    }
    resp = requests.post(f"{ZOHO_API}/Notes", headers=headers, json={"data": [note]})
    if resp.ok:
        note_count += 1

print(f"Logged {note_count} email notes")
print("Sync complete.")
```

Install and run:

File: `run.sh`

```bash
pip install requests

export ZOHO_CLIENT_ID="your_client_id"
export ZOHO_CLIENT_SECRET="your_client_secret"
export ZOHO_REFRESH_TOKEN="your_refresh_token"

python3 sync_zoho.py
```

## Next steps

- [Export email data to Salesforce](https://cli.nylas.com/guides/export-email-to-salesforce) — Bulk API 2.0, SOQL queries, and Apex triggers
- [Export email data to HubSpot](https://cli.nylas.com/guides/export-email-to-hubspot) — batch endpoints, auto-company creation, and timeline events
- [Enrich contact and company info from email](https://cli.nylas.com/guides/enrich-contacts-from-email) — extract job titles and phone numbers for richer Zoho Lead records
- [Organize emails by company and domain](https://cli.nylas.com/guides/organize-emails-by-company) — group your inbox by sender domain before importing
- [Personalize outbound email from the CLI](https://cli.nylas.com/guides/personalize-outbound-email-cli) — use Zoho CRM data for personalized follow-ups
- [CRM Email Workflows](https://cli.nylas.com/guides/crm-email-workflows) — the full series hub with all 8 guides
- [Command reference](https://cli.nylas.com/docs/commands) — every flag, subcommand, and example
