Guide

Export Email Data to Dynamics 365

Microsoft Dynamics 365 Sales is the CRM backbone for enterprise teams running on the Microsoft ecosystem. This guide shows you how to extract email and contact data from the Nylas CLI and push it into Dynamics 365 — creating contacts, associating accounts, and logging email activities — using both the CSV Import Wizard and the Dynamics 365 Web API.

Why sync email data to Dynamics 365

Dynamics 365 Sales is the CRM of choice for organizations already invested in the Microsoft ecosystem — Azure, Microsoft 365, Teams, Power Platform. But CRM data quality degrades fast when reps have to manually create contacts and log activities. Email metadata is the antidote: every message contains structured sender info, timestamps, subject lines, and thread context that Dynamics 365 needs to maintain an accurate timeline of customer interactions.

Syncing email data to Dynamics 365 gives you four things. First, enriched contacts — email addresses, names, phone numbers, and company domains extracted from messages and signatures flow into the contact and account entities. Second, auto-logged activities — every email becomes an email activity record with sender, recipients, subject, and timestamp, eliminating manual activity logging. Third, Microsoft ecosystem integration — data in Dynamics 365 is immediately available in Power BI dashboards, Power Automate flows, and Teams collaboration. Fourth, pipeline visibility — associating email activities with opportunities and leads gives sales managers real-time insight into deal engagement.

Export data from Nylas CLI

Start by exporting emails and contacts as JSON. The --json flag produces structured output that you can pipe through jq for filtering or feed directly into a Python or TypeScript script.

# Export recent emails as JSON
nylas email list --json --limit 200 > emails.json

# Export contacts
nylas contacts list --json --limit 500 > contacts.json

# Preview the email structure
cat emails.json | jq '.[0] | {from, to, subject, date, id}'

# Extract unique sender domains (filter freemail)
cat emails.json | jq -r '[.[] | .from[0].email | split("@")[1]]
  | unique
  | .[]' | grep -vE '^(gmail|yahoo|outlook|hotmail)\.com$'

