Sales teams live in Dynamics 365, but the email that drives deals sits in Microsoft 365, Gmail, and a dozen other mailboxes the org has connected over the years. You want each of those messages to land on the right account or contact as a tracked activity. This recipe wires that pipeline: read mail once through the Email API, then write it to Dynamics as a native Dataverse email activity record.
The split matters. One side reads from every connected mailbox with a single schema; the other side speaks the Dataverse Web API. Keeping them separate means a new provider never changes your Dynamics code, and a Dataverse field rename never touches your inbox code.
Why use Nylas instead of a per-mailbox connector?
Section titled “Why use Nylas instead of a per-mailbox connector?”A Dynamics 365 email integration that reads inboxes directly has to handle each provider’s auth and sync quirks on its own. The Email API collapses Google, Microsoft, Yahoo, iCloud, IMAP, and EWS behind one REST schema, so the read side of your sync stays identical no matter which of the 6 providers a user connected.
- One schema, every provider. A message from Gmail and a message from Microsoft 365 deserialize into the same fields, so your Dataverse mapping code runs unchanged.
- OAuth handled for you. The API refreshes provider tokens automatically. You don’t store or rotate Google and Microsoft refresh tokens yourself.
- Real-time or batch. Poll with
GET /messagesfor backfills, or subscribe to themessage.createdwebhook so new mail flows into Dynamics within seconds. - Dataverse stays the system of record. Email lands as a first-class
emailsactivity, queryable in views, advanced find, and Power Automate flows.
For the generic version of this pattern across any CRM, see Sync email contacts to a CRM. This page is specific to Dataverse and its email activity entity.
Before you begin
Section titled “Before you begin”You need a Nylas application with at least one connected email grant, plus a Dynamics 365 environment with a Dataverse organization URL (it looks like https://yourorg.crm.dynamics.com). On the Dynamics side you register an app in Microsoft Entra ID and grant it the Dataverse permission so it can write activity records. The whole flow uses 2 access tokens: one API key for reads, one Entra ID bearer token for writes.
Authenticate to the Dataverse Web API
Section titled “Authenticate to the Dataverse Web API”The Dataverse Web API authenticates with OAuth 2.0 through Microsoft Entra ID, the same identity platform behind Microsoft 365. Per the Microsoft authentication docs, you register an application, grant it the Dataverse API permission, and request a token scoped to your organization URL with the .default scope (for example https://yourorg.crm.dynamics.com/.default). Send that token as a bearer token on every request, and use the v9.2 endpoint under /api/data/v9.2/.
# Client credentials token request against Microsoft Entra IDcurl --request POST \ --url "https://login.microsoftonline.com/<TENANT_ID>/oauth2/v2.0/token" \ --header "Content-Type: application/x-www-form-urlencoded" \ --data "grant_type=client_credentials" \ --data "client_id=<AZURE_APP_CLIENT_ID>" \ --data "client_secret=<AZURE_APP_SECRET>" \ --data "scope=https://yourorg.crm.dynamics.com/.default"The response carries an access_token valid for about 3,600 seconds. Cache it and reuse it across the batch instead of minting one per email, or you’ll hit Entra ID throttling fast.
List messages from the connected mailbox
Section titled “List messages from the connected mailbox”Pull recent mail with GET /v3/grants/{grant_id}/messages. The endpoint returns up to 200 messages per page and accepts filters like received_after, from, and in (folder), so a nightly job can grab only the last day’s mail. Each message carries from, to, subject, date, body, and a stable id you can store as a dedupe key on the Dynamics side.
curl --compressed --request GET \ --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages?limit=5" \ --header 'Accept: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>' \ --header 'Content-Type: application/json'import Nylas from "nylas";
const nylas = new Nylas({ apiKey: "<NYLAS_API_KEY>", apiUri: "<NYLAS_API_URI>",});
const messages = await nylas.messages.list({ identifier: "<NYLAS_GRANT_ID>", queryParams: { limit: 50 },});from nylas import Client
nylas = Client("<NYLAS_API_KEY>", "<NYLAS_API_URI>")
messages = nylas.messages.list( "<NYLAS_GRANT_ID>", query_params={"limit": 50},)The same request works whether the grant is a Microsoft 365, Gmail, or IMAP account, so you write this read path once.
Map an email to a Dataverse activity
Section titled “Map an email to a Dataverse activity”A Dynamics email lives in the emails entity, which inherits from activitypointer. The fields you care about map cleanly: subject holds the subject line, description holds the body, directioncode marks incoming versus outgoing, and torecipients holds a semicolon-delimited recipient string. The activityid GUID is the primary key, and Dynamics generates it unless you supply one. The 7 fields in the table below cover everything a tracked email needs.
| Nylas message field | Dataverse emails field | Notes |
|---|---|---|
subject | subject | Primary name column on the entity |
body | description | HTML body of the activity |
from[].email | sender | Sender address string |
to[].email | torecipients | Semicolon-delimited per Microsoft’s format |
| direction | directioncode | false incoming, true outgoing (boolean) |
date | actualend | When the activity completed |
message id | subject prefix or custom field | Store for idempotent upserts |
The two fields most people miss are regardingobjectid and email_activity_parties. The first links the activity to the account or contact it concerns; the second is the collection of senders and recipients as activityparty rows.
Create the email activity in Dataverse
Section titled “Create the email activity in Dataverse”Send a POST to [organization URL]/api/data/v9.2/emails to create the record. The Dataverse create docs confirm the entity set name is emails and a successful create returns 204 No Content with an OData-EntityId header pointing at the new row. Use @odata.bind on regardingobjectid_contact to attach the email to an existing contact in one atomic write.
curl --request POST \ --url "https://yourorg.crm.dynamics.com/api/data/v9.2/emails" \ --header "Authorization: Bearer <DATAVERSE_TOKEN>" \ --header "Content-Type: application/json; charset=utf-8" \ --header "OData-Version: 4.0" \ --data '{ "subject": "Re: Q2 renewal", "description": "<p>Thanks for the call earlier.</p>", "directioncode": false, "torecipients": "[email protected]", "[email protected]": "/contacts(00000000-0000-0000-0000-000000000001)" }'The directioncode: false value tags this as an incoming email; the field is a boolean, so set it to true for sent mail rather than an integer.
Set sender and recipients with activity parties
Section titled “Set sender and recipients with activity parties”Dynamics models who an email is from and to through the email_activity_parties collection, where each row is an activityparty carrying a participationtypemask. Mask 1 is the sender (From), 2 is To, 3 is CC, and 4 is BCC, per the activityparty reference. You can deep-insert all parties in the same POST that creates the email, so one request writes the activity and its 3 or 4 party rows together.
import requests
org = "https://yourorg.crm.dynamics.com"headers = { "Authorization": f"Bearer {dataverse_token}", "Content-Type": "application/json; charset=utf-8", "OData-Version": "4.0",}
body = { "subject": msg["subject"], "description": msg.get("body", ""), "directioncode": False, "email_activity_parties": [ { "participationtypemask": 1, # 1 = sender (From) }, { "participationtypemask": 2, # 2 = To recipient }, ],}
resp = requests.post(f"{org}/api/data/v9.2/emails", json=body, headers=headers)resp.raise_for_status() # 204 No Content on successA party can point at an existing record with [email protected] or carry a raw addressused string when the address isn’t in Dynamics yet. That keeps external senders from blocking the write.
Sync new mail in real time with a webhook
Section titled “Sync new mail in real time with a webhook”For a live sync, subscribe to the message.created webhook instead of polling. Nylas fires this trigger the moment a new message arrives in any connected mailbox, so a renewal reply shows up as a Dynamics activity in seconds rather than waiting for the next nightly run. The payload carries the same message id you use as a dedupe key, which matters because providers can deliver the same notification more than once. A single subscription covers all 6 providers without per-mailbox setup.
import hashlib, hmac
def handle_webhook(payload, signature, secret): # Verify the X-Nylas-Signature header before trusting the body expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, signature): return 401
event = json.loads(payload) if event["type"] == "message.created": message = event["data"]["object"] create_dataverse_email(message) # the POST from the section above return 200Verify the X-Nylas-Signature header with your webhook secret before writing to Dynamics, and dedupe on the message id so a redelivered event doesn’t create a duplicate activity. For the full webhook setup, see how to process webhooks in real time.
Things to know about Dynamics 365 and Dataverse
Section titled “Things to know about Dynamics 365 and Dataverse”Dataverse has rules that trip up email sync if you don’t plan for them. A few worth knowing before you point this at a production org:
- API throttling is per user, per 5 minutes. Dataverse enforces service protection limits of 6,000 requests per user in a sliding 5-minute window, plus a 20-minute combined execution-time cap. Batch your writes and reuse a single Entra ID token rather than firing one request per email.
regardingobjectidis polymorphic. The bind property name encodes the target type, so it’s[email protected]for an account and[email protected]for a contact. Pick the right one based on what the email concerns, or the write fails.statecodeandstatuscodecontrol the activity lifecycle. A new email defaults to Open. Setstatecodeto 1 (Completed) for mail that’s already been sent or received so it doesn’t sit in users’ activity queues as a pending task.torecipientsis a string, not a collection. Dynamics stores it as a semicolon-delimited address list for display, separate from the structuredemail_activity_partiesrows. Populate both: the string for the UI, the parties for the relationships.- Email tracking can create duplicates. If the org already runs server-side sync or the Dynamics 365 App for Outlook, the same message may get promoted twice. Check
messageid(the internet message ID) before inserting, and skip if Dynamics already has it.
Things to know about reading from Nylas
Section titled “Things to know about reading from Nylas”The read side has its own details worth pinning down. The message id returned by GET /messages is stable for the life of the grant, which makes it a reliable dedupe key on the Dynamics side. Bodies come back as HTML by default, which maps directly onto the Dataverse description field with no conversion. A single list request returns up to 200 messages per page, so a backfill walks the cursor rather than fetching one message at a time. One gotcha: a single thread can produce many message.created events, so filter on thread_id if you only want to log the first message of a conversation rather than every reply.
Next steps
Section titled “Next steps”- Sync email contacts to a CRM for the generic, provider-agnostic pipeline
- Export email data to Salesforce for the same pattern mapped onto Salesforce objects
- Nylas for CRM platforms for the full set of CRM integration recipes
- Process webhooks in real time to wire up the
message.createdtrigger