Skip to main content

Microsoft 365 Connector

The Microsoft 365 connector extracts meeting and email engagement signals from Microsoft Graph API using a privacy-first, metadata-only architecture. Email body content is never requested or stored. A mandatory Data Processing Agreement (DPA) gate blocks all sync operations until a tenant admin explicitly accepts.

Prerequisites

Before connecting Microsoft 365, you need:

  1. An Azure AD app registration with application permissions (not delegated)
  2. Admin consent granted for the target Microsoft 365 tenant
  3. The DPA accepted by a tenant admin in the GTM Clarity dashboard
  4. The environment variables listed below

Required Azure AD Permissions

PermissionTypePurpose
Calendars.ReadApplicationRead calendar events for all users
Mail.ReadBasic.AllApplicationRead email metadata (not body) for all users
User.Read.AllApplicationEnumerate users for sync
Application vs Delegated permissions

The connector uses application permissions, not delegated. This is required because background Temporal workers cannot present a signed-in user context. Application permissions require admin consent.

Environment Variables

VariableDescription
O365_CLIENT_IDAzure AD application (client) ID
O365_CLIENT_SECRETAzure AD application client secret
O365_REDIRECT_URIAdmin consent redirect URI
CONNECTOR_ENCRYPTION_KEY32-byte hex string for AES-256-GCM token encryption

Privacy Architecture

Privacy Invariants

These rules are enforced at the code level and cannot be bypassed:

  1. DPA gate first -- assertDpaAccepted() is the FIRST statement in sync(). If the DPA is not accepted, sync throws immediately.
  2. Metadata only -- the Mail.ReadBasic.All scope prevents access to email bodies at the API level. Field selection in sync modules provides a second layer of protection.
  3. Domain allowlist -- only participants on explicitly allowed external domains produce activity records. The default allowlist is empty (zero domains), making the system fail-safe.
  4. Internal exclusion -- the tenant's own O365 domain is always excluded from ingestion. Internal-only meetings and emails produce zero activities.
  5. No body storage -- email subject and conversation ID are the only text fields stored. No attachments, no body content.

Domain Allowlist

The privacy filter is configured per-tenant:

FieldDescription
allowedExternalDomainsArray of domains to ingest (e.g., ["acme.com", "partner.io"])
tenantDomainThe tenant's own O365 domain (always excluded)
Fail-safe default

If allowedExternalDomains is empty (the default), zero events are ingested. An admin must explicitly add each external domain before any data flows.

Authentication

The connector uses MSAL (Microsoft Authentication Library) with the client credentials flow.

Token Management

  • Tokens are acquired via ConfidentialClientApplication.acquireTokenByClientCredential()
  • Token cache includes a 5-minute buffer before expiry to prevent mid-sync failures
  • The Graph SDK client factory creates an authenticated client with automatic token refresh
  • All tokens are encrypted at rest with AES-256-GCM

DPA Gate

The DPA (Data Processing Agreement) gate prevents sync from running until a tenant admin explicitly accepts. This is implemented in dpa-gate.ts:

FunctionPurpose
assertDpaAccepted(tenantId, db)Throws if DPA not accepted; called as first line of sync()
acceptDpa(tenantId, userId, connectorId, db)Records acceptance with timestamp and accepting user ID

The DPA record is stored in the o365_config table with:

  • dpaAcceptedAt -- timestamp of acceptance
  • dpaAcceptedByUserId -- Clerk user ID of the accepting admin

Sync Pipeline

The connector syncs two data types from Microsoft Graph: calendar events and email metadata.

Calendar Event Sync

ParameterValue
Graph endpoint/users/{userId}/calendarView/delta
Lookback window90 days on initial sync
PaginationDelta queries with @odata.deltaLink
Output typeOne meeting_attend activity per filtered attendee

Calendar events produce per-attendee activities: a meeting with 3 external attendees on allowed domains creates 3 separate CanonicalActivity records. This enables per-person engagement scoring.