# Build a contacts CSV for Dynamics 365 import
cat contacts.json | jq -r '
  ["First Name","Last Name","Email","Phone","Company"],
  (.[] | [
    (.given_name // ""),
    (.surname // ""),
    (.emails[0].email // ""),
    (.phone_numbers[0].number // ""),
    (.company_name // "")
  ]) | @csv' > dynamics-contacts.csv

Dynamics 365 entity model mapping

Dynamics 365 uses an entity-based data model. Understanding how email data maps to native entities is critical for a clean import. Here are the key mappings.

# Nylas email/contact fields → Dynamics 365 entity fields
#
# ┌─────────────────────────┬──────────────────────────────────────┐
# │ Nylas field             │ Dynamics 365 entity.field            │
# ├─────────────────────────┼──────────────────────────────────────┤
# │ contact.emails[0]       │ contact.emailaddress1                │
# │ contact.given_name      │ contact.firstname                    │
# │ contact.surname         │ contact.lastname                     │
# │ contact.company_name    │ account.name                         │
# │ contact.phone_numbers[0]│ contact.telephone1                   │
# │ contact.job_title       │ contact.jobtitle                     │
# │ contact.web_pages[0]    │ contact.websiteurl                   │
# │ email.from[0]           │ email.from (activityparty)           │
# │ email.to[]              │ email.to (activityparty list)        │
# │ email.subject           │ email.subject                        │
# │ email.date              │ email.actualstart                    │
# │ email.body              │ email.description                    │
# │ email.id                │ email.regardingobjectid (link)       │
# └─────────────────────────┴──────────────────────────────────────┘
#
# Target entities:
#   contact   — individual people (emailaddress1, firstname, lastname, telephone1)
#   account   — companies (name, websiteurl, telephone1)
#   lead      — unqualified prospects (emailaddress1, firstname, lastname, companyname)
#   email     — email activity records (subject, description, actualstart)
#   task      — follow-up tasks (subject, description, scheduledstart)

The contact entity holds individuals. account holds companies. Contacts are linked to accounts via the parentcustomerid lookup field. Email activities use activityparty records to represent sender and recipients, with a participationtypemask field distinguishing From (1), To (2), CC (3), and BCC (4).

CSV import via Dynamics 365 Import Wizard

The fastest way to get contacts into Dynamics 365 is the built-in Import Wizard. It accepts CSV files and maps columns to entity fields interactively. This is ideal for one-time bulk imports or when you do not have API access.

# Step 1: Generate a CSV from Nylas CLI contact data
nylas contacts list --json --limit 500 | jq -r '
  ["First Name","Last Name","Email Address 1","Business Phone","Company Name","Job Title"],
  (.[] | [
    (.given_name // ""),
    (.surname // ""),
    (.emails[0].email // ""),
    (.phone_numbers[0].number // ""),
    (.company_name // ""),
    (.job_title // "")
  ]) | @csv' > dynamics-import.csv

# Step 2: Open Dynamics 365 → Settings → Data Management → Imports
# Step 3: Upload dynamics-import.csv
# Step 4: Map columns:
#   "First Name"       → contact.firstname
#   "Last Name"        → contact.lastname
#   "Email Address 1"  → contact.emailaddress1
#   "Business Phone"   → contact.telephone1
#   "Company Name"     → contact.companyname (or create account lookup)
#   "Job Title"        → contact.jobtitle
# Step 5: Choose duplicate detection rules (match on emailaddress1)
# Step 6: Submit import — Dynamics processes it asynchronously

echo "Generated $(wc -l < dynamics-import.csv) rows for Dynamics 365 import"

For recurring imports, consider Power Platform Dataflows. Create a dataflow in Power Apps that reads from a cloud storage location (Azure Blob, SharePoint, or OneDrive), maps columns to Dynamics 365 entities, and runs on a schedule. Your pipeline becomes: Nylas CLI exports to a shared folder, the dataflow picks it up and loads it into Dynamics.

Dynamics 365 Web API import

For programmatic imports, use the Dynamics 365 Web API — an OData v4 REST API available at https://<org>.api.crm.dynamics.com/api/data/v9.2/. Authentication uses OAuth 2.0 with an Azure AD (Entra ID) app registration.

# Register an Azure AD app for Dynamics 365 API access:
# 1. Go to Azure Portal → App registrations → New registration
# 2. Add API permission: Dynamics CRM → user_impersonation
# 3. Create a client secret
# 4. Note: Application (client) ID, Directory (tenant) ID, Client secret

# Get an OAuth 2.0 access 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')

# Create a contact
curl -s -X POST \
  "https://<org>.api.crm.dynamics.com/api/data/v9.2/contacts" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "OData-MaxVersion: 4.0" \
  -H "OData-Version: 4.0" \
  -H "Prefer: return=representation" \
  -d '{
    "firstname": "Sarah",
    "lastname": "Chen",
    "emailaddress1": "sarah@acme.com",
    "telephone1": "+1-555-0142",
    "jobtitle": "VP of Engineering"
  }' | jq '{contactid, fullname, emailaddress1}'

# Create an account
curl -s -X POST \
  "https://<org>.api.crm.dynamics.com/api/data/v9.2/accounts" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "OData-MaxVersion: 4.0" \
  -H "OData-Version: 4.0" \
  -d '{
    "name": "Acme Corp",
    "websiteurl": "https://acme.com",
    "telephone1": "+1-555-0100"
  }' | jq '{accountid, name}'

# Link contact to account (associate)
curl -s -X PATCH \
  "https://<org>.api.crm.dynamics.com/api/data/v9.2/contacts(<contact-id>)" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "OData-MaxVersion: 4.0" \
  -H "OData-Version: 4.0" \
  -d '{
    "parentcustomerid_account@odata.bind": "/accounts(<account-id>)"
  }'

The Web API uses standard OData conventions. POST creates records, PATCH updates them, and navigation properties like parentcustomerid_account@odata.bind create lookup relationships between entities. The Prefer: return=representation header tells the API to return the created record, including its system-generated ID.

Log email as activity records

Email activities in Dynamics 365 use the email entity, which inherits from activitypointer. Each email has a party list — an array of activityparty records representing sender (From), recipients (To), CC, and BCC. The participationtypemask field distinguishes the role: 1 = From, 2 = To, 3 = CC, 4 = BCC.

# Log an email activity with party lists
curl -s -X POST \
  "https://<org>.api.crm.dynamics.com/api/data/v9.2/emails" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "OData-MaxVersion: 4.0" \
  -H "OData-Version: 4.0" \
  -H "Prefer: return=representation" \
  -d '{
    "subject": "Re: Q1 Planning Discussion",
    "description": "Follow-up on roadmap alignment and resource allocation.",
    "actualstart": "2026-03-12T14:30:00Z",
    "actualend": "2026-03-12T14:30:00Z",
    "directioncode": true,
    "email_activity_parties": [
      {
        "partyid_systemuser@odata.bind": "/systemusers(<your-user-id>)",
        "participationtypemask": 1
      },
      {
        "partyid_contact@odata.bind": "/contacts(<contact-id>)",
        "participationtypemask": 2
      }
    ]
  }' | jq '{activityid, subject, actualstart}'

# Link email activity to an opportunity (regarding)
curl -s -X PATCH \
  "https://<org>.api.crm.dynamics.com/api/data/v9.2/emails(<activity-id>)" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "OData-MaxVersion: 4.0" \
  -H "OData-Version: 4.0" \
  -d '{
    "regardingobjectid_opportunity@odata.bind": "/opportunities(<opportunity-id>)"
  }'

# Mark activity as completed
curl -s -X PATCH \
  "https://<org>.api.crm.dynamics.com/api/data/v9.2/emails(<activity-id>)" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "OData-MaxVersion: 4.0" \
  -H "OData-Version: 4.0" \
  -d '{"statecode": 1, "statuscode": 2}'

The directioncode field indicates whether the email was outgoing (true) or incoming (false). Setting regardingobjectid ties the activity to a specific opportunity, lead, or account — making it visible in that record’s timeline view.

Scheduled sync script

For ongoing synchronization, run a shell script on a schedule that exports new emails from Nylas CLI and upserts them into Dynamics 365. The script uses email address as the deduplication key for contacts and message ID for email activities.

#!/usr/bin/env bash
# sync-to-dynamics.sh — Export recent emails and upsert into Dynamics 365
# Run via cron: */30 * * * * /path/to/sync-to-dynamics.sh

set -euo pipefail

ORG="yourorg"
BASE_URL="https://$ORG.api.crm.dynamics.com/api/data/v9.2"
TENANT_ID="<tenant-id>"
CLIENT_ID="<client-id>"
CLIENT_SECRET="<client-secret>"

# Get access token
TOKEN=$(curl -sf -X POST \
  "https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token" \
  -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')

HEADERS=(
  -H "Authorization: Bearer $TOKEN"
  -H "Content-Type: application/json"
  -H "OData-MaxVersion: 4.0"
  -H "OData-Version: 4.0"
  -H "Prefer: return=representation"
)

# Export recent emails (last 30 minutes)
nylas email list --json --limit 50 > /tmp/recent-emails.json

# Upsert contacts from sender addresses
cat /tmp/recent-emails.json | jq -c '.[] | .from[0]' | while read -r sender; do
  EMAIL=$(echo "$sender" | jq -r '.email')
  NAME=$(echo "$sender" | jq -r '.name // ""')
  FIRST=$(echo "$NAME" | awk '{print $1}')
  LAST=$(echo "$NAME" | awk '{$1=""; print}' | xargs)
  DOMAIN=$(echo "$EMAIL" | cut -d@ -f2)

  # Check if contact exists
  EXISTING=$(curl -sf "$BASE_URL/contacts?\$filter=emailaddress1 eq '$EMAIL'&\$select=contactid" \
    "${HEADERS[@]}" | jq -r '.value[0].contactid // empty')

  if [ -z "$EXISTING" ]; then
    # Create new contact
    curl -sf -X POST "$BASE_URL/contacts" "${HEADERS[@]}" \
      -d "$(jq -n \
        --arg fn "$FIRST" \
        --arg ln "$LAST" \
        --arg em "$EMAIL" \
        '{firstname: $fn, lastname: $ln, emailaddress1: $em}')" \
      | jq '{contactid, fullname}'
    echo "Created contact: $EMAIL"
  else
    echo "Contact exists: $EMAIL ($EXISTING)"
  fi
done

echo "Sync complete at $(date -u +%Y-%m-%dT%H:%M:%SZ)"

Python version with MSAL authentication

A full Python script that authenticates via MSAL (Microsoft Authentication Library), exports data from the Nylas CLI, and upserts contacts and email activities into Dynamics 365. This handles pagination, deduplication, account association, and error reporting.

#!/usr/bin/env python3
"""Export Nylas CLI email data to Microsoft Dynamics 365 Sales.

Requirements:
    pip install msal requests

Environment variables:
    DYNAMICS_ORG        — Dynamics 365 org name (e.g. "contoso")
    AZURE_TENANT_ID     — Azure AD tenant ID
    AZURE_CLIENT_ID     — App registration client ID
    AZURE_CLIENT_SECRET — App registration client secret
"""

import json
import os
import subprocess
import sys
from typing import Any

import msal
import requests

# ── Configuration ──────────────────────────────────────────────

ORG = os.environ["DYNAMICS_ORG"]
TENANT_ID = os.environ["AZURE_TENANT_ID"]
CLIENT_ID = os.environ["AZURE_CLIENT_ID"]
CLIENT_SECRET = os.environ["AZURE_CLIENT_SECRET"]

BASE_URL = f"https://{ORG}.api.crm.dynamics.com/api/data/v9.2"
SCOPE = [f"https://{ORG}.crm.dynamics.com/.default"]

HEADERS = {
    "OData-MaxVersion": "4.0",
    "OData-Version": "4.0",
    "Prefer": "return=representation",
}


# ── Authentication ─────────────────────────────────────────────

def get_access_token() -> str:
    """Acquire an OAuth 2.0 token via MSAL client credentials."""
    app = msal.ConfidentialClientApplication(
        CLIENT_ID,
        authority=f"https://login.microsoftonline.com/{TENANT_ID}",
        client_credential=CLIENT_SECRET,
    )
    result = app.acquire_token_for_client(scopes=SCOPE)
    if "access_token" not in result:
        print(f"Auth failed: {result.get('error_description', 'unknown')}", file=sys.stderr)
        sys.exit(1)
    return result["access_token"]


def api_session(token: str) -> requests.Session:
    """Build a requests session with auth and OData headers."""
    s = requests.Session()
    s.headers.update({
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
        **HEADERS,
    })
    return s


# ── Nylas CLI helpers ──────────────────────────────────────────

def run_cli(*args: str) -> str:
    result = subprocess.run(
        ["nylas", *args],
        capture_output=True, text=True, check=True,
    )
    return result.stdout


def export_emails(limit: int = 200) -> list[dict]:
    raw = run_cli("email", "list", "--json", "--limit", str(limit))
    return json.loads(raw)


def export_contacts(limit: int = 500) -> list[dict]:
    raw = run_cli("contacts", "list", "--json", "--limit", str(limit))
    return json.loads(raw)


# ── Dynamics 365 operations ────────────────────────────────────

def find_contact(session: requests.Session, email: str) -> str | None:
    """Find a contact by email. Returns contactid or None."""
    resp = session.get(
        f"{BASE_URL}/contacts",
        params={
            "$filter": f"emailaddress1 eq '{email}'",
            "$select": "contactid",
            "$top": "1",
        },
    )
    resp.raise_for_status()
    records = resp.json().get("value", [])
    return records[0]["contactid"] if records else None


def upsert_contact(session: requests.Session, contact: dict[str, Any]) -> str:
    """Create or update a contact. Returns contactid."""
    email = contact.get("emails", [{}])[0].get("email", "")
    if not email:
        raise ValueError("Contact has no email address")

    existing_id = find_contact(session, email)
    payload = {
        "emailaddress1": email,
        "firstname": contact.get("given_name", ""),
        "lastname": contact.get("surname", ""),
        "telephone1": (contact.get("phone_numbers") or [{}])[0].get("number", ""),
        "jobtitle": contact.get("job_title", ""),
        "websiteurl": (contact.get("web_pages") or [{}])[0].get("url", ""),
    }
    # Remove empty values
    payload = {k: v for k, v in payload.items() if v}

    if existing_id:
        resp = session.patch(f"{BASE_URL}/contacts({existing_id})", json=payload)
        resp.raise_for_status()
        print(f"  Updated contact: {email} ({existing_id})")
        return existing_id
    else:
        resp = session.post(f"{BASE_URL}/contacts", json=payload)
        resp.raise_for_status()
        contact_id = resp.json()["contactid"]
        print(f"  Created contact: {email} ({contact_id})")
        return contact_id


def find_or_create_account(session: requests.Session, domain: str) -> str:
    """Find or create an account by domain. Returns accountid."""
    company = domain.split(".")[0].title()
    resp = session.get(
        f"{BASE_URL}/accounts",
        params={
            "$filter": f"name eq '{company}'",
            "$select": "accountid",
            "$top": "1",
        },
    )
    resp.raise_for_status()
    records = resp.json().get("value", [])

    if records:
        return records[0]["accountid"]

    resp = session.post(f"{BASE_URL}/accounts", json={
        "name": company,
        "websiteurl": f"https://{domain}",
    })
    resp.raise_for_status()
    account_id = resp.json()["accountid"]
    print(f"  Created account: {company} ({account_id})")
    return account_id


def link_contact_to_account(
    session: requests.Session, contact_id: str, account_id: str
) -> None:
    """Associate a contact with a parent account."""
    session.patch(
        f"{BASE_URL}/contacts({contact_id})",
        json={
            "parentcustomerid_account@odata.bind": f"/accounts({account_id})",
        },
    ).raise_for_status()


def log_email_activity(
    session: requests.Session,
    email: dict[str, Any],
    sender_contact_id: str | None,
    recipient_contact_ids: list[str],
) -> str:
    """Create an email activity record with party lists."""
    parties: list[dict] = []

    # From party
    if sender_contact_id:
        parties.append({
            "partyid_contact@odata.bind": f"/contacts({sender_contact_id})",
            "participationtypemask": 1,
        })

    # To parties
    for cid in recipient_contact_ids:
        parties.append({
            "partyid_contact@odata.bind": f"/contacts({cid})",
            "participationtypemask": 2,
        })

    payload = {
        "subject": email.get("subject", "(no subject)"),
        "description": email.get("snippet", ""),
        "actualstart": email.get("date", ""),
        "actualend": email.get("date", ""),
        "directioncode": True,
        "email_activity_parties": parties,
    }

    resp = session.post(f"{BASE_URL}/emails", json=payload)
    resp.raise_for_status()
    activity_id = resp.json()["activityid"]
    print(f"  Logged email activity: {email.get('subject', '')} ({activity_id})")
    return activity_id


# ── Main pipeline ──────────────────────────────────────────────

def main() -> None:
    print("Authenticating with Azure AD...")
    token = get_access_token()
    session = api_session(token)

    # Phase 1: Sync contacts
    print("\nExporting contacts from Nylas CLI...")
    contacts = export_contacts(limit=500)
    print(f"Found {len(contacts)} contacts")

    contact_map: dict[str, str] = {}  # email → contactid
    freemail_domains = {"gmail.com", "yahoo.com", "outlook.com", "hotmail.com"}

    print("\nUpserting contacts into Dynamics 365...")
    for c in contacts:
        emails = c.get("emails", [])
        if not emails:
            continue
        email_addr = emails[0]["email"]
        domain = email_addr.split("@")[1]

        # Skip freemail — no meaningful account to create
        if domain in freemail_domains:
            contact_id = upsert_contact(session, c)
            contact_map[email_addr] = contact_id
            continue

        contact_id = upsert_contact(session, c)
        contact_map[email_addr] = contact_id

        # Create or find account and link
        account_id = find_or_create_account(session, domain)
        link_contact_to_account(session, contact_id, account_id)

    # Phase 2: Log email activities
    print("\nExporting emails from Nylas CLI...")
    emails = export_emails(limit=200)
    print(f"Found {len(emails)} emails")

    print("\nLogging email activities into Dynamics 365...")
    for em in emails:
        from_email = em.get("from", [{}])[0].get("email", "")
        to_emails = [r["email"] for r in em.get("to", []) if "email" in r]

        sender_id = contact_map.get(from_email)
        recipient_ids = [
            contact_map[e] for e in to_emails if e in contact_map
        ]

        if sender_id or recipient_ids:
            log_email_activity(session, em, sender_id, recipient_ids)

    print("\nSync complete.")


if __name__ == "__main__":
    main()

TypeScript version with MSAL Node

The same pipeline in TypeScript using @azure/msal-node for authentication and the native fetch API for Dynamics 365 Web API calls.

#!/usr/bin/env npx tsx
/**
 * Export Nylas CLI email data to Microsoft Dynamics 365 Sales.
 *
 * Requirements:
 *   npm install @azure/msal-node
 *
 * Environment variables:
 *   DYNAMICS_ORG, AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET
 */

import { ConfidentialClientApplication } from "@azure/msal-node";
import { execFileSync } from "child_process";

// ── Configuration ──────────────────────────────────────────────

const ORG = process.env.DYNAMICS_ORG!;
const TENANT_ID = process.env.AZURE_TENANT_ID!;
const CLIENT_ID = process.env.AZURE_CLIENT_ID!;
const CLIENT_SECRET = process.env.AZURE_CLIENT_SECRET!;

const BASE_URL = `https://${ORG}.api.crm.dynamics.com/api/data/v9.2`;

const ODATA_HEADERS: Record<string, string> = {
  "Content-Type": "application/json",
  "OData-MaxVersion": "4.0",
  "OData-Version": "4.0",
  "Prefer": "return=representation",
};

const FREEMAIL_DOMAINS = new Set([
  "gmail.com", "yahoo.com", "outlook.com", "hotmail.com",
]);

// ── Authentication ─────────────────────────────────────────────

async function getAccessToken(): Promise<string> {
  const app = new ConfidentialClientApplication({
    auth: {
      clientId: CLIENT_ID,
      authority: `https://login.microsoftonline.com/${TENANT_ID}`,
      clientSecret: CLIENT_SECRET,
    },
  });
  const result = await app.acquireTokenByClientCredential({
    scopes: [`https://${ORG}.crm.dynamics.com/.default`],
  });
  if (!result?.accessToken) {
    throw new Error("Failed to acquire access token");
  }
  return result.accessToken;
}

function headers(token: string): Record<string, string> {
  return { ...ODATA_HEADERS, Authorization: `Bearer ${token}` };
}

// ── Nylas CLI helpers ──────────────────────────────────────────

function runCli(...args: string[]): string {
  return execFileSync("nylas", args, { encoding: "utf-8" });
}

interface NylasEmail {
  id: string;
  from: { email: string; name?: string }[];
  to: { email: string; name?: string }[];
  subject?: string;
  snippet?: string;
  date?: string;
}

interface NylasContact {
  given_name?: string;
  surname?: string;
  emails?: { email: string }[];
  phone_numbers?: { number: string }[];
  company_name?: string;
  job_title?: string;
}

// ── Dynamics 365 operations ────────────────────────────────────

async function findContact(
  token: string,
  email: string,
): Promise<string | null> {
  const params = new URLSearchParams({
    $filter: `emailaddress1 eq '${email}'`,
    $select: "contactid",
    $top: "1",
  });
  const resp = await fetch(`${BASE_URL}/contacts?${params}`, {
    headers: headers(token),
  });
  if (!resp.ok) throw new Error(`Find contact failed: ${resp.status}`);
  const data = await resp.json();
  return data.value?.[0]?.contactid ?? null;
}

async function upsertContact(
  token: string,
  contact: NylasContact,
): Promise<string> {
  const email = contact.emails?.[0]?.email;
  if (!email) throw new Error("Contact has no email");

  const payload: Record<string, string> = {};
  if (email) payload.emailaddress1 = email;
  if (contact.given_name) payload.firstname = contact.given_name;
  if (contact.surname) payload.lastname = contact.surname;
  if (contact.phone_numbers?.[0]?.number)
    payload.telephone1 = contact.phone_numbers[0].number;
  if (contact.job_title) payload.jobtitle = contact.job_title;

  const existingId = await findContact(token, email);

  if (existingId) {
    const resp = await fetch(`${BASE_URL}/contacts(${existingId})`, {
      method: "PATCH",
      headers: headers(token),
      body: JSON.stringify(payload),
    });
    if (!resp.ok) throw new Error(`Update contact failed: ${resp.status}`);
    console.log(`  Updated contact: ${email} (${existingId})`);
    return existingId;
  }

  const resp = await fetch(`${BASE_URL}/contacts`, {
    method: "POST",
    headers: headers(token),
    body: JSON.stringify(payload),
  });
  if (!resp.ok) throw new Error(`Create contact failed: ${resp.status}`);
  const data = await resp.json();
  console.log(`  Created contact: ${email} (${data.contactid})`);
  return data.contactid;
}

async function findOrCreateAccount(
  token: string,
  domain: string,
): Promise<string> {
  const company = domain.split(".")[0].charAt(0).toUpperCase()
    + domain.split(".")[0].slice(1);

  const params = new URLSearchParams({
    $filter: `name eq '${company}'`,
    $select: "accountid",
    $top: "1",
  });
  const resp = await fetch(`${BASE_URL}/accounts?${params}`, {
    headers: headers(token),
  });
  if (!resp.ok) throw new Error(`Find account failed: ${resp.status}`);
  const data = await resp.json();
  if (data.value?.[0]) return data.value[0].accountid;

  const createResp = await fetch(`${BASE_URL}/accounts`, {
    method: "POST",
    headers: headers(token),
    body: JSON.stringify({
      name: company,
      websiteurl: `https://${domain}`,
    }),
  });
  if (!createResp.ok) throw new Error(`Create account failed: ${createResp.status}`);
  const created = await createResp.json();
  console.log(`  Created account: ${company} (${created.accountid})`);
  return created.accountid;
}

async function linkContactToAccount(
  token: string,
  contactId: string,
  accountId: string,
): Promise<void> {
  const resp = await fetch(`${BASE_URL}/contacts(${contactId})`, {
    method: "PATCH",
    headers: headers(token),
    body: JSON.stringify({
      "parentcustomerid_account@odata.bind": `/accounts(${accountId})`,
    }),
  });
  if (!resp.ok) throw new Error(`Link contact to account failed: ${resp.status}`);
}

interface ActivityParty {
  "partyid_contact@odata.bind": string;
  participationtypemask: number;
}

async function logEmailActivity(
  token: string,
  email: NylasEmail,
  senderContactId: string | null,
  recipientContactIds: string[],
): Promise<string> {
  const parties: ActivityParty[] = [];

  if (senderContactId) {
    parties.push({
      "partyid_contact@odata.bind": `/contacts(${senderContactId})`,
      participationtypemask: 1,
    });
  }
  for (const cid of recipientContactIds) {
    parties.push({
      "partyid_contact@odata.bind": `/contacts(${cid})`,
      participationtypemask: 2,
    });
  }

  const resp = await fetch(`${BASE_URL}/emails`, {
    method: "POST",
    headers: headers(token),
    body: JSON.stringify({
      subject: email.subject ?? "(no subject)",
      description: email.snippet ?? "",
      actualstart: email.date ?? "",
      actualend: email.date ?? "",
      directioncode: true,
      email_activity_parties: parties,
    }),
  });
  if (!resp.ok) throw new Error(`Create email activity failed: ${resp.status}`);
  const data = await resp.json();
  console.log(`  Logged email: ${email.subject} (${data.activityid})`);
  return data.activityid;
}

// ── Main pipeline ──────────────────────────────────────────────

async function main(): Promise<void> {
  console.log("Authenticating with Azure AD...");
  const token = await getAccessToken();

  // Phase 1: Sync contacts
  console.log("\nExporting contacts from Nylas CLI...");
  const contacts: NylasContact[] = JSON.parse(
    runCli("contacts", "list", "--json", "--limit", "500"),
  );
  console.log(`Found ${contacts.length} contacts`);

  const contactMap = new Map<string, string>(); // email → contactid

  console.log("\nUpserting contacts into Dynamics 365...");
  for (const c of contacts) {
    const email = c.emails?.[0]?.email;
    if (!email) continue;
    const domain = email.split("@")[1];

    const contactId = await upsertContact(token, c);
    contactMap.set(email, contactId);

    if (!FREEMAIL_DOMAINS.has(domain)) {
      const accountId = await findOrCreateAccount(token, domain);
      await linkContactToAccount(token, contactId, accountId);
    }
  }

  // Phase 2: Log email activities
  console.log("\nExporting emails from Nylas CLI...");
  const emails: NylasEmail[] = JSON.parse(
    runCli("email", "list", "--json", "--limit", "200"),
  );
  console.log(`Found ${emails.length} emails`);

  console.log("\nLogging email activities into Dynamics 365...");
  for (const em of emails) {
    const fromEmail = em.from?.[0]?.email ?? "";
    const toEmails = (em.to ?? []).map((r) => r.email);

    const senderId = contactMap.get(fromEmail) ?? null;
    const recipientIds = toEmails
      .map((e) => contactMap.get(e))
      .filter((id): id is string => !!id);

    if (senderId || recipientIds.length > 0) {
      await logEmailActivity(token, em, senderId, recipientIds);
    }
  }

  console.log("\nSync complete.");
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

Next steps