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 Gmail, Outlook, Exchange, Yahoo, iCloud, and IMAP 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.

By Prem Keshari

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.

Export from Nylas CLI

Pull email and contact data into local JSON, then reshape it into Zoho CRM's module format. Zoho's upsert endpoint expects fields like First_Name, Last_Name, and Company with title-case naming.

# Export recent emails and contacts as JSON
nylas email list --json --limit 500 > emails.json
nylas contacts list --json --limit 500 > contacts.json

# Preview data in Zoho Lead module format
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.

# 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.

# 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
// 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. 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 (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.

#!/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:

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