# Sync email to Microsoft Dynamics 365

Source: https://developer.nylas.com/docs/cookbook/use-cases/sync/sync-dynamics-email/

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?

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](/docs/cookbook/use-cases/sync/sync-email-crm/). This page is specific to Dataverse and its email activity entity.

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


> **Info:** 
> **New to Nylas?** Start with the [quickstart guide](/docs/v3/getting-started/) to set up your app and connect a test account before continuing here.


### 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](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/authenticate-web-api), 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/`.

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

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

````bash [listMessages-Request]
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'
````

```js [listMessages-Node.js SDK]


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 },
});
```

```python [listMessages-Python SDK]
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

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

Send a `POST` to `[organization URL]/api/data/v9.2/emails` to create the record. The [Dataverse create docs](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/create-entity-web-api) 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.

```bash
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": "ada@acme.test",
    "regardingobjectid_contact@odata.bind": "/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

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](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/reference/activityparty). 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.

```python


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,
    "regardingobjectid_account@odata.bind": f"/accounts({account_id})",
    "email_activity_parties": [
        {
            "partyid_contact@odata.bind": f"/contacts({contact_id})",
            "participationtypemask": 1,  # 1 = sender (From)
        },
        {
            "addressused": "sales@yourorg.com",
            "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 `partyid_contact@odata.bind` 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

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.

```python


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](/docs/cookbook/use-cases/build/realtime-webhooks/).

## 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 `regardingobjectid_account@odata.bind` for an account and `regardingobjectid_contact@odata.bind` 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.

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

- [Sync email contacts to a CRM](/docs/cookbook/use-cases/sync/sync-email-crm/) for the generic, provider-agnostic pipeline
- [Export email data to Salesforce](/docs/cookbook/use-cases/sync/export-to-salesforce/) for the same pattern mapped onto Salesforce objects
- [Nylas for CRM platforms](/docs/cookbook/use-cases/industries/crm/) for the full set of CRM integration recipes
- [Process webhooks in real time](/docs/cookbook/use-cases/build/realtime-webhooks/) to wire up the `message.created` trigger