Skip to content
Skip to main content

Export email data to Salesforce

Salesforce’s three-object data model is unusually email-friendly: Contact is a person, Account is a company, Task is an activity. An email naturally is a Task linked via WhoId (the contact) and WhatId (the account). This recipe maps email exported from the Nylas CLI onto that triplet, with the API choice driven by batch size.

The full pipeline runs as a scheduled job and produces standard Tasks that show up in dashboards, reports, automation, and Apex triggers — unlike Einstein Activity Capture, which buries email in a separate store and gates it behind a $75/user/month seat.

Email fieldSalesforce objectField
Sender addressContact.Emailupsert key
Sender nameContact.FirstName / LastNameparsed
Sender domainAccount.Nameresolved or created
SubjectTask.Subject
DateTask.ActivityDate
Body snippetTask.Descriptionfirst 32K chars
DirectionTask.Status”Completed”
Contact linkTask.WhoIdpopulated after Contact upsert
Account linkTask.WhatIdpopulated after Account resolve

Two helpful junction objects to remember:

  • AccountContactRelation — one Contact can belong to multiple Accounts. Critical when a person leaves Acme to join Globex; both relationships stay queryable.
  • OpportunityContactRole — links Contacts to Opportunities with a role (Decision Maker, Evaluator, Influencer). Lets sales interpret email engagement contextually.
Batch sizeAPIWhy
< 200 recordsComposite APIOne HTTP call, up to 25 subrequests with inter-request @{ref.field} referencing
200 – 10,000 recordsREST APIStandard sobjects endpoint; respects governor limits
> 10,000 recordsBulk API 2.0Asynchronous; up to 150M records / 24h; bypasses synchronous governor limits

For weekly syncs of a sales team’s email, Composite covers most of it. For initial backfills (a year of mail), Bulk 2.0 is the only option.

import requests, json
session = requests.Session()
session.headers["Authorization"] = f"Bearer {SF_TOKEN}"
url = f"{SF_INSTANCE}/services/data/v60.0/composite"
payload = {
"compositeRequest": [
{
"method": "POST",
"url": "/services/data/v60.0/sobjects/Account/",
"referenceId": "newAccount",
"body": {"Name": "Acme Corp"}
},
{
"method": "POST",
"url": "/services/data/v60.0/sobjects/Contact/",
"referenceId": "newContact",
"body": {
"FirstName": "Ada",
"LastName": "Lovelace",
"Email": "[email protected]",
"AccountId": "@{newAccount.id}"
}
},
{
"method": "POST",
"url": "/services/data/v60.0/sobjects/Task/",
"referenceId": "newTask",
"body": {
"Subject": "Re: Q2 plan",
"ActivityDate": "2026-04-22",
"Status": "Completed",
"WhoId": "@{newContact.id}",
"WhatId": "@{newAccount.id}"
}
}
]
}
r = session.post(url, json=payload)
r.raise_for_status()

The @{ref.id} syntax lets the second and third subrequests reference IDs created by the first — one round trip handles the whole Account → Contact → Task cascade.

import csv, requests
# Step 1: create the job
job = session.post(
f"{SF_INSTANCE}/services/data/v60.0/jobs/ingest",
json={"object":"Task", "operation":"insert", "contentType":"CSV"},
).json()
# Step 2: upload the CSV
with open("tasks.csv", "rb") as f:
session.put(
f"{SF_INSTANCE}/services/data/v60.0/jobs/ingest/{job['id']}/batches",
data=f, headers={"Content-Type": "text/csv"},
)
# Step 3: close (and start processing)
session.patch(
f"{SF_INSTANCE}/services/data/v60.0/jobs/ingest/{job['id']}",
json={"state": "UploadComplete"},
)
# Step 4: poll for completion
while True:
j = session.get(f"{SF_INSTANCE}/services/data/v60.0/jobs/ingest/{job['id']}").json()
if j["state"] in ("JobComplete", "Failed", "Aborted"):
break
time.sleep(10)

CSV upload is dramatically faster than REST for thousands of records, and the operation runs server-side so your script doesn’t sit on a 30-minute HTTP connection.

Two strategies for matching emails to existing Accounts:

  1. Domain match. SOQL: SELECT Id FROM Account WHERE Website LIKE '%acme.com%'. Cheap; works when the Account record actually has a Website.
  2. Name match. SOQL: SELECT Id FROM Account WHERE Name = 'Acme Corp'. Also cheap, also flaky — “Acme Corp” vs “Acme Corporation” trip up exact match.

For production, do both with domain first and a name fallback, and accept that some emails won’t link to an Account at all (free-mail senders most obviously). The Task can still create with WhatId null — it just won’t roll up to an Account dashboard.

Bulk Task creation fires Apex triggers on every record. Two things help:

  • Use Bulk 2.0 for any batch > 200 — it processes server-side and is friendlier to the trigger framework.
  • If your org has heavy logic on Task triggers, deploy a Trigger.context check that exempts your bulk-load user from synchronous logic, and run the post-load workflow asynchronously.

Einstein Activity Capture (EAC) is Salesforce’s first-party email sync. It’s licensed at $75/user/month and stores email in a “supplemental” object that isn’t queryable from SOQL, doesn’t fire triggers, and doesn’t appear in standard reports. For most teams that’s a regression. CLI-based sync produces standard Tasks — full SOQL, full reports, full automation — and the cost is the LLM bill plus your API license.

  • Daily REST limits. Standard REST API caps at 100,000 calls and 10,000 DML operations per 24h. A weekly sync of 5,000 emails uses ~50 composite calls — well within limits.
  • Field-level security. The connected app needs CRUD on Contact, Account, Task. Don’t run as an admin user; create a dedicated integration user with the minimum profile.
  • Duplicate management. Salesforce’s duplicate rules will block contact creation if a fuzzy match exists. Either run with Sforce-Duplicate-Rule-Header: allowSave=true (allow create), or upsert on Email to merge.