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.
The mapping
Section titled “The mapping”| Email field | Salesforce object | Field |
|---|---|---|
| Sender address | Contact.Email | upsert key |
| Sender name | Contact.FirstName / LastName | parsed |
| Sender domain | Account.Name | resolved or created |
| Subject | Task.Subject | |
| Date | Task.ActivityDate | |
| Body snippet | Task.Description | first 32K chars |
| Direction | Task.Status | ”Completed” |
| Contact link | Task.WhoId | populated after Contact upsert |
| Account link | Task.WhatId | populated 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.
Pick the API
Section titled “Pick the API”| Batch size | API | Why |
|---|---|---|
| < 200 records | Composite API | One HTTP call, up to 25 subrequests with inter-request @{ref.field} referencing |
| 200 – 10,000 records | REST API | Standard sobjects endpoint; respects governor limits |
| > 10,000 records | Bulk API 2.0 | Asynchronous; 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.
Composite API: small batch
Section titled “Composite API: small batch”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", "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.
Bulk API 2.0: backfill
Section titled “Bulk API 2.0: backfill”import csv, requests
# Step 1: create the jobjob = session.post( f"{SF_INSTANCE}/services/data/v60.0/jobs/ingest", json={"object":"Task", "operation":"insert", "contentType":"CSV"},).json()
# Step 2: upload the CSVwith 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 completionwhile 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.
Resolving Accounts
Section titled “Resolving Accounts”Two strategies for matching emails to existing Accounts:
- Domain match. SOQL:
SELECT Id FROM Account WHERE Website LIKE '%acme.com%'. Cheap; works when the Account record actually has a Website. - 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.
Apex trigger considerations
Section titled “Apex trigger considerations”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.contextcheck that exempts your bulk-load user from synchronous logic, and run the post-load workflow asynchronously.
Why not Einstein Activity Capture?
Section titled “Why not Einstein Activity Capture?”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.
Things to know
Section titled “Things to know”- 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 onEmailto merge.
Next steps
Section titled “Next steps”- Sync email contacts to a CRM — the generic pipeline
- Export to HubSpot
- Export to Pipedrive