Guide
Export Email Data to Dynamics 365
Dynamics 365 Sales runs on Dataverse (formerly Common Data Service) and integrates deeply with Azure AD, Power Automate, and Power BI. This guide exports email data from any email provider using Nylas CLI and pushes it into Dynamics 365 via the OData Web API — covering Azure AD authentication, entity relationships, activityparty records, and Power Automate scheduling.
Written by Hazik Director of Product Management
Reviewed by Hazik
Server-side sync vs. CLI-based import
Dynamics 365 has a built-in email sync called server-side synchronization. It connects directly to Exchange Online or Exchange on-prem and automatically tracks emails as activities. According to Microsoft's documentation, server-side sync “processes emails, appointments, contacts, and tasks” between Dynamics and Exchange.
Server-side sync works well for Microsoft-only environments. But it has hard limits:
- It only connects to Exchange. Gmail, Yahoo, iCloud, and third-party IMAP servers aren't supported. According to Microsoft, “Server-side synchronization or the Email Router can process email from POP3/SMTP systems,” but POP3 support was deprecated in 2020.
- It processes emails per-mailbox. There's no bulk historical import. If you have 18 months of email history you want in Dynamics, server-side sync won't backfill it.
- It requires Exchange admin configuration. Setting up server-side sync for Exchange on-prem involves configuring the Email Server Profile, testing connectivity, and approving mailboxes. That's an Exchange admin task, not a CRM admin task.
The other built-in option is Dynamics 365 App for Outlook, a browser sidebar that lets users manually track emails. It depends on user behavior, which means adoption rates of 20-30% in practice.
A CLI-based approach works with any email provider, processes historical data in bulk, and runs unattended on a schedule.
Map fields to Dataverse entity columns
Dataverse field names differ from every other CRM and from standard email data formats. Contacts use numbered suffixes like emailaddress1 instead of email, and telephone1 instead of phone. Mapping Nylas CLI JSON output to these Dataverse-specific column names is the first step before pushing records through the OData v4 API.
Dataverse supports up to 3 email fields per contact (emailaddress1 through emailaddress3) and up to 3 phone fields (telephone1 through telephone3). The Nylas CLI exports contacts as JSON arrays with nested emails and phone_numbers arrays, so a jq transform maps position 0 to the primary Dataverse field. Running this preview before any API call prevents silent data loss from mismatched keys.
# Pull email history and contacts
nylas email list --json --limit 500 > emails.json
nylas contacts list --json --limit 500 > contacts.json
# Dynamics-specific: map to Dataverse entity columns
# Note the numbered field names (emailaddress1, telephone1) unique to Dynamics
cat contacts.json | jq '.[0] | {
"contact.firstname": .given_name,
"contact.lastname": .surname,
"contact.emailaddress1": .emails[0].email,
"contact.telephone1": .phone_numbers[0].number,
"contact.jobtitle": .job_title
}'Azure AD (Entra ID) authentication
Every Dynamics 365 API call requires an OAuth 2.0 bearer token issued by Azure AD, now called Microsoft Entra ID. The token endpoint lives at login.microsoftonline.com and tokens expire after 3,600 seconds (1 hour) by default. For unattended CLI syncs, the client credentials grant avoids any browser-based login and issues tokens directly to the registered application.
Setting up the Azure AD app registration takes 4 steps:
- Go to Azure Portal → App registrations → New registration
- Add API permission: Dynamics CRM →
user_impersonation - Create a client secret (or use certificate auth for production — Microsoft recommends certificates over secrets for any app handling more than 10,000 requests per day)
- Note the Application (client) ID, Directory (tenant) ID, and the secret value
For server-to-server sync, the client credentials flow requires an Application User in Dynamics 365 mapped to a security role with Create, Read, and Write permissions on the contact, account, and email entities. According to Microsoft's Entra ID documentation, client credential tokens are cached by MSAL and refreshed automatically 5 minutes before expiration, so long-running scripts don't need manual token renewal.
# Get an OAuth 2.0 token using client credentials
TOKEN=$(curl -s -X POST \
"https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "scope=https://$ORG.crm.dynamics.com/.default" \
-d "grant_type=client_credentials" | jq -r '.access_token')
# Verify access — list first 3 contacts
curl -s "https://$ORG.api.crm.dynamics.com/api/data/v9.2/contacts?\$top=3&\$select=fullname,emailaddress1" \
-H "Authorization: Bearer $TOKEN" \
-H "OData-MaxVersion: 4.0" \
-H "OData-Version: 4.0" | jq '.value[] | {fullname, emailaddress1}'Dataverse entity model
Dynamics 365 Sales stores all records in Dataverse, formerly called Common Data Service. The entity model for email import involves 4 core tables (contact, account, email, opportunity) and 1 junction record type (activityparty). Dataverse exposes over 400 standard entities, but email sync only touches these 5. Understanding how they relate prevents orphaned records and broken timeline views.
| Entity | API path | Key fields | Purpose |
|---|---|---|---|
| contact | /contacts | firstname, lastname, emailaddress1, telephone1 | Individual people |
| account | /accounts | name, websiteurl, telephone1 | Companies |
/emails | subject, description, actualstart, directioncode | Email activity records | |
| opportunity | /opportunities | name, estimatedvalue, estimatedclosedate | Deals / pipeline |
| activityparty | (inline in email) | partyid, participationtypemask | From/To/CC/BCC on emails |
The parentcustomerid polymorphic lookup links contacts to accounts. The regardingobjectid polymorphic lookup links email activities to opportunities, accounts, or other entities.
activityparty records represent email participants. The participationtypemask field identifies the role: 1 = From, 2 = To, 3 = CC, 4 = BCC. Each email activity has an email_activity_parties collection containing one or more activityparty records. This is unique to Dynamics 365 and doesn't exist in Salesforce or HubSpot.
Dynamics 365 Sales vs. Customer Service
Dynamics 365 Sales and Dynamics 365 Customer Service share the same Dataverse tables but route email activities to different parent entities. Sales links emails to opportunities via regardingobjectid_opportunity, while Customer Service links them to cases (incidents) via regardingobjectid_incident. According to Microsoft's 2025 release wave documentation, over 90% of Dynamics 365 deployments run one or both of these apps. The API calls are identical between the two — only the regardingobjectid binding changes.
- Sales uses opportunities, leads, and quotes. Email activities link to opportunities via
regardingobjectid_opportunity. The timeline view on an Opportunity shows all associated emails. - Customer Service uses cases (incidents) and queues. Email activities link to cases via
regardingobjectid_incident. Incoming emails can auto-create cases through routing rules.
When building the sync script, set the regardingobjectid binding based on which Dynamics 365 app the organization runs. A single script can support both by accepting a target entity type as a configuration parameter.
Dataverse Web API (OData) operations
The Dynamics 365 Web API follows OData v4 conventions and is the primary way to create, update, and relate records programmatically. POST creates records, PATCH updates or upserts them, and navigation properties expressed as @odata.bind annotations create lookup relationships between entities. The current API version is v9.2, which Microsoft introduced in 2022 to support enhanced query capabilities.
Every request to the Dataverse Web API requires 3 standard OData headers: OData-MaxVersion: 4.0, OData-Version: 4.0, and the authorization bearer token. Adding Prefer: return=representation makes the API return the created record in the response body, which is necessary for capturing the auto-generated contactid or activityid GUID. Without that header, POST requests return only a 204 status with the record ID in the OData-EntityId response header.
# Common headers for all Dynamics 365 API calls
HEADERS=(
-H "Authorization: Bearer $TOKEN"
-H "Content-Type: application/json"
-H "OData-MaxVersion: 4.0"
-H "OData-Version: 4.0"
-H "Prefer: return=representation"
)
# Create a contact
CONTACT_ID=$(curl -s -X POST \
"https://$ORG.api.crm.dynamics.com/api/data/v9.2/contacts" \
"${HEADERS[@]}" \
-d '{
"firstname": "Sarah",
"lastname": "Chen",
"emailaddress1": "sarah@acme.com",
"telephone1": "+1-555-0142",
"jobtitle": "VP of Engineering"
}' | jq -r '.contactid')
# Create an account and link contact to it
ACCOUNT_ID=$(curl -s -X POST \
"https://$ORG.api.crm.dynamics.com/api/data/v9.2/accounts" \
"${HEADERS[@]}" \
-d '{
"name": "Acme Corp",
"websiteurl": "https://acme.com"
}' | jq -r '.accountid')
# Associate contact with account (set parent customer)
curl -s -X PATCH \
"https://$ORG.api.crm.dynamics.com/api/data/v9.2/contacts($CONTACT_ID)" \
"${HEADERS[@]}" \
-d "{
\"parentcustomerid_account@odata.bind\": \"/accounts($ACCOUNT_ID)\"
}"
# Create an email activity with party lists
curl -s -X POST \
"https://$ORG.api.crm.dynamics.com/api/data/v9.2/emails" \
"${HEADERS[@]}" \
-d "{
\"subject\": \"Re: Q1 Planning Discussion\",
\"description\": \"Follow-up on roadmap alignment.\",
\"actualstart\": \"2026-03-12T14:30:00Z\",
\"directioncode\": false,
\"email_activity_parties\": [
{
\"partyid_contact@odata.bind\": \"/contacts($CONTACT_ID)\",
\"participationtypemask\": 1
}
]
}" | jq '{activityid, subject}'
# Upsert contact by email (alternate key)
# Requires an alternate key defined on emailaddress1 in Dataverse
curl -s -X PATCH \
"https://$ORG.api.crm.dynamics.com/api/data/v9.2/contacts(emailaddress1='sarah@acme.com')" \
"${HEADERS[@]}" \
-d '{
"firstname": "Sarah",
"lastname": "Chen",
"telephone1": "+1-555-0142"
}'Power Automate integration
Power Automate, formerly Microsoft Flow, schedules recurring Nylas CLI syncs without cron. A cloud flow with a Recurrence trigger calls an Azure Function that runs the CLI export and pushes records to Dynamics 365. According to Microsoft, Power Automate processes over 10 billion actions per month across its customer base, making it the standard orchestration layer for Dynamics 365 workflows.
The architecture follows a 4-step chain: Power Automate fires on schedule, calls an Azure Function via HTTP trigger, the function runs nylas email list --json and upserts records through the Dataverse Web API, and results are logged back to Power Automate's run history. Power Automate retries failed runs up to 4 times with exponential backoff and can send Teams notifications on success or error.
A simpler option for teams that don't want Azure Functions: use Power Automate's “Run a script” connector with a self-hosted agent. Install the on-premises data gateway, register it with Power Automate, and create a flow that runs the sync script on the machine where the Nylas CLI is installed. The flow trigger handles scheduling; the script handles the actual export and import.
# Azure Function (Python) that Power Automate can trigger via HTTP
# Deploy to Azure Functions with: func azure functionapp publish <app-name>
import json
import subprocess
import os
import azure.functions as func
import msal
import requests
def main(req: func.HttpRequest) -> func.HttpResponse:
# Auth to Dynamics 365
app = msal.ConfidentialClientApplication(
os.environ["AZURE_CLIENT_ID"],
authority=f"https://login.microsoftonline.com/{os.environ['AZURE_TENANT_ID']}",
client_credential=os.environ["AZURE_CLIENT_SECRET"],
)
token = app.acquire_token_for_client(
scopes=[f"https://{os.environ['DYNAMICS_ORG']}.crm.dynamics.com/.default"]
)["access_token"]
# Export from Nylas CLI (installed on the Function App)
emails = json.loads(subprocess.run(
["nylas", "email", "list", "--json", "--limit", "100"],
capture_output=True, text=True, check=True,
).stdout)
# Upsert contacts and log activities
base = f"https://{os.environ['DYNAMICS_ORG']}.api.crm.dynamics.com/api/data/v9.2"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"OData-MaxVersion": "4.0",
"OData-Version": "4.0",
}
created = 0
for msg in emails:
sender = (msg.get("from") or [{}])[0]
email = sender.get("email", "")
if not email:
continue
name = sender.get("name", "").split(" ", 1)
# Upsert contact
requests.patch(
f"{base}/contacts(emailaddress1='{email}')",
headers=headers,
json={
"firstname": name[0] if name else "",
"lastname": name[1] if len(name) > 1 else "Unknown",
},
)
created += 1
return func.HttpResponse(
json.dumps({"synced": created, "total_emails": len(emails)}),
mimetype="application/json",
)Batch operations with $batch
The Dynamics 365 Web API supports OData $batch requests that bundle multiple create, update, or delete operations into a single HTTP call. Each batch request can contain up to 1,000 individual operations, which means importing 500 contacts takes 1 HTTP round trip instead of 500. According to Microsoft's Dataverse documentation, batch requests reduce network latency overhead and count as a single API call against the per-user throttle limit of 6,000 requests per 5-minute window.
Batch requests use multipart/mixed content type with a custom boundary string. Each operation inside the batch is a self-contained HTTP request with its own method, path, and body. The Dataverse API processes batch operations sequentially by default, returning a multipart response with individual status codes for each operation.
# OData $batch request — create 3 contacts in one HTTP call
curl -s -X POST \
"https://$ORG.api.crm.dynamics.com/api/data/v9.2/\$batch" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: multipart/mixed; boundary=batch_boundary" \
-H "OData-MaxVersion: 4.0" \
--data-binary @- << 'BATCH'
--batch_boundary
Content-Type: application/http
Content-Transfer-Encoding: binary
POST /api/data/v9.2/contacts HTTP/1.1
Content-Type: application/json
{"firstname":"Sarah","lastname":"Chen","emailaddress1":"sarah@acme.com"}
--batch_boundary
Content-Type: application/http
Content-Transfer-Encoding: binary
POST /api/data/v9.2/contacts HTTP/1.1
Content-Type: application/json
{"firstname":"James","lastname":"Park","emailaddress1":"james@globex.com"}
--batch_boundary
Content-Type: application/http
Content-Transfer-Encoding: binary
POST /api/data/v9.2/contacts HTTP/1.1
Content-Type: application/json
{"firstname":"Maria","lastname":"Santos","emailaddress1":"maria@initech.com"}
--batch_boundary--
BATCHFor change sets (transactional batches where all operations succeed or all fail), wrap the operations in a changeset boundary. This is useful when creating a contact, account, and email activity that must all succeed together.
Python sync with MSAL
A full Python sync script authenticates to Azure AD via the MSAL library, exports emails and contacts from the Nylas CLI, upserts contacts using Dataverse alternate keys, creates account records for non-freemail domains, and logs email activities with activityparty records. The script handles the 2-phase pattern that Dynamics 365 requires: contacts and accounts must exist before email activities can reference them through partyid_contact@odata.bind.
The MSAL library (msal on PyPI) handles token caching and automatic refresh. Tokens issued by the client credentials grant last 3,600 seconds, and MSAL renews them 5 minutes before expiration without any manual intervention. The script uses requests.Session to reuse TCP connections across all API calls, which reduces per-request overhead by approximately 50 milliseconds compared to individual requests.get or requests.post calls.
#!/usr/bin/env python3
"""Sync Nylas CLI email data to Dynamics 365 via Dataverse Web API."""
import json
import subprocess
import os
import sys
import msal
import requests
ORG = os.environ["DYNAMICS_ORG"]
TENANT = os.environ["AZURE_TENANT_ID"]
CLIENT_ID = os.environ["AZURE_CLIENT_ID"]
SECRET = os.environ["AZURE_CLIENT_SECRET"]
BASE = f"https://{ORG}.api.crm.dynamics.com/api/data/v9.2"
FREEMAIL = {"gmail.com", "yahoo.com", "outlook.com", "hotmail.com"}
# Authenticate via MSAL client credentials
app = msal.ConfidentialClientApplication(
CLIENT_ID,
authority=f"https://login.microsoftonline.com/{TENANT}",
client_credential=SECRET,
)
result = app.acquire_token_for_client(
scopes=[f"https://{ORG}.crm.dynamics.com/.default"]
)
if "access_token" not in result:
print(f"Auth failed: {result.get('error_description')}", file=sys.stderr)
sys.exit(1)
session = requests.Session()
session.headers.update({
"Authorization": f"Bearer {result['access_token']}",
"Content-Type": "application/json",
"OData-MaxVersion": "4.0",
"OData-Version": "4.0",
"Prefer": "return=representation",
})
# 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: Upsert contacts and accounts
contact_map: dict[str, str] = {} # email -> contactid
for c in contacts:
email_list = c.get("emails", [])
if not email_list:
continue
addr = email_list[0]["email"]
domain = addr.split("@")[1] if "@" in addr else ""
# Upsert contact (requires alternate key on emailaddress1)
payload = {
"emailaddress1": addr,
"firstname": c.get("given_name", ""),
"lastname": c.get("surname", "") or "Unknown",
"telephone1": (c.get("phone_numbers") or [{}])[0].get("number", ""),
"jobtitle": c.get("job_title", ""),
}
payload = {k: v for k, v in payload.items() if v}
resp = session.patch(f"{BASE}/contacts(emailaddress1='{addr}')", json=payload)
if resp.status_code == 404:
# Alternate key not configured — create directly
resp = session.post(f"{BASE}/contacts", json=payload)
if resp.ok:
cid = resp.json().get("contactid")
if cid:
contact_map[addr] = cid
# Create account for non-freemail domains
if domain and domain not in FREEMAIL:
company = domain.split(".")[0].title()
# Check if account exists
check = session.get(f"{BASE}/accounts", params={
"$filter": f"name eq '{company}'",
"$select": "accountid",
"$top": "1",
})
existing = check.json().get("value", [])
if not existing:
acct = session.post(f"{BASE}/accounts", json={
"name": company,
"websiteurl": f"https://{domain}",
})
if acct.ok:
account_id = acct.json()["accountid"]
# Link contact to account
cid = contact_map.get(addr)
if cid:
session.patch(f"{BASE}/contacts({cid})", json={
"parentcustomerid_account@odata.bind": f"/accounts({account_id})",
})
print(f"Upserted {len(contact_map)} contacts")
# Phase 2: Log email activities with activityparty
activity_count = 0
for msg in emails:
sender = (msg.get("from") or [{}])[0]
sender_email = sender.get("email", "")
sender_id = contact_map.get(sender_email)
if not sender_id:
continue
parties = [{
"partyid_contact@odata.bind": f"/contacts({sender_id})",
"participationtypemask": 1, # From
}]
# Add To recipients if they exist in our contact map
for recip in msg.get("to", []):
recip_id = contact_map.get(recip.get("email", ""))
if recip_id:
parties.append({
"partyid_contact@odata.bind": f"/contacts({recip_id})",
"participationtypemask": 2, # To
})
resp = session.post(f"{BASE}/emails", json={
"subject": (msg.get("subject") or "(no subject)")[:200],
"description": (msg.get("snippet") or "")[:2000],
"actualstart": msg.get("date", ""),
"directioncode": False, # incoming
"email_activity_parties": parties,
})
if resp.ok:
activity_count += 1
print(f"Logged {activity_count} email activities")
print("Sync complete.")The script requires 2 Python packages: msal (Microsoft Authentication Library, ~1.2 MB) and requests. Set 4 environment variables with the Azure AD app registration values before running. The DYNAMICS_ORG value is the subdomain from your Dynamics 365 URL (e.g., contoso from contoso.crm.dynamics.com).
pip install msal requests
export DYNAMICS_ORG="yourorg"
export AZURE_TENANT_ID="your-tenant-id"
export AZURE_CLIENT_ID="your-client-id"
export AZURE_CLIENT_SECRET="your-client-secret"
python3 dynamics365_sync.pyNext steps
- Export email data to Salesforce — Bulk API 2.0, SOQL queries, and Apex triggers
- Organize emails by company and domain — group your inbox by sender domain before importing into Dynamics 365
- Enrich contact and company info from email — extract job titles and phone numbers to populate richer Dynamics 365 contact records
- Personalize outbound email from the CLI — use Dynamics 365 contact data to send personalized follow-ups
- CRM Email Workflows — the full series hub with all 8 guides
- Command reference — every flag, subcommand, and example
- Microsoft Learn: Dataverse Web API overview — the OData v4 contract Dynamics 365 sits on top of
- Dataverse Web API: execute batch operations — reduces API call count when importing email-derived records
- Microsoft Entra ID: OAuth 2.0 auth-code flow — required token issuance for the Dataverse environment