Email Metadata Sync

ParameterValue
Graph endpoint/users/{userId}/messages/delta
Fields requestedid, subject, receivedDateTime, conversationId, sender, recipients
Output typeOne email_thread activity per message with allowed participants
Delta queries

Both calendar and email sync use Graph API delta queries. On the first run, the connector performs a full pull and receives a deltaLink. Subsequent runs use the delta link to fetch only changes since the last sync. Delta tokens are persisted in the database per connector.

Event Transformation

Calendar Events to Activities

Each calendar event is transformed as follows:

  1. Skip events with @removed property (delta deletions)
  2. Apply filterCalendarAttendees() from the privacy module
  3. If no attendees pass the domain filter, produce zero activities
  4. For each passing attendee, create a meeting_attend activity:
{
externalId: "o365_cal_{eventId}_{attendeeEmail}",
type: "meeting_attend",
channel: "meetings",
occurredAt: startTime,
personExternalId: attendeeEmail,
properties: {
meetingSubject: "Q1 Planning",
durationMinutes: 60,
isOnline: true,
organizerEmail: "org@tenant.com",
attendeeCount: 5,
responseStatus: "accepted",
accountDomain: "acme.com",
}
}

Email Messages to Activities

Each email message is transformed as follows:

  1. Skip messages with @removed property
  2. Collect all participants (sender + to + cc)
  3. Apply filterEmailParticipants() from the privacy module
  4. If no participants pass, return null (internal-only thread)
  5. Create one email_thread activity:
{
externalId: "o365_email_{messageId}",
type: "email_thread",
channel: "email",
occurredAt: receivedDateTime,
personExternalId: "buyer@acme.com",
properties: {
conversationId: "conv_abc",
participantCount: 2,
participantEmails: ["buyer@acme.com", "champion@acme.com"],
}
}

Default Field Mappings

Calendar Mappings

Source FieldTarget FieldTransform
idexternalIdnone
subjectproperties.meetingSubjecttrim
start.dateTimeoccurredAtnone
attendees[].emailAddress.addresspersonExternalIdlowercase
organizer.emailAddress.addressproperties.organizerEmaillowercase
isOnlineMeetingproperties.isOnlinenone

Email Mappings

Source FieldTarget FieldTransform
idexternalIdnone
receivedDateTimeoccurredAtnone
conversationIdproperties.conversationIdnone
toRecipients[].emailAddress.addresspersonExternalIdlowercase
subjectproperties.subjecttrim

Setup Steps

  1. Register an Azure AD application with the required application permissions
  2. Grant admin consent for the target Microsoft 365 tenant
  3. Configure environment variables (O365_CLIENT_ID, O365_CLIENT_SECRET, O365_REDIRECT_URI, CONNECTOR_ENCRYPTION_KEY)
  4. Navigate to Settings > Connectors in the GTM Clarity dashboard
  5. Click "Connect Microsoft 365" to initiate the admin consent flow
  6. Accept the DPA -- a tenant admin must review and accept the Data Processing Agreement
  7. Configure the domain allowlist -- add external domains you want to track (e.g., prospect companies)
  8. Trigger initial sync -- the system will pull 90 days of calendar and email history
No data until allowlist is configured

With the default empty allowlist, zero activities will be created even after a successful sync. You must add at least one external domain.

Health Check

The health check verifies Graph API accessibility by making a lightweight GET /organization?$select=id&$top=1 call. This confirms the application has valid credentials and the target tenant is reachable.

Troubleshooting

IssueCauseResolution
Sync blocked by DPADPA not acceptedHave a tenant admin accept the DPA in connector settings
Zero activities after syncEmpty domain allowlistAdd prospect domains to the allowlist
Token acquisition failsMissing admin consentRe-run admin consent flow for the Microsoft tenant
Calendar events missingInsufficient permissionsVerify Calendars.Read application permission is granted
Email metadata errorsWrong permission scopeConfirm Mail.ReadBasic.All (not Mail.Read) is granted