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:
- An Azure AD app registration with application permissions (not delegated)
- Admin consent granted for the target Microsoft 365 tenant
- The DPA accepted by a tenant admin in the GTM Clarity dashboard
- The environment variables listed below
Required Azure AD Permissions
| Permission | Type | Purpose |
|---|---|---|
Calendars.Read | Application | Read calendar events for all users |
Mail.ReadBasic.All | Application | Read email metadata (not body) for all users |
User.Read.All | Application | Enumerate users for sync |
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
| Variable | Description |
|---|---|
O365_CLIENT_ID | Azure AD application (client) ID |
O365_CLIENT_SECRET | Azure AD application client secret |
O365_REDIRECT_URI | Admin consent redirect URI |
CONNECTOR_ENCRYPTION_KEY | 32-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:
- DPA gate first --
assertDpaAccepted()is the FIRST statement insync(). If the DPA is not accepted, sync throws immediately. - Metadata only -- the
Mail.ReadBasic.Allscope prevents access to email bodies at the API level. Field selection in sync modules provides a second layer of protection. - Domain allowlist -- only participants on explicitly allowed external domains produce activity records. The default allowlist is empty (zero domains), making the system fail-safe.
- Internal exclusion -- the tenant's own O365 domain is always excluded from ingestion. Internal-only meetings and emails produce zero activities.
- 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:
| Field | Description |
|---|---|
allowedExternalDomains | Array of domains to ingest (e.g., ["acme.com", "partner.io"]) |
tenantDomain | The tenant's own O365 domain (always excluded) |
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.
Admin Consent 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:
| Function | Purpose |
|---|---|
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 acceptancedpaAcceptedByUserId-- 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
| Parameter | Value |
|---|---|
| Graph endpoint | /users/{userId}/calendarView/delta |
| Lookback window | 90 days on initial sync |
| Pagination | Delta queries with @odata.deltaLink |
| Output type | One 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
| Parameter | Value |
|---|---|
| Graph endpoint | /users/{userId}/messages/delta |
| Fields requested | id, subject, receivedDateTime, conversationId, sender, recipients |
| Output type | One email_thread activity per message with allowed participants |
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:
- Skip events with
@removedproperty (delta deletions) - Apply
filterCalendarAttendees()from the privacy module - If no attendees pass the domain filter, produce zero activities
- For each passing attendee, create a
meeting_attendactivity:
{
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:
- Skip messages with
@removedproperty - Collect all participants (sender + to + cc)
- Apply
filterEmailParticipants()from the privacy module - If no participants pass, return
null(internal-only thread) - Create one
email_threadactivity:
{
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 Field | Target Field | Transform |
|---|---|---|
id | externalId | none |
subject | properties.meetingSubject | trim |
start.dateTime | occurredAt | none |
attendees[].emailAddress.address | personExternalId | lowercase |
organizer.emailAddress.address | properties.organizerEmail | lowercase |
isOnlineMeeting | properties.isOnline | none |
Email Mappings
| Source Field | Target Field | Transform |
|---|---|---|
id | externalId | none |
receivedDateTime | occurredAt | none |
conversationId | properties.conversationId | none |
toRecipients[].emailAddress.address | personExternalId | lowercase |
subject | properties.subject | trim |
Setup Steps
- Register an Azure AD application with the required application permissions
- Grant admin consent for the target Microsoft 365 tenant
- Configure environment variables (
O365_CLIENT_ID,O365_CLIENT_SECRET,O365_REDIRECT_URI,CONNECTOR_ENCRYPTION_KEY) - Navigate to Settings > Connectors in the GTM Clarity dashboard
- Click "Connect Microsoft 365" to initiate the admin consent flow
- Accept the DPA -- a tenant admin must review and accept the Data Processing Agreement
- Configure the domain allowlist -- add external domains you want to track (e.g., prospect companies)
- Trigger initial sync -- the system will pull 90 days of calendar and email history
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
| Issue | Cause | Resolution |
|---|---|---|
| Sync blocked by DPA | DPA not accepted | Have a tenant admin accept the DPA in connector settings |
| Zero activities after sync | Empty domain allowlist | Add prospect domains to the allowlist |
| Token acquisition fails | Missing admin consent | Re-run admin consent flow for the Microsoft tenant |
| Calendar events missing | Insufficient permissions | Verify Calendars.Read application permission is granted |
| Email metadata errors | Wrong permission scope | Confirm Mail.ReadBasic.All (not Mail.Read) is granted |