Guide
Export Email Data to Salesforce
Salesforce captures relationships, but only when someone logs them. This guide exports email data from Gmail, Outlook, Exchange, Yahoo, iCloud, and IMAP using Nylas CLI and pushes it into Salesforce — covering SOQL upserts, Bulk API 2.0 for large imports, governor limit strategies, and Apex triggers that fire on Task creation.
By Caleb Geene
Einstein Activity Capture vs. CLI-based sync
Salesforce's built-in option is Einstein Activity Capture (EAC). It connects to Gmail or Outlook and auto-logs emails. Sounds perfect, but there's a catch that trips up most teams.
EAC stores captured emails in a separate data store outside standard Salesforce objects. According to Salesforce's own documentation, these activity records “are not the same as standard Task or Event records.” That means:
- EAC emails don't appear in SOQL queries or Salesforce reports
- Apex triggers don't fire when EAC logs an email
- Workflow rules and Process Builder can't reference them
- Data exports and backups won't include them
- EAC requires Sales Cloud Einstein licenses at $75/user/month
A CLI-based sync creates standard Task records. They show up in reports, trigger Apex code, and participate in every automation Salesforce offers. For a 50-person sales team, skipping EAC licenses also saves $45,000/year.
Export from Nylas CLI
Start by pulling email and contact data into local JSON files. The --json flag returns structured output that maps directly to Salesforce's Contact, Account, and Task fields.
# Export recent emails and contacts as JSON
nylas email list --json --limit 500 > emails.json
nylas contacts list --json --limit 500 > contacts.json
# Preview how fields map to Salesforce objects
cat contacts.json | jq '.[0] | {
SF_FirstName: .given_name,
SF_LastName: .surname,
SF_Email: .emails[0].email,
SF_Phone: .phone_numbers[0].number,
SF_Title: .job_title
}'Salesforce object relationships
Salesforce's data model has more nuance than Lead/Contact/Account. Understanding the junction objects is the difference between a clean import and orphaned records.
AccountContactRelation is a junction object that lets one Contact belong to multiple Accounts. Before Winter '22, Contacts had a single AccountId lookup. Now you can model the reality that a VP of Engineering at Acme might also sit on the board of three other companies. When importing email contacts, create AccountContactRelation records for every domain the contact emails from.
OpportunityContactRole links Contacts to Opportunities with a role: Decision Maker, Evaluator, Economic Buyer, etc. When you log an email as a Task linked to an Opportunity, the Contact's role on that Opportunity determines how sales managers interpret the engagement. If the Decision Maker went silent for 2 weeks, that's a different signal than if the Evaluator did.
# SOQL: Find all Opportunities for a Contact
SELECT OpportunityId, Role, IsPrimary
FROM OpportunityContactRole
WHERE ContactId = '003xx000004TmiQAAS'
# SOQL: Find all Accounts a Contact is related to
SELECT AccountId, Account.Name, Roles, IsActive
FROM AccountContactRelation
WHERE ContactId = '003xx000004TmiQAAS'
# SOQL: Find Contacts who haven't been emailed in 30 days
SELECT Id, Name, Email, Account.Name,
(SELECT Subject, ActivityDate FROM Tasks
WHERE Type = 'Email' ORDER BY ActivityDate DESC LIMIT 1)
FROM Contact
WHERE Id NOT IN (
SELECT WhoId FROM Task
WHERE Type = 'Email' AND ActivityDate > LAST_N_DAYS:30
)
AND AccountId IN (
SELECT AccountId FROM Opportunity WHERE IsClosed = false
)Governor limits and Bulk API 2.0
Salesforce enforces strict governor limits. The key ones for email import:
- 10,000 DML operations per synchronous transaction
- 100 SOQL queries per synchronous transaction
- 6 MB heap size for Apex triggers fired by your inserts
- 100,000 records/day for standard REST API calls
For imports under 200 records, the standard REST API works fine. For anything larger, use Bulk API 2.0. It processes records asynchronously in server-side batches and doesn't count against synchronous governor limits. According to Salesforce documentation, Bulk API 2.0 can handle up to 150 million records per 24-hour rolling period.
# Step 1: Create a Bulk API 2.0 job
JOB_ID=$(curl -s -X POST "$SF_INSTANCE/services/data/v59.0/jobs/ingest" \
-H "Authorization: Bearer $SF_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"object": "Contact",
"operation": "upsert",
"externalIdFieldName": "Email",
"contentType": "CSV",
"lineEnding": "LF"
}' | jq -r '.id')
echo "Created bulk job: $JOB_ID"
# Step 2: Upload CSV data
curl -s -X PUT "$SF_INSTANCE/services/data/v59.0/jobs/ingest/$JOB_ID/batches" \
-H "Authorization: Bearer $SF_TOKEN" \
-H "Content-Type: text/csv" \
--data-binary @salesforce_contacts.csv
# Step 3: Close the job to start processing
curl -s -X PATCH "$SF_INSTANCE/services/data/v59.0/jobs/ingest/$JOB_ID" \
-H "Authorization: Bearer $SF_TOKEN" \
-H "Content-Type: application/json" \
-d '{"state": "UploadComplete"}'
# Step 4: Poll for completion
while true; do
STATE=$(curl -s "$SF_INSTANCE/services/data/v59.0/jobs/ingest/$JOB_ID" \
-H "Authorization: Bearer $SF_TOKEN" | jq -r '.state')
echo "Job state: $STATE"
[[ "$STATE" == "JobComplete" || "$STATE" == "Failed" ]] && break
sleep 5
done
# Step 5: Check results
curl -s "$SF_INSTANCE/services/data/v59.0/jobs/ingest/$JOB_ID" \
-H "Authorization: Bearer $SF_TOKEN" \
| jq '{numberRecordsProcessed, numberRecordsFailed}'Generate the CSV from Nylas CLI output with jq:
# Transform contacts.json into Salesforce Bulk API CSV
cat contacts.json | jq -r '
["FirstName","LastName","Email","Phone","Title"],
(.[] | [
(.given_name // ""),
(.surname // "Unknown"),
((.emails // [])[0].email // ""),
((.phone_numbers // [])[0].number // ""),
(.job_title // "")
])
| @csv' > salesforce_contacts.csvComposite requests for small batches
When you're importing under 200 records and want to create Contacts, link them to Accounts, and log Tasks in a single API call, use Salesforce's Composite API. It batches up to 25 subrequests in one HTTP round-trip, and subrequests can reference each other.
# Composite request: create Account + Contact + Task in one call
curl -s -X POST "$SF_INSTANCE/services/data/v59.0/composite" \
-H "Authorization: Bearer $SF_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"allOrNone": true,
"compositeRequest": [
{
"method": "POST",
"url": "/services/data/v59.0/sobjects/Account",
"referenceId": "newAccount",
"body": {
"Name": "Acme Corp",
"Website": "https://acme.com"
}
},
{
"method": "POST",
"url": "/services/data/v59.0/sobjects/Contact",
"referenceId": "newContact",
"body": {
"FirstName": "Sarah",
"LastName": "Chen",
"Email": "sarah@acme.com",
"AccountId": "@{newAccount.id}"
}
},
{
"method": "POST",
"url": "/services/data/v59.0/sobjects/Task",
"referenceId": "newTask",
"body": {
"WhoId": "@{newContact.id}",
"WhatId": "@{newAccount.id}",
"Subject": "Re: Q3 proposal follow-up",
"ActivityDate": "2026-03-10",
"Status": "Completed",
"Type": "Email"
}
}
]
}'The @{newAccount.id} syntax references the ID returned by an earlier subrequest. The allOrNone: true flag rolls back everything if any subrequest fails, preventing orphaned records.
Apex triggers on Task creation
When your sync creates Task records, Apex triggers fire automatically. This is one of the big advantages over Einstein Activity Capture. You can build automations that respond to email activity in real time.
Here's an Apex trigger that updates a custom “Last Email Date” field on the Contact whenever an email Task is logged:
// Apex trigger: update Contact.Last_Email_Date__c when email Task is created
// Deploy via sfdx: sfdx force:source:push
trigger UpdateLastEmailDate on Task (after insert) {
Set<Id> contactIds = new Set<Id>();
for (Task t : Trigger.new) {
// Only process email-type Tasks linked to a Contact
if (t.Type == 'Email' && t.WhoId != null
&& String.valueOf(t.WhoId).startsWith('003')) {
contactIds.add(t.WhoId);
}
}
if (contactIds.isEmpty()) return;
// Batch the update to stay within governor limits
List<Contact> toUpdate = new List<Contact>();
for (Id cid : contactIds) {
toUpdate.add(new Contact(
Id = cid,
Last_Email_Date__c = Date.today()
));
}
// This single DML counts as 1 operation regardless of list size
update toUpdate;
}More trigger ideas that fire on email Task creation:
- Auto-update Opportunity stage if a Contact with the Decision Maker role on an Opportunity receives an email after 14+ days of silence
- Create a follow-up Task if no reply is logged within 3 business days of an outbound email
- Update Account health score based on email frequency across all Contacts at the Account
- Send Slack alert via Salesforce outbound message when a dormant Account gets re-engaged
sfdx CLI interop
If you already use the Salesforce CLI (sfdx), you can pipe Nylas CLI output directly into sfdx for record creation without writing a separate script. The sfdx force:data:record:create command accepts field-value pairs.
# Create a Contact from Nylas CLI output using sfdx
CONTACT=$(nylas contacts list --json --limit 1 | jq '.[0]')
sfdx force:data:record:create \
--sobject Contact \
--values "FirstName='$(echo $CONTACT | jq -r '.given_name')' \
LastName='$(echo $CONTACT | jq -r '.surname // "Unknown"')' \
Email='$(echo $CONTACT | jq -r '.emails[0].email')'"
# Bulk upsert via sfdx using CSV
sfdx force:data:bulk:upsert \
--sobject Contact \
--csvfile salesforce_contacts.csv \
--externalid Email \
--wait 10
# Query to verify import
sfdx force:data:soql:query \
--query "SELECT Id, Name, Email, CreatedDate
FROM Contact
WHERE CreatedDate = TODAY
ORDER BY CreatedDate DESC
LIMIT 20"Python sync with simple_salesforce
This script uses Bulk API 2.0 for contacts (handling governor limits) and standard REST for Tasks. It resolves AccountContactRelation and OpportunityContactRole before logging activities.
#!/usr/bin/env python3
"""Sync Nylas CLI email data to Salesforce via Bulk API 2.0."""
import json
import subprocess
import os
from simple_salesforce import Salesforce
sf = Salesforce(
username=os.environ["SF_USERNAME"],
password=os.environ["SF_PASSWORD"],
security_token=os.environ["SF_SECURITY_TOKEN"],
)
# 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: Bulk upsert Contacts
contact_records = []
for c in contacts:
email_list = c.get("emails", [])
if not email_list:
continue
contact_records.append({
"Email": email_list[0]["email"],
"FirstName": c.get("given_name", ""),
"LastName": c.get("surname", "") or "Unknown",
"Phone": (c.get("phone_numbers") or [{}])[0].get("number", ""),
"Title": c.get("job_title", ""),
})
# Salesforce bulk upsert — bypasses synchronous governor limits
if contact_records:
result = sf.bulk.Contact.upsert(contact_records, "Email")
success = sum(1 for r in result if r.get("success"))
print(f"Bulk upserted {success}/{len(contact_records)} contacts")
# Phase 2: Build email-to-ContactId map via SOQL
email_addrs = [r["Email"] for r in contact_records if r["Email"]]
contact_map: dict[str, str] = {}
# SOQL IN clause has 4000-char limit — batch queries
for i in range(0, len(email_addrs), 50):
batch = email_addrs[i:i+50]
quoted = ",".join(f"'{e}'" for e in batch)
results = sf.query(
f"SELECT Id, Email FROM Contact WHERE Email IN ({quoted})"
)
for rec in results["records"]:
contact_map[rec["Email"]] = rec["Id"]
print(f"Mapped {len(contact_map)} contacts by email")
# Phase 3: Log emails as Tasks with WhoId + WhatId
# Find open Opportunities for Contacts
opp_map: dict[str, str] = {}
if contact_map:
cids = ",".join(f"'{v}'" for v in contact_map.values())
ocr_results = sf.query(
f"""SELECT ContactId, OpportunityId
FROM OpportunityContactRole
WHERE ContactId IN ({cids})
AND Opportunity.IsClosed = false"""
)
for rec in ocr_results["records"]:
opp_map[rec["ContactId"]] = rec["OpportunityId"]
task_count = 0
for msg in emails:
sender_email = (msg.get("from") or [{}])[0].get("email", "")
contact_id = contact_map.get(sender_email)
if not contact_id:
continue
task_data = {
"WhoId": contact_id,
"Subject": (msg.get("subject") or "No subject")[:255],
"ActivityDate": msg.get("date", "").split("T")[0],
"Status": "Completed",
"Type": "Email",
"Description": (msg.get("body") or "")[:500],
}
# Link to Opportunity if Contact has one
opp_id = opp_map.get(contact_id)
if opp_id:
task_data["WhatId"] = opp_id
sf.Task.create(task_data)
task_count += 1
print(f"Logged {task_count} email Tasks")
print(f" {len(opp_map)} linked to Opportunities")Install and run:
pip install simple-salesforce
export SF_USERNAME="you@company.com"
export SF_PASSWORD="your-password"
export SF_SECURITY_TOKEN="your-security-token"
python3 sync_salesforce.pyData Cloud integration
Salesforce Data Cloud (formerly Customer Data Platform) unifies data from multiple sources into a single customer profile. If your org has Data Cloud enabled, email data imported via CLI feeds into the unified profile automatically when it hits the Contact object.
The flow: Nylas CLI exports email → Bulk API creates Task records → Data Cloud ingests Task + Contact data → unified profile shows email engagement alongside web visits, support tickets, and product usage. Data Cloud processes ingestion within 15 minutes of record creation, according to Salesforce's Data Cloud documentation.
For direct Data Cloud ingestion (bypassing standard objects), use the Ingestion API:
# Data Cloud Ingestion API — stream email events directly
curl -s -X POST "$SF_INSTANCE/api/v1/ingest/sources/<connector-id>/<object-name>" \
-H "Authorization: Bearer $SF_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"data": [
{
"email_address": "sarah@acme.com",
"event_type": "email_received",
"subject": "Re: Q3 proposal",
"timestamp": "2026-03-12T14:30:00Z",
"thread_length": 5,
"reply_time_minutes": 45
}
]
}'Next steps
- Export email data to HubSpot — the same workflow targeting HubSpot's API v3 batch endpoints and timeline events
- Organize emails by company and domain — group your inbox by sender domain before importing to Salesforce
- Enrich contacts from email signatures — extract job titles and phone numbers to fill in Salesforce fields
- Build contact hierarchy from email — infer reporting lines for Account planning
- CRM Email Workflows series — all 8 guides for extracting CRM intelligence from your inbox
- Full command reference — every Nylas CLI flag and subcommand documented