cirle 1

PSMA – Tracking M365 User Activity in MIM with the Graph Reports API

If you have been working in identity governance for any length of time, you have almost certainly encountered the question: “Is this account actually being used?” It sounds simple. It is not. In a mature Microsoft 365 environment, answering it properly requires pulling together sign-in data from Entra ID, mailbox activity from Exchange Online, and enough freshness in the data to make a governance decision you can defend.

This post introduces a new Granfeldt PSMA — mim-m365-activity — that does exactly that, and specifically digs into why the Microsoft Graph getEmailActivityUserDetail endpoint is the right tool for the Exchange side of this problem.

Why people want this data

The classic driver is licence cost (or security compliance). An Exchange Online Plan 2 licence sitting on an account that last received an email two years ago represents a straightforward saving once you can prove the inactivity. But the value does not stop at procurement.

  • Access certification: Reviewers need real activity signals, not just “account exists.” A last-activity date from Exchange and a last sign-in date from Entra together give a much more honest picture of risk than either alone.
  • Orphan and stale account detection: AD accounts that have been off-boarded from HR but never disabled often show no Entra sign-in activity but may still have mail flow, forwarding rules, or delegation — the mailbox side tells the full story.
  • Joiner–mover–leaver automation: Being able to suppress or extend provisioning actions based on whether an account has been genuinely active makes lifecycle rules far more defensible.
  • Audit and compliance: Many regulatory frameworks require evidence that privileged and service accounts are monitored. Having this data flowing into MIM’s metaverse means it can be reported on, acted on, and exported to whatever governance tooling is downstream.

The frustrating part, historically, has been getting this data into MIM in a clean, reliable, and maintainable way. The Graph Reports API solves the Exchange half of this cleanly.

The getEmailActivityUserDetail endpoint

The Microsoft Graph reportRoot: getEmailActivityUserDetail endpoint returns a per-user breakdown of Exchange Online mail activity over a specified reporting window (D7, D30, D90, or D180). What makes it genuinely useful for governance work rather than just reporting dashboards is the combination of fields it returns:

FieldTypeGovernance value
User Principal NamestringJoin key to Entra / MIM objects
Last Activity DatedateCore inactivity signal for mailbox
Send CountintDistinguish read-only from active senders
Receive CountintDetect mail flow without human interaction
Read CountintConfirm genuine user engagement
Is DeletedboolSoft-delete detection
Deleted DatedateRetention / recovery window tracking
Assigned ProductsstringLicence reconciliation

Note on privacy settings: By default, Microsoft anonymises user-identifying data in usage reports at tenant level. You need to turn off the “Show concealed user, group, and site names in all reports” setting in the Microsoft 365 admin centre to see real UPNs. The PSMA README covers this.

The endpoint is available on both the /v1.0 and /beta tracks. The beta variant also supports JSON output via ?$format=application/json which simplifies parsing, though the v1.0 CSV response is stable and well-suited to the PowerShell pipeline used here.

The call itself is minimal — a single authenticated GET against the reports endpoint with a period parameter.

# Get email activity for the last 30 days
$uri = "https://graph.microsoft.com/v1.0/reports/getEmailActivityUserDetail(period='D30')"

The required Graph permission is Reports.Read.All (application permission). No delegated permission is needed for the PSMA use case — the management agent authenticates as an app registration with a client secret or certificate.

How the PSMA works

The management agent combines two data sources per user object: the Graph Reports API (Exchange activity) and the Graph /users/{id}/signInActivity endpoint (Entra sign-in data). These are merged on UPN and presented as a single flat object in the MIM connector space.

Repository structure

The repo follows the standard Granfeldt PSMA layout — three scripts and a schema file:

PSSchema.ps1 — defines the object class and attribute schema presented to MIM Sync

<#
.SYNOPSIS
PSSchema.ps1 – M365 Management Agent Schema Definition
.DESCRIPTION
Schema for Entra and Exchange Online last sign-in activity
Source System:
– Entra ID (User and Audit Logs)
– Exchange Online (Optional)
– Exchange Usage Reports (Optional)
.NOTES
Version History
– v1.0 – Initial schema definition for M365 activity data
.LINK
Author – Almero Steyn – https://www.puttyq.com
Integralis Website – http://www.integralis.co.za
Integralis Support – [email protected]
Granfeldt PowerShell Management Agent – https://github.com/sorengranfeldt/psma
#>
param
(
$Username,
$Password,
$Credentials,
$AuxUsername,
$AuxPassword,
$AuxCredentials,
$ConfigurationParameter
)
$obj = New-Object Type PSCustomObject
$obj | Add-Member Type NoteProperty Name "Anchor-UserPrincipalName|String" Value "[email protected]"
$obj | Add-Member Type NoteProperty Name "objectClass|String" Value "user"
$obj | Add-Member Type NoteProperty Name "LastInteractiveSignIn|String" Value "2023-01-01T12:00:00Z"
$obj | Add-Member Type NoteProperty Name "LastInteractiveAgeDays|String" Value "345"
$obj | Add-Member Type NoteProperty Name "LastNonInteractiveSignIn|String" Value "2023-01-15T12:00:00Z"
$obj | Add-Member Type NoteProperty Name "LastNonInteractiveAgeDays|String" Value "345"
$obj | Add-Member Type NoteProperty Name "LastSentMailDate|String" Value "2023-01-20T12:00:00Z"
$obj | Add-Member Type NoteProperty Name "LastSentMailAgeDays|String" Value "345"
$obj | Add-Member Type NoteProperty Name "ReportReadCount|String" Value "100"
$obj | Add-Member Type NoteProperty Name "ReportReceiveCount|String" Value "100"
$obj | Add-Member Type NoteProperty Name "ReportSendCount|String" Value "100"
$obj | Add-Member Type NoteProperty Name "ReportLastActivityDate|String" Value "2023-01-20T12:00:00Z"
$obj | Add-Member Type NoteProperty Name "ReportPeriod|String" Value "D180"
$obj

PSImport.ps1 — full import script; calls both Graph endpoints and merges results

<#
.SYNOPSIS
PSImport.ps1 – M365 Management Agent Import Script
.DESCRIPTION
A PSMA to import M365 activity (directory and Exchange Online) from M365.
This import script reads Entra user, audit logs, and Exchange Online activity from
Microsoft 365 and exposes it to Microsoft Identity Manager / ECMA Host through the Granfeldt PowerShell Management Agent.
Required Entra ID (Azure AD) permissions (Application Registration):
– AuditLog.Read.All (sign-in activity)
– Directory.Read.All (user enumeration)
– Report.Read.All (getEmailActivityUserDetail report)
– User.Read.All (user enumeration)
OPTIONAL – Required Exchange Online permissions (Application Registration):
– Mail.Read (mailbox activity)
– Exchange Admin or delegated mailbox access (for Get-Mailbox and Get-MailboxStatistics)
.NOTES
Version History
v1.0 – Initial M365 PSImport script with sign-in activity and optional mailbox activity
v1.1 – Added feature flag to enable/disable Exchange mailbox check, and error handling for non-mailbox users
v1.2 – Added getEmailActivityUserDetail report import (with configurable period)
v1.3 – Added structured logging via PowerShell Logging module
.LINK
Author – Almero Steyn – https://www.puttyq.com
Integralis Website – http://www.integralis.co.za
Integralis Support – [email protected]
Granfeldt PowerShell Management Agent – https://github.com/sorengranfeldt/psma
#>
PARAM
(
$Username,
$Password,
$Credentials,
$AuxUsername,
$AuxPassword,
$AuxCredentials,
$OperationType,
$UsePagedImport,
$PageSize,
$ImportPageNumber,
$ConfigurationParameter,
$Schema
)
BEGIN
{
# Setup structured logging using PowerShell Logging module
Import-Module Logging
# Logging – Configure targets
$LogPath = if ($ConfigurationParameter.LogPath) {
$ConfigurationParameter.LogPath
} else {
".\Debug"
}
Add-LoggingTarget -Name File -Configuration @{
Path = "$LogPath\Import-%{+%Y%m%d}.log"
Append = $true
Encoding = 'ascii'
RotateAfterAmount = 10
RotateAmount = 1
RotateAfterSize = 1048576
}
Add-LoggingTarget -Name Console -Configuration @{
PrintException = $true
ColorMapping = @{
'DEBUG' = 'Blue'
'INFO' = 'Green'
'WARNING' = 'Yellow'
'ERROR' = 'Red'
}
}
Set-LoggingDefaultLevel -Level 'DEBUG'
Set-LoggingDefaultFormat -Format '[%{timestamp:+yyyy/MM/dd HH:mm:ss.fff}] [%{level:-7}] – %{message}'
Write-Log -Level 'INFO' -Message 'M365 Activity Import starting'
# Function to get last sent mail date for a user, with error handling for non-mailbox users and feature flag to enable/disable Exchange check
function Get-LastSentMailDate {
param(
[string]$UserPrincipalName,
[hashtable]$Headers,
[bool]$EnableExchangeCheck
)
$lastSentMailDate = $null
if ($EnableExchangeCheck) {
$MailUri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/mailFolders('sentitems')/messages?`$top=1&`$orderby=sentDateTime desc"
try {
$MailResponse = Invoke-RestMethod -Uri $MailUri -Headers $Headers
if ($MailResponse.value -and $MailResponse.value.Count -gt 0) {
$lastSentMailDate = $MailResponse.value[0].sentDateTime
Write-Log -Level 'DEBUG' -Message " Last sent mail date: $lastSentMailDate"
}
else {
$lastSentMailDate = $null
}
}
catch {
$lastSentMailDate = $null
Write-Log -Level 'WARNING' -Message " Mailbox not enabled or error: $_"
}
}
else {
$lastSentMailDate = $null
}
return $lastSentMailDate
}
function isValuePresent($passedValue)
{
if($null -ne $passedValue -and $passedValue -ne '')
{
return $true
}
return $false
}
# Variables for Entra App Registration
$ClientId = $Username
$TenantId = $Password
$ClientSecret = $AuxPassword
# Check Exchange mailbox check feature flag
$EnableExchangeCheck = ($ConfigurationParameter.EnableExchangeCheck -eq "true")
# Check getEmailActivityUserDetail via Graph API
if ($ConfigurationParameter.EmailActivityUserDetail -in @('D180','D90','D30','D7')) {
$EmailActivityUserDetail = $true
$EmailActivityUserDetailDays = $ConfigurationParameter.EmailActivityUserDetail
} else {
$EmailActivityUserDetail = $false
$EmailActivityUserDetailDays = 'D0'
}
# Uncomment to override with local test config (see TestConfig.local.ps1.example)
# . "$PSScriptRoot\TestConfig.local.ps1"
# Authenticate to Microsoft Graph (non-interactive)
$TokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
$Body = @{
client_id = $ClientId
scope = "https://graph.microsoft.com/.default"
client_secret = $ClientSecret
grant_type = "client_credentials"
}
try {
$TokenResponse = Invoke-RestMethod -Method Post -Uri $TokenEndpoint -Body $Body
$AccessToken = $TokenResponse.access_token
Write-Log -Level 'INFO' -Message "Successfully authenticated to Microsoft Graph"
}
catch {
Write-Log -Level 'ERROR' -Message "Failed to authenticate to Microsoft Graph: $_"
throw
}
Write-Log -Level 'INFO' -Message "EnableExchangeCheck: $EnableExchangeCheck"
Write-Log -Level 'INFO' -Message "EmailActivityUserDetail: $EmailActivityUserDetail (period: $EmailActivityUserDetailDays)"
}
PROCESS
{
# Get all users from Entra ID in batches, including signInActivity
$Headers = @{ Authorization = "Bearer $AccessToken" }
$users = [System.Collections.Generic.List[object]]::new()
$batchSize = 500
$filter = "userType eq 'Member'"
$select = "id,userPrincipalName,mail,mailNickname,signInActivity"
$nextLink = "https://graph.microsoft.com/v1.0/users?`$filter=$filter&`$select=$select&`$top=$batchSize"
while ($nextLink) {
Write-Log -Level 'DEBUG' -Message "Fetching batch: $nextLink"
try {
$UsersResponse = Invoke-RestMethod -Uri $nextLink -Headers $Headers
}
catch {
Write-Log -Level 'ERROR' -Message "Failed to fetch users from Microsoft Graph: $nextLink – $_"
throw
}
$UsersResponse.value | ForEach-Object { $users.Add($_) }
if ($UsersResponse.'@odata.nextLink') {
$nextLink = $UsersResponse.'@odata.nextLink'
}
else {
$nextLink = $null
}
}
Write-Log -Level 'INFO' -Message "Fetched $($users.Count) users from Entra ID"
# Get EmailActivityUserDetail report details (if enabled)
if ($EmailActivityUserDetail) {
Write-Log -Level 'INFO' -Message "Fetching EmailActivityUserDetail report (period: $EmailActivityUserDetailDays)"
try {
$EmailActivityUserDetailReportResults = Invoke-RestMethod -Method Get -Uri "https://graph.microsoft.com/v1.0/reports/getEmailActivityUserDetail(period='$EmailActivityUserDetailDays')" -Headers $Headers
$EmailActivityUserDetailData = $EmailActivityUserDetailReportResults | ConvertFrom-csv
$EmailActivityLookup = @{}
foreach ($record in $EmailActivityUserDetailData) {
$EmailActivityLookup[$record."User Principal Name"] = $record
}
Write-Log -Level 'INFO' -Message "EmailActivityUserDetail report loaded – $($EmailActivityUserDetailData.Count) records"
}
catch {
Write-Log -Level 'ERROR' -Message "Failed to fetch EmailActivityUserDetail report: $_"
$EmailActivityUserDetail = $false
}
}
# Loop through users and print sign-in dates, with optional Exchange mailbox check
for ($i = 0; $i -lt $users.Count; $i++) {
$user = $users[$i]
$userPrincipalName = $user.userPrincipalName
Write-Log -Level 'INFO' -Message "Processing user $($i + 1)/$($users.Count): $userPrincipalName"
# signInActivity is now included in $user
$lastInteractiveDate = $null
$lastNonInteractiveDate = $null
if ($user.signInActivity) {
$lastInteractiveDate = $user.signInActivity.lastSignInDateTime
$lastNonInteractiveDate = $user.signInActivity.lastNonInteractiveSignInDateTime
}
if ($user.mail -and $user.mailNickname) {
$lastSentMailDate = Get-LastSentMailDate -UserPrincipalName $userPrincipalName -Headers $Headers -EnableExchangeCheck $EnableExchangeCheck
}
else {
$lastSentMailDate = $null
Write-Log -Level 'DEBUG' -Message " Skipping Exchange mailbox check: no mail or mailNickname."
}
# Calculate age in days for each activity type
$now = Get-Date
$interactiveAgeDays = if ($lastInteractiveDate) { [math]::Ceiling(($now – [datetime]$lastInteractiveDate).TotalDays) } else { $null }
$nonInteractiveAgeDays = if ($lastNonInteractiveDate) { [math]::Ceiling(($now – [datetime]$lastNonInteractiveDate).TotalDays) } else { $null }
$sentMailAgeDays = if ($lastSentMailDate) { [math]::Ceiling(($now – [datetime]$lastSentMailDate).TotalDays) } else { $null }
# Emit import object to PSMA pipeline
$obj = @{}
$obj.Add("objectClass", "user")
if (isValuePresent $userPrincipalName) { $obj.Add("UserPrincipalName", $userPrincipalName.ToString()) }
if (isValuePresent $lastInteractiveDate) { $obj.Add("LastInteractiveSignIn", $lastInteractiveDate.ToString()) }
if (isValuePresent $interactiveAgeDays) { $obj.Add("LastInteractiveAgeDays", $interactiveAgeDays.ToString()) }
if (isValuePresent $lastNonInteractiveDate) { $obj.Add("LastNonInteractiveSignIn", $lastNonInteractiveDate.ToString()) }
if (isValuePresent $nonInteractiveAgeDays) { $obj.Add("LastNonInteractiveAgeDays", $nonInteractiveAgeDays.ToString()) }
if (isValuePresent $lastSentMailDate) { $obj.Add("LastSentMailDate", $lastSentMailDate.ToString()) }
if (isValuePresent $sentMailAgeDays) { $obj.Add("LastSentMailAgeDays", $sentMailAgeDays.ToString()) }
if ($EmailActivityUserDetail) {
$ActivityDetails = $EmailActivityLookup[$userPrincipalName]
if ($ActivityDetails) {
Write-Log -Level 'DEBUG' -Message " Email activity data found for $userPrincipalName"
if (isValuePresent $ActivityDetails."Read Count") { $obj.Add("ReportReadCount", ($ActivityDetails."Read Count").ToString()) }
if (isValuePresent $ActivityDetails."Receive Count") { $obj.Add("ReportReceiveCount", ($ActivityDetails."Receive Count").ToString()) }
if (isValuePresent $ActivityDetails."Send Count") { $obj.Add("ReportSendCount", ($ActivityDetails."Send Count").ToString()) }
if (isValuePresent $ActivityDetails."Last Activity Date") { $obj.Add("ReportLastActivityDate", ($ActivityDetails."Last Activity Date").ToString()) }
if (isValuePresent $ActivityDetails."Report Period") { $obj.Add("ReportPeriod", ($ActivityDetails."Report Period").ToString()) }
}
else {
Write-Log -Level 'WARNING' -Message " No email activity report data found for $userPrincipalName"
}
}
$obj
}
}
END
{
Write-Log -Level 'INFO' -Message "M365 Activity Import complete – processed $($users.Count) users"
Wait-Logging
}

App registration requirements

Create a dedicated app registration in Entra ID. The MA needs three Graph application permissions:

  • Reports.Read.All — for the email activity report
  • AuditLog.Read.All — for the sign-in activity data
  • User.Read.All – for all users data

All require admin consent. A certificate credential is recommended over a client secret for production deployments.

Authentication

# Token acquisition in PSImport.ps1 (client credentials flow)
$tokenBody = @{
grant_type = "client_credentials"
client_id = $settings.ClientId
client_secret = $settings.ClientSecret
scope = "https://graph.microsoft.com/.default"
}
$tokenResponse = Invoke-RestMethod \`
-Uri "https://login.microsoftonline.com/$($settings.TenantId)/oauth2/v2.0/token" \`
-Method Post \`
-Body $tokenBody
$accessToken = $tokenResponse.access_token

Merging the two data sources

# 1. Pull email activity report (returns CSV, convert to objects)
$emailActivity = Invoke-RestMethod \`
-Uri "https://graph.microsoft.com/v1.0/reports/getEmailActivityUserDetail(period='D30')" \`
-Headers @{Authorization="Bearer $accessToken"} | ConvertFrom-Csv
# Build a lookup hashtable keyed on UPN for fast joins
$emailIndex = @{}
foreach ($row in $emailActivity) {
$emailIndex[$row.'User Principal Name'] = $row
}
# 2. Enumerate users with signInActivity (paged)
$usersUri = "https://graph.microsoft.com/v1.0/users?`$select=id,userPrincipalName,displayName,signInActivity&`$top=999"
do {
$page = Invoke-RestMethod -Uri $usersUri -Headers @{Authorization="Bearer $accessToken"}
foreach ($user in $page.value) {
$mail = $emailIndex[$user.userPrincipalName]
# Emit combined object to MIM connector space
$obj = New-Object -Type PSCustomObject
$obj | Add-Member -NotePropertyName "Anchor-UPN|String" -NotePropertyValue $user.userPrincipalName
$obj | Add-Member -NotePropertyName "displayName|String" -NotePropertyValue $user.displayName
$obj | Add-Member -NotePropertyName "lastSignInDateTime|String" -NotePropertyValue $user.signInActivity.lastSignInDateTime
$obj | Add-Member -NotePropertyName "mailLastActivityDate|String" -NotePropertyValue $mail?.'Last Activity Date'
$obj | Add-Member -NotePropertyName "mailSendCount|String" -NotePropertyValue $mail?.'Send Count'
$obj | Add-Member -NotePropertyName "mailReceiveCount|String" -NotePropertyValue $mail?.'Receive Count'
$obj # yield to MIM
}
$usersUri = $page.'@odata.nextLink'
} while ($usersUri)
view raw merge-data.ps1 hosted with ❤ by GitHub

MIM Sync configuration

Once the connector space is populated, the activity attributes flow into the MIM metaverse via standard inbound synchronisation rules. From there they are available to:

  • Outbound sync rules that set a stale-account flag on the AD user object
  • MIM Service workflows triggered by a calculated “days since last activity” attribute
  • Reporting exports via MIM’s built-in reporting or a downstream BI connector

Getting started

The full implementation — including the schema script, import script, settings template, and a detailed README covering app registration, privacy settings, and MIM configuration — is available on GitHub.

As always, if you hit something interesting in your own tenant — an edge case with guest accounts in the reports data, or a pattern worth sharing — contributions and issues on the repo are welcome.

Back to top