Skip to content
Skip to main content

Sync email to Microsoft Dynamics 365

Last updated:

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 /messages for backfills, or subscribe to the message.created webhook so new mail flows into Dynamics within seconds.
  • Dataverse stays the system of record. Email lands as a first-class emails activity, 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.

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.

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 ID
curl --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.

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.

The same request works whether the grant is a Microsoft 365, Gmail, or IMAP account, so you write this read path once.

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 fieldDataverse emails fieldNotes
subjectsubjectPrimary name column on the entity
bodydescriptionHTML body of the activity
from[].emailsenderSender address string
to[].emailtorecipientsSemicolon-delimited per Microsoft’s format
directiondirectioncodefalse incoming, true outgoing (boolean)
dateactualendWhen the activity completed
message idsubject prefix or custom fieldStore 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.

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 protected]": f"/accounts({account_id})",
"email_activity_parties": [
{
"[email protected]": f"/contacts({contact_id})",
"participationtypemask": 1, # 1 = sender (From)
},
{
"addressused": "[email protected]",
"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 success

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

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 200

Verify 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.
  • regardingobjectid is 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.
  • statecode and statuscode control the activity lifecycle. A new email defaults to Open. Set statecode to 1 (Completed) for mail that’s already been sent or received so it doesn’t sit in users’ activity queues as a pending task.
  • torecipients is a string, not a collection. Dynamics stores it as a semicolon-delimited address list for display, separate from the structured email_activity_parties rows. 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.

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.