Tuesday, September 15, 2026

Building a Serverless Microsoft 365 Message Center Monitor with Azure Automation

Building a Serverless Microsoft 365 Message Center Monitor with Azure Automation

Microsoft 365 admins live in the Message Center — but "check the Message Center daily" is exactly the kind of task that should never depend on a human remembering. This post walks through a small, serverless watcher: an Azure Automation PowerShell runbook that reads Message Center posts via Microsoft Graph, filters for ones with an action-required-by date, and hands off anything new to a Power Automate flow for Teams and email delivery.

No VMs, no credentials to rotate, no local state file — just a managed identity, a Graph permission, and two Automation Variables doing the remembering for you.

Architecture at a Glance

LayerComponentJob
TriggerAzure Automation ScheduleFires the runbook on a cadence (e.g. daily)
ComputeAzure Automation PowerShell 7.2 runbookReads Graph, filters, dedupes, calls the webhook
IdentityAutomation Account System-assigned Managed IdentityAuthenticates to Graph — no secrets stored anywhere
Data sourceMicrosoft Graph /admin/serviceAnnouncement/messagesReturns all Message Center posts for the tenant
StateAzure Automation VariablesHold config + a JSON dedupe cache (the sandbox has no persistent disk)
DeliveryPower Automate HTTP-triggered flowPosts an adaptive card to Teams and sends a digest email

Why split it this way instead of one giant script? The runbook's only job is "find what changed and describe it as JSON." Everything about how it looks in Teams or email lives in the Power Automate flow, so you can redesign the notification without touching the PowerShell at all.

Why Not the Built-in Power Automate Connector?

Power Automate ships a native "Microsoft 365 message center" connector — worth ruling out before building this. It only syncs posts into Planner; it has no Teams or email action. That gap is what justifies going straight to Graph.

Prerequisites

#RequirementNotes
1An Azure Automation AccountPowerShell 7.2 runtime environment
2Global Admin / Privileged Role Administrator accessNeeded once, to grant the Graph permission below
3Microsoft.Graph.Authentication moduleImported into the Automation Account's runtime environment
4A Power Automate flow with an HTTP request triggerReceives the JSON payload; builds the Teams card + email
510 minutes for Azure AD permission propagationApp role grants to a managed identity aren't instant — more on this below

Step 1 — Enable the Managed Identity

Automation Account → Identity → turn on System-assigned. This creates a service principal in Microsoft Entra ID that represents the Automation Account itself — no app registration, no client secret.

Step 2 — Grant the Graph Permission

The runbook needs the application permission ServiceMessage.Read.All on Microsoft Graph. This is an app-only, admin-consent-only permission — there's no portal blade for assigning Graph app roles to a managed identity, so it's a scripted step either way. Run one of the two options below, once, from an account with Global Admin or Privileged Role Administrator rights.

Option A — Azure CLI (az rest), e.g. from Azure Cloud Shell

No extra modules to install — az is already signed in wherever Cloud Shell runs.

# One-time: grant ServiceMessage.Read.All to the Automation Account's managed identity
$automationAccountName = "<your-automation-account-name>"

$mi       = az ad sp list --display-name $automationAccountName `
              --query "[0].{id:id}" -o json | ConvertFrom-Json
$graphSp  = az ad sp show --id 00000003-0000-0000-c000-000000000000 `
              --query "{id:id, approle:appRoles[?value=='ServiceMessage.Read.All'].id | [0]}" `
              -o json | ConvertFrom-Json

$body = @{
    principalId = $mi.id
    resourceId  = $graphSp.id
    appRoleId   = $graphSp.approle
} | ConvertTo-Json -Compress

az rest --method POST `
  --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$($mi.id)/appRoleAssignments" `
  --headers "Content-Type=application/json" `
  --body $body

Option B — PowerShell 7 with the Microsoft Graph SDK

Same end result, using Microsoft.Graph.Applications cmdlets instead of raw REST calls. Useful if you're standardizing on Graph PowerShell everywhere and don't want an Azure CLI dependency.

# One-time: grant ServiceMessage.Read.All to the Automation Account's managed identity
# Requires the Microsoft.Graph.Applications module:
#   Install-Module Microsoft.Graph.Applications -Scope CurrentUser -Force

$automationAccountName = "<your-automation-account-name>"

Connect-MgGraph -Scopes 'Application.Read.All', 'AppRoleAssignment.ReadWrite.All' -NoWelcome

# The managed identity's service principal (same object as $mi.id in Option A)
$mi = Get-MgServicePrincipal -Filter "displayName eq '$automationAccountName'"

# Microsoft Graph's own service principal, and the ServiceMessage.Read.All app role on it
$graphSp  = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
$appRole  = $graphSp.AppRoles | Where-Object { $_.Value -eq 'ServiceMessage.Read.All' }

New-MgServicePrincipalAppRoleAssignment `
    -ServicePrincipalId $mi.Id `
    -PrincipalId        $mi.Id `
    -ResourceId         $graphSp.Id `
    -AppRoleId          $appRole.Id

If a Permissions requested consent dialog appears on Connect-MgGraph, choose Consent on behalf of your organization and accept — that's the admin-consent step for AppRoleAssignment.ReadWrite.All.

Either option returns the new appRoleAssignment object, including "resourceDisplayName": "Microsoft Graph" (or ResourceDisplayName in the PowerShell object) and your Automation Account's name as the principal. Give it 1–2 minutes before your first test run — directory replication lags slightly behind the write, and an immediate retry can still 403.

Step 3 — Import the Graph Module

Automation Account → ModulesBrowse gallery → search Microsoft.Graph.Authentication → target it at your PowerShell 7.2 runtime environment. This can take several minutes the first time.

Step 4 — Create the Automation Variables

Variable nameEncrypted?Example valuePurpose
MC_WebhookUrlYesyour Power Automate HTTP trigger URLSigned URL — treat it like a credential
MC_LookaheadDaysNo14Notify on messages due within N days
MC_OverdueGraceDaysNo30Keep showing overdue messages for N days before dropping them
MC_WhatIfModeNotruetrue = print the payload only; false = actually POST
MC_StateJsonNo{}Dedupe cache — the runbook updates this after every run

Type the webhook URL directly into the encrypted variable field in the portal. It carries a signature, so it shouldn't pass through any other script or log.

Step 5 — Create the Runbook

Automation Account → RunbooksCreate → PowerShell, runtime 7.2, paste in the script below → Save (leave unpublished until you've tested it).

The Complete Script

<#
.SYNOPSIS
    Azure Automation runbook - monitors Microsoft 365 Message Center posts that carry an
    "Action required by" date and posts new/changed items to a Power Automate flow for
    Teams + email notification.

.DESCRIPTION
    PowerShell 7.2 runbook. Authenticates with the Automation Account's managed identity -
    no interactive sign-in, no local state file. All configuration and the run-to-run
    dedupe cache live in Automation Variables, because the runbook sandbox has no
    persistent local disk between jobs.
#>

# ============================================================
# Step 0 - read configuration
# ------------------------------------------------------------
# $UseAutomationVariables = $true once the Variables below have been created.
# Until then it stays $false so you can test the Graph/dedupe/notify logic
# with direct values.
# ============================================================
$UseAutomationVariables = $false

if ($UseAutomationVariables) {
    $WebhookUrl       = Get-AutomationVariable -Name 'MC_WebhookUrl'
    $LookaheadDays    = [int](Get-AutomationVariable -Name 'MC_LookaheadDays')
    $OverdueGraceDays = [int](Get-AutomationVariable -Name 'MC_OverdueGraceDays')
    $WhatIfMode       = [System.Convert]::ToBoolean((Get-AutomationVariable -Name 'MC_WhatIfMode'))
} else {
    # TEMP direct values for testing - move these into Automation Variables and flip
    # $UseAutomationVariables to $true before this runs unattended on a schedule.
    $WebhookUrl       = 'https://<your-flow-environment>.environment.api.powerplatform.com/powerautomate/automations/direct/...'
    $LookaheadDays    = 14
    $OverdueGraceDays = 30
    $WhatIfMode       = $true
}

# ============================================================
# Step 1 - connect to Microsoft Graph using the managed identity
# ============================================================
Connect-MgGraph -Identity -NoWelcome

# ============================================================
# Step 2 - pull all current service messages (paginated)
# ============================================================
$uri = 'https://graph.microsoft.com/v1.0/admin/serviceAnnouncement/messages?$top=100'
$allMessages = [System.Collections.Generic.List[object]]::new()

while ($uri) {
    $page = Invoke-MgGraphRequest -Method GET -Uri $uri
    $allMessages.AddRange([object[]]$page.value)
    $uri = $page.'@odata.nextLink'
}

Write-Output "Retrieved $($allMessages.Count) total message(s) from Message Center."

# ============================================================
# Step 3 - keep only messages with an act-by date in range
# ============================================================
$now          = Get-Date
$horizon      = $now.AddDays($LookaheadDays)
$overdueFloor = $now.AddDays(-10 * $OverdueGraceDays)

$flagged = $allMessages | Where-Object {
    $_.actionRequiredByDateTime -and
    ([datetime]$_.actionRequiredByDateTime) -le $horizon -and
    ([datetime]$_.actionRequiredByDateTime) -ge $overdueFloor
}

Write-Output "Found $($flagged.Count) message(s) with an action-required date in range."

# ============================================================
# Step 4 - load the dedupe cache from the Automation Variable
# ============================================================
if ($UseAutomationVariables) {
    $stateJson = Get-AutomationVariable -Name 'MC_StateJson'
    if ([string]::IsNullOrWhiteSpace($stateJson)) {
        $state = @{}
    } else {
        $state = $stateJson | ConvertFrom-Json -AsHashtable
    }
} else {
    # TEMP - no persisted state while testing, so every flagged message looks "new".
    $state = @{}
}

# ============================================================
# Step 5 - build the list of messages that are new or changed since last run
# ============================================================
$toNotify = [System.Collections.Generic.List[object]]::new()

foreach ($msg in $flagged) {
    $lastKnown      = $state[$msg.id]
    $isNewOrChanged = -not $lastKnown -or ($lastKnown -ne $msg.lastModifiedDateTime)

    if ($isNewOrChanged) {
        $daysToAct = [math]::Round((([datetime]$msg.actionRequiredByDateTime) - (Get-Date)).TotalDays, 1)

        $plainSummary = ''
        if (-not [string]::IsNullOrWhiteSpace($msg.body.content)) {
            $plainSummary = [regex]::Replace($msg.body.content, '<[^>]+>', ' ')
            $plainSummary = [System.Net.WebUtility]::HtmlDecode($plainSummary)
            $plainSummary = ($plainSummary -replace '\s+', ' ').Trim()
            if ($plainSummary.Length -gt 320) {
                $plainSummary = $plainSummary.Substring(0, 320) + '...'
            } else {
                $plainSummary = $plainSummary
            }
        }

        $status = 'Upcoming'
        if ($daysToAct -lt 0) {
            $status = 'Overdue'
        } elseif ($daysToAct -le 7) {
            $status = 'DueSoon'
        } else {
            $status = 'Upcoming'
        }

        $toNotify.Add([pscustomobject]@{
            id                   = $msg.id
            title                = $msg.title
            services             = ($msg.services -join ', ')
            category             = $msg.category
            severity             = $msg.severity
            isMajorChange        = [bool]$msg.isMajorChange
            actionRequiredByDate = ([datetime]$msg.actionRequiredByDateTime).ToString('yyyy-MM-dd')
            daysRemaining        = $daysToAct
            status               = $status
            summary              = $plainSummary
            link                 = "https://admin.microsoft.com/#/MessageCenter/:/messages/$($msg.id)"
        })
    } else {
        continue
    }
}

Write-Output "$($toNotify.Count) message(s) are new or updated since the last run."

# ============================================================
# Step 6 - send to Power Automate (or preview if $WhatIfMode) and persist state
# ============================================================
if ($toNotify.Count -gt 0) {
    $payload = @{
        generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
        messages       = $toNotify
    } | ConvertTo-Json -Depth 6

    if ($WhatIfMode) {
        Write-Output 'WhatIfMode is on - payload that would be POSTed:'
        Write-Output $payload
    } else {
        Invoke-RestMethod -Method Post -Uri $WebhookUrl -Body $payload -ContentType 'application/json'
    }

    foreach ($item in $toNotify) {
        $msgObj = $flagged | Where-Object { $_.id -eq $item.id } | Select-Object -First 1
        $state[$item.id] = $msgObj.lastModifiedDateTime
    }

    if ($WhatIfMode -or -not $UseAutomationVariables) {
        Write-Output 'WhatIfMode is on, or running with direct test values - state variable not updated.'
    } else {
        $newStateJson = $state | ConvertTo-Json -Depth 5 -Compress
        Set-AutomationVariable -Name 'MC_StateJson' -Value $newStateJson
    }
} else {
    Write-Output 'Nothing new to notify - done.'
}

# ============================================================
# Step 7 - disconnect (good hygiene; the sandbox is torn down after the job anyway)
# ============================================================
Disconnect-MgGraph | Out-Null

How the Logic Works

ConceptImplementationWhy
Lookahead window$horizon = now + $LookaheadDaysSurfaces messages due soon, not the entire backlog
Overdue grace$overdueFloor = now - (10 × $OverdueGraceDays)Keeps recently-overdue items visible without re-alerting on ancient ones forever
DedupeCompare lastModifiedDateTime against the cached value per message idA message only re-notifies if Microsoft actually edited it
Status bucketsOverdue / DueSoon (≤7 days) / UpcomingLets the Power Automate flow style the Teams card by urgency
HTML strippingRegex strip tags + HtmlDecode + 320-char truncateMessage bodies are HTML; Teams/email summaries need plain text

Testing the Runbook

Use the Test pane (Edit Runbook → Test pane → Start) before publishing. With WhatIfMode = $true, a clean run prints something like:

Retrieved 100 total message(s) from Message Center.
Found 15 message(s) with an action-required date in range.
1 message(s) are new or updated since the last run.
WhatIfMode is on - payload that would be POSTed:
{
  "messages": [
    {
      "id": "MC000000",
      "title": "Example: A Microsoft 365 service is changing",
      "status": "DueSoon",
      "daysRemaining": 5.2,
      ...
    }
  ]
}

That's the signal everything upstream of the webhook — auth, permissions, filtering, dedupe — is working. Nothing has actually been posted anywhere yet.

Troubleshooting: Two Errors You Will Hit

ErrorCauseFix
Variable not found. To create this Variable, navigate to the Variables blade...Get-AutomationVariable called before the Variable existsEither create the Variable, or set $UseAutomationVariables = $false and supply direct values while you finish setup
403 Forbidden on GET /admin/serviceAnnouncement/messagesManaged identity authenticated fine, but ServiceMessage.Read.All hasn't been granted (or hasn't propagated yet)Run the Step 2 grant script; wait 1–2 minutes; retry the test

Both are expected, sequential milestones — not signs something is broken. If you see the 403 immediately after granting the permission, that's Azure AD replication lag, not a wrong app role ID.

Going Live Checklist

  •  ServiceMessage.Read.All granted to the managed identity and confirmed via a successful test run
  •  All five Automation Variables created, MC_WebhookUrl marked Encrypted
  •  Script updated: $UseAutomationVariables = $true
  •  MC_WhatIfMode flipped to false only after you've reviewed a WhatIf payload
  •  Runbook Published (not just saved as draft)
  •  A Schedule linked to the runbook (daily is a reasonable starting cadence)
  •  Power Automate flow tested end-to-end with a sample payload

Extending It

  • Add a services filter so you only get alerted for the workloads you own (Exchange, Teams, SharePoint, etc.)
  • Route Overdue items to a different Teams channel than Upcoming ones
  • Log every run's payload to a Storage Account or Log Analytics workspace for an audit trail
  • Swap the single webhook for multiple flows — one per admin team — keyed off the services field

This pattern — Graph read, Automation Variables for state, a thin webhook to Power Automate for delivery — generalizes well beyond Message Center. The same shape works for app registration secret expiry, license usage thresholds, or any other "check something on a schedule and tell a human if it matters" automation.

Monday, September 14, 2026

Message Center "Action Required By" Notification Automation

Message Center "Action Required By" Notification Automation

Automates alerting on Microsoft 365 admin center Message Center posts that carry an Act by date — the actionRequiredByDateTime field you see in the "Act by" column — and pushes new or updated items to Microsoft Teams and email so nothing with a deadline gets missed in the Message Center inbox.

This fits the "Message Center digest" item already on your AUTOKIT365 backlog.

Assumptions made

Since this is meant to run unattended, a few defaults were picked rather than asked about — change any of them in the runbook parameters or flow before you deploy:

AssumptionDefaultWhere to change
Notify on messages due within14 days-LookaheadDays param
Still show messages that recently became overduelast 30 days-OverdueGraceDays param
Re-notify only when a message is new or its content changedyes (dedupe on lastModifiedDateTime)state cache logic
Run frequencyonce dailyAutomation schedule
Destinationone Teams channel + one mailbox/DLflow inputs

Architecture



The runbook only decides what to notify; the Power Automate flow owns the actual Teams post and email send. This mirrors the split you already use in the app registration / secret expiry monitor (runbook → HTTP-triggered flow → notification).

1. Data source

ItemValue
EndpointGET /admin/serviceAnnouncement/messages (Graph v1.0)
Permission (app, on the managed identity)ServiceMessage.Read.All — admin consent required
Key fieldactionRequiredByDateTime (DateTimeOffset, null when no action is needed)
Other useful fieldstitle, services, category, severity, isMajorChange, lastModifiedDateTime, body.content
Admin center deep link patternhttps://admin.microsoft.com/#/MessageCenter/:/messages/{id}

$filter on actionRequiredByDateTime isn't reliable server-side, so the runbook pulls all current messages and filters locally — same approach as the secret-expiry runbook.

2. Grant the managed identity permission

Run once, as a Global Administrator or Privileged Role Administrator, against the Automation Account's system-assigned managed identity (reuse the same identity as your existing runbooks if they live in the same Automation Account):

Connect-MgGraph -Scopes 'AppRoleAssignment.ReadWrite.All','Application.Read.All'

$graphSp   = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
$identity  = Get-MgServicePrincipal -Filter "displayName eq '<AutomationAccountName>'"
$appRole   = $graphSp.AppRoles | Where-Object { $_.Value -eq 'ServiceMessage.Read.All' -and $_.AllowedMemberTypes -contains 'Application' }

New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $identity.Id `
    -PrincipalId $identity.Id -ResourceId $graphSp.Id -AppRoleId $appRole.Id

3. Deploy the runbook

The full script is Invoke-MessageCenterActionRequiredMonitor.ps1 (delivered alongside this guide). Setup:

StepDetail
Automation AccountReuse your existing M365 Automation Account
RuntimePowerShell 7.x runbook
ModuleMicrosoft.Graph.Authentication
IdentitySystem-assigned managed identity (permission granted in step 2)
Automation variable: MessageCenterWebhookUrlString, encrypted — the Power Automate flow's HTTP POST URL (from step 4)
Automation variable: MessageCenterNotifyStateString, plain — starts empty {}, the runbook maintains it as a dedupe cache
ScheduleDaily (or twice daily if you want tighter latency on new posts)
Dry runRun once manually with -WhatIf to see the payload without posting or updating state

4. Build the Power Automate flow

Create an automated cloud flow with trigger "When a HTTP request is received", then generate its request schema from a sample of the runbook's payload:

{
  "generatedAtUtc": "2026-09-14T08:00:00Z",
  "messages": [
    {
      "id": "MC000000",
      "title": "Example service update",
      "services": "Microsoft Teams",
      "category": "planForChange",
      "severity": "normal",
      "isMajorChange": true,
      "actionRequiredByDate": "2026-10-01",
      "daysRemaining": 17.2,
      "status": "Upcoming",
      "summary": "Plain-text summary of the message body, truncated.",
      "link": "https://admin.microsoft.com/#/MessageCenter/:/messages/MC000000"
    }
  ]
}

Then add:

#ActionConfiguration
1Apply to eachInput: messages from the trigger body
2Teams → Post adaptive card in a chat or channelPost as: Flow bot · Post in: Channel · Team/Channel: your target channel · Adaptive Card: template below
3Outlook → Send an email (V2) (outside the loop, after it)To: your DL/mailbox · Subject: Message Center: action required (@{length(triggerBody()?['messages'])} item(s)) · Body: HTML table built with Create HTML table on messages, set to columns Title/Service/ActbyDate/Status/Link
4ResponseStatus 202, so the runbook's Invoke-RestMethod call returns cleanly

Sending one Teams card per item (step 2) keeps each card actionable and clickable; batching all items into a single daily email (step 3) avoids inbox flooding. Swap that around if you'd rather have one digest card in Teams too — same "Create HTML table" approach can drive a single summary card via a Compose action instead of the loop.

Adaptive Card template (per message)

{
  "type": "AdaptiveCard",
  "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
  "version": "1.4",
  "body": [
    {
      "type": "TextBlock",
      "text": "Message Center: action required",
      "weight": "Bolder",
      "size": "Medium"
    },
    {
      "type": "TextBlock",
      "text": "@{items('Apply_to_each')?['title']}",
      "wrap": true,
      "weight": "Bolder"
    },
    {
      "type": "FactSet",
      "facts": [
        { "title": "Service", "value": "@{items('Apply_to_each')?['services']}" },
        { "title": "Act by", "value": "@{items('Apply_to_each')?['actionRequiredByDate']}" },
        { "title": "Status", "value": "@{items('Apply_to_each')?['status']}" },
        { "title": "Severity", "value": "@{items('Apply_to_each')?['severity']}" }
      ]
    },
    {
      "type": "TextBlock",
      "text": "@{items('Apply_to_each')?['summary']}",
      "wrap": true,
      "isSubtle": true
    }
  ],
  "actions": [
    {
      "type": "Action.OpenUrl",
      "title": "Open in Message Center",
      "url": "@{items('Apply_to_each')?['link']}"
    }
  ]
}

5. Alert logic recap

StatusMeaning
UpcomingAct-by date is more than 7 days out but within the lookahead window
DueSoonAct-by date is within 7 days
OverdueAct-by date has already passed (still surfaced for up to OverdueGraceDays, in case it's still unresolved)

A message is only sent again if it's new, or Microsoft edited it since the last run (lastModifiedDateTime changed) — so a 14-day-out item doesn't re-page you daily as it counts down.

6. Testing checklist

  • Run the runbook manually with -WhatIf — confirm the payload looks right and no HTTP call is made
  • Point MessageCenterWebhookUrl at a test Teams channel first, run once for real, confirm the card renders
  • Confirm the email digest arrives and the HTML table is readable in Outlook
  • Re-run the runbook immediately after a successful run — confirm it reports 0 new items (dedupe working)
  • Manually edit MessageCenterNotifyState back to {} to confirm a full re-notify works as expected
  • Switch the webhook to the real channel/mailbox and enable the schedule

Lighter-weight alternative (no Azure Automation)

If you'd rather not use an Automation Account for this one, the same logic fits in a single Power Automate flow: Recurrence trigger (daily) → HTTP with Microsoft Entra ID action calling GET https://graph.microsoft.com/v1.0/admin/serviceAnnouncement/messages (app registration with ServiceMessage.Read.All, admin consent) → Filter array on actionRequiredByDateTime → the same Teams/email actions as above. You lose the dedupe cache (Power Automate has no simple persistent key-value store without adding Dataverse or SharePoint), so you'd either accept daily re-notifications or add a small SharePoint list to track "already notified" message IDs. The runbook approach above is the one worth building if you want it to match the rest of your AUTOKIT365 monitors.

Note: the built-in "Microsoft 365 message center" connector (Preview) was checked and ruled out for this — its only action is syncing Message Center posts into a Planner plan; it has no Teams or email notification capability.

Featured Post

Building a Serverless Microsoft 365 Message Center Monitor with Azure Automation

Building a Serverless Microsoft 365 Message Center Monitor with Azure Automation Microsoft 365 admins live in the Message Center — but ...

Popular posts