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 Gmail, Outlook, Exchange, Yahoo, iCloud, and IMAP 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.
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.
Export from Nylas CLI
Pull email and contact data into local JSON files, then preview how fields map to Dataverse entity columns. Dynamics 365 uses different field names than other CRMs (e.g., emailaddress1 instead of Email).
# Export recent emails and contacts as JSON
nylas email list --json --limit 500 > emails.json
nylas contacts list --json --limit 500 > contacts.json
# Preview Dataverse entity mapping
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 token from Azure AD (now called Microsoft Entra ID). You need to register an app, grant it Dynamics CRM permissions, and create a client secret.
Setup 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)
- Note the Application (client) ID, Directory (tenant) ID, and the secret value
For server-to-server sync (no user interaction), use the client credentials flow. This requires an Application User in Dynamics 365 mapped to a security role with Create/Read/Write permissions on the contact, account, and email entities.
# 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 data in Dataverse (formerly Common Data Service). The entity model for email import involves four core tables and one junction record type:
| 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 use them differently. This matters for email import because the entities you target depend on which app your org uses.
- 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.
For email import targeting Sales, link email activities to opportunities. For Customer Service, link them to cases. The API calls are identical; only the regardingobjectid binding changes.
Dataverse Web API (OData) operations
The Dynamics 365 Web API follows OData v4 conventions. POST creates records, PATCH updates them, and navigation properties create lookup relationships. All requests need the OData headers.
# 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) can schedule your Nylas CLI sync to run on a recurring basis. Instead of cron, you use a Power Automate cloud flow with a “Recurrence” trigger.
The architecture: Power Automate triggers on schedule → calls an Azure Function or Logic App → the function runs the Nylas CLI export and Dynamics 365 API calls → results are logged back to Power Automate.
For teams already using Power Platform, this approach integrates with existing monitoring and alerting. Power Automate logs every run, retries on failure, and can send Teams notifications on success or error.
A simpler option: 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 your sync script on the machine where 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 operations into a single HTTP call. Each batch can contain up to 1,000 operations. This reduces round trips from hundreds to one.
# 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 complete Python script using MSAL for Azure AD authentication, Dataverse alternate key upserts, and email activity creation with activityparty records.
#!/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.")Install and run:
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