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 Senior SRE

Reviewed by Nick Barraclough

VerifiedCLI 3.1.1 · Gmail, Outlook · last tested April 11, 2026

SalesInbox vs. CLI-based import

Zoho CRM's built-in SalesInbox email client organizes incoming mail by CRM context, but it processes emails only as they arrive and cannot backfill historical data. A CLI-based approach using Nylas CLI exports months of email history from any provider, maps senders to custom fields, and pushes records into Zoho CRM without per-user license upgrades -- handling imports that SalesInbox was not designed for.

SalesInbox is useful for daily email management, but it has specific limits for bulk 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 REST API v2 expects title-case field names like First_Name, Last_Name, and Company -- a naming convention unique to Zoho, since Salesforce uses camelCase and HubSpot uses snake_case. Exporting email and contact data with Nylas CLI, then reshaping the JSON with jq, produces records that match Zoho's Lead module schema in a single pipeline.

The export script pulls up to 500 emails and 500 contacts, then maps sender fields to Zoho's title-case format. Zoho's upsert endpoint accepts a maximum of 100 records per request, so large imports need batching. The jq pipeline extracts the first 3 senders as a quick verification before processing the full dataset.

# 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 all data into modules, where each module acts as a database table with typed fields and relationships. Understanding 5 core modules -- Leads, Contacts, Accounts, Deals, and Notes -- is necessary before importing email data, because each module has its own required fields, deduplication rules, and API endpoint. Getting the target module wrong means records end up in the wrong pipeline stage.

  • 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 CRM's SQL-like query interface for finding existing records before an import. Running a COQL query against Leads and Contacts before upserting 500 records typically reduces API calls by 40-60%, because existing records can be skipped or updated instead of inserted. COQL supports SELECT, WHERE, GROUP BY, ORDER BY, and aggregate functions like COUNT, and returns results in under 200ms for queries hitting fewer than 2,000 records.

The examples query Contacts by email address, filter Leads by creation date and source, and aggregate Lead counts by company domain. Each query uses Zoho's /crm/v2/coql endpoint with an OAuth bearer token. The LIMIT clause caps results at the API maximum of 2,000 records per request.

# 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"
  }'

For large imports, run COQL queries against both the Leads and Contacts modules to build a set of existing email addresses. Pass the results into a local lookup (a Python set or a jq index call) and skip any sender that already has a CRM record, sending only net-new records through the upsert endpoint.

API v2 upsert with duplicate detection

Zoho CRM's upsert endpoint at /crm/v2/Leads/upsert is the safest way to import email data because it checks a field you specify -- typically Email -- and either creates a new record or updates the existing one. The endpoint accepts up to 100 records per request, and Zoho's documentation confirms it counts as 1 API credit per record regardless of whether the action is an insert or update.

The Lead upsert script extracts unique senders from the exported email JSON, deduplicates by email address, and upserts them as Leads with duplicate_check_fields: ["Email"]. The Account upsert script creates Account records from unique sender domains, filtering out freemail providers like gmail.com and yahoo.com.

# 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 that runs server-side inside Zoho CRM as custom functions, workflow actions, and button scripts. Deluge functions execute in under 10 seconds per invocation (Zoho enforces a 10-second timeout) and can call up to 25 external APIs per execution. For email imports, Deluge handles business logic that standard Zoho workflows can't express -- like territory-based Lead assignment based on sender domain suffixes or automatic Lead scoring from email engagement metrics.

Common Deluge use cases for email import include:

  • 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

This Deluge function auto-assigns a Lead to a territory owner based on the sender's email domain suffix. It checks for UK and German top-level domains and routes them to the EMEA team. According to Zoho's developer documentation, custom functions are created in Setup → Developer Space → Functions and triggered by Workflow Rules that fire on record creation or update.

// 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 that defines and enforces allowed state transitions for records. When an email import creates or updates a Lead, Blueprint transitions fire automatically based on field values -- for example, setting Lead_Source to “Email” can trigger the first transition. Zoho supports up to 50 Blueprint transitions per module, each with mandatory fields, SLA timers, and Deluge scripts attached.

A typical email-to-Lead Blueprint has 4 stages:

  1. NewContacted: triggers when Lead_Source is set to “Email” (your import sets this)
  2. ContactedEngaged: triggers when a second Note is attached (your sync logs a follow-up email)
  3. EngagedQualified: requires a sales rep to manually qualify after reviewing email history
  4. QualifiedConverted: 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 that automates data movement between Zoho CRM and external services on a schedule. Unlike Zapier, which costs $150-450/month for comparable task volume, Zoho Flow is included at no additional cost for Zoho One subscribers ($45/employee/month). A scheduled Flow can trigger every hour, call a webhook that runs Nylas CLI on your server, and push the resulting email data into Zoho CRM with automatic failure notifications.

The architecture for scheduled email sync with Zoho Flow has 4 steps:

  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

Zoho Flow supports up to 20,000 tasks per month on the free tier and unlimited tasks on Zoho One plans. Each Flow run logs its status, execution time, and any errors, making it straightforward to monitor sync health without building custom alerting.

Zoho One ecosystem integration

Zoho One bundles 45+ integrated apps at $45/employee/month, and email data imported into Zoho CRM automatically flows to other apps in the suite without additional API configuration. Once a Lead or Contact record exists in CRM with email activity logged as Notes, Zoho Analytics, Zoho Campaigns, Zoho Desk, and Zia (Zoho's AI assistant) can all read and act on that data through Zoho's internal data-sharing layer.

  • 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 production-ready Python sync script ties together the individual steps covered in this guide: Zoho OAuth token refresh, COQL-based deduplication, batched upserts of up to 100 records per request, and email subject logging as CRM Notes. Zoho OAuth access tokens expire after 60 minutes, so the script refreshes the token at the start of every run using the /oauth/v2/token endpoint with a long-lived refresh token. The full script runs in under 30 seconds for a typical 500-email export.

#!/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.")

The script requires the requests library and 3 environment variables for Zoho OAuth credentials. You can generate a refresh token from the Zoho API Console at api-console.zoho.com by registering a Self Client application and requesting the ZohoCRM.modules.ALL scope.

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