# Export email data to Salesforce

Source: https://developer.nylas.com/docs/cookbook/use-cases/sync/export-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.

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

| 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

```python


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": "ada@acme.test",
        "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

```python


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

## Resolving Accounts

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.

## 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.context` check that exempts your bulk-load user from synchronous logic, and run the post-load workflow asynchronously.

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

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

## Next steps

- [Sync email contacts to a CRM](/docs/cookbook/use-cases/sync/sync-email-crm/) — the generic pipeline
- [Export to HubSpot](/docs/cookbook/use-cases/sync/export-to-hubspot/)
- [Export to Pipedrive](/docs/cookbook/use-cases/sync/export-to-pipedrive/